demofly 0.2.8 → 0.2.9

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 (50) 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 +575 -49
  11. package/dist/commands/generate.js.map +1 -1
  12. package/dist/commands/push.d.ts.map +1 -1
  13. package/dist/commands/push.js +20 -1
  14. package/dist/commands/push.js.map +1 -1
  15. package/dist/commands/render.d.ts.map +1 -1
  16. package/dist/commands/render.js +5 -2
  17. package/dist/commands/render.js.map +1 -1
  18. package/dist/commands/tts.d.ts.map +1 -1
  19. package/dist/commands/tts.js +127 -12
  20. package/dist/commands/tts.js.map +1 -1
  21. package/dist/commands/voices/select.d.ts.map +1 -1
  22. package/dist/commands/voices/select.js +66 -11
  23. package/dist/commands/voices/select.js.map +1 -1
  24. package/dist/lib/audio-probe.d.ts +23 -0
  25. package/dist/lib/audio-probe.d.ts.map +1 -0
  26. package/dist/lib/audio-probe.js +59 -0
  27. package/dist/lib/audio-probe.js.map +1 -0
  28. package/dist/lib/demo-config.d.ts +7 -0
  29. package/dist/lib/demo-config.d.ts.map +1 -1
  30. package/dist/lib/demo-config.js +9 -0
  31. package/dist/lib/demo-config.js.map +1 -1
  32. package/dist/lib/edit-proposals.d.ts +48 -1
  33. package/dist/lib/edit-proposals.d.ts.map +1 -1
  34. package/dist/lib/edit-proposals.js +49 -0
  35. package/dist/lib/edit-proposals.js.map +1 -1
  36. package/dist/lib/push-project.d.ts +37 -0
  37. package/dist/lib/push-project.d.ts.map +1 -0
  38. package/dist/lib/push-project.js +194 -0
  39. package/dist/lib/push-project.js.map +1 -0
  40. package/dist/lib/retiming.js +47 -0
  41. package/dist/lib/retiming.js.map +1 -1
  42. package/dist/lib/scene-assembler.d.ts +29 -0
  43. package/dist/lib/scene-assembler.d.ts.map +1 -0
  44. package/dist/lib/scene-assembler.js +202 -0
  45. package/dist/lib/scene-assembler.js.map +1 -0
  46. package/dist/lib/voice-resolver.d.ts +4 -0
  47. package/dist/lib/voice-resolver.d.ts.map +1 -1
  48. package/dist/lib/voice-resolver.js +5 -2
  49. package/dist/lib/voice-resolver.js.map +1 -1
  50. 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";
@@ -12,14 +12,18 @@ import { retimeAndAssemble } from "../lib/retiming.js";
12
12
  import { getToken } from "../lib/credentials.js";
13
13
  import { createApiClient, getAppUrl } from "../lib/api-client.js";
14
14
  import { getDefaultVoice } from "../lib/voice-config.js";
15
- import { isCloudVoice, KOKORO_VOICES, KOKORO_DEFAULT_VOICE, resolveVoiceFromFlags, } from "../lib/voice-resolver.js";
15
+ import { getDemoVoicePreference, setDemoVoicePreference } from "../lib/demo-config.js";
16
+ import { isCloudVoice, KOKORO_DEFAULT_VOICE, resolveVoiceFromFlags, } from "../lib/voice-resolver.js";
16
17
  import { generateCloudAudio, getSubscriptionStatus } from "../lib/cloud-tts.js";
17
18
  function validateDemoArtifacts(demoDir, demo) {
18
- const specPath = resolve(demoDir, "demo.spec.ts");
19
19
  const configPath = resolve(demoDir, "playwright.config.ts");
20
+ const scenesDir = resolve(demoDir, "scenes");
21
+ const legacySpecPath = resolve(demoDir, "demo.spec.ts");
20
22
  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.`);
23
+ const hasScenes = existsSync(scenesDir) && readdirSync(scenesDir).some(f => f.endsWith(".spec.ts"));
24
+ const hasLegacySpec = existsSync(legacySpecPath);
25
+ if (!hasScenes && !hasLegacySpec) {
26
+ console.error(`No scene specs or demo.spec.ts found in demofly/${demo}/. Create one with /demofly:create ${demo} in Claude Code.`);
23
27
  valid = false;
24
28
  }
25
29
  if (!existsSync(configPath)) {
@@ -28,6 +32,382 @@ function validateDemoArtifacts(demoDir, demo) {
28
32
  }
29
33
  return valid;
30
34
  }
35
+ function readSceneGroups(demoDir) {
36
+ const sharedPath = resolve(demoDir, "scenes", "shared.ts");
37
+ if (!existsSync(sharedPath))
38
+ return null;
39
+ const content = readFileSync(sharedPath, "utf-8");
40
+ const match = content.match(/export\s+const\s+SCENE_GROUPS\s*=\s*(\[[\s\S]*?\n\];)/);
41
+ if (!match)
42
+ return null;
43
+ try {
44
+ // Parse the TypeScript array literal as JSON (strip trailing commas, convert single quotes)
45
+ const jsonStr = match[1]
46
+ .replace(/'/g, '"')
47
+ .replace(/,\s*]/g, "]")
48
+ .replace(/,\s*}/g, "}")
49
+ .replace(/(\w+)\s*:/g, '"$1":');
50
+ return JSON.parse(jsonStr);
51
+ }
52
+ catch {
53
+ debug("Could not parse SCENE_GROUPS from shared.ts, falling back to file scan");
54
+ return null;
55
+ }
56
+ }
57
+ function discoverSceneGroups(demoDir) {
58
+ // Try reading from shared.ts first
59
+ const fromShared = readSceneGroups(demoDir);
60
+ if (fromShared)
61
+ return fromShared;
62
+ // Fall back to scanning scene spec files
63
+ const scenesDir = resolve(demoDir, "scenes");
64
+ if (!existsSync(scenesDir))
65
+ return [];
66
+ const specFiles = readdirSync(scenesDir)
67
+ .filter(f => f.endsWith(".spec.ts") && f.startsWith("scene-"))
68
+ .sort();
69
+ return specFiles.map(f => {
70
+ const id = f.replace(".spec.ts", "");
71
+ const scenes = id.split("-").filter(p => /^\d+$/.test(p)).map(n => `scene-${n}`);
72
+ if (scenes.length === 0)
73
+ scenes.push(id);
74
+ return { id, scenes, startUrl: "/", independent: scenes.length === 1 };
75
+ });
76
+ }
77
+ function findGroupForScene(groups, sceneId) {
78
+ return groups.find(g => g.scenes.includes(sceneId)) ?? null;
79
+ }
80
+ function runSceneGroupRecording(demoDir, demo, group, force) {
81
+ const scenesRecordingsDir = resolve(demoDir, "recordings", "scenes");
82
+ const groupVideoDir = resolve(scenesRecordingsDir, group.id);
83
+ const groupVideoPath = resolve(groupVideoDir, "video.webm");
84
+ // Skip if already recorded (unless --force)
85
+ if (!force && existsSync(groupVideoPath)) {
86
+ debug(`Skipping ${group.id}: recording exists at ${groupVideoPath}`);
87
+ return { groupId: group.id, scenes: group.scenes, status: "skipped" };
88
+ }
89
+ const specPath = `demofly/${demo}/scenes/${group.id}.spec.ts`;
90
+ const configPath = `demofly/${demo}/playwright.config.ts`;
91
+ const cmd = `npx playwright test ${specPath} --config ${configPath}`;
92
+ debug(`Recording scene group: ${cmd}`);
93
+ console.log(`\nRecording ${group.id}...`);
94
+ try {
95
+ const output = execSync(cmd, {
96
+ encoding: "utf-8",
97
+ timeout: 600_000,
98
+ stdio: ["pipe", "pipe", "pipe"],
99
+ cwd: resolve(demoDir, "..", ".."),
100
+ });
101
+ // Extract timing and save
102
+ const timingData = parseTimingMarkers(output);
103
+ mkdirSync(groupVideoDir, { recursive: true });
104
+ // Find and move video
105
+ const videoPath = findRecordedVideo(demoDir);
106
+ if (videoPath) {
107
+ execSync(`cp "${videoPath}" "${groupVideoPath}"`);
108
+ }
109
+ // Write timing data for the group
110
+ writeFileSync(resolve(groupVideoDir, "timing.json"), JSON.stringify(timingData, null, 2), "utf-8");
111
+ // If this is a multi-scene group, split into per-scene clips and timing
112
+ if (group.scenes.length > 1 && videoPath) {
113
+ splitGroupIntoScenes(groupVideoPath, timingData, scenesRecordingsDir, group);
114
+ }
115
+ else if (group.scenes.length === 1 && group.scenes[0] !== group.id) {
116
+ // Single scene with different scene name — symlink/copy
117
+ const sceneDir = resolve(scenesRecordingsDir, group.scenes[0]);
118
+ mkdirSync(sceneDir, { recursive: true });
119
+ execSync(`cp "${groupVideoPath}" "${resolve(sceneDir, "video.webm")}"`);
120
+ writeFileSync(resolve(sceneDir, "timing.json"), JSON.stringify(timingData, null, 2), "utf-8");
121
+ }
122
+ const durationMs = timingData.totalDuration;
123
+ console.log(` ${group.id}: recorded (${formatDuration(durationMs)})`);
124
+ return {
125
+ groupId: group.id,
126
+ scenes: group.scenes,
127
+ status: "success",
128
+ videoPath: groupVideoPath,
129
+ durationMs,
130
+ };
131
+ }
132
+ catch (error) {
133
+ const execError = error;
134
+ const errMsg = execError.stderr ?? execError.stdout ?? "Unknown error";
135
+ console.error(` ${group.id}: FAILED`);
136
+ debug(`Recording failed for ${group.id}: ${errMsg}`);
137
+ return {
138
+ groupId: group.id,
139
+ scenes: group.scenes,
140
+ status: "failed",
141
+ error: errMsg.slice(0, 500),
142
+ };
143
+ }
144
+ }
145
+ function splitGroupIntoScenes(groupVideoPath, timingData, scenesRecordingsDir, group) {
146
+ for (const sceneId of group.scenes) {
147
+ const sceneTimingData = extractSceneTiming(timingData, sceneId);
148
+ if (!sceneTimingData)
149
+ continue;
150
+ const sceneDir = resolve(scenesRecordingsDir, sceneId);
151
+ mkdirSync(sceneDir, { recursive: true });
152
+ // Split video for this scene
153
+ const startSec = sceneTimingData.scenes[0].startMs / 1000;
154
+ const durationSec = (sceneTimingData.scenes[0].endMs - sceneTimingData.scenes[0].startMs) / 1000;
155
+ const outputPath = resolve(sceneDir, "video.webm");
156
+ // Use a temp file when input and output are the same (single-scene groups)
157
+ const sameFile = resolve(groupVideoPath) === resolve(outputPath);
158
+ const writePath = sameFile ? resolve(sceneDir, "video.trimmed.webm") : outputPath;
159
+ try {
160
+ execSync(`ffmpeg -y -i "${groupVideoPath}" -ss ${startSec} -t ${durationSec} -c copy "${writePath}"`, { encoding: "utf-8", timeout: 300_000, stdio: ["pipe", "pipe", "pipe"] });
161
+ if (sameFile) {
162
+ renameSync(writePath, outputPath);
163
+ }
164
+ }
165
+ catch (err) {
166
+ const execError = err;
167
+ console.warn(` Warning: Failed to split ${sceneId} from group ${group.id}`);
168
+ if (execError.stderr)
169
+ debug(execError.stderr);
170
+ }
171
+ // Write scene-relative timing
172
+ writeFileSync(resolve(sceneDir, "timing.json"), JSON.stringify(sceneTimingData, null, 2), "utf-8");
173
+ }
174
+ }
175
+ function extractSceneTiming(timingData, sceneId) {
176
+ const scene = timingData.scenes.find(s => s.sceneId === sceneId);
177
+ if (!scene)
178
+ return null;
179
+ const sceneStartMs = scene.startMs;
180
+ return {
181
+ totalDuration: scene.endMs - scene.startMs,
182
+ scenes: [{
183
+ sceneId: scene.sceneId,
184
+ startMs: 0,
185
+ endMs: scene.endMs - sceneStartMs,
186
+ markers: scene.markers.map(m => ({
187
+ ...m,
188
+ ms: m.ms - sceneStartMs,
189
+ })),
190
+ }],
191
+ };
192
+ }
193
+ function buildCombinedTimingData(demoDir, groups) {
194
+ const allScenes = [];
195
+ let cumulativeMs = 0;
196
+ for (const group of groups) {
197
+ for (const sceneId of group.scenes) {
198
+ const sceneTimingPath = resolve(demoDir, "recordings", "scenes", sceneId, "timing.json");
199
+ if (!existsSync(sceneTimingPath)) {
200
+ // Also check group-level timing
201
+ const groupTimingPath = resolve(demoDir, "recordings", "scenes", group.id, "timing.json");
202
+ if (existsSync(groupTimingPath)) {
203
+ try {
204
+ const groupTiming = normalizeTimingData(JSON.parse(readFileSync(groupTimingPath, "utf-8")));
205
+ const sceneData = groupTiming.scenes.find(s => s.sceneId === sceneId);
206
+ if (sceneData) {
207
+ allScenes.push({
208
+ ...sceneData,
209
+ startMs: cumulativeMs,
210
+ endMs: cumulativeMs + (sceneData.endMs - sceneData.startMs),
211
+ markers: sceneData.markers.map(m => ({
212
+ ...m,
213
+ ms: cumulativeMs + m.ms - sceneData.startMs,
214
+ })),
215
+ });
216
+ cumulativeMs += sceneData.endMs - sceneData.startMs;
217
+ }
218
+ }
219
+ catch { /* skip */ }
220
+ }
221
+ continue;
222
+ }
223
+ try {
224
+ const sceneTiming = normalizeTimingData(JSON.parse(readFileSync(sceneTimingPath, "utf-8")));
225
+ for (const scene of sceneTiming.scenes) {
226
+ allScenes.push({
227
+ ...scene,
228
+ startMs: cumulativeMs + scene.startMs,
229
+ endMs: cumulativeMs + scene.endMs,
230
+ markers: scene.markers.map(m => ({
231
+ ...m,
232
+ ms: cumulativeMs + m.ms,
233
+ })),
234
+ });
235
+ }
236
+ cumulativeMs += sceneTiming.totalDuration;
237
+ }
238
+ catch { /* skip */ }
239
+ }
240
+ }
241
+ return { totalDuration: cumulativeMs, scenes: allScenes };
242
+ }
243
+ function collectSceneClips(demoDir, groups) {
244
+ const clips = [];
245
+ for (const group of groups) {
246
+ for (const sceneId of group.scenes) {
247
+ const sceneDir = resolve(demoDir, "recordings", "scenes", sceneId);
248
+ const videoPath = resolve(sceneDir, "video.webm");
249
+ const timingPath = resolve(sceneDir, "timing.json");
250
+ if (!existsSync(videoPath))
251
+ continue;
252
+ let durationMs = 0;
253
+ let trimStartMs = 0;
254
+ if (existsSync(timingPath)) {
255
+ try {
256
+ const timing = normalizeTimingData(JSON.parse(readFileSync(timingPath, "utf-8")));
257
+ const firstScene = timing.scenes[0];
258
+ if (firstScene) {
259
+ trimStartMs = firstScene.startMs;
260
+ durationMs = firstScene.endMs - firstScene.startMs;
261
+ }
262
+ else {
263
+ durationMs = timing.totalDuration;
264
+ }
265
+ }
266
+ catch { /* use 0 */ }
267
+ }
268
+ clips.push({
269
+ sceneId,
270
+ videoPath,
271
+ durationMs,
272
+ trimStartMs,
273
+ transition: "hard_cut", // default; could be overridden from narration metadata
274
+ });
275
+ }
276
+ }
277
+ return clips;
278
+ }
279
+ function assembleFromSceneClips(clips, audioMatches, recordingsDir, applyWatermark = false) {
280
+ if (clips.length === 0)
281
+ return null;
282
+ const outputPath = resolve(recordingsDir, "final.mp4");
283
+ const inputArgs = [];
284
+ const filterParts = [];
285
+ // Add all video inputs
286
+ for (const clip of clips) {
287
+ inputArgs.push("-i", clip.videoPath);
288
+ }
289
+ // Add audio inputs
290
+ let cumulativeMs = 0;
291
+ for (let i = 0; i < clips.length; i++) {
292
+ const clip = clips[i];
293
+ const audioMatch = audioMatches.find(a => a.sceneId === clip.sceneId);
294
+ if (audioMatch) {
295
+ inputArgs.push("-i", audioMatch.filePath);
296
+ const audioIdx = inputArgs.filter(a => a === "-i").length - 1;
297
+ filterParts.push(`[${audioIdx}:a]adelay=${cumulativeMs}|${cumulativeMs}[a${audioIdx}]`);
298
+ }
299
+ cumulativeMs += clip.durationMs;
300
+ }
301
+ // Build video concatenation filter
302
+ // Trim white screen from start of each clip using timing data
303
+ if (clips.length === 1) {
304
+ const clip = clips[0];
305
+ if (clip.trimStartMs > 0) {
306
+ const trimSec = clip.trimStartMs / 1000;
307
+ const durSec = clip.durationMs / 1000;
308
+ filterParts.push(`[0:v]trim=start=${trimSec}:duration=${durSec},setpts=PTS-STARTPTS[vconcat]`);
309
+ }
310
+ }
311
+ else {
312
+ // Scale and trim all inputs to consistent format for concat
313
+ const scaledParts = [];
314
+ for (let i = 0; i < clips.length; i++) {
315
+ const clip = clips[i];
316
+ if (clip.trimStartMs > 0) {
317
+ const trimSec = clip.trimStartMs / 1000;
318
+ const durSec = clip.durationMs / 1000;
319
+ scaledParts.push(`[${i}:v]trim=start=${trimSec}:duration=${durSec},setpts=PTS-STARTPTS[v${i}]`);
320
+ }
321
+ else {
322
+ scaledParts.push(`[${i}:v]setpts=PTS-STARTPTS[v${i}]`);
323
+ }
324
+ }
325
+ filterParts.push(...scaledParts);
326
+ // Apply transitions between clips
327
+ let currentLabel = "v0";
328
+ for (let i = 1; i < clips.length; i++) {
329
+ const transition = clips[i].transition;
330
+ const nextLabel = i === clips.length - 1 ? "vconcat" : `vt${i}`;
331
+ if (transition === "crossfade") {
332
+ filterParts.push(`[${currentLabel}][v${i}]xfade=transition=fade:duration=0.3:offset=${(clips[i - 1].durationMs - 300) / 1000}[${nextLabel}]`);
333
+ }
334
+ else if (transition === "black_gap") {
335
+ // Insert a 0.2s black frame between clips
336
+ filterParts.push(`color=c=black:s=1280x800:d=0.2[black${i}]`);
337
+ filterParts.push(`[${currentLabel}][black${i}][v${i}]concat=n=3:v=1:a=0[${nextLabel}]`);
338
+ }
339
+ else {
340
+ // hard_cut — simple concat
341
+ filterParts.push(`[${currentLabel}][v${i}]concat=n=2:v=1:a=0[${nextLabel}]`);
342
+ }
343
+ currentLabel = nextLabel;
344
+ }
345
+ }
346
+ // Build audio mix
347
+ const audioFilterParts = filterParts.filter(p => p.includes("adelay"));
348
+ const audioLabels = audioFilterParts.map(p => {
349
+ const match = p.match(/\[a(\d+)\]$/);
350
+ return match ? `[a${match[1]}]` : "";
351
+ }).filter(Boolean);
352
+ let audioMap = "";
353
+ if (audioLabels.length > 0) {
354
+ filterParts.push(`${audioLabels.join("")}amix=inputs=${audioLabels.length}:normalize=0[aout]`);
355
+ audioMap = "[aout]";
356
+ }
357
+ // Add watermark if needed
358
+ let videoMap = "[vconcat]";
359
+ const watermarkPath = applyWatermark ? getWatermarkPath() : null;
360
+ if (watermarkPath && existsSync(watermarkPath)) {
361
+ inputArgs.push("-i", watermarkPath);
362
+ const wmIdx = inputArgs.filter(a => a === "-i").length - 1;
363
+ filterParts.push(`[${wmIdx}:v]scale=120:-1,format=rgba,colorchannelmixer=aa=0.6[wm]`, `[vconcat][wm]overlay=W-w-24:H-h-16[vout]`);
364
+ videoMap = "[vout]";
365
+ }
366
+ const mapArgs = ["-map", videoMap];
367
+ if (audioMap) {
368
+ mapArgs.push("-map", audioMap);
369
+ }
370
+ const args = [
371
+ "ffmpeg", "-y",
372
+ ...inputArgs,
373
+ "-filter_complex", `"${filterParts.join("; ")}"`,
374
+ ...mapArgs,
375
+ "-c:v", "libx264", "-preset", "fast", "-crf", "23", "-pix_fmt", "yuv420p",
376
+ "-shortest",
377
+ outputPath,
378
+ ];
379
+ console.log("Assembling from per-scene clips...\n");
380
+ try {
381
+ execSync(args.join(" "), {
382
+ encoding: "utf-8",
383
+ timeout: 600_000,
384
+ stdio: ["pipe", "pipe", "pipe"],
385
+ });
386
+ return outputPath;
387
+ }
388
+ catch (error) {
389
+ const execError = error;
390
+ console.error("ffmpeg assembly from per-scene clips failed:");
391
+ if (execError.stderr) {
392
+ debug(execError.stderr);
393
+ }
394
+ return null;
395
+ }
396
+ }
397
+ function printSceneResults(results) {
398
+ console.log("\n--- Per-Scene Recording Results ---\n");
399
+ console.log(" Scene Group Status Duration");
400
+ console.log(" ───────────── ──────── ────────");
401
+ for (const r of results) {
402
+ const status = r.status === "success" ? "OK" : r.status === "skipped" ? "skipped" : "FAILED";
403
+ const duration = r.durationMs ? formatDuration(r.durationMs) : "-";
404
+ console.log(` ${r.groupId.padEnd(16)}${status.padEnd(11)}${duration}`);
405
+ }
406
+ const succeeded = results.filter(r => r.status === "success").length;
407
+ const skipped = results.filter(r => r.status === "skipped").length;
408
+ const failed = results.filter(r => r.status === "failed").length;
409
+ console.log(`\n Total: ${succeeded} recorded, ${skipped} skipped, ${failed} failed\n`);
410
+ }
31
411
  function runPlaywrightTest(demoDir, demo) {
32
412
  const specPath = `demofly/${demo}/demo.spec.ts`;
33
413
  const configPath = `demofly/${demo}/playwright.config.ts`;
@@ -155,12 +535,10 @@ function buildFfmpegCommand(videoPath, audioMatches, timingData, outputPath, app
155
535
  // Build video filter chain with optional watermark overlay
156
536
  let videoMap = "0:v";
157
537
  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
538
+ // Scale watermark to fixed 120px wide, position bottom-right with padding
160
539
  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]`;
540
+ `; [${watermarkInputIndex}:v]scale=120:-1,format=rgba,colorchannelmixer=aa=0.6[wm]` +
541
+ `; [0:v][wm]overlay=W-w-24:H-h-16[vout]`;
164
542
  videoMap = "[vout]";
165
543
  }
166
544
  const args = [
@@ -216,8 +594,14 @@ export function splitScenes(videoPath, timingData, recordingsDir) {
216
594
  const startSec = scene.startMs / 1000;
217
595
  const durationSec = (scene.endMs - scene.startMs) / 1000;
218
596
  const outputPath = resolve(sceneDir, "video.webm");
597
+ // Use a temp file when input and output are the same path
598
+ const sameFile = resolve(videoPath) === resolve(outputPath);
599
+ const writePath = sameFile ? resolve(sceneDir, "video.trimmed.webm") : outputPath;
219
600
  try {
220
- execSync(`ffmpeg -y -i "${videoPath}" -ss ${startSec} -t ${durationSec} -c copy "${outputPath}"`, { encoding: "utf-8", timeout: 300_000, stdio: ["pipe", "pipe", "pipe"] });
601
+ execSync(`ffmpeg -y -i "${videoPath}" -ss ${startSec} -t ${durationSec} -c copy "${writePath}"`, { encoding: "utf-8", timeout: 300_000, stdio: ["pipe", "pipe", "pipe"] });
602
+ if (sameFile) {
603
+ renameSync(writePath, outputPath);
604
+ }
221
605
  outputs.push(outputPath);
222
606
  }
223
607
  catch (error) {
@@ -396,11 +780,13 @@ function shouldRegenerateAudio(projectDir, resolvedVoice, explicitVoiceFlag, tim
396
780
  return true;
397
781
  return meta.name !== resolvedVoice.name || meta.provider !== resolvedVoice.provider;
398
782
  }
399
- function resolveVoiceForGenerate(voiceName, providerName) {
783
+ function resolveVoiceForGenerate(voiceName, providerName, projectDir) {
784
+ const demoVoicePreference = projectDir ? getDemoVoicePreference(projectDir) : null;
400
785
  return resolveVoiceFromFlags(voiceName, providerName, {
401
786
  getDefaultVoice,
402
787
  getToken,
403
788
  createApiClient,
789
+ demoVoicePreference,
404
790
  });
405
791
  }
406
792
  async function generateAudioForProject(narrationPath, projectDir, voice, speed, sceneFilter) {
@@ -419,23 +805,31 @@ async function generateAudioForProject(narrationPath, projectDir, voice, speed,
419
805
  }
420
806
  if (results.length > 0) {
421
807
  writeVoiceMetadata(projectDir, actualVoice);
808
+ // Auto-set demo voice preference on first generation
809
+ if (!getDemoVoicePreference(projectDir)) {
810
+ setDemoVoicePreference(projectDir, { name: actualVoice.name, provider: actualVoice.provider });
811
+ }
422
812
  }
423
813
  return { ttsResults: results, actualVoice };
424
814
  }
425
815
  async function generateCloudTtsInline(narrationPath, projectDir, voice, speed, sceneFilter) {
426
816
  const token = await getToken();
427
817
  if (!token) {
428
- return fallbackToKokoro(narrationPath, projectDir, speed, "Not authenticated.", sceneFilter);
818
+ console.error("Not authenticated. Premium voices require authentication.");
819
+ console.error("Run `demofly auth login` to authenticate, or use a free voice.\n");
820
+ process.exit(1);
429
821
  }
430
822
  const api = createApiClient(token);
431
823
  try {
432
824
  const sub = await getSubscriptionStatus(api);
433
825
  if (sub.plan !== "pro") {
434
- return fallbackToKokoro(narrationPath, projectDir, speed, `Premium voices require a Pro subscription. Upgrade at ${getAppUrl()}/upgrade`, sceneFilter);
826
+ console.error(`Premium voices require a Pro subscription. Upgrade at ${getAppUrl()}/upgrade`);
827
+ process.exit(1);
435
828
  }
436
829
  }
437
830
  catch {
438
- return fallbackToKokoro(narrationPath, projectDir, speed, "Cloud TTS unavailable.", sceneFilter);
831
+ console.error("Could not verify subscription status. Cloud TTS unavailable.");
832
+ process.exit(1);
439
833
  }
440
834
  const content = readFileSync(narrationPath, "utf-8");
441
835
  let scenes = parseTranscript(content);
@@ -444,10 +838,42 @@ async function generateCloudTtsInline(narrationPath, projectDir, voice, speed, s
444
838
  }
445
839
  if (scenes.length === 0)
446
840
  return { ttsResults: [], actualVoice: voice };
841
+ // Credit pre-check: estimate total cost before starting
842
+ const allText = scenes.map(s => s.text).join(" ");
843
+ const operationType = `${voice.provider}-tts`;
844
+ try {
845
+ const estimate = await api.post("/credits/estimate", { operation: operationType, input: { text: allText } });
846
+ if (!estimate.sufficient) {
847
+ console.log(`\nThis demo needs ~${estimate.estimatedCredits} credits but you have ${estimate.available}.`);
848
+ if (process.stdin.isTTY) {
849
+ const useFree = await promptYesNo(`Generate with free voice (${getDefaultFreeVoice().name}) instead?`);
850
+ if (useFree) {
851
+ console.log();
852
+ const freeVoice = getDefaultFreeVoice();
853
+ const ttsResults = generateAllAudio(narrationPath, projectDir, {
854
+ voice: freeVoice.providerId,
855
+ speed,
856
+ sceneFilter,
857
+ });
858
+ return { ttsResults, actualVoice: freeVoice };
859
+ }
860
+ }
861
+ else {
862
+ console.log(`Run interactively to switch to a free voice, or use --voice heart.`);
863
+ }
864
+ console.log(`\nUse \`demofly voices select ${basename(projectDir)}\` to choose a different voice, or add credits at ${getAppUrl()}`);
865
+ process.exit(1);
866
+ }
867
+ }
868
+ catch {
869
+ // Credit API unreachable — warn and proceed optimistically
870
+ console.warn("Warning: Could not verify credit balance. Proceeding with generation.\n");
871
+ }
447
872
  const audioDir = resolve(projectDir, "audio");
448
873
  if (!existsSync(audioDir))
449
874
  mkdirSync(audioDir, { recursive: true });
450
875
  const results = [];
876
+ const generatedFiles = [];
451
877
  for (const scene of scenes) {
452
878
  console.log(` TTS: ${scene.sceneId} (${scene.text.length} chars)`);
453
879
  try {
@@ -456,27 +882,35 @@ async function generateCloudTtsInline(narrationPath, projectDir, voice, speed, s
456
882
  const elapsedS = (performance.now() - start) / 1000;
457
883
  const outputPath = resolve(audioDir, `${scene.sceneId}.mp3`);
458
884
  writeFileSync(outputPath, Buffer.from(audio));
885
+ generatedFiles.push(outputPath);
459
886
  results.push({ sceneId: scene.sceneId, filePath: outputPath, durationS, peakMemoryMb: 0, elapsedS });
460
887
  console.log(` -> ${durationS.toFixed(1)}s audio, ${elapsedS.toFixed(1)}s elapsed`);
461
888
  }
462
889
  catch (err) {
463
- if (err.status === 403) {
464
- return fallbackToKokoro(narrationPath, projectDir, speed, `Premium voices require a Pro subscription. Upgrade at ${getAppUrl()}/upgrade`, sceneFilter);
890
+ // Clean up audio files generated during this failed run
891
+ for (const file of generatedFiles) {
892
+ try {
893
+ unlinkSync(file);
894
+ }
895
+ catch { }
465
896
  }
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);
897
+ if (err.status === 402) {
898
+ console.error(`\nRan out of credits during generation at ${scene.sceneId}. No audio was produced.`);
899
+ }
900
+ else if (err.status === 403) {
901
+ console.error(`\nPremium voices require a Pro subscription. Upgrade at ${getAppUrl()}/upgrade`);
902
+ }
903
+ else {
904
+ console.error(`\nPremium voice generation failed for ${scene.sceneId}: ${err.message}`);
905
+ }
906
+ console.error(`Re-run with a free voice (--voice heart) or use \`demofly voices select ${basename(projectDir)}\` to change voice.\n`);
907
+ process.exit(1);
468
908
  }
469
909
  }
470
910
  return { ttsResults: results, actualVoice: voice };
471
911
  }
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 };
912
+ function getDefaultFreeVoice() {
913
+ return KOKORO_DEFAULT_VOICE;
480
914
  }
481
915
  export function registerGenerateCommand(program) {
482
916
  program
@@ -488,7 +922,8 @@ export function registerGenerateCommand(program) {
488
922
  .option("--align", "Produce alignment.json from timestamps + timing data")
489
923
  .option("--assemble", "Full intelligent assembly (align + edit proposals + retiming)")
490
924
  .option("--video", "Record video only (skip audio generation)")
491
- .option("--scene <id>", "Generate audio for a specific scene only")
925
+ .option("--scene <id>", "Record or generate audio for a specific scene only")
926
+ .option("--force", "Re-record scenes even if recordings exist")
492
927
  .option("--voice <name>", "TTS voice name")
493
928
  .option("--provider <provider>", "TTS provider (e.g., elevenlabs, openai, kokoro)")
494
929
  .option("--speed <multiplier>", "TTS speed multiplier", "1.0")
@@ -517,7 +952,7 @@ export function registerGenerateCommand(program) {
517
952
  process.exit(1);
518
953
  }
519
954
  const speed = parseFloat(opts.speed);
520
- const voice = await resolveVoiceForGenerate(opts.voice, opts.provider);
955
+ const voice = await resolveVoiceForGenerate(opts.voice, opts.provider, projectDir);
521
956
  const { ttsResults, actualVoice } = await generateAudioForProject(sourcePath, projectDir, voice, speed, opts.scene);
522
957
  console.log("\n--- demofly generate --audio summary ---\n");
523
958
  console.log(` Voice: ${actualVoice.name} (${actualVoice.provider})`);
@@ -563,7 +998,31 @@ export function registerGenerateCommand(program) {
563
998
  }
564
999
  // Video-only mode: record and exit
565
1000
  if (opts.video) {
566
- debug("Video-only mode: running Playwright test");
1001
+ const sceneGroups = discoverSceneGroups(projectDir);
1002
+ if (sceneGroups.length > 0) {
1003
+ // Per-scene recording
1004
+ debug("Video-only mode: per-scene recording");
1005
+ let groupsToRecord = sceneGroups;
1006
+ if (opts.scene) {
1007
+ const group = findGroupForScene(sceneGroups, opts.scene);
1008
+ if (!group) {
1009
+ console.error(`Scene "${opts.scene}" not found in any scene group.`);
1010
+ process.exit(1);
1011
+ }
1012
+ groupsToRecord = [group];
1013
+ }
1014
+ const results = [];
1015
+ for (const group of groupsToRecord) {
1016
+ results.push(runSceneGroupRecording(projectDir, demo, group, opts.force ?? false));
1017
+ }
1018
+ printSceneResults(results);
1019
+ const anyFailed = results.some(r => r.status === "failed");
1020
+ if (anyFailed)
1021
+ process.exit(2);
1022
+ return;
1023
+ }
1024
+ // Legacy monolithic recording
1025
+ debug("Video-only mode: running Playwright test (legacy)");
567
1026
  const result = recordDemo(projectDir, demo);
568
1027
  const videoPath = result.videoPath
569
1028
  ? resolve(recordingsDir, "video.webm")
@@ -582,27 +1041,64 @@ export function registerGenerateCommand(program) {
582
1041
  let timingPath;
583
1042
  // Step 2: Record or use existing artifacts
584
1043
  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.");
1044
+ const sceneGroups = discoverSceneGroups(projectDir);
1045
+ if (sceneGroups.length > 0) {
1046
+ // Per-scene recording mode
1047
+ debug("Recording mode: per-scene recording");
1048
+ let groupsToRecord = sceneGroups;
1049
+ if (opts.scene) {
1050
+ const group = findGroupForScene(sceneGroups, opts.scene);
1051
+ if (!group) {
1052
+ console.error(`Scene "${opts.scene}" not found in any scene group.`);
1053
+ process.exit(1);
1054
+ }
1055
+ groupsToRecord = [group];
1056
+ }
1057
+ const results = [];
1058
+ for (const group of groupsToRecord) {
1059
+ results.push(runSceneGroupRecording(projectDir, demo, group, opts.force ?? false));
1060
+ }
1061
+ printSceneResults(results);
1062
+ const anyFailed = results.some(r => r.status === "failed");
1063
+ // Build combined timing data from per-scene recordings
1064
+ timingData = buildCombinedTimingData(projectDir, sceneGroups);
1065
+ timingPath = resolve(recordingsDir, "timing.json");
1066
+ writeTimingJson(recordingsDir, timingData);
1067
+ // Use the first successful scene's parent video or look for combined
1068
+ const firstSuccess = results.find(r => r.status === "success");
1069
+ videoPath = firstSuccess?.videoPath ?? null;
1070
+ if (anyFailed) {
1071
+ console.warn("Some scenes failed to record. Assembly will use available clips.\n");
1072
+ }
1073
+ }
1074
+ else {
1075
+ // Legacy monolithic recording
1076
+ debug("Recording mode: running Playwright test (legacy)");
1077
+ const result = recordDemo(projectDir, demo);
1078
+ videoPath = result.videoPath
1079
+ ? resolve(recordingsDir, "video.webm")
1080
+ : null;
1081
+ const stdout = result.stdout;
1082
+ timingData = parseTimingMarkers(stdout);
1083
+ timingPath = resolve(recordingsDir, "timing.json");
1084
+ if (timingData.scenes.length === 0) {
1085
+ console.warn("Warning: No DEMOFLY timing markers found in test output. Video was still recorded.");
1086
+ }
595
1087
  }
596
1088
  }
597
1089
  else {
598
1090
  // Assembler-first: look for existing artifacts
599
1091
  const existingVideo = resolve(recordingsDir, "video.webm");
600
1092
  const existingTiming = resolve(recordingsDir, "timing.json");
1093
+ // Check for video: either legacy combined video or per-scene clips
601
1094
  const hasVideo = existsSync(existingVideo);
1095
+ const scenesDir = resolve(recordingsDir, "scenes");
1096
+ const hasSceneClips = existsSync(scenesDir) && readdirSync(scenesDir, { withFileTypes: true })
1097
+ .some(d => d.isDirectory() && existsSync(resolve(scenesDir, d.name, "video.webm")));
602
1098
  const hasTiming = existsSync(existingTiming);
603
- if (!hasVideo || !hasTiming) {
1099
+ if ((!hasVideo && !hasSceneClips) || !hasTiming) {
604
1100
  const missingParts = [];
605
- if (!hasVideo)
1101
+ if (!hasVideo && !hasSceneClips)
606
1102
  missingParts.push("recordings/video.webm");
607
1103
  if (!hasTiming)
608
1104
  missingParts.push("recordings/timing.json");
@@ -637,7 +1133,7 @@ export function registerGenerateCommand(program) {
637
1133
  }
638
1134
  // If we didn't record (existing artifacts found), load from disk
639
1135
  if (!videoPath) {
640
- videoPath = existingVideo;
1136
+ videoPath = hasVideo ? existingVideo : null; // null when only scene clips exist
641
1137
  timingData = normalizeTimingData(JSON.parse(readFileSync(existingTiming, "utf-8")));
642
1138
  timingPath = existingTiming;
643
1139
  }
@@ -660,7 +1156,7 @@ export function registerGenerateCommand(program) {
660
1156
  const narrationSourcePath = resolve(projectDir, "narration.md");
661
1157
  if (opts.audio !== false && existsSync(narrationSourcePath)) {
662
1158
  const speed = parseFloat(opts.speed);
663
- const voice = await resolveVoiceForGenerate(opts.voice, opts.provider);
1159
+ const voice = await resolveVoiceForGenerate(opts.voice, opts.provider, projectDir);
664
1160
  const explicitVoiceFlag = !!(opts.voice || opts.provider);
665
1161
  if (opts.scene || shouldRegenerateAudio(projectDir, voice, explicitVoiceFlag, timingData)) {
666
1162
  const { ttsResults, actualVoice } = await generateAudioForProject(narrationSourcePath, projectDir, voice, speed, opts.scene);
@@ -696,6 +1192,11 @@ export function registerGenerateCommand(program) {
696
1192
  const { matched, missing } = findAudioFiles(projectDir, timingData);
697
1193
  debug(`Audio files: ${matched.length} matched, ${missing.length} missing`);
698
1194
  let finalPath = null;
1195
+ // Check for per-scene clips
1196
+ const sceneGroups = discoverSceneGroups(projectDir);
1197
+ const sceneClips = sceneGroups.length > 0
1198
+ ? collectSceneClips(projectDir, sceneGroups)
1199
+ : [];
699
1200
  if (matched.length > 0) {
700
1201
  if (!hasFfmpeg()) {
701
1202
  console.error("Audio files found but ffmpeg is not installed. Install ffmpeg to stitch audio with video.");
@@ -724,16 +1225,34 @@ export function registerGenerateCommand(program) {
724
1225
  // Fall back to simple stitch if intelligent assembly failed
725
1226
  if (!finalPath) {
726
1227
  console.log(" Intelligent assembly incomplete, falling back to simple stitch.\n");
727
- finalPath = stitchAudio(videoPath, matched, timingData, recordingsDir, applyWatermark);
1228
+ if (sceneClips.length > 0) {
1229
+ finalPath = assembleFromSceneClips(sceneClips, matched, recordingsDir, applyWatermark);
1230
+ }
1231
+ else {
1232
+ finalPath = stitchAudio(videoPath, matched, timingData, recordingsDir, applyWatermark);
1233
+ }
728
1234
  }
729
1235
  }
1236
+ else if (sceneClips.length > 0) {
1237
+ // Per-scene assembly (default when scene clips exist)
1238
+ finalPath = assembleFromSceneClips(sceneClips, matched, recordingsDir, applyWatermark);
1239
+ }
730
1240
  else {
731
- // Simple stitch (default)
1241
+ // Legacy simple stitch (default)
732
1242
  finalPath = stitchAudio(videoPath, matched, timingData, recordingsDir, applyWatermark);
733
1243
  }
734
1244
  }
1245
+ else if (sceneClips.length > 0) {
1246
+ // Per-scene clips but no audio — concatenate video only
1247
+ if (hasFfmpeg()) {
1248
+ finalPath = assembleFromSceneClips(sceneClips, [], recordingsDir, applyWatermark);
1249
+ }
1250
+ else {
1251
+ console.warn("Warning: ffmpeg not available. Cannot assemble per-scene clips.");
1252
+ }
1253
+ }
735
1254
  else if (videoPath) {
736
- // No audio — copy or convert video as final output
1255
+ // No audio — copy or convert single video as final output
737
1256
  if (hasFfmpeg()) {
738
1257
  const outputPath = resolve(recordingsDir, "final.mp4");
739
1258
  console.log("No audio files found. Converting video to mp4...\n");
@@ -741,7 +1260,7 @@ export function registerGenerateCommand(program) {
741
1260
  const watermarkPath = getWatermarkPath();
742
1261
  if (applyWatermark && existsSync(watermarkPath)) {
743
1262
  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]" ` +
1263
+ `-filter_complex "[1:v]scale=120:-1,format=rgba,colorchannelmixer=aa=0.6[wm];[0:v][wm]overlay=W-w-24:H-h-16[vout]" ` +
745
1264
  `-map "[vout]" -c:v libx264 -preset fast -crf 23 -pix_fmt yuv420p "${outputPath}"`, { encoding: "utf-8", timeout: 600_000, stdio: ["pipe", "pipe", "pipe"] });
746
1265
  }
747
1266
  else {
@@ -769,10 +1288,17 @@ export function registerGenerateCommand(program) {
769
1288
  console.warn("Warning: No recorded video found. Skipping assembly.");
770
1289
  }
771
1290
  // Step 5: Split scenes and extract thumbnails
772
- if (videoPath && timingData.scenes.length > 0 && hasFfmpeg()) {
1291
+ // Skip splitting when per-scene recordings already exist — splitScenes is
1292
+ // for legacy monolithic recordings and would corrupt per-scene videos by
1293
+ // seeking with cumulative timing offsets into individual scene files.
1294
+ const hasPerSceneRecordings = sceneGroups.length > 0
1295
+ && sceneGroups.some(g => existsSync(resolve(recordingsDir, "scenes", g.id, "video.webm")));
1296
+ if (videoPath && timingData.scenes.length > 0 && hasFfmpeg() && !hasPerSceneRecordings) {
773
1297
  console.log("Splitting video into per-scene clips...\n");
774
1298
  const sceneClips = splitScenes(videoPath, timingData, recordingsDir);
775
1299
  console.log(` Split ${sceneClips.length} scene clip(s)`);
1300
+ }
1301
+ if (videoPath && timingData.scenes.length > 0 && hasFfmpeg()) {
776
1302
  console.log("Extracting thumbnails...\n");
777
1303
  const thumbnails = extractThumbnails(videoPath, timingData, recordingsDir);
778
1304
  console.log(` Extracted ${thumbnails.length} thumbnail(s)\n`);