Communitygithub.com

Video Studio (video-studio)

Code-generated video production (Chinese narration, deterministic frame rendering, ffmpeg, edge-tts, QA gates). Use when asked to make a video, turn a report into a video, animate a story, fix A/V sync or blurry text in a generated video.

What is Video Studio (video-studio)?

Video Studio is a comprehensive, code-driven video production skill that generates videos entirely from code—no stock footage, no manual editing. It orchestrates a deterministic pipeline: script writing, edge-tts narration, timing derived from actual TTS durations (not estimates) for perfect synchronization, frame-by-frame rendering with PIL or headless Canvas, ffmpeg encoding, numpy sample-level audio mixing, and automated quality gates. The skill enforces a strict 'propose before produce' workflow: it always generates multiple visual/audio design proposals (via `propose.py`) that differ across ≥2 design dimensions, lets the user choose, freezes the design into `work/design.json`, and only then renders the full video. It supports PIL (40-80 fps, ideal for explainers, data, and UI-style videos) and a headless browser Canvas2D/WebGL engine (8-12 fps, for particle effects, vector animations, shader-driven visuals). Key features include deterministic frame functions (no random/time), audio-first timing, breakpoint-resumable renders, a design ledger to avoid repeating looks across projects, comprehensive per-stage staging (from style via QA), and a built-in proposal system with revision history and diff capability. It is designed for platforms like Claude Code, Cursor, and Codex, and its skill markdown provides the full implementation reference for all commands and conventions.

Works with~Claude Code~Codex CLI~Cursor
npx skills add https://github.com/znlgis/dsh-video-studio-skill/tree/HEAD/skills/video-studio

Ask in your favorite AI

Open a new chat with this agent skill pre-loaded.

Documentation

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)

  1. 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.
  2. Each frame is a pure function: draw(t, frame) -> Image. Forbid random/time(), use filmkit.hash2/hash3/vnoise for randomness. The same frame number must always produce the exact same image.
  3. Subtitles and large text must be in the safe zone: Body text y ∈ [58, 974], subtitles y ∈ [872, 1030], never exceed 1030.
  4. Direct raw frame encoding: PIL frames → rawvideo pipe → libx264. Do not use cv2.VideoWriter as an intermediate file (2x slower and adds a second compression loss).
  5. Audio mixing uses numpy sample-level summation. Do not feed a single ffmpeg command with hundreds of inputs (causes non-deterministic silence on Windows).
  6. Must run QC script + visually inspect 3 still frames (using read_image) before delivery. "Can render frames" ≠ "no issues."
  7. Audio follows visuals: Register cue points for sound effects in the scene code (via K.CueBus / scenes.py's cues() hook). Do not manually align audio after the fact. When visuals change (different hold duration, pace adjustment), cue points update automatically.
  8. If unsure, add hold, don't slow down the entire piece: Use hold in film.json (global) or lines[].hold (per sentence) to add pauses after speech ends; align music with bpm for 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 ReactionWhat You Do
"This one" / picks onepython 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:

  1. Before propose.py --confirm, render.py's frames/all stages are blocked by a proposal gate (default warning, --require-proposal causes failure).
  2. 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 < 2 will be flagged.
  3. Each candidate must have a one-line Chinese pitch (pitch), clearly stating "what this proposal aims to achieve" — not just "Proposal A/Proposal B."
  4. No built-in templates to choose from: All fields in design.json are 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 --seed for reproducibility).
  5. Cross-video deduplication ledger (enabled by default): --confirm records 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-ledger to inspect, --no-ledger to ignore for this run, environment variable VIDEO_STUDIO_LEDGER to change path or set to off to 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 the work/ and out/ directories generated during execution).

<SKILL_DIR> refers to the resourceBase.path reported by the skill tool when this skill is loaded. Installed as a plugin, it is the skills/video-studio/ directory within this plugin package (the profile's node_modules/@dsh-gis/dsh-video-studio-skill is 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/ and schemas/ 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 as seg_<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 is total_frames / jobs / 4 rounded to whole seconds (jobs=4, 900 frames gives ~8 second chunks). Override with chunk_frames in film.json.

After changing dialog / scenes.py / design / encoding parameters, --resume will refuse to reuse old chunks (the plan fingerprint is stored in work/segments/_plan.json) and prompt you to rm -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)

StageOutputKey ActionCommon Mistake
P0.5 Define Designwork/design.json + work/style.jsonpropose.py generates proposals → --confirm freezes design; --stage style only parses confirmed design and prints theme/visual language/layout rotation/subtitlesSkipping → render.py errors (no design = no look); rendering without confirmation triggers proposal warning
P0 Set BaselineCONVENTIONS.mdResolution/framerate/duration/BPM→frame conversion calculated once, shared across entire projectEach file writing magic numbers like 150 → scene boundaries misaligned
P1 Write Scriptfilm.jsonSplit each sentence into text (spoken) and sub (subtitle); each segment has a kind; optional lines[].type to lock compositionUsing 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 CheckFACTS.mdEvery number/year on screen has a source number; maintain a "never show" blacklistSkipping this step → visual and narration numbers conflict, first comment will point out your error
P3 TTSwork/tts/*.mp3 + tts.jsonedge-tts synthesizes sentence by sentence, ffmpeg -f f32le measures actual duration, disk cachedEstimating duration by word count → subtitle drift
P4 Layout Timelinework/timeline.json + sub.srtdur = lead + measured speech + tail, punctuation must be within its own segmentSegment too short → narration cut off by next segment
P5 Mix Audiowork/audio.wavnumpy bus: narration + music + sfx summed sample by sampleffmpeg multi-input mixing → non-deterministic silence
P6 Master Stills3 still framesRender 3 frames first to check composition/colors/font weight, then render full videoRendering the full video directly → 36000 frames later discovering ugly font
P7 Final Videoout/film.mp4rawvideo→libx264 CRF18 + two-pass loudnorm to -16 LUFSBitrate too low/loudness -25 LUFS → "looks like a draft"

Note: The default crf in filmkit / render.py is 18; assets/film.json is 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 write crf in your own script, it defaults to 18; for sharper text, use 17 (approx +30% file size).

3. Choosing a Rendering Engine

ScenarioChoiceMeasured SpeedSuitable For
Graphics/Data/Explainer/UI-stylePIL (numpy+cv2 assisted) — filmkit default40–80 fps @1080p30 (example video: 75)Most Chinese explainer videos, report-to-video
Needs smooth vectors/particles/3D/paper texturesHeadless browser Canvas2D/WebGL + CDP screenshot → image2pipe (webengine.py)80–130 ms/frame (integrated GPU, PNG screenshot)Documentary-level pieces, complex visual language
Pure data animationmanim (if installed)Depends on sceneMathematics/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 in work/design.json are read by assets/scenes.py. The --engine web page 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 follow design.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)
Dependenciesnumpy + Pillow (+cv2 for sharpness comparison)+ playwright + Chromium/Edge
Speed40–80 frames/sec8–12 frames/sec (per-frame screenshot + encoding, an order of magnitude slower)
Visual LanguageBitmap compositing, gradients, geometry, textCanvas2D/WebGL: particles, deformation, shaders, vector animations
DeterminismNatural (pure functions for drawing)Guaranteed by precisely seeking the timeline to f/FPS (via window.renderFrame)
DebuggingModify a frame and run itDebug in the browser with index.html?dev=1&t=seconds
Suitable ForGraphics/Data/Explainer/Promotional VideosDocumentary-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):

  1. All tweens must be on a single paused main timeline; rendering the f-th frame = seeking it to f / FPS seconds; must not rely on playback.
  2. Procedural content like loops/particles/waves must use a "calculate by time" updater (update(fn)), not cumulative setInterval.
  3. 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_lines auto-wraps at max_w=1560 px), the "24 characters" is a rough upper bound, not a hard validation — the constant SUB_MAX_CHARS=24 is 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

LayerWho Does ItPersisted ToDescription
Narrationfilmkit.tts (edge-tts)work/tts/*.mp3The sole authority for duration: the timeline is derived from it
Musicmusicgen.py (parametric orchestration)work/music.wavGenerated on the fly based on design's bpm/key/scale/mood/seed + orchestration parameters; no pre-existing musical styles to choose from
SFXsfxkit.py (physical modeling)work/sfx.wavSynthesized 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 sfx automatically 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.

GateCheckFails IfOutlier Handling
G0 Planfilm.json passes schema...Schema error → stop with code hint
G1 AssetsAudio files exist + have correct lengthMissing or corrupted files--stage tts to regenerate; stale file > tokens → warn "stale TTS, rerun tts"
G2 TimelineSentences non-overlapping + total length = expected ±0.5sOut-of-bounds / over 0.5s driftTarget frame count difference → warn "N frames mismatch after mux, length gate will catch"
G3 CuesAll cue registrants have a rendererMissing / duplicate / 0-duration cuesPrint list of unresolved cues
G4 MusicDuration matches timeline ±0.5s (no gap/overshoot)MismatchGap → interpolate or refill; overshoot → stop
G5 SFXAll sfx.mp3 durations align with cuesUnmatched / timed poorlyLeftovers → warn "orphan sfx"
G6 Audio3 tracks mix to -16 LUFS ±0.5 / True Peak ≤ -0.5 dBTPOut of spec or clippingTrue Peak > -0.5 dBTP → increase headroom; mix > 0.25s deviation → wait/rerun
G7 FramesAll frames output + no batch failuresBatch error / zero outputCheck chunk finish log; --resume reruns empty chunks
G8 EncodeDuration ±0.03s / VMAF ≥ 88(CRF18 reference) / no freeze frames ≥ 5GOP error / black frames / out-of-syncRe-encode; if VMAF < 88, try --crf 17
G9 TextQANo text overlaps / no uncommanded blanks / no flash-textWrong subtitle timing (visual > audio + 0.33s)webengine.py textqa compares rendered frames + subtitle SRT
G10 Stills3 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 ReleaseAll gates passed + last sign-off fingerprint matches current inputStale sign-off / manual --skipPrint 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:

ProblemSymptomSolution
ffmpeg < 5.0loudnorm returns inconsistent LUFSffmpeg -version, upgrade
edge-tts no moduleModuleNotFoundError: No module named 'edge_tts'pip install edge-tts
Network unavailable + cache miss—stage tts stuck / errorPre-download or fallback offline TTS; —tls-bypass skips TLS check for intranet
Windows ffmpeg pathFileNotFoundError: ffmpegAdd to PATH or $env:FFMPEG_PATH="C:\\path\\to\\ffmpeg.exe"

8. References

ReferenceLocationPurpose
Design Contractschemas/design.schema.jsonDefines all fields and constraints of design.json
Proposal Protocolreferences/proposal.mdDescribes proposal generation, revision, comparison, and confirmation workflow
Built-in Scene Rendererassets/scenes.pyReference implementation of draw_frame, including all built-in scene types
film.json Schemaschemas/film.schema.jsonDefines script structure, sentence types, chapter layout
Pipeline Stage Mapreferences/stages.mdData flow, dependency graph, and cross-stage cache invalidation rules for all stages
QC Gate Mapreferences/gates.mdChecks, failure modes, and outlier handling for all 12 gates
Timeline Formatschemas/timeline.schema.jsonDefines timeline output format, composing sentence start, end, duration, and composition reference
Browser Engine Protocolreferences/webengine.mdHow to write a browser page that the engine can drive renderFrame / getCues
Audio Bus Contractreferences/audiobus.mdTrack levels, ducking rules, final limiter parameters for each audio layer
VMAF Baseline Dataresources/vmaf-baseline.csvBaseline VMAF scores for CRF 18/17 and different engine configurations

Related Skills