@scenar/cli 0.9.1 → 0.11.0
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/example-bundle/assets/index-DxmqjG-8.js +388 -0
- package/example-bundle/assets/style-B2sxnVVZ.css +1 -0
- package/example-bundle/embed.js +1 -1
- package/example-bundle/index.html +2 -2
- package/example-bundle/logo.png +0 -0
- package/example-bundle/pack-manifest.json +33 -9
- package/example-bundle/soundtrack/music.mp3 +0 -0
- package/example-bundle/soundtrack/sfx/click.mp3 +0 -0
- package/example-bundle/soundtrack/sfx/keystroke.mp3 +0 -0
- package/package.json +4 -4
- package/src/__tests__/collect-pack-shots.test.ts +27 -1
- package/src/__tests__/elevenlabs-provider.test.ts +187 -0
- package/src/__tests__/embed-snippet.test.ts +5 -0
- package/src/__tests__/find-authored-soundtrack.test.ts +54 -0
- package/src/__tests__/find-authored-title-cards.test.ts +65 -0
- package/src/__tests__/find-authored-viewport.test.ts +58 -0
- package/src/__tests__/load-bundle.test.ts +67 -0
- package/src/__tests__/narrate-command.test.ts +65 -1
- package/src/__tests__/narration-cache.test.ts +134 -0
- package/src/__tests__/pack-bundle-contract.test.ts +2 -0
- package/src/__tests__/pack-generate-embed-entry.test.ts +99 -16
- package/src/__tests__/presenter-command.test.ts +366 -0
- package/src/__tests__/render-command.test.ts +11 -0
- package/src/__tests__/render-generate-entry.test.ts +102 -0
- package/src/__tests__/resolve-pack-viewport.test.ts +46 -0
- package/src/__tests__/resolve-provider.test.ts +32 -3
- package/src/__tests__/scenario-validator.test.ts +174 -0
- package/src/__tests__/stage-assets.test.ts +269 -0
- package/src/commands/narrate.js +1 -1
- package/src/commands/narrate.js.map +1 -1
- package/src/commands/narrate.ts +1 -1
- package/src/commands/pack.d.ts.map +1 -1
- package/src/commands/pack.js +23 -4
- package/src/commands/pack.js.map +1 -1
- package/src/commands/pack.ts +26 -5
- package/src/commands/presenter.d.ts +3 -0
- package/src/commands/presenter.d.ts.map +1 -0
- package/src/commands/presenter.js +89 -0
- package/src/commands/presenter.js.map +1 -0
- package/src/commands/presenter.ts +107 -0
- package/src/commands/render.d.ts.map +1 -1
- package/src/commands/render.js +2 -0
- package/src/commands/render.js.map +1 -1
- package/src/commands/render.ts +6 -0
- package/src/embed/embed-snippet.d.ts +5 -0
- package/src/embed/embed-snippet.d.ts.map +1 -1
- package/src/embed/embed-snippet.js +6 -1
- package/src/embed/embed-snippet.js.map +1 -1
- package/src/embed/embed-snippet.ts +6 -1
- package/src/index.d.ts.map +1 -1
- package/src/index.js +2 -0
- package/src/index.js.map +1 -1
- package/src/index.ts +2 -0
- package/src/narrate/run-narrate.d.ts +15 -1
- package/src/narrate/run-narrate.d.ts.map +1 -1
- package/src/narrate/run-narrate.js +11 -4
- package/src/narrate/run-narrate.js.map +1 -1
- package/src/narrate/run-narrate.ts +15 -6
- package/src/pack/bundle-contract.d.ts +1 -0
- package/src/pack/bundle-contract.d.ts.map +1 -1
- package/src/pack/bundle-contract.js +3 -0
- package/src/pack/bundle-contract.js.map +1 -1
- package/src/pack/bundle-contract.ts +3 -0
- package/src/pack/collect-pack-shots.d.ts +16 -5
- package/src/pack/collect-pack-shots.d.ts.map +1 -1
- package/src/pack/collect-pack-shots.js +13 -4
- package/src/pack/collect-pack-shots.js.map +1 -1
- package/src/pack/collect-pack-shots.ts +35 -8
- package/src/pack/generate-embed-entry.d.ts +7 -0
- package/src/pack/generate-embed-entry.d.ts.map +1 -1
- package/src/pack/generate-embed-entry.js +105 -7
- package/src/pack/generate-embed-entry.js.map +1 -1
- package/src/pack/generate-embed-entry.ts +110 -7
- package/src/pack/run-pack.d.ts +8 -2
- package/src/pack/run-pack.d.ts.map +1 -1
- package/src/pack/run-pack.js +120 -17
- package/src/pack/run-pack.js.map +1 -1
- package/src/pack/run-pack.ts +164 -19
- package/src/pack/viewport.d.ts +17 -0
- package/src/pack/viewport.d.ts.map +1 -1
- package/src/pack/viewport.js +19 -0
- package/src/pack/viewport.js.map +1 -1
- package/src/pack/viewport.ts +29 -0
- package/src/presenter/heygen-client.d.ts +69 -0
- package/src/presenter/heygen-client.d.ts.map +1 -0
- package/src/presenter/heygen-client.js +154 -0
- package/src/presenter/heygen-client.js.map +1 -0
- package/src/presenter/heygen-client.ts +224 -0
- package/src/presenter/run-presenter.d.ts +67 -0
- package/src/presenter/run-presenter.d.ts.map +1 -0
- package/src/presenter/run-presenter.js +348 -0
- package/src/presenter/run-presenter.js.map +1 -0
- package/src/presenter/run-presenter.ts +536 -0
- package/src/render/generate-entry.d.ts +4 -0
- package/src/render/generate-entry.d.ts.map +1 -1
- package/src/render/generate-entry.js +75 -7
- package/src/render/generate-entry.js.map +1 -1
- package/src/render/generate-entry.ts +84 -7
- package/src/render/run-render.d.ts +6 -0
- package/src/render/run-render.d.ts.map +1 -1
- package/src/render/run-render.js +58 -0
- package/src/render/run-render.js.map +1 -1
- package/src/render/run-render.ts +72 -0
- package/src/render/stage-assets.d.ts +44 -0
- package/src/render/stage-assets.d.ts.map +1 -0
- package/src/render/stage-assets.js +91 -0
- package/src/render/stage-assets.js.map +1 -0
- package/src/render/stage-assets.ts +130 -0
- package/src/tts/echogarden.d.ts.map +1 -1
- package/src/tts/echogarden.js +4 -2
- package/src/tts/echogarden.js.map +1 -1
- package/src/tts/echogarden.ts +5 -2
- package/src/tts/edge-tts.d.ts.map +1 -1
- package/src/tts/edge-tts.js +2 -1
- package/src/tts/edge-tts.js.map +1 -1
- package/src/tts/edge-tts.ts +2 -1
- package/src/tts/elevenlabs.d.ts +15 -0
- package/src/tts/elevenlabs.d.ts.map +1 -0
- package/src/tts/elevenlabs.js +73 -0
- package/src/tts/elevenlabs.js.map +1 -0
- package/src/tts/elevenlabs.ts +105 -0
- package/src/tts/openai.d.ts.map +1 -1
- package/src/tts/openai.js +2 -1
- package/src/tts/openai.js.map +1 -1
- package/src/tts/openai.ts +2 -1
- package/src/tts/resolve-provider.d.ts +1 -1
- package/src/tts/resolve-provider.d.ts.map +1 -1
- package/src/tts/resolve-provider.js +10 -3
- package/src/tts/resolve-provider.js.map +1 -1
- package/src/tts/resolve-provider.ts +12 -4
- package/src/tts/types.d.ts +9 -0
- package/src/tts/types.d.ts.map +1 -1
- package/src/tts/types.ts +10 -0
- package/src/util/confirm.d.ts +11 -0
- package/src/util/confirm.d.ts.map +1 -0
- package/src/util/confirm.js +24 -0
- package/src/util/confirm.js.map +1 -0
- package/src/util/confirm.ts +23 -0
- package/src/util/load-bundle.d.ts +17 -1
- package/src/util/load-bundle.d.ts.map +1 -1
- package/src/util/load-bundle.js +19 -11
- package/src/util/load-bundle.js.map +1 -1
- package/src/util/load-bundle.ts +38 -12
- package/src/util/load-ts.d.ts +100 -0
- package/src/util/load-ts.d.ts.map +1 -1
- package/src/util/load-ts.js +176 -0
- package/src/util/load-ts.js.map +1 -1
- package/src/util/load-ts.ts +227 -0
- package/src/util/narration-cache.d.ts +13 -5
- package/src/util/narration-cache.d.ts.map +1 -1
- package/src/util/narration-cache.js +10 -8
- package/src/util/narration-cache.js.map +1 -1
- package/src/util/narration-cache.ts +22 -6
- package/src/util/presenter-cache.d.ts +44 -0
- package/src/util/presenter-cache.d.ts.map +1 -0
- package/src/util/presenter-cache.js +43 -0
- package/src/util/presenter-cache.js.map +1 -0
- package/src/util/presenter-cache.ts +101 -0
- package/src/util/scenario-assets.d.ts +47 -0
- package/src/util/scenario-assets.d.ts.map +1 -0
- package/src/util/scenario-assets.js +71 -0
- package/src/util/scenario-assets.js.map +1 -0
- package/src/util/scenario-assets.ts +111 -0
- package/src/util/soundtrack-assets.d.ts +34 -0
- package/src/util/soundtrack-assets.d.ts.map +1 -0
- package/src/util/soundtrack-assets.js +48 -0
- package/src/util/soundtrack-assets.js.map +1 -0
- package/src/util/soundtrack-assets.ts +69 -0
- package/src/validate/scenario-validator.d.ts.map +1 -1
- package/src/validate/scenario-validator.js +82 -0
- package/src/validate/scenario-validator.js.map +1 -1
- package/src/validate/scenario-validator.ts +110 -0
- package/tsconfig.tsbuildinfo +1 -1
- package/example-bundle/assets/index-DlmG1Uh7.js +0 -388
- package/example-bundle/assets/style-BjquDnTE.css +0 -1
|
@@ -0,0 +1,536 @@
|
|
|
1
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
2
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
3
|
+
import { discoverScenarios } from "../util/discover-scenarios.js";
|
|
4
|
+
import { loadStepsFromTs } from "../util/load-ts.js";
|
|
5
|
+
import { loadStepsFromFile } from "../narrate/run-narrate.js";
|
|
6
|
+
import { fileExists } from "../util/narration-cache.js";
|
|
7
|
+
import {
|
|
8
|
+
buildPresenterCacheFile,
|
|
9
|
+
computePresenterHash,
|
|
10
|
+
isPresenterCached,
|
|
11
|
+
loadPresenterCache,
|
|
12
|
+
presenterFingerprint,
|
|
13
|
+
savePresenterCache,
|
|
14
|
+
} from "../util/presenter-cache.js";
|
|
15
|
+
import {
|
|
16
|
+
HEYGEN_RATE_PER_MIN,
|
|
17
|
+
type HeygenEngineFlag,
|
|
18
|
+
type HeygenRequestContext,
|
|
19
|
+
createAvatarVideo,
|
|
20
|
+
downloadVideo,
|
|
21
|
+
pollVideo,
|
|
22
|
+
uploadAudioAsset,
|
|
23
|
+
} from "./heygen-client.js";
|
|
24
|
+
|
|
25
|
+
/** Default generation poll cadence and per-step budget. */
|
|
26
|
+
const POLL_INTERVAL_MS = 5_000;
|
|
27
|
+
const POLL_TIMEOUT_MS = 15 * 60 * 1_000;
|
|
28
|
+
/** Progress-line cadence during a minutes-long generation. */
|
|
29
|
+
const PROGRESS_LOG_EVERY_MS = 30_000;
|
|
30
|
+
|
|
31
|
+
const CONTAINER = "mp4"; // fixed for v1 (Safari renders VP9-alpha opaque)
|
|
32
|
+
|
|
33
|
+
/** Options for {@link runPresenter}. Paths may be relative; resolved here. */
|
|
34
|
+
export interface RunPresenterOptions {
|
|
35
|
+
/** A scenario YAML file, a .ts steps file, or a directory of scenarios. */
|
|
36
|
+
readonly target: string;
|
|
37
|
+
/** HeyGen avatar id. Falls back to the HEYGEN_AVATAR_ID env var. */
|
|
38
|
+
readonly avatar?: string;
|
|
39
|
+
/** Generation engine (default: iii). */
|
|
40
|
+
readonly engine?: HeygenEngineFlag;
|
|
41
|
+
/** Output resolution tier (default: 720p). */
|
|
42
|
+
readonly resolution?: string;
|
|
43
|
+
/** Output directory for clips (default: presenter/ beside the scenario). */
|
|
44
|
+
readonly out?: string;
|
|
45
|
+
/** URL path prefix for the `src` fields written into the manifest. */
|
|
46
|
+
readonly baseUrl?: string;
|
|
47
|
+
/** Skip the cost confirmation (CI / scripts). */
|
|
48
|
+
readonly yes?: boolean;
|
|
49
|
+
/** Progress sink for per-step/per-scenario messages (stderr). */
|
|
50
|
+
readonly onLog?: (message: string) => void;
|
|
51
|
+
/** Injectable confirmation prompt (the command wires util/confirm). */
|
|
52
|
+
readonly confirmImpl?: (question: string) => Promise<boolean>;
|
|
53
|
+
/** Whether stdin can answer a prompt (default: process.stdin.isTTY). */
|
|
54
|
+
readonly isTty?: boolean;
|
|
55
|
+
/** Test seams for the minutes-long polling loop. */
|
|
56
|
+
readonly pollIntervalMs?: number;
|
|
57
|
+
readonly pollTimeoutMs?: number;
|
|
58
|
+
readonly sleep?: (ms: number) => Promise<void>;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Per-scenario presenter outcome. */
|
|
62
|
+
export interface PresentedScenario {
|
|
63
|
+
readonly id: string;
|
|
64
|
+
readonly generated: number;
|
|
65
|
+
readonly cached: number;
|
|
66
|
+
readonly failed: number;
|
|
67
|
+
/** True when no step opts into the presenter (nothing to generate). */
|
|
68
|
+
readonly skipped: boolean;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Aggregate result of a presenter run. */
|
|
72
|
+
export interface PresenterResult {
|
|
73
|
+
readonly mode: "file" | "directory";
|
|
74
|
+
readonly scenarios: PresentedScenario[];
|
|
75
|
+
readonly totalGenerated: number;
|
|
76
|
+
readonly totalCached: number;
|
|
77
|
+
readonly totalFailed: number;
|
|
78
|
+
readonly totalSkipped: number;
|
|
79
|
+
readonly errors: Array<{ id: string; message: string }>;
|
|
80
|
+
/** True when the cost gate was declined or unanswerable. */
|
|
81
|
+
readonly aborted: boolean;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
interface NarrationManifestFile {
|
|
85
|
+
steps: Array<{ src: string; durationMs: number } | null>;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
interface ResolvedConfig {
|
|
89
|
+
readonly apiKey: string;
|
|
90
|
+
readonly avatarId: string;
|
|
91
|
+
readonly engine: HeygenEngineFlag;
|
|
92
|
+
readonly resolution: string;
|
|
93
|
+
readonly fingerprint: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Shorten an avatar id for display: de95f90d… → de95…8ad8. */
|
|
97
|
+
function shortAvatarId(id: string): string {
|
|
98
|
+
return id.length > 10 ? `${id.slice(0, 4)}…${id.slice(-4)}` : id;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Generate presenter clips for a scenario file or a directory of
|
|
103
|
+
* scenarios — the orchestration behind `scenar presenter`. For each
|
|
104
|
+
* step marked `presenter: true`, uploads that step's narration audio
|
|
105
|
+
* to HeyGen, generates a lip-synced avatar clip, downloads it beside
|
|
106
|
+
* the narration output, and writes a positional `presenter/manifest.json`
|
|
107
|
+
* keyed to AUTHORED steps (like the narration manifest).
|
|
108
|
+
*
|
|
109
|
+
* Spend is visible and consented: before any paid call the batch
|
|
110
|
+
* estimate is printed and confirmed (or `--yes`). Failures never
|
|
111
|
+
* corrupt state — the manifest and cache are written from completed
|
|
112
|
+
* work only, so a rerun regenerates exactly the missing steps.
|
|
113
|
+
*/
|
|
114
|
+
export async function runPresenter(options: RunPresenterOptions): Promise<PresenterResult> {
|
|
115
|
+
const onLog = options.onLog ?? (() => {});
|
|
116
|
+
|
|
117
|
+
// Every prerequisite that needs no network is checked before any work.
|
|
118
|
+
const apiKey = process.env["HEYGEN_API_KEY"];
|
|
119
|
+
if (!apiKey) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
"HEYGEN_API_KEY environment variable is not set.\n\n" +
|
|
122
|
+
"Set it before running presenter:\n" +
|
|
123
|
+
" export HEYGEN_API_KEY=...",
|
|
124
|
+
);
|
|
125
|
+
}
|
|
126
|
+
const avatarId = options.avatar ?? process.env["HEYGEN_AVATAR_ID"];
|
|
127
|
+
if (!avatarId) {
|
|
128
|
+
throw new Error("No avatar id. Pass --avatar <id> or set HEYGEN_AVATAR_ID.");
|
|
129
|
+
}
|
|
130
|
+
const engine = options.engine ?? "iii";
|
|
131
|
+
const resolution = options.resolution ?? "720p";
|
|
132
|
+
const config: ResolvedConfig = {
|
|
133
|
+
apiKey,
|
|
134
|
+
avatarId,
|
|
135
|
+
engine,
|
|
136
|
+
resolution,
|
|
137
|
+
fingerprint: presenterFingerprint(engine, resolution, CONTAINER, avatarId),
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const resolved = resolve(options.target);
|
|
141
|
+
const info = await stat(resolved);
|
|
142
|
+
|
|
143
|
+
return info.isDirectory()
|
|
144
|
+
? presentDirectory(resolved, options, config, onLog)
|
|
145
|
+
: presentSingleFile(resolved, options, config, onLog);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
async function presentSingleFile(
|
|
149
|
+
filePath: string,
|
|
150
|
+
options: RunPresenterOptions,
|
|
151
|
+
config: ResolvedConfig,
|
|
152
|
+
onLog: (message: string) => void,
|
|
153
|
+
): Promise<PresenterResult> {
|
|
154
|
+
const scenarioDir = dirname(filePath);
|
|
155
|
+
const id = basename(scenarioDir);
|
|
156
|
+
const { steps } = await loadStepsFromFile(filePath);
|
|
157
|
+
|
|
158
|
+
const outDir = options.out ?? join(scenarioDir, "presenter");
|
|
159
|
+
const outcome = await presentScenario({
|
|
160
|
+
scenarioId: id,
|
|
161
|
+
steps,
|
|
162
|
+
scenarioDir,
|
|
163
|
+
outDir,
|
|
164
|
+
options,
|
|
165
|
+
config,
|
|
166
|
+
onLog,
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
return {
|
|
170
|
+
mode: "file",
|
|
171
|
+
scenarios: [outcome.result],
|
|
172
|
+
totalGenerated: outcome.result.generated,
|
|
173
|
+
totalCached: outcome.result.cached,
|
|
174
|
+
totalFailed: outcome.result.failed,
|
|
175
|
+
totalSkipped: outcome.result.skipped ? 1 : 0,
|
|
176
|
+
errors: [],
|
|
177
|
+
aborted: outcome.aborted,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function presentDirectory(
|
|
182
|
+
dirPath: string,
|
|
183
|
+
options: RunPresenterOptions,
|
|
184
|
+
config: ResolvedConfig,
|
|
185
|
+
onLog: (message: string) => void,
|
|
186
|
+
): Promise<PresenterResult> {
|
|
187
|
+
const scenarios = await discoverScenarios(dirPath);
|
|
188
|
+
const results: PresentedScenario[] = [];
|
|
189
|
+
const errors: Array<{ id: string; message: string }> = [];
|
|
190
|
+
let totals = { generated: 0, cached: 0, failed: 0, skipped: 0 };
|
|
191
|
+
let aborted = false;
|
|
192
|
+
|
|
193
|
+
if (scenarios.length > 0) {
|
|
194
|
+
onLog(`Discovered ${scenarios.length} scenario(s)`);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
for (const scenario of scenarios) {
|
|
198
|
+
onLog(` ${scenario.id}`);
|
|
199
|
+
try {
|
|
200
|
+
const steps = await loadStepsFromTs(scenario.stepsPath);
|
|
201
|
+
const scenarioDir = dirname(scenario.stepsPath);
|
|
202
|
+
const outDir = options.out
|
|
203
|
+
? join(options.out, scenario.id)
|
|
204
|
+
: join(scenarioDir, "presenter");
|
|
205
|
+
|
|
206
|
+
const outcome = await presentScenario({
|
|
207
|
+
scenarioId: scenario.id,
|
|
208
|
+
steps,
|
|
209
|
+
scenarioDir,
|
|
210
|
+
outDir,
|
|
211
|
+
options,
|
|
212
|
+
config,
|
|
213
|
+
onLog,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
results.push(outcome.result);
|
|
217
|
+
totals = {
|
|
218
|
+
generated: totals.generated + outcome.result.generated,
|
|
219
|
+
cached: totals.cached + outcome.result.cached,
|
|
220
|
+
failed: totals.failed + outcome.result.failed,
|
|
221
|
+
skipped: totals.skipped + (outcome.result.skipped ? 1 : 0),
|
|
222
|
+
};
|
|
223
|
+
if (outcome.aborted) {
|
|
224
|
+
aborted = true;
|
|
225
|
+
break; // a declined gate stops the batch — never spend past a "no"
|
|
226
|
+
}
|
|
227
|
+
} catch (error) {
|
|
228
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
229
|
+
onLog(` \x1b[31mfailed\x1b[0m: ${message}`);
|
|
230
|
+
errors.push({ id: scenario.id, message });
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
mode: "directory",
|
|
236
|
+
scenarios: results,
|
|
237
|
+
totalGenerated: totals.generated,
|
|
238
|
+
totalCached: totals.cached,
|
|
239
|
+
totalFailed: totals.failed,
|
|
240
|
+
totalSkipped: totals.skipped,
|
|
241
|
+
errors,
|
|
242
|
+
aborted,
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
interface PresentScenarioInput {
|
|
247
|
+
readonly scenarioId: string;
|
|
248
|
+
readonly steps: Array<{ narration?: string; narrationText?: string; presenter?: boolean }>;
|
|
249
|
+
readonly scenarioDir: string;
|
|
250
|
+
readonly outDir: string;
|
|
251
|
+
readonly options: RunPresenterOptions;
|
|
252
|
+
readonly config: ResolvedConfig;
|
|
253
|
+
readonly onLog: (message: string) => void;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
interface PlanStep {
|
|
257
|
+
readonly index: number;
|
|
258
|
+
readonly durationMs: number;
|
|
259
|
+
readonly audioPath: string;
|
|
260
|
+
readonly hash: string;
|
|
261
|
+
readonly cached: boolean;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function presentScenario(
|
|
265
|
+
input: PresentScenarioInput,
|
|
266
|
+
): Promise<{ result: PresentedScenario; aborted: boolean }> {
|
|
267
|
+
const { scenarioId, steps, scenarioDir, outDir, options, config, onLog } = input;
|
|
268
|
+
|
|
269
|
+
const optedIn = steps
|
|
270
|
+
.map((step, index) => ({ step, index }))
|
|
271
|
+
.filter(({ step }) => step.presenter === true);
|
|
272
|
+
|
|
273
|
+
if (optedIn.length === 0) {
|
|
274
|
+
return {
|
|
275
|
+
result: { id: scenarioId, generated: 0, cached: 0, failed: 0, skipped: true },
|
|
276
|
+
aborted: false,
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Authoring contradiction (load-time rule, guarded here too in the
|
|
281
|
+
// validator's wording): a presenter step must have a narration script.
|
|
282
|
+
const unscripted = optedIn.filter(({ step }) => {
|
|
283
|
+
const text = step.narration ?? step.narrationText;
|
|
284
|
+
return typeof text !== "string" || text.length === 0;
|
|
285
|
+
});
|
|
286
|
+
if (unscripted.length > 0) {
|
|
287
|
+
const indices = unscripted.map(({ index }) => index).join(", ");
|
|
288
|
+
throw new Error(
|
|
289
|
+
`steps[${indices}].presenter: presenter requires narration_text — ` +
|
|
290
|
+
"a presenter clip is derived from the step's narration audio.",
|
|
291
|
+
);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Operational prerequisite: narration audio must already exist —
|
|
295
|
+
// the clip is derived from it by definition.
|
|
296
|
+
const narrationDir = join(scenarioDir, "narration");
|
|
297
|
+
let narrationManifest: NarrationManifestFile | null = null;
|
|
298
|
+
try {
|
|
299
|
+
narrationManifest = JSON.parse(
|
|
300
|
+
await readFile(join(narrationDir, "manifest.json"), "utf-8"),
|
|
301
|
+
) as NarrationManifestFile;
|
|
302
|
+
} catch {
|
|
303
|
+
// Reported below with the per-step gap list.
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const missingAudio: number[] = [];
|
|
307
|
+
const ready: Array<{ index: number; durationMs: number; audioPath: string }> = [];
|
|
308
|
+
for (const { index } of optedIn) {
|
|
309
|
+
const entry = narrationManifest?.steps[index];
|
|
310
|
+
const audioPath = join(narrationDir, `step-${index}.mp3`);
|
|
311
|
+
if (!entry || !(await fileExists(audioPath))) {
|
|
312
|
+
missingAudio.push(index);
|
|
313
|
+
} else {
|
|
314
|
+
ready.push({ index, durationMs: entry.durationMs, audioPath });
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
if (missingAudio.length > 0) {
|
|
318
|
+
throw new Error(
|
|
319
|
+
`steps ${missingAudio.join(", ")} opt into the presenter but have no narration audio. ` +
|
|
320
|
+
`Run 'scenar narrate ${options.target}' first — presenter clips are derived from narration audio.`,
|
|
321
|
+
);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Cache pass: the per-step hash is the narration MP3 bytes, so
|
|
325
|
+
// re-narrating a step invalidates its clip automatically.
|
|
326
|
+
const existingCache = await loadPresenterCache(outDir);
|
|
327
|
+
const plan: PlanStep[] = [];
|
|
328
|
+
for (const step of ready) {
|
|
329
|
+
const audioBytes = await readFile(step.audioPath);
|
|
330
|
+
const hash = computePresenterHash(audioBytes);
|
|
331
|
+
const clipPath = join(outDir, `step-${step.index}.mp4`);
|
|
332
|
+
const cached =
|
|
333
|
+
isPresenterCached(existingCache, step.index, hash, config.fingerprint) &&
|
|
334
|
+
(await fileExists(clipPath));
|
|
335
|
+
plan.push({ ...step, hash, cached });
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const toGenerate = plan.filter((step) => !step.cached);
|
|
339
|
+
|
|
340
|
+
// The plan — printed before anything spends money.
|
|
341
|
+
onLog(
|
|
342
|
+
`Presenter plan — ${scenarioId} (engine ${config.engine}, ${config.resolution}, ` +
|
|
343
|
+
`avatar ${shortAvatarId(config.avatarId)})`,
|
|
344
|
+
);
|
|
345
|
+
for (const step of plan) {
|
|
346
|
+
const seconds = (step.durationMs / 1000).toFixed(1);
|
|
347
|
+
onLog(` step ${step.index} ${seconds}s ${step.cached ? "cached" : "generate"}`);
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
if (toGenerate.length === 0) {
|
|
351
|
+
onLog("Nothing to generate — all clips cached.");
|
|
352
|
+
// Still (re)write manifest and cache from the cached clips, so a
|
|
353
|
+
// deleted manifest regenerates without a paid call.
|
|
354
|
+
await writeOutputs(outDir, scenarioId, steps.length, plan, [], options, config);
|
|
355
|
+
return {
|
|
356
|
+
result: { id: scenarioId, generated: 0, cached: plan.length, failed: 0, skipped: false },
|
|
357
|
+
aborted: false,
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const totalAudioMs = toGenerate.reduce((sum, step) => sum + step.durationMs, 0);
|
|
362
|
+
const rate = HEYGEN_RATE_PER_MIN[config.engine];
|
|
363
|
+
const estimate = (totalAudioMs / 60_000) * rate;
|
|
364
|
+
onLog(
|
|
365
|
+
`Estimated cost: ${toGenerate.length} clip(s), ${(totalAudioMs / 1000).toFixed(1)}s audio → ` +
|
|
366
|
+
`~$${estimate.toFixed(2)} (rate: ~$${rate.toFixed(2)}/min, engine ${config.engine})`,
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
// The cost gate. Never spend silently: a pipe that cannot answer must
|
|
370
|
+
// pass --yes explicitly.
|
|
371
|
+
if (!options.yes) {
|
|
372
|
+
const isTty = options.isTty ?? process.stdin.isTTY === true;
|
|
373
|
+
if (!isTty) {
|
|
374
|
+
onLog("stdin is not a TTY; pass --yes to confirm the estimated spend.");
|
|
375
|
+
return {
|
|
376
|
+
result: { id: scenarioId, generated: 0, cached: 0, failed: 0, skipped: false },
|
|
377
|
+
aborted: true,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
const confirmed = await input.options.confirmImpl?.("Proceed?");
|
|
381
|
+
if (!confirmed) {
|
|
382
|
+
onLog("Aborted — nothing generated, nothing spent.");
|
|
383
|
+
return {
|
|
384
|
+
result: { id: scenarioId, generated: 0, cached: 0, failed: 0, skipped: false },
|
|
385
|
+
aborted: true,
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Sequential generation (HeyGen concurrency limits are unverified —
|
|
391
|
+
// the sequential loop is the honest v1).
|
|
392
|
+
const ctx: HeygenRequestContext = {
|
|
393
|
+
apiKey: config.apiKey,
|
|
394
|
+
sleep: options.sleep,
|
|
395
|
+
onRetry: (message) => onLog(` ${message}`),
|
|
396
|
+
};
|
|
397
|
+
await mkdir(outDir, { recursive: true });
|
|
398
|
+
|
|
399
|
+
const completed: Array<{ index: number; durationMs: number; hash: string }> = [];
|
|
400
|
+
let failed = 0;
|
|
401
|
+
|
|
402
|
+
for (const step of toGenerate) {
|
|
403
|
+
try {
|
|
404
|
+
const audioBytes = await readFile(step.audioPath);
|
|
405
|
+
const assetId = await uploadAudioAsset(ctx, audioBytes, `step-${step.index}.mp3`);
|
|
406
|
+
const videoId = await createAvatarVideo(ctx, {
|
|
407
|
+
avatarId: config.avatarId,
|
|
408
|
+
audioAssetId: assetId,
|
|
409
|
+
engine: config.engine,
|
|
410
|
+
resolution: config.resolution,
|
|
411
|
+
title: `scenar ${scenarioId} step-${step.index} ${step.hash.slice(0, 8)}`,
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
onLog(` step ${step.index}: generating (video_id ${videoId.slice(0, 8)}…)`);
|
|
415
|
+
let lastProgressMs = 0;
|
|
416
|
+
const outcome = await pollVideo(ctx, videoId, {
|
|
417
|
+
intervalMs: options.pollIntervalMs ?? POLL_INTERVAL_MS,
|
|
418
|
+
timeoutMs: options.pollTimeoutMs ?? POLL_TIMEOUT_MS,
|
|
419
|
+
onTick: (elapsedMs) => {
|
|
420
|
+
if (elapsedMs - lastProgressMs >= PROGRESS_LOG_EVERY_MS) {
|
|
421
|
+
lastProgressMs = elapsedMs;
|
|
422
|
+
onLog(
|
|
423
|
+
` step ${step.index}: generating (video_id ${videoId.slice(0, 8)}…) ` +
|
|
424
|
+
`${Math.round(elapsedMs / 1000)}s`,
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
if (outcome.status === "timeout") {
|
|
431
|
+
failed++;
|
|
432
|
+
onLog(
|
|
433
|
+
`\x1b[31m step ${step.index}: timed out after ${Math.round(
|
|
434
|
+
(options.pollTimeoutMs ?? POLL_TIMEOUT_MS) / 60_000,
|
|
435
|
+
)}min — check video ${videoId} in the HeyGen dashboard\x1b[0m`,
|
|
436
|
+
);
|
|
437
|
+
continue;
|
|
438
|
+
}
|
|
439
|
+
if (outcome.status === "failed" || !outcome.videoUrl) {
|
|
440
|
+
failed++;
|
|
441
|
+
onLog(
|
|
442
|
+
`\x1b[31m step ${step.index}: HeyGen generation failed: ` +
|
|
443
|
+
`${outcome.failureMessage ?? "no video_url returned"}\x1b[0m`,
|
|
444
|
+
);
|
|
445
|
+
continue;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const clip = await downloadVideo(outcome.videoUrl);
|
|
449
|
+
await writeFile(join(outDir, `step-${step.index}.mp4`), clip);
|
|
450
|
+
completed.push({ index: step.index, durationMs: step.durationMs, hash: step.hash });
|
|
451
|
+
onLog(
|
|
452
|
+
`\x1b[32m+\x1b[0m step ${step.index}: ${step.durationMs}ms clip (${clip.length} bytes)`,
|
|
453
|
+
);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
failed++;
|
|
456
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
457
|
+
onLog(`\x1b[31m step ${step.index}: ${message}\x1b[0m`);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
await writeOutputs(outDir, scenarioId, steps.length, plan, completed, options, config);
|
|
462
|
+
|
|
463
|
+
const cachedCount = plan.filter((step) => step.cached).length;
|
|
464
|
+
onLog(
|
|
465
|
+
`\x1b[32m+\x1b[0m ${scenarioId}: ${completed.length} generated, ` +
|
|
466
|
+
`${cachedCount} cached${failed > 0 ? `, \x1b[31m${failed} failed\x1b[0m` : ""}`,
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
return {
|
|
470
|
+
result: {
|
|
471
|
+
id: scenarioId,
|
|
472
|
+
generated: completed.length,
|
|
473
|
+
cached: cachedCount,
|
|
474
|
+
failed,
|
|
475
|
+
skipped: false,
|
|
476
|
+
},
|
|
477
|
+
aborted: false,
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Write `manifest.json` and `.presenter-cache.json` from completed work
|
|
483
|
+
* only (cached steps + this run's successes). Failed and non-opted
|
|
484
|
+
* steps stay `null` — playback degrades to no-presenter on those steps
|
|
485
|
+
* by design, and a rerun regenerates exactly the missing ones.
|
|
486
|
+
*/
|
|
487
|
+
async function writeOutputs(
|
|
488
|
+
outDir: string,
|
|
489
|
+
scenarioId: string,
|
|
490
|
+
totalSteps: number,
|
|
491
|
+
plan: PlanStep[],
|
|
492
|
+
completed: Array<{ index: number; durationMs: number; hash: string }>,
|
|
493
|
+
options: RunPresenterOptions,
|
|
494
|
+
config: ResolvedConfig,
|
|
495
|
+
): Promise<void> {
|
|
496
|
+
await mkdir(outDir, { recursive: true });
|
|
497
|
+
|
|
498
|
+
const srcPrefix = options.baseUrl
|
|
499
|
+
? `${options.baseUrl.replace(/\/$/, "")}/${scenarioId}`
|
|
500
|
+
: ".";
|
|
501
|
+
|
|
502
|
+
const manifestEntries = new Map<number, { src: string; durationMs: number }>();
|
|
503
|
+
const cacheEntries = new Map<number, { hash: string; durationMs: number }>();
|
|
504
|
+
|
|
505
|
+
for (const step of plan.filter((p) => p.cached)) {
|
|
506
|
+
manifestEntries.set(step.index, {
|
|
507
|
+
src: `${srcPrefix}/step-${step.index}.mp4`,
|
|
508
|
+
durationMs: step.durationMs,
|
|
509
|
+
});
|
|
510
|
+
cacheEntries.set(step.index, { hash: step.hash, durationMs: step.durationMs });
|
|
511
|
+
}
|
|
512
|
+
for (const step of completed) {
|
|
513
|
+
manifestEntries.set(step.index, {
|
|
514
|
+
src: `${srcPrefix}/step-${step.index}.mp4`,
|
|
515
|
+
durationMs: step.durationMs,
|
|
516
|
+
});
|
|
517
|
+
cacheEntries.set(step.index, { hash: step.hash, durationMs: step.durationMs });
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
const manifestSteps: Array<{ src: string; durationMs: number } | null> = Array.from(
|
|
521
|
+
{ length: totalSteps },
|
|
522
|
+
() => null,
|
|
523
|
+
);
|
|
524
|
+
for (const [index, entry] of manifestEntries) {
|
|
525
|
+
manifestSteps[index] = entry;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
await writeFile(
|
|
529
|
+
join(outDir, "manifest.json"),
|
|
530
|
+
JSON.stringify({ steps: manifestSteps }, null, 2) + "\n",
|
|
531
|
+
);
|
|
532
|
+
await savePresenterCache(
|
|
533
|
+
outDir,
|
|
534
|
+
buildPresenterCacheFile(config.fingerprint, totalSteps, cacheEntries),
|
|
535
|
+
);
|
|
536
|
+
}
|
|
@@ -13,12 +13,16 @@ export interface EntryGeneratorInput {
|
|
|
13
13
|
scenarioId: string;
|
|
14
14
|
/** Whether the scenario has a narration manifest. */
|
|
15
15
|
hasNarration: boolean;
|
|
16
|
+
/** Whether the scenario has a presenter manifest (+ clips). */
|
|
17
|
+
hasPresenter: boolean;
|
|
16
18
|
/** Absolute path to the providers file, or null if none found. */
|
|
17
19
|
providersPath: string | null;
|
|
18
20
|
fps: number;
|
|
19
21
|
width: number;
|
|
20
22
|
height: number;
|
|
21
23
|
compositionId: string;
|
|
24
|
+
/** Burn step captions into the video (`scenar render --captions`). */
|
|
25
|
+
captions: boolean;
|
|
22
26
|
}
|
|
23
27
|
/**
|
|
24
28
|
* Produce the TypeScript source for a Remotion entry file that renders
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-entry.d.ts","sourceRoot":"","sources":["../../../src/render/generate-entry.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;IACpB,yDAAyD;IACzD,cAAc,EAAE,MAAM,CAAC;IACvB,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,qDAAqD;IACrD,YAAY,EAAE,OAAO,CAAC;IACtB,kEAAkE;IAClE,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"generate-entry.d.ts","sourceRoot":"","sources":["../../../src/render/generate-entry.ts"],"names":[],"mappings":"AAEA;;;;;GAKG;AACH,MAAM,WAAW,mBAAmB;IAClC,8EAA8E;IAC9E,WAAW,EAAE,MAAM,CAAC;IACpB,yDAAyD;IACzD,cAAc,EAAE,MAAM,CAAC;IACvB,uDAAuD;IACvD,UAAU,EAAE,MAAM,CAAC;IACnB,qDAAqD;IACrD,YAAY,EAAE,OAAO,CAAC;IACtB,+DAA+D;IAC/D,YAAY,EAAE,OAAO,CAAC;IACtB,kEAAkE;IAClE,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,GAAG,EAAE,MAAM,CAAC;IACZ,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,MAAM,CAAC;IACf,aAAa,EAAE,MAAM,CAAC;IACtB,sEAAsE;IACtE,QAAQ,EAAE,OAAO,CAAC;CACnB;AAED;;;;;;;;;;;;;;;;GAgBG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,mBAAmB,GAAG,MAAM,CAoLxE"}
|
|
@@ -24,7 +24,17 @@ export function generateRemotionEntry(input) {
|
|
|
24
24
|
// --- Imports ---
|
|
25
25
|
lines.push(`import { registerRoot } from "remotion";`);
|
|
26
26
|
lines.push(`import { Composition, AbsoluteFill } from "remotion";`);
|
|
27
|
-
lines.push(`import { ScenarioComposition, calculateScenarioTimeline } from "@scenar/remotion";`);
|
|
27
|
+
lines.push(`import { ScenarioComposition, applyTitleCards, calculateScenarioTimeline } from "@scenar/remotion";`);
|
|
28
|
+
lines.push(`import { SCENAR_CLASS } from "@scenar/react";`);
|
|
29
|
+
// The same stylesheet pair the packed embed entry loads: without them,
|
|
30
|
+
// authored step content built on @scenar/react (shells, page templates)
|
|
31
|
+
// renders unstyled — black frames in the MP4 (scenar#23). Remotion's
|
|
32
|
+
// bundler handles plain CSS imports natively; styles.css is the
|
|
33
|
+
// self-contained build, so no Tailwind processing is needed. The
|
|
34
|
+
// composition root carries SCENAR_CLASS (below) so the `.scenar`-scoped
|
|
35
|
+
// theme tokens resolve, exactly as the embed entry scopes its tree.
|
|
36
|
+
lines.push(`import "@scenar/react/theme.css";`);
|
|
37
|
+
lines.push(`import "@scenar/react/styles.css";`);
|
|
28
38
|
lines.push(`import { renderStep } from ${JSON.stringify(renderImport)};`);
|
|
29
39
|
lines.push(`import * as _stepsModule from ${JSON.stringify(stepsImport)};`);
|
|
30
40
|
if (input.hasNarration) {
|
|
@@ -34,6 +44,13 @@ export function generateRemotionEntry(input) {
|
|
|
34
44
|
else {
|
|
35
45
|
lines.push(`const _manifest = undefined;`);
|
|
36
46
|
}
|
|
47
|
+
if (input.hasPresenter) {
|
|
48
|
+
const presenterManifestPath = `${scenarioPath}/presenter/manifest.json`;
|
|
49
|
+
lines.push(`import _presenterManifest from ${JSON.stringify(presenterManifestPath)};`);
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
lines.push(`const _presenterManifest = undefined;`);
|
|
53
|
+
}
|
|
37
54
|
if (input.providersPath) {
|
|
38
55
|
const providersImport = toPosix(input.providersPath).replace(/\.[^.]+$/, "");
|
|
39
56
|
lines.push(`import { PreviewProviders as _Providers } from ${JSON.stringify(providersImport)};`);
|
|
@@ -54,23 +71,74 @@ export function generateRemotionEntry(input) {
|
|
|
54
71
|
lines.push(`}`);
|
|
55
72
|
lines.push(``);
|
|
56
73
|
lines.push(`const _steps: any = _findSteps(_stepsModule as unknown as Record<string, unknown>);`);
|
|
74
|
+
// --- Soundtrack resolution (mirror of the CLI's findAuthoredSoundtrack) ---
|
|
75
|
+
// Discovered from the very module the bundle bakes in, at module-eval
|
|
76
|
+
// time, so the render can never disagree with the authored config: a
|
|
77
|
+
// scenario-shaped export (createScenario) wins, then a `soundtrack`
|
|
78
|
+
// named export; absent means no soundtrack.
|
|
79
|
+
lines.push(``);
|
|
80
|
+
lines.push(`function _findSoundtrack(mod: Record<string, unknown>): any {`);
|
|
81
|
+
lines.push(` for (const val of Object.values(mod)) {`);
|
|
82
|
+
lines.push(` if (`);
|
|
83
|
+
lines.push(` typeof val === "object" && val !== null && !Array.isArray(val) &&`);
|
|
84
|
+
lines.push(` "soundtrack" in val &&`);
|
|
85
|
+
lines.push(` Array.isArray((val as any).steps) && (val as any).steps.length > 0 &&`);
|
|
86
|
+
lines.push(` typeof (val as any).steps[0] === "object" && (val as any).steps[0] !== null &&`);
|
|
87
|
+
lines.push(` "delayMs" in (val as any).steps[0]`);
|
|
88
|
+
lines.push(` ) return (val as any).soundtrack;`);
|
|
89
|
+
lines.push(` }`);
|
|
90
|
+
lines.push(` return (mod as any)["soundtrack"];`);
|
|
91
|
+
lines.push(`}`);
|
|
92
|
+
lines.push(``);
|
|
93
|
+
lines.push(`const _soundtrack: any = _findSoundtrack(_stepsModule as unknown as Record<string, unknown>);`);
|
|
94
|
+
// --- Title cards resolution (mirror of the CLI's findAuthoredTitleCards) ---
|
|
95
|
+
// Same discovery contract as _findSoundtrack. The config is discovered
|
|
96
|
+
// here and applied immediately below — bundle assembly is THE one
|
|
97
|
+
// card-synthesis point (applyTitleCards from @scenar/core, re-exported
|
|
98
|
+
// by @scenar/remotion so no extra dependency is needed).
|
|
99
|
+
lines.push(``);
|
|
100
|
+
lines.push(`function _findTitleCards(mod: Record<string, unknown>): any {`);
|
|
101
|
+
lines.push(` for (const val of Object.values(mod)) {`);
|
|
102
|
+
lines.push(` if (`);
|
|
103
|
+
lines.push(` typeof val === "object" && val !== null && !Array.isArray(val) &&`);
|
|
104
|
+
lines.push(` "titleCards" in val &&`);
|
|
105
|
+
lines.push(` Array.isArray((val as any).steps) && (val as any).steps.length > 0 &&`);
|
|
106
|
+
lines.push(` typeof (val as any).steps[0] === "object" && (val as any).steps[0] !== null &&`);
|
|
107
|
+
lines.push(` "delayMs" in (val as any).steps[0]`);
|
|
108
|
+
lines.push(` ) return (val as any).titleCards;`);
|
|
109
|
+
lines.push(` }`);
|
|
110
|
+
lines.push(` return (mod as any)["titleCards"];`);
|
|
111
|
+
lines.push(`}`);
|
|
112
|
+
lines.push(``);
|
|
113
|
+
lines.push(`const _titleCards: any = _findTitleCards(_stepsModule as unknown as Record<string, unknown>);`);
|
|
57
114
|
// --- Bundle + timeline ---
|
|
115
|
+
// Card synthesis runs before the timeline calculation so an intro or
|
|
116
|
+
// outro card lengthens durationInFrames exactly like an authored step.
|
|
58
117
|
lines.push(``);
|
|
118
|
+
lines.push(`const _applied = applyTitleCards(_steps, _manifest, _titleCards, _presenterManifest);`);
|
|
59
119
|
lines.push(`const _bundle = {`);
|
|
60
120
|
lines.push(` id: ${JSON.stringify(input.scenarioId)},`);
|
|
61
|
-
lines.push(` steps:
|
|
62
|
-
lines.push(` narrationManifest:
|
|
121
|
+
lines.push(` steps: _applied.steps as any,`);
|
|
122
|
+
lines.push(` narrationManifest: _applied.narrationManifest,`);
|
|
123
|
+
lines.push(` soundtrack: _soundtrack,`);
|
|
124
|
+
lines.push(` presenterManifest: _applied.presenterManifest,`);
|
|
63
125
|
lines.push(`};`);
|
|
64
126
|
lines.push(``);
|
|
65
127
|
lines.push(`const _timeline = calculateScenarioTimeline(_bundle.steps, _bundle.narrationManifest, ${input.fps});`);
|
|
66
128
|
// --- Video root component ---
|
|
67
129
|
lines.push(``);
|
|
68
130
|
lines.push(`function _VideoRoot() {`);
|
|
131
|
+
// `captions` is baked into the generated JSX (not read at render time):
|
|
132
|
+
// the entry is written per render invocation, so the flag's value IS the
|
|
133
|
+
// render's configuration — same treatment as fps/width/height above.
|
|
134
|
+
const compositionOpen = input.captions
|
|
135
|
+
? `<ScenarioComposition bundle={_bundle} captions>`
|
|
136
|
+
: `<ScenarioComposition bundle={_bundle}>`;
|
|
69
137
|
if (input.providersPath) {
|
|
70
138
|
lines.push(` return (`);
|
|
71
|
-
lines.push(` <AbsoluteFill>`);
|
|
139
|
+
lines.push(` <AbsoluteFill className={SCENAR_CLASS}>`);
|
|
72
140
|
lines.push(` <_Providers>`);
|
|
73
|
-
lines.push(`
|
|
141
|
+
lines.push(` ${compositionOpen}`);
|
|
74
142
|
lines.push(` {(data: any, stepIndex: number) => renderStep(data, stepIndex)}`);
|
|
75
143
|
lines.push(` </ScenarioComposition>`);
|
|
76
144
|
lines.push(` </_Providers>`);
|
|
@@ -79,8 +147,8 @@ export function generateRemotionEntry(input) {
|
|
|
79
147
|
}
|
|
80
148
|
else {
|
|
81
149
|
lines.push(` return (`);
|
|
82
|
-
lines.push(` <AbsoluteFill>`);
|
|
83
|
-
lines.push(`
|
|
150
|
+
lines.push(` <AbsoluteFill className={SCENAR_CLASS}>`);
|
|
151
|
+
lines.push(` ${compositionOpen}`);
|
|
84
152
|
lines.push(` {(data: any, stepIndex: number) => renderStep(data, stepIndex)}`);
|
|
85
153
|
lines.push(` </ScenarioComposition>`);
|
|
86
154
|
lines.push(` </AbsoluteFill>`);
|