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.
- package/LICENSE +21 -21
- package/README.md +120 -118
- package/docs/architecture.md +401 -401
- package/docs/aspect-ratio-presets.md +72 -72
- package/docs/releasing.md +19 -19
- package/docs/usage.md +1193 -1193
- package/package.json +52 -52
- package/requirements-otio.txt +1 -1
- package/src/capabilities.mjs +307 -307
- package/src/cli.mjs +646 -646
- package/src/doctor.mjs +133 -133
- package/src/edits/analysis-to-edl.mjs +43 -43
- package/src/edits/detect-silence.mjs +51 -51
- package/src/edits/find-mistake-candidates.mjs +42 -42
- package/src/edits/plan-shot-placements.mjs +105 -105
- package/src/edits/plan-silence-cuts.mjs +49 -49
- package/src/edits/retime-transcript.mjs +105 -105
- package/src/files.mjs +80 -80
- package/src/index.mjs +44 -44
- package/src/interchange/otio.mjs +204 -204
- package/src/interchange/otio_bridge.py +521 -521
- package/src/media/analysis-provider.mjs +75 -75
- package/src/media/compatibility.mjs +45 -45
- package/src/media/ffmpeg-capabilities.mjs +129 -129
- package/src/media/frame-rate.mjs +58 -58
- package/src/media/frame-time.mjs +22 -22
- package/src/media/preview-assets.mjs +350 -350
- package/src/media/probe.mjs +149 -149
- package/src/media/render-cuts.mjs +90 -90
- package/src/media/scene-detection.mjs +107 -107
- package/src/media/scene-review.mjs +205 -205
- package/src/project/assets.mjs +1194 -1194
- package/src/project/create.mjs +100 -100
- package/src/project/presets.mjs +44 -44
- package/src/render/checkpoints.mjs +192 -192
- package/src/render/formats.mjs +163 -163
- package/src/render/hyperframes.mjs +590 -590
- package/src/render/process.mjs +111 -111
- package/src/render/progress.mjs +46 -46
- package/src/render/timeline-control.mjs +53 -53
- package/src/timeline/assemble.mjs +119 -119
- package/src/timeline/audio-effects.mjs +75 -75
- package/src/timeline/audio-mastering.mjs +236 -236
- package/src/timeline/audio-mix.mjs +105 -105
- package/src/timeline/audio-track-effects.mjs +139 -139
- package/src/timeline/audio-volume.mjs +63 -63
- package/src/timeline/background.mjs +8 -8
- package/src/timeline/blend-mode.mjs +34 -34
- package/src/timeline/caption-layouts.mjs +62 -61
- package/src/timeline/captions.mjs +80 -80
- package/src/timeline/edit.mjs +965 -965
- package/src/timeline/fingerprint.mjs +6 -6
- package/src/timeline/from-edl.mjs +31 -31
- package/src/timeline/history-store.mjs +281 -281
- package/src/timeline/history.mjs +69 -69
- package/src/timeline/inspect.mjs +117 -117
- package/src/timeline/keyframes.mjs +158 -158
- package/src/timeline/layers.mjs +18 -18
- package/src/timeline/mask.mjs +53 -53
- package/src/timeline/project.mjs +52 -52
- package/src/timeline/render.mjs +759 -759
- package/src/timeline/sidechain-render.mjs +209 -209
- package/src/timeline/source-fit.mjs +15 -15
- package/src/timeline/speech-bubble.mjs +26 -26
- package/src/timeline/speed-ramp.mjs +188 -188
- package/src/timeline/track-state.mjs +22 -22
- package/src/timeline/transition.mjs +34 -34
- package/src/timeline/validate.mjs +322 -322
- package/src/transcript/corrections.mjs +21 -21
- package/src/transcript/normalize.mjs +17 -17
- package/src/transcript/provider.mjs +42 -42
- package/src/transcript/subtitles.mjs +57 -57
- 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
|
+
}
|