@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,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
|
+
}
|