@editframe/elements 0.46.4 → 0.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/elements/EFMedia/BufferedSeekingInput.d.ts +50 -0
- package/dist/elements/EFMedia/BufferedSeekingInput.js +6 -5
- package/dist/elements/EFMedia/BufferedSeekingInput.js.map +1 -1
- package/dist/elements/EFMedia/CachedFetcher.js +23 -33
- package/dist/elements/EFMedia/CachedFetcher.js.map +1 -1
- package/dist/elements/EFMedia/SegmentTransport.d.ts +2 -2
- package/dist/elements/EFMedia/SegmentTransport.js.map +1 -1
- package/dist/elements/EFMedia/shared/ThumbnailExtractor.js +53 -0
- package/dist/elements/EFMedia/shared/ThumbnailExtractor.js.map +1 -1
- package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.js +20 -5
- package/dist/elements/EFMedia/videoTasks/MainVideoInputCache.js.map +1 -1
- package/dist/elements/EFMedia/videoTasks/ScrubInputCache.d.ts +48 -0
- package/dist/elements/EFMedia/videoTasks/ScrubInputCache.js +36 -7
- package/dist/elements/EFMedia/videoTasks/ScrubInputCache.js.map +1 -1
- package/dist/elements/EFMotionBlur.d.ts +134 -0
- package/dist/elements/EFMotionBlur.js +809 -0
- package/dist/elements/EFMotionBlur.js.map +1 -0
- package/dist/elements/EFTemporal.js +1 -2
- package/dist/elements/EFTemporal.js.map +1 -1
- package/dist/elements/EFText.d.ts +20 -0
- package/dist/elements/EFText.js +66 -9
- package/dist/elements/EFText.js.map +1 -1
- package/dist/elements/EFTimegroup.d.ts +12 -0
- package/dist/elements/EFTimegroup.js +43 -4
- package/dist/elements/EFTimegroup.js.map +1 -1
- package/dist/elements/EFVideo.d.ts +26 -0
- package/dist/elements/EFVideo.js +114 -36
- package/dist/elements/EFVideo.js.map +1 -1
- package/dist/elements/SampleBuffer.d.ts +19 -0
- package/dist/elements/updateAnimations.js +49 -3
- package/dist/elements/updateAnimations.js.map +1 -1
- package/dist/gui/EFWorkbench.d.ts +1 -0
- package/dist/gui/EFWorkbench.js +15 -0
- package/dist/gui/EFWorkbench.js.map +1 -1
- package/dist/gui/EFWorkbench.spacebar.js +26 -0
- package/dist/gui/EFWorkbench.spacebar.js.map +1 -0
- package/dist/gui/TWMixin.js +1 -1
- package/dist/gui/TWMixin.js.map +1 -1
- package/dist/gui/timeline/EFTimeline.d.ts +18 -1
- package/dist/gui/timeline/EFTimeline.js +119 -25
- package/dist/gui/timeline/EFTimeline.js.map +1 -1
- package/dist/gui/timeline/timelineStateContext.d.ts +2 -0
- package/dist/gui/timeline/timelineStateContext.js.map +1 -1
- package/dist/gui/timeline/tracks/EFThumbnailStrip.js +14 -8
- package/dist/gui/timeline/tracks/EFThumbnailStrip.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/preview/FrameController.d.ts +22 -1
- package/dist/preview/FrameController.js +26 -5
- package/dist/preview/FrameController.js.map +1 -1
- package/dist/preview/QualityUpgradeScheduler.d.ts +11 -2
- package/dist/preview/QualityUpgradeScheduler.js +31 -21
- package/dist/preview/QualityUpgradeScheduler.js.map +1 -1
- package/dist/preview/renderTimegroupToCanvas.js +4 -0
- package/dist/preview/renderTimegroupToCanvas.js.map +1 -1
- package/dist/preview/renderTimegroupToCanvas.types.d.ts +2 -0
- package/dist/preview/renderTimegroupToVideo.js +3 -0
- package/dist/preview/renderTimegroupToVideo.js.map +1 -1
- package/dist/preview/rendering/serializeTimelineDirect.js +30 -35
- package/dist/preview/rendering/serializeTimelineDirect.js.map +1 -1
- package/dist/style.css +4 -0
- package/dist/utils/LRUCache.js +17 -5
- package/dist/utils/LRUCache.js.map +1 -1
- package/dist/version.js +1 -1
- package/package.json +2 -2
|
@@ -0,0 +1,809 @@
|
|
|
1
|
+
import { getTrackedAnimationsForSubtree } from "./updateAnimations.js";
|
|
2
|
+
|
|
3
|
+
//#region src/elements/EFMotionBlur.ts
|
|
4
|
+
/** Multiplier on rotAmount → rotScale for centered displacement range. */
|
|
5
|
+
const ROT_SCALE_FACTOR = 2;
|
|
6
|
+
/** Multiplier on isotropicAmount → zoomScale. >2 amplifies zoom visibility;
|
|
7
|
+
* higher values tilt the spiral angle toward radial. */
|
|
8
|
+
const ZOOM_SCALE_FACTOR = 3;
|
|
9
|
+
/** Maximum arc displacement in pixels (caps rotAmount). */
|
|
10
|
+
const MAX_ROT_AMOUNT_PX = 50;
|
|
11
|
+
/** Maximum zoom displacement in pixels (caps isotropicAmount). */
|
|
12
|
+
const MAX_ZOOM_AMOUNT_PX = 50;
|
|
13
|
+
/** Maximum translation displacement in pixels. */
|
|
14
|
+
const MAX_TRANS_AMOUNT_PX = 100;
|
|
15
|
+
/** Gaussian sigma for temporal weights: sigma = max(n / SIGMA_DIVISOR, 2). */
|
|
16
|
+
const GAUSSIAN_SIGMA_DIVISOR = 5.5;
|
|
17
|
+
/** Spiral output blur: coefficient on step spacing. Must stay small to
|
|
18
|
+
* preserve zero-displacement center sharpness. */
|
|
19
|
+
const SPIRAL_BLUR_COEFF = .15;
|
|
20
|
+
/** Spiral output blur: hard cap in pixels. */
|
|
21
|
+
const SPIRAL_BLUR_CAP = .8;
|
|
22
|
+
/** Spiral output blur: floor in pixels. */
|
|
23
|
+
const SPIRAL_BLUR_FLOOR = .3;
|
|
24
|
+
/** Translation output blur: coefficient on step spacing. Can be larger than
|
|
25
|
+
* spiral because all pixels shift equally (no center-vs-edge gradient). */
|
|
26
|
+
const TRANS_BLUR_COEFF = .35;
|
|
27
|
+
/** Translation output blur: hard cap in pixels. */
|
|
28
|
+
const TRANS_BLUR_CAP = 1.5;
|
|
29
|
+
/** Translation output blur: floor in pixels. */
|
|
30
|
+
const TRANS_BLUR_FLOOR = .3;
|
|
31
|
+
/** Spiral angle quantization: angles rounded to π/(12*2) ≈ 7.5° steps. */
|
|
32
|
+
const SPIRAL_ANGLE_QUANT = 12;
|
|
33
|
+
/**
|
|
34
|
+
* Extracts the 2D rotation angle (degrees) and uniform scale from a CSS
|
|
35
|
+
* transform string. Handles `matrix(a,b,c,d,tx,ty)`, `rotate(Ndeg)`,
|
|
36
|
+
* `scale(N)`, and `none`.
|
|
37
|
+
*/
|
|
38
|
+
function parseMatrix(transform) {
|
|
39
|
+
if (!transform || transform === "none") return {
|
|
40
|
+
angle: 0,
|
|
41
|
+
scale: 1
|
|
42
|
+
};
|
|
43
|
+
const mm = transform.match(/^matrix\(([^)]+)\)/);
|
|
44
|
+
if (mm) {
|
|
45
|
+
const [a, b] = mm[1].split(",").map(Number);
|
|
46
|
+
const scale = Math.sqrt(a * a + b * b);
|
|
47
|
+
return {
|
|
48
|
+
angle: Math.atan2(b, a) * 180 / Math.PI,
|
|
49
|
+
scale: scale || 1
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const rm = transform.match(/rotate\(([^)]+)\)/);
|
|
53
|
+
if (rm) {
|
|
54
|
+
const v = rm[1].trim();
|
|
55
|
+
return {
|
|
56
|
+
angle: v.endsWith("rad") ? parseFloat(v) * 180 / Math.PI : parseFloat(v),
|
|
57
|
+
scale: 1
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
const sm = transform.match(/^scale\(([^)]+)\)/);
|
|
61
|
+
if (sm) return {
|
|
62
|
+
angle: 0,
|
|
63
|
+
scale: sm[1].split(",").map(Number)[0] ?? 1
|
|
64
|
+
};
|
|
65
|
+
return {
|
|
66
|
+
angle: 0,
|
|
67
|
+
scale: 1
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Reads the rotation angle and scale from an element's current animation state.
|
|
72
|
+
*
|
|
73
|
+
* Prefers direct keyframe interpolation via `getComputedTiming().progress` +
|
|
74
|
+
* `getKeyframes()` — this is synchronous and not subject to Chromium's deferred
|
|
75
|
+
* WAAPI style application (where `getComputedStyle` may lag behind `currentTime`).
|
|
76
|
+
*
|
|
77
|
+
* Falls back to `commitStyles()` + `getComputedStyle` for animations whose keyframes
|
|
78
|
+
* use complex transform lists that our simple interpolation can't handle.
|
|
79
|
+
*/
|
|
80
|
+
function readTransformMatrix(child, anims) {
|
|
81
|
+
for (const anim of anims) {
|
|
82
|
+
const effect = anim.effect;
|
|
83
|
+
if (!(effect instanceof KeyframeEffect)) continue;
|
|
84
|
+
const progress = effect.getComputedTiming().progress;
|
|
85
|
+
if (progress === null || progress === void 0) continue;
|
|
86
|
+
const result$1 = interpolateTransformKeyframes(effect.getKeyframes(), progress);
|
|
87
|
+
if (result$1 !== null) return result$1;
|
|
88
|
+
}
|
|
89
|
+
let committed = false;
|
|
90
|
+
for (const anim of anims) try {
|
|
91
|
+
anim.commitStyles?.();
|
|
92
|
+
committed = true;
|
|
93
|
+
} catch (_) {}
|
|
94
|
+
child.getBoundingClientRect();
|
|
95
|
+
const result = parseMatrix(getComputedStyle(child).transform);
|
|
96
|
+
if (committed) child.style.removeProperty("transform");
|
|
97
|
+
return result;
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Decomposes a CSS transform string into cumulative rotation (degrees) and
|
|
101
|
+
* uniform scale. Handles compound transforms like `scale(0.3) rotate(90deg)`,
|
|
102
|
+
* pure rotations, pure scales, and matrix() values.
|
|
103
|
+
*
|
|
104
|
+
* Multiple rotate() functions are summed (so the orbit pattern
|
|
105
|
+
* `rotate(360deg) translateX(90px) rotate(-360deg)` correctly yields 0°).
|
|
106
|
+
*
|
|
107
|
+
* Returns null only when no rotation or scale function is found and the
|
|
108
|
+
* string is not a matrix.
|
|
109
|
+
*/
|
|
110
|
+
function decomposeKeyframeTransform(transform) {
|
|
111
|
+
if (!transform || transform === "none") return {
|
|
112
|
+
angle: 0,
|
|
113
|
+
scale: 1
|
|
114
|
+
};
|
|
115
|
+
const mm = transform.match(/^matrix\(([^)]+)\)/);
|
|
116
|
+
if (mm) {
|
|
117
|
+
const [a, b] = mm[1].split(",").map(Number);
|
|
118
|
+
const scale = Math.sqrt(a * a + b * b);
|
|
119
|
+
return {
|
|
120
|
+
angle: Math.atan2(b, a) * 180 / Math.PI,
|
|
121
|
+
scale: scale || 1
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
let totalAngle = 0;
|
|
125
|
+
let totalScale = 1;
|
|
126
|
+
let found = false;
|
|
127
|
+
for (const m of transform.matchAll(/rotate\(([^)]+)\)/g)) {
|
|
128
|
+
const v = m[1].trim();
|
|
129
|
+
totalAngle += v.endsWith("rad") ? parseFloat(v) * 180 / Math.PI : parseFloat(v);
|
|
130
|
+
found = true;
|
|
131
|
+
}
|
|
132
|
+
const sm = transform.match(/scale\(([^),]+)(?:,\s*([^)]+))?\)/);
|
|
133
|
+
if (sm) {
|
|
134
|
+
const sx = parseFloat(sm[1]);
|
|
135
|
+
const sy = sm[2] !== void 0 ? parseFloat(sm[2]) : sx;
|
|
136
|
+
if (Math.abs(sx - sy) < .001) {
|
|
137
|
+
totalScale = sx;
|
|
138
|
+
found = true;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
return found ? {
|
|
142
|
+
angle: totalAngle,
|
|
143
|
+
scale: totalScale
|
|
144
|
+
} : null;
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Solves a cubic-bezier curve: given control points (x1,y1,x2,y2) and input
|
|
148
|
+
* parameter `t` (0–1), returns the output value y. Uses Newton-Raphson
|
|
149
|
+
* iteration to invert the x(u) polynomial.
|
|
150
|
+
*/
|
|
151
|
+
function solveCubicBezier(x1, y1, x2, y2, t) {
|
|
152
|
+
if (t <= 0) return 0;
|
|
153
|
+
if (t >= 1) return 1;
|
|
154
|
+
let u = t;
|
|
155
|
+
for (let i = 0; i < 8; i++) {
|
|
156
|
+
const u2$1 = u * u, u3$1 = u2$1 * u;
|
|
157
|
+
const xu = 3 * (1 - u) * (1 - u) * u * x1 + 3 * (1 - u) * u2$1 * x2 + u3$1;
|
|
158
|
+
const dxu = 3 * (1 - u) * (1 - u) * x1 + 6 * (1 - u) * u * (x2 - x1) + 3 * u2$1 * (1 - x2);
|
|
159
|
+
if (Math.abs(dxu) < 1e-9) break;
|
|
160
|
+
u -= (xu - t) / dxu;
|
|
161
|
+
u = Math.max(0, Math.min(1, u));
|
|
162
|
+
}
|
|
163
|
+
const u2 = u * u, u3 = u2 * u;
|
|
164
|
+
return 3 * (1 - u) * (1 - u) * u * y1 + 3 * (1 - u) * u2 * y2 + u3;
|
|
165
|
+
}
|
|
166
|
+
const EASING_KEYWORDS = {
|
|
167
|
+
ease: [
|
|
168
|
+
.25,
|
|
169
|
+
.1,
|
|
170
|
+
.25,
|
|
171
|
+
1
|
|
172
|
+
],
|
|
173
|
+
"ease-in": [
|
|
174
|
+
.42,
|
|
175
|
+
0,
|
|
176
|
+
1,
|
|
177
|
+
1
|
|
178
|
+
],
|
|
179
|
+
"ease-out": [
|
|
180
|
+
0,
|
|
181
|
+
0,
|
|
182
|
+
.58,
|
|
183
|
+
1
|
|
184
|
+
],
|
|
185
|
+
"ease-in-out": [
|
|
186
|
+
.42,
|
|
187
|
+
0,
|
|
188
|
+
.58,
|
|
189
|
+
1
|
|
190
|
+
]
|
|
191
|
+
};
|
|
192
|
+
/**
|
|
193
|
+
* Applies a CSS easing function to a linear parameter t (0–1).
|
|
194
|
+
* Handles keyword easings and `cubic-bezier(x1,y1,x2,y2)`.
|
|
195
|
+
*/
|
|
196
|
+
function applyEasing(easing, t) {
|
|
197
|
+
if (!easing || easing === "linear") return t;
|
|
198
|
+
const kw = EASING_KEYWORDS[easing];
|
|
199
|
+
if (kw) return solveCubicBezier(kw[0], kw[1], kw[2], kw[3], t);
|
|
200
|
+
const cbm = easing.match(/cubic-bezier\(\s*([^,]+),\s*([^,]+),\s*([^,]+),\s*([^)]+)\)/);
|
|
201
|
+
if (cbm) return solveCubicBezier(parseFloat(cbm[1]), parseFloat(cbm[2]), parseFloat(cbm[3]), parseFloat(cbm[4]), t);
|
|
202
|
+
return t;
|
|
203
|
+
}
|
|
204
|
+
/**
|
|
205
|
+
* Interpolates angle (degrees) and scale from a WAAPI keyframe list at the
|
|
206
|
+
* given progress (0–1).
|
|
207
|
+
*
|
|
208
|
+
* Uses the original CSS value strings from the keyframes (not computed matrices)
|
|
209
|
+
* so `rotate(0deg) → rotate(360deg)` interpolates correctly as 0→360°.
|
|
210
|
+
*
|
|
211
|
+
* Handles compound transforms (e.g. `scale(0.3) rotate(90deg)`) by decomposing
|
|
212
|
+
* each keyframe individually, and applies per-keyframe easing so that blur
|
|
213
|
+
* intensity tracks the actual eased velocity rather than the linear one.
|
|
214
|
+
*/
|
|
215
|
+
function interpolateTransformKeyframes(keyframes, progress) {
|
|
216
|
+
const withTransform = keyframes.filter((k) => k.transform != null);
|
|
217
|
+
if (withTransform.length < 2) return null;
|
|
218
|
+
let lo = withTransform[0];
|
|
219
|
+
let hi = withTransform[withTransform.length - 1];
|
|
220
|
+
for (let i = 0; i < withTransform.length - 1; i++) {
|
|
221
|
+
const a = withTransform[i];
|
|
222
|
+
const b = withTransform[i + 1];
|
|
223
|
+
const aOff = typeof a.offset === "number" ? a.offset : i / (withTransform.length - 1);
|
|
224
|
+
const bOff = typeof b.offset === "number" ? b.offset : (i + 1) / (withTransform.length - 1);
|
|
225
|
+
if (progress >= aOff && progress <= bOff) {
|
|
226
|
+
lo = a;
|
|
227
|
+
hi = b;
|
|
228
|
+
break;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const loOff = typeof lo.offset === "number" ? lo.offset : 0;
|
|
232
|
+
const hiOff = typeof hi.offset === "number" ? hi.offset : 1;
|
|
233
|
+
const linearT = hiOff === loOff ? 0 : (progress - loOff) / (hiOff - loOff);
|
|
234
|
+
const t = applyEasing(lo.easing, linearT);
|
|
235
|
+
const loD = decomposeKeyframeTransform(String(lo.transform ?? "none"));
|
|
236
|
+
const hiD = decomposeKeyframeTransform(String(hi.transform ?? "none"));
|
|
237
|
+
if (!loD || !hiD) return null;
|
|
238
|
+
if (loD.angle === 0 && hiD.angle === 0 && loD.scale === 1 && hiD.scale === 1) return null;
|
|
239
|
+
return {
|
|
240
|
+
angle: loD.angle + (hiD.angle - loD.angle) * t,
|
|
241
|
+
scale: loD.scale + (hiD.scale - loD.scale) * t
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
/**
|
|
245
|
+
* Gaussian temporal weights for N blur steps.
|
|
246
|
+
* Soft window (vs equal weights) eliminates the "stack of discrete frames" look.
|
|
247
|
+
*/
|
|
248
|
+
function gaussianWeights(n) {
|
|
249
|
+
if (n <= 1) return [1];
|
|
250
|
+
const mid = (n - 1) / 2;
|
|
251
|
+
const sigma = Math.max(n / GAUSSIAN_SIGMA_DIVISOR, 2);
|
|
252
|
+
return Array.from({ length: n }, (_, i) => Math.exp(-.5 * ((i - mid) / sigma) ** 2));
|
|
253
|
+
}
|
|
254
|
+
/**
|
|
255
|
+
* Maximum distance from a point to any corner of a rectangle.
|
|
256
|
+
*/
|
|
257
|
+
function maxDistToCorner(cx, cy, w, h) {
|
|
258
|
+
let m = 0;
|
|
259
|
+
for (const px of [0, w]) for (const py of [0, h]) {
|
|
260
|
+
const d = Math.hypot(px - cx, py - cy);
|
|
261
|
+
if (d > m) m = d;
|
|
262
|
+
}
|
|
263
|
+
return m || 1;
|
|
264
|
+
}
|
|
265
|
+
/**
|
|
266
|
+
* Generates a canvas displacement map encoding a **spiral** vector field.
|
|
267
|
+
*
|
|
268
|
+
* Each pixel encodes a 2D displacement direction as (R, G) where:
|
|
269
|
+
* R = 128 + vx*127 G = 128 + vy*127
|
|
270
|
+
*
|
|
271
|
+
* The vector at each pixel is:
|
|
272
|
+
* v = sin(spiralAngle) × tangent + cos(spiralAngle) × radial
|
|
273
|
+
*
|
|
274
|
+
* Because tangent ⊥ radial and both have magnitude r/maxR, the combined
|
|
275
|
+
* vector also has magnitude r/maxR regardless of spiralAngle. This means
|
|
276
|
+
* one map + one set of N displaced copies correctly captures simultaneous
|
|
277
|
+
* rotation and zoom as a single spiral smear — no sequential stages needed.
|
|
278
|
+
*
|
|
279
|
+
* spiralAngle = atan2(rotScale, zoomScale):
|
|
280
|
+
* π/2 → pure tangential (rotation only)
|
|
281
|
+
* 0 → pure radial (zoom only)
|
|
282
|
+
* between → spiral
|
|
283
|
+
*/
|
|
284
|
+
function buildSpiralMap(pw, ph, ox, oy, iw, ih, cx, cy, spiralAngle) {
|
|
285
|
+
const ctx = new OffscreenCanvas(pw, ph).getContext("2d");
|
|
286
|
+
const img = ctx.createImageData(pw, ph);
|
|
287
|
+
const maxR = maxDistToCorner(cx, cy, iw, ih);
|
|
288
|
+
const sinA = Math.sin(spiralAngle);
|
|
289
|
+
const cosA = Math.cos(spiralAngle);
|
|
290
|
+
for (let py = 0; py < ph; py++) for (let px = 0; px < pw; px++) {
|
|
291
|
+
const ux = px - ox;
|
|
292
|
+
const uy = py - oy;
|
|
293
|
+
const i = (py * pw + px) * 4;
|
|
294
|
+
const dx = ux - cx;
|
|
295
|
+
const dy = uy - cy;
|
|
296
|
+
const vx = sinA * (-dy / maxR) + cosA * (dx / maxR);
|
|
297
|
+
const vy = sinA * (dx / maxR) + cosA * (dy / maxR);
|
|
298
|
+
img.data[i] = Math.round(128 + vx * 127);
|
|
299
|
+
img.data[i + 1] = Math.round(128 + vy * 127);
|
|
300
|
+
img.data[i + 2] = 128;
|
|
301
|
+
img.data[i + 3] = 255;
|
|
302
|
+
}
|
|
303
|
+
ctx.putImageData(img, 0, 0);
|
|
304
|
+
const c = document.createElement("canvas");
|
|
305
|
+
c.width = pw;
|
|
306
|
+
c.height = ph;
|
|
307
|
+
c.getContext("2d").putImageData(img, 0, 0);
|
|
308
|
+
return c.toDataURL();
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Appends the SVG filter primitives for one temporal displacement blur stage
|
|
312
|
+
* into an existing filter string. Takes `inResult` as input and writes
|
|
313
|
+
* the final blended output to `outResult`.
|
|
314
|
+
*
|
|
315
|
+
* When `centered=true`, steps run from -totalScale/2 → +totalScale/2, placing
|
|
316
|
+
* the un-displaced copy at the center of the range. This is used for rotation
|
|
317
|
+
* blur so the current frame sits in the middle of the smear.
|
|
318
|
+
* When `centered=false` (default), steps run from 0 → totalScale.
|
|
319
|
+
*
|
|
320
|
+
* Produces N copies each displaced by the map at increasing scale,
|
|
321
|
+
* blended with Gaussian temporal weights. A small feGaussianBlur softens slice edges.
|
|
322
|
+
*/
|
|
323
|
+
function appendTemporalBlurStage(xml, prefix, inResult, outResult, steps, totalScale, mapResult, centered = false) {
|
|
324
|
+
const weights = gaussianWeights(steps);
|
|
325
|
+
const stepSpacing = steps > 1 ? Math.abs(totalScale) / (steps - 1) / 2 : 0;
|
|
326
|
+
const outBlur = Math.min(SPIRAL_BLUR_CAP, Math.max(SPIRAL_BLUR_FLOOR, stepSpacing * SPIRAL_BLUR_COEFF));
|
|
327
|
+
for (let i = 0; i < steps; i++) {
|
|
328
|
+
const t = steps > 1 ? i / (steps - 1) : 0;
|
|
329
|
+
const scale = centered ? ((t - .5) * totalScale).toFixed(4) : (t * totalScale).toFixed(4);
|
|
330
|
+
xml += ` <feDisplacementMap in="${inResult}" in2="${mapResult}"
|
|
331
|
+
scale="${scale}" xChannelSelector="R" yChannelSelector="G"
|
|
332
|
+
result="${prefix}s${i}"/>\n`;
|
|
333
|
+
}
|
|
334
|
+
xml += ` <feComposite in="${prefix}s0" in2="${prefix}s0" operator="arithmetic"
|
|
335
|
+
k1="0" k2="1" k3="0" k4="0" result="${prefix}avg0"/>\n`;
|
|
336
|
+
let sumW = weights[0];
|
|
337
|
+
for (let i = 1; i < steps; i++) {
|
|
338
|
+
const sumWNew = sumW + weights[i];
|
|
339
|
+
const k2 = (sumW / sumWNew).toFixed(8);
|
|
340
|
+
const k3 = (weights[i] / sumWNew).toFixed(8);
|
|
341
|
+
xml += ` <feComposite in="${prefix}avg${i - 1}" in2="${prefix}s${i}" operator="arithmetic"
|
|
342
|
+
k1="0" k2="${k2}" k3="${k3}" k4="0" result="${prefix}avg${i}"/>\n`;
|
|
343
|
+
sumW = sumWNew;
|
|
344
|
+
}
|
|
345
|
+
xml += ` <feGaussianBlur in="${prefix}avg${steps - 1}"
|
|
346
|
+
stdDeviation="${outBlur.toFixed(3)}" result="${outResult}"/>\n`;
|
|
347
|
+
return xml;
|
|
348
|
+
}
|
|
349
|
+
/**
|
|
350
|
+
* Appends SVG filter primitives for a directional translation blur using
|
|
351
|
+
* feOffset. Produces N copies of `inResult` shifted along (vx, vy) from
|
|
352
|
+
* 0 → (totalDx, totalDy), blended with Gaussian temporal weights.
|
|
353
|
+
* No wrapper rotation needed — direction is encoded directly as dx/dy offsets.
|
|
354
|
+
*/
|
|
355
|
+
function appendTranslationBlurStage(xml, prefix, inResult, outResult, steps, totalDx, totalDy) {
|
|
356
|
+
const weights = gaussianWeights(steps);
|
|
357
|
+
const sweep = Math.sqrt(totalDx ** 2 + totalDy ** 2);
|
|
358
|
+
const stepSpacing = steps > 1 ? sweep / (steps - 1) : 0;
|
|
359
|
+
const outBlur = Math.min(TRANS_BLUR_CAP, Math.max(TRANS_BLUR_FLOOR, stepSpacing * TRANS_BLUR_COEFF));
|
|
360
|
+
for (let i = 0; i < steps; i++) {
|
|
361
|
+
const t = steps > 1 ? i / (steps - 1) : 0;
|
|
362
|
+
const dx = (t * totalDx).toFixed(3);
|
|
363
|
+
const dy = (t * totalDy).toFixed(3);
|
|
364
|
+
xml += ` <feOffset in="${inResult}" dx="${dx}" dy="${dy}" result="${prefix}s${i}"/>\n`;
|
|
365
|
+
}
|
|
366
|
+
xml += ` <feComposite in="${prefix}s0" in2="${prefix}s0" operator="arithmetic"
|
|
367
|
+
k1="0" k2="1" k3="0" k4="0" result="${prefix}avg0"/>\n`;
|
|
368
|
+
let sumW = weights[0];
|
|
369
|
+
for (let i = 1; i < steps; i++) {
|
|
370
|
+
const sumWNew = sumW + weights[i];
|
|
371
|
+
const k2 = (sumW / sumWNew).toFixed(8);
|
|
372
|
+
const k3 = (weights[i] / sumWNew).toFixed(8);
|
|
373
|
+
xml += ` <feComposite in="${prefix}avg${i - 1}" in2="${prefix}s${i}" operator="arithmetic"
|
|
374
|
+
k1="0" k2="${k2}" k3="${k3}" k4="0" result="${prefix}avg${i}"/>\n`;
|
|
375
|
+
sumW = sumWNew;
|
|
376
|
+
}
|
|
377
|
+
xml += ` <feGaussianBlur in="${prefix}avg${steps - 1}"
|
|
378
|
+
stdDeviation="${outBlur.toFixed(3)}" result="${outResult}"/>\n`;
|
|
379
|
+
return xml;
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Step-count limits for each render quality tier.
|
|
383
|
+
*
|
|
384
|
+
* "preview" — real-time playback in the workbench. Caps steps aggressively
|
|
385
|
+
* to keep the SVG filter primitive count under ~30 per element so
|
|
386
|
+
* imgLoad stays under 2ms for combined blur.
|
|
387
|
+
*
|
|
388
|
+
* "render" — final MP4 export. Uses the full step budget for smooth,
|
|
389
|
+
* artifact-free motion blur at the cost of heavier SVG rendering.
|
|
390
|
+
*/
|
|
391
|
+
const STEP_LIMITS = {
|
|
392
|
+
preview: {
|
|
393
|
+
trans: 16,
|
|
394
|
+
spiral: 16
|
|
395
|
+
},
|
|
396
|
+
render: {
|
|
397
|
+
trans: 31,
|
|
398
|
+
spiral: 50
|
|
399
|
+
}
|
|
400
|
+
};
|
|
401
|
+
function transSteps(sweepPx) {
|
|
402
|
+
if (sweepPx <= 0) return 2;
|
|
403
|
+
const cap = STEP_LIMITS[EFMotionBlur.renderQuality].trans;
|
|
404
|
+
return Math.max(2, Math.min(cap, Math.ceil(sweepPx) + 1));
|
|
405
|
+
}
|
|
406
|
+
function spiralSteps(sweepPx, rotDeg) {
|
|
407
|
+
const cap = STEP_LIMITS[EFMotionBlur.renderQuality].spiral;
|
|
408
|
+
const fromPx = sweepPx > 0 ? Math.ceil(sweepPx / 1) + 1 : 2;
|
|
409
|
+
const fromAngle = rotDeg > 0 ? Math.ceil(rotDeg / .65) + 1 : 2;
|
|
410
|
+
return Math.max(2, Math.min(cap, Math.max(fromPx, fromAngle)));
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* `<ef-motionblur>` applies motion blur to its child for two types of motion:
|
|
414
|
+
*
|
|
415
|
+
* **Translation** — N `feOffset` copies along the velocity vector, Gaussian-blended.
|
|
416
|
+
*
|
|
417
|
+
* **Spiral** (rotation + zoom combined) — N displaced copies using a single
|
|
418
|
+
* spiral vector map that encodes the weighted sum of tangential (rotation) and
|
|
419
|
+
* radial (zoom) components. Because tangent ⊥ radial with equal magnitude,
|
|
420
|
+
* the combined map always has magnitude r/maxR regardless of the rot/zoom ratio.
|
|
421
|
+
* One pass correctly captures simultaneous rotation and scaling as a spiral
|
|
422
|
+
* smear rather than the generic blur that sequential stages produce.
|
|
423
|
+
*/
|
|
424
|
+
const _EFMotionBlurBase = typeof HTMLElement !== "undefined" ? HTMLElement : class {};
|
|
425
|
+
var EFMotionBlur = class extends _EFMotionBlurBase {
|
|
426
|
+
constructor(..._args) {
|
|
427
|
+
super(..._args);
|
|
428
|
+
this._outer = null;
|
|
429
|
+
this._filterEl = null;
|
|
430
|
+
this._svg = null;
|
|
431
|
+
this._filterId = null;
|
|
432
|
+
this._spiralMapURI = null;
|
|
433
|
+
this._lastMapW = 0;
|
|
434
|
+
this._lastMapH = 0;
|
|
435
|
+
this._lastSpiralAngle = NaN;
|
|
436
|
+
this._filterNode = null;
|
|
437
|
+
this._transOffsets = [];
|
|
438
|
+
this._spiralDisplacements = [];
|
|
439
|
+
this._spiralFeImage = null;
|
|
440
|
+
this._spiralBlur = null;
|
|
441
|
+
this._lastStepConfig = "";
|
|
442
|
+
this._computedDx = 0;
|
|
443
|
+
this._computedDy = 0;
|
|
444
|
+
this._computedAngle = 0;
|
|
445
|
+
this._computedDirectionalAmount = 0;
|
|
446
|
+
this._computedRotAmount = 0;
|
|
447
|
+
this._computedRotDeg = 0;
|
|
448
|
+
this._computedIsotropicAmount = 0;
|
|
449
|
+
this._hasExplicitAngle = false;
|
|
450
|
+
this._hasExplicitAmount = false;
|
|
451
|
+
}
|
|
452
|
+
static {
|
|
453
|
+
this.observedAttributes = [
|
|
454
|
+
"angle",
|
|
455
|
+
"amount",
|
|
456
|
+
"shutter-angle",
|
|
457
|
+
"fps",
|
|
458
|
+
"sensitivity",
|
|
459
|
+
"threshold"
|
|
460
|
+
];
|
|
461
|
+
}
|
|
462
|
+
static {
|
|
463
|
+
this.renderQuality = "preview";
|
|
464
|
+
}
|
|
465
|
+
get _computedAmount() {
|
|
466
|
+
return this._computedDirectionalAmount;
|
|
467
|
+
}
|
|
468
|
+
set _computedAmount(v) {
|
|
469
|
+
this._computedDirectionalAmount = v;
|
|
470
|
+
}
|
|
471
|
+
connectedCallback() {
|
|
472
|
+
this._filterId = "ef-mb-" + Math.random().toString(36).slice(2);
|
|
473
|
+
this._inject();
|
|
474
|
+
this._update();
|
|
475
|
+
}
|
|
476
|
+
disconnectedCallback() {}
|
|
477
|
+
attributeChangedCallback(name, _old, newValue) {
|
|
478
|
+
if (name === "angle") this._hasExplicitAngle = newValue !== null;
|
|
479
|
+
if (name === "amount") this._hasExplicitAmount = newValue !== null;
|
|
480
|
+
if (this._outer) this._update();
|
|
481
|
+
}
|
|
482
|
+
get angle() {
|
|
483
|
+
return parseFloat(this.getAttribute("angle") ?? "0");
|
|
484
|
+
}
|
|
485
|
+
set angle(v) {
|
|
486
|
+
this.setAttribute("angle", String(v));
|
|
487
|
+
}
|
|
488
|
+
get amount() {
|
|
489
|
+
return parseFloat(this.getAttribute("amount") ?? "0");
|
|
490
|
+
}
|
|
491
|
+
set amount(v) {
|
|
492
|
+
this.setAttribute("amount", String(v));
|
|
493
|
+
}
|
|
494
|
+
get shutterAngle() {
|
|
495
|
+
return parseFloat(this.getAttribute("shutter-angle") ?? "180");
|
|
496
|
+
}
|
|
497
|
+
set shutterAngle(v) {
|
|
498
|
+
this.setAttribute("shutter-angle", String(v));
|
|
499
|
+
}
|
|
500
|
+
get fps() {
|
|
501
|
+
return parseFloat(this.getAttribute("fps") ?? "30");
|
|
502
|
+
}
|
|
503
|
+
set fps(v) {
|
|
504
|
+
this.setAttribute("fps", String(v));
|
|
505
|
+
}
|
|
506
|
+
get sensitivity() {
|
|
507
|
+
return parseFloat(this.getAttribute("sensitivity") ?? "1");
|
|
508
|
+
}
|
|
509
|
+
set sensitivity(v) {
|
|
510
|
+
this.setAttribute("sensitivity", String(v));
|
|
511
|
+
}
|
|
512
|
+
get threshold() {
|
|
513
|
+
return parseFloat(this.getAttribute("threshold") ?? "0.5");
|
|
514
|
+
}
|
|
515
|
+
set threshold(v) {
|
|
516
|
+
this.setAttribute("threshold", String(v));
|
|
517
|
+
}
|
|
518
|
+
get shutterMs() {
|
|
519
|
+
return this.shutterAngle / 360 * (1e3 / this.fps);
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* One-time DOM setup. Creates the SVG filter host and two wrapper divs
|
|
523
|
+
* (_outer for the SVG definition, _filterEl for the filtered content).
|
|
524
|
+
* The SVG filter is rebuilt dynamically in _update().
|
|
525
|
+
*/
|
|
526
|
+
_inject() {
|
|
527
|
+
const existingOuter = this.querySelector("[data-blur-outer]");
|
|
528
|
+
if (existingOuter) {
|
|
529
|
+
this._outer = existingOuter;
|
|
530
|
+
this._filterEl = existingOuter.querySelector("[data-blur-filter]");
|
|
531
|
+
this._svg = existingOuter.querySelector("svg");
|
|
532
|
+
if (!this._svg) {
|
|
533
|
+
this._svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
534
|
+
this._svg.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none";
|
|
535
|
+
existingOuter.insertBefore(this._svg, this._filterEl);
|
|
536
|
+
}
|
|
537
|
+
this._svg.innerHTML = `<defs><filter id="${this._filterId}"></filter></defs>`;
|
|
538
|
+
this.style.display = "contents";
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
this._svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
542
|
+
this._svg.style.cssText = "position:absolute;width:0;height:0;overflow:hidden;pointer-events:none";
|
|
543
|
+
this._svg.innerHTML = `<defs><filter id="${this._filterId}"></filter></defs>`;
|
|
544
|
+
this._outer = document.createElement("div");
|
|
545
|
+
this._outer.dataset.blurOuter = "";
|
|
546
|
+
this._outer.dataset.efStaticStyle = "display:block;transform:none;transform-origin:center center";
|
|
547
|
+
this._outer.style.transformOrigin = "center center";
|
|
548
|
+
this._filterEl = document.createElement("div");
|
|
549
|
+
this._filterEl.dataset.blurFilter = "";
|
|
550
|
+
this._filterEl.style.transformOrigin = "center center";
|
|
551
|
+
while (this.firstChild) this._filterEl.appendChild(this.firstChild);
|
|
552
|
+
this._outer.appendChild(this._svg);
|
|
553
|
+
this._outer.appendChild(this._filterEl);
|
|
554
|
+
this.appendChild(this._outer);
|
|
555
|
+
this.style.display = "contents";
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Rebuilds wrapper transforms and the SVG filter. Called on attribute changes
|
|
559
|
+
* and after each sample().
|
|
560
|
+
*
|
|
561
|
+
* The SVG contains up to two filter stages:
|
|
562
|
+
* 1. feOffset×N — directional (translation) blur
|
|
563
|
+
* 2. Spiral (feImage + feDisplacementMap×N + feComposite blend + feGaussianBlur)
|
|
564
|
+
* — combined rotation + zoom in one pass via a spiral vector map.
|
|
565
|
+
*
|
|
566
|
+
* On first call (or when step counts change), builds the full SVG filter DOM.
|
|
567
|
+
* On subsequent calls, updates only the attributes that change per frame.
|
|
568
|
+
*/
|
|
569
|
+
_update() {
|
|
570
|
+
if (!this._outer || !this._filterEl || !this._svg) return;
|
|
571
|
+
this._outer.style.transform = "none";
|
|
572
|
+
this._outer.style.display = "block";
|
|
573
|
+
this._filterEl.style.transform = "none";
|
|
574
|
+
this._filterEl.style.display = "block";
|
|
575
|
+
const dx = this._hasExplicitAmount ? this.amount * Math.cos(this.angle * Math.PI / 180) : this._computedDx;
|
|
576
|
+
const dy = this._hasExplicitAmount ? this.amount * Math.sin(this.angle * Math.PI / 180) : this._computedDy;
|
|
577
|
+
const directionalMag = Math.sqrt(dx * dx + dy * dy);
|
|
578
|
+
const rotAmount = this._hasExplicitAmount ? 0 : this._computedRotAmount;
|
|
579
|
+
const isotropicAmount = this._hasExplicitAmount ? 0 : this._computedIsotropicAmount;
|
|
580
|
+
const anyBlur = directionalMag > this.threshold || Math.abs(rotAmount) > this.threshold || isotropicAmount > this.threshold;
|
|
581
|
+
this._filterEl.style.display = "block";
|
|
582
|
+
if (!anyBlur) {
|
|
583
|
+
this._filterEl.style.filter = "none";
|
|
584
|
+
this._filterNode = null;
|
|
585
|
+
this._svg.innerHTML = `<defs><filter id="${this._filterId}"></filter></defs>`;
|
|
586
|
+
if (this._outer) this._outer._cachedFilterSVG = void 0;
|
|
587
|
+
return;
|
|
588
|
+
}
|
|
589
|
+
const rotScale = rotAmount * ROT_SCALE_FACTOR;
|
|
590
|
+
const zoomScale = isotropicAmount * ZOOM_SCALE_FACTOR;
|
|
591
|
+
const combinedScale = Math.sqrt(rotScale ** 2 + zoomScale ** 2);
|
|
592
|
+
const spiralAngle = Math.atan2(rotScale, zoomScale);
|
|
593
|
+
const anySpiralBlur = combinedScale > this.threshold;
|
|
594
|
+
const child = this._filterEl.firstElementChild ?? this._filterEl;
|
|
595
|
+
const childRect = child.getBoundingClientRect();
|
|
596
|
+
const filterElRect = this._filterEl.getBoundingClientRect();
|
|
597
|
+
const filterElLogicalW = this._filterEl.offsetWidth || 1;
|
|
598
|
+
const ancestorScale = filterElRect.width / filterElLogicalW || 1;
|
|
599
|
+
const iw = child.offsetWidth || childRect.width || 100;
|
|
600
|
+
const ih = child.offsetHeight || childRect.height || 100;
|
|
601
|
+
const bw = childRect.width / ancestorScale;
|
|
602
|
+
const bh = childRect.height / ancestorScale;
|
|
603
|
+
const cx = ((childRect.left + childRect.right) / 2 - filterElRect.left) / ancestorScale;
|
|
604
|
+
const cy = ((childRect.top + childRect.bottom) / 2 - filterElRect.top) / ancestorScale;
|
|
605
|
+
const maxBBox = Math.ceil(Math.hypot(iw, ih));
|
|
606
|
+
const mapPad = 64;
|
|
607
|
+
const mapSide = maxBBox + 2 * mapPad;
|
|
608
|
+
const mapContentOx = mapPad + Math.round((maxBBox - iw) / 2);
|
|
609
|
+
const mapContentOy = mapPad + Math.round((maxBBox - ih) / 2);
|
|
610
|
+
this._ensureMaps(mapSide, mapSide, mapContentOx, mapContentOy, iw, ih, spiralAngle);
|
|
611
|
+
const margin = Math.ceil(Math.hypot(bw, bh) / 2) + Math.ceil(combinedScale / 2) + Math.ceil(Math.abs(dx)) + Math.ceil(Math.abs(dy)) + 64;
|
|
612
|
+
const filterX = Math.floor(cx - margin);
|
|
613
|
+
const filterY = Math.floor(cy - margin);
|
|
614
|
+
const filterW = Math.ceil(margin * 2);
|
|
615
|
+
const filterH = Math.ceil(margin * 2);
|
|
616
|
+
const mapImgX = Math.round(cx - mapSide / 2);
|
|
617
|
+
const mapImgY = Math.round(cy - mapSide / 2);
|
|
618
|
+
const transStepCount = directionalMag > this.threshold ? transSteps(directionalMag) : 0;
|
|
619
|
+
const spiralStepCount = anySpiralBlur ? spiralSteps(combinedScale, this._computedRotDeg) : 0;
|
|
620
|
+
const stepConfig = `${transStepCount},${spiralStepCount}`;
|
|
621
|
+
if (stepConfig !== this._lastStepConfig || !this._filterNode) {
|
|
622
|
+
this._lastStepConfig = stepConfig;
|
|
623
|
+
this._buildFilterDOM(transStepCount, spiralStepCount, mapSide);
|
|
624
|
+
}
|
|
625
|
+
const f = this._filterNode;
|
|
626
|
+
f.setAttribute("x", String(filterX));
|
|
627
|
+
f.setAttribute("y", String(filterY));
|
|
628
|
+
f.setAttribute("width", String(filterW));
|
|
629
|
+
f.setAttribute("height", String(filterH));
|
|
630
|
+
if (transStepCount > 0) for (let i = 0; i < this._transOffsets.length; i++) {
|
|
631
|
+
const t = this._transOffsets.length > 1 ? i / (this._transOffsets.length - 1) : 0;
|
|
632
|
+
this._transOffsets[i].setAttribute("dx", String(t * dx));
|
|
633
|
+
this._transOffsets[i].setAttribute("dy", String(t * dy));
|
|
634
|
+
}
|
|
635
|
+
if (spiralStepCount > 0 && this._spiralFeImage) {
|
|
636
|
+
this._spiralFeImage.setAttribute("x", String(mapImgX));
|
|
637
|
+
this._spiralFeImage.setAttribute("y", String(mapImgY));
|
|
638
|
+
if (this._spiralMapURI) this._spiralFeImage.setAttribute("href", this._spiralMapURI);
|
|
639
|
+
for (let i = 0; i < this._spiralDisplacements.length; i++) {
|
|
640
|
+
const t = this._spiralDisplacements.length > 1 ? i / (this._spiralDisplacements.length - 1) : 0;
|
|
641
|
+
this._spiralDisplacements[i].setAttribute("scale", String((t - .5) * combinedScale));
|
|
642
|
+
}
|
|
643
|
+
const nSpiral = this._spiralDisplacements.length;
|
|
644
|
+
const spiralSpacing = nSpiral > 1 ? combinedScale / (nSpiral - 1) / 2 : 0;
|
|
645
|
+
this._spiralBlur?.setAttribute("stdDeviation", String(Math.min(SPIRAL_BLUR_CAP, Math.max(SPIRAL_BLUR_FLOOR, spiralSpacing * SPIRAL_BLUR_COEFF))));
|
|
646
|
+
}
|
|
647
|
+
this._filterEl.style.filter = `url(#${this._filterId})`;
|
|
648
|
+
this._refreshSerializationCache();
|
|
649
|
+
}
|
|
650
|
+
/**
|
|
651
|
+
* Refreshes the serialization cache on _outer. The serializer reads this
|
|
652
|
+
* instead of calling XMLSerializer on the SVG defs, which is important
|
|
653
|
+
* when rotation/zoom displacement maps embed large base64 data URIs.
|
|
654
|
+
*/
|
|
655
|
+
_refreshSerializationCache() {
|
|
656
|
+
if (!this._filterNode || !this._outer) return;
|
|
657
|
+
this._outer._cachedFilterSVG = new XMLSerializer().serializeToString(this._filterNode);
|
|
658
|
+
}
|
|
659
|
+
/**
|
|
660
|
+
* Builds the full SVG filter DOM tree. Called once when step configuration changes.
|
|
661
|
+
* Stores references to mutable elements for fast per-frame attribute updates.
|
|
662
|
+
*/
|
|
663
|
+
_buildFilterDOM(transStepCount, spiralStepCount, mapSide) {
|
|
664
|
+
let xml = `<filter id="${this._filterId}" filterUnits="userSpaceOnUse" primitiveUnits="userSpaceOnUse" color-interpolation-filters="sRGB">\n`;
|
|
665
|
+
let lastResult = "SourceGraphic";
|
|
666
|
+
if (transStepCount > 0) {
|
|
667
|
+
xml = appendTranslationBlurStage(xml, "t", lastResult, "transblur", transStepCount, 0, 0);
|
|
668
|
+
lastResult = "transblur";
|
|
669
|
+
}
|
|
670
|
+
if (spiralStepCount > 0 && this._spiralMapURI) {
|
|
671
|
+
xml += ` <feImage href="${this._spiralMapURI}" preserveAspectRatio="none" width="${mapSide}" height="${mapSide}" result="spiralmap"/>\n`;
|
|
672
|
+
xml = appendTemporalBlurStage(xml, "sp", lastResult, "spiralblur", spiralStepCount, 0, "spiralmap", true);
|
|
673
|
+
lastResult = "spiralblur";
|
|
674
|
+
}
|
|
675
|
+
if (lastResult === "SourceGraphic") xml += ` <feComposite in="SourceGraphic" in2="SourceGraphic" operator="arithmetic" k1="0" k2="1" k3="0" k4="0"/>\n`;
|
|
676
|
+
xml += `</filter>`;
|
|
677
|
+
this._svg.innerHTML = `<defs>${xml}</defs>`;
|
|
678
|
+
this._filterNode = this._svg.querySelector("filter");
|
|
679
|
+
this._transOffsets = Array.from(this._svg.querySelectorAll("feOffset"));
|
|
680
|
+
this._spiralFeImage = this._svg.querySelector("feImage[result=\"spiralmap\"]");
|
|
681
|
+
this._spiralDisplacements = Array.from(this._svg.querySelectorAll("feDisplacementMap[result^=\"sps\"]"));
|
|
682
|
+
this._spiralBlur = this._svg.querySelector("feGaussianBlur[result=\"spiralblur\"]");
|
|
683
|
+
}
|
|
684
|
+
/**
|
|
685
|
+
* Generates or refreshes the spiral displacement map.
|
|
686
|
+
* Cached by element size and spiral angle (quantized to ~5°).
|
|
687
|
+
* The feImage href is updated in the fast path when the map URI changes.
|
|
688
|
+
*/
|
|
689
|
+
_ensureMaps(pw, ph, ox, oy, iw, ih, spiralAngle) {
|
|
690
|
+
const riw = Math.round(iw) || 100;
|
|
691
|
+
const rih = Math.round(ih) || 100;
|
|
692
|
+
const quantizedAngle = Math.round(spiralAngle * SPIRAL_ANGLE_QUANT) / SPIRAL_ANGLE_QUANT;
|
|
693
|
+
const sizeChanged = Math.abs(riw - this._lastMapW) > 4 || Math.abs(rih - this._lastMapH) > 4;
|
|
694
|
+
const angleChanged = Math.abs(quantizedAngle - this._lastSpiralAngle) > .001 || isNaN(this._lastSpiralAngle);
|
|
695
|
+
if (!sizeChanged && !angleChanged) return;
|
|
696
|
+
this._lastMapW = riw;
|
|
697
|
+
this._lastMapH = rih;
|
|
698
|
+
this._lastSpiralAngle = quantizedAngle;
|
|
699
|
+
this._spiralMapURI = buildSpiralMap(pw, ph, ox, oy, riw, rih, riw / 2, rih / 2, quantizedAngle);
|
|
700
|
+
}
|
|
701
|
+
/**
|
|
702
|
+
* Samples the child's current position and transform, computing blur amounts.
|
|
703
|
+
* Called by `updateAnimations` after all animation times are settled.
|
|
704
|
+
*/
|
|
705
|
+
sample(_timestamp = performance.now()) {
|
|
706
|
+
if (this._hasExplicitAngle && this._hasExplicitAmount) return;
|
|
707
|
+
if (!this._filterEl) return;
|
|
708
|
+
const child = this._filterEl.firstElementChild ?? this._filterEl;
|
|
709
|
+
const det = this._computeDeterministicTransform(child);
|
|
710
|
+
if (det !== null) this._applyTransform(det.dx, det.dy, det.dAngle, det.dScale, det.halfDiag);
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Deterministic two-seek measurement. Seeks all animations back by shutterMs,
|
|
714
|
+
* reads positions and transforms, restores. Returns null when no seekable
|
|
715
|
+
* animations exist or currentTime < shutterMs.
|
|
716
|
+
*/
|
|
717
|
+
_computeDeterministicTransform(child) {
|
|
718
|
+
const tracked = getTrackedAnimationsForSubtree(child);
|
|
719
|
+
const animations = tracked.length > 0 ? tracked : child.getAnimations({ subtree: true });
|
|
720
|
+
if (animations.length === 0) return null;
|
|
721
|
+
const seekable = [];
|
|
722
|
+
const currentTimes = [];
|
|
723
|
+
for (const anim of animations) {
|
|
724
|
+
const t = anim.currentTime;
|
|
725
|
+
if (t !== null && typeof t === "number") {
|
|
726
|
+
seekable.push(anim);
|
|
727
|
+
currentTimes.push(t);
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
if (seekable.length === 0) return null;
|
|
731
|
+
const shutterMs = this.shutterMs;
|
|
732
|
+
if (currentTimes.every((t) => t < shutterMs)) return null;
|
|
733
|
+
if (seekable.every((anim) => {
|
|
734
|
+
const timing = anim.effect?.getComputedTiming();
|
|
735
|
+
if (!timing) return false;
|
|
736
|
+
const { activeDuration, progress } = timing;
|
|
737
|
+
return progress === null && typeof activeDuration === "number" && isFinite(activeDuration);
|
|
738
|
+
})) return null;
|
|
739
|
+
const outerWas = this._outer.style.transform;
|
|
740
|
+
const filterWas = this._filterEl.style.transform;
|
|
741
|
+
this._outer.style.transform = "none";
|
|
742
|
+
this._filterEl.style.transform = "none";
|
|
743
|
+
const filterElLogicalW = this._filterEl.offsetWidth || 1;
|
|
744
|
+
const ancestorScale = this._filterEl.getBoundingClientRect().width / filterElLogicalW || 1;
|
|
745
|
+
const r1 = child.getBoundingClientRect();
|
|
746
|
+
const cx1 = r1.left + r1.width / 2;
|
|
747
|
+
const cy1 = r1.top + r1.height / 2;
|
|
748
|
+
const halfDiag = Math.sqrt((child.offsetWidth || 1) ** 2 + (child.offsetHeight || 1) ** 2) / 2;
|
|
749
|
+
const { angle: angle1, scale: scale1 } = readTransformMatrix(child, seekable);
|
|
750
|
+
for (let i = 0; i < seekable.length; i++) {
|
|
751
|
+
const activeDuration = (seekable[i].effect?.getComputedTiming())?.activeDuration;
|
|
752
|
+
const endTime = typeof activeDuration === "number" && isFinite(activeDuration) ? activeDuration : Infinity;
|
|
753
|
+
seekable[i].currentTime = Math.min(Math.max(0, currentTimes[i] - shutterMs), endTime);
|
|
754
|
+
}
|
|
755
|
+
const r0 = child.getBoundingClientRect();
|
|
756
|
+
const cx0 = r0.left + r0.width / 2;
|
|
757
|
+
const cy0 = r0.top + r0.height / 2;
|
|
758
|
+
const { angle: angle0, scale: scale0 } = readTransformMatrix(child, seekable);
|
|
759
|
+
for (let i = 0; i < seekable.length; i++) seekable[i].currentTime = currentTimes[i];
|
|
760
|
+
this._outer.style.transform = outerWas;
|
|
761
|
+
this._filterEl.style.transform = filterWas;
|
|
762
|
+
return {
|
|
763
|
+
dx: (cx1 - cx0) / ancestorScale,
|
|
764
|
+
dy: (cy1 - cy0) / ancestorScale,
|
|
765
|
+
dAngle: angle1 - angle0,
|
|
766
|
+
dScale: scale1 - scale0,
|
|
767
|
+
halfDiag
|
|
768
|
+
};
|
|
769
|
+
}
|
|
770
|
+
/**
|
|
771
|
+
* Converts measured deltas to blur amounts and calls _update() when changed.
|
|
772
|
+
*
|
|
773
|
+
* Translation → directional box blur amount + angle.
|
|
774
|
+
* Rotation → arc displacement amount (signed: + = CCW, − = CW).
|
|
775
|
+
* Arc length at half-diagonal radius: R × |Δθ_rad|.
|
|
776
|
+
* Stored signed so the displacement map is applied in the
|
|
777
|
+
* correct tangential direction.
|
|
778
|
+
* Scale → radial displacement amount: |Δscale| × half-diagonal.
|
|
779
|
+
*/
|
|
780
|
+
_applyTransform(dx, dy, dAngle, dScale, halfDiag) {
|
|
781
|
+
const s = this.sensitivity;
|
|
782
|
+
const mag = Math.sqrt(dx ** 2 + dy ** 2);
|
|
783
|
+
const scaledMag = Math.min(mag * s, MAX_TRANS_AMOUNT_PX);
|
|
784
|
+
const newDx = mag < this.threshold ? 0 : dx / mag * scaledMag;
|
|
785
|
+
const newDy = mag < this.threshold ? 0 : dy / mag * scaledMag;
|
|
786
|
+
const newDir = mag < this.threshold ? 0 : scaledMag;
|
|
787
|
+
const newAngle = mag < this.threshold ? this._computedAngle : Math.atan2(dy, dx) * 180 / Math.PI;
|
|
788
|
+
const dAngleRad = dAngle * Math.PI / 180;
|
|
789
|
+
const arcLen = halfDiag * Math.abs(dAngleRad) * s;
|
|
790
|
+
const newRot = Math.min(arcLen, MAX_ROT_AMOUNT_PX) * (dAngle > 0 ? -1 : 1);
|
|
791
|
+
const newRotDeg = Math.abs(dAngle);
|
|
792
|
+
const rawZoom = Math.abs(dScale) * halfDiag * s;
|
|
793
|
+
const newZoom = rawZoom < this.threshold ? 0 : Math.min(rawZoom, MAX_ZOOM_AMOUNT_PX);
|
|
794
|
+
if (!(newDx !== this._computedDx || newDy !== this._computedDy || newDir !== this._computedDirectionalAmount || newAngle !== this._computedAngle || newRot !== this._computedRotAmount || newRotDeg !== this._computedRotDeg || newZoom !== this._computedIsotropicAmount)) return;
|
|
795
|
+
this._computedDx = newDx;
|
|
796
|
+
this._computedDy = newDy;
|
|
797
|
+
this._computedDirectionalAmount = newDir;
|
|
798
|
+
this._computedAngle = newAngle;
|
|
799
|
+
this._computedRotAmount = newRot;
|
|
800
|
+
this._computedRotDeg = newRotDeg;
|
|
801
|
+
this._computedIsotropicAmount = newZoom;
|
|
802
|
+
this._update();
|
|
803
|
+
}
|
|
804
|
+
};
|
|
805
|
+
if (typeof customElements !== "undefined") customElements.define("ef-motionblur", EFMotionBlur);
|
|
806
|
+
|
|
807
|
+
//#endregion
|
|
808
|
+
export { EFMotionBlur };
|
|
809
|
+
//# sourceMappingURL=EFMotionBlur.js.map
|