@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.
Files changed (43) hide show
  1. package/README.md +63 -1
  2. package/dist/describe.d.ts +87 -0
  3. package/dist/describe.d.ts.map +1 -0
  4. package/dist/describe.js +210 -0
  5. package/dist/duration.d.ts +9 -3
  6. package/dist/duration.d.ts.map +1 -1
  7. package/dist/duration.js +22 -5
  8. package/dist/frame-rate.d.ts +30 -0
  9. package/dist/frame-rate.d.ts.map +1 -0
  10. package/dist/frame-rate.js +73 -0
  11. package/dist/index.d.ts +9 -6
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/index.js +7 -4
  14. package/dist/ir-builder.d.ts +61 -8
  15. package/dist/ir-builder.d.ts.map +1 -1
  16. package/dist/ir-builder.js +714 -256
  17. package/dist/ir.d.ts +203 -2
  18. package/dist/ir.d.ts.map +1 -1
  19. package/dist/ir.js +4 -2
  20. package/dist/media.d.ts +49 -5
  21. package/dist/media.d.ts.map +1 -1
  22. package/dist/media.js +223 -33
  23. package/dist/motion-tracks.d.ts +10 -0
  24. package/dist/motion-tracks.d.ts.map +1 -0
  25. package/dist/motion-tracks.js +150 -0
  26. package/dist/motion.d.ts +34 -0
  27. package/dist/motion.d.ts.map +1 -1
  28. package/dist/motion.js +51 -11
  29. package/dist/plugins/invoke.d.ts.map +1 -1
  30. package/dist/plugins/invoke.js +5 -2
  31. package/dist/timeline.d.ts +3 -0
  32. package/dist/timeline.d.ts.map +1 -1
  33. package/dist/timeline.js +101 -41
  34. package/dist/types.d.ts +214 -28
  35. package/dist/types.d.ts.map +1 -1
  36. package/dist/types.js +8 -9
  37. package/package.json +2 -2
  38. package/skills/scenerok/SKILL.md +5 -2
  39. package/skills/scenerok/references/catalog-index.json +1 -1
  40. package/skills/scenerok/references/editing-actions.md +43 -1
  41. package/skills/scenerok/references/sdk-guide.md +149 -49
  42. package/skills/scenerok/references/vidscript-guide.md +49 -13
  43. package/skills/scenerok/references/vidscript-strict.md +1 -1
@@ -1,18 +1,99 @@
1
1
  /**
2
- * Shared IR builder — appends SDK nodes onto an IRTimeline.
3
- * Used by @scenerok/sdk Timeline and (later) optionally by the VidScript compiler.
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
- let idCounter = 0;
10
- export function resetIrBuilderIds(seed = 0) {
11
- idCounter = seed;
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 nextId(prefix) {
14
- idCounter += 1;
15
- return `${prefix}-${idCounter}`;
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 defaultTextStyle(partial = {}) {
26
- return {
27
- font: 'Inter',
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 (descriptor.type === 'fade'
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 resolveAnimationWindow(descriptor, blockStart, blockEnd) {
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
- const alignEnd = descriptor.align === 'end' || isExitFade(descriptor);
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 lowerAnimations(descriptors, blockStart, blockEnd) {
64
- const animations = [];
65
- const motion = [];
66
- for (const descriptor of descriptors) {
67
- const window = resolveAnimationWindow(descriptor, blockStart, blockEnd);
68
- const id = nextId('anim');
69
- animations.push({
70
- id,
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
- // Lower common opacity / transform props into motion tracks (preview/render parity).
81
- for (const key of Object.keys({ ...descriptor.from, ...descriptor.to })) {
82
- const fromVal = descriptor.from[key];
83
- const toVal = descriptor.to[key];
84
- if (fromVal === undefined && toVal === undefined)
85
- continue;
86
- motion.push({
87
- id: nextId('motion'),
88
- target: key,
89
- sourceAnimationId: id,
90
- plugin: descriptor.plugin,
91
- keyframes: [
92
- { time: window.start, value: (fromVal ?? toVal) },
93
- { time: window.end, value: (toVal ?? fromVal) },
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
- return { animations, motion };
363
+ (ir.groups ??= []).push(group);
364
+ return group;
99
365
  }
100
- export function createIrBuilder(options = {}) {
101
- const ir = defaultIRTimeline();
102
- if (options.width != null)
103
- ir.width = options.width;
104
- if (options.height != null)
105
- ir.height = options.height;
106
- if (options.fps != null)
107
- ir.fps = options.fps;
108
- if (options.background != null)
109
- ir.background = options.background;
110
- const out = options.output ?? {};
111
- ir.output = {
112
- ...ir.output,
113
- path: out.path ?? ir.output.path,
114
- format: out.format ?? ir.output.format,
115
- codec: out.codec ?? ir.output.codec,
116
- bitrate: out.bitrate ?? ir.output.bitrate,
117
- resolution: out.resolution
118
- ?? `${ir.width}x${ir.height}`,
119
- fps: out.fps ?? ir.fps,
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
- /** Declare or replace the timeline grid used by `area:` text placement. */
124
- export function setGrid(state, spec) {
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
- function resolveTextAreaPlacement(state, areaName) {
130
- const areas = state.gridAreas
131
- ?? resolveGridAreas(defaultGridSpec(state.ir.width, state.ir.height));
132
- const rect = areas.get(areaName);
133
- if (!rect) {
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
- return placementFromGridArea(rect);
400
+ const marks = markIr(state.ir);
401
+ place();
402
+ wrapInGroup(state, marks, start, end, { props: { blend }, effects: [] });
137
403
  }
138
- function bumpDuration(state, end) {
139
- if (end > state.ir.duration)
140
- state.ir.duration = end;
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 scheduleVideo(state, start, end, clip) {
143
- const { animations, motion } = lowerAnimations(clip.animations, start, end);
144
- const irClip = {
145
- id: nextId('clip'),
146
- source: clip.source,
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: clip.options.trimStart != null ? toSeconds(clip.options.trimStart) : 0,
150
- trimEnd: clip.options.trimEnd != null ? toSeconds(clip.options.trimEnd) : 0,
151
- speed: clip.options.speed ?? 1,
152
- loop: clip.options.loop ?? 0,
153
- opacity: clip.options.opacity ?? 1,
154
- width: clip.options.width ?? state.ir.width,
155
- height: clip.options.height ?? state.ir.height,
156
- x: clip.options.x ?? 0,
157
- y: clip.options.y ?? 0,
158
- animations: animations.length ? animations : undefined,
159
- motion: motion.length ? motion : undefined,
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
- const { animations, motion } = lowerAnimations(layer.animations, start, end);
168
- // Resolve `area:` like VidScript: center in the cell and cap maxWidth/maxHeight.
169
- // Explicit x/y/maxWidth/maxHeight win over the area.
170
- let areaX;
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 ?? areaX ?? '50%',
190
- y: opts.y ?? areaY ?? '50%',
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
- maxWidth: opts.maxWidth ?? areaMaxWidth,
200
- maxHeight: opts.maxHeight ?? areaMaxHeight,
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
- const textLayer = {
209
- type: 'text',
210
- id: nextId('text'),
211
- content: layer.content,
212
- start,
213
- end,
214
- style,
215
- animations: animations.length ? animations : undefined,
216
- motion: motion.length ? motion : undefined,
217
- };
218
- state.ir.layers.push(textLayer);
219
- state.visualCursor = Math.max(state.visualCursor, end);
220
- bumpDuration(state, end);
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 duration = Math.max(0, end - start);
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: track.options.volume ?? 1,
230
- fadeIn: track.options.fadeIn != null ? toSeconds(track.options.fadeIn) : 0,
231
- fadeOut: track.options.fadeOut != null ? toSeconds(track.options.fadeOut) : 0,
232
- loudnessNormalization: track.options.loudnessNormalization,
233
- targetLufs: track.options.targetLufs,
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
- state.audioCursor = Math.max(state.audioCursor, end);
237
- bumpDuration(state, end);
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 atFrom = ir.clips.filter((c) => c.start <= Ts && c.end >= Ts);
305
- let fromId;
306
- if (atFrom.length === 0) {
307
- fromId = null;
308
- }
309
- else if (atFrom.length === 1) {
310
- fromId = atFrom[0].id;
311
- }
312
- else {
313
- fromId = null;
314
- warnings.push(`transition at ${Ts}s: multiple clips active at the start seam; transitions are only defined at a clean cut — use Overlay/Composite for layered clips`);
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
- function scheduleNestedTimeline(state, start, end, nested) {
343
- const nestedIr = nested.toIR();
344
- const offset = start;
345
- const span = Math.max(0, end - start);
346
- const scale = nestedIr.duration > 0 ? span / nestedIr.duration : 1;
347
- const remap = (t) => offset + t * scale;
348
- for (const clip of nestedIr.clips) {
349
- state.ir.clips.push({
350
- ...clip,
351
- id: nextId('clip'),
352
- start: remap(clip.start),
353
- end: remap(clip.end),
354
- });
355
- }
356
- for (const layer of nestedIr.layers) {
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
- for (const audio of nestedIr.audio) {
373
- state.ir.audio.push({
374
- ...audio,
375
- id: nextId('audio'),
376
- start: remap(audio.start),
377
- duration: audio.duration * scale,
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
- for (const surface of nestedIr.surfaces) {
381
- state.ir.surfaces.push({
382
- ...surface,
383
- id: nextId('surface'),
384
- start: remap(surface.start),
385
- end: remap(surface.end),
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
- const use = {
389
- name: nested.options.name ?? 'nested',
390
- sourceOffset: 0,
391
- start,
392
- end,
393
- };
394
- state.ir.timelineUses = [...(state.ir.timelineUses ?? []), use];
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
- export function scheduleAt(state, start, end, value) {
399
- if (end < start) {
400
- throw new RangeError(`Invalid range: end (${end}) < start (${start})`);
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
- scheduleVideo(state, start, end, value);
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
- export function inferAutoDuration(value, explicit) {
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
- return 3;
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 a SDK media node (video/text/audio/filter/shader/transition/timeline)');
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
  }