product

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:

SurfaceSource → … → OutputService(s)
Audiobookmanuscript/prompt → chapters → TTS → assemble → M4B/ACXseason-audio.ts, season-novel-chapter-audio.ts, acx-export.ts
Scene / animaticchapter → shots → images → video → lip-sync → assemblescene-studio.ts
Maxell Reelentry → 2-voice script → render → 1080×1920 MP4maxell-* / reels
Screenplaynovella → beats → Fountain → FDX/PDFscreenplay-engine.ts, screenplay-export.ts
Print paperbackseason → interior+cover PDF → Luluprint-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 → ttsassemble(audio) → M4B/ACX
  • scene/animatic = IR → breakdown → image(refs+keyframes) → videolipsyncassemble(video)
  • reel = IR(1 seg) → image/videolipsyncassemble(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)

  1. 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.
  2. 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.
  3. Introduce the Production IR + source→Work router — unify the ingest bridges behind one router; make the IR the contract between ingest and renderers.
  4. 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' fragile FFMPEG.replace(/ffmpeg$/,'ffprobe') path-munge replaced by the real FFPROBE binary. (#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: the loudnorm=… 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 duplicate probeDurationLocal folded into the kit's probeDurationSeconds. 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.

EMBERKILN PIPELINE ARCHITECTURE — Docs | HiveJournal