@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.
@@ -0,0 +1,480 @@
1
+ import * as fs from "fs/promises";
2
+ import * as path from "path";
3
+ import { checkFFmpeg, getMediaDuration, runFFmpeg } from "../utils/media.js";
4
+ import { getFlagValue, hasFlag } from "../utils/args.js";
5
+ import { getOutputPaths, resolveRoot, readJson, toAbsolute } from "../utils/paths.js";
6
+
7
+ function printHelp() {
8
+ console.log(`
9
+ Concatenate per-step video clips into final demo videos
10
+
11
+ Usage:
12
+ sceneforge concat [options]
13
+
14
+ Options:
15
+ --demo <name> Process a specific demo by name
16
+ --all Process all demos with step clips
17
+ --intro <path> Intro video to prepend (overrides YAML config)
18
+ --outro <path> Outro video to append (overrides YAML config)
19
+ --music <path> Background music file (overrides YAML config)
20
+ --music-volume <0-1> Background music volume (default: 0.15)
21
+ --music-loop Loop music if shorter than video
22
+ --music-fade-in <s> Fade in duration for music (default: 1)
23
+ --music-fade-out <s> Fade out duration for music (default: 2)
24
+ --root <path> Project root (defaults to cwd)
25
+ --output-dir <path> Output directory (defaults to e2e/output or output)
26
+ --help, -h Show this help message
27
+
28
+ Examples:
29
+ sceneforge concat --demo create-quote
30
+ sceneforge concat --demo create-quote --intro intro.mp4 --outro outro.mp4
31
+ sceneforge concat --demo create-quote --music background.mp3 --music-volume 0.2
32
+ sceneforge concat --all
33
+ `);
34
+ }
35
+
36
+ async function resolveMediaPath(filePath, rootDir) {
37
+ if (!filePath) return null;
38
+ const resolved = path.isAbsolute(filePath) ? filePath : path.join(rootDir, filePath);
39
+ try {
40
+ await fs.access(resolved);
41
+ return resolved;
42
+ } catch {
43
+ return null;
44
+ }
45
+ }
46
+
47
+ async function loadMediaConfig(demoName, paths, rootDir) {
48
+ // Try to load script JSON which may contain the original definition's media config
49
+ const scriptPath = path.join(paths.scriptsDir, `${demoName}.json`);
50
+ try {
51
+ const script = await readJson(scriptPath);
52
+ return script.definition?.media || null;
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+
58
+ async function buildConcatWithIntroOutro(stepFiles, demoDir, introPath, outroPath, outputPath) {
59
+ const allInputs = [];
60
+ const inputPaths = [];
61
+ let inputIndex = 0;
62
+
63
+ // Add intro if present
64
+ if (introPath) {
65
+ allInputs.push("-i", introPath);
66
+ inputPaths.push({ type: "intro", index: inputIndex });
67
+ inputIndex++;
68
+ }
69
+
70
+ // Add step files
71
+ for (const file of stepFiles) {
72
+ allInputs.push("-i", path.join(demoDir, file));
73
+ inputPaths.push({ type: "step", index: inputIndex, file });
74
+ inputIndex++;
75
+ }
76
+
77
+ // Add outro if present
78
+ if (outroPath) {
79
+ allInputs.push("-i", outroPath);
80
+ inputPaths.push({ type: "outro", index: inputIndex });
81
+ inputIndex++;
82
+ }
83
+
84
+ const concatInputs = inputPaths.map(({ index }) => `[${index}:v:0][${index}:a:0]`).join("");
85
+ const filterGraph = `${concatInputs}concat=n=${inputPaths.length}:v=1:a=1[outv][outa]`;
86
+
87
+ await runFFmpeg([
88
+ "-y",
89
+ ...allInputs,
90
+ "-filter_complex",
91
+ filterGraph,
92
+ "-map",
93
+ "[outv]",
94
+ "-map",
95
+ "[outa]",
96
+ "-c:v",
97
+ "libx264",
98
+ "-preset",
99
+ "fast",
100
+ "-c:a",
101
+ "aac",
102
+ "-b:a",
103
+ "192k",
104
+ "-movflags",
105
+ "+faststart",
106
+ outputPath,
107
+ ]);
108
+ }
109
+
110
+ async function addBackgroundMusic(videoPath, musicPath, outputPath, options = {}) {
111
+ const {
112
+ volume = 0.15,
113
+ loop = true,
114
+ fadeIn = 1,
115
+ fadeOut = 2,
116
+ startAt = null,
117
+ endAt = null,
118
+ } = options;
119
+
120
+ const videoDuration = await getMediaDuration(videoPath);
121
+ const musicDuration = await getMediaDuration(musicPath);
122
+
123
+ // Calculate start and end times
124
+ let musicStart = 0;
125
+ let musicEnd = videoDuration;
126
+
127
+ if (startAt) {
128
+ if (startAt.type === "time") {
129
+ musicStart = startAt.seconds;
130
+ } else if (startAt.type === "afterIntro" && startAt.introDuration) {
131
+ musicStart = startAt.introDuration;
132
+ }
133
+ }
134
+
135
+ if (endAt) {
136
+ if (endAt.type === "time") {
137
+ musicEnd = endAt.seconds;
138
+ } else if (endAt.type === "beforeOutro" && endAt.outroDuration) {
139
+ musicEnd = videoDuration - endAt.outroDuration;
140
+ }
141
+ }
142
+
143
+ const musicPlayDuration = musicEnd - musicStart;
144
+
145
+ // Build the audio filter
146
+ let audioFilter = "";
147
+
148
+ if (loop && musicDuration < musicPlayDuration) {
149
+ // Loop the music to cover the duration
150
+ const loopCount = Math.ceil(musicPlayDuration / musicDuration);
151
+ audioFilter = `[1:a]aloop=loop=${loopCount}:size=${Math.ceil(musicDuration * 48000)},atrim=0:${musicPlayDuration}`;
152
+ } else {
153
+ audioFilter = `[1:a]atrim=0:${Math.min(musicDuration, musicPlayDuration)}`;
154
+ }
155
+
156
+ // Add fade in/out
157
+ if (fadeIn > 0) {
158
+ audioFilter += `,afade=t=in:st=0:d=${fadeIn}`;
159
+ }
160
+ if (fadeOut > 0) {
161
+ const fadeOutStart = Math.max(0, musicPlayDuration - fadeOut);
162
+ audioFilter += `,afade=t=out:st=${fadeOutStart}:d=${fadeOut}`;
163
+ }
164
+
165
+ // Apply volume
166
+ audioFilter += `,volume=${volume}`;
167
+
168
+ // Delay if starting after the beginning
169
+ if (musicStart > 0) {
170
+ audioFilter += `,adelay=${Math.round(musicStart * 1000)}|${Math.round(musicStart * 1000)}`;
171
+ }
172
+
173
+ // Pad to match video duration
174
+ audioFilter += `,apad=whole_dur=${videoDuration}`;
175
+ audioFilter += "[music];";
176
+
177
+ // Mix with original audio
178
+ audioFilter += `[0:a][music]amix=inputs=2:duration=first:dropout_transition=0[outa]`;
179
+
180
+ await runFFmpeg([
181
+ "-y",
182
+ "-i",
183
+ videoPath,
184
+ "-i",
185
+ musicPath,
186
+ "-filter_complex",
187
+ audioFilter,
188
+ "-map",
189
+ "0:v",
190
+ "-map",
191
+ "[outa]",
192
+ "-c:v",
193
+ "copy",
194
+ "-c:a",
195
+ "aac",
196
+ "-b:a",
197
+ "192k",
198
+ "-movflags",
199
+ "+faststart",
200
+ outputPath,
201
+ ]);
202
+ }
203
+
204
+ async function concatDemo(demoName, paths, options = {}) {
205
+ console.log(`\n[concat] Processing: ${demoName}\n`);
206
+
207
+ const { rootDir, introOverride, outroOverride, musicOverride, musicOptions = {} } = options;
208
+ const demoDir = path.join(paths.videosDir, demoName);
209
+
210
+ try {
211
+ const files = await fs.readdir(demoDir);
212
+ const stepFiles = files.filter((file) => file.endsWith("_with_audio.mp4")).sort();
213
+
214
+ if (stepFiles.length === 0) {
215
+ console.error(`[concat] ✗ No step clips with audio found in ${demoDir}`);
216
+ console.error("[concat] Run sceneforge add-audio first");
217
+ return;
218
+ }
219
+
220
+ console.log(`[concat] Found ${stepFiles.length} step clips with audio`);
221
+
222
+ // Load media config from definition
223
+ const mediaConfig = await loadMediaConfig(demoName, paths, rootDir);
224
+
225
+ // Resolve intro path (CLI override takes precedence)
226
+ let introPath = null;
227
+ if (introOverride) {
228
+ introPath = await resolveMediaPath(introOverride, rootDir);
229
+ if (!introPath) {
230
+ console.warn(`[concat] ⚠ Intro file not found: ${introOverride}`);
231
+ }
232
+ } else if (mediaConfig?.intro?.file) {
233
+ introPath = await resolveMediaPath(mediaConfig.intro.file, rootDir);
234
+ if (!introPath) {
235
+ console.warn(`[concat] ⚠ Intro file from config not found: ${mediaConfig.intro.file}`);
236
+ }
237
+ }
238
+
239
+ // Resolve outro path
240
+ let outroPath = null;
241
+ if (outroOverride) {
242
+ outroPath = await resolveMediaPath(outroOverride, rootDir);
243
+ if (!outroPath) {
244
+ console.warn(`[concat] ⚠ Outro file not found: ${outroOverride}`);
245
+ }
246
+ } else if (mediaConfig?.outro?.file) {
247
+ outroPath = await resolveMediaPath(mediaConfig.outro.file, rootDir);
248
+ if (!outroPath) {
249
+ console.warn(`[concat] ⚠ Outro file from config not found: ${mediaConfig.outro.file}`);
250
+ }
251
+ }
252
+
253
+ // Resolve music path
254
+ let musicPath = null;
255
+ let finalMusicOptions = { ...musicOptions };
256
+ if (musicOverride) {
257
+ musicPath = await resolveMediaPath(musicOverride, rootDir);
258
+ if (!musicPath) {
259
+ console.warn(`[concat] ⚠ Music file not found: ${musicOverride}`);
260
+ }
261
+ } else if (mediaConfig?.backgroundMusic?.file) {
262
+ musicPath = await resolveMediaPath(mediaConfig.backgroundMusic.file, rootDir);
263
+ if (!musicPath) {
264
+ console.warn(`[concat] ⚠ Music file from config not found: ${mediaConfig.backgroundMusic.file}`);
265
+ }
266
+ // Use config values as defaults
267
+ finalMusicOptions = {
268
+ volume: mediaConfig.backgroundMusic.volume ?? finalMusicOptions.volume,
269
+ loop: mediaConfig.backgroundMusic.loop ?? finalMusicOptions.loop,
270
+ fadeIn: mediaConfig.backgroundMusic.fadeIn ?? finalMusicOptions.fadeIn,
271
+ fadeOut: mediaConfig.backgroundMusic.fadeOut ?? finalMusicOptions.fadeOut,
272
+ startAt: mediaConfig.backgroundMusic.startAt ?? finalMusicOptions.startAt,
273
+ endAt: mediaConfig.backgroundMusic.endAt ?? finalMusicOptions.endAt,
274
+ };
275
+ }
276
+
277
+ await fs.mkdir(paths.finalDir, { recursive: true });
278
+
279
+ const hasIntroOutro = introPath || outroPath;
280
+ const hasMusic = musicPath;
281
+ const tempConcatPath = path.join(paths.finalDir, `${demoName}_temp_concat.mp4`);
282
+ const outputPath = path.join(paths.finalDir, `${demoName}.mp4`);
283
+
284
+ // Step 1: Concatenate steps with optional intro/outro
285
+ if (hasIntroOutro) {
286
+ if (introPath) console.log(`[concat] Adding intro: ${path.basename(introPath)}`);
287
+ if (outroPath) console.log(`[concat] Adding outro: ${path.basename(outroPath)}`);
288
+ }
289
+
290
+ console.log("[concat] Concatenating clips...");
291
+
292
+ if (hasIntroOutro) {
293
+ await buildConcatWithIntroOutro(
294
+ stepFiles,
295
+ demoDir,
296
+ introPath,
297
+ outroPath,
298
+ hasMusic ? tempConcatPath : outputPath
299
+ );
300
+ } else {
301
+ // Original concatenation logic for steps only
302
+ const inputArgs = stepFiles.flatMap((file) => ["-i", path.join(demoDir, file)]);
303
+ const concatInputs = stepFiles.map((_, index) => `[${index}:v:0][${index}:a:0]`).join("");
304
+ const filterGraph = `${concatInputs}concat=n=${stepFiles.length}:v=1:a=1[outv][outa]`;
305
+
306
+ await runFFmpeg([
307
+ "-y",
308
+ ...inputArgs,
309
+ "-filter_complex",
310
+ filterGraph,
311
+ "-map",
312
+ "[outv]",
313
+ "-map",
314
+ "[outa]",
315
+ "-c:v",
316
+ "libx264",
317
+ "-preset",
318
+ "fast",
319
+ "-c:a",
320
+ "aac",
321
+ "-b:a",
322
+ "192k",
323
+ "-movflags",
324
+ "+faststart",
325
+ hasMusic ? tempConcatPath : outputPath,
326
+ ]);
327
+ }
328
+
329
+ // Step 2: Add background music if present
330
+ if (hasMusic) {
331
+ console.log(`[concat] Adding background music: ${path.basename(musicPath)}`);
332
+ console.log(`[concat] Volume: ${(finalMusicOptions.volume ?? 0.15) * 100}%`);
333
+ if (finalMusicOptions.loop !== false) console.log("[concat] Loop: enabled");
334
+
335
+ // Calculate intro/outro durations for startAt/endAt
336
+ let introDuration = 0;
337
+ let outroDuration = 0;
338
+ if (introPath) {
339
+ introDuration = await getMediaDuration(introPath);
340
+ }
341
+ if (outroPath) {
342
+ outroDuration = await getMediaDuration(outroPath);
343
+ }
344
+
345
+ // Enhance startAt/endAt with calculated durations
346
+ if (finalMusicOptions.startAt?.type === "afterIntro") {
347
+ finalMusicOptions.startAt = { ...finalMusicOptions.startAt, introDuration };
348
+ }
349
+ if (finalMusicOptions.endAt?.type === "beforeOutro") {
350
+ finalMusicOptions.endAt = { ...finalMusicOptions.endAt, outroDuration };
351
+ }
352
+
353
+ const musicInputPath = hasIntroOutro ? tempConcatPath : tempConcatPath;
354
+ // If we had intro/outro, input is tempConcatPath; otherwise we need to create it
355
+ if (!hasIntroOutro) {
356
+ // Rename the previous output to temp for music processing
357
+ await fs.rename(outputPath, tempConcatPath);
358
+ }
359
+
360
+ await addBackgroundMusic(tempConcatPath, musicPath, outputPath, finalMusicOptions);
361
+
362
+ // Clean up temp file
363
+ await fs.unlink(tempConcatPath).catch(() => {});
364
+ }
365
+
366
+ const duration = await getMediaDuration(outputPath);
367
+
368
+ console.log(`[concat] ✓ Created: ${outputPath}`);
369
+ console.log(`[concat] Duration: ${duration.toFixed(2)}s`);
370
+
371
+ // Log what was included
372
+ const features = [];
373
+ if (introPath) features.push("intro");
374
+ if (outroPath) features.push("outro");
375
+ if (musicPath) features.push("background music");
376
+ if (features.length > 0) {
377
+ console.log(`[concat] Includes: ${features.join(", ")}`);
378
+ }
379
+ } catch (error) {
380
+ console.error(`[concat] Error processing ${demoName}:`, error);
381
+ throw error;
382
+ }
383
+ }
384
+
385
+ async function concatAll(paths, options) {
386
+ console.log("\n[concat] Processing all demos...\n");
387
+
388
+ try {
389
+ const demoDirs = await fs.readdir(paths.videosDir);
390
+ const demosToProcess = [];
391
+
392
+ for (const dir of demoDirs) {
393
+ const demoPath = path.join(paths.videosDir, dir);
394
+ const stat = await fs.stat(demoPath);
395
+
396
+ if (!stat.isDirectory()) continue;
397
+
398
+ const files = await fs.readdir(demoPath);
399
+ const hasAudioClips = files.some((file) => file.endsWith("_with_audio.mp4"));
400
+
401
+ if (hasAudioClips) {
402
+ demosToProcess.push(dir);
403
+ }
404
+ }
405
+
406
+ if (demosToProcess.length === 0) {
407
+ console.log("[concat] No demos ready for concatenation");
408
+ console.log("[concat] Make sure you've run sceneforge add-audio");
409
+ return;
410
+ }
411
+
412
+ console.log(`[concat] Found ${demosToProcess.length} demo(s) to process\n`);
413
+
414
+ for (const demo of demosToProcess) {
415
+ await concatDemo(demo, paths, options);
416
+ }
417
+
418
+ console.log("\n[concat] All demos processed!");
419
+ console.log(`[concat] Output: ${paths.finalDir}`);
420
+ } catch (error) {
421
+ console.error("[concat] Error:", error);
422
+ }
423
+ }
424
+
425
+ export async function runConcatCommand(argv) {
426
+ const args = argv ?? process.argv.slice(2);
427
+ const help = hasFlag(args, "--help") || hasFlag(args, "-h");
428
+ const demo = getFlagValue(args, "--demo");
429
+ const all = hasFlag(args, "--all");
430
+ const root = getFlagValue(args, "--root");
431
+ const outputDir = getFlagValue(args, "--output-dir");
432
+
433
+ // New media options
434
+ const introOverride = getFlagValue(args, "--intro");
435
+ const outroOverride = getFlagValue(args, "--outro");
436
+ const musicOverride = getFlagValue(args, "--music");
437
+ const musicVolume = parseFloat(getFlagValue(args, "--music-volume") ?? "0.15");
438
+ const musicLoop = hasFlag(args, "--music-loop");
439
+ const musicFadeIn = parseFloat(getFlagValue(args, "--music-fade-in") ?? "1");
440
+ const musicFadeOut = parseFloat(getFlagValue(args, "--music-fade-out") ?? "2");
441
+
442
+ if (help) {
443
+ printHelp();
444
+ return;
445
+ }
446
+
447
+ const hasFFmpeg = await checkFFmpeg();
448
+ if (!hasFFmpeg) {
449
+ console.error("[error] FFmpeg is not installed");
450
+ process.exit(1);
451
+ }
452
+
453
+ const rootDir = resolveRoot(root);
454
+ const paths = await getOutputPaths(rootDir, outputDir);
455
+
456
+ const options = {
457
+ rootDir,
458
+ introOverride,
459
+ outroOverride,
460
+ musicOverride,
461
+ musicOptions: {
462
+ volume: musicVolume,
463
+ loop: musicLoop || true, // Default to true
464
+ fadeIn: musicFadeIn,
465
+ fadeOut: musicFadeOut,
466
+ },
467
+ };
468
+
469
+ if (demo) {
470
+ await concatDemo(demo, paths, options);
471
+ return;
472
+ }
473
+
474
+ if (all) {
475
+ await concatAll(paths, options);
476
+ return;
477
+ }
478
+
479
+ printHelp();
480
+ }
@@ -0,0 +1,102 @@
1
+ import { config as loadEnv } from "dotenv";
2
+ import { runFFmpeg, runFFprobe } from "../utils/media.js";
3
+ import { getFlagValue, hasFlag } from "../utils/args.js";
4
+ import { resolveEnvFile, resolveRoot } from "../utils/paths.js";
5
+
6
+ function printHelp() {
7
+ console.log(`
8
+ Run environment diagnostics for sceneforge
9
+
10
+ Usage:
11
+ sceneforge doctor [options]
12
+
13
+ Options:
14
+ --root <path> Project root (defaults to cwd)
15
+ --env-file <path> Environment file to load
16
+ --json Output diagnostics as JSON
17
+ --help, -h Show this help message
18
+ `);
19
+ }
20
+
21
+ async function checkBinary(name, runner) {
22
+ try {
23
+ const { stdout, stderr } = await runner(["-version"]);
24
+ const output = `${stdout}\n${stderr}`.trim();
25
+ const firstLine = output.split("\n")[0] || "";
26
+ return { ok: true, version: firstLine };
27
+ } catch (error) {
28
+ return { ok: false, error: error instanceof Error ? error.message : String(error) };
29
+ }
30
+ }
31
+
32
+ export async function runDoctorCommand(argv) {
33
+ const args = argv ?? process.argv.slice(2);
34
+ const help = hasFlag(args, "--help") || hasFlag(args, "-h");
35
+ const asJson = hasFlag(args, "--json");
36
+ const root = getFlagValue(args, "--root");
37
+ const envFile = getFlagValue(args, "--env-file");
38
+
39
+ if (help) {
40
+ printHelp();
41
+ return;
42
+ }
43
+
44
+ const rootDir = resolveRoot(root);
45
+ const resolvedEnvFile = await resolveEnvFile(rootDir, envFile);
46
+ if (resolvedEnvFile) {
47
+ loadEnv({ path: resolvedEnvFile });
48
+ }
49
+
50
+ const checks = [];
51
+
52
+ const ffmpeg = await checkBinary("ffmpeg", runFFmpeg);
53
+ checks.push({
54
+ name: "ffmpeg",
55
+ status: ffmpeg.ok ? "ok" : "missing",
56
+ detail: ffmpeg.ok ? ffmpeg.version : ffmpeg.error,
57
+ });
58
+
59
+ const ffprobe = await checkBinary("ffprobe", runFFprobe);
60
+ checks.push({
61
+ name: "ffprobe",
62
+ status: ffprobe.ok ? "ok" : "missing",
63
+ detail: ffprobe.ok ? ffprobe.version : ffprobe.error,
64
+ });
65
+
66
+ const envChecks = [
67
+ { key: "ELEVENLABS_API_KEY", required: false },
68
+ { key: "ELEVENLABS_VOICE_ID", required: false },
69
+ ];
70
+ for (const envCheck of envChecks) {
71
+ const value = process.env[envCheck.key];
72
+ checks.push({
73
+ name: envCheck.key,
74
+ status: value ? "ok" : envCheck.required ? "missing" : "optional",
75
+ detail: value ? "set" : "not set",
76
+ });
77
+ }
78
+
79
+ const diagnostics = {
80
+ ok: checks.every((check) => check.status === "ok" || check.status === "optional"),
81
+ rootDir,
82
+ nodeVersion: process.version,
83
+ platform: process.platform,
84
+ arch: process.arch,
85
+ checks,
86
+ };
87
+
88
+ if (asJson) {
89
+ console.log(JSON.stringify(diagnostics, null, 2));
90
+ return;
91
+ }
92
+
93
+ console.log("\n[doctor] SceneForge diagnostics\n");
94
+ for (const check of checks) {
95
+ const statusLabel = check.status === "ok" ? "✓" : check.status === "optional" ? "•" : "✗";
96
+ console.log(`[doctor] ${statusLabel} ${check.name}: ${check.detail}`);
97
+ }
98
+ console.log(`\n[doctor] Result: ${diagnostics.ok ? "OK" : "Issues detected"}`);
99
+ if (!diagnostics.ok) {
100
+ process.exitCode = 1;
101
+ }
102
+ }