Open-source template for "one photo → AI video that follows a viral template" SaaS sites, one site per trending keyword. ai video generation, admin console.
The project's README from github.com/qianshuixia/shrimpreel. As one markdown file: README.md.
English | 简体中文
Built by 浅水虾 (qianshuixia).
An open-source template for "one photo → an AI video that follows a viral template" SaaS sites. Every trending keyword gets its own site: create a repo from this template, change the config, copy and media, and ship. Everything runs on Cloudflare: Workers + D1 + R2 + KV + Queues + Rate Limiting + Cron Triggers.
https://github.com/user-attachments/assets/ce92074a-2a09-4a3e-b750-4b888d12abf8
A 72-second tour, no sound (Chinese version; MP4 files in docs/media). Screens are from a local run; the demo site "Neon Shuffle AI" and its clips are placeholders.
Status: the full flow works locally (unit tests + Playwright e2e). It has not been deployed as a production site yet; ROADMAP.md lists what has and hasn't been verified against real services. Code comments, ROADMAP and
docs/are currently in Chinese.
/, Chinese at /zh./api/*, /media/*, /sitemap.xml, /robots.txt, IndexNow key file); entry point src/server.ts.mock is for local dev and e2e only.mock for local dev and e2e. See "Video providers" below./guides hub, related articles), legal pages, contact form; SSR canonical, hreflang, JSON-LD and sitemap; copy is code-split per locale; npm run seo:check lints the whole site.Feature notes and plans: docs/SAAS_VIDEO_FEATURES.md and ROADMAP.md. SEO rules, keyword planning and audit results: docs/SEO_AUDIT.md. All environment variables and the launch-related site.ts fields: docs/env.md. Adding a payment provider and the webhook response-code contract: docs/payments.md. The subscription switch, plans and provider setup: docs/subscriptions.md. Conventions to follow before changing code (credits, payment fulfillment, bilingual copy, migrations, e2e): AGENTS.md. The docs/ files and AGENTS.md are in Chinese.
npm install
cp .dev.vars.example .dev.vars # set at least SESSION_SECRET
npm run seed:local # apply D1 migrations and upload seed/templates/*.mp4 to local R2
npm run dev -- --host 127.0.0.1 # http://127.0.0.1:3000
devEngines in package.json warns on older versions. Running npm install with an older npm rewrites package-lock.json into a form that npm ci on CI rejects (Missing: @emnapi/core@… from lock file).PAYMENT_PROVIDER=mock and VIDEO_PROVIDER=mock by default: buying opens a mock checkout and generating copies the template video. Nothing is charged.POST /api/auth/dev-login); in production it returns 404. Dev login, mock payments and /api/dev/* only accept direct requests from localhost: anything that came through a tunnel or proxy (with cf-connecting-ip, X-Forwarded-For or similar headers) gets 404. Someone on your LAN can fake those conditions, so don't start the dev server with --host 0.0.0.0 or expose it to the internet directly; for webhooks use scripts/webhook-proxy.mjs (see below).MODERATION_PROVIDER=mock by default: an upload form with mockOutcome=reject simulates a rejected image. Leave TURNSTILE_SITE_KEY empty to disable Turnstile.POST /api/dev/sweep {"days": 8} (localhost only) runs retention cleanup as if N days had passed, for the signed-in user's media only.ADMIN_EMAILS can open /admin; locally that is [email protected]. They are made admins when they sign in, and removing an address later doesn't take the role away: also click "取消管理员" (remove admin) on that user's page under /admin/users.GET /api/dev/emails?to=<email>&ref=<order or takedown id> (localhost only). POST /api/dev/takedown-reminders {"takedownId": "tdr_…", "hours": 37} runs the takedown reminder check for one request as if N hours had passed.--host 127.0.0.1, otherwise Playwright and other tools may resolve localhost to IPv6 and fail to connect. Stop the dev server before npm install, or locked .wrangler / miniflare files cause EBUSY.drainUnreadBody in src/server.ts only runs in dev: with the current miniflare 5 beta, a Worker that responds without reading the request body breaks the next request on the same connection (500 fetch failed). After upgrading @cloudflare/vite-plugin, try removing it and run npm run test:e2e to check the CSRF tests.Each keyword site is its own repository that tracks this template as upstream, so payment, security and framework fixes are made once in the template and merged into every site.
With Claude Code, once the site repo exists (step 1) you can just say "new hot site: Moon Hop, the moon-jump dance on TikTok". The bundled new-hot-site skill (.claude/skills/new-hot-site/) covers steps 2–4 and the local part of step 7: it writes a site brief, runs the scaffolder, sets pricing, rewrites the copy in both languages, generates placeholder media, then runs typecheck, tests, build, seo:check and an offline doctor. It only touches per-site files and never commits or deploys. Missing information (final domain, licensed media, etc.) is left as TODO(site): … comments; npm run doctor lists them and blocks a production deploy (APP_ENV=production) while any remain.
Create the site repo
git clone https://github.com/qianshuixia/shrimpreel.git moonhop && cd moonhop
git remote rename origin upstream
git remote add origin [email protected]:<owner>/moonhop.git
Don't use GitHub's "Use this template": the new repo shares no history with the template, so git merge upstream/main won't work later. Forks of a public repo can't be private, so site repos usually aren't forks either.
Run the scaffolder
node scripts/new-site.mjs --keyword "Moon Hop" --domain moonhop.ai --keywords "AI dance video,Moon Hop filter" # dry run
node scripts/new-site.mjs --keyword "Moon Hop" --domain moonhop.ai --keywords "AI dance video,Moon Hop filter" --write # write
It updates the following, then prints a checklist of what still has to be done by hand:
site.ts: keyword, brand, domain, email, template.id, article slugs, secondary keywords, publish/update dates; it also clears the previous site's verification tokens, IndexNow key and analytics IDs.wrangler.jsonc: resource names and the ratelimit namespace_id; D1/KV ids are reset to placeholders.package.json: name.wrangler.staging.jsonc (the template's own staging config).Adjust credit packs (packages), the subscription switch and plans (subscriptionsEnabled, plans; set the switch to false to sell packs only), durations and video.falModel in src/config/site.ts as needed. A pack can set anchorPriceCents to show a struck-through regular price and a discount. Use only a price that pack was actually sold at (EU rules use the lowest price in the 30 days before the reduction); new sites usually leave it unset.
Rewrite the copy in src/content/en.ts and src/content/zh.ts. Copy uses variables such as {keyword}. For every keyword, rewrite at least hero, whatIs, ideas, FAQ and both articles, and cover the secondary keywords naturally in the home page body. Rewrite the home page problem and withWithout blocks for the new trend, or set them to null to hide them. Swapping only the keyword across the whole site can be treated by Google as scaled content abuse.
articles in site.ts (key, slug, publish and update dates, optional related), then add the same key under articles in every locale's content file; typecheck fails if one is missing. The article lives at /<slug> and is added to /guides, the sitemap and other articles' related links automatically. trend and howTo are linked from the home page; keep them. Aim for at least one pillar page of 1,200+ words plus 4–6 long-tail pages (tutorials, ideas, troubleshooting, comparisons) per site.getContent(locale) as usual; only code that needs copy outside the root route and before rendering has to await loadContent(locale) first.LOCALES and LOCALE_META in site.ts, create src/content/<locale>.ts (same type as en.ts), and add a dynamic import to loaders in src/lib/i18n.ts. Email templates are typed per locale as well, so typecheck lists what else needs translating.Replace the media
seed/templates/<template.id>-<seconds>s.mp4: source videos for generation; upload them to templates/ in R2 after deploying.public/demo/*: home page demo videos and posters. They must be your own or licensed, with the source credited in site.examples.*.credit.public/og.png, public/favicon.svg.node scripts/generate-placeholder-media.mjs (requires ffmpeg) regenerates original placeholder media, named after site.durations and the brand. Delete the old template.id files from seed/templates/ afterwards.Set vars in wrangler.jsonc: SITE_URL, APP_ENV=production, PAYMENT_PROVIDER, VIDEO_PROVIDER, MODERATION_PROVIDER, TURNSTILE_SITE_KEY, ADMIN_EMAILS, EMAIL_FROM, *_PRODUCTS, GOOGLE_CLIENT_ID; write the D1/KV ids back after creating the resources (see below).
Create products with your payment provider: one one-time product per credit pack and one subscription product per plan, with the product ids in WAFFO_PRODUCTS or DODO_PRODUCTS, e.g. {"starter":"prod_xxx","party":"prod_yyy","squad":"prod_zzz","monthly":"prod_aaa","yearly":"prod_bbb"}. Waffo subscriptions also need WAFFO_STORE_ID for the customer portal; with the subscription switch off, the setup scripts skip plan products. For Waffo, node scripts/waffo-setup.mjs --store STO_xxx --apply creates them in bulk; for Dodo, node scripts/dodo-setup.mjs --apply --write-dev-vars (add --webhook <url> to also create the webhook and write its signing secret to .dev.vars). Prices on the site are display-only; the provider's dashboard is what actually gets charged, so keep them in sync.
Verify locally: npm run typecheck && npm test && npm run test:e2e && npm run seo:check, then deploy (npm run deploy runs the pre-deploy check first, see below).
After launch
npm run seo:check -- --base https://<domain> --production.https://<domain>/sitemap.xml.site.seo.indexNowKey is set, run npm run seo:indexnow -- --submit.launch-audit skill runs these checks and the remaining launch checklist in one go (see "Deploying to Cloudflare").With Claude Code, just say "sync the template". The sync-upstream skill (.claude/skills/sync-upstream/) prints a sync plan, merges, resolves conflicts by the layers below, fills in local config and runs the full verification. It leaves the merge staged for you to review before committing. If the site doesn't have the skill yet, have it read the template's copy: git show upstream/main:.claude/skills/sync-upstream/SKILL.md (on Windows Git Bash, prefix with MSYS_NO_PATHCONV=1).
By hand:
git fetch upstream
git show upstream/main:scripts/sync-plan.mjs > .tmp/sync-plan.mjs && node .tmp/sync-plan.mjs # sync plan, read-only
git merge --no-ff upstream/main
npm install && npm run typecheck && npm test && npm run test:e2e
npm run db:migrate:remote # before deploying, if the template added files to migrations/
The plan uses the template's latest scripts/sync-plan.mjs and lists the sync base, new template commits, changes grouped by layer (shared code / site layer / removed in sites / lockfile), files changed on both sides, shared files the site has changed, new migrations, and new vars and secrets. --json prints machine-readable output.
The base is git merge-base HEAD upstream/main, so sync only with git merge. Squashing, rebasing or cherry-picking template commits leaves the base where it was, and the next plan lists already-merged commits again.
End the merge commit message with Template-sync: <template commit sha> (the plan prints it); git log --grep "Template-sync:" lists past syncs.
Keep site-specific changes in src/config/site.ts, src/content/*, public/*, seed/, wrangler.jsonc and the name in package.json. Keep everything else identical to the template: fix shared code in the template and merge it into each site rather than patching one site.
When those files conflict, keep the site's values and take the template's new keys; typecheck reports missing copy keys.
If the template changed wrangler.staging.jsonc, the site gets a modify/delete conflict; resolve it with git rm wrangler.staging.jsonc.
Create the resources once per site (names must match wrangler.jsonc):
npx wrangler d1 create <name> # put the database_id in wrangler.jsonc
npx wrangler r2 bucket create <name>-media
npx wrangler kv namespace create <name>-kv # put the id in wrangler.jsonc; titles are unique per account, don't call them all KV
npx wrangler queues create <name>-jobs
npx wrangler queues create <name>-jobs-dlq
npm run db:migrate:remote
npx wrangler r2 object put <name>-media/templates/<file>.mp4 --file seed/templates/<file>.mp4 --content-type video/mp4 --remote
ratelimits[].namespace_id must be unique within an account; don't share the same numbers across sites.SESSION_SECRET, GOOGLE_CLIENT_SECRET, WAFFO_MERCHANT_ID, WAFFO_PRIVATE_KEY, DODO_API_KEY, DODO_WEBHOOK_SECRET, FAL_KEY; optionally YIR_API_KEY (VIDEO_PROVIDER=yir), OPENAI_API_KEY (image moderation) and TURNSTILE_SECRET_KEY. Set them one by one with npx wrangler secret put <NAME>, or sync them in one go with scripts/sync-secrets.mjs:cp .dev.vars.example .dev.vars.production # fill in production values; .dev.vars.* is gitignored
node scripts/sync-secrets.mjs # plan only: new, existing, remote-only, still missing; never prints values
node scripts/sync-secrets.mjs --apply # write the secrets the Worker does not have yet
wrangler secret bulk over stdin, never through a temp file, and wrangler's output is redacted before it is printed.SESSION_SECRET signs everyone out and invalidates signed image URLs); add --overwrite to replace them. Secrets that exist only on the Worker are listed, never deleted..dev.vars.example or is a var, a value is empty or still the placeholder, or SESSION_SECRET is weak. If the remote list cannot be read (not logged in, or the Worker does not exist yet on the first deploy) it needs --overwrite; wrangler creates the Worker if it is missing.node scripts/sync-secrets.mjs -c wrangler.staging.jsonc --file .dev.vars.staging.SESSION_SECRET signs the photo URLs given to the video provider and the video provider callback token. Generate it with openssl rand -hex 32. In production and staging, a missing value, one shorter than 32 characters or the value from .dev.vars.example is refused: jobs can't be submitted (they are failed and refunded after the queue retries, and Workers Logs names the cause) and /media/uploads/* returns 503.npm run doctor. npm run deploy runs it first and stops on errors.APP_ENV, that SITE_URL matches site.domain, leftover mocks, the secrets required by the selected payment, video and moderation providers, that *_PRODUCTS covers every credit pack, placeholder bindings and resource ids, the Cron trigger and dead-letter queue, unfilled TODO(site) markers in the site files, and unapplied migrations on the remote D1.wrangler secret list; values are never read. wrangler must be logged in. On the first deploy the Worker does not exist yet, so set secrets first (wrangler secret put or sync-secrets --apply --overwrite; both create the Worker). --offline skips every remote query, and -c wrangler.staging.jsonc checks the staging config.src/server/config-health.ts) against the runtime env, including the strength of SESSION_SECRET. It only lists problems and never shows values.npm run deploy. Attach a custom domain to the Worker in the Cloudflare dashboard; SITE_URL and site.domain must match it (canonical URLs, the sitemap and the CSRF same-origin check depend on it).APP_ENV=production or staging, checkout, generation and upload return 503 if they are still configured with mock, and dev login, the mock payment endpoint and the /checkout/mock page return 404. robots.txt only allows crawling in production.SMOKE_BASE_URL=https://<domain> npm run test:smoke; add SMOKE_EXPECT=staging for staging.deploy.yml. A manual run does typecheck, unit tests, offline doctor, db:migrate:remote, npm run deploy, then the post-deploy smoke (report uploaded as an artifact).production (Required reviewers recommended) with CLOUDFLARE_API_TOKEN and CLOUDFLARE_ACCOUNT_ID. Start the token from the "Edit Cloudflare Workers" template and add Account-level D1 Edit and Queues Edit, which the template lacks.npx wrangler rollback if needed. Migrations are not rolled back, so keep them compatible with the previous code.launch-audit skill (.claude/skills/launch-audit/) runs the pre-deploy checks (typecheck, test, build, doctor, TODO(site)) and the live checks (seo:check, test:smoke, HTTPS and R2 source videos, mobile Lighthouse and screenshots), then goes through the manual items (webmaster tools, a real payment and refund, sending domain, R2 lifecycle, Google OAuth and so on). It writes .tmp/launch-audit/<date>/report.md with one of three verdicts: ready, ready with follow-ups, or not ready. It is read-only: no deploys, migrations or secret writes./checkout/mock page return 404; cross-site POSTs are blocked; unsigned payment and video provider webhooks return 401; security headers are present (HSTS on https); robots.txt matches the environment..tmp/smoke-prod/."workers_dev": false and "preview_urls": false in the site's wrangler.jsonc. Otherwise the production Worker also answers on *.workers.dev, outside any WAF or rate-limiting rules on your domain, and each version gets a public Version URL that runs that version's code with the same D1 and R2. The template ships with workers_dev on so a new site is reachable before its domain is attached.Without a domain, wrangler.staging.jsonc deploys the template itself to https://video-template-staging.<subdomain>.workers.dev to check real bindings, Google sign-in, payment providers in test mode and video provider callbacks.
APP_ENV=staging: like production, mocks and dev login are disabled and cookies are Secure, but robots.txt blocks crawling. Canonical URLs still point at site.domain.-staging, separate from the production site. tests/unit/staging.test.ts checks that its bindings and var keys match wrangler.jsonc. new-site deletes this file.EMAIL_FROM is empty) and no image moderation by default (MODERATION_PROVIDER=none).npx wrangler d1 create video-template-staging # put the database_id in wrangler.staging.jsonc
npx wrangler r2 bucket create video-template-staging-media
npx wrangler kv namespace create video-template-staging-kv # put the id in wrangler.staging.jsonc
npx wrangler queues create video-template-staging-jobs
npx wrangler queues create video-template-staging-jobs-dlq
npx wrangler r2 bucket lifecycle add video-template-staging-media expire-uploads uploads/ --expire-days 8
npx wrangler r2 bucket lifecycle add video-template-staging-media expire-outputs outputs/ --expire-days 31
npx wrangler d1 migrations apply DB --remote -c wrangler.staging.jsonc
npx wrangler r2 object put video-template-staging-media/templates/<file>.mp4 --file seed/templates/<file>.mp4 --content-type video/mp4 --remote
npm run doctor -- -c wrangler.staging.jsonc # pre-deploy check
npx vite build --mode staging && npx wrangler deploy # deploy reads the staging config from the build output
node scripts/sync-secrets.mjs -c wrangler.staging.jsonc --file .dev.vars.staging --apply # or wrangler secret put <NAME> -c wrangler.staging.jsonc one by one
SMOKE_BASE_URL=https://video-template-staging.<subdomain>.workers.dev SMOKE_EXPECT=staging npm run test:smoke # post-deploy smoke
deploy, pass -c wrangler.staging.jsonc to every staging wrangler command: without it, commands such as deploy follow the config of the most recent build. npm run deploy rebuilds with wrangler.jsonc first.<domain> with the workers.dev host. Use only test stores with Waffo and Dodo.| Service | Where | URL |
|---|---|---|
| Google OAuth | Google Cloud Console → Authorized redirect URIs | https://<domain>/api/auth/google/callback |
| Waffo Pancake | Dashboard → Settings → Webhooks | https://<domain>/api/webhooks/waffo |
| Dodo Payments | Dashboard → Webhooks | https://<domain>/api/webhooks/dodo |
| fal.ai | Added by the code | https://<domain>/api/webhooks/fal |
| yir.ai | Added by the code (https URLs only; local dev relies on polling) | https://<domain>/api/webhooks/yir |
WAFFO_ENVIRONMENT is test or prod. Webhooks are verified with Waffo's public RSA key (bundled in src/server/payments/waffo.ts), so no secret is needed. WAFFO_PRIVATE_KEY accepts PKCS1/PKCS8 and can be a single line with \n.TKT_; the PSP creates REF_ and sends refund.succeeded later, which took 12–22 minutes in test mode. Credits are clawed back when the webhook arrives.waffo_refund_exists / refund_window_expired.REF_ refund id (TKT_ is rejected, otherwise the late webhook would claw back credits a second time).node scripts/webhook-proxy.mjs --port 3100 forwards only POST /api/webhooks/{waffo,dodo}; point the tunnel at it instead of exposing the dev server. Pinggy works (ssh -p 443 -R0:127.0.0.1:3100 [email protected], 60 minutes on the free plan); localhost.run dropped often. To run the checkout and webhooks end to end with sandbox e2e, see docs/payments.md.DODO_ENVIRONMENT is test or live. Webhooks are verified per Standard Webhooks; DODO_WEBHOOK_SECRET is the whsec_... value from the dashboard.dodo-setup.mjs needs Products write, plus Webhooks write with --webhook; at runtime the key needs Checkout sessions and Refunds write, plus read access to Checkout sessions and Payments for order reconciliation; selling subscriptions also needs Subscriptions write (cancel and undo), Customers write (customer portal) and Subscriptions read.refund_window_expired.payment_provider_conflict. Full refunds on a new store or right after a payout often fail for this reason.succeeded immediately and refund.succeeded arrives about 2 seconds later. Credits are still clawed back on the webhook.orderId etc.); refund events don't, so the order is looked up by payment_id. If the tunnel changes domain, update the URL with --webhook-id; the signing secret stays the same.return_url with payment_id, status and email (the buyer's email) appended. GA4's page_location has the email stripped.FAL_KEY or YIR_API_KEY. A Cron every 5 minutes also polls stuck jobs and fails them (with a credit refund) after 45 minutes.idempotency_key (order:<id>, order_refund:<id>, generation:<id>, …) in the same D1 batch as the grant and balance updates, so a duplicate credit rolls back as a whole.credit_grants) that may expire. Purchased credits never expire; the sign-up bonus expires after the validity in runtime settings (default site.signupBonusValidDays, 0 = never); admins can set a validity of 1–3650 days when granting credits.credit_allocations. Allocation runs in SQL: insufficient credits roll back the whole batch, and concurrent spends can't overdraw. A failed render is refunded to the same grants; a refund clawback takes the order's own grant first.expire ledger entry; spending also voids the user's expired grants first. users.credits caches the sum of remaining credits, so it can include credits that expired in the last 5 minutes; spending is never affected./admin/redeem-codes generates up to 1000 random codes at a time (12 characters without look-alikes such as I/O/0/1, shown as XXXX-XXXX-XXXX) or one named campaign code (4–32 letters and digits, e.g. LAUNCH2026), and exports a batch as CSV. Each code has a credit amount, a number of uses (each account can use a code once), an optional credit validity and an optional redemption deadline; codes can be disabled, which doesn't touch credits already redeemed.POST /api/redeem, case, spaces and dashes ignored); it is rate-limited per user and per IP and needs Turnstile when it's configured. A redemption is a grant with source redeem and the code as its ref, so it expires and is spent like any other grant.already_redeemed./i/<code> on the settings page (8 characters without look-alikes, created on first view). When a friend signs up through it and their first order is paid, the inviter gets the reward set in runtime settings (default site.referralRewardCredits = 10; 0 in site.ts turns the feature off and hides the card). Rewards are credits only, never cash.vt_ref cookie (30 days, noindex) that is consumed at sign-up; only brand-new accounts are bound, never existing users or the inviter themselves.site.referralMaxPerIp sign-ups per inviter from the same IP (a salted hash; IPv6 by /64); later ones are recorded as rejected. At most site.referralMonthlyCap rewards per inviter in any 30 days, and no reward if the inviter is banned or deleted when the order is paid./admin/referrals page lists inviters by rewards and the latest invites with their status and rejection reason. Data export includes each side's own records without the other side's id, email or the IP hash; the privacy policy mentions the cookie and the hash while the feature is on./admin/settings changes non-secret parameters without a deploy: maintenance mode, a site-wide announcement per locale, the sign-up bonus and its validity, the referral reward and a site-wide daily generation cap (src/server/settings.ts). Only values that differ from the defaults in site.ts are stored, in the D1 settings table; saving a default deletes the row, so later site.ts changes still apply. Reads go through KV with a 60-second cache, so a change can take about a minute to reach every location. Secrets stay in wrangler secrets. Every change is audited with its before and after values.maintenance for new generations and checkouts and shows a banner on every page. Sign-in, viewing, downloads and payment webhooks keep working, and admins are exempt so they can verify.daily_limit without charging; concurrent submits are bounded by a guard row in the same D1 batch. 0 = no cap. Use it together with the spend limit in the provider's console.paused), so resuming doesn't reward a later order. When site.referralRewardCredits is 0 the field is locked, because the privacy policy is generated from site.ts.site.ts, where a lower price fails because such payments would be held as mismatches; Waffo: signature and auth by looking up a non-existent order), the video provider (auth and balance, warning below $5) and moderation (auth). Probes are read-only or clean up after themselves, never create a checkout or submit a job, and redact secret values from their messages.site.subscriptionsEnabled. The template ships with monthly $14.90 and yearly $149, both granting 60 credits a month; set the switch to false to sell credit packs only (the subscription copy in the pricing page, terms, refund policy and FAQ disappears with it, while existing subscriptions keep renewing and granting credits). The switch, changing plans, provider setup and troubleshooting are covered in the manual, docs/subscriptions.md.subscription) and are spent before longer-lived credits. Yearly plans grant the first month at payment and the 5-minute Cron grants the rest month by month, idempotently.subscription_exists), and an account that will still renew can't be deleted (409 subscription_active).WAFFO_STORE_ID.payment_events (provider, event_id) is unique. Replays, concurrent retries and a late "paid" after a refund all take effect once. Refunds claw back MIN(balance, credits in the pack) and the ledger records what was actually deducted.productMetadata.packageId) must match, and in the same currency the amount actually charged (tax included) must not be below the list price. Different currencies (Dodo Adaptive Currency) and discount codes skip the amount check. A mismatch is not credited and is recorded as mismatch; a paid event with no matching order is recorded as orphan (events whose metadata.site names another site sharing the merchant account are ignored). Both return 200 and raise the "payments to review" alert: mark the order paid in the admin after checking, or refund it in the provider dashboard (the refund webhook then closes the order as refunded without touching credits).src/server/reconcile.ts; Dodo: checkout session → payment, Waffo: GraphQL payments by merchant order id). The success page asks once at about 15 s and once at about 60 s; the 5-minute Cron checks pending orders between 10 minutes and 24 hours old with exponential backoff (about 10, 20, 40… minutes, at most 10 orders per run); the admin order page has a "查单" (check with provider) action to use before mark-paid. A payment found this way goes through the same checks and idempotency keys as the webhook, so a late webhook isn't credited again. The browser can only send the order id, never a payment status. Orders that already have a mismatch event are left for manual review.generations.provider_cost_cents: the actual charge when yir's bill is already settled, otherwise an estimate from the prices in site.video.costCentsPerVideoSecond and sourceFps when changing the model, resolution or source frame rate.VIDEO_PROVIDER picks fal (default), yir or mock. yir.ai is an image and video API router. To use it, set VIDEO_PROVIDER=yir and the YIR_API_KEY secret, and adjust the model and prompt in site.video.yir (src/server/video/yir.ts).wan/v2.2-14b/animate/replace). The integration uses Kling 2.6 Motion Control (reference mode: the photo plus the template's source video). The template ships the integration code only and it hasn't been tested with a real key (submit, status and error mapping are covered by unit tests), so verify output quality and price yourself; costCentsPerSecond: 7 is an estimate, so update it from the yir model page before launch.Idempotency-Key, so a resubmit after a queue redelivery or an interrupted Worker gets the same yir job back and isn't billed twice. yir matches requests by body, so the signed photo URL is derived from the job's creation time (valid for 2 hours) and stays the same on resubmit.max_cost: yir rejects max_cost on requests that reference media by URL. Motion Control is billed by the reference video's length, and the template's source videos have fixed lengths, so the price is already bounded. To cap total spend, set a monthly limit on the API key in the yir Console; past it, submits fail with YIR_SPEND_LIMIT_EXCEEDED.site.video.yir.routingOnly defaults to null: the API key's Routing Profile picks the channel and a failed attempt can fall back to another. With channel IDs (such as ['official']) only those channels are used, and the job fails if they all fail. Either way, list the channels that actually handle photos in your privacy policy./api/* requests (except webhooks, CSP reports and browser error reports) must have an Origin matching the site; uploads are type-checked by magic bytes; uploads and generation are rate-limited per user; banning a user invalidates their sessions immediately; admin actions are written to admin_audit.redirect after sign-in (sign-in page and Google callback) only accepts same-site paths: src/lib/redirect.ts rejects backslashes, control characters and anything that normalizes to a leading //, so links like /sign-in?redirect=/\evil.com land on the dashboard.message; everyone else gets just the error code, and the details go to Workers Logs as [api] <method> <path> <code>: <message>./api/auth/google/nonce, which is also stored in a cookie, and the two must match when the credential is submitted.sub: if the same email comes back with a different Google account (a different sub), sign-in is refused and the sign-in page asks the user to contact support (account_conflict). Accounts created by dev login (no sub) are bound on their first Google sign-in.deleted_accounts table, salted with SESSION_SECRET), so signing up again with the same email gets no sign-up bonus and no referral binding. Rotating SESSION_SECRET invalidates the old hashes.Content-Length (otherwise 411); oversized uploads get 413 before the body is read. Provider outputs over 100 MB fail the generation and refund credits; with a length they stream straight into R2, without one the download is counted as it is read and stops at 48 MB.order (Dodo's return URL adds the buyer's email), the Worker 302s to a clean URL before rendering, so the email never reaches the page, analytics, error reports or browser history. The first request's URL still appears in Workers Logs./v/<random id> (16 characters, not enumerable; /zh/v/… works too). On by default; set site.share.enabled to false to hide the button and the page. Face-swap or real-person sites should consider turning it off.noindex, and share pages aren't in the sitemap.og:image; the video URL goes into og:video. The poster goes through the same image moderation as uploads (422 share_rejected if it fails). If capturing fails (the browser can't decode the video, or it times out), the video is shared without a poster and the site's default OG image is used./media/shares/<id>/video and /media/shares/<id>/poster.jpg with Range support and Cache-Control: public, max-age=300, so a visitor who already opened it may keep playing from cache for up to 5 minutes after sharing stops.0015_shares.sql adds share_id, share_poster_key and shared_at to generations. Data export includes the share link and when sharing started; while the feature is on, the privacy policy has a "Shared videos" section.Content-Security-Policy-Report-Only; the policy lives in src/server/csp.ts.src/server.ts → ssr.nonce in src/router.tsx) and added to every inline script the framework emits; script-src does not allow 'unsafe-inline'. Put custom inline scripts in headScripts() in __root.tsx instead of writing <script> directly.csp.ts when adding third-party scripts, iframes or APIs./api/csp-report and are only written to Workers Logs as [csp] <directive> blocked=… page=… (URLs without query, rate-limited per IP). Once production has run for a while without false positives, change the header name in server.ts to Content-Security-Policy to enforce it.Permissions-Policy that disables camera, microphone, geolocation, payment and USB.<brand>-ai-video-<video id>.mp4, so a reporter can identify a video by its file name. Videos have no burned-in watermark and no C2PA manifest yet (requires Containers; see FEATURES P0-1). Share pages show the same badge and an AI disclosure, and link preview images carry an AI label in the top-left corner.site.retention; photos are kept 7 days and videos 30 by default, and the privacy policy reads both numbers.expired_at in D1, up to 90 per type per run. Photos used by in-progress jobs are kept.media_expired. Orders and the credit ledger are unaffected.templates/.npx wrangler r2 bucket lifecycle add <name>-media expire-uploads uploads/ --expire-days 8
npx wrangler r2 bucket lifecycle add <name>-media expire-outputs outputs/ --expire-days 31
/report; no account needed.takedown_requests with a deadline 48 hours later (site.takedownSlaHours). A gen_ video id or a share link (/v/<id>, with or without a language prefix) in the locator is linked automatically; the report link on a share page pre-fills the share URL./admin/takedowns; the overview shows pending and overdue counts.removed_at, so share links return 410;blocked_media, so uploading the same image again returns 422 content_rejected.admin_audit. ADMIN_EMAILS gets an email for new reports, 12 hours before the deadline and when overdue (see "Transactional email"), but someone still needs to check the queue daily.MODERATION_PROVIDER. Moderation runs before writing to R2; rejected images are never stored and no credits are spent.openai: OpenAI omni-moderation-latest (free), requires OPENAI_API_KEY. Blocks the sexual, violence/graphic and self-harm categories. Uploads are refused with 503 if the API fails.none: no moderation.mock: non-production only.vars.TURNSTILE_SITE_KEY, then run npx wrangler secret put TURNSTILE_SECRET_KEY.site.analytics; the "Cookies and analytics" section of the privacy policy adapts to it.cfBeaconToken (Cloudflare Web Analytics), which sets no cookies and needs no consent banner.ga4Id is set, together with a consent banner and a "Cookie settings" footer link. It uses Consent Mode v2 basic: gtag.js isn't loaded until the visitor accepts, events only go to dataLayer, and ad consent is always denied. Declining or withdrawing deletes _ga, _ga_* and _gid (on the current host and each parent domain). The choice is stored as consent.analytics in localStorage; see src/lib/consent.ts.vt_attr (90 days). With ga4Id set it waits for consent like GA4: accepting records the current visit's source, declining or withdrawing deletes it, and visitors without the cookie are attributed as direct on sign-up and orders (visits are still counted). Without ga4Id there is no banner and vt_attr is written directly; under a strict reading of the EU ePrivacy Directive that also needs prior consent, so enable the banner if you mainly serve EU users. See src/lib/attribution.ts.GET /api/account/export) with the account, sessions, orders, credit ledger and grants, uploads and generations, contact messages, the user's own takedown reports and email log; rate-limited per user. It excludes R2 paths, provider request ids, internal errors and costs, and reports others filed against the user's videos. Media files aren't bundled; those still within retention can be downloaded from the dashboard. Field list: src/server/account-export.ts.sessions is schema only), gzips it to backups/d1/<date>.sql.gz in MEDIA and keeps 30 days (src/server/backup.ts). It tries at most 3 times a day and emails ADMIN_EMAILS a backup_failed alert if the third attempt fails. There is no public route to backups/, and R2 lifecycle rules shouldn't cover it.d1_migrations records). Without --apply the script only plans: it downloads the backup, counts rows per table and checks that the target is empty. Then import:node scripts/restore-d1.mjs --date 2026-10-21 --remote --database <new-db>
node scripts/restore-d1.mjs --date 2026-10-21 --remote --database <new-db> --apply
Then point database_id in wrangler.jsonc at the new database and redeploy. Sessions aren't backed up, so everyone has to sign in again.npx wrangler d1 time-travel restore <db> --timestamp=<unix>. Use Time Travel for mistakes; the R2 backups are for longer retention, a deleted database or moving accounts.POST /api/dev/backup exports once immediately (localhost only); restore it into a separate local state with --file <path> --local --persist-to <dir> to check it.Sent directly from the Worker with Cloudflare Email Service (public beta): the send_email binding is EMAIL and the sender is vars.EMAIL_FROM.
ADMIN_EMAILS, in Chinese): new takedown; one reminder 12 hours before the deadline and one when overdue; new contact message (Reply-To is the sender). Senders don't get an auto-reply, so the form can't be used to email arbitrary addresses.email_outbox table with a unique dedupe_key (e.g. order_paid:<order id>), so webhook replays, queue redelivery and overlapping Crons send one email./admin/emails searches by recipient, order id or takedown id and can resend failed or unsent emails; the overview shows the failure count.vars.EMAIL_FROM to noreply@<domain> (new-site.mjs does this), then run npm run db:migrate:remote.EMAIL_FROM empty or no binding, nothing is sent and emails are marked "not sent"; resend them from the admin once configured. Local wrangler dev only simulates sending; don't add "remote": true to the binding.A health check (src/server/alerts.ts) runs on the 5-minute Cron. When a threshold is crossed it emails ADMIN_EMAILS (in Chinese) and writes console.error('[alert] …') to Workers Logs:
| Alert | Condition | Admin page |
|---|---|---|
| Generation failure rate | In the last hour, ≥ 3 failed jobs and ≥ 30% of finished jobs (excluding manual admin failures and takedowns) | /admin/generations |
| Dead letters | A job was failed in the last hour because its queue message ran out of retries | /admin/generations |
| Video provider account | In the last hour a job failed because the key is missing or revoked, the yir balance is empty or the key's monthly limit is used up (alerts on the first one) | /admin/generations |
| Backlog | A job created more than 30 minutes ago is still unfinished | /admin/generations |
| Payment webhooks | A payment webhook failed to process in the last 24 hours | /admin/events |
| Payments to review | In the last 24 hours a paid webhook had no matching order, or its amount, currency or product didn't match the order (until the order is marked paid or refunded) | /admin/events |
| In the last 6 hours, ≥ 3 emails failed or are still unsent after 3 attempts (alert emails excluded) | /admin/emails |
dedupe_key admin_alert:<type>:<bucket>:<admin email>. If the problem persists, it alerts again in the next bucket.<name>-jobs-dlq. The same Worker consumes it (src/server.ts routes by the -dlq suffix), fails the job (error_code = dead_letter) and refunds credits; the user gets a generation-failed email. New sites need both queues (see "Deploying to Cloudflare"); new-site.mjs renames both.backup_failed alert is emailed when the third export of the day fails. See "Database backups" under "Compliance and content safety".PAYMENT_NOTIFY_WEBHOOK_URL and every order's first credit (including renewals and admin mark-paid) posts a message to a Telegram, Feishu / Lark, Discord or Slack chat with the amount, package, credits, source and order id. URL formats are in docs/env.md (in Chinese). Failures are only logged and never block crediting; the webhook URL (which contains a token) never appears in logs or errors.[alert] lines are the source of truth. For a second channel, push logs to an external monitoring service with Workers Logpush and alert on [alert].src/lib/error-report.ts with sendBeacon to /api/client-error, which only writes to Workers Logs: [client-error] id=… kind=error|rejection|boundary page=… message=… stack=….id=<ID> to find the stack trace when a user reports it.Script error. is ignored.connect-src in csp.ts).npm run typecheck
npm test # vitest: Waffo/Dodo signatures and event mapping, refund ratios and refund id rules, attribution parsing, mocks disabled in production, Turnstile and moderation error paths, email templates and retry decisions, alert thresholds and buckets, GA4 not loading before consent and cookie removal on withdrawal, CSP report and browser error summaries and redaction, sign-in redirect validation, PKCE and nonce for Google sign-in, binding accounts by sub and no second sign-up bonus for deleted emails, output download size limits, SQL escaping in D1 backups, payment notification formats and URL redaction, the attribution cookie waiting for consent, 5xx details hidden from non-admins, weak SESSION_SECRET refused in production, launch config check and doctor rules and wrangler output parsing, template sync plan layers, secret sync plan validation and redaction, credit accounting, redeem codes, referral rewards and subscriptions (calendar months, yearly grants, renewals, out-of-order events, aligning the current grant's expiry when the period is corrected, refunds, Dodo/Waffo event mapping) on SQLite, runtime settings validation, cache and daily cap, connection probes and their redaction, the e2e catalog matching the tests
npm run test:e2e # Playwright: run npm run seed:local first; reuses a dev server already running on 127.0.0.1:3000
E2E_PORT=3200 npm run test:e2e # use another port when 3000 is taken
npm run seo:check # needs the dev server running; writes its report to .tmp/seo-check/
SMOKE_BASE_URL=https://<domain> npm run test:smoke # read-only production smoke, see "Deploying to Cloudflare"
SANDBOX_PROVIDER=dodo E2E_PORT=3200 npm run test:sandbox # real test-mode checkout (dodo or waffo); needs test credentials in .dev.vars, not run in CI
The risk-ranked e2e acceptance catalog and known gaps are in tests/e2e/CATALOG.md (in Chinese). Update it when you add or rename a test; npm test checks that the two match. For sandbox e2e prerequisites, webhook mode and test cards, see "sandbox e2e" in docs/payments.md (in Chinese).
e2e coverage (64 tests):
/report, removed by an admin. Both videos from the same photo are deleted, unrelated videos are kept, and re-uploading the photo is blocked; a handled report can't be handled again, and the outcome is audited.redirect values after sign-in fall back to the dashboard, and unknown error codes on the sign-in page (such as __proto__) show the generic message; the email parameter on the payment result page is removed by a 302 before rendering.Artifacts go to .tmp/video-template-e2e/: report/ (HTML report with screenshots and traces for every step), results.json and test-results/.
src/config/site.ts per-site config
src/content/ English and Chinese copy (one chunk per locale)
src/routes/ pages ({-$locale} is the optional locale prefix, admin/ is the admin)
src/server/ API: auth, credits, orders, payments/, video/, jobs (queue, dead letters, Cron), alerts, admin, seo, retention, takedown, moderation, turnstile, email (outbox), notifications (events → email), settings (runtime settings), probes (connection tests), csp, client-error
migrations/ D1 migrations
containers/postprocess/ post-processing container: burned-in watermark, C2PA signing, cover and preview (experimental, not wired into the Worker yet; see ROADMAP)
seed/templates/ source videos for generation
.claude/skills/ Claude Code skills: new-hot-site (launch a keyword site), sync-upstream (merge template updates), launch-audit (launch checks)
scripts/ new-site, doctor (pre-deploy checks), sync-plan (template sync plan), sync-secrets (secret sync), seo-check, indexnow, waffo-setup, dodo-setup, webhook-proxy, seed-local, placeholder media
tests/ unit, e2e (mock providers), smoke (post-deploy), sandbox (payment provider test mode)
Open-source template for "one photo → AI video that follows a viral template" SaaS sites, one site per trending keyword. ai video generation, admin console.