Video Generation
A text-to-video render as one card — prompt, a storyboard that fills in frame by frame, a staged bar that cannot rewind, a poster with a duration chip when it lands, and a failure that keeps the frames it managed to make.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/video-generation.jsonPrompt
Build a React + TypeScript + Tailwind "VideoGeneration" card with zod and
lucide-react. It renders ONE text-to-video render job while it is still being
made, and the finished clip's poster once it lands.
Contract
- A zod schema (`videoGenerationJobSchema` in a sibling contract file) is the
single source of truth for one job: { id, prompt, state, stage, progress,
frames, durationS?, posterUrl?, etaS?, model?, width?, height?, fps?, error? }.
- `state` and `stage` are SEPARATE unions and both are needed. state: "queued" |
"running" | "ready" | "failed" | "canceled" answers "is it moving, finished or
broken"; stage: "queued" | "rendering" | "encoding" answers "where". A job that
ends keeps the stage it stopped inside, which is the whole difference between
"rejected before a single frame existed" and "24 frames rendered and the muxer
fell over" — only the second one still has something to show.
- `canceled` is a first-class outcome, not "failed in a nicer colour": the human
asked for it, nothing is broken, and the frames already made are still real.
- A frame is { id, t, url?, error?, alt? }. A slot EXISTS before its pixels do —
`url` absent means scheduled but not reached — because the empty slots are the
shape of the finished clip. A non-blank `error` IS the slot's failure state;
there is no separate flag.
- `progress` is an OVERALL percent. Non-finite or out-of-range values fall back to
an estimate derived from the storyboard, so a backend that only reports frames
still gets a truthful bar.
- A separate envelope status — "loading" | "empty" | "error" | "ready" — is the
card's own render state, independent of the job's state. `status="error"` means
the status endpoint failed; the RENDER failing is `state: "failed"`, and the two
must never be worded the same way.
- Props: job: Job | null; status; onPlay?(job); onCancel?(id); onRetry?(id);
onSelectFrame?(frame, index); onReload?; maxFrames? (24); promptPreviewChars?
(140); stageLabels?; formatDuration?; formatEta?; showStages? (true);
showStoryboard? (true); emptyState?; errorMessage?; label? ("Video generation");
className plus the rest of the element props, ref forwarded to the <article>.
Numeric props are clamped (maxFrames >= 1, promptPreviewChars >= 40) so a 0 or
a NaN can never render an empty strip.
- `onPlay` and `onCancel` collide with React's DOM attributes (media / dialog
events), so the props type omits those two names from HTMLAttributes.
Behavior
- THE BAR CANNOT REWIND. Rendering owns 0–90 of the bar and encoding the last
tenth, and each stage carries a floor (queued 0, rendering 1, encoding 90).
Backends that report a per-stage percent restart at 0 on every boundary;
composing the reported value with the floor turns that reset into a step
forward. 24 of 24 frames is 90%, not 100% — there is still a file to write.
- A queued job renders an INDETERMINATE bar, not 0%: a 0% bar reads as a job that
is running and getting nowhere. The indeterminate branch drops aria-valuenow
entirely instead of lying with a zero.
- The headline names the slot in flight: "Rendering frame 11 of 24", where the
ordinal is the first UNSETTLED slot (neither a picture nor a reject) — not
"rendered + 1", which counts backwards every time a frame is rejected. The bar
counts settled slots for the same reason: one bad frame must not park progress
for the rest of the run.
- THE PREVIEW FOLLOWS THE NEWEST FRAME UNTIL THE HUMAN TAKES OVER. Selection is
`null` = follow; clicking or arrowing to a frame pins it and a "Follow the
newest frame" control appears (it reads "Back to the poster" once the job is
ready). A pinned frame that disappears from the array falls back to the head
instead of blanking the preview.
- The storyboard is ONE tab stop with a roving tabindex, the way a listbox
behaves: exactly one option carries tabIndex 0, arrows move between RENDERED
frames only (skipping slots with no picture), Home/End reach the ends, and
nothing wraps. Pending slots stay options marked aria-disabled, so their
existence is announced without pretending they are pickable.
- A FAILURE SALVAGES. Failed and canceled jobs keep every frame they made, say how
many survived, and dim the preview instead of clearing it. Throwing the output
away is what forces someone to re-run the expensive half.
- Cancel and retry are ONE-SHOT. The lock lives in a ref read and written
synchronously inside the click handler, because several clicks dispatched in one
task all read the same stale state; it is keyed to the job id plus the job
state, so a job that moves on releases the lock by itself and a second, genuine
action stays available. Both buttons use aria-disabled, never the native
`disabled` attribute, which would blur the button mid-action and drop it out of
the tab order.
- Truncation is announced, never silent: the strip draws `maxFrames` slots above
"Showing 24 of 48 frames" with a reveal toggle, and a long prompt clamps to two
lines above "Show the full prompt".
- Broken IMAGES are tracked separately from broken FRAMES. An <img> that fails to
load is recorded client-side (its onError adds the id to a set) and drawn as
"image unavailable", but it is never counted in the job's failure stats — those
belong to the backend.
- The provider error is the one thing never capped: no max-height, no line clamp,
wrapped, monospace, shown in full.
- A new job id resets the card by adjusting state during render (no effect): the
takeover, the frame reveal, the prompt disclosure and the broken-image set all
clear, so a recycled card can never show the previous render's selection.
- The card owns NO CLOCK and does no polling. It renders exactly what it is
handed, so the server, a replay and a screenshot all produce the same markup.
Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
bg-primary for the bar and the selected-frame ring, bg-primary/5 with
border-primary/50 for the active stage pill, text-destructive with
bg-destructive/5 for a failure, bg-muted-foreground for a canceled bar,
bg-background/80 plus a backdrop blur for the chips floating over the poster.
No hard-coded colours anywhere.
- Five job states are five SHAPES — hourglass / spinner / check / alert / ban —
so they survive a monochrome theme and a colour-blind reader.
- The preview takes its aspect ratio from width/height (falling back to 16/9), so
a 9:16 vertical cut is not letterboxed into a landscape box.
- Motion is decoration only: the spinner, the indeterminate pulse and the gradient
sweep over a live preview all stop or vanish under prefers-reduced-motion, and
every transition is motion-reduce-guarded. Nothing functional depends on them.
- ONE persistent sr-only role="status" line spells the state out, and it speaks in
FRAMES, not percent: percent changes on every poll and would turn a screen
reader into a metronome, while the frame ordinal changes exactly when there is
something new to look at.
- Frame thumbnails carry alt="" because their option already has the aria-label —
otherwise every slot is read out twice.
- cn() merges the consumer's className into the root, which also spreads the
remaining element props and forwards its ref.
Customization levers
- Density: `showStages` drops the three-step pipeline strip and `showStoryboard`
drops the frame strip entirely — a card in a narrow rail keeps just the preview,
the bar and the headline.
- Budget: `maxFrames` decides how many slots are drawn before the reveal (lower it
in a list of many jobs, raise it for a single hero card) and
`promptPreviewChars` decides when the prompt grows a toggle. Never make either
cap silent; the withheld count is part of the reading.
- Vocabulary: `stageLabels` renames the pipeline (Queued / Rendering / Encoding →
Waiting / Drawing / Muxing) and the rename flows into the headline too.
`formatDuration` and `formatEta` own the two time strings — swap the timecode
for a frame count, or the bucketed ETA for a hard countdown.
- Affordances: pass only the callbacks you can honour. No `onPlay`, no play
control over the poster (playback belongs to your player); no `onCancel`, no
Stop; no `onRetry`, no Render again; no `onReload`, no retry in the envelope
error. `onSelectFrame` lets a parent mirror the selection into a lightbox.
- Shape: `width` / `height` set the preview's aspect ratio, and `posterUrl` is
optional — omit it and a finished job falls back to its last rendered frame.
- Copy: `label` names the region, `emptyState` replaces the empty body, and
`errorMessage` owns the envelope failure text.Concepts
- Staged progress that cannot rewind — rendering owns the first 90% and encoding the last tenth, and every stage carries a floor. Backends that report a per-stage percent reset to 0 on each boundary; composing the report with the floor turns that reset into a step forward, because a bar that travels backwards is the fastest way to make someone reload a perfectly healthy job.
- The storyboard is the shape of the clip — slots are drawn before their pixels exist, so the reader watches a known-length film fill in instead of an unbounded list grow. A rejected slot becomes a visible break in the strip, and progress counts settled slots (a picture or a reject) so one bad frame can't park the bar for the rest of the run.
- Follow-the-newest with takeover — the preview tracks the head frame until a human picks one, at which point the selection is pinned and a "follow again" control appears. Auto-follow that refuses to yield is the same bug as a chat window that scrolls away from the message you are reading.
- Partial output is an outcome, not debris — failure and cancellation keep every frame already made and say how many survived. A card that clears its own output on failure forces a full re-run of the expensive half, and those stills are usually enough to tell you whether the prompt was even close.
- One-shot action gate — the cancel / retry lock is a ref read and written synchronously in the handler, because several clicks in one task all observe the same stale state. Keying it to the job id plus the job state makes it expire by itself when the job moves on, instead of leaving a dead button behind.
- Announced caps — 24 of 48 slots drawn, a two-line prompt clamp, each stating exactly what it withholds right next to the control that reveals it. A silent cut is worse than no cut: it makes you reason about a render you can only half see.
Image Generation
An image-generation result card — the prompt above a variations grid that blurs up and scans while it renders, per-image download / upscale / vary, a first-class safety-blocked state, and the seed behind whichever variation you pick.
Chatbot Widget
An embeddable support chat widget — launcher bubble with an acknowledged unread badge, a panel that keeps its draft when minimised, quick replies, and a rating row on the way out.