Skip to content

0017 — studio render & preview: silent draft + full reel (R-UI-8/11)

Status: IMPLEMENTED (studio Phase 2B, 2026-06-26). §7 decisions resolved — all per the recommendations: (1) per-project Renderer wrapping extracted build.RenderInto; (2) reuse the job store + Job.Output, free, elapsed-only; (3) in-memory render → 409 + message; (4) one render per workspace, second → 409; (5) generalize serveFile to ReadSeeker streaming now; (6) distinct outputs reel-<slug>-draft.mp4 / reel-<slug>.mp4; (7) a collapsible Preview pane.) Date: 2026-06-26 Depends on: the render core (pkg/cmd/reel/buildinternal/render/ffmpeg, internal/render/cards), the Phase-1 async job harness (spec 0013), per-project config (0014 + the gen-config fix), and selected VO/music from 2A (0016 — VO drives card timing). Parent: spec 0015 (§5 build order: 2B after 2A; D1 lock-out, D2 elapsed-only, D3 ReadSeeker serving — all confirmed).

1. Goal & scope

Surface R-UI-8 (silent draft preview) + R-UI-11 (full render) in the editor: render the current workspace to an mp4 and play it back in the browser. The render core exists (reel build, shelling out to ffmpeg); the studio surfaces neither.

Render is LOCAL-ONLY (spec 0015 D1, confirmed by the #68 spike: no CGO-free in-memory ffmpeg path is viable today; the eventual lift is the sibling afmpeg project). In-memory projects get a clear "switch to a local checkout to render" message, not a broken render. This is the defining constraint of 2B.

In scope: a render seam (per-project, local-only) wrapping the render core; a render job (one long job → one mp4, elapsed-only, free); silent-draft + full actions; mp4 streaming for <video> playback; the local-only gate; a preview pane. Out of scope: in-memory render (afmpeg), posting (2C), exposure/auth (2D), progress percentage (elapsed-only v1).

2. What already exists (reuse map)

Need Existing core Gap
Render build.RunBuildrenderCards + timingAndAudio + buildTimeline + ffmpeg.Renderer.Render CLI-shaped: resolves ws from the global reel root + slug; emits via cliout; not a studio seam
Renderer provider.RenderFactory (default ffmpeg), internal/render/ffmpeg (exec seam, 10-min timeout) resolved against global props.Config (fix per-project like the gen seam)
Silent vs full BuildOptions.Silent (storyboard dur, no VO) vs VO-driven timing
Async harness startGeneration/runJob/getJob (0013) cost/ETA/takes-shaped — render is free, one output, elapsed-only
Serve bytes serveFile (afero.ReadFile + ServeContent) full-buffers the file — D3: stream via the afero.File ReadSeeker for mp4

3. The studio surface (proposed)

Method · path Effect
POST /api/v1/workspace/{slug}/render {silent?, theme?} start a render job → 202 {id} (409 if in-memory, or already rendering)
GET /api/v1/workspace/{slug}/jobs/{id} poll {state, elapsed_ms, output, error} (the shared job poll)
GET /api/v1/workspace/{slug}/file/{path...} stream the mp4 for <video> (existing route, D3 ReadSeeker)

silent: true → R-UI-8 draft (storyboard timing, no VO/music — fast, no audio deps); silent omitted/false → R-UI-11 full (VO-driven timing + music bed). Output lands in the workspace: reel-<slug>.mp4 (full) / reel-<slug>-draft.mp4 (silent), served back by path.

4. The render seam (per-project, local-only)

A Renderer seam mirroring the Generator seam — narrow, fakeable, per-project:

type Renderer interface {
    // Available reports whether rendering can proceed (ffmpeg resolvable); the
    // local-only gate is enforced by the handler, not here.
    Available() error
    // Render renders the workspace at dir (a real OS path — render is local-only)
    // to opts.Out, resolving the renderer + theme against cfg (the active project's
    // config). Returns the output path + duration.
    Render(ctx context.Context, cfg config.Containable, dir string, opts RenderOpts) (RenderResult, error)
}
type RenderOpts struct { Silent bool; Theme string; Out string }
type RenderResult struct { VideoPath string; DurationSec float64; Cards int }
  • Live impl wraps a refactored render core: extract build.RenderInto(ctx, props, sbPath, ws, opts) (ffmpeg.Video, error) from runBuild (everything after storyboard-path resolution, minus the cliout emit). runBuild (CLI) becomes resolveStoryboardPath → RenderInto → emit; the studio calls RenderInto with the explicit workspace dir + a props scoped to cfg (per-project, like the gen fix). (RenderInto may later move to an internal/ package if the pkg/studio → pkg/cmd import direction grates — noted, not blocking.)
  • Fake impl (tests) writes a stub mp4 into the workspace — no ffmpeg, no exec.
  • noopRenderer (default when unwired) → render unavailable.

5. The render job (one output, free, elapsed-only)

Render reuses the job store but differs from generation on three axes (0015 C2): - One output, not N takes — the job carries the mp4 path. Add Job.Output string (the rendered file, workspace-relative) rather than overloading Takes. - Free — local ffmpeg, no API spend → no cost cue (Cost omitted/zero for render jobs). The money machinery is for paid generation only. - Elapsed-only (0015 D2) — render time is hard to estimate (card count × ffmpeg); show measured elapsed_ms, no eta_ms. kind: "render".

A small startRender entry (sibling to startGeneration) registers the job, captures cfg := a.config() + the local dir, and runs Renderer.Render in the background, recording Output (or the error) on completion. One render per project at a time: a render request while one is in flight for the same workspace → 409 (render is CPU-heavy; no queue in v1).

6. Serving the mp4 (D3 — ReadSeeker streaming)

Generalize serveFile: instead of afero.ReadFile (whole file into memory) + a bytes.Reader, Open the afero.File and hand it to http.ServeContent (an io.ReadSeeker) so range requests stream without buffering the whole mp4. For a local OS project the file is an *os.File → efficient ranged <video> scrubbing. Keep the nosniff + no-store headers + the traversal guard. This also benefits image/audio takes (no behaviour change; just unbuffered).

7. Decisions to confirm before building

  1. Render seam shape (4). A per-project Renderer(cfg, dir, opts) wrapping a refactored build.RenderInto (rec) — vs duplicating the orchestration in studio. Confirm extracting RenderInto from runBuild (CLI parity preserved via the wrapper), and that pkg/studio importing pkg/cmd/reel/build is acceptable for v1.
  2. Render job model (5). Reuse the job store + add Job.Output, free (no cost), elapsed-only (rec) — vs a separate render-progress type.
  3. Local-only gate (1). In-memory project render → 409 + "switch to a local checkout to render" (rec, per D1). Confirm the status (409 Conflict vs 422).
  4. Render concurrency (5). One render per workspace at a time; a second request → 409 (rec) — vs a queue or cancel-and-restart.
  5. mp4 serving (6). Generalize serveFile to afero.File ReadSeeker streaming (rec, D3) — confirm doing it now (it's small and 2B needs it for <video>).
  6. Silent-draft output (3). Silent → reel-<slug>-draft.mp4, full → reel-<slug>.mp4 (rec, distinct files so a draft doesn't clobber a good full render) — vs one reel-<slug>.mp4 reused.
  7. Preview UI placement (8). A collapsible Preview pane in the editor (a RenderPanel: silent + full buttons, job elapsed/state, a <video> of the result, disabled-with-reason for in-memory projects) — rec — vs a block in the associated panel. Mobile-first/collapsible per R-UI-20/21.

8. Testing / DoD

Failing test → code → green just ci. A fake Renderer (stub mp4, no ffmpeg/exec) for the studio handler tests + godog (render job → done → the mp4 serves; in-memory project render → 409). Parity: RenderInto keeps reel build behaviour (the existing build tests stay green). The real ffmpeg render stays an INT_TEST=1-gated integration test. -race over the render job path. Docs: the studio component page (render & preview section) + the render local-only note cross-linked to the #68 spike and the afmpeg project. /simplify + /code-review.