@t3lnet/sceneforge 1.0.4 → 1.0.6
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 +4 -4
- package/cli/cli.js +80 -0
- package/cli/commands/add-audio-to-steps.js +328 -0
- package/cli/commands/concat-final-videos.js +480 -0
- package/cli/commands/doctor.js +102 -0
- package/cli/commands/generate-voiceover.js +304 -0
- package/cli/commands/pipeline.js +314 -0
- package/cli/commands/record-demo.js +305 -0
- package/cli/commands/setup.js +218 -0
- package/cli/commands/split-video.js +236 -0
- package/cli/utils/args.js +15 -0
- package/cli/utils/media.js +81 -0
- package/cli/utils/paths.js +93 -0
- package/cli/utils/sanitize.js +19 -0
- package/package.json +6 -1
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { config as loadEnv } from "dotenv";
|
|
4
|
+
import {
|
|
5
|
+
createVoiceSynthesizer,
|
|
6
|
+
generateTimingManifest,
|
|
7
|
+
} from "@t3lnet/sceneforge";
|
|
8
|
+
import { getFlagValue, hasFlag } from "../utils/args.js";
|
|
9
|
+
import {
|
|
10
|
+
getOutputPaths,
|
|
11
|
+
resolveEnvFile,
|
|
12
|
+
resolveRoot,
|
|
13
|
+
toAbsolute,
|
|
14
|
+
} from "../utils/paths.js";
|
|
15
|
+
|
|
16
|
+
function printHelp() {
|
|
17
|
+
console.log(`
|
|
18
|
+
Generate voiceover audio from demo script JSON files using ElevenLabs
|
|
19
|
+
|
|
20
|
+
Usage:
|
|
21
|
+
sceneforge voiceover [options]
|
|
22
|
+
|
|
23
|
+
Options:
|
|
24
|
+
--demo <name> Generate voiceover for a specific demo (e.g., create-quote)
|
|
25
|
+
--script <path> Generate voiceover for a specific script JSON file
|
|
26
|
+
--all Generate voiceover for all scripts in output/scripts/
|
|
27
|
+
--list-voices List available voices from your ElevenLabs account
|
|
28
|
+
--generate-sounds Generate UI sound effects (click, hover, etc.)
|
|
29
|
+
--generate-music Generate background music tracks
|
|
30
|
+
--voice-id <id> Override the voice ID (default: ELEVENLABS_VOICE_ID env var)
|
|
31
|
+
--music-style <s> Music style: corporate, tech, calm, upbeat (default: tech)
|
|
32
|
+
--root <path> Project root (defaults to cwd)
|
|
33
|
+
--output-dir <path> Output directory (defaults to e2e/output or output)
|
|
34
|
+
--env-file <path> Environment file to load
|
|
35
|
+
--help, -h Show this help message
|
|
36
|
+
|
|
37
|
+
Environment Variables:
|
|
38
|
+
ELEVENLABS_API_KEY Your ElevenLabs API key (required)
|
|
39
|
+
ELEVENLABS_VOICE_ID Default voice ID for narration
|
|
40
|
+
|
|
41
|
+
Examples:
|
|
42
|
+
sceneforge voiceover --list-voices
|
|
43
|
+
sceneforge voiceover --demo create-quote
|
|
44
|
+
sceneforge voiceover --script output/scripts/create-quote.json
|
|
45
|
+
sceneforge voiceover --all
|
|
46
|
+
sceneforge voiceover --generate-sounds
|
|
47
|
+
sceneforge voiceover --generate-music --music-style calm
|
|
48
|
+
`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function getConfig(flags) {
|
|
52
|
+
const apiKey = process.env.ELEVENLABS_API_KEY;
|
|
53
|
+
if (!apiKey) {
|
|
54
|
+
console.error("[error] ELEVENLABS_API_KEY environment variable is required");
|
|
55
|
+
console.error("[error] Get your API key from: https://elevenlabs.io/app/settings/api-keys");
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const voiceId = flags.voiceId || process.env.ELEVENLABS_VOICE_ID;
|
|
60
|
+
if (!voiceId && !flags.listVoices && !flags.generateSounds && !flags.generateMusic) {
|
|
61
|
+
console.error("[error] Voice ID is required for voiceover generation");
|
|
62
|
+
console.error("[error] Set ELEVENLABS_VOICE_ID env var or use --voice-id flag");
|
|
63
|
+
console.error("[error] Run with --list-voices to see available voices");
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
apiKey,
|
|
69
|
+
voiceId: voiceId || "",
|
|
70
|
+
modelId: "eleven_multilingual_v2",
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function listVoices(config) {
|
|
75
|
+
console.log("\n[voice] Fetching available voices...\n");
|
|
76
|
+
|
|
77
|
+
const synthesizer = createVoiceSynthesizer(config);
|
|
78
|
+
const voices = await synthesizer.listVoices();
|
|
79
|
+
|
|
80
|
+
console.log("Available Voices:");
|
|
81
|
+
console.log("─".repeat(70));
|
|
82
|
+
|
|
83
|
+
const grouped = voices.reduce((acc, voice) => {
|
|
84
|
+
if (!acc[voice.category]) acc[voice.category] = [];
|
|
85
|
+
acc[voice.category].push(voice);
|
|
86
|
+
return acc;
|
|
87
|
+
}, {});
|
|
88
|
+
|
|
89
|
+
for (const [category, categoryVoices] of Object.entries(grouped)) {
|
|
90
|
+
console.log(`\n${category.toUpperCase()}:`);
|
|
91
|
+
for (const voice of categoryVoices) {
|
|
92
|
+
console.log(` ${voice.name.padEnd(30)} ${voice.voiceId}`);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log("\n" + "─".repeat(70));
|
|
97
|
+
console.log(`Total: ${voices.length} voices`);
|
|
98
|
+
console.log("\nTo use a voice, set ELEVENLABS_VOICE_ID=<voiceId> or use --voice-id <voiceId>");
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
async function generateSoundEffects(config, soundsDir) {
|
|
102
|
+
console.log("\n[sounds] Generating UI sound effects...\n");
|
|
103
|
+
|
|
104
|
+
await fs.mkdir(soundsDir, { recursive: true });
|
|
105
|
+
|
|
106
|
+
const synthesizer = createVoiceSynthesizer(config);
|
|
107
|
+
|
|
108
|
+
const sounds = [
|
|
109
|
+
{
|
|
110
|
+
name: "click",
|
|
111
|
+
description: "soft UI button click, subtle digital interface sound, clean",
|
|
112
|
+
duration: 0.5,
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
name: "hover",
|
|
116
|
+
description: "very subtle UI hover sound, soft whoosh, gentle digital feedback",
|
|
117
|
+
duration: 0.5,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
name: "success",
|
|
121
|
+
description: "positive success chime, gentle confirmation sound, pleasant digital tone",
|
|
122
|
+
duration: 1.0,
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
name: "upload",
|
|
126
|
+
description: "file upload complete sound, subtle positive notification, digital",
|
|
127
|
+
duration: 0.8,
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "transition",
|
|
131
|
+
description: "smooth page transition whoosh, subtle, modern UI sound",
|
|
132
|
+
duration: 0.6,
|
|
133
|
+
},
|
|
134
|
+
];
|
|
135
|
+
|
|
136
|
+
for (const sound of sounds) {
|
|
137
|
+
const outputPath = path.join(soundsDir, `${sound.name}.mp3`);
|
|
138
|
+
console.log(`[sounds] Generating: ${sound.name}...`);
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
await synthesizer.generateSoundEffect(sound.description, outputPath, {
|
|
142
|
+
durationSeconds: sound.duration,
|
|
143
|
+
promptInfluence: 0.4,
|
|
144
|
+
});
|
|
145
|
+
console.log(`[sounds] ✓ Created: ${outputPath}`);
|
|
146
|
+
} catch (error) {
|
|
147
|
+
console.error(`[sounds] ✗ Failed to generate ${sound.name}:`, error);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
console.log("\n[sounds] Sound effects generation complete!");
|
|
152
|
+
console.log(`[sounds] Output directory: ${soundsDir}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function generateBackgroundMusic(config, audioDir, style) {
|
|
156
|
+
console.log(`\n[music] Generating background music (${style} style)...\n`);
|
|
157
|
+
|
|
158
|
+
const musicDir = path.join(audioDir, "music");
|
|
159
|
+
await fs.mkdir(musicDir, { recursive: true });
|
|
160
|
+
|
|
161
|
+
const synthesizer = createVoiceSynthesizer(config);
|
|
162
|
+
|
|
163
|
+
const outputPath = path.join(musicDir, `background_${style}.mp3`);
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
await synthesizer.generateBackgroundMusic(outputPath, {
|
|
167
|
+
style,
|
|
168
|
+
durationSeconds: 30,
|
|
169
|
+
});
|
|
170
|
+
console.log(`[music] ✓ Created: ${outputPath}`);
|
|
171
|
+
console.log("\n[music] Note: For longer music, you may want to loop this track");
|
|
172
|
+
} catch (error) {
|
|
173
|
+
console.error("[music] ✗ Failed to generate music:", error);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
async function generateVoiceoverForScript(config, scriptPath, outputDir) {
|
|
178
|
+
console.log(`\n[voice] Processing: ${scriptPath}\n`);
|
|
179
|
+
|
|
180
|
+
const synthesizer = createVoiceSynthesizer(config);
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const result = await synthesizer.synthesizeScript(scriptPath, outputDir, {
|
|
184
|
+
voiceSettings: {
|
|
185
|
+
stability: 0.5,
|
|
186
|
+
similarityBoost: 0.75,
|
|
187
|
+
style: 0.0,
|
|
188
|
+
},
|
|
189
|
+
generateClickSounds: false,
|
|
190
|
+
onProgress: (current, total, stepId) => {
|
|
191
|
+
console.log(`[voice] [${current}/${total}] Synthesizing: ${stepId}`);
|
|
192
|
+
},
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const manifestPath = path.join(outputDir, "audio", result.demoName, "manifest.json");
|
|
196
|
+
await generateTimingManifest(scriptPath, result, manifestPath);
|
|
197
|
+
|
|
198
|
+
console.log(`\n[voice] ✓ Completed: ${result.demoName}`);
|
|
199
|
+
console.log(`[voice] Segments: ${result.segments.length}`);
|
|
200
|
+
console.log(`[voice] Output: ${path.join(outputDir, "audio", result.demoName)}`);
|
|
201
|
+
console.log(`[voice] Manifest: ${manifestPath}`);
|
|
202
|
+
|
|
203
|
+
return result;
|
|
204
|
+
} catch (error) {
|
|
205
|
+
console.error(`[voice] ✗ Failed to process ${scriptPath}:`, error);
|
|
206
|
+
throw error;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
async function generateAllVoiceovers(config, scriptsDir, outputDir) {
|
|
211
|
+
console.log("\n[voice] Generating voiceovers for all scripts...\n");
|
|
212
|
+
|
|
213
|
+
const files = await fs.readdir(scriptsDir);
|
|
214
|
+
const scriptFiles = files.filter(
|
|
215
|
+
(file) => file.endsWith(".json") && !file.endsWith(".voice.json") && !file.endsWith("manifest.json")
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (scriptFiles.length === 0) {
|
|
219
|
+
console.log("[voice] No script files found in", scriptsDir);
|
|
220
|
+
console.log("[voice] Generate scripts before running voiceover");
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
console.log(`[voice] Found ${scriptFiles.length} script(s) to process\n`);
|
|
225
|
+
|
|
226
|
+
for (const file of scriptFiles) {
|
|
227
|
+
const scriptPath = path.join(scriptsDir, file);
|
|
228
|
+
await generateVoiceoverForScript(config, scriptPath, outputDir);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
console.log("\n[voice] All voiceovers generated!");
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export async function runGenerateVoiceoverCommand(argv) {
|
|
235
|
+
const args = argv ?? process.argv.slice(2);
|
|
236
|
+
const help = hasFlag(args, "--help") || hasFlag(args, "-h");
|
|
237
|
+
const root = getFlagValue(args, "--root");
|
|
238
|
+
const outputDirOverride = getFlagValue(args, "--output-dir");
|
|
239
|
+
const envFile = getFlagValue(args, "--env-file");
|
|
240
|
+
|
|
241
|
+
const flags = {
|
|
242
|
+
script: getFlagValue(args, "--script"),
|
|
243
|
+
demo: getFlagValue(args, "--demo"),
|
|
244
|
+
all: hasFlag(args, "--all"),
|
|
245
|
+
listVoices: hasFlag(args, "--list-voices"),
|
|
246
|
+
generateSounds: hasFlag(args, "--generate-sounds"),
|
|
247
|
+
generateMusic: hasFlag(args, "--generate-music"),
|
|
248
|
+
voiceId: getFlagValue(args, "--voice-id"),
|
|
249
|
+
musicStyle: getFlagValue(args, "--music-style") || "tech",
|
|
250
|
+
};
|
|
251
|
+
|
|
252
|
+
if (help) {
|
|
253
|
+
printHelp();
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const rootDir = resolveRoot(root);
|
|
258
|
+
const outputPaths = await getOutputPaths(rootDir, outputDirOverride);
|
|
259
|
+
|
|
260
|
+
if (flags.demo && !flags.script) {
|
|
261
|
+
flags.script = path.join(outputPaths.scriptsDir, `${flags.demo}.json`);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (flags.script) {
|
|
265
|
+
flags.script = toAbsolute(rootDir, flags.script);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const resolvedEnvFile = await resolveEnvFile(rootDir, envFile);
|
|
269
|
+
if (resolvedEnvFile) {
|
|
270
|
+
loadEnv({ path: resolvedEnvFile });
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const config = await getConfig(flags);
|
|
274
|
+
|
|
275
|
+
if (flags.listVoices) {
|
|
276
|
+
await listVoices(config);
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
if (flags.generateSounds) {
|
|
281
|
+
await generateSoundEffects(config, path.join(outputPaths.audioDir, "sounds"));
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (flags.generateMusic) {
|
|
286
|
+
const style = ["corporate", "tech", "calm", "upbeat"].includes(flags.musicStyle)
|
|
287
|
+
? flags.musicStyle
|
|
288
|
+
: "tech";
|
|
289
|
+
await generateBackgroundMusic(config, outputPaths.audioDir, style);
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (flags.script) {
|
|
294
|
+
await generateVoiceoverForScript(config, flags.script, outputPaths.outputDir);
|
|
295
|
+
return;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
if (flags.all) {
|
|
299
|
+
await generateAllVoiceovers(config, outputPaths.scriptsDir, outputPaths.outputDir);
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
printHelp();
|
|
304
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import * as fs from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { loadDemoDefinition } from "@t3lnet/sceneforge";
|
|
4
|
+
import { getFlagValue, hasFlag } from "../utils/args.js";
|
|
5
|
+
import { ensureDir, getOutputPaths, resolveRoot, toAbsolute } from "../utils/paths.js";
|
|
6
|
+
import { runRecordDemoCommand } from "./record-demo.js";
|
|
7
|
+
import { runSplitVideoCommand } from "./split-video.js";
|
|
8
|
+
import { runGenerateVoiceoverCommand } from "./generate-voiceover.js";
|
|
9
|
+
import { runAddAudioCommand } from "./add-audio-to-steps.js";
|
|
10
|
+
import { runConcatCommand } from "./concat-final-videos.js";
|
|
11
|
+
|
|
12
|
+
function printHelp() {
|
|
13
|
+
console.log(`
|
|
14
|
+
Run the full demo pipeline (record → split → voiceover → add-audio → concat)
|
|
15
|
+
|
|
16
|
+
Usage:
|
|
17
|
+
sceneforge pipeline [options]
|
|
18
|
+
|
|
19
|
+
Required (same as record):
|
|
20
|
+
--definition <path> Path to the YAML demo definition
|
|
21
|
+
--demo <name> Demo name (resolved in --definitions-dir)
|
|
22
|
+
--base-url <url> Base URL for the demo
|
|
23
|
+
|
|
24
|
+
Pipeline options:
|
|
25
|
+
--clean Remove output artifacts before running
|
|
26
|
+
--dry-run Print planned steps without running them
|
|
27
|
+
--resume Skip steps that already have output artifacts
|
|
28
|
+
--progress Show pipeline step progress markers
|
|
29
|
+
--padding <sec> Extra padding after audio ends (default: 0.3)
|
|
30
|
+
--env-file <path> Env file for ElevenLabs credentials
|
|
31
|
+
--voice-id <id> Override ElevenLabs voice ID
|
|
32
|
+
--root <path> Project root (defaults to cwd)
|
|
33
|
+
--output-dir <path> Output directory (defaults to output or e2e/output)
|
|
34
|
+
|
|
35
|
+
Media options (for final video):
|
|
36
|
+
--intro <path> Intro video to prepend (overrides YAML config)
|
|
37
|
+
--outro <path> Outro video to append (overrides YAML config)
|
|
38
|
+
--music <path> Background music file (overrides YAML config)
|
|
39
|
+
--music-volume <0-1> Background music volume (default: 0.15)
|
|
40
|
+
--music-loop Loop music if shorter than video
|
|
41
|
+
--music-fade-in <s> Fade in duration for music (default: 1)
|
|
42
|
+
--music-fade-out <s> Fade out duration for music (default: 2)
|
|
43
|
+
|
|
44
|
+
--help, -h Show this help message
|
|
45
|
+
|
|
46
|
+
Examples:
|
|
47
|
+
sceneforge pipeline --definition demo-definitions/create-quote.yaml --base-url http://localhost:5173
|
|
48
|
+
sceneforge pipeline --demo create-quote --definitions-dir examples --base-url http://localhost:5173 --clean
|
|
49
|
+
sceneforge pipeline --demo create-quote --output-dir output --resume --progress
|
|
50
|
+
sceneforge pipeline --demo create-quote --intro assets/intro.mp4 --music assets/bg-music.mp3
|
|
51
|
+
`);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function resolveDemoName(rootDir, args) {
|
|
55
|
+
const demo = getFlagValue(args, "--demo");
|
|
56
|
+
if (demo) {
|
|
57
|
+
return demo;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const definitionArg = getFlagValue(args, "--definition");
|
|
61
|
+
if (!definitionArg) {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const definitionPath = toAbsolute(rootDir, definitionArg);
|
|
66
|
+
const definition = await loadDemoDefinition(definitionPath);
|
|
67
|
+
return definition.name;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function cleanOutput(paths) {
|
|
71
|
+
const dirs = [
|
|
72
|
+
paths.scriptsDir,
|
|
73
|
+
paths.videosDir,
|
|
74
|
+
paths.audioDir,
|
|
75
|
+
paths.finalDir,
|
|
76
|
+
paths.tempDir,
|
|
77
|
+
paths.testResultsDir,
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
for (const dir of dirs) {
|
|
81
|
+
await fs.rm(dir, { recursive: true, force: true });
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
await ensureDir(paths.outputDir);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function pathExists(filePath) {
|
|
88
|
+
try {
|
|
89
|
+
await fs.access(filePath);
|
|
90
|
+
return true;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function hasRecordedAssets(demoName, paths) {
|
|
97
|
+
const scriptPath = path.join(paths.scriptsDir, `${demoName}.json`);
|
|
98
|
+
const videoPath = path.join(paths.videosDir, `${demoName}.webm`);
|
|
99
|
+
const altVideoPath = path.join(paths.videosDir, `${demoName}-flow.webm`);
|
|
100
|
+
const hasScript = await pathExists(scriptPath);
|
|
101
|
+
const hasVideo = (await pathExists(videoPath)) || (await pathExists(altVideoPath));
|
|
102
|
+
return hasScript && hasVideo;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async function hasSplitOutputs(demoName, paths) {
|
|
106
|
+
const manifestPath = path.join(paths.videosDir, demoName, "steps-manifest.json");
|
|
107
|
+
return pathExists(manifestPath);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function hasVoiceoverOutputs(demoName, paths) {
|
|
111
|
+
const manifestPath = path.join(paths.audioDir, demoName, "manifest.json");
|
|
112
|
+
return pathExists(manifestPath);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function hasAudioOutputs(demoName, paths) {
|
|
116
|
+
const demoDir = path.join(paths.videosDir, demoName);
|
|
117
|
+
try {
|
|
118
|
+
const files = await fs.readdir(demoDir);
|
|
119
|
+
return files.some((file) => file.endsWith("_with_audio.mp4"));
|
|
120
|
+
} catch {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function hasConcatOutputs(demoName, paths) {
|
|
126
|
+
const outputPath = path.join(paths.finalDir, `${demoName}.mp4`);
|
|
127
|
+
return pathExists(outputPath);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
async function buildPipelinePlan(demoName, paths, resume) {
|
|
131
|
+
if (!resume) {
|
|
132
|
+
return {
|
|
133
|
+
record: true,
|
|
134
|
+
split: true,
|
|
135
|
+
voiceover: true,
|
|
136
|
+
addAudio: true,
|
|
137
|
+
concat: true,
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
record: !(await hasRecordedAssets(demoName, paths)),
|
|
143
|
+
split: !(await hasSplitOutputs(demoName, paths)),
|
|
144
|
+
voiceover: !(await hasVoiceoverOutputs(demoName, paths)),
|
|
145
|
+
addAudio: !(await hasAudioOutputs(demoName, paths)),
|
|
146
|
+
concat: !(await hasConcatOutputs(demoName, paths)),
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function logPipelineStep(showProgress, index, total, message) {
|
|
151
|
+
const prefix = showProgress ? `[pipeline] [${index}/${total}]` : "[pipeline]";
|
|
152
|
+
console.log(`${prefix} ${message}`);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function withoutFlag(args, flag) {
|
|
156
|
+
const result = [];
|
|
157
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
158
|
+
if (args[i] === flag) {
|
|
159
|
+
continue;
|
|
160
|
+
}
|
|
161
|
+
result.push(args[i]);
|
|
162
|
+
}
|
|
163
|
+
return result;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export async function runPipelineCommand(argv) {
|
|
167
|
+
const args = argv ?? process.argv.slice(2);
|
|
168
|
+
const help = hasFlag(args, "--help") || hasFlag(args, "-h");
|
|
169
|
+
|
|
170
|
+
if (help) {
|
|
171
|
+
printHelp();
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const root = getFlagValue(args, "--root");
|
|
176
|
+
const outputDir = getFlagValue(args, "--output-dir");
|
|
177
|
+
const envFile = getFlagValue(args, "--env-file");
|
|
178
|
+
const voiceId = getFlagValue(args, "--voice-id");
|
|
179
|
+
const padding = getFlagValue(args, "--padding");
|
|
180
|
+
const clean = hasFlag(args, "--clean");
|
|
181
|
+
const dryRun = hasFlag(args, "--dry-run");
|
|
182
|
+
const resume = hasFlag(args, "--resume");
|
|
183
|
+
const showProgress = hasFlag(args, "--progress");
|
|
184
|
+
const baseUrl = getFlagValue(args, "--base-url");
|
|
185
|
+
|
|
186
|
+
// New media options
|
|
187
|
+
const intro = getFlagValue(args, "--intro");
|
|
188
|
+
const outro = getFlagValue(args, "--outro");
|
|
189
|
+
const music = getFlagValue(args, "--music");
|
|
190
|
+
const musicVolume = getFlagValue(args, "--music-volume");
|
|
191
|
+
const musicLoop = hasFlag(args, "--music-loop");
|
|
192
|
+
const musicFadeIn = getFlagValue(args, "--music-fade-in");
|
|
193
|
+
const musicFadeOut = getFlagValue(args, "--music-fade-out");
|
|
194
|
+
|
|
195
|
+
const rootDir = resolveRoot(root);
|
|
196
|
+
const outputPaths = await getOutputPaths(rootDir, outputDir);
|
|
197
|
+
|
|
198
|
+
const demoName = await resolveDemoName(rootDir, args);
|
|
199
|
+
if (!demoName) {
|
|
200
|
+
console.error("[error] Provide --demo or --definition to run the pipeline");
|
|
201
|
+
printHelp();
|
|
202
|
+
process.exit(1);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const effectiveResume = resume && !clean;
|
|
206
|
+
const plan = await buildPipelinePlan(demoName, outputPaths, effectiveResume);
|
|
207
|
+
|
|
208
|
+
if (plan.record && !baseUrl) {
|
|
209
|
+
console.error("[error] --base-url is required to record a demo");
|
|
210
|
+
printHelp();
|
|
211
|
+
process.exit(1);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if (dryRun) {
|
|
215
|
+
if (clean) {
|
|
216
|
+
console.log("[pipeline] --clean specified; resume will be ignored");
|
|
217
|
+
}
|
|
218
|
+
console.log(`[pipeline] Dry run for: ${demoName}`);
|
|
219
|
+
const steps = [
|
|
220
|
+
["record", plan.record],
|
|
221
|
+
["split", plan.split],
|
|
222
|
+
["voiceover", plan.voiceover],
|
|
223
|
+
["add-audio", plan.addAudio],
|
|
224
|
+
["concat", plan.concat],
|
|
225
|
+
];
|
|
226
|
+
for (const [step, shouldRun] of steps) {
|
|
227
|
+
console.log(`- ${step}: ${shouldRun ? "run" : "skip (--resume)"}`);
|
|
228
|
+
}
|
|
229
|
+
if (intro || outro || music) {
|
|
230
|
+
console.log("\nMedia options:");
|
|
231
|
+
if (intro) console.log(` - Intro: ${intro}`);
|
|
232
|
+
if (outro) console.log(` - Outro: ${outro}`);
|
|
233
|
+
if (music) console.log(` - Music: ${music}`);
|
|
234
|
+
}
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (clean) {
|
|
239
|
+
await cleanOutput(outputPaths);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const totalSteps = 5;
|
|
243
|
+
let stepIndex = 0;
|
|
244
|
+
|
|
245
|
+
const runStep = async (label, shouldRun, fn) => {
|
|
246
|
+
stepIndex += 1;
|
|
247
|
+
if (!shouldRun) {
|
|
248
|
+
logPipelineStep(showProgress, stepIndex, totalSteps, `${label} (skipped --resume)`);
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
logPipelineStep(showProgress, stepIndex, totalSteps, `Starting ${label}`);
|
|
252
|
+
await fn();
|
|
253
|
+
logPipelineStep(showProgress, stepIndex, totalSteps, `Completed ${label}`);
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
let recordArgs = withoutFlag(args, "--clean");
|
|
257
|
+
recordArgs = withoutFlag(recordArgs, "--resume");
|
|
258
|
+
recordArgs = withoutFlag(recordArgs, "--dry-run");
|
|
259
|
+
recordArgs = withoutFlag(recordArgs, "--progress");
|
|
260
|
+
await runStep("record", plan.record, () => runRecordDemoCommand(recordArgs));
|
|
261
|
+
|
|
262
|
+
const sharedArgs = [];
|
|
263
|
+
if (root) {
|
|
264
|
+
sharedArgs.push("--root", root);
|
|
265
|
+
}
|
|
266
|
+
if (outputDir) {
|
|
267
|
+
sharedArgs.push("--output-dir", outputDir);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
await runStep("split", plan.split, () =>
|
|
271
|
+
runSplitVideoCommand(["--demo", demoName, ...sharedArgs])
|
|
272
|
+
);
|
|
273
|
+
|
|
274
|
+
const voiceArgs = ["--demo", demoName, ...sharedArgs];
|
|
275
|
+
if (envFile) {
|
|
276
|
+
voiceArgs.push("--env-file", envFile);
|
|
277
|
+
}
|
|
278
|
+
if (voiceId) {
|
|
279
|
+
voiceArgs.push("--voice-id", voiceId);
|
|
280
|
+
}
|
|
281
|
+
await runStep("voiceover", plan.voiceover, () => runGenerateVoiceoverCommand(voiceArgs));
|
|
282
|
+
|
|
283
|
+
const audioArgs = ["--demo", demoName, ...sharedArgs];
|
|
284
|
+
if (padding) {
|
|
285
|
+
audioArgs.push("--padding", padding);
|
|
286
|
+
}
|
|
287
|
+
await runStep("add-audio", plan.addAudio, () => runAddAudioCommand(audioArgs));
|
|
288
|
+
|
|
289
|
+
// Build concat args with media options
|
|
290
|
+
const concatArgs = ["--demo", demoName, ...sharedArgs];
|
|
291
|
+
if (intro) {
|
|
292
|
+
concatArgs.push("--intro", intro);
|
|
293
|
+
}
|
|
294
|
+
if (outro) {
|
|
295
|
+
concatArgs.push("--outro", outro);
|
|
296
|
+
}
|
|
297
|
+
if (music) {
|
|
298
|
+
concatArgs.push("--music", music);
|
|
299
|
+
}
|
|
300
|
+
if (musicVolume) {
|
|
301
|
+
concatArgs.push("--music-volume", musicVolume);
|
|
302
|
+
}
|
|
303
|
+
if (musicLoop) {
|
|
304
|
+
concatArgs.push("--music-loop");
|
|
305
|
+
}
|
|
306
|
+
if (musicFadeIn) {
|
|
307
|
+
concatArgs.push("--music-fade-in", musicFadeIn);
|
|
308
|
+
}
|
|
309
|
+
if (musicFadeOut) {
|
|
310
|
+
concatArgs.push("--music-fade-out", musicFadeOut);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
await runStep("concat", plan.concat, () => runConcatCommand(concatArgs));
|
|
314
|
+
}
|