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
package/src/interchange/otio.mjs
CHANGED
|
@@ -1,204 +1,204 @@
|
|
|
1
|
-
import { spawnSync } from 'node:child_process';
|
|
2
|
-
import { createHash } from 'node:crypto';
|
|
3
|
-
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
|
|
4
|
-
import { extname, join, resolve } from 'node:path';
|
|
5
|
-
import { fileURLToPath } from 'node:url';
|
|
6
|
-
import { registerMediaAsset } from '../project/assets.mjs';
|
|
7
|
-
import { validateTimeline } from '../timeline/validate.mjs';
|
|
8
|
-
|
|
9
|
-
const bridgePath = fileURLToPath(new URL('./otio_bridge.py', import.meta.url));
|
|
10
|
-
const ENGINE_CLIP_SETTINGS = new Set([
|
|
11
|
-
'audioEffects', 'volumeKeyframes', 'reverse', 'speedRamp',
|
|
12
|
-
'transitionIn', 'transitionOut', 'fadeInSeconds', 'fadeOutSeconds',
|
|
13
|
-
'keyframes', 'fit', 'x', 'y', 'width', 'height',
|
|
14
|
-
'objectPositionX', 'objectPositionY', 'zoom', 'mask', 'blendMode',
|
|
15
|
-
'brightness', 'contrast', 'saturation', 'blurPixels', 'hueDegrees',
|
|
16
|
-
'audioStreamIndex', 'videoStreamIndex', 'opacity', 'volume',
|
|
17
|
-
]);
|
|
18
|
-
const ENGINE_TRACK_SETTINGS = new Set(['audioEffects', 'volumeKeyframes', 'volume']);
|
|
19
|
-
|
|
20
|
-
function runBridge(request) {
|
|
21
|
-
const python = process.env.AIVE_OTIO_PYTHON || process.env.PYTHON || 'python';
|
|
22
|
-
const result = spawnSync(python, [bridgePath], {
|
|
23
|
-
input: JSON.stringify(request),
|
|
24
|
-
encoding: 'utf8',
|
|
25
|
-
windowsHide: true,
|
|
26
|
-
maxBuffer: 64 * 1024 * 1024,
|
|
27
|
-
});
|
|
28
|
-
if (result.error) {
|
|
29
|
-
throw new Error(`Could not start the OpenTimelineIO bridge with ${python}: ${result.error.message}. Install the optional dependency with "python -m pip install -r requirements-otio.txt" or set AIVE_OTIO_PYTHON.`);
|
|
30
|
-
}
|
|
31
|
-
if (result.status !== 0) {
|
|
32
|
-
const details = result.stderr.trim() || result.stdout.trim();
|
|
33
|
-
if (/No module named ['"]?opentimelineio/.test(details)) {
|
|
34
|
-
throw new Error('OpenTimelineIO is not installed. Install the optional dependency with "python -m pip install -r requirements-otio.txt" or set AIVE_OTIO_PYTHON.');
|
|
35
|
-
}
|
|
36
|
-
throw new Error(`OpenTimelineIO interchange failed: ${details || `Python exited with status ${result.status}`}`);
|
|
37
|
-
}
|
|
38
|
-
try {
|
|
39
|
-
return JSON.parse(result.stdout).result;
|
|
40
|
-
} catch (error) {
|
|
41
|
-
throw new Error(`OpenTimelineIO bridge returned invalid JSON: ${error.message}`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
export function exportTimelineToOtio(projectPath, timeline) {
|
|
46
|
-
validateTimeline(timeline);
|
|
47
|
-
return runBridge({ operation: 'export', projectRoot: resolve(projectPath), timeline });
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
export function importTimelineFromOtio(projectPath, otioPath) {
|
|
51
|
-
const root = resolve(projectPath);
|
|
52
|
-
const input = resolve(otioPath);
|
|
53
|
-
const metadataPath = join(root, 'meta.json');
|
|
54
|
-
if (!existsSync(metadataPath)) throw new Error(`Not an Engine project: ${root}`);
|
|
55
|
-
const imported = runBridge({ operation: 'import', otioPath: input, otioText: readFileSync(input, 'utf8') });
|
|
56
|
-
const projectMeta = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
57
|
-
const original = imported.engineTimeline;
|
|
58
|
-
const assets = original ? structuredClone(original.assets) : [];
|
|
59
|
-
const tracks = [];
|
|
60
|
-
const registered = new Map();
|
|
61
|
-
const referenceMatches = new Map();
|
|
62
|
-
const usedTrackIds = new Set();
|
|
63
|
-
const findExisting = (mediaPath) => assets.find((asset) => resolve(root, asset.path) === resolve(mediaPath));
|
|
64
|
-
const usedIds = new Set(assets.map((asset) => asset.id));
|
|
65
|
-
|
|
66
|
-
for (const sourceTrack of imported.tracks) {
|
|
67
|
-
const engineTrack = original?.tracks.find((track) => track.id === sourceTrack.engineTrackId);
|
|
68
|
-
const trackId = uniqueId(engineTrack?.id ?? safeId(sourceTrack.name, `${sourceTrack.kind}-track-${sourceTrack.order + 1}`), usedTrackIds);
|
|
69
|
-
const usedClipIds = new Set();
|
|
70
|
-
const clips = [];
|
|
71
|
-
const hasCrossfades = sourceTrack.clips.some((clip) => clip.crossfade);
|
|
72
|
-
const trackState = sourceTrack.kind === 'audio'
|
|
73
|
-
? {
|
|
74
|
-
...(sourceTrack.muted !== undefined
|
|
75
|
-
? { muted: sourceTrack.muted }
|
|
76
|
-
: sourceTrack.enabled === false && sourceTrack.solo !== true ? { muted: true } : engineTrack?.muted === true ? { muted: false } : {}),
|
|
77
|
-
...(sourceTrack.solo === undefined ? {} : { solo: sourceTrack.solo }),
|
|
78
|
-
...(sourceTrack.locked === undefined ? {} : { locked: sourceTrack.locked }),
|
|
79
|
-
}
|
|
80
|
-
: {
|
|
81
|
-
...(sourceTrack.enabled === false ? { visible: false } : engineTrack?.visible === false ? { visible: true } : {}),
|
|
82
|
-
...(sourceTrack.locked === undefined ? {} : { locked: sourceTrack.locked }),
|
|
83
|
-
};
|
|
84
|
-
for (const sourceClip of sourceTrack.clips) {
|
|
85
|
-
if (!existsSync(sourceClip.path)) throw new Error(`OTIO clip media is missing: ${sourceClip.path}`);
|
|
86
|
-
const engineAsset = original?.assets.find((asset) => asset.id === sourceClip.engineAssetId);
|
|
87
|
-
let asset = findExisting(sourceClip.path);
|
|
88
|
-
if (!asset && engineAsset && assetMatchesReference(root, engineAsset, sourceClip.path, referenceMatches)) {
|
|
89
|
-
asset = assets.find((item) => item.id === engineAsset.id);
|
|
90
|
-
}
|
|
91
|
-
if (!asset) {
|
|
92
|
-
const preferredId = engineAsset?.id ?? safeId(sourceClip.name, `media-${clips.length + 1}`);
|
|
93
|
-
const id = uniqueId(preferredId, usedIds);
|
|
94
|
-
asset = registered.get(resolve(sourceClip.path));
|
|
95
|
-
if (!asset) {
|
|
96
|
-
asset = registerMediaAsset(root, sourceClip.path, { id });
|
|
97
|
-
registered.set(resolve(sourceClip.path), asset);
|
|
98
|
-
assets.push(asset);
|
|
99
|
-
usedIds.add(asset.id);
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
const priorClip = sourceClip.crossfade ? {} : { ...(engineTrack?.clips.find((clip) => clip.id === sourceClip.engineClipId) ?? {}) };
|
|
103
|
-
delete priorClip.playbackRate;
|
|
104
|
-
delete priorClip.speedRamp;
|
|
105
|
-
delete priorClip.reverse;
|
|
106
|
-
for (const [key, value] of Object.entries(sourceClip.engineSettings ?? {})) {
|
|
107
|
-
if (ENGINE_CLIP_SETTINGS.has(key)) priorClip[key] = value;
|
|
108
|
-
}
|
|
109
|
-
const clip = {
|
|
110
|
-
...priorClip,
|
|
111
|
-
id: uniqueId(priorClip.id ?? safeId(sourceClip.name, `${sourceTrack.kind}-clip-${clips.length + 1}`), usedClipIds),
|
|
112
|
-
assetId: asset.id,
|
|
113
|
-
sourceIn: sourceClip.sourceIn,
|
|
114
|
-
sourceOut: sourceClip.sourceOut,
|
|
115
|
-
timelineStart: sourceClip.timelineStart,
|
|
116
|
-
durationSeconds: sourceClip.durationSeconds,
|
|
117
|
-
...(sourceClip.enabled === false || Object.hasOwn(priorClip, 'enabled') ? { enabled: sourceClip.enabled } : {}),
|
|
118
|
-
...(sourceClip.playbackRate !== undefined ? { playbackRate: sourceClip.playbackRate } : {}),
|
|
119
|
-
...(sourceClip.crossfade?.role === 'incoming' ? { fadeInSeconds: sourceClip.crossfade.durationSeconds } : {}),
|
|
120
|
-
...(sourceClip.crossfade?.role === 'outgoing' && sourceTrack.kind === 'audio' ? { fadeOutSeconds: sourceClip.crossfade.durationSeconds } : {}),
|
|
121
|
-
};
|
|
122
|
-
clips.push(clip);
|
|
123
|
-
}
|
|
124
|
-
const importedTrackSettings = Object.fromEntries(Object.entries(sourceTrack.engineSettings ?? {}).filter(([key]) => ENGINE_TRACK_SETTINGS.has(key)));
|
|
125
|
-
tracks.push({
|
|
126
|
-
...(engineTrack ?? {}),
|
|
127
|
-
...importedTrackSettings,
|
|
128
|
-
id: trackId,
|
|
129
|
-
kind: sourceTrack.kind,
|
|
130
|
-
order: sourceTrack.order,
|
|
131
|
-
...(hasCrossfades ? { allowOverlap: true } : {}),
|
|
132
|
-
...trackState,
|
|
133
|
-
clips,
|
|
134
|
-
});
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
for (const track of original?.tracks ?? []) {
|
|
138
|
-
if (track.kind !== 'overlay') continue;
|
|
139
|
-
tracks.push({ ...track, order: tracks.length, id: uniqueId(track.id, usedTrackIds) });
|
|
140
|
-
}
|
|
141
|
-
tracks.forEach((track, order) => { track.order = order; });
|
|
142
|
-
const durationSeconds = Math.max(
|
|
143
|
-
original?.project.durationSeconds ?? projectMeta.durationSeconds,
|
|
144
|
-
imported.durationSeconds ?? 0,
|
|
145
|
-
...tracks.flatMap((track) => track.clips.map((clip) => clip.timelineStart + clip.durationSeconds)),
|
|
146
|
-
0.001,
|
|
147
|
-
);
|
|
148
|
-
const timeline = {
|
|
149
|
-
...(original ?? {}),
|
|
150
|
-
schemaVersion: 1,
|
|
151
|
-
project: { ...(original?.project ?? projectMeta), durationSeconds },
|
|
152
|
-
assets,
|
|
153
|
-
tracks,
|
|
154
|
-
...(imported.markers.length || Object.hasOwn(original ?? {}, 'markers') ? { markers: imported.markers } : {}),
|
|
155
|
-
};
|
|
156
|
-
validateTimeline(timeline);
|
|
157
|
-
return timeline;
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function assetMatchesReference(root, asset, sourcePath, cache) {
|
|
161
|
-
const key = `${asset.id}\0${resolve(sourcePath)}`;
|
|
162
|
-
if (cache.has(key)) return cache.get(key);
|
|
163
|
-
const registeredPath = resolve(root, asset.path);
|
|
164
|
-
if (!existsSync(registeredPath) || !existsSync(sourcePath)
|
|
165
|
-
|| !statSync(registeredPath).isFile() || !statSync(sourcePath).isFile()
|
|
166
|
-
|| lstatSync(registeredPath).isSymbolicLink()) {
|
|
167
|
-
cache.set(key, false);
|
|
168
|
-
return false;
|
|
169
|
-
}
|
|
170
|
-
const matches = hashFile(registeredPath) === asset.sha256 && hashFile(sourcePath) === asset.sha256;
|
|
171
|
-
cache.set(key, matches);
|
|
172
|
-
return matches;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
function hashFile(path) {
|
|
176
|
-
const hash = createHash('sha256');
|
|
177
|
-
const descriptor = openSync(path, 'r');
|
|
178
|
-
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
179
|
-
try {
|
|
180
|
-
let bytes;
|
|
181
|
-
while ((bytes = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
|
|
182
|
-
} finally {
|
|
183
|
-
closeSync(descriptor);
|
|
184
|
-
}
|
|
185
|
-
return hash.digest('hex');
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
function safeId(value, fallback) {
|
|
189
|
-
const id = String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
190
|
-
return /^[a-z]/.test(id) ? id : fallback;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
function uniqueId(requested, used) {
|
|
194
|
-
const base = safeId(requested, 'media');
|
|
195
|
-
let id = base;
|
|
196
|
-
let suffix = 2;
|
|
197
|
-
while (used.has(id)) id = `${base}-${suffix++}`;
|
|
198
|
-
used.add(id);
|
|
199
|
-
return id;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export function isOtioPath(path) {
|
|
203
|
-
return extname(path).toLowerCase() === '.otio';
|
|
204
|
-
}
|
|
1
|
+
import { spawnSync } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { closeSync, existsSync, lstatSync, openSync, readFileSync, readSync, statSync } from 'node:fs';
|
|
4
|
+
import { extname, join, resolve } from 'node:path';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import { registerMediaAsset } from '../project/assets.mjs';
|
|
7
|
+
import { validateTimeline } from '../timeline/validate.mjs';
|
|
8
|
+
|
|
9
|
+
const bridgePath = fileURLToPath(new URL('./otio_bridge.py', import.meta.url));
|
|
10
|
+
const ENGINE_CLIP_SETTINGS = new Set([
|
|
11
|
+
'audioEffects', 'volumeKeyframes', 'reverse', 'speedRamp',
|
|
12
|
+
'transitionIn', 'transitionOut', 'fadeInSeconds', 'fadeOutSeconds',
|
|
13
|
+
'keyframes', 'fit', 'x', 'y', 'width', 'height',
|
|
14
|
+
'objectPositionX', 'objectPositionY', 'zoom', 'mask', 'blendMode',
|
|
15
|
+
'brightness', 'contrast', 'saturation', 'blurPixels', 'hueDegrees',
|
|
16
|
+
'audioStreamIndex', 'videoStreamIndex', 'opacity', 'volume',
|
|
17
|
+
]);
|
|
18
|
+
const ENGINE_TRACK_SETTINGS = new Set(['audioEffects', 'volumeKeyframes', 'volume']);
|
|
19
|
+
|
|
20
|
+
function runBridge(request) {
|
|
21
|
+
const python = process.env.AIVE_OTIO_PYTHON || process.env.PYTHON || 'python';
|
|
22
|
+
const result = spawnSync(python, [bridgePath], {
|
|
23
|
+
input: JSON.stringify(request),
|
|
24
|
+
encoding: 'utf8',
|
|
25
|
+
windowsHide: true,
|
|
26
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
27
|
+
});
|
|
28
|
+
if (result.error) {
|
|
29
|
+
throw new Error(`Could not start the OpenTimelineIO bridge with ${python}: ${result.error.message}. Install the optional dependency with "python -m pip install -r requirements-otio.txt" or set AIVE_OTIO_PYTHON.`);
|
|
30
|
+
}
|
|
31
|
+
if (result.status !== 0) {
|
|
32
|
+
const details = result.stderr.trim() || result.stdout.trim();
|
|
33
|
+
if (/No module named ['"]?opentimelineio/.test(details)) {
|
|
34
|
+
throw new Error('OpenTimelineIO is not installed. Install the optional dependency with "python -m pip install -r requirements-otio.txt" or set AIVE_OTIO_PYTHON.');
|
|
35
|
+
}
|
|
36
|
+
throw new Error(`OpenTimelineIO interchange failed: ${details || `Python exited with status ${result.status}`}`);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(result.stdout).result;
|
|
40
|
+
} catch (error) {
|
|
41
|
+
throw new Error(`OpenTimelineIO bridge returned invalid JSON: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function exportTimelineToOtio(projectPath, timeline) {
|
|
46
|
+
validateTimeline(timeline);
|
|
47
|
+
return runBridge({ operation: 'export', projectRoot: resolve(projectPath), timeline });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function importTimelineFromOtio(projectPath, otioPath) {
|
|
51
|
+
const root = resolve(projectPath);
|
|
52
|
+
const input = resolve(otioPath);
|
|
53
|
+
const metadataPath = join(root, 'meta.json');
|
|
54
|
+
if (!existsSync(metadataPath)) throw new Error(`Not an Engine project: ${root}`);
|
|
55
|
+
const imported = runBridge({ operation: 'import', otioPath: input, otioText: readFileSync(input, 'utf8') });
|
|
56
|
+
const projectMeta = JSON.parse(readFileSync(metadataPath, 'utf8'));
|
|
57
|
+
const original = imported.engineTimeline;
|
|
58
|
+
const assets = original ? structuredClone(original.assets) : [];
|
|
59
|
+
const tracks = [];
|
|
60
|
+
const registered = new Map();
|
|
61
|
+
const referenceMatches = new Map();
|
|
62
|
+
const usedTrackIds = new Set();
|
|
63
|
+
const findExisting = (mediaPath) => assets.find((asset) => resolve(root, asset.path) === resolve(mediaPath));
|
|
64
|
+
const usedIds = new Set(assets.map((asset) => asset.id));
|
|
65
|
+
|
|
66
|
+
for (const sourceTrack of imported.tracks) {
|
|
67
|
+
const engineTrack = original?.tracks.find((track) => track.id === sourceTrack.engineTrackId);
|
|
68
|
+
const trackId = uniqueId(engineTrack?.id ?? safeId(sourceTrack.name, `${sourceTrack.kind}-track-${sourceTrack.order + 1}`), usedTrackIds);
|
|
69
|
+
const usedClipIds = new Set();
|
|
70
|
+
const clips = [];
|
|
71
|
+
const hasCrossfades = sourceTrack.clips.some((clip) => clip.crossfade);
|
|
72
|
+
const trackState = sourceTrack.kind === 'audio'
|
|
73
|
+
? {
|
|
74
|
+
...(sourceTrack.muted !== undefined
|
|
75
|
+
? { muted: sourceTrack.muted }
|
|
76
|
+
: sourceTrack.enabled === false && sourceTrack.solo !== true ? { muted: true } : engineTrack?.muted === true ? { muted: false } : {}),
|
|
77
|
+
...(sourceTrack.solo === undefined ? {} : { solo: sourceTrack.solo }),
|
|
78
|
+
...(sourceTrack.locked === undefined ? {} : { locked: sourceTrack.locked }),
|
|
79
|
+
}
|
|
80
|
+
: {
|
|
81
|
+
...(sourceTrack.enabled === false ? { visible: false } : engineTrack?.visible === false ? { visible: true } : {}),
|
|
82
|
+
...(sourceTrack.locked === undefined ? {} : { locked: sourceTrack.locked }),
|
|
83
|
+
};
|
|
84
|
+
for (const sourceClip of sourceTrack.clips) {
|
|
85
|
+
if (!existsSync(sourceClip.path)) throw new Error(`OTIO clip media is missing: ${sourceClip.path}`);
|
|
86
|
+
const engineAsset = original?.assets.find((asset) => asset.id === sourceClip.engineAssetId);
|
|
87
|
+
let asset = findExisting(sourceClip.path);
|
|
88
|
+
if (!asset && engineAsset && assetMatchesReference(root, engineAsset, sourceClip.path, referenceMatches)) {
|
|
89
|
+
asset = assets.find((item) => item.id === engineAsset.id);
|
|
90
|
+
}
|
|
91
|
+
if (!asset) {
|
|
92
|
+
const preferredId = engineAsset?.id ?? safeId(sourceClip.name, `media-${clips.length + 1}`);
|
|
93
|
+
const id = uniqueId(preferredId, usedIds);
|
|
94
|
+
asset = registered.get(resolve(sourceClip.path));
|
|
95
|
+
if (!asset) {
|
|
96
|
+
asset = registerMediaAsset(root, sourceClip.path, { id });
|
|
97
|
+
registered.set(resolve(sourceClip.path), asset);
|
|
98
|
+
assets.push(asset);
|
|
99
|
+
usedIds.add(asset.id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
const priorClip = sourceClip.crossfade ? {} : { ...(engineTrack?.clips.find((clip) => clip.id === sourceClip.engineClipId) ?? {}) };
|
|
103
|
+
delete priorClip.playbackRate;
|
|
104
|
+
delete priorClip.speedRamp;
|
|
105
|
+
delete priorClip.reverse;
|
|
106
|
+
for (const [key, value] of Object.entries(sourceClip.engineSettings ?? {})) {
|
|
107
|
+
if (ENGINE_CLIP_SETTINGS.has(key)) priorClip[key] = value;
|
|
108
|
+
}
|
|
109
|
+
const clip = {
|
|
110
|
+
...priorClip,
|
|
111
|
+
id: uniqueId(priorClip.id ?? safeId(sourceClip.name, `${sourceTrack.kind}-clip-${clips.length + 1}`), usedClipIds),
|
|
112
|
+
assetId: asset.id,
|
|
113
|
+
sourceIn: sourceClip.sourceIn,
|
|
114
|
+
sourceOut: sourceClip.sourceOut,
|
|
115
|
+
timelineStart: sourceClip.timelineStart,
|
|
116
|
+
durationSeconds: sourceClip.durationSeconds,
|
|
117
|
+
...(sourceClip.enabled === false || Object.hasOwn(priorClip, 'enabled') ? { enabled: sourceClip.enabled } : {}),
|
|
118
|
+
...(sourceClip.playbackRate !== undefined ? { playbackRate: sourceClip.playbackRate } : {}),
|
|
119
|
+
...(sourceClip.crossfade?.role === 'incoming' ? { fadeInSeconds: sourceClip.crossfade.durationSeconds } : {}),
|
|
120
|
+
...(sourceClip.crossfade?.role === 'outgoing' && sourceTrack.kind === 'audio' ? { fadeOutSeconds: sourceClip.crossfade.durationSeconds } : {}),
|
|
121
|
+
};
|
|
122
|
+
clips.push(clip);
|
|
123
|
+
}
|
|
124
|
+
const importedTrackSettings = Object.fromEntries(Object.entries(sourceTrack.engineSettings ?? {}).filter(([key]) => ENGINE_TRACK_SETTINGS.has(key)));
|
|
125
|
+
tracks.push({
|
|
126
|
+
...(engineTrack ?? {}),
|
|
127
|
+
...importedTrackSettings,
|
|
128
|
+
id: trackId,
|
|
129
|
+
kind: sourceTrack.kind,
|
|
130
|
+
order: sourceTrack.order,
|
|
131
|
+
...(hasCrossfades ? { allowOverlap: true } : {}),
|
|
132
|
+
...trackState,
|
|
133
|
+
clips,
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
for (const track of original?.tracks ?? []) {
|
|
138
|
+
if (track.kind !== 'overlay') continue;
|
|
139
|
+
tracks.push({ ...track, order: tracks.length, id: uniqueId(track.id, usedTrackIds) });
|
|
140
|
+
}
|
|
141
|
+
tracks.forEach((track, order) => { track.order = order; });
|
|
142
|
+
const durationSeconds = Math.max(
|
|
143
|
+
original?.project.durationSeconds ?? projectMeta.durationSeconds,
|
|
144
|
+
imported.durationSeconds ?? 0,
|
|
145
|
+
...tracks.flatMap((track) => track.clips.map((clip) => clip.timelineStart + clip.durationSeconds)),
|
|
146
|
+
0.001,
|
|
147
|
+
);
|
|
148
|
+
const timeline = {
|
|
149
|
+
...(original ?? {}),
|
|
150
|
+
schemaVersion: 1,
|
|
151
|
+
project: { ...(original?.project ?? projectMeta), durationSeconds },
|
|
152
|
+
assets,
|
|
153
|
+
tracks,
|
|
154
|
+
...(imported.markers.length || Object.hasOwn(original ?? {}, 'markers') ? { markers: imported.markers } : {}),
|
|
155
|
+
};
|
|
156
|
+
validateTimeline(timeline);
|
|
157
|
+
return timeline;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function assetMatchesReference(root, asset, sourcePath, cache) {
|
|
161
|
+
const key = `${asset.id}\0${resolve(sourcePath)}`;
|
|
162
|
+
if (cache.has(key)) return cache.get(key);
|
|
163
|
+
const registeredPath = resolve(root, asset.path);
|
|
164
|
+
if (!existsSync(registeredPath) || !existsSync(sourcePath)
|
|
165
|
+
|| !statSync(registeredPath).isFile() || !statSync(sourcePath).isFile()
|
|
166
|
+
|| lstatSync(registeredPath).isSymbolicLink()) {
|
|
167
|
+
cache.set(key, false);
|
|
168
|
+
return false;
|
|
169
|
+
}
|
|
170
|
+
const matches = hashFile(registeredPath) === asset.sha256 && hashFile(sourcePath) === asset.sha256;
|
|
171
|
+
cache.set(key, matches);
|
|
172
|
+
return matches;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function hashFile(path) {
|
|
176
|
+
const hash = createHash('sha256');
|
|
177
|
+
const descriptor = openSync(path, 'r');
|
|
178
|
+
const buffer = Buffer.allocUnsafe(1024 * 1024);
|
|
179
|
+
try {
|
|
180
|
+
let bytes;
|
|
181
|
+
while ((bytes = readSync(descriptor, buffer, 0, buffer.length, null)) > 0) hash.update(buffer.subarray(0, bytes));
|
|
182
|
+
} finally {
|
|
183
|
+
closeSync(descriptor);
|
|
184
|
+
}
|
|
185
|
+
return hash.digest('hex');
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function safeId(value, fallback) {
|
|
189
|
+
const id = String(value ?? '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
190
|
+
return /^[a-z]/.test(id) ? id : fallback;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function uniqueId(requested, used) {
|
|
194
|
+
const base = safeId(requested, 'media');
|
|
195
|
+
let id = base;
|
|
196
|
+
let suffix = 2;
|
|
197
|
+
while (used.has(id)) id = `${base}-${suffix++}`;
|
|
198
|
+
used.add(id);
|
|
199
|
+
return id;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function isOtioPath(path) {
|
|
203
|
+
return extname(path).toLowerCase() === '.otio';
|
|
204
|
+
}
|