How a merch video is generated, stored, polled, and shown to the fan (ZLY-2014)
A video photo bomb is a free digital merch product. The fan takes a selfie, the cover stays the design template, and the result is a short clip instead of a still image.
Image generation waits on the request. Video generation does not. The generate request only puts the session on a queue and returns. A poller checks the provider until the clip is ready, copies it into our bucket, and emails the fan.
The original write-up is the draft in docs/ZLY-2014 Video photo bomb integration in the merch system/. That draft proposed two provider shapes (a hidden iframe, and an async API) plus a product type named video-booth and a registry of many providers. What shipped is the async path only. There is no iframe, no streaming preview, and one model.
DEFAULT-VIDEO1, product type video). A design with designKind = video takes the same path.History stores the finished clip as a delivered experience, even if the fan never reaches confirm.
POST /api/merch/ai/generate detects a video from the product type or the design kind and calls enqueueMerchVideoGeneration. It does not call the image pipeline.
The session row is the queue. Relevant columns on merch_session:
| Column | Role |
|---|---|
generation_media_kind | video |
generation_status | pending, in_progress, succeeded, or failed |
generation_attempts | Submits so far. Stops at 3. |
generation_provider_operation | The gateway job handle, kept so the next tick can check it |
generation_error | Last provider or submit error |
video_media_key | Media filename of the finished mp4 |
delayed_email | Where the ready and failure emails go |
A second generate for a clip that already succeeded returns that file. A failed session stays failed until the fan regenerates. A job that is already pending or in_progress is not submitted again; the poller is armed and the client keeps waiting.
New videos use Grok Imagine Video through Vercel AI Gateway. The template and selfie are supplied together, with the selfie defining facial identity and the template defining the artwork, clothing, pose, and composition. This replaces Seedance for new generations; videos already started with Seedance can still finish.
| Setting | Value |
|---|---|
| Duration | 5 seconds |
| Aspect | Square (1:1) |
| Resolution | 480p by default. The design can select 720p. Older designs set to 1080p use 720p, the model's maximum. |
| References | The design template, then the selfie. An overlay logo, when the design has one, is a third reference. |
Grok receives the original hosted images without adding a grid or overwriting either source. The template and selfie remain separate references, in that order.
When both references are present, the video is instructed to replace the face while preserving the template's layout, text, clothing, and pose. The design's AI Prompt in Generation Settings still controls motion, audio, and other creative direction. Existing prompts can keep referring to the template as [Image 1] and the selfie as [Image 2]; those labels are adapted for Grok automatically. Small text and exact layout can still vary in the generated result.
MERCH_VIDEO_PROVIDER=mock skips the gateway and stores a tiny sample mp4. MERCH_VIDEO_MOCK_FAILS fails the first N mock attempts so the retry path can be exercised.
Historical Seedance experiment. This is not applied to Grok generations. Some customer selfies were rejected before video generation because the reference contained a real human face. BytePlus documents this restriction and its supported portrait-asset options.
The experiment used a white grid on copies of the reference images before sending them to Seedance. The following notes describe that earlier test, not the current Grok flow.
The fan still takes or uploads a selfie normally. No extra editor or upload step is shown to them.

This is the processed selfie reference, not a generated video frame. The original photo remains unchanged. The documentation keeps its own copy of the S3 reference, so the illustration does not depend on retaining a particular test generation.
After adjusting the grid spacing, recent local tests produced videos without the earlier real-face input error. Earlier grid settings did not resolve it. That is an observed test result, not a guarantee that every selfie will be accepted. We do not have confirmation of the provider's detector internals or a supported grid exception.
We also tested Alibaba Wan and Grok Imagine Video, but their results did not meet the quality we wanted for this cover. Seedance tests in a newly created Higgsfield account also accepted some attempts and blocked others with the same test selfies. Those were tests of the Higgsfield interface, not a validated API integration.
BytePlus offers a separate real-person verification and portrait authorization flow. Applying a grid does not complete that verification or register an authorized portrait.
These independent reports give context for the experiment; they are not provider guarantees:
The provider returns a URL or raw bytes. The app copies that into the shared app bucket (AWS_BUCKET_NAME), never the website media bucket.
| S3 prefix | merch/video/ |
| Object | {original-name}-{suffix}.mp4 |
| Content type | video/mp4 |
| Media row | createMedia with that filename, key, bucket, and region |
| Session | video_media_key = the media filename, not the S3 URL |
The confirm page plays it through the media proxy (/api/media/{filename}), the same way an image is shown. The experience row keeps the same filename on ai_art_key so History can open the clip.
The Lambda does not generate video and does not talk to the video provider. It is an alarm clock. EventBridge Scheduler invokes it, and it POSTs to the app.
One function and one schedule group per environment, in us-east-2:
| Environment | Function | Schedule group | Callback |
|---|---|---|---|
| dev | merch-video-poll-dev | merch-video-poll-dev | https://dev.zooly.ai/api/merch/video/poll |
| stage | merch-video-poll-stage | merch-video-poll-stage | https://stage.zooly.ai/api/merch/video/poll |
| prod | merch-video-poll-prod | merch-video-poll-prod | https://app.zooly.ai/api/merch/video/poll |
Shared IAM roles: merch-video-poll-lambda-role (the function) and merch-video-poll-scheduler-role (EventBridge may invoke merch-video-poll-*).
The schedule itself is one-shot, named merch-video-poll, inside that group. Expression at(yyyy-mm-ddThh:mm:ss), 20 seconds out, flexible window off, and delete after it fires. The app creates or updates that schedule when a job is queued and again after a tick that still finds waiting jobs. When the queue is empty, nothing re-arms the timer.
POST /api/merch/video/poll requires Authorization: Bearer $CRON_SECRET. Each call walks up to 10 waiting jobs:
in_progress: ask the gateway once. Still running means wait for the next tick.A check still runs after the third attempt, so a slow success is not thrown away. Only a job with no live operation and 3 attempts already spent is closed as failed.
Deploy or redeploy with packages/merch/srv/infra/video-poll-lambda/deploy.sh (AWS profile zooly). ./deploy.sh dev updates one environment. --code-only ships index.mjs and leaves config alone. --vercel writes the four app variables onto the matching Vercel project (production and preview only, so a laptop pull cannot arm a real timer).
App variables:
| Variable | Meaning |
|---|---|
MERCH_VIDEO_POLL_LAMBDA_ARN | Function to invoke |
MERCH_VIDEO_POLL_SCHEDULER_ROLE_ARN | Role EventBridge assumes |
MERCH_VIDEO_POLL_CALLBACK_URL | Absolute URL of /api/merch/video/poll for that environment |
MERCH_VIDEO_POLL_SCHEDULE_GROUP | merch-video-poll-dev, -stage, or -prod |
On the function:
| Variable | Meaning |
|---|---|
MERCH_VIDEO_POLL_CALLBACK_URL | Same poll URL |
MERCH_VIDEO_POLL_CALLBACK_SECRET | The same value as the app's CRON_SECRET |
A laptop has no EventBridge timer. Each generation-status read, while the fan's progress bar is open, advances the queue itself. That is why a local clip can finish with the four MERCH_VIDEO_POLL_* variables unset. Production leaves the status read alone and waits for the Lambda.
The confirm page plays the clip above the existing headline. Share, Orders, and the Generation stats screen still expect a still image.
video-booth product type and no MERCH_VIDEO_PROVIDERS registry. The type is video. The model is the constant above.On This Page
OverviewWhat the fan seesFlowThe modelGrid approach: real-face reference bypassWhat happens to the imagesExample selfie sent to the modelWhat worked, and what remains limitedFurther readingWhere the file is storedThe AWS clockLocalAfter the file existsWhat the draft described and the code does not do