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.
- 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/open.d.ts.map +1 -1
- package/dist/commands/demos/open.js +2 -1
- package/dist/commands/demos/open.js.map +1 -1
- package/dist/commands/generate.d.ts.map +1 -1
- package/dist/commands/generate.js +467 -198
- package/dist/commands/generate.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +1 -15
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/push.d.ts.map +1 -1
- package/dist/commands/push.js +20 -1
- 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/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/checks.d.ts.map +1 -1
- package/dist/lib/checks.js +51 -16
- package/dist/lib/checks.js.map +1 -1
- 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/retiming.js +47 -0
- package/dist/lib/retiming.js.map +1 -1
- package/dist/lib/scene-assembler.d.ts +32 -0
- package/dist/lib/scene-assembler.d.ts.map +1 -0
- package/dist/lib/scene-assembler.js +185 -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";
|
|
@@ -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 {
|
|
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
|
-
|
|
22
|
-
|
|
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 "${
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
467
|
-
|
|
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
|
|
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 };
|
|
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>", "
|
|
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
|
-
|
|
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
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
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:
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
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
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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
|
-
|
|
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
|