voxflow 1.15.2 → 1.15.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -38,9 +38,14 @@ const DEFAULT_CARD_SEC = 4;
38
38
  * Phase 0 is silent — every card gets DEFAULT_CARD_SEC. Phase 1 will
39
39
  * splice per-card TTS in and replace this with audio-driven durations.
40
40
  */
41
- function buildInputProps(deck) {
41
+ function buildInputProps(deck, opts = {}) {
42
+ // Map of cardIdx → audio URL produced by prepareVoiceovers (or empty when
43
+ // the renderer runs silent). Threads into PaperSlideDeckProps.cards[].slide
44
+ // .voiceoverSrc so the composition's <Audio> element fetches it during
45
+ // Remotion's headless render.
46
+ const voiceoverByIdx = opts.voiceoverByIdx || {};
42
47
  const numberBadge = null;
43
- const cards = deck.cards.map((card) => {
48
+ const cards = deck.cards.map((card, i) => {
44
49
  const slide = {
45
50
  kind: card.kind,
46
51
  header: deck.header,
@@ -49,7 +54,7 @@ function buildInputProps(deck) {
49
54
  figureKeyword: card.figureKeyword ?? null,
50
55
  seriesTitle: deck.seriesTitle,
51
56
  seriesTagline: deck.seriesTagline,
52
- voiceoverSrc: null,
57
+ voiceoverSrc: voiceoverByIdx[i] || null,
53
58
  numberBadge,
54
59
  imageUrl: card.imageUrl,
55
60
  };
@@ -181,8 +186,53 @@ async function render(opts) {
181
186
  const outputDir = path.dirname(outputPath);
182
187
  if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
183
188
 
189
+ // ─── Voiceover prep (Phase 1) ────────────────────────────────────────
190
+ // Synthesize per-card TTS up front so renderMedia's headless Chromium
191
+ // can fetch each clip as the composition plays. Reuses the audition
192
+ // cache so a card the user previewed in stage doesn't pay quota again.
193
+ // Skip the whole pass on --no-audio (back-compat with Phase 0 silent).
194
+ const includeAudio = opts.noAudio !== true;
195
+ let voiceoverByIdx = {};
196
+ let voiceoverServer = null;
197
+ let voiceoverSkipped = [];
198
+ if (includeAudio) {
199
+ const { createTtsAuditionClient } = require('../stage-core/tts-audition');
200
+ const { startVoiceoverServer, prepareVoiceovers } = require('../stage-core/voiceover-mux');
201
+ const audClient = createTtsAuditionClient();
202
+ voiceoverServer = await startVoiceoverServer({ cacheDir: audClient.cacheDir });
203
+ let synthCount = 0;
204
+ let cacheCount = 0;
205
+ const prep = await prepareVoiceovers({
206
+ deck,
207
+ auditionClient: audClient,
208
+ baseUrl: voiceoverServer.url,
209
+ onProgress: (p) => {
210
+ if (p.fromCache) cacheCount += 1; else synthCount += 1;
211
+ process.stdout.write(
212
+ `\r[slice render] voiceover ${p.cardIdx + 1}/${p.total} ` +
213
+ `(${p.fromCache ? 'cache' : 'synth'}) `
214
+ );
215
+ },
216
+ });
217
+ voiceoverByIdx = prep.byIdx;
218
+ voiceoverSkipped = prep.skipped;
219
+ if (synthCount > 0 || cacheCount > 0) process.stdout.write('\n');
220
+ if (Object.keys(voiceoverByIdx).length === 0) {
221
+ const fatal = voiceoverSkipped.find(
222
+ (s) => s.reason === 'not_logged_in' || s.reason === 'quota_exceeded'
223
+ );
224
+ if (fatal) {
225
+ console.warn(
226
+ `[slice render] ⚠ audio skipped — ${fatal.reason}` +
227
+ (fatal.message ? `: ${fatal.message}` : '') +
228
+ ' (rendering silent video; pass --no-audio to suppress this notice)'
229
+ );
230
+ }
231
+ }
232
+ }
233
+
184
234
  const serveUrl = resolveServeUrl();
185
- const inputProps = buildInputProps(deck);
235
+ const inputProps = buildInputProps(deck, { voiceoverByIdx });
186
236
 
187
237
  // Lazy require so users who never run `slice render` don't pay the
188
238
  // remotion install cost at CLI startup (renderer pulls in puppeteer-
@@ -246,24 +296,38 @@ async function render(opts) {
246
296
  const totalMs = Date.now() - t0;
247
297
  process.stdout.write('\n');
248
298
 
299
+ // Tear down the localhost audio file server after the render is fully
300
+ // committed to disk so a hanging Chromium fetch can't be interrupted.
301
+ if (voiceoverServer) {
302
+ try { await voiceoverServer.close(); } catch { /* best-effort */ }
303
+ }
304
+
249
305
  const stat = fs.statSync(outputPath);
250
306
  console.log(`[slice render] done in ${fmtSec(totalMs)} — ${humanSize(stat.size)}`);
251
307
  console.log(`[slice render] saved to ${outputPath}`);
252
- return { outputPath, totalMs, frames: lastFrame, size: stat.size };
308
+ return {
309
+ outputPath,
310
+ totalMs,
311
+ frames: lastFrame,
312
+ size: stat.size,
313
+ voiceoverCount: Object.keys(voiceoverByIdx).length,
314
+ voiceoverSkipped,
315
+ };
253
316
  }
254
317
 
255
318
  async function handle(args) {
256
319
  const { parseFlag } = require('../core/args');
257
320
  const output = parseFlag(args, '--output', '-o');
321
+ const noAudio = args.includes('--no-audio');
258
322
  const positional = args.find(
259
323
  (a) => !a.startsWith('-') && !a.startsWith('--')
260
324
  );
261
325
  if (!positional) {
262
- console.error('Usage: voxflow slice render <deck.json> [--output out.mp4]');
326
+ console.error('Usage: voxflow slice render <deck.json> [--output out.mp4] [--no-audio]');
263
327
  process.exit(1);
264
328
  }
265
329
  try {
266
- await render({ deckPath: positional, output });
330
+ await render({ deckPath: positional, output, noAudio });
267
331
  } catch (err) {
268
332
  console.error(`\nslice render failed: ${err.message}`);
269
333
  if (process.env.VOXFLOW_DEBUG) console.error(err.stack);
@@ -19,8 +19,13 @@ const { watchDeckFile } = require('../stage-core/watcher');
19
19
  const { createEventBus } = require('../stage-core/event-bus');
20
20
  const { createSnapshotStore } = require('../stage-core/snapshot-store');
21
21
  const { createCloudRenderClient } = require('../stage-core/cloud-render');
22
+ const { startLocalRender, getJobStatus } = require('../stage-core/local-render');
23
+ const { createTtsAuditionClient } = require('../stage-core/tts-audition');
24
+ const { validatePaperSlideDeck, isV2LayoutTreeDeck } = require('../internal/deck-validator');
22
25
  const { renderSliceStageHtml } = require('../stage-ui/slice/template');
23
26
  const { emit: emitTelemetry } = require('../core/telemetry');
27
+ const { readCachedToken } = require('../core/auth');
28
+ const { SYNTHESIZE_DEFAULTS } = require('../core/config');
24
29
 
25
30
  // Sourced from the canonical registry at repo root. Previously this list
26
31
  // silently fell out of sync (lagged at 6 themes while the rest of the repo
@@ -137,12 +142,104 @@ async function startSliceStage(opts) {
137
142
  // user logged into; the page never sees the JWT.
138
143
  const cloudRender = opts.cloudRender || createCloudRenderClient();
139
144
 
145
+ // ─── Local render bridge ───────────────────────────────────────────────
146
+ // Wires @remotion/renderer into stage so the page can produce mp4 without
147
+ // touching the cloud. The bridge captures the deck snapshot at submit
148
+ // time and proxies progress events through the existing SSE bus.
149
+ const localRenderBridge = opts.localRender || {
150
+ start({ output, onProgress, onDone, onError }) {
151
+ if (!snapshot.deck) {
152
+ const err = new Error('No deck loaded — fix the JSON parse error first.');
153
+ err.code = 'invalid_deck';
154
+ throw err;
155
+ }
156
+ return startLocalRender({
157
+ deck: snapshot.deck,
158
+ deckPath,
159
+ output,
160
+ onProgress,
161
+ onDone,
162
+ onError,
163
+ });
164
+ },
165
+ status: (jobId) => getJobStatus(jobId),
166
+ };
167
+
168
+ // ─── Inline deck save bridge (Task B) ──────────────────────────────────
169
+ // Validates the incoming deck against the same schema the cloud route
170
+ // uses, then writes atomically (tmp → rename). The watcher already
171
+ // broadcasts the resulting change to SSE — no extra fan-out needed.
172
+ const deckSaverBridge = opts.deckSaver || {
173
+ save(deck) {
174
+ if (isV2LayoutTreeDeck(deck)) {
175
+ return {
176
+ ok: false,
177
+ message: 'V2 LayoutTree decks are not supported for inline edit yet — edit deck.json directly.',
178
+ };
179
+ }
180
+ try {
181
+ validatePaperSlideDeck(deck);
182
+ } catch (err) {
183
+ return { ok: false, message: err.message, errors: [err.message] };
184
+ }
185
+ const tmp = `${deckPath}.tmp-${process.pid}-${Date.now()}`;
186
+ const out = JSON.stringify(deck, null, 2) + '\n';
187
+ fs.writeFileSync(tmp, out, 'utf8');
188
+ fs.renameSync(tmp, deckPath);
189
+ return { ok: true };
190
+ },
191
+ };
192
+
193
+ // ─── TTS audition bridge (per-card ▶ on stage UI) ──────────────────────
194
+ // Resolves `card.voiceover` / `card.voiceId` / `card.narration` against the
195
+ // live deck snapshot at request time so editing the deck → ▶ replays the
196
+ // new content immediately. Audio is cached by content hash so iterative
197
+ // re-listens cost zero quota after the first call.
198
+ const auditionBridge = opts.audition || (() => {
199
+ const tts = opts.ttsClient || createTtsAuditionClient(opts.ttsClientOpts || {});
200
+ return {
201
+ async play({ cardIndex, voiceOverride }) {
202
+ if (!snapshot.deck) return { code: 'no_deck', message: 'no deck loaded' };
203
+ const cards = Array.isArray(snapshot.deck.cards) ? snapshot.deck.cards : [];
204
+ const card = cards[cardIndex];
205
+ if (!card) return { code: 'card_not_found', message: `no card at index ${cardIndex}` };
206
+ const vo = card.voiceover || {};
207
+ if (vo.enabled === false) {
208
+ return { code: 'voiceover_disabled', message: 'card voiceover.enabled = false' };
209
+ }
210
+ const text = (typeof vo.text === 'string' && vo.text.trim()) ? vo.text : card.narration;
211
+ if (typeof text !== 'string' || !text.trim()) {
212
+ return { code: 'invalid_text', message: 'no text to synthesize (card.voiceover.text or card.narration)' };
213
+ }
214
+ const voiceId = (voiceOverride && String(voiceOverride).trim())
215
+ || vo.voiceId
216
+ || card.voiceId
217
+ || SYNTHESIZE_DEFAULTS.voice;
218
+ const speed = typeof vo.rate === 'number' ? vo.rate : 1.0;
219
+ return tts.audition({ voiceId, text, speed, format: 'mp3' });
220
+ },
221
+ };
222
+ })();
223
+
224
+ // Boot-time auth probe so the UI can emphasise local vs cloud render.
225
+ // We treat any cached, non-expired token as "logged in"; the actual
226
+ // request flow still revalidates on /api/quota-balance.
227
+ const tokenAvailable = (() => {
228
+ if (typeof opts.tokenAvailable === 'boolean') return opts.tokenAvailable;
229
+ try { return !!readCachedToken(); } catch { return false; }
230
+ })();
231
+
140
232
  const handle = await startStageServer({
141
233
  uiHtml: initialHtml,
142
234
  getDeckSnapshot: () => snapshot,
143
235
  subscribe: bus.subscribe,
144
236
  snapshots: snapshotsBridge,
145
237
  cloudRender,
238
+ localRender: localRenderBridge,
239
+ deckSaver: deckSaverBridge,
240
+ audition: auditionBridge,
241
+ publishEvent: bus.publish,
242
+ tokenAvailable,
146
243
  preferredPort,
147
244
  maxPortTries,
148
245
  });
@@ -128,6 +128,49 @@ function validateListPayload(list, i) {
128
128
  });
129
129
  }
130
130
 
131
+ // Optional per-card voiceover override. Extends the legacy `card.voiceId`
132
+ // (V1-only) with a nested object that carries audio behavior toggles — silent
133
+ // card, custom TTS text override, speech rate — so stage's audition endpoint
134
+ // and the local-render mux pass resolve a single source of truth per card.
135
+ // All fields are optional inside an optional object: omitting `voiceover`
136
+ // entirely keeps existing decks unchanged. Render-time resolution (highest
137
+ // precedence first):
138
+ // voiceId = voiceover.voiceId ?? card.voiceId ?? job-level default
139
+ // text = voiceover.text ?? card.narration
140
+ // enabled = voiceover.enabled ?? true
141
+ // rate = voiceover.rate ?? 1.0
142
+ const VOICEOVER_TEXT_MAX = 500;
143
+ function validateVoiceoverShape(vo, cardIdx) {
144
+ if (vo == null) return;
145
+ if (typeof vo !== 'object' || Array.isArray(vo)) {
146
+ throw new Error(`cards[${cardIdx}].voiceover must be an object`);
147
+ }
148
+ if (vo.enabled != null && typeof vo.enabled !== 'boolean') {
149
+ throw new Error(`cards[${cardIdx}].voiceover.enabled must be boolean`);
150
+ }
151
+ if (vo.voiceId != null) {
152
+ if (typeof vo.voiceId !== 'string' || !vo.voiceId.trim()) {
153
+ throw new Error(`cards[${cardIdx}].voiceover.voiceId must be non-empty string when present`);
154
+ }
155
+ if (vo.voiceId.length > 128) {
156
+ throw new Error(`cards[${cardIdx}].voiceover.voiceId too long (${vo.voiceId.length} > 128)`);
157
+ }
158
+ }
159
+ if (vo.text != null) {
160
+ if (typeof vo.text !== 'string') {
161
+ throw new Error(`cards[${cardIdx}].voiceover.text must be string`);
162
+ }
163
+ if (vo.text.length > VOICEOVER_TEXT_MAX) {
164
+ throw new Error(`cards[${cardIdx}].voiceover.text too long (${vo.text.length} > ${VOICEOVER_TEXT_MAX})`);
165
+ }
166
+ }
167
+ if (vo.rate != null) {
168
+ if (typeof vo.rate !== 'number' || !Number.isFinite(vo.rate) || vo.rate < 0.5 || vo.rate > 2.0) {
169
+ throw new Error(`cards[${cardIdx}].voiceover.rate must be number in [0.5, 2.0]`);
170
+ }
171
+ }
172
+ }
173
+
131
174
  function validatePaperSlideDeck(deck) {
132
175
  if (!deck || typeof deck !== 'object') throw new Error('deck missing');
133
176
  for (const f of ['header', 'seriesTitle', 'seriesTagline']) {
@@ -235,6 +278,7 @@ function validatePaperSlideDeck(deck) {
235
278
  throw new Error(`cards[${i}].voiceId too long (${card.voiceId.length} > 128)`);
236
279
  }
237
280
  }
281
+ validateVoiceoverShape(card.voiceover, i);
238
282
  // Optional per-card image URL — photo-feature / atmospheric themes
239
283
  // composite it as a full-bleed background; other themes ignore it.
240
284
  // Shape-check only (string, length cap, http(s) prefix); reachability
@@ -446,6 +490,7 @@ function validatePaperSlideDeckV2(deck) {
446
490
  if (stepsEls.length !== 1) throw new Error(`cards[${i}] list card must contain exactly one steps element (got ${stepsEls.length})`);
447
491
  richCounts.list += 1;
448
492
  }
493
+ validateVoiceoverShape(card.voiceover, i);
449
494
  });
450
495
  // Cap on rich-kind variety — at most 1 of each (same as V1 prompt rule)
451
496
  for (const k of Object.keys(richCounts)) {
@@ -477,6 +522,7 @@ module.exports = {
477
522
  validateQuotePayload,
478
523
  validateDataPayload,
479
524
  validateListPayload,
525
+ validateVoiceoverShape,
480
526
  QUOTE_TEXT_MAX,
481
527
  QUOTE_ATTRIBUTION_MAX,
482
528
  DATA_VALUE_MAX,
@@ -485,4 +531,5 @@ module.exports = {
485
531
  LIST_ITEM_MAX_LEN,
486
532
  LIST_ITEM_MIN_COUNT,
487
533
  LIST_ITEM_MAX_COUNT,
534
+ VOICEOVER_TEXT_MAX,
488
535
  };
@@ -0,0 +1,324 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Stage → @remotion/renderer bridge for the *offline* Slice renderer.
5
+ *
6
+ * Why this exists: stage's existing "Render mp4" button proxies to the cloud
7
+ * backend (Fly render-worker), which requires `voxflow login` and burns 500
8
+ * quota per call. The Phase 0 promise was offline-first authoring — if a
9
+ * skill-driven user never logged in, the only render path on the stage page
10
+ * was broken. This module wires the same `@remotion/renderer` flow that
11
+ * `voxflow slice render` already uses (see `slice-render.js`) into stage,
12
+ * so the page can produce an mp4 against the pre-bundled Remotion serveUrl
13
+ * at `cli/dist/remotion-bundle/` with no network round-trip.
14
+ *
15
+ * Public surface:
16
+ * startLocalRender({ deck, output, onProgress, onDone, onError })
17
+ * → { jobId } — non-blocking, returns immediately
18
+ * getJobStatus(jobId) → { state, progress, framesRendered,
19
+ * framesTotal, outputPath, error }
20
+ *
21
+ * State machine per job:
22
+ * pending → preparing → rendering → completed
23
+ * └→ failed
24
+ *
25
+ * Concurrency: jobs run sequentially on the stage server's event loop —
26
+ * `@remotion/renderer` spawns headless Chromium in subprocess workers and is
27
+ * CPU-bound, so parallel renders would thrash. The stage server is for
28
+ * single-author iteration; queueing is acceptable.
29
+ *
30
+ * Reuses helpers from slice-render.js (single source of truth):
31
+ * - buildInputProps — validator deck → PaperSlideDeckProps
32
+ * - resolveServeUrl — find dist/remotion-bundle in dev + ncc layouts
33
+ * - chromeBinaryExists — detect first-run cold start (so we can warn)
34
+ * - THEME_TO_DECK_ID — slice theme id → Remotion composition id
35
+ */
36
+
37
+ const crypto = require('crypto');
38
+ const fs = require('fs');
39
+ const path = require('path');
40
+
41
+ const {
42
+ buildInputProps,
43
+ resolveServeUrl,
44
+ chromeBinaryExists,
45
+ THEME_TO_DECK_ID,
46
+ DEFAULT_THEME,
47
+ } = require('../commands/slice-render');
48
+ const { createTtsAuditionClient } = require('./tts-audition');
49
+ const { startVoiceoverServer, prepareVoiceovers } = require('./voiceover-mux');
50
+
51
+ // In-memory job table. We never persist jobs — a stage restart wipes history,
52
+ // which is fine because the produced mp4 lives on disk under the user's deck
53
+ // directory. The page only knows about jobs while it's open.
54
+ const jobs = new Map();
55
+
56
+ function makeJobId() {
57
+ // crypto.randomUUID is Node 19+; the cli's engines field requires 20+, so
58
+ // it's always available. Tests can stub the module to inject deterministic
59
+ // ids if needed (see local-render.test.js).
60
+ return crypto.randomUUID();
61
+ }
62
+
63
+ /**
64
+ * Default output path: sibling of the deck file, same basename, `.mp4` ext.
65
+ * /tmp/.../my-deck.json → /tmp/.../my-deck.mp4
66
+ *
67
+ * Falls back to `out.mp4` if the deck wasn't loaded from disk (deck-in-memory
68
+ * is hypothetical at the moment; current stage always has a deckPath, but we
69
+ * future-proof for an MCP-piped variant).
70
+ */
71
+ function defaultOutputPath(deckPath) {
72
+ if (deckPath && typeof deckPath === 'string') {
73
+ const dir = path.dirname(deckPath);
74
+ const base = path.basename(deckPath, path.extname(deckPath));
75
+ return path.join(dir, `${base}.mp4`);
76
+ }
77
+ return path.resolve(process.cwd(), 'out.mp4');
78
+ }
79
+
80
+ /**
81
+ * Lazy-load @remotion/renderer. Doing this at module load would force every
82
+ * stage boot to pay the ~30 MB resolver cost; we defer until the first job.
83
+ * The renderer is a peer of slice-render.js (same version) so the bundle
84
+ * deduplicates in node_modules.
85
+ */
86
+ function loadRenderer() {
87
+ return require('@remotion/renderer');
88
+ }
89
+
90
+ /**
91
+ * Start an offline Remotion render against the pre-bundled serveUrl.
92
+ *
93
+ * @param {object} opts
94
+ * @param {object} opts.deck Validator-shaped deck (cards + theme).
95
+ * @param {string} [opts.deckPath] For default output path inference.
96
+ * @param {string} [opts.output] Optional explicit output path.
97
+ * @param {(p:object) => void} [opts.onProgress]
98
+ * Called on every Remotion progress tick. Payload:
99
+ * { jobId, progress (0..1), framesRendered, framesTotal }
100
+ * @param {(d:object) => void} [opts.onDone]
101
+ * Called with { jobId, outputPath, totalMs, sizeBytes } when the mp4 is written.
102
+ * @param {(e:object) => void} [opts.onError]
103
+ * Called with { jobId, message } on any failure (synchronous or async).
104
+ * @returns {{ jobId:string, outputPath:string }}
105
+ * Returned synchronously so the HTTP route can respond immediately while
106
+ * the render proceeds in the background.
107
+ */
108
+ function startLocalRender(opts) {
109
+ const deck = opts && opts.deck;
110
+ if (!deck || !Array.isArray(deck.cards)) {
111
+ const err = new Error('startLocalRender: deck.cards required');
112
+ err.code = 'invalid_deck';
113
+ throw err;
114
+ }
115
+ const theme = deck.theme || DEFAULT_THEME;
116
+ const deckId = THEME_TO_DECK_ID[theme];
117
+ if (!deckId) {
118
+ const err = new Error(`Unknown theme "${theme}". Valid: ${Object.keys(THEME_TO_DECK_ID).join(', ')}`);
119
+ err.code = 'invalid_theme';
120
+ throw err;
121
+ }
122
+
123
+ const outputPath = path.resolve(
124
+ opts.output || defaultOutputPath(opts.deckPath)
125
+ );
126
+ const outputDir = path.dirname(outputPath);
127
+ if (!fs.existsSync(outputDir)) fs.mkdirSync(outputDir, { recursive: true });
128
+
129
+ const jobId = makeJobId();
130
+ const job = {
131
+ jobId,
132
+ state: 'pending',
133
+ progress: 0,
134
+ framesRendered: 0,
135
+ framesTotal: 0,
136
+ outputPath,
137
+ theme,
138
+ deckId,
139
+ error: null,
140
+ startedAt: Date.now(),
141
+ finishedAt: null,
142
+ sizeBytes: null,
143
+ };
144
+ jobs.set(jobId, job);
145
+
146
+ // Kick off the actual work on the next tick so the caller's POST handler
147
+ // can flush the response with the jobId before any renderer init starts.
148
+ setImmediate(() => {
149
+ runRender({
150
+ job,
151
+ deck,
152
+ onProgress: opts.onProgress,
153
+ onDone: opts.onDone,
154
+ onError: opts.onError,
155
+ });
156
+ });
157
+
158
+ return { jobId, outputPath };
159
+ }
160
+
161
+ async function runRender({ job, deck, onProgress, onDone, onError }) {
162
+ const { jobId, outputPath, deckId } = job;
163
+ let voiceoverServer = null;
164
+ try {
165
+ job.state = 'preparing';
166
+
167
+ // ─── Voiceover prep (Phase 1) ──────────────────────────────────────
168
+ // Stage's Render button defaults to including audio — users in stage
169
+ // are iterating and expect a richer preview. The audition cache makes
170
+ // re-renders effectively free for cards they already previewed.
171
+ // Falls back to silent video on not_logged_in / quota_exceeded
172
+ // (recorded in job.voiceoverSkipped so the UI can surface the reason).
173
+ let voiceoverByIdx = {};
174
+ let voiceoverSkipped = [];
175
+ try {
176
+ const audClient = createTtsAuditionClient();
177
+ voiceoverServer = await startVoiceoverServer({ cacheDir: audClient.cacheDir });
178
+ const prep = await prepareVoiceovers({
179
+ deck,
180
+ auditionClient: audClient,
181
+ baseUrl: voiceoverServer.url,
182
+ onProgress: (p) => {
183
+ if (typeof onProgress === 'function') {
184
+ try {
185
+ onProgress({
186
+ jobId,
187
+ progress: 0,
188
+ framesRendered: 0,
189
+ framesTotal: 0,
190
+ phase: 'voiceover',
191
+ voiceoverIndex: p.cardIdx + 1,
192
+ voiceoverTotal: p.total,
193
+ voiceoverFromCache: p.fromCache,
194
+ });
195
+ } catch { /* swallow */ }
196
+ }
197
+ },
198
+ });
199
+ voiceoverByIdx = prep.byIdx;
200
+ voiceoverSkipped = prep.skipped;
201
+ } catch (err) {
202
+ // Voiceover prep failure is non-fatal — fall back to silent render
203
+ // so a TTS outage / first-run-without-login still produces an mp4.
204
+ voiceoverSkipped = [{ cardIdx: -1, reason: 'voiceover_prep_failed', message: err.message }];
205
+ if (voiceoverServer) {
206
+ try { await voiceoverServer.close(); } catch { /* */ }
207
+ voiceoverServer = null;
208
+ }
209
+ }
210
+ job.voiceoverCount = Object.keys(voiceoverByIdx).length;
211
+ job.voiceoverSkipped = voiceoverSkipped;
212
+
213
+ const serveUrl = resolveServeUrl();
214
+ const inputProps = buildInputProps(deck, { voiceoverByIdx });
215
+
216
+ const renderer = loadRenderer();
217
+ job.coldStart = !chromeBinaryExists();
218
+
219
+ const composition = await renderer.selectComposition({
220
+ serveUrl,
221
+ id: deckId,
222
+ inputProps,
223
+ });
224
+ job.framesTotal = composition.durationInFrames;
225
+ job.state = 'rendering';
226
+ if (typeof onProgress === 'function') {
227
+ try {
228
+ onProgress({
229
+ jobId,
230
+ progress: 0,
231
+ framesRendered: 0,
232
+ framesTotal: job.framesTotal,
233
+ });
234
+ } catch { /* swallow consumer errors */ }
235
+ }
236
+
237
+ await renderer.renderMedia({
238
+ composition,
239
+ serveUrl,
240
+ codec: 'h264',
241
+ outputLocation: outputPath,
242
+ inputProps,
243
+ imageFormat: 'jpeg',
244
+ jpegQuality: 85,
245
+ concurrency: 4,
246
+ x264Preset: 'ultrafast',
247
+ chromiumOptions: { gl: 'swiftshader' },
248
+ offthreadVideoCacheSizeInBytes: 512 * 1024 * 1024,
249
+ onProgress: ({ progress, renderedFrames }) => {
250
+ job.progress = progress;
251
+ job.framesRendered = renderedFrames;
252
+ if (typeof onProgress === 'function') {
253
+ try {
254
+ onProgress({
255
+ jobId,
256
+ progress,
257
+ framesRendered: renderedFrames,
258
+ framesTotal: job.framesTotal,
259
+ });
260
+ } catch { /* swallow */ }
261
+ }
262
+ },
263
+ });
264
+
265
+ const stat = fs.statSync(outputPath);
266
+ job.state = 'completed';
267
+ job.progress = 1;
268
+ job.sizeBytes = stat.size;
269
+ job.finishedAt = Date.now();
270
+ if (typeof onDone === 'function') {
271
+ try {
272
+ onDone({
273
+ jobId,
274
+ outputPath,
275
+ totalMs: job.finishedAt - job.startedAt,
276
+ sizeBytes: job.sizeBytes,
277
+ });
278
+ } catch { /* swallow */ }
279
+ }
280
+ } catch (err) {
281
+ job.state = 'failed';
282
+ job.error = err && err.message ? err.message : String(err);
283
+ job.finishedAt = Date.now();
284
+ if (typeof onError === 'function') {
285
+ try { onError({ jobId, message: job.error }); } catch { /* swallow */ }
286
+ }
287
+ } finally {
288
+ // Always tear down the audio file server, including on render failure,
289
+ // so a stale localhost listener doesn't leak across jobs.
290
+ if (voiceoverServer) {
291
+ try { await voiceoverServer.close(); } catch { /* best-effort */ }
292
+ }
293
+ }
294
+ }
295
+
296
+ function getJobStatus(jobId) {
297
+ const job = jobs.get(jobId);
298
+ if (!job) return null;
299
+ return {
300
+ jobId: job.jobId,
301
+ state: job.state,
302
+ progress: job.progress,
303
+ framesRendered: job.framesRendered,
304
+ framesTotal: job.framesTotal,
305
+ outputPath: job.outputPath,
306
+ error: job.error,
307
+ sizeBytes: job.sizeBytes,
308
+ startedAt: job.startedAt,
309
+ finishedAt: job.finishedAt,
310
+ };
311
+ }
312
+
313
+ /** Test-only: nuke the job table between cases. Not exposed in module exports
314
+ * publicly to discourage callers from clearing state in production paths. */
315
+ function _resetJobsForTests() {
316
+ jobs.clear();
317
+ }
318
+
319
+ module.exports = {
320
+ startLocalRender,
321
+ getJobStatus,
322
+ defaultOutputPath,
323
+ _resetJobsForTests,
324
+ };