@scenerok/sdk 0.8.0 → 0.10.0
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/README.md +63 -1
- package/dist/describe.d.ts +87 -0
- package/dist/describe.d.ts.map +1 -0
- package/dist/describe.js +210 -0
- package/dist/duration.d.ts +9 -3
- package/dist/duration.d.ts.map +1 -1
- package/dist/duration.js +22 -5
- package/dist/frame-rate.d.ts +30 -0
- package/dist/frame-rate.d.ts.map +1 -0
- package/dist/frame-rate.js +73 -0
- package/dist/index.d.ts +9 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -4
- package/dist/ir-builder.d.ts +61 -8
- package/dist/ir-builder.d.ts.map +1 -1
- package/dist/ir-builder.js +714 -256
- package/dist/ir.d.ts +203 -2
- package/dist/ir.d.ts.map +1 -1
- package/dist/ir.js +4 -2
- package/dist/media.d.ts +49 -5
- package/dist/media.d.ts.map +1 -1
- package/dist/media.js +223 -33
- package/dist/motion-tracks.d.ts +10 -0
- package/dist/motion-tracks.d.ts.map +1 -0
- package/dist/motion-tracks.js +150 -0
- package/dist/motion.d.ts +34 -0
- package/dist/motion.d.ts.map +1 -1
- package/dist/motion.js +51 -11
- package/dist/plugins/invoke.d.ts.map +1 -1
- package/dist/plugins/invoke.js +5 -2
- package/dist/timeline.d.ts +3 -0
- package/dist/timeline.d.ts.map +1 -1
- package/dist/timeline.js +101 -41
- package/dist/types.d.ts +214 -28
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -9
- package/package.json +2 -2
- package/skills/scenerok/SKILL.md +5 -2
- package/skills/scenerok/references/catalog-index.json +1 -1
- package/skills/scenerok/references/editing-actions.md +43 -1
- package/skills/scenerok/references/sdk-guide.md +149 -49
- package/skills/scenerok/references/vidscript-guide.md +49 -13
- package/skills/scenerok/references/vidscript-strict.md +1 -1
package/dist/ir-builder.js
CHANGED
|
@@ -1,18 +1,99 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* IR lowering for SDK nodes — the TypeScript twin of the VidScript compiler's
|
|
3
|
+
* placement rules (`placeVidValue` in the platform's `src/parser/compiler-v2.ts`),
|
|
4
|
+
* so an SDK scene and the VidScript that says the same thing emit the same IR.
|
|
5
|
+
*
|
|
6
|
+
* A timeline records placements and lowers them in `toIR()`. Lowering late lets
|
|
7
|
+
* a nested timeline take its parent's canvas and share its id counter.
|
|
4
8
|
*/
|
|
5
9
|
import { defaultIRTimeline, } from './ir.js';
|
|
6
10
|
import { toSeconds } from './duration.js';
|
|
11
|
+
import { frameRateOf, isFractionalTimeBase, snapToFrame, timecodeToSeconds } from './frame-rate.js';
|
|
7
12
|
import { defaultGridSpec, placementFromGridArea, resolveGridAreas, } from './grid.js';
|
|
13
|
+
import { DEFAULT_STILL_LENGTH, playedLength } from './media.js';
|
|
14
|
+
import { animationsToMotionTracks } from './motion-tracks.js';
|
|
8
15
|
import { isSchedulable } from './types.js';
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
16
|
+
const FALLBACK_CANVAS = { width: 1080, height: 1920, fps: 30 };
|
|
17
|
+
/**
|
|
18
|
+
* The canvas `timeline()` uses when it gives no size: the host's (a VidScript
|
|
19
|
+
* import sets `globalThis.__scenerokCanvas` to the importing script's output),
|
|
20
|
+
* else 1080×1920 at 30 fps.
|
|
21
|
+
*/
|
|
22
|
+
export function hostCanvas() {
|
|
23
|
+
const host = globalThis.__scenerokCanvas;
|
|
24
|
+
return {
|
|
25
|
+
width: positive(host?.width) ?? FALLBACK_CANVAS.width,
|
|
26
|
+
height: positive(host?.height) ?? FALLBACK_CANVAS.height,
|
|
27
|
+
fps: positive(host?.fps) ?? FALLBACK_CANVAS.fps,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
function positive(value) {
|
|
31
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : undefined;
|
|
32
|
+
}
|
|
33
|
+
/** @deprecated Ids are numbered per `toIR()` call now; kept for compatibility. */
|
|
34
|
+
export function resetIrBuilderIds(_seed = 0) {
|
|
35
|
+
// Each lowering starts its own counter, so output is deterministic without a reset.
|
|
36
|
+
}
|
|
37
|
+
export function createIrBuilder(options = {}, canvas = hostCanvas(), ids = { next: 0 }) {
|
|
38
|
+
const ir = defaultIRTimeline();
|
|
39
|
+
ir.width = options.width ?? canvas.width;
|
|
40
|
+
ir.height = options.height ?? canvas.height;
|
|
41
|
+
ir.fps = options.fps ?? canvas.fps;
|
|
42
|
+
if (options.background != null)
|
|
43
|
+
ir.background = options.background;
|
|
44
|
+
const timeBase = frameRateOf(ir.fps);
|
|
45
|
+
if (isFractionalTimeBase(timeBase))
|
|
46
|
+
ir.timeBase = timeBase;
|
|
47
|
+
const out = options.output ?? {};
|
|
48
|
+
ir.output = {
|
|
49
|
+
...ir.output,
|
|
50
|
+
path: out.path ?? ir.output.path,
|
|
51
|
+
format: out.format ?? ir.output.format,
|
|
52
|
+
codec: out.codec ?? ir.output.codec,
|
|
53
|
+
bitrate: out.bitrate ?? ir.output.bitrate,
|
|
54
|
+
resolution: out.resolution ?? `${ir.width}x${ir.height}`,
|
|
55
|
+
fps: out.fps ?? ir.fps,
|
|
56
|
+
};
|
|
57
|
+
if (out.startTimecode !== undefined) {
|
|
58
|
+
// Validates the fields against the output rate; throws with the reason.
|
|
59
|
+
timecodeToSeconds(out.startTimecode, ir.output.fps, 'startTimecode');
|
|
60
|
+
ir.output.startTimecode = out.startTimecode;
|
|
61
|
+
}
|
|
62
|
+
if (out.burnIn !== undefined) {
|
|
63
|
+
if (out.burnIn !== 'timecode')
|
|
64
|
+
throw new TypeError(`output burnIn must be 'timecode', got '${String(out.burnIn)}'`);
|
|
65
|
+
ir.output.burnIn = 'timecode';
|
|
66
|
+
}
|
|
67
|
+
return { ir, visualCursor: 0, audioCursor: 0, gridAreas: null, timeBase, ids };
|
|
68
|
+
}
|
|
69
|
+
function nextId(state, prefix) {
|
|
70
|
+
state.ids.next += 1;
|
|
71
|
+
return `${prefix}-${state.ids.next}`;
|
|
72
|
+
}
|
|
73
|
+
/** Declare or replace the timeline grid used by `area:` placement. */
|
|
74
|
+
export function setGrid(state, spec) {
|
|
75
|
+
const areas = resolveGridAreas(spec);
|
|
76
|
+
state.gridAreas = areas;
|
|
77
|
+
return areas;
|
|
12
78
|
}
|
|
13
|
-
function
|
|
14
|
-
|
|
15
|
-
|
|
79
|
+
function gridRect(state, areaName) {
|
|
80
|
+
const areas = state.gridAreas
|
|
81
|
+
?? resolveGridAreas(defaultGridSpec(state.ir.width, state.ir.height));
|
|
82
|
+
const rect = areas.get(areaName);
|
|
83
|
+
if (!rect) {
|
|
84
|
+
throw new Error(`Unknown grid area '${areaName}' (available: ${[...areas.keys()].join(', ')})`);
|
|
85
|
+
}
|
|
86
|
+
return rect;
|
|
87
|
+
}
|
|
88
|
+
/** The pixel rect of a grid area (videos, images, and solids place by their top-left). */
|
|
89
|
+
function gridPixelRect(state, areaName) {
|
|
90
|
+
const rect = gridRect(state, areaName);
|
|
91
|
+
return {
|
|
92
|
+
x: (rect.x / 100) * state.ir.width,
|
|
93
|
+
y: (rect.y / 100) * state.ir.height,
|
|
94
|
+
width: (rect.width / 100) * state.ir.width,
|
|
95
|
+
height: (rect.height / 100) * state.ir.height,
|
|
96
|
+
};
|
|
16
97
|
}
|
|
17
98
|
function definedEntries(partial) {
|
|
18
99
|
const out = {};
|
|
@@ -22,52 +103,32 @@ function definedEntries(partial) {
|
|
|
22
103
|
}
|
|
23
104
|
return out;
|
|
24
105
|
}
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
size: 48,
|
|
29
|
-
color: '#FFFFFF',
|
|
30
|
-
stroke: 'transparent',
|
|
31
|
-
strokeWidth: 0,
|
|
32
|
-
position: 'center',
|
|
33
|
-
bold: false,
|
|
34
|
-
italic: false,
|
|
35
|
-
x: '50%',
|
|
36
|
-
y: '50%',
|
|
37
|
-
rotation: 0,
|
|
38
|
-
opacity: 1,
|
|
39
|
-
align: 'center',
|
|
40
|
-
lineHeight: 1.2,
|
|
41
|
-
letterSpacing: 0,
|
|
42
|
-
shadowColor: 'transparent',
|
|
43
|
-
shadowBlur: 0,
|
|
44
|
-
shadowOffsetX: 0,
|
|
45
|
-
shadowOffsetY: 0,
|
|
46
|
-
...definedEntries(partial),
|
|
47
|
-
};
|
|
106
|
+
function bumpDuration(state, end) {
|
|
107
|
+
if (end > state.ir.duration)
|
|
108
|
+
state.ir.duration = end;
|
|
48
109
|
}
|
|
110
|
+
// ─── Animation ───
|
|
49
111
|
function isExitFade(descriptor) {
|
|
50
|
-
return
|
|
51
|
-
&& descriptor.from?.opacity === 1
|
|
52
|
-
&& descriptor.to?.opacity === 0);
|
|
112
|
+
return descriptor.type === 'fade' && descriptor.from?.opacity === 1 && descriptor.to?.opacity === 0;
|
|
53
113
|
}
|
|
54
|
-
function
|
|
114
|
+
function animationWindow(descriptor, blockStart, blockEnd) {
|
|
55
115
|
const span = Math.max(0, blockEnd - blockStart);
|
|
56
116
|
const duration = descriptor.duration > 0 ? Math.min(descriptor.duration, span) : span;
|
|
57
|
-
|
|
58
|
-
if (alignEnd) {
|
|
117
|
+
if (descriptor.align === 'end' || isExitFade(descriptor)) {
|
|
59
118
|
return { start: blockEnd - duration, end: blockEnd };
|
|
60
119
|
}
|
|
61
120
|
return { start: blockStart, end: Math.min(blockEnd, blockStart + duration) };
|
|
62
121
|
}
|
|
63
|
-
function
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
122
|
+
function normalizeAnims(animate) {
|
|
123
|
+
if (!animate)
|
|
124
|
+
return [];
|
|
125
|
+
return Array.isArray(animate) ? animate : [animate];
|
|
126
|
+
}
|
|
127
|
+
function lowerAnimations(state, descriptors, blockStart, blockEnd) {
|
|
128
|
+
const animations = descriptors.map((descriptor) => {
|
|
129
|
+
const window = animationWindow(descriptor, blockStart, blockEnd);
|
|
130
|
+
return {
|
|
131
|
+
id: nextId(state, 'anim'),
|
|
71
132
|
type: descriptor.type,
|
|
72
133
|
plugin: descriptor.plugin,
|
|
73
134
|
from: { ...descriptor.from },
|
|
@@ -76,128 +137,442 @@ function lowerAnimations(descriptors, blockStart, blockEnd) {
|
|
|
76
137
|
end: window.end,
|
|
77
138
|
easing: descriptor.easing,
|
|
78
139
|
loop: descriptor.loop,
|
|
79
|
-
}
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
140
|
+
};
|
|
141
|
+
});
|
|
142
|
+
return { animations, motion: animationsToMotionTracks(animations) };
|
|
143
|
+
}
|
|
144
|
+
function attachAnimations(state, target, descriptors, start, end) {
|
|
145
|
+
if (descriptors.length === 0)
|
|
146
|
+
return;
|
|
147
|
+
const { animations, motion } = lowerAnimations(state, descriptors, start, end);
|
|
148
|
+
target.animations = [...(target.animations ?? []), ...animations];
|
|
149
|
+
target.motion = [...(target.motion ?? []), ...motion];
|
|
150
|
+
}
|
|
151
|
+
// ─── Placement properties (VidScript `applyVideoParams`) ───
|
|
152
|
+
const BLEND_MODES = ['normal', 'multiply', 'screen', 'overlay', 'difference', 'add', 'subtract', 'darken', 'lighten'];
|
|
153
|
+
const BLEND_ALIASES = {
|
|
154
|
+
'source-over': 'normal',
|
|
155
|
+
src: 'normal',
|
|
156
|
+
'linear-dodge': 'add',
|
|
157
|
+
lineardodge: 'add',
|
|
158
|
+
'linear-burn': 'subtract',
|
|
159
|
+
'color-dodge': 'screen',
|
|
160
|
+
'color-burn': 'multiply',
|
|
161
|
+
'soft-light': 'overlay',
|
|
162
|
+
'hard-light': 'overlay',
|
|
163
|
+
};
|
|
164
|
+
export function normalizeBlendMode(raw) {
|
|
165
|
+
const key = String(raw).trim().toLowerCase().replace(/[\s_]/g, '-');
|
|
166
|
+
const mode = BLEND_MODES.includes(key) ? key : BLEND_ALIASES[key];
|
|
167
|
+
if (!mode)
|
|
168
|
+
throw new RangeError(`Unsupported blend mode '${raw}'. Use one of: ${BLEND_MODES.join(', ')}.`);
|
|
169
|
+
return mode;
|
|
170
|
+
}
|
|
171
|
+
function resolveMotionBlur(option) {
|
|
172
|
+
if (option === false)
|
|
173
|
+
return null;
|
|
174
|
+
const spec = option === true ? {} : option;
|
|
175
|
+
const shutter = spec.shutter ?? 180;
|
|
176
|
+
const samples = spec.samples ?? 8;
|
|
177
|
+
if (!(shutter > 0 && shutter <= 360))
|
|
178
|
+
throw new RangeError(`motionBlur shutter must be above 0 and at most 360, got ${shutter}`);
|
|
179
|
+
if (!Number.isInteger(samples) || samples < 2 || samples > 64) {
|
|
180
|
+
throw new RangeError(`motionBlur samples must be a whole number from 2 to 64, got ${samples}`);
|
|
181
|
+
}
|
|
182
|
+
return { shutter, samples };
|
|
183
|
+
}
|
|
184
|
+
function validateCorners(corners) {
|
|
185
|
+
if (!Array.isArray(corners) || corners.length !== 4
|
|
186
|
+
|| corners.some((p) => !Array.isArray(p) || p.length < 2 || !p.every((n) => Number.isFinite(n)))) {
|
|
187
|
+
throw new TypeError('corners must be four [x, y] points in output pixels, TL → TR → BR → BL.');
|
|
188
|
+
}
|
|
189
|
+
return corners.map((p) => [p[0], p[1]]);
|
|
190
|
+
}
|
|
191
|
+
/** Geometry, opacity, motion, and shape of a placed picture, written onto a clip. */
|
|
192
|
+
function applyPlacement(state, clip, opts) {
|
|
193
|
+
attachAnimations(state, clip, normalizeAnims(opts.animate), clip.start, clip.end);
|
|
194
|
+
if (opts.area !== undefined) {
|
|
195
|
+
const rect = gridPixelRect(state, opts.area);
|
|
196
|
+
clip.x = rect.x;
|
|
197
|
+
clip.y = rect.y;
|
|
198
|
+
clip.width = rect.width;
|
|
199
|
+
clip.height = rect.height;
|
|
200
|
+
}
|
|
201
|
+
if (opts.width !== undefined)
|
|
202
|
+
clip.width = opts.width;
|
|
203
|
+
if (opts.height !== undefined)
|
|
204
|
+
clip.height = opts.height;
|
|
205
|
+
if (opts.x !== undefined)
|
|
206
|
+
clip.x = opts.x;
|
|
207
|
+
if (opts.y !== undefined)
|
|
208
|
+
clip.y = opts.y;
|
|
209
|
+
if (opts.opacity !== undefined)
|
|
210
|
+
clip.opacity = opts.opacity;
|
|
211
|
+
if (typeof opts.rotation === 'number' && !(clip.motion ?? []).some((t) => t.target === 'transform.rotation.z')) {
|
|
212
|
+
attachAnimations(state, clip, [{
|
|
213
|
+
__sdk: true, kind: 'animation', type: 'rotation',
|
|
214
|
+
from: { rotation: opts.rotation }, to: { rotation: opts.rotation }, duration: 0,
|
|
215
|
+
}], clip.start, clip.end);
|
|
216
|
+
}
|
|
217
|
+
if (opts.radius !== undefined) {
|
|
218
|
+
if (!Number.isFinite(opts.radius) || opts.radius < 0)
|
|
219
|
+
throw new RangeError(`Invalid radius '${opts.radius}'.`);
|
|
220
|
+
if (opts.radius > 0)
|
|
221
|
+
clip.radius = opts.radius;
|
|
222
|
+
}
|
|
223
|
+
if (opts.corners !== undefined)
|
|
224
|
+
clip.corners = validateCorners(opts.corners);
|
|
225
|
+
if (opts.inputColorspace !== undefined || opts.outputColorspace !== undefined) {
|
|
226
|
+
clip.colorspace = definedEntries({ input: opts.inputColorspace, output: opts.outputColorspace });
|
|
227
|
+
}
|
|
228
|
+
if (opts.motionBlur !== undefined) {
|
|
229
|
+
const blur = resolveMotionBlur(opts.motionBlur);
|
|
230
|
+
if (blur)
|
|
231
|
+
clip.motionBlur = blur;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
/** What a blended item draws over: anything visible in its window. */
|
|
235
|
+
function findBelow(state, start, end, exceptId) {
|
|
236
|
+
const { ir } = state;
|
|
237
|
+
const overlaps = (item) => item.start < end && item.end > start;
|
|
238
|
+
const below = [...ir.clips].reverse().find((c) => c.id !== exceptId && overlaps(c))
|
|
239
|
+
?? [...(ir.groups ?? [])].reverse().find((g) => g.id !== exceptId && overlaps(g))
|
|
240
|
+
?? [...ir.layers].reverse().find((l) => l.type === 'text' && overlaps(l))
|
|
241
|
+
?? [...ir.surfaces].reverse().find(overlaps);
|
|
242
|
+
if (!below) {
|
|
243
|
+
throw new Error(`blend needs something below it to blend with in ${start}s..${end}s; place it first`);
|
|
244
|
+
}
|
|
245
|
+
return below;
|
|
246
|
+
}
|
|
247
|
+
function markIr(ir) {
|
|
248
|
+
return {
|
|
249
|
+
clips: ir.clips.length,
|
|
250
|
+
layers: ir.layers.length,
|
|
251
|
+
surfaces: ir.surfaces.length,
|
|
252
|
+
groups: ir.groups?.length ?? 0,
|
|
253
|
+
effects: ir.effects.length,
|
|
254
|
+
audio: ir.audio.length,
|
|
255
|
+
markers: ir.markers?.length ?? 0,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
function takePictureItems(ir, marks) {
|
|
259
|
+
const items = {
|
|
260
|
+
clips: ir.clips.splice(marks.clips),
|
|
261
|
+
layers: ir.layers.splice(marks.layers),
|
|
262
|
+
surfaces: ir.surfaces.splice(marks.surfaces),
|
|
263
|
+
groups: (ir.groups ?? []).splice(marks.groups),
|
|
264
|
+
effects: ir.effects.splice(marks.effects),
|
|
265
|
+
};
|
|
266
|
+
if (ir.groups && ir.groups.length === 0)
|
|
267
|
+
delete ir.groups;
|
|
268
|
+
return items;
|
|
269
|
+
}
|
|
270
|
+
function shiftTimed(item, delta) {
|
|
271
|
+
return {
|
|
272
|
+
...item,
|
|
273
|
+
start: item.start + delta,
|
|
274
|
+
end: item.end + delta,
|
|
275
|
+
...(item.animations ? { animations: item.animations.map((a) => ({ ...a, start: a.start + delta, end: a.end + delta })) } : {}),
|
|
276
|
+
...(item.motion
|
|
277
|
+
? { motion: item.motion.map((t) => ({ ...t, keyframes: t.keyframes.map((k) => ({ ...k, time: k.time + delta })) })) }
|
|
278
|
+
: {}),
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
function shiftPicture(items, delta) {
|
|
282
|
+
if (delta === 0)
|
|
283
|
+
return items;
|
|
284
|
+
return {
|
|
285
|
+
clips: items.clips.map((c) => shiftTimed(c, delta)),
|
|
286
|
+
layers: items.layers.map((l) => (l.type === 'text' ? shiftTimed(l, delta) : { ...l, start: l.start + delta, end: l.end + delta })),
|
|
287
|
+
surfaces: items.surfaces.map((s) => shiftTimed(s, delta)),
|
|
288
|
+
// A group's children are in its own time; only the group moves.
|
|
289
|
+
groups: items.groups.map((g) => shiftTimed(g, delta)),
|
|
290
|
+
effects: items.effects.map((e) => shiftTimed(e, delta)),
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
function checkGroupChildren(items) {
|
|
294
|
+
if (items.surfaces.some((s) => s.type === 'transition')) {
|
|
295
|
+
throw new Error('A transition cannot be inside a group (it draws outside the compositor); keep it on the top-level timeline');
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* Move what a placement just emitted into one `IRGroup` — VidScript's
|
|
300
|
+
* `wrapAddedItemsInGroup`. Children land in group-local time; audio and
|
|
301
|
+
* markers stay on the timeline.
|
|
302
|
+
*/
|
|
303
|
+
function wrapInGroup(state, marks, start, end, placement) {
|
|
304
|
+
const { ir } = state;
|
|
305
|
+
const items = shiftPicture(takePictureItems(ir, marks), -start);
|
|
306
|
+
checkGroupChildren(items);
|
|
307
|
+
const { blend, ...props } = placement.props;
|
|
308
|
+
const width = placement.size?.width ?? ir.width;
|
|
309
|
+
const height = placement.size?.height ?? ir.height;
|
|
310
|
+
// Placement properties are a clip's: a scratch clip takes them, the group reads them back.
|
|
311
|
+
const scratch = {
|
|
312
|
+
id: nextId(state, 'group'), source: '', start, end, trimStart: 0, trimEnd: 0, speed: 1, loop: 1,
|
|
313
|
+
opacity: 1, width, height, x: 0, y: 0,
|
|
314
|
+
};
|
|
315
|
+
applyPlacement(state, scratch, props);
|
|
316
|
+
const grades = [
|
|
317
|
+
...items.effects,
|
|
318
|
+
...placement.effects.map((effect) => ({ ...effect, id: nextId(state, 'fx'), start: 0, end: end - start })),
|
|
319
|
+
];
|
|
320
|
+
const group = {
|
|
321
|
+
id: scratch.id,
|
|
322
|
+
start,
|
|
323
|
+
end,
|
|
324
|
+
clips: items.clips,
|
|
325
|
+
layers: items.layers,
|
|
326
|
+
surfaces: items.surfaces,
|
|
327
|
+
...(items.groups.length > 0 ? { groups: items.groups } : {}),
|
|
328
|
+
...(grades.length > 0 ? { effects: grades } : {}),
|
|
329
|
+
width,
|
|
330
|
+
height,
|
|
331
|
+
x: scratch.x,
|
|
332
|
+
y: scratch.y,
|
|
333
|
+
opacity: scratch.opacity,
|
|
334
|
+
};
|
|
335
|
+
if (scratch.width !== width)
|
|
336
|
+
group.displayWidth = scratch.width;
|
|
337
|
+
if (scratch.height !== height)
|
|
338
|
+
group.displayHeight = scratch.height;
|
|
339
|
+
if (scratch.radius !== undefined)
|
|
340
|
+
group.radius = scratch.radius;
|
|
341
|
+
if (scratch.corners)
|
|
342
|
+
group.corners = scratch.corners;
|
|
343
|
+
if (scratch.animations)
|
|
344
|
+
group.animations = scratch.animations;
|
|
345
|
+
if (scratch.motion)
|
|
346
|
+
group.motion = scratch.motion;
|
|
347
|
+
if (scratch.motionBlur)
|
|
348
|
+
group.motionBlur = scratch.motionBlur;
|
|
349
|
+
if (placement.crop) {
|
|
350
|
+
const c = placement.crop;
|
|
351
|
+
if (c.x + c.width > width || c.y + c.height > height) {
|
|
352
|
+
throw new RangeError(`crop ${c.width}x${c.height} at (${c.x}, ${c.y}) is outside the ${width}x${height} picture`);
|
|
353
|
+
}
|
|
354
|
+
group.crop = c;
|
|
355
|
+
}
|
|
356
|
+
if (blend !== undefined) {
|
|
357
|
+
const mode = normalizeBlendMode(blend);
|
|
358
|
+
if (mode !== 'normal') {
|
|
359
|
+
findBelow(state, start, end);
|
|
360
|
+
group.blend = mode;
|
|
96
361
|
}
|
|
97
362
|
}
|
|
98
|
-
|
|
363
|
+
(ir.groups ??= []).push(group);
|
|
364
|
+
return group;
|
|
99
365
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
366
|
+
/** The matte of a mask: the value placed over the host's window, as a group in local time. */
|
|
367
|
+
function buildMatte(state, matte, start, end) {
|
|
368
|
+
const { ir } = state;
|
|
369
|
+
const marks = markIr(ir);
|
|
370
|
+
scheduleAt(state, start, end, matte, { snap: false, advance: false });
|
|
371
|
+
ir.audio.splice(marks.audio);
|
|
372
|
+
ir.markers?.splice(marks.markers);
|
|
373
|
+
const items = shiftPicture(takePictureItems(ir, marks), -start);
|
|
374
|
+
checkGroupChildren(items);
|
|
375
|
+
return {
|
|
376
|
+
id: nextId(state, 'matte'),
|
|
377
|
+
start: 0,
|
|
378
|
+
end: end - start,
|
|
379
|
+
clips: items.clips,
|
|
380
|
+
layers: items.layers,
|
|
381
|
+
surfaces: items.surfaces,
|
|
382
|
+
...(items.groups.length > 0 ? { groups: items.groups } : {}),
|
|
383
|
+
...(items.effects.length > 0 ? { effects: items.effects } : {}),
|
|
384
|
+
width: ir.width,
|
|
385
|
+
height: ir.height,
|
|
386
|
+
x: 0,
|
|
387
|
+
y: 0,
|
|
388
|
+
opacity: 1,
|
|
120
389
|
};
|
|
121
|
-
return { ir, visualCursor: 0, audioCursor: 0, gridAreas: null };
|
|
122
390
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
const areas = resolveGridAreas(spec);
|
|
126
|
-
state.gridAreas = areas;
|
|
127
|
-
return areas;
|
|
391
|
+
function attachMask(state, group, mask) {
|
|
392
|
+
group.mask = { group: buildMatte(state, mask.matte, group.start, group.end), mode: mask.mode };
|
|
128
393
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
throw new Error(`Unknown grid area '${areaName}' (available: ${[...areas.keys()].join(', ')})`);
|
|
394
|
+
/** Run `place`; with a blend other than `normal`, what it emitted becomes a blended group. */
|
|
395
|
+
function placeBlended(state, blend, start, end, place) {
|
|
396
|
+
if (blend === undefined || normalizeBlendMode(blend) === 'normal') {
|
|
397
|
+
place();
|
|
398
|
+
return;
|
|
135
399
|
}
|
|
136
|
-
|
|
400
|
+
const marks = markIr(state.ir);
|
|
401
|
+
place();
|
|
402
|
+
wrapInGroup(state, marks, start, end, { props: { blend }, effects: [] });
|
|
137
403
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
404
|
+
// ─── Kinds ───
|
|
405
|
+
/** EBU R128 targets VidScript applies to generated sound and `loudness: "ebu-r128"`. */
|
|
406
|
+
const EBU_R128 = {
|
|
407
|
+
loudnessNormalization: 'ebu-r128',
|
|
408
|
+
targetLufs: -16,
|
|
409
|
+
truePeakDb: -1.5,
|
|
410
|
+
loudnessRangeLufs: 11,
|
|
411
|
+
};
|
|
412
|
+
/**
|
|
413
|
+
* How long a value with a known length occupies a window: its own length when
|
|
414
|
+
* shorter (a clip ends when it stops playing), else the window.
|
|
415
|
+
*/
|
|
416
|
+
function fitToWindow(known, start, end) {
|
|
417
|
+
const span = Math.max(0, end - start);
|
|
418
|
+
if (known > 0 && (span <= 0 || known < span))
|
|
419
|
+
return known;
|
|
420
|
+
return span;
|
|
141
421
|
}
|
|
142
|
-
function
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
422
|
+
function scheduleMedia(state, start, end, node) {
|
|
423
|
+
const opts = node.options;
|
|
424
|
+
if (opts.enabled === false)
|
|
425
|
+
return end;
|
|
426
|
+
const edits = node.edits ?? {};
|
|
427
|
+
if (edits.mask) {
|
|
428
|
+
// The matte multiplies the picture as one unit, so the clip draws in a
|
|
429
|
+
// group; only `blend` belongs to the group, which sees the picture below.
|
|
430
|
+
const { blend, ...clipOpts } = opts;
|
|
431
|
+
const marks = markIr(state.ir);
|
|
432
|
+
const clipEnd = scheduleMedia(state, start, end, {
|
|
433
|
+
...node, options: clipOpts, edits: { ...edits, mask: undefined },
|
|
434
|
+
});
|
|
435
|
+
const group = wrapInGroup(state, marks, start, clipEnd, { props: blend === undefined ? {} : { blend }, effects: [] });
|
|
436
|
+
attachMask(state, group, edits.mask);
|
|
437
|
+
return clipEnd;
|
|
438
|
+
}
|
|
439
|
+
const mediaDuration = node.kind === 'video' ? node.mediaDuration : undefined;
|
|
440
|
+
const played = node.kind === 'video' ? playedLength(edits, mediaDuration) : 0;
|
|
441
|
+
const clipEnd = start + fitToWindow(played, start, end);
|
|
442
|
+
const sequence = node.kind === 'video' ? node.sequence : undefined;
|
|
443
|
+
const rate = node.kind === 'video' && !sequence ? node.options.rate : undefined;
|
|
444
|
+
const clip = {
|
|
445
|
+
id: nextId(state, 'clip'),
|
|
446
|
+
source: node.source,
|
|
447
|
+
...(sequence ? { sequence } : {}),
|
|
448
|
+
...(rate !== undefined ? { sourceRate: rate } : {}),
|
|
147
449
|
start,
|
|
148
|
-
end,
|
|
149
|
-
trimStart:
|
|
150
|
-
trimEnd:
|
|
151
|
-
speed:
|
|
152
|
-
loop:
|
|
153
|
-
opacity:
|
|
154
|
-
width:
|
|
155
|
-
height:
|
|
156
|
-
x:
|
|
157
|
-
y:
|
|
158
|
-
|
|
159
|
-
|
|
450
|
+
end: clipEnd,
|
|
451
|
+
trimStart: edits.trimStart ?? 0,
|
|
452
|
+
trimEnd: edits.trimEnd ?? 0,
|
|
453
|
+
speed: edits.speed ?? 1,
|
|
454
|
+
loop: edits.loop ?? 1,
|
|
455
|
+
opacity: edits.opacity ?? 1,
|
|
456
|
+
width: edits.width ?? state.ir.width,
|
|
457
|
+
height: edits.height ?? state.ir.height,
|
|
458
|
+
x: 0,
|
|
459
|
+
y: 0,
|
|
460
|
+
...(edits.crop ? { crop: edits.crop } : {}),
|
|
461
|
+
};
|
|
462
|
+
applyPlacement(state, clip, opts);
|
|
463
|
+
if (edits.effects && edits.effects.length > 0)
|
|
464
|
+
clip.effects = edits.effects.map((e) => ({ ...e }));
|
|
465
|
+
let compositeMode;
|
|
466
|
+
if (opts.blend !== undefined) {
|
|
467
|
+
const mode = normalizeBlendMode(opts.blend);
|
|
468
|
+
if (mode !== 'normal')
|
|
469
|
+
compositeMode = mode;
|
|
470
|
+
}
|
|
471
|
+
const below = compositeMode ? findBelow(state, clip.start, clip.end, clip.id) : null;
|
|
472
|
+
state.ir.clips.push(clip);
|
|
473
|
+
if (compositeMode && below) {
|
|
474
|
+
// `blend:` on a clip lowers to a composite layer over what is below it.
|
|
475
|
+
state.ir.layers.push({
|
|
476
|
+
type: 'composite',
|
|
477
|
+
id: nextId(state, 'composite'),
|
|
478
|
+
targetClipId: below.id,
|
|
479
|
+
otherClipId: clip.id,
|
|
480
|
+
start: clip.start,
|
|
481
|
+
end: clip.end,
|
|
482
|
+
x: 0,
|
|
483
|
+
y: 0,
|
|
484
|
+
opacity: clip.opacity,
|
|
485
|
+
mode: compositeMode,
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
if (node.kind === 'video' && opts.nativeAudio)
|
|
489
|
+
addClipSound(state, clip, opts.volume);
|
|
490
|
+
return clipEnd;
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* A generated clip's own sound, cut the way the clip is (VidScript `addClipSound`):
|
|
494
|
+
* from its in-point, one pass of its source range. Audio cannot retime, so a
|
|
495
|
+
* sped-up clip's sound is left out and a looped clip's plays once.
|
|
496
|
+
*/
|
|
497
|
+
function addClipSound(state, clip, volume) {
|
|
498
|
+
if (clip.speed !== 1)
|
|
499
|
+
return;
|
|
500
|
+
const placed = Math.max(0, clip.end - clip.start);
|
|
501
|
+
const loop = Math.max(1, Math.floor(clip.loop || 1));
|
|
502
|
+
const onePass = clip.trimEnd > clip.trimStart ? clip.trimEnd - clip.trimStart : placed / loop;
|
|
503
|
+
const duration = Math.min(placed, onePass);
|
|
504
|
+
if (duration <= 0)
|
|
505
|
+
return;
|
|
506
|
+
state.ir.audio.push({
|
|
507
|
+
id: nextId(state, 'audio'),
|
|
508
|
+
source: clip.source,
|
|
509
|
+
start: clip.start,
|
|
510
|
+
duration,
|
|
511
|
+
volume: volume ?? 1,
|
|
512
|
+
fadeIn: 0,
|
|
513
|
+
fadeOut: 0,
|
|
514
|
+
...(clip.trimStart > 0 ? { sourceOffset: clip.trimStart } : {}),
|
|
515
|
+
...EBU_R128,
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
function defaultTextStyle(partial = {}) {
|
|
519
|
+
return {
|
|
520
|
+
font: 'Inter',
|
|
521
|
+
size: 48,
|
|
522
|
+
color: '#FFFFFF',
|
|
523
|
+
stroke: 'transparent',
|
|
524
|
+
strokeWidth: 0,
|
|
525
|
+
position: 'center',
|
|
526
|
+
bold: false,
|
|
527
|
+
italic: false,
|
|
528
|
+
x: '50%',
|
|
529
|
+
y: '50%',
|
|
530
|
+
rotation: 0,
|
|
531
|
+
opacity: 1,
|
|
532
|
+
align: 'center',
|
|
533
|
+
lineHeight: 1.2,
|
|
534
|
+
letterSpacing: 0,
|
|
535
|
+
shadowColor: 'transparent',
|
|
536
|
+
shadowBlur: 0,
|
|
537
|
+
shadowOffsetX: 0,
|
|
538
|
+
shadowOffsetY: 0,
|
|
539
|
+
...definedEntries(partial),
|
|
160
540
|
};
|
|
161
|
-
state.ir.clips.push(irClip);
|
|
162
|
-
state.visualCursor = Math.max(state.visualCursor, end);
|
|
163
|
-
bumpDuration(state, end);
|
|
164
541
|
}
|
|
165
542
|
function scheduleText(state, start, end, layer) {
|
|
166
543
|
const opts = layer.options;
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
let areaY;
|
|
172
|
-
let areaMaxWidth;
|
|
173
|
-
let areaMaxHeight;
|
|
174
|
-
if (opts.area) {
|
|
175
|
-
const placement = resolveTextAreaPlacement(state, opts.area);
|
|
176
|
-
areaX = placement.x;
|
|
177
|
-
areaY = placement.y;
|
|
178
|
-
areaMaxWidth = placement.maxWidth;
|
|
179
|
-
areaMaxHeight = placement.maxHeight;
|
|
544
|
+
if (opts.enabled === false)
|
|
545
|
+
return;
|
|
546
|
+
if (opts.stagger !== undefined && opts.stagger !== 'word' && opts.stagger !== 'char') {
|
|
547
|
+
throw new RangeError(`Unsupported stagger '${String(opts.stagger)}'. Use "word" or "char".`);
|
|
180
548
|
}
|
|
549
|
+
// `area:` centres in the cell and caps maxWidth/maxHeight, as VidScript does.
|
|
550
|
+
let area;
|
|
551
|
+
if (opts.area)
|
|
552
|
+
area = placementFromGridArea(gridRect(state, opts.area));
|
|
181
553
|
const style = defaultTextStyle({
|
|
182
554
|
font: opts.font,
|
|
183
555
|
size: opts.size,
|
|
556
|
+
minSize: opts.minSize,
|
|
184
557
|
color: opts.color,
|
|
185
558
|
stroke: opts.stroke,
|
|
186
559
|
strokeWidth: opts.strokeWidth,
|
|
187
560
|
bold: opts.bold,
|
|
188
561
|
italic: opts.italic,
|
|
189
|
-
x: opts.x ??
|
|
190
|
-
y: opts.y ??
|
|
562
|
+
x: opts.x ?? area?.x ?? '50%',
|
|
563
|
+
y: opts.y ?? area?.y ?? '50%',
|
|
191
564
|
rotation: opts.rotation,
|
|
192
565
|
opacity: opts.opacity,
|
|
193
566
|
align: opts.align,
|
|
194
567
|
lineHeight: opts.lineHeight,
|
|
195
568
|
letterSpacing: opts.letterSpacing,
|
|
196
569
|
position: opts.position ?? 'center',
|
|
570
|
+
stagger: opts.stagger,
|
|
197
571
|
fit: opts.fit,
|
|
198
572
|
overflow: opts.overflow,
|
|
199
|
-
|
|
200
|
-
|
|
573
|
+
safeArea: opts.safeArea,
|
|
574
|
+
maxWidth: opts.maxWidth ?? area?.maxWidth,
|
|
575
|
+
maxHeight: opts.maxHeight ?? area?.maxHeight,
|
|
201
576
|
maxLines: opts.maxLines,
|
|
202
577
|
anchor: opts.anchor,
|
|
203
578
|
shadowColor: opts.shadowColor,
|
|
@@ -205,65 +580,109 @@ function scheduleText(state, start, end, layer) {
|
|
|
205
580
|
shadowOffsetX: opts.shadowOffsetX,
|
|
206
581
|
shadowOffsetY: opts.shadowOffsetY,
|
|
207
582
|
});
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
583
|
+
placeBlended(state, opts.blend, start, end, () => {
|
|
584
|
+
const textLayer = {
|
|
585
|
+
type: 'text',
|
|
586
|
+
id: nextId(state, 'text'),
|
|
587
|
+
content: layer.content,
|
|
588
|
+
start,
|
|
589
|
+
end,
|
|
590
|
+
style,
|
|
591
|
+
};
|
|
592
|
+
attachAnimations(state, textLayer, layer.animations, start, end);
|
|
593
|
+
state.ir.layers.push(textLayer);
|
|
594
|
+
});
|
|
595
|
+
}
|
|
596
|
+
function scheduleSolid(state, start, end, node) {
|
|
597
|
+
const opts = node.options;
|
|
598
|
+
if (opts.enabled === false)
|
|
599
|
+
return;
|
|
600
|
+
const rect = opts.area !== undefined ? gridPixelRect(state, opts.area) : null;
|
|
601
|
+
if (opts.radius !== undefined && (!Number.isFinite(opts.radius) || opts.radius < 0)) {
|
|
602
|
+
throw new RangeError(`Invalid radius '${opts.radius}'.`);
|
|
603
|
+
}
|
|
604
|
+
placeBlended(state, opts.blend, start, end, () => {
|
|
605
|
+
const surface = {
|
|
606
|
+
id: nextId(state, 'surface'),
|
|
607
|
+
type: 'solid',
|
|
608
|
+
start,
|
|
609
|
+
end,
|
|
610
|
+
data: {
|
|
611
|
+
color: node.color,
|
|
612
|
+
width: opts.width ?? rect?.width ?? state.ir.width,
|
|
613
|
+
height: opts.height ?? rect?.height ?? state.ir.height,
|
|
614
|
+
x: opts.x ?? rect?.x ?? 0,
|
|
615
|
+
y: opts.y ?? rect?.y ?? 0,
|
|
616
|
+
opacity: opts.opacity ?? 1,
|
|
617
|
+
...(opts.radius ? { radius: opts.radius } : {}),
|
|
618
|
+
...(opts.corners ? { corners: validateCorners(opts.corners) } : {}),
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
attachAnimations(state, surface, normalizeAnims(opts.animate), start, end);
|
|
622
|
+
state.ir.surfaces.push(surface);
|
|
623
|
+
});
|
|
221
624
|
}
|
|
222
625
|
function scheduleAudio(state, start, end, track) {
|
|
223
|
-
const
|
|
626
|
+
const opts = track.options;
|
|
627
|
+
const duration = fitToWindow(track.mediaDuration ?? 0, start, end);
|
|
628
|
+
const sourceOffset = opts.trimStart !== undefined ? toSeconds(opts.trimStart) : 0;
|
|
629
|
+
const duck = typeof opts.duckUnder === 'string' ? opts.duckUnder.trim() : opts.duckUnder?.source;
|
|
630
|
+
const ebu = opts.loudness === 'ebu-r128' || opts.loudnessNormalization === 'ebu-r128';
|
|
224
631
|
const audio = {
|
|
225
|
-
id: nextId('audio'),
|
|
632
|
+
id: nextId(state, 'audio'),
|
|
226
633
|
source: track.source,
|
|
227
634
|
start,
|
|
228
635
|
duration,
|
|
229
|
-
volume:
|
|
230
|
-
fadeIn:
|
|
231
|
-
fadeOut:
|
|
232
|
-
|
|
233
|
-
|
|
636
|
+
volume: opts.volume ?? 1,
|
|
637
|
+
fadeIn: opts.fadeIn != null ? toSeconds(opts.fadeIn) : 0,
|
|
638
|
+
fadeOut: opts.fadeOut != null ? toSeconds(opts.fadeOut) : 0,
|
|
639
|
+
...(sourceOffset > 0 ? { sourceOffset } : {}),
|
|
640
|
+
...(opts.pan !== undefined ? { pan: opts.pan } : {}),
|
|
641
|
+
...(duck ? { duckUnder: duck } : {}),
|
|
642
|
+
...(ebu ? { ...EBU_R128, ...(opts.targetLufs !== undefined ? { targetLufs: opts.targetLufs } : {}) } : {}),
|
|
234
643
|
};
|
|
644
|
+
attachAnimations(state, audio, normalizeAnims(opts.animate), start, end > start ? end : start + duration);
|
|
235
645
|
state.ir.audio.push(audio);
|
|
236
|
-
|
|
237
|
-
|
|
646
|
+
return start + duration;
|
|
647
|
+
}
|
|
648
|
+
function scheduleMarker(state, start, end, node) {
|
|
649
|
+
(state.ir.markers ??= []).push({
|
|
650
|
+
id: nextId(state, 'marker'),
|
|
651
|
+
name: node.name,
|
|
652
|
+
time: start,
|
|
653
|
+
duration: Math.max(0, end - start),
|
|
654
|
+
...(node.metadata ? { metadata: { ...node.metadata } } : {}),
|
|
655
|
+
});
|
|
238
656
|
}
|
|
239
657
|
function scheduleFilter(state, start, end, effect) {
|
|
658
|
+
if (effect.name.trim().toLowerCase() === 'lut') {
|
|
659
|
+
throw new TypeError('filter("lut") reads a .cube file at compile time; use VidScript: filter "lut", file: "look.cube"');
|
|
660
|
+
}
|
|
240
661
|
const builtin = {
|
|
241
662
|
type: 'builtin',
|
|
242
|
-
id: nextId('fx'),
|
|
663
|
+
id: nextId(state, 'fx'),
|
|
243
664
|
name: effect.name,
|
|
244
665
|
start,
|
|
245
666
|
end,
|
|
246
667
|
params: { ...effect.params },
|
|
247
668
|
};
|
|
248
669
|
state.ir.effects.push(builtin);
|
|
249
|
-
bumpDuration(state, end);
|
|
250
670
|
}
|
|
251
671
|
function scheduleShader(state, start, end, effect) {
|
|
252
672
|
const shaderFx = {
|
|
253
673
|
type: 'shader',
|
|
254
|
-
id: nextId('shader'),
|
|
674
|
+
id: nextId(state, 'shader'),
|
|
255
675
|
shaderSource: effect.shaderSource,
|
|
256
676
|
start,
|
|
257
677
|
end,
|
|
258
678
|
params: { ...effect.params },
|
|
259
679
|
};
|
|
260
680
|
state.ir.effects.push(shaderFx);
|
|
261
|
-
bumpDuration(state, end);
|
|
262
681
|
}
|
|
263
682
|
function scheduleTransition(state, start, end, effect) {
|
|
264
683
|
const opts = effect.options;
|
|
265
684
|
const surface = {
|
|
266
|
-
id: nextId('surface'),
|
|
685
|
+
id: nextId(state, 'surface'),
|
|
267
686
|
type: 'transition',
|
|
268
687
|
start,
|
|
269
688
|
end,
|
|
@@ -281,6 +700,8 @@ function scheduleTransition(state, start, end, effect) {
|
|
|
281
700
|
x: opts.x,
|
|
282
701
|
y: opts.y,
|
|
283
702
|
opacity: opts.opacity ?? 1,
|
|
703
|
+
...(opts.inOffset !== undefined ? { inOffset: toSeconds(opts.inOffset) } : {}),
|
|
704
|
+
...(opts.outOffset !== undefined ? { outOffset: toSeconds(opts.outOffset) } : {}),
|
|
284
705
|
},
|
|
285
706
|
metadata: {
|
|
286
707
|
package: '@scenerok/transitions',
|
|
@@ -288,8 +709,6 @@ function scheduleTransition(state, start, end, effect) {
|
|
|
288
709
|
},
|
|
289
710
|
};
|
|
290
711
|
state.ir.surfaces.push(surface);
|
|
291
|
-
state.visualCursor = Math.max(state.visualCursor, end);
|
|
292
|
-
bumpDuration(state, end);
|
|
293
712
|
}
|
|
294
713
|
/**
|
|
295
714
|
* Post-pass: resolve fromId/toId for transition surfaces from neighboring clips.
|
|
@@ -301,143 +720,182 @@ export function bindTransitions(ir, warnings = []) {
|
|
|
301
720
|
continue;
|
|
302
721
|
const Ts = surface.start;
|
|
303
722
|
const Te = surface.end;
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
}
|
|
316
|
-
const atTo = ir.clips.filter((c) => c.start <= Te && c.end >= Te);
|
|
317
|
-
let toId;
|
|
318
|
-
if (atTo.length === 0) {
|
|
319
|
-
toId = null;
|
|
320
|
-
}
|
|
321
|
-
else if (atTo.length === 1) {
|
|
322
|
-
toId = atTo[0].id;
|
|
323
|
-
}
|
|
324
|
-
else {
|
|
325
|
-
toId = null;
|
|
326
|
-
warnings.push(`transition at ${Te}s: multiple clips active at the end seam; transitions are only defined at a clean cut — use Overlay/Composite for layered clips`);
|
|
327
|
-
}
|
|
723
|
+
const side = (t, edge) => {
|
|
724
|
+
const active = ir.clips.filter((c) => c.start <= t && c.end >= t);
|
|
725
|
+
if (active.length === 1)
|
|
726
|
+
return active[0].id;
|
|
727
|
+
if (active.length > 1) {
|
|
728
|
+
warnings.push(`transition at ${t}s: multiple clips active at the ${edge} seam; transitions are only defined at a clean cut — use blend for layered clips`);
|
|
729
|
+
}
|
|
730
|
+
return null;
|
|
731
|
+
};
|
|
732
|
+
const fromId = side(Ts, 'start');
|
|
733
|
+
const toId = side(Te, 'end');
|
|
328
734
|
const data = surface.data;
|
|
329
|
-
if (fromId && toId && fromId === toId)
|
|
735
|
+
if (fromId && toId && fromId === toId)
|
|
330
736
|
data.self = true;
|
|
331
|
-
}
|
|
332
737
|
const userMode = typeof data.mode === 'string' ? data.mode : '';
|
|
333
|
-
if (!userMode)
|
|
738
|
+
if (!userMode)
|
|
334
739
|
data.mode = fromId && toId ? 'roll' : 'freeze';
|
|
335
|
-
}
|
|
336
740
|
data.fromId = fromId;
|
|
337
741
|
data.toId = toId;
|
|
338
742
|
data.progressStart = Ts;
|
|
339
743
|
data.progressEnd = Te;
|
|
340
744
|
}
|
|
341
745
|
}
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
const
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
state.ir.layers.push({
|
|
358
|
-
...layer,
|
|
359
|
-
id: nextId(layer.type === 'text' ? 'text' : 'layer'),
|
|
360
|
-
start: remap(layer.start),
|
|
361
|
-
end: remap(layer.end),
|
|
362
|
-
});
|
|
363
|
-
}
|
|
364
|
-
for (const effect of nestedIr.effects) {
|
|
365
|
-
state.ir.effects.push({
|
|
366
|
-
...effect,
|
|
367
|
-
id: nextId('fx'),
|
|
368
|
-
start: remap(effect.start),
|
|
369
|
-
end: remap(effect.end),
|
|
370
|
-
});
|
|
746
|
+
const RECORD = Symbol.for('scenerok.timeline.record');
|
|
747
|
+
export function timelineRecordOf(value) {
|
|
748
|
+
const record = value[RECORD];
|
|
749
|
+
if (!record)
|
|
750
|
+
throw new TypeError('Not a SceneRok timeline');
|
|
751
|
+
return record;
|
|
752
|
+
}
|
|
753
|
+
export { RECORD as TIMELINE_RECORD };
|
|
754
|
+
/** Lower a recorded timeline into a fresh builder on `canvas`. Transitions are not bound. */
|
|
755
|
+
export function lowerTimeline(record, canvas, ids = { next: 0 }) {
|
|
756
|
+
const state = createIrBuilder(record.options, canvas, ids);
|
|
757
|
+
if (record.grid)
|
|
758
|
+
setGrid(state, record.grid);
|
|
759
|
+
for (const entry of record.entries) {
|
|
760
|
+
scheduleAt(state, entry.start, entry.end, entry.value);
|
|
371
761
|
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
762
|
+
return state;
|
|
763
|
+
}
|
|
764
|
+
function isTimelineView(value) {
|
|
765
|
+
return 'placement' in value && 'timeline' in value;
|
|
766
|
+
}
|
|
767
|
+
function scheduleNestedTimeline(state, start, end, value) {
|
|
768
|
+
const view = isTimelineView(value) ? value : null;
|
|
769
|
+
const nested = view ? view.timeline : value;
|
|
770
|
+
const placement = view?.placement ?? {};
|
|
771
|
+
if (placement.enabled === false)
|
|
772
|
+
return end;
|
|
773
|
+
const record = timelineRecordOf(nested);
|
|
774
|
+
// A nested timeline without its own size or rate lays out on its parent's canvas.
|
|
775
|
+
const child = lowerTimeline(record, { width: state.ir.width, height: state.ir.height, fps: state.ir.fps }, state.ids);
|
|
776
|
+
const childIr = child.ir;
|
|
777
|
+
const extentEnd = start + childIr.duration;
|
|
778
|
+
const effects = view?.effects ?? [];
|
|
779
|
+
const crop = view?.cropRect;
|
|
780
|
+
const mask = view?.maskSpec;
|
|
781
|
+
const sized = childIr.width !== state.ir.width || childIr.height !== state.ir.height;
|
|
782
|
+
// Properties, a grade (its own or applied), a crop, a mask, or its own canvas
|
|
783
|
+
// size: the timeline draws as one group. Otherwise it joins flat — same pixels.
|
|
784
|
+
const grouped = Object.keys(placement).length > 0 || effects.length > 0 || crop !== undefined
|
|
785
|
+
|| mask !== undefined || childIr.effects.length > 0 || sized;
|
|
786
|
+
const marks = markIr(state.ir);
|
|
787
|
+
const shift = (x) => shiftTimed(x, start);
|
|
788
|
+
state.ir.clips.push(...childIr.clips.map(shift));
|
|
789
|
+
state.ir.layers.push(...childIr.layers.map((l) => (l.type === 'text' ? shift(l) : { ...l, start: l.start + start, end: l.end + start })));
|
|
790
|
+
state.ir.surfaces.push(...childIr.surfaces.map(shift));
|
|
791
|
+
if (childIr.groups?.length)
|
|
792
|
+
(state.ir.groups ??= []).push(...childIr.groups.map(shift));
|
|
793
|
+
state.ir.effects.push(...childIr.effects.map(shift));
|
|
794
|
+
state.ir.audio.push(...childIr.audio.map((a) => ({
|
|
795
|
+
...a,
|
|
796
|
+
start: a.start + start,
|
|
797
|
+
...(a.animations ? { animations: a.animations.map((x) => ({ ...x, start: x.start + start, end: x.end + start })) } : {}),
|
|
798
|
+
...(a.motion ? { motion: a.motion.map((t) => ({ ...t, keyframes: t.keyframes.map((k) => ({ ...k, time: k.time + start })) })) } : {}),
|
|
799
|
+
})));
|
|
800
|
+
if (childIr.markers?.length) {
|
|
801
|
+
(state.ir.markers ??= []).push(...childIr.markers.map((m) => ({ ...m, time: m.time + start })));
|
|
379
802
|
}
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
803
|
+
let placedEnd = extentEnd;
|
|
804
|
+
if (grouped) {
|
|
805
|
+
// The group lives for the placement's window; a longer body is cut off at it.
|
|
806
|
+
placedEnd = end > start ? Math.min(extentEnd, end) : extentEnd;
|
|
807
|
+
const group = wrapInGroup(state, marks, start, placedEnd, {
|
|
808
|
+
props: placement,
|
|
809
|
+
effects,
|
|
810
|
+
crop,
|
|
811
|
+
size: { width: childIr.width, height: childIr.height },
|
|
386
812
|
});
|
|
813
|
+
if (mask)
|
|
814
|
+
attachMask(state, group, mask);
|
|
387
815
|
}
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
state.visualCursor = Math.max(state.visualCursor, end);
|
|
396
|
-
bumpDuration(state, end);
|
|
816
|
+
state.ir.timelineUses = [...(state.ir.timelineUses ?? []), {
|
|
817
|
+
name: nested.options.name ?? 'nested',
|
|
818
|
+
sourceOffset: 0,
|
|
819
|
+
start,
|
|
820
|
+
end: placedEnd,
|
|
821
|
+
}];
|
|
822
|
+
return placedEnd;
|
|
397
823
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
824
|
+
/** Lower one placement into `state.ir`. Returns where it ended. */
|
|
825
|
+
export function scheduleAt(state, rawStart, rawEnd, value, options = {}) {
|
|
826
|
+
if (rawEnd < rawStart) {
|
|
827
|
+
throw new RangeError(`Invalid range: end (${rawEnd}) < start (${rawStart})`);
|
|
401
828
|
}
|
|
829
|
+
// Block boundaries are frame boundaries of the output, as in VidScript.
|
|
830
|
+
const snap = options.snap !== false;
|
|
831
|
+
const start = snap ? snapToFrame(rawStart, state.timeBase) : rawStart;
|
|
832
|
+
const end = snap ? Math.max(start, snapToFrame(rawEnd, state.timeBase)) : rawEnd;
|
|
833
|
+
let placedEnd = end;
|
|
834
|
+
let channel = 'visual';
|
|
402
835
|
switch (value.kind) {
|
|
403
836
|
case 'video':
|
|
404
|
-
|
|
837
|
+
case 'image':
|
|
838
|
+
placedEnd = scheduleMedia(state, start, end, value);
|
|
405
839
|
break;
|
|
406
840
|
case 'text':
|
|
407
841
|
scheduleText(state, start, end, value);
|
|
408
842
|
break;
|
|
843
|
+
case 'solid':
|
|
844
|
+
scheduleSolid(state, start, end, value);
|
|
845
|
+
break;
|
|
409
846
|
case 'audio':
|
|
410
|
-
scheduleAudio(state, start, end, value);
|
|
847
|
+
placedEnd = scheduleAudio(state, start, end, value);
|
|
848
|
+
channel = 'audio';
|
|
849
|
+
break;
|
|
850
|
+
case 'gap':
|
|
851
|
+
break;
|
|
852
|
+
case 'marker':
|
|
853
|
+
scheduleMarker(state, start, end, value);
|
|
854
|
+
channel = 'none';
|
|
411
855
|
break;
|
|
412
856
|
case 'filter':
|
|
413
857
|
scheduleFilter(state, start, end, value);
|
|
858
|
+
channel = 'none';
|
|
414
859
|
break;
|
|
415
860
|
case 'shader':
|
|
416
861
|
scheduleShader(state, start, end, value);
|
|
862
|
+
channel = 'none';
|
|
417
863
|
break;
|
|
418
864
|
case 'transition':
|
|
419
865
|
scheduleTransition(state, start, end, value);
|
|
420
866
|
break;
|
|
421
867
|
case 'timeline':
|
|
422
|
-
scheduleNestedTimeline(state, start, end, value);
|
|
868
|
+
placedEnd = scheduleNestedTimeline(state, start, end, value);
|
|
423
869
|
break;
|
|
424
870
|
default:
|
|
425
871
|
throw new TypeError(`Unsupported schedulable kind: ${value.kind}`);
|
|
426
872
|
}
|
|
873
|
+
if (options.advance !== false) {
|
|
874
|
+
if (channel === 'visual')
|
|
875
|
+
state.visualCursor = Math.max(state.visualCursor, placedEnd);
|
|
876
|
+
if (channel === 'audio')
|
|
877
|
+
state.audioCursor = Math.max(state.audioCursor, placedEnd);
|
|
878
|
+
}
|
|
879
|
+
bumpDuration(state, Math.max(end, placedEnd));
|
|
880
|
+
return placedEnd;
|
|
427
881
|
}
|
|
428
|
-
|
|
882
|
+
/** The span `push` gives a value with no explicit duration. */
|
|
883
|
+
export function inferAutoDuration(value, explicit, fps) {
|
|
429
884
|
if (explicit !== undefined)
|
|
430
|
-
return toSeconds(explicit);
|
|
885
|
+
return toSeconds(explicit, fps);
|
|
431
886
|
switch (value.kind) {
|
|
432
887
|
case 'video':
|
|
433
888
|
case 'audio':
|
|
434
|
-
return value.duration;
|
|
435
889
|
case 'timeline':
|
|
436
|
-
return value.duration;
|
|
437
890
|
case 'transition':
|
|
438
891
|
return value.duration;
|
|
892
|
+
case 'image':
|
|
439
893
|
case 'text':
|
|
440
|
-
|
|
894
|
+
case 'solid':
|
|
895
|
+
return DEFAULT_STILL_LENGTH;
|
|
896
|
+
case 'marker':
|
|
897
|
+
case 'gap':
|
|
898
|
+
return 0;
|
|
441
899
|
case 'filter':
|
|
442
900
|
case 'shader':
|
|
443
901
|
return 1;
|
|
@@ -447,6 +905,6 @@ export function inferAutoDuration(value, explicit) {
|
|
|
447
905
|
}
|
|
448
906
|
export function assertSchedulable(value) {
|
|
449
907
|
if (!isSchedulable(value)) {
|
|
450
|
-
throw new TypeError('Timeline assignment RHS must be
|
|
908
|
+
throw new TypeError('Timeline assignment RHS must be an SDK node (video/image/text/audio/solid/gap/marker/filter/shader/transition/timeline)');
|
|
451
909
|
}
|
|
452
910
|
}
|