demofly 0.2.7 → 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.
- package/README.md +154 -0
- package/dist/commands/analyze.d.ts +3 -0
- package/dist/commands/analyze.d.ts.map +1 -0
- package/dist/commands/analyze.js +163 -0
- package/dist/commands/analyze.js.map +1 -0
- package/dist/commands/demos/get.d.ts.map +1 -1
- package/dist/commands/demos/get.js +3 -1
- package/dist/commands/demos/get.js.map +1 -1
- package/dist/commands/demos/open.d.ts.map +1 -1
- package/dist/commands/demos/open.js +3 -1
- package/dist/commands/demos/open.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +575 -49
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +50 -6
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +23 -3
- package/dist/commands/push.js.map +1 -1
- package/dist/commands/render.d.ts.map +1 -1
- package/dist/commands/render.js +5 -2
- package/dist/commands/render.js.map +1 -1
- package/dist/commands/tts.d.ts.map +1 -1
- package/dist/commands/tts.js +127 -12
- package/dist/commands/tts.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +19 -0
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/voices/select.d.ts.map +1 -1
- package/dist/commands/voices/select.js +66 -11
- package/dist/commands/voices/select.js.map +1 -1
- package/dist/lib/audio-probe.d.ts +23 -0
- package/dist/lib/audio-probe.d.ts.map +1 -0
- package/dist/lib/audio-probe.js +59 -0
- package/dist/lib/audio-probe.js.map +1 -0
- package/dist/lib/demo-config.d.ts +7 -0
- package/dist/lib/demo-config.d.ts.map +1 -1
- package/dist/lib/demo-config.js +9 -0
- package/dist/lib/demo-config.js.map +1 -1
- package/dist/lib/edit-proposals.d.ts +48 -1
- package/dist/lib/edit-proposals.d.ts.map +1 -1
- package/dist/lib/edit-proposals.js +49 -0
- package/dist/lib/edit-proposals.js.map +1 -1
- package/dist/lib/push-project.d.ts +37 -0
- package/dist/lib/push-project.d.ts.map +1 -0
- package/dist/lib/push-project.js +194 -0
- package/dist/lib/push-project.js.map +1 -0
- package/dist/lib/resolve-demo.d.ts +9 -0
- package/dist/lib/resolve-demo.d.ts.map +1 -0
- package/dist/lib/resolve-demo.js +10 -0
- package/dist/lib/resolve-demo.js.map +1 -0
- package/dist/lib/retiming.js +47 -0
- package/dist/lib/retiming.js.map +1 -1
- package/dist/lib/scene-assembler.d.ts +29 -0
- package/dist/lib/scene-assembler.d.ts.map +1 -0
- package/dist/lib/scene-assembler.js +202 -0
- package/dist/lib/scene-assembler.js.map +1 -0
- package/dist/lib/voice-resolver.d.ts +4 -0
- package/dist/lib/voice-resolver.d.ts.map +1 -1
- package/dist/lib/voice-resolver.js +5 -2
- package/dist/lib/voice-resolver.js.map +1 -1
- 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 {
|
|
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
|
-
|
|
22
|
-
|
|
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
|
|
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]
|
|
162
|
-
`; [
|
|
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 "${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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
|
|
473
|
-
|
|
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>", "
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
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]
|
|
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
|
-
|
|
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`);
|