DANIEL KLIEWERThe book

The Dissolve That Wasn't Applied: Six Rust Tools, Four Silent Failures, and a 2:57 Film

By
11 min read
The Dissolve That Wasn't Applied: Six Rust Tools, Four Silent Failures, and a 2:57 Film

The brief was a film. Not an image, not a prompt — a finished two-to-three minute cinema piece: storyboard, shot list, colour grade, score, subtitles, opening credits, end credits, and a deliberate visual arc. The constraint was the interesting part: use the Rust reimplementations of Adobe Creative Cloud that are already installed, prefer them over anything external, and never assume a command that succeeded actually did its job.

The film is two minutes fifty-seven. Fifteen stills, one cloned voice, a synthesised score, and a headless edit. You can watch it here: The Last Human in the Loop.

What this post is about is the second sentence of that brief, because four separate things reported success and were wrong. A transition API that accepts two different id namespaces and only tells you half of the time. A caption track that exports a perfectly correct SRT and never draws a single letter. A grade that no-ops when you forget to select clips. And two shots that were frozen frames wearing a keyframe, invisible until I measured them.

Every command below was run on 2026-10-25 on an M4 Pro with 48 GB unified memory. Every number came off real output. Everything is local — no prompt, sample or frame left the machine.


0. The pipeline

Six tools, each doing the job it is actually for:

StageToolWhat it does
Stillsqwen-image-studio — Qwen-Image-2.1-Turbo, MLX Q8fifteen 1536×864 frames from one portrait
Gradelightcraft-cli (Lightroom)one transform applied to all fifteen stills
Line artvectorcraft-cli (Illustrator)HUD frame, schematic grid, scanlines → crisp PNGs
Compositingeffectcraft-cli (After Effects)eighteen comps: camera moves, parallax, overlays, type
Voicevox — Qwen3-TTS 1.7B 4-bit, MLXzero-shot clone of chris.wav
Scorenumpy + scipyfour-section bed, ducked against the narration
Assemblyfilmcraft-cli (Premiere)timeline, dissolves, subtitles, look, export

Total wall time: 63.5 minutes of image generation, 100 seconds of rendering, about 20 minutes of encode — and considerably longer than all of that finding out why the picture was not moving.


1. Fifteen frames, one face

Same rule as the previous film, and it mattered again: source every frame from the original reference, never from the previous output. Chaining edits compounds artifacts and inherits the last scene's lighting onto the subject.

Fifteen stills, 1536×864, 8 steps, serialised because Metal is one GPU:

n=15  mean 253.9 s  min 227.9 s  max 273.0 s  total 63.5 min

Sixty-three minutes of GPU is not something you want to lose to a timeout, so the generator appends each finished beat to a manifest and skips beats already marked done. A killed turn mid-batch costs you nothing.

The one thing that cost me a full cycle: I ran Whisper transcription concurrently with the image model. Both load multi-GB weights. Swap reached 7.3 GB of 8 GB and the OS sent the image server a SIGKILL:

Connection refused: http://127.0.0.1:8787/api/status
$ ps aux | grep qwen-image
... -9

The fix is boring and absolute: serialise anything that loads model weights. A CPU compositor can overlap with a diffusion model; two large models cannot.


2. The voice, and the check that actually means something

vox is a Qwen3-TTS web UI with zero-shot cloning on :7860, using the same chris.wav profile as the previous film. 399 words across sixteen beats.

A WAV with the right duration proves nothing about what it says. So every clip goes through Whisper (mlx-community/whisper-large-v3-turbo-asr-fp16) and is scored word-by-word against the script:

MetricValue
Beats16 / 16
Total speech156.51 s
Mean word coverage0.997
Worst beat0.958

That worst beat was worth having: the script said "you" where the model delivered "he", and the coverage score caught it before the edit did. It got regenerated.

One operational note that cost a run: vox's venv is venv/, not .venv/, and it has mlx-audio but not requests. The server runs there; the HTTP client that drives it has to run somewhere else — I used the image studio's venv. Launch the server as a tracked background process. An interrupted turn takes a foreground process with it, and a dead TTS server mid-batch looks exactly like a network problem.


3. The score, verified structurally

No synth, no samples — a four-section electronic bed synthesised in numpy with a 2 dB duck under the narration, written from the actual narration timings.

You cannot verify music by "trusting the process", and an agent cannot verify it by ear. What you can do is measure it, and I did: dominant energy at 26–85 Hz in Act I (72% below 80 Hz), 98% sub-bass in Act II, 75% mid-band with a measurable 0.5 s pulse in Act III (envelope autocorrelation 0.555 against 0.325 in Act I), and a 99% mid-band pad for Act IV.

That is a structural proof that the four sections differ and that the duck is real. It is not a proof that it is good.


4. The bug that reported success

This is the one worth the post, and it is not the one I expected.

I placed eighteen clips on the timeline, added the caption track, applied the grade, applied the dissolves, exported. Every command returned success. The export wrote a file:

exported the_last_human_in_the_loop.mp4 (100.0 MB)

And all seventeen dissolves had been skipped, because the loop that applies them printed this seventeen times:

dissolve skipped for SH01: no edit point at the playhead
dissolve skipped for SH02: no edit point at the playhead
...
applied 0 dissolves

Hard cuts, exactly what I asked it not to ship. The script treated a failed transition as a warning and exported anyway.

The cause is an id namespace trap, and it is genuinely nasty:

timeline.place takes ITEM ids. applyVideoTransition, timeline.select and Lumetri take SEQUENCE CLIP ids. They are different namespaces, and passing an item id where a clip id belongs fails with the misleading "no edit point at the playhead" rather than "no such clip".

The fix is to read the placed clips back and map them by timeline order:

seq = json.loads(fc("inspect", "sequence"))
placed = sorted(seq["video"][0]["items"], key=lambda i: i["start"])
clip_ids = {e["comp"]: it["clip"] for e, it in zip(tl["elements"], placed)}

Then the playhead has to be moved to each cut before the transition is applied — applyVideoTransition works on the edit point at the playhead, not on a named clip:

fc("exec", "playhead.set", js({"seconds": e["start"]}))
fc("exec", "sequence.applyVideoTransition",
   js({"clip": clip_ids[e["comp"]], "effect": "cross_dissolve",
       "frames": 14, "edge": "in"}))

After the fix: 17 dissolves applied.

But the important change was not the fix — it was refusing to ship the broken state:

if n == 0:
    raise SystemExit("no dissolves applied — refusing to export a hard-cut film")

A silent skip and a successful export is the worst outcome in the world, and it is the default behaviour. Every effect that can silently no-op needs a guard that makes its absence fatal.


5. Three more silent failures

The caption track that never renders. Fifty-one cards went onto a real subtitle track. captions.list showed them all with correct in/out points. captions.export wrote a perfectly correct SRT — first card at 00:00:06,000, text matching. And not one letter appeared in the exported picture. captions.showAll changed nothing.

Cause: FilmCraft's caption track is data, not a burn-in layer. The exporter does not composite it.

The obvious fallback was to burn the SRT with ffmpeg. That also failed, and for a reason worth writing down: this machine's ffmpeg has no libass, no subtitles filter, no ass filter, and no drawtext. Not misconfigured — absent:

$ ffmpeg -filters | grep -E "\b(ass|subtitles|drawtext)\b"
(nothing)

So there is no way to burn text with ffmpeg on this box under any invocation.

The fix was to stop trying to burn a caption track and to use a tool that composites: each subtitle card became a native FilmCraft title graphic on a video track, which is what the NLE is for:

out = fc("exec", "graphics.newText", js({
    "text": text, "box": [1400, 96], "position": [68, 690],
    "size": 34, "font": "Helvetica Neue",
    "seconds": dur, "track": 1, "time": int(start * TICKS), "newClip": True}))

Forty-nine graphics, verified by reading frames back off the exported file.

The grade that no-ops. lumetri.applyPreset {"name": ...} failed with "select clips first". Selecting requires clip ids (the same trap as above), not item ids:

fc("exec", "timeline.select", js({"clips": picture_ids}))
fc("exec", "lumetri.applyPreset", js({"name": LOOK, "clips": picture_ids}))

Made fatal on failure, for the same reason as the dissolves.

Two shots frozen for ten seconds. The motion audit — mean absolute luma difference per segment — reported two segments at 0.63 and 0.08. Both had camera: "static", and with no camera movement the dust and overlay layers have nothing to move against. A static keyframe is not a static shot: nothing moves at all.

Changed both to a slow push (107→113%) and re-rendered only those two comps. Post-fix: 42.0 and 43.5.


6. Two smaller traps

Caption overlap from rounding. captions.add failed with "a caption already covers this time". Proportional sentence timing plus a 0.5 s minimum duration let "Deterministic." — a 0.44 s span — overrun the next card by 43 ms. The fix is to compute raw spans first, then clamp each card's end against the next card's start with a 60 ms gap, and dry-run the overlap check before touching the NLE:

end = min(end, raw[i + 1][0] - 0.02)

The score ended 2.5 s before the picture. The credits comp was built at 9.0 s while the timeline allotted 6.5 s. The film ran 179.79 s against a 177.26 s score, so the last two and a half seconds of credits played over silence. Set the credits comp to 6.5 s, re-rendered it, re-assembled. Runtime is now 177.29 s, matching the score to within a frame.


7. What I measured on the delivered file

Not the export's own summary — the file.

CheckResult
Streamsh264 1536×864 24/1 + aac 48 kHz stereo
Duration177.29 s (4255 frames)
Full decodeclean, no errors
Loudness−16.21 LUFS integrated, −1.48 dBTP
Motion per shotall 18 segments above 6.4 — no frozen clip
Narration contenttranscribed back, mean coverage 0.997, worst 0.958
Subtitlesread back off sampled frames at 8 points; text matches, none clipped
Titles / creditsread back off sampled frames; spelling correct
Dissolves17 applied, confirmed in the project
Gradeapplied to all 18 picture clips, confirmed not skipped
Size100,208,701 bytes

The subtitle check is the one most likely to be skipped, and it is the one that matters most. Subtitle text is invisible to ffprobe, to the export summary, and to every byte-count comparison in the world. The only way to know a subtitle is on screen is to extract a frame and look at it:

ffmpeg -ss 9 -i output/the_last_human_in_the_loop.mp4 -frames:v 1 -y /tmp/f.png

I read frames back at eight points across the runtime and confirmed the text matches the script at each one.

One judgement call worth flagging: the closing card displays its line as hero typography, so its subtitle card is deliberately suppressed. Showing the same sentence twice — once as the film's title, once as a caption — reads as a bug, not as thoroughness.


8. What I would tell an agent

  1. Two id namespaces in one API is the trap that bites hardest. Read the placed clips back from inspect sequence; never assume the ids you placed with are the ids the effect commands want.
  2. Make silent skips fatal. A guard that raises when a dissolve count is zero is the difference between shipping hard cuts and knowing you did.
  3. A caption track is not a burned subtitle. Check whether your exporter composites captions at all before building a pipeline on them.
  4. Check your ffmpeg for libass before you rely on it. ffmpeg -filters | grep subtitles — absent means no text, under any flag.
  5. Measure motion, do not trust the return code. Mean luma difference between two frames inside a clip is the only proof a keyframe is doing work.
  6. A static camera is not a static shot. With nothing to move against, a frozen layer stays frozen.
  7. Verify speech by transcribing it back. Duration is not content. Coverage against the script is.
  8. Verify music structurally, and say so. Spectra and envelope autocorrelation prove structure; they do not prove taste.
  9. Read frames back off the exported file. Titles, subtitles and grade are invisible to every automated check.
  10. Serialise anything that loads model weights. One OOM kill costs more than all the waiting.

9. Where it lives

The film: The Last Human in the Loop

Sixteen frames in order, sampled from the delivered file:

Sixteen story frames with camera moves and grade

The closing card:

The closing line, set as the final card

The whole production is twelve scripts, checked in alongside the assets:

pipeline/
  01_generate_stills.py    # 15 stills, manifest-resumable, aborts after 2 failures
  02_generate_narration.py # vox + chris.wav, 503-aware retry
  03_verify_narration.py   # Whisper transcription + coverage score
  04_prepare_audio.py      # silence-trim + body timeline
  04b_finalize_timeline.py # + title/credits, absolute times
  05_compose_score.py      # four sections, ducking, act-by-act spectra
  06_grade_stills.py       # lightcraft, one transform for all
  07_build_overlays.py     # vectorcraft SVGs + procedural raster
  08_build_shots.py        # 18 comps
  09_render_shots.py       # → shots/clips/*.mp4
  10_assemble.py           # FilmCraft timeline, dissolves, look, SRT
  11_add_subtitles.py      # burned-in subtitles + final export
output/
  the_last_human_in_the_loop.mp4   # 177.29 s, −16.21 LUFS
  the_last_human_in_the_loop.srt   # subtitle sidecar
  SCRIPT.md  TIMELINE.md  PRODUCTION_REPORT.md  RUNBOOK.md

The image half is qwen-image-studio, written up in the previous post. The NLE is FilmCraft, MIT/Apache-2.0, and the headless CLI is what makes an edit scriptable at all. vox is the Qwen3-TTS web UI over MLX.

One licensing note that applies to the stills and not the code: the Qwen-Image weights are under the Qwen Research License Agreement, non-commercial use only. The FilmCraft code is MIT/Apache-2.0 and vox is MIT. If any of this becomes commercial, the image model is the piece to renegotiate.


Everything here ran locally: the stills, the voice, the score, the composites and the encode. No prompt, sample or frame left the machine. The only network traffic was uploading the finished film.

Related research

Oct 25, 2026

The Keyframe That Went Nowhere: Building a 3:42 Film Out of Ten Generated Frames

A complete, verified log of turning one selfie into a 3:42 film: ten stills from Qwen-Image-2.1-Turbo, a cloned voice reading a 615-word monologue, and a FilmCraft headless edit. Covers the dimension rule that rejects exact 16:9, a vox venv that does not exist under the name everyone uses, and three NLE failures that all reported success — a keyframe time in the wrong space, a quality flag that is silently ignored, and a loudness setting that nests. Every command was run. Every number came off real output.

Oct 24, 2026

The Checkpoint That Couldn't Run: Getting Qwen-Image-2.1-Turbo Onto an M4 Pro

A complete, verified log of getting Qwen-Image-2.1-Turbo to run on an Apple M4 Pro. Covers the CUDA quickstart that cannot work on arm64 macOS and how to find the MLX conversion instead, choosing weights by free disk rather than by the model card, a Pillow pin that fails like a broken install, a 17.8 GB download that stalls and then fails with a misleading error, building a browser studio over the pipeline, a thread-affinity bug that passed every test I wrote, the swap wall that makes a job look hung, and the exact recipe so an agent can replicate it. Every command was run. Every number came off real output.

Oct 23, 2026

The Trust Store Nobody Patched: A Log of Patching eMudhra, Node.js, and a Lie in npm Audit

A complete, verified log of responding to the October 2026 security advisory: the eMudhra certificate compromise, the Node.js security release, and PHP2Shell. Covers mapping a hosting-provider advisory onto a real stack, the grep that lies about certificate trust stores, proving a runtime is vulnerable by decoding its bundled roots, pinning a Node version, a fail-closed build-time gate that refuses to compile on an unpatched runtime, a 20-test suite that is mutation-checked, and the critical Next.js RCE hiding in a routine npm install. Every command was run. Every number came off real output.

Oct 23, 2026

Two Models, One Harness: Swapping Flint for Gemma 4 Without Losing Either

A complete, verified log of choosing a new local model to drive Hermes on an Apple M4 Pro, and getting it to coexist with the model already running. Covers surveying what actually exists on Hugging Face, verifying a GGUF header before a 17 GB download, the two llama-server flags gemma4 refuses to run without, an out-of-memory wall that turned every request into a 500, why a 4B dense model can cost more GPU memory than a 26B mixture-of-experts, a speed measurement that broke my own claim, and the launchd switch that lets both models stay available. Every command was run. Every number came off real output.

Oct 22, 2026

Hoisting Recursive Flint Aboard Hermes: A Pirate's Guide to Running a 40B MoE Locally

A complete, verified log of running Recursive's 39.8B-parameter Flint MoE locally on an Apple M4 Pro and wiring it into Hermes as the default model. Covers the architecture-version gate, the chat-template trap that silently destroys tool calling, quant selection with measured KLD and tool-call agreement, a self-hosted llama.cpp install, launchd persistence, and every measured number. Written so an agent can repeat it without making the same mistakes.

Oct 11, 2026

Building a Rust-Native Creative Suite on Apple Silicon: A Complete, Verified Installation

The complete build, launch, and verification record for the Rust-native creative suite — 8 applications, all written in Rust, all native Apple Silicon, all built from source on a MacBook Pro M4 Pro. Every command was run. Every number is real.

More from the archive