@mcut/compositor 0.1.0-alpha.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/dist/index.js ADDED
@@ -0,0 +1,2647 @@
1
+ import { buildFilterString, getActiveLayout, getActiveTransitionPairs, getAngleTransitionAt, getLayout, getMulticamSourceTimeMs, getRunStyleAt, getSourceTimeMs, getTransitionCompletion, isElementActiveAt, registerEffectType, resolveAnimatedElement, toCompositeOperation } from "@mcut/timeline";
2
+ import { z } from "zod";
3
+ //#region src/geometry.ts
4
+ /**
5
+ * Element coordinates are center-origin: (0, 0) is the canvas center and the
6
+ * element is anchored at its own center. This converts to canvas pixels.
7
+ */
8
+ function toCanvasPoint(project, x, y) {
9
+ return {
10
+ x: project.width / 2 + x,
11
+ y: project.height / 2 + y
12
+ };
13
+ }
14
+ function fromCanvasPoint(project, x, y) {
15
+ return {
16
+ x: x - project.width / 2,
17
+ y: y - project.height / 2
18
+ };
19
+ }
20
+ const degToRad = (deg) => deg * Math.PI / 180;
21
+ function getElementNaturalSize(element, helpers = {}) {
22
+ if (element.type === "audio" || element.type === "caption" || element.type === "multicam") return null;
23
+ if (element.type === "text") return helpers.measureText?.(element.text, element.style, element.box, element.runs) ?? null;
24
+ const size = helpers.getAssetSize?.(element.assetId) ?? null;
25
+ if (size && "crop" in element && element.crop) return {
26
+ width: size.width * element.crop.w,
27
+ height: size.height * element.crop.h
28
+ };
29
+ return size;
30
+ }
31
+ function getElementDisplaySize(element, helpers = {}) {
32
+ if (!("transform" in element)) return null;
33
+ const natural = getElementNaturalSize(element, helpers);
34
+ if (!natural || natural.width <= 0 || natural.height <= 0) return null;
35
+ return {
36
+ width: natural.width * Math.abs(element.transform.scaleX),
37
+ height: natural.height * Math.abs(element.transform.scaleY)
38
+ };
39
+ }
40
+ function getTransformForDisplaySize(transform, natural, patch) {
41
+ if (natural.width <= 0 || natural.height <= 0) return transform;
42
+ const signX = transform.scaleX < 0 ? -1 : 1;
43
+ const signY = transform.scaleY < 0 ? -1 : 1;
44
+ let scaleX = patch.width !== void 0 ? signX * Math.max(.001, patch.width / natural.width) : transform.scaleX;
45
+ let scaleY = patch.height !== void 0 ? signY * Math.max(.001, patch.height / natural.height) : transform.scaleY;
46
+ if (patch.preserveAspect) {
47
+ if (patch.width !== void 0 && patch.height === void 0) scaleY = signY * Math.abs(scaleX);
48
+ if (patch.height !== void 0 && patch.width === void 0) scaleX = signX * Math.abs(scaleY);
49
+ }
50
+ return {
51
+ ...transform,
52
+ scaleX,
53
+ scaleY
54
+ };
55
+ }
56
+ /**
57
+ * The element's oriented bounding box on the canvas, or `null` when its size
58
+ * is unknown (e.g. unprobed media without a frame yet). Captions are
59
+ * positioned by their style band and are not transformable; they have no OBB.
60
+ */
61
+ function getElementOBB(project, element, helpers = {}) {
62
+ if (element.type === "audio" || element.type === "caption" || element.type === "multicam") return null;
63
+ const natural = getElementNaturalSize(element, helpers);
64
+ const transform = element.transform;
65
+ if (!natural || natural.width <= 0 || natural.height <= 0) return null;
66
+ const center = toCanvasPoint(project, transform.x, transform.y);
67
+ return {
68
+ cx: center.x,
69
+ cy: center.y,
70
+ width: natural.width * Math.abs(transform.scaleX),
71
+ height: natural.height * Math.abs(transform.scaleY),
72
+ rotation: transform.rotation
73
+ };
74
+ }
75
+ /** Is the canvas-space point inside the (rotated) box? */
76
+ function hitTestOBB(obb, x, y) {
77
+ const rad = degToRad(-obb.rotation);
78
+ const dx = x - obb.cx;
79
+ const dy = y - obb.cy;
80
+ const localX = dx * Math.cos(rad) - dy * Math.sin(rad);
81
+ const localY = dx * Math.sin(rad) + dy * Math.cos(rad);
82
+ return Math.abs(localX) <= obb.width / 2 && Math.abs(localY) <= obb.height / 2;
83
+ }
84
+ /** Distance of the rotate handle above the box's top edge (canvas px). */
85
+ const ROTATE_HANDLE_OFFSET = 32;
86
+ /** The 8 resize handles plus the rotate handle, in canvas space. */
87
+ function getHandles(obb) {
88
+ const rad = degToRad(obb.rotation);
89
+ const cos = Math.cos(rad);
90
+ const sin = Math.sin(rad);
91
+ const hw = obb.width / 2;
92
+ const hh = obb.height / 2;
93
+ return [
94
+ [
95
+ "nw",
96
+ -hw,
97
+ -hh
98
+ ],
99
+ [
100
+ "n",
101
+ 0,
102
+ -hh
103
+ ],
104
+ [
105
+ "ne",
106
+ hw,
107
+ -hh
108
+ ],
109
+ [
110
+ "e",
111
+ hw,
112
+ 0
113
+ ],
114
+ [
115
+ "se",
116
+ hw,
117
+ hh
118
+ ],
119
+ [
120
+ "s",
121
+ 0,
122
+ hh
123
+ ],
124
+ [
125
+ "sw",
126
+ -hw,
127
+ hh
128
+ ],
129
+ [
130
+ "w",
131
+ -hw,
132
+ 0
133
+ ],
134
+ [
135
+ "rotate",
136
+ 0,
137
+ -hh - 32
138
+ ]
139
+ ].map(([id, lx, ly]) => ({
140
+ id,
141
+ x: obb.cx + lx * cos - ly * sin,
142
+ y: obb.cy + lx * sin + ly * cos
143
+ }));
144
+ }
145
+ /** Hit-test the handles (square hit area of `size` px around each). */
146
+ function hitTestHandles(obb, x, y, size = 12) {
147
+ for (const handle of getHandles(obb)) if (Math.abs(x - handle.x) <= size && Math.abs(y - handle.y) <= size) return handle.id;
148
+ return null;
149
+ }
150
+ /**
151
+ * "Contain" scale factor for fitting a `width`×`height` media into the
152
+ * project frame (used when inserting media elements).
153
+ */
154
+ function getFitScale(project, width, height) {
155
+ if (width <= 0 || height <= 0) return 1;
156
+ return Math.min(project.width / width, project.height / height);
157
+ }
158
+ //#endregion
159
+ //#region src/backend.ts
160
+ /**
161
+ * Transform + opacity + effect-stack filter + blend mode around a canvas2d
162
+ * draw (the original withTransform). `ctx.filter` is unsupported in some
163
+ * older engines; there the stack degrades to an unfiltered render rather
164
+ * than failing.
165
+ */
166
+ function applyChrome(ctx, chrome, draw) {
167
+ ctx.save();
168
+ ctx.globalAlpha *= chrome.opacity;
169
+ const filter = buildFilterString(chrome.effects);
170
+ if (filter && "filter" in ctx) ctx.filter = filter;
171
+ if (chrome.blendMode) ctx.globalCompositeOperation = toCompositeOperation(chrome.blendMode);
172
+ ctx.translate(chrome.centerX, chrome.centerY);
173
+ if (chrome.rotationDeg !== 0) ctx.rotate(chrome.rotationDeg * Math.PI / 180);
174
+ ctx.scale(chrome.scaleX, chrome.scaleY);
175
+ draw();
176
+ ctx.restore();
177
+ }
178
+ /** Draw an {@link ImageQuad} in local (chrome-applied) coordinates. */
179
+ function drawImageQuad2D(ctx, quad) {
180
+ ctx.save();
181
+ if (quad.cornerRadius > 0) {
182
+ ctx.beginPath();
183
+ ctx.roundRect(-quad.dw / 2, -quad.dh / 2, quad.dw, quad.dh, quad.cornerRadius);
184
+ ctx.clip();
185
+ }
186
+ if (quad.src) ctx.drawImage(quad.image, quad.src.sx, quad.src.sy, quad.src.sw, quad.src.sh, -quad.dw / 2, -quad.dh / 2, quad.dw, quad.dh);
187
+ else ctx.drawImage(quad.image, -quad.dw / 2, -quad.dh / 2, quad.dw, quad.dh);
188
+ ctx.restore();
189
+ }
190
+ /** The canvas2d backend: draws straight into the target context. */
191
+ var Canvas2DBackend = class {
192
+ ctx;
193
+ width;
194
+ height;
195
+ kind = "canvas2d";
196
+ constructor(ctx, width, height) {
197
+ this.ctx = ctx;
198
+ this.width = width;
199
+ this.height = height;
200
+ }
201
+ beginFrame(backgroundColor) {
202
+ this.ctx.save();
203
+ this.ctx.fillStyle = backgroundColor;
204
+ this.ctx.fillRect(0, 0, this.width, this.height);
205
+ }
206
+ endFrame() {
207
+ this.ctx.restore();
208
+ }
209
+ acquireRaster() {
210
+ return this.ctx;
211
+ }
212
+ drawImageQuad(quad, chrome) {
213
+ applyChrome(this.ctx, chrome, () => drawImageQuad2D(this.ctx, quad));
214
+ }
215
+ pushRasterScope() {}
216
+ popRasterScope() {}
217
+ };
218
+ /**
219
+ * Build the per-element render context. `ctx` is a getter so backends learn
220
+ * when raster content is actually being drawn (GPU backends flush the
221
+ * raster surface lazily, in z-order).
222
+ */
223
+ function createElementContext(backend, project, track, timeMs, source) {
224
+ return {
225
+ backend,
226
+ project,
227
+ track,
228
+ timeMs,
229
+ source,
230
+ get ctx() {
231
+ return backend.acquireRaster();
232
+ }
233
+ };
234
+ }
235
+ //#endregion
236
+ //#region src/motion-blur.ts
237
+ /**
238
+ * Per-element motion blur, After Effects' layer model: the element renders
239
+ * N times at sub-frame moments inside a shutter window centered on the
240
+ * frame, each pass at 1/N alpha, accumulated additively in a scratch canvas
241
+ * and composited once. Deterministic — sample times derive only from the
242
+ * frame time and project fps, so preview, scrubbing in any order, and export
243
+ * all blur identically.
244
+ *
245
+ * Only KEYFRAMED transform motion blurs (the keyframes give us the
246
+ * sub-frame transforms analytically); static clips and motion inside the
247
+ * source footage are untouched. Video sub-samples reuse the single source
248
+ * frame at the frame's center time — only the transform sweeps.
249
+ */
250
+ const TRANSFORM_PROPERTIES = [
251
+ "position.x",
252
+ "position.y",
253
+ "scale.x",
254
+ "scale.y",
255
+ "rotation"
256
+ ];
257
+ const DEFAULT_SAMPLES = 8;
258
+ /** Movement gates: skip the N-pass cost when travel inside the window is invisible. */
259
+ const MIN_TRAVEL_PX = .75;
260
+ const MIN_ROTATION_DEG = .05;
261
+ const MIN_SCALE_DELTA = .002;
262
+ function getMotionBlur(element) {
263
+ return "motionBlur" in element ? element.motionBlur : void 0;
264
+ }
265
+ function hasTransformMotion(element) {
266
+ const keyframes = "keyframes" in element ? element.keyframes : void 0;
267
+ if (!keyframes) return false;
268
+ return TRANSFORM_PROPERTIES.some((property) => (keyframes[property]?.length ?? 0) >= 2);
269
+ }
270
+ function isMovingBetween(element, t0, t1) {
271
+ const a = resolveAnimatedElement(element, t0);
272
+ const b = resolveAnimatedElement(element, t1);
273
+ if (!("transform" in a) || !("transform" in b)) return false;
274
+ const from = a.transform;
275
+ const to = b.transform;
276
+ if (Math.hypot(to.x - from.x, to.y - from.y) >= MIN_TRAVEL_PX) return true;
277
+ if (Math.abs(to.rotation - from.rotation) >= MIN_ROTATION_DEG) return true;
278
+ return Math.abs(to.scaleX - from.scaleX) >= MIN_SCALE_DELTA || Math.abs(to.scaleY - from.scaleY) >= MIN_SCALE_DELTA;
279
+ }
280
+ /** Cached accumulation surface; cleared before every use, so reuse is safe. */
281
+ let cachedScratch = null;
282
+ function acquireScratch(width, height, options) {
283
+ if (options.createScratchContext) return options.createScratchContext(width, height);
284
+ if (typeof OffscreenCanvas === "undefined") return null;
285
+ const cachedCanvas = cachedScratch?.canvas;
286
+ if (!cachedScratch || cachedCanvas?.width !== width || cachedCanvas?.height !== height) {
287
+ const ctx = new OffscreenCanvas(width, height).getContext("2d");
288
+ if (!ctx) return null;
289
+ cachedScratch = ctx;
290
+ }
291
+ return cachedScratch;
292
+ }
293
+ /**
294
+ * Render `element` with motion blur into `ctx`. Returns false when motion
295
+ * blur does not apply (off, no keyframed transform motion, sub-threshold
296
+ * travel, or no scratch surface available) — the caller then renders the
297
+ * plain single-sample pass.
298
+ */
299
+ function renderElementWithMotionBlur(backend, project, track, element, timeMs, options, renderer) {
300
+ const blur = getMotionBlur(element);
301
+ if (!blur?.enabled) return false;
302
+ if (!hasTransformMotion(element)) return false;
303
+ const windowMs = 1e3 / project.fps * (blur.shutterAngle / 360);
304
+ if (!(windowMs > 0)) return false;
305
+ const start = timeMs - windowMs / 2;
306
+ if (!isMovingBetween(element, start, start + windowMs)) return false;
307
+ const scratch = acquireScratch(project.width, project.height, options);
308
+ if (!scratch) return false;
309
+ const samples = Math.max(2, Math.min(64, Math.round(options.motionBlurSamples ?? DEFAULT_SAMPLES)));
310
+ scratch.clearRect(0, 0, project.width, project.height);
311
+ scratch.save();
312
+ scratch.globalCompositeOperation = "lighter";
313
+ scratch.globalAlpha = 1 / samples;
314
+ const subBackend = new Canvas2DBackend(scratch, project.width, project.height);
315
+ for (let i = 0; i < samples; i++) {
316
+ const resolved = resolveAnimatedElement(element, start + windowMs * ((i + .5) / samples));
317
+ renderer("blendMode" in resolved && resolved.blendMode ? {
318
+ ...resolved,
319
+ blendMode: void 0
320
+ } : resolved, createElementContext(subBackend, project, track, timeMs, options.source));
321
+ }
322
+ scratch.restore();
323
+ const ctx = backend.acquireRaster();
324
+ ctx.save();
325
+ const blendMode = "blendMode" in element ? element.blendMode : void 0;
326
+ if (blendMode) ctx.globalCompositeOperation = toCompositeOperation(blendMode);
327
+ ctx.drawImage(scratch.canvas, 0, 0);
328
+ ctx.restore();
329
+ return true;
330
+ }
331
+ //#endregion
332
+ //#region src/transition-renderers.ts
333
+ const transitionRenderers = /* @__PURE__ */ new Map();
334
+ /**
335
+ * Register the renderer half of a transition type. Built-ins register below
336
+ * through the same call; re-registering overrides (e.g. to restyle a
337
+ * built-in wipe).
338
+ */
339
+ function registerTransitionRenderer(type, renderer) {
340
+ transitionRenderers.set(type, renderer);
341
+ }
342
+ /** The registered renderer for `type`, or undefined (degrade to a hard cut). */
343
+ function getTransitionRenderer(type) {
344
+ return transitionRenderers.get(type);
345
+ }
346
+ registerTransitionRenderer("dissolve", ({ ctx, completion, drawLeft, drawRight }) => {
347
+ drawLeft();
348
+ ctx.save();
349
+ ctx.globalAlpha *= completion;
350
+ drawRight();
351
+ ctx.restore();
352
+ });
353
+ const fade = (color) => ({ ctx, project, pair, timeMs, completion, drawLeft, drawRight }) => {
354
+ if (timeMs < pair.cutMs) drawLeft();
355
+ else drawRight();
356
+ const veil = completion < .5 ? completion * 2 : (1 - completion) * 2;
357
+ if (veil > 0) {
358
+ ctx.save();
359
+ ctx.globalAlpha *= veil;
360
+ ctx.fillStyle = color;
361
+ ctx.fillRect(0, 0, project.width, project.height);
362
+ ctx.restore();
363
+ }
364
+ };
365
+ registerTransitionRenderer("fade-black", fade("#000000"));
366
+ registerTransitionRenderer("fade-white", fade("#ffffff"));
367
+ const slide = (direction) => ({ ctx, project, completion, drawLeft, drawRight }) => {
368
+ drawLeft();
369
+ const remaining = (1 - completion) * (1 - completion);
370
+ ctx.save();
371
+ ctx.translate(remaining * project.width * direction, 0);
372
+ drawRight();
373
+ ctx.restore();
374
+ };
375
+ registerTransitionRenderer("slide-left", slide(1));
376
+ registerTransitionRenderer("slide-right", slide(-1));
377
+ const wipe = (fromLeft) => ({ ctx, project, completion, drawLeft, drawRight }) => {
378
+ drawLeft();
379
+ const revealed = project.width * completion;
380
+ ctx.save();
381
+ ctx.beginPath();
382
+ if (fromLeft) ctx.rect(0, 0, revealed, project.height);
383
+ else ctx.rect(project.width - revealed, 0, revealed, project.height);
384
+ ctx.clip();
385
+ drawRight();
386
+ ctx.restore();
387
+ };
388
+ registerTransitionRenderer("wipe-right", wipe(true));
389
+ registerTransitionRenderer("wipe-left", wipe(false));
390
+ //#endregion
391
+ //#region src/text.ts
392
+ function buildFont(style) {
393
+ const fontStyle = style.fontStyle === "italic" ? "italic " : "";
394
+ const family = quoteFamily(style.fontFamily);
395
+ return `${fontStyle}${style.fontWeight} ${style.fontSize}px ${family}`;
396
+ }
397
+ const GENERIC_FAMILIES = new Set([
398
+ "serif",
399
+ "sans-serif",
400
+ "monospace",
401
+ "cursive",
402
+ "fantasy",
403
+ "system-ui",
404
+ "ui-serif",
405
+ "ui-sans-serif",
406
+ "ui-monospace",
407
+ "ui-rounded"
408
+ ]);
409
+ /**
410
+ * Quote a single family name for the canvas font shorthand ("Bebas Neue"
411
+ * breaks the parse unquoted). Generic keywords and pre-built stacks
412
+ * (commas/quotes) pass through untouched.
413
+ */
414
+ function quoteFamily(family) {
415
+ const trimmed = family.trim();
416
+ if (GENERIC_FAMILIES.has(trimmed) || /[,"']/.test(trimmed)) return trimmed;
417
+ return `"${trimmed}"`;
418
+ }
419
+ /** Render-time case transform; the stored text keeps the user's casing. */
420
+ function applyTextTransform(text, transform) {
421
+ if (transform === "uppercase") return text.toUpperCase();
422
+ if (transform === "lowercase") return text.toLowerCase();
423
+ return text;
424
+ }
425
+ function segmentFont(style, run) {
426
+ return buildFont({
427
+ fontStyle: run.fontStyle ?? style.fontStyle ?? "normal",
428
+ fontWeight: run.fontWeight ?? style.fontWeight,
429
+ fontSize: style.fontSize,
430
+ fontFamily: style.fontFamily
431
+ });
432
+ }
433
+ /**
434
+ * Slice [from, to) of the SOURCE text into measured segments, splitting at
435
+ * run boundaries. Case transform applies per segment (after slicing), so
436
+ * run offsets stay valid even for case-changing transforms.
437
+ */
438
+ function sliceSegments(measure, text, style, runs, from, to, letterSpacing) {
439
+ if (to <= from) return [];
440
+ const edges = new Set([from, to]);
441
+ for (const run of runs) {
442
+ if (run.start > from && run.start < to) edges.add(run.start);
443
+ if (run.end > from && run.end < to) edges.add(run.end);
444
+ }
445
+ const sorted = [...edges].sort((a, b) => a - b);
446
+ const segments = [];
447
+ for (let i = 0; i < sorted.length - 1; i++) {
448
+ const a = sorted[i];
449
+ const b = sorted[i + 1];
450
+ const run = getRunStyleAt(runs, a);
451
+ const font = segmentFont(style, run);
452
+ const segText = applyTextTransform(text.slice(a, b), style.textTransform ?? "none");
453
+ segments.push({
454
+ text: segText,
455
+ width: measure(segText, font, letterSpacing),
456
+ font,
457
+ ...run.color !== void 0 ? { color: run.color } : {}
458
+ });
459
+ }
460
+ return segments;
461
+ }
462
+ /** Sum of segment widths over a source range (wrap candidate measure). */
463
+ function rangeWidth(measure, text, style, runs, from, to, letterSpacing) {
464
+ let width = 0;
465
+ for (const seg of sliceSegments(measure, text, style, runs, from, to, letterSpacing)) width += seg.width;
466
+ return width;
467
+ }
468
+ function wrapLine(measure, font, line, maxWidth, letterSpacing) {
469
+ const words = line.match(/\S+/g);
470
+ if (!words || words.length === 0) return [{
471
+ text: "",
472
+ width: 0
473
+ }];
474
+ const lines = [];
475
+ let current = words[0];
476
+ let currentWidth = measure(current, font, letterSpacing);
477
+ for (const word of words.slice(1)) {
478
+ const candidate = `${current} ${word}`;
479
+ const candidateWidth = measure(candidate, font, letterSpacing);
480
+ if (candidateWidth <= maxWidth) {
481
+ current = candidate;
482
+ currentWidth = candidateWidth;
483
+ continue;
484
+ }
485
+ lines.push({
486
+ text: current,
487
+ width: currentWidth
488
+ });
489
+ current = word;
490
+ currentWidth = measure(current, font, letterSpacing);
491
+ }
492
+ lines.push({
493
+ text: current,
494
+ width: currentWidth
495
+ });
496
+ return lines;
497
+ }
498
+ /**
499
+ * Run-aware layout: lines slice at run boundaries into measured segments.
500
+ * Font size is element-global (runs vary only color/weight/italic), so line
501
+ * metrics stay uniform; only widths differ per segment.
502
+ */
503
+ function layoutRunLines(measure, text, style, runs, innerBoxWidth, letterSpacing) {
504
+ const lines = [];
505
+ const finishLine = (from, to) => {
506
+ const segments = sliceSegments(measure, text, style, runs, from, to, letterSpacing);
507
+ lines.push({
508
+ text: segments.map((seg) => seg.text).join(""),
509
+ width: segments.reduce((sum, seg) => sum + seg.width, 0),
510
+ segments
511
+ });
512
+ };
513
+ let lineStart = 0;
514
+ const sourceLines = [];
515
+ for (let i = 0; i <= text.length; i++) if (i === text.length || text[i] === "\n") {
516
+ sourceLines.push({
517
+ start: lineStart,
518
+ end: i
519
+ });
520
+ lineStart = i + 1;
521
+ }
522
+ for (const source of sourceLines) {
523
+ if (!innerBoxWidth) {
524
+ finishLine(source.start, source.end);
525
+ continue;
526
+ }
527
+ const lineText = text.slice(source.start, source.end);
528
+ const words = [];
529
+ const matcher = /\S+/g;
530
+ for (let m = matcher.exec(lineText); m; m = matcher.exec(lineText)) words.push({
531
+ start: source.start + m.index,
532
+ end: source.start + m.index + m[0].length
533
+ });
534
+ if (words.length === 0) {
535
+ finishLine(source.start, source.start);
536
+ continue;
537
+ }
538
+ let visualStart = words[0].start;
539
+ let lastEnd = words[0].end;
540
+ for (const word of words.slice(1)) {
541
+ if (rangeWidth(measure, text, style, runs, visualStart, word.end, letterSpacing) <= innerBoxWidth) {
542
+ lastEnd = word.end;
543
+ continue;
544
+ }
545
+ finishLine(visualStart, lastEnd);
546
+ visualStart = word.start;
547
+ lastEnd = word.end;
548
+ }
549
+ finishLine(visualStart, lastEnd);
550
+ }
551
+ return lines;
552
+ }
553
+ /**
554
+ * Lay out a (possibly multi-line) text element. Lines come from explicit
555
+ * newlines only unless a text box width is provided.
556
+ */
557
+ function layoutTextBlock(measure, text, style, options = {}) {
558
+ const font = buildFont(style);
559
+ const letterSpacing = style.letterSpacing ?? 0;
560
+ const lineHeight = style.fontSize * (style.lineHeight ?? 1.25);
561
+ const padding = style.backgroundColor ? style.fontSize * .25 : 0;
562
+ const box = options.box;
563
+ const innerBoxWidth = box ? Math.max(1, box.width - padding * 2) : null;
564
+ const runs = options.runs && options.runs.length > 0 ? options.runs : null;
565
+ const lines = runs ? layoutRunLines(measure, text, style, runs, innerBoxWidth, letterSpacing) : applyTextTransform(text, style.textTransform ?? "none").split("\n").flatMap((line) => innerBoxWidth ? wrapLine(measure, font, line, innerBoxWidth, letterSpacing) : [{
566
+ text: line,
567
+ width: measure(line, font, letterSpacing)
568
+ }]);
569
+ const maxLineWidth = Math.max(0, ...lines.map((l) => l.width));
570
+ const autoHeight = lines.length * lineHeight + padding * 2;
571
+ return {
572
+ lines,
573
+ font,
574
+ lineHeight,
575
+ padding,
576
+ overflow: box?.overflow ?? null,
577
+ width: box ? box.width : maxLineWidth + padding * 2,
578
+ height: box?.height ?? autoHeight
579
+ };
580
+ }
581
+ /**
582
+ * Greedily wrap caption words into centered lines no wider than `maxWidth`.
583
+ * Falls back to whitespace-split text when word timings are absent.
584
+ */
585
+ function layoutCaption(measure, element, style, maxWidth) {
586
+ const font = buildFont(style);
587
+ const lineHeight = style.fontSize * 1.3;
588
+ const spaceWidth = measure(" ", font);
589
+ const words = element.words && element.words.length > 0 ? element.words : element.text.split(/\s+/).filter(Boolean).map((text) => ({
590
+ text,
591
+ startMs: void 0,
592
+ endMs: void 0
593
+ }));
594
+ const lines = [];
595
+ let current = [];
596
+ let currentWidth = 0;
597
+ for (const word of words) {
598
+ const width = measure(word.text, font);
599
+ const widthWithSpace = current.length === 0 ? width : currentWidth + spaceWidth + width;
600
+ if (current.length > 0 && widthWithSpace > maxWidth) {
601
+ lines.push({
602
+ words: current,
603
+ width: currentWidth
604
+ });
605
+ current = [];
606
+ currentWidth = 0;
607
+ }
608
+ const x = current.length === 0 ? 0 : currentWidth + spaceWidth;
609
+ current.push({
610
+ text: word.text,
611
+ x,
612
+ width,
613
+ startMs: word.startMs,
614
+ endMs: word.endMs
615
+ });
616
+ currentWidth = x + width;
617
+ }
618
+ if (current.length > 0) lines.push({
619
+ words: current,
620
+ width: currentWidth
621
+ });
622
+ return {
623
+ lines,
624
+ font,
625
+ lineHeight,
626
+ spaceWidth
627
+ };
628
+ }
629
+ //#endregion
630
+ //#region src/renderers.ts
631
+ const renderers = /* @__PURE__ */ new Map();
632
+ /**
633
+ * Register a renderer for an element type. Built-in types can be overridden;
634
+ * custom element types (added via custom commands) plug in here — the
635
+ * compositor side of the engine's command registry.
636
+ */
637
+ function registerElementRenderer(type, renderer) {
638
+ renderers.set(type, renderer);
639
+ }
640
+ function getElementRenderer(type) {
641
+ return renderers.get(type);
642
+ }
643
+ /**
644
+ * `letterSpacing` shipped in Chromium 99+/Safari 17 but is still missing from
645
+ * some engines (and OffscreenCanvas typings); feature-detect and degrade to
646
+ * no tracking — measurement and drawing stay consistent either way.
647
+ */
648
+ function setLetterSpacing(ctx, px) {
649
+ if ("letterSpacing" in ctx) ctx.letterSpacing = `${px}px`;
650
+ }
651
+ function measureWith(ctx) {
652
+ return (text, font, letterSpacingPx) => {
653
+ ctx.font = font;
654
+ setLetterSpacing(ctx, letterSpacingPx ?? 0);
655
+ return ctx.measureText(text).width;
656
+ };
657
+ }
658
+ function getImageSize(source) {
659
+ if (typeof HTMLVideoElement !== "undefined" && source instanceof HTMLVideoElement) return {
660
+ width: source.videoWidth,
661
+ height: source.videoHeight
662
+ };
663
+ if (typeof HTMLImageElement !== "undefined" && source instanceof HTMLImageElement) return {
664
+ width: source.naturalWidth,
665
+ height: source.naturalHeight
666
+ };
667
+ if (typeof VideoFrame !== "undefined" && source instanceof VideoFrame) return {
668
+ width: source.displayWidth,
669
+ height: source.displayHeight
670
+ };
671
+ const maybe = source;
672
+ return {
673
+ width: typeof maybe.width === "number" ? maybe.width : maybe.width?.baseVal.value ?? 0,
674
+ height: typeof maybe.height === "number" ? maybe.height : maybe.height?.baseVal.value ?? 0
675
+ };
676
+ }
677
+ /** Resolve an element's visual chrome to frame coordinates (backend input). */
678
+ function chromeOf(context, element) {
679
+ const center = toCanvasPoint(context.project, element.transform.x, element.transform.y);
680
+ return {
681
+ centerX: center.x,
682
+ centerY: center.y,
683
+ rotationDeg: element.transform.rotation,
684
+ scaleX: element.transform.scaleX,
685
+ scaleY: element.transform.scaleY,
686
+ opacity: element.opacity,
687
+ blendMode: element.blendMode,
688
+ effects: element.effects
689
+ };
690
+ }
691
+ /**
692
+ * Transform + opacity + effect-stack filter + blend mode around a canvas2d
693
+ * draw (see backend.ts `applyChrome` for the actual state handling).
694
+ */
695
+ function withTransform(ctx, context, element, draw) {
696
+ applyChrome(ctx, chromeOf(context, element), draw);
697
+ }
698
+ /**
699
+ * Frame chrome around a centered `dw`×`dh` media draw: drop shadow behind
700
+ * the rounded rect, rounded clip on the content, inside border on top — the
701
+ * layout-slot look, available as element-level fields (style.ts primitives).
702
+ * Strokes paint INSIDE the bounds (clip + doubled width) so the frame's
703
+ * geometry — and every snap/handle derived from it — stays exact.
704
+ */
705
+ function withFrameChrome(ctx, style, dw, dh, draw) {
706
+ const radius = (style.cornerRadius ?? 0) * Math.min(dw, dh);
707
+ const tracePath = () => {
708
+ ctx.beginPath();
709
+ ctx.roundRect(-dw / 2, -dh / 2, dw, dh, radius);
710
+ };
711
+ if (style.shadow) {
712
+ ctx.save();
713
+ ctx.shadowColor = style.shadow.color;
714
+ ctx.shadowBlur = style.shadow.blur;
715
+ ctx.shadowOffsetX = style.shadow.offsetX;
716
+ ctx.shadowOffsetY = style.shadow.offsetY;
717
+ ctx.fillStyle = "#000";
718
+ tracePath();
719
+ ctx.fill();
720
+ ctx.restore();
721
+ }
722
+ ctx.save();
723
+ if (radius > 0) {
724
+ tracePath();
725
+ ctx.clip();
726
+ }
727
+ draw();
728
+ ctx.restore();
729
+ if (style.stroke) {
730
+ ctx.save();
731
+ tracePath();
732
+ ctx.clip();
733
+ ctx.strokeStyle = style.stroke.color;
734
+ ctx.lineWidth = style.stroke.width * 2;
735
+ tracePath();
736
+ ctx.stroke();
737
+ ctx.restore();
738
+ }
739
+ }
740
+ /** Source rect for a crop mask, in the actual frame's pixel space. */
741
+ function cropSourceRect(crop, frame) {
742
+ if (!crop) return null;
743
+ const { width: fw, height: fh } = getImageSize(frame);
744
+ if (fw <= 0 || fh <= 0) return null;
745
+ return {
746
+ sx: crop.x * fw,
747
+ sy: crop.y * fh,
748
+ sw: crop.w * fw,
749
+ sh: crop.h * fh
750
+ };
751
+ }
752
+ /**
753
+ * Composite a media frame: the plain case (no stroke/shadow chrome) goes
754
+ * through the backend's structured quad path — on GPU backends that is the
755
+ * zero-copy fast path with WGSL effects — while framed draws keep the
756
+ * canvas2d chrome (shadow + rounded clip + inside border) on the raster
757
+ * surface.
758
+ */
759
+ function drawMediaFrame(context, element, frame, dw, dh) {
760
+ const src = cropSourceRect(element.crop, frame);
761
+ if (!element.stroke && !element.shadow) {
762
+ context.backend.drawImageQuad({
763
+ image: frame,
764
+ src,
765
+ dw,
766
+ dh,
767
+ cornerRadius: (element.cornerRadius ?? 0) * Math.min(dw, dh)
768
+ }, chromeOf(context, element));
769
+ return;
770
+ }
771
+ const ctx = context.ctx;
772
+ withTransform(ctx, context, element, () => {
773
+ withFrameChrome(ctx, element, dw, dh, () => {
774
+ if (src) ctx.drawImage(frame, src.sx, src.sy, src.sw, src.sh, -dw / 2, -dh / 2, dw, dh);
775
+ else ctx.drawImage(frame, -dw / 2, -dh / 2, dw, dh);
776
+ });
777
+ });
778
+ }
779
+ const renderVideo = (element, context) => {
780
+ if (!context.source) return;
781
+ const sourceTimeMs = Math.max(0, getSourceTimeMs(element, context.timeMs - element.startMs));
782
+ const frame = context.source.getFrame(element.assetId, sourceTimeMs);
783
+ if (!frame) return;
784
+ const asset = context.project.assets[element.assetId];
785
+ const { width, height } = asset?.width && asset?.height ? {
786
+ width: asset.width,
787
+ height: asset.height
788
+ } : getImageSize(frame);
789
+ if (width <= 0 || height <= 0) return;
790
+ drawMediaFrame(context, element, frame, width * (element.crop?.w ?? 1), height * (element.crop?.h ?? 1));
791
+ };
792
+ const renderImage = (element, context) => {
793
+ if (!context.source) return;
794
+ const frame = context.source.getFrame(element.assetId, 0);
795
+ if (!frame) return;
796
+ const { width, height } = getImageSize(frame);
797
+ if (width <= 0 || height <= 0) return;
798
+ drawMediaFrame(context, element, frame, width * (element.crop?.w ?? 1), height * (element.crop?.h ?? 1));
799
+ };
800
+ const renderText = (element, context) => {
801
+ const { ctx } = context;
802
+ const layout = layoutTextBlock(measureWith(ctx), element.text, element.style, {
803
+ box: element.box,
804
+ ...element.runs ? { runs: element.runs } : {}
805
+ });
806
+ withTransform(ctx, context, element, () => {
807
+ if (element.style.backgroundColor) {
808
+ ctx.fillStyle = element.style.backgroundColor;
809
+ ctx.beginPath();
810
+ ctx.roundRect(-layout.width / 2, -layout.height / 2, layout.width, layout.height, element.style.fontSize * .15);
811
+ ctx.fill();
812
+ }
813
+ if (layout.overflow === "clip") {
814
+ ctx.beginPath();
815
+ ctx.rect(-layout.width / 2, -layout.height / 2, layout.width, layout.height);
816
+ ctx.clip();
817
+ }
818
+ const { style } = element;
819
+ ctx.font = layout.font;
820
+ setLetterSpacing(ctx, style.letterSpacing ?? 0);
821
+ ctx.textBaseline = "middle";
822
+ const stroke = style.stroke && style.stroke.width > 0 ? style.stroke : null;
823
+ if (stroke) {
824
+ ctx.strokeStyle = stroke.color;
825
+ ctx.lineWidth = stroke.width * 2;
826
+ ctx.lineJoin = "round";
827
+ }
828
+ const innerWidth = Math.max(1, layout.width - layout.padding * 2);
829
+ const setShadow = () => {
830
+ if (!style.shadow) return;
831
+ ctx.shadowColor = style.shadow.color;
832
+ ctx.shadowBlur = style.shadow.blur;
833
+ ctx.shadowOffsetX = style.shadow.offsetX;
834
+ ctx.shadowOffsetY = style.shadow.offsetY;
835
+ };
836
+ const clearShadow = () => {
837
+ ctx.shadowColor = "transparent";
838
+ ctx.shadowBlur = 0;
839
+ ctx.shadowOffsetX = 0;
840
+ ctx.shadowOffsetY = 0;
841
+ };
842
+ for (let i = 0; i < layout.lines.length; i++) {
843
+ const line = layout.lines[i];
844
+ const y = -layout.height / 2 + layout.padding + layout.lineHeight * (i + .5);
845
+ if (line.segments) {
846
+ ctx.textAlign = "left";
847
+ let x = style.align === "left" ? -innerWidth / 2 : style.align === "right" ? innerWidth / 2 - line.width : -line.width / 2;
848
+ for (const segment of line.segments) {
849
+ ctx.font = segment.font;
850
+ setShadow();
851
+ if (stroke) {
852
+ ctx.strokeText(segment.text, x, y);
853
+ clearShadow();
854
+ }
855
+ ctx.fillStyle = segment.color ?? style.color;
856
+ ctx.fillText(segment.text, x, y);
857
+ if (style.shadow && !stroke) clearShadow();
858
+ x += segment.width;
859
+ }
860
+ ctx.font = layout.font;
861
+ continue;
862
+ }
863
+ let x;
864
+ if (style.align === "left") {
865
+ ctx.textAlign = "left";
866
+ x = -innerWidth / 2;
867
+ } else if (style.align === "right") {
868
+ ctx.textAlign = "right";
869
+ x = innerWidth / 2;
870
+ } else {
871
+ ctx.textAlign = "center";
872
+ x = 0;
873
+ }
874
+ setShadow();
875
+ if (stroke) {
876
+ ctx.strokeText(line.text, x, y);
877
+ clearShadow();
878
+ }
879
+ ctx.fillStyle = style.color;
880
+ ctx.fillText(line.text, x, y);
881
+ if (style.shadow && !stroke) clearShadow();
882
+ }
883
+ });
884
+ };
885
+ const renderCaption = (element, context) => {
886
+ const { ctx, project, timeMs } = context;
887
+ const style = element.style;
888
+ const maxWidth = project.width * .85;
889
+ const layout = layoutCaption(measureWith(ctx), element, style, maxWidth);
890
+ if (layout.lines.length === 0) return;
891
+ const blockHeight = layout.lines.length * layout.lineHeight;
892
+ let blockTop;
893
+ if (style.position === "top") blockTop = project.height * .08;
894
+ else if (style.position === "middle") blockTop = project.height / 2 - blockHeight / 2;
895
+ else blockTop = project.height * .92 - blockHeight;
896
+ const relativeMs = timeMs - element.startMs;
897
+ const padX = style.fontSize * .4;
898
+ const padY = style.fontSize * .18;
899
+ ctx.save();
900
+ ctx.font = layout.font;
901
+ ctx.textBaseline = "middle";
902
+ ctx.textAlign = "left";
903
+ for (let i = 0; i < layout.lines.length; i++) {
904
+ const line = layout.lines[i];
905
+ const lineLeft = project.width / 2 - line.width / 2;
906
+ const lineCenterY = blockTop + layout.lineHeight * (i + .5);
907
+ if (style.backgroundColor) {
908
+ ctx.fillStyle = style.backgroundColor;
909
+ ctx.beginPath();
910
+ ctx.roundRect(lineLeft - padX, lineCenterY - layout.lineHeight / 2 + (layout.lineHeight - style.fontSize) / 2 - padY, line.width + padX * 2, style.fontSize + padY * 2, style.fontSize * .15);
911
+ ctx.fill();
912
+ }
913
+ for (const word of line.words) {
914
+ ctx.fillStyle = style.activeWordColor !== void 0 && word.startMs !== void 0 && word.endMs !== void 0 && relativeMs >= word.startMs && relativeMs < word.endMs ? style.activeWordColor : style.color;
915
+ ctx.fillText(word.text, lineLeft + word.x, lineCenterY);
916
+ }
917
+ }
918
+ ctx.restore();
919
+ };
920
+ /**
921
+ * Multicam: composes the sources of the ACTIVE layout (the cut under the
922
+ * playhead) into normalized slot rects — cover/contain crop via 9-arg
923
+ * drawImage, rounded clip, optional drop shadow. Same renderer for preview
924
+ * and export; decode parity comes from getFrameRequests.
925
+ */
926
+ const renderMulticam = (element, context) => {
927
+ if (!context.source) return;
928
+ const { ctx, project } = context;
929
+ const W = project.width;
930
+ const H = project.height;
931
+ const drawLayout = (layout) => {
932
+ if (!layout) return;
933
+ withTransform(ctx, context, element, () => {
934
+ for (const slot of layout.slots) {
935
+ const source = element.sources.find((s) => s.key === slot.source);
936
+ if (!source) continue;
937
+ const sourceTimeMs = getMulticamSourceTimeMs(element, source, context.timeMs);
938
+ const frame = context.source.getFrame(source.assetId, sourceTimeMs);
939
+ if (!frame) continue;
940
+ const { width: fw, height: fh } = getImageSize(frame);
941
+ if (fw <= 0 || fh <= 0) continue;
942
+ const rx = (slot.rect.x - .5) * W;
943
+ const ry = (slot.rect.y - .5) * H;
944
+ const rw = slot.rect.w * W;
945
+ const rh = slot.rect.h * H;
946
+ const radius = slot.cornerRadius * Math.min(rw, rh);
947
+ if (slot.shadow) {
948
+ ctx.save();
949
+ ctx.shadowColor = "rgba(0, 0, 0, 0.45)";
950
+ ctx.shadowBlur = Math.min(rw, rh) * .12;
951
+ ctx.shadowOffsetY = Math.min(rw, rh) * .04;
952
+ ctx.fillStyle = "#000";
953
+ ctx.beginPath();
954
+ ctx.roundRect(rx, ry, rw, rh, radius);
955
+ ctx.fill();
956
+ ctx.restore();
957
+ }
958
+ const scale = slot.fit === "cover" ? Math.max(rw / fw, rh / fh) : Math.min(rw / fw, rh / fh);
959
+ const sw = Math.min(fw, rw / scale);
960
+ const sh = Math.min(fh, rh / scale);
961
+ const sx = (fw - sw) * (slot.focus?.x ?? .5);
962
+ const sy = (fh - sh) * (slot.focus?.y ?? .5);
963
+ const dw = sw * scale;
964
+ const dh = sh * scale;
965
+ const dx = rx + (rw - dw) / 2;
966
+ const dy = ry + (rh - dh) / 2;
967
+ ctx.save();
968
+ if (radius > 0) {
969
+ ctx.beginPath();
970
+ ctx.roundRect(rx, ry, rw, rh, radius);
971
+ ctx.clip();
972
+ }
973
+ ctx.drawImage(frame, sx, sy, sw, sh, dx, dy, dw, dh);
974
+ ctx.restore();
975
+ if (slot.stroke) {
976
+ ctx.save();
977
+ ctx.beginPath();
978
+ ctx.roundRect(rx, ry, rw, rh, radius);
979
+ ctx.clip();
980
+ ctx.strokeStyle = slot.stroke.color;
981
+ ctx.lineWidth = slot.stroke.width * 2;
982
+ ctx.beginPath();
983
+ ctx.roundRect(rx, ry, rw, rh, radius);
984
+ ctx.stroke();
985
+ ctx.restore();
986
+ }
987
+ }
988
+ });
989
+ };
990
+ const window = getAngleTransitionAt(element, context.timeMs - element.startMs);
991
+ if (window) {
992
+ const renderer = getTransitionRenderer(window.type);
993
+ if (renderer) {
994
+ const pair = {
995
+ left: element,
996
+ right: element,
997
+ cutMs: element.startMs + window.cutMs,
998
+ durationMs: window.durationMs,
999
+ type: window.type
1000
+ };
1001
+ renderer({
1002
+ ctx,
1003
+ project,
1004
+ pair,
1005
+ timeMs: context.timeMs,
1006
+ completion: getTransitionCompletion(pair, context.timeMs),
1007
+ drawLeft: () => drawLayout(getLayout(project.layouts, window.fromLayoutId)),
1008
+ drawRight: () => drawLayout(getLayout(project.layouts, window.toLayoutId))
1009
+ });
1010
+ return;
1011
+ }
1012
+ }
1013
+ drawLayout(getActiveLayout(project, element, context.timeMs));
1014
+ };
1015
+ registerElementRenderer("video", renderVideo);
1016
+ registerElementRenderer("multicam", renderMulticam);
1017
+ registerElementRenderer("image", renderImage);
1018
+ registerElementRenderer("text", renderText);
1019
+ registerElementRenderer("caption", renderCaption);
1020
+ registerElementRenderer("audio", () => {});
1021
+ //#endregion
1022
+ //#region src/render-frame.ts
1023
+ /**
1024
+ * Render one frame of `project` at `timeMs` into `ctx` (canvas2d path).
1025
+ *
1026
+ * Pure with respect to inputs: the same project, time, and frame source
1027
+ * produce the same pixels — which is what makes the export path
1028
+ * deterministic. The context is expected to be in project coordinates
1029
+ * (`project.width` × `project.height`); callers rendering at other sizes
1030
+ * apply their own transform before calling.
1031
+ */
1032
+ function renderFrame(ctx, project, timeMs, options = {}) {
1033
+ renderFrameWith(new Canvas2DBackend(ctx, project.width, project.height), project, timeMs, options);
1034
+ }
1035
+ /**
1036
+ * Render one frame through an explicit {@link RenderBackend}. The
1037
+ * track/element walk, transition pairing, and animation resolution here are
1038
+ * backend-agnostic; only the draws differ.
1039
+ *
1040
+ * Tracks render bottom-up (index 0 first), elements in start order. Clips
1041
+ * joined by a transition render through the blend instead of the normal
1042
+ * pass while the window (centered on their cut) is active.
1043
+ */
1044
+ function renderFrameWith(backend, project, timeMs, options = {}) {
1045
+ backend.beginFrame(options.backgroundColor ?? "#000000");
1046
+ for (const track of project.tracks) {
1047
+ if (track.hidden) continue;
1048
+ const pairs = getActiveTransitionPairs(track, timeMs);
1049
+ const blending = /* @__PURE__ */ new Set();
1050
+ for (const pair of pairs) {
1051
+ blending.add(pair.left.id);
1052
+ blending.add(pair.right.id);
1053
+ }
1054
+ for (const element of track.elements) {
1055
+ if (element.startMs > timeMs) break;
1056
+ if (options.skipElementIds?.has(element.id)) continue;
1057
+ if (blending.has(element.id)) continue;
1058
+ if (!isElementActiveAt(element, timeMs)) continue;
1059
+ renderElement(backend, project, track, element, timeMs, options);
1060
+ }
1061
+ for (const pair of pairs) renderTransition(backend, project, track, pair, timeMs, options);
1062
+ }
1063
+ backend.endFrame();
1064
+ }
1065
+ function renderElement(backend, project, track, element, timeMs, options) {
1066
+ const renderer = getElementRenderer(element.type);
1067
+ if (!renderer) return;
1068
+ if (renderElementWithMotionBlur(backend, project, track, element, timeMs, options, renderer)) return;
1069
+ renderer(resolveAnimatedElement(element, timeMs), createElementContext(backend, project, track, timeMs, options.source));
1070
+ }
1071
+ /**
1072
+ * Blend a transition pair at `timeMs` via its registered renderer. Unknown
1073
+ * types degrade to a hard cut (left before the cut, right after) so a
1074
+ * project from a plugin you don't have still plays.
1075
+ *
1076
+ * The mix manipulates canvas2d state (alpha, clips, translations) around the
1077
+ * pair's draws, so the whole window renders inside a raster scope — on GPU
1078
+ * backends both sides rasterize into the shared scratch and composite as one
1079
+ * layer, which is exactly the canvas2d-correct result.
1080
+ */
1081
+ function renderTransition(backend, project, track, pair, timeMs, options) {
1082
+ const completion = getTransitionCompletion(pair, timeMs);
1083
+ backend.pushRasterScope();
1084
+ try {
1085
+ const context = {
1086
+ ctx: backend.acquireRaster(),
1087
+ project,
1088
+ pair,
1089
+ timeMs,
1090
+ completion,
1091
+ drawLeft: () => renderElement(backend, project, track, pair.left, timeMs, options),
1092
+ drawRight: () => renderElement(backend, project, track, pair.right, timeMs, options)
1093
+ };
1094
+ const renderer = getTransitionRenderer(pair.type);
1095
+ if (renderer) renderer(context);
1096
+ else if (timeMs < pair.cutMs) context.drawLeft();
1097
+ else context.drawRight();
1098
+ } finally {
1099
+ backend.popRasterScope();
1100
+ }
1101
+ }
1102
+ //#endregion
1103
+ //#region src/gpu-effects.ts
1104
+ /**
1105
+ * GPU-only effects — they exist only on the WebGPU backend (the canvas2d
1106
+ * reference path has no per-pixel programmability, so `toFilter` is inert
1107
+ * and the layer renders without them there). Registered through the same
1108
+ * registry the CSS-filter built-ins use, so they parse in saved projects,
1109
+ * validate, and get inspector UI for free.
1110
+ */
1111
+ const curvePointSchema = z.object({
1112
+ /** Input level 0..1. */
1113
+ x: z.number().min(0).max(1),
1114
+ /** Output level 0..1. */
1115
+ y: z.number().min(0).max(1)
1116
+ });
1117
+ let registered = false;
1118
+ /** Idempotent; the compositor registers these at module load. */
1119
+ function registerGpuEffectTypes() {
1120
+ if (registered) return;
1121
+ registered = true;
1122
+ registerEffectType({
1123
+ type: "chroma-key",
1124
+ shape: {
1125
+ /** Key color to remove (green-screen green by default). */
1126
+ keyColor: z.string().default("#00ff00"),
1127
+ /** Chroma distance (YCbCr plane) treated as fully transparent. */
1128
+ tolerance: z.number().min(0).max(1).default(.25),
1129
+ /** Distance band over which alpha ramps back in. */
1130
+ softness: z.number().min(0).max(1).default(.1),
1131
+ /** How strongly key-colored spill is pulled out of kept pixels. */
1132
+ spillSuppression: z.number().min(0).max(1).default(.5)
1133
+ },
1134
+ toFilter: () => "",
1135
+ param: {
1136
+ key: "tolerance",
1137
+ min: 0,
1138
+ max: 1
1139
+ }
1140
+ });
1141
+ registerEffectType({
1142
+ type: "curves",
1143
+ shape: {
1144
+ /** Master curve, applied after the per-channel ones. */
1145
+ rgb: z.array(curvePointSchema).optional(),
1146
+ red: z.array(curvePointSchema).optional(),
1147
+ green: z.array(curvePointSchema).optional(),
1148
+ blue: z.array(curvePointSchema).optional()
1149
+ },
1150
+ toFilter: () => ""
1151
+ });
1152
+ registerEffectType({
1153
+ type: "lut3d",
1154
+ shape: {
1155
+ /**
1156
+ * Identifier of a LUT registered with the WebGPU backend at runtime
1157
+ * (`WebGPUBackend.registerLut3D`) — LUT pixel data doesn't belong in
1158
+ * project JSON.
1159
+ */
1160
+ lutId: z.string().min(1),
1161
+ /** Blend between original (0) and graded (1). */
1162
+ intensity: z.number().min(0).max(1).default(1)
1163
+ },
1164
+ toFilter: () => ""
1165
+ });
1166
+ }
1167
+ registerGpuEffectTypes();
1168
+ //#endregion
1169
+ //#region src/webgpu/color.ts
1170
+ /** Minimal CSS color → linear-ish RGBA for GPU clear values (0..1, straight). */
1171
+ const NAMED = {
1172
+ black: [
1173
+ 0,
1174
+ 0,
1175
+ 0,
1176
+ 1
1177
+ ],
1178
+ white: [
1179
+ 1,
1180
+ 1,
1181
+ 1,
1182
+ 1
1183
+ ],
1184
+ red: [
1185
+ 1,
1186
+ 0,
1187
+ 0,
1188
+ 1
1189
+ ],
1190
+ green: [
1191
+ 0,
1192
+ 128 / 255,
1193
+ 0,
1194
+ 1
1195
+ ],
1196
+ blue: [
1197
+ 0,
1198
+ 0,
1199
+ 1,
1200
+ 1
1201
+ ],
1202
+ gray: [
1203
+ 128 / 255,
1204
+ 128 / 255,
1205
+ 128 / 255,
1206
+ 1
1207
+ ],
1208
+ grey: [
1209
+ 128 / 255,
1210
+ 128 / 255,
1211
+ 128 / 255,
1212
+ 1
1213
+ ],
1214
+ transparent: [
1215
+ 0,
1216
+ 0,
1217
+ 0,
1218
+ 0
1219
+ ]
1220
+ };
1221
+ /**
1222
+ * Parse the CSS colors the compositor actually meets (#hex, rgb()/rgba(),
1223
+ * a few names). Unknown input falls back to opaque black — the same color
1224
+ * the canvas2d path would effectively paint for an invalid background.
1225
+ */
1226
+ function parseCssColor(input) {
1227
+ const value = input.trim().toLowerCase();
1228
+ const named = NAMED[value];
1229
+ if (named) return [...named];
1230
+ if (value.startsWith("#")) {
1231
+ const hex = value.slice(1);
1232
+ if (hex.length === 3 || hex.length === 4) {
1233
+ const parts = [...hex].map((c) => Number.parseInt(c + c, 16));
1234
+ if (parts.every((p) => Number.isFinite(p))) return [
1235
+ parts[0] / 255,
1236
+ parts[1] / 255,
1237
+ parts[2] / 255,
1238
+ hex.length === 4 ? parts[3] / 255 : 1
1239
+ ];
1240
+ }
1241
+ if (hex.length === 6 || hex.length === 8) {
1242
+ const parts = [
1243
+ 0,
1244
+ 2,
1245
+ 4,
1246
+ 6
1247
+ ].slice(0, hex.length / 2).map((i) => Number.parseInt(hex.slice(i, i + 2), 16));
1248
+ if (parts.every((p) => Number.isFinite(p))) return [
1249
+ parts[0] / 255,
1250
+ parts[1] / 255,
1251
+ parts[2] / 255,
1252
+ hex.length === 8 ? parts[3] / 255 : 1
1253
+ ];
1254
+ }
1255
+ }
1256
+ const fn = value.match(/^rgba?\(([^)]+)\)$/);
1257
+ if (fn) {
1258
+ const parts = fn[1].split(/[\s,/]+/).filter(Boolean);
1259
+ if (parts.length >= 3) {
1260
+ const channel = (raw) => raw.endsWith("%") ? Number.parseFloat(raw) / 100 * 255 : Number.parseFloat(raw);
1261
+ const r = channel(parts[0]);
1262
+ const g = channel(parts[1]);
1263
+ const b = channel(parts[2]);
1264
+ const rawA = parts[3];
1265
+ const a = rawA === void 0 ? 1 : rawA.endsWith("%") ? Number.parseFloat(rawA) / 100 : Number.parseFloat(rawA);
1266
+ if ([
1267
+ r,
1268
+ g,
1269
+ b,
1270
+ a
1271
+ ].every((p) => Number.isFinite(p))) return [
1272
+ Math.min(1, Math.max(0, r / 255)),
1273
+ Math.min(1, Math.max(0, g / 255)),
1274
+ Math.min(1, Math.max(0, b / 255)),
1275
+ Math.min(1, Math.max(0, a))
1276
+ ];
1277
+ }
1278
+ }
1279
+ return [
1280
+ 0,
1281
+ 0,
1282
+ 0,
1283
+ 1
1284
+ ];
1285
+ }
1286
+ //#endregion
1287
+ //#region src/webgpu/effect-plan.ts
1288
+ /**
1289
+ * Compile an element's effect stack into the GPU pass plan, preserving
1290
+ * stack order (CSS filter semantics: left to right — blur-then-brightness
1291
+ * is not brightness-then-blur). Consecutive color-space effects fuse into
1292
+ * ONE fragment pass that loops an ordered op list; blur, drop-shadow, and
1293
+ * 3D LUTs become their own passes. `css` (raw CSS filter strings) cannot
1294
+ * run on GPU — those layers fall back to the canvas2d raster path.
1295
+ */
1296
+ /** Op kinds, mirrored in COLOR_SHADER — keep the numbering in sync. */
1297
+ const COLOR_OP = {
1298
+ brightness: 1,
1299
+ contrast: 2,
1300
+ saturate: 3,
1301
+ grayscale: 4,
1302
+ sepia: 5,
1303
+ hueRotate: 6,
1304
+ invert: 7,
1305
+ chromaKey: 8,
1306
+ curves: 9
1307
+ };
1308
+ const params = (...values) => values;
1309
+ /** Monotone-x piecewise-linear curve → 256-entry LUT (identity when empty). */
1310
+ function curveToLut(points) {
1311
+ const lut = new Float32Array(256);
1312
+ const sorted = [...points ?? []].sort((a, b) => a.x - b.x);
1313
+ if (sorted.length === 0) {
1314
+ for (let i = 0; i < 256; i++) lut[i] = i / 255;
1315
+ return lut;
1316
+ }
1317
+ for (let i = 0; i < 256; i++) {
1318
+ const x = i / 255;
1319
+ const after = sorted.findIndex((p) => p.x >= x);
1320
+ if (after < 0) lut[i] = sorted[sorted.length - 1].y;
1321
+ else if (after === 0) lut[i] = sorted[0].y;
1322
+ else {
1323
+ const a = sorted[after - 1];
1324
+ const b = sorted[after];
1325
+ const t = b.x === a.x ? 0 : (x - a.x) / (b.x - a.x);
1326
+ lut[i] = a.y + (b.y - a.y) * t;
1327
+ }
1328
+ lut[i] = Math.min(1, Math.max(0, lut[i]));
1329
+ }
1330
+ return lut;
1331
+ }
1332
+ function planEffects(effects) {
1333
+ const plan = {
1334
+ passes: [],
1335
+ unsupported: false
1336
+ };
1337
+ if (!effects) return plan;
1338
+ const colorRun = () => {
1339
+ const last = plan.passes[plan.passes.length - 1];
1340
+ if (last && last.kind === "color" && last.ops.length < 16 && !last.curves) return last;
1341
+ const run = {
1342
+ kind: "color",
1343
+ ops: [],
1344
+ curves: null
1345
+ };
1346
+ plan.passes.push(run);
1347
+ return run;
1348
+ };
1349
+ for (const effect of effects) {
1350
+ if (!effect.enabled) continue;
1351
+ switch (effect.type) {
1352
+ case "brightness":
1353
+ if (effect.amount !== 1) colorRun().ops.push({
1354
+ kind: COLOR_OP.brightness,
1355
+ params: params(effect.amount)
1356
+ });
1357
+ break;
1358
+ case "contrast":
1359
+ if (effect.amount !== 1) colorRun().ops.push({
1360
+ kind: COLOR_OP.contrast,
1361
+ params: params(effect.amount)
1362
+ });
1363
+ break;
1364
+ case "saturate":
1365
+ if (effect.amount !== 1) colorRun().ops.push({
1366
+ kind: COLOR_OP.saturate,
1367
+ params: params(effect.amount)
1368
+ });
1369
+ break;
1370
+ case "grayscale":
1371
+ if (effect.amount > 0) colorRun().ops.push({
1372
+ kind: COLOR_OP.grayscale,
1373
+ params: params(effect.amount)
1374
+ });
1375
+ break;
1376
+ case "sepia":
1377
+ if (effect.amount > 0) colorRun().ops.push({
1378
+ kind: COLOR_OP.sepia,
1379
+ params: params(effect.amount)
1380
+ });
1381
+ break;
1382
+ case "hue-rotate":
1383
+ if (effect.degrees !== 0) colorRun().ops.push({
1384
+ kind: COLOR_OP.hueRotate,
1385
+ params: params(effect.degrees * Math.PI / 180)
1386
+ });
1387
+ break;
1388
+ case "invert":
1389
+ if (effect.amount > 0) colorRun().ops.push({
1390
+ kind: COLOR_OP.invert,
1391
+ params: params(effect.amount)
1392
+ });
1393
+ break;
1394
+ case "blur":
1395
+ if (effect.radius > 0) plan.passes.push({
1396
+ kind: "blur",
1397
+ radius: effect.radius
1398
+ });
1399
+ break;
1400
+ case "drop-shadow":
1401
+ plan.passes.push({
1402
+ kind: "shadow",
1403
+ offsetX: effect.offsetX,
1404
+ offsetY: effect.offsetY,
1405
+ blur: effect.blur,
1406
+ color: parseCssColor(effect.color)
1407
+ });
1408
+ break;
1409
+ case "css":
1410
+ plan.unsupported = true;
1411
+ break;
1412
+ default: {
1413
+ const record = effect;
1414
+ if (record.type === "chroma-key") {
1415
+ const key = parseCssColor(String(record.keyColor ?? "#00ff00"));
1416
+ colorRun().ops.push({
1417
+ kind: COLOR_OP.chromaKey,
1418
+ params: params(key[0], key[1], key[2], Number(record.tolerance ?? .25), Number(record.softness ?? .1), Number(record.spillSuppression ?? .5))
1419
+ });
1420
+ } else if (record.type === "curves") {
1421
+ const channels = record;
1422
+ const master = curveToLut(channels.rgb);
1423
+ const compose = (channel) => {
1424
+ const out = new Float32Array(256);
1425
+ for (let i = 0; i < 256; i++) out[i] = master[Math.round(channel[i] * 255)];
1426
+ return out;
1427
+ };
1428
+ const run = colorRun();
1429
+ run.curves = {
1430
+ r: compose(curveToLut(channels.red)),
1431
+ g: compose(curveToLut(channels.green)),
1432
+ b: compose(curveToLut(channels.blue))
1433
+ };
1434
+ run.ops.push({
1435
+ kind: COLOR_OP.curves,
1436
+ params: params()
1437
+ });
1438
+ } else if (record.type === "lut3d") plan.passes.push({
1439
+ kind: "lut3d",
1440
+ lutId: String(record.lutId ?? ""),
1441
+ intensity: Number(record.intensity ?? 1)
1442
+ });
1443
+ else plan.unsupported = true;
1444
+ }
1445
+ }
1446
+ }
1447
+ return plan;
1448
+ }
1449
+ /** True when this stack can only render through the canvas2d raster path. */
1450
+ function hasUnsupportedEffects(effects) {
1451
+ if (!effects) return false;
1452
+ return planEffects(effects).unsupported;
1453
+ }
1454
+ //#endregion
1455
+ //#region src/webgpu/shaders.ts
1456
+ /**
1457
+ * WGSL for the WebGPU backend. All composite work happens in full-frame
1458
+ * passes over a ping-pong pair of offscreen textures: each pass samples the
1459
+ * accumulated frame (dst) and one prepared layer texture (src), maps the
1460
+ * fragment back into the layer's local space with the inverse chrome
1461
+ * transform, and blends by mode — one blend.wgsl with a mode switch,
1462
+ * correctness over micro-optimization.
1463
+ *
1464
+ * Layer textures hold PREMULTIPLIED alpha (canvas uploads premultiply;
1465
+ * VideoFrames are effectively opaque). Blend formulas operate on straight
1466
+ * color, so src/dst unpremultiply around the math.
1467
+ */
1468
+ /** Fullscreen triangle; uv covers [0,1]² across the target. */
1469
+ const FULLSCREEN_VERTEX = `
1470
+ struct VertexOut {
1471
+ @builtin(position) position: vec4f,
1472
+ @location(0) uv: vec2f,
1473
+ }
1474
+
1475
+ @vertex
1476
+ fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut {
1477
+ var out: VertexOut;
1478
+ let pos = array<vec2f, 3>(vec2f(-1.0, -1.0), vec2f(3.0, -1.0), vec2f(-1.0, 3.0));
1479
+ let p = pos[index];
1480
+ out.position = vec4f(p, 0.0, 1.0);
1481
+ out.uv = vec2f((p.x + 1.0) * 0.5, (1.0 - p.y) * 0.5);
1482
+ return out;
1483
+ }
1484
+ `;
1485
+ /**
1486
+ * The composite pass: dst = blend(layer at inverse-transformed position,
1487
+ * dst). Pixels outside the layer's quad pass dst through untouched.
1488
+ */
1489
+ const COMPOSITE_SHADER = `
1490
+ ${FULLSCREEN_VERTEX}
1491
+
1492
+ struct CompositeUniforms {
1493
+ // local = inv * (frame - center)
1494
+ inv: vec4f, // m00, m01, m10, m11
1495
+ center: vec2f,
1496
+ halfSize: vec2f, // dw/2, dh/2
1497
+ frameSize: vec2f,
1498
+ cornerRadius: f32,
1499
+ opacity: f32,
1500
+ mode: u32,
1501
+ identity: u32, // 1 = raster layer: sample at uv directly
1502
+ _pad: vec2f,
1503
+ }
1504
+
1505
+ @group(0) @binding(0) var dstTex: texture_2d<f32>;
1506
+ @group(0) @binding(1) var srcTex: texture_2d<f32>;
1507
+ @group(0) @binding(2) var srcSampler: sampler;
1508
+ @group(0) @binding(3) var<uniform> u: CompositeUniforms;
1509
+
1510
+
1511
+ fn lum(c: vec3f) -> f32 {
1512
+ return dot(c, vec3f(0.3, 0.59, 0.11));
1513
+ }
1514
+
1515
+ fn clip_color(c_in: vec3f) -> vec3f {
1516
+ var c = c_in;
1517
+ let l = lum(c);
1518
+ let n = min(min(c.r, c.g), c.b);
1519
+ let x = max(max(c.r, c.g), c.b);
1520
+ if (n < 0.0) {
1521
+ c = vec3f(l) + (c - vec3f(l)) * l / max(l - n, 1e-6);
1522
+ }
1523
+ if (x > 1.0) {
1524
+ c = vec3f(l) + (c - vec3f(l)) * (1.0 - l) / max(x - l, 1e-6);
1525
+ }
1526
+ return c;
1527
+ }
1528
+
1529
+ fn set_lum(c: vec3f, l: f32) -> vec3f {
1530
+ return clip_color(c + vec3f(l - lum(c)));
1531
+ }
1532
+
1533
+ fn sat(c: vec3f) -> f32 {
1534
+ return max(max(c.r, c.g), c.b) - min(min(c.r, c.g), c.b);
1535
+ }
1536
+
1537
+ fn set_sat(c: vec3f, s: f32) -> vec3f {
1538
+ let mn = min(min(c.r, c.g), c.b);
1539
+ let mx = max(max(c.r, c.g), c.b);
1540
+ var out = vec3f(0.0);
1541
+ if (mx > mn) {
1542
+ out = (c - vec3f(mn)) * s / (mx - mn);
1543
+ }
1544
+ return out;
1545
+ }
1546
+
1547
+ fn hard_light_channel(s: f32, d: f32) -> f32 {
1548
+ if (s <= 0.5) { return 2.0 * s * d; }
1549
+ return 1.0 - 2.0 * (1.0 - s) * (1.0 - d);
1550
+ }
1551
+
1552
+ fn soft_light_channel(s: f32, d: f32) -> f32 {
1553
+ if (s <= 0.5) {
1554
+ return d - (1.0 - 2.0 * s) * d * (1.0 - d);
1555
+ }
1556
+ var dd: f32;
1557
+ if (d <= 0.25) {
1558
+ dd = ((16.0 * d - 12.0) * d + 4.0) * d;
1559
+ } else {
1560
+ dd = sqrt(d);
1561
+ }
1562
+ return d + (2.0 * s - 1.0) * (dd - d);
1563
+ }
1564
+
1565
+ fn color_dodge_channel(s: f32, d: f32) -> f32 {
1566
+ if (d <= 0.0) { return 0.0; }
1567
+ if (s >= 1.0) { return 1.0; }
1568
+ return min(1.0, d / (1.0 - s));
1569
+ }
1570
+
1571
+ fn color_burn_channel(s: f32, d: f32) -> f32 {
1572
+ if (d >= 1.0) { return 1.0; }
1573
+ if (s <= 0.0) { return 0.0; }
1574
+ return 1.0 - min(1.0, (1.0 - d) / s);
1575
+ }
1576
+
1577
+ // W3C compositing-and-blending-1 B(Cb, Cs); straight (unpremultiplied) color.
1578
+ fn blend_colors(mode: u32, src: vec3f, dst: vec3f) -> vec3f {
1579
+ switch (mode) {
1580
+ case 1u: { return src * dst; } // multiply
1581
+ case 2u: { return src + dst - src * dst; } // screen
1582
+ case 3u: { // overlay
1583
+ return vec3f(
1584
+ hard_light_channel(dst.r, src.r),
1585
+ hard_light_channel(dst.g, src.g),
1586
+ hard_light_channel(dst.b, src.b),
1587
+ );
1588
+ }
1589
+ case 4u: { return min(src, dst); } // darken
1590
+ case 5u: { return max(src, dst); } // lighten
1591
+ case 6u: { // color-dodge
1592
+ return vec3f(
1593
+ color_dodge_channel(src.r, dst.r),
1594
+ color_dodge_channel(src.g, dst.g),
1595
+ color_dodge_channel(src.b, dst.b),
1596
+ );
1597
+ }
1598
+ case 7u: { // color-burn
1599
+ return vec3f(
1600
+ color_burn_channel(src.r, dst.r),
1601
+ color_burn_channel(src.g, dst.g),
1602
+ color_burn_channel(src.b, dst.b),
1603
+ );
1604
+ }
1605
+ case 8u: { // hard-light
1606
+ return vec3f(
1607
+ hard_light_channel(src.r, dst.r),
1608
+ hard_light_channel(src.g, dst.g),
1609
+ hard_light_channel(src.b, dst.b),
1610
+ );
1611
+ }
1612
+ case 9u: { // soft-light
1613
+ return vec3f(
1614
+ soft_light_channel(src.r, dst.r),
1615
+ soft_light_channel(src.g, dst.g),
1616
+ soft_light_channel(src.b, dst.b),
1617
+ );
1618
+ }
1619
+ case 10u: { return abs(src - dst); } // difference
1620
+ case 11u: { return src + dst - 2.0 * src * dst; } // exclusion
1621
+ case 12u: { return set_lum(set_sat(src, sat(dst)), lum(dst)); } // hue
1622
+ case 13u: { return set_lum(set_sat(dst, sat(src)), lum(dst)); } // saturation
1623
+ case 14u: { return set_lum(src, lum(dst)); } // color
1624
+ case 15u: { return set_lum(dst, lum(src)); } // luminosity
1625
+ default: { return src; } // normal
1626
+ }
1627
+ }
1628
+
1629
+
1630
+ // Signed distance to a centered rounded rect of half-size b, radius r.
1631
+ fn rounded_rect_sdf(p: vec2f, b: vec2f, r: f32) -> f32 {
1632
+ let q = abs(p) - b + vec2f(r);
1633
+ return length(max(q, vec2f(0.0))) + min(max(q.x, q.y), 0.0) - r;
1634
+ }
1635
+
1636
+ @fragment
1637
+ fn fs_main(in: VertexOut) -> @location(0) vec4f {
1638
+ let dst = textureSampleLevel(dstTex, srcSampler, in.uv, 0.0);
1639
+
1640
+ var src: vec4f;
1641
+ if (u.identity == 1u) {
1642
+ src = textureSampleLevel(srcTex, srcSampler, in.uv, 0.0);
1643
+ } else {
1644
+ let frame = in.uv * u.frameSize;
1645
+ let local = vec2f(
1646
+ u.inv.x * (frame.x - u.center.x) + u.inv.y * (frame.y - u.center.y),
1647
+ u.inv.z * (frame.x - u.center.x) + u.inv.w * (frame.y - u.center.y),
1648
+ );
1649
+ if (abs(local.x) > u.halfSize.x || abs(local.y) > u.halfSize.y) {
1650
+ return dst;
1651
+ }
1652
+ let uv = (local + u.halfSize) / (u.halfSize * 2.0);
1653
+ src = textureSampleLevel(srcTex, srcSampler, uv, 0.0);
1654
+ if (u.cornerRadius > 0.0) {
1655
+ let d = rounded_rect_sdf(local, u.halfSize, u.cornerRadius);
1656
+ // ~1px feather in local units (no derivatives needed for our scales).
1657
+ src = src * clamp(0.5 - d, 0.0, 1.0);
1658
+ }
1659
+ }
1660
+
1661
+ src = src * u.opacity;
1662
+
1663
+ // Premultiplied src-over for normal; spec blending otherwise.
1664
+ if (u.mode == 0u) {
1665
+ return src + dst * (1.0 - src.a);
1666
+ }
1667
+
1668
+ let sa = src.a;
1669
+ let da = dst.a;
1670
+ var sc = vec3f(0.0);
1671
+ if (sa > 0.0) { sc = src.rgb / sa; }
1672
+ var dc = vec3f(0.0);
1673
+ if (da > 0.0) { dc = dst.rgb / da; }
1674
+ let blended = blend_colors(u.mode, sc, dc);
1675
+ // Cs' = (1 - ab) * Cs + ab * B(Cb, Cs), then source-over composite.
1676
+ let mixed = mix(sc, blended, da);
1677
+ let outA = sa + da * (1.0 - sa);
1678
+ let outRgb = mixed * sa + dc * da * (1.0 - sa);
1679
+ return vec4f(outRgb, outA);
1680
+ }
1681
+ `;
1682
+ /** Prepare pass: sample (and crop) the source image into a layer texture. */
1683
+ const PREPARE_SHADER = (external) => `
1684
+ ${FULLSCREEN_VERTEX}
1685
+
1686
+ struct PrepareUniforms {
1687
+ cropOrigin: vec2f, // normalized crop origin in the source
1688
+ cropSize: vec2f, // normalized crop size
1689
+ }
1690
+
1691
+ @group(0) @binding(0) var src: ${external ? "texture_external" : "texture_2d<f32>"};
1692
+ @group(0) @binding(1) var srcSampler: sampler;
1693
+ @group(0) @binding(2) var<uniform> u: PrepareUniforms;
1694
+
1695
+ @fragment
1696
+ fn fs_main(in: VertexOut) -> @location(0) vec4f {
1697
+ let uv = u.cropOrigin + in.uv * u.cropSize;
1698
+ ${external ? "let color = textureSampleBaseClampToEdge(src, srcSampler, uv);" : "let color = textureSampleLevel(src, srcSampler, uv, 0.0);"}
1699
+ return color;
1700
+ }
1701
+ `;
1702
+ /**
1703
+ * Fused color pass: applies the ordered op list (brightness/contrast/
1704
+ * saturate/grayscale/sepia/hue-rotate/invert/chroma-key/curves) in one
1705
+ * fragment invocation. Curves sample a 256×1 LUT texture.
1706
+ */
1707
+ const COLOR_SHADER = `
1708
+ ${FULLSCREEN_VERTEX}
1709
+
1710
+ struct ColorOp {
1711
+ kind: vec4u, // x = op kind
1712
+ a: vec4f, // params 0..3
1713
+ b: vec4f, // params 4..7
1714
+ }
1715
+
1716
+ struct ColorUniforms {
1717
+ count: vec4u,
1718
+ ops: array<ColorOp, 16>,
1719
+ }
1720
+
1721
+ @group(0) @binding(0) var src: texture_2d<f32>;
1722
+ @group(0) @binding(1) var srcSampler: sampler;
1723
+ @group(0) @binding(2) var<uniform> u: ColorUniforms;
1724
+ @group(0) @binding(3) var curvesTex: texture_2d<f32>;
1725
+
1726
+ const LUMA = vec3f(0.2126, 0.7152, 0.0722);
1727
+
1728
+ fn apply_op(op: ColorOp, color_in: vec4f) -> vec4f {
1729
+ var color = color_in;
1730
+ switch (op.kind.x) {
1731
+ case 1u: { // brightness
1732
+ color = vec4f(color.rgb * op.a.x, color.a);
1733
+ }
1734
+ case 2u: { // contrast
1735
+ color = vec4f((color.rgb - 0.5) * op.a.x + 0.5, color.a);
1736
+ }
1737
+ case 3u: { // saturate
1738
+ let l = dot(color.rgb, LUMA);
1739
+ color = vec4f(mix(vec3f(l), color.rgb, op.a.x), color.a);
1740
+ }
1741
+ case 4u: { // grayscale
1742
+ let l = dot(color.rgb, LUMA);
1743
+ color = vec4f(mix(color.rgb, vec3f(l), op.a.x), color.a);
1744
+ }
1745
+ case 5u: { // sepia
1746
+ let s = vec3f(
1747
+ dot(color.rgb, vec3f(0.393, 0.769, 0.189)),
1748
+ dot(color.rgb, vec3f(0.349, 0.686, 0.168)),
1749
+ dot(color.rgb, vec3f(0.272, 0.534, 0.131)),
1750
+ );
1751
+ color = vec4f(mix(color.rgb, s, op.a.x), color.a);
1752
+ }
1753
+ case 6u: { // hue-rotate (radians)
1754
+ let angle = op.a.x;
1755
+ let c = cos(angle);
1756
+ let s = sin(angle);
1757
+ // CSS filter hue-rotation matrix.
1758
+ let m = mat3x3f(
1759
+ vec3f(0.213 + c * 0.787 - s * 0.213, 0.213 - c * 0.213 + s * 0.143, 0.213 - c * 0.213 - s * 0.787),
1760
+ vec3f(0.715 - c * 0.715 - s * 0.715, 0.715 + c * 0.285 + s * 0.140, 0.715 - c * 0.715 + s * 0.715),
1761
+ vec3f(0.072 - c * 0.072 + s * 0.928, 0.072 - c * 0.072 - s * 0.283, 0.072 + c * 0.928 + s * 0.072),
1762
+ );
1763
+ color = vec4f(clamp(m * color.rgb, vec3f(0.0), vec3f(1.0)), color.a);
1764
+ }
1765
+ case 7u: { // invert
1766
+ color = vec4f(mix(color.rgb, vec3f(1.0) - color.rgb, op.a.x), color.a);
1767
+ }
1768
+ case 8u: { // chroma key: a = key rgb + tolerance, b = softness, spill
1769
+ let key = op.a.rgb;
1770
+ // Distance in the CbCr plane — luma-independent keying.
1771
+ let cb = -0.169 * color.r - 0.331 * color.g + 0.5 * color.b;
1772
+ let cr = 0.5 * color.r - 0.419 * color.g - 0.081 * color.b;
1773
+ let kcb = -0.169 * key.r - 0.331 * key.g + 0.5 * key.b;
1774
+ let kcr = 0.5 * key.r - 0.419 * key.g - 0.081 * key.b;
1775
+ let distance = length(vec2f(cb - kcb, cr - kcr));
1776
+ let tolerance = op.a.w;
1777
+ let softness = max(op.b.x, 1e-4);
1778
+ let keyAlpha = clamp((distance - tolerance) / softness, 0.0, 1.0);
1779
+ // Spill suppression: pull the dominant key channel toward the others.
1780
+ var rgb = color.rgb;
1781
+ let spill = op.b.y;
1782
+ if (spill > 0.0 && keyAlpha > 0.0) {
1783
+ let isGreen = key.g >= key.r && key.g >= key.b;
1784
+ if (isGreen) {
1785
+ let limit = max(rgb.r, rgb.b);
1786
+ rgb.g = mix(rgb.g, min(rgb.g, limit), spill);
1787
+ } else {
1788
+ let limit = max(rgb.g, rgb.b);
1789
+ rgb.r = mix(rgb.r, min(rgb.r, limit), spill);
1790
+ }
1791
+ }
1792
+ color = vec4f(rgb * keyAlpha, color.a * keyAlpha);
1793
+ }
1794
+ case 9u: { // curves via 256×1 LUT texture (rgb channels)
1795
+ let r = textureSampleLevel(curvesTex, srcSampler, vec2f(color.r * (255.0 / 256.0) + 0.5 / 256.0, 0.5), 0.0).r;
1796
+ let g = textureSampleLevel(curvesTex, srcSampler, vec2f(color.g * (255.0 / 256.0) + 0.5 / 256.0, 0.5), 0.0).g;
1797
+ let b = textureSampleLevel(curvesTex, srcSampler, vec2f(color.b * (255.0 / 256.0) + 0.5 / 256.0, 0.5), 0.0).b;
1798
+ color = vec4f(r, g, b, color.a);
1799
+ }
1800
+ default: {}
1801
+ }
1802
+ return color;
1803
+ }
1804
+
1805
+ @fragment
1806
+ fn fs_main(in: VertexOut) -> @location(0) vec4f {
1807
+ var color = textureSampleLevel(src, srcSampler, in.uv, 0.0);
1808
+ // Work in straight alpha for color math.
1809
+ let alpha = color.a;
1810
+ if (alpha > 0.0) {
1811
+ color = vec4f(color.rgb / alpha, alpha);
1812
+ }
1813
+ for (var i = 0u; i < u.count.x; i = i + 1u) {
1814
+ color = apply_op(u.ops[i], color);
1815
+ }
1816
+ return vec4f(color.rgb * color.a, color.a);
1817
+ }
1818
+ `;
1819
+ /** Separable Gaussian blur, one direction per pass (weights in a storage buffer). */
1820
+ const BLUR_SHADER = `
1821
+ ${FULLSCREEN_VERTEX}
1822
+
1823
+ struct BlurUniforms {
1824
+ direction: vec2f, // (1,0) then (0,1), in texels
1825
+ texelSize: vec2f,
1826
+ halfTaps: u32,
1827
+ _pad0: u32,
1828
+ _pad1: u32,
1829
+ _pad2: u32,
1830
+ }
1831
+
1832
+ @group(0) @binding(0) var src: texture_2d<f32>;
1833
+ @group(0) @binding(1) var srcSampler: sampler;
1834
+ @group(0) @binding(2) var<uniform> u: BlurUniforms;
1835
+ @group(0) @binding(3) var<storage, read> weights: array<f32>;
1836
+
1837
+ @fragment
1838
+ fn fs_main(in: VertexOut) -> @location(0) vec4f {
1839
+ var sum = vec4f(0.0);
1840
+ let half = i32(u.halfTaps);
1841
+ for (var i = -half; i <= half; i = i + 1) {
1842
+ let w = weights[i + half];
1843
+ let offset = u.direction * u.texelSize * f32(i);
1844
+ sum = sum + textureSampleLevel(src, srcSampler, in.uv + offset, 0.0) * w;
1845
+ }
1846
+ return sum;
1847
+ }
1848
+ `;
1849
+ /**
1850
+ * Drop-shadow compose: out = layer over (shadowColor × blurredAlpha at
1851
+ * offset). Both textures are layer-sized; the offset arrives in layer UVs.
1852
+ */
1853
+ const SHADOW_SHADER = `
1854
+ ${FULLSCREEN_VERTEX}
1855
+
1856
+ struct ShadowUniforms {
1857
+ color: vec4f, // straight alpha
1858
+ offsetUv: vec2f,
1859
+ _pad: vec2f,
1860
+ }
1861
+
1862
+ @group(0) @binding(0) var layerTex: texture_2d<f32>;
1863
+ @group(0) @binding(1) var blurredTex: texture_2d<f32>;
1864
+ @group(0) @binding(2) var srcSampler: sampler;
1865
+ @group(0) @binding(3) var<uniform> u: ShadowUniforms;
1866
+
1867
+ @fragment
1868
+ fn fs_main(in: VertexOut) -> @location(0) vec4f {
1869
+ let layer = textureSampleLevel(layerTex, srcSampler, in.uv, 0.0);
1870
+ let shadowUv = in.uv - u.offsetUv;
1871
+ var shadowAlpha = 0.0;
1872
+ if (shadowUv.x >= 0.0 && shadowUv.x <= 1.0 && shadowUv.y >= 0.0 && shadowUv.y <= 1.0) {
1873
+ shadowAlpha = textureSampleLevel(blurredTex, srcSampler, shadowUv, 0.0).a;
1874
+ }
1875
+ let shadow = vec4f(u.color.rgb, 1.0) * (u.color.a * shadowAlpha);
1876
+ return layer + shadow * (1.0 - layer.a);
1877
+ }
1878
+ `;
1879
+ /**
1880
+ * 3D LUT grade: trilinear sample of a size³ table flattened into a 2D
1881
+ * texture (slices side by side: width = size², height = size).
1882
+ */
1883
+ const LUT3D_SHADER = `
1884
+ ${FULLSCREEN_VERTEX}
1885
+
1886
+ struct LutUniforms {
1887
+ size: f32,
1888
+ intensity: f32,
1889
+ _pad: vec2f,
1890
+ }
1891
+
1892
+ @group(0) @binding(0) var src: texture_2d<f32>;
1893
+ @group(0) @binding(1) var srcSampler: sampler;
1894
+ @group(0) @binding(2) var lutTex: texture_2d<f32>;
1895
+ @group(0) @binding(3) var<uniform> u: LutUniforms;
1896
+
1897
+ fn sample_lut(rgb: vec3f, size: f32) -> vec3f {
1898
+ let scaled = clamp(rgb, vec3f(0.0), vec3f(1.0)) * (size - 1.0);
1899
+ let slice0 = floor(scaled.b);
1900
+ let slice1 = min(slice0 + 1.0, size - 1.0);
1901
+ let f = scaled.b - slice0;
1902
+ let uvBase = vec2f((scaled.r + 0.5) / (size * size), (scaled.g + 0.5) / size);
1903
+ let uv0 = vec2f(uvBase.x + slice0 / size, uvBase.y);
1904
+ let uv1 = vec2f(uvBase.x + slice1 / size, uvBase.y);
1905
+ let c0 = textureSampleLevel(lutTex, srcSampler, uv0, 0.0).rgb;
1906
+ let c1 = textureSampleLevel(lutTex, srcSampler, uv1, 0.0).rgb;
1907
+ return mix(c0, c1, f);
1908
+ }
1909
+
1910
+ @fragment
1911
+ fn fs_main(in: VertexOut) -> @location(0) vec4f {
1912
+ var color = textureSampleLevel(src, srcSampler, in.uv, 0.0);
1913
+ let alpha = color.a;
1914
+ var rgb = color.rgb;
1915
+ if (alpha > 0.0) { rgb = rgb / alpha; }
1916
+ let graded = sample_lut(rgb, u.size);
1917
+ rgb = mix(rgb, graded, u.intensity);
1918
+ return vec4f(rgb * alpha, alpha);
1919
+ }
1920
+ `;
1921
+ /** Final present: stretch the accumulated frame onto the canvas texture. */
1922
+ const PRESENT_SHADER = `
1923
+ ${FULLSCREEN_VERTEX}
1924
+
1925
+ @group(0) @binding(0) var src: texture_2d<f32>;
1926
+ @group(0) @binding(1) var srcSampler: sampler;
1927
+
1928
+ @fragment
1929
+ fn fs_main(in: VertexOut) -> @location(0) vec4f {
1930
+ return textureSampleLevel(src, srcSampler, in.uv, 0.0);
1931
+ }
1932
+ `;
1933
+ /** Blend mode name → shader id (0 = normal). Keep in sync with blend_colors. */
1934
+ const BLEND_MODE_IDS = {
1935
+ normal: 0,
1936
+ multiply: 1,
1937
+ screen: 2,
1938
+ overlay: 3,
1939
+ darken: 4,
1940
+ lighten: 5,
1941
+ "color-dodge": 6,
1942
+ "color-burn": 7,
1943
+ "hard-light": 8,
1944
+ "soft-light": 9,
1945
+ difference: 10,
1946
+ exclusion: 11,
1947
+ hue: 12,
1948
+ saturation: 13,
1949
+ color: 14,
1950
+ luminosity: 15
1951
+ };
1952
+ //#endregion
1953
+ //#region src/webgpu/transform.ts
1954
+ function invertChrome(chrome) {
1955
+ const { scaleX, scaleY, rotationDeg, centerX, centerY } = chrome;
1956
+ if (scaleX === 0 || scaleY === 0) return {
1957
+ m00: 0,
1958
+ m01: 0,
1959
+ m10: 0,
1960
+ m11: 0,
1961
+ centerX,
1962
+ centerY,
1963
+ degenerate: true
1964
+ };
1965
+ const angle = -rotationDeg * Math.PI / 180;
1966
+ const cos = Math.cos(angle);
1967
+ const sin = Math.sin(angle);
1968
+ return {
1969
+ m00: cos / scaleX,
1970
+ m01: -sin / scaleX,
1971
+ m10: sin / scaleY,
1972
+ m11: cos / scaleY,
1973
+ centerX,
1974
+ centerY,
1975
+ degenerate: false
1976
+ };
1977
+ }
1978
+ /**
1979
+ * 1D Gaussian kernel for a CSS-style blur radius (σ = radius / 2, kernel
1980
+ * support 3σ each side), normalized to sum 1.
1981
+ */
1982
+ function gaussianKernel(radius) {
1983
+ const sigma = Math.max(.1, radius / 2);
1984
+ const half = Math.max(1, Math.ceil(sigma * 3));
1985
+ const weights = new Float32Array(half * 2 + 1);
1986
+ let sum = 0;
1987
+ for (let i = -half; i <= half; i++) {
1988
+ const w = Math.exp(-(i * i) / (2 * sigma * sigma));
1989
+ weights[i + half] = w;
1990
+ sum += w;
1991
+ }
1992
+ for (let i = 0; i < weights.length; i++) weights[i] = weights[i] / sum;
1993
+ return weights;
1994
+ }
1995
+ //#endregion
1996
+ //#region src/webgpu/webgpu-backend.ts
1997
+ /** Whether this runtime exposes WebGPU at all (Chrome 113+/Safari 26+/Firefox 141+). */
1998
+ function isWebGPUSupported() {
1999
+ return typeof navigator !== "undefined" && "gpu" in navigator && !!navigator.gpu;
2000
+ }
2001
+ const FORMAT = "rgba8unorm";
2002
+ const TEXTURE_USAGE = {
2003
+ COPY_DST: 2,
2004
+ TEXTURE_BINDING: 4,
2005
+ RENDER_ATTACHMENT: 16
2006
+ };
2007
+ const BUFFER_USAGE = {
2008
+ COPY_DST: 8,
2009
+ UNIFORM: 64,
2010
+ STORAGE: 128
2011
+ };
2012
+ var WebGPUBackend = class WebGPUBackend {
2013
+ kind = "webgpu";
2014
+ width;
2015
+ height;
2016
+ device;
2017
+ context;
2018
+ presentationFormat;
2019
+ acc;
2020
+ accIndex = 0;
2021
+ rasterCanvas;
2022
+ rasterCtx;
2023
+ rasterDirty = false;
2024
+ rasterScope = 0;
2025
+ sampler;
2026
+ pipelines;
2027
+ texturePool = [];
2028
+ frameBuffers = [];
2029
+ identityCurves;
2030
+ luts = /* @__PURE__ */ new Map();
2031
+ static async create(options) {
2032
+ let device = options.device;
2033
+ if (!device) {
2034
+ if (!isWebGPUSupported()) throw new Error("WebGPU is not available in this browser");
2035
+ const adapter = await navigator.gpu.requestAdapter();
2036
+ if (!adapter) throw new Error("No WebGPU adapter available");
2037
+ device = await adapter.requestDevice();
2038
+ }
2039
+ return new WebGPUBackend(device, options);
2040
+ }
2041
+ constructor(device, options) {
2042
+ this.device = device;
2043
+ this.width = options.width;
2044
+ this.height = options.height;
2045
+ const context = options.canvas.getContext("webgpu");
2046
+ if (!context) throw new Error("Could not create a webgpu canvas context");
2047
+ this.context = context;
2048
+ this.presentationFormat = typeof navigator !== "undefined" && navigator.gpu ? navigator.gpu.getPreferredCanvasFormat() : FORMAT;
2049
+ context.configure({
2050
+ device,
2051
+ format: this.presentationFormat,
2052
+ alphaMode: "opaque"
2053
+ });
2054
+ this.rasterCanvas = new OffscreenCanvas(this.width, this.height);
2055
+ const rasterCtx = this.rasterCanvas.getContext("2d");
2056
+ if (!rasterCtx) throw new Error("Could not create the raster scratch context");
2057
+ this.rasterCtx = rasterCtx;
2058
+ this.acc = [this.createAccTexture(), this.createAccTexture()];
2059
+ this.sampler = device.createSampler({
2060
+ magFilter: "linear",
2061
+ minFilter: "linear",
2062
+ addressModeU: "clamp-to-edge",
2063
+ addressModeV: "clamp-to-edge"
2064
+ });
2065
+ const fullscreen = (code, format) => {
2066
+ const module = device.createShaderModule({ code });
2067
+ return device.createRenderPipeline({
2068
+ layout: "auto",
2069
+ vertex: {
2070
+ module,
2071
+ entryPoint: "vs_main"
2072
+ },
2073
+ fragment: {
2074
+ module,
2075
+ entryPoint: "fs_main",
2076
+ targets: [{ format }]
2077
+ },
2078
+ primitive: { topology: "triangle-list" }
2079
+ });
2080
+ };
2081
+ this.pipelines = {
2082
+ composite: fullscreen(COMPOSITE_SHADER, FORMAT),
2083
+ prepare2d: fullscreen(PREPARE_SHADER(false), FORMAT),
2084
+ prepareExternal: fullscreen(PREPARE_SHADER(true), FORMAT),
2085
+ color: fullscreen(COLOR_SHADER, FORMAT),
2086
+ blur: fullscreen(BLUR_SHADER, FORMAT),
2087
+ shadow: fullscreen(SHADOW_SHADER, FORMAT),
2088
+ lut3d: fullscreen(LUT3D_SHADER, FORMAT),
2089
+ present: fullscreen(PRESENT_SHADER, this.presentationFormat)
2090
+ };
2091
+ this.identityCurves = device.createTexture({
2092
+ size: {
2093
+ width: 256,
2094
+ height: 1
2095
+ },
2096
+ format: FORMAT,
2097
+ usage: TEXTURE_USAGE.TEXTURE_BINDING | TEXTURE_USAGE.COPY_DST
2098
+ });
2099
+ const identity = new Uint8Array(256 * 4);
2100
+ for (let i = 0; i < 256; i++) {
2101
+ identity[i * 4] = i;
2102
+ identity[i * 4 + 1] = i;
2103
+ identity[i * 4 + 2] = i;
2104
+ identity[i * 4 + 3] = 255;
2105
+ }
2106
+ device.queue.writeTexture({ texture: this.identityCurves }, identity, { bytesPerRow: 256 * 4 }, {
2107
+ width: 256,
2108
+ height: 1
2109
+ });
2110
+ }
2111
+ /**
2112
+ * Register a 3D LUT for `lut3d` effects: `data` is size³ RGB triples
2113
+ * (0..1, red fastest), flattened to a (size² × size) 2D texture.
2114
+ */
2115
+ registerLut3D(lutId, size, data) {
2116
+ if (data.length < size * size * size * 3) throw new Error("LUT data is too short for its size");
2117
+ const width = size * size;
2118
+ const bytes = new Uint8Array(width * size * 4);
2119
+ for (let b = 0; b < size; b++) for (let g = 0; g < size; g++) for (let r = 0; r < size; r++) {
2120
+ const src = ((b * size + g) * size + r) * 3;
2121
+ const dst = (g * width + b * size + r) * 4;
2122
+ bytes[dst] = Math.round(Math.min(1, Math.max(0, data[src])) * 255);
2123
+ bytes[dst + 1] = Math.round(Math.min(1, Math.max(0, data[src + 1])) * 255);
2124
+ bytes[dst + 2] = Math.round(Math.min(1, Math.max(0, data[src + 2])) * 255);
2125
+ bytes[dst + 3] = 255;
2126
+ }
2127
+ const texture = this.device.createTexture({
2128
+ size: {
2129
+ width,
2130
+ height: size
2131
+ },
2132
+ format: FORMAT,
2133
+ usage: TEXTURE_USAGE.TEXTURE_BINDING | TEXTURE_USAGE.COPY_DST
2134
+ });
2135
+ this.device.queue.writeTexture({ texture }, bytes, { bytesPerRow: width * 4 }, {
2136
+ width,
2137
+ height: size
2138
+ });
2139
+ this.luts.get(lutId)?.texture.destroy();
2140
+ this.luts.set(lutId, {
2141
+ texture,
2142
+ size
2143
+ });
2144
+ }
2145
+ beginFrame(backgroundColor) {
2146
+ const [r, g, b] = parseCssColor(backgroundColor);
2147
+ this.accIndex = 0;
2148
+ const encoder = this.device.createCommandEncoder();
2149
+ encoder.beginRenderPass({ colorAttachments: [{
2150
+ view: this.acc[0].createView(),
2151
+ loadOp: "clear",
2152
+ storeOp: "store",
2153
+ clearValue: {
2154
+ r,
2155
+ g,
2156
+ b,
2157
+ a: 1
2158
+ }
2159
+ }] }).end();
2160
+ this.device.queue.submit([encoder.finish()]);
2161
+ }
2162
+ endFrame() {
2163
+ this.flushRaster();
2164
+ const encoder = this.device.createCommandEncoder();
2165
+ const pass = encoder.beginRenderPass({ colorAttachments: [{
2166
+ view: this.context.getCurrentTexture().createView(),
2167
+ loadOp: "clear",
2168
+ storeOp: "store",
2169
+ clearValue: {
2170
+ r: 0,
2171
+ g: 0,
2172
+ b: 0,
2173
+ a: 1
2174
+ }
2175
+ }] });
2176
+ pass.setPipeline(this.pipelines.present);
2177
+ pass.setBindGroup(0, this.device.createBindGroup({
2178
+ layout: this.pipelines.present.getBindGroupLayout(0),
2179
+ entries: [{
2180
+ binding: 0,
2181
+ resource: this.acc[this.accIndex].createView()
2182
+ }, {
2183
+ binding: 1,
2184
+ resource: this.sampler
2185
+ }]
2186
+ }));
2187
+ pass.draw(3);
2188
+ pass.end();
2189
+ this.device.queue.submit([encoder.finish()]);
2190
+ for (const buffer of this.frameBuffers) buffer.destroy();
2191
+ this.frameBuffers = [];
2192
+ for (const pooled of this.texturePool) pooled.inUse = false;
2193
+ }
2194
+ acquireRaster() {
2195
+ this.rasterDirty = true;
2196
+ return this.rasterCtx;
2197
+ }
2198
+ pushRasterScope() {
2199
+ this.rasterScope++;
2200
+ }
2201
+ popRasterScope() {
2202
+ this.rasterScope = Math.max(0, this.rasterScope - 1);
2203
+ }
2204
+ drawImageQuad(quad, chrome) {
2205
+ if (chrome.opacity <= 0) return;
2206
+ const plan = planEffects(chrome.effects);
2207
+ if (this.rasterScope > 0 || plan.unsupported) {
2208
+ const ctx = this.acquireRaster();
2209
+ applyChrome(ctx, chrome, () => drawImageQuad2D(ctx, quad));
2210
+ return;
2211
+ }
2212
+ const inverse = invertChrome(chrome);
2213
+ if (inverse.degenerate) return;
2214
+ this.flushRaster();
2215
+ try {
2216
+ this.drawQuadOnGpu(quad, chrome, plan, inverse);
2217
+ } catch {
2218
+ const ctx = this.acquireRaster();
2219
+ applyChrome(ctx, chrome, () => drawImageQuad2D(ctx, quad));
2220
+ }
2221
+ }
2222
+ /** Free GPU resources. The backend is unusable afterwards. */
2223
+ dispose() {
2224
+ for (const pooled of this.texturePool) pooled.texture.destroy();
2225
+ this.texturePool.length = 0;
2226
+ for (const buffer of this.frameBuffers) buffer.destroy();
2227
+ this.frameBuffers = [];
2228
+ this.acc[0].destroy();
2229
+ this.acc[1].destroy();
2230
+ this.identityCurves.destroy();
2231
+ for (const { texture } of this.luts.values()) texture.destroy();
2232
+ this.luts.clear();
2233
+ this.context.unconfigure();
2234
+ }
2235
+ createAccTexture() {
2236
+ return this.device.createTexture({
2237
+ size: {
2238
+ width: this.width,
2239
+ height: this.height
2240
+ },
2241
+ format: FORMAT,
2242
+ usage: TEXTURE_USAGE.RENDER_ATTACHMENT | TEXTURE_USAGE.TEXTURE_BINDING
2243
+ });
2244
+ }
2245
+ acquireTexture(width, height) {
2246
+ const found = this.texturePool.find((p) => !p.inUse && p.width === width && p.height === height);
2247
+ if (found) {
2248
+ found.inUse = true;
2249
+ return found.texture;
2250
+ }
2251
+ const texture = this.device.createTexture({
2252
+ size: {
2253
+ width,
2254
+ height
2255
+ },
2256
+ format: FORMAT,
2257
+ usage: TEXTURE_USAGE.RENDER_ATTACHMENT | TEXTURE_USAGE.TEXTURE_BINDING | TEXTURE_USAGE.COPY_DST
2258
+ });
2259
+ this.texturePool.push({
2260
+ texture,
2261
+ width,
2262
+ height,
2263
+ inUse: true
2264
+ });
2265
+ return texture;
2266
+ }
2267
+ releaseTexture(texture) {
2268
+ const pooled = this.texturePool.find((p) => p.texture === texture);
2269
+ if (pooled) pooled.inUse = false;
2270
+ }
2271
+ uniformBuffer(data) {
2272
+ const buffer = this.device.createBuffer({
2273
+ size: Math.max(16, Math.ceil(data.byteLength / 16) * 16),
2274
+ usage: BUFFER_USAGE.UNIFORM | BUFFER_USAGE.COPY_DST
2275
+ });
2276
+ this.device.queue.writeBuffer(buffer, 0, data);
2277
+ this.frameBuffers.push(buffer);
2278
+ return buffer;
2279
+ }
2280
+ storageBuffer(data) {
2281
+ const buffer = this.device.createBuffer({
2282
+ size: Math.max(16, Math.ceil(data.byteLength / 16) * 16),
2283
+ usage: BUFFER_USAGE.STORAGE | BUFFER_USAGE.COPY_DST
2284
+ });
2285
+ this.device.queue.writeBuffer(buffer, 0, data);
2286
+ this.frameBuffers.push(buffer);
2287
+ return buffer;
2288
+ }
2289
+ /** Upload the raster scratch and composite it as a normal full-frame layer. */
2290
+ flushRaster() {
2291
+ if (!this.rasterDirty) return;
2292
+ this.rasterDirty = false;
2293
+ const texture = this.acquireTexture(this.width, this.height);
2294
+ this.device.queue.copyExternalImageToTexture({ source: this.rasterCanvas }, {
2295
+ texture,
2296
+ premultipliedAlpha: true
2297
+ }, {
2298
+ width: this.width,
2299
+ height: this.height
2300
+ });
2301
+ this.compositeFullFrame(texture, {
2302
+ identity: true,
2303
+ opacity: 1,
2304
+ mode: 0
2305
+ });
2306
+ this.releaseTexture(texture);
2307
+ this.rasterCtx.clearRect(0, 0, this.width, this.height);
2308
+ }
2309
+ drawQuadOnGpu(quad, chrome, plan, inverse) {
2310
+ const limits = this.device.limits.maxTextureDimension2D;
2311
+ const lw = Math.max(1, Math.min(limits, Math.round(quad.dw)));
2312
+ const lh = Math.max(1, Math.min(limits, Math.round(quad.dh)));
2313
+ let layer = this.prepareLayer(quad, lw, lh);
2314
+ for (const pass of plan.passes) layer = this.runEffectPass(layer, pass, lw, lh);
2315
+ this.compositeFullFrame(layer, {
2316
+ identity: false,
2317
+ opacity: chrome.opacity,
2318
+ mode: BLEND_MODE_IDS[chrome.blendMode ?? "normal"] ?? 0,
2319
+ inverse,
2320
+ halfW: quad.dw / 2,
2321
+ halfH: quad.dh / 2,
2322
+ cornerRadius: quad.cornerRadius
2323
+ });
2324
+ this.releaseTexture(layer);
2325
+ }
2326
+ /** Sample (and crop) the source into a fresh layer texture. */
2327
+ prepareLayer(quad, lw, lh) {
2328
+ const layer = this.acquireTexture(lw, lh);
2329
+ const { width: iw, height: ih } = getImageSize(quad.image);
2330
+ const crop = quad.src;
2331
+ const uniforms = new Float32Array(4);
2332
+ if (crop && iw > 0 && ih > 0) uniforms.set([
2333
+ crop.sx / iw,
2334
+ crop.sy / ih,
2335
+ crop.sw / iw,
2336
+ crop.sh / ih
2337
+ ]);
2338
+ else uniforms.set([
2339
+ 0,
2340
+ 0,
2341
+ 1,
2342
+ 1
2343
+ ]);
2344
+ const uniformBuffer = this.uniformBuffer(uniforms.buffer);
2345
+ const isExternal = typeof VideoFrame !== "undefined" && quad.image instanceof VideoFrame || typeof HTMLVideoElement !== "undefined" && quad.image instanceof HTMLVideoElement;
2346
+ const encoder = this.device.createCommandEncoder();
2347
+ const pass = encoder.beginRenderPass({ colorAttachments: [{
2348
+ view: layer.createView(),
2349
+ loadOp: "clear",
2350
+ storeOp: "store",
2351
+ clearValue: {
2352
+ r: 0,
2353
+ g: 0,
2354
+ b: 0,
2355
+ a: 0
2356
+ }
2357
+ }] });
2358
+ if (isExternal) {
2359
+ const external = this.device.importExternalTexture({ source: quad.image });
2360
+ pass.setPipeline(this.pipelines.prepareExternal);
2361
+ pass.setBindGroup(0, this.device.createBindGroup({
2362
+ layout: this.pipelines.prepareExternal.getBindGroupLayout(0),
2363
+ entries: [
2364
+ {
2365
+ binding: 0,
2366
+ resource: external
2367
+ },
2368
+ {
2369
+ binding: 1,
2370
+ resource: this.sampler
2371
+ },
2372
+ {
2373
+ binding: 2,
2374
+ resource: { buffer: uniformBuffer }
2375
+ }
2376
+ ]
2377
+ }));
2378
+ } else {
2379
+ const sw = Math.max(1, iw);
2380
+ const sh = Math.max(1, ih);
2381
+ const staging = this.acquireTexture(sw, sh);
2382
+ this.device.queue.copyExternalImageToTexture({ source: quad.image }, {
2383
+ texture: staging,
2384
+ premultipliedAlpha: true
2385
+ }, {
2386
+ width: sw,
2387
+ height: sh
2388
+ });
2389
+ pass.setPipeline(this.pipelines.prepare2d);
2390
+ pass.setBindGroup(0, this.device.createBindGroup({
2391
+ layout: this.pipelines.prepare2d.getBindGroupLayout(0),
2392
+ entries: [
2393
+ {
2394
+ binding: 0,
2395
+ resource: staging.createView()
2396
+ },
2397
+ {
2398
+ binding: 1,
2399
+ resource: this.sampler
2400
+ },
2401
+ {
2402
+ binding: 2,
2403
+ resource: { buffer: uniformBuffer }
2404
+ }
2405
+ ]
2406
+ }));
2407
+ this.releaseTexture(staging);
2408
+ }
2409
+ pass.draw(3);
2410
+ pass.end();
2411
+ this.device.queue.submit([encoder.finish()]);
2412
+ return layer;
2413
+ }
2414
+ runEffectPass(layer, pass, lw, lh) {
2415
+ switch (pass.kind) {
2416
+ case "color": return this.runColorPass(layer, pass, lw, lh);
2417
+ case "blur": return this.runBlurPasses(layer, pass.radius, lw, lh);
2418
+ case "shadow": {
2419
+ const blurred = this.runBlurPasses(layer, pass.blur, lw, lh, true);
2420
+ const out = this.acquireTexture(lw, lh);
2421
+ const uniforms = new Float32Array(8);
2422
+ uniforms.set(pass.color, 0);
2423
+ uniforms.set([pass.offsetX / lw, pass.offsetY / lh], 4);
2424
+ this.renderFullscreen(this.pipelines.shadow, out, [
2425
+ {
2426
+ binding: 0,
2427
+ resource: layer.createView()
2428
+ },
2429
+ {
2430
+ binding: 1,
2431
+ resource: blurred.createView()
2432
+ },
2433
+ {
2434
+ binding: 2,
2435
+ resource: this.sampler
2436
+ },
2437
+ {
2438
+ binding: 3,
2439
+ resource: { buffer: this.uniformBuffer(uniforms.buffer) }
2440
+ }
2441
+ ]);
2442
+ this.releaseTexture(blurred);
2443
+ this.releaseTexture(layer);
2444
+ return out;
2445
+ }
2446
+ case "lut3d": {
2447
+ const lut = this.luts.get(pass.lutId);
2448
+ if (!lut) return layer;
2449
+ const out = this.acquireTexture(lw, lh);
2450
+ const uniforms = new Float32Array(4);
2451
+ uniforms.set([lut.size, pass.intensity]);
2452
+ this.renderFullscreen(this.pipelines.lut3d, out, [
2453
+ {
2454
+ binding: 0,
2455
+ resource: layer.createView()
2456
+ },
2457
+ {
2458
+ binding: 1,
2459
+ resource: this.sampler
2460
+ },
2461
+ {
2462
+ binding: 2,
2463
+ resource: lut.texture.createView()
2464
+ },
2465
+ {
2466
+ binding: 3,
2467
+ resource: { buffer: this.uniformBuffer(uniforms.buffer) }
2468
+ }
2469
+ ]);
2470
+ this.releaseTexture(layer);
2471
+ return out;
2472
+ }
2473
+ }
2474
+ }
2475
+ runColorPass(layer, pass, lw, lh) {
2476
+ if (pass.ops.length === 0) return layer;
2477
+ const out = this.acquireTexture(lw, lh);
2478
+ const buffer = /* @__PURE__ */ new ArrayBuffer(784);
2479
+ const u32 = new Uint32Array(buffer);
2480
+ const f32 = new Float32Array(buffer);
2481
+ u32[0] = Math.min(16, pass.ops.length);
2482
+ for (let i = 0; i < Math.min(16, pass.ops.length); i++) {
2483
+ const base = (16 + i * 48) / 4;
2484
+ u32[base] = pass.ops[i].kind;
2485
+ for (let p = 0; p < 8; p++) f32[base + 4 + p] = pass.ops[i].params[p] ?? 0;
2486
+ }
2487
+ let curvesTexture = this.identityCurves;
2488
+ if (pass.curves) {
2489
+ curvesTexture = this.acquireTexture(256, 1);
2490
+ const bytes = new Uint8Array(256 * 4);
2491
+ for (let i = 0; i < 256; i++) {
2492
+ bytes[i * 4] = Math.round(pass.curves.r[i] * 255);
2493
+ bytes[i * 4 + 1] = Math.round(pass.curves.g[i] * 255);
2494
+ bytes[i * 4 + 2] = Math.round(pass.curves.b[i] * 255);
2495
+ bytes[i * 4 + 3] = 255;
2496
+ }
2497
+ this.device.queue.writeTexture({ texture: curvesTexture }, bytes, { bytesPerRow: 256 * 4 }, {
2498
+ width: 256,
2499
+ height: 1
2500
+ });
2501
+ }
2502
+ this.renderFullscreen(this.pipelines.color, out, [
2503
+ {
2504
+ binding: 0,
2505
+ resource: layer.createView()
2506
+ },
2507
+ {
2508
+ binding: 1,
2509
+ resource: this.sampler
2510
+ },
2511
+ {
2512
+ binding: 2,
2513
+ resource: { buffer: this.uniformBuffer(buffer) }
2514
+ },
2515
+ {
2516
+ binding: 3,
2517
+ resource: curvesTexture.createView()
2518
+ }
2519
+ ]);
2520
+ if (curvesTexture !== this.identityCurves) this.releaseTexture(curvesTexture);
2521
+ this.releaseTexture(layer);
2522
+ return out;
2523
+ }
2524
+ runBlurPasses(layer, radius, lw, lh, keepInput = false) {
2525
+ if (radius <= 0) return layer;
2526
+ const weights = gaussianKernel(radius);
2527
+ const halfTaps = (weights.length - 1) / 2;
2528
+ const weightsBuffer = this.storageBuffer(weights);
2529
+ const blurUniforms = (dx, dy) => {
2530
+ const buffer = /* @__PURE__ */ new ArrayBuffer(32);
2531
+ const f32 = new Float32Array(buffer);
2532
+ const u32 = new Uint32Array(buffer);
2533
+ f32[0] = dx;
2534
+ f32[1] = dy;
2535
+ f32[2] = 1 / lw;
2536
+ f32[3] = 1 / lh;
2537
+ u32[4] = halfTaps;
2538
+ return this.uniformBuffer(buffer);
2539
+ };
2540
+ const horizontal = this.acquireTexture(lw, lh);
2541
+ this.renderFullscreen(this.pipelines.blur, horizontal, [
2542
+ {
2543
+ binding: 0,
2544
+ resource: layer.createView()
2545
+ },
2546
+ {
2547
+ binding: 1,
2548
+ resource: this.sampler
2549
+ },
2550
+ {
2551
+ binding: 2,
2552
+ resource: { buffer: blurUniforms(1, 0) }
2553
+ },
2554
+ {
2555
+ binding: 3,
2556
+ resource: { buffer: weightsBuffer }
2557
+ }
2558
+ ]);
2559
+ const vertical = this.acquireTexture(lw, lh);
2560
+ this.renderFullscreen(this.pipelines.blur, vertical, [
2561
+ {
2562
+ binding: 0,
2563
+ resource: horizontal.createView()
2564
+ },
2565
+ {
2566
+ binding: 1,
2567
+ resource: this.sampler
2568
+ },
2569
+ {
2570
+ binding: 2,
2571
+ resource: { buffer: blurUniforms(0, 1) }
2572
+ },
2573
+ {
2574
+ binding: 3,
2575
+ resource: { buffer: weightsBuffer }
2576
+ }
2577
+ ]);
2578
+ this.releaseTexture(horizontal);
2579
+ if (!keepInput) this.releaseTexture(layer);
2580
+ return vertical;
2581
+ }
2582
+ compositeFullFrame(layer, options) {
2583
+ const src = this.acc[this.accIndex];
2584
+ const dst = this.acc[1 - this.accIndex];
2585
+ const buffer = /* @__PURE__ */ new ArrayBuffer(64);
2586
+ const f32 = new Float32Array(buffer);
2587
+ const u32 = new Uint32Array(buffer);
2588
+ const inv = options.inverse;
2589
+ f32[0] = inv?.m00 ?? 1;
2590
+ f32[1] = inv?.m01 ?? 0;
2591
+ f32[2] = inv?.m10 ?? 0;
2592
+ f32[3] = inv?.m11 ?? 1;
2593
+ f32[4] = inv?.centerX ?? 0;
2594
+ f32[5] = inv?.centerY ?? 0;
2595
+ f32[6] = options.halfW ?? 0;
2596
+ f32[7] = options.halfH ?? 0;
2597
+ f32[8] = this.width;
2598
+ f32[9] = this.height;
2599
+ f32[10] = options.cornerRadius ?? 0;
2600
+ f32[11] = options.opacity;
2601
+ u32[12] = options.mode;
2602
+ u32[13] = options.identity ? 1 : 0;
2603
+ this.renderFullscreen(this.pipelines.composite, dst, [
2604
+ {
2605
+ binding: 0,
2606
+ resource: src.createView()
2607
+ },
2608
+ {
2609
+ binding: 1,
2610
+ resource: layer.createView()
2611
+ },
2612
+ {
2613
+ binding: 2,
2614
+ resource: this.sampler
2615
+ },
2616
+ {
2617
+ binding: 3,
2618
+ resource: { buffer: this.uniformBuffer(buffer) }
2619
+ }
2620
+ ]);
2621
+ this.accIndex = 1 - this.accIndex;
2622
+ }
2623
+ renderFullscreen(pipeline, target, entries) {
2624
+ const encoder = this.device.createCommandEncoder();
2625
+ const pass = encoder.beginRenderPass({ colorAttachments: [{
2626
+ view: target.createView(),
2627
+ loadOp: "clear",
2628
+ storeOp: "store",
2629
+ clearValue: {
2630
+ r: 0,
2631
+ g: 0,
2632
+ b: 0,
2633
+ a: 0
2634
+ }
2635
+ }] });
2636
+ pass.setPipeline(pipeline);
2637
+ pass.setBindGroup(0, this.device.createBindGroup({
2638
+ layout: pipeline.getBindGroupLayout(0),
2639
+ entries
2640
+ }));
2641
+ pass.draw(3);
2642
+ pass.end();
2643
+ this.device.queue.submit([encoder.finish()]);
2644
+ }
2645
+ };
2646
+ //#endregion
2647
+ export { Canvas2DBackend, ROTATE_HANDLE_OFFSET, WebGPUBackend, applyChrome, applyTextTransform, buildFont, createElementContext, curveToLut, degToRad, drawImageQuad2D, fromCanvasPoint, getElementDisplaySize, getElementNaturalSize, getElementOBB, getElementRenderer, getFitScale, getHandles, getImageSize, getTransformForDisplaySize, getTransitionRenderer, hasUnsupportedEffects, hitTestHandles, hitTestOBB, isWebGPUSupported, layoutCaption, layoutTextBlock, measureWith, planEffects, registerElementRenderer, registerGpuEffectTypes, registerTransitionRenderer, renderFrame, renderFrameWith, toCanvasPoint };