MemeMotion 開發紀錄:在 Cloudflare Workers 上把一張照片製作成約 13 秒賽車迷因影片
Build log: turning one photo into a ~13-second racing meme video on Cloudflare Workers
作者在 Cloudflare Workers 上建置 MemeMotion,讓用戶上傳一張照片,約 4 分鐘後取得一段約 13 秒、配有原創配樂的賽車迷因影片。生成流程會先檢查照片是否有一張正面人臉,並在輸出後移除原有音軌、合成配樂;非審核拒絕的生成失敗最多自動重試一次,之後退款。影片和來源照片會在工作完成 7 天後刪除;約 4 分鐘的完成時間來自首個實際工作,並非平均值。
A short racing clip has been going around on short-video apps: someone in a deep squat, flying down a highway at absurd speed, with a chase behind them. People wanted to put their own face in it. I built a small site that does exactly one thing: you upload one photo, and about 4 minutes later you get an about 13-second racing meme video with an original backing track.
This post is the build log: the pipeline, the parts that were harder than they looked, and the mistakes that cost me re-runs. No benchmarks; where I only have one data point, I say so.
Disclosure: I built this. MemeMotion is unofficial and not affiliated with TikTok, the creator, or the Crazy Frog rights holders. Motion reference from the original post by @wdmowski. Rights to the original clip belong to its creator.
The shape of the product
The constraints I set myself before writing any code:
- One template, one button. No prompt box. The user picks a photo and pays; everything else is fixed on the server.
- Pay per video, no subscription. One credit = one video. Packs are $7.99 / 1, $14.99 / 2, $27.99 / 4, paid once.
- Google sign-in before generating, so jobs, credits and downloads belong to an account.
- Short retention. The finished video (and the source photo) is deleted 7 days after the job finishes.
- Never charge for something that can't work. Check the photo before money moves; refund automatically when generation fails.
The pipeline
flowchart LR
A[Photo upload] --> B[Face check<br/>exactly 1 frontal face]
B -->|reject| X[Error, nothing charged]
B --> C[Charge 1 credit<br/>D1 ledger]
C --> D{Site budget / user slot free?}
D -->|no| P[Park job<br/>cron promotes later]
D -->|yes| Q[Cloudflare Queue]
P --> Q
Q --> E[Video-to-video model on fal<br/>reference clip + photo]
E -->|moderation reject| R[Refund, no retry]
E -->|other failure| S[1 automatic rerun, then refund]
E --> F[Strip audio, verify length]
F --> G[Mux original backing track]
G --> H[R2 + email 'ready']
H --> I[Deleted after 7 days]Everything runs in one Cloudflare Worker: D1 for users, jobs and the credit ledger; R2 for photos, reference media and outputs; a Queue producer/consumer for jobs; and two cron triggers for sweeps and cleanup.
Step 1: check the photo before charging
The model wants one clear face. If you send a group photo or a profile shot, it still bills you and the result is bad. So the first thing that runs, before any credit or budget is touched, is a cheap check: exactly one, roughly frontal face.
Workers can't run a face-landmark model in-process, and I didn't want a new vendor just for this, so the check uses a small vision model on the same provider I already send the photo to. The comment in the code says it plainly:
/**
* Pre-charge photo check: exactly one, roughly frontal face.
* ...
* Runs before any credit or budget is touched.
*/
Two details that mattered in practice:
- Tiny face boxes are usually faces in posters or backgrounds, so boxes below 1.5% of the frame are ignored.
- If the check service itself errors, the request fails closed: a 503 and nothing charged. Silently letting it through would mean charging for jobs that are likely to fail.
Step 2: the reference clip is the product
This is a video-to-video job: a fixed motion reference plus the user's photo. Getting the reference right turned out to be most of the work.
The reference is stored privately in R2 and handed to the model through a short-lived signed link. It is never used as a public <video> source on the site; the pages show only outputs made with fictional people. The page credits the original post.
I went through more than one model while prototyping (the code still has a switch for three providers), and the default is now a recast-style model that keeps the reference's camera move, timing and poses and swaps in the person from the photo. It bills by output seconds, and output length equals reference length, which brings me to the most useful bug of the project.
The close-pass shot that broke identity
The first outputs looked right for most of the clip, then in the last 1–2 seconds the face drifted away from the uploaded photo and back toward the man in the template footage.
The cause was the end of the reference: a fast close pass by the camera with heavy motion blur. In that shot the model had almost nothing sharp to anchor the new face to, so it fell back to the person it could see in the reference.
The fix didn't need any paid re-runs. I trimmed the reference so that shot is gone:
/**
* The racing reference is trimmed to exactly 12.80s (384 frames; the old shot 5,
* a blurred close pass, made the rider's face drift) ...
*/
export const RECAST_REF_SECONDS = 12.8;
and tightened the prompt so the person from the photo stays the rider "in every shot, including any close pass by the camera".
A side effect: billing follows output length, so the shorter reference also made every clip cheaper. It's also why the site says "about 13 seconds" instead of 15.
Step 3: a guard against "the model returned a different length"
Because the pipeline assumes output length equals reference length, the runner checks it. The tolerance is deliberately small:
export function outputDurationMatches(inputSeconds: number, outputSeconds: number): boolean {
if (!Number.isFinite(inputSeconds) || !Number.isFinite(outputSeconds)) return false;
if (inputSeconds <= 0 || outputSeconds <= 0) return false;
const tolerance = Math.max(0.2, Math.min(0.5, inputSeconds * 0.03));
return Math.abs(outputSeconds - inputSeconds) <= tolerance;
}
The same module rewrites the MP4 timing table (stts) to constant frame rate when needed, and there's a test named "accepts small encoder drift and rejects a shortened or lengthened clip". A clip that is a second short would break the music sync in the next step, so it's better to catch it here.
Step 4: strip the audio, then add our own
Whatever audio comes back must not ship. The reference had its audio removed when it was prepared, but I don't trust that alone, so there's a small MP4 box walker that drops audio tracks from the result and zeroes the orphaned sample bytes:
/**
* Drop audio tracks from a non-fragmented MP4 and zero the orphaned sample bytes.
* Reference-clip audio must not ship with the result. If the file is not an MP4
* we can prove is silent, callers should refuse to store it.
*/
The music is an original backing track generated for the site, not any existing song. It's stored privately in R2 and merged with a hosted ffmpeg merge endpoint. The trick that removed all filter work: the track is cut to 13.0 s with a 0.5 s fade-out that ends at 12.8 s, which is exactly the clip length. The merge never cuts the music mid-fade.
Muxing is best effort. If it fails, the job still succeeds with the silent version. Users get both: the music version by default, and a silent copy, because some platforms want you to add a sound inside the app.
The job page. The uploaded photo is blurred here for the screenshot.
Step 5: money, queues and refunds
This is the part I'd tell anyone building a paid generator to design first.
One active job per user. A unique index on active jobs in D1 enforces it. If someone pays for another video while one is running, it's charged and parked as waiting_slot, then started when the previous job ends (queue consumer hook) or by the cron sweep. If it can never start, its credit is refunded once.
A site-wide daily spend cap. Generation costs real money per second of output, so there's a daily cap on provider spend plus a per-user daily limit. At the cap, a new job is parked as waiting_budget with no credit charged and no budget reserved, and the cron promotes parked jobs oldest-first when budget frees up. An admin alert goes out once per day when spend crosses a threshold.
Failures are classified, not retried blindly.
- A moderation rejection from the provider is final: never resubmitted, credit refunded, plain message to the user.
- Other failures get at most one automatic rerun, with a fresh idempotency key so the provider doesn't dedupe it onto the failed request. Then refund.
- Refunds are deduplicated per job, so a retry storm can't refund twice.
Never submit twice by accident. A network failure on submit doesn't mean the job wasn't created. Before any second submit, the runner looks up an existing request for that job; a failed status check never triggers a submit.
Step 6: delivery, sharing and the 7-day rule
When a job finishes, the user gets one "ready" email (or one "failed" email) and the video is in their account.
Name in the greeting blurred.
The share panel links to the site, not to the video. Videos stay private until the user posts the file themselves; on mobile, TikTok/Instagram use the native share sheet with a download fallback.
Retention is enforced in code, not policy text: when a job completes it gets expires_at = now + 7 days, and a cleanup job deletes both the output and the original photo from R2. Failed or never-finished jobs are cleaned up 7 days after creation.
What I'd do differently
- Watch the reference shot by shot before tuning prompts. The single biggest quality fix was removing one shot from the reference, and it cost nothing.
- Make output length a server constant from day one. My copy said "15 seconds" for a while and had to be changed everywhere when the reference changed.
- Write the refund paths before the happy path. Parking, one rerun and per-job dedupe were much easier to reason about as a state machine than as patches.
- Keep one fact source. Prices live in one JSON catalog that both Stripe and the UI read, so copy and checkout can't drift apart.
Numbers I can actually stand behind
- Output: about 13 seconds (the reference is 12.80 s).
- Time to finish: about 4 minutes. This is from the first live job, a single data point, not an average.
- Retention: 7 days.
That's it. If you've fought identity drift in video-to-video models, I'd like to hear what worked for you in the comments.
If you want to try it: MemeMotion. MemeMotion is unofficial and not affiliated with TikTok, the creator, or the Crazy Frog rights holders. Motion reference from the original post by @wdmowski.
來源:DEV Community · dev.to


