What does Video Studio (video-studio) do?
In one sentence: Video = Script + Audio-measured timing + Deterministic frame functions + ffmpeg encoding + Automated QC. All visuals and audio are code-generated: zero stock footage, reproducible, pause-and-resume capable, scriptable verification.
0. Hard Rules (Breaking any one will definitely ruin the final video)
- Audio first, visuals second. The timeline must be derived from actual TTS durations. Never estimate by word count. A timeline based on word counts will drift noticeably starting around sentence 20.
- Each frame is a pure function:
draw(t, frame) -> Image. Forbidrandom/time(), usefilmkit.hash2/hash3/vnoisefor randomness. The same frame number must always produce the exact same image. - Subtitles and large text must be in the safe zone: Body text
y ∈ [58, 974], subtitlesy ∈ [872, 1030], never exceed 1030. - Direct raw frame encoding: PIL frames →
rawvideopipe →libx264. Do not usecv2.VideoWriteras an intermediate file (2x slower and adds a second compression loss). - Audio mixing uses numpy sample-level summation. Do not feed a single ffmpeg command with hundreds of inputs (causes non-deterministic silence on Windows).
- Must run QC script + visually inspect 3 still frames (using
read_image) before delivery. "Can render frames" ≠ "no issues." - Audio follows visuals: Register cue points for sound effects in the scene code (via
K.CueBus/scenes.py'scues()hook). Do not manually align audio after the fact. When visuals change (different hold duration, pace adjustment), cue points update automatically. - If unsure, add hold, don't slow down the entire piece: Use
holdinfilm.json(global) orlines[].hold(per sentence) to add pauses after speech ends; align music withbpmfor bar matching.
0.5 [Hard Rule] Propose First, Then Produce (Regardless of User Request)
The user must see a proposal even if they didn't ask for it. This is the most important product behavior of this skill, not a suggestion. Reason: If the appearance is chosen by the program on behalf of the user, the user has no decision-making input from start to finish, and the result will inevitably be "every video looks the same." Therefore, the workflow is fixed: Generate several design options on the spot → User picks one → Freeze the design → Then produce the video.
Fixed Action (the very first thing for any new video production task):
python propose.py --brief "<Topic/Audience/Length/Tone>" --script film.json --n 3
This produces N candidate proposals (each = one design spec + 4 still frames + cost + design distance from other candidates),
saved to work/proposal.json, and prints a message like "Confirm one / or use --revise if none satisfy." Fully offline, takes seconds.
| User Reaction | What You Do |
|---|---|
| "This one" / picks one | python propose.py --confirm --candidate B, then proceed to render |
| "Too cold/too fancy/change voice/faster pace" | python propose.py --revise "<original feedback>" — new revision, old revision retained for diff |
| "None work, start over" | Same as above, make the request specific (the --revise text is saved in proposal.feedback) |
| Explicitly says "You decide" | Still produce a proposal first, pick one you recommend and explain why, then get user approval |
Hard Constraints:
- Before
propose.py --confirm,render.py'sframes/allstages are blocked by a proposal gate (default warning,--require-proposalcauses failure). - Candidates must differ in ≥2 design dimensions (color palette/motif/transition/easing/numbering/music/voice). Changing only colors doesn't count as multiple proposals;
diversity.vs[].n < 2will be flagged. - Each candidate must have a one-line Chinese pitch (
pitch), clearly stating "what this proposal aims to achieve" — not just "Proposal A/Proposal B." - No built-in templates to choose from: All fields in
design.jsonare open (35 themed fields + five visual language signatures + sound/music/rhythm), colors are any point in continuous hue space, not picked from a few pre-built looks. Each proposal is generated on the spot — running the same brief twice yields different designs (use--seedfor reproducibility). - Cross-video deduplication ledger (enabled by default):
--confirmrecords the frozen design into~/.dsh/video-studio/design-ledger.json(max 500 entries). Next proposal will actively avoid looks recently delivered — candidates whose design distance to any video in the ledger is < 2 dimensions will be automatically re-sampled with a different nonce and a warning. This turns "different videos look different" from "relying on randomness" to "having a memory." Use--show-ledgerto inspect,--no-ledgerto ignore for this run, environment variableVIDEO_STUDIO_LEDGERto change path or set tooffto disable. Ledger read/write failures always treat it as empty, never block production.
There is only one exception to skip the proposal: Pure fixes / pure re-renders (no content change, no design change, only fixing A/V sync or re-outputting the video).
In this case, run without --require-proposal and explain in the response "Proposal skipped this time because…"
0.5.1 Where the Look Comes From: Only Two Paths
There are no "built-in styles" to choose from. Any style value written in film.json (auto / legacy / some id) will be
clearly rejected with two-step guidance — the script does not determine the look; the look is determined by work/design.json.
Similarly, this skill has no THEMES/STYLES/VISUAL_LANGUAGES tables, no presets/ directory;
music is generated on the fly by musicgen.py based on parameters, and scenes.draw_frame has no legacy branches.
To get a specific look, there are only two paths:
# ① Describe requirements in natural language (recognizes keywords in must_have/avoid)
python propose.py --brief "Tech keynote, deep blue, letterboxed" --script film.json
# ② Write the design.json directly (all fields open, colors are any RGB)
python render.py --script film.json --stage all # Prerequisite: work/design.json already exists
For detailed contract, see references/proposal.md and schemas/design.schema.json.
1. Five Steps to Get Started (Zero Copy: No copying scripts or templates)
This path involves zero "copy" actions. Skill scripts are executed directly from the skill directory; your project directory only needs a
film.json(plus thework/andout/directories generated during execution).
<SKILL_DIR>refers to theresourceBase.pathreported by theskilltool when this skill is loaded. Installed as a plugin, it is theskills/video-studio/directory within this plugin package (the profile'snode_modules/@dsh-gis/dsh-video-studio-skillis a symlink). If not found, locate by name:find ~ -path '*/skills/video-studio/scripts/filmkit.py'.
<REPO>refers to the package root (repository root, or after installation,node_modules/@dsh-gis/dsh-video-studio-skill/in the profile), which is two levels above<SKILL_DIR>.tools/andschemas/are under<REPO>, not under<SKILL_DIR>.
# 1) Write only a film.json (the only file you need to manually create in your project directory)
mkdir -p myfilm && cd myfilm
cat > film.json <<'JSON'
{
"title": "My Video",
"lines": [
{"id": "l01", "kind": "hook", "chapter": "01 · Opening",
"text": "This is visuals and audio generated entirely from code.", "sub": "Zero assets, all code-generated", "big": "Zero assets, all code-generated"},
{"id": "l02", "kind": "body", "chapter": "02 · Body", "kicker": "PIPELINE",
"text": "The entire pipeline has only five steps: write script, generate narration, layout timeline, render frame by frame, mix and encode.",
"sub": "Five steps to a finished video, each persisted", "big": "Five steps to a finished video",
"items": [{"text": "Script film.json"}, {"text": "Narration"}, {"text": "Timeline"}, {"text": "Frame-by-frame rendering"}, {"text": "Mixing"}]},
{"id": "l03", "kind": "outro", "chapter": "99 · Closing",
"text": "First define the script, then the audio, and only then the visuals.", "sub": "Order matters more than effort", "big": "Order matters more than effort"}
]
}
JSON
# lines[].text = text to be read aloud (natural spoken language); sub = on-screen subtitle (short); kind = hook/body/quote/outro/emphasis/calm
# Visual style/music/voice/rhythm are all determined by the design, there is **no** `style` field to write in film.json.
# Contract validation (optional, takes a few seconds):
python <SKILL_DIR>/scripts/filmcheck.py film.json
# 2) Run a self-check first (optional; 69 regression guardrails, about 10–20 seconds, no network; missing optional modules will be flagged SKIP)
python <SKILL_DIR>/scripts/selfcheck.py
# 3) [Hard Rule] Propose first, then produce — the same brief gives different results each time, so the user must see the options
python <SKILL_DIR>/scripts/propose.py \
--brief "Three years of AI LLMs, 30-second explainer, deep blue" \
--script film.json --work work
# Produces 3 candidates: each with a design + contact sheet of stills (work/proposals/rev1/_compare.png) + design distances,
# results in seconds, fully offline. Look at the side-by-side comparison, pick one.
# 4) Confirm a candidate, freeze the design (work/design.json) — subsequent rendering accepts this design only
python <SKILL_DIR>/scripts/propose.py --confirm --candidate A --work work
# Not satisfied? Pick another or revise: --revise "Make it colder, less decoration, change to female voice" (new revision, old one kept for diff)
# To reproduce a specific run: add --seed 42 on the first proposal (no seed = new nonce each time, this is the source of "every video is different")
# 5) View stills first, then render the full video; if --scenes is omitted, the built-in renderer in the skill is used
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage stills \
--stills "l01:3,l02:5,l03:2.5"
# → work/stills/_sheet.png: Visual inspect using read_image, don't skip this step
python <SKILL_DIR>/scripts/render.py --script film.json --work work \
--out out/film.mp4 --stage all
# TTS→Layout→Music→SFX→Mix→Frames→Encode→QC — all-in-one; intermediate artifacts all in work/, can be resumed from breakpoints.
About --scenes (the key to zero copy): Without --scenes, render.py looks for the scene renderer in this order:
① scenes.py in the same directory as the script → ② Built-in assets/scenes.py in the skill. So by default you don't need to write anything
or copy anything; the built-in renderer produces the video directly. Only when you want to customize the visuals should you place your own scenes.py
(implementing draw_frame and optionally cues()) in the same directory as the script — it will be auto-discovered. You can also use --scenes my_scenes.py explicitly.
The built-in renderer source code is the best example: <SKILL_DIR>/assets/scenes.py.
# Actual signature (six parameters, missing one will crash): `scenes.draw_frame`
def draw_frame(scene, t_local, dur, t_global, script, frame):
# scene : Dictionary for this segment from the timeline (id / kind / scene_type / sub / big / items / stats ...)
# t_local : Time within the segment (seconds, 0..dur) — entrance/continuous animations advance within it
# dur : Total duration of this segment (seconds)
# t_global : Global time in the video (seconds) — cross-segment continuous effects (slow push, breathing) use this
# script : The entire script/plan dictionary (plan, theme colors, words, etc.; cached, don't modify in place)
# frame : Absolute frame number (integer) — use for deterministic noise/phase, not time()
return img # PIL.Image, must be 1920x1080 RGB (start with K.new_frame() for safety)
When writing a custom
scenes.py, start by copying one renderer (like_draw_hero) from<SKILL_DIR>/assets/scenes.py. It has already solved pitfalls like pure functions, cached typography, and pre-baked base layers.
Why propose first: There are no "built-in styles" to choose; the look is determined by design.json.
The same brief run twice yields different designs (palette/motif/transitions/music/voice can all differ),
so the workflow must be "generate proposals → human confirms → freeze → produce"; rendering without confirmation
will trigger a proposal warning from render.py (adding --require-proposal causes it to fail outright).
For the design contract, see references/proposal.md.
Staged execution (each stage does only its own task, persisted and individually restartable):
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage style # Parse confirmed design, print theme/visual language/layout rotation/subtitle style
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage tts # Synthesize narration only + measure actual duration (cached with input validation)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage layout # Build timeline only + select compositions, output timeline.json / sub.srt / storyboard.json
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage cues # Generate cue points only -> work/cues.json
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage score # Generate music only -> work/music.wav (musicgen on-the-fly orchestration)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage sfx # Generate sound effects track only (sfxkit, based on cues.json)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage audio # Mix audio only (narration + music + sfx, narration prioritized)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage frames # Render frames only (--engine pil|web, --jobs N chunked parallel)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage mux # Encode only (video stream copy, two-pass loudnorm)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage qa # Run QC only (read-only input, writes only to work/qa/)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage stills # Render master stills + contact sheet only
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage accept --gate stills # Human sign-off on one gate (writes work/signoff.json)
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage explain # Report "which stages are stale, why, and what to run" only, doesn't run any stage
The full --stage all order: style → tts → layout → cues → score → sfx → audio → frames → mux → qa.
Any step that fails will stop the process and not continue running with bad inputs.
Estimate cost before rendering (optional; estimate.py is a repository tool, called via <REPO>):
python <REPO>/tools/estimate.py --script film.json --jobs 4
It provides frame count / per-stage time / disk usage / chunking suggestions (--json for machine-readable output). It gives an upper bound, not a guarantee;
if work/timeline.json exists, it uses actual durations; otherwise, rough estimate by word count. To skip full-video VMAF, write
"qa": {"vmaf": {"mode": "off"}} in film.json (both estimate and render read this field, G11 logs it as skipped).
Inspect delivery credentials before sign-off:
cat out/QC-REPORT.md # Gate matrix with pass / fail / skipped tiers; skipped = not executed this run, not equal to pass
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage accept --gate release
Sign-off is bound to content fingerprints: changing the script / scenes.py / design invalidates old sign-offs, requiring re-inspection and re-signing.
To force sign-off with skipped gates (treated as failures): add --strict-gates.
Long-form video rendering (chunked parallel + resume from breakpoints):
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage frames --jobs 4
python <SKILL_DIR>/scripts/render.py --script film.json --work work --stage frames --jobs 4 --resume
Chunks are stored in
work/segments/, named asseg_<start_frame>_<end_frame>.mp4; before concatenation, an explicit gap detection is performed, missing chunks cause an immediate error — a missing segment does not produce a partial video. Default chunk size istotal_frames / jobs / 4rounded to whole seconds (jobs=4, 900 frames gives ~8 second chunks). Override withchunk_framesinfilm.json.After changing dialog /
scenes.py/ design / encoding parameters,--resumewill refuse to reuse old chunks (the plan fingerprint is stored inwork/segments/_plan.json) and prompt you torm -rf work/segments— this is intentional: old chunks with a new timeline would produce a video where "visuals and narration don't match," and the duration gate wouldn't catch it.
2. Standard Eight Stages (P0–P7)
| Stage | Output | Key Action | Common Mistake |
|---|---|---|---|
| P0.5 Define Design | work/design.json + work/style.json | propose.py generates proposals → --confirm freezes design; --stage style only parses confirmed design and prints theme/visual language/layout rotation/subtitles | Skipping → render.py errors (no design = no look); rendering without confirmation triggers proposal warning |
| P0 Set Baseline | CONVENTIONS.md | Resolution/framerate/duration/BPM→frame conversion calculated once, shared across entire project | Each file writing magic numbers like 150 → scene boundaries misaligned |
| P1 Write Script | film.json | Split each sentence into text (spoken) and sub (subtitle); each segment has a kind; optional lines[].type to lock composition | Using one field for two purposes → subtitle too long or reads stiffly; Don't write style (it no longer affects visuals; look is determined by work/design.json) |
| P2 Fact Check | FACTS.md | Every number/year on screen has a source number; maintain a "never show" blacklist | Skipping this step → visual and narration numbers conflict, first comment will point out your error |
| P3 TTS | work/tts/*.mp3 + tts.json | edge-tts synthesizes sentence by sentence, ffmpeg -f f32le measures actual duration, disk cached | Estimating duration by word count → subtitle drift |
| P4 Layout Timeline | work/timeline.json + sub.srt | dur = lead + measured speech + tail, punctuation must be within its own segment | Segment too short → narration cut off by next segment |
| P5 Mix Audio | work/audio.wav | numpy bus: narration + music + sfx summed sample by sample | ffmpeg multi-input mixing → non-deterministic silence |
| P6 Master Stills | 3 still frames | Render 3 frames first to check composition/colors/font weight, then render full video | Rendering the full video directly → 36000 frames later discovering ugly font |
| P7 Final Video | out/film.mp4 | rawvideo→libx264 CRF18 + two-pass loudnorm to -16 LUFS | Bitrate too low/loudness -25 LUFS → "looks like a draft" |
Note: The default
crfinfilmkit/render.pyis 18;assets/film.jsonis just a test fixture (used to verify the full pipeline and golden frame/gate regression, not a template for you to copy). It uses 17. If you don't writecrfin your own script, it defaults to 18; for sharper text, use 17 (approx +30% file size).
3. Choosing a Rendering Engine
| Scenario | Choice | Measured Speed | Suitable For |
|---|---|---|---|
| Graphics/Data/Explainer/UI-style | PIL (numpy+cv2 assisted) — filmkit default | 40–80 fps @1080p30 (example video: 75) | Most Chinese explainer videos, report-to-video |
| Needs smooth vectors/particles/3D/paper textures | Headless browser Canvas2D/WebGL + CDP screenshot → image2pipe (webengine.py) | 80–130 ms/frame (integrated GPU, PNG screenshot) | Documentary-level pieces, complex visual language |
| Pure data animation | manim (if installed) | Depends on scene | Mathematics/formula derivation |
The speeds in the table above are measured on a single machine under no load (will vary with different hardware and more complex visuals; see §12). Don't treat the ranges as guarantees.
Design only covers the PIL engine (
engine: pil, default): The colors/fonts/layouts/motifs inwork/design.jsonare read byassets/scenes.py. The--engine webpage is written by you (assets/web/is just a working example page, not a production template); its visuals don't follow the design. However, music and narration do followdesign.json(they don't depend on the rendering engine).
Default to PIL: Fewer dependencies (numpy+Pillow is sufficient), faster startup, strong determinism, lower debugging cost. Use the browser engine only when you need "continuous particle deformation / real-time shader textures":
cp -r <SKILL_DIR>/assets/web ./web # Page template (native Canvas2D, zero third-party libraries)
pip install playwright && python -m playwright install chromium # Only needed for the web engine
python render.py ... --engine web --stage cues # Export cue points from the page
python render.py ... --engine web --stage frames --jobs 3 --resume
python webengine.py textqa ./web/index.html # Check for text overlaps/out of frame/flashes/never-appeared
Trade-offs between the two paths (decide carefully before choosing; switching engines requires redoing the visuals):
PIL (engine: pil) | Browser (engine: web) | |
|---|---|---|
| Dependencies | numpy + Pillow (+cv2 for sharpness comparison) | + playwright + Chromium/Edge |
| Speed | 40–80 frames/sec | 8–12 frames/sec (per-frame screenshot + encoding, an order of magnitude slower) |
| Visual Language | Bitmap compositing, gradients, geometry, text | Canvas2D/WebGL: particles, deformation, shaders, vector animations |
| Determinism | Natural (pure functions for drawing) | Guaranteed by precisely seeking the timeline to f/FPS (via window.renderFrame) |
| Debugging | Modify a frame and run it | Debug in the browser with index.html?dev=1&t=seconds |
| Suitable For | Graphics/Data/Explainer/Promotional Videos | Documentary-level pieces, continuous visual language sequences |
Three hard constraints for the browser engine (implemented in webengine.py, must also be followed when writing your own page):
- All tweens must be on a single paused main timeline; rendering the f-th frame = seeking it to
f / FPSseconds; must not rely on playback. - Procedural content like loops/particles/waves must use a "calculate by time" updater (
update(fn)), not cumulativesetInterval. - The page must expose
window.ready / DURATION / renderFrame(f) / getCues(); no CDN, no network requests.
For cross-session bit-exact reproducibility (after crash restart, multiple renders for diffing, QC comparing two images), enable deterministic mode:
--deterministic or environment variable WEBENGINE_DETERMINISTIC=1 (disables GPU rasterization, empirically consistent across 8/8 sessions,
cost ~+22%/frame). Disabled by default: within the same session, it's inherently consistent; cross-session differences are limited to ≤4 pixels, ±1 level of anti-aliasing.
4. Visual Quality Checklist (These Items Determine "Does It Look Professional")
- Brightness & Depth: Dark background (
#0a1420) + bright top accent; avoid pure black or white — user screens will eat the gradients. - Font Weight & Size: Large text 84–96px, subtitles 34–38px, subtitles 46px; use
msyhbd.ttc(Microsoft YaHei Bold) for Chinese. - Subtitles: ≤ 2 lines, approx 24 Chinese characters per line, with semi-transparent background,
y ≥ 872. Actual line breaking is based on pixel width (fit_sub_linesauto-wraps atmax_w=1560 px), the "24 characters" is a rough upper bound, not a hard validation — the constantSUB_MAX_CHARS=24is currently not referenced by any code, don't rely on it to stop you. - Rhythm: Visual change every 3–6 seconds (new card / movement / number counter); if static for more than 6 seconds, the viewer leaves.
- Camera: Only use 3–15% slow push; avoid large zooming in/out.
- Color Grading: One primary color + one accent color for the entire video, no more than 4 hues.
- Audio: Master at -16 LUFS / True Peak ≤ -1 dBTP; narration 12 dB above ambient gaps; leave 1.4 dB headroom before AAC.
5. Sound: Music + SFX + Cue Points
The philosophy in this section comes from a retrospective on a real finished video (
ai-history-film): Bad sound isn't "not loud enough," it's "sound doesn't know what's happening on screen." The order of resolution is always: first make the sound aware of what's happening on screen (cue points), then discuss timbre, and finally mastering.
5.1 Three Audio Layers
| Layer | Who Does It | Persisted To | Description |
|---|---|---|---|
| Narration | filmkit.tts (edge-tts) | work/tts/*.mp3 | The sole authority for duration: the timeline is derived from it |
| Music | musicgen.py (parametric orchestration) | work/music.wav | Generated on the fly based on design's bpm/key/scale/mood/seed + orchestration parameters; no pre-existing musical styles to choose from |
| SFX | sfxkit.py (physical modeling) | work/sfx.wav | Synthesized per cue point registered in cues.json |
All three buses are mixed in --stage audio: When SFX plays, music automatically ducks (duck_db defaults to 4 dB,
cinema/epic 5.5 dB — with a 3 dB duck, music is almost at the same level as SFX during a transition, causing a muddy sound),
finally handed to ffmpeg's two-pass loudnorm for -16 LUFS / True Peak ≤ -1 dBTP. Do not do final loudness normalization yourself in numpy —
you have loudnorm, which is far more accurate than manual normalization, and True Peak is the gate.
Intra-piece separation: Music already has transitions at segment boundaries (swells/risers/sub_boom from the orchestrator); the SFX track doesn't double up whooshes/risers at the same boundary —
--stage sfxautomatically reduces transition-like cues that fall within [boundary - 2.2 s, boundary] by -3 dB (sfx.duck_transitions).
[continues...]
6. QC Gates (G0–G11)
All gates are idempotent — run any number of times, they always produce the same result. Never modify inputs.
| Gate | Check | Fails If | Outlier Handling |
|---|---|---|---|
| G0 Plan | film.json passes schema | ... | Schema error → stop with code hint |
| G1 Assets | Audio files exist + have correct length | Missing or corrupted files | --stage tts to regenerate; stale file > tokens → warn "stale TTS, rerun tts" |
| G2 Timeline | Sentences non-overlapping + total length = expected ±0.5s | Out-of-bounds / over 0.5s drift | Target frame count difference → warn "N frames mismatch after mux, length gate will catch" |
| G3 Cues | All cue registrants have a renderer | Missing / duplicate / 0-duration cues | Print list of unresolved cues |
| G4 Music | Duration matches timeline ±0.5s (no gap/overshoot) | Mismatch | Gap → interpolate or refill; overshoot → stop |
| G5 SFX | All sfx.mp3 durations align with cues | Unmatched / timed poorly | Leftovers → warn "orphan sfx" |
| G6 Audio | 3 tracks mix to -16 LUFS ±0.5 / True Peak ≤ -0.5 dBTP | Out of spec or clipping | True Peak > -0.5 dBTP → increase headroom; mix > 0.25s deviation → wait/rerun |
| G7 Frames | All frames output + no batch failures | Batch error / zero output | Check chunk finish log; --resume reruns empty chunks |
| G8 Encode | Duration ±0.03s / VMAF ≥ 88(CRF18 reference) / no freeze frames ≥ 5 | GOP error / black frames / out-of-sync | Re-encode; if VMAF < 88, try --crf 17 |
| G9 TextQA | No text overlaps / no uncommanded blanks / no flash-text | Wrong subtitle timing (visual > audio + 0.33s) | webengine.py textqa compares rendered frames + subtitle SRT |
| G10 Stills | 3 stills compared with master (SSIM ≥ 0.92) | Sign-off expired (script/design/scenes changed) | Re-inspect stills, keep screen open; --stage accept --gate stills |
| G11 Release | All gates passed + last sign-off fingerprint matches current input | Stale sign-off / manual --skip | Print diff: "N files changed: A, B, C…" → user must re-sign |
G7 & G8 output detours: Floats do not affect the video of the current render, only delay correction to the next render. Detours are not pass.
G8 VMAF reference: CRF 18 encodes average ~92.5±2.5 (source 1080p text). Below 88 causes visual quality traffic light (yellow: ≥ 86, can pass but will warn;
red: < 86, forced failure). Different engines have different baselines: PIL average 92, browser 89 — in film.json, adjust with "qa.vmaf.engine_offsets": {"web": 3}.
Not recommended unless the engine is the limiting factor (using WebGL with complex shaders).
G8 freeze frame detection: Preceded by 0 still frames ≥ 0.3s; detects "repeated frames" by comparing pixel hash. Manual judgment needed: intentional still picture vs machine glitch.
Use --mux no-detect to skip (GCAT times out, GCAT must watch side-by-side). Never output invisible black side bars; use letterbox widening (letterboxing can cause VMAF drops).
G10 stills master: Even if design/scenes haven't changed, rendering with a different engine version (Pillow 10→11) may cause differences.
--stage stills outputs work/stills/_master/ with commit hash → when rendering is repeated, compare diff → re-inspect stills.
G11 release gate: The only gate that needs human confirmation. Even if all automated G0–G10 pass, G11 fail will block out/ output.
During --stage accept --gate release, read the QC report aloud: "G1 / G2 / … all pass. Check three stills?"
7. Environment Check & Common Integration Problems
7.1 Main Dependency Tree
filmkit
├── numpy # Core compute (audio mixing, pixel ops)
├── Pillow # Frame rendering (PIL engine)
├── edge-tts # Narration synthesis (Microsoft edge TTS)
├── ffmpeg (binary) # Encode + loudnorm
├── (optional) opencv-python # VMAF comparison, format conversion
└── (optional) playwright # Web engine
Key hard constraints:
| Problem | Symptom | Solution |
|---|---|---|
| ffmpeg < 5.0 | loudnorm returns inconsistent LUFS | ffmpeg -version, upgrade |
| edge-tts no module | ModuleNotFoundError: No module named 'edge_tts' | pip install edge-tts |
| Network unavailable + cache miss | —stage tts stuck / error | Pre-download or fallback offline TTS; —tls-bypass skips TLS check for intranet |
| Windows ffmpeg path | FileNotFoundError: ffmpeg | Add to PATH or $env:FFMPEG_PATH="C:\\path\\to\\ffmpeg.exe" |
8. References
| Reference | Location | Purpose |
|---|---|---|
| Design Contract | schemas/design.schema.json | Defines all fields and constraints of design.json |
| Proposal Protocol | references/proposal.md | Describes proposal generation, revision, comparison, and confirmation workflow |
| Built-in Scene Renderer | assets/scenes.py | Reference implementation of draw_frame, including all built-in scene types |
| film.json Schema | schemas/film.schema.json | Defines script structure, sentence types, chapter layout |
| Pipeline Stage Map | references/stages.md | Data flow, dependency graph, and cross-stage cache invalidation rules for all stages |
| QC Gate Map | references/gates.md | Checks, failure modes, and outlier handling for all 12 gates |
| Timeline Format | schemas/timeline.schema.json | Defines timeline output format, composing sentence start, end, duration, and composition reference |
| Browser Engine Protocol | references/webengine.md | How to write a browser page that the engine can drive renderFrame / getCues |
| Audio Bus Contract | references/audiobus.md | Track levels, ducking rules, final limiter parameters for each audio layer |
| VMAF Baseline Data | resources/vmaf-baseline.csv | Baseline VMAF scores for CRF 18/17 and different engine configurations |