rollberry 0.1.9 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,722 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { basename, dirname, resolve } from 'node:path';
3
+ import { MAX_FPS } from './capture/constants.js';
4
+ import { parseCaptureUrl, validateHideSelector } from './capture/utils.js';
5
+ import { CliError, DEFAULT_DURATION, DEFAULT_FPS, DEFAULT_MOTION, DEFAULT_OUT_FILE, DEFAULT_TIMEOUT_MS, DEFAULT_VIEWPORT, deriveSidecarPath, parseMotion, parseNonNegativeNumber, parsePositiveInt, parsePositiveNumber, parseViewport, parseWaitFor, } from './options.js';
6
+ const DEFAULT_TIMELINE_ACTION_HOLD_SECONDS = 0.4;
7
+ const DEFAULT_INTERMEDIATE_VIDEO_ENCODING = {
8
+ preset: 'slow',
9
+ crf: 18,
10
+ };
11
+ const DEFAULT_FINAL_MP4_VIDEO_ENCODING = {
12
+ format: 'mp4',
13
+ preset: 'slow',
14
+ crf: 18,
15
+ };
16
+ const DEFAULT_FINAL_WEBM_VIDEO_ENCODING = {
17
+ format: 'webm',
18
+ deadline: 'good',
19
+ crf: 32,
20
+ };
21
+ export async function loadRenderProject(options) {
22
+ let rawText;
23
+ try {
24
+ rawText = await readFile(options.projectPath, 'utf8');
25
+ }
26
+ catch (error) {
27
+ throw new CliError(error instanceof Error
28
+ ? `Failed to read project file: ${error.message}`
29
+ : 'Failed to read project file.');
30
+ }
31
+ let rawConfig;
32
+ try {
33
+ rawConfig = JSON.parse(rawText);
34
+ }
35
+ catch (error) {
36
+ throw new CliError(error instanceof Error
37
+ ? `Project file is not valid JSON: ${error.message}`
38
+ : 'Project file is not valid JSON.');
39
+ }
40
+ if (!isRecord(rawConfig)) {
41
+ throw new CliError('Project file must contain a JSON object at the top level.');
42
+ }
43
+ const schemaVersion = rawConfig.schemaVersion;
44
+ if (schemaVersion !== undefined && schemaVersion !== 1) {
45
+ throw new CliError(`Unsupported project schemaVersion: ${String(schemaVersion)}`);
46
+ }
47
+ const projectDir = dirname(options.projectPath);
48
+ const projectBaseName = basename(options.projectPath).replace(/\.project\.json$/u, '');
49
+ const defaults = parseDefaults(rawConfig.defaults);
50
+ const scenes = parseScenes(rawConfig.scenes, defaults);
51
+ const outputs = parseOutputs({
52
+ rawOutputs: rawConfig.outputs,
53
+ defaults,
54
+ projectDir,
55
+ projectBaseName: projectBaseName === basename(options.projectPath)
56
+ ? basename(options.projectPath, '.json')
57
+ : projectBaseName,
58
+ force: options.force,
59
+ });
60
+ const selectedOutputs = options.outputNames.length === 0
61
+ ? outputs
62
+ : outputs.filter((output) => options.outputNames.includes(output.name));
63
+ if (selectedOutputs.length === 0) {
64
+ throw new CliError(`No configured outputs matched --output: ${options.outputNames.join(', ')}`);
65
+ }
66
+ return {
67
+ projectPath: options.projectPath,
68
+ projectName: rawConfig.name && typeof rawConfig.name === 'string'
69
+ ? rawConfig.name
70
+ : basename(options.projectPath),
71
+ summaryManifestPath: resolve(projectDir, readOptionalString(rawConfig, 'summaryManifest') ??
72
+ `${projectBaseName}.render-summary.json`),
73
+ timeoutMs: defaults.timeoutMs,
74
+ scenes,
75
+ outputs: selectedOutputs,
76
+ };
77
+ }
78
+ function parseDefaults(rawDefaults) {
79
+ if (rawDefaults !== undefined && !isRecord(rawDefaults)) {
80
+ throw new CliError('"defaults" must be an object.');
81
+ }
82
+ const defaults = rawDefaults ?? {};
83
+ const viewport = parseViewportField(readOptionalString(defaults, 'viewport') ?? DEFAULT_VIEWPORT, 'defaults.viewport');
84
+ const fps = parseFps(readOptionalNumber(defaults, 'fps') ?? DEFAULT_FPS, 'defaults.fps');
85
+ const duration = parseDurationField(defaults, 'defaults.duration', DEFAULT_DURATION);
86
+ const motion = parseMotionField(readOptionalString(defaults, 'motion') ?? DEFAULT_MOTION, 'defaults.motion');
87
+ const timeoutMs = parsePositiveIntField(readOptionalNumber(defaults, 'timeoutMs') ?? DEFAULT_TIMEOUT_MS, 'defaults.timeoutMs');
88
+ const waitFor = parseWaitForField(readOptionalString(defaults, 'waitFor') ?? 'load', 'defaults.waitFor');
89
+ const hideSelectors = parseHideSelectors(defaults.hideSelectors, 'defaults.hideSelectors');
90
+ const holdAfterSeconds = parseNonNegativeField(readOptionalNumber(defaults, 'holdAfterSeconds') ?? 0, 'defaults.holdAfterSeconds');
91
+ return {
92
+ viewport,
93
+ fps,
94
+ duration,
95
+ motion,
96
+ timeoutMs,
97
+ waitFor,
98
+ hideSelectors,
99
+ holdAfterSeconds,
100
+ };
101
+ }
102
+ function parseScenes(rawScenes, defaults) {
103
+ if (!Array.isArray(rawScenes) || rawScenes.length === 0) {
104
+ throw new CliError('"scenes" must be a non-empty array.');
105
+ }
106
+ const scenes = rawScenes.map((rawScene, index) => parseScene(rawScene, defaults, index));
107
+ return scenes;
108
+ }
109
+ function parseScene(rawScene, defaults, index) {
110
+ if (!isRecord(rawScene)) {
111
+ throw new CliError(`scenes[${index}] must be an object.`);
112
+ }
113
+ const urlRaw = readRequiredString(rawScene, 'url', `scenes[${index}].url`);
114
+ const url = parseUrlField(urlRaw, `scenes[${index}].url`);
115
+ const sceneHideSelectors = parseHideSelectors(rawScene.hideSelectors, `scenes[${index}].hideSelectors`);
116
+ const duration = parseDurationField(rawScene, `scenes[${index}].duration`, defaults.duration);
117
+ const motion = parseMotionField(readOptionalString(rawScene, 'motion') ?? defaults.motion, `scenes[${index}].motion`);
118
+ return {
119
+ name: readOptionalString(rawScene, 'name'),
120
+ url,
121
+ duration,
122
+ motion,
123
+ waitFor: parseWaitForField(readOptionalString(rawScene, 'waitFor') ??
124
+ stringifyWaitFor(defaults.waitFor), `scenes[${index}].waitFor`),
125
+ hideSelectors: dedupeSelectors([
126
+ ...defaults.hideSelectors,
127
+ ...sceneHideSelectors,
128
+ ]),
129
+ holdAfterSeconds: parseNonNegativeField(readOptionalNumber(rawScene, 'holdAfterSeconds') ??
130
+ defaults.holdAfterSeconds, `scenes[${index}].holdAfterSeconds`),
131
+ actions: parseActions(rawScene.actions, `scenes[${index}].actions`),
132
+ timeline: parseTimeline(rawScene.timeline, {
133
+ duration,
134
+ motion,
135
+ }, `scenes[${index}].timeline`),
136
+ };
137
+ }
138
+ function parseOutputs(input) {
139
+ const { rawOutputs, defaults, projectDir, projectBaseName, force } = input;
140
+ if (rawOutputs === undefined) {
141
+ return [
142
+ {
143
+ name: 'default',
144
+ outPath: resolve(projectDir, DEFAULT_OUT_FILE),
145
+ format: 'mp4',
146
+ manifestPath: resolve(projectDir, deriveSidecarPath(DEFAULT_OUT_FILE, '.manifest.json')),
147
+ logFilePath: resolve(projectDir, deriveSidecarPath(DEFAULT_OUT_FILE, '.log.jsonl')),
148
+ viewport: defaults.viewport,
149
+ fps: defaults.fps,
150
+ intermediateArtifact: buildDefaultIntermediateArtifactProfile(),
151
+ finalVideo: { ...DEFAULT_FINAL_MP4_VIDEO_ENCODING },
152
+ force,
153
+ },
154
+ ];
155
+ }
156
+ if (!Array.isArray(rawOutputs) || rawOutputs.length === 0) {
157
+ throw new CliError('"outputs" must be a non-empty array when provided.');
158
+ }
159
+ const outputs = rawOutputs.map((rawOutput, index) => parseOutput({
160
+ rawOutput,
161
+ index,
162
+ defaults,
163
+ projectDir,
164
+ projectBaseName,
165
+ force,
166
+ totalOutputs: rawOutputs.length,
167
+ }));
168
+ const names = new Set();
169
+ for (const output of outputs) {
170
+ if (names.has(output.name)) {
171
+ throw new CliError(`Duplicate output name in project file: ${output.name}`);
172
+ }
173
+ names.add(output.name);
174
+ }
175
+ return outputs;
176
+ }
177
+ function parseOutput(input) {
178
+ const { rawOutput, index, defaults, projectDir, projectBaseName, force, totalOutputs, } = input;
179
+ if (!isRecord(rawOutput)) {
180
+ throw new CliError(`outputs[${index}] must be an object.`);
181
+ }
182
+ const name = readOptionalString(rawOutput, 'name') ??
183
+ (totalOutputs === 1 ? 'default' : `output-${index + 1}`);
184
+ const configuredOutPath = readOptionalString(rawOutput, 'out');
185
+ const format = parseOutputFormat(readOptionalString(rawOutput, 'format'), configuredOutPath, `outputs[${index}].format`);
186
+ const derivedOutName = name === 'default'
187
+ ? `${projectBaseName}.${format}`
188
+ : `${projectBaseName}-${name}.${format}`;
189
+ const outPath = resolve(projectDir, configuredOutPath ?? derivedOutName);
190
+ const manifestPath = resolve(projectDir, readOptionalString(rawOutput, 'manifest') ??
191
+ deriveSidecarPath(configuredOutPath ?? derivedOutName, '.manifest.json'));
192
+ const logFilePath = resolve(projectDir, readOptionalString(rawOutput, 'logFile') ??
193
+ deriveSidecarPath(configuredOutPath ?? derivedOutName, '.log.jsonl'));
194
+ const debugFramesDir = readOptionalString(rawOutput, 'debugFramesDir');
195
+ const audio = parseAudioTrack(rawOutput.audio, projectDir, format, `outputs[${index}].audio`);
196
+ const subtitles = parseSubtitleTrack(rawOutput.subtitles, projectDir, `outputs[${index}].subtitles`);
197
+ const transition = parseTransition(rawOutput.transition, `outputs[${index}].transition`);
198
+ const intermediateArtifact = parseIntermediateArtifactProfile(rawOutput, `outputs[${index}]`);
199
+ const finalVideo = parseFinalVideoEncoding(rawOutput.finalVideo, format, `outputs[${index}].finalVideo`);
200
+ return {
201
+ name,
202
+ outPath,
203
+ format,
204
+ manifestPath,
205
+ logFilePath,
206
+ viewport: parseViewportField(readOptionalString(rawOutput, 'viewport') ??
207
+ stringifyViewport(defaults.viewport), `outputs[${index}].viewport`),
208
+ fps: parseFps(readOptionalNumber(rawOutput, 'fps') ?? defaults.fps, `outputs[${index}].fps`),
209
+ debugFramesDir: debugFramesDir
210
+ ? resolve(projectDir, debugFramesDir)
211
+ : undefined,
212
+ audio,
213
+ subtitles,
214
+ transition,
215
+ intermediateArtifact,
216
+ finalVideo,
217
+ force,
218
+ };
219
+ }
220
+ function parseActions(rawActions, fieldPath) {
221
+ if (rawActions === undefined) {
222
+ return [];
223
+ }
224
+ if (!Array.isArray(rawActions)) {
225
+ throw new CliError(`"${fieldPath}" must be an array.`);
226
+ }
227
+ return rawActions.map((rawAction, index) => parseAction(rawAction, `${fieldPath}[${index}]`, {
228
+ allowWait: true,
229
+ }));
230
+ }
231
+ function parseTimeline(rawTimeline, defaults, fieldPath) {
232
+ if (rawTimeline === undefined) {
233
+ return [];
234
+ }
235
+ if (!Array.isArray(rawTimeline) || rawTimeline.length === 0) {
236
+ throw new CliError(`"${fieldPath}" must be a non-empty array.`);
237
+ }
238
+ return rawTimeline.map((rawStep, index) => parseTimelineSegment(rawStep, defaults, `${fieldPath}[${index}]`));
239
+ }
240
+ function parseTimelineSegment(rawStep, defaults, fieldPath) {
241
+ if (!isRecord(rawStep)) {
242
+ throw new CliError(`"${fieldPath}" must be an object.`);
243
+ }
244
+ const type = readRequiredString(rawStep, 'type', `${fieldPath}.type`);
245
+ if (type === 'pause') {
246
+ return {
247
+ kind: 'pause',
248
+ durationSeconds: parsePositiveNumberField(readRequiredNumber(rawStep, 'duration', `${fieldPath}.duration`), `${fieldPath}.duration`),
249
+ };
250
+ }
251
+ if (type === 'wait') {
252
+ return {
253
+ kind: 'pause',
254
+ durationSeconds: parsePositiveIntField(readRequiredNumber(rawStep, 'ms', `${fieldPath}.ms`), `${fieldPath}.ms`) / 1000,
255
+ };
256
+ }
257
+ if (type === 'scroll') {
258
+ return {
259
+ kind: 'scroll',
260
+ duration: parseOptionalDurationField(rawStep, `${fieldPath}.duration`, defaults.duration),
261
+ motion: parseMotionField(readOptionalString(rawStep, 'motion') ?? defaults.motion, `${fieldPath}.motion`),
262
+ target: parseTimelineScrollTarget(rawStep, fieldPath),
263
+ };
264
+ }
265
+ const action = parseAction(rawStep, fieldPath, {
266
+ allowWait: false,
267
+ });
268
+ if (action.kind === 'wait') {
269
+ throw new CliError(`"${fieldPath}.type" cannot use "wait" here. Use "pause" or "ms".`);
270
+ }
271
+ return {
272
+ kind: 'action',
273
+ action,
274
+ holdAfterSeconds: parseNonNegativeField(readOptionalNumber(rawStep, 'holdAfterSeconds') ??
275
+ DEFAULT_TIMELINE_ACTION_HOLD_SECONDS, `${fieldPath}.holdAfterSeconds`),
276
+ };
277
+ }
278
+ function parseTimelineScrollTarget(rawStep, fieldPath) {
279
+ const hasTo = Object.hasOwn(rawStep, 'to');
280
+ const hasBy = Object.hasOwn(rawStep, 'by');
281
+ const hasToSelector = Object.hasOwn(rawStep, 'toSelector');
282
+ const targetCount = Number(hasTo) + Number(hasBy) + Number(hasToSelector);
283
+ if (targetCount !== 1) {
284
+ throw new CliError(`"${fieldPath}" must define exactly one of "to", "by", or "toSelector".`);
285
+ }
286
+ if (hasTo) {
287
+ const to = rawStep.to;
288
+ if (to === 'bottom') {
289
+ return { kind: 'bottom' };
290
+ }
291
+ if (typeof to !== 'number') {
292
+ throw new CliError(`"${fieldPath}.to" must be a number or "bottom".`);
293
+ }
294
+ return {
295
+ kind: 'absolute',
296
+ top: parseNonNegativeField(to, `${fieldPath}.to`),
297
+ };
298
+ }
299
+ if (hasBy) {
300
+ const by = rawStep.by;
301
+ if (typeof by !== 'number' || !Number.isFinite(by)) {
302
+ throw new CliError(`"${fieldPath}.by" must be a number.`);
303
+ }
304
+ return {
305
+ kind: 'relative',
306
+ delta: by,
307
+ };
308
+ }
309
+ return {
310
+ kind: 'selector',
311
+ selector: readRequiredString(rawStep, 'toSelector', `${fieldPath}.toSelector`),
312
+ block: parseScrollAlignment(readOptionalString(rawStep, 'block') ?? 'center', `${fieldPath}.block`),
313
+ };
314
+ }
315
+ function parseAction(rawAction, fieldPath, options) {
316
+ if (!isRecord(rawAction)) {
317
+ throw new CliError(`"${fieldPath}" must be an object.`);
318
+ }
319
+ const type = readRequiredString(rawAction, 'type', `${fieldPath}.type`);
320
+ switch (type) {
321
+ case 'wait':
322
+ if (!options.allowWait) {
323
+ throw new CliError(`Unsupported action type at ${fieldPath}.type: ${type}`);
324
+ }
325
+ return {
326
+ kind: 'wait',
327
+ ms: parsePositiveIntField(readRequiredNumber(rawAction, 'ms', `${fieldPath}.ms`), `${fieldPath}.ms`),
328
+ };
329
+ case 'click':
330
+ return {
331
+ kind: 'click',
332
+ selector: readRequiredString(rawAction, 'selector', `${fieldPath}.selector`),
333
+ };
334
+ case 'hover':
335
+ return {
336
+ kind: 'hover',
337
+ selector: readRequiredString(rawAction, 'selector', `${fieldPath}.selector`),
338
+ };
339
+ case 'press':
340
+ return {
341
+ kind: 'press',
342
+ key: readRequiredString(rawAction, 'key', `${fieldPath}.key`),
343
+ };
344
+ case 'type':
345
+ return {
346
+ kind: 'type',
347
+ selector: readRequiredString(rawAction, 'selector', `${fieldPath}.selector`),
348
+ text: readRequiredString(rawAction, 'text', `${fieldPath}.text`),
349
+ clear: typeof rawAction.clear === 'boolean' ? rawAction.clear : true,
350
+ };
351
+ case 'scroll-to':
352
+ return {
353
+ kind: 'scroll-to',
354
+ selector: readRequiredString(rawAction, 'selector', `${fieldPath}.selector`),
355
+ block: parseScrollAlignment(readOptionalString(rawAction, 'block') ?? 'center', `${fieldPath}.block`),
356
+ };
357
+ default:
358
+ throw new CliError(`Unsupported action type at ${fieldPath}.type: ${type}`);
359
+ }
360
+ }
361
+ function parseViewportField(rawValue, fieldPath) {
362
+ try {
363
+ return parseViewport(rawValue);
364
+ }
365
+ catch (error) {
366
+ throw new CliError(`${fieldPath} is invalid: ${error instanceof Error ? error.message : 'invalid viewport'}`);
367
+ }
368
+ }
369
+ function parseDurationField(rawObject, fieldPath, fallback) {
370
+ if (!Object.hasOwn(rawObject, 'duration')) {
371
+ return fallback;
372
+ }
373
+ const value = rawObject.duration;
374
+ if (value === 'auto') {
375
+ return 'auto';
376
+ }
377
+ if (typeof value !== 'number') {
378
+ throw new CliError(`"${fieldPath}" must be "auto" or a positive number.`);
379
+ }
380
+ return parsePositiveNumberField(value, fieldPath);
381
+ }
382
+ function parseOptionalDurationField(rawObject, fieldPath, fallback) {
383
+ if (!Object.hasOwn(rawObject, 'duration')) {
384
+ return fallback;
385
+ }
386
+ return parseDurationField(rawObject, fieldPath, fallback);
387
+ }
388
+ function parseMotionField(rawValue, fieldPath) {
389
+ try {
390
+ return parseMotion(rawValue);
391
+ }
392
+ catch (error) {
393
+ throw new CliError(`${fieldPath} is invalid: ${error instanceof Error ? error.message : 'invalid motion'}`);
394
+ }
395
+ }
396
+ function parseWaitForField(rawValue, fieldPath) {
397
+ try {
398
+ return parseWaitFor(rawValue);
399
+ }
400
+ catch (error) {
401
+ throw new CliError(`${fieldPath} is invalid: ${error instanceof Error ? error.message : 'invalid waitFor'}`);
402
+ }
403
+ }
404
+ function parseUrlField(rawValue, fieldPath) {
405
+ try {
406
+ return parseCaptureUrl(rawValue);
407
+ }
408
+ catch (error) {
409
+ throw new CliError(`${fieldPath} is invalid: ${error instanceof Error ? error.message : 'invalid URL'}`);
410
+ }
411
+ }
412
+ function parseHideSelectors(rawValue, fieldPath) {
413
+ if (rawValue === undefined) {
414
+ return [];
415
+ }
416
+ if (!Array.isArray(rawValue)) {
417
+ throw new CliError(`"${fieldPath}" must be an array of CSS selectors.`);
418
+ }
419
+ return rawValue.map((value, index) => {
420
+ if (typeof value !== 'string') {
421
+ throw new CliError(`"${fieldPath}[${index}]" must be a string.`);
422
+ }
423
+ try {
424
+ validateHideSelector(value);
425
+ }
426
+ catch (error) {
427
+ throw new CliError(`${fieldPath}[${index}] is invalid: ${error instanceof Error ? error.message : 'invalid selector'}`);
428
+ }
429
+ return value;
430
+ });
431
+ }
432
+ function parsePositiveIntField(value, fieldPath) {
433
+ try {
434
+ return parsePositiveInt(String(value));
435
+ }
436
+ catch (error) {
437
+ throw new CliError(`${fieldPath} is invalid: ${error instanceof Error ? error.message : 'invalid integer'}`);
438
+ }
439
+ }
440
+ function parsePositiveNumberField(value, fieldPath) {
441
+ try {
442
+ return parsePositiveNumber(String(value));
443
+ }
444
+ catch (error) {
445
+ throw new CliError(`${fieldPath} is invalid: ${error instanceof Error ? error.message : 'invalid number'}`);
446
+ }
447
+ }
448
+ function parseNonNegativeField(value, fieldPath) {
449
+ try {
450
+ return parseNonNegativeNumber(String(value));
451
+ }
452
+ catch (error) {
453
+ throw new CliError(`${fieldPath} is invalid: ${error instanceof Error ? error.message : 'invalid non-negative number'}`);
454
+ }
455
+ }
456
+ function parseFps(value, fieldPath) {
457
+ const fps = parsePositiveIntField(value, fieldPath);
458
+ if (fps > MAX_FPS) {
459
+ throw new CliError(`${fieldPath} must be at most ${MAX_FPS}.`);
460
+ }
461
+ return fps;
462
+ }
463
+ function parseOutputFormat(rawFormat, configuredOutPath, fieldPath) {
464
+ const inferredFormat = configuredOutPath
465
+ ? inferOutputFormatFromPath(configuredOutPath)
466
+ : undefined;
467
+ const format = rawFormat ?? inferredFormat ?? 'mp4';
468
+ if (format === 'mp4' || format === 'webm') {
469
+ return format;
470
+ }
471
+ throw new CliError(`${fieldPath} must be "mp4" or "webm".`);
472
+ }
473
+ function inferOutputFormatFromPath(path) {
474
+ if (path.endsWith('.webm')) {
475
+ return 'webm';
476
+ }
477
+ if (path.endsWith('.mp4')) {
478
+ return 'mp4';
479
+ }
480
+ return undefined;
481
+ }
482
+ function parseAudioTrack(rawAudio, projectDir, format, fieldPath) {
483
+ if (rawAudio === undefined) {
484
+ return undefined;
485
+ }
486
+ if (format !== 'mp4' && format !== 'webm') {
487
+ throw new CliError(`${fieldPath} is not supported for format "${format}".`);
488
+ }
489
+ if (!isRecord(rawAudio)) {
490
+ throw new CliError(`"${fieldPath}" must be an object.`);
491
+ }
492
+ return {
493
+ sourcePath: resolve(projectDir, readRequiredString(rawAudio, 'path', `${fieldPath}.path`)),
494
+ volume: parseNonNegativeField(readOptionalNumber(rawAudio, 'volume') ?? 1, `${fieldPath}.volume`),
495
+ loop: typeof rawAudio.loop === 'boolean' ? rawAudio.loop : true,
496
+ };
497
+ }
498
+ function parseSubtitleTrack(rawSubtitles, projectDir, fieldPath) {
499
+ if (rawSubtitles === undefined) {
500
+ return undefined;
501
+ }
502
+ if (!isRecord(rawSubtitles)) {
503
+ throw new CliError(`"${fieldPath}" must be an object.`);
504
+ }
505
+ const sourcePath = resolve(projectDir, readRequiredString(rawSubtitles, 'path', `${fieldPath}.path`));
506
+ return {
507
+ sourcePath,
508
+ format: parseSubtitleFormat(sourcePath, `${fieldPath}.path`),
509
+ mode: parseSubtitleMode(readOptionalString(rawSubtitles, 'mode') ?? 'soft', `${fieldPath}.mode`),
510
+ };
511
+ }
512
+ function parseTransition(rawTransition, fieldPath) {
513
+ if (rawTransition === undefined) {
514
+ return undefined;
515
+ }
516
+ if (!isRecord(rawTransition)) {
517
+ throw new CliError(`"${fieldPath}" must be an object.`);
518
+ }
519
+ const type = readRequiredString(rawTransition, 'type', `${fieldPath}.type`);
520
+ if (type !== 'fade-in' && type !== 'crossfade') {
521
+ throw new CliError(`${fieldPath}.type must be "fade-in" or "crossfade".`);
522
+ }
523
+ return {
524
+ kind: type,
525
+ durationSeconds: parsePositiveNumberField(readRequiredNumber(rawTransition, 'duration', `${fieldPath}.duration`), `${fieldPath}.duration`),
526
+ };
527
+ }
528
+ function parseIntermediateVideoEncoding(rawIntermediateVideo, fieldPath) {
529
+ if (rawIntermediateVideo === undefined) {
530
+ return { ...DEFAULT_INTERMEDIATE_VIDEO_ENCODING };
531
+ }
532
+ if (!isRecord(rawIntermediateVideo)) {
533
+ throw new CliError(`"${fieldPath}" must be an object.`);
534
+ }
535
+ return {
536
+ preset: parseH264Preset(readOptionalString(rawIntermediateVideo, 'preset') ??
537
+ DEFAULT_INTERMEDIATE_VIDEO_ENCODING.preset, `${fieldPath}.preset`),
538
+ crf: parseH264Crf(readOptionalNumber(rawIntermediateVideo, 'crf') ??
539
+ DEFAULT_INTERMEDIATE_VIDEO_ENCODING.crf, `${fieldPath}.crf`),
540
+ };
541
+ }
542
+ function parseIntermediateArtifactProfile(rawOutput, fieldPath) {
543
+ if (Object.hasOwn(rawOutput, 'intermediateArtifact') &&
544
+ Object.hasOwn(rawOutput, 'intermediateVideo')) {
545
+ throw new CliError(`${fieldPath} cannot define both "intermediateArtifact" and "intermediateVideo".`);
546
+ }
547
+ const rawIntermediateArtifact = rawOutput.intermediateArtifact;
548
+ if (rawIntermediateArtifact === undefined) {
549
+ return {
550
+ format: 'mp4',
551
+ extension: '.mp4',
552
+ videoEncoding: parseIntermediateVideoEncoding(rawOutput.intermediateVideo, `${fieldPath}.intermediateVideo`),
553
+ };
554
+ }
555
+ if (!isRecord(rawIntermediateArtifact)) {
556
+ throw new CliError(`"${fieldPath}.intermediateArtifact" must be an object.`);
557
+ }
558
+ const format = readOptionalString(rawIntermediateArtifact, 'format') ?? 'mp4';
559
+ if (format !== 'mp4') {
560
+ throw new CliError(`${fieldPath}.intermediateArtifact.format must currently be "mp4".`);
561
+ }
562
+ return {
563
+ format: 'mp4',
564
+ extension: '.mp4',
565
+ videoEncoding: parseIntermediateVideoEncoding(rawIntermediateArtifact, `${fieldPath}.intermediateArtifact`),
566
+ };
567
+ }
568
+ function parseFinalVideoEncoding(rawFinalVideo, format, fieldPath) {
569
+ if (rawFinalVideo === undefined) {
570
+ return format === 'mp4'
571
+ ? { ...DEFAULT_FINAL_MP4_VIDEO_ENCODING }
572
+ : { ...DEFAULT_FINAL_WEBM_VIDEO_ENCODING };
573
+ }
574
+ if (!isRecord(rawFinalVideo)) {
575
+ throw new CliError(`"${fieldPath}" must be an object.`);
576
+ }
577
+ if (format === 'mp4') {
578
+ if (Object.hasOwn(rawFinalVideo, 'deadline')) {
579
+ throw new CliError(`${fieldPath}.deadline is only supported for "webm" outputs.`);
580
+ }
581
+ return {
582
+ format: 'mp4',
583
+ preset: parseH264Preset(readOptionalString(rawFinalVideo, 'preset') ??
584
+ DEFAULT_FINAL_MP4_VIDEO_ENCODING.preset, `${fieldPath}.preset`),
585
+ crf: parseH264Crf(readOptionalNumber(rawFinalVideo, 'crf') ??
586
+ DEFAULT_FINAL_MP4_VIDEO_ENCODING.crf, `${fieldPath}.crf`),
587
+ };
588
+ }
589
+ if (Object.hasOwn(rawFinalVideo, 'preset')) {
590
+ throw new CliError(`${fieldPath}.preset is only supported for "mp4" outputs.`);
591
+ }
592
+ return {
593
+ format: 'webm',
594
+ deadline: parseVp9Deadline(readOptionalString(rawFinalVideo, 'deadline') ??
595
+ DEFAULT_FINAL_WEBM_VIDEO_ENCODING.deadline, `${fieldPath}.deadline`),
596
+ crf: parseVp9Crf(readOptionalNumber(rawFinalVideo, 'crf') ??
597
+ DEFAULT_FINAL_WEBM_VIDEO_ENCODING.crf, `${fieldPath}.crf`),
598
+ };
599
+ }
600
+ function parseSubtitleFormat(sourcePath, fieldPath) {
601
+ const normalizedPath = sourcePath.toLowerCase();
602
+ if (normalizedPath.endsWith('.srt')) {
603
+ return 'srt';
604
+ }
605
+ if (normalizedPath.endsWith('.vtt') || normalizedPath.endsWith('.webvtt')) {
606
+ return 'webvtt';
607
+ }
608
+ throw new CliError(`${fieldPath} must point to a .srt, .vtt, or .webvtt subtitle file.`);
609
+ }
610
+ function parseSubtitleMode(value, fieldPath) {
611
+ if (value === 'soft' || value === 'burn-in') {
612
+ return value;
613
+ }
614
+ throw new CliError(`${fieldPath} must be "soft" or "burn-in".`);
615
+ }
616
+ function parseH264Preset(value, fieldPath) {
617
+ switch (value) {
618
+ case 'ultrafast':
619
+ case 'superfast':
620
+ case 'veryfast':
621
+ case 'faster':
622
+ case 'fast':
623
+ case 'medium':
624
+ case 'slow':
625
+ case 'slower':
626
+ case 'veryslow':
627
+ case 'placebo':
628
+ return value;
629
+ default:
630
+ throw new CliError(`${fieldPath} must be one of: ultrafast, superfast, veryfast, faster, fast, medium, slow, slower, veryslow, placebo.`);
631
+ }
632
+ }
633
+ function parseH264Crf(value, fieldPath) {
634
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
635
+ throw new CliError(`${fieldPath} must be an integer between 0 and 51.`);
636
+ }
637
+ if (value < 0 || value > 51) {
638
+ throw new CliError(`${fieldPath} must be between 0 and 51.`);
639
+ }
640
+ return value;
641
+ }
642
+ function parseVp9Deadline(value, fieldPath) {
643
+ if (value === 'best' || value === 'good' || value === 'realtime') {
644
+ return value;
645
+ }
646
+ throw new CliError(`${fieldPath} must be one of: best, good, realtime.`);
647
+ }
648
+ function parseVp9Crf(value, fieldPath) {
649
+ if (!Number.isFinite(value) || !Number.isInteger(value)) {
650
+ throw new CliError(`${fieldPath} must be an integer between 0 and 63.`);
651
+ }
652
+ if (value < 0 || value > 63) {
653
+ throw new CliError(`${fieldPath} must be between 0 and 63.`);
654
+ }
655
+ return value;
656
+ }
657
+ function buildDefaultIntermediateArtifactProfile() {
658
+ return {
659
+ format: 'mp4',
660
+ extension: '.mp4',
661
+ videoEncoding: { ...DEFAULT_INTERMEDIATE_VIDEO_ENCODING },
662
+ };
663
+ }
664
+ function parseScrollAlignment(value, fieldPath) {
665
+ if (value === 'start' || value === 'center' || value === 'end') {
666
+ return value;
667
+ }
668
+ throw new CliError(`${fieldPath} must be one of: start, center, end.`);
669
+ }
670
+ function stringifyViewport(viewport) {
671
+ return `${viewport.width}x${viewport.height}`;
672
+ }
673
+ function stringifyWaitFor(waitFor) {
674
+ switch (waitFor.kind) {
675
+ case 'load':
676
+ return 'load';
677
+ case 'selector':
678
+ return `selector:${waitFor.selector}`;
679
+ case 'delay':
680
+ return `ms:${waitFor.ms}`;
681
+ }
682
+ }
683
+ function dedupeSelectors(selectors) {
684
+ return [...new Set(selectors)];
685
+ }
686
+ function readOptionalString(value, key) {
687
+ const candidate = value[key];
688
+ if (candidate === undefined) {
689
+ return undefined;
690
+ }
691
+ if (typeof candidate !== 'string') {
692
+ throw new CliError(`"${key}" must be a string.`);
693
+ }
694
+ return candidate;
695
+ }
696
+ function readOptionalNumber(value, key) {
697
+ const candidate = value[key];
698
+ if (candidate === undefined) {
699
+ return undefined;
700
+ }
701
+ if (typeof candidate !== 'number') {
702
+ throw new CliError(`"${key}" must be a number.`);
703
+ }
704
+ return candidate;
705
+ }
706
+ function readRequiredString(value, key, fieldPath) {
707
+ const candidate = value[key];
708
+ if (typeof candidate !== 'string' || candidate.length === 0) {
709
+ throw new CliError(`"${fieldPath}" must be a non-empty string.`);
710
+ }
711
+ return candidate;
712
+ }
713
+ function readRequiredNumber(value, key, fieldPath) {
714
+ const candidate = value[key];
715
+ if (typeof candidate !== 'number') {
716
+ throw new CliError(`"${fieldPath}" must be a number.`);
717
+ }
718
+ return candidate;
719
+ }
720
+ function isRecord(value) {
721
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
722
+ }