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