aivee 0.1.0-alpha.0 → 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 -21
  3. package/README.md +120 -118
  4. package/docs/architecture.md +401 -401
  5. package/docs/aspect-ratio-presets.md +72 -72
  6. package/docs/releasing.md +19 -19
  7. package/docs/usage.md +1193 -1193
  8. package/package.json +52 -52
  9. package/requirements-otio.txt +1 -1
  10. package/src/capabilities.mjs +307 -307
  11. package/src/cli.mjs +646 -646
  12. package/src/doctor.mjs +133 -133
  13. package/src/edits/analysis-to-edl.mjs +43 -43
  14. package/src/edits/detect-silence.mjs +51 -51
  15. package/src/edits/find-mistake-candidates.mjs +42 -42
  16. package/src/edits/plan-shot-placements.mjs +105 -105
  17. package/src/edits/plan-silence-cuts.mjs +49 -49
  18. package/src/edits/retime-transcript.mjs +105 -105
  19. package/src/files.mjs +80 -80
  20. package/src/index.mjs +44 -44
  21. package/src/interchange/otio.mjs +204 -204
  22. package/src/interchange/otio_bridge.py +521 -521
  23. package/src/media/analysis-provider.mjs +75 -75
  24. package/src/media/compatibility.mjs +45 -45
  25. package/src/media/ffmpeg-capabilities.mjs +129 -129
  26. package/src/media/frame-rate.mjs +58 -58
  27. package/src/media/frame-time.mjs +22 -22
  28. package/src/media/preview-assets.mjs +350 -350
  29. package/src/media/probe.mjs +149 -149
  30. package/src/media/render-cuts.mjs +90 -90
  31. package/src/media/scene-detection.mjs +107 -107
  32. package/src/media/scene-review.mjs +205 -205
  33. package/src/project/assets.mjs +1194 -1194
  34. package/src/project/create.mjs +100 -100
  35. package/src/project/presets.mjs +44 -44
  36. package/src/render/checkpoints.mjs +192 -192
  37. package/src/render/formats.mjs +163 -163
  38. package/src/render/hyperframes.mjs +590 -590
  39. package/src/render/process.mjs +111 -111
  40. package/src/render/progress.mjs +46 -46
  41. package/src/render/timeline-control.mjs +53 -53
  42. package/src/timeline/assemble.mjs +119 -119
  43. package/src/timeline/audio-effects.mjs +75 -75
  44. package/src/timeline/audio-mastering.mjs +236 -236
  45. package/src/timeline/audio-mix.mjs +105 -105
  46. package/src/timeline/audio-track-effects.mjs +139 -139
  47. package/src/timeline/audio-volume.mjs +63 -63
  48. package/src/timeline/background.mjs +8 -8
  49. package/src/timeline/blend-mode.mjs +34 -34
  50. package/src/timeline/caption-layouts.mjs +62 -61
  51. package/src/timeline/captions.mjs +80 -80
  52. package/src/timeline/edit.mjs +965 -965
  53. package/src/timeline/fingerprint.mjs +6 -6
  54. package/src/timeline/from-edl.mjs +31 -31
  55. package/src/timeline/history-store.mjs +281 -281
  56. package/src/timeline/history.mjs +69 -69
  57. package/src/timeline/inspect.mjs +117 -117
  58. package/src/timeline/keyframes.mjs +158 -158
  59. package/src/timeline/layers.mjs +18 -18
  60. package/src/timeline/mask.mjs +53 -53
  61. package/src/timeline/project.mjs +52 -52
  62. package/src/timeline/render.mjs +759 -759
  63. package/src/timeline/sidechain-render.mjs +209 -209
  64. package/src/timeline/source-fit.mjs +15 -15
  65. package/src/timeline/speech-bubble.mjs +26 -26
  66. package/src/timeline/speed-ramp.mjs +188 -188
  67. package/src/timeline/track-state.mjs +22 -22
  68. package/src/timeline/transition.mjs +34 -34
  69. package/src/timeline/validate.mjs +322 -322
  70. package/src/transcript/corrections.mjs +21 -21
  71. package/src/transcript/normalize.mjs +17 -17
  72. package/src/transcript/provider.mjs +42 -42
  73. package/src/transcript/subtitles.mjs +57 -57
  74. package/src/transcript/validate.mjs +39 -39
@@ -1,307 +1,307 @@
1
- import { spawnSync } from 'node:child_process';
2
- import { createRequire } from 'node:module';
3
- import { listFfmpegDecoders, listFfmpegDemuxers, listFfmpegEncoders, listFfmpegFilters } from './media/ffmpeg-capabilities.mjs';
4
- import { CANVAS_PRESETS } from './project/presets.mjs';
5
-
6
- const require = createRequire(import.meta.url);
7
- const VIDEO_ENCODERS = Object.freeze({
8
- h264: 'libx264',
9
- h265: 'libx265',
10
- vp9: 'libvpx-vp9',
11
- prores: 'prores_ks',
12
- });
13
-
14
- export function getEngineCapabilities(options = {}) {
15
- if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Capability options must be an object.');
16
- for (const key of Object.keys(options)) if (!['ffmpeg', 'ffprobe', 'otioPython'].includes(key)) throw new TypeError(`Unsupported capability option: ${key}.`);
17
- const ffmpegCommand = options.ffmpeg ?? 'ffmpeg';
18
- const ffprobeCommand = options.ffprobe ?? 'ffprobe';
19
- const otioPythonCommand = options.otioPython ?? process.env.AIVE_OTIO_PYTHON ?? process.env.PYTHON ?? 'python';
20
- if (typeof ffmpegCommand !== 'string' || !ffmpegCommand.trim()) throw new TypeError('ffmpeg must be a non-empty command path.');
21
- if (typeof ffprobeCommand !== 'string' || !ffprobeCommand.trim()) throw new TypeError('ffprobe must be a non-empty command path.');
22
- if (typeof otioPythonCommand !== 'string' || !otioPythonCommand.trim()) throw new TypeError('otioPython must be a non-empty command path.');
23
-
24
- const nodeVersion = process.versions.node;
25
- const nodeMajor = Number(nodeVersion.split('.')[0]);
26
- const ffmpeg = inspectCommand(ffmpegCommand);
27
- const ffprobe = inspectCommand(ffprobeCommand);
28
- const openTimelineIO = inspectPythonModule(otioPythonCommand, 'opentimelineio');
29
- let encoders = new Set();
30
- let decoders = new Set();
31
- let demuxers = new Set();
32
- let filters = new Set();
33
- let ffmpegInspectionError;
34
- let inputInspectionError;
35
- if (ffmpeg.available) {
36
- try {
37
- encoders = listFfmpegEncoders(ffmpegCommand);
38
- filters = listFfmpegFilters(ffmpegCommand);
39
- } catch (error) {
40
- ffmpegInspectionError = error.message;
41
- }
42
- try {
43
- decoders = listFfmpegDecoders(ffmpegCommand);
44
- demuxers = listFfmpegDemuxers(ffmpegCommand);
45
- } catch (error) {
46
- inputInspectionError = error.message;
47
- }
48
- }
49
-
50
- const hyperframes = packageAvailable('hyperframes');
51
- const gsap = packageAvailable('gsap');
52
- const renderRuntimeAvailable = nodeMajor >= 22 && ffmpeg.available && ffprobe.available && hyperframes && gsap;
53
- const supportsFilter = (name) => filters.has(name);
54
- const supportsEncoder = (name) => encoders.has(name);
55
- const colorProfiles = {
56
- rec709: { available: supportsFilter('colorspace'), formats: ['mp4', 'mov', 'webm'] },
57
- 'rec2020-sdr': { available: supportsFilter('colorspace'), formats: ['mp4', 'mov', 'webm'] },
58
- hdr10: { available: supportsFilter('zscale') && supportsEncoder('libx265'), formats: ['mp4', 'mov'] },
59
- };
60
- const codecsByFormat = {
61
- mp4: { h264: supportsEncoder('libx264'), h265: supportsEncoder('libx265') },
62
- mov: { h264: supportsEncoder('libx264'), h265: supportsEncoder('libx265'), prores: supportsEncoder('prores_ks') },
63
- webm: { vp9: supportsEncoder('libvpx-vp9') },
64
- };
65
- const outputColorProfiles = (format) => Object.fromEntries(Object.entries(colorProfiles)
66
- .filter(([, profile]) => profile.formats.includes(format))
67
- .map(([id, profile]) => {
68
- const compatibleEncoderAvailable = id === 'hdr10'
69
- ? codecsByFormat[format]?.h265 === true
70
- : Object.values(codecsByFormat[format] ?? {}).some(Boolean);
71
- return [id, profile.available && compatibleEncoderAvailable];
72
- }));
73
- const inputContainers = {
74
- mp4: ['mp4'],
75
- mov: ['mov'],
76
- avi: ['avi'],
77
- matroska: ['matroska'],
78
- webm: ['webm'],
79
- mpegts: ['mpegts'],
80
- mpeg: ['mpeg'],
81
- mp3: ['mp3'],
82
- ogg: ['ogg'],
83
- wav: ['wav'],
84
- image2: ['image2'],
85
- };
86
- return {
87
- schemaVersion: 1,
88
- node: { version: nodeVersion, supported: nodeMajor >= 22 },
89
- tools: {
90
- ffmpeg: { ...ffmpeg, ...(ffmpegInspectionError ? { inspectionError: ffmpegInspectionError } : {}) },
91
- ffprobe,
92
- openTimelineIO: {
93
- available: openTimelineIO.available,
94
- optional: true,
95
- ...(openTimelineIO.version ? { version: openTimelineIO.version } : {}),
96
- ...(openTimelineIO.reason ? { reason: openTimelineIO.reason } : {}),
97
- },
98
- hyperframes: { available: hyperframes },
99
- gsap: { available: gsap },
100
- browserRuntime: { available: null, checked: false, checkCommand: 'npm run doctor' },
101
- },
102
- media: {
103
- assetIngest: {
104
- available: ffmpeg.available && ffprobe.available,
105
- batchRegistration: true,
106
- batchSemantics: 'sequential-best-effort',
107
- progressCallbacks: {
108
- supported: true,
109
- asyncApiOnly: true,
110
- stages: ['asset-batch', 'asset-batch-item', 'asset-copy', 'asset-probe', 'asset-publish', 'asset-relink-copy', 'asset-relink-probe', 'asset-relink-publish'],
111
- eventFields: ['stage', 'phase'],
112
- optionalEventFields: ['percent', 'completed', 'total', 'assetId', 'batchIndex', 'batchTotal', 'registered', 'failed', 'message'],
113
- },
114
- },
115
- assetVerification: {
116
- available: true,
117
- progressCallbacks: {
118
- supported: true,
119
- asyncApiOnly: true,
120
- stages: ['asset-verification'],
121
- eventFields: ['stage', 'phase'],
122
- optionalEventFields: ['percent', 'completed', 'total', 'assetId', 'variant'],
123
- },
124
- },
125
- input: {
126
- available: ffmpeg.available && ffprobe.available,
127
- inventoryChecked: ffmpeg.available && ffprobe.available && !inputInspectionError,
128
- ...(inputInspectionError ? { inspectionError: inputInspectionError } : {}),
129
- containers: Object.fromEntries(Object.entries(inputContainers)
130
- .map(([name, aliases]) => [name, aliases.some((alias) => demuxers.has(alias))])),
131
- videoDecoders: Object.fromEntries(['h264', 'hevc', 'av1', 'vp8', 'vp9', 'prores', 'mpeg2video', 'mpeg4']
132
- .map((name) => [name, decoders.has(name)])),
133
- audioDecoders: Object.fromEntries(['aac', 'mp3', 'opus', 'vorbis', 'flac', 'pcm_s16le']
134
- .map((name) => [name, decoders.has(name)])),
135
- },
136
- proxyGeneration: {
137
- available: ffmpeg.available && ffprobe.available,
138
- profileVersion: 2,
139
- sourceUpscaling: false,
140
- keyframeIntervalSeconds: 1,
141
- forcedKeyframes: true,
142
- progressCallbacks: {
143
- supported: true,
144
- asyncApiOnly: true,
145
- stages: ['proxy-preflight', 'proxy-generation', 'proxy-validation', 'proxy-publish'],
146
- eventFields: ['stage', 'phase'],
147
- optionalEventFields: ['percent', 'completed', 'total', 'assetId', 'reason'],
148
- },
149
- },
150
- sceneDetection: {
151
- available: ffmpeg.available && ffprobe.available && filters.has('select') && filters.has('metadata'),
152
- analyzer: 'ffmpeg-scene-score',
153
- defaultThreshold: 0.35,
154
- thresholdRange: [0.05, 1],
155
- defaultMinimumSpacingSeconds: 0.5,
156
- reviewRequired: true,
157
- outputTypes: ['scene-cut'],
158
- cancellation: true,
159
- },
160
- sceneReviewPack: {
161
- available: ffmpeg.available && ffprobe.available
162
- && filters.has('select') && filters.has('metadata') && filters.has('scale') && filters.has('pad') && filters.has('tile'),
163
- thumbnailDefaultSize: 320,
164
- thumbnailSizeRange: [80, 1280],
165
- defaultContactSheetLimit: 60,
166
- contactSheetLimitRange: [1, 120],
167
- reviewRequired: true,
168
- cancellation: true,
169
- progressCallbacks: {
170
- supported: true,
171
- asyncApiOnly: true,
172
- stages: ['scene-source-fingerprint', 'scene-analysis', 'scene-thumbnails', 'scene-contact-sheet', 'scene-review-publish'],
173
- eventFields: ['stage', 'phase'],
174
- optionalEventFields: ['completed', 'total', 'percent', 'message'],
175
- },
176
- },
177
- sceneReviewPlacementPlanning: {
178
- available: true,
179
- requires: ['validated-timeline', 'registered-video-asset-matching-scene-review-sha256', 'scene-review-pack', 'explicit-shot-selections'],
180
- output: 'review-required-timeline-operations',
181
- appliesChanges: false,
182
- maximumPlacements: 500,
183
- },
184
- reviewPlanApplication: {
185
- available: true,
186
- explicitCommandRequired: true,
187
- staleTimelineRejected: true,
188
- undoable: true,
189
- maximumOperations: 2000,
190
- },
191
- },
192
- interchange: {
193
- openTimelineIO: {
194
- available: openTimelineIO.available,
195
- formats: ['.otio'],
196
- import: true,
197
- export: true,
198
- optionalDependency: 'Python OpenTimelineIO',
199
- installCommand: 'python -m pip install -r requirements-otio.txt',
200
- ...(openTimelineIO.version ? { pythonPackageVersion: openTimelineIO.version } : {}),
201
- ...(openTimelineIO.reason ? { reason: openTimelineIO.reason } : {}),
202
- },
203
- },
204
- render: {
205
- requirementsAvailable: renderRuntimeAvailable,
206
- progressCallbacks: {
207
- supported: true,
208
- asyncApiOnly: true,
209
- stages: ['preprocessing', 'proxy-resolution', 'audio-alignment', 'playback-processing', 'audio-effects', 'sidechain-processing', 'capture', 'encoding', 'validation', 'publish', 'timeline-validation', 'mastering', 'mastering-analysis', 'mastering-encode', 'mastering-validation', 'mastering-publish'],
210
- eventFields: ['stage', 'phase'],
211
- optionalEventFields: ['percent', 'completed', 'total', 'target', 'codec', 'reason'],
212
- },
213
- videoEncoders: Object.fromEntries(Object.entries(VIDEO_ENCODERS).map(([id, name]) => [id, encoders.has(name)])),
214
- outputs: {
215
- mp4: {
216
- available: renderRuntimeAvailable && Object.values(codecsByFormat.mp4).some(Boolean),
217
- codecs: codecsByFormat.mp4,
218
- defaultCodec: 'h264',
219
- defaultCodecAvailable: codecsByFormat.mp4.h264,
220
- colorProfiles: outputColorProfiles('mp4'),
221
- },
222
- mov: {
223
- available: renderRuntimeAvailable && Object.values(codecsByFormat.mov).some(Boolean),
224
- codecs: codecsByFormat.mov,
225
- defaultCodec: 'h264',
226
- defaultCodecAvailable: codecsByFormat.mov.h264,
227
- colorProfiles: outputColorProfiles('mov'),
228
- },
229
- webm: {
230
- available: renderRuntimeAvailable && Object.values(codecsByFormat.webm).some(Boolean),
231
- codecs: codecsByFormat.webm,
232
- defaultCodec: 'vp9',
233
- defaultCodecAvailable: codecsByFormat.webm.vp9,
234
- colorProfiles: outputColorProfiles('webm'),
235
- },
236
- gif: { available: renderRuntimeAvailable && supportsEncoder('gif') },
237
- 'png-sequence': { available: hyperframes },
238
- },
239
- colorProfiles,
240
- canvas: {
241
- backgroundColor: {
242
- available: true,
243
- default: '#10141c',
244
- transparent: true,
245
- acceptedHexDigits: [3, 4, 6, 8],
246
- },
247
- },
248
- canvasPresets: CANVAS_PRESETS.map(({ id, aspectRatio, width, height, platforms }) => ({
249
- id, aspectRatio, width, height, platforms: [...platforms],
250
- })),
251
- filters: {
252
- rubberband: filters.has('rubberband'),
253
- afftdn: filters.has('afftdn'),
254
- stereotools: filters.has('stereotools'),
255
- equalizer: filters.has('equalizer'),
256
- highpass: filters.has('highpass'),
257
- lowpass: filters.has('lowpass'),
258
- acompressor: filters.has('acompressor'),
259
- sidechaincompress: filters.has('sidechaincompress'),
260
- loudnorm: filters.has('loudnorm'),
261
- alimiter: filters.has('alimiter'),
262
- aresample: filters.has('aresample'),
263
- reverse: filters.has('reverse'),
264
- areverse: filters.has('areverse'),
265
- colorspace: filters.has('colorspace'),
266
- zscale: filters.has('zscale'),
267
- },
268
- },
269
- requiredToolsReady: nodeMajor >= 22 && ffmpeg.available && ffprobe.available && hyperframes && gsap,
270
- };
271
- }
272
-
273
- function inspectCommand(command, args = ['-version']) {
274
- const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024 });
275
- if (result.error || result.status !== 0) return { available: false };
276
- return { available: true, version: result.stdout.trim().split(/\r?\n/, 1)[0] };
277
- }
278
-
279
- function inspectPythonModule(command, moduleName) {
280
- const result = spawnSync(command, ['-c', `import ${moduleName} as module; assert 'otio_json' in module.adapters.available_adapter_names(), 'otio_json adapter unavailable'; print(getattr(module, '__version__', 'installed'))`], {
281
- encoding: 'utf8',
282
- windowsHide: true,
283
- maxBuffer: 1024 * 1024,
284
- });
285
- if (result.error) return { available: false, reason: 'python-not-found' };
286
- if (result.status !== 0) {
287
- return {
288
- available: false,
289
- reason: /No module named ['"]?opentimelineio/.test(`${result.stderr}\n${result.stdout}`)
290
- ? 'package-not-installed'
291
- : /otio_json adapter unavailable/.test(`${result.stderr}\n${result.stdout}`)
292
- ? 'json-adapter-unavailable'
293
- : 'package-import-failed',
294
- };
295
- }
296
- const version = result.stdout.trim().split(/\r?\n/, 1)[0];
297
- return { available: true, ...(version ? { version } : {}) };
298
- }
299
-
300
- function packageAvailable(name) {
301
- try {
302
- require.resolve(`${name}/package.json`);
303
- return true;
304
- } catch {
305
- return false;
306
- }
307
- }
1
+ import { spawnSync } from 'node:child_process';
2
+ import { createRequire } from 'node:module';
3
+ import { listFfmpegDecoders, listFfmpegDemuxers, listFfmpegEncoders, listFfmpegFilters } from './media/ffmpeg-capabilities.mjs';
4
+ import { CANVAS_PRESETS } from './project/presets.mjs';
5
+
6
+ const require = createRequire(import.meta.url);
7
+ const VIDEO_ENCODERS = Object.freeze({
8
+ h264: 'libx264',
9
+ h265: 'libx265',
10
+ vp9: 'libvpx-vp9',
11
+ prores: 'prores_ks',
12
+ });
13
+
14
+ export function getEngineCapabilities(options = {}) {
15
+ if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Capability options must be an object.');
16
+ for (const key of Object.keys(options)) if (!['ffmpeg', 'ffprobe', 'otioPython'].includes(key)) throw new TypeError(`Unsupported capability option: ${key}.`);
17
+ const ffmpegCommand = options.ffmpeg ?? 'ffmpeg';
18
+ const ffprobeCommand = options.ffprobe ?? 'ffprobe';
19
+ const otioPythonCommand = options.otioPython ?? process.env.AIVE_OTIO_PYTHON ?? process.env.PYTHON ?? 'python';
20
+ if (typeof ffmpegCommand !== 'string' || !ffmpegCommand.trim()) throw new TypeError('ffmpeg must be a non-empty command path.');
21
+ if (typeof ffprobeCommand !== 'string' || !ffprobeCommand.trim()) throw new TypeError('ffprobe must be a non-empty command path.');
22
+ if (typeof otioPythonCommand !== 'string' || !otioPythonCommand.trim()) throw new TypeError('otioPython must be a non-empty command path.');
23
+
24
+ const nodeVersion = process.versions.node;
25
+ const nodeMajor = Number(nodeVersion.split('.')[0]);
26
+ const ffmpeg = inspectCommand(ffmpegCommand);
27
+ const ffprobe = inspectCommand(ffprobeCommand);
28
+ const openTimelineIO = inspectPythonModule(otioPythonCommand, 'opentimelineio');
29
+ let encoders = new Set();
30
+ let decoders = new Set();
31
+ let demuxers = new Set();
32
+ let filters = new Set();
33
+ let ffmpegInspectionError;
34
+ let inputInspectionError;
35
+ if (ffmpeg.available) {
36
+ try {
37
+ encoders = listFfmpegEncoders(ffmpegCommand);
38
+ filters = listFfmpegFilters(ffmpegCommand);
39
+ } catch (error) {
40
+ ffmpegInspectionError = error.message;
41
+ }
42
+ try {
43
+ decoders = listFfmpegDecoders(ffmpegCommand);
44
+ demuxers = listFfmpegDemuxers(ffmpegCommand);
45
+ } catch (error) {
46
+ inputInspectionError = error.message;
47
+ }
48
+ }
49
+
50
+ const hyperframes = packageAvailable('hyperframes');
51
+ const gsap = packageAvailable('gsap');
52
+ const renderRuntimeAvailable = nodeMajor >= 22 && ffmpeg.available && ffprobe.available && hyperframes && gsap;
53
+ const supportsFilter = (name) => filters.has(name);
54
+ const supportsEncoder = (name) => encoders.has(name);
55
+ const colorProfiles = {
56
+ rec709: { available: supportsFilter('colorspace'), formats: ['mp4', 'mov', 'webm'] },
57
+ 'rec2020-sdr': { available: supportsFilter('colorspace'), formats: ['mp4', 'mov', 'webm'] },
58
+ hdr10: { available: supportsFilter('zscale') && supportsEncoder('libx265'), formats: ['mp4', 'mov'] },
59
+ };
60
+ const codecsByFormat = {
61
+ mp4: { h264: supportsEncoder('libx264'), h265: supportsEncoder('libx265') },
62
+ mov: { h264: supportsEncoder('libx264'), h265: supportsEncoder('libx265'), prores: supportsEncoder('prores_ks') },
63
+ webm: { vp9: supportsEncoder('libvpx-vp9') },
64
+ };
65
+ const outputColorProfiles = (format) => Object.fromEntries(Object.entries(colorProfiles)
66
+ .filter(([, profile]) => profile.formats.includes(format))
67
+ .map(([id, profile]) => {
68
+ const compatibleEncoderAvailable = id === 'hdr10'
69
+ ? codecsByFormat[format]?.h265 === true
70
+ : Object.values(codecsByFormat[format] ?? {}).some(Boolean);
71
+ return [id, profile.available && compatibleEncoderAvailable];
72
+ }));
73
+ const inputContainers = {
74
+ mp4: ['mp4'],
75
+ mov: ['mov'],
76
+ avi: ['avi'],
77
+ matroska: ['matroska'],
78
+ webm: ['webm'],
79
+ mpegts: ['mpegts'],
80
+ mpeg: ['mpeg'],
81
+ mp3: ['mp3'],
82
+ ogg: ['ogg'],
83
+ wav: ['wav'],
84
+ image2: ['image2'],
85
+ };
86
+ return {
87
+ schemaVersion: 1,
88
+ node: { version: nodeVersion, supported: nodeMajor >= 22 },
89
+ tools: {
90
+ ffmpeg: { ...ffmpeg, ...(ffmpegInspectionError ? { inspectionError: ffmpegInspectionError } : {}) },
91
+ ffprobe,
92
+ openTimelineIO: {
93
+ available: openTimelineIO.available,
94
+ optional: true,
95
+ ...(openTimelineIO.version ? { version: openTimelineIO.version } : {}),
96
+ ...(openTimelineIO.reason ? { reason: openTimelineIO.reason } : {}),
97
+ },
98
+ hyperframes: { available: hyperframes },
99
+ gsap: { available: gsap },
100
+ browserRuntime: { available: null, checked: false, checkCommand: 'npm run doctor' },
101
+ },
102
+ media: {
103
+ assetIngest: {
104
+ available: ffmpeg.available && ffprobe.available,
105
+ batchRegistration: true,
106
+ batchSemantics: 'sequential-best-effort',
107
+ progressCallbacks: {
108
+ supported: true,
109
+ asyncApiOnly: true,
110
+ stages: ['asset-batch', 'asset-batch-item', 'asset-copy', 'asset-probe', 'asset-publish', 'asset-relink-copy', 'asset-relink-probe', 'asset-relink-publish'],
111
+ eventFields: ['stage', 'phase'],
112
+ optionalEventFields: ['percent', 'completed', 'total', 'assetId', 'batchIndex', 'batchTotal', 'registered', 'failed', 'message'],
113
+ },
114
+ },
115
+ assetVerification: {
116
+ available: true,
117
+ progressCallbacks: {
118
+ supported: true,
119
+ asyncApiOnly: true,
120
+ stages: ['asset-verification'],
121
+ eventFields: ['stage', 'phase'],
122
+ optionalEventFields: ['percent', 'completed', 'total', 'assetId', 'variant'],
123
+ },
124
+ },
125
+ input: {
126
+ available: ffmpeg.available && ffprobe.available,
127
+ inventoryChecked: ffmpeg.available && ffprobe.available && !inputInspectionError,
128
+ ...(inputInspectionError ? { inspectionError: inputInspectionError } : {}),
129
+ containers: Object.fromEntries(Object.entries(inputContainers)
130
+ .map(([name, aliases]) => [name, aliases.some((alias) => demuxers.has(alias))])),
131
+ videoDecoders: Object.fromEntries(['h264', 'hevc', 'av1', 'vp8', 'vp9', 'prores', 'mpeg2video', 'mpeg4']
132
+ .map((name) => [name, decoders.has(name)])),
133
+ audioDecoders: Object.fromEntries(['aac', 'mp3', 'opus', 'vorbis', 'flac', 'pcm_s16le']
134
+ .map((name) => [name, decoders.has(name)])),
135
+ },
136
+ proxyGeneration: {
137
+ available: ffmpeg.available && ffprobe.available,
138
+ profileVersion: 2,
139
+ sourceUpscaling: false,
140
+ keyframeIntervalSeconds: 1,
141
+ forcedKeyframes: true,
142
+ progressCallbacks: {
143
+ supported: true,
144
+ asyncApiOnly: true,
145
+ stages: ['proxy-preflight', 'proxy-generation', 'proxy-validation', 'proxy-publish'],
146
+ eventFields: ['stage', 'phase'],
147
+ optionalEventFields: ['percent', 'completed', 'total', 'assetId', 'reason'],
148
+ },
149
+ },
150
+ sceneDetection: {
151
+ available: ffmpeg.available && ffprobe.available && filters.has('select') && filters.has('metadata'),
152
+ analyzer: 'ffmpeg-scene-score',
153
+ defaultThreshold: 0.35,
154
+ thresholdRange: [0.05, 1],
155
+ defaultMinimumSpacingSeconds: 0.5,
156
+ reviewRequired: true,
157
+ outputTypes: ['scene-cut'],
158
+ cancellation: true,
159
+ },
160
+ sceneReviewPack: {
161
+ available: ffmpeg.available && ffprobe.available
162
+ && filters.has('select') && filters.has('metadata') && filters.has('scale') && filters.has('pad') && filters.has('tile'),
163
+ thumbnailDefaultSize: 320,
164
+ thumbnailSizeRange: [80, 1280],
165
+ defaultContactSheetLimit: 60,
166
+ contactSheetLimitRange: [1, 120],
167
+ reviewRequired: true,
168
+ cancellation: true,
169
+ progressCallbacks: {
170
+ supported: true,
171
+ asyncApiOnly: true,
172
+ stages: ['scene-source-fingerprint', 'scene-analysis', 'scene-thumbnails', 'scene-contact-sheet', 'scene-review-publish'],
173
+ eventFields: ['stage', 'phase'],
174
+ optionalEventFields: ['completed', 'total', 'percent', 'message'],
175
+ },
176
+ },
177
+ sceneReviewPlacementPlanning: {
178
+ available: true,
179
+ requires: ['validated-timeline', 'registered-video-asset-matching-scene-review-sha256', 'scene-review-pack', 'explicit-shot-selections'],
180
+ output: 'review-required-timeline-operations',
181
+ appliesChanges: false,
182
+ maximumPlacements: 500,
183
+ },
184
+ reviewPlanApplication: {
185
+ available: true,
186
+ explicitCommandRequired: true,
187
+ staleTimelineRejected: true,
188
+ undoable: true,
189
+ maximumOperations: 2000,
190
+ },
191
+ },
192
+ interchange: {
193
+ openTimelineIO: {
194
+ available: openTimelineIO.available,
195
+ formats: ['.otio'],
196
+ import: true,
197
+ export: true,
198
+ optionalDependency: 'Python OpenTimelineIO',
199
+ installCommand: 'python -m pip install -r requirements-otio.txt',
200
+ ...(openTimelineIO.version ? { pythonPackageVersion: openTimelineIO.version } : {}),
201
+ ...(openTimelineIO.reason ? { reason: openTimelineIO.reason } : {}),
202
+ },
203
+ },
204
+ render: {
205
+ requirementsAvailable: renderRuntimeAvailable,
206
+ progressCallbacks: {
207
+ supported: true,
208
+ asyncApiOnly: true,
209
+ stages: ['preprocessing', 'proxy-resolution', 'audio-alignment', 'playback-processing', 'audio-effects', 'sidechain-processing', 'capture', 'encoding', 'validation', 'publish', 'timeline-validation', 'mastering', 'mastering-analysis', 'mastering-encode', 'mastering-validation', 'mastering-publish'],
210
+ eventFields: ['stage', 'phase'],
211
+ optionalEventFields: ['percent', 'completed', 'total', 'target', 'codec', 'reason'],
212
+ },
213
+ videoEncoders: Object.fromEntries(Object.entries(VIDEO_ENCODERS).map(([id, name]) => [id, encoders.has(name)])),
214
+ outputs: {
215
+ mp4: {
216
+ available: renderRuntimeAvailable && Object.values(codecsByFormat.mp4).some(Boolean),
217
+ codecs: codecsByFormat.mp4,
218
+ defaultCodec: 'h264',
219
+ defaultCodecAvailable: codecsByFormat.mp4.h264,
220
+ colorProfiles: outputColorProfiles('mp4'),
221
+ },
222
+ mov: {
223
+ available: renderRuntimeAvailable && Object.values(codecsByFormat.mov).some(Boolean),
224
+ codecs: codecsByFormat.mov,
225
+ defaultCodec: 'h264',
226
+ defaultCodecAvailable: codecsByFormat.mov.h264,
227
+ colorProfiles: outputColorProfiles('mov'),
228
+ },
229
+ webm: {
230
+ available: renderRuntimeAvailable && Object.values(codecsByFormat.webm).some(Boolean),
231
+ codecs: codecsByFormat.webm,
232
+ defaultCodec: 'vp9',
233
+ defaultCodecAvailable: codecsByFormat.webm.vp9,
234
+ colorProfiles: outputColorProfiles('webm'),
235
+ },
236
+ gif: { available: renderRuntimeAvailable && supportsEncoder('gif') },
237
+ 'png-sequence': { available: hyperframes },
238
+ },
239
+ colorProfiles,
240
+ canvas: {
241
+ backgroundColor: {
242
+ available: true,
243
+ default: '#10141c',
244
+ transparent: true,
245
+ acceptedHexDigits: [3, 4, 6, 8],
246
+ },
247
+ },
248
+ canvasPresets: CANVAS_PRESETS.map(({ id, aspectRatio, width, height, platforms }) => ({
249
+ id, aspectRatio, width, height, platforms: [...platforms],
250
+ })),
251
+ filters: {
252
+ rubberband: filters.has('rubberband'),
253
+ afftdn: filters.has('afftdn'),
254
+ stereotools: filters.has('stereotools'),
255
+ equalizer: filters.has('equalizer'),
256
+ highpass: filters.has('highpass'),
257
+ lowpass: filters.has('lowpass'),
258
+ acompressor: filters.has('acompressor'),
259
+ sidechaincompress: filters.has('sidechaincompress'),
260
+ loudnorm: filters.has('loudnorm'),
261
+ alimiter: filters.has('alimiter'),
262
+ aresample: filters.has('aresample'),
263
+ reverse: filters.has('reverse'),
264
+ areverse: filters.has('areverse'),
265
+ colorspace: filters.has('colorspace'),
266
+ zscale: filters.has('zscale'),
267
+ },
268
+ },
269
+ requiredToolsReady: nodeMajor >= 22 && ffmpeg.available && ffprobe.available && hyperframes && gsap,
270
+ };
271
+ }
272
+
273
+ function inspectCommand(command, args = ['-version']) {
274
+ const result = spawnSync(command, args, { encoding: 'utf8', windowsHide: true, maxBuffer: 1024 * 1024 });
275
+ if (result.error || result.status !== 0) return { available: false };
276
+ return { available: true, version: result.stdout.trim().split(/\r?\n/, 1)[0] };
277
+ }
278
+
279
+ function inspectPythonModule(command, moduleName) {
280
+ const result = spawnSync(command, ['-c', `import ${moduleName} as module; assert 'otio_json' in module.adapters.available_adapter_names(), 'otio_json adapter unavailable'; print(getattr(module, '__version__', 'installed'))`], {
281
+ encoding: 'utf8',
282
+ windowsHide: true,
283
+ maxBuffer: 1024 * 1024,
284
+ });
285
+ if (result.error) return { available: false, reason: 'python-not-found' };
286
+ if (result.status !== 0) {
287
+ return {
288
+ available: false,
289
+ reason: /No module named ['"]?opentimelineio/.test(`${result.stderr}\n${result.stdout}`)
290
+ ? 'package-not-installed'
291
+ : /otio_json adapter unavailable/.test(`${result.stderr}\n${result.stdout}`)
292
+ ? 'json-adapter-unavailable'
293
+ : 'package-import-failed',
294
+ };
295
+ }
296
+ const version = result.stdout.trim().split(/\r?\n/, 1)[0];
297
+ return { available: true, ...(version ? { version } : {}) };
298
+ }
299
+
300
+ function packageAvailable(name) {
301
+ try {
302
+ require.resolve(`${name}/package.json`);
303
+ return true;
304
+ } catch {
305
+ return false;
306
+ }
307
+ }