demofly 0.2.8 → 0.2.10

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.
Files changed (56) hide show
  1. package/README.md +154 -0
  2. package/dist/commands/analyze.d.ts +3 -0
  3. package/dist/commands/analyze.d.ts.map +1 -0
  4. package/dist/commands/analyze.js +163 -0
  5. package/dist/commands/analyze.js.map +1 -0
  6. package/dist/commands/demos/open.d.ts.map +1 -1
  7. package/dist/commands/demos/open.js +2 -1
  8. package/dist/commands/demos/open.js.map +1 -1
  9. package/dist/commands/generate.d.ts.map +1 -1
  10. package/dist/commands/generate.js +467 -198
  11. package/dist/commands/generate.js.map +1 -1
  12. package/dist/commands/init.d.ts.map +1 -1
  13. package/dist/commands/init.js +1 -15
  14. package/dist/commands/init.js.map +1 -1
  15. package/dist/commands/push.d.ts.map +1 -1
  16. package/dist/commands/push.js +20 -1
  17. package/dist/commands/push.js.map +1 -1
  18. package/dist/commands/render.d.ts.map +1 -1
  19. package/dist/commands/render.js +5 -2
  20. package/dist/commands/render.js.map +1 -1
  21. package/dist/commands/tts.d.ts.map +1 -1
  22. package/dist/commands/tts.js +127 -12
  23. package/dist/commands/tts.js.map +1 -1
  24. package/dist/commands/voices/select.d.ts.map +1 -1
  25. package/dist/commands/voices/select.js +66 -11
  26. package/dist/commands/voices/select.js.map +1 -1
  27. package/dist/lib/audio-probe.d.ts +23 -0
  28. package/dist/lib/audio-probe.d.ts.map +1 -0
  29. package/dist/lib/audio-probe.js +59 -0
  30. package/dist/lib/audio-probe.js.map +1 -0
  31. package/dist/lib/checks.d.ts.map +1 -1
  32. package/dist/lib/checks.js +51 -16
  33. package/dist/lib/checks.js.map +1 -1
  34. package/dist/lib/demo-config.d.ts +7 -0
  35. package/dist/lib/demo-config.d.ts.map +1 -1
  36. package/dist/lib/demo-config.js +9 -0
  37. package/dist/lib/demo-config.js.map +1 -1
  38. package/dist/lib/edit-proposals.d.ts +48 -1
  39. package/dist/lib/edit-proposals.d.ts.map +1 -1
  40. package/dist/lib/edit-proposals.js +49 -0
  41. package/dist/lib/edit-proposals.js.map +1 -1
  42. package/dist/lib/push-project.d.ts +37 -0
  43. package/dist/lib/push-project.d.ts.map +1 -0
  44. package/dist/lib/push-project.js +194 -0
  45. package/dist/lib/push-project.js.map +1 -0
  46. package/dist/lib/retiming.js +47 -0
  47. package/dist/lib/retiming.js.map +1 -1
  48. package/dist/lib/scene-assembler.d.ts +32 -0
  49. package/dist/lib/scene-assembler.d.ts.map +1 -0
  50. package/dist/lib/scene-assembler.js +185 -0
  51. package/dist/lib/scene-assembler.js.map +1 -0
  52. package/dist/lib/voice-resolver.d.ts +4 -0
  53. package/dist/lib/voice-resolver.d.ts.map +1 -1
  54. package/dist/lib/voice-resolver.js +5 -2
  55. package/dist/lib/voice-resolver.js.map +1 -1
  56. package/package.json +1 -1
@@ -1,5 +1,5 @@
1
1
  import { execSync } from "node:child_process";
2
- import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, copyFileSync, } from "node:fs";
2
+ import { existsSync, mkdirSync, readdirSync, writeFileSync, readFileSync, copyFileSync, renameSync, unlinkSync, } from "node:fs";
3
3
  import { resolve, basename, extname } from "node:path";
4
4
  import * as readline from "node:readline";
5
5
  import { parseTimingMarkers, normalizeTimingData } from "../lib/timing.js";
@@ -7,19 +7,22 @@ import { debug } from "../lib/logger.js";
7
7
  import { generateAllAudio, parseTranscript } from "../lib/tts.js";
8
8
  import { extractTimestamps } from "../lib/timestamps.js";
9
9
  import { buildAndWriteAlignment } from "../lib/alignment.js";
10
- import { generateAndWriteEditProposals } from "../lib/edit-proposals.js";
11
- import { retimeAndAssemble } from "../lib/retiming.js";
12
10
  import { getToken } from "../lib/credentials.js";
13
11
  import { createApiClient, getAppUrl } from "../lib/api-client.js";
14
12
  import { getDefaultVoice } from "../lib/voice-config.js";
15
- import { isCloudVoice, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, resolveVoiceFromFlags, } from "../lib/voice-resolver.js";
13
+ import { getDemoVoicePreference, setDemoVoicePreference } from "../lib/demo-config.js";
14
+ import { isCloudVoice, KOKORO_DEFAULT_VOICE, resolveVoiceFromFlags, } from "../lib/voice-resolver.js";
16
15
  import { generateCloudAudio, getSubscriptionStatus } from "../lib/cloud-tts.js";
16
+ import { assembleScenes } from "../lib/scene-assembler.js";
17
17
  function validateDemoArtifacts(demoDir, demo) {
18
- const specPath = resolve(demoDir, "demo.spec.ts");
19
18
  const configPath = resolve(demoDir, "playwright.config.ts");
19
+ const scenesDir = resolve(demoDir, "scenes");
20
+ const legacySpecPath = resolve(demoDir, "demo.spec.ts");
20
21
  let valid = true;
21
- if (!existsSync(specPath)) {
22
- console.error(`No demo.spec.ts found in demofly/${demo}/. Create one with /demofly:create ${demo} in Claude Code.`);
22
+ const hasScenes = existsSync(scenesDir) && readdirSync(scenesDir).some(f => f.endsWith(".spec.ts"));
23
+ const hasLegacySpec = existsSync(legacySpecPath);
24
+ if (!hasScenes && !hasLegacySpec) {
25
+ console.error(`No scene specs or demo.spec.ts found in demofly/${demo}/. Create one with /demofly:create ${demo} in Claude Code.`);
23
26
  valid = false;
24
27
  }
25
28
  if (!existsSync(configPath)) {
@@ -28,6 +31,264 @@ function validateDemoArtifacts(demoDir, demo) {
28
31
  }
29
32
  return valid;
30
33
  }
34
+ function readSceneGroups(demoDir) {
35
+ const sharedPath = resolve(demoDir, "scenes", "shared.ts");
36
+ if (!existsSync(sharedPath))
37
+ return null;
38
+ const content = readFileSync(sharedPath, "utf-8");
39
+ const match = content.match(/export\s+const\s+SCENE_GROUPS\s*=\s*(\[[\s\S]*?\n\];)/);
40
+ if (!match)
41
+ return null;
42
+ try {
43
+ // Parse the TypeScript array literal as JSON (strip trailing commas, convert single quotes)
44
+ const jsonStr = match[1]
45
+ .replace(/'/g, '"')
46
+ .replace(/,\s*]/g, "]")
47
+ .replace(/,\s*}/g, "}")
48
+ .replace(/(\w+)\s*:/g, '"$1":');
49
+ return JSON.parse(jsonStr);
50
+ }
51
+ catch {
52
+ debug("Could not parse SCENE_GROUPS from shared.ts, falling back to file scan");
53
+ return null;
54
+ }
55
+ }
56
+ function discoverSceneGroups(demoDir) {
57
+ // Try reading from shared.ts first
58
+ const fromShared = readSceneGroups(demoDir);
59
+ if (fromShared)
60
+ return fromShared;
61
+ // Fall back to scanning scene spec files
62
+ const scenesDir = resolve(demoDir, "scenes");
63
+ if (!existsSync(scenesDir))
64
+ return [];
65
+ const specFiles = readdirSync(scenesDir)
66
+ .filter(f => f.endsWith(".spec.ts") && f.startsWith("scene-"))
67
+ .sort();
68
+ return specFiles.map(f => {
69
+ const id = f.replace(".spec.ts", "");
70
+ const scenes = id.split("-").filter(p => /^\d+$/.test(p)).map(n => `scene-${n}`);
71
+ if (scenes.length === 0)
72
+ scenes.push(id);
73
+ return { id, scenes, startUrl: "/", independent: scenes.length === 1 };
74
+ });
75
+ }
76
+ function findGroupForScene(groups, sceneId) {
77
+ return groups.find(g => g.scenes.includes(sceneId)) ?? null;
78
+ }
79
+ function runSceneGroupRecording(demoDir, demo, group, force) {
80
+ const scenesRecordingsDir = resolve(demoDir, "recordings", "scenes");
81
+ const groupVideoDir = resolve(scenesRecordingsDir, group.id);
82
+ const groupVideoPath = resolve(groupVideoDir, "video.webm");
83
+ // Skip if already recorded (unless --force)
84
+ if (!force && existsSync(groupVideoPath)) {
85
+ debug(`Skipping ${group.id}: recording exists at ${groupVideoPath}`);
86
+ return { groupId: group.id, scenes: group.scenes, status: "skipped" };
87
+ }
88
+ const specPath = `demofly/${demo}/scenes/${group.id}.spec.ts`;
89
+ const configPath = `demofly/${demo}/playwright.config.ts`;
90
+ const cmd = `npx playwright test ${specPath} --config ${configPath}`;
91
+ debug(`Recording scene group: ${cmd}`);
92
+ console.log(`\nRecording ${group.id}...`);
93
+ try {
94
+ const output = execSync(cmd, {
95
+ encoding: "utf-8",
96
+ timeout: 600_000,
97
+ stdio: ["pipe", "pipe", "pipe"],
98
+ cwd: resolve(demoDir, "..", ".."),
99
+ });
100
+ // Extract timing and save
101
+ const timingData = parseTimingMarkers(output);
102
+ mkdirSync(groupVideoDir, { recursive: true });
103
+ // Find and move video
104
+ const videoPath = findRecordedVideo(demoDir);
105
+ if (videoPath) {
106
+ execSync(`cp "${videoPath}" "${groupVideoPath}"`);
107
+ }
108
+ // Write timing data for the group
109
+ writeFileSync(resolve(groupVideoDir, "timing.json"), JSON.stringify(timingData, null, 2), "utf-8");
110
+ // If this is a multi-scene group, split into per-scene clips and timing
111
+ if (group.scenes.length > 1 && videoPath) {
112
+ splitGroupIntoScenes(groupVideoPath, timingData, scenesRecordingsDir, group);
113
+ }
114
+ else if (group.scenes.length === 1 && group.scenes[0] !== group.id) {
115
+ // Single scene with different scene name — symlink/copy
116
+ const sceneDir = resolve(scenesRecordingsDir, group.scenes[0]);
117
+ mkdirSync(sceneDir, { recursive: true });
118
+ execSync(`cp "${groupVideoPath}" "${resolve(sceneDir, "video.webm")}"`);
119
+ writeFileSync(resolve(sceneDir, "timing.json"), JSON.stringify(timingData, null, 2), "utf-8");
120
+ }
121
+ const durationMs = timingData.totalDuration;
122
+ console.log(` ${group.id}: recorded (${formatDuration(durationMs)})`);
123
+ return {
124
+ groupId: group.id,
125
+ scenes: group.scenes,
126
+ status: "success",
127
+ videoPath: groupVideoPath,
128
+ durationMs,
129
+ };
130
+ }
131
+ catch (error) {
132
+ const execError = error;
133
+ const errMsg = execError.stderr ?? execError.stdout ?? "Unknown error";
134
+ console.error(` ${group.id}: FAILED`);
135
+ debug(`Recording failed for ${group.id}: ${errMsg}`);
136
+ return {
137
+ groupId: group.id,
138
+ scenes: group.scenes,
139
+ status: "failed",
140
+ error: errMsg.slice(0, 500),
141
+ };
142
+ }
143
+ }
144
+ function splitGroupIntoScenes(groupVideoPath, timingData, scenesRecordingsDir, group) {
145
+ for (const sceneId of group.scenes) {
146
+ const sceneTimingData = extractSceneTiming(timingData, sceneId);
147
+ if (!sceneTimingData)
148
+ continue;
149
+ const sceneDir = resolve(scenesRecordingsDir, sceneId);
150
+ mkdirSync(sceneDir, { recursive: true });
151
+ // Split video for this scene
152
+ const startSec = sceneTimingData.scenes[0].startMs / 1000;
153
+ const durationSec = (sceneTimingData.scenes[0].endMs - sceneTimingData.scenes[0].startMs) / 1000;
154
+ const outputPath = resolve(sceneDir, "video.webm");
155
+ // Use a temp file when input and output are the same (single-scene groups)
156
+ const sameFile = resolve(groupVideoPath) === resolve(outputPath);
157
+ const writePath = sameFile ? resolve(sceneDir, "video.trimmed.webm") : outputPath;
158
+ try {
159
+ execSync(`ffmpeg -y -i "${groupVideoPath}" -ss ${startSec} -t ${durationSec} -c copy "${writePath}"`, { encoding: "utf-8", timeout: 300_000, stdio: ["pipe", "pipe", "pipe"] });
160
+ if (sameFile) {
161
+ renameSync(writePath, outputPath);
162
+ }
163
+ }
164
+ catch (err) {
165
+ const execError = err;
166
+ console.warn(` Warning: Failed to split ${sceneId} from group ${group.id}`);
167
+ if (execError.stderr)
168
+ debug(execError.stderr);
169
+ }
170
+ // Write scene-relative timing
171
+ writeFileSync(resolve(sceneDir, "timing.json"), JSON.stringify(sceneTimingData, null, 2), "utf-8");
172
+ }
173
+ }
174
+ function extractSceneTiming(timingData, sceneId) {
175
+ const scene = timingData.scenes.find(s => s.sceneId === sceneId);
176
+ if (!scene)
177
+ return null;
178
+ const sceneStartMs = scene.startMs;
179
+ return {
180
+ totalDuration: scene.endMs - scene.startMs,
181
+ scenes: [{
182
+ sceneId: scene.sceneId,
183
+ startMs: 0,
184
+ endMs: scene.endMs - sceneStartMs,
185
+ markers: scene.markers.map(m => ({
186
+ ...m,
187
+ ms: m.ms - sceneStartMs,
188
+ })),
189
+ }],
190
+ };
191
+ }
192
+ function buildCombinedTimingData(demoDir, groups) {
193
+ const allScenes = [];
194
+ let cumulativeMs = 0;
195
+ for (const group of groups) {
196
+ for (const sceneId of group.scenes) {
197
+ const sceneTimingPath = resolve(demoDir, "recordings", "scenes", sceneId, "timing.json");
198
+ if (!existsSync(sceneTimingPath)) {
199
+ // Also check group-level timing
200
+ const groupTimingPath = resolve(demoDir, "recordings", "scenes", group.id, "timing.json");
201
+ if (existsSync(groupTimingPath)) {
202
+ try {
203
+ const groupTiming = normalizeTimingData(JSON.parse(readFileSync(groupTimingPath, "utf-8")));
204
+ const sceneData = groupTiming.scenes.find(s => s.sceneId === sceneId);
205
+ if (sceneData) {
206
+ allScenes.push({
207
+ ...sceneData,
208
+ startMs: cumulativeMs,
209
+ endMs: cumulativeMs + (sceneData.endMs - sceneData.startMs),
210
+ markers: sceneData.markers.map(m => ({
211
+ ...m,
212
+ ms: cumulativeMs + m.ms - sceneData.startMs,
213
+ })),
214
+ });
215
+ cumulativeMs += sceneData.endMs - sceneData.startMs;
216
+ }
217
+ }
218
+ catch { /* skip */ }
219
+ }
220
+ continue;
221
+ }
222
+ try {
223
+ const sceneTiming = normalizeTimingData(JSON.parse(readFileSync(sceneTimingPath, "utf-8")));
224
+ for (const scene of sceneTiming.scenes) {
225
+ allScenes.push({
226
+ ...scene,
227
+ startMs: cumulativeMs + scene.startMs,
228
+ endMs: cumulativeMs + scene.endMs,
229
+ markers: scene.markers.map(m => ({
230
+ ...m,
231
+ ms: cumulativeMs + m.ms,
232
+ })),
233
+ });
234
+ }
235
+ cumulativeMs += sceneTiming.totalDuration;
236
+ }
237
+ catch { /* skip */ }
238
+ }
239
+ }
240
+ return { totalDuration: cumulativeMs, scenes: allScenes };
241
+ }
242
+ function collectSceneClips(demoDir, groups) {
243
+ const clips = [];
244
+ for (const group of groups) {
245
+ for (const sceneId of group.scenes) {
246
+ const sceneDir = resolve(demoDir, "recordings", "scenes", sceneId);
247
+ const videoPath = resolve(sceneDir, "video.webm");
248
+ const timingPath = resolve(sceneDir, "timing.json");
249
+ if (!existsSync(videoPath))
250
+ continue;
251
+ let durationMs = 0;
252
+ let trimStartMs = 0;
253
+ if (existsSync(timingPath)) {
254
+ try {
255
+ const timing = normalizeTimingData(JSON.parse(readFileSync(timingPath, "utf-8")));
256
+ const firstScene = timing.scenes[0];
257
+ if (firstScene) {
258
+ trimStartMs = firstScene.startMs;
259
+ durationMs = firstScene.endMs - firstScene.startMs;
260
+ }
261
+ else {
262
+ durationMs = timing.totalDuration;
263
+ }
264
+ }
265
+ catch { /* use 0 */ }
266
+ }
267
+ clips.push({
268
+ sceneId,
269
+ videoPath,
270
+ durationMs,
271
+ trimStartMs,
272
+ transition: "hard_cut", // default; could be overridden from narration metadata
273
+ });
274
+ }
275
+ }
276
+ return clips;
277
+ }
278
+ function printSceneResults(results) {
279
+ console.log("\n--- Per-Scene Recording Results ---\n");
280
+ console.log(" Scene Group Status Duration");
281
+ console.log(" ───────────── ──────── ────────");
282
+ for (const r of results) {
283
+ const status = r.status === "success" ? "OK" : r.status === "skipped" ? "skipped" : "FAILED";
284
+ const duration = r.durationMs ? formatDuration(r.durationMs) : "-";
285
+ console.log(` ${r.groupId.padEnd(16)}${status.padEnd(11)}${duration}`);
286
+ }
287
+ const succeeded = results.filter(r => r.status === "success").length;
288
+ const skipped = results.filter(r => r.status === "skipped").length;
289
+ const failed = results.filter(r => r.status === "failed").length;
290
+ console.log(`\n Total: ${succeeded} recorded, ${skipped} skipped, ${failed} failed\n`);
291
+ }
31
292
  function runPlaywrightTest(demoDir, demo) {
32
293
  const specPath = `demofly/${demo}/demo.spec.ts`;
33
294
  const configPath = `demofly/${demo}/playwright.config.ts`;
@@ -119,94 +380,6 @@ function findAudioFiles(demoDir, timingData) {
119
380
  }
120
381
  return { matched, missing };
121
382
  }
122
- function getWatermarkPath() {
123
- return resolve(import.meta.dirname ?? __dirname, "../../assets/watermark.png");
124
- }
125
- function buildFfmpegCommand(videoPath, audioMatches, timingData, outputPath, applyWatermark = false) {
126
- const sceneMap = new Map(timingData.scenes.map((s) => [s.sceneId, s]));
127
- const inputArgs = ["-i", videoPath];
128
- const filterParts = [];
129
- // If watermarking, add the watermark PNG as an input
130
- const watermarkPath = applyWatermark ? getWatermarkPath() : null;
131
- let watermarkInputIndex = -1;
132
- if (watermarkPath && existsSync(watermarkPath)) {
133
- watermarkInputIndex = 1; // will be shifted after audio inputs are added
134
- }
135
- for (let i = 0; i < audioMatches.length; i++) {
136
- const match = audioMatches[i];
137
- const scene = sceneMap.get(match.sceneId);
138
- if (!scene)
139
- continue;
140
- inputArgs.push("-i", match.filePath);
141
- const audioIndex = i + 1;
142
- const delayMs = scene.startMs;
143
- filterParts.push(`[${audioIndex}:a]adelay=${delayMs}|${delayMs}[a${audioIndex}]`);
144
- }
145
- // Add watermark input after all audio inputs
146
- if (watermarkPath && existsSync(watermarkPath)) {
147
- inputArgs.push("-i", watermarkPath);
148
- watermarkInputIndex = inputArgs.filter((a) => a === "-i").length - 1;
149
- }
150
- const mixInputs = filterParts
151
- .map((_, i) => `[a${i + 1}]`)
152
- .join("");
153
- let filterComplex = filterParts.join("; ") +
154
- `; ${mixInputs}amix=inputs=${audioMatches.length}:normalize=0[aout]`;
155
- // Build video filter chain with optional watermark overlay
156
- let videoMap = "0:v";
157
- if (applyWatermark && watermarkInputIndex >= 0) {
158
- // Scale watermark to ~8% of video width, position bottom-right with padding
159
- // Use scale2ref so dimensions reference the main video, not the watermark PNG
160
- filterComplex +=
161
- `; [${watermarkInputIndex}:v][0:v]scale2ref=w=main_w*0.08:h=-1[wm_scaled][vmain]` +
162
- `; [wm_scaled]format=rgba,colorchannelmixer=aa=0.6[wm]` +
163
- `; [vmain][wm]overlay=W-w-24:H-h-16[vout]`;
164
- videoMap = "[vout]";
165
- }
166
- const args = [
167
- "ffmpeg",
168
- "-y",
169
- ...inputArgs,
170
- "-filter_complex",
171
- `"${filterComplex}"`,
172
- "-map",
173
- videoMap,
174
- "-map",
175
- "[aout]",
176
- "-c:v",
177
- "libx264",
178
- "-preset",
179
- "fast",
180
- "-crf",
181
- "23",
182
- "-pix_fmt",
183
- "yuv420p",
184
- "-shortest",
185
- outputPath,
186
- ];
187
- return args.join(" ");
188
- }
189
- function stitchAudio(videoPath, audioMatches, timingData, recordingsDir, applyWatermark = false) {
190
- const outputPath = resolve(recordingsDir, "final.mp4");
191
- const cmd = buildFfmpegCommand(videoPath, audioMatches, timingData, outputPath, applyWatermark);
192
- console.log(`Stitching audio with ffmpeg...\n`);
193
- try {
194
- execSync(cmd, {
195
- encoding: "utf-8",
196
- timeout: 600_000,
197
- stdio: ["pipe", "pipe", "pipe"],
198
- });
199
- return outputPath;
200
- }
201
- catch (error) {
202
- const execError = error;
203
- console.error("ffmpeg stitching failed:");
204
- if (execError.stderr) {
205
- console.error(execError.stderr);
206
- }
207
- return null;
208
- }
209
- }
210
383
  export function splitScenes(videoPath, timingData, recordingsDir) {
211
384
  const scenesDir = resolve(recordingsDir, "scenes");
212
385
  const outputs = [];
@@ -216,8 +389,14 @@ export function splitScenes(videoPath, timingData, recordingsDir) {
216
389
  const startSec = scene.startMs / 1000;
217
390
  const durationSec = (scene.endMs - scene.startMs) / 1000;
218
391
  const outputPath = resolve(sceneDir, "video.webm");
392
+ // Use a temp file when input and output are the same path
393
+ const sameFile = resolve(videoPath) === resolve(outputPath);
394
+ const writePath = sameFile ? resolve(sceneDir, "video.trimmed.webm") : outputPath;
219
395
  try {
220
- execSync(`ffmpeg -y -i "${videoPath}" -ss ${startSec} -t ${durationSec} -c copy "${outputPath}"`, { encoding: "utf-8", timeout: 300_000, stdio: ["pipe", "pipe", "pipe"] });
396
+ execSync(`ffmpeg -y -i "${videoPath}" -ss ${startSec} -t ${durationSec} -c copy "${writePath}"`, { encoding: "utf-8", timeout: 300_000, stdio: ["pipe", "pipe", "pipe"] });
397
+ if (sameFile) {
398
+ renameSync(writePath, outputPath);
399
+ }
221
400
  outputs.push(outputPath);
222
401
  }
223
402
  catch (error) {
@@ -307,7 +486,7 @@ function formatDuration(ms) {
307
486
  }
308
487
  return `${seconds}s`;
309
488
  }
310
- function printSummary(videoPath, timingPath, finalPath, timingData, voice) {
489
+ function printSummary(videoPath, timingPath, finalPath, timingData, voice, assemblyResult) {
311
490
  console.log("\n--- demofly generate summary ---\n");
312
491
  if (videoPath) {
313
492
  console.log(` Video: ${videoPath}`);
@@ -323,7 +502,18 @@ function printSummary(videoPath, timingPath, finalPath, timingData, voice) {
323
502
  else {
324
503
  console.log(` Stitched: no`);
325
504
  }
326
- console.log(` Duration: ${formatDuration(timingData.totalDuration)}`);
505
+ if (assemblyResult) {
506
+ const corrected = assemblyResult.corrections.filter((c) => c.correction !== "none");
507
+ if (corrected.length > 0) {
508
+ const methods = new Set(corrected.map((c) => c.correction));
509
+ const methodStr = [...methods].join(", ");
510
+ console.log(` Corrected: ${corrected.length} scene(s) via ${methodStr}`);
511
+ }
512
+ console.log(` Duration: ${formatDuration(assemblyResult.adjustedTotalDurationMs)}`);
513
+ }
514
+ else {
515
+ console.log(` Duration: ${formatDuration(timingData.totalDuration)}`);
516
+ }
327
517
  console.log(` Scenes: ${timingData.scenes.length}`);
328
518
  console.log();
329
519
  }
@@ -396,11 +586,13 @@ function shouldRegenerateAudio(projectDir, resolvedVoice, explicitVoiceFlag, tim
396
586
  return true;
397
587
  return meta.name !== resolvedVoice.name || meta.provider !== resolvedVoice.provider;
398
588
  }
399
- function resolveVoiceForGenerate(voiceName, providerName) {
589
+ function resolveVoiceForGenerate(voiceName, providerName, projectDir) {
590
+ const demoVoicePreference = projectDir ? getDemoVoicePreference(projectDir) : null;
400
591
  return resolveVoiceFromFlags(voiceName, providerName, {
401
592
  getDefaultVoice,
402
593
  getToken,
403
594
  createApiClient,
595
+ demoVoicePreference,
404
596
  });
405
597
  }
406
598
  async function generateAudioForProject(narrationPath, projectDir, voice, speed, sceneFilter) {
@@ -419,23 +611,31 @@ async function generateAudioForProject(narrationPath, projectDir, voice, speed,
419
611
  }
420
612
  if (results.length > 0) {
421
613
  writeVoiceMetadata(projectDir, actualVoice);
614
+ // Auto-set demo voice preference on first generation
615
+ if (!getDemoVoicePreference(projectDir)) {
616
+ setDemoVoicePreference(projectDir, { name: actualVoice.name, provider: actualVoice.provider });
617
+ }
422
618
  }
423
619
  return { ttsResults: results, actualVoice };
424
620
  }
425
621
  async function generateCloudTtsInline(narrationPath, projectDir, voice, speed, sceneFilter) {
426
622
  const token = await getToken();
427
623
  if (!token) {
428
- return fallbackToKokoro(narrationPath, projectDir, speed, "Not authenticated.", sceneFilter);
624
+ console.error("Not authenticated. Premium voices require authentication.");
625
+ console.error("Run `demofly auth login` to authenticate, or use a free voice.\n");
626
+ process.exit(1);
429
627
  }
430
628
  const api = createApiClient(token);
431
629
  try {
432
630
  const sub = await getSubscriptionStatus(api);
433
631
  if (sub.plan !== "pro") {
434
- return fallbackToKokoro(narrationPath, projectDir, speed, `Premium voices require a Pro subscription. Upgrade at ${getAppUrl()}/upgrade`, sceneFilter);
632
+ console.error(`Premium voices require a Pro subscription. Upgrade at ${getAppUrl()}/upgrade`);
633
+ process.exit(1);
435
634
  }
436
635
  }
437
636
  catch {
438
- return fallbackToKokoro(narrationPath, projectDir, speed, "Cloud TTS unavailable.", sceneFilter);
637
+ console.error("Could not verify subscription status. Cloud TTS unavailable.");
638
+ process.exit(1);
439
639
  }
440
640
  const content = readFileSync(narrationPath, "utf-8");
441
641
  let scenes = parseTranscript(content);
@@ -444,10 +644,42 @@ async function generateCloudTtsInline(narrationPath, projectDir, voice, speed, s
444
644
  }
445
645
  if (scenes.length === 0)
446
646
  return { ttsResults: [], actualVoice: voice };
647
+ // Credit pre-check: estimate total cost before starting
648
+ const allText = scenes.map(s => s.text).join(" ");
649
+ const operationType = `${voice.provider}-tts`;
650
+ try {
651
+ const estimate = await api.post("/credits/estimate", { operation: operationType, input: { text: allText } });
652
+ if (!estimate.sufficient) {
653
+ console.log(`\nThis demo needs ~${estimate.estimatedCredits} credits but you have ${estimate.available}.`);
654
+ if (process.stdin.isTTY) {
655
+ const useFree = await promptYesNo(`Generate with free voice (${getDefaultFreeVoice().name}) instead?`);
656
+ if (useFree) {
657
+ console.log();
658
+ const freeVoice = getDefaultFreeVoice();
659
+ const ttsResults = generateAllAudio(narrationPath, projectDir, {
660
+ voice: freeVoice.providerId,
661
+ speed,
662
+ sceneFilter,
663
+ });
664
+ return { ttsResults, actualVoice: freeVoice };
665
+ }
666
+ }
667
+ else {
668
+ console.log(`Run interactively to switch to a free voice, or use --voice heart.`);
669
+ }
670
+ console.log(`\nUse \`demofly voices select ${basename(projectDir)}\` to choose a different voice, or add credits at ${getAppUrl()}`);
671
+ process.exit(1);
672
+ }
673
+ }
674
+ catch {
675
+ // Credit API unreachable — warn and proceed optimistically
676
+ console.warn("Warning: Could not verify credit balance. Proceeding with generation.\n");
677
+ }
447
678
  const audioDir = resolve(projectDir, "audio");
448
679
  if (!existsSync(audioDir))
449
680
  mkdirSync(audioDir, { recursive: true });
450
681
  const results = [];
682
+ const generatedFiles = [];
451
683
  for (const scene of scenes) {
452
684
  console.log(` TTS: ${scene.sceneId} (${scene.text.length} chars)`);
453
685
  try {
@@ -456,27 +688,35 @@ async function generateCloudTtsInline(narrationPath, projectDir, voice, speed, s
456
688
  const elapsedS = (performance.now() - start) / 1000;
457
689
  const outputPath = resolve(audioDir, `${scene.sceneId}.mp3`);
458
690
  writeFileSync(outputPath, Buffer.from(audio));
691
+ generatedFiles.push(outputPath);
459
692
  results.push({ sceneId: scene.sceneId, filePath: outputPath, durationS, peakMemoryMb: 0, elapsedS });
460
693
  console.log(` -> ${durationS.toFixed(1)}s audio, ${elapsedS.toFixed(1)}s elapsed`);
461
694
  }
462
695
  catch (err) {
463
- if (err.status === 403) {
464
- return fallbackToKokoro(narrationPath, projectDir, speed, `Premium voices require a Pro subscription. Upgrade at ${getAppUrl()}/upgrade`, sceneFilter);
696
+ // Clean up audio files generated during this failed run
697
+ for (const file of generatedFiles) {
698
+ try {
699
+ unlinkSync(file);
700
+ }
701
+ catch { }
465
702
  }
466
- console.warn(` Cloud TTS failed for ${scene.sceneId}: ${err.message}`);
467
- return fallbackToKokoro(narrationPath, projectDir, speed, "Cloud TTS unavailable, falling back to local voice.", sceneFilter);
703
+ if (err.status === 402) {
704
+ console.error(`\nRan out of credits during generation at ${scene.sceneId}. No audio was produced.`);
705
+ }
706
+ else if (err.status === 403) {
707
+ console.error(`\nPremium voices require a Pro subscription. Upgrade at ${getAppUrl()}/upgrade`);
708
+ }
709
+ else {
710
+ console.error(`\nPremium voice generation failed for ${scene.sceneId}: ${err.message}`);
711
+ }
712
+ console.error(`Re-run with a free voice (--voice heart) or use \`demofly voices select ${basename(projectDir)}\` to change voice.\n`);
713
+ process.exit(1);
468
714
  }
469
715
  }
470
716
  return { ttsResults: results, actualVoice: voice };
471
717
  }
472
- function fallbackToKokoro(narrationPath, projectDir, speed, reason, sceneFilter) {
473
- console.warn(`${reason} Falling back to Kokoro.\n`);
474
- const ttsResults = generateAllAudio(narrationPath, projectDir, {
475
- voice: KOKORO_VOICES[0].kokoroId,
476
- speed,
477
- sceneFilter,
478
- });
479
- return { ttsResults, actualVoice: KOKORO_DEFAULT_VOICE };
718
+ function getDefaultFreeVoice() {
719
+ return KOKORO_DEFAULT_VOICE;
480
720
  }
481
721
  export function registerGenerateCommand(program) {
482
722
  program
@@ -488,11 +728,13 @@ export function registerGenerateCommand(program) {
488
728
  .option("--align", "Produce alignment.json from timestamps + timing data")
489
729
  .option("--assemble", "Full intelligent assembly (align + edit proposals + retiming)")
490
730
  .option("--video", "Record video only (skip audio generation)")
491
- .option("--scene <id>", "Generate audio for a specific scene only")
731
+ .option("--scene <id>", "Record or generate audio for a specific scene only")
732
+ .option("--force", "Re-record scenes even if recordings exist")
492
733
  .option("--voice <name>", "TTS voice name")
493
734
  .option("--provider <provider>", "TTS provider (e.g., elevenlabs, openai, kokoro)")
494
735
  .option("--speed <multiplier>", "TTS speed multiplier", "1.0")
495
736
  .option("--no-audio", "Skip TTS generation entirely")
737
+ .option("--atempo", "Speed up audio instead of freeze-framing when audio overflows scene window")
496
738
  .action(async (demo, opts) => {
497
739
  const projectDir = resolve(process.cwd(), "demofly", demo);
498
740
  debug(`Resolved demo directory: ${projectDir}`);
@@ -517,7 +759,7 @@ export function registerGenerateCommand(program) {
517
759
  process.exit(1);
518
760
  }
519
761
  const speed = parseFloat(opts.speed);
520
- const voice = await resolveVoiceForGenerate(opts.voice, opts.provider);
762
+ const voice = await resolveVoiceForGenerate(opts.voice, opts.provider, projectDir);
521
763
  const { ttsResults, actualVoice } = await generateAudioForProject(sourcePath, projectDir, voice, speed, opts.scene);
522
764
  console.log("\n--- demofly generate --audio summary ---\n");
523
765
  console.log(` Voice: ${actualVoice.name} (${actualVoice.provider})`);
@@ -563,7 +805,31 @@ export function registerGenerateCommand(program) {
563
805
  }
564
806
  // Video-only mode: record and exit
565
807
  if (opts.video) {
566
- debug("Video-only mode: running Playwright test");
808
+ const sceneGroups = discoverSceneGroups(projectDir);
809
+ if (sceneGroups.length > 0) {
810
+ // Per-scene recording
811
+ debug("Video-only mode: per-scene recording");
812
+ let groupsToRecord = sceneGroups;
813
+ if (opts.scene) {
814
+ const group = findGroupForScene(sceneGroups, opts.scene);
815
+ if (!group) {
816
+ console.error(`Scene "${opts.scene}" not found in any scene group.`);
817
+ process.exit(1);
818
+ }
819
+ groupsToRecord = [group];
820
+ }
821
+ const results = [];
822
+ for (const group of groupsToRecord) {
823
+ results.push(runSceneGroupRecording(projectDir, demo, group, opts.force ?? false));
824
+ }
825
+ printSceneResults(results);
826
+ const anyFailed = results.some(r => r.status === "failed");
827
+ if (anyFailed)
828
+ process.exit(2);
829
+ return;
830
+ }
831
+ // Legacy monolithic recording
832
+ debug("Video-only mode: running Playwright test (legacy)");
567
833
  const result = recordDemo(projectDir, demo);
568
834
  const videoPath = result.videoPath
569
835
  ? resolve(recordingsDir, "video.webm")
@@ -582,27 +848,64 @@ export function registerGenerateCommand(program) {
582
848
  let timingPath;
583
849
  // Step 2: Record or use existing artifacts
584
850
  if (opts.record) {
585
- debug("Recording mode: running Playwright test");
586
- const result = recordDemo(projectDir, demo);
587
- videoPath = result.videoPath
588
- ? resolve(recordingsDir, "video.webm")
589
- : null;
590
- const stdout = result.stdout;
591
- timingData = parseTimingMarkers(stdout);
592
- timingPath = resolve(recordingsDir, "timing.json");
593
- if (timingData.scenes.length === 0) {
594
- console.warn("Warning: No DEMOFLY timing markers found in test output. Video was still recorded.");
851
+ const sceneGroups = discoverSceneGroups(projectDir);
852
+ if (sceneGroups.length > 0) {
853
+ // Per-scene recording mode
854
+ debug("Recording mode: per-scene recording");
855
+ let groupsToRecord = sceneGroups;
856
+ if (opts.scene) {
857
+ const group = findGroupForScene(sceneGroups, opts.scene);
858
+ if (!group) {
859
+ console.error(`Scene "${opts.scene}" not found in any scene group.`);
860
+ process.exit(1);
861
+ }
862
+ groupsToRecord = [group];
863
+ }
864
+ const results = [];
865
+ for (const group of groupsToRecord) {
866
+ results.push(runSceneGroupRecording(projectDir, demo, group, opts.force ?? false));
867
+ }
868
+ printSceneResults(results);
869
+ const anyFailed = results.some(r => r.status === "failed");
870
+ // Build combined timing data from per-scene recordings
871
+ timingData = buildCombinedTimingData(projectDir, sceneGroups);
872
+ timingPath = resolve(recordingsDir, "timing.json");
873
+ writeTimingJson(recordingsDir, timingData);
874
+ // Use the first successful scene's parent video or look for combined
875
+ const firstSuccess = results.find(r => r.status === "success");
876
+ videoPath = firstSuccess?.videoPath ?? null;
877
+ if (anyFailed) {
878
+ console.warn("Some scenes failed to record. Assembly will use available clips.\n");
879
+ }
880
+ }
881
+ else {
882
+ // Legacy monolithic recording
883
+ debug("Recording mode: running Playwright test (legacy)");
884
+ const result = recordDemo(projectDir, demo);
885
+ videoPath = result.videoPath
886
+ ? resolve(recordingsDir, "video.webm")
887
+ : null;
888
+ const stdout = result.stdout;
889
+ timingData = parseTimingMarkers(stdout);
890
+ timingPath = resolve(recordingsDir, "timing.json");
891
+ if (timingData.scenes.length === 0) {
892
+ console.warn("Warning: No DEMOFLY timing markers found in test output. Video was still recorded.");
893
+ }
595
894
  }
596
895
  }
597
896
  else {
598
897
  // Assembler-first: look for existing artifacts
599
898
  const existingVideo = resolve(recordingsDir, "video.webm");
600
899
  const existingTiming = resolve(recordingsDir, "timing.json");
900
+ // Check for video: either legacy combined video or per-scene clips
601
901
  const hasVideo = existsSync(existingVideo);
902
+ const scenesDir = resolve(recordingsDir, "scenes");
903
+ const hasSceneClips = existsSync(scenesDir) && readdirSync(scenesDir, { withFileTypes: true })
904
+ .some(d => d.isDirectory() && existsSync(resolve(scenesDir, d.name, "video.webm")));
602
905
  const hasTiming = existsSync(existingTiming);
603
- if (!hasVideo || !hasTiming) {
906
+ if ((!hasVideo && !hasSceneClips) || !hasTiming) {
604
907
  const missingParts = [];
605
- if (!hasVideo)
908
+ if (!hasVideo && !hasSceneClips)
606
909
  missingParts.push("recordings/video.webm");
607
910
  if (!hasTiming)
608
911
  missingParts.push("recordings/timing.json");
@@ -637,7 +940,7 @@ export function registerGenerateCommand(program) {
637
940
  }
638
941
  // If we didn't record (existing artifacts found), load from disk
639
942
  if (!videoPath) {
640
- videoPath = existingVideo;
943
+ videoPath = hasVideo ? existingVideo : null; // null when only scene clips exist
641
944
  timingData = normalizeTimingData(JSON.parse(readFileSync(existingTiming, "utf-8")));
642
945
  timingPath = existingTiming;
643
946
  }
@@ -660,7 +963,7 @@ export function registerGenerateCommand(program) {
660
963
  const narrationSourcePath = resolve(projectDir, "narration.md");
661
964
  if (opts.audio !== false && existsSync(narrationSourcePath)) {
662
965
  const speed = parseFloat(opts.speed);
663
- const voice = await resolveVoiceForGenerate(opts.voice, opts.provider);
966
+ const voice = await resolveVoiceForGenerate(opts.voice, opts.provider, projectDir);
664
967
  const explicitVoiceFlag = !!(opts.voice || opts.provider);
665
968
  if (opts.scene || shouldRegenerateAudio(projectDir, voice, explicitVoiceFlag, timingData)) {
666
969
  const { ttsResults, actualVoice } = await generateAudioForProject(narrationSourcePath, projectDir, voice, speed, opts.scene);
@@ -692,87 +995,53 @@ export function registerGenerateCommand(program) {
692
995
  if (applyWatermark) {
693
996
  debug("Applying DemoFly watermark (free tier)");
694
997
  }
695
- // Step 5: Find audio files and check ffmpeg
998
+ // Step 5: Assemble video with per-scene pipeline
999
+ const audioDir = resolve(projectDir, "audio");
696
1000
  const { matched, missing } = findAudioFiles(projectDir, timingData);
697
1001
  debug(`Audio files: ${matched.length} matched, ${missing.length} missing`);
698
1002
  let finalPath = null;
699
- if (matched.length > 0) {
700
- if (!hasFfmpeg()) {
701
- console.error("Audio files found but ffmpeg is not installed. Install ffmpeg to stitch audio with video.");
702
- process.exit(1);
703
- }
704
- if (missing.length > 0) {
1003
+ let assemblyResult = null;
1004
+ // Check for per-scene clips
1005
+ const sceneGroups = discoverSceneGroups(projectDir);
1006
+ const sceneClips = sceneGroups.length > 0
1007
+ ? collectSceneClips(projectDir, sceneGroups)
1008
+ : [];
1009
+ if (sceneClips.length > 0 && hasFfmpeg()) {
1010
+ if (missing.length > 0 && matched.length > 0) {
705
1011
  console.warn(`Warning: No audio files found for scenes: ${missing.join(", ")}`);
706
1012
  }
707
- if (opts.assemble) {
708
- // Intelligent assembly: align → edit proposals → retiming
709
- console.log("Running intelligent assembly pipeline...\n");
710
- // Step 4a: Build alignment
711
- console.log(" Step 1: Building alignment...");
712
- const alignment = buildAndWriteAlignment(projectDir);
713
- if (alignment) {
714
- // Step 4b: Generate edit proposals
715
- console.log(" Step 2: Generating edit proposals...");
716
- const proposals = generateAndWriteEditProposals(projectDir);
717
- if (proposals && proposals.decisions.length > 0) {
718
- // Step 4c: Retiming + assembly
719
- console.log(" Step 3: Retiming and assembling...\n");
720
- const audioDir = resolve(projectDir, "audio");
721
- finalPath = retimeAndAssemble(videoPath, audioDir, timingData, proposals, recordingsDir);
722
- }
723
- }
724
- // Fall back to simple stitch if intelligent assembly failed
725
- if (!finalPath) {
726
- console.log(" Intelligent assembly incomplete, falling back to simple stitch.\n");
727
- finalPath = stitchAudio(videoPath, matched, timingData, recordingsDir, applyWatermark);
728
- }
1013
+ console.log("Assembling video with per-scene pipeline...\n");
1014
+ try {
1015
+ assemblyResult = assembleScenes(sceneClips.map(c => ({ sceneId: c.sceneId, videoPath: c.videoPath, durationMs: c.durationMs })), audioDir, recordingsDir, { useAtempo: opts.atempo });
1016
+ finalPath = assemblyResult.finalPath;
729
1017
  }
730
- else {
731
- // Simple stitch (default)
732
- finalPath = stitchAudio(videoPath, matched, timingData, recordingsDir, applyWatermark);
1018
+ catch (error) {
1019
+ const msg = error instanceof Error ? error.message : String(error);
1020
+ console.error(`Assembly failed: ${msg}`);
733
1021
  }
734
1022
  }
735
- else if (videoPath) {
736
- // No audio — copy or convert video as final output
737
- if (hasFfmpeg()) {
738
- const outputPath = resolve(recordingsDir, "final.mp4");
739
- console.log("No audio files found. Converting video to mp4...\n");
740
- try {
741
- const watermarkPath = getWatermarkPath();
742
- if (applyWatermark && existsSync(watermarkPath)) {
743
- execSync(`ffmpeg -y -i "${videoPath}" -i "${watermarkPath}" ` +
744
- `-filter_complex "[1:v][0:v]scale2ref=w=main_w*0.08:h=-1[wm_scaled][vmain];[wm_scaled]format=rgba,colorchannelmixer=aa=0.6[wm];[vmain][wm]overlay=W-w-24:H-h-16[vout]" ` +
745
- `-map "[vout]" -c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p "${outputPath}"`, { encoding: "utf-8", timeout: 600_000, stdio: ["pipe", "pipe", "pipe"] });
746
- }
747
- else {
748
- execSync(`ffmpeg -y -i "${videoPath}" -c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p "${outputPath}"`, { encoding: "utf-8", timeout: 600_000, stdio: ["pipe", "pipe", "pipe"] });
749
- }
750
- finalPath = outputPath;
751
- }
752
- catch (error) {
753
- const execError = error;
754
- console.error("ffmpeg conversion failed:");
755
- if (execError.stderr) {
756
- console.error(execError.stderr);
757
- }
758
- }
759
- }
760
- else {
761
- // No ffmpeg — copy webm as-is
762
- const outputPath = resolve(recordingsDir, "final.webm");
763
- console.log("No audio and no ffmpeg. Copying video as-is.\n");
764
- copyFileSync(videoPath, outputPath);
765
- finalPath = outputPath;
766
- }
1023
+ else if (videoPath && !hasFfmpeg()) {
1024
+ // No ffmpeg — copy webm as-is
1025
+ const outputPath = resolve(recordingsDir, "final.webm");
1026
+ console.log("No ffmpeg available. Copying video as-is.\n");
1027
+ copyFileSync(videoPath, outputPath);
1028
+ finalPath = outputPath;
767
1029
  }
768
- else if (!videoPath) {
1030
+ else if (!videoPath && sceneClips.length === 0) {
769
1031
  console.warn("Warning: No recorded video found. Skipping assembly.");
770
1032
  }
771
1033
  // Step 5: Split scenes and extract thumbnails
772
- if (videoPath && timingData.scenes.length > 0 && hasFfmpeg()) {
1034
+ // Skip splitting when per-scene recordings already exist — splitScenes is
1035
+ // for legacy monolithic recordings and would corrupt per-scene videos by
1036
+ // seeking with cumulative timing offsets into individual scene files.
1037
+ const hasPerSceneRecordings = sceneGroups.length > 0
1038
+ && sceneGroups.some(g => existsSync(resolve(recordingsDir, "scenes", g.id, "video.webm")));
1039
+ if (videoPath && timingData.scenes.length > 0 && hasFfmpeg() && !hasPerSceneRecordings) {
773
1040
  console.log("Splitting video into per-scene clips...\n");
774
1041
  const sceneClips = splitScenes(videoPath, timingData, recordingsDir);
775
1042
  console.log(` Split ${sceneClips.length} scene clip(s)`);
1043
+ }
1044
+ if (videoPath && timingData.scenes.length > 0 && hasFfmpeg()) {
776
1045
  console.log("Extracting thumbnails...\n");
777
1046
  const thumbnails = extractThumbnails(videoPath, timingData, recordingsDir);
778
1047
  console.log(` Extracted ${thumbnails.length} thumbnail(s)\n`);
@@ -781,7 +1050,7 @@ export function registerGenerateCommand(program) {
781
1050
  console.warn("Warning: ffmpeg not available. Skipping scene splitting and thumbnail extraction.");
782
1051
  }
783
1052
  // Step 6: Print summary
784
- printSummary(videoPath, timingPath, finalPath, timingData, voiceUsed);
1053
+ printSummary(videoPath, timingPath, finalPath, timingData, voiceUsed, assemblyResult);
785
1054
  });
786
1055
  }
787
1056
  //# sourceMappingURL=generate.js.map