aivee 0.1.0-alpha.0 → 0.1.0-dev.59872e1
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/CHANGELOG.md +34 -0
- 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,158 +1,158 @@
|
|
|
1
|
-
import { gsap } from 'gsap';
|
|
2
|
-
|
|
3
|
-
const PROPERTIES = new Set(['x', 'y', 'width', 'height', 'opacity', 'rotationDegrees', 'objectPositionX', 'objectPositionY', 'zoom', 'maskProgress', 'brightness', 'contrast', 'saturation', 'blurPixels', 'hueDegrees']);
|
|
4
|
-
const EASES = new Set([
|
|
5
|
-
'none', 'power1.in', 'power1.out', 'power1.inOut', 'power2.in', 'power2.out', 'power2.inOut',
|
|
6
|
-
'power3.in', 'power3.out', 'power3.inOut', 'power4.in', 'power4.out', 'power4.inOut',
|
|
7
|
-
'sine.in', 'sine.out', 'sine.inOut', 'back.in', 'back.out', 'back.inOut',
|
|
8
|
-
]);
|
|
9
|
-
|
|
10
|
-
export function validateVisualKeyframes(track, clip) {
|
|
11
|
-
if (clip.keyframes === undefined) return;
|
|
12
|
-
if (track.kind !== 'video' && track.kind !== 'overlay') {
|
|
13
|
-
throw new TypeError(`Clip ${clip.id} keyframes are supported on video and overlay clips.`);
|
|
14
|
-
}
|
|
15
|
-
if (!Array.isArray(clip.keyframes) || clip.keyframes.length === 0) throw new TypeError(`Clip ${clip.id} keyframes must be a non-empty array.`);
|
|
16
|
-
if (clip.keyframes[0]?.time !== 0) throw new TypeError(`Clip ${clip.id} keyframes must start at time 0.`);
|
|
17
|
-
if (clip.keyframes.some((frame) => frame?.properties?.opacity !== undefined) && ((clip.fadeInSeconds ?? 0) > 0 || (clip.fadeOutSeconds ?? 0) > 0)) {
|
|
18
|
-
throw new TypeError(`Clip ${clip.id} cannot combine opacity keyframes with fadeInSeconds or fadeOutSeconds.`);
|
|
19
|
-
}
|
|
20
|
-
const hasMaskProgress = clip.keyframes.some((frame) => frame?.properties?.maskProgress !== undefined);
|
|
21
|
-
if (hasMaskProgress && clip.mask === undefined) throw new TypeError(`Clip ${clip.id} maskProgress keyframes require a static mask.`);
|
|
22
|
-
if (hasMaskProgress && clip.keyframes[0]?.properties?.maskProgress === undefined) throw new TypeError(`Clip ${clip.id} maskProgress keyframes must set a value at time 0.`);
|
|
23
|
-
|
|
24
|
-
const defaults = track.kind === 'video' || typeof clip.text !== 'string'
|
|
25
|
-
? { x: clip.x ?? 0, y: clip.y ?? 0, width: clip.width ?? 1, height: clip.height ?? 1 }
|
|
26
|
-
: { x: clip.x ?? 0.08, y: clip.y ?? 0.76, width: clip.width ?? 0.84, height: clip.height ?? 0.16 };
|
|
27
|
-
let previousTime = -1;
|
|
28
|
-
const current = { ...defaults };
|
|
29
|
-
for (const [index, frame] of clip.keyframes.entries()) {
|
|
30
|
-
if (!frame || typeof frame !== 'object' || Array.isArray(frame) || !Number.isFinite(frame.time) || frame.time < 0 || frame.time > clip.durationSeconds) {
|
|
31
|
-
throw new TypeError(`Clip ${clip.id} keyframe ${index} time must be within the clip duration.`);
|
|
32
|
-
}
|
|
33
|
-
if (frame.time <= previousTime) throw new TypeError(`Clip ${clip.id} keyframe times must be strictly increasing.`);
|
|
34
|
-
if (!frame.properties || typeof frame.properties !== 'object' || Array.isArray(frame.properties) || Object.keys(frame.properties).length === 0) {
|
|
35
|
-
throw new TypeError(`Clip ${clip.id} keyframe ${index} needs at least one property.`);
|
|
36
|
-
}
|
|
37
|
-
for (const [property, value] of Object.entries(frame.properties)) {
|
|
38
|
-
if (!PROPERTIES.has(property) || !Number.isFinite(value)) throw new TypeError(`Clip ${clip.id} keyframe ${index} has an unsupported or non-finite property: ${property}.`);
|
|
39
|
-
if (property === 'opacity' && (value < 0 || value > 1)) throw new TypeError(`Clip ${clip.id} keyframe opacity must be between 0 and 1.`);
|
|
40
|
-
if (property === 'rotationDegrees' && Math.abs(value) > 3600) throw new TypeError(`Clip ${clip.id} keyframe rotationDegrees must be between -3600 and 3600.`);
|
|
41
|
-
if (['objectPositionX', 'objectPositionY'].includes(property) && (track.kind !== 'video' || value < 0 || value > 1)) {
|
|
42
|
-
throw new TypeError(`Clip ${clip.id} keyframe ${property} must be between 0 and 1 on a video track.`);
|
|
43
|
-
}
|
|
44
|
-
if (property === 'zoom' && (track.kind !== 'video' || value < 1 || value > 4)) {
|
|
45
|
-
throw new TypeError(`Clip ${clip.id} keyframe zoom must be between 1 and 4 on a video track.`);
|
|
46
|
-
}
|
|
47
|
-
if (property === 'maskProgress' && (clip.mask === undefined || value < 0 || value > 1)) {
|
|
48
|
-
throw new TypeError(`Clip ${clip.id} keyframe maskProgress must be between 0 and 1 and requires a static mask.`);
|
|
49
|
-
}
|
|
50
|
-
if (['brightness', 'contrast', 'saturation'].includes(property) && (value < 0 || value > 5)) throw new TypeError(`Clip ${clip.id} keyframe ${property} must be between 0 and 5.`);
|
|
51
|
-
if (property === 'blurPixels' && (value < 0 || value > 100)) throw new TypeError(`Clip ${clip.id} keyframe blurPixels must be between 0 and 100.`);
|
|
52
|
-
if (property === 'hueDegrees' && Math.abs(value) > 360) throw new TypeError(`Clip ${clip.id} keyframe hueDegrees must be between -360 and 360.`);
|
|
53
|
-
if (['width', 'height'].includes(property) && value <= 0) throw new TypeError(`Clip ${clip.id} keyframe ${property} must be positive.`);
|
|
54
|
-
current[property] = value;
|
|
55
|
-
}
|
|
56
|
-
if (![current.x, current.y, current.width, current.height].every(Number.isFinite) || current.x < 0 || current.y < 0 || current.width <= 0 || current.height <= 0 || current.x + current.width > 1 || current.y + current.height > 1) {
|
|
57
|
-
throw new TypeError(`Clip ${clip.id} keyframe placement must fit within normalized canvas bounds.`);
|
|
58
|
-
}
|
|
59
|
-
if (frame.ease !== undefined) validateEase(frame.ease, `Clip ${clip.id} keyframe ease`);
|
|
60
|
-
if (frame.eases !== undefined) {
|
|
61
|
-
if (!frame.eases || typeof frame.eases !== 'object' || Array.isArray(frame.eases)) throw new TypeError(`Clip ${clip.id} keyframe eases must be an object.`);
|
|
62
|
-
for (const [property, ease] of Object.entries(frame.eases)) {
|
|
63
|
-
if (!Object.hasOwn(frame.properties, property)) throw new TypeError(`Clip ${clip.id} keyframe ease ${property} needs a matching property.`);
|
|
64
|
-
validateEase(ease, `Clip ${clip.id} keyframe ease ${property}`);
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
previousTime = frame.time;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
export function sliceVisualKeyframes(keyframes, start, end) {
|
|
72
|
-
if (!keyframes) return undefined;
|
|
73
|
-
const properties = new Set(keyframes.flatMap((frame) => Object.keys(frame.properties)));
|
|
74
|
-
const frames = new Map();
|
|
75
|
-
const add = (time, property, value, ease) => {
|
|
76
|
-
const frame = frames.get(time) ?? { time, properties: {}, eases: {} };
|
|
77
|
-
frame.properties[property] = value;
|
|
78
|
-
if (ease !== undefined) frame.eases[property] = ease;
|
|
79
|
-
frames.set(time, frame);
|
|
80
|
-
};
|
|
81
|
-
|
|
82
|
-
for (const property of properties) {
|
|
83
|
-
const points = keyframes.filter((frame) => Object.hasOwn(frame.properties, property));
|
|
84
|
-
const initial = sampleProperty(points, property, start);
|
|
85
|
-
if (initial !== undefined) add(0, property, initial);
|
|
86
|
-
|
|
87
|
-
for (let index = 0; index < points.length; index++) {
|
|
88
|
-
const point = points[index];
|
|
89
|
-
if (point.time <= start || point.time > end) continue;
|
|
90
|
-
const prior = points[index - 1];
|
|
91
|
-
const ease = prior && point.time > start
|
|
92
|
-
? sliceEase(point.eases?.[property] ?? point.ease ?? 'none', Math.max(0, (start - prior.time) / (point.time - prior.time)), 1)
|
|
93
|
-
: point.eases?.[property] ?? point.ease;
|
|
94
|
-
add(point.time - start, property, point.properties[property], ease);
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
const ending = sampleProperty(points, property, end);
|
|
98
|
-
if (ending !== undefined && !points.some((point) => point.time === end)) {
|
|
99
|
-
const priorIndex = points.findLastIndex((point) => point.time < end);
|
|
100
|
-
const prior = points[priorIndex];
|
|
101
|
-
const next = points[priorIndex + 1];
|
|
102
|
-
if (prior && next) {
|
|
103
|
-
const ease = next.eases?.[property] ?? next.ease ?? 'none';
|
|
104
|
-
const from = Math.max(start, prior.time);
|
|
105
|
-
const to = end;
|
|
106
|
-
add(end - start, property, ending, sliceEase(ease, (from - prior.time) / (next.time - prior.time), (to - prior.time) / (next.time - prior.time)));
|
|
107
|
-
}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
return [...frames.values()]
|
|
112
|
-
.sort((left, right) => left.time - right.time)
|
|
113
|
-
.map((frame) => {
|
|
114
|
-
if (Object.keys(frame.eases).length === 0) delete frame.eases;
|
|
115
|
-
return frame;
|
|
116
|
-
});
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
function sampleProperty(points, property, time) {
|
|
120
|
-
const priorIndex = points.findLastIndex((point) => point.time <= time);
|
|
121
|
-
if (priorIndex < 0) return undefined;
|
|
122
|
-
const prior = points[priorIndex];
|
|
123
|
-
const next = points[priorIndex + 1];
|
|
124
|
-
if (!next) return prior.properties[property];
|
|
125
|
-
const progress = (time - prior.time) / (next.time - prior.time);
|
|
126
|
-
const ease = next.eases?.[property] ?? next.ease ?? 'none';
|
|
127
|
-
return prior.properties[property] + (next.properties[property] - prior.properties[property]) * easeProgress(ease, progress);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function sliceEase(ease, from, to) {
|
|
131
|
-
if (typeof ease === 'string') {
|
|
132
|
-
if (from === 0 && to === 1) return ease;
|
|
133
|
-
return { type: 'range', name: ease, from, to };
|
|
134
|
-
}
|
|
135
|
-
return {
|
|
136
|
-
type: 'range', name: ease.name,
|
|
137
|
-
from: ease.from + (ease.to - ease.from) * from,
|
|
138
|
-
to: ease.from + (ease.to - ease.from) * to,
|
|
139
|
-
};
|
|
140
|
-
}
|
|
141
|
-
|
|
142
|
-
function easeProgress(ease, progress) {
|
|
143
|
-
if (typeof ease === 'string') return gsap.parseEase(ease)(progress);
|
|
144
|
-
const fn = gsap.parseEase(ease.name);
|
|
145
|
-
const from = fn(ease.from), to = fn(ease.to);
|
|
146
|
-
return (fn(ease.from + (ease.to - ease.from) * progress) - from) / (to - from);
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
function validateEase(ease, label) {
|
|
150
|
-
if (typeof ease === 'string' && EASES.has(ease)) return;
|
|
151
|
-
if (!ease || typeof ease !== 'object' || Array.isArray(ease) || ease.type !== 'range' || !EASES.has(ease.name)
|
|
152
|
-
|| Object.keys(ease).some((key) => !['type', 'name', 'from', 'to'].includes(key))
|
|
153
|
-
|| !Number.isFinite(ease.from) || !Number.isFinite(ease.to) || ease.from < 0 || ease.to > 1 || ease.from >= ease.to) {
|
|
154
|
-
throw new TypeError(`${label} is unsupported.`);
|
|
155
|
-
}
|
|
156
|
-
const fn = gsap.parseEase(ease.name);
|
|
157
|
-
if (Math.abs(fn(ease.to) - fn(ease.from)) < 1e-9) throw new TypeError(`${label} has no progress across its range.`);
|
|
158
|
-
}
|
|
1
|
+
import { gsap } from 'gsap';
|
|
2
|
+
|
|
3
|
+
const PROPERTIES = new Set(['x', 'y', 'width', 'height', 'opacity', 'rotationDegrees', 'objectPositionX', 'objectPositionY', 'zoom', 'maskProgress', 'brightness', 'contrast', 'saturation', 'blurPixels', 'hueDegrees']);
|
|
4
|
+
const EASES = new Set([
|
|
5
|
+
'none', 'power1.in', 'power1.out', 'power1.inOut', 'power2.in', 'power2.out', 'power2.inOut',
|
|
6
|
+
'power3.in', 'power3.out', 'power3.inOut', 'power4.in', 'power4.out', 'power4.inOut',
|
|
7
|
+
'sine.in', 'sine.out', 'sine.inOut', 'back.in', 'back.out', 'back.inOut',
|
|
8
|
+
]);
|
|
9
|
+
|
|
10
|
+
export function validateVisualKeyframes(track, clip) {
|
|
11
|
+
if (clip.keyframes === undefined) return;
|
|
12
|
+
if (track.kind !== 'video' && track.kind !== 'overlay') {
|
|
13
|
+
throw new TypeError(`Clip ${clip.id} keyframes are supported on video and overlay clips.`);
|
|
14
|
+
}
|
|
15
|
+
if (!Array.isArray(clip.keyframes) || clip.keyframes.length === 0) throw new TypeError(`Clip ${clip.id} keyframes must be a non-empty array.`);
|
|
16
|
+
if (clip.keyframes[0]?.time !== 0) throw new TypeError(`Clip ${clip.id} keyframes must start at time 0.`);
|
|
17
|
+
if (clip.keyframes.some((frame) => frame?.properties?.opacity !== undefined) && ((clip.fadeInSeconds ?? 0) > 0 || (clip.fadeOutSeconds ?? 0) > 0)) {
|
|
18
|
+
throw new TypeError(`Clip ${clip.id} cannot combine opacity keyframes with fadeInSeconds or fadeOutSeconds.`);
|
|
19
|
+
}
|
|
20
|
+
const hasMaskProgress = clip.keyframes.some((frame) => frame?.properties?.maskProgress !== undefined);
|
|
21
|
+
if (hasMaskProgress && clip.mask === undefined) throw new TypeError(`Clip ${clip.id} maskProgress keyframes require a static mask.`);
|
|
22
|
+
if (hasMaskProgress && clip.keyframes[0]?.properties?.maskProgress === undefined) throw new TypeError(`Clip ${clip.id} maskProgress keyframes must set a value at time 0.`);
|
|
23
|
+
|
|
24
|
+
const defaults = track.kind === 'video' || typeof clip.text !== 'string'
|
|
25
|
+
? { x: clip.x ?? 0, y: clip.y ?? 0, width: clip.width ?? 1, height: clip.height ?? 1 }
|
|
26
|
+
: { x: clip.x ?? 0.08, y: clip.y ?? 0.76, width: clip.width ?? 0.84, height: clip.height ?? 0.16 };
|
|
27
|
+
let previousTime = -1;
|
|
28
|
+
const current = { ...defaults };
|
|
29
|
+
for (const [index, frame] of clip.keyframes.entries()) {
|
|
30
|
+
if (!frame || typeof frame !== 'object' || Array.isArray(frame) || !Number.isFinite(frame.time) || frame.time < 0 || frame.time > clip.durationSeconds) {
|
|
31
|
+
throw new TypeError(`Clip ${clip.id} keyframe ${index} time must be within the clip duration.`);
|
|
32
|
+
}
|
|
33
|
+
if (frame.time <= previousTime) throw new TypeError(`Clip ${clip.id} keyframe times must be strictly increasing.`);
|
|
34
|
+
if (!frame.properties || typeof frame.properties !== 'object' || Array.isArray(frame.properties) || Object.keys(frame.properties).length === 0) {
|
|
35
|
+
throw new TypeError(`Clip ${clip.id} keyframe ${index} needs at least one property.`);
|
|
36
|
+
}
|
|
37
|
+
for (const [property, value] of Object.entries(frame.properties)) {
|
|
38
|
+
if (!PROPERTIES.has(property) || !Number.isFinite(value)) throw new TypeError(`Clip ${clip.id} keyframe ${index} has an unsupported or non-finite property: ${property}.`);
|
|
39
|
+
if (property === 'opacity' && (value < 0 || value > 1)) throw new TypeError(`Clip ${clip.id} keyframe opacity must be between 0 and 1.`);
|
|
40
|
+
if (property === 'rotationDegrees' && Math.abs(value) > 3600) throw new TypeError(`Clip ${clip.id} keyframe rotationDegrees must be between -3600 and 3600.`);
|
|
41
|
+
if (['objectPositionX', 'objectPositionY'].includes(property) && (track.kind !== 'video' || value < 0 || value > 1)) {
|
|
42
|
+
throw new TypeError(`Clip ${clip.id} keyframe ${property} must be between 0 and 1 on a video track.`);
|
|
43
|
+
}
|
|
44
|
+
if (property === 'zoom' && (track.kind !== 'video' || value < 1 || value > 4)) {
|
|
45
|
+
throw new TypeError(`Clip ${clip.id} keyframe zoom must be between 1 and 4 on a video track.`);
|
|
46
|
+
}
|
|
47
|
+
if (property === 'maskProgress' && (clip.mask === undefined || value < 0 || value > 1)) {
|
|
48
|
+
throw new TypeError(`Clip ${clip.id} keyframe maskProgress must be between 0 and 1 and requires a static mask.`);
|
|
49
|
+
}
|
|
50
|
+
if (['brightness', 'contrast', 'saturation'].includes(property) && (value < 0 || value > 5)) throw new TypeError(`Clip ${clip.id} keyframe ${property} must be between 0 and 5.`);
|
|
51
|
+
if (property === 'blurPixels' && (value < 0 || value > 100)) throw new TypeError(`Clip ${clip.id} keyframe blurPixels must be between 0 and 100.`);
|
|
52
|
+
if (property === 'hueDegrees' && Math.abs(value) > 360) throw new TypeError(`Clip ${clip.id} keyframe hueDegrees must be between -360 and 360.`);
|
|
53
|
+
if (['width', 'height'].includes(property) && value <= 0) throw new TypeError(`Clip ${clip.id} keyframe ${property} must be positive.`);
|
|
54
|
+
current[property] = value;
|
|
55
|
+
}
|
|
56
|
+
if (![current.x, current.y, current.width, current.height].every(Number.isFinite) || current.x < 0 || current.y < 0 || current.width <= 0 || current.height <= 0 || current.x + current.width > 1 || current.y + current.height > 1) {
|
|
57
|
+
throw new TypeError(`Clip ${clip.id} keyframe placement must fit within normalized canvas bounds.`);
|
|
58
|
+
}
|
|
59
|
+
if (frame.ease !== undefined) validateEase(frame.ease, `Clip ${clip.id} keyframe ease`);
|
|
60
|
+
if (frame.eases !== undefined) {
|
|
61
|
+
if (!frame.eases || typeof frame.eases !== 'object' || Array.isArray(frame.eases)) throw new TypeError(`Clip ${clip.id} keyframe eases must be an object.`);
|
|
62
|
+
for (const [property, ease] of Object.entries(frame.eases)) {
|
|
63
|
+
if (!Object.hasOwn(frame.properties, property)) throw new TypeError(`Clip ${clip.id} keyframe ease ${property} needs a matching property.`);
|
|
64
|
+
validateEase(ease, `Clip ${clip.id} keyframe ease ${property}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
previousTime = frame.time;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function sliceVisualKeyframes(keyframes, start, end) {
|
|
72
|
+
if (!keyframes) return undefined;
|
|
73
|
+
const properties = new Set(keyframes.flatMap((frame) => Object.keys(frame.properties)));
|
|
74
|
+
const frames = new Map();
|
|
75
|
+
const add = (time, property, value, ease) => {
|
|
76
|
+
const frame = frames.get(time) ?? { time, properties: {}, eases: {} };
|
|
77
|
+
frame.properties[property] = value;
|
|
78
|
+
if (ease !== undefined) frame.eases[property] = ease;
|
|
79
|
+
frames.set(time, frame);
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
for (const property of properties) {
|
|
83
|
+
const points = keyframes.filter((frame) => Object.hasOwn(frame.properties, property));
|
|
84
|
+
const initial = sampleProperty(points, property, start);
|
|
85
|
+
if (initial !== undefined) add(0, property, initial);
|
|
86
|
+
|
|
87
|
+
for (let index = 0; index < points.length; index++) {
|
|
88
|
+
const point = points[index];
|
|
89
|
+
if (point.time <= start || point.time > end) continue;
|
|
90
|
+
const prior = points[index - 1];
|
|
91
|
+
const ease = prior && point.time > start
|
|
92
|
+
? sliceEase(point.eases?.[property] ?? point.ease ?? 'none', Math.max(0, (start - prior.time) / (point.time - prior.time)), 1)
|
|
93
|
+
: point.eases?.[property] ?? point.ease;
|
|
94
|
+
add(point.time - start, property, point.properties[property], ease);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const ending = sampleProperty(points, property, end);
|
|
98
|
+
if (ending !== undefined && !points.some((point) => point.time === end)) {
|
|
99
|
+
const priorIndex = points.findLastIndex((point) => point.time < end);
|
|
100
|
+
const prior = points[priorIndex];
|
|
101
|
+
const next = points[priorIndex + 1];
|
|
102
|
+
if (prior && next) {
|
|
103
|
+
const ease = next.eases?.[property] ?? next.ease ?? 'none';
|
|
104
|
+
const from = Math.max(start, prior.time);
|
|
105
|
+
const to = end;
|
|
106
|
+
add(end - start, property, ending, sliceEase(ease, (from - prior.time) / (next.time - prior.time), (to - prior.time) / (next.time - prior.time)));
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return [...frames.values()]
|
|
112
|
+
.sort((left, right) => left.time - right.time)
|
|
113
|
+
.map((frame) => {
|
|
114
|
+
if (Object.keys(frame.eases).length === 0) delete frame.eases;
|
|
115
|
+
return frame;
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function sampleProperty(points, property, time) {
|
|
120
|
+
const priorIndex = points.findLastIndex((point) => point.time <= time);
|
|
121
|
+
if (priorIndex < 0) return undefined;
|
|
122
|
+
const prior = points[priorIndex];
|
|
123
|
+
const next = points[priorIndex + 1];
|
|
124
|
+
if (!next) return prior.properties[property];
|
|
125
|
+
const progress = (time - prior.time) / (next.time - prior.time);
|
|
126
|
+
const ease = next.eases?.[property] ?? next.ease ?? 'none';
|
|
127
|
+
return prior.properties[property] + (next.properties[property] - prior.properties[property]) * easeProgress(ease, progress);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function sliceEase(ease, from, to) {
|
|
131
|
+
if (typeof ease === 'string') {
|
|
132
|
+
if (from === 0 && to === 1) return ease;
|
|
133
|
+
return { type: 'range', name: ease, from, to };
|
|
134
|
+
}
|
|
135
|
+
return {
|
|
136
|
+
type: 'range', name: ease.name,
|
|
137
|
+
from: ease.from + (ease.to - ease.from) * from,
|
|
138
|
+
to: ease.from + (ease.to - ease.from) * to,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function easeProgress(ease, progress) {
|
|
143
|
+
if (typeof ease === 'string') return gsap.parseEase(ease)(progress);
|
|
144
|
+
const fn = gsap.parseEase(ease.name);
|
|
145
|
+
const from = fn(ease.from), to = fn(ease.to);
|
|
146
|
+
return (fn(ease.from + (ease.to - ease.from) * progress) - from) / (to - from);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function validateEase(ease, label) {
|
|
150
|
+
if (typeof ease === 'string' && EASES.has(ease)) return;
|
|
151
|
+
if (!ease || typeof ease !== 'object' || Array.isArray(ease) || ease.type !== 'range' || !EASES.has(ease.name)
|
|
152
|
+
|| Object.keys(ease).some((key) => !['type', 'name', 'from', 'to'].includes(key))
|
|
153
|
+
|| !Number.isFinite(ease.from) || !Number.isFinite(ease.to) || ease.from < 0 || ease.to > 1 || ease.from >= ease.to) {
|
|
154
|
+
throw new TypeError(`${label} is unsupported.`);
|
|
155
|
+
}
|
|
156
|
+
const fn = gsap.parseEase(ease.name);
|
|
157
|
+
if (Math.abs(fn(ease.to) - fn(ease.from)) < 1e-9) throw new TypeError(`${label} has no progress across its range.`);
|
|
158
|
+
}
|
package/src/timeline/layers.mjs
CHANGED
|
@@ -1,18 +1,18 @@
|
|
|
1
|
-
/** Assign renderer layer indices while preserving track order and clip overlap. */
|
|
2
|
-
export function assignRenderTrackIndices(timeline) {
|
|
3
|
-
const orderedTracks = [...timeline.tracks].sort((a, b) => a.order - b.order);
|
|
4
|
-
const indices = new Map();
|
|
5
|
-
let baseIndex = 0;
|
|
6
|
-
for (const track of orderedTracks) {
|
|
7
|
-
const laneEnds = [];
|
|
8
|
-
const clips = [...track.clips].sort((a, b) => a.timelineStart - b.timelineStart || a.id.localeCompare(b.id));
|
|
9
|
-
for (const clip of clips) {
|
|
10
|
-
let lane = laneEnds.findIndex((end) => end <= clip.timelineStart + 1e-6);
|
|
11
|
-
if (lane < 0) lane = laneEnds.length;
|
|
12
|
-
laneEnds[lane] = clip.timelineStart + clip.durationSeconds;
|
|
13
|
-
indices.set(`${track.id}/${clip.id}`, baseIndex + lane);
|
|
14
|
-
}
|
|
15
|
-
baseIndex += Math.max(1, laneEnds.length);
|
|
16
|
-
}
|
|
17
|
-
return indices;
|
|
18
|
-
}
|
|
1
|
+
/** Assign renderer layer indices while preserving track order and clip overlap. */
|
|
2
|
+
export function assignRenderTrackIndices(timeline) {
|
|
3
|
+
const orderedTracks = [...timeline.tracks].sort((a, b) => a.order - b.order);
|
|
4
|
+
const indices = new Map();
|
|
5
|
+
let baseIndex = 0;
|
|
6
|
+
for (const track of orderedTracks) {
|
|
7
|
+
const laneEnds = [];
|
|
8
|
+
const clips = [...track.clips].sort((a, b) => a.timelineStart - b.timelineStart || a.id.localeCompare(b.id));
|
|
9
|
+
for (const clip of clips) {
|
|
10
|
+
let lane = laneEnds.findIndex((end) => end <= clip.timelineStart + 1e-6);
|
|
11
|
+
if (lane < 0) lane = laneEnds.length;
|
|
12
|
+
laneEnds[lane] = clip.timelineStart + clip.durationSeconds;
|
|
13
|
+
indices.set(`${track.id}/${clip.id}`, baseIndex + lane);
|
|
14
|
+
}
|
|
15
|
+
baseIndex += Math.max(1, laneEnds.length);
|
|
16
|
+
}
|
|
17
|
+
return indices;
|
|
18
|
+
}
|
package/src/timeline/mask.mjs
CHANGED
|
@@ -1,53 +1,53 @@
|
|
|
1
|
-
const SHAPES = new Set(['circle', 'ellipse', 'rect']);
|
|
2
|
-
|
|
3
|
-
export function validateVisualMask(mask, label = 'Visual mask') {
|
|
4
|
-
if (!mask || typeof mask !== 'object' || Array.isArray(mask) || !SHAPES.has(mask.type)) {
|
|
5
|
-
throw new TypeError(`${label} type must be circle, ellipse, or rect.`);
|
|
6
|
-
}
|
|
7
|
-
const fields = mask.type === 'circle'
|
|
8
|
-
? ['type', 'centerX', 'centerY', 'radius']
|
|
9
|
-
: mask.type === 'ellipse'
|
|
10
|
-
? ['type', 'centerX', 'centerY', 'radiusX', 'radiusY']
|
|
11
|
-
: ['type', 'x', 'y', 'width', 'height'];
|
|
12
|
-
if (Object.keys(mask).some((field) => !fields.includes(field))) {
|
|
13
|
-
throw new TypeError(`${label} contains unsupported fields for ${mask.type}.`);
|
|
14
|
-
}
|
|
15
|
-
const values = fields.filter((field) => field !== 'type').map((field) => mask[field]);
|
|
16
|
-
if (!values.every((value) => Number.isFinite(value) && value >= 0 && value <= 1)) {
|
|
17
|
-
throw new TypeError(`${label} values must be finite normalized numbers from 0 to 1.`);
|
|
18
|
-
}
|
|
19
|
-
if (mask.type === 'circle' && mask.radius <= 0) throw new TypeError(`${label} radius must be greater than 0.`);
|
|
20
|
-
if (mask.type === 'ellipse' && (mask.radiusX <= 0 || mask.radiusY <= 0)) throw new TypeError(`${label} radii must be greater than 0.`);
|
|
21
|
-
if (mask.type === 'rect' && (mask.width <= 0 || mask.height <= 0 || mask.x + mask.width > 1 || mask.y + mask.height > 1)) {
|
|
22
|
-
throw new TypeError(`${label} rectangle must have positive size within normalized bounds.`);
|
|
23
|
-
}
|
|
24
|
-
return mask;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
export function visualMaskClipPath(mask, animated = false) {
|
|
28
|
-
validateVisualMask(mask);
|
|
29
|
-
if (mask.type === 'circle') return `circle(${animated ? scaleLength(mask.radius) : percent(mask.radius)} at ${percent(mask.centerX)} ${percent(mask.centerY)})`;
|
|
30
|
-
if (mask.type === 'ellipse') return `ellipse(${animated ? scaleLength(mask.radiusX) : percent(mask.radiusX)} ${animated ? scaleLength(mask.radiusY) : percent(mask.radiusY)} at ${percent(mask.centerX)} ${percent(mask.centerY)})`;
|
|
31
|
-
if (animated) {
|
|
32
|
-
const x1 = scaleFromCenter(mask.x), y1 = scaleFromCenter(mask.y);
|
|
33
|
-
const x2 = scaleFromCenter(mask.x + mask.width), y2 = scaleFromCenter(mask.y + mask.height);
|
|
34
|
-
return `polygon(${x1} ${y1}, ${x2} ${y1}, ${x2} ${y2}, ${x1} ${y2})`;
|
|
35
|
-
}
|
|
36
|
-
const top = percent(mask.y);
|
|
37
|
-
const right = percent(1 - mask.x - mask.width);
|
|
38
|
-
const bottom = percent(1 - mask.y - mask.height);
|
|
39
|
-
const left = percent(mask.x);
|
|
40
|
-
return `inset(${top} ${right} ${bottom} ${left})`;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
function scaleLength(value) {
|
|
44
|
-
return `calc(${percent(value)} * var(--engine-mask-progress, 1))`;
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function scaleFromCenter(value) {
|
|
48
|
-
return `calc(50% + (${percent(value)} - 50%) * var(--engine-mask-progress, 1))`;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function percent(value) {
|
|
52
|
-
return `${Number((value * 100).toFixed(6))}%`;
|
|
53
|
-
}
|
|
1
|
+
const SHAPES = new Set(['circle', 'ellipse', 'rect']);
|
|
2
|
+
|
|
3
|
+
export function validateVisualMask(mask, label = 'Visual mask') {
|
|
4
|
+
if (!mask || typeof mask !== 'object' || Array.isArray(mask) || !SHAPES.has(mask.type)) {
|
|
5
|
+
throw new TypeError(`${label} type must be circle, ellipse, or rect.`);
|
|
6
|
+
}
|
|
7
|
+
const fields = mask.type === 'circle'
|
|
8
|
+
? ['type', 'centerX', 'centerY', 'radius']
|
|
9
|
+
: mask.type === 'ellipse'
|
|
10
|
+
? ['type', 'centerX', 'centerY', 'radiusX', 'radiusY']
|
|
11
|
+
: ['type', 'x', 'y', 'width', 'height'];
|
|
12
|
+
if (Object.keys(mask).some((field) => !fields.includes(field))) {
|
|
13
|
+
throw new TypeError(`${label} contains unsupported fields for ${mask.type}.`);
|
|
14
|
+
}
|
|
15
|
+
const values = fields.filter((field) => field !== 'type').map((field) => mask[field]);
|
|
16
|
+
if (!values.every((value) => Number.isFinite(value) && value >= 0 && value <= 1)) {
|
|
17
|
+
throw new TypeError(`${label} values must be finite normalized numbers from 0 to 1.`);
|
|
18
|
+
}
|
|
19
|
+
if (mask.type === 'circle' && mask.radius <= 0) throw new TypeError(`${label} radius must be greater than 0.`);
|
|
20
|
+
if (mask.type === 'ellipse' && (mask.radiusX <= 0 || mask.radiusY <= 0)) throw new TypeError(`${label} radii must be greater than 0.`);
|
|
21
|
+
if (mask.type === 'rect' && (mask.width <= 0 || mask.height <= 0 || mask.x + mask.width > 1 || mask.y + mask.height > 1)) {
|
|
22
|
+
throw new TypeError(`${label} rectangle must have positive size within normalized bounds.`);
|
|
23
|
+
}
|
|
24
|
+
return mask;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function visualMaskClipPath(mask, animated = false) {
|
|
28
|
+
validateVisualMask(mask);
|
|
29
|
+
if (mask.type === 'circle') return `circle(${animated ? scaleLength(mask.radius) : percent(mask.radius)} at ${percent(mask.centerX)} ${percent(mask.centerY)})`;
|
|
30
|
+
if (mask.type === 'ellipse') return `ellipse(${animated ? scaleLength(mask.radiusX) : percent(mask.radiusX)} ${animated ? scaleLength(mask.radiusY) : percent(mask.radiusY)} at ${percent(mask.centerX)} ${percent(mask.centerY)})`;
|
|
31
|
+
if (animated) {
|
|
32
|
+
const x1 = scaleFromCenter(mask.x), y1 = scaleFromCenter(mask.y);
|
|
33
|
+
const x2 = scaleFromCenter(mask.x + mask.width), y2 = scaleFromCenter(mask.y + mask.height);
|
|
34
|
+
return `polygon(${x1} ${y1}, ${x2} ${y1}, ${x2} ${y2}, ${x1} ${y2})`;
|
|
35
|
+
}
|
|
36
|
+
const top = percent(mask.y);
|
|
37
|
+
const right = percent(1 - mask.x - mask.width);
|
|
38
|
+
const bottom = percent(1 - mask.y - mask.height);
|
|
39
|
+
const left = percent(mask.x);
|
|
40
|
+
return `inset(${top} ${right} ${bottom} ${left})`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function scaleLength(value) {
|
|
44
|
+
return `calc(${percent(value)} * var(--engine-mask-progress, 1))`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function scaleFromCenter(value) {
|
|
48
|
+
return `calc(50% + (${percent(value)} - 50%) * var(--engine-mask-progress, 1))`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function percent(value) {
|
|
52
|
+
return `${Number((value * 100).toFixed(6))}%`;
|
|
53
|
+
}
|
package/src/timeline/project.mjs
CHANGED
|
@@ -1,52 +1,52 @@
|
|
|
1
|
-
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
-
import { join, resolve } from 'node:path';
|
|
3
|
-
import { listProjectAssets } from '../project/assets.mjs';
|
|
4
|
-
import { validateTimeline } from './validate.mjs';
|
|
5
|
-
|
|
6
|
-
export function createProjectTimeline(projectPath, options = {}) {
|
|
7
|
-
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Timeline creation options must be an object.');
|
|
8
|
-
for (const key of Object.keys(options)) if (key !== 'durationSeconds') throw new TypeError(`Unsupported timeline creation option: ${key}.`);
|
|
9
|
-
const root = resolve(projectPath);
|
|
10
|
-
const metaPath = join(root, 'meta.json');
|
|
11
|
-
if (!existsSync(metaPath) || lstatSync(metaPath).isSymbolicLink() || !lstatSync(metaPath).isFile()) {
|
|
12
|
-
throw new Error(`Not an Engine project: ${root}`);
|
|
13
|
-
}
|
|
14
|
-
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
15
|
-
const durationSeconds = options.durationSeconds ?? meta.durationSeconds;
|
|
16
|
-
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) throw new TypeError('Timeline durationSeconds must be positive.');
|
|
17
|
-
const catalog = listProjectAssets(root);
|
|
18
|
-
const timeline = {
|
|
19
|
-
schemaVersion: 1,
|
|
20
|
-
project: {
|
|
21
|
-
id: meta.id,
|
|
22
|
-
width: meta.width,
|
|
23
|
-
height: meta.height,
|
|
24
|
-
fps: meta.fps,
|
|
25
|
-
durationSeconds,
|
|
26
|
-
...(meta.sourceFit === undefined ? {} : { sourceFit: meta.sourceFit }),
|
|
27
|
-
...(meta.backgroundColor === undefined ? {} : { backgroundColor: meta.backgroundColor }),
|
|
28
|
-
},
|
|
29
|
-
assets: catalog.assets.map((asset) => ({
|
|
30
|
-
id: asset.id,
|
|
31
|
-
path: asset.path,
|
|
32
|
-
type: asset.type,
|
|
33
|
-
...(asset.durationSeconds === undefined ? {} : { durationSeconds: asset.durationSeconds }),
|
|
34
|
-
...(asset.width === undefined ? {} : { width: asset.width }),
|
|
35
|
-
...(asset.height === undefined ? {} : { height: asset.height }),
|
|
36
|
-
...(asset.frameRate === undefined ? {} : { frameRate: asset.frameRate }),
|
|
37
|
-
...(asset.nominalFrameRate === undefined ? {} : { nominalFrameRate: asset.nominalFrameRate }),
|
|
38
|
-
...(asset.hasAudio === undefined ? {} : { hasAudio: asset.hasAudio }),
|
|
39
|
-
...(asset.videoStreams === undefined ? {} : { videoStreams: structuredClone(asset.videoStreams) }),
|
|
40
|
-
...(asset.audioStreams === undefined ? {} : { audioStreams: structuredClone(asset.audioStreams) }),
|
|
41
|
-
...(asset.streamTiming === undefined ? {} : { streamTiming: structuredClone(asset.streamTiming) }),
|
|
42
|
-
sha256: asset.sha256,
|
|
43
|
-
})),
|
|
44
|
-
markers: [],
|
|
45
|
-
tracks: [
|
|
46
|
-
{ id: 'video-main', kind: 'video', order: 0, clips: [] },
|
|
47
|
-
{ id: 'audio-main', kind: 'audio', order: 1, clips: [] },
|
|
48
|
-
{ id: 'overlay-main', kind: 'overlay', order: 2, clips: [] },
|
|
49
|
-
],
|
|
50
|
-
};
|
|
51
|
-
return validateTimeline(timeline);
|
|
52
|
-
}
|
|
1
|
+
import { existsSync, lstatSync, readFileSync } from 'node:fs';
|
|
2
|
+
import { join, resolve } from 'node:path';
|
|
3
|
+
import { listProjectAssets } from '../project/assets.mjs';
|
|
4
|
+
import { validateTimeline } from './validate.mjs';
|
|
5
|
+
|
|
6
|
+
export function createProjectTimeline(projectPath, options = {}) {
|
|
7
|
+
if (!options || typeof options !== 'object' || Array.isArray(options)) throw new TypeError('Timeline creation options must be an object.');
|
|
8
|
+
for (const key of Object.keys(options)) if (key !== 'durationSeconds') throw new TypeError(`Unsupported timeline creation option: ${key}.`);
|
|
9
|
+
const root = resolve(projectPath);
|
|
10
|
+
const metaPath = join(root, 'meta.json');
|
|
11
|
+
if (!existsSync(metaPath) || lstatSync(metaPath).isSymbolicLink() || !lstatSync(metaPath).isFile()) {
|
|
12
|
+
throw new Error(`Not an Engine project: ${root}`);
|
|
13
|
+
}
|
|
14
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
15
|
+
const durationSeconds = options.durationSeconds ?? meta.durationSeconds;
|
|
16
|
+
if (!Number.isFinite(durationSeconds) || durationSeconds <= 0) throw new TypeError('Timeline durationSeconds must be positive.');
|
|
17
|
+
const catalog = listProjectAssets(root);
|
|
18
|
+
const timeline = {
|
|
19
|
+
schemaVersion: 1,
|
|
20
|
+
project: {
|
|
21
|
+
id: meta.id,
|
|
22
|
+
width: meta.width,
|
|
23
|
+
height: meta.height,
|
|
24
|
+
fps: meta.fps,
|
|
25
|
+
durationSeconds,
|
|
26
|
+
...(meta.sourceFit === undefined ? {} : { sourceFit: meta.sourceFit }),
|
|
27
|
+
...(meta.backgroundColor === undefined ? {} : { backgroundColor: meta.backgroundColor }),
|
|
28
|
+
},
|
|
29
|
+
assets: catalog.assets.map((asset) => ({
|
|
30
|
+
id: asset.id,
|
|
31
|
+
path: asset.path,
|
|
32
|
+
type: asset.type,
|
|
33
|
+
...(asset.durationSeconds === undefined ? {} : { durationSeconds: asset.durationSeconds }),
|
|
34
|
+
...(asset.width === undefined ? {} : { width: asset.width }),
|
|
35
|
+
...(asset.height === undefined ? {} : { height: asset.height }),
|
|
36
|
+
...(asset.frameRate === undefined ? {} : { frameRate: asset.frameRate }),
|
|
37
|
+
...(asset.nominalFrameRate === undefined ? {} : { nominalFrameRate: asset.nominalFrameRate }),
|
|
38
|
+
...(asset.hasAudio === undefined ? {} : { hasAudio: asset.hasAudio }),
|
|
39
|
+
...(asset.videoStreams === undefined ? {} : { videoStreams: structuredClone(asset.videoStreams) }),
|
|
40
|
+
...(asset.audioStreams === undefined ? {} : { audioStreams: structuredClone(asset.audioStreams) }),
|
|
41
|
+
...(asset.streamTiming === undefined ? {} : { streamTiming: structuredClone(asset.streamTiming) }),
|
|
42
|
+
sha256: asset.sha256,
|
|
43
|
+
})),
|
|
44
|
+
markers: [],
|
|
45
|
+
tracks: [
|
|
46
|
+
{ id: 'video-main', kind: 'video', order: 0, clips: [] },
|
|
47
|
+
{ id: 'audio-main', kind: 'audio', order: 1, clips: [] },
|
|
48
|
+
{ id: 'overlay-main', kind: 'overlay', order: 2, clips: [] },
|
|
49
|
+
],
|
|
50
|
+
};
|
|
51
|
+
return validateTimeline(timeline);
|
|
52
|
+
}
|