@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/LICENSE +187 -0
- package/README.md +11 -0
- package/dist/index.d.ts +507 -0
- package/dist/index.js +2647 -0
- package/package.json +53 -0
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 };
|