@t3lnet/sceneforge 1.0.15 → 1.0.17

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 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 --all
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
- "-c:v",
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
- "-c:v",
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
- "-c:v",
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
- "-c:v",
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
- "aac",
199
+ DEFAULT_AUDIO_CODEC,
198
200
  "-b:a",
199
- "192k",
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
- "-c:v",
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) {
@@ -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
- "-c:v",
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,154 @@
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
+ // Only use crfOverride if it's a valid finite number
69
+ const crf = (crfOverride !== undefined && Number.isFinite(crfOverride)) ? crfOverride : preset.crf;
70
+ const encodingPreset = preset.preset;
71
+
72
+ const args = ["-c:v", codec, "-preset", encodingPreset, "-crf", String(crf)];
73
+
74
+ if (includeAudio) {
75
+ args.push("-c:a", DEFAULT_AUDIO_CODEC, "-b:a", DEFAULT_AUDIO_BITRATE);
76
+ }
77
+
78
+ return args;
79
+ }
80
+
81
+ /**
82
+ * Parse quality-related CLI arguments
83
+ * @param {string[]} args - CLI arguments
84
+ * @param {Function} getFlagValue - Function to get flag values
85
+ * @param {Function} hasFlag - Function to check flag presence
86
+ * @returns {Object} Parsed quality options
87
+ */
88
+ export function parseQualityArgs(args, getFlagValue, hasFlag) {
89
+ const quality = getFlagValue(args, "--quality") || DEFAULT_QUALITY;
90
+ const crfValue = getFlagValue(args, "--crf");
91
+ const codec = getFlagValue(args, "--codec") || DEFAULT_CODEC;
92
+
93
+ // Validate quality preset
94
+ if (!QUALITY_PRESETS[quality]) {
95
+ console.warn(
96
+ `[warning] Unknown quality preset "${quality}", using "${DEFAULT_QUALITY}"`
97
+ );
98
+ }
99
+
100
+ // Validate codec
101
+ if (!SUPPORTED_CODECS[codec]) {
102
+ console.warn(
103
+ `[warning] Unknown codec "${codec}", using "${DEFAULT_CODEC}"`
104
+ );
105
+ }
106
+
107
+ // Parse CRF value, only set if it's a valid number
108
+ let crf = undefined;
109
+ if (crfValue !== undefined && crfValue !== null && crfValue !== "") {
110
+ const parsed = parseInt(crfValue, 10);
111
+ if (!Number.isNaN(parsed)) {
112
+ crf = parsed;
113
+ }
114
+ }
115
+
116
+ return {
117
+ quality: QUALITY_PRESETS[quality] ? quality : DEFAULT_QUALITY,
118
+ crf,
119
+ codec: SUPPORTED_CODECS[codec] ? codec : DEFAULT_CODEC,
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Get help text for quality options
125
+ * @returns {string} Help text for quality CLI options
126
+ */
127
+ export function getQualityHelpText() {
128
+ return `
129
+ Video Quality Options:
130
+ --quality <preset> Quality preset: low, medium, high (default: medium)
131
+ - low: CRF 28, fast preset (smaller files, quick encoding)
132
+ - medium: CRF 18, medium preset (balanced)
133
+ - high: CRF 10, slow preset (best quality, larger files)
134
+ --crf <value> Override CRF value (0-51, lower = better quality)
135
+ --codec <codec> Video codec: libx264, libx265 (default: libx264)
136
+ - libx264: H.264, excellent compatibility
137
+ - libx265: H.265/HEVC, ~50% smaller files`;
138
+ }
139
+
140
+ /**
141
+ * Log the quality settings being used
142
+ * @param {Object} options - Quality options
143
+ * @param {string} prefix - Log prefix (e.g., "[split]")
144
+ */
145
+ export function logQualitySettings(options, prefix = "") {
146
+ const { quality, crf, codec } = options;
147
+ const preset = QUALITY_PRESETS[quality] || QUALITY_PRESETS[DEFAULT_QUALITY];
148
+ const effectiveCrf = crf !== undefined ? crf : preset.crf;
149
+ const codecInfo = SUPPORTED_CODECS[codec] || SUPPORTED_CODECS[DEFAULT_CODEC];
150
+
151
+ console.log(
152
+ `${prefix} Quality: ${quality} (CRF ${effectiveCrf}, ${preset.preset} preset, ${codecInfo.name})`
153
+ );
154
+ }
@@ -103,8 +103,11 @@ sceneforge split --demo <name> [options]
103
103
  |------|-------------|
104
104
  | `--demo` | Demo name (folder in output) |
105
105
  | `--output`, `-o` | Output directory |
106
+ | `--quality` | Quality preset: low, medium, high (default: medium) |
107
+ | `--crf` | Override CRF value (0-51, lower = better) |
108
+ | `--codec` | Video codec: libx264, libx265 (default: libx264) |
106
109
 
107
- **Video Quality:** Encodes with H.264 (libx264), CRF 18, medium preset for high-quality output.
110
+ **Video Quality:** Configurable via `--quality` preset or `--crf`/`--codec` flags. Default: medium preset (CRF 18, libx264).
108
111
 
109
112
  ### voiceover
110
113
  Generate voiceover audio from scripts.
@@ -137,8 +140,11 @@ sceneforge add-audio --demo <name> [options]
137
140
  |------|-------------|
138
141
  | `--demo` | Demo name |
139
142
  | `--output`, `-o` | Output directory |
143
+ | `--quality` | Quality preset: low, medium, high (default: medium) |
144
+ | `--crf` | Override CRF value (0-51, lower = better) |
145
+ | `--codec` | Video codec: libx264, libx265 (default: libx264) |
140
146
 
141
- **Video Quality:** Re-encodes with H.264 (libx264), CRF 18, medium preset. Audio encoded as AAC at 192kbps.
147
+ **Video Quality:** Configurable via `--quality` preset. Default: medium (CRF 18, libx264). Audio: AAC 192kbps.
142
148
 
143
149
  ### concat
144
150
  Concatenate step clips into final video.
@@ -152,8 +158,15 @@ sceneforge concat --demo <name> [options]
152
158
  |------|-------------|
153
159
  | `--demo` | Demo name |
154
160
  | `--output`, `-o` | Output directory |
161
+ | `--quality` | Quality preset: low, medium, high (default: medium) |
162
+ | `--crf` | Override CRF value (0-51, lower = better) |
163
+ | `--codec` | Video codec: libx264, libx265 (default: libx264) |
164
+ | `--intro` | Intro video to prepend |
165
+ | `--outro` | Outro video to append |
166
+ | `--music` | Background music file |
167
+ | `--music-volume` | Music volume 0-1 (default: 0.15) |
155
168
 
156
- **Video Quality:** Final encode with H.264 (libx264), CRF 18, medium preset. Includes `+faststart` for web streaming optimization. Audio encoded as AAC at 192kbps.
169
+ **Video Quality:** Configurable via `--quality` preset. Default: medium (CRF 18, libx264). Includes `+faststart` for web streaming. Audio: AAC 192kbps.
157
170
 
158
171
  ### doctor
159
172
  Run environment diagnostics.
@@ -249,24 +262,55 @@ sceneforge record -d demo.yaml -b http://localhost:3000 --headed --slowmo 500
249
262
 
250
263
  ## Video Quality
251
264
 
252
- SceneForge uses high-quality FFmpeg encoding settings optimized for multi-pass video processing:
265
+ SceneForge provides configurable video quality settings via CLI flags on `split`, `add-audio`, and `concat` commands.
253
266
 
254
- | Setting | Value | Purpose |
255
- |---------|-------|---------|
256
- | Codec | `libx264` | H.264 for broad compatibility |
257
- | Preset | `medium` | Balanced quality/speed tradeoff |
258
- | CRF | `18` | High quality (visually lossless) |
259
- | Audio | `aac @ 192k` | High-quality audio |
260
- | Flags | `+faststart` | Web streaming optimization |
267
+ ### Quality Presets
268
+
269
+ | Preset | CRF | Encoding | Use Case |
270
+ |--------|-----|----------|----------|
271
+ | `low` | 28 | fast | Quick drafts, smaller files |
272
+ | `medium` | 18 | medium | Default - balanced quality and size |
273
+ | `high` | 10 | slow | Final delivery, best quality |
274
+
275
+ ### Supported Codecs
276
+
277
+ | Codec | Name | Description |
278
+ |-------|------|-------------|
279
+ | `libx264` | H.264 | Excellent compatibility (default) |
280
+ | `libx265` | H.265/HEVC | ~50% smaller files, slower encoding |
281
+
282
+ ### CLI Flags
283
+
284
+ ```bash
285
+ --quality <preset> # low, medium, high (default: medium)
286
+ --crf <value> # Override CRF (0-51, lower = better)
287
+ --codec <codec> # libx264 or libx265 (default: libx264)
288
+ ```
289
+
290
+ ### Examples
291
+
292
+ ```bash
293
+ # High quality for final output
294
+ sceneforge concat --demo my-demo --quality high
295
+
296
+ # Smaller files with H.265 codec
297
+ sceneforge concat --demo my-demo --codec libx265
298
+
299
+ # Custom CRF for fine control
300
+ sceneforge split --demo my-demo --crf 15
301
+ ```
302
+
303
+ ### Why Quality Matters
261
304
 
262
- **Why these settings:**
263
305
  - Videos go through multiple processing stages (split → add-audio → concat)
264
306
  - Each re-encoding can degrade quality (generation loss)
265
- - CRF 18 preserves quality across all stages
266
- - Medium preset provides good compression without sacrificing quality
307
+ - Higher quality settings (lower CRF) preserve fidelity across stages
308
+ - The `high` preset (CRF 10) produces near-lossless quality
309
+
310
+ ### CRF Reference
267
311
 
268
- **CRF Reference:**
269
312
  - 0 = Lossless (huge files)
270
- - 18 = Visually lossless (SceneForge default)
313
+ - 10 = Near-lossless (high preset)
314
+ - 18 = Visually lossless (medium preset, default)
271
315
  - 23 = FFmpeg default
272
- - 28+ = Lower quality, smaller files
316
+ - 28 = Lower quality (low preset)
@@ -218,24 +218,26 @@ Aim for variance within ±500ms per step:
218
218
  - [ ] No unexpected pauses
219
219
 
220
220
  ### Video Quality Check
221
- SceneForge uses high-quality encoding settings to preserve video fidelity:
221
+ SceneForge provides configurable quality settings to preserve video fidelity:
222
222
  - [ ] Video appears sharp (no compression artifacts)
223
223
  - [ ] Colors are accurate (no banding)
224
224
  - [ ] Text is readable (no blur from compression)
225
225
  - [ ] No visible quality degradation between steps
226
226
 
227
- **Encoding Settings Used:**
228
- | Setting | Value | Purpose |
229
- |---------|-------|---------|
230
- | Codec | `libx264` | H.264 for compatibility |
231
- | CRF | `18` | High quality (visually lossless) |
232
- | Preset | `medium` | Balanced quality/speed |
233
- | Audio | `aac @ 192k` | High-quality audio |
227
+ **Quality Presets:**
228
+ | Preset | CRF | Encoding | Use Case |
229
+ |--------|-----|----------|----------|
230
+ | `low` | 28 | fast | Quick drafts |
231
+ | `medium` | 18 | medium | Default - balanced |
232
+ | `high` | 10 | slow | Final delivery |
233
+
234
+ **CLI Flags:** `--quality`, `--crf`, `--codec` (available on split, add-audio, concat)
234
235
 
235
236
  **If you notice quality issues:**
236
- 1. Ensure source recording is high quality (check `output/videos/<demo>.webm`)
237
- 2. Re-run the pipeline to regenerate all clips
238
- 3. Check available disk space (low space can affect encoding)
237
+ 1. Re-run with `--quality high` for better fidelity
238
+ 2. Ensure source recording is high quality (check `output/videos/<demo>.webm`)
239
+ 3. Try `--codec libx265` for better compression at same quality
240
+ 4. Check available disk space (low space can affect encoding)
239
241
 
240
242
  ## Final Checklist
241
243
 
@@ -103,8 +103,11 @@ sceneforge split --demo <name> [options]
103
103
  |------|-------------|
104
104
  | `--demo` | Demo name (folder in output) |
105
105
  | `--output`, `-o` | Output directory |
106
+ | `--quality` | Quality preset: low, medium, high (default: medium) |
107
+ | `--crf` | Override CRF value (0-51, lower = better) |
108
+ | `--codec` | Video codec: libx264, libx265 (default: libx264) |
106
109
 
107
- **Video Quality:** Encodes with H.264 (libx264), CRF 18, medium preset for high-quality output.
110
+ **Video Quality:** Configurable via `--quality` preset or `--crf`/`--codec` flags. Default: medium preset (CRF 18, libx264).
108
111
 
109
112
  ### voiceover
110
113
  Generate voiceover audio from scripts.
@@ -137,8 +140,11 @@ sceneforge add-audio --demo <name> [options]
137
140
  |------|-------------|
138
141
  | `--demo` | Demo name |
139
142
  | `--output`, `-o` | Output directory |
143
+ | `--quality` | Quality preset: low, medium, high (default: medium) |
144
+ | `--crf` | Override CRF value (0-51, lower = better) |
145
+ | `--codec` | Video codec: libx264, libx265 (default: libx264) |
140
146
 
141
- **Video Quality:** Re-encodes with H.264 (libx264), CRF 18, medium preset. Audio encoded as AAC at 192kbps.
147
+ **Video Quality:** Configurable via `--quality` preset. Default: medium (CRF 18, libx264). Audio: AAC 192kbps.
142
148
 
143
149
  ### concat
144
150
  Concatenate step clips into final video.
@@ -152,8 +158,15 @@ sceneforge concat --demo <name> [options]
152
158
  |------|-------------|
153
159
  | `--demo` | Demo name |
154
160
  | `--output`, `-o` | Output directory |
161
+ | `--quality` | Quality preset: low, medium, high (default: medium) |
162
+ | `--crf` | Override CRF value (0-51, lower = better) |
163
+ | `--codec` | Video codec: libx264, libx265 (default: libx264) |
164
+ | `--intro` | Intro video to prepend |
165
+ | `--outro` | Outro video to append |
166
+ | `--music` | Background music file |
167
+ | `--music-volume` | Music volume 0-1 (default: 0.15) |
155
168
 
156
- **Video Quality:** Final encode with H.264 (libx264), CRF 18, medium preset. Includes `+faststart` for web streaming optimization. Audio encoded as AAC at 192kbps.
169
+ **Video Quality:** Configurable via `--quality` preset. Default: medium (CRF 18, libx264). Includes `+faststart` for web streaming. Audio: AAC 192kbps.
157
170
 
158
171
  ### doctor
159
172
  Run environment diagnostics.
@@ -249,24 +262,55 @@ sceneforge record -d demo.yaml -b http://localhost:3000 --headed --slowmo 500
249
262
 
250
263
  ## Video Quality
251
264
 
252
- SceneForge uses high-quality FFmpeg encoding settings optimized for multi-pass video processing:
265
+ SceneForge provides configurable video quality settings via CLI flags on `split`, `add-audio`, and `concat` commands.
253
266
 
254
- | Setting | Value | Purpose |
255
- |---------|-------|---------|
256
- | Codec | `libx264` | H.264 for broad compatibility |
257
- | Preset | `medium` | Balanced quality/speed tradeoff |
258
- | CRF | `18` | High quality (visually lossless) |
259
- | Audio | `aac @ 192k` | High-quality audio |
260
- | Flags | `+faststart` | Web streaming optimization |
267
+ ### Quality Presets
268
+
269
+ | Preset | CRF | Encoding | Use Case |
270
+ |--------|-----|----------|----------|
271
+ | `low` | 28 | fast | Quick drafts, smaller files |
272
+ | `medium` | 18 | medium | Default - balanced quality and size |
273
+ | `high` | 10 | slow | Final delivery, best quality |
274
+
275
+ ### Supported Codecs
276
+
277
+ | Codec | Name | Description |
278
+ |-------|------|-------------|
279
+ | `libx264` | H.264 | Excellent compatibility (default) |
280
+ | `libx265` | H.265/HEVC | ~50% smaller files, slower encoding |
281
+
282
+ ### CLI Flags
283
+
284
+ ```bash
285
+ --quality <preset> # low, medium, high (default: medium)
286
+ --crf <value> # Override CRF (0-51, lower = better)
287
+ --codec <codec> # libx264 or libx265 (default: libx264)
288
+ ```
289
+
290
+ ### Examples
291
+
292
+ ```bash
293
+ # High quality for final output
294
+ sceneforge concat --demo my-demo --quality high
295
+
296
+ # Smaller files with H.265 codec
297
+ sceneforge concat --demo my-demo --codec libx265
298
+
299
+ # Custom CRF for fine control
300
+ sceneforge split --demo my-demo --crf 15
301
+ ```
302
+
303
+ ### Why Quality Matters
261
304
 
262
- **Why these settings:**
263
305
  - Videos go through multiple processing stages (split → add-audio → concat)
264
306
  - Each re-encoding can degrade quality (generation loss)
265
- - CRF 18 preserves quality across all stages
266
- - Medium preset provides good compression without sacrificing quality
307
+ - Higher quality settings (lower CRF) preserve fidelity across stages
308
+ - The `high` preset (CRF 10) produces near-lossless quality
309
+
310
+ ### CRF Reference
267
311
 
268
- **CRF Reference:**
269
312
  - 0 = Lossless (huge files)
270
- - 18 = Visually lossless (SceneForge default)
313
+ - 10 = Near-lossless (high preset)
314
+ - 18 = Visually lossless (medium preset, default)
271
315
  - 23 = FFmpeg default
272
- - 28+ = Lower quality, smaller files
316
+ - 28 = Lower quality (low preset)
@@ -218,24 +218,26 @@ Aim for variance within ±500ms per step:
218
218
  - [ ] No unexpected pauses
219
219
 
220
220
  ### Video Quality Check
221
- SceneForge uses high-quality encoding settings to preserve video fidelity:
221
+ SceneForge provides configurable quality settings to preserve video fidelity:
222
222
  - [ ] Video appears sharp (no compression artifacts)
223
223
  - [ ] Colors are accurate (no banding)
224
224
  - [ ] Text is readable (no blur from compression)
225
225
  - [ ] No visible quality degradation between steps
226
226
 
227
- **Encoding Settings Used:**
228
- | Setting | Value | Purpose |
229
- |---------|-------|---------|
230
- | Codec | `libx264` | H.264 for compatibility |
231
- | CRF | `18` | High quality (visually lossless) |
232
- | Preset | `medium` | Balanced quality/speed |
233
- | Audio | `aac @ 192k` | High-quality audio |
227
+ **Quality Presets:**
228
+ | Preset | CRF | Encoding | Use Case |
229
+ |--------|-----|----------|----------|
230
+ | `low` | 28 | fast | Quick drafts |
231
+ | `medium` | 18 | medium | Default - balanced |
232
+ | `high` | 10 | slow | Final delivery |
233
+
234
+ **CLI Flags:** `--quality`, `--crf`, `--codec` (available on split, add-audio, concat)
234
235
 
235
236
  **If you notice quality issues:**
236
- 1. Ensure source recording is high quality (check `output/videos/<demo>.webm`)
237
- 2. Re-run the pipeline to regenerate all clips
238
- 3. Check available disk space (low space can affect encoding)
237
+ 1. Re-run with `--quality high` for better fidelity
238
+ 2. Ensure source recording is high quality (check `output/videos/<demo>.webm`)
239
+ 3. Try `--codec libx265` for better compression at same quality
240
+ 4. Check available disk space (low space can affect encoding)
239
241
 
240
242
  ## Final Checklist
241
243
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@t3lnet/sceneforge",
3
- "version": "1.0.15",
3
+ "version": "1.0.17",
4
4
  "description": "SceneForge runner and generation utilities for YAML-driven demos",
5
5
  "license": "MIT",
6
6
  "author": "T3LNET",