@t3lnet/sceneforge 1.0.14 → 1.0.16
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 +45 -0
- package/cli/commands/add-audio-to-steps.js +22 -38
- package/cli/commands/concat-final-videos.js +27 -25
- package/cli/commands/pipeline.js +30 -4
- package/cli/commands/split-video.js +18 -11
- package/cli/utils/quality.js +144 -0
- package/context/context-builder.ts +56 -0
- package/context/templates/base/cli-reference.md +61 -17
- package/context/templates/stages/stage4-rebalancing.md +13 -11
- package/context/tests/context-builder.test.ts +15 -2
- package/dist/index.cjs +47 -0
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +47 -0
- package/dist/index.js.map +1 -1
- package/dist/templates/base/cli-reference.md +61 -17
- package/dist/templates/stages/stage4-rebalancing.md +13 -11
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -72,6 +72,51 @@ npx sceneforge voice-cache prune --days 7 # Remove old entries
|
|
|
72
72
|
|
|
73
73
|
Cache is stored in `.voice-cache/` in your project root.
|
|
74
74
|
|
|
75
|
+
## Video Quality Settings
|
|
76
|
+
|
|
77
|
+
SceneForge provides configurable video quality settings for the video processing pipeline.
|
|
78
|
+
|
|
79
|
+
**Quality Presets:**
|
|
80
|
+
|
|
81
|
+
| Preset | CRF | Encoding | Use Case |
|
|
82
|
+
|--------|-----|----------|----------|
|
|
83
|
+
| `low` | 28 | fast | Quick drafts, smaller files |
|
|
84
|
+
| `medium` | 18 | medium | Default - balanced quality and size |
|
|
85
|
+
| `high` | 10 | slow | Final delivery, best quality |
|
|
86
|
+
|
|
87
|
+
**Supported Codecs:**
|
|
88
|
+
|
|
89
|
+
| Codec | Description |
|
|
90
|
+
|-------|-------------|
|
|
91
|
+
| `libx264` | H.264 - excellent compatibility (default) |
|
|
92
|
+
| `libx265` | H.265/HEVC - ~50% smaller files |
|
|
93
|
+
|
|
94
|
+
**CLI Flags:**
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
--quality <preset> # low, medium, high (default: medium)
|
|
98
|
+
--crf <value> # Override CRF (0-51, lower = better)
|
|
99
|
+
--codec <codec> # libx264 or libx265
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Examples:**
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
# High quality for final output
|
|
106
|
+
npx sceneforge concat --demo my-demo --quality high
|
|
107
|
+
|
|
108
|
+
# Smaller files with H.265
|
|
109
|
+
npx sceneforge concat --demo my-demo --codec libx265
|
|
110
|
+
|
|
111
|
+
# Custom CRF value
|
|
112
|
+
npx sceneforge split --demo my-demo --crf 15
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
**Why it matters:**
|
|
116
|
+
- Videos pass through multiple stages (split → add-audio → concat)
|
|
117
|
+
- Higher quality (lower CRF) prevents generation loss
|
|
118
|
+
- `high` preset (CRF 10) produces near-lossless quality for professional demos
|
|
119
|
+
|
|
75
120
|
## Notes
|
|
76
121
|
|
|
77
122
|
- Voiceover generation uses ElevenLabs and requires `ELEVENLABS_API_KEY` + `ELEVENLABS_VOICE_ID`.
|
|
@@ -4,6 +4,12 @@ import { checkFFmpeg, getMediaDuration, runFFmpeg } from "../utils/media.js";
|
|
|
4
4
|
import { getFlagValue, hasFlag } from "../utils/args.js";
|
|
5
5
|
import { getOutputPaths, resolveRoot, readJson } from "../utils/paths.js";
|
|
6
6
|
import { sanitizeFileSegment } from "../utils/sanitize.js";
|
|
7
|
+
import {
|
|
8
|
+
getVideoEncodingArgs,
|
|
9
|
+
parseQualityArgs,
|
|
10
|
+
getQualityHelpText,
|
|
11
|
+
logQualitySettings,
|
|
12
|
+
} from "../utils/quality.js";
|
|
7
13
|
|
|
8
14
|
function printHelp() {
|
|
9
15
|
console.log(`
|
|
@@ -19,20 +25,23 @@ Options:
|
|
|
19
25
|
--root <path> Project root (defaults to cwd)
|
|
20
26
|
--output-dir <path> Output directory (defaults to e2e/output or output)
|
|
21
27
|
--help, -h Show this help message
|
|
28
|
+
${getQualityHelpText()}
|
|
22
29
|
|
|
23
30
|
Output:
|
|
24
31
|
Creates step_XX_<stepId>_with_audio.mp4 files in the videos/<demo>/ folder
|
|
25
32
|
|
|
26
33
|
Examples:
|
|
27
34
|
sceneforge add-audio --demo create-quote
|
|
28
|
-
sceneforge add-audio --
|
|
35
|
+
sceneforge add-audio --demo create-quote --quality high
|
|
36
|
+
sceneforge add-audio --all --codec libx265
|
|
29
37
|
`);
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
async function addAudioToStep(videoPath, audioPath, outputPath, padding, nextVideoPath) {
|
|
40
|
+
async function addAudioToStep(videoPath, audioPath, outputPath, padding, nextVideoPath, qualityOptions = {}) {
|
|
33
41
|
const videoDuration = await getMediaDuration(videoPath);
|
|
34
42
|
const audioDuration = await getMediaDuration(audioPath);
|
|
35
43
|
const targetDuration = audioDuration + padding;
|
|
44
|
+
const encodingArgs = getVideoEncodingArgs(qualityOptions);
|
|
36
45
|
|
|
37
46
|
if (targetDuration <= videoDuration) {
|
|
38
47
|
const padDuration = Math.max(0, videoDuration - audioDuration);
|
|
@@ -50,16 +59,7 @@ async function addAudioToStep(videoPath, audioPath, outputPath, padding, nextVid
|
|
|
50
59
|
"[a]",
|
|
51
60
|
"-t",
|
|
52
61
|
String(videoDuration),
|
|
53
|
-
|
|
54
|
-
"libx264",
|
|
55
|
-
"-preset",
|
|
56
|
-
"medium",
|
|
57
|
-
"-crf",
|
|
58
|
-
"18",
|
|
59
|
-
"-c:a",
|
|
60
|
-
"aac",
|
|
61
|
-
"-b:a",
|
|
62
|
-
"192k",
|
|
62
|
+
...encodingArgs,
|
|
63
63
|
outputPath,
|
|
64
64
|
]);
|
|
65
65
|
return;
|
|
@@ -89,16 +89,7 @@ async function addAudioToStep(videoPath, audioPath, outputPath, padding, nextVid
|
|
|
89
89
|
"[outv]",
|
|
90
90
|
"-map",
|
|
91
91
|
"2:a",
|
|
92
|
-
|
|
93
|
-
"libx264",
|
|
94
|
-
"-preset",
|
|
95
|
-
"medium",
|
|
96
|
-
"-crf",
|
|
97
|
-
"18",
|
|
98
|
-
"-c:a",
|
|
99
|
-
"aac",
|
|
100
|
-
"-b:a",
|
|
101
|
-
"192k",
|
|
92
|
+
...encodingArgs,
|
|
102
93
|
"-t",
|
|
103
94
|
String(targetDuration),
|
|
104
95
|
outputPath,
|
|
@@ -118,24 +109,16 @@ async function addAudioToStep(videoPath, audioPath, outputPath, padding, nextVid
|
|
|
118
109
|
"[v]",
|
|
119
110
|
"-map",
|
|
120
111
|
"1:a",
|
|
121
|
-
|
|
122
|
-
"libx264",
|
|
123
|
-
"-preset",
|
|
124
|
-
"medium",
|
|
125
|
-
"-crf",
|
|
126
|
-
"18",
|
|
127
|
-
"-c:a",
|
|
128
|
-
"aac",
|
|
129
|
-
"-b:a",
|
|
130
|
-
"192k",
|
|
112
|
+
...encodingArgs,
|
|
131
113
|
"-t",
|
|
132
114
|
String(targetDuration),
|
|
133
115
|
outputPath,
|
|
134
116
|
]);
|
|
135
117
|
}
|
|
136
118
|
|
|
137
|
-
async function processDemo(demoName, paths, padding) {
|
|
119
|
+
async function processDemo(demoName, paths, padding, qualityOptions = {}) {
|
|
138
120
|
console.log(`\n[audio] Processing: ${demoName}\n`);
|
|
121
|
+
logQualitySettings(qualityOptions, "[audio]");
|
|
139
122
|
|
|
140
123
|
const stepsManifestPath = path.join(paths.videosDir, demoName, "steps-manifest.json");
|
|
141
124
|
let stepsManifest;
|
|
@@ -221,7 +204,7 @@ async function processDemo(demoName, paths, padding) {
|
|
|
221
204
|
);
|
|
222
205
|
|
|
223
206
|
try {
|
|
224
|
-
await addAudioToStep(step.videoFile, audioSegment.audioFile, outputPath, padding, nextVideoPath);
|
|
207
|
+
await addAudioToStep(step.videoFile, audioSegment.audioFile, outputPath, padding, nextVideoPath, qualityOptions);
|
|
225
208
|
outputFiles.push(outputPath);
|
|
226
209
|
} catch (error) {
|
|
227
210
|
console.error(`[audio] ${paddedIndex}. ${step.stepId}: ✗ Failed to process`);
|
|
@@ -254,7 +237,7 @@ async function processDemo(demoName, paths, padding) {
|
|
|
254
237
|
console.log(`[audio] Output: ${path.join(paths.videosDir, demoName)}`);
|
|
255
238
|
}
|
|
256
239
|
|
|
257
|
-
async function processAll(paths, padding) {
|
|
240
|
+
async function processAll(paths, padding, qualityOptions = {}) {
|
|
258
241
|
console.log("\n[audio] Processing all demos...\n");
|
|
259
242
|
|
|
260
243
|
try {
|
|
@@ -285,7 +268,7 @@ async function processAll(paths, padding) {
|
|
|
285
268
|
console.log(`[audio] Found ${demosToProcess.length} demo(s) to process\n`);
|
|
286
269
|
|
|
287
270
|
for (const demo of demosToProcess) {
|
|
288
|
-
await processDemo(demo, paths, padding);
|
|
271
|
+
await processDemo(demo, paths, padding, qualityOptions);
|
|
289
272
|
}
|
|
290
273
|
|
|
291
274
|
await fs.rm(paths.tempDir, { recursive: true, force: true });
|
|
@@ -318,15 +301,16 @@ export async function runAddAudioCommand(argv) {
|
|
|
318
301
|
|
|
319
302
|
const rootDir = resolveRoot(root);
|
|
320
303
|
const paths = await getOutputPaths(rootDir, outputDir);
|
|
304
|
+
const qualityOptions = parseQualityArgs(args, getFlagValue, hasFlag);
|
|
321
305
|
|
|
322
306
|
if (demo) {
|
|
323
|
-
await processDemo(demo, paths, padding);
|
|
307
|
+
await processDemo(demo, paths, padding, qualityOptions);
|
|
324
308
|
await fs.rm(paths.tempDir, { recursive: true, force: true });
|
|
325
309
|
return;
|
|
326
310
|
}
|
|
327
311
|
|
|
328
312
|
if (all) {
|
|
329
|
-
await processAll(paths, padding);
|
|
313
|
+
await processAll(paths, padding, qualityOptions);
|
|
330
314
|
return;
|
|
331
315
|
}
|
|
332
316
|
|
|
@@ -3,6 +3,14 @@ import * as path from "path";
|
|
|
3
3
|
import { checkFFmpeg, getMediaDuration, runFFmpeg } from "../utils/media.js";
|
|
4
4
|
import { getFlagValue, hasFlag } from "../utils/args.js";
|
|
5
5
|
import { getOutputPaths, resolveRoot, readJson, toAbsolute } from "../utils/paths.js";
|
|
6
|
+
import {
|
|
7
|
+
getVideoEncodingArgs,
|
|
8
|
+
parseQualityArgs,
|
|
9
|
+
getQualityHelpText,
|
|
10
|
+
logQualitySettings,
|
|
11
|
+
DEFAULT_AUDIO_CODEC,
|
|
12
|
+
DEFAULT_AUDIO_BITRATE,
|
|
13
|
+
} from "../utils/quality.js";
|
|
6
14
|
|
|
7
15
|
function printHelp() {
|
|
8
16
|
console.log(`
|
|
@@ -24,12 +32,14 @@ Options:
|
|
|
24
32
|
--root <path> Project root (defaults to cwd)
|
|
25
33
|
--output-dir <path> Output directory (defaults to e2e/output or output)
|
|
26
34
|
--help, -h Show this help message
|
|
35
|
+
${getQualityHelpText()}
|
|
27
36
|
|
|
28
37
|
Examples:
|
|
29
38
|
sceneforge concat --demo create-quote
|
|
39
|
+
sceneforge concat --demo create-quote --quality high
|
|
30
40
|
sceneforge concat --demo create-quote --intro intro.mp4 --outro outro.mp4
|
|
31
41
|
sceneforge concat --demo create-quote --music background.mp3 --music-volume 0.2
|
|
32
|
-
sceneforge concat --all
|
|
42
|
+
sceneforge concat --all --codec libx265
|
|
33
43
|
`);
|
|
34
44
|
}
|
|
35
45
|
|
|
@@ -55,7 +65,7 @@ async function loadMediaConfig(demoName, paths, rootDir) {
|
|
|
55
65
|
}
|
|
56
66
|
}
|
|
57
67
|
|
|
58
|
-
async function buildConcatWithIntroOutro(stepFiles, demoDir, introPath, outroPath, outputPath) {
|
|
68
|
+
async function buildConcatWithIntroOutro(stepFiles, demoDir, introPath, outroPath, outputPath, qualityOptions = {}) {
|
|
59
69
|
const allInputs = [];
|
|
60
70
|
const inputPaths = [];
|
|
61
71
|
let inputIndex = 0;
|
|
@@ -83,6 +93,7 @@ async function buildConcatWithIntroOutro(stepFiles, demoDir, introPath, outroPat
|
|
|
83
93
|
|
|
84
94
|
const concatInputs = inputPaths.map(({ index }) => `[${index}:v:0][${index}:a:0]`).join("");
|
|
85
95
|
const filterGraph = `${concatInputs}concat=n=${inputPaths.length}:v=1:a=1[outv][outa]`;
|
|
96
|
+
const encodingArgs = getVideoEncodingArgs(qualityOptions);
|
|
86
97
|
|
|
87
98
|
await runFFmpeg([
|
|
88
99
|
"-y",
|
|
@@ -93,16 +104,7 @@ async function buildConcatWithIntroOutro(stepFiles, demoDir, introPath, outroPat
|
|
|
93
104
|
"[outv]",
|
|
94
105
|
"-map",
|
|
95
106
|
"[outa]",
|
|
96
|
-
|
|
97
|
-
"libx264",
|
|
98
|
-
"-preset",
|
|
99
|
-
"medium",
|
|
100
|
-
"-crf",
|
|
101
|
-
"18",
|
|
102
|
-
"-c:a",
|
|
103
|
-
"aac",
|
|
104
|
-
"-b:a",
|
|
105
|
-
"192k",
|
|
107
|
+
...encodingArgs,
|
|
106
108
|
"-movflags",
|
|
107
109
|
"+faststart",
|
|
108
110
|
outputPath,
|
|
@@ -194,9 +196,9 @@ async function addBackgroundMusic(videoPath, musicPath, outputPath, options = {}
|
|
|
194
196
|
"-c:v",
|
|
195
197
|
"copy",
|
|
196
198
|
"-c:a",
|
|
197
|
-
|
|
199
|
+
DEFAULT_AUDIO_CODEC,
|
|
198
200
|
"-b:a",
|
|
199
|
-
|
|
201
|
+
DEFAULT_AUDIO_BITRATE,
|
|
200
202
|
"-movflags",
|
|
201
203
|
"+faststart",
|
|
202
204
|
outputPath,
|
|
@@ -205,6 +207,9 @@ async function addBackgroundMusic(videoPath, musicPath, outputPath, options = {}
|
|
|
205
207
|
|
|
206
208
|
async function concatDemo(demoName, paths, options = {}) {
|
|
207
209
|
console.log(`\n[concat] Processing: ${demoName}\n`);
|
|
210
|
+
if (options.qualityOptions) {
|
|
211
|
+
logQualitySettings(options.qualityOptions, "[concat]");
|
|
212
|
+
}
|
|
208
213
|
|
|
209
214
|
const { rootDir, introOverride, outroOverride, musicOverride, musicOptions = {} } = options;
|
|
210
215
|
const demoDir = path.join(paths.videosDir, demoName);
|
|
@@ -291,19 +296,23 @@ async function concatDemo(demoName, paths, options = {}) {
|
|
|
291
296
|
|
|
292
297
|
console.log("[concat] Concatenating clips...");
|
|
293
298
|
|
|
299
|
+
const qualityOptions = options.qualityOptions || {};
|
|
300
|
+
|
|
294
301
|
if (hasIntroOutro) {
|
|
295
302
|
await buildConcatWithIntroOutro(
|
|
296
303
|
stepFiles,
|
|
297
304
|
demoDir,
|
|
298
305
|
introPath,
|
|
299
306
|
outroPath,
|
|
300
|
-
hasMusic ? tempConcatPath : outputPath
|
|
307
|
+
hasMusic ? tempConcatPath : outputPath,
|
|
308
|
+
qualityOptions
|
|
301
309
|
);
|
|
302
310
|
} else {
|
|
303
311
|
// Original concatenation logic for steps only
|
|
304
312
|
const inputArgs = stepFiles.flatMap((file) => ["-i", path.join(demoDir, file)]);
|
|
305
313
|
const concatInputs = stepFiles.map((_, index) => `[${index}:v:0][${index}:a:0]`).join("");
|
|
306
314
|
const filterGraph = `${concatInputs}concat=n=${stepFiles.length}:v=1:a=1[outv][outa]`;
|
|
315
|
+
const encodingArgs = getVideoEncodingArgs(qualityOptions);
|
|
307
316
|
|
|
308
317
|
await runFFmpeg([
|
|
309
318
|
"-y",
|
|
@@ -314,16 +323,7 @@ async function concatDemo(demoName, paths, options = {}) {
|
|
|
314
323
|
"[outv]",
|
|
315
324
|
"-map",
|
|
316
325
|
"[outa]",
|
|
317
|
-
|
|
318
|
-
"libx264",
|
|
319
|
-
"-preset",
|
|
320
|
-
"medium",
|
|
321
|
-
"-crf",
|
|
322
|
-
"18",
|
|
323
|
-
"-c:a",
|
|
324
|
-
"aac",
|
|
325
|
-
"-b:a",
|
|
326
|
-
"192k",
|
|
326
|
+
...encodingArgs,
|
|
327
327
|
"-movflags",
|
|
328
328
|
"+faststart",
|
|
329
329
|
hasMusic ? tempConcatPath : outputPath,
|
|
@@ -456,6 +456,7 @@ export async function runConcatCommand(argv) {
|
|
|
456
456
|
|
|
457
457
|
const rootDir = resolveRoot(root);
|
|
458
458
|
const paths = await getOutputPaths(rootDir, outputDir);
|
|
459
|
+
const qualityOptions = parseQualityArgs(args, getFlagValue, hasFlag);
|
|
459
460
|
|
|
460
461
|
const options = {
|
|
461
462
|
rootDir,
|
|
@@ -468,6 +469,7 @@ export async function runConcatCommand(argv) {
|
|
|
468
469
|
fadeIn: musicFadeIn,
|
|
469
470
|
fadeOut: musicFadeOut,
|
|
470
471
|
},
|
|
472
|
+
qualityOptions,
|
|
471
473
|
};
|
|
472
474
|
|
|
473
475
|
if (demo) {
|
package/cli/commands/pipeline.js
CHANGED
|
@@ -3,6 +3,7 @@ import * as path from "path";
|
|
|
3
3
|
import { loadDemoDefinition } from "@t3lnet/sceneforge";
|
|
4
4
|
import { getFlagValue, hasFlag } from "../utils/args.js";
|
|
5
5
|
import { ensureDir, getOutputPaths, resolveRoot, toAbsolute } from "../utils/paths.js";
|
|
6
|
+
import { getQualityHelpText } from "../utils/quality.js";
|
|
6
7
|
import { runRecordDemoCommand } from "./record-demo.js";
|
|
7
8
|
import { runSplitVideoCommand } from "./split-video.js";
|
|
8
9
|
import { runGenerateVoiceoverCommand } from "./generate-voiceover.js";
|
|
@@ -40,6 +41,7 @@ Media options (for final video):
|
|
|
40
41
|
--music-loop Loop music if shorter than video
|
|
41
42
|
--music-fade-in <s> Fade in duration for music (default: 1)
|
|
42
43
|
--music-fade-out <s> Fade out duration for music (default: 2)
|
|
44
|
+
${getQualityHelpText()}
|
|
43
45
|
|
|
44
46
|
--help, -h Show this help message
|
|
45
47
|
|
|
@@ -48,6 +50,7 @@ Examples:
|
|
|
48
50
|
sceneforge pipeline --demo create-quote --definitions-dir examples --base-url http://localhost:5173 --clean
|
|
49
51
|
sceneforge pipeline --demo create-quote --output-dir output --resume --progress
|
|
50
52
|
sceneforge pipeline --demo create-quote --intro assets/intro.mp4 --music assets/bg-music.mp3
|
|
53
|
+
sceneforge pipeline --demo create-quote --base-url http://localhost:5173 --quality high
|
|
51
54
|
`);
|
|
52
55
|
}
|
|
53
56
|
|
|
@@ -192,6 +195,11 @@ export async function runPipelineCommand(argv) {
|
|
|
192
195
|
const musicFadeIn = getFlagValue(args, "--music-fade-in");
|
|
193
196
|
const musicFadeOut = getFlagValue(args, "--music-fade-out");
|
|
194
197
|
|
|
198
|
+
// Video quality options
|
|
199
|
+
const quality = getFlagValue(args, "--quality");
|
|
200
|
+
const crf = getFlagValue(args, "--crf");
|
|
201
|
+
const codec = getFlagValue(args, "--codec");
|
|
202
|
+
|
|
195
203
|
const rootDir = resolveRoot(root);
|
|
196
204
|
const outputPaths = await getOutputPaths(rootDir, outputDir);
|
|
197
205
|
|
|
@@ -232,6 +240,12 @@ export async function runPipelineCommand(argv) {
|
|
|
232
240
|
if (outro) console.log(` - Outro: ${outro}`);
|
|
233
241
|
if (music) console.log(` - Music: ${music}`);
|
|
234
242
|
}
|
|
243
|
+
if (quality || crf || codec) {
|
|
244
|
+
console.log("\nVideo quality options:");
|
|
245
|
+
if (quality) console.log(` - Quality preset: ${quality}`);
|
|
246
|
+
if (crf) console.log(` - CRF: ${crf}`);
|
|
247
|
+
if (codec) console.log(` - Codec: ${codec}`);
|
|
248
|
+
}
|
|
235
249
|
return;
|
|
236
250
|
}
|
|
237
251
|
|
|
@@ -267,8 +281,20 @@ export async function runPipelineCommand(argv) {
|
|
|
267
281
|
sharedArgs.push("--output-dir", outputDir);
|
|
268
282
|
}
|
|
269
283
|
|
|
284
|
+
// Build quality args to pass through to video processing commands
|
|
285
|
+
const qualityArgs = [];
|
|
286
|
+
if (quality) {
|
|
287
|
+
qualityArgs.push("--quality", quality);
|
|
288
|
+
}
|
|
289
|
+
if (crf) {
|
|
290
|
+
qualityArgs.push("--crf", crf);
|
|
291
|
+
}
|
|
292
|
+
if (codec) {
|
|
293
|
+
qualityArgs.push("--codec", codec);
|
|
294
|
+
}
|
|
295
|
+
|
|
270
296
|
await runStep("split", plan.split, () =>
|
|
271
|
-
runSplitVideoCommand(["--demo", demoName, ...sharedArgs])
|
|
297
|
+
runSplitVideoCommand(["--demo", demoName, ...sharedArgs, ...qualityArgs])
|
|
272
298
|
);
|
|
273
299
|
|
|
274
300
|
const voiceArgs = ["--demo", demoName, ...sharedArgs];
|
|
@@ -280,14 +306,14 @@ export async function runPipelineCommand(argv) {
|
|
|
280
306
|
}
|
|
281
307
|
await runStep("voiceover", plan.voiceover, () => runGenerateVoiceoverCommand(voiceArgs));
|
|
282
308
|
|
|
283
|
-
const audioArgs = ["--demo", demoName, ...sharedArgs];
|
|
309
|
+
const audioArgs = ["--demo", demoName, ...sharedArgs, ...qualityArgs];
|
|
284
310
|
if (padding) {
|
|
285
311
|
audioArgs.push("--padding", padding);
|
|
286
312
|
}
|
|
287
313
|
await runStep("add-audio", plan.addAudio, () => runAddAudioCommand(audioArgs));
|
|
288
314
|
|
|
289
|
-
// Build concat args with media options
|
|
290
|
-
const concatArgs = ["--demo", demoName, ...sharedArgs];
|
|
315
|
+
// Build concat args with media options and quality settings
|
|
316
|
+
const concatArgs = ["--demo", demoName, ...sharedArgs, ...qualityArgs];
|
|
291
317
|
if (intro) {
|
|
292
318
|
concatArgs.push("--intro", intro);
|
|
293
319
|
}
|
|
@@ -4,6 +4,12 @@ import { checkFFmpeg, getMediaDuration, runFFmpeg } from "../utils/media.js";
|
|
|
4
4
|
import { getOutputPaths, readJson, resolveRoot } from "../utils/paths.js";
|
|
5
5
|
import { getFlagValue, hasFlag } from "../utils/args.js";
|
|
6
6
|
import { sanitizeFileSegment } from "../utils/sanitize.js";
|
|
7
|
+
import {
|
|
8
|
+
getVideoEncodingArgs,
|
|
9
|
+
parseQualityArgs,
|
|
10
|
+
getQualityHelpText,
|
|
11
|
+
logQualitySettings,
|
|
12
|
+
} from "../utils/quality.js";
|
|
7
13
|
|
|
8
14
|
function printHelp() {
|
|
9
15
|
console.log(`
|
|
@@ -18,9 +24,12 @@ Options:
|
|
|
18
24
|
--root <path> Project root (defaults to cwd)
|
|
19
25
|
--output-dir <path> Output directory (defaults to e2e/output or output)
|
|
20
26
|
--help, -h Show this help message
|
|
27
|
+
${getQualityHelpText()}
|
|
21
28
|
|
|
22
29
|
Examples:
|
|
23
30
|
sceneforge split --demo create-quote
|
|
31
|
+
sceneforge split --demo create-quote --quality high
|
|
32
|
+
sceneforge split --demo create-quote --crf 15 --codec libx265
|
|
24
33
|
sceneforge split --all
|
|
25
34
|
`);
|
|
26
35
|
}
|
|
@@ -54,8 +63,9 @@ async function findVideoFile(demoName, videosDir, testResultsDir) {
|
|
|
54
63
|
return null;
|
|
55
64
|
}
|
|
56
65
|
|
|
57
|
-
async function splitDemo(demoName, paths) {
|
|
66
|
+
async function splitDemo(demoName, paths, qualityOptions = {}) {
|
|
58
67
|
console.log(`\n[split] Processing: ${demoName}\n`);
|
|
68
|
+
logQualitySettings(qualityOptions, "[split]");
|
|
59
69
|
|
|
60
70
|
const scriptPath = path.join(paths.scriptsDir, `${demoName}.json`);
|
|
61
71
|
let script;
|
|
@@ -116,6 +126,7 @@ async function splitDemo(demoName, paths) {
|
|
|
116
126
|
);
|
|
117
127
|
|
|
118
128
|
try {
|
|
129
|
+
const encodingArgs = getVideoEncodingArgs({ ...qualityOptions, includeAudio: false });
|
|
119
130
|
await runFFmpeg([
|
|
120
131
|
"-y",
|
|
121
132
|
"-i",
|
|
@@ -124,12 +135,7 @@ async function splitDemo(demoName, paths) {
|
|
|
124
135
|
String(startSec),
|
|
125
136
|
"-t",
|
|
126
137
|
String(duration),
|
|
127
|
-
|
|
128
|
-
"libx264",
|
|
129
|
-
"-preset",
|
|
130
|
-
"medium",
|
|
131
|
-
"-crf",
|
|
132
|
-
"18",
|
|
138
|
+
...encodingArgs,
|
|
133
139
|
"-an",
|
|
134
140
|
outputPath,
|
|
135
141
|
]);
|
|
@@ -175,7 +181,7 @@ async function splitDemo(demoName, paths) {
|
|
|
175
181
|
console.log(`[split] Manifest: ${manifestPath}`);
|
|
176
182
|
}
|
|
177
183
|
|
|
178
|
-
async function splitAll(paths) {
|
|
184
|
+
async function splitAll(paths, qualityOptions = {}) {
|
|
179
185
|
console.log("\n[split] Processing all demos...\n");
|
|
180
186
|
|
|
181
187
|
try {
|
|
@@ -193,7 +199,7 @@ async function splitAll(paths) {
|
|
|
193
199
|
|
|
194
200
|
for (const file of scriptFiles) {
|
|
195
201
|
const demoName = path.basename(file, ".json");
|
|
196
|
-
await splitDemo(demoName, paths);
|
|
202
|
+
await splitDemo(demoName, paths, qualityOptions);
|
|
197
203
|
}
|
|
198
204
|
|
|
199
205
|
console.log("\n[split] All demos processed!");
|
|
@@ -223,14 +229,15 @@ export async function runSplitVideoCommand(argv) {
|
|
|
223
229
|
|
|
224
230
|
const rootDir = resolveRoot(root);
|
|
225
231
|
const paths = await getOutputPaths(rootDir, outputDir);
|
|
232
|
+
const qualityOptions = parseQualityArgs(args, getFlagValue, hasFlag);
|
|
226
233
|
|
|
227
234
|
if (demo) {
|
|
228
|
-
await splitDemo(demo, paths);
|
|
235
|
+
await splitDemo(demo, paths, qualityOptions);
|
|
229
236
|
return;
|
|
230
237
|
}
|
|
231
238
|
|
|
232
239
|
if (all) {
|
|
233
|
-
await splitAll(paths);
|
|
240
|
+
await splitAll(paths, qualityOptions);
|
|
234
241
|
return;
|
|
235
242
|
}
|
|
236
243
|
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Video quality configuration for FFmpeg encoding
|
|
3
|
+
*
|
|
4
|
+
* Quality Presets:
|
|
5
|
+
* - low: Fast encoding, smaller files, suitable for drafts
|
|
6
|
+
* - medium: Balanced quality and file size (default)
|
|
7
|
+
* - high: Best quality, larger files, suitable for final delivery
|
|
8
|
+
*
|
|
9
|
+
* Supported Codecs:
|
|
10
|
+
* - libx264: H.264, excellent compatibility (default)
|
|
11
|
+
* - libx265: H.265/HEVC, ~50% smaller files, slower encoding
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const QUALITY_PRESETS = {
|
|
15
|
+
low: {
|
|
16
|
+
crf: 28,
|
|
17
|
+
preset: "fast",
|
|
18
|
+
description: "Fast encoding, smaller files, suitable for drafts",
|
|
19
|
+
},
|
|
20
|
+
medium: {
|
|
21
|
+
crf: 18,
|
|
22
|
+
preset: "medium",
|
|
23
|
+
description: "Balanced quality and file size (default)",
|
|
24
|
+
},
|
|
25
|
+
high: {
|
|
26
|
+
crf: 10,
|
|
27
|
+
preset: "slow",
|
|
28
|
+
description: "Best quality, larger files, suitable for final delivery",
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export const SUPPORTED_CODECS = {
|
|
33
|
+
libx264: {
|
|
34
|
+
name: "H.264",
|
|
35
|
+
description: "Excellent compatibility, plays everywhere",
|
|
36
|
+
crfRange: "0-51 (lower = better)",
|
|
37
|
+
},
|
|
38
|
+
libx265: {
|
|
39
|
+
name: "H.265/HEVC",
|
|
40
|
+
description: "~50% smaller files, slower encoding, good compatibility",
|
|
41
|
+
crfRange: "0-51 (lower = better)",
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_CODEC = "libx264";
|
|
46
|
+
export const DEFAULT_QUALITY = "medium";
|
|
47
|
+
export const DEFAULT_AUDIO_CODEC = "aac";
|
|
48
|
+
export const DEFAULT_AUDIO_BITRATE = "192k";
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get video encoding arguments for FFmpeg
|
|
52
|
+
* @param {Object} options
|
|
53
|
+
* @param {string} [options.quality] - Quality preset: low, medium, high
|
|
54
|
+
* @param {number} [options.crf] - Override CRF value (0-51, lower = better)
|
|
55
|
+
* @param {string} [options.codec] - Video codec: libx264, libx265
|
|
56
|
+
* @param {boolean} [options.includeAudio] - Include audio encoding args
|
|
57
|
+
* @returns {string[]} FFmpeg arguments for video encoding
|
|
58
|
+
*/
|
|
59
|
+
export function getVideoEncodingArgs(options = {}) {
|
|
60
|
+
const {
|
|
61
|
+
quality = DEFAULT_QUALITY,
|
|
62
|
+
crf: crfOverride,
|
|
63
|
+
codec = DEFAULT_CODEC,
|
|
64
|
+
includeAudio = true,
|
|
65
|
+
} = options;
|
|
66
|
+
|
|
67
|
+
const preset = QUALITY_PRESETS[quality] || QUALITY_PRESETS[DEFAULT_QUALITY];
|
|
68
|
+
const crf = crfOverride !== undefined ? crfOverride : preset.crf;
|
|
69
|
+
const encodingPreset = preset.preset;
|
|
70
|
+
|
|
71
|
+
const args = ["-c:v", codec, "-preset", encodingPreset, "-crf", String(crf)];
|
|
72
|
+
|
|
73
|
+
if (includeAudio) {
|
|
74
|
+
args.push("-c:a", DEFAULT_AUDIO_CODEC, "-b:a", DEFAULT_AUDIO_BITRATE);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return args;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Parse quality-related CLI arguments
|
|
82
|
+
* @param {string[]} args - CLI arguments
|
|
83
|
+
* @param {Function} getFlagValue - Function to get flag values
|
|
84
|
+
* @param {Function} hasFlag - Function to check flag presence
|
|
85
|
+
* @returns {Object} Parsed quality options
|
|
86
|
+
*/
|
|
87
|
+
export function parseQualityArgs(args, getFlagValue, hasFlag) {
|
|
88
|
+
const quality = getFlagValue(args, "--quality") || DEFAULT_QUALITY;
|
|
89
|
+
const crfValue = getFlagValue(args, "--crf");
|
|
90
|
+
const codec = getFlagValue(args, "--codec") || DEFAULT_CODEC;
|
|
91
|
+
|
|
92
|
+
// Validate quality preset
|
|
93
|
+
if (!QUALITY_PRESETS[quality]) {
|
|
94
|
+
console.warn(
|
|
95
|
+
`[warning] Unknown quality preset "${quality}", using "${DEFAULT_QUALITY}"`
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Validate codec
|
|
100
|
+
if (!SUPPORTED_CODECS[codec]) {
|
|
101
|
+
console.warn(
|
|
102
|
+
`[warning] Unknown codec "${codec}", using "${DEFAULT_CODEC}"`
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
quality: QUALITY_PRESETS[quality] ? quality : DEFAULT_QUALITY,
|
|
108
|
+
crf: crfValue !== undefined ? parseInt(crfValue, 10) : undefined,
|
|
109
|
+
codec: SUPPORTED_CODECS[codec] ? codec : DEFAULT_CODEC,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Get help text for quality options
|
|
115
|
+
* @returns {string} Help text for quality CLI options
|
|
116
|
+
*/
|
|
117
|
+
export function getQualityHelpText() {
|
|
118
|
+
return `
|
|
119
|
+
Video Quality Options:
|
|
120
|
+
--quality <preset> Quality preset: low, medium, high (default: medium)
|
|
121
|
+
- low: CRF 28, fast preset (smaller files, quick encoding)
|
|
122
|
+
- medium: CRF 18, medium preset (balanced)
|
|
123
|
+
- high: CRF 10, slow preset (best quality, larger files)
|
|
124
|
+
--crf <value> Override CRF value (0-51, lower = better quality)
|
|
125
|
+
--codec <codec> Video codec: libx264, libx265 (default: libx264)
|
|
126
|
+
- libx264: H.264, excellent compatibility
|
|
127
|
+
- libx265: H.265/HEVC, ~50% smaller files`;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Log the quality settings being used
|
|
132
|
+
* @param {Object} options - Quality options
|
|
133
|
+
* @param {string} prefix - Log prefix (e.g., "[split]")
|
|
134
|
+
*/
|
|
135
|
+
export function logQualitySettings(options, prefix = "") {
|
|
136
|
+
const { quality, crf, codec } = options;
|
|
137
|
+
const preset = QUALITY_PRESETS[quality] || QUALITY_PRESETS[DEFAULT_QUALITY];
|
|
138
|
+
const effectiveCrf = crf !== undefined ? crf : preset.crf;
|
|
139
|
+
const codecInfo = SUPPORTED_CODECS[codec] || SUPPORTED_CODECS[DEFAULT_CODEC];
|
|
140
|
+
|
|
141
|
+
console.log(
|
|
142
|
+
`${prefix} Quality: ${quality} (CRF ${effectiveCrf}, ${preset.preset} preset, ${codecInfo.name})`
|
|
143
|
+
);
|
|
144
|
+
}
|