aivee 0.0.0-stage → 0.1.0-dev.59872e1

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.
Files changed (74) hide show
  1. package/CHANGELOG.md +34 -0
  2. package/LICENSE +21 -0
  3. package/README.md +119 -2
  4. package/docs/architecture.md +401 -0
  5. package/docs/aspect-ratio-presets.md +72 -0
  6. package/docs/releasing.md +19 -0
  7. package/docs/usage.md +1193 -0
  8. package/package.json +50 -4
  9. package/requirements-otio.txt +1 -0
  10. package/src/capabilities.mjs +307 -0
  11. package/src/cli.mjs +646 -0
  12. package/src/doctor.mjs +133 -0
  13. package/src/edits/analysis-to-edl.mjs +43 -0
  14. package/src/edits/detect-silence.mjs +51 -0
  15. package/src/edits/find-mistake-candidates.mjs +42 -0
  16. package/src/edits/plan-shot-placements.mjs +105 -0
  17. package/src/edits/plan-silence-cuts.mjs +49 -0
  18. package/src/edits/retime-transcript.mjs +105 -0
  19. package/src/files.mjs +80 -0
  20. package/src/index.mjs +44 -0
  21. package/src/interchange/otio.mjs +204 -0
  22. package/src/interchange/otio_bridge.py +521 -0
  23. package/src/media/analysis-provider.mjs +75 -0
  24. package/src/media/compatibility.mjs +45 -0
  25. package/src/media/ffmpeg-capabilities.mjs +129 -0
  26. package/src/media/frame-rate.mjs +58 -0
  27. package/src/media/frame-time.mjs +22 -0
  28. package/src/media/preview-assets.mjs +350 -0
  29. package/src/media/probe.mjs +149 -0
  30. package/src/media/render-cuts.mjs +90 -0
  31. package/src/media/scene-detection.mjs +107 -0
  32. package/src/media/scene-review.mjs +205 -0
  33. package/src/project/assets.mjs +1194 -0
  34. package/src/project/create.mjs +100 -0
  35. package/src/project/presets.mjs +44 -0
  36. package/src/render/checkpoints.mjs +192 -0
  37. package/src/render/formats.mjs +163 -0
  38. package/src/render/hyperframes.mjs +590 -0
  39. package/src/render/process.mjs +111 -0
  40. package/src/render/progress.mjs +46 -0
  41. package/src/render/timeline-control.mjs +53 -0
  42. package/src/timeline/assemble.mjs +119 -0
  43. package/src/timeline/audio-effects.mjs +75 -0
  44. package/src/timeline/audio-mastering.mjs +236 -0
  45. package/src/timeline/audio-mix.mjs +105 -0
  46. package/src/timeline/audio-track-effects.mjs +139 -0
  47. package/src/timeline/audio-volume.mjs +63 -0
  48. package/src/timeline/background.mjs +8 -0
  49. package/src/timeline/blend-mode.mjs +34 -0
  50. package/src/timeline/caption-layouts.mjs +62 -0
  51. package/src/timeline/captions.mjs +80 -0
  52. package/src/timeline/edit.mjs +965 -0
  53. package/src/timeline/fingerprint.mjs +6 -0
  54. package/src/timeline/from-edl.mjs +31 -0
  55. package/src/timeline/history-store.mjs +281 -0
  56. package/src/timeline/history.mjs +69 -0
  57. package/src/timeline/inspect.mjs +117 -0
  58. package/src/timeline/keyframes.mjs +158 -0
  59. package/src/timeline/layers.mjs +18 -0
  60. package/src/timeline/mask.mjs +53 -0
  61. package/src/timeline/project.mjs +52 -0
  62. package/src/timeline/render.mjs +759 -0
  63. package/src/timeline/sidechain-render.mjs +209 -0
  64. package/src/timeline/source-fit.mjs +15 -0
  65. package/src/timeline/speech-bubble.mjs +26 -0
  66. package/src/timeline/speed-ramp.mjs +188 -0
  67. package/src/timeline/track-state.mjs +22 -0
  68. package/src/timeline/transition.mjs +34 -0
  69. package/src/timeline/validate.mjs +322 -0
  70. package/src/transcript/corrections.mjs +21 -0
  71. package/src/transcript/normalize.mjs +17 -0
  72. package/src/transcript/provider.mjs +42 -0
  73. package/src/transcript/subtitles.mjs +57 -0
  74. package/src/transcript/validate.mjs +39 -0
@@ -0,0 +1,100 @@
1
+ import { copyFileSync, existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
2
+ import { dirname, join, resolve } from 'node:path';
3
+ import { createRequire } from 'node:module';
4
+ import { resolveCanvasPreset } from './presets.mjs';
5
+ import { DEFAULT_SOURCE_FIT, validateSourceFit } from '../timeline/source-fit.mjs';
6
+ import { createProjectTimeline } from '../timeline/project.mjs';
7
+ import { normalizeFrameRate } from '../media/frame-rate.mjs';
8
+ import { DEFAULT_PROJECT_BACKGROUND_COLOR, validateProjectBackgroundColor } from '../timeline/background.mjs';
9
+
10
+ const require = createRequire(import.meta.url);
11
+
12
+ export function createVideoProject(root, slug, options = {}) {
13
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) {
14
+ throw new TypeError('Use a lowercase kebab-case slug, not a path.');
15
+ }
16
+ const { fps: requestedFps = 30, durationSeconds = 10, sourceFit = DEFAULT_SOURCE_FIT, backgroundColor = DEFAULT_PROJECT_BACKGROUND_COLOR } = options;
17
+ if (options.preset !== undefined && (options.width !== undefined || options.height !== undefined)) {
18
+ throw new TypeError('Choose a canvas preset or custom width and height, not both.');
19
+ }
20
+ const preset = options.preset === undefined ? undefined : resolveCanvasPreset(options.preset);
21
+ const width = preset?.width ?? options.width ?? 1920;
22
+ const height = preset?.height ?? options.height ?? 1080;
23
+ validateSourceFit(sourceFit, 'sourceFit');
24
+ validateProjectBackgroundColor(backgroundColor, 'backgroundColor');
25
+ if (![width, height].every((value) => Number.isSafeInteger(value) && value > 0)) {
26
+ throw new TypeError('Width and height must be positive integers.');
27
+ }
28
+ const fps = normalizeFrameRate(requestedFps);
29
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) {
30
+ throw new TypeError('durationSeconds must be a positive number.');
31
+ }
32
+ const projectPath = resolve(root, 'video-projects', slug);
33
+ if (existsSync(projectPath)) throw new Error(`Project already exists: ${slug}`);
34
+ const projectsPath = join(root, 'video-projects');
35
+
36
+ const project = {
37
+ schemaVersion: 1,
38
+ id: slug,
39
+ name: slug,
40
+ width,
41
+ height,
42
+ fps,
43
+ durationSeconds,
44
+ sourceFit,
45
+ backgroundColor,
46
+ ...(preset ? { canvasPreset: preset.id } : {}),
47
+ };
48
+ const directories = ['assets', 'transcripts', 'edits', 'compositions', 'renders'];
49
+ mkdirSync(projectsPath, { recursive: true });
50
+ mkdirSync(projectPath);
51
+ try {
52
+ for (const directory of directories) mkdirSync(join(projectPath, directory));
53
+ const gsapPath = join(dirname(require.resolve('gsap')), 'gsap.min.js');
54
+ copyFileSync(gsapPath, join(projectPath, 'assets', 'gsap.min.js'));
55
+ writeFileSync(join(projectPath, 'meta.json'), `${JSON.stringify(project, null, 2)}\n`, { flag: 'wx' });
56
+ writeFileSync(join(projectPath, 'hyperframes.json'), `${JSON.stringify({
57
+ '$schema': 'https://hyperframes.heygen.com/schema/hyperframes.json',
58
+ paths: { blocks: 'compositions', components: 'compositions/components', assets: 'assets' },
59
+ }, null, 2)}\n`, { flag: 'wx' });
60
+ writeFileSync(join(projectPath, 'index.html'), starterHtml(project), { flag: 'wx' });
61
+ writeFileSync(join(projectPath, 'timeline.json'), `${JSON.stringify(createProjectTimeline(projectPath), null, 2)}\n`, { flag: 'wx' });
62
+ } catch (error) {
63
+ rmSync(projectPath, { recursive: true, force: true });
64
+ throw error;
65
+ }
66
+ return projectPath;
67
+ }
68
+
69
+ function starterHtml({ name, width, height, durationSeconds, backgroundColor }) {
70
+ const background = backgroundColor ?? 'transparent';
71
+ return `<!doctype html>
72
+ <html lang="en">
73
+ <head>
74
+ <meta charset="utf-8">
75
+ <meta name="viewport" content="width=${width}, initial-scale=1">
76
+ <title>${escapeHtml(name)}</title>
77
+ <link rel="icon" href="data:,">
78
+ <style>html,body,#root{box-sizing:border-box;margin:0;width:${width}px;height:${height}px}body{background:${background};color:#f5f5f5;font:48px system-ui,sans-serif}#root{display:grid;place-items:center}</style>
79
+ <script src="assets/gsap.min.js"></script>
80
+ </head>
81
+ <body>
82
+ <div id="root" data-composition-id="root" data-start="0" data-duration="${durationSeconds}" data-width="${width}" data-height="${height}">
83
+ <div id="title" class="clip" data-start="0" data-duration="${durationSeconds}" data-track-index="0">${escapeHtml(name)}</div>
84
+ </div>
85
+ <script>
86
+ const timeline = gsap.timeline({ paused: true });
87
+ timeline.from('#title', { opacity: 0, y: 24, duration: 0.8, ease: 'power2.out' });
88
+ window.__timelines = window.__timelines || {};
89
+ window.__timelines.root = timeline;
90
+ </script>
91
+ </body>
92
+ </html>
93
+ `;
94
+ }
95
+
96
+ function escapeHtml(value) {
97
+ return value.replace(/[&<>"']/g, (character) => ({
98
+ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
99
+ })[character]);
100
+ }
@@ -0,0 +1,44 @@
1
+ const presets = [
2
+ {
3
+ id: 'vertical',
4
+ aspectRatio: '9:16',
5
+ width: 1080,
6
+ height: 1920,
7
+ platforms: ['TikTok', 'Instagram Reels and Stories', 'Facebook Reels and Stories', 'YouTube Shorts'],
8
+ },
9
+ {
10
+ id: 'portrait-feed',
11
+ aspectRatio: '4:5',
12
+ width: 1080,
13
+ height: 1350,
14
+ platforms: ['Instagram feed', 'LinkedIn feed'],
15
+ },
16
+ {
17
+ id: 'square',
18
+ aspectRatio: '1:1',
19
+ width: 1080,
20
+ height: 1080,
21
+ platforms: ['TikTok', 'Instagram feed', 'LinkedIn feed'],
22
+ },
23
+ {
24
+ id: 'landscape',
25
+ aspectRatio: '16:9',
26
+ width: 1920,
27
+ height: 1080,
28
+ platforms: ['YouTube', 'LinkedIn feed'],
29
+ },
30
+ ].map((preset) => Object.freeze({ ...preset, platforms: Object.freeze(preset.platforms) }));
31
+
32
+ export const CANVAS_PRESETS = Object.freeze(presets);
33
+
34
+ export function listCanvasPresets() {
35
+ return CANVAS_PRESETS;
36
+ }
37
+
38
+ export function resolveCanvasPreset(id) {
39
+ const preset = CANVAS_PRESETS.find((candidate) => candidate.id === id);
40
+ if (!preset) {
41
+ throw new TypeError(`Unknown canvas preset "${id}". Available presets: ${CANVAS_PRESETS.map(({ id: name }) => name).join(', ')}.`);
42
+ }
43
+ return preset;
44
+ }
@@ -0,0 +1,192 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { constants as fsConstants, createReadStream } from 'node:fs';
3
+ import { copyFile, lstat, mkdir, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises';
4
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
5
+
6
+ const CHECKPOINT_VERSION = 1;
7
+ const EXCLUDED_DIRECTORIES = new Set(['.git', '.aive-checkpoints', 'node_modules', 'renders']);
8
+
9
+ export async function fingerprintRenderProject(projectPath, profile) {
10
+ const root = resolve(projectPath);
11
+ const hash = createHash('sha256');
12
+ hash.update(JSON.stringify(profile));
13
+ const files = await collectFiles(root);
14
+ for (const file of files) {
15
+ const relativePath = relative(root, file).split(sep).join('/');
16
+ hash.update(`\0${relativePath}\0`);
17
+ for await (const chunk of createReadStream(file)) hash.update(chunk);
18
+ }
19
+ return hash.digest('hex');
20
+ }
21
+
22
+ export async function prepareRenderCheckpoint(outputPath, fingerprint, options = {}) {
23
+ const maxBytes = options.maxBytes ?? 8 * 1024 ** 3;
24
+ const maxAgeHours = options.maxAgeHours ?? 72;
25
+ validateCheckpointLimits(maxBytes, maxAgeHours);
26
+ const root = join(dirname(resolve(outputPath)), '.aive-checkpoints');
27
+ await pruneExpiredRenderCheckpoints(root, maxAgeHours);
28
+ const id = createHash('sha256').update(`${basename(outputPath)}\0${fingerprint}`).digest('hex');
29
+ const directory = join(root, id);
30
+ await assertSafeCheckpointDirectory(root, directory);
31
+ const artifact = join(directory, `capture.${extension(outputPath)}`);
32
+ const manifest = join(directory, 'checkpoint.json');
33
+ return { root, id, directory, artifact, manifest, fingerprint, maxBytes, maxAgeHours };
34
+ }
35
+
36
+ export async function restoreRenderCheckpoint(checkpoint) {
37
+ await assertSafeCheckpointDirectory(checkpoint.root, checkpoint.directory);
38
+ let record;
39
+ try {
40
+ const parsed = JSON.parse(await readFile(checkpoint.manifest, 'utf8'));
41
+ const info = await lstat(checkpoint.artifact);
42
+ if (info.isSymbolicLink() || !info.isFile() || info.size <= 0 || info.size > checkpoint.maxBytes || parsed.sizeBytes !== info.size
43
+ || parsed.version !== CHECKPOINT_VERSION || parsed.fingerprint !== checkpoint.fingerprint
44
+ || parsed.artifact !== basename(checkpoint.artifact) || !Number.isFinite(parsed.createdAt)
45
+ || Date.now() - parsed.createdAt > checkpoint.maxAgeHours * 60 * 60 * 1000) {
46
+ await rm(checkpoint.directory, { recursive: true, force: true });
47
+ return undefined;
48
+ }
49
+ record = parsed;
50
+ if (await hashFile(checkpoint.artifact) !== record.sha256) {
51
+ await rm(checkpoint.directory, { recursive: true, force: true });
52
+ return undefined;
53
+ }
54
+ return checkpoint.artifact;
55
+ } catch (error) {
56
+ if (error.code === 'ENOENT' || error instanceof SyntaxError) await rm(checkpoint.directory, { recursive: true, force: true });
57
+ return undefined;
58
+ }
59
+ }
60
+
61
+ export async function saveRenderCheckpoint(checkpoint, completedArtifact) {
62
+ await assertSafeCheckpointDirectory(checkpoint.root, checkpoint.directory);
63
+ const info = await stat(completedArtifact);
64
+ if (!info.isFile() || info.size <= 0) throw new Error('Cannot checkpoint an empty render artifact.');
65
+ if (info.size > checkpoint.maxBytes) {
66
+ await rm(checkpoint.directory, { recursive: true, force: true });
67
+ return undefined;
68
+ }
69
+ await mkdir(checkpoint.directory, { recursive: true, mode: 0o700 });
70
+ const temporaryArtifact = `${checkpoint.artifact}.tmp`;
71
+ const temporaryManifest = `${checkpoint.manifest}.tmp`;
72
+ try {
73
+ await rm(temporaryArtifact, { force: true });
74
+ await rm(temporaryManifest, { force: true });
75
+ await copyFile(completedArtifact, temporaryArtifact);
76
+ const record = {
77
+ version: CHECKPOINT_VERSION,
78
+ fingerprint: checkpoint.fingerprint,
79
+ artifact: basename(checkpoint.artifact),
80
+ sizeBytes: info.size,
81
+ sha256: await hashFile(temporaryArtifact),
82
+ createdAt: Date.now(),
83
+ };
84
+ await writeFile(temporaryManifest, `${JSON.stringify(record, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
85
+ await copyFile(temporaryArtifact, checkpoint.artifact, fsConstants.COPYFILE_EXCL);
86
+ await rm(temporaryArtifact, { force: true });
87
+ await copyFile(temporaryManifest, checkpoint.manifest, fsConstants.COPYFILE_EXCL);
88
+ await rm(temporaryManifest, { force: true });
89
+ return checkpoint.artifact;
90
+ } catch (error) {
91
+ await rm(temporaryArtifact, { force: true });
92
+ await rm(temporaryManifest, { force: true });
93
+ throw error;
94
+ }
95
+ }
96
+
97
+ export async function removeRenderCheckpoint(checkpoint) {
98
+ await assertSafeCheckpointDirectory(checkpoint.root, checkpoint.directory);
99
+ await rm(checkpoint.directory, { recursive: true, force: true });
100
+ }
101
+
102
+ export async function pruneExpiredRenderCheckpoints(rootPath, maxAgeHours = 72) {
103
+ const root = resolve(rootPath);
104
+ const rootInfo = await lstatIfPresent(root);
105
+ if (rootInfo && (rootInfo.isSymbolicLink() || !rootInfo.isDirectory())) {
106
+ throw new TypeError('Render checkpoint root must be a real directory, not a symbolic link.');
107
+ }
108
+ let entries;
109
+ try {
110
+ entries = await readdir(root, { withFileTypes: true });
111
+ } catch (error) {
112
+ if (error.code === 'ENOENT') return;
113
+ throw error;
114
+ }
115
+ const expireBefore = Date.now() - maxAgeHours * 60 * 60 * 1000;
116
+ for (const entry of entries) {
117
+ if (!entry.isDirectory()) continue;
118
+ const directory = join(root, entry.name);
119
+ try {
120
+ const record = JSON.parse(await readFile(join(directory, 'checkpoint.json'), 'utf8'));
121
+ if (!Number.isFinite(record.createdAt) || record.createdAt < expireBefore) {
122
+ await rm(directory, { recursive: true, force: true });
123
+ }
124
+ } catch {
125
+ await rm(directory, { recursive: true, force: true });
126
+ }
127
+ }
128
+ }
129
+
130
+ async function assertSafeCheckpointDirectory(rootPath, directoryPath) {
131
+ const root = resolve(rootPath);
132
+ const directory = resolve(directoryPath);
133
+ if (dirname(directory) !== root) throw new TypeError('Render checkpoint directory must be directly inside its checkpoint root.');
134
+ const rootInfo = await lstatIfPresent(root);
135
+ if (rootInfo && (rootInfo.isSymbolicLink() || !rootInfo.isDirectory())) {
136
+ throw new TypeError('Render checkpoint root must be a real directory, not a symbolic link.');
137
+ }
138
+ const directoryInfo = await lstatIfPresent(directory);
139
+ if (directoryInfo && (directoryInfo.isSymbolicLink() || !directoryInfo.isDirectory())) {
140
+ throw new TypeError('Render checkpoint directory must be a real directory, not a symbolic link.');
141
+ }
142
+ }
143
+
144
+ async function lstatIfPresent(path) {
145
+ try {
146
+ return await lstat(path);
147
+ } catch (error) {
148
+ if (error.code === 'ENOENT') return undefined;
149
+ throw error;
150
+ }
151
+ }
152
+
153
+ export function validateCheckpointLimits(maxBytes, maxAgeHours) {
154
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1024 * 1024 || maxBytes > 100 * 1024 ** 3) {
155
+ throw new TypeError('checkpointMaxBytes must be an integer from 1048576 to 107374182400.');
156
+ }
157
+ if (!Number.isSafeInteger(maxAgeHours) || maxAgeHours < 1 || maxAgeHours > 720) {
158
+ throw new TypeError('checkpointMaxAgeHours must be an integer from 1 to 720.');
159
+ }
160
+ }
161
+
162
+ async function collectFiles(root) {
163
+ const files = [];
164
+ const visit = async (directory) => {
165
+ const entries = await readdir(directory, { withFileTypes: true });
166
+ entries.sort((left, right) => left.name.localeCompare(right.name));
167
+ for (const entry of entries) {
168
+ const path = join(directory, entry.name);
169
+ if (entry.isSymbolicLink()) {
170
+ const target = await stat(path);
171
+ if (target.isFile()) files.push(path);
172
+ else if (target.isDirectory()) throw new Error(`Cannot fingerprint a project containing a linked directory: ${relative(root, path)}`);
173
+ }
174
+ if (entry.isDirectory()) {
175
+ if (!EXCLUDED_DIRECTORIES.has(entry.name)) await visit(path);
176
+ } else if (entry.isFile()) files.push(path);
177
+ }
178
+ };
179
+ await visit(root);
180
+ return files;
181
+ }
182
+
183
+ async function hashFile(path) {
184
+ const hash = createHash('sha256');
185
+ for await (const chunk of createReadStream(path)) hash.update(chunk);
186
+ return hash.digest('hex');
187
+ }
188
+
189
+ function extension(path) {
190
+ const value = basename(path).split('.').at(-1).toLowerCase();
191
+ return /^[a-z0-9]{1,8}$/.test(value) ? value : 'bin';
192
+ }
@@ -0,0 +1,163 @@
1
+ const EXTENSIONS = new Map([
2
+ ['.mp4', 'mp4'],
3
+ ['.webm', 'webm'],
4
+ ['.mov', 'mov'],
5
+ ['.gif', 'gif'],
6
+ ]);
7
+
8
+ export function resolveRenderFormat(outputPath, requestedFormat) {
9
+ if (requestedFormat === 'png-sequence') return 'png-sequence';
10
+ const extension = outputPath ? outputPath.slice(outputPath.lastIndexOf('.')).toLowerCase() : undefined;
11
+ const inferred = EXTENSIONS.get(extension);
12
+ const format = requestedFormat ?? inferred ?? 'mp4';
13
+ if (!['mp4', 'webm', 'mov', 'gif', 'png-sequence'].includes(format)) {
14
+ throw new TypeError('Format must be mp4, webm, mov, gif, or png-sequence.');
15
+ }
16
+ if (requestedFormat && inferred && requestedFormat !== inferred) {
17
+ throw new TypeError(`Output extension ${extension} does not match requested ${requestedFormat} format.`);
18
+ }
19
+ if (outputPath && extension && !inferred) throw new TypeError(`Unsupported render output extension: ${extension}`);
20
+ const resolvedExtension = `.${format}`;
21
+ if (outputPath && format !== 'png-sequence' && extension !== resolvedExtension) {
22
+ throw new TypeError(`Render output for ${format} must use the ${resolvedExtension} extension.`);
23
+ }
24
+ return format;
25
+ }
26
+
27
+ export function resolveEncoderArgs(format, options = {}) {
28
+ const encoder = resolveVideoEncoder(format, options);
29
+ if (encoder.codec) return [];
30
+ if (encoder.crf !== undefined) return ['--crf', String(encoder.crf)];
31
+ if (encoder.videoBitrate !== undefined) return ['--video-bitrate', encoder.videoBitrate];
32
+ return [];
33
+ }
34
+
35
+ export function resolveColorProfile(profile, format) {
36
+ if (profile === undefined) return undefined;
37
+ if (profile === 'hdr10' && !['mp4', 'mov'].includes(format)) {
38
+ throw new TypeError(`The hdr10 color profile requires MP4 or MOV output, not ${format}.`);
39
+ }
40
+ if (!['mp4', 'mov', 'webm'].includes(format)) {
41
+ throw new TypeError(`Color profiles are supported for MP4, MOV, and WebM output, not ${format}.`);
42
+ }
43
+ const profiles = {
44
+ 'rec709': {
45
+ id: 'rec709',
46
+ filter: 'colorspace=iall=bt709:space=bt709:primaries=bt709:trc=bt709:range=tv',
47
+ colorSpace: 'bt709',
48
+ colorTransfer: 'bt709',
49
+ colorPrimaries: 'bt709',
50
+ colorRange: 'tv',
51
+ requiredFilter: 'colorspace',
52
+ },
53
+ 'rec2020-sdr': {
54
+ id: 'rec2020-sdr',
55
+ filter: 'colorspace=iall=bt709:space=bt2020nc:primaries=bt2020:trc=bt709:range=tv',
56
+ colorSpace: 'bt2020nc',
57
+ colorTransfer: 'bt709',
58
+ colorPrimaries: 'bt2020',
59
+ colorRange: 'tv',
60
+ requiredFilter: 'colorspace',
61
+ },
62
+ hdr10: {
63
+ id: 'hdr10',
64
+ filter: 'zscale=pin=bt709:tin=bt709:min=bt709:rin=tv:p=bt2020:t=smpte2084:m=bt2020nc:r=tv:npl=203,format=yuv420p10le',
65
+ colorSpace: 'bt2020nc',
66
+ colorTransfer: 'smpte2084',
67
+ colorPrimaries: 'bt2020',
68
+ colorRange: 'tv',
69
+ pixelFormat: 'yuv420p10le',
70
+ requiredFilter: 'zscale',
71
+ masteringDisplay: 'G(8500,39850)B(6550,2300)R(35400,14600)WP(15635,16450)L(10000000,1)',
72
+ contentLightLevel: '203,203',
73
+ },
74
+ };
75
+ const resolved = profiles[profile];
76
+ if (!resolved) throw new TypeError(`Unsupported colorProfile ${String(profile)}; choose rec709, rec2020-sdr, or hdr10.`);
77
+ return resolved;
78
+ }
79
+
80
+ export function resolveProfileEncoder(format, encoder, colorProfile) {
81
+ if (colorProfile?.id === 'hdr10') {
82
+ if (encoder.codec && encoder.codecId !== 'h265') {
83
+ throw new TypeError('The hdr10 color profile requires the h265 codec.');
84
+ }
85
+ return encoder.codec ? encoder : { ...encoder, codecId: 'h265', codec: 'libx265' };
86
+ }
87
+ if (encoder.codec) return encoder;
88
+ const codecId = format === 'webm' ? 'vp9' : 'h264';
89
+ const codec = format === 'webm' ? 'libvpx-vp9' : 'libx264';
90
+ return { ...encoder, codecId, codec };
91
+ }
92
+
93
+ export function resolveVideoEncoder(format, options = {}) {
94
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Render options must be an object.');
95
+ for (const field of Object.keys(options)) if (!['codec', 'crf', 'videoBitrate'].includes(field)) throw new TypeError(`Unsupported encoder option: ${field}.`);
96
+ if (options.crf !== undefined && options.videoBitrate !== undefined) throw new TypeError('Choose either crf or videoBitrate, not both.');
97
+
98
+ const codecs = format === 'webm'
99
+ ? { vp9: 'libvpx-vp9' }
100
+ : format === 'mov'
101
+ ? { h264: 'libx264', h265: 'libx265', prores: 'prores_ks' }
102
+ : format === 'mp4'
103
+ ? { h264: 'libx264', h265: 'libx265' }
104
+ : {};
105
+ if (options.codec !== undefined && (typeof options.codec !== 'string' || codecs[options.codec] === undefined)) {
106
+ throw new TypeError(`Render codec is unsupported for ${format}; choose one of: ${Object.keys(codecs).join(', ') || 'none'}.`);
107
+ }
108
+
109
+ if (options.crf !== undefined) {
110
+ if (!['mp4', 'mov', 'webm'].includes(format)) throw new TypeError(`CRF control is not supported for ${format} output.`);
111
+ const maximum = options.codec === 'vp9' || (format === 'webm' && options.codec === undefined) ? 63 : 51;
112
+ if (options.codec === 'prores'
113
+ || !Number.isSafeInteger(options.crf) || options.crf < 0 || options.crf > maximum) {
114
+ throw new TypeError(`Render crf must be an integer from 0 to ${maximum} for ${options.codec ?? format}.`);
115
+ }
116
+ }
117
+ if (options.videoBitrate !== undefined && (!['mp4', 'mov', 'webm'].includes(format)
118
+ || options.codec === 'prores'
119
+ || typeof options.videoBitrate !== 'string'
120
+ || !/^(?:[1-9]\d{0,5})(?:\.\d{1,2})?[KMG]?$/.test(options.videoBitrate))) {
121
+ throw new TypeError('Render videoBitrate must be a positive bitrate such as 800K, 5M, or 1.5M on a bitrate-based encoder.');
122
+ }
123
+
124
+ return {
125
+ ...(options.codec ? { codec: codecs[options.codec], codecId: options.codec } : {}),
126
+ ...(options.crf !== undefined ? { crf: options.crf } : {}),
127
+ ...(options.videoBitrate !== undefined ? { videoBitrate: options.videoBitrate } : {}),
128
+ };
129
+ }
130
+
131
+ export function resolveRenderControlArgs(options = {}) {
132
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Render options must be an object.');
133
+ for (const field of Object.keys(options)) {
134
+ if (!['format', 'encoder', 'colorProfile', 'workers', 'lowMemoryMode', 'timeoutSeconds', 'resumeCheckpoint', 'checkpointMaxBytes', 'checkpointMaxAgeHours'].includes(field)) throw new TypeError(`Unsupported render option: ${field}.`);
135
+ }
136
+ if (options.workers !== undefined
137
+ && options.workers !== 'auto'
138
+ && (!Number.isSafeInteger(options.workers) || options.workers < 1 || options.workers > 64)) {
139
+ throw new TypeError('Render workers must be auto or an integer from 1 to 64.');
140
+ }
141
+ if (options.lowMemoryMode !== undefined && typeof options.lowMemoryMode !== 'boolean') {
142
+ throw new TypeError('Render lowMemoryMode must be a boolean.');
143
+ }
144
+ if (options.timeoutSeconds !== undefined
145
+ && (!Number.isSafeInteger(options.timeoutSeconds) || options.timeoutSeconds < 1 || options.timeoutSeconds > 86_400)) {
146
+ throw new TypeError('Render timeoutSeconds must be an integer from 1 to 86400.');
147
+ }
148
+ if (options.resumeCheckpoint !== undefined && typeof options.resumeCheckpoint !== 'boolean') {
149
+ throw new TypeError('Render resumeCheckpoint must be a boolean.');
150
+ }
151
+ if (options.checkpointMaxBytes !== undefined
152
+ && (!Number.isSafeInteger(options.checkpointMaxBytes) || options.checkpointMaxBytes < 1024 * 1024 || options.checkpointMaxBytes > 100 * 1024 ** 3)) {
153
+ throw new TypeError('checkpointMaxBytes must be an integer from 1048576 to 107374182400.');
154
+ }
155
+ if (options.checkpointMaxAgeHours !== undefined
156
+ && (!Number.isSafeInteger(options.checkpointMaxAgeHours) || options.checkpointMaxAgeHours < 1 || options.checkpointMaxAgeHours > 720)) {
157
+ throw new TypeError('checkpointMaxAgeHours must be an integer from 1 to 720.');
158
+ }
159
+ return [
160
+ ...(options.workers !== undefined ? ['--workers', String(options.workers)] : []),
161
+ ...(options.lowMemoryMode === true ? ['--low-memory-mode'] : options.lowMemoryMode === false ? ['--no-low-memory-mode'] : []),
162
+ ];
163
+ }