aivee 0.0.0-stage → 0.1.0-dev.7025438

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 (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +117 -2
  3. package/docs/architecture.md +401 -0
  4. package/docs/aspect-ratio-presets.md +72 -0
  5. package/docs/releasing.md +19 -0
  6. package/docs/usage.md +1193 -0
  7. package/package.json +50 -4
  8. package/requirements-otio.txt +1 -0
  9. package/src/capabilities.mjs +307 -0
  10. package/src/cli.mjs +646 -0
  11. package/src/doctor.mjs +133 -0
  12. package/src/edits/analysis-to-edl.mjs +43 -0
  13. package/src/edits/detect-silence.mjs +51 -0
  14. package/src/edits/find-mistake-candidates.mjs +42 -0
  15. package/src/edits/plan-shot-placements.mjs +105 -0
  16. package/src/edits/plan-silence-cuts.mjs +49 -0
  17. package/src/edits/retime-transcript.mjs +105 -0
  18. package/src/files.mjs +80 -0
  19. package/src/index.mjs +44 -0
  20. package/src/interchange/otio.mjs +204 -0
  21. package/src/interchange/otio_bridge.py +521 -0
  22. package/src/media/analysis-provider.mjs +75 -0
  23. package/src/media/compatibility.mjs +45 -0
  24. package/src/media/ffmpeg-capabilities.mjs +129 -0
  25. package/src/media/frame-rate.mjs +58 -0
  26. package/src/media/frame-time.mjs +22 -0
  27. package/src/media/preview-assets.mjs +350 -0
  28. package/src/media/probe.mjs +149 -0
  29. package/src/media/render-cuts.mjs +90 -0
  30. package/src/media/scene-detection.mjs +107 -0
  31. package/src/media/scene-review.mjs +205 -0
  32. package/src/project/assets.mjs +1194 -0
  33. package/src/project/create.mjs +100 -0
  34. package/src/project/presets.mjs +44 -0
  35. package/src/render/checkpoints.mjs +192 -0
  36. package/src/render/formats.mjs +163 -0
  37. package/src/render/hyperframes.mjs +590 -0
  38. package/src/render/process.mjs +111 -0
  39. package/src/render/progress.mjs +46 -0
  40. package/src/render/timeline-control.mjs +53 -0
  41. package/src/timeline/assemble.mjs +119 -0
  42. package/src/timeline/audio-effects.mjs +75 -0
  43. package/src/timeline/audio-mastering.mjs +236 -0
  44. package/src/timeline/audio-mix.mjs +105 -0
  45. package/src/timeline/audio-track-effects.mjs +139 -0
  46. package/src/timeline/audio-volume.mjs +63 -0
  47. package/src/timeline/background.mjs +8 -0
  48. package/src/timeline/blend-mode.mjs +34 -0
  49. package/src/timeline/caption-layouts.mjs +61 -0
  50. package/src/timeline/captions.mjs +80 -0
  51. package/src/timeline/edit.mjs +965 -0
  52. package/src/timeline/fingerprint.mjs +6 -0
  53. package/src/timeline/from-edl.mjs +31 -0
  54. package/src/timeline/history-store.mjs +281 -0
  55. package/src/timeline/history.mjs +69 -0
  56. package/src/timeline/inspect.mjs +117 -0
  57. package/src/timeline/keyframes.mjs +158 -0
  58. package/src/timeline/layers.mjs +18 -0
  59. package/src/timeline/mask.mjs +53 -0
  60. package/src/timeline/project.mjs +52 -0
  61. package/src/timeline/render.mjs +759 -0
  62. package/src/timeline/sidechain-render.mjs +209 -0
  63. package/src/timeline/source-fit.mjs +15 -0
  64. package/src/timeline/speech-bubble.mjs +26 -0
  65. package/src/timeline/speed-ramp.mjs +188 -0
  66. package/src/timeline/track-state.mjs +22 -0
  67. package/src/timeline/transition.mjs +34 -0
  68. package/src/timeline/validate.mjs +322 -0
  69. package/src/transcript/corrections.mjs +21 -0
  70. package/src/transcript/normalize.mjs +17 -0
  71. package/src/transcript/provider.mjs +42 -0
  72. package/src/transcript/subtitles.mjs +57 -0
  73. package/src/transcript/validate.mjs +39 -0
@@ -0,0 +1,322 @@
1
+ import { validateSourceFit } from './source-fit.mjs';
2
+ import { validateAudioMix } from './audio-mix.mjs';
3
+ import { frameRateNumber } from '../media/frame-rate.mjs';
4
+ import { validateVisualKeyframes } from './keyframes.mjs';
5
+ import { validateAudioMastering } from './audio-mastering.mjs';
6
+ import { validateVisualMask } from './mask.mjs';
7
+ import { validateSpeedRamp } from './speed-ramp.mjs';
8
+ import { validateClipTransition } from './transition.mjs';
9
+ import { validateBlendMode } from './blend-mode.mjs';
10
+ import { validateAudioClipEffects } from './audio-effects.mjs';
11
+ import { validateAudioTrackVolumeKeyframes, validateAudioVolumeKeyframes } from './audio-volume.mjs';
12
+ import { validateProjectBackgroundColor } from './background.mjs';
13
+ import { validateSpeechBubble } from './speech-bubble.mjs';
14
+
15
+ const TRACK_KINDS = new Set(['video', 'audio', 'overlay']);
16
+
17
+ export function validateTimeline(timeline) {
18
+ if (!timeline || typeof timeline !== 'object' || Array.isArray(timeline) || timeline.schemaVersion !== 1) throw new TypeError('Timeline must be an object with schemaVersion 1.');
19
+ const project = timeline.project;
20
+ if (!project || typeof project !== 'object' || !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(project.id ?? '')) throw new TypeError('Timeline project needs a kebab-case id.');
21
+ for (const field of ['width', 'height']) if (!Number.isSafeInteger(project[field]) || project[field] <= 0) throw new TypeError(`Timeline project ${field} must be a positive integer.`);
22
+ frameRateNumber(project.fps);
23
+ if (!Number.isFinite(project.durationSeconds) || project.durationSeconds <= 0) throw new TypeError('Timeline project durationSeconds must be positive.');
24
+ validateSourceFit(project.sourceFit, 'Timeline project sourceFit');
25
+ if (project.backgroundColor !== undefined) validateProjectBackgroundColor(project.backgroundColor, 'Timeline project backgroundColor');
26
+ if (!Array.isArray(timeline.assets) || !Array.isArray(timeline.tracks)) throw new TypeError('Timeline assets and tracks must be arrays.');
27
+ if (timeline.markers !== undefined) {
28
+ if (!Array.isArray(timeline.markers)) throw new TypeError('Timeline markers must be an array.');
29
+ const markerIds = new Set();
30
+ for (const marker of timeline.markers) {
31
+ if (!marker || typeof marker !== 'object' || Array.isArray(marker)
32
+ || !/^[a-z][a-z0-9-]*$/.test(marker.id ?? '') || markerIds.has(marker.id)) {
33
+ throw new TypeError('Timeline marker ids must be unique kebab-case ids.');
34
+ }
35
+ if (!Number.isFinite(marker.timeSeconds) || marker.timeSeconds < 0 || marker.timeSeconds > project.durationSeconds) {
36
+ throw new TypeError(`Timeline marker ${marker.id} must be within the project duration.`);
37
+ }
38
+ if (typeof marker.label !== 'string' || !marker.label.trim() || marker.label.length > 200) {
39
+ throw new TypeError(`Timeline marker ${marker.id} label must contain 1 to 200 characters.`);
40
+ }
41
+ if (marker.color !== undefined && (typeof marker.color !== 'string' || !/^#[0-9a-f]{6}$/i.test(marker.color))) {
42
+ throw new TypeError(`Timeline marker ${marker.id} color must be a six-digit hex color.`);
43
+ }
44
+ for (const field of Object.keys(marker)) {
45
+ if (!['id', 'timeSeconds', 'label', 'color'].includes(field)) throw new TypeError(`Timeline marker ${marker.id} has unsupported field ${field}.`);
46
+ if (marker[field] === undefined) throw new TypeError(`Timeline marker ${marker.id} field ${field} must not be undefined.`);
47
+ }
48
+ markerIds.add(marker.id);
49
+ }
50
+ }
51
+
52
+ const assets = new Set();
53
+ for (const asset of timeline.assets) {
54
+ if (!asset || !/^[a-z][a-z0-9-]*$/.test(asset.id ?? '') || assets.has(asset.id)) throw new TypeError('Timeline asset ids must be unique kebab-case ids.');
55
+ if (typeof asset.path !== 'string' || !asset.path.trim() || asset.path.startsWith('/') || /^[a-z]:[\\/]/i.test(asset.path) || asset.path.split(/[\\/]/).includes('..')) throw new TypeError(`Asset ${asset.id} needs a project-relative path without traversal.`);
56
+ if (asset.type !== undefined && !['video', 'audio', 'image'].includes(asset.type)) throw new TypeError(`Asset ${asset.id} has an unsupported media type.`);
57
+ if (asset.hasAudio !== undefined && typeof asset.hasAudio !== 'boolean') throw new TypeError(`Asset ${asset.id} has invalid audio metadata.`);
58
+ if (asset.videoStreams !== undefined) validateVideoStreams(asset.videoStreams, asset.id);
59
+ if (asset.audioStreams !== undefined) validateAudioStreams(asset.audioStreams, asset.id);
60
+ if (asset.durationSeconds !== undefined && (!Number.isFinite(asset.durationSeconds) || asset.durationSeconds <= 0)) throw new TypeError(`Asset ${asset.id} durationSeconds must be positive.`);
61
+ if (asset.streamTiming !== undefined) validateStreamTiming(asset.streamTiming, asset.id);
62
+ assets.add(asset.id);
63
+ }
64
+
65
+ const trackIds = new Set();
66
+ const trackOrders = new Set();
67
+ for (const track of timeline.tracks) {
68
+ if (!track || !/^[a-z][a-z0-9-]*$/.test(track.id ?? '') || trackIds.has(track.id)) throw new TypeError('Timeline track ids must be unique kebab-case ids.');
69
+ if (!TRACK_KINDS.has(track.kind) || !Number.isSafeInteger(track.order) || track.order < 0 || !Array.isArray(track.clips)) throw new TypeError(`Track ${track.id} has invalid kind, order, or clips.`);
70
+ if (trackOrders.has(track.order)) throw new TypeError(`Timeline track order ${track.order} is used more than once.`);
71
+ if (track.muted !== undefined && (track.kind !== 'audio' || typeof track.muted !== 'boolean')) throw new TypeError(`Track ${track.id} muted must be a boolean on an audio track.`);
72
+ if (track.solo !== undefined && (track.kind !== 'audio' || typeof track.solo !== 'boolean')) throw new TypeError(`Track ${track.id} solo must be a boolean on an audio track.`);
73
+ if (track.locked !== undefined && typeof track.locked !== 'boolean') throw new TypeError(`Track ${track.id} locked must be a boolean.`);
74
+ if (track.visible !== undefined && ((track.kind !== 'video' && track.kind !== 'overlay') || typeof track.visible !== 'boolean')) throw new TypeError(`Track ${track.id} visible must be a boolean on a video or overlay track.`);
75
+ if (track.allowOverlap !== undefined && typeof track.allowOverlap !== 'boolean') throw new TypeError(`Track ${track.id} allowOverlap must be a boolean.`);
76
+ if (track.volume !== undefined && (track.kind !== 'audio' || !Number.isFinite(track.volume) || track.volume < 0 || track.volume > 1)) throw new TypeError(`Track ${track.id} volume must be between 0 and 1 on an audio track.`);
77
+ if (track.audioEffects !== undefined) {
78
+ if (track.kind !== 'audio') throw new TypeError(`Track ${track.id} audioEffects are supported only on audio tracks.`);
79
+ validateAudioClipEffects(track.audioEffects, `track ${track.id}`);
80
+ }
81
+ validateAudioTrackVolumeKeyframes(track.volumeKeyframes, track, project.durationSeconds);
82
+ trackIds.add(track.id);
83
+ trackOrders.add(track.order);
84
+ const clipIds = new Set();
85
+ const clips = [...track.clips].sort((a, b) => a.timelineStart - b.timelineStart);
86
+ let previousEnd = 0;
87
+ for (const clip of clips) {
88
+ if (!clip || !/^[a-z][a-z0-9-]*$/.test(clip.id ?? '') || clipIds.has(clip.id)) throw new TypeError(`Track ${track.id} clip ids must be unique kebab-case ids.`);
89
+ clipIds.add(clip.id);
90
+ if (clip.enabled !== undefined && typeof clip.enabled !== 'boolean') throw new TypeError(`Clip ${clip.id} enabled must be a boolean.`);
91
+ if (!Number.isFinite(clip.timelineStart) || clip.timelineStart < 0 || !Number.isFinite(clip.durationSeconds) || clip.durationSeconds <= 0 || clip.timelineStart + clip.durationSeconds > project.durationSeconds + 1e-6) throw new TypeError(`Clip ${clip.id} has invalid timeline bounds.`);
92
+ if (track.kind === 'overlay') {
93
+ const hasComposition = typeof clip.composition === 'string' && clip.composition.trim();
94
+ const hasText = typeof clip.text === 'string' && clip.text.trim();
95
+ if (Boolean(hasComposition) === Boolean(hasText)) throw new TypeError(`Overlay ${clip.id} must define either a composition or text.`);
96
+ if (hasText) validateTextClip(clip);
97
+ } else if (!assets.has(clip.assetId)) throw new TypeError(`Clip ${clip.id} is missing its asset for the ${track.kind} track.`);
98
+ const assetRecord = timeline.assets.find((item) => item.id === clip.assetId);
99
+ if (track.kind === 'video' && assetRecord?.type === 'audio') throw new TypeError(`Video clip ${clip.id} references an audio-only asset.`);
100
+ if (track.kind === 'audio' && assetRecord?.type === 'image') throw new TypeError(`Audio clip ${clip.id} references an image asset.`);
101
+ if (track.kind === 'audio' && assetRecord?.type === 'video' && assetRecord.hasAudio === false) throw new TypeError(`Audio clip ${clip.id} references a video asset without audio.`);
102
+ if (clip.audioEffects !== undefined) {
103
+ if (track.kind !== 'audio') throw new TypeError(`Clip ${clip.id} audioEffects are supported only on audio tracks.`);
104
+ validateAudioClipEffects(clip.audioEffects, clip.id);
105
+ }
106
+ if (clip.volumeKeyframes !== undefined) {
107
+ if (track.kind !== 'audio') throw new TypeError(`Clip ${clip.id} volumeKeyframes are supported only on audio tracks.`);
108
+ validateAudioVolumeKeyframes(clip.volumeKeyframes, clip);
109
+ }
110
+ if (clip.audioStreamIndex !== undefined) {
111
+ if (track.kind !== 'audio' || !Number.isSafeInteger(clip.audioStreamIndex) || clip.audioStreamIndex < 0) {
112
+ throw new TypeError(`Clip ${clip.id} audioStreamIndex must be a non-negative integer on an audio track.`);
113
+ }
114
+ if (assetRecord?.audioStreams && clip.audioStreamIndex >= assetRecord.audioStreams.length) {
115
+ throw new TypeError(`Clip ${clip.id} audioStreamIndex exceeds the audio streams in asset ${assetRecord.id}.`);
116
+ }
117
+ if (!assetRecord?.audioStreams && clip.audioStreamIndex > 0) {
118
+ throw new TypeError(`Asset ${assetRecord?.id ?? clip.assetId} has no probed audio stream metadata for index ${clip.audioStreamIndex}.`);
119
+ }
120
+ }
121
+ if (clip.videoStreamIndex !== undefined) {
122
+ if (track.kind !== 'video' || assetRecord?.type === 'image' || !Number.isSafeInteger(clip.videoStreamIndex) || clip.videoStreamIndex < 0) {
123
+ throw new TypeError(`Clip ${clip.id} videoStreamIndex must be a non-negative integer on a video track.`);
124
+ }
125
+ if (assetRecord?.videoStreams && clip.videoStreamIndex >= assetRecord.videoStreams.length) {
126
+ throw new TypeError(`Clip ${clip.id} videoStreamIndex exceeds the video streams in asset ${assetRecord.id}.`);
127
+ }
128
+ if (!assetRecord?.videoStreams && clip.videoStreamIndex > 0) {
129
+ throw new TypeError(`Asset ${assetRecord?.id ?? clip.assetId} has no probed video stream metadata for index ${clip.videoStreamIndex}.`);
130
+ }
131
+ }
132
+ if (clip.fit !== undefined && track.kind !== 'video') throw new TypeError(`Clip ${clip.id} can set fit only on a video track.`);
133
+ if (track.kind === 'video') {
134
+ validateSourceFit(clip.fit, `Clip ${clip.id} fit`);
135
+ validatePlacement(clip, `Video clip ${clip.id}`);
136
+ }
137
+ if (clip.mask !== undefined) {
138
+ if (track.kind !== 'video' && track.kind !== 'overlay') throw new TypeError(`Clip ${clip.id} masks are supported on video and overlay tracks.`);
139
+ validateVisualMask(clip.mask, `Clip ${clip.id} mask`);
140
+ }
141
+ validateTransitions(track, clip);
142
+ validateBlendMode(clip, track.kind);
143
+ validateVisualKeyframes(track, clip);
144
+ if (clip.speedRamp !== undefined) {
145
+ if ((track.kind !== 'video' && track.kind !== 'audio')
146
+ || (track.kind === 'video' && assetRecord?.type === 'image')) {
147
+ throw new TypeError(`Clip ${clip.id} speedRamp requires video or audio media.`);
148
+ }
149
+ validateSpeedRamp(clip);
150
+ }
151
+ if (clip.playbackRate !== undefined && (!Number.isFinite(clip.playbackRate) || clip.playbackRate < 0.1 || clip.playbackRate > 5 || track.kind === 'overlay' || (track.kind === 'video' && assetRecord?.type === 'image'))) {
152
+ throw new TypeError(`Clip ${clip.id} playbackRate must be from 0.1 to 5 on video or audio media.`);
153
+ }
154
+ if (clip.reverse !== undefined && (typeof clip.reverse !== 'boolean'
155
+ || !['video', 'audio'].includes(track.kind)
156
+ || (track.kind === 'video' && assetRecord?.type === 'image'))) {
157
+ throw new TypeError(`Clip ${clip.id} reverse must be a boolean on video or audio media.`);
158
+ }
159
+ if (clip.reverse === true && clip.speedRamp !== undefined) {
160
+ throw new TypeError(`Clip ${clip.id} cannot combine reverse playback with a speedRamp.`);
161
+ }
162
+ if (clip.volume !== undefined && (track.kind !== 'audio' || !Number.isFinite(clip.volume) || clip.volume < 0 || clip.volume > 1)) throw new TypeError(`Clip ${clip.id} volume must be between 0 and 1 on an audio track.`);
163
+ for (const fade of ['fadeInSeconds', 'fadeOutSeconds']) {
164
+ if (clip[fade] !== undefined && (!Number.isFinite(clip[fade]) || clip[fade] < 0 || clip[fade] > clip.durationSeconds)) throw new TypeError(`Clip ${clip.id} ${fade} must be between 0 and its duration.`);
165
+ }
166
+ if ((clip.fadeInSeconds ?? 0) + (clip.fadeOutSeconds ?? 0) > clip.durationSeconds + 1e-6) throw new TypeError(`Clip ${clip.id} fades cannot exceed the clip duration.`);
167
+ if (track.kind !== 'overlay' && (!Number.isFinite(clip.sourceIn) || !Number.isFinite(clip.sourceOut) || clip.sourceIn < 0 || clip.sourceOut <= clip.sourceIn || (clip.speedRamp === undefined && Math.abs(clip.sourceOut - clip.sourceIn - clip.durationSeconds * (clip.playbackRate ?? 1)) > 1e-6))) throw new TypeError(`Clip ${clip.id} source range must match its timeline duration multiplied by playbackRate.`);
168
+ if (track.kind !== 'overlay' && Number.isFinite(assetRecord?.durationSeconds) && clip.sourceOut > assetRecord.durationSeconds + 1e-6) throw new TypeError(`Clip ${clip.id} sourceOut exceeds asset ${assetRecord.id} duration.`);
169
+ if (!track.allowOverlap && clip.timelineStart < previousEnd - 1e-6) throw new TypeError(`Track ${track.id} contains overlapping clips; set allowOverlap to true to permit them.`);
170
+ previousEnd = clip.timelineStart + clip.durationSeconds;
171
+ }
172
+ }
173
+ validateAudioMix(timeline);
174
+ validateAudioMastering(timeline);
175
+ return timeline;
176
+ }
177
+
178
+ function validateTransitions(track, clip) {
179
+ const transitions = ['transitionIn', 'transitionOut'].filter((field) => clip[field] !== undefined);
180
+ if (transitions.length === 0) return;
181
+ if (track.kind !== 'video' && track.kind !== 'overlay') throw new TypeError(`Clip ${clip.id} transitions are supported on video and overlay tracks.`);
182
+ for (const field of transitions) validateClipTransition(clip[field], clip, `Clip ${clip.id} ${field}`);
183
+ const incoming = clip.transitionIn;
184
+ const outgoing = clip.transitionOut;
185
+ if ((incoming?.durationSeconds ?? 0) + (outgoing?.durationSeconds ?? 0) > clip.durationSeconds + 1e-6) {
186
+ throw new TypeError(`Clip ${clip.id} transition durations cannot overlap.`);
187
+ }
188
+ if (clip.mask && [incoming, outgoing].some((transition) => transition?.type === 'wipe')) {
189
+ throw new TypeError(`Clip ${clip.id} wipe transitions cannot be combined with a static mask.`);
190
+ }
191
+ if (incoming?.type === 'wipe' && outgoing?.type === 'wipe' && incoming.direction !== outgoing.direction) {
192
+ throw new TypeError(`Clip ${clip.id} wipe transitions must use the same direction.`);
193
+ }
194
+ const usesOpacity = clip.keyframes?.some((frame) => frame.properties.opacity !== undefined);
195
+ if (usesOpacity && [incoming, outgoing].some((transition) => transition?.type === 'fade')) {
196
+ throw new TypeError(`Clip ${clip.id} fade transitions cannot be combined with opacity keyframes.`);
197
+ }
198
+ if (incoming?.type === 'fade' && (clip.fadeInSeconds ?? 0) > 0) {
199
+ throw new TypeError(`Clip ${clip.id} fade-in transition cannot be combined with fadeInSeconds.`);
200
+ }
201
+ if (outgoing?.type === 'fade' && (clip.fadeOutSeconds ?? 0) > 0) {
202
+ throw new TypeError(`Clip ${clip.id} fade-out transition cannot be combined with fadeOutSeconds.`);
203
+ }
204
+ }
205
+
206
+ function validateStreamTiming(timing, assetId) {
207
+ if (!timing || typeof timing !== 'object' || Array.isArray(timing)) throw new TypeError(`Asset ${assetId} streamTiming must be an object.`);
208
+ for (const field of ['containerStartSeconds', 'videoStartSeconds', 'videoDurationSeconds', 'audioStartSeconds', 'audioDurationSeconds']) {
209
+ if (timing[field] !== undefined && timing[field] !== null && !Number.isFinite(timing[field])) throw new TypeError(`Asset ${assetId} streamTiming.${field} must be finite or null.`);
210
+ }
211
+ for (const field of ['videoTimeBase', 'audioTimeBase']) {
212
+ if (timing[field] !== undefined && timing[field] !== null && typeof timing[field] !== 'string') throw new TypeError(`Asset ${assetId} streamTiming.${field} must be a string or null.`);
213
+ }
214
+ }
215
+
216
+ function validateAudioStreams(streams, assetId) {
217
+ if (!Array.isArray(streams)) throw new TypeError(`Asset ${assetId} audioStreams must be an array.`);
218
+ for (const [index, stream] of streams.entries()) {
219
+ if (!stream || typeof stream !== 'object' || Array.isArray(stream)
220
+ || stream.index !== index
221
+ || !Number.isSafeInteger(stream.streamIndex) || stream.streamIndex < 0
222
+ || (stream.codec !== null && stream.codec !== undefined && typeof stream.codec !== 'string')
223
+ || (stream.channels !== null && stream.channels !== undefined && (!Number.isSafeInteger(stream.channels) || stream.channels < 1))
224
+ || (stream.channelLayout !== null && stream.channelLayout !== undefined && typeof stream.channelLayout !== 'string')
225
+ || (stream.sampleRate !== null && stream.sampleRate !== undefined && (!Number.isSafeInteger(stream.sampleRate) || stream.sampleRate < 1))
226
+ || (stream.startSeconds !== null && stream.startSeconds !== undefined && !Number.isFinite(stream.startSeconds))
227
+ || (stream.durationSeconds !== null && stream.durationSeconds !== undefined && (!Number.isFinite(stream.durationSeconds) || stream.durationSeconds <= 0))
228
+ || (stream.timeBase !== null && stream.timeBase !== undefined && typeof stream.timeBase !== 'string')
229
+ || (stream.language !== null && stream.language !== undefined && typeof stream.language !== 'string')
230
+ || (stream.title !== null && stream.title !== undefined && typeof stream.title !== 'string')) {
231
+ throw new TypeError(`Asset ${assetId} audio stream ${index} has invalid metadata.`);
232
+ }
233
+ }
234
+ }
235
+
236
+ function validateVideoStreams(streams, assetId) {
237
+ if (!Array.isArray(streams)) throw new TypeError(`Asset ${assetId} videoStreams must be an array.`);
238
+ for (const stream of streams) {
239
+ if (!stream || typeof stream !== 'object' || Array.isArray(stream)
240
+ || (stream.index !== null && stream.index !== undefined && (!Number.isSafeInteger(stream.index) || stream.index < 0))
241
+ || (stream.streamIndex !== null && stream.streamIndex !== undefined && (!Number.isSafeInteger(stream.streamIndex) || stream.streamIndex < 0))
242
+ || (stream.codec !== null && stream.codec !== undefined && typeof stream.codec !== 'string')
243
+ || (stream.width !== null && stream.width !== undefined && (!Number.isSafeInteger(stream.width) || stream.width < 1))
244
+ || (stream.height !== null && stream.height !== undefined && (!Number.isSafeInteger(stream.height) || stream.height < 1))
245
+ || (stream.frameRate !== null && stream.frameRate !== undefined && (!Number.isFinite(stream.frameRate) || stream.frameRate <= 0))
246
+ || (stream.startSeconds !== null && stream.startSeconds !== undefined && !Number.isFinite(stream.startSeconds))
247
+ || (stream.durationSeconds !== null && stream.durationSeconds !== undefined && (!Number.isFinite(stream.durationSeconds) || stream.durationSeconds <= 0))
248
+ || (stream.timeBase !== null && stream.timeBase !== undefined && typeof stream.timeBase !== 'string')) {
249
+ throw new TypeError(`Asset ${assetId} video stream has invalid metadata.`);
250
+ }
251
+ }
252
+ }
253
+
254
+ function validateTextClip(clip) {
255
+ const position = { x: clip.x ?? 0.08, y: clip.y ?? 0.76, width: clip.width ?? 0.84, height: clip.height ?? 0.16 };
256
+ if (!Object.values(position).every(Number.isFinite) || position.x < 0 || position.y < 0 || position.width <= 0 || position.height <= 0 || position.x + position.width > 1 || position.y + position.height > 1) throw new TypeError(`Text overlay ${clip.id} has invalid normalized placement.`);
257
+ if (clip.wordWrap !== undefined && typeof clip.wordWrap !== 'boolean') throw new TypeError(`Text overlay ${clip.id} wordWrap must be a boolean.`);
258
+ if (clip.wordsPerLine !== undefined && (!Number.isSafeInteger(clip.wordsPerLine) || clip.wordsPerLine < 1 || clip.wordsPerLine > 20)) throw new TypeError(`Text overlay ${clip.id} wordsPerLine must be an integer from 1 to 20.`);
259
+ if (clip.wordTimings !== undefined) {
260
+ if (!Array.isArray(clip.wordTimings) || clip.wordTimings.length === 0) throw new TypeError(`Text overlay ${clip.id} wordTimings must be a non-empty array.`);
261
+ let previousStart = -Infinity;
262
+ for (const [index, word] of clip.wordTimings.entries()) {
263
+ if (!word || typeof word !== 'object' || Array.isArray(word)
264
+ || Object.keys(word).some((field) => !['text', 'start', 'end'].includes(field))
265
+ || typeof word.text !== 'string' || !word.text.trim()
266
+ || !Number.isFinite(word.start) || !Number.isFinite(word.end)
267
+ || word.start < 0 || word.end <= word.start || word.end > clip.durationSeconds + 1e-6
268
+ || word.start < previousStart) {
269
+ throw new TypeError(`Text overlay ${clip.id} wordTimings entry ${index + 1} is invalid.`);
270
+ }
271
+ previousStart = word.start;
272
+ }
273
+ if (clip.wordTimings.map((word) => word.text).join(' ') !== clip.text) throw new TypeError(`Text overlay ${clip.id} wordTimings text must match its caption text.`);
274
+ if (typeof clip.wordHighlightColor !== 'string' || !/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(clip.wordHighlightColor)) {
275
+ throw new TypeError(`Text overlay ${clip.id} wordHighlightColor must be a hex color.`);
276
+ }
277
+ if (clip.wordHighlightAnimation !== undefined && !['scale', 'pop'].includes(clip.wordHighlightAnimation)) {
278
+ throw new TypeError(`Text overlay ${clip.id} wordHighlightAnimation must be scale or pop.`);
279
+ }
280
+ } else if (clip.wordHighlightColor !== undefined || clip.wordHighlightAnimation !== undefined) {
281
+ throw new TypeError(`Text overlay ${clip.id} word highlighting options require wordTimings.`);
282
+ }
283
+ if (clip.fontSize !== undefined && (!Number.isSafeInteger(clip.fontSize) || clip.fontSize < 8 || clip.fontSize > 256)) throw new TypeError(`Text overlay ${clip.id} fontSize must be an integer from 8 to 256.`);
284
+ if (clip.fontFamily !== undefined && (typeof clip.fontFamily !== 'string' || !/^[\w ,'-]{1,80}$/.test(clip.fontFamily))) throw new TypeError(`Text overlay ${clip.id} fontFamily contains unsupported characters.`);
285
+ if (clip.fontWeight !== undefined && !['normal', 'bold', 100, 200, 300, 400, 500, 600, 700, 800, 900].includes(clip.fontWeight)) throw new TypeError(`Text overlay ${clip.id} fontWeight must be normal, bold, or a hundred-step weight from 100 to 900.`);
286
+ if (clip.lineHeight !== undefined && (!Number.isFinite(clip.lineHeight) || clip.lineHeight < 0.8 || clip.lineHeight > 2.5)) throw new TypeError(`Text overlay ${clip.id} lineHeight must be from 0.8 to 2.5.`);
287
+ if (clip.verticalAlign !== undefined && !['top', 'center', 'bottom'].includes(clip.verticalAlign)) throw new TypeError(`Text overlay ${clip.id} verticalAlign must be top, center, or bottom.`);
288
+ if (clip.paddingPixels !== undefined && (!Number.isSafeInteger(clip.paddingPixels) || clip.paddingPixels < 0 || clip.paddingPixels > 128)) throw new TypeError(`Text overlay ${clip.id} paddingPixels must be an integer from 0 to 128.`);
289
+ for (const color of ['color', 'backgroundColor']) {
290
+ if (clip[color] !== undefined && clip[color] !== 'transparent' && !/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(clip[color])) throw new TypeError(`Text overlay ${clip.id} ${color} must be a hex color or transparent.`);
291
+ }
292
+ if (clip.textAlign !== undefined && !['left', 'center', 'right'].includes(clip.textAlign)) throw new TypeError(`Text overlay ${clip.id} textAlign must be left, center, or right.`);
293
+ if (clip.bubble !== undefined) validateSpeechBubble(clip.bubble, `Text overlay ${clip.id} bubble`);
294
+ if (clip.textOutline !== undefined) {
295
+ const outline = clip.textOutline;
296
+ if (!outline || typeof outline !== 'object' || Array.isArray(outline)
297
+ || Object.keys(outline).some((field) => !['color', 'widthPixels'].includes(field))
298
+ || typeof outline.color !== 'string' || !/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(outline.color)
299
+ || !Number.isFinite(outline.widthPixels) || outline.widthPixels <= 0 || outline.widthPixels > 12) {
300
+ throw new TypeError(`Text overlay ${clip.id} textOutline must define a hex color and widthPixels from 0 to 12.`);
301
+ }
302
+ }
303
+ if (clip.textShadow !== undefined) {
304
+ const shadow = clip.textShadow;
305
+ if (!shadow || typeof shadow !== 'object' || Array.isArray(shadow)
306
+ || Object.keys(shadow).some((field) => !['color', 'blurPixels', 'offsetXPixels', 'offsetYPixels'].includes(field))
307
+ || typeof shadow.color !== 'string' || !/^#(?:[\da-f]{3}|[\da-f]{4}|[\da-f]{6}|[\da-f]{8})$/i.test(shadow.color)
308
+ || !Number.isFinite(shadow.blurPixels) || shadow.blurPixels < 0 || shadow.blurPixels > 32
309
+ || !Number.isFinite(shadow.offsetXPixels) || Math.abs(shadow.offsetXPixels) > 32
310
+ || !Number.isFinite(shadow.offsetYPixels) || Math.abs(shadow.offsetYPixels) > 32) {
311
+ throw new TypeError(`Text overlay ${clip.id} textShadow has invalid color or bounds.`);
312
+ }
313
+ }
314
+ }
315
+
316
+ function validatePlacement(clip, label) {
317
+ const x = clip.x ?? 0;
318
+ const y = clip.y ?? 0;
319
+ const width = clip.width ?? 1;
320
+ const height = clip.height ?? 1;
321
+ if (![x, y, width, height].every(Number.isFinite) || x < 0 || y < 0 || width <= 0 || height <= 0 || x + width > 1 || y + height > 1) throw new TypeError(`${label} placement must fit within normalized canvas bounds.`);
322
+ }
@@ -0,0 +1,21 @@
1
+ import { validateTranscript } from './validate.mjs';
2
+
3
+ export function applyTranscriptCorrections(transcript, corrections) {
4
+ validateTranscript(transcript);
5
+ if (!Array.isArray(corrections)) throw new TypeError('Transcript corrections must be an array.');
6
+ const words = transcript.words.map((word) => ({ ...word }));
7
+ const updated = new Set();
8
+ for (const [index, correction] of corrections.entries()) {
9
+ if (!correction || !Number.isSafeInteger(correction.wordIndex) || correction.wordIndex < 0 || correction.wordIndex >= words.length) throw new TypeError(`Correction ${index + 1} has an invalid wordIndex.`);
10
+ if (updated.has(correction.wordIndex)) throw new TypeError(`Word ${correction.wordIndex} has duplicate corrections.`);
11
+ updated.add(correction.wordIndex);
12
+ const word = words[correction.wordIndex];
13
+ if (correction.text !== undefined) {
14
+ if (typeof correction.text !== 'string' || !correction.text.trim()) throw new TypeError(`Correction ${index + 1} text must be a non-empty string.`);
15
+ word.text = correction.text.trim();
16
+ }
17
+ if (correction.start !== undefined) word.start = correction.start;
18
+ if (correction.end !== undefined) word.end = correction.end;
19
+ }
20
+ return validateTranscript({ ...transcript, words });
21
+ }
@@ -0,0 +1,17 @@
1
+ import { validateTranscript } from './validate.mjs';
2
+
3
+ export function normalizeTranscript(input, durationSeconds, granularity = 'word') {
4
+ const sourceWords = Array.isArray(input) ? input : input?.words;
5
+ if (!Array.isArray(sourceWords)) throw new TypeError('Transcript input must contain a words array.');
6
+ const words = sourceWords
7
+ .filter((word) => word?.type === undefined || word.type === 'word')
8
+ .map((word) => ({
9
+ text: String(word.text ?? word.word ?? '').trim(),
10
+ start: Number(word.start),
11
+ end: Number(word.end),
12
+ }))
13
+ .filter((word) => word.text);
14
+ const transcript = { schemaVersion: 1, durationSeconds, granularity, words };
15
+ validateTranscript(transcript);
16
+ return transcript;
17
+ }
@@ -0,0 +1,42 @@
1
+ import { existsSync, openSync, closeSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import { probeMedia } from '../media/probe.mjs';
4
+ import { normalizeTranscript } from './normalize.mjs';
5
+
6
+ /** Run an injected transcription provider and persist its normalized result. */
7
+ export async function transcribeWithProvider(projectPath, mediaPath, provider, options = {}) {
8
+ const project = resolve(projectPath);
9
+ const media = resolve(mediaPath);
10
+ if (!existsSync(join(project, 'meta.json'))) throw new Error(`Not a video project: ${project}`);
11
+ if (!provider || typeof provider !== 'object'
12
+ || !/^[a-z][a-z0-9-]*$/.test(provider.id ?? '')
13
+ || typeof provider.transcribe !== 'function') {
14
+ throw new TypeError('Provider must define a kebab-case id and async transcribe function.');
15
+ }
16
+ const granularity = options.granularity ?? 'word';
17
+ if (!['word', 'cue'].includes(granularity)) throw new TypeError('Provider granularity must be word or cue.');
18
+
19
+ const mediaInfo = probeMedia(media, { requireVideo: false });
20
+ const raw = await provider.transcribe({
21
+ mediaPath: media,
22
+ durationSeconds: mediaInfo.durationSeconds,
23
+ options: structuredClone(options.providerOptions ?? {}),
24
+ });
25
+ const transcript = normalizeTranscript(raw, mediaInfo.durationSeconds, granularity);
26
+ const transcriptPath = join(project, 'transcripts', 'transcript.json');
27
+ const fd = openSync(transcriptPath, 'wx');
28
+ try {
29
+ writeFileSync(fd, `${JSON.stringify(transcript, null, 2)}\n`);
30
+ } catch (error) {
31
+ unlinkSync(transcriptPath);
32
+ throw error;
33
+ } finally {
34
+ closeSync(fd);
35
+ }
36
+ return {
37
+ providerId: provider.id,
38
+ transcriptPath,
39
+ wordCount: transcript.words.length,
40
+ durationSeconds: transcript.durationSeconds,
41
+ };
42
+ }
@@ -0,0 +1,57 @@
1
+ import { validateTranscript } from './validate.mjs';
2
+
3
+ export function parseSubtitleText(contents, format) {
4
+ if (typeof contents !== 'string') throw new TypeError('Subtitle contents must be text.');
5
+ if (!['srt', 'vtt'].includes(format)) throw new TypeError('Subtitle format must be srt or vtt.');
6
+ const text = contents.replace(/^\uFEFF/, '').replaceAll('\r\n', '\n').replaceAll('\r', '\n');
7
+ const words = [];
8
+ for (const block of text.split(/\n\s*\n/)) {
9
+ const lines = block.split('\n');
10
+ const timeIndex = lines.findIndex((line) => line.includes('-->'));
11
+ if (timeIndex < 0) continue;
12
+ const match = lines[timeIndex].match(/^\s*((?:\d{2}:)?\d{2}:\d{2}[,.]\d{3})\s+-->\s+((?:\d{2}:)?\d{2}:\d{2}[,.]\d{3})(?:\s+.*)?$/);
13
+ if (!match) throw new TypeError(`Invalid ${format.toUpperCase()} timestamp: ${lines[timeIndex]}`);
14
+ const start = parseTimestamp(match[1]);
15
+ const end = parseTimestamp(match[2]);
16
+ const cueText = lines.slice(timeIndex + 1).join(' ')
17
+ .replace(/<[^>]*>/g, '')
18
+ .replace(/&amp;/gi, '&').replace(/&lt;/gi, '<').replace(/&gt;/gi, '>').replace(/&nbsp;/gi, ' ')
19
+ .replace(/\s+/g, ' ').trim();
20
+ if (cueText) words.push({ text: cueText, start, end });
21
+ }
22
+ if (!words.length) throw new TypeError(`No subtitle cues found in ${format.toUpperCase()} input.`);
23
+ return { words };
24
+ }
25
+
26
+ export function formatSubtitleText(transcript, format) {
27
+ validateTranscript(transcript);
28
+ if (!['srt', 'vtt'].includes(format)) throw new TypeError('Subtitle format must be srt or vtt.');
29
+ const cues = transcript.words.map((word, index) => [
30
+ ...(format === 'srt' ? [String(index + 1)] : []),
31
+ `${formatTimestamp(word.start, format)} --> ${formatTimestamp(word.end, format)}`,
32
+ word.text.replace(/\s*\n\s*/g, ' '),
33
+ ].join('\n'));
34
+ return `${format === 'vtt' ? 'WEBVTT\n\n' : ''}${cues.join('\n\n')}\n`;
35
+ }
36
+
37
+ function parseTimestamp(value) {
38
+ const normalized = value.replace(',', '.');
39
+ const parts = normalized.split(':');
40
+ const seconds = Number(parts.pop());
41
+ const minutes = Number(parts.pop());
42
+ const hours = parts.length ? Number(parts.pop()) : 0;
43
+ if (![hours, minutes, seconds].every(Number.isFinite) || minutes >= 60 || seconds >= 60) {
44
+ throw new TypeError(`Invalid subtitle time: ${value}`);
45
+ }
46
+ return hours * 3600 + minutes * 60 + seconds;
47
+ }
48
+
49
+ function formatTimestamp(seconds, format) {
50
+ const milliseconds = Math.round(seconds * 1000);
51
+ const hours = Math.floor(milliseconds / 3_600_000);
52
+ const minutes = Math.floor((milliseconds % 3_600_000) / 60_000);
53
+ const wholeSeconds = Math.floor((milliseconds % 60_000) / 1000);
54
+ const fraction = milliseconds % 1000;
55
+ const time = `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}:${String(wholeSeconds).padStart(2, '0')}`;
56
+ return `${time}${format === 'srt' ? ',' : '.'}${String(fraction).padStart(3, '0')}`;
57
+ }
@@ -0,0 +1,39 @@
1
+ export function validateTranscript(transcript) {
2
+ if (!transcript || typeof transcript !== 'object' || Array.isArray(transcript)) {
3
+ throw new TypeError('Transcript must be a JSON object.');
4
+ }
5
+ if (transcript.schemaVersion !== 1) {
6
+ throw new TypeError('Transcript schemaVersion must be 1.');
7
+ }
8
+ if (!Number.isFinite(transcript.durationSeconds) || transcript.durationSeconds <= 0) {
9
+ throw new TypeError('Transcript durationSeconds must be a positive number.');
10
+ }
11
+ if (!Array.isArray(transcript.words)) {
12
+ throw new TypeError('Transcript words must be an array.');
13
+ }
14
+ if (transcript.granularity !== undefined && !['word', 'cue'].includes(transcript.granularity)) {
15
+ throw new TypeError('Transcript granularity must be word or cue.');
16
+ }
17
+
18
+ let previousStart = -Infinity;
19
+ for (const [index, word] of transcript.words.entries()) {
20
+ const label = `Transcript word ${index + 1}`;
21
+ if (!word || typeof word !== 'object' || Array.isArray(word)) {
22
+ throw new TypeError(`${label} must be an object.`);
23
+ }
24
+ if (typeof word.text !== 'string' || !word.text.trim()) {
25
+ throw new TypeError(`${label} text must be a non-empty string.`);
26
+ }
27
+ if (!Number.isFinite(word.start) || !Number.isFinite(word.end) || word.start < 0 || word.end <= word.start) {
28
+ throw new TypeError(`${label} must have finite times with 0 <= start < end.`);
29
+ }
30
+ if (word.end > transcript.durationSeconds) {
31
+ throw new TypeError(`${label} ends after the source duration.`);
32
+ }
33
+ if (word.start < previousStart) {
34
+ throw new TypeError(`${label} starts before the previous word.`);
35
+ }
36
+ previousStart = word.start;
37
+ }
38
+ return transcript;
39
+ }