Did the chained workflow keep the character?

Castbook chains open-weights models (keyframe → video → stitch) on Modal GPUs, scores every shot against the character's face, re-rolls the shots that drift, and records what each step cost. Workflows are versioned configs, so two recipes can be run on the same script and compared on identity, retries and dollars per accepted second of video.

Generated character portrait c01

Character c01: one generated, front-facing portrait (no real people). Every score below is ArcFace similarity to this face.

Shots passed first try
2/3 vs 1/3
first/last-frame vs keyframe → video
Cost per accepted second
$0.0116
warm H100 time, every attempt counted
One 5 s Wan 2.2 clip, warm
$0.025
22 s inference · 832×480 · 4-step
Fixing a too-small face
~$0.003
one re-framed keyframe, vs 3.6× clip cost at 720p

Same script, same character, three ways to chain it

A 3-shot script (a street at dawn, a café counter, a café window) through three workflow versions. Each shot's clip must pass the identity gate: p10 similarity ≥ 0.5 over the frames that can be scored.

Keyframe → video (an agent's usual recipe)
keyframe_i2v@2 · 2/3 shots passed the identity gate
Start + end keyframes → first/last-frame video
keyframe_flf@1 · 2/3 shots passed the identity gate
Chain each clip off the last frame
lastframe_chain@1 · 0/3 shots passed the identity gate

WorkflowAcceptedFirst tryMin p10AttemptsWarm $$ / accepted s
keyframe_i2v@22/31/30.5010$0.129$0.0127
keyframe_flf@12/32/30.5212$0.117$0.0116
lastframe_chain@10/30/3–9$0.142–

Chaining off the last frame can't change scenes. Shots 2 and 3 never reach the café: in the "café window" shot she's still on the street. Its faces stay too small to verify, so nothing ships. Re-anchoring with start and end keyframes beat the usual keyframe → video recipe: both verifiable shots passed on the first try, identity was higher, and cost per accepted second was 9% lower even with an extra keyframe per shot, because no clip had to be re-rolled. One character and one seed, so read it as a direction, not a result. Full scoreboard →

A number you can trust, and knowing when you can't

The first run scored every frame and got a clip mean of 0.33, min 0.01: it looked like the face drifted. It mostly hadn't. She turns toward a window, and ArcFace collapses on profiles even for the right person. The judge now leaves out frames turned past 45°, frames with a hand over the face, and faces under 64 px, and returns pass, fail or unverifiable instead of a misleading low score.

Same clip, re-judged on CPU (no GPU, $0): mean 0.33 → 0.67, min 0.01 → 0.61, 76% of frames profile.
profile · not scored0.000.250.500.751.00012243648607280framekeyframe 0.76

Similarity per sampled frame. Shaded frames are profile and not scored; diamonds mark a hand on the face. Hover for values, click to seek. Run page →

Fix it at the cheap step

Shot 1's face was 79 px wide in the keyframe, about 53 px in the 480p clip, so every frame was unverifiable. Rendering clips at 720p made it measurable, but at 3.6× the clip cost. The framing check predicts the clip's face size from the keyframe and re-frames the keyframe instead, for about $0.003.

Wide keyframe, small face
Before: face 79 px, keyframe identity 0.52
Re-framed keyframe, large face
After one re-frame: face 244 px, keyframe identity 0.84
WorkflowAcceptedWarm $$ / accepted s
keyframe_flf@22/3$0.120$0.0118
keyframe_flf@32/3$0.345$0.0341
keyframe_flf@42/3$0.122$0.0121

Shot 1 in each: @2 unverifiable (0% scorable), @3 fail at p10 0.44, @4 fail at p10 0.48, the same answer as 720p with a 480p clip (3.6× cheaper). The answer is honest: once the face is big enough to measure, this shot does drift, so lowering the threshold would have shipped a bad clip. Full scoreboard →

How it works

  1. Character: one portrait → ArcFace embedding.
  2. Keyframes: Qwen-Image-Edit-2511 composes each shot's start (and end) still from the portrait. Gated: identity, plus a face-size and camera-direction check, re-rolled or re-prompted by cause.
  3. Clips: Wan 2.2 I2V A14B animates between keyframes. Gated the same way.
  4. Stitch: ffmpeg concat. One failed shot degrades the run; it doesn't fail it.
  5. Every step is content-addressed: sha256(step, model@rev, params, inputs, seed). A re-run or a new workflow version only computes what changed, so A/B arms share upstream work.
  6. On Modal: one GPU class per model family, weights on a Volume, a CPU orchestrator and a FastAPI layer that scale on their own. The unit tests run in CI without a GPU.
POST /v1/workflows/keyframe_flf@4/runs
Idempotency-Key: cafe-morning-c01-1

202 {
  "request_id": "req_6e1e633dc910",
  "status": "queued",
  "replayed": false,
  "estimate": { "usd_low": 0.081, "usd_high": 1.267 },
  "status_url": "…/v1/runs/req_6e1e633dc910",
  "cancel_url": "…/v1/runs/req_6e1e633dc910/cancel",
  "result_url": "…/v1/runs/req_6e1e633dc910/result"
}

Async submit, poll and cancel; an Idempotency-Key replays the original run instead of paying twice; the run reports per-shot attempts, verdicts and billed GPU time. The estimate is priced from measured timings before anything runs (here for 3 shots: best case vs every retry and a cold start).

What a run costs

StepModelCold load Inference$ (cold)$ (warm)
KeyframeQwen-Image-Edit-2511, 4-step Lightning57 s 5.4 s$0.068 $0.006
ClipWan 2.2 I2V A14B, 4-step Lightning227 s 22.4 s$0.274 $0.025

Warm inference is already cheap and fast; the cost is the cold start. Wan's weights took 227 s to load and cast in this run, so most of a small run's bill is model loading. Re-saved bf16 weights plus Modal memory snapshots are the next step.