@kitsra/kavio-browser-renderer 0.1.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/LICENSE +94 -0
- package/dist/index.d.ts +105 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1561 -0
- package/package.json +31 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1561 @@
|
|
|
1
|
+
import { evaluateEasing, evaluateLayer, evaluateTransitionSeries, getCanvasDimensions, isLayerActive, resolvePoint } from "@kitsra/kavio-core";
|
|
2
|
+
export function createBrowserRenderer(options = {}) {
|
|
3
|
+
let loaded;
|
|
4
|
+
let resources = emptyCompositionResources();
|
|
5
|
+
return {
|
|
6
|
+
get ready() {
|
|
7
|
+
return resources.ready;
|
|
8
|
+
},
|
|
9
|
+
async loadComposition(composition) {
|
|
10
|
+
releaseCompositionResources(resources);
|
|
11
|
+
loaded = cloneComposition(composition);
|
|
12
|
+
resources = prepareCompositionResources(loaded, options);
|
|
13
|
+
resources.ready.catch(() => undefined);
|
|
14
|
+
return getLoadedComposition(loaded);
|
|
15
|
+
},
|
|
16
|
+
async renderFrame(frame) {
|
|
17
|
+
if (!loaded) {
|
|
18
|
+
throw new Error("No composition loaded.");
|
|
19
|
+
}
|
|
20
|
+
assertRenderableFrame(frame, loaded);
|
|
21
|
+
await resources.ready;
|
|
22
|
+
return renderCompositionFrame(loaded, frame, options);
|
|
23
|
+
}
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
export function installBrowserRendererRuntime(options = {}) {
|
|
27
|
+
const host = getRuntimeHost(options.document);
|
|
28
|
+
const renderer = createBrowserRenderer(options);
|
|
29
|
+
const runtime = {
|
|
30
|
+
get ready() {
|
|
31
|
+
return renderer.ready;
|
|
32
|
+
},
|
|
33
|
+
loadComposition: (composition) => renderer.loadComposition(composition),
|
|
34
|
+
renderFrame: (frame) => renderer.renderFrame(frame),
|
|
35
|
+
createPreviewController: (controllerOptions) => createBrowserPreviewController(withDefinedOptions({
|
|
36
|
+
document: controllerOptions?.document ?? options.document,
|
|
37
|
+
root: controllerOptions?.root ?? options.root,
|
|
38
|
+
controlsRoot: controllerOptions?.controlsRoot,
|
|
39
|
+
loop: controllerOptions?.loop,
|
|
40
|
+
frameStep: controllerOptions?.frameStep,
|
|
41
|
+
runtime
|
|
42
|
+
}))
|
|
43
|
+
};
|
|
44
|
+
Object.defineProperty(host, "__kavio", {
|
|
45
|
+
configurable: true,
|
|
46
|
+
enumerable: false,
|
|
47
|
+
value: runtime,
|
|
48
|
+
writable: false
|
|
49
|
+
});
|
|
50
|
+
return runtime;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Pure render-harness HTML generator (no Node APIs). Produces a page that maps
|
|
54
|
+
* `@kitsra/kavio-core` and the browser renderer to server-vendored module URLs, fetches
|
|
55
|
+
* `/composition.json`, installs the runtime on `window.__kavio`, and signals
|
|
56
|
+
* `window.__kavioReady`. Shared by the CLI `preview` command and the render
|
|
57
|
+
* worker's `PlaywrightDriver` so preview and export paint the identical page.
|
|
58
|
+
*/
|
|
59
|
+
export function createRenderHarnessHtml(options) {
|
|
60
|
+
const importmap = JSON.stringify({ imports: { "@kitsra/kavio-core": "/vendor/core/index.js" } });
|
|
61
|
+
return `<!doctype html>
|
|
62
|
+
<html lang="en">
|
|
63
|
+
<head>
|
|
64
|
+
<meta charset="utf-8">
|
|
65
|
+
<title>Kavio Render Harness</title>
|
|
66
|
+
<script type="importmap">${importmap}</script>
|
|
67
|
+
<style>
|
|
68
|
+
html, body { margin: 0; padding: 0; background: transparent; }
|
|
69
|
+
#stage { width: ${options.width}px; height: ${options.height}px; position: relative; overflow: hidden; }
|
|
70
|
+
</style>
|
|
71
|
+
</head>
|
|
72
|
+
<body>
|
|
73
|
+
<div id="stage" data-kavio-runtime-root="true"></div>
|
|
74
|
+
<script type="module">
|
|
75
|
+
import { installBrowserRendererRuntime } from "/vendor/browser-renderer/index.js";
|
|
76
|
+
const stage = document.getElementById("stage");
|
|
77
|
+
const runtime = installBrowserRendererRuntime({ root: stage });
|
|
78
|
+
const composition = await fetch("/composition.json").then((response) => response.json());
|
|
79
|
+
await runtime.loadComposition(composition);
|
|
80
|
+
window.__kavioReady = true;
|
|
81
|
+
</script>
|
|
82
|
+
</body>
|
|
83
|
+
</html>
|
|
84
|
+
`;
|
|
85
|
+
}
|
|
86
|
+
export function createBrowserPreviewController(options = {}) {
|
|
87
|
+
const runtime = options.runtime ??
|
|
88
|
+
installBrowserRendererRuntime(withDefinedOptions({ document: options.document, root: options.root }));
|
|
89
|
+
const loop = options.loop ?? true;
|
|
90
|
+
const frameStep = Math.max(1, Math.trunc(options.frameStep ?? 1));
|
|
91
|
+
let sourceComposition;
|
|
92
|
+
let previewComposition;
|
|
93
|
+
let playTimer;
|
|
94
|
+
const state = {
|
|
95
|
+
frame: 0,
|
|
96
|
+
playing: false,
|
|
97
|
+
safeZonesVisible: false,
|
|
98
|
+
exportIndex: null,
|
|
99
|
+
exportName: null,
|
|
100
|
+
width: 0,
|
|
101
|
+
height: 0,
|
|
102
|
+
fps: 30,
|
|
103
|
+
durationFrames: 0
|
|
104
|
+
};
|
|
105
|
+
const controls = options.controlsRoot ? createPreviewControls(options.controlsRoot.ownerDocument) : null;
|
|
106
|
+
if (controls && options.controlsRoot) {
|
|
107
|
+
options.controlsRoot.replaceChildren(controls.element);
|
|
108
|
+
controls.bind({
|
|
109
|
+
onFrameInput: (frame) => {
|
|
110
|
+
void setFrame(frame);
|
|
111
|
+
},
|
|
112
|
+
onPlayToggle: () => togglePlayback(),
|
|
113
|
+
onSafeZoneToggle: (visible) => setSafeZonesVisible(visible),
|
|
114
|
+
onExportChange: (selection) => {
|
|
115
|
+
void setPreviewExport(selection);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
async function loadComposition(composition, loadOptions = {}) {
|
|
120
|
+
pause();
|
|
121
|
+
sourceComposition = cloneComposition(composition);
|
|
122
|
+
const preview = createExportPreviewComposition(sourceComposition, loadOptions.export ?? 0);
|
|
123
|
+
previewComposition = preview.composition;
|
|
124
|
+
state.exportIndex = preview.exportIndex;
|
|
125
|
+
state.exportName = preview.exportPreset?.name ?? null;
|
|
126
|
+
const loaded = await runtime.loadComposition(previewComposition);
|
|
127
|
+
updateLoadedState(loaded);
|
|
128
|
+
state.frame = clampFrame(state.frame, state.durationFrames);
|
|
129
|
+
updateControls();
|
|
130
|
+
await renderFrame(state.frame);
|
|
131
|
+
return loaded;
|
|
132
|
+
}
|
|
133
|
+
async function renderFrame(frame = state.frame) {
|
|
134
|
+
assertPreviewLoaded(previewComposition);
|
|
135
|
+
const nextFrame = clampFrame(frame, state.durationFrames);
|
|
136
|
+
const rendered = await runtime.renderFrame(nextFrame);
|
|
137
|
+
state.frame = rendered.frame;
|
|
138
|
+
applyPreviewSafeZones(getPreviewRoot(options), state.safeZonesVisible, rendered);
|
|
139
|
+
updateControls();
|
|
140
|
+
return rendered;
|
|
141
|
+
}
|
|
142
|
+
async function setFrame(frame) {
|
|
143
|
+
return renderFrame(frame);
|
|
144
|
+
}
|
|
145
|
+
async function step(delta = frameStep) {
|
|
146
|
+
const next = nextPreviewFrame(state.frame, delta, state.durationFrames, loop);
|
|
147
|
+
if (next === null) {
|
|
148
|
+
pause();
|
|
149
|
+
return renderFrame(state.frame);
|
|
150
|
+
}
|
|
151
|
+
return renderFrame(next);
|
|
152
|
+
}
|
|
153
|
+
function play() {
|
|
154
|
+
assertPreviewLoaded(previewComposition);
|
|
155
|
+
if (state.playing) {
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
state.playing = true;
|
|
159
|
+
updateControls();
|
|
160
|
+
const delay = Math.max(1, Math.round(1000 / Math.max(1, state.fps)));
|
|
161
|
+
playTimer = setInterval(() => {
|
|
162
|
+
void step(frameStep).catch(() => pause());
|
|
163
|
+
}, delay);
|
|
164
|
+
}
|
|
165
|
+
function pause() {
|
|
166
|
+
if (playTimer !== undefined) {
|
|
167
|
+
clearInterval(playTimer);
|
|
168
|
+
playTimer = undefined;
|
|
169
|
+
}
|
|
170
|
+
state.playing = false;
|
|
171
|
+
updateControls();
|
|
172
|
+
}
|
|
173
|
+
function togglePlayback(force) {
|
|
174
|
+
const shouldPlay = force ?? !state.playing;
|
|
175
|
+
if (shouldPlay) {
|
|
176
|
+
play();
|
|
177
|
+
}
|
|
178
|
+
else {
|
|
179
|
+
pause();
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
function setSafeZonesVisible(visible) {
|
|
183
|
+
state.safeZonesVisible = visible;
|
|
184
|
+
if (state.durationFrames > 0) {
|
|
185
|
+
applyPreviewSafeZones(getPreviewRoot(options), visible, {
|
|
186
|
+
frame: state.frame,
|
|
187
|
+
width: state.width,
|
|
188
|
+
height: state.height,
|
|
189
|
+
layers: []
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
updateControls();
|
|
193
|
+
}
|
|
194
|
+
async function setPreviewExport(selection) {
|
|
195
|
+
assertPreviewLoaded(sourceComposition);
|
|
196
|
+
pause();
|
|
197
|
+
const preview = createExportPreviewComposition(sourceComposition, selection);
|
|
198
|
+
previewComposition = preview.composition;
|
|
199
|
+
state.exportIndex = preview.exportIndex;
|
|
200
|
+
state.exportName = preview.exportPreset?.name ?? null;
|
|
201
|
+
const loaded = await runtime.loadComposition(previewComposition);
|
|
202
|
+
updateLoadedState(loaded);
|
|
203
|
+
state.frame = clampFrame(state.frame, state.durationFrames);
|
|
204
|
+
updateControls();
|
|
205
|
+
await renderFrame(state.frame);
|
|
206
|
+
return loaded;
|
|
207
|
+
}
|
|
208
|
+
function destroy() {
|
|
209
|
+
pause();
|
|
210
|
+
applyPreviewSafeZones(getPreviewRoot(options), false, {
|
|
211
|
+
frame: state.frame,
|
|
212
|
+
width: state.width,
|
|
213
|
+
height: state.height,
|
|
214
|
+
layers: []
|
|
215
|
+
});
|
|
216
|
+
controls?.element.remove();
|
|
217
|
+
}
|
|
218
|
+
function updateLoadedState(loaded) {
|
|
219
|
+
state.width = loaded.width;
|
|
220
|
+
state.height = loaded.height;
|
|
221
|
+
state.fps = loaded.fps;
|
|
222
|
+
state.durationFrames = loaded.durationFrames;
|
|
223
|
+
}
|
|
224
|
+
function updateControls() {
|
|
225
|
+
controls?.update(state, sourceComposition?.exports ?? []);
|
|
226
|
+
}
|
|
227
|
+
return {
|
|
228
|
+
state,
|
|
229
|
+
controls: controls?.element ?? null,
|
|
230
|
+
loadComposition,
|
|
231
|
+
renderFrame,
|
|
232
|
+
setFrame,
|
|
233
|
+
step,
|
|
234
|
+
play,
|
|
235
|
+
pause,
|
|
236
|
+
togglePlayback,
|
|
237
|
+
setSafeZonesVisible,
|
|
238
|
+
setPreviewExport,
|
|
239
|
+
destroy
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
export function createExportPreviewComposition(composition, selection = 0) {
|
|
243
|
+
const exportIndex = findPreviewExportIndex(composition.exports, selection);
|
|
244
|
+
const exportPreset = exportIndex === null ? null : (composition.exports[exportIndex] ?? null);
|
|
245
|
+
const preview = cloneComposition(composition);
|
|
246
|
+
if (!exportPreset) {
|
|
247
|
+
return {
|
|
248
|
+
composition: preview,
|
|
249
|
+
exportPreset,
|
|
250
|
+
exportIndex
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
preview.composition.width = exportPreset.width;
|
|
254
|
+
preview.composition.height = exportPreset.height;
|
|
255
|
+
preview.composition.fps = exportPreset.fps ?? preview.composition.fps;
|
|
256
|
+
preview.exports = [cloneJson(exportPreset)];
|
|
257
|
+
if (exportPreset.layerOverrides) {
|
|
258
|
+
preview.layers = preview.layers.map((layer) => applyPreviewLayerOverride(layer, exportPreset.layerOverrides?.[layer.id]));
|
|
259
|
+
}
|
|
260
|
+
return {
|
|
261
|
+
composition: preview,
|
|
262
|
+
exportPreset: cloneJson(exportPreset),
|
|
263
|
+
exportIndex
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
export function nextPreviewFrame(frame, delta, durationFrames, loop = true) {
|
|
267
|
+
if (durationFrames <= 0) {
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
const step = Math.trunc(delta);
|
|
271
|
+
if (step === 0) {
|
|
272
|
+
return clampFrame(frame, durationFrames);
|
|
273
|
+
}
|
|
274
|
+
const next = frame + step;
|
|
275
|
+
if (next >= 0 && next < durationFrames) {
|
|
276
|
+
return next;
|
|
277
|
+
}
|
|
278
|
+
if (!loop) {
|
|
279
|
+
return null;
|
|
280
|
+
}
|
|
281
|
+
return ((next % durationFrames) + durationFrames) % durationFrames;
|
|
282
|
+
}
|
|
283
|
+
export function applyPreviewSafeZones(root, visible, frame) {
|
|
284
|
+
removePreviewSafeZones(root);
|
|
285
|
+
if (!visible) {
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
const stage = root.querySelector("[data-kavio-stage='true']");
|
|
289
|
+
if (!stage) {
|
|
290
|
+
return;
|
|
291
|
+
}
|
|
292
|
+
stage.append(createPreviewSafeZoneOverlay(stage.ownerDocument, frame.width, frame.height));
|
|
293
|
+
}
|
|
294
|
+
export function createPreviewSafeZoneOverlay(document, width, height) {
|
|
295
|
+
const overlay = document.createElement("div");
|
|
296
|
+
overlay.dataset.kavioPreviewSafeZones = "true";
|
|
297
|
+
overlay.style.position = "absolute";
|
|
298
|
+
overlay.style.inset = "0";
|
|
299
|
+
overlay.style.pointerEvents = "none";
|
|
300
|
+
overlay.style.zIndex = "2147483647";
|
|
301
|
+
overlay.style.boxSizing = "border-box";
|
|
302
|
+
const action = createSafeZoneBox(document, "action", width, height, 0.9, "rgba(0, 200, 255, 0.8)");
|
|
303
|
+
const title = createSafeZoneBox(document, "title", width, height, 0.8, "rgba(255, 214, 0, 0.85)");
|
|
304
|
+
overlay.replaceChildren(action, title);
|
|
305
|
+
return overlay;
|
|
306
|
+
}
|
|
307
|
+
function renderCompositionFrame(composition, frame, options) {
|
|
308
|
+
const dimensions = getCanvasDimensions(composition.composition);
|
|
309
|
+
const root = getRenderRoot(options);
|
|
310
|
+
const stage = createStage(root, dimensions, composition.exports[0]?.background);
|
|
311
|
+
const transitionStates = activeTransitionRenderStates(composition, frame, dimensions);
|
|
312
|
+
const layerPromises = composition.layers.flatMap((layer, index) => {
|
|
313
|
+
const transitionState = transitionStates.get(layer.id);
|
|
314
|
+
if (transitionState !== undefined) {
|
|
315
|
+
return [
|
|
316
|
+
renderLayer(stage.ownerDocument, composition, layer, index, frame, dimensions, {
|
|
317
|
+
evaluation: transitionState.evaluation,
|
|
318
|
+
transition: transitionState
|
|
319
|
+
})
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
return isLayerActive(layer, frame) ? [renderLayer(stage.ownerDocument, composition, layer, index, frame, dimensions)] : [];
|
|
323
|
+
});
|
|
324
|
+
return Promise.all(layerPromises).then(async (layers) => {
|
|
325
|
+
stage.replaceChildren(...layers.map((layer) => layer.element));
|
|
326
|
+
root.replaceChildren(stage);
|
|
327
|
+
await waitForDocumentFonts(stage.ownerDocument);
|
|
328
|
+
return {
|
|
329
|
+
frame,
|
|
330
|
+
width: dimensions.width,
|
|
331
|
+
height: dimensions.height,
|
|
332
|
+
layers
|
|
333
|
+
};
|
|
334
|
+
});
|
|
335
|
+
}
|
|
336
|
+
async function renderLayer(document, composition, layer, index, frame, dimensions, options = {}) {
|
|
337
|
+
const evaluation = options.evaluation ?? evaluateLayer(layer, frame, dimensions);
|
|
338
|
+
const element = document.createElement("div");
|
|
339
|
+
element.dataset.kavioLayerId = layer.id;
|
|
340
|
+
element.dataset.kavioLayerType = layer.type;
|
|
341
|
+
if (options.transition !== undefined) {
|
|
342
|
+
element.dataset.kavioTransitionSeries = "true";
|
|
343
|
+
element.dataset.kavioTransitionTrack = options.transition.overlap.trackId;
|
|
344
|
+
element.dataset.kavioTransitionClip = options.transition.clipId;
|
|
345
|
+
element.dataset.kavioTransitionRole = options.transition.role;
|
|
346
|
+
element.dataset.kavioTransitionType = options.transition.overlap.transition.type;
|
|
347
|
+
}
|
|
348
|
+
element.style.position = "absolute";
|
|
349
|
+
element.style.boxSizing = "border-box";
|
|
350
|
+
element.style.left = `${evaluation.position.x}px`;
|
|
351
|
+
element.style.top = `${evaluation.position.y}px`;
|
|
352
|
+
element.style.opacity = String(evaluation.opacity);
|
|
353
|
+
element.style.zIndex = String(layer.z ?? index);
|
|
354
|
+
applyEvaluatedSize(element, evaluation);
|
|
355
|
+
const content = await applyLayerContent(document, element, composition, layer, evaluation, dimensions);
|
|
356
|
+
const renderedSize = resolveRenderedSize(evaluation, content.intrinsicSize);
|
|
357
|
+
applyRenderedSize(element, evaluation, renderedSize);
|
|
358
|
+
applyLayerTransform(element, layer, evaluation, dimensions);
|
|
359
|
+
applyLayerReveal(element, evaluation);
|
|
360
|
+
applyLayerFilter(element, evaluation);
|
|
361
|
+
applyLayerWash(document, element, evaluation);
|
|
362
|
+
applyLayerMask(element, composition, evaluation.mask);
|
|
363
|
+
return {
|
|
364
|
+
id: layer.id,
|
|
365
|
+
type: layer.type,
|
|
366
|
+
localFrame: evaluation.localFrame,
|
|
367
|
+
element,
|
|
368
|
+
evaluation
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
function activeTransitionRenderStates(composition, frame, dimensions) {
|
|
372
|
+
const states = new Map();
|
|
373
|
+
const overlaps = evaluateTransitionSeries(composition, frame, dimensions);
|
|
374
|
+
for (const overlap of overlaps) {
|
|
375
|
+
states.set(overlap.previous.layerId, {
|
|
376
|
+
overlap,
|
|
377
|
+
role: "previous",
|
|
378
|
+
clipId: overlap.previous.clipId,
|
|
379
|
+
evaluation: overlap.previous.layer
|
|
380
|
+
});
|
|
381
|
+
states.set(overlap.next.layerId, {
|
|
382
|
+
overlap,
|
|
383
|
+
role: "next",
|
|
384
|
+
clipId: overlap.next.clipId,
|
|
385
|
+
evaluation: overlap.next.layer
|
|
386
|
+
});
|
|
387
|
+
}
|
|
388
|
+
return states;
|
|
389
|
+
}
|
|
390
|
+
function applyLayerReveal(element, evaluation) {
|
|
391
|
+
if (!evaluation.reveal && !evaluation.revealShape && !evaluation.revealPattern) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
element.style.overflow = "hidden";
|
|
395
|
+
if (evaluation.revealShape) {
|
|
396
|
+
element.style.clipPath = revealShapeClipPath(evaluation.revealShape);
|
|
397
|
+
}
|
|
398
|
+
else if (evaluation.reveal) {
|
|
399
|
+
const { top, right, bottom, left } = evaluation.reveal;
|
|
400
|
+
element.style.clipPath = `inset(${top}% ${right}% ${bottom}% ${left}%)`;
|
|
401
|
+
}
|
|
402
|
+
if (evaluation.revealPattern?.kind === "clock") {
|
|
403
|
+
element.style.clipPath = clockWipeClipPath(evaluation.revealPattern);
|
|
404
|
+
}
|
|
405
|
+
else if (evaluation.revealPattern) {
|
|
406
|
+
applyRevealPatternMask(element, evaluation.revealPattern);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
function applyLayerFilter(element, evaluation) {
|
|
410
|
+
const filters = [];
|
|
411
|
+
if (evaluation.filter?.blur !== undefined && evaluation.filter.blur > 0) {
|
|
412
|
+
filters.push(`blur(${evaluation.filter.blur}px)`);
|
|
413
|
+
}
|
|
414
|
+
if (filters.length > 0) {
|
|
415
|
+
element.style.filter = filters.join(" ");
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function applyLayerWash(document, element, evaluation) {
|
|
419
|
+
if (!evaluation.wash || evaluation.wash.opacity <= 0) {
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
element.style.overflow = "hidden";
|
|
423
|
+
const overlay = document.createElement("div");
|
|
424
|
+
overlay.dataset.kavioTransitionOverlay = "wash";
|
|
425
|
+
overlay.style.position = "absolute";
|
|
426
|
+
overlay.style.inset = "0";
|
|
427
|
+
overlay.style.pointerEvents = "none";
|
|
428
|
+
overlay.style.background = evaluation.wash.color;
|
|
429
|
+
overlay.style.opacity = String(evaluation.wash.opacity);
|
|
430
|
+
overlay.style.zIndex = "2147483647";
|
|
431
|
+
element.append(overlay);
|
|
432
|
+
}
|
|
433
|
+
function applyLayerMask(element, composition, mask) {
|
|
434
|
+
if (!mask) {
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
const source = mask.source;
|
|
438
|
+
element.dataset.kavioMaskKind = source.kind;
|
|
439
|
+
element.dataset.kavioMaskInverted = mask.invert === true ? "true" : "false";
|
|
440
|
+
element.style.overflow = "hidden";
|
|
441
|
+
if (mask.opacity !== undefined) {
|
|
442
|
+
element.dataset.kavioMaskOpacity = formatUnit(mask.opacity);
|
|
443
|
+
const currentOpacity = Number(element.style.opacity || "1");
|
|
444
|
+
if (Number.isFinite(currentOpacity)) {
|
|
445
|
+
element.style.opacity = String(currentOpacity * mask.opacity);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
switch (source.kind) {
|
|
449
|
+
case "shape":
|
|
450
|
+
element.dataset.kavioMaskShape = source.shape;
|
|
451
|
+
applyCssMaskImage(element, shapeMaskImage(source.shape, mask.invert === true));
|
|
452
|
+
break;
|
|
453
|
+
case "asset": {
|
|
454
|
+
if (mask.invert === true) {
|
|
455
|
+
throw new Error("Inverted asset masks are not supported by the stable browser renderer.");
|
|
456
|
+
}
|
|
457
|
+
const asset = composition.assets[source.asset];
|
|
458
|
+
if (!asset || asset.type !== "image") {
|
|
459
|
+
throw new Error(`Mask source references missing image asset "${source.asset}".`);
|
|
460
|
+
}
|
|
461
|
+
element.dataset.kavioMaskAsset = source.asset;
|
|
462
|
+
element.dataset.kavioMaskMode = source.mode ?? "alpha";
|
|
463
|
+
applyCssMaskImage(element, `url("${escapeCssString(asset.src)}")`);
|
|
464
|
+
element.style.maskSize = "100% 100%";
|
|
465
|
+
element.style.webkitMaskSize = "100% 100%";
|
|
466
|
+
element.style.maskRepeat = "no-repeat";
|
|
467
|
+
element.style.webkitMaskRepeat = "no-repeat";
|
|
468
|
+
break;
|
|
469
|
+
}
|
|
470
|
+
case "procedural":
|
|
471
|
+
element.dataset.kavioMaskType = source.type;
|
|
472
|
+
element.dataset.kavioMaskSeed = String(source.seed);
|
|
473
|
+
applyCssMaskImage(element, proceduralMaskImage(source, mask.invert === true));
|
|
474
|
+
if (source.type === "scanlines") {
|
|
475
|
+
const period = Math.max(2, Math.round(source.frequency ?? 8));
|
|
476
|
+
const offset = source.seed % period;
|
|
477
|
+
element.style.maskPosition = `0 ${offset}px`;
|
|
478
|
+
element.style.webkitMaskPosition = `0 ${offset}px`;
|
|
479
|
+
}
|
|
480
|
+
break;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
function applyCssMaskImage(element, image) {
|
|
484
|
+
element.style.maskImage = image;
|
|
485
|
+
element.style.webkitMaskImage = image;
|
|
486
|
+
element.style.maskSize = "100% 100%";
|
|
487
|
+
element.style.webkitMaskSize = "100% 100%";
|
|
488
|
+
}
|
|
489
|
+
function shapeMaskImage(shape, inverted) {
|
|
490
|
+
const visible = maskColor(!inverted);
|
|
491
|
+
const hidden = maskColor(inverted);
|
|
492
|
+
switch (shape) {
|
|
493
|
+
case "circle":
|
|
494
|
+
return `radial-gradient(circle at 50% 50%, ${visible} 0 50%, ${hidden} 50.5% 100%)`;
|
|
495
|
+
case "diamond":
|
|
496
|
+
return `conic-gradient(from 45deg at 50% 50%, ${hidden} 0 12.5%, ${visible} 12.5% 37.5%, ${hidden} 37.5% 62.5%, ${visible} 62.5% 87.5%, ${hidden} 87.5% 100%)`;
|
|
497
|
+
case "rect":
|
|
498
|
+
return `linear-gradient(${visible}, ${visible})`;
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function proceduralMaskImage(source, inverted) {
|
|
502
|
+
const visible = maskColor(!inverted);
|
|
503
|
+
const hidden = maskColor(inverted);
|
|
504
|
+
const softness = Math.round((source.softness ?? 0.12) * 100);
|
|
505
|
+
switch (source.type) {
|
|
506
|
+
case "linearGradient": {
|
|
507
|
+
const direction = source.direction ?? seededDirection(source.seed);
|
|
508
|
+
const edge = Math.max(0, Math.min(49, softness));
|
|
509
|
+
return `linear-gradient(${linearGradientDirection(direction)}, ${hidden} 0%, ${hidden} ${50 - edge}%, ${visible} ${50 + edge}%, ${visible} 100%)`;
|
|
510
|
+
}
|
|
511
|
+
case "radialGradient": {
|
|
512
|
+
const edge = Math.max(0, Math.min(49, softness));
|
|
513
|
+
return `radial-gradient(circle at 50% 50%, ${visible} 0%, ${visible} ${50 - edge}%, ${hidden} ${50 + edge}%, ${hidden} 100%)`;
|
|
514
|
+
}
|
|
515
|
+
case "scanlines": {
|
|
516
|
+
const period = Math.max(2, Math.round(source.frequency ?? 8));
|
|
517
|
+
const line = Math.max(1, Math.floor(period / 2));
|
|
518
|
+
return `repeating-linear-gradient(to bottom, ${visible} 0 ${line}px, ${hidden} ${line}px ${period}px)`;
|
|
519
|
+
}
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
function maskColor(visible) {
|
|
523
|
+
return visible ? "#000" : "transparent";
|
|
524
|
+
}
|
|
525
|
+
function seededDirection(seed) {
|
|
526
|
+
const directions = ["right", "left", "down", "up"];
|
|
527
|
+
return directions[Math.abs(seed) % directions.length] ?? "right";
|
|
528
|
+
}
|
|
529
|
+
function linearGradientDirection(direction) {
|
|
530
|
+
switch (direction) {
|
|
531
|
+
case "up":
|
|
532
|
+
return "to top";
|
|
533
|
+
case "down":
|
|
534
|
+
return "to bottom";
|
|
535
|
+
case "left":
|
|
536
|
+
return "to left";
|
|
537
|
+
case "right":
|
|
538
|
+
return "to right";
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
function revealShapeClipPath(reveal) {
|
|
542
|
+
const progress = Math.max(0, Math.min(1, reveal.progress));
|
|
543
|
+
if (reveal.shape === "diamond") {
|
|
544
|
+
const radius = progress * 100;
|
|
545
|
+
return `polygon(50% ${50 - radius}%, ${50 + radius}% 50%, 50% ${50 + radius}%, ${50 - radius}% 50%)`;
|
|
546
|
+
}
|
|
547
|
+
return `circle(${progress * 75}% at 50% 50%)`;
|
|
548
|
+
}
|
|
549
|
+
function clockWipeClipPath(reveal) {
|
|
550
|
+
const progress = Math.max(0, Math.min(1, reveal.progress));
|
|
551
|
+
if (progress <= 0) {
|
|
552
|
+
return "polygon(50% 50%, 50% 0%, 50% 0%)";
|
|
553
|
+
}
|
|
554
|
+
if (progress >= 1) {
|
|
555
|
+
return "inset(0%)";
|
|
556
|
+
}
|
|
557
|
+
const clockwise = reveal.direction !== "left" && reveal.direction !== "up";
|
|
558
|
+
const steps = Math.max(2, Math.ceil((progress * 360) / 15));
|
|
559
|
+
const points = ["50% 50%"];
|
|
560
|
+
for (let index = 0; index <= steps; index += 1) {
|
|
561
|
+
const angle = -90 + (clockwise ? 1 : -1) * progress * 360 * (index / steps);
|
|
562
|
+
const point = squareEdgePoint(angle);
|
|
563
|
+
points.push(`${point.x}% ${point.y}%`);
|
|
564
|
+
}
|
|
565
|
+
return `polygon(${points.join(", ")})`;
|
|
566
|
+
}
|
|
567
|
+
function squareEdgePoint(angleDegrees) {
|
|
568
|
+
const angle = (angleDegrees * Math.PI) / 180;
|
|
569
|
+
const dx = Math.cos(angle);
|
|
570
|
+
const dy = Math.sin(angle);
|
|
571
|
+
const scale = 50 / Math.max(Math.abs(dx), Math.abs(dy));
|
|
572
|
+
return {
|
|
573
|
+
x: 50 + dx * scale,
|
|
574
|
+
y: 50 + dy * scale
|
|
575
|
+
};
|
|
576
|
+
}
|
|
577
|
+
function applyRevealPatternMask(element, reveal) {
|
|
578
|
+
if (reveal.kind === "clock") {
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
const progress = Math.max(0, Math.min(1, reveal.progress));
|
|
582
|
+
const rows = Math.max(1, Math.min(32, Math.round(reveal.rows)));
|
|
583
|
+
const columns = Math.max(1, Math.min(32, Math.round(reveal.columns)));
|
|
584
|
+
const total = rows * columns;
|
|
585
|
+
const masks = [];
|
|
586
|
+
const sizes = [];
|
|
587
|
+
const positions = [];
|
|
588
|
+
const tileWidth = 100 / columns;
|
|
589
|
+
const tileHeight = 100 / rows;
|
|
590
|
+
for (let row = 0; row < rows; row += 1) {
|
|
591
|
+
for (let column = 0; column < columns; column += 1) {
|
|
592
|
+
const order = reveal.kind === "tiles" ? centeredTileOrder(row, column, rows, columns) : directionalTileOrder(row, column, rows, columns, reveal.direction);
|
|
593
|
+
const tileProgress = reveal.kind === "bars" ? progress : staggeredTileProgress(progress, order, total, reveal.kind);
|
|
594
|
+
if (tileProgress <= 0) {
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const placement = tileMaskPlacement(row, column, tileWidth, tileHeight, tileProgress, reveal.direction, reveal.kind);
|
|
598
|
+
masks.push("linear-gradient(#000 0 0)");
|
|
599
|
+
sizes.push(`${placement.width}% ${placement.height}%`);
|
|
600
|
+
positions.push(`${placement.x}% ${placement.y}%`);
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
if (masks.length === 0) {
|
|
604
|
+
element.style.clipPath = "inset(100%)";
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
607
|
+
setMaskProperty(element, "mask-image", masks.join(", "));
|
|
608
|
+
setMaskProperty(element, "mask-size", sizes.join(", "));
|
|
609
|
+
setMaskProperty(element, "mask-position", positions.join(", "));
|
|
610
|
+
setMaskProperty(element, "mask-repeat", masks.map(() => "no-repeat").join(", "));
|
|
611
|
+
}
|
|
612
|
+
function staggeredTileProgress(progress, order, total, kind) {
|
|
613
|
+
if (kind === "bars") {
|
|
614
|
+
return progress;
|
|
615
|
+
}
|
|
616
|
+
const spread = kind === "tiles" ? 0.55 : 0.75;
|
|
617
|
+
const delay = total <= 1 ? 0 : (order / (total - 1)) * spread;
|
|
618
|
+
return Math.max(0, Math.min(1, (progress - delay) / (1 - delay)));
|
|
619
|
+
}
|
|
620
|
+
function directionalTileOrder(row, column, rows, columns, direction) {
|
|
621
|
+
switch (direction) {
|
|
622
|
+
case "left":
|
|
623
|
+
return row * columns + (columns - 1 - column);
|
|
624
|
+
case "up":
|
|
625
|
+
return column * rows + (rows - 1 - row);
|
|
626
|
+
case "down":
|
|
627
|
+
return column * rows + row;
|
|
628
|
+
case "right":
|
|
629
|
+
default:
|
|
630
|
+
return row * columns + column;
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
function centeredTileOrder(row, column, rows, columns) {
|
|
634
|
+
const centerRow = (rows - 1) / 2;
|
|
635
|
+
const centerColumn = (columns - 1) / 2;
|
|
636
|
+
const distance = Math.hypot(row - centerRow, column - centerColumn);
|
|
637
|
+
const maxDistance = Math.hypot(centerRow, centerColumn) || 1;
|
|
638
|
+
return Math.round((distance / maxDistance) * (rows * columns - 1));
|
|
639
|
+
}
|
|
640
|
+
function tileMaskPlacement(row, column, tileWidth, tileHeight, progress, direction, kind) {
|
|
641
|
+
if (kind === "tiles") {
|
|
642
|
+
const width = tileWidth * progress;
|
|
643
|
+
const height = tileHeight * progress;
|
|
644
|
+
return {
|
|
645
|
+
x: column * tileWidth + (tileWidth - width) / 2,
|
|
646
|
+
y: row * tileHeight + (tileHeight - height) / 2,
|
|
647
|
+
width,
|
|
648
|
+
height
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
if (direction === "up" || direction === "down") {
|
|
652
|
+
const height = tileHeight * progress;
|
|
653
|
+
return {
|
|
654
|
+
x: column * tileWidth,
|
|
655
|
+
y: direction === "up" ? (row + 1) * tileHeight - height : row * tileHeight,
|
|
656
|
+
width: tileWidth,
|
|
657
|
+
height
|
|
658
|
+
};
|
|
659
|
+
}
|
|
660
|
+
const width = tileWidth * progress;
|
|
661
|
+
return {
|
|
662
|
+
x: direction === "left" ? (column + 1) * tileWidth - width : column * tileWidth,
|
|
663
|
+
y: row * tileHeight,
|
|
664
|
+
width,
|
|
665
|
+
height: tileHeight
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
function setMaskProperty(element, property, value) {
|
|
669
|
+
element.style.setProperty(property, value);
|
|
670
|
+
element.style.setProperty(`-webkit-${property}`, value);
|
|
671
|
+
}
|
|
672
|
+
async function applyLayerContent(document, element, composition, layer, evaluation, dimensions) {
|
|
673
|
+
switch (layer.type) {
|
|
674
|
+
case "text":
|
|
675
|
+
applyTextStyle(element, layer.style);
|
|
676
|
+
applyTextContent(document, element, layer, evaluation);
|
|
677
|
+
return {};
|
|
678
|
+
case "caption":
|
|
679
|
+
applyCaptionContent(document, element, layer, evaluation.caption, dimensions);
|
|
680
|
+
return {};
|
|
681
|
+
case "shape":
|
|
682
|
+
element.dataset.kavioShape = layer.shape;
|
|
683
|
+
element.style.backgroundColor = layer.fill ?? "transparent";
|
|
684
|
+
element.style.borderRadius = layer.radius === undefined ? "0" : `${layer.radius}px`;
|
|
685
|
+
if (layer.stroke) {
|
|
686
|
+
element.style.border = `${layer.stroke.width}px solid ${layer.stroke.color}`;
|
|
687
|
+
}
|
|
688
|
+
else {
|
|
689
|
+
element.style.border = "0 solid transparent";
|
|
690
|
+
}
|
|
691
|
+
return {};
|
|
692
|
+
case "image":
|
|
693
|
+
return applyImageContent(document, element, composition, layer);
|
|
694
|
+
case "video":
|
|
695
|
+
return applyVideoContent(document, element, composition, layer, evaluation);
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const SCRAMBLE_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789#$%&*";
|
|
699
|
+
function applyTextContent(document, element, layer, evaluation) {
|
|
700
|
+
const motion = layer.textMotion;
|
|
701
|
+
if (!motion) {
|
|
702
|
+
element.textContent = layer.text;
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
applyTextMotionRestingBox(element, motion, evaluation);
|
|
706
|
+
const split = motion.split ?? defaultTextMotionSplit(motion.type);
|
|
707
|
+
const fragments = splitTextMotionFragments(layer.text, split);
|
|
708
|
+
const animatableCount = fragments.filter((fragment) => fragment.animatable).length;
|
|
709
|
+
let animatableIndex = 0;
|
|
710
|
+
const nodes = [];
|
|
711
|
+
element.dataset.kavioTextMotionType = motion.type;
|
|
712
|
+
element.dataset.kavioTextMotionSplit = split;
|
|
713
|
+
element.dataset.kavioTextMotionPreserveLayout = String(motion.preserveLayout !== false);
|
|
714
|
+
for (const fragment of fragments) {
|
|
715
|
+
if (!fragment.animatable) {
|
|
716
|
+
nodes.push(document.createTextNode(fragment.text));
|
|
717
|
+
if (fragment.breakAfter) {
|
|
718
|
+
nodes.push(document.createTextNode("\n"));
|
|
719
|
+
}
|
|
720
|
+
continue;
|
|
721
|
+
}
|
|
722
|
+
const fragmentIndex = animatableIndex;
|
|
723
|
+
animatableIndex += 1;
|
|
724
|
+
const span = document.createElement("span");
|
|
725
|
+
span.dataset.kavioTextFragmentIndex = String(fragmentIndex);
|
|
726
|
+
span.dataset.kavioTextFragmentOrder = String(textMotionOrderIndex(fragmentIndex, animatableCount, motion.origin));
|
|
727
|
+
span.style.display = "inline-block";
|
|
728
|
+
span.style.whiteSpace = split === "line" ? "pre-wrap" : "pre";
|
|
729
|
+
span.textContent = renderTextMotionFragmentText(fragment.text, fragmentIndex, evaluation.localFrame, motion, animatableCount);
|
|
730
|
+
applyTextMotionFragmentStyle(span, fragmentIndex, animatableCount, evaluation.localFrame, motion);
|
|
731
|
+
nodes.push(span);
|
|
732
|
+
if (fragment.breakAfter) {
|
|
733
|
+
nodes.push(document.createTextNode("\n"));
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
element.replaceChildren(...nodes);
|
|
737
|
+
}
|
|
738
|
+
function applyTextMotionRestingBox(element, motion, evaluation) {
|
|
739
|
+
if (motion.restingBox?.width !== undefined && evaluation.size.width === null) {
|
|
740
|
+
element.style.width = `${motion.restingBox.width}px`;
|
|
741
|
+
}
|
|
742
|
+
if (motion.restingBox?.height !== undefined && evaluation.size.height === null) {
|
|
743
|
+
element.style.height = `${motion.restingBox.height}px`;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
function defaultTextMotionSplit(type) {
|
|
747
|
+
switch (type) {
|
|
748
|
+
case "cascade":
|
|
749
|
+
case "highlightSweep":
|
|
750
|
+
return "word";
|
|
751
|
+
case "typeOn":
|
|
752
|
+
case "scramble":
|
|
753
|
+
case "trackingIn":
|
|
754
|
+
return "char";
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
function splitTextMotionFragments(text, split) {
|
|
758
|
+
switch (split) {
|
|
759
|
+
case "none":
|
|
760
|
+
return [{ text, animatable: text.length > 0 }];
|
|
761
|
+
case "word":
|
|
762
|
+
return text
|
|
763
|
+
.split(/(\s+)/u)
|
|
764
|
+
.filter((part) => part.length > 0)
|
|
765
|
+
.map((part) => ({ text: part, animatable: !/^\s+$/u.test(part) }));
|
|
766
|
+
case "char":
|
|
767
|
+
return Array.from(text).map((character) => ({ text: character, animatable: !/^\s$/u.test(character) }));
|
|
768
|
+
case "line": {
|
|
769
|
+
const lines = text.split(/\r\n?|\n/);
|
|
770
|
+
return lines.map((line, index) => ({ text: line, animatable: line.length > 0, breakAfter: index < lines.length - 1 }));
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
function applyTextMotionFragmentStyle(element, index, count, localFrame, motion) {
|
|
775
|
+
const progress = textMotionFragmentProgress(index, count, localFrame, motion);
|
|
776
|
+
element.dataset.kavioTextFragmentState = progress >= 1 ? "complete" : progress > 0 ? "active" : "pending";
|
|
777
|
+
switch (motion.type) {
|
|
778
|
+
case "typeOn":
|
|
779
|
+
element.style.opacity = localFrame >= textMotionDelayFrame(index, count, motion) ? "1" : "0";
|
|
780
|
+
return;
|
|
781
|
+
case "cascade": {
|
|
782
|
+
const offset = cascadeOffset(progress, motion);
|
|
783
|
+
element.style.opacity = String(roundMotion(progress));
|
|
784
|
+
element.style.transform = `translate(${roundMotion(offset.x)}px, ${roundMotion(offset.y)}px)`;
|
|
785
|
+
return;
|
|
786
|
+
}
|
|
787
|
+
case "scramble":
|
|
788
|
+
element.style.opacity = localFrame >= textMotionDelayFrame(index, count, motion) ? "1" : "0";
|
|
789
|
+
return;
|
|
790
|
+
case "highlightSweep":
|
|
791
|
+
applyHighlightSweepStyle(element, index, count, localFrame, motion);
|
|
792
|
+
return;
|
|
793
|
+
case "trackingIn": {
|
|
794
|
+
const order = textMotionOrderIndex(index, count, motion.origin);
|
|
795
|
+
const direction = index < (count - 1) / 2 ? -1 : 1;
|
|
796
|
+
const amount = (motion.amount ?? motion.intensity ?? 12) * (1 - progress);
|
|
797
|
+
element.style.opacity = String(roundMotion(progress));
|
|
798
|
+
element.style.transform = `translateX(${roundMotion(direction * amount * (order + 1))}px)`;
|
|
799
|
+
return;
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
}
|
|
803
|
+
function renderTextMotionFragmentText(text, index, localFrame, motion, count) {
|
|
804
|
+
if (motion.type !== "scramble") {
|
|
805
|
+
return text;
|
|
806
|
+
}
|
|
807
|
+
const progress = textMotionFragmentProgress(index, count, localFrame, motion);
|
|
808
|
+
if (progress >= 1 || localFrame < textMotionDelayFrame(index, count, motion)) {
|
|
809
|
+
return text;
|
|
810
|
+
}
|
|
811
|
+
return Array.from(text)
|
|
812
|
+
.map((character, characterIndex) => /\s/u.test(character)
|
|
813
|
+
? character
|
|
814
|
+
: SCRAMBLE_ALPHABET[textMotionHash(motion.seed ?? 0, index, characterIndex, localFrame) % SCRAMBLE_ALPHABET.length])
|
|
815
|
+
.join("");
|
|
816
|
+
}
|
|
817
|
+
function applyHighlightSweepStyle(element, index, count, localFrame, motion) {
|
|
818
|
+
const durationFrames = motion.durationFrames ?? 18;
|
|
819
|
+
const raw = durationFrames <= 1 ? 1 : clamp01(localFrame / (durationFrames - 1));
|
|
820
|
+
const progress = evaluateEasing(motion.easing ?? "outCubic", raw);
|
|
821
|
+
const sweep = progress * Math.max(0, count - 1);
|
|
822
|
+
const order = textMotionOrderIndex(index, count, motion.origin);
|
|
823
|
+
const radius = Math.max(0.5, motion.intensity ?? 0.75);
|
|
824
|
+
const distance = Math.abs(order - sweep);
|
|
825
|
+
element.style.opacity = "1";
|
|
826
|
+
if (distance <= radius) {
|
|
827
|
+
element.dataset.kavioTextFragmentState = "active";
|
|
828
|
+
element.style.backgroundColor = motion.color ?? "rgba(255, 214, 0, 0.55)";
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
function cascadeOffset(progress, motion) {
|
|
832
|
+
const amount = (motion.amount ?? motion.intensity ?? 24) * (1 - progress);
|
|
833
|
+
switch (motion.direction ?? "up") {
|
|
834
|
+
case "down":
|
|
835
|
+
return { x: 0, y: -amount };
|
|
836
|
+
case "left":
|
|
837
|
+
return { x: amount, y: 0 };
|
|
838
|
+
case "right":
|
|
839
|
+
return { x: -amount, y: 0 };
|
|
840
|
+
case "up":
|
|
841
|
+
return { x: 0, y: amount };
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
function textMotionFragmentProgress(index, count, localFrame, motion) {
|
|
845
|
+
const delayFrame = textMotionDelayFrame(index, count, motion);
|
|
846
|
+
if (localFrame < delayFrame) {
|
|
847
|
+
return 0;
|
|
848
|
+
}
|
|
849
|
+
const durationFrames = motion.durationFrames ?? 12;
|
|
850
|
+
if (durationFrames <= 1) {
|
|
851
|
+
return 1;
|
|
852
|
+
}
|
|
853
|
+
const raw = clamp01((localFrame - delayFrame) / (durationFrames - 1));
|
|
854
|
+
return evaluateEasing(motion.easing ?? "outCubic", raw);
|
|
855
|
+
}
|
|
856
|
+
function textMotionDelayFrame(index, count, motion) {
|
|
857
|
+
const order = textMotionOrderIndex(index, count, motion.origin);
|
|
858
|
+
const staggerFrames = motion.staggerFrames ?? defaultTextMotionStagger(motion.type);
|
|
859
|
+
if (staggerFrames > 0) {
|
|
860
|
+
return Math.round(order * staggerFrames);
|
|
861
|
+
}
|
|
862
|
+
const durationFrames = motion.durationFrames ?? 1;
|
|
863
|
+
return count <= 1 ? 0 : Math.round((order / (count - 1)) * Math.max(0, durationFrames - 1));
|
|
864
|
+
}
|
|
865
|
+
function textMotionOrderIndex(index, count, origin) {
|
|
866
|
+
switch (origin ?? "start") {
|
|
867
|
+
case "end":
|
|
868
|
+
return Math.max(0, count - 1 - index);
|
|
869
|
+
case "center":
|
|
870
|
+
return Math.abs(index - (count - 1) / 2);
|
|
871
|
+
case "start":
|
|
872
|
+
return index;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
function defaultTextMotionStagger(type) {
|
|
876
|
+
switch (type) {
|
|
877
|
+
case "highlightSweep":
|
|
878
|
+
return 0;
|
|
879
|
+
case "cascade":
|
|
880
|
+
return 2;
|
|
881
|
+
case "typeOn":
|
|
882
|
+
case "scramble":
|
|
883
|
+
case "trackingIn":
|
|
884
|
+
return 1;
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
function textMotionHash(seed, fragmentIndex, characterIndex, frame) {
|
|
888
|
+
let value = seed | 0;
|
|
889
|
+
value = Math.imul(value ^ (fragmentIndex + 1), 2_654_435_761);
|
|
890
|
+
value = Math.imul(value ^ (characterIndex + 17), 2_246_822_519);
|
|
891
|
+
value = Math.imul(value ^ (frame + 101), 3_266_489_917);
|
|
892
|
+
return value >>> 0;
|
|
893
|
+
}
|
|
894
|
+
function clamp01(value) {
|
|
895
|
+
return Math.max(0, Math.min(1, value));
|
|
896
|
+
}
|
|
897
|
+
function roundMotion(value) {
|
|
898
|
+
return Math.round(value * 1_000_000) / 1_000_000;
|
|
899
|
+
}
|
|
900
|
+
function applyTextStyle(element, style) {
|
|
901
|
+
const wrap = style?.wrap !== false;
|
|
902
|
+
element.style.whiteSpace = wrap ? "pre-wrap" : "pre";
|
|
903
|
+
element.style.overflowWrap = wrap ? "break-word" : "normal";
|
|
904
|
+
element.style.color = style?.color ?? "currentColor";
|
|
905
|
+
element.style.fontFamily = style?.fontFamily ?? "sans-serif";
|
|
906
|
+
element.style.fontSize = `${style?.fontSize ?? 16}px`;
|
|
907
|
+
element.style.fontWeight = style?.fontWeight === undefined ? "400" : String(style.fontWeight);
|
|
908
|
+
element.style.fontStyle = style?.fontStyle ?? "normal";
|
|
909
|
+
element.style.lineHeight = style?.lineHeight === undefined ? "normal" : String(style.lineHeight);
|
|
910
|
+
element.style.letterSpacing = style?.letterSpacing === undefined ? "normal" : `${style.letterSpacing}px`;
|
|
911
|
+
element.style.textAlign = style?.align ?? "left";
|
|
912
|
+
element.style.backgroundColor = style?.background ?? "transparent";
|
|
913
|
+
element.style.padding = style?.padding === undefined ? "0" : `${style.padding}px`;
|
|
914
|
+
if (style?.maxLines !== undefined && style.maxLines > 0) {
|
|
915
|
+
element.style.display = "-webkit-box";
|
|
916
|
+
element.style.overflow = "hidden";
|
|
917
|
+
element.style.setProperty("-webkit-box-orient", "vertical");
|
|
918
|
+
element.style.setProperty("-webkit-line-clamp", String(style.maxLines));
|
|
919
|
+
}
|
|
920
|
+
else {
|
|
921
|
+
element.style.display = "block";
|
|
922
|
+
}
|
|
923
|
+
if (style?.stroke) {
|
|
924
|
+
element.style.setProperty("-webkit-text-stroke", `${style.stroke.width}px ${style.stroke.color}`);
|
|
925
|
+
}
|
|
926
|
+
else {
|
|
927
|
+
element.style.setProperty("-webkit-text-stroke", "0 transparent");
|
|
928
|
+
}
|
|
929
|
+
if (style?.shadow) {
|
|
930
|
+
element.style.textShadow = `${style.shadow.x}px ${style.shadow.y}px ${style.shadow.blur}px ${style.shadow.color}`;
|
|
931
|
+
}
|
|
932
|
+
else {
|
|
933
|
+
element.style.textShadow = "none";
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
function createStage(root, dimensions, background) {
|
|
937
|
+
const stage = root.ownerDocument.createElement("div");
|
|
938
|
+
stage.dataset.kavioStage = "true";
|
|
939
|
+
stage.style.position = "relative";
|
|
940
|
+
stage.style.overflow = "hidden";
|
|
941
|
+
stage.style.width = `${dimensions.width}px`;
|
|
942
|
+
stage.style.height = `${dimensions.height}px`;
|
|
943
|
+
stage.style.background = background === null || background === "transparent" ? "transparent" : (background ?? "transparent");
|
|
944
|
+
return stage;
|
|
945
|
+
}
|
|
946
|
+
function createPreviewControls(document) {
|
|
947
|
+
const element = document.createElement("div");
|
|
948
|
+
element.dataset.kavioPreviewControls = "true";
|
|
949
|
+
element.style.display = "grid";
|
|
950
|
+
element.style.gridTemplateColumns = "auto minmax(160px, 1fr) auto auto auto";
|
|
951
|
+
element.style.gap = "8px";
|
|
952
|
+
element.style.alignItems = "center";
|
|
953
|
+
const playButton = document.createElement("button");
|
|
954
|
+
playButton.type = "button";
|
|
955
|
+
playButton.dataset.kavioPreviewControl = "play";
|
|
956
|
+
const scrubber = document.createElement("input");
|
|
957
|
+
scrubber.type = "range";
|
|
958
|
+
scrubber.min = "0";
|
|
959
|
+
scrubber.step = "1";
|
|
960
|
+
scrubber.dataset.kavioPreviewControl = "scrubber";
|
|
961
|
+
const frameOutput = document.createElement("output");
|
|
962
|
+
frameOutput.dataset.kavioPreviewControl = "frame";
|
|
963
|
+
const safeZonesLabel = document.createElement("label");
|
|
964
|
+
safeZonesLabel.dataset.kavioPreviewControl = "safe-zones";
|
|
965
|
+
const safeZones = document.createElement("input");
|
|
966
|
+
safeZones.type = "checkbox";
|
|
967
|
+
safeZonesLabel.append(safeZones);
|
|
968
|
+
safeZonesLabel.append(document.createTextNode(" Safe zones"));
|
|
969
|
+
const exportSelect = document.createElement("select");
|
|
970
|
+
exportSelect.dataset.kavioPreviewControl = "export";
|
|
971
|
+
element.replaceChildren(playButton, scrubber, frameOutput, safeZonesLabel, exportSelect);
|
|
972
|
+
return {
|
|
973
|
+
element,
|
|
974
|
+
bind(bindings) {
|
|
975
|
+
scrubber.addEventListener("input", () => bindings.onFrameInput(Number(scrubber.value)));
|
|
976
|
+
playButton.addEventListener("click", () => bindings.onPlayToggle());
|
|
977
|
+
safeZones.addEventListener("change", () => bindings.onSafeZoneToggle(safeZones.checked));
|
|
978
|
+
exportSelect.addEventListener("change", () => {
|
|
979
|
+
const value = exportSelect.value;
|
|
980
|
+
bindings.onExportChange(value === "" ? null : Number(value));
|
|
981
|
+
});
|
|
982
|
+
},
|
|
983
|
+
update(state, exports) {
|
|
984
|
+
playButton.textContent = state.playing ? "Pause" : "Play";
|
|
985
|
+
scrubber.max = String(Math.max(0, state.durationFrames - 1));
|
|
986
|
+
scrubber.value = String(clampFrame(state.frame, state.durationFrames));
|
|
987
|
+
frameOutput.textContent = `${state.frame + 1}/${Math.max(1, state.durationFrames)}`;
|
|
988
|
+
safeZones.checked = state.safeZonesVisible;
|
|
989
|
+
replaceExportOptions(document, exportSelect, exports, state.exportIndex);
|
|
990
|
+
}
|
|
991
|
+
};
|
|
992
|
+
}
|
|
993
|
+
function replaceExportOptions(document, select, exports, selectedIndex) {
|
|
994
|
+
const options = exports.map((preset, index) => {
|
|
995
|
+
const option = document.createElement("option");
|
|
996
|
+
option.value = String(index);
|
|
997
|
+
option.textContent = `${preset.name} (${preset.width}x${preset.height})`;
|
|
998
|
+
option.selected = selectedIndex === index;
|
|
999
|
+
return option;
|
|
1000
|
+
});
|
|
1001
|
+
select.replaceChildren(...options);
|
|
1002
|
+
}
|
|
1003
|
+
function createSafeZoneBox(document, zone, width, height, scale, color) {
|
|
1004
|
+
const box = document.createElement("div");
|
|
1005
|
+
const zoneWidth = Math.round(width * scale);
|
|
1006
|
+
const zoneHeight = Math.round(height * scale);
|
|
1007
|
+
box.dataset.kavioPreviewSafeZone = zone;
|
|
1008
|
+
box.style.position = "absolute";
|
|
1009
|
+
box.style.left = `${Math.round((width - zoneWidth) / 2)}px`;
|
|
1010
|
+
box.style.top = `${Math.round((height - zoneHeight) / 2)}px`;
|
|
1011
|
+
box.style.width = `${zoneWidth}px`;
|
|
1012
|
+
box.style.height = `${zoneHeight}px`;
|
|
1013
|
+
box.style.border = `1px dashed ${color}`;
|
|
1014
|
+
box.style.boxSizing = "border-box";
|
|
1015
|
+
return box;
|
|
1016
|
+
}
|
|
1017
|
+
function removePreviewSafeZones(root) {
|
|
1018
|
+
for (const overlay of root.querySelectorAll("[data-kavio-preview-safe-zones='true']")) {
|
|
1019
|
+
overlay.remove();
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
function getPreviewRoot(options) {
|
|
1023
|
+
if (options.root) {
|
|
1024
|
+
return options.root;
|
|
1025
|
+
}
|
|
1026
|
+
return getRenderRoot(withDefinedOptions({ document: options.document }));
|
|
1027
|
+
}
|
|
1028
|
+
function findPreviewExportIndex(exports, selection) {
|
|
1029
|
+
if (selection === null || selection === undefined) {
|
|
1030
|
+
return null;
|
|
1031
|
+
}
|
|
1032
|
+
if (typeof selection === "number") {
|
|
1033
|
+
if (!Number.isInteger(selection) || selection < 0 || selection >= exports.length) {
|
|
1034
|
+
throw new Error(`Unknown export preset index "${selection}".`);
|
|
1035
|
+
}
|
|
1036
|
+
return selection;
|
|
1037
|
+
}
|
|
1038
|
+
const index = exports.findIndex((preset) => preset.name === selection);
|
|
1039
|
+
if (index < 0) {
|
|
1040
|
+
throw new Error(`Unknown export preset "${selection}".`);
|
|
1041
|
+
}
|
|
1042
|
+
return index;
|
|
1043
|
+
}
|
|
1044
|
+
function applyPreviewLayerOverride(layer, override) {
|
|
1045
|
+
if (!override) {
|
|
1046
|
+
return layer;
|
|
1047
|
+
}
|
|
1048
|
+
return {
|
|
1049
|
+
...layer,
|
|
1050
|
+
...cloneJson(override),
|
|
1051
|
+
id: layer.id,
|
|
1052
|
+
type: layer.type
|
|
1053
|
+
};
|
|
1054
|
+
}
|
|
1055
|
+
function assertPreviewLoaded(value) {
|
|
1056
|
+
if (!value) {
|
|
1057
|
+
throw new Error("No preview composition loaded.");
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
function clampFrame(frame, durationFrames) {
|
|
1061
|
+
if (durationFrames <= 0) {
|
|
1062
|
+
return 0;
|
|
1063
|
+
}
|
|
1064
|
+
if (!Number.isFinite(frame)) {
|
|
1065
|
+
return 0;
|
|
1066
|
+
}
|
|
1067
|
+
return Math.min(Math.max(0, Math.trunc(frame)), durationFrames - 1);
|
|
1068
|
+
}
|
|
1069
|
+
function emptyCompositionResources() {
|
|
1070
|
+
return {
|
|
1071
|
+
ready: Promise.resolve(),
|
|
1072
|
+
fontFaces: []
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
function prepareCompositionResources(composition, options) {
|
|
1076
|
+
const document = getOptionalRuntimeDocument(options);
|
|
1077
|
+
if (!document) {
|
|
1078
|
+
return emptyCompositionResources();
|
|
1079
|
+
}
|
|
1080
|
+
const fontFaces = [];
|
|
1081
|
+
const ready = Promise.all([loadFontAssets(document, composition, fontFaces), preloadImageAssets(document, composition)]).then(() => undefined);
|
|
1082
|
+
return {
|
|
1083
|
+
ready,
|
|
1084
|
+
fontFaces,
|
|
1085
|
+
document
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
function releaseCompositionResources(resources) {
|
|
1089
|
+
const fonts = resources.document?.fonts;
|
|
1090
|
+
if (!fonts) {
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
for (const face of resources.fontFaces) {
|
|
1094
|
+
fonts.delete(face);
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
async function loadFontAssets(document, composition, fontFaces) {
|
|
1098
|
+
const fontAssets = Object.values(composition.assets).filter((asset) => asset.type === "font");
|
|
1099
|
+
if (fontAssets.length === 0) {
|
|
1100
|
+
await waitForDocumentFonts(document);
|
|
1101
|
+
return;
|
|
1102
|
+
}
|
|
1103
|
+
const fonts = document.fonts;
|
|
1104
|
+
const FontFaceConstructor = getFontFaceConstructor(document);
|
|
1105
|
+
if (!fonts || !FontFaceConstructor) {
|
|
1106
|
+
throw new Error("Font assets require the CSS Font Loading API to avoid silent system fallback.");
|
|
1107
|
+
}
|
|
1108
|
+
await Promise.all(fontAssets.map(async (asset) => {
|
|
1109
|
+
const face = new FontFaceConstructor(asset.family, `url("${escapeCssString(asset.src)}")`, fontDescriptors(asset));
|
|
1110
|
+
fonts.add(face);
|
|
1111
|
+
fontFaces.push(face);
|
|
1112
|
+
await face.load();
|
|
1113
|
+
}));
|
|
1114
|
+
await fonts.ready;
|
|
1115
|
+
}
|
|
1116
|
+
function getFontFaceConstructor(document) {
|
|
1117
|
+
return document.defaultView?.FontFace ?? (typeof FontFace === "undefined" ? undefined : FontFace);
|
|
1118
|
+
}
|
|
1119
|
+
function fontDescriptors(asset) {
|
|
1120
|
+
const descriptors = {};
|
|
1121
|
+
if (asset.weight !== undefined) {
|
|
1122
|
+
descriptors.weight = String(asset.weight);
|
|
1123
|
+
}
|
|
1124
|
+
if (asset.style !== undefined) {
|
|
1125
|
+
descriptors.style = asset.style;
|
|
1126
|
+
}
|
|
1127
|
+
return descriptors;
|
|
1128
|
+
}
|
|
1129
|
+
async function preloadImageAssets(document, composition) {
|
|
1130
|
+
await Promise.all(Object.entries(composition.assets)
|
|
1131
|
+
.filter((entry) => entry[1].type === "image")
|
|
1132
|
+
.map(([assetId, asset]) => decodeImageSource(document, asset.src, `image asset "${assetId}"`)));
|
|
1133
|
+
}
|
|
1134
|
+
async function applyImageContent(document, element, composition, layer) {
|
|
1135
|
+
const asset = composition.assets[layer.asset];
|
|
1136
|
+
if (!asset || asset.type !== "image") {
|
|
1137
|
+
throw new Error(`Image layer "${layer.id}" references missing image asset "${layer.asset}".`);
|
|
1138
|
+
}
|
|
1139
|
+
const image = document.createElement("img");
|
|
1140
|
+
image.alt = "";
|
|
1141
|
+
image.decoding = "sync";
|
|
1142
|
+
image.draggable = false;
|
|
1143
|
+
image.src = asset.src;
|
|
1144
|
+
applyMediaPlaceholderContent(element, layer.asset, layer.fit);
|
|
1145
|
+
applyImageFit(image, layer.fit ?? "cover");
|
|
1146
|
+
element.replaceChildren(image);
|
|
1147
|
+
await decodeImageElement(image, `image asset "${layer.asset}"`);
|
|
1148
|
+
return {
|
|
1149
|
+
intrinsicSize: {
|
|
1150
|
+
width: image.naturalWidth,
|
|
1151
|
+
height: image.naturalHeight
|
|
1152
|
+
}
|
|
1153
|
+
};
|
|
1154
|
+
}
|
|
1155
|
+
function applyVideoContent(document, element, composition, layer, evaluation) {
|
|
1156
|
+
const asset = composition.assets[layer.asset];
|
|
1157
|
+
if (!asset || asset.type !== "video") {
|
|
1158
|
+
throw new Error(`Video layer "${layer.id}" references missing video asset "${layer.asset}".`);
|
|
1159
|
+
}
|
|
1160
|
+
const video = document.createElement("video");
|
|
1161
|
+
video.src = asset.src;
|
|
1162
|
+
video.muted = layer.muted ?? true;
|
|
1163
|
+
video.loop = asset.loop ?? false;
|
|
1164
|
+
video.preload = "auto";
|
|
1165
|
+
video.playsInline = true;
|
|
1166
|
+
video.dataset.kavioMediaType = "video";
|
|
1167
|
+
applyMediaPlaceholderContent(element, layer.asset, layer.fit);
|
|
1168
|
+
applyMediaFit(video, layer.fit ?? "cover");
|
|
1169
|
+
applyVideoCropPreview(element, video, layer.crop, evaluation.localFrame);
|
|
1170
|
+
element.replaceChildren(video);
|
|
1171
|
+
return {};
|
|
1172
|
+
}
|
|
1173
|
+
function applyMediaPlaceholderContent(element, assetId, fit) {
|
|
1174
|
+
element.dataset.kavioAsset = assetId;
|
|
1175
|
+
element.dataset.kavioFit = fit ?? "cover";
|
|
1176
|
+
element.style.overflow = "hidden";
|
|
1177
|
+
}
|
|
1178
|
+
function applyImageFit(image, fit) {
|
|
1179
|
+
applyMediaFit(image, fit);
|
|
1180
|
+
}
|
|
1181
|
+
function applyMediaFit(media, fit) {
|
|
1182
|
+
media.style.display = "block";
|
|
1183
|
+
media.style.width = "100%";
|
|
1184
|
+
media.style.height = "100%";
|
|
1185
|
+
media.style.objectFit = fit;
|
|
1186
|
+
media.style.objectPosition = "center center";
|
|
1187
|
+
media.style.pointerEvents = "none";
|
|
1188
|
+
media.style.userSelect = "none";
|
|
1189
|
+
}
|
|
1190
|
+
function applyVideoCropPreview(element, video, crop, localFrame) {
|
|
1191
|
+
if (crop?.mode !== "subject") {
|
|
1192
|
+
element.dataset.kavioCropMode = crop?.mode ?? "center";
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
const focus = evaluateSubjectCropFocus(crop, localFrame);
|
|
1196
|
+
const xPercent = `${formatPercent(focus.x)}%`;
|
|
1197
|
+
const yPercent = `${formatPercent(focus.y)}%`;
|
|
1198
|
+
element.dataset.kavioCropMode = "subject";
|
|
1199
|
+
element.dataset.kavioSubjectX = formatUnit(focus.x);
|
|
1200
|
+
element.dataset.kavioSubjectY = formatUnit(focus.y);
|
|
1201
|
+
video.style.objectPosition = `${xPercent} ${yPercent}`;
|
|
1202
|
+
}
|
|
1203
|
+
function evaluateSubjectCropFocus(crop, localFrame) {
|
|
1204
|
+
const keyframes = [...(crop.keyframes ?? [])].sort((a, b) => a.frame - b.frame);
|
|
1205
|
+
if (keyframes.length === 0) {
|
|
1206
|
+
return {
|
|
1207
|
+
x: clampUnit(crop.x ?? 0.5),
|
|
1208
|
+
y: clampUnit(crop.y ?? 0.5)
|
|
1209
|
+
};
|
|
1210
|
+
}
|
|
1211
|
+
const first = keyframes[0];
|
|
1212
|
+
if (localFrame <= first.frame) {
|
|
1213
|
+
return { x: clampUnit(first.x), y: clampUnit(first.y) };
|
|
1214
|
+
}
|
|
1215
|
+
for (let index = 1; index < keyframes.length; index += 1) {
|
|
1216
|
+
const next = keyframes[index];
|
|
1217
|
+
if (localFrame <= next.frame) {
|
|
1218
|
+
const previous = keyframes[index - 1];
|
|
1219
|
+
const span = next.frame - previous.frame;
|
|
1220
|
+
const t = span <= 0 ? 1 : (localFrame - previous.frame) / span;
|
|
1221
|
+
return {
|
|
1222
|
+
x: clampUnit(lerp(previous.x, next.x, t)),
|
|
1223
|
+
y: clampUnit(lerp(previous.y, next.y, t))
|
|
1224
|
+
};
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
const last = keyframes[keyframes.length - 1];
|
|
1228
|
+
return { x: clampUnit(last.x), y: clampUnit(last.y) };
|
|
1229
|
+
}
|
|
1230
|
+
function lerp(from, to, t) {
|
|
1231
|
+
return from + (to - from) * t;
|
|
1232
|
+
}
|
|
1233
|
+
function clampUnit(value) {
|
|
1234
|
+
if (!Number.isFinite(value)) {
|
|
1235
|
+
return 0.5;
|
|
1236
|
+
}
|
|
1237
|
+
return Math.min(1, Math.max(0, value));
|
|
1238
|
+
}
|
|
1239
|
+
function formatUnit(value) {
|
|
1240
|
+
return String(Number(value.toFixed(4)));
|
|
1241
|
+
}
|
|
1242
|
+
function formatPercent(value) {
|
|
1243
|
+
return String(Number((value * 100).toFixed(4)));
|
|
1244
|
+
}
|
|
1245
|
+
async function decodeImageSource(document, src, label) {
|
|
1246
|
+
const image = document.createElement("img");
|
|
1247
|
+
image.decoding = "sync";
|
|
1248
|
+
image.src = src;
|
|
1249
|
+
await decodeImageElement(image, label);
|
|
1250
|
+
}
|
|
1251
|
+
async function decodeImageElement(image, label) {
|
|
1252
|
+
try {
|
|
1253
|
+
if (typeof image.decode === "function") {
|
|
1254
|
+
await image.decode();
|
|
1255
|
+
}
|
|
1256
|
+
else {
|
|
1257
|
+
await waitForImageLoad(image);
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
catch (error) {
|
|
1261
|
+
if (!image.complete || image.naturalWidth <= 0) {
|
|
1262
|
+
throw new Error(`Failed to decode ${label}.`, { cause: error });
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
if (image.naturalWidth <= 0 || image.naturalHeight <= 0) {
|
|
1266
|
+
throw new Error(`Failed to decode ${label}.`);
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
function waitForImageLoad(image) {
|
|
1270
|
+
if (image.complete && image.naturalWidth > 0) {
|
|
1271
|
+
return Promise.resolve();
|
|
1272
|
+
}
|
|
1273
|
+
return new Promise((resolve, reject) => {
|
|
1274
|
+
image.addEventListener("load", () => resolve(), { once: true });
|
|
1275
|
+
image.addEventListener("error", () => reject(new Error("Image load failed.")), { once: true });
|
|
1276
|
+
});
|
|
1277
|
+
}
|
|
1278
|
+
function applyCaptionContent(document, element, layer, caption, dimensions) {
|
|
1279
|
+
applyTextStyle(element, layer.style);
|
|
1280
|
+
applyCaptionLayout(element, layer, dimensions);
|
|
1281
|
+
if (!caption?.visible) {
|
|
1282
|
+
element.textContent = "";
|
|
1283
|
+
element.dataset.kavioCaptionVisible = "false";
|
|
1284
|
+
return;
|
|
1285
|
+
}
|
|
1286
|
+
element.dataset.kavioCaptionVisible = "true";
|
|
1287
|
+
element.dataset.kavioCaptionCueIndex = String(caption.cueIndex ?? "");
|
|
1288
|
+
element.dataset.kavioCaptionHighlightMode = caption.highlightMode;
|
|
1289
|
+
if (caption.highlightMode === "word" && caption.words.length > 0) {
|
|
1290
|
+
renderCaptionWords(document, element, caption, layer.style);
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
const text = wrapCaptionText(caption.lineText, layer.style);
|
|
1294
|
+
if (caption.highlightMode === "line") {
|
|
1295
|
+
const highlight = document.createElement("span");
|
|
1296
|
+
highlight.textContent = text;
|
|
1297
|
+
applyCaptionHighlightStyle(highlight, layer.style);
|
|
1298
|
+
element.replaceChildren(highlight);
|
|
1299
|
+
return;
|
|
1300
|
+
}
|
|
1301
|
+
element.textContent = text;
|
|
1302
|
+
}
|
|
1303
|
+
function applyCaptionLayout(element, layer, dimensions) {
|
|
1304
|
+
const style = layer.style;
|
|
1305
|
+
if (style?.maxCharsPerLine && style.maxCharsPerLine > 0) {
|
|
1306
|
+
element.style.maxWidth = `${style.maxCharsPerLine}ch`;
|
|
1307
|
+
}
|
|
1308
|
+
if (usesCaptionSafeArea(layer) && layer.size?.width === undefined) {
|
|
1309
|
+
element.style.width = `${Math.round(dimensions.width * 0.86)}px`;
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1312
|
+
function renderCaptionWords(document, element, caption, style) {
|
|
1313
|
+
const nodes = [];
|
|
1314
|
+
caption.words.forEach((word, index) => {
|
|
1315
|
+
if (index > 0) {
|
|
1316
|
+
nodes.push(document.createTextNode(" "));
|
|
1317
|
+
}
|
|
1318
|
+
const span = document.createElement("span");
|
|
1319
|
+
span.textContent = word.text;
|
|
1320
|
+
span.dataset.kavioCaptionWordIndex = String(word.index);
|
|
1321
|
+
span.dataset.kavioCaptionWordState = word.state;
|
|
1322
|
+
if (word.active) {
|
|
1323
|
+
span.dataset.kavioCaptionWordActive = "true";
|
|
1324
|
+
applyCaptionHighlightStyle(span, style);
|
|
1325
|
+
}
|
|
1326
|
+
nodes.push(span);
|
|
1327
|
+
});
|
|
1328
|
+
element.replaceChildren(...nodes);
|
|
1329
|
+
}
|
|
1330
|
+
function applyCaptionHighlightStyle(element, style) {
|
|
1331
|
+
const highlight = style?.highlight;
|
|
1332
|
+
element.style.color = highlight?.color ?? style?.color ?? "currentColor";
|
|
1333
|
+
if (highlight?.scale !== undefined && highlight.scale !== 1) {
|
|
1334
|
+
element.style.display = "inline-block";
|
|
1335
|
+
element.style.transform = `scale(${highlight.scale})`;
|
|
1336
|
+
element.style.transformOrigin = "center";
|
|
1337
|
+
}
|
|
1338
|
+
}
|
|
1339
|
+
function wrapCaptionText(text, style) {
|
|
1340
|
+
const maxChars = style?.maxCharsPerLine;
|
|
1341
|
+
const maxLines = style?.maxLines;
|
|
1342
|
+
const sourceLines = text.split(/\r\n?|\n/);
|
|
1343
|
+
const lines = maxChars && maxChars > 0
|
|
1344
|
+
? sourceLines.flatMap((line) => wrapCaptionLine(line, maxChars))
|
|
1345
|
+
: sourceLines;
|
|
1346
|
+
return maxLines && maxLines > 0 ? lines.slice(0, maxLines).join("\n") : lines.join("\n");
|
|
1347
|
+
}
|
|
1348
|
+
function wrapCaptionLine(line, maxChars) {
|
|
1349
|
+
const words = line.split(/\s+/).filter(Boolean);
|
|
1350
|
+
if (words.length === 0) {
|
|
1351
|
+
return [""];
|
|
1352
|
+
}
|
|
1353
|
+
const lines = [];
|
|
1354
|
+
let current = "";
|
|
1355
|
+
for (const word of words) {
|
|
1356
|
+
const next = current.length === 0 ? word : `${current} ${word}`;
|
|
1357
|
+
if (next.length <= maxChars || current.length === 0) {
|
|
1358
|
+
current = next;
|
|
1359
|
+
continue;
|
|
1360
|
+
}
|
|
1361
|
+
lines.push(current);
|
|
1362
|
+
current = word;
|
|
1363
|
+
}
|
|
1364
|
+
if (current.length > 0) {
|
|
1365
|
+
lines.push(current);
|
|
1366
|
+
}
|
|
1367
|
+
return lines;
|
|
1368
|
+
}
|
|
1369
|
+
function applyEvaluatedSize(element, evaluation) {
|
|
1370
|
+
if (evaluation.size.width !== null) {
|
|
1371
|
+
element.style.width = `${evaluation.size.width}px`;
|
|
1372
|
+
}
|
|
1373
|
+
if (evaluation.size.height !== null) {
|
|
1374
|
+
element.style.height = `${evaluation.size.height}px`;
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
function resolveRenderedSize(evaluation, intrinsicSize) {
|
|
1378
|
+
if (!intrinsicSize || intrinsicSize.width <= 0 || intrinsicSize.height <= 0) {
|
|
1379
|
+
return evaluation.size;
|
|
1380
|
+
}
|
|
1381
|
+
if (evaluation.size.width !== null && evaluation.size.height !== null) {
|
|
1382
|
+
return evaluation.size;
|
|
1383
|
+
}
|
|
1384
|
+
if (evaluation.size.width !== null) {
|
|
1385
|
+
return {
|
|
1386
|
+
width: evaluation.size.width,
|
|
1387
|
+
height: (intrinsicSize.height / intrinsicSize.width) * evaluation.size.width
|
|
1388
|
+
};
|
|
1389
|
+
}
|
|
1390
|
+
if (evaluation.size.height !== null) {
|
|
1391
|
+
return {
|
|
1392
|
+
width: (intrinsicSize.width / intrinsicSize.height) * evaluation.size.height,
|
|
1393
|
+
height: evaluation.size.height
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
1396
|
+
return intrinsicSize;
|
|
1397
|
+
}
|
|
1398
|
+
function applyRenderedSize(element, evaluation, renderedSize) {
|
|
1399
|
+
if (evaluation.size.width === null && renderedSize.width !== null) {
|
|
1400
|
+
element.style.width = `${renderedSize.width}px`;
|
|
1401
|
+
}
|
|
1402
|
+
if (evaluation.size.height === null && renderedSize.height !== null) {
|
|
1403
|
+
element.style.height = `${renderedSize.height}px`;
|
|
1404
|
+
}
|
|
1405
|
+
}
|
|
1406
|
+
function applyLayerTransform(element, layer, evaluation, dimensions) {
|
|
1407
|
+
if (layer.type === "caption" && usesCaptionSafeArea(layer)) {
|
|
1408
|
+
const placement = resolveCaptionSafeAreaPlacement(layer, dimensions);
|
|
1409
|
+
element.style.left = `${placement.position.x}px`;
|
|
1410
|
+
element.style.top = `${placement.position.y}px`;
|
|
1411
|
+
element.style.transformOrigin = `${placement.anchor.x * 100}% ${placement.anchor.y * 100}%`;
|
|
1412
|
+
element.style.transform = [
|
|
1413
|
+
`translate(${-placement.anchor.x * 100}%, ${-placement.anchor.y * 100}%)`,
|
|
1414
|
+
...transitionTransformParts(evaluation),
|
|
1415
|
+
`rotate(${evaluation.rotation}deg)`,
|
|
1416
|
+
`scale(${evaluation.scale})`
|
|
1417
|
+
].join(" ");
|
|
1418
|
+
return;
|
|
1419
|
+
}
|
|
1420
|
+
element.style.transformOrigin = `${evaluation.anchor.x * 100}% ${evaluation.anchor.y * 100}%`;
|
|
1421
|
+
if (evaluation.transform) {
|
|
1422
|
+
element.style.transformStyle = "preserve-3d";
|
|
1423
|
+
element.style.backfaceVisibility = "hidden";
|
|
1424
|
+
}
|
|
1425
|
+
element.style.transform = [
|
|
1426
|
+
`translate(${-evaluation.anchor.x * 100}%, ${-evaluation.anchor.y * 100}%)`,
|
|
1427
|
+
...transitionTransformParts(evaluation),
|
|
1428
|
+
`rotate(${evaluation.rotation}deg)`,
|
|
1429
|
+
`scale(${evaluation.scale})`
|
|
1430
|
+
].join(" ");
|
|
1431
|
+
}
|
|
1432
|
+
function transitionTransformParts(evaluation) {
|
|
1433
|
+
if (!evaluation.transform) {
|
|
1434
|
+
return [];
|
|
1435
|
+
}
|
|
1436
|
+
return [
|
|
1437
|
+
`perspective(1200px)`,
|
|
1438
|
+
`rotateX(${evaluation.transform.rotateX}deg)`,
|
|
1439
|
+
`rotateY(${evaluation.transform.rotateY}deg)`,
|
|
1440
|
+
`skewX(${evaluation.transform.skewX}deg)`,
|
|
1441
|
+
`skewY(${evaluation.transform.skewY}deg)`,
|
|
1442
|
+
`scaleX(${evaluation.transform.scaleX})`,
|
|
1443
|
+
`scaleY(${evaluation.transform.scaleY})`
|
|
1444
|
+
];
|
|
1445
|
+
}
|
|
1446
|
+
function usesCaptionSafeArea(layer) {
|
|
1447
|
+
return layer.position === undefined && layer.anchor === undefined;
|
|
1448
|
+
}
|
|
1449
|
+
function resolveCaptionSafeAreaPlacement(layer, dimensions) {
|
|
1450
|
+
const safeArea = layer.safeArea ?? "bottom";
|
|
1451
|
+
if (typeof safeArea === "object") {
|
|
1452
|
+
return {
|
|
1453
|
+
position: resolvePoint(safeArea, dimensions),
|
|
1454
|
+
anchor: { x: 0.5, y: 0.5 }
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
const inset = Math.round(dimensions.height * 0.08);
|
|
1458
|
+
switch (safeArea) {
|
|
1459
|
+
case "top":
|
|
1460
|
+
return {
|
|
1461
|
+
position: { x: dimensions.width / 2, y: inset },
|
|
1462
|
+
anchor: { x: 0.5, y: 0 }
|
|
1463
|
+
};
|
|
1464
|
+
case "center":
|
|
1465
|
+
return {
|
|
1466
|
+
position: { x: dimensions.width / 2, y: dimensions.height / 2 },
|
|
1467
|
+
anchor: { x: 0.5, y: 0.5 }
|
|
1468
|
+
};
|
|
1469
|
+
case "bottom":
|
|
1470
|
+
default:
|
|
1471
|
+
return {
|
|
1472
|
+
position: { x: dimensions.width / 2, y: dimensions.height - inset },
|
|
1473
|
+
anchor: { x: 0.5, y: 1 }
|
|
1474
|
+
};
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
async function waitForDocumentFonts(document) {
|
|
1478
|
+
if (document.fonts) {
|
|
1479
|
+
await document.fonts.ready;
|
|
1480
|
+
}
|
|
1481
|
+
}
|
|
1482
|
+
function escapeCssString(value) {
|
|
1483
|
+
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\n/g, "\\a ");
|
|
1484
|
+
}
|
|
1485
|
+
function getRenderRoot(options) {
|
|
1486
|
+
if (options.root) {
|
|
1487
|
+
return options.root;
|
|
1488
|
+
}
|
|
1489
|
+
const document = getRuntimeDocument(options.document);
|
|
1490
|
+
const existingRoot = document.querySelector("[data-kavio-runtime-root='true']");
|
|
1491
|
+
if (existingRoot) {
|
|
1492
|
+
return existingRoot;
|
|
1493
|
+
}
|
|
1494
|
+
if (!document.body) {
|
|
1495
|
+
throw new Error("Browser renderer requires a document body or an explicit root element.");
|
|
1496
|
+
}
|
|
1497
|
+
const root = document.createElement("div");
|
|
1498
|
+
root.dataset.kavioRuntimeRoot = "true";
|
|
1499
|
+
document.body.append(root);
|
|
1500
|
+
return root;
|
|
1501
|
+
}
|
|
1502
|
+
function getLoadedComposition(composition) {
|
|
1503
|
+
const dimensions = getCanvasDimensions(composition.composition);
|
|
1504
|
+
return {
|
|
1505
|
+
width: dimensions.width,
|
|
1506
|
+
height: dimensions.height,
|
|
1507
|
+
fps: composition.composition.fps,
|
|
1508
|
+
durationFrames: composition.composition.durationFrames
|
|
1509
|
+
};
|
|
1510
|
+
}
|
|
1511
|
+
function assertRenderableFrame(frame, composition) {
|
|
1512
|
+
if (!Number.isInteger(frame) || frame < 0) {
|
|
1513
|
+
throw new Error("Frame must be a non-negative integer.");
|
|
1514
|
+
}
|
|
1515
|
+
if (frame >= composition.composition.durationFrames) {
|
|
1516
|
+
throw new Error("Frame must be less than composition.durationFrames.");
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
function cloneComposition(composition) {
|
|
1520
|
+
return cloneJson(composition);
|
|
1521
|
+
}
|
|
1522
|
+
function cloneJson(value) {
|
|
1523
|
+
return JSON.parse(JSON.stringify(value));
|
|
1524
|
+
}
|
|
1525
|
+
function withDefinedOptions(options) {
|
|
1526
|
+
const defined = {};
|
|
1527
|
+
for (const [key, value] of Object.entries(options)) {
|
|
1528
|
+
if (value !== undefined) {
|
|
1529
|
+
defined[key] = value;
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
return defined;
|
|
1533
|
+
}
|
|
1534
|
+
function getOptionalRuntimeDocument(options) {
|
|
1535
|
+
if (options.root) {
|
|
1536
|
+
return options.root.ownerDocument;
|
|
1537
|
+
}
|
|
1538
|
+
if (options.document) {
|
|
1539
|
+
return options.document;
|
|
1540
|
+
}
|
|
1541
|
+
return typeof document === "undefined" ? undefined : document;
|
|
1542
|
+
}
|
|
1543
|
+
function getRuntimeHost(documentOverride) {
|
|
1544
|
+
const defaultView = getRuntimeDocument(documentOverride).defaultView;
|
|
1545
|
+
if (defaultView) {
|
|
1546
|
+
return defaultView;
|
|
1547
|
+
}
|
|
1548
|
+
if (typeof window === "undefined") {
|
|
1549
|
+
throw new Error("Browser renderer runtime installation requires a Window.");
|
|
1550
|
+
}
|
|
1551
|
+
return window;
|
|
1552
|
+
}
|
|
1553
|
+
function getRuntimeDocument(documentOverride) {
|
|
1554
|
+
if (documentOverride) {
|
|
1555
|
+
return documentOverride;
|
|
1556
|
+
}
|
|
1557
|
+
if (typeof document === "undefined") {
|
|
1558
|
+
throw new Error("Browser renderer requires a DOM document.");
|
|
1559
|
+
}
|
|
1560
|
+
return document;
|
|
1561
|
+
}
|