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.
- package/dist/index.js +1 -1
- package/lib/commands/slice-render.js +71 -7
- package/lib/commands/slice-stage.js +97 -0
- package/lib/internal/deck-validator.js +47 -0
- package/lib/stage-core/local-render.js +324 -0
- package/lib/stage-core/server.js +228 -2
- package/lib/stage-core/tts-audition.js +0 -0
- package/lib/stage-core/voiceover-mux.js +183 -0
- package/lib/stage-ui/slice/template.js +660 -7
- package/package.json +1 -1
- package/skills/voxflow-slice/SKILL.md +75 -2
|
@@ -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 {
|
|
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
|
+
};
|