aivee 0.1.0-alpha.0 → 0.1.0-dev.d34a6ba

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 -21
  2. package/README.md +120 -118
  3. package/docs/architecture.md +401 -401
  4. package/docs/aspect-ratio-presets.md +72 -72
  5. package/docs/releasing.md +19 -19
  6. package/docs/usage.md +1193 -1193
  7. package/package.json +52 -52
  8. package/requirements-otio.txt +1 -1
  9. package/src/capabilities.mjs +307 -307
  10. package/src/cli.mjs +646 -646
  11. package/src/doctor.mjs +133 -133
  12. package/src/edits/analysis-to-edl.mjs +43 -43
  13. package/src/edits/detect-silence.mjs +51 -51
  14. package/src/edits/find-mistake-candidates.mjs +42 -42
  15. package/src/edits/plan-shot-placements.mjs +105 -105
  16. package/src/edits/plan-silence-cuts.mjs +49 -49
  17. package/src/edits/retime-transcript.mjs +105 -105
  18. package/src/files.mjs +80 -80
  19. package/src/index.mjs +44 -44
  20. package/src/interchange/otio.mjs +204 -204
  21. package/src/interchange/otio_bridge.py +521 -521
  22. package/src/media/analysis-provider.mjs +75 -75
  23. package/src/media/compatibility.mjs +45 -45
  24. package/src/media/ffmpeg-capabilities.mjs +129 -129
  25. package/src/media/frame-rate.mjs +58 -58
  26. package/src/media/frame-time.mjs +22 -22
  27. package/src/media/preview-assets.mjs +350 -350
  28. package/src/media/probe.mjs +149 -149
  29. package/src/media/render-cuts.mjs +90 -90
  30. package/src/media/scene-detection.mjs +107 -107
  31. package/src/media/scene-review.mjs +205 -205
  32. package/src/project/assets.mjs +1194 -1194
  33. package/src/project/create.mjs +100 -100
  34. package/src/project/presets.mjs +44 -44
  35. package/src/render/checkpoints.mjs +192 -192
  36. package/src/render/formats.mjs +163 -163
  37. package/src/render/hyperframes.mjs +590 -590
  38. package/src/render/process.mjs +111 -111
  39. package/src/render/progress.mjs +46 -46
  40. package/src/render/timeline-control.mjs +53 -53
  41. package/src/timeline/assemble.mjs +119 -119
  42. package/src/timeline/audio-effects.mjs +75 -75
  43. package/src/timeline/audio-mastering.mjs +236 -236
  44. package/src/timeline/audio-mix.mjs +105 -105
  45. package/src/timeline/audio-track-effects.mjs +139 -139
  46. package/src/timeline/audio-volume.mjs +63 -63
  47. package/src/timeline/background.mjs +8 -8
  48. package/src/timeline/blend-mode.mjs +34 -34
  49. package/src/timeline/caption-layouts.mjs +62 -61
  50. package/src/timeline/captions.mjs +80 -80
  51. package/src/timeline/edit.mjs +965 -965
  52. package/src/timeline/fingerprint.mjs +6 -6
  53. package/src/timeline/from-edl.mjs +31 -31
  54. package/src/timeline/history-store.mjs +281 -281
  55. package/src/timeline/history.mjs +69 -69
  56. package/src/timeline/inspect.mjs +117 -117
  57. package/src/timeline/keyframes.mjs +158 -158
  58. package/src/timeline/layers.mjs +18 -18
  59. package/src/timeline/mask.mjs +53 -53
  60. package/src/timeline/project.mjs +52 -52
  61. package/src/timeline/render.mjs +759 -759
  62. package/src/timeline/sidechain-render.mjs +209 -209
  63. package/src/timeline/source-fit.mjs +15 -15
  64. package/src/timeline/speech-bubble.mjs +26 -26
  65. package/src/timeline/speed-ramp.mjs +188 -188
  66. package/src/timeline/track-state.mjs +22 -22
  67. package/src/timeline/transition.mjs +34 -34
  68. package/src/timeline/validate.mjs +322 -322
  69. package/src/transcript/corrections.mjs +21 -21
  70. package/src/transcript/normalize.mjs +17 -17
  71. package/src/transcript/provider.mjs +42 -42
  72. package/src/transcript/subtitles.mjs +57 -57
  73. package/src/transcript/validate.mjs +39 -39
@@ -1,105 +1,105 @@
1
- import { previewTimelineEdit } from '../timeline/edit.mjs';
2
- import { timelineFingerprint } from '../timeline/fingerprint.mjs';
3
-
4
- const MAXIMUM_PLACEMENTS = 500;
5
-
6
- /** Build a review-only clip insertion plan from explicit scene-review selections. */
7
- export function planSceneReviewPlacements(timeline, sceneReview, assetId, trackId, placements) {
8
- if (!sceneReview || typeof sceneReview !== 'object' || Array.isArray(sceneReview)
9
- || sceneReview.schemaVersion !== 2 || sceneReview.reviewRequired !== true
10
- || !/^[a-f0-9]{64}$/.test(sceneReview.sourceSha256 ?? '')
11
- || !Array.isArray(sceneReview.shots) || !Number.isFinite(sceneReview.durationSeconds)
12
- || sceneReview.durationSeconds <= 0) {
13
- throw new TypeError('Scene review must be a schemaVersion 2 review-required pack with a SHA-256 source identity, positive duration, and shots array. Regenerate older packs.');
14
- }
15
- if (!Array.isArray(placements) || placements.length === 0 || placements.length > MAXIMUM_PLACEMENTS) {
16
- throw new TypeError(`placements must contain between 1 and ${MAXIMUM_PLACEMENTS} shot selections.`);
17
- }
18
- if (typeof assetId !== 'string' || !assetId.trim()) throw new TypeError('assetId must be a non-empty string.');
19
- if (typeof trackId !== 'string' || !trackId.trim()) throw new TypeError('trackId must be a non-empty string.');
20
-
21
- const asset = timeline?.assets?.find((candidate) => candidate.id === assetId);
22
- if (!asset || asset.type !== 'video' || !Number.isFinite(asset.durationSeconds) || asset.durationSeconds <= 0) {
23
- throw new TypeError(`B-roll asset ${assetId} must be a registered video asset with a known duration.`);
24
- }
25
- if (asset.sha256 !== sceneReview.sourceSha256) {
26
- throw new TypeError(`B-roll asset ${assetId} does not match the source media used by this scene-review pack.`);
27
- }
28
- const track = timeline?.tracks?.find((candidate) => candidate.id === trackId);
29
- if (!track || track.kind !== 'video') throw new TypeError(`B-roll destination ${trackId} must be an existing video track.`);
30
- if (track.locked === true) throw new TypeError(`B-roll destination track ${trackId} is locked.`);
31
-
32
- const shots = new Map();
33
- for (const shot of sceneReview.shots) {
34
- if (!shot || !Number.isSafeInteger(shot.index) || shot.index < 1
35
- || !Number.isFinite(shot.startSeconds) || !Number.isFinite(shot.endSeconds)
36
- || shot.startSeconds < 0 || shot.endSeconds <= shot.startSeconds
37
- || shot.endSeconds > sceneReview.durationSeconds + 1e-6
38
- || shots.has(shot.index)) {
39
- throw new TypeError('Scene review contains an invalid or duplicate shot range.');
40
- }
41
- shots.set(shot.index, shot);
42
- }
43
-
44
- const usedClipIds = new Set();
45
- const operations = placements.map((placement, index) => {
46
- if (!placement || typeof placement !== 'object' || Array.isArray(placement)) {
47
- throw new TypeError(`Placement ${index + 1} must be an object.`);
48
- }
49
- const allowedFields = new Set(['shotIndex', 'timelineStartSeconds', 'sourceOffsetSeconds', 'durationSeconds', 'clipId']);
50
- if (Object.keys(placement).some((field) => !allowedFields.has(field))) {
51
- throw new TypeError(`Placement ${index + 1} contains an unsupported field.`);
52
- }
53
- if (Object.values(placement).some((value) => value === undefined)) {
54
- throw new TypeError(`Placement ${index + 1} fields must not be undefined.`);
55
- }
56
- const shot = shots.get(placement.shotIndex);
57
- if (!shot) throw new TypeError(`Placement ${index + 1} references unknown shot ${placement.shotIndex}.`);
58
- const timelineStart = placement.timelineStartSeconds;
59
- const sourceOffset = placement.sourceOffsetSeconds ?? 0;
60
- if (!Number.isFinite(timelineStart) || timelineStart < 0) {
61
- throw new TypeError(`Placement ${index + 1} timelineStartSeconds must be non-negative.`);
62
- }
63
- if (!Number.isFinite(sourceOffset) || sourceOffset < 0 || sourceOffset >= shot.endSeconds - shot.startSeconds) {
64
- throw new TypeError(`Placement ${index + 1} sourceOffsetSeconds must fall within shot ${shot.index}.`);
65
- }
66
- const sourceIn = shot.startSeconds + sourceOffset;
67
- const durationSeconds = placement.durationSeconds ?? shot.endSeconds - sourceIn;
68
- if (!Number.isFinite(durationSeconds) || durationSeconds <= 0
69
- || sourceIn + durationSeconds > shot.endSeconds + 1e-6) {
70
- throw new TypeError(`Placement ${index + 1} durationSeconds must fit within shot ${shot.index}.`);
71
- }
72
- if (sourceIn + durationSeconds > asset.durationSeconds + 1e-6) {
73
- throw new TypeError(`Placement ${index + 1} source range exceeds B-roll asset ${assetId}.`);
74
- }
75
- const clipId = placement.clipId ?? `broll-shot-${String(index + 1).padStart(4, '0')}`;
76
- if (!/^[a-z][a-z0-9-]*$/.test(clipId) || usedClipIds.has(clipId)) {
77
- throw new TypeError(`Placement ${index + 1} clipId must be a unique kebab-case id.`);
78
- }
79
- usedClipIds.add(clipId);
80
- return {
81
- type: 'add-clip',
82
- trackId,
83
- clip: {
84
- id: clipId,
85
- assetId,
86
- timelineStart,
87
- sourceIn,
88
- sourceOut: sourceIn + durationSeconds,
89
- durationSeconds,
90
- },
91
- };
92
- });
93
-
94
- return {
95
- schemaVersion: 1,
96
- type: 'scene-review-placement-plan',
97
- reviewRequired: true,
98
- baseTimelineSha256: timelineFingerprint(timeline),
99
- sourceSha256: sceneReview.sourceSha256,
100
- assetId,
101
- trackId,
102
- operations,
103
- preview: previewTimelineEdit(timeline, operations),
104
- };
105
- }
1
+ import { previewTimelineEdit } from '../timeline/edit.mjs';
2
+ import { timelineFingerprint } from '../timeline/fingerprint.mjs';
3
+
4
+ const MAXIMUM_PLACEMENTS = 500;
5
+
6
+ /** Build a review-only clip insertion plan from explicit scene-review selections. */
7
+ export function planSceneReviewPlacements(timeline, sceneReview, assetId, trackId, placements) {
8
+ if (!sceneReview || typeof sceneReview !== 'object' || Array.isArray(sceneReview)
9
+ || sceneReview.schemaVersion !== 2 || sceneReview.reviewRequired !== true
10
+ || !/^[a-f0-9]{64}$/.test(sceneReview.sourceSha256 ?? '')
11
+ || !Array.isArray(sceneReview.shots) || !Number.isFinite(sceneReview.durationSeconds)
12
+ || sceneReview.durationSeconds <= 0) {
13
+ throw new TypeError('Scene review must be a schemaVersion 2 review-required pack with a SHA-256 source identity, positive duration, and shots array. Regenerate older packs.');
14
+ }
15
+ if (!Array.isArray(placements) || placements.length === 0 || placements.length > MAXIMUM_PLACEMENTS) {
16
+ throw new TypeError(`placements must contain between 1 and ${MAXIMUM_PLACEMENTS} shot selections.`);
17
+ }
18
+ if (typeof assetId !== 'string' || !assetId.trim()) throw new TypeError('assetId must be a non-empty string.');
19
+ if (typeof trackId !== 'string' || !trackId.trim()) throw new TypeError('trackId must be a non-empty string.');
20
+
21
+ const asset = timeline?.assets?.find((candidate) => candidate.id === assetId);
22
+ if (!asset || asset.type !== 'video' || !Number.isFinite(asset.durationSeconds) || asset.durationSeconds <= 0) {
23
+ throw new TypeError(`B-roll asset ${assetId} must be a registered video asset with a known duration.`);
24
+ }
25
+ if (asset.sha256 !== sceneReview.sourceSha256) {
26
+ throw new TypeError(`B-roll asset ${assetId} does not match the source media used by this scene-review pack.`);
27
+ }
28
+ const track = timeline?.tracks?.find((candidate) => candidate.id === trackId);
29
+ if (!track || track.kind !== 'video') throw new TypeError(`B-roll destination ${trackId} must be an existing video track.`);
30
+ if (track.locked === true) throw new TypeError(`B-roll destination track ${trackId} is locked.`);
31
+
32
+ const shots = new Map();
33
+ for (const shot of sceneReview.shots) {
34
+ if (!shot || !Number.isSafeInteger(shot.index) || shot.index < 1
35
+ || !Number.isFinite(shot.startSeconds) || !Number.isFinite(shot.endSeconds)
36
+ || shot.startSeconds < 0 || shot.endSeconds <= shot.startSeconds
37
+ || shot.endSeconds > sceneReview.durationSeconds + 1e-6
38
+ || shots.has(shot.index)) {
39
+ throw new TypeError('Scene review contains an invalid or duplicate shot range.');
40
+ }
41
+ shots.set(shot.index, shot);
42
+ }
43
+
44
+ const usedClipIds = new Set();
45
+ const operations = placements.map((placement, index) => {
46
+ if (!placement || typeof placement !== 'object' || Array.isArray(placement)) {
47
+ throw new TypeError(`Placement ${index + 1} must be an object.`);
48
+ }
49
+ const allowedFields = new Set(['shotIndex', 'timelineStartSeconds', 'sourceOffsetSeconds', 'durationSeconds', 'clipId']);
50
+ if (Object.keys(placement).some((field) => !allowedFields.has(field))) {
51
+ throw new TypeError(`Placement ${index + 1} contains an unsupported field.`);
52
+ }
53
+ if (Object.values(placement).some((value) => value === undefined)) {
54
+ throw new TypeError(`Placement ${index + 1} fields must not be undefined.`);
55
+ }
56
+ const shot = shots.get(placement.shotIndex);
57
+ if (!shot) throw new TypeError(`Placement ${index + 1} references unknown shot ${placement.shotIndex}.`);
58
+ const timelineStart = placement.timelineStartSeconds;
59
+ const sourceOffset = placement.sourceOffsetSeconds ?? 0;
60
+ if (!Number.isFinite(timelineStart) || timelineStart < 0) {
61
+ throw new TypeError(`Placement ${index + 1} timelineStartSeconds must be non-negative.`);
62
+ }
63
+ if (!Number.isFinite(sourceOffset) || sourceOffset < 0 || sourceOffset >= shot.endSeconds - shot.startSeconds) {
64
+ throw new TypeError(`Placement ${index + 1} sourceOffsetSeconds must fall within shot ${shot.index}.`);
65
+ }
66
+ const sourceIn = shot.startSeconds + sourceOffset;
67
+ const durationSeconds = placement.durationSeconds ?? shot.endSeconds - sourceIn;
68
+ if (!Number.isFinite(durationSeconds) || durationSeconds <= 0
69
+ || sourceIn + durationSeconds > shot.endSeconds + 1e-6) {
70
+ throw new TypeError(`Placement ${index + 1} durationSeconds must fit within shot ${shot.index}.`);
71
+ }
72
+ if (sourceIn + durationSeconds > asset.durationSeconds + 1e-6) {
73
+ throw new TypeError(`Placement ${index + 1} source range exceeds B-roll asset ${assetId}.`);
74
+ }
75
+ const clipId = placement.clipId ?? `broll-shot-${String(index + 1).padStart(4, '0')}`;
76
+ if (!/^[a-z][a-z0-9-]*$/.test(clipId) || usedClipIds.has(clipId)) {
77
+ throw new TypeError(`Placement ${index + 1} clipId must be a unique kebab-case id.`);
78
+ }
79
+ usedClipIds.add(clipId);
80
+ return {
81
+ type: 'add-clip',
82
+ trackId,
83
+ clip: {
84
+ id: clipId,
85
+ assetId,
86
+ timelineStart,
87
+ sourceIn,
88
+ sourceOut: sourceIn + durationSeconds,
89
+ durationSeconds,
90
+ },
91
+ };
92
+ });
93
+
94
+ return {
95
+ schemaVersion: 1,
96
+ type: 'scene-review-placement-plan',
97
+ reviewRequired: true,
98
+ baseTimelineSha256: timelineFingerprint(timeline),
99
+ sourceSha256: sceneReview.sourceSha256,
100
+ assetId,
101
+ trackId,
102
+ operations,
103
+ preview: previewTimelineEdit(timeline, operations),
104
+ };
105
+ }
@@ -1,49 +1,49 @@
1
- import { validateTranscript } from '../transcript/validate.mjs';
2
- import { validateEdl } from './retime-transcript.mjs';
3
-
4
- export function planSilenceCuts(transcript, options = {}) {
5
- validateTranscript(transcript);
6
- const { minGap = 0.55, headPad = 0.22, tailPad = 0.34 } = options;
7
- for (const [name, value] of Object.entries({ minGap, headPad, tailPad })) {
8
- if (!Number.isFinite(value) || value < 0 || (name === 'minGap' && value === 0)) {
9
- throw new TypeError(`${name} must be ${name === 'minGap' ? 'positive' : 'non-negative'}.`);
10
- }
11
- }
12
- if (transcript.words.length === 0) throw new TypeError('Cannot plan silence cuts without timed words.');
13
-
14
- const words = transcript.words;
15
- const cuts = [];
16
- const headEnd = words[0].start - headPad;
17
- if (headEnd > 0) cuts.push({ sourceStart: 0, sourceEnd: headEnd, reason: 'head silence' });
18
-
19
- for (let index = 1; index < words.length; index++) {
20
- const previous = words[index - 1];
21
- const next = words[index];
22
- const gap = next.start - previous.end;
23
- if (gap < minGap) continue;
24
-
25
- const keep = gap >= 2 ? 0.24 : /[.!?]$/.test(previous.text) ? 0.2 : 0.14;
26
- const sourceStart = previous.end + keep * 0.55;
27
- const sourceEnd = next.start - keep * 0.45;
28
- if (sourceEnd > sourceStart) {
29
- cuts.push({
30
- sourceStart: round(sourceStart),
31
- sourceEnd: round(sourceEnd),
32
- reason: `pause ${gap.toFixed(2)}s shortened to ${keep.toFixed(2)}s`,
33
- });
34
- }
35
- }
36
-
37
- const tailStart = words.at(-1).end + tailPad;
38
- if (tailStart < transcript.durationSeconds) {
39
- cuts.push({ sourceStart: tailStart, sourceEnd: transcript.durationSeconds, reason: 'tail silence' });
40
- }
41
-
42
- const edl = { schemaVersion: 1, sourceDurationSeconds: transcript.durationSeconds, cuts };
43
- validateEdl(edl, transcript.durationSeconds);
44
- return edl;
45
- }
46
-
47
- function round(value) {
48
- return Number(value.toFixed(3));
49
- }
1
+ import { validateTranscript } from '../transcript/validate.mjs';
2
+ import { validateEdl } from './retime-transcript.mjs';
3
+
4
+ export function planSilenceCuts(transcript, options = {}) {
5
+ validateTranscript(transcript);
6
+ const { minGap = 0.55, headPad = 0.22, tailPad = 0.34 } = options;
7
+ for (const [name, value] of Object.entries({ minGap, headPad, tailPad })) {
8
+ if (!Number.isFinite(value) || value < 0 || (name === 'minGap' && value === 0)) {
9
+ throw new TypeError(`${name} must be ${name === 'minGap' ? 'positive' : 'non-negative'}.`);
10
+ }
11
+ }
12
+ if (transcript.words.length === 0) throw new TypeError('Cannot plan silence cuts without timed words.');
13
+
14
+ const words = transcript.words;
15
+ const cuts = [];
16
+ const headEnd = words[0].start - headPad;
17
+ if (headEnd > 0) cuts.push({ sourceStart: 0, sourceEnd: headEnd, reason: 'head silence' });
18
+
19
+ for (let index = 1; index < words.length; index++) {
20
+ const previous = words[index - 1];
21
+ const next = words[index];
22
+ const gap = next.start - previous.end;
23
+ if (gap < minGap) continue;
24
+
25
+ const keep = gap >= 2 ? 0.24 : /[.!?]$/.test(previous.text) ? 0.2 : 0.14;
26
+ const sourceStart = previous.end + keep * 0.55;
27
+ const sourceEnd = next.start - keep * 0.45;
28
+ if (sourceEnd > sourceStart) {
29
+ cuts.push({
30
+ sourceStart: round(sourceStart),
31
+ sourceEnd: round(sourceEnd),
32
+ reason: `pause ${gap.toFixed(2)}s shortened to ${keep.toFixed(2)}s`,
33
+ });
34
+ }
35
+ }
36
+
37
+ const tailStart = words.at(-1).end + tailPad;
38
+ if (tailStart < transcript.durationSeconds) {
39
+ cuts.push({ sourceStart: tailStart, sourceEnd: transcript.durationSeconds, reason: 'tail silence' });
40
+ }
41
+
42
+ const edl = { schemaVersion: 1, sourceDurationSeconds: transcript.durationSeconds, cuts };
43
+ validateEdl(edl, transcript.durationSeconds);
44
+ return edl;
45
+ }
46
+
47
+ function round(value) {
48
+ return Number(value.toFixed(3));
49
+ }
@@ -1,105 +1,105 @@
1
- import { validateTranscript } from '../transcript/validate.mjs';
2
-
3
- export function retimeTranscript(transcript, edl) {
4
- validateTranscript(transcript);
5
- validateEdl(edl, transcript.durationSeconds);
6
- assertEdlApproved(edl);
7
-
8
- const words = transcript.words.flatMap((word) => {
9
- const containingCut = edl.cuts.find(
10
- (cut) => cut.sourceStart <= word.start && cut.sourceEnd >= word.end
11
- );
12
- if (containingCut) return [];
13
-
14
- const partialCut = edl.cuts.find(
15
- (cut) => cut.sourceStart < word.end && cut.sourceEnd > word.start
16
- );
17
- if (partialCut) {
18
- throw new TypeError(`EDL cut ${partialCut.sourceStart}-${partialCut.sourceEnd} splits transcript word "${word.text}".`);
19
- }
20
-
21
- const removedBefore = edl.cuts
22
- .filter((cut) => cut.sourceEnd <= word.start)
23
- .reduce((total, cut) => total + cut.sourceEnd - cut.sourceStart, 0);
24
- return [{ ...word, start: word.start - removedBefore, end: word.end - removedBefore }];
25
- });
26
-
27
- const removedDuration = edl.cuts.reduce(
28
- (total, cut) => total + cut.sourceEnd - cut.sourceStart,
29
- 0
30
- );
31
- if (removedDuration >= transcript.durationSeconds) {
32
- throw new TypeError('EDL must leave a positive output duration.');
33
- }
34
- return {
35
- schemaVersion: 1,
36
- durationSeconds: transcript.durationSeconds - removedDuration,
37
- ...(transcript.granularity ? { granularity: transcript.granularity } : {}),
38
- words,
39
- };
40
- }
41
-
42
- export function validateEdl(edl, sourceDurationSeconds) {
43
- if (!edl || typeof edl !== 'object' || Array.isArray(edl)) {
44
- throw new TypeError('EDL must be a JSON object.');
45
- }
46
- if (edl.schemaVersion !== 1) {
47
- throw new TypeError('EDL schemaVersion must be 1.');
48
- }
49
- if (!Number.isFinite(edl.sourceDurationSeconds) || edl.sourceDurationSeconds <= 0) {
50
- throw new TypeError('EDL sourceDurationSeconds must be a positive number.');
51
- }
52
- if (Math.abs(edl.sourceDurationSeconds - sourceDurationSeconds) > 1e-6) {
53
- throw new TypeError('EDL sourceDurationSeconds must match the transcript duration.');
54
- }
55
- if (edl.reviewStatus !== undefined && !['needs-review', 'approved'].includes(edl.reviewStatus)) {
56
- throw new TypeError('EDL reviewStatus must be needs-review or approved.');
57
- }
58
- if (!Array.isArray(edl.cuts)) {
59
- throw new TypeError('EDL cuts must be an array.');
60
- }
61
-
62
- let previousEnd = 0;
63
- for (const [index, cut] of edl.cuts.entries()) {
64
- const label = `EDL cut ${index + 1}`;
65
- if (!cut || typeof cut !== 'object' || Array.isArray(cut)) {
66
- throw new TypeError(`${label} must be an object.`);
67
- }
68
- if (!Number.isFinite(cut.sourceStart) || !Number.isFinite(cut.sourceEnd) || cut.sourceStart < 0 || cut.sourceEnd <= cut.sourceStart) {
69
- throw new TypeError(`${label} must have finite times with 0 <= sourceStart < sourceEnd.`);
70
- }
71
- if (cut.sourceEnd > sourceDurationSeconds) {
72
- throw new TypeError(`${label} ends after the source duration.`);
73
- }
74
- if (cut.sourceStart < previousEnd) {
75
- throw new TypeError(`${label} overlaps or is out of order.`);
76
- }
77
- if (typeof cut.reason !== 'string' || !cut.reason.trim()) {
78
- throw new TypeError(`${label} reason must be a non-empty string.`);
79
- }
80
- previousEnd = cut.sourceEnd;
81
- }
82
- const removedDuration = edl.cuts.reduce(
83
- (total, cut) => total + cut.sourceEnd - cut.sourceStart,
84
- 0
85
- );
86
- if (removedDuration >= sourceDurationSeconds) {
87
- throw new TypeError('EDL must leave a positive output duration.');
88
- }
89
- return edl;
90
- }
91
-
92
- export function approveEdl(edl) {
93
- validateEdl(edl, edl?.sourceDurationSeconds);
94
- if (edl.reviewStatus !== 'needs-review') throw new TypeError('Only an EDL marked needs-review can be approved.');
95
- const approved = structuredClone(edl);
96
- approved.reviewStatus = 'approved';
97
- return approved;
98
- }
99
-
100
- export function assertEdlApproved(edl) {
101
- if (edl?.reviewStatus === 'needs-review') {
102
- throw new TypeError('EDL contains unapproved edit suggestions; review and approve it before editing.');
103
- }
104
- return edl;
105
- }
1
+ import { validateTranscript } from '../transcript/validate.mjs';
2
+
3
+ export function retimeTranscript(transcript, edl) {
4
+ validateTranscript(transcript);
5
+ validateEdl(edl, transcript.durationSeconds);
6
+ assertEdlApproved(edl);
7
+
8
+ const words = transcript.words.flatMap((word) => {
9
+ const containingCut = edl.cuts.find(
10
+ (cut) => cut.sourceStart <= word.start && cut.sourceEnd >= word.end
11
+ );
12
+ if (containingCut) return [];
13
+
14
+ const partialCut = edl.cuts.find(
15
+ (cut) => cut.sourceStart < word.end && cut.sourceEnd > word.start
16
+ );
17
+ if (partialCut) {
18
+ throw new TypeError(`EDL cut ${partialCut.sourceStart}-${partialCut.sourceEnd} splits transcript word "${word.text}".`);
19
+ }
20
+
21
+ const removedBefore = edl.cuts
22
+ .filter((cut) => cut.sourceEnd <= word.start)
23
+ .reduce((total, cut) => total + cut.sourceEnd - cut.sourceStart, 0);
24
+ return [{ ...word, start: word.start - removedBefore, end: word.end - removedBefore }];
25
+ });
26
+
27
+ const removedDuration = edl.cuts.reduce(
28
+ (total, cut) => total + cut.sourceEnd - cut.sourceStart,
29
+ 0
30
+ );
31
+ if (removedDuration >= transcript.durationSeconds) {
32
+ throw new TypeError('EDL must leave a positive output duration.');
33
+ }
34
+ return {
35
+ schemaVersion: 1,
36
+ durationSeconds: transcript.durationSeconds - removedDuration,
37
+ ...(transcript.granularity ? { granularity: transcript.granularity } : {}),
38
+ words,
39
+ };
40
+ }
41
+
42
+ export function validateEdl(edl, sourceDurationSeconds) {
43
+ if (!edl || typeof edl !== 'object' || Array.isArray(edl)) {
44
+ throw new TypeError('EDL must be a JSON object.');
45
+ }
46
+ if (edl.schemaVersion !== 1) {
47
+ throw new TypeError('EDL schemaVersion must be 1.');
48
+ }
49
+ if (!Number.isFinite(edl.sourceDurationSeconds) || edl.sourceDurationSeconds <= 0) {
50
+ throw new TypeError('EDL sourceDurationSeconds must be a positive number.');
51
+ }
52
+ if (Math.abs(edl.sourceDurationSeconds - sourceDurationSeconds) > 1e-6) {
53
+ throw new TypeError('EDL sourceDurationSeconds must match the transcript duration.');
54
+ }
55
+ if (edl.reviewStatus !== undefined && !['needs-review', 'approved'].includes(edl.reviewStatus)) {
56
+ throw new TypeError('EDL reviewStatus must be needs-review or approved.');
57
+ }
58
+ if (!Array.isArray(edl.cuts)) {
59
+ throw new TypeError('EDL cuts must be an array.');
60
+ }
61
+
62
+ let previousEnd = 0;
63
+ for (const [index, cut] of edl.cuts.entries()) {
64
+ const label = `EDL cut ${index + 1}`;
65
+ if (!cut || typeof cut !== 'object' || Array.isArray(cut)) {
66
+ throw new TypeError(`${label} must be an object.`);
67
+ }
68
+ if (!Number.isFinite(cut.sourceStart) || !Number.isFinite(cut.sourceEnd) || cut.sourceStart < 0 || cut.sourceEnd <= cut.sourceStart) {
69
+ throw new TypeError(`${label} must have finite times with 0 <= sourceStart < sourceEnd.`);
70
+ }
71
+ if (cut.sourceEnd > sourceDurationSeconds) {
72
+ throw new TypeError(`${label} ends after the source duration.`);
73
+ }
74
+ if (cut.sourceStart < previousEnd) {
75
+ throw new TypeError(`${label} overlaps or is out of order.`);
76
+ }
77
+ if (typeof cut.reason !== 'string' || !cut.reason.trim()) {
78
+ throw new TypeError(`${label} reason must be a non-empty string.`);
79
+ }
80
+ previousEnd = cut.sourceEnd;
81
+ }
82
+ const removedDuration = edl.cuts.reduce(
83
+ (total, cut) => total + cut.sourceEnd - cut.sourceStart,
84
+ 0
85
+ );
86
+ if (removedDuration >= sourceDurationSeconds) {
87
+ throw new TypeError('EDL must leave a positive output duration.');
88
+ }
89
+ return edl;
90
+ }
91
+
92
+ export function approveEdl(edl) {
93
+ validateEdl(edl, edl?.sourceDurationSeconds);
94
+ if (edl.reviewStatus !== 'needs-review') throw new TypeError('Only an EDL marked needs-review can be approved.');
95
+ const approved = structuredClone(edl);
96
+ approved.reviewStatus = 'approved';
97
+ return approved;
98
+ }
99
+
100
+ export function assertEdlApproved(edl) {
101
+ if (edl?.reviewStatus === 'needs-review') {
102
+ throw new TypeError('EDL contains unapproved edit suggestions; review and approve it before editing.');
103
+ }
104
+ return edl;
105
+ }