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
package/src/doctor.mjs ADDED
@@ -0,0 +1,133 @@
1
+ #!/usr/bin/env node
2
+ import { spawnSync } from 'node:child_process';
3
+ import { createRequire } from 'node:module';
4
+ import { listFfmpegEncoders, listFfmpegFilters } from './media/ffmpeg-capabilities.mjs';
5
+ import { doctorHyperframes } from './render/hyperframes.mjs';
6
+
7
+ const nodeVersion = process.versions.node.split('.').map(Number);
8
+ const require = createRequire(import.meta.url);
9
+ const results = [
10
+ {
11
+ name: 'Node.js',
12
+ ok: nodeVersion[0] >= 22,
13
+ detail: `found ${process.versions.node}; requires 22 or newer`,
14
+ },
15
+ checkCommand('FFmpeg', 'ffmpeg'),
16
+ checkCommand('ffprobe', 'ffprobe'),
17
+ checkPackage('HyperFrames', 'hyperframes'),
18
+ checkPackage('GSAP', 'gsap'),
19
+ ];
20
+
21
+ for (const result of results) {
22
+ console.log(`${result.ok ? '✓' : '✗'} ${result.name}: ${result.detail}`);
23
+ if (!result.ok && result.name !== 'Node.js') {
24
+ if (result.name === 'FFmpeg' || result.name === 'ffprobe') {
25
+ console.log(' Install FFmpeg with ffprobe and make both commands available on PATH.');
26
+ console.log(' Guide: https://ffmpeg.org/download.html');
27
+ } else if (result.name === 'HyperFrames' || result.name === 'GSAP') {
28
+ console.log(' Run npm install in the engine repository.');
29
+ }
30
+ } else if (!result.ok && result.name === 'Node.js') {
31
+ console.log(' Install Node.js 22 or newer: https://nodejs.org/en/download');
32
+ }
33
+ }
34
+
35
+ const ready = results.every((result) => result.ok);
36
+ const otioPython = process.env.AIVE_OTIO_PYTHON || process.env.PYTHON || 'python';
37
+ const openTimelineIO = checkPythonModule('OpenTimelineIO', otioPython);
38
+ console.log(`${openTimelineIO.ok ? '✓' : '⚠'} Optional ${openTimelineIO.name}: ${openTimelineIO.detail}`);
39
+ if (!openTimelineIO.ok) {
40
+ console.log(' Install with `python -m pip install -r requirements-otio.txt` or set `AIVE_OTIO_PYTHON` to a Python command with OpenTimelineIO installed.');
41
+ }
42
+ if (results.find((result) => result.name === 'HyperFrames')?.ok) {
43
+ console.log('\nChecking HyperFrames renderer requirements:');
44
+ try {
45
+ doctorHyperframes(process.cwd());
46
+ } catch (error) {
47
+ console.error(error.message);
48
+ process.exitCode = 1;
49
+ }
50
+ }
51
+ console.log(ready
52
+ ? 'Core Engine requirements are available; optional and feature-specific tools are reported above.'
53
+ : 'Some required Engine tools are missing.');
54
+ try {
55
+ const filters = listFfmpegFilters();
56
+ for (const [filter, requirement] of [
57
+ ['rubberband', 'pitch-preserving audio speed ramps'],
58
+ ['equalizer', 'audio equalization and mastering bands'],
59
+ ['highpass', 'audio high-pass filtering'],
60
+ ['lowpass', 'audio low-pass filtering'],
61
+ ['acompressor', 'audio compression effects'],
62
+ ['afftdn', 'per-clip spectral denoise'],
63
+ ['stereotools', 'per-clip stereo balance'],
64
+ ['sidechaincompress', 'signal-aware sidechain compression'],
65
+ ['loudnorm', 'audio loudness mastering'],
66
+ ['alimiter', 'audio peak limiting'],
67
+ ['aresample', 'mastered output channel mapping'],
68
+ ['reverse', 'video reverse playback'],
69
+ ['areverse', 'audio reverse playback'],
70
+ ['colorspace', 'Rec.709 and Rec.2020 SDR output profiles'],
71
+ ['zscale', 'HDR10 output'],
72
+ ]) {
73
+ const available = filters.has(filter);
74
+ console.log(`${available ? '✓' : '⚠'} FFmpeg ${filter} filter: ${available ? 'available' : `not available; required for ${requirement}`}`);
75
+ }
76
+ } catch (error) {
77
+ console.log(`⚠ FFmpeg filters: could not verify (${error.message})`);
78
+ }
79
+ if (results.find((result) => result.name === 'FFmpeg')?.ok) {
80
+ try {
81
+ const encoders = listFfmpegEncoders();
82
+ for (const [name, encoder] of [['H.264', 'libx264'], ['H.265', 'libx265'], ['VP9', 'libvpx-vp9'], ['ProRes', 'prores_ks']]) {
83
+ const available = encoders.has(encoder);
84
+ console.log(`${available ? '✓' : '⚠'} FFmpeg ${name} encoder: ${available ? 'available' : `not available (${encoder})`}`);
85
+ }
86
+ } catch (error) {
87
+ console.log(`⚠ FFmpeg encoders: could not verify (${error.message})`);
88
+ }
89
+ }
90
+ if (!ready) process.exitCode = 1;
91
+
92
+ function checkCommand(label, command, args = ['-version']) {
93
+ const result = spawnSync(command, args, {
94
+ encoding: 'utf8',
95
+ windowsHide: true,
96
+ });
97
+ if (result.error || result.status !== 0) {
98
+ return { name: label, ok: false, detail: 'not found or failed to run' };
99
+ }
100
+ const firstLine = result.stdout.trim().split(/\r?\n/, 1)[0];
101
+ return { name: label, ok: true, detail: firstLine };
102
+ }
103
+
104
+ function checkPackage(label, packageName) {
105
+ try {
106
+ require.resolve(`${packageName}/package.json`);
107
+ return { name: label, ok: true, detail: 'installed' };
108
+ } catch {
109
+ return { name: label, ok: false, detail: 'not installed' };
110
+ }
111
+ }
112
+
113
+ function checkPythonModule(label, command) {
114
+ const result = spawnSync(command, ['-c', "import opentimelineio as otio; assert 'otio_json' in otio.adapters.available_adapter_names(), 'otio_json adapter unavailable'; print(getattr(otio, '__version__', 'installed'))"], {
115
+ encoding: 'utf8',
116
+ windowsHide: true,
117
+ maxBuffer: 1024 * 1024,
118
+ });
119
+ if (result.error) return { name: label, ok: false, detail: `Python command unavailable (${command})` };
120
+ if (result.status !== 0) {
121
+ const details = `${result.stderr}\n${result.stdout}`;
122
+ return {
123
+ name: label,
124
+ ok: false,
125
+ detail: /No module named ['"]?opentimelineio/.test(details)
126
+ ? 'Python package not installed'
127
+ : /otio_json adapter unavailable/.test(details)
128
+ ? 'JSON adapter not available'
129
+ : 'Python package failed to import',
130
+ };
131
+ }
132
+ return { name: label, ok: true, detail: `Python package ${result.stdout.trim().split(/\r?\n/, 1)[0]}` };
133
+ }
@@ -0,0 +1,43 @@
1
+ import { validateEdl } from './retime-transcript.mjs';
2
+ import { normalizeMediaFindings } from '../media/analysis-provider.mjs';
3
+
4
+ /** Convert explicit provider cut suggestions into an EDL that requires review. */
5
+ export function mediaAnalysisToEdl(analysis, { minConfidence = 0 } = {}) {
6
+ if (!analysis || typeof analysis !== 'object' || analysis.schemaVersion !== 1
7
+ || !Number.isFinite(analysis.durationSeconds) || analysis.durationSeconds <= 0
8
+ || !Array.isArray(analysis.findings)) {
9
+ throw new TypeError('Media analysis must be a normalized schemaVersion 1 result.');
10
+ }
11
+ if (!Number.isFinite(minConfidence) || minConfidence < 0 || minConfidence > 1) {
12
+ throw new TypeError('minConfidence must be between 0 and 1.');
13
+ }
14
+ const normalizedFindings = normalizeMediaFindings({ findings: analysis.findings }, analysis.durationSeconds).findings;
15
+
16
+ const cuts = normalizedFindings
17
+ .filter((finding) => finding.suggestion && (finding.confidence ?? 0) >= minConfidence)
18
+ .map((finding) => ({
19
+ sourceStart: finding.suggestion.sourceStart,
20
+ sourceEnd: finding.suggestion.sourceEnd,
21
+ reason: `${finding.id}: ${finding.suggestion.reason}`,
22
+ }))
23
+ .sort((left, right) => left.sourceStart - right.sourceStart || left.sourceEnd - right.sourceEnd)
24
+ .reduce((result, cut) => {
25
+ const previous = result.at(-1);
26
+ if (previous && cut.sourceStart < previous.sourceEnd) {
27
+ previous.sourceEnd = Math.max(previous.sourceEnd, cut.sourceEnd);
28
+ previous.reason = `${previous.reason}; ${cut.reason}`;
29
+ } else {
30
+ result.push(cut);
31
+ }
32
+ return result;
33
+ }, []);
34
+
35
+ const edl = {
36
+ schemaVersion: 1,
37
+ sourceDurationSeconds: analysis.durationSeconds,
38
+ reviewStatus: 'needs-review',
39
+ cuts,
40
+ };
41
+ validateEdl(edl, analysis.durationSeconds);
42
+ return edl;
43
+ }
@@ -0,0 +1,51 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { probeMedia } from '../media/probe.mjs';
3
+ import { validateEdl } from './retime-transcript.mjs';
4
+
5
+ export function detectAcousticSilence(inputPath, options = {}) {
6
+ const media = probeMedia(inputPath, { requireVideo: false });
7
+ if (!media.hasAudio) throw new TypeError('Acoustic silence detection requires an audio stream.');
8
+ const noiseDb = options.noiseDb ?? -35;
9
+ const minSilenceSeconds = options.minSilenceSeconds ?? 0.5;
10
+ const paddingSeconds = options.paddingSeconds ?? 0.12;
11
+ if (!Number.isFinite(noiseDb) || noiseDb < -90 || noiseDb > 0) throw new TypeError('noiseDb must be between -90 and 0.');
12
+ if (!Number.isFinite(minSilenceSeconds) || minSilenceSeconds <= 0) throw new TypeError('minSilenceSeconds must be positive.');
13
+ if (!Number.isFinite(paddingSeconds) || paddingSeconds < 0) throw new TypeError('paddingSeconds must be non-negative.');
14
+
15
+ const result = spawnSync('ffmpeg', [
16
+ '-nostdin', '-hide_banner', '-i', media.path,
17
+ '-af', `silencedetect=noise=${noiseDb}dB:d=${minSilenceSeconds}`,
18
+ '-f', 'null', '-',
19
+ ], { encoding: 'utf8', windowsHide: true });
20
+ if (result.error || result.status !== 0) throw new Error(`FFmpeg silence detection failed: ${result.error?.message ?? result.stderr.trim()}`);
21
+
22
+ const intervals = parseSilenceIntervals(result.stderr, media.durationSeconds);
23
+ const cuts = intervals.flatMap(({ start, end }) => {
24
+ const sourceStart = round(start + paddingSeconds);
25
+ const sourceEnd = round(end - paddingSeconds);
26
+ return sourceEnd > sourceStart ? [{ sourceStart, sourceEnd, reason: `acoustic silence (${round(end - start)}s)` }] : [];
27
+ });
28
+ const edl = { schemaVersion: 1, sourceDurationSeconds: media.durationSeconds, cuts };
29
+ validateEdl(edl, media.durationSeconds);
30
+ return edl;
31
+ }
32
+
33
+ export function parseSilenceIntervals(log, durationSeconds) {
34
+ const intervals = [];
35
+ const events = /silence_(start|end):\s*(-?(?:\d+(?:\.\d*)?|\.\d+))/g;
36
+ let silenceStart;
37
+ for (const match of log.matchAll(events)) {
38
+ const time = Number(match[2]);
39
+ if (match[1] === 'start') silenceStart = Math.max(0, time);
40
+ else if (silenceStart !== undefined && time > silenceStart) {
41
+ intervals.push({ start: silenceStart, end: Math.min(time, durationSeconds) });
42
+ silenceStart = undefined;
43
+ }
44
+ }
45
+ if (silenceStart !== undefined && durationSeconds > silenceStart) intervals.push({ start: silenceStart, end: durationSeconds });
46
+ return intervals;
47
+ }
48
+
49
+ function round(value) {
50
+ return Number(value.toFixed(3));
51
+ }
@@ -0,0 +1,42 @@
1
+ import { validateTranscript } from '../transcript/validate.mjs';
2
+
3
+ export function findMistakeCandidates(transcript, { maxRepeatGap = 0.5 } = {}) {
4
+ validateTranscript(transcript);
5
+ if (transcript.granularity === 'cue') {
6
+ throw new TypeError('Mistake candidates require a word-level transcript.');
7
+ }
8
+ if (!Number.isFinite(maxRepeatGap) || maxRepeatGap < 0) {
9
+ throw new TypeError('maxRepeatGap must be a non-negative number.');
10
+ }
11
+
12
+ const candidates = [];
13
+ for (let index = 1; index < transcript.words.length; index++) {
14
+ const first = transcript.words[index - 1];
15
+ const second = transcript.words[index];
16
+ const firstToken = normalize(first.text);
17
+ const secondToken = normalize(second.text);
18
+ const gap = second.start - first.end;
19
+ if (!firstToken || firstToken !== secondToken || gap < 0 || gap > maxRepeatGap) continue;
20
+
21
+ candidates.push({
22
+ id: `repeat-${String(candidates.length + 1).padStart(3, '0')}`,
23
+ type: 'repeated-word',
24
+ confidence: 'review',
25
+ cut: {
26
+ sourceStart: first.start,
27
+ sourceEnd: second.start,
28
+ reason: `possible repeated word: ${first.text}`,
29
+ },
30
+ removes: first.text,
31
+ keeps: second.text,
32
+ context: transcript.words.slice(Math.max(0, index - 4), Math.min(transcript.words.length, index + 4))
33
+ .map((word) => word.text)
34
+ .join(' '),
35
+ });
36
+ }
37
+ return candidates;
38
+ }
39
+
40
+ function normalize(text) {
41
+ return text.toLowerCase().normalize('NFKC').replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, '');
42
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
@@ -0,0 +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
+ }
package/src/files.mjs ADDED
@@ -0,0 +1,80 @@
1
+ import { closeSync, createReadStream, linkSync, openSync, readSync, rmSync, statSync, writeSync } from 'node:fs';
2
+ import { link, open, rm } from 'node:fs/promises';
3
+ import { Transform } from 'node:stream';
4
+ import { pipeline } from 'node:stream/promises';
5
+
6
+ /** Publish a staged file without overwriting an existing destination. */
7
+ export function linkOrCopySync(source, destination) {
8
+ try {
9
+ linkSync(source, destination);
10
+ } catch (error) {
11
+ if (!isUnsupportedHardLinkError(error)) throw error;
12
+ copyFileExclusiveSync(source, destination);
13
+ }
14
+ }
15
+
16
+ /** Publish a staged file asynchronously and report bytes only when copied. */
17
+ export async function linkOrCopyAsync(source, destination, { signal, onCopyProgress } = {}) {
18
+ try {
19
+ await link(source, destination);
20
+ } catch (error) {
21
+ if (!isUnsupportedHardLinkError(error)) throw error;
22
+ const destinationHandle = await open(destination, 'wx');
23
+ try {
24
+ const progress = onCopyProgress ? createCopyProgress(source, onCopyProgress) : undefined;
25
+ const streams = [createReadStream(source, { signal }), ...(progress ? [progress] : []), destinationHandle.createWriteStream()];
26
+ await pipeline(...streams, { signal });
27
+ } catch (copyError) {
28
+ await destinationHandle.close().catch(() => {});
29
+ await rm(destination, { force: true }).catch(() => {});
30
+ throw copyError;
31
+ }
32
+ }
33
+ }
34
+
35
+ function isUnsupportedHardLinkError(error) {
36
+ return ['EPERM', 'EXDEV', 'ENOSYS', 'ENOTSUP', 'EOPNOTSUPP'].includes(error?.code);
37
+ }
38
+
39
+ function copyFileExclusiveSync(source, destination) {
40
+ let sourceDescriptor;
41
+ let destinationDescriptor;
42
+ try {
43
+ sourceDescriptor = openSync(source, 'r');
44
+ destinationDescriptor = openSync(destination, 'wx');
45
+ const buffer = Buffer.allocUnsafe(1024 * 1024);
46
+ let bytesRead;
47
+ while ((bytesRead = readSync(sourceDescriptor, buffer, 0, buffer.length, null)) > 0) {
48
+ let offset = 0;
49
+ while (offset < bytesRead) offset += writeSync(destinationDescriptor, buffer, offset, bytesRead - offset);
50
+ }
51
+ closeSync(destinationDescriptor);
52
+ destinationDescriptor = undefined;
53
+ } catch (error) {
54
+ if (destinationDescriptor !== undefined) {
55
+ try { closeSync(destinationDescriptor); } catch { /* Preserve the original copy error. */ }
56
+ destinationDescriptor = undefined;
57
+ try { rmSync(destination, { force: true }); } catch { /* Preserve the original copy error. */ }
58
+ }
59
+ throw error;
60
+ } finally {
61
+ if (sourceDescriptor !== undefined) closeSync(sourceDescriptor);
62
+ }
63
+ }
64
+
65
+ function createCopyProgress(source, onCopyProgress) {
66
+ const totalBytes = statSync(source).size;
67
+ let completedBytes = 0;
68
+ let lastPercent = -1;
69
+ return new Transform({
70
+ transform(chunk, encoding, callback) {
71
+ completedBytes += chunk.length;
72
+ const percent = totalBytes > 0 ? Math.floor(completedBytes / totalBytes * 100) : 100;
73
+ if (percent > lastPercent || completedBytes >= totalBytes) {
74
+ lastPercent = percent;
75
+ onCopyProgress(completedBytes, totalBytes);
76
+ }
77
+ callback(null, chunk);
78
+ },
79
+ });
80
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,44 @@
1
+ export { validateTranscript } from './transcript/validate.mjs';
2
+ export { getEngineCapabilities } from './capabilities.mjs';
3
+ export { validateTimeline } from './timeline/validate.mjs';
4
+ export { inspectTimeline } from './timeline/inspect.mjs';
5
+ export { blendModeStyle, validateBlendMode } from './timeline/blend-mode.mjs';
6
+ export { timelineForRender } from './timeline/track-state.mjs';
7
+ export { SPEED_RAMP_EASES, speedRampDuration } from './timeline/speed-ramp.mjs';
8
+ export { timelineFromEdl } from './timeline/from-edl.mjs';
9
+ export { renderTimeline, renderTimelineAsync, renderTimelineRange, renderTimelineRangeAsync } from './timeline/render.mjs';
10
+ export { DEFAULT_SOURCE_FIT, SOURCE_FIT_MODES, resolveSourceFit, validateSourceFit } from './timeline/source-fit.mjs';
11
+ export { addTimelineClip, addTimelineTrack, crossfadeTimelineClips, detachAudioFromTimelineClip, duplicateTimelineClip, editTimeline, editTimelineBatch, extractTimelineRange, insertTimelineGap, moveTimelineClip, moveTimelineClipToTrack, previewTimelineEdit, removeTimelineClip, removeTimelineMarker, removeTimelineTrack, reorderTimelineTrack, replaceTimelineClipAsset, rippleDeleteTimelineClip, rippleDeleteTimelineRange, setTimelineAudioMastering, setTimelineAudioMix, setTimelineClipAudioEffects, setTimelineClipAudioState, setTimelineClipState, setTimelineClipVideoState, setTimelineClipFraming, setTimelineClipKeyframes, setTimelineClipReverse, setTimelineClipSpeed, setTimelineClipVolumeKeyframes, setTimelineMarker, setTimelineOverlay, setTimelineProjectState, setTimelineTrackAudioEffects, setTimelineTrackState, setTimelineTransition, slipTimelineClip, splitTimelineClip, trimTimelineClip } from './timeline/edit.mjs';
12
+ export { createTimelineHistory } from './timeline/history.mjs';
13
+ export { applyTimelineReviewPlan, editTimelineFile, undoTimelineFile, redoTimelineFile, getTimelineFileHistory, resetTimelineFileHistory } from './timeline/history-store.mjs';
14
+ export { timelineFingerprint } from './timeline/fingerprint.mjs';
15
+ export { createProjectTimeline } from './timeline/project.mjs';
16
+ export { createCaptionTrack } from './timeline/captions.mjs';
17
+ export { CAPTION_LAYOUT_PRESETS, listCaptionLayoutPresets, resolveCaptionLayoutPreset } from './timeline/caption-layouts.mjs';
18
+ export { assembleEditedTimeline, renderEditedProject, renderEditedProjectAsync } from './timeline/assemble.mjs';
19
+ export { normalizeTranscript } from './transcript/normalize.mjs';
20
+ export { formatSubtitleText, parseSubtitleText } from './transcript/subtitles.mjs';
21
+ export { transcribeWithProvider } from './transcript/provider.mjs';
22
+ export { approveEdl, assertEdlApproved, retimeTranscript, validateEdl } from './edits/retime-transcript.mjs';
23
+ export { mediaAnalysisToEdl } from './edits/analysis-to-edl.mjs';
24
+ export { planSilenceCuts } from './edits/plan-silence-cuts.mjs';
25
+ export { detectAcousticSilence, parseSilenceIntervals } from './edits/detect-silence.mjs';
26
+ export { findMistakeCandidates } from './edits/find-mistake-candidates.mjs';
27
+ export { planSceneReviewPlacements } from './edits/plan-shot-placements.mjs';
28
+ export { applyTranscriptCorrections } from './transcript/corrections.mjs';
29
+ export { parseProbeOutput, probeMedia, probeMediaAsync } from './media/probe.mjs';
30
+ export { inspectMediaCompatibility } from './media/compatibility.mjs';
31
+ export { analyzeMediaWithProvider, normalizeMediaFindings } from './media/analysis-provider.mjs';
32
+ export { detectSceneChangesAsync, parseSceneDetectionOutput } from './media/scene-detection.mjs';
33
+ export { buildShotRanges, createSceneReviewPackAsync } from './media/scene-review.mjs';
34
+ export { generateAssetPreview } from './media/preview-assets.mjs';
35
+ export { normalizeFrameRate, frameRateNumber } from './media/frame-rate.mjs';
36
+ export { assertFfmpegDecoder, assertFfmpegDemuxer, assertFfmpegEncoder, hasFfmpegDecoder, hasFfmpegDemuxer, hasFfmpegEncoder, hasFfmpegFilter, listFfmpegDecoders, listFfmpegDemuxers, listFfmpegEncoders, listFfmpegFilters, parseFfmpegDecoders, parseFfmpegDemuxers, parseFfmpegEncoders, parseFfmpegFilters } from './media/ffmpeg-capabilities.mjs';
37
+ export { secondsToFrame, frameToSeconds, snapTimeToFrame } from './media/frame-time.mjs';
38
+ export { createFilterGraph, getKeepRanges, renderCuts } from './media/render-cuts.mjs';
39
+ export { createVideoProject } from './project/create.mjs';
40
+ export { createAssetProxy, createAssetProxyAsync, listProjectAssets, registerMediaAsset, registerMediaAssetAsync, registerMediaAssetsAsync, relinkProjectAsset, relinkProjectAssetAsync, resolveAssetProxies, resolveAssetProxiesAsync, unregisterProjectAsset, verifyProjectAssets, verifyProjectAssetsAsync } from './project/assets.mjs';
41
+ export { CANVAS_PRESETS, listCanvasPresets, resolveCanvasPreset } from './project/presets.mjs';
42
+ export { doctorHyperframes, importProjectTranscript, lintProject, previewProject, renderProject, renderProjectAsync, transcribeProject } from './render/hyperframes.mjs';
43
+ export { resolveColorProfile, resolveEncoderArgs, resolveProfileEncoder, resolveRenderControlArgs, resolveRenderFormat, resolveVideoEncoder } from './render/formats.mjs';
44
+ export { exportTimelineToOtio, importTimelineFromOtio, isOtioPath } from './interchange/otio.mjs';