# Movie Export

> MUST READ before recording, exporting, or batch-encoding any movie or image sequence from TouchDesigner: the Realtime trap, zero-drop verification, deterministic per-frame export, async file-reader staleness.

- Skill: `dylanroscover/movie-export` (Agent Skill)
- Install (CLI): `npx skillmds@latest add dylanroscover/movie-export`
- Raw SKILL.md: https://api.skillmd.com/api/skills/dylanroscover/movie-export/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: Coding & Dev Tools
- Author: dylanroscover (https://skillmd.com/u/dylanroscover)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/dylanroscover/movie-export

---

<!-- Generated by Embody/Envoy - Do not remove this comment - sha:2180381ef3e17edc -->

# Movie Export

## Movie Export / Offline Rendering (zero dropped frames)

Recording a movie with a [Movie File Out TOP](https://docs.derivative.ca/Movie_File_Out_TOP) is a heavy build. Monitor for drops DURING the render (not only after), and treat ANY dropped frame as a failed render. "Done" requires proof that every frame is unique -- a file can be the right length and still be full of duplicates.

### The Realtime trap (the #1 cause of juddered exports)

The **Realtime** flag (timeline bar; Python `project.realTime`, a read/write bool) is **ON by default** in TD (the default cooking mode skips frames to keep wall-clock pace; the [Project Class](https://docs.derivative.ca/Project_Class) page documents the read/write semantics, not the default). With it ON, TD **skips any frame it cannot cook within the `project.cookRate` budget** (default 60) -- [Project Class](https://docs.derivative.ca/Project_Class): *"When True, frames may be skipped in order to maintain the cookRate. When False, all frames are processed sequentially regardless of duration."* When a frame is skipped during recording, the Movie File Out **replicates the previous image** so the file stays the right length (its Info CHOP describes `last_frames_written` as possibly *"multiple repeats of the same image if TouchDesigner dropped frames"*). Net: the file has the correct frame COUNT but contains duplicated frames -- visible judder.

**Rule: before any movie render, capture the prior flag, then go non-realtime:**

```python
prior = project.realTime          # CAPTURE first -- never assume it was True
project.realTime = False          # cook every frame regardless of duration
```

TD now cooks every frame completely regardless of how long it takes -- nothing is skipped ([Movie File Out TOP](https://docs.derivative.ca/Movie_File_Out_TOP): *"Recording a movie without frame drops can be done in non-realtime by turning off the Realtime flag."*).

**Restoring Realtime is a footgun -- handle every exit path.** Restore `project.realTime = prior` (the captured value, NOT a hardcoded `True` -- the user may have deliberately had it OFF) when the render ends. With the async `run(delayFrames=...)` driver below there is NO Python `try/finally` that spans the render, so you cannot wrap it in `finally`. Route ALL exits -- last frame written, a force-cook exception, a drop/count-mismatch abort, AND user cancel -- through one `_finish(prior)` helper that sets `project.realTime = prior` and `mfo.par.record = 0`. Wrap each per-frame body in `try/except` so a mid-sequence error calls `_finish` instead of orphaning the scheduled chain and stranding TD non-realtime (which looks like a frozen UI -- the timeline runs only as fast as it cooks). If you re-enter after an interrupted render, read and restore `project.realTime` to the user's intended value BEFORE starting a new one.

### Monitor DURING the render -- abort on the first drop

Do not wait until the file is closed to discover frame 12 dropped (on a long render that wastes minutes of GPU time). Inside the per-frame driver, after each step, read the Movie File Out **Info CHOP** `total_frames_dropped` (and, on the addframe path, confirm `last_frames_written == 1` -- each pulse must write exactly one unique frame). If `total_frames_dropped` ever increments, STOP immediately, route to `_finish(prior)`, and report the offending frame index -- never let the render run to completion past the first drop. The [Perform CHOP](https://docs.derivative.ca/Perform_CHOP) `droppedframes`/`cook` channels are a cheap per-frame corroborating signal (`cook == 0` marks a skipped frame).

### Prove the render is good -- length and uniqueness are SEPARATE checks

A render can be the right length yet full of duplicates, so verify both classes:

**Length / completeness (does NOT prove zero drops):**
- `total_frames_written == requested_frame_count` (Info CHOP) and on-disk count == requested (`ffprobe -count_frames`). A correct count proves only that the file is the right LENGTH -- replicated frames are counted as written, so this can pass on a juddered file.

**Uniqueness / no drops (the actual drop proof):**
- `total_frames_dropped == 0` (Info CHOP) -- *"the number [of] frames TouchDesigner failed to provide unique images for"* -- AND
- duplicate-frame detection: `ffmpeg -vf mpdecimate` keeps every frame, or a per-frame `framemd5` (`ffmpeg -f framemd5`) shows no consecutive identical hashes. (mpdecimate with default thresholds also flags slow-but-distinct frames; `framemd5` detects only EXACT replication.)

**Let the encoder drain before verifying.** The Movie File Out encodes on a background thread; the last frames sit in a queue that must flush before the file is complete. Do NOT `project.quit()`, delete the op, or run the external `ffprobe`/`mpdecimate` check the instant the final frame is pulsed -- you may read a truncated file and misreport a drop. After the last frame, set `record = 0` to finalize, wait until the file size/mtime is stable across a couple of frames (or a bounded number of `delayFrames`), THEN run the on-disk check.

### Deterministic per-frame export (exact frame count)

For a frame-accurate offline render (e.g. an exact-loop sequence driven by a uniform):

- Capture `prior`; set `project.realTime = False`.
- Movie File Out `type = 'stopframemovie'` (or `'imagesequence'`), `pause = 1`, `record = 1`.
- For each frame `i` in `0..N-1`: set that frame's state (uniforms/params), **force-cook the source TOP and confirm it actually cooked** (`cookedThisFrame` True / `totalCooks` incremented, no cook error or GLSL fallback image), then `mfo.par.addframe.pulse()` -- Add Frame writes exactly one frame per pulse (Pause must be On to enable it).
- **Step across real frames with `run('...', delayFrames=1)`** -- a blocking Python `for` loop CANNOT advance TD frames, so the Movie File Out never writes. The driver self-schedules one step per frame and routes every exit through `_finish(prior)`.
- `TOP.save(path)` per frame also works but is ~seconds/frame (synchronous GPU readback + encode) -- too slow for long sequences; prefer the Movie File Out's threaded encoder.
- **This force-cook-then-`addframe` ordering is same-pass-safe ONLY for a GENERATIVE source** (uniforms/params drive the pixels, so a forced cook produces the new frame in the same pass). If the source is a FILE READER (Movie File In / image sequence), its reload is asynchronous and this ordering captures STALE content -- use the pipelined-preload pattern in "Async file readers serve stale content" below instead.

`performLongOperation` is NOT a documented Project/UI method -- do not rely on it. Use `project.realTime = False` + `run(delayFrames=...)` chunking.

### Async file readers serve stale content -- the batch-encode trap

Batch-encoding a set of file sequences (PNG sequence, `.mov`, image sequence) through one Movie File Out is the deterministic path above, but the SOURCE is now a file reader whose content updates ASYNCHRONOUSLY. It can hand the encoder the wrong pixels with no error, no warning, correct resolution -- silent corruption that every container check passes and only a human eye (or an out-of-process decode) catches. There are TWO stale-content layers, and the second defeats the "obvious" fix for the first:

- **Layer 1 -- a reload completes only across real frame advances.** Setting a [Movie File In](https://docs.derivative.ca/Movie_File_In_TOP) `par.file` + `reloadpulse.pulse()` then `cook(force=True)` in the SAME frame does not reliably apply the reload -- the op serves its previous cached texture. Pull-based cooking makes it worse: an undemanded reader never cooks at all, so scheduling the first `addframe` a few frames later via `run(delayFrames=N)` does NOT help unless something actually cooks the reader during those frames. Symptom: frame 0 of every output after the first holds the PRIOR scene's last frame.
- **Layer 2 -- a mid-pass reload does not propagate downstream in the same cook pass.** Even after a preroll settles the reader, when the pending reload is applied DURING a forced-cook pass, ops downstream (blur -> GLSL -> cross -> upscaler -> null -> writer) can still consume the PRE-reload texture -- *even with the whole chain force-cooked in dependency order*. The reader's own `numpyArray()` shows the new content (so a reader-side uniqueness guard PASSES) while the writer captures the old frame; the new content propagates one real frame later. Symptom: the output lags the reader by exactly one frame -- `[f0, f0, f1, ..., f(N-2)]`: head frame duplicated, final source frame dropped, a 2-frame motion step at the loop wrap.
- **'Specify Index' image-sequence playback pre-reads asynchronously too.** Under forced non-realtime stepping it serves stale frames -- a fractional-index 60fps encode writes long duplicate-frame runs at implausible speed. Per-file explicit stepping is the only reliable non-realtime pattern.

**Correct pattern -- pipeline the load one frame ahead, and verify AT THE POINT OF CAPTURE (the writer's input), not at the source:**

1. **Preroll each new scene.** Set `file(0)` + reload pulse, then cook the FULL chain across ~5 real frames (`run`-chained, `delayFrames=1`) BEFORE the first `addframe`.
2. **Per frame `i`:** cook the chain -> fingerprint the WRITER'S INPUT TOP (e.g. `null.numpyArray()` downsampled) and require it to differ from the last WRITTEN fingerprint (retry on the next real frame if equal; bounded) -> `addframe` -> ONLY THEN set `file(i+1)` + pulse -> schedule step `i+1` with `delayFrames=1`. The reload lands during the inter-step frame advance, so the next pass reads fresh content with no retry in steady state.
3. **Persist the fingerprint across scene boundaries** -- a stale frame 0 equals the previous scene's final fingerprint, which is exactly what catches it.

"Force-cook the whole chain in dependency order" is NOT sufficient on its own (Layer 2). Fingerprint the WRITER'S input, not the reader's, and let each reload settle across a real frame advance.

**Content verification is SEPARATE from container/drop verification (container checks lie by omission), and must be done OUT OF PROCESS:**

- Length / rate / clean-decode all PASS on corrupt content -- necessary but INSUFFICIENT. Content-verify frame 0 AND frame N-1 of EVERY output.
- **External ffmpeg is the mandatory render-acceptance gate.** Decode the finished file out of process for byte-honest frame diffs -- ffmpeg's NotchLC decoder is decode-only in FFmpeg >= 4.3. That decode is the render's acceptance test, not any in-TD check.
- **Never QC through TD during a render.** A second Movie File In inherits the same async-seek staleness (self-consistent WRONG answers) AND perturbs the running encode.
- **Cross-domain pixel compares are invalid.** `TOP.numpyArray()` returns linearized values; comparing against sRGB PNG bytes (cv2/PIL) yields ~0.1-0.4 baseline diffs that swamp any single-frame difference. Compare same-domain ONLY.

### If drops happen, the fix depends on the mode

- **Realtime recording (Realtime ON) dropped frames** -> set `project.realTime = False` and re-render.
- **Deterministic / non-realtime path dropped frames** -> Realtime is already OFF, so "re-render with realtime off" is a no-op. The cause is elsewhere: the source TOP was not force-cooked to completion before the pulse, a GLSL/compile error produced the fallback image, or the encoder stalled. Confirm each frame the source cooked uniquely (`cookedThisFrame` True, `totalCooks` incremented, no errors, `last_frames_written == 1`) before pulsing.


