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/build → internal/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.RunBuild → renderCards + 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)fromrunBuild(everything after storyboard-path resolution, minus the cliout emit).runBuild(CLI) becomesresolveStoryboardPath → RenderInto → emit; the studio callsRenderIntowith the explicit workspace dir + a props scoped tocfg(per-project, like the gen fix). (RenderIntomay later move to aninternal/package if thepkg/studio → pkg/cmdimport 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¶
- Render seam shape (4). A per-project
Renderer(cfg, dir, opts)wrapping a refactoredbuild.RenderInto(rec) — vs duplicating the orchestration in studio. Confirm extractingRenderIntofromrunBuild(CLI parity preserved via the wrapper), and thatpkg/studioimportingpkg/cmd/reel/buildis acceptable for v1. - Render job model (5). Reuse the job store + add
Job.Output, free (no cost), elapsed-only (rec) — vs a separate render-progress type. - Local-only gate (1). In-memory project render →
409+ "switch to a local checkout to render" (rec, per D1). Confirm the status (409 Conflictvs422). - Render concurrency (5). One render per workspace at a time; a second request →
409(rec) — vs a queue or cancel-and-restart. - mp4 serving (6). Generalize
serveFiletoafero.FileReadSeeker streaming (rec, D3) — confirm doing it now (it's small and 2B needs it for<video>). - 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 onereel-<slug>.mp4reused. - 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.