Skip to content

M2: the native renderer - #3

Merged
davior merged 2 commits into
mainfrom
claude/funny-knuth-en5fly
Oct 16, 2026
Merged

davior merged 2 commits into
mainfrom
claude/funny-knuth-en5fly

Conversation

@davior

@davior davior commented Oct 16, 2026

Copy link
Copy Markdown
Owner

M2 of the plan of attack. GVC now renders a project to an MP4, including what the VideoSpec described and Notes' renderer refused:

  • a soundtrack that changes through the video: any number of tracks anchored to scenes, crossfading;
  • a default transition with per-scene overrides;
  • Ken Burns moves between two frames of a picture;
  • trimmed GAM clips, title and chapter cards, narration, chapters and subtitles.

Needs davior/gecko-notes#211 deployed in Notes. Narration is read through Notes, and GVC refuses to narrate against a Notes without it rather than risk a video that changes voice partway through.

What you can do

  • Scenes tab. Each scene gets:
    • "In with": a transition (cut, fades, dissolve, slides, wipes…) and its length, or the project default;
    • "Camera": the Ken Burns presets, or Move between two frames. That opens a frame editor over the picture: drag the green start box and the red end box, resize them by their corners, or type exact values. The boxes keep the video's shape.
  • Soundtrack tab.
    • Add music or sound from GAM.
    • Set where each track plays, from and until any scene's start or end, with an offset.
    • Set volume, fades, "quieter under narration" (ducking) and looping.
    • Crossfade from the track above overlaps two tracks by 3 s with matching fades, anchored to the scene, so it stays put when scenes move.
  • Render tab.
    • The estimate: length and narration characters.
    • What blocks a render: unsaved edits, files still in a note, placeholders still to find.
    • Preview, small and fast, with placeholders drawn as slates.
    • Render, the full version.
    • Live progress and a Stop button.
    • The result, with a player, download and .srt, and past renders.

How

  • The engine is copied from Notes (app/render/: ffmpeg, compose, narration, pause markup, shot cache), together with Notes' own tests for it (359 of them).
  • compile.py is the spec doc's §5.8 compile step, done natively.
    • Scenes are cut at quote and code beats.
    • Defaults cascade field by field.
    • Each join takes the incoming scene's transition: blends overlap (at most half the shorter shot), dips are drawn inside both shots, cuts cut.
    • Structural anchors resolve to seconds.
    • An orphaned anchor drops its track with a warning, never an error.
  • New ffmpeg builders:
    • a zoompan move between two picture frames;
    • trims cut out once, so a looping trim repeats the trim and not the whole file;
    • a stitch with its own transition at each join;
    • an N-track mix, with ducked tracks summed and sidechaincompressed against the narration.
  • Latent bug fixed in the stitch. concat re-times its output, so a cut followed by a blend gave xfade two different timebases and the stitch failed to build. Every input is now normalised first. Notes' copy has the same bug in its cut-then-crossfade path.
  • Media comes from GAM into data/cache/media, keyed on GAM id and size, so a second render fetches nothing. A GAM clip plays from its parent's file at its in_point.
  • Narration is read per beat through Notes, so a track anchored to a beat lands exactly. The engine and voice are pinned from Notes' answer to the first chunk.
  • Jobs.
    • Renders run on GAM's job runner (copied) as the user who asked.
    • Their token is held in memory only. A server restart ends a render ("start it again") rather than leaving a 30-day credential in SQLite.
    • Finished files are served at signed, ranged URLs (/media/renders/…), because <video> can't send a header.

Deployment notes

  • Hairpin fix for the gvc.geckopico.com issue. docker-compose.prod.yml now maps assets.geckopico.com and notes.geckopico.com to host-gateway for the backend. From inside the server, GVC calling GAM by its public name hit hairpin NAT and every search failed with gam_unavailable; via the Docker host it reaches Caddy directly, with the same names and certificates.
  • Volume. All of data/ is mounted now (database, renders, media cache).
  • Default GAM address. GAM_BASE_URL defaults to https://assets.geckopico.com.
  • New settings in .env.example: RENDER_CONCURRENCY (1), RENDER_MAX_SEGMENTS, RENDER_MAX_NARRATION_CHARS, RENDER_CACHE_MAX_GB, MEDIA_URL_TTL_SECONDS.
  • One migration (render_job), run by the entrypoint as usual.

Tests

  • Backend: pytest -q passes, 445 tests. That includes Notes' engine tests and these new ones:
    • Compiler tests on the worked example, among them that its two music beds overlap at the cut into s_ruins.
    • Real-ffmpeg renders of a spec with a title card, a Ken Burns path, a dissolve, a trimmed clip and two crossfading tracks, then probed for streams, duration and chapters.
    • Media cache: a second render fetches no media.
    • Placeholders: they refuse a full render by name and become slates in a preview.
    • Speech: a Notes without #211 stops the render.
    • Render API: the requester's token reaching GAM and Notes, signed and ranged file serving, a tampered signature refused, cancel, restart, ownership.
  • Frontend: format check, lint, npm test (89 tests) and build all pass.
  • The rendered output, checked by eye and ear. Frames along the timeline show the title card, the Ken Burns push towards the chosen frame, the dissolve midway, the fade through black and the trimmed clip. Measuring each song's tone over time: silence under the title card, song 1 from the valley, song 2 fading in 1 s before the river, song 1 gone after the overlap.
  • End to end in a browser against real GAM (main) and a stand-in for #211's speech:
    1. set a dissolve and a two-frame path in the scene cards;
    2. added two tracks from GAM and crossfaded them in the Soundtrack tab;
    3. saved and previewed. The render finished (9.6 s, no warnings) and plays from its signed URL (206 on a range request).

Not in this PR (M2b)

  • Timed text, lower-third and image overlays. They render as warnings for now, never silently dropped.
  • Save to GAM (GAM-3).
  • Credits.

🤖 Generated with Claude Code

https://claude.ai/code/session_016fiRJAt34ag62iPiuMkNhm


Generated by Claude Code

GVC renders a VideoSpec to an MP4, with what the format promised and
Notes' renderer refused:
- a soundtrack that changes through the video;
- a default transition with per-scene overrides;
- Ken Burns moves between two frames of a picture;
- trimmed GAM clips, cards, narration, chapters and subtitles.

The engine is copied from Notes (app/render/: ffmpeg, compose,
narration, pause markup, shot cache) with its tests. It is driven by
GVC's own compile step rather than Notes' shot list:
- compile.py cuts scenes at quote/code beats and cascades defaults
  field by field;
- each join gets its own transition (blends overlap, dips draw inside
  the shots, cuts cut);
- structural anchors resolve to seconds, and an orphaned anchor drops
  its element with a warning.

New builders:
- a zoompan move between two picture frames;
- trimmed clips cut out once, so a loop repeats the trim;
- a stitch with a transition per join;
- a mix of any number of tracks, ducked ones compressed against the
  narration.

The stitch normalises every input's timebase first: concat re-times its
output, and xfade refused a cut followed by a blend, a latent bug in
Notes' copy too.

Media comes from GAM through a cache keyed on GAM id and size.
Placeholders draw as slates in a preview and refuse a full render;
files still in a note refuse both, with the fix.

Narration is read through Notes, one beat at a time, so beat anchors
are exact. The engine and voice are pinned after the first chunk
(gecko-notes#211). A Notes without that refuses, so a video never
changes voice partway through.

Renders run on GAM's job runner as their requester. The token is held
in memory only, so a restart ends a render instead of storing a
credential. Finished files are served at signed, ranged URLs.

The editor gains:
- per-scene transition and Ken Burns controls, with a frame editor;
- a Soundtrack tab, with crossfade in one click;
- a Render tab with the estimate, what blocks a render, progress, the
  result and past renders.

Deployment:
- all of data/ is mounted (renders, media cache);
- GAM_BASE_URL defaults to assets.geckopico.com;
- the prod overlay points the backend at the Docker host for the
  sibling names. GVC's server calling GAM by its public name from
  inside the same machine failed with gam_unavailable (hairpin NAT).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fiRJAt34ag62iPiuMkNhm
The renderer's compose module imports PIL. It was installed in the local
venv but never pinned, so CI and the Docker image lacked it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016fiRJAt34ag62iPiuMkNhm
@davior
davior marked this pull request as ready for review October 16, 2026 05:32
@davior
davior merged commit 8b8da80 into main Oct 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants