EmberKiln production pipeline — DRY architecture (north-star)
Forward-looking target architecture for unifying EmberKiln's content-production surfaces into one flexible-yet-DRY pipeline. Direction, not a build — the incremental sequence at the bottom is how to get there without a big-bang rewrite. Brand context: [memory project_movie_studio_vision], the studio rebrand, and the "one story, every form" thesis on
/emberkiln.
Why
"One story, every form" is currently five surfaces that each re-implement the same pipeline:
| Surface | Source → … → Output | Service(s) |
|---|---|---|
| Audiobook | manuscript/prompt → chapters → TTS → assemble → M4B/ACX | season-audio.ts, season-novel-chapter-audio.ts, acx-export.ts |
| Scene / animatic | chapter → shots → images → video → lip-sync → assemble | scene-studio.ts |
| Maxell Reel | entry → 2-voice script → render → 1080×1920 MP4 | maxell-* / reels |
| Screenplay | novella → beats → Fountain → FDX/PDF | screenplay-engine.ts, screenplay-export.ts |
| Print paperback | season → interior+cover PDF → Lulu | print-export.ts |
They call the same handful of providers but each owns its own glue, cost
metering, and assembly (verified): Scene Studio → Replicate (Kling) + Flux +
lip-sync + recordGenerationCost; audiobook → ElevenLabs + ffmpeg/loudnorm + its
own metering; reels → its own ffmpeg render. That's duplicated orchestration,
duplicated cost/quota layers, and duplicated ffmpeg assembly.
The convergence thesis: every surface is the same shape —
source → structured plan → render assets → assemble → distribute. They differ
only in which stages run and what config. So: one core, many recipes.
Target architecture (4 layers)
1. Source → Work (ingest)
Manuscript (EPUB/DOCX/PDF), journal entry, prompt, or existing season chapter all
normalize to one Work (title, author, prose, metadata, provenance). This
collapses today's separate bridges — /studio/new/from-note, /studio/new/paste,
reels/new?from_entry_id, manuscript ingest — into one "send this Work to
surface X" router.
2. Production IR (the linchpin)
A canonical document: ordered segments, each
{ text, speaker/voice, visual_prompt, dialogue, duration }.
- The audiobook renderer reads
text+voice. - The scene renderer reads
visual_prompt+duration. - Lip-sync reads
dialogue+ the rendered clip. - The screenplay is another view of the same IR (segments → Fountain).
- A reel is a 1–3-segment IR + a template.
One representation, many renderers. This is what makes the pipeline flexible (any source → any output) without copy-paste.
3. Render kit (shared primitives — metered + cached)
Extract the provider calls + assembly + metering that the surfaces re-implement
today into shared, individually-metered, individually-cached stage adapters:
tts() · image() · video() · lipsync() · assemble() (ffmpeg concat /
loudnorm / mux) · upload() · meter() (ONE cost/quota layer, replacing the
scene daily-cap + tts-spend + per-surface tracking). This is the consolidation
of what's split across scene-studio.ts + season-audio.ts.
4. Surface recipes (thin)
Each surface becomes an ordered selection of kit stages over the IR + config:
- audiobook = IR →
tts→assemble(audio)→ M4B/ACX - scene/animatic = IR → breakdown →
image(refs+keyframes) →video→lipsync→assemble(video) - reel = IR(1 seg) →
image/video→lipsync→assemble(1080×1920)+ CTA - screenplay = IR → Fountain → FDX/PDF
- print = IR → typeset → PDF → Lulu
Recipes stay thin but separate — see the caution.
The one caution (flexible yet DRY)
DRY the primitives + the IR — those are genuinely shared. Keep the recipes thin but separate. A single mega-orchestrator forcing every surface through one code path would be worse than the current duplication (every surface's sequence + config differs enough that the abstraction would leak everywhere). The win is shared stages + a shared intermediate, not a shared orchestrator.
Incremental path (no big-bang)
- Extract the render kit — pull the provider calls + ffmpeg assembly + cost metering that Scene Studio, audiobook, and reels already call into shared adapters. Pure refactor, no behavior change; each surface keeps working.
- Converge the shared composition recipes — pull the ffmpeg recipes that
more than one surface genuinely shares into the kit as parameterized builders.
Correction (found while executing): the original plan said "converge Reels
→ Scene Studio engine," but tracing the code, those are not the same surface —
Scene Studio is a generated-keyframe → clip → lip-sync pipeline; Reels is a
static-template + TTS pipeline. The reel's real twin is cafe-winner Shorts
(the reels code even says "same recipe as cafe-winner-shorts"). Both hand-rolled
the same "loop a still/clip under a voiceover → 1080×1920 MP4" invocation, so
that recipe (
composeStillVideo) is the honest first composition merge — not a forced Reels↔Scene marriage. Same pattern next for the audiobook loudnorm/M4B mux and Scene Studio's clip-concat assemble. - Introduce the Production IR + source→Work router — unify the ingest bridges behind one router; make the IR the contract between ingest and renderers.
- Re-express the remaining surfaces (audiobook, screenplay, print) as recipes over IR + kit.
Cross-cutting wins once unified
- One cost/quota layer instead of three.
- One job/status/heartbeat model for every render.
- One storage layout + one "made with EmberKiln" provenance trail.
Payoff (and the dogfood)
In this model, "Al, a trope" is one Work → one IR (the monologue) → the
scene recipe makes the video, the audiobook recipe makes the VO, the reel
recipe makes the vertical cut — all from the same source. Today that's three
surfaces; unified, it's one Work with three render targets. That's literally
"one story, every form" as an engine — and the cleanest possible dogfood.
Status
North-star architecture. Steps 1–2 underway — the render kit module exists at
apps/backend/src/services/render-kit/:
- provider-client adapters (
getReplicateClient/getOpenAiClient) — consolidated the inline Replicate/OpenAI instantiation scene-studio + season-music + season-media each duplicated. (#820) - ffmpeg primitives (
FFMPEG/FFPROBE/runFfmpeg/runFfprobe/probeDurationSeconds) — one canonical binary resolution (re-exported by season-audio for its 15 importers); season-music + season-video + reels de-duped onto the shared runner; reels' fragileFFMPEG.replace(/ffmpeg$/,'ffprobe')path-munge replaced by the realFFPROBEbinary. (#821, #823) - composition recipes (
composeStillVideo/buildStillVideoArgs) — the first recipe (vs. primitive): the "loop a still/clip under a voiceover → 1080×1920 MP4" build that Maxell Reels and cafe-winner Shorts had each hand-rolled. Golden-tested byte-for-byte against the three original invocations. (#824) - loudnorm recipe (
buildLoudnormFilter/parseLoudnormMeasurement) — the audiobook loudness pair: theloudnorm=…filter builder (chapter master two-pass -16 LUFS + ACX single-pass -20 LUFS — 3 call sites across mastering + acx-export) and the fragile pass-1 measurement JSON parser. Golden-tested (filter strings byte-for-byte + parser valid/garbage/missing-field). acx's duplicateprobeDurationLocalfolded into the kit'sprobeDurationSeconds. The M4B mux stayed in acx-export — single caller, not yet worth extracting. (#826) - Enabling fix: extracted the shared supabase client to a leaf module so route-level unit tests stop booting the whole app (a circular-import TDZ). (#822)
All behavior-preserving. Next render-kit candidates: more composition recipes (audiobook loudnorm/M4B mux, Scene Studio clip-concat assemble), then the Production IR + the source→Work router, then re-express audiobook/screenplay/print as recipes over IR + kit.