| name | movie-export |
| description | 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. |
Movie Export
Movie Export / Offline Rendering (zero dropped frames)
Recording a movie with a 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 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: "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:
prior = project.realTime
project.realTime = False
TD now cooks every frame completely regardless of how long it takes -- nothing is skipped (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 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
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:
- 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.
- 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.
- 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.