fix(registry): make motion-blur match After Effects shutter-based blur (#4008)

* fix(registry): make motion-blur match After Effects shutter-based blur

The motion-blur catalog snippet estimated speed from the x/y delta
between consecutive timeline updates and drew three one-sided ghost
copies with decaying alpha plus a Gaussian on top. After Effects does
temporal supersampling: each frame integrates the layer over a shutter
window (shutter angle, shutter phase, samples per frame), producing a
symmetric box smear of length speed x shutter time.

The snippet now does the same inside the page. After every timeline
update a deferred pass seeks the GSAP timeline to N sub-frame times
across the shutter window (events suppressed), reads each target's x/y,
restores the frame time, and drives an SVG filter that averages N
feOffset copies with equal weight (feComposite arithmetic, 1/N, sRGB).
Defaults follow AE: shutterAngle 180, shutterPhase -90, samplesPerFrame
16; fps comes from the render config, then the root data-fps, then 30.
Displacements are mapped through the inverse of the element's own
scale/rotation; percent x/y values are resolved to px; SVG targets use
their bbox; an existing inline filter is kept and the blur appended to
it. The pass runs in a microtask rather than inside the tracker tween's
onUpdate, because seeking GSAP from within its own render reads stale
tween state and suppresses the composition's callbacks on forward
seeks. The heuristic options blurScale/blurMax/stretchScale/stretchMax
are removed.

Reference: an AE-algorithm reconstruction of the same composition
rendered without the component at an effective 960 fps and averaged
over the 16 sub-frames of the 180 degree window per output frame.
Against it (1920x1080, 30 fps, solid + text translating linear then
power2.inOut): old mean PSNR 53.2 dB (min 26.7), SSIM 0.9930; new mean
PSNR 72.4 dB (min 52.6), SSIM 0.9998. The 10-90 edge ramp at 66.7
px/frame is 26.8 px in the reference, 27.5 px new, 14.5/6.3 px
(one-sided) old; at the ease peak 79.3 vs 79.5 vs 7.9/17.7 px, with
smear centres matching to 0.1 px.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(registry): drop render-config fps probe from motion-blur and refresh its docs

The snippet resolved fps by reconstructing the renderer's private seek
config key so the compiler's inline-script guard would not strip it. A
public catalog snippet must not depend on that key, so fps is now the
explicit option, then the root data-fps, then 30; renders with an fps
override pass fps explicitly. The catalog page's Usage, How it works and
Options sections still described the old velocity heuristic and are
rewritten for the shutter model. The 1/N weight keeps full precision.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

* fix(registry): match the After Effects reference export's shutter window

The shutter model landed on this branch was built against a reconstruction
of After Effects' algorithm, not against After Effects. A real 1920x1080 /
30 fps export of translating text disagrees with it on three points, all
measurable off the export itself.

The window is two frames wide, not half a frame. On the measured frame the
outermost duplicate on the trailing side sits 128 px back while the previous
frame is 125.8 px back, and the outermost leading duplicate sits 189 px ahead
while the next frame is 184.9 px ahead: the window ends land on the
neighbouring frames. Defaults become shutterAngle 720, shutterPhase -360.

Duplicates sit on the sub-interval boundaries, both ends included, not at
sub-interval centres. The staircase across a stroke steps every 15.6 px on a
125.8 px displacement and every 23.0 px on a 184.9 px one, i.e. an eighth of
a frame, with the run closing exactly on the neighbouring frame's position.
samplesPerFrame now means sub-intervals of the window, so there are N+1
copies at k/N rather than N copies at (k+0.5)/N.

The frame-time instance is drawn sharp over the smear. At full ink the core
bounding box is the whole word at every frame and total ink rises to 1.44x
the unblurred frame's, neither of which an average can produce. A final
feComposite over of SourceGraphic reproduces it. Each duplicate stays at
1/N: the staircase plateaus step by 0.063 +- 0.002 of the sharp text
intensity, flat, with no taper toward the window edges, and triangle
weighting scores 1.6 dB worse against the export.

Verified by replaying the export's translating section as a composition
whose sprite is the export's own rest-frame ink and whose eight legs follow
its own measured per-frame positions, rendered at 1920x1080 / 30 fps and
compared frame by frame over the text band. Mean PSNR against the export
rises from 24.19 dB (min 17.07) to 32.51 dB (min 27.57) across 72 frames,
and from 19.83 dB (min 17.07) to 32.03 dB (min 27.57) across the 26 frames
that actually move. Ghost extents match to a few px and the rendered
staircase plateaus match the export's to two decimal places. The export's
later scale, skew, flip and 3D rotation sections are out of reach here:
integrating anything but translation inside the shutter is engine work
tracked in issue #4010.

The filter region now clamps its bounds through zero. It was derived from
the duplicate offsets alone, which was correct while the output was nothing
but duplicates; the sharp instance draws at offset zero, so a window sitting
entirely on one side of the frame time - any shutterPhase above 0 or below
-shutterAngle - produced a region starting inside the element's own box and
cut the instance off. The defaults straddle the frame time and were never
affected, so the measured numbers above are unchanged.

Co-Authored-By: Miguel Ángel <miguel.sierra@heygen.com>

---------

Co-authored-by: Miguel Ángel <miguel.sierra@heygen.com>
This commit is contained in:
miga-heygen
2026-09-17 00:51:03 +00:00
committed by GitHub
co-authored by Miguel Ángel
parent 462f9db73d
commit 9aa60a844f
12 changed files with 1273 additions and 641 deletions
+291 -176
View File
@@ -1,6 +1,6 @@
---
title: "Motion Blur"
description: "Velocity-driven motion blur — samples element position each frame and applies a one-sided SVG feGaussianBlur ghost trail proportional to speed"
description: "After Effects-style motion blur — integrates each element over a shutter window (shutter angle, phase, samples per frame) by sampling the GSAP timeline at sub-frame times and averaging equal-weight SVG copies along the real trajectory"
---
import { InstallCommand } from "/snippets/install-command.jsx";
@@ -24,50 +24,158 @@ That writes one file: `compositions/components/motion-blur.html`.
```html
<!--
Motion Blur — velocity-driven directional motion blur.
Motion Blur — After Effects-style shutter-based motion blur.
Usage: paste this snippet into your composition, then call
attachMotionBlur() with any element animated by your GSAP timeline.
The snippet hooks into the timeline's onUpdate callback, tracks the
GSAP x/y position of each target frame-by-frame, and applies:
1. Ghost copies of the element at increasing backward offsets with
decreasing opacity — inherently one-sided, no forward blur component.
2. A small Gaussian blur at the current position so the element looks
in-motion (blurry) rather than sharp on top of the trail.
3. An optional scaleX/Y stretch in the direction of travel (off by
default; enable via stretchMax > 0 if you want the effect).
How it works — the same model After Effects uses for the layer Motion Blur
switch (temporal supersampling), synthesised inside the page because the
renderer captures one instant per frame with no shutter:
Both effects clear automatically when the element decelerates to rest.
1. After every timeline update (each rendered frame), the snippet seeks
the timeline to `samplesPerFrame` sub-frame times spread evenly across
the shutter window, reads each target's GSAP x/y at every sample, then
restores the frame time. Seeks run with events suppressed, so no user
callbacks fire and the DOM ends up exactly where the frame left it.
2. An SVG filter stacks one copy of the element per sample (feOffset to
the sampled position relative to the current one) and adds them at
equal weight (feComposite arithmetic, 1/N each, sRGB space). Result:
a box smear along the actual trajectory whose length is speed ×
shutter time, with no decaying one-sided trail. The tails — where
fewer copies overlap — step down evenly at 1/N per copy; wherever the
element travels less than its own size the copies pile up past 1 and
clamp, so the middle of the smear is solid rather than a plateau.
3. The element itself is then composited over that smear, sharp and at
full opacity. No single duplicate ever exceeds 1/N, so a moving edge
resolves as a staircase of ghosts behind a solid frame-time instance.
4. When the sampled positions collapse to the current one (element at
rest), the filter is removed so the element renders sharp.
Shutter window (AE semantics, per frame at time t, frame interval 1/fps):
shutterTime = shutterAngle / 360 / fps
windowStart = t + shutterPhase / 360 / fps
copy k = windowStart + k / N × shutterTime, k = 0..N (N+1 copies)
N counts sub-intervals, so both ends of the window carry a copy.
Defaults (shutterAngle 720, shutterPhase -360) integrate [t - 1/fps, t + 1/fps]:
a solid moving at 2000 px/s at 30 fps smears 133 px, centred on its position.
These defaults are measured, not assumed. On a 1920×1080 / 30 fps After Effects
export of translating text, the outermost duplicate on the trailing side sits
exactly at the previous frame's position and the outermost leading duplicate
exactly at the next frame's, with 8 evenly spaced duplicates in between on each
side (window = 2 frames = 720°, phase -360°, 16 sub-intervals). Reading the ghost
staircase across a stroke gives steps of 0.063 ± 0.002 of the sharp text
intensity — a flat 1/16 per duplicate, with no taper toward the window edges
(triangle weighting scores 1.6 dB worse against the same export). The sharp
instance on top is what keeps total ink above the unblurred frame's.
Requirements:
- Elements must be animated via GSAP x/y (transform), not left/top.
- Call attachMotionBlur() AFTER defining all tweens, before
window.__timelines registration.
- Elements must be animated via GSAP x/y (transform), not left/top. The
element's own scale/rotation is compensated; skew, 3D rotation and
transformed ancestors are not.
- The host must render the timeline for the blur to update — HyperFrames
seeks every frame. A fresh paused timeline that is never seeked shows no
blur on its first frame.
- Call attachMotionBlur() AFTER defining all tweens (the timeline's final
duration must be known), before window.__timelines registration.
- GSAP must be loaded before this snippet executes.
API:
attachMotionBlur(selector, timeline, options?)
Options:
blurScale — directional blur per px/s of velocity (default 0.008)
blurMax — max blur radius on the motion axis in px (default 20)
stretchScale — scaleX/Y added per px/s (default 0.0002)
stretchMax — max stretch factor above 1.0 (default 0, disabled)
axis — "x" | "y" | "both" (default "both")
shutterAngle — degrees of the frame interval the shutter is open
(default 720 = two frames, as measured off the AE
reference; 360 = one frame, 0 disables)
shutterPhase — degrees offset of the window start from the frame
time (default -360 = centred on the frame, one frame
back, like the AE reference)
samplesPerFrame — sub-intervals of the shutter window, so N+1 duplicates
each at 1/N opacity (default 16, max 64)
fps — composition frame rate (default: the root's data-fps,
else 30). Pass it explicitly when rendering with an
fps override (`hyperframes render --fps`).
axis — "x" | "y" | "both" (default "both")
-->
<script>
(function () {
if (!window._hfMbUid) window._hfMbUid = 0;
// Shutter length is measured in frames, so the snippet must know the
// composition frame rate: explicit option, else the root's data-fps, else 30.
function resolveFps(optionFps) {
var explicit = Number(optionFps);
if (explicit > 0) return explicit;
var root = document.querySelector("[data-composition-id][data-fps]");
var rootFps = root ? Number(root.getAttribute("data-fps")) : 0;
if (rootFps > 0) return rootFps;
return 30;
}
function numOption(value, fallback) {
return value !== undefined ? Number(value) : fallback;
}
// objectBoundingBox percentage for a px offset plus padding.
function regionPercent(offset, size, padPercent) {
return ((offset / size) * 100 + padPercent).toFixed(2) + "%";
}
window.attachMotionBlur = function (selector, tl, opts) {
opts = opts || {};
var blurScale = opts.blurScale !== undefined ? opts.blurScale : 0.008;
var blurMax = opts.blurMax !== undefined ? opts.blurMax : 20;
var stretchScale = opts.stretchScale !== undefined ? opts.stretchScale : 0.0002;
var stretchMax = opts.stretchMax !== undefined ? opts.stretchMax : 0;
var shutterAngle = numOption(opts.shutterAngle, 720);
var shutterPhase = numOption(opts.shutterPhase, -360);
var requestedSamples = numOption(opts.samplesPerFrame, 16);
var samples = Number.isFinite(requestedSamples)
? Math.max(2, Math.min(64, Math.round(requestedSamples)))
: 16;
var axis = opts.axis || "both";
var fps = resolveFps(opts.fps);
var useX = axis !== "y";
var useY = axis !== "x";
// Untransformed box size; SVG elements have no offsetWidth, so use the
// same bbox GSAP uses to resolve their percent transforms.
function boxSize(el, horizontal) {
var box = typeof el.offsetWidth === "number" ? el : el.getBBox ? el.getBBox() : null;
var size = box
? horizontal
? box.offsetWidth || box.width
: box.offsetHeight || box.height
: 0;
return size || el.getBoundingClientRect()[horizontal ? "width" : "height"] || 1;
}
// GSAP x/y may carry a % unit and xPercent/yPercent is a separate term;
// resolve both against the element's own box so samples are in px.
function readAxis(el, prop, percentProp, size) {
var px = parseFloat(gsap.getProperty(el, prop, "px")) || 0;
var percent = parseFloat(gsap.getProperty(el, percentProp)) || 0;
return px + (percent * size) / 100;
}
function readPosition(el) {
return {
x: useX ? readAxis(el, "x", "xPercent", boxSize(el, true)) : 0,
y: useY ? readAxis(el, "y", "yPercent", boxSize(el, false)) : 0,
};
}
// CSS filters run in the element's pre-transform space while GSAP x/y are
// post-transform, so a displacement is mapped through the inverse of the
// element's own scale/rotation. Ancestor transforms are not compensated.
function toFilterSpace(el, dx, dy) {
var sx = parseFloat(gsap.getProperty(el, "scaleX")) || 1;
var sy = parseFloat(gsap.getProperty(el, "scaleY")) || 1;
var theta = ((parseFloat(gsap.getProperty(el, "rotation")) || 0) * Math.PI) / 180;
var cos = Math.cos(theta);
var sin = Math.sin(theta);
return { dx: (dx * cos + dy * sin) / sx, dy: (-dx * sin + dy * cos) / sy };
}
var items = Array.isArray(selector) ? selector : [selector];
var targets = items.reduce(function (acc, s) {
@@ -81,15 +189,17 @@ That writes one file: `compositions/components/motion-blur.html`.
return acc;
}, []);
// One SVG filter per target. Ghost copies placed behind → inherently one-sided.
// Small top Gaussian makes element look blurry at current position, not sharp.
var ns = "http://www.w3.org/2000/svg";
var GHOST_CFG = [
{ frac: 0.25, slope: 0.55 },
{ frac: 0.55, slope: 0.28 },
{ frac: 1.0, slope: 0.1 },
];
// `samples` counts sub-intervals of the shutter window, so there is one copy at
// every interval boundary — both ends of the window included — and each carries
// 1/samples of the source. That is what the reference export shows: the outermost
// copy sits exactly one frame away from the frame time, not half a step short.
var copies = samples + 1;
var weight = String(1 / samples);
// One SVG filter per target: N+1 offset copies of SourceGraphic added at 1/N
// each, then the element itself over the top. Accumulating in sRGB matches
// AE's default (non-linear) 8-bpc working space.
var state = targets.map(function (el) {
var uid = "hf-mb-" + window._hfMbUid++;
var svg = document.createElementNS(ns, "svg");
@@ -97,70 +207,147 @@ That writes one file: `compositions/components/motion-blur.html`.
var filter = document.createElementNS(ns, "filter");
filter.id = uid;
filter.setAttribute("x", "-110%");
filter.setAttribute("y", "-25%");
filter.setAttribute("width", "260%");
filter.setAttribute("height", "150%");
filter.setAttribute("color-interpolation-filters", "sRGB");
var ghosts = GHOST_CFG.map(function (cfg, gi) {
var offsets = [];
for (var k = 0; k < copies; k++) {
var feOff = document.createElementNS(ns, "feOffset");
feOff.setAttribute("in", "SourceGraphic");
feOff.setAttribute("dx", "0");
feOff.setAttribute("dy", "0");
feOff.setAttribute("result", "go" + gi);
var feGB = document.createElementNS(ns, "feGaussianBlur");
feGB.setAttribute("in", "go" + gi);
feGB.setAttribute("stdDeviation", "0 0");
feGB.setAttribute("result", "gb" + gi);
var feCT = document.createElementNS(ns, "feComponentTransfer");
feCT.setAttribute("in", "gb" + gi);
feCT.setAttribute("result", "gf" + gi);
var feFuncA = document.createElementNS(ns, "feFuncA");
feFuncA.setAttribute("type", "linear");
feFuncA.setAttribute("slope", String(cfg.slope));
feCT.appendChild(feFuncA);
feOff.setAttribute("result", "s" + k);
filter.appendChild(feOff);
filter.appendChild(feGB);
filter.appendChild(feCT);
return { feOff: feOff, feGB: feGB, frac: cfg.frac };
});
var feTopBlur = document.createElementNS(ns, "feGaussianBlur");
feTopBlur.setAttribute("in", "SourceGraphic");
feTopBlur.setAttribute("stdDeviation", "0 0");
feTopBlur.setAttribute("result", "top");
filter.appendChild(feTopBlur);
var feMerge = document.createElementNS(ns, "feMerge");
for (var mi = GHOST_CFG.length - 1; mi >= 0; mi--) {
var mn = document.createElementNS(ns, "feMergeNode");
mn.setAttribute("in", "gf" + mi);
feMerge.appendChild(mn);
offsets.push(feOff);
}
var mnTop = document.createElementNS(ns, "feMergeNode");
mnTop.setAttribute("in", "top");
feMerge.appendChild(mnTop);
filter.appendChild(feMerge);
// Pairwise accumulation: the first composite scales both inputs by 1/N, every
// later one adds the next copy at 1/N to the running sum. The N+1 weights total
// (N+1)/N, so an element wider than its own smear saturates through the middle
// and the filter clamps there. That is the saturation the reference shows, and
// the sharp instance covers the same region regardless.
var accumulated = "s0";
for (var c = 1; c < copies; c++) {
var feComp = document.createElementNS(ns, "feComposite");
feComp.setAttribute("operator", "arithmetic");
feComp.setAttribute("in", accumulated);
feComp.setAttribute("in2", "s" + c);
feComp.setAttribute("k1", "0");
feComp.setAttribute("k2", c === 1 ? weight : "1");
feComp.setAttribute("k3", weight);
feComp.setAttribute("k4", "0");
accumulated = "a" + c;
feComp.setAttribute("result", accumulated);
filter.appendChild(feComp);
}
// The frame-time instance is drawn sharp, at full opacity, over the smear.
// Every blurred copy stays at 1/N, so nothing but the instance the frame is
// actually at reads as solid. Unnamed, so it is the filter output.
var feOver = document.createElementNS(ns, "feComposite");
feOver.setAttribute("operator", "over");
feOver.setAttribute("in", "SourceGraphic");
feOver.setAttribute("in2", accumulated);
filter.appendChild(feOver);
svg.appendChild(filter);
document.body.appendChild(svg);
return {
el: el,
ghosts: ghosts,
feTopBlur: feTopBlur,
filterId: uid,
prevX: parseFloat(gsap.getProperty(el, "x")) || 0,
prevY: parseFloat(gsap.getProperty(el, "y")) || 0,
prevTime: tl.time(),
};
return { el: el, offsets: offsets, filter: filter, filterId: uid, xs: [], ys: [] };
});
// tl.eventCallback("onUpdate") is not available in the HyperFrames renderer —
// the runtime proxies the timeline object. Tween onUpdate fires on every seek.
// The element may carry its own inline filter (authored or tweened by
// GSAP); the blur is appended to that chain and removed from it, never
// written over it.
var OWN_FILTER = /\s*url\(["']?#hf-mb-\d+["']?\)/g;
function baseFilter(el) {
return el.style.filter.replace(OWN_FILTER, "").trim();
}
function clearBlur(s) {
s.el.style.filter = baseFilter(s.el);
}
var scheduled = false;
function applyShutter() {
var shutterTime = shutterAngle / 360 / fps;
if (!(shutterTime > 0)) {
state.forEach(clearBlur);
return;
}
var t0 = tl.time();
var duration = tl.duration();
var windowStart = t0 + shutterPhase / 360 / fps;
// Sample the real trajectory: seek to each sub-frame time with events
// suppressed (so no tween callbacks, including this tracker's, fire),
// read positions, then restore the frame time.
try {
for (var k = 0; k < copies; k++) {
var tk = windowStart + (k / samples) * shutterTime;
tl.time(Math.min(duration, Math.max(0, tk)), true);
state.forEach(function (s) {
var pos = readPosition(s.el);
s.xs[k] = pos.x;
s.ys[k] = pos.y;
});
}
} finally {
tl.time(t0, true);
}
state.forEach(function (s) {
var current = readPosition(s.el);
var minDx = Infinity;
var maxDx = -Infinity;
var minDy = Infinity;
var maxDy = -Infinity;
for (var i = 0; i < copies; i++) {
var d = toFilterSpace(s.el, s.xs[i] - current.x, s.ys[i] - current.y);
var dx = d.dx;
var dy = d.dy;
s.offsets[i].setAttribute("dx", dx.toFixed(3));
s.offsets[i].setAttribute("dy", dy.toFixed(3));
if (dx < minDx) minDx = dx;
if (dx > maxDx) maxDx = dx;
if (dy < minDy) minDy = dy;
if (dy > maxDy) maxDy = dy;
}
// Below half a pixel of smear the average is indistinguishable from
// the source — render sharp instead of paying for the filter.
if (maxDx - minDx < 0.5 && maxDy - minDy < 0.5) {
clearBlur(s);
return;
}
// Grow the filter region to cover every copy AND the sharp instance the
// final composite draws at offset 0 (objectBoundingBox units, 10% padding
// so anti-aliased edges are not clipped). Clamping the bounds through 0
// matters whenever the window sits entirely on one side of the frame time
// — any shutterPhase above 0 or below -shutterAngle — because then no copy
// is at 0 and a region derived from the copies alone cuts the instance off.
var x0 = Math.min(0, minDx);
var x1 = Math.max(0, maxDx);
var y0 = Math.min(0, minDy);
var y1 = Math.max(0, maxDy);
var w = boxSize(s.el, true);
var h = boxSize(s.el, false);
s.filter.setAttribute("x", regionPercent(x0, w, -10));
s.filter.setAttribute("width", regionPercent(x1 - x0, w, 120));
s.filter.setAttribute("y", regionPercent(y0, h, -10));
s.filter.setAttribute("height", regionPercent(y1 - y0, h, 120));
var base = baseFilter(s.el);
s.el.style.filter = (base ? base + " " : "") + "url(#" + s.filterId + ")";
});
}
// tl.eventCallback("onUpdate") is unavailable under the HyperFrames runtime proxy, so a
// tracker tween's onUpdate fires on every seek. Sampling is deferred to a microtask:
// seeking GSAP from inside its own render reads stale tween state, while the microtask
// runs after the seek returns and before the frame is captured or painted.
var _proxy = { t: 0 };
tl.to(
_proxy,
@@ -169,88 +356,11 @@ That writes one file: `compositions/components/motion-blur.html`.
duration: Math.max(tl.duration(), 0.1),
ease: "none",
onUpdate: function () {
var time = tl.time();
state.forEach(function (s) {
var x = parseFloat(gsap.getProperty(s.el, "x")) || 0;
var y = parseFloat(gsap.getProperty(s.el, "y")) || 0;
var dt = time - s.prevTime;
if (dt > 0.0005) {
var vx = axis !== "y" ? (x - s.prevX) / dt : 0;
var vy = axis !== "x" ? (y - s.prevY) / dt : 0;
var bx = Math.min(Math.abs(vx) * blurScale, blurMax);
var by = Math.min(Math.abs(vy) * blurScale, blurMax);
var bxFinal = axis !== "y" ? bx : Math.max(by * 0.08, 0.4);
var byFinal = axis !== "x" ? by : Math.max(bx * 0.08, 0.4);
var active = bx > 0.3 || by > 0.3;
if (active) {
s.el.style.filter = "url(#" + s.filterId + ")";
s.ghosts.forEach(function (g) {
var dx = axis !== "y" ? (vx >= 0 ? -bxFinal * g.frac : bxFinal * g.frac) : 0;
var dy = axis !== "x" ? (vy >= 0 ? -byFinal * g.frac : byFinal * g.frac) : 0;
g.feOff.setAttribute("dx", dx.toFixed(2));
g.feOff.setAttribute("dy", dy.toFixed(2));
var gbx =
axis !== "y"
? (bxFinal * g.frac * 0.5).toFixed(2)
: Math.max(byFinal * g.frac * 0.04, 0.4).toFixed(2);
var gby =
axis !== "x"
? (byFinal * g.frac * 0.5).toFixed(2)
: Math.max(bxFinal * g.frac * 0.04, 0.4).toFixed(2);
g.feGB.setAttribute("stdDeviation", gbx + " " + gby);
});
s.feTopBlur.setAttribute(
"stdDeviation",
(bxFinal * 0.15).toFixed(2) + " " + (byFinal * 0.15).toFixed(2),
);
if (stretchMax > 0) {
var sx = 1 + Math.min(Math.abs(vx) * stretchScale, stretchMax);
var sy = 1 + Math.min(Math.abs(vy) * stretchScale, stretchMax);
var ox = axis !== "y" ? (vx >= 0 ? "100% 50%" : "0% 50%") : "50% 50%";
var oy = axis !== "x" ? (vy >= 0 ? "50% 100%" : "50% 0%") : "50% 50%";
gsap.set(s.el, {
scaleX: axis !== "y" ? sx : 1,
scaleY: axis !== "x" ? sy : 1,
transformOrigin: axis === "x" ? ox : axis === "y" ? oy : "50% 50%",
});
}
} else {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
if (stretchMax > 0) {
gsap.set(s.el, { scaleX: 1, scaleY: 1, transformOrigin: "50% 50%" });
}
}
s.prevX = x;
s.prevY = y;
s.prevTime = time;
} else if (dt < -0.0005) {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
if (stretchMax > 0) {
gsap.set(s.el, { scaleX: 1, scaleY: 1, transformOrigin: "50% 50%" });
}
s.prevX = x;
s.prevY = y;
s.prevTime = time;
}
// dt ≈ 0: double-fire — skip.
if (scheduled) return;
scheduled = true;
Promise.resolve().then(function () {
scheduled = false;
applyShutter();
});
},
},
@@ -273,7 +383,7 @@ That writes one file: `compositions/components/motion-blur.html`.
// Call AFTER tweens, BEFORE window.__timelines registration.
// attachMotionBlur adds a tracking tween with onUpdate — must be called after
// tl.set()/tl.to() have established the final timeline duration.
attachMotionBlur("#my-box", tl, { axis: "x" });
attachMotionBlur("#my-box", tl, { shutterAngle: 720, samplesPerFrame: 16 });
window.__timelines = window.__timelines || {};
window.__timelines["my-composition"] = tl;
@@ -291,8 +401,9 @@ Paste the snippet into your composition, then call `attachMotionBlur()` after yo
tl.set(document.body, {}, DATA_DURATION);
attachMotionBlur("#my-box", tl, {
axis: "x", // "x" | "y" | "both"
blurMax: 40, // max blur radius in px (default 20)
shutterAngle: 720, // degrees of the frame interval the shutter is open
samplesPerFrame: 16, // sub-intervals of the window; 17 duplicates at 1/16 each
axis: "x", // "x" | "y" | "both"
});
window.__timelines = window.__timelines || {};
@@ -301,26 +412,30 @@ window.__timelines["my-composition"] = tl;
## How it works
Each target element gets its own SVG filter. On every timeline seek, `attachMotionBlur` samples the element's GSAP `x`/`y` position, computes velocity, and drives three SVG filter primitives:
This is the After Effects layer Motion Blur model — temporal supersampling — synthesised inside the page, because the renderer captures one instant per frame with no shutter.
1. **Ghost copies** — three faded, blurred copies of the element placed behind it at increasing offsets proportional to speed. Inherently one-sided: no forward blur.
2. **Top blur** — a small symmetric Gaussian at the current position so the element looks in-motion rather than crisp on top of the trail.
1. **Sample the trajectory** — after every timeline seek, the snippet seeks the timeline (events suppressed) to one sub-frame time per window boundary, reads each target's GSAP `x`/`y`, then restores the frame time. The window is `shutterAngle / 360 / fps` seconds long and starts `shutterPhase / 360 / fps` seconds from the frame time; `samplesPerFrame` divides it into that many sub-intervals, so there are `samplesPerFrame + 1` samples and both ends of the window carry one.
2. **Add the duplicates** — each target gets an SVG filter that stacks one `feOffset` copy of the element per sample at its sampled position and adds them at equal weight (`feComposite` arithmetic, 1/N each, sRGB). A solid translating at speed `v` becomes a box smear of length `v × shutterTime`, centred on its instantaneous position — no one-sided trail and no Gaussian halo. No single duplicate exceeds `1/N`, so the tails step down evenly; where the element travels less than its own size the copies overlap past 1 and clamp, leaving the middle solid rather than a plateau.
3. **Composite the sharp instance on top** — the element is then drawn over the smear at full opacity, so the position the frame is actually at stays crisp and only the duplicates are faint. This is the single largest difference from a plain average, and the one that makes the result read like After Effects rather than like a long exposure.
4. **Rest is sharp** — when the sampled positions collapse onto the current one, the filter is removed.
Blur scales linearly with velocity up to `blurMax`. Both the ghost trail and top blur clear automatically when the element decelerates to rest.
The defaults come from measurement, not from the After Effects UI's nominal values. On a 1920×1080 / 30 fps export of translating text, the outermost duplicate on each side sits exactly one frame away from the frame time, with 8 evenly spaced duplicates between it and the centre — a 720° window at −360° phase, divided into 16. The staircase across a stroke steps by 0.063 ± 0.002 of the sharp text intensity per duplicate: flat 1/16, no taper toward the window edges. Replaying that export through this component scores 32.5 dB mean PSNR against it (min 27.6 over the 72 translating frames), against 24.2 dB (min 17.1) for the previous half-frame centred window.
Copies follow the real trajectory, so eased and curved motion smear correctly. The element's own scale and rotation are compensated when mapping the displacements into filter space; skew, 3D rotation and transformed ancestors are not. Only translation is integrated — rotation, scale or opacity changes within the shutter window are not blurred.
## Options
| Option | Default | Description |
| --- | --- | --- |
| `axis` | `"both"` | Motion axis — `"x"`, `"y"`, or `"both"` |
| `blurScale` | `0.008` | Blur per px/s of velocity |
| `blurMax` | `20` | Max blur radius on the motion axis (px) |
| `stretchScale` | `0.0002` | scaleX/Y added per px/s (requires `stretchMax > 0`) |
| `stretchMax` | `0` | Max stretch above 1.0 — disabled by default |
| `shutterAngle` | `720` | Degrees of the frame interval the shutter is open. `720` spans two frames, the width measured off the After Effects reference; `360` spans one; `0` disables the blur |
| `shutterPhase` | `-360` | Degrees offset of the window start from the frame time. `-360` starts the window one frame back, which with a 720° angle centres it on the frame |
| `samplesPerFrame` | `16` | Sub-intervals of the shutter window (2–64), so `N + 1` duplicates at `1/N` opacity each. More sub-intervals close the gaps between duplicates at high speed at a higher filter cost |
| `fps` | root `data-fps`, else `30` | Composition frame rate. Pass it explicitly when rendering with an fps override (`hyperframes render --fps`) |
| `axis` | `"both"` | Motion axis sampled — `"x"`, `"y"`, or `"both"` |
{/* hf:generated-footer */}
Tagged `effect` `motion-blur` `velocity` `animation` `physics`.
Tagged `effect` `motion-blur` `shutter` `after-effects` `velocity` `animation`.
## Related topics
+4 -3
View File
@@ -3340,13 +3340,14 @@
"name": "motion-blur",
"type": "component",
"title": "Motion Blur",
"description": "Velocity-driven motion blur — samples element position each frame and applies a one-sided SVG feGaussianBlur ghost trail proportional to speed",
"description": "After Effects-style motion blur — integrates each element over a shutter window (shutter angle, phase, samples per frame) by sampling the GSAP timeline at sub-frame times and averaging equal-weight SVG copies along the real trajectory",
"tags": [
"effect",
"motion-blur",
"shutter",
"after-effects",
"velocity",
"animation",
"physics"
"animation"
],
"href": "/catalog/components/motion-blur",
"preview": "https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/motion-blur.png"
File diff suppressed because one or more lines are too long
@@ -0,0 +1,17 @@
{
"source": "After Effects export, 1920x1080, 30 fps, bold text translating along x, no other transforms",
"howMeasured": "Frame 5 of the export. The frame-time instance renders sharp at full opacity, so the ink > 0.97 bounding box tracks the word exactly; the duplicates show up as a staircase of plateaus across a vertical stroke. Rows 470 / 540 / 600 agree.",
"frame": 5,
"trailingDisplacementPx": 125.8,
"leadingDisplacementPx": 184.9,
"comment:displacements": "Core-centre travel from frame 4 to 5 and from 5 to 6. Measured ghost reach past the core is 128 px trailing and 189 px leading (both include the anti-aliased edge), i.e. the window ends land on the neighbouring frames' positions.",
"trailingCopyEdgesPx": [531, 546, 562, 578, 594, 609, 625, 640],
"sharpEdgePx": 656,
"comment:edges": "Rising edges of the leading stroke's duplicates on the trailing tail, row 470, outermost first; the sharp instance's edge closes the run. Mean spacing 15.6 px against a 125.8 px frame displacement = 1/8 frame per duplicate.",
"subIntervalsPerWindow": 16,
"windowFramesBefore": 1,
"windowFramesAfter": 1,
"copyOpacity": 0.0625,
"measuredPlateauIncrements": [0.063, 0.065, 0.063, 0.063, 0.063],
"comment:opacity": "Plateau heights across the trailing staircase, as a fraction of the sharp text intensity: 0.07 0.13 0.20 0.26 0.32 0.38. Flat, not tapered toward the window edge, and no duplicate approaches full opacity."
}
@@ -0,0 +1,247 @@
// @vitest-environment happy-dom
import { readFileSync } from "node:fs";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach, describe, expect, it } from "vitest";
import reference from "./__fixtures__/motion-blur-ae-reference.json";
const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), "../../../..");
function readRepoFile(relativePath: string): string {
return readFileSync(resolve(repoRoot, relativePath), "utf8");
}
const snippetHtml = readRepoFile("registry/components/motion-blur/motion-blur.html");
/** First line of the snippet's IIFE body, which runs to the `};` closing it at the same indent. */
const BODY_FIRST_LINE = "if (!window._hfMbUid) window._hfMbUid = 0;";
const SNIPPET_INDENT = " ".repeat(4);
const INLINED_INDENT = " ".repeat(10);
/**
* The snippet's IIFE body, leading whitespace dropped so the installable snippet and its inlined
* copies compare regardless of how deeply each one nests it.
*/
function snippetBody(source: string, indent: string): string {
const start = source.indexOf(indent + BODY_FIRST_LINE);
const endMarker = `\n${indent}};`;
const end = source.indexOf(endMarker, start);
if (start < 0 || end < 0) {
throw new Error(`could not locate the snippet body at indent ${indent.length}`);
}
return source
.slice(start, end + endMarker.length)
.split("\n")
.map((line) => line.trimStart())
.join("\n");
}
const FPS = 30;
/** Timeline time of the frame under test, and a duration long enough that no seek is clamped. */
const FRAME_TIME_S = 5;
const DURATION_S = 10;
const WORD_WIDTH = 1046;
const WORD_HEIGHT = 193;
/** The slice of GSAP's timeline API the snippet drives; `time()` reads, `time(value)` seeks. */
interface Timeline {
time(value?: number, suppressEvents?: boolean): number | Timeline;
duration(): number;
to(target: unknown, vars: { onUpdate?: () => void }): Timeline;
}
/**
* The reference's own trajectory around the measured frame: a parabola through the core centres of
* frames 4, 5 and 6, with frame 5 at t = 0. Offsets are relative to the frame-time position, so the
* shutter window's ends must land on exactly the trailing and leading displacements the fixture
* records — which is the whole claim being pinned.
*/
function offsetAtFrames(df: number): number {
const back = -reference.trailingDisplacementPx;
const fwd = reference.leadingDisplacementPx;
return ((fwd + back) / 2) * df * df + ((fwd - back) / 2) * df;
}
function installSnippet(): void {
const body = snippetHtml.slice(
snippetHtml.indexOf("<script>") + "<script>".length,
snippetHtml.indexOf("</script>"),
);
new Function(body)();
}
function makeTimeline(): { tl: Timeline; currentTime: () => number; fire: () => void } {
let now = FRAME_TIME_S;
let onUpdate: (() => void) | undefined;
const tl: Timeline = {
time(value?: number) {
if (value === undefined) return now;
now = value;
return tl;
},
duration: () => DURATION_S,
to(_target, vars) {
onUpdate = vars.onUpdate;
return tl;
},
};
return { tl, currentTime: () => now, fire: () => onUpdate?.() };
}
function installGsap(currentTime: () => number, trajectory: (df: number) => number): void {
(globalThis as unknown as { gsap: unknown }).gsap = {
getProperty(_el: Element, prop: string) {
if (prop === "x") return `${trajectory((currentTime() - FRAME_TIME_S) * FPS)}px`;
if (prop === "scaleX" || prop === "scaleY") return 1;
return 0;
},
};
}
async function attach(
options: Record<string, unknown>,
trajectory: (df: number) => number = offsetAtFrames,
): Promise<Element> {
const el = document.createElement("div");
el.id = "word";
Object.defineProperty(el, "offsetWidth", { value: WORD_WIDTH });
Object.defineProperty(el, "offsetHeight", { value: WORD_HEIGHT });
document.body.appendChild(el);
const { tl, currentTime, fire } = makeTimeline();
installGsap(currentTime, trajectory);
installSnippet();
(
window as unknown as { attachMotionBlur: (s: string, t: Timeline, o: unknown) => void }
).attachMotionBlur("#word", tl, options);
fire();
await Promise.resolve();
const filter = document.querySelector("filter");
if (!filter) throw new Error("motion-blur filter was not created");
return filter;
}
/** Horizontal offset of every duplicate, in window order. */
function copyOffsets(filter: Element): number[] {
return [...filter.querySelectorAll("feOffset")].map((node) => Number(node.getAttribute("dx")));
}
/** The duplicates sitting at the two ends of the shutter window. */
function windowEdges(offsets: number[]): { trailing: number; leading: number } {
const [trailing] = offsets;
const leading = offsets.at(-1);
if (trailing === undefined || leading === undefined) {
throw new Error("the filter carries no duplicates");
}
return { trailing, leading };
}
/** Gap between each pair of neighbouring duplicates. */
function copyPitches(offsets: number[]): number[] {
const pitches: number[] = [];
offsets.forEach((offset, index) => {
const previous = offsets[index - 1];
if (previous !== undefined) pitches.push(offset - previous);
});
return pitches;
}
afterEach(() => {
document.body.innerHTML = "";
});
describe("motion-blur snippet copies", () => {
// The demo and the example composition have to inline the snippet — a catalog demo is a single
// self-contained file and cannot import one. That makes three copies of the same shutter model, so
// the copies are asserted equal here rather than left to drift silently.
it.each([
"registry/components/motion-blur/demo.html",
"registry/examples/motion-blur/index.html",
])("%s inlines the installable snippet verbatim", (relativePath) => {
expect(snippetBody(readRepoFile(relativePath), INLINED_INDENT)).toBe(
snippetBody(snippetHtml, SNIPPET_INDENT),
);
});
});
describe("motion-blur shutter matches the After Effects reference", () => {
it("opens the shutter over the frame before and the frame after, at the measured sub-interval count", async () => {
const offsets = copyOffsets(await attach({ fps: FPS }));
const { trailing, leading } = windowEdges(offsets);
expect(offsets).toHaveLength(reference.subIntervalsPerWindow + 1);
expect(trailing).toBeCloseTo(-reference.trailingDisplacementPx, 1);
expect(leading).toBeCloseTo(reference.leadingDisplacementPx, 1);
});
it("spaces the duplicates at the measured staircase pitch", async () => {
// The reference's staircase is even (15, 16, 16, 15, 16, 16, 16 px across a 125.8 px frame
// displacement), so a constant-velocity trajectory has to come out evenly spaced at 1/8 frame.
const speed = reference.trailingDisplacementPx;
const offsets = copyOffsets(await attach({ fps: FPS }, (df) => speed * df));
const expected = speed / (reference.subIntervalsPerWindow / 2);
for (const pitch of copyPitches(offsets)) expect(pitch).toBeCloseTo(expected, 3);
});
it("gives every duplicate the measured 1/16 opacity and none full opacity", async () => {
const filter = await attach({ fps: FPS });
const weights = [...filter.querySelectorAll("feComposite[operator='arithmetic']")].map((node) =>
Number(node.getAttribute("k3")),
);
expect(weights).toHaveLength(reference.subIntervalsPerWindow);
for (const weight of weights) expect(weight).toBeCloseTo(reference.copyOpacity, 6);
// Flat, not tapered: the reference's plateau increments are all the same height.
expect(new Set(weights).size).toBe(1);
});
it("accumulates every duplicate exactly once, in one unbroken chain", async () => {
const filter = await attach({ fps: FPS });
const adds = [...filter.querySelectorAll("feComposite[operator='arithmetic']")];
const weight = 1 / reference.subIntervalsPerWindow;
// The first composite is the only one that has to scale BOTH inputs, because it is the
// only one whose `in` is a raw duplicate rather than the running sum. Getting its k2
// wrong lets one duplicate through at full weight — the exact thing the reference rules out.
expect(adds[0]?.getAttribute("in")).toBe("s0");
expect(Number(adds[0]?.getAttribute("k2"))).toBeCloseTo(weight, 6);
for (const add of adds.slice(1)) expect(Number(add.getAttribute("k2"))).toBe(1);
// Each composite must fold in the next duplicate and feed the one after it, so every
// duplicate reaches the output and none is added twice.
adds.forEach((add, index) => {
expect(add.getAttribute("in")).toBe(index === 0 ? "s0" : `a${index}`);
expect(add.getAttribute("in2")).toBe(`s${index + 1}`);
expect(add.getAttribute("result")).toBe(`a${index + 1}`);
});
});
it("composites the sharp frame-time instance over the full accumulated smear", async () => {
const filter = await attach({ fps: FPS });
const last = filter.lastElementChild;
expect(last?.tagName).toBe("feComposite");
expect(last?.getAttribute("operator")).toBe("over");
expect(last?.getAttribute("in")).toBe("SourceGraphic");
// in2 must be the END of the accumulation chain, not some intermediate or single copy.
expect(last?.getAttribute("in2")).toBe(`a${reference.subIntervalsPerWindow}`);
expect(last?.getAttribute("result")).toBeNull();
});
it("keeps the sharp instance inside the filter region when the window is entirely one-sided", async () => {
// shutterPhase 0 opens the shutter at the frame time, so every duplicate is ahead of the
// element and no duplicate sits at offset 0 — but the sharp instance still does. A region
// derived from the duplicates alone starts inside the element's own box and clips it.
const filter = await attach({ fps: FPS, shutterPhase: 360 });
const offsets = copyOffsets(filter);
expect(Math.min(...offsets)).toBeGreaterThan(0);
expect(Number.parseFloat(filter.getAttribute("x") ?? "")).toBeLessThanOrEqual(0);
const right =
Number.parseFloat(filter.getAttribute("x") ?? "") +
Number.parseFloat(filter.getAttribute("width") ?? "");
expect(right).toBeGreaterThanOrEqual((Math.max(...offsets) / WORD_WIDTH) * 100 + 100);
});
});
Binary file not shown.
+1 -1
View File
@@ -2,7 +2,7 @@
"model": "bge-small-en-v1.5",
"modelRevision": "ea104dacec62c0de699686887e3f920caeb4f3e3",
"dimensions": 384,
"revision": "b33b552d4f830abfcccb2bfe9495ac6284a7e7c05b3d3f4df630dfd5e753e518",
"revision": "34370735fa1a93099519873178e547c43b14f3bd69491013577515f4bb4f5957",
"names": [
"ai-chat-reveal",
"animated-bar-chart",
+217 -144
View File
@@ -189,7 +189,9 @@
>
<div class="header">
<span class="header-title">Motion Blur — Velocity Showcase</span>
<span class="header-sub">feGaussianBlur + scaleX · blur ∝ velocity</span>
<span class="header-sub"
>17 duplicates at 1/16 · 720° shutter · smear = speed × shutter time</span
>
</div>
<div class="group" style="top: 80px">
@@ -257,15 +259,80 @@
<script>
(function () {
if (!window._hfMbUid) window._hfMbUid = 0;
var ns = "http://www.w3.org/2000/svg";
// Shutter length is measured in frames, so the snippet must know the
// composition frame rate: explicit option, else the root's data-fps, else 30.
function resolveFps(optionFps) {
var explicit = Number(optionFps);
if (explicit > 0) return explicit;
var root = document.querySelector("[data-composition-id][data-fps]");
var rootFps = root ? Number(root.getAttribute("data-fps")) : 0;
if (rootFps > 0) return rootFps;
return 30;
}
function numOption(value, fallback) {
return value !== undefined ? Number(value) : fallback;
}
// objectBoundingBox percentage for a px offset plus padding.
function regionPercent(offset, size, padPercent) {
return ((offset / size) * 100 + padPercent).toFixed(2) + "%";
}
window.attachMotionBlur = function (selector, tl, opts) {
opts = opts || {};
var blurScale = opts.blurScale !== undefined ? opts.blurScale : 0.008;
var blurMax = opts.blurMax !== undefined ? opts.blurMax : 20;
var stretchScale = opts.stretchScale !== undefined ? opts.stretchScale : 0.0002;
var stretchMax = opts.stretchMax !== undefined ? opts.stretchMax : 0;
var shutterAngle = numOption(opts.shutterAngle, 720);
var shutterPhase = numOption(opts.shutterPhase, -360);
var requestedSamples = numOption(opts.samplesPerFrame, 16);
var samples = Number.isFinite(requestedSamples)
? Math.max(2, Math.min(64, Math.round(requestedSamples)))
: 16;
var axis = opts.axis || "both";
var fps = resolveFps(opts.fps);
var useX = axis !== "y";
var useY = axis !== "x";
// Untransformed box size; SVG elements have no offsetWidth, so use the
// same bbox GSAP uses to resolve their percent transforms.
function boxSize(el, horizontal) {
var box = typeof el.offsetWidth === "number" ? el : el.getBBox ? el.getBBox() : null;
var size = box
? horizontal
? box.offsetWidth || box.width
: box.offsetHeight || box.height
: 0;
return size || el.getBoundingClientRect()[horizontal ? "width" : "height"] || 1;
}
// GSAP x/y may carry a % unit and xPercent/yPercent is a separate term;
// resolve both against the element's own box so samples are in px.
function readAxis(el, prop, percentProp, size) {
var px = parseFloat(gsap.getProperty(el, prop, "px")) || 0;
var percent = parseFloat(gsap.getProperty(el, percentProp)) || 0;
return px + (percent * size) / 100;
}
function readPosition(el) {
return {
x: useX ? readAxis(el, "x", "xPercent", boxSize(el, true)) : 0,
y: useY ? readAxis(el, "y", "yPercent", boxSize(el, false)) : 0,
};
}
// CSS filters run in the element's pre-transform space while GSAP x/y are
// post-transform, so a displacement is mapped through the inverse of the
// element's own scale/rotation. Ancestor transforms are not compensated.
function toFilterSpace(el, dx, dy) {
var sx = parseFloat(gsap.getProperty(el, "scaleX")) || 1;
var sy = parseFloat(gsap.getProperty(el, "scaleY")) || 1;
var theta = ((parseFloat(gsap.getProperty(el, "rotation")) || 0) * Math.PI) / 180;
var cos = Math.cos(theta);
var sin = Math.sin(theta);
return { dx: (dx * cos + dy * sin) / sx, dy: (-dx * sin + dy * cos) / sy };
}
var items = Array.isArray(selector) ? selector : [selector];
var targets = items.reduce(function (acc, s) {
@@ -279,12 +346,17 @@
return acc;
}, []);
var GHOST_CFG = [
{ frac: 0.25, slope: 0.55 },
{ frac: 0.55, slope: 0.28 },
{ frac: 1.0, slope: 0.1 },
];
var ns = "http://www.w3.org/2000/svg";
// `samples` counts sub-intervals of the shutter window, so there is one copy at
// every interval boundary — both ends of the window included — and each carries
// 1/samples of the source. That is what the reference export shows: the outermost
// copy sits exactly one frame away from the frame time, not half a step short.
var copies = samples + 1;
var weight = String(1 / samples);
// One SVG filter per target: N+1 offset copies of SourceGraphic added at 1/N
// each, then the element itself over the top. Accumulating in sRGB matches
// AE's default (non-linear) 8-bpc working space.
var state = targets.map(function (el) {
var uid = "hf-mb-" + window._hfMbUid++;
var svg = document.createElementNS(ns, "svg");
@@ -292,68 +364,147 @@
var filter = document.createElementNS(ns, "filter");
filter.id = uid;
filter.setAttribute("x", "-110%");
filter.setAttribute("y", "-25%");
filter.setAttribute("width", "260%");
filter.setAttribute("height", "150%");
filter.setAttribute("color-interpolation-filters", "sRGB");
var ghosts = GHOST_CFG.map(function (cfg, gi) {
var offsets = [];
for (var k = 0; k < copies; k++) {
var feOff = document.createElementNS(ns, "feOffset");
feOff.setAttribute("in", "SourceGraphic");
feOff.setAttribute("dx", "0");
feOff.setAttribute("dy", "0");
feOff.setAttribute("result", "go" + gi);
var feGB = document.createElementNS(ns, "feGaussianBlur");
feGB.setAttribute("in", "go" + gi);
feGB.setAttribute("stdDeviation", "0 0");
feGB.setAttribute("result", "gb" + gi);
var feCT = document.createElementNS(ns, "feComponentTransfer");
feCT.setAttribute("in", "gb" + gi);
feCT.setAttribute("result", "gf" + gi);
var feFuncA = document.createElementNS(ns, "feFuncA");
feFuncA.setAttribute("type", "linear");
feFuncA.setAttribute("slope", String(cfg.slope));
feCT.appendChild(feFuncA);
feOff.setAttribute("result", "s" + k);
filter.appendChild(feOff);
filter.appendChild(feGB);
filter.appendChild(feCT);
return { feOff: feOff, feGB: feGB, frac: cfg.frac };
});
var feTopBlur = document.createElementNS(ns, "feGaussianBlur");
feTopBlur.setAttribute("in", "SourceGraphic");
feTopBlur.setAttribute("stdDeviation", "0 0");
feTopBlur.setAttribute("result", "top");
filter.appendChild(feTopBlur);
var feMerge = document.createElementNS(ns, "feMerge");
for (var mi = GHOST_CFG.length - 1; mi >= 0; mi--) {
var mn = document.createElementNS(ns, "feMergeNode");
mn.setAttribute("in", "gf" + mi);
feMerge.appendChild(mn);
offsets.push(feOff);
}
var mnTop = document.createElementNS(ns, "feMergeNode");
mnTop.setAttribute("in", "top");
feMerge.appendChild(mnTop);
filter.appendChild(feMerge);
// Pairwise accumulation: the first composite scales both inputs by 1/N, every
// later one adds the next copy at 1/N to the running sum. The N+1 weights total
// (N+1)/N, so an element wider than its own smear saturates through the middle
// and the filter clamps there. That is the saturation the reference shows, and
// the sharp instance covers the same region regardless.
var accumulated = "s0";
for (var c = 1; c < copies; c++) {
var feComp = document.createElementNS(ns, "feComposite");
feComp.setAttribute("operator", "arithmetic");
feComp.setAttribute("in", accumulated);
feComp.setAttribute("in2", "s" + c);
feComp.setAttribute("k1", "0");
feComp.setAttribute("k2", c === 1 ? weight : "1");
feComp.setAttribute("k3", weight);
feComp.setAttribute("k4", "0");
accumulated = "a" + c;
feComp.setAttribute("result", accumulated);
filter.appendChild(feComp);
}
// The frame-time instance is drawn sharp, at full opacity, over the smear.
// Every blurred copy stays at 1/N, so nothing but the instance the frame is
// actually at reads as solid. Unnamed, so it is the filter output.
var feOver = document.createElementNS(ns, "feComposite");
feOver.setAttribute("operator", "over");
feOver.setAttribute("in", "SourceGraphic");
feOver.setAttribute("in2", accumulated);
filter.appendChild(feOver);
svg.appendChild(filter);
document.body.appendChild(svg);
return {
el: el,
ghosts: ghosts,
feTopBlur: feTopBlur,
filterId: uid,
prevX: parseFloat(gsap.getProperty(el, "x")) || 0,
prevY: parseFloat(gsap.getProperty(el, "y")) || 0,
prevTime: tl.time(),
};
return { el: el, offsets: offsets, filter: filter, filterId: uid, xs: [], ys: [] };
});
// The element may carry its own inline filter (authored or tweened by
// GSAP); the blur is appended to that chain and removed from it, never
// written over it.
var OWN_FILTER = /\s*url\(["']?#hf-mb-\d+["']?\)/g;
function baseFilter(el) {
return el.style.filter.replace(OWN_FILTER, "").trim();
}
function clearBlur(s) {
s.el.style.filter = baseFilter(s.el);
}
var scheduled = false;
function applyShutter() {
var shutterTime = shutterAngle / 360 / fps;
if (!(shutterTime > 0)) {
state.forEach(clearBlur);
return;
}
var t0 = tl.time();
var duration = tl.duration();
var windowStart = t0 + shutterPhase / 360 / fps;
// Sample the real trajectory: seek to each sub-frame time with events
// suppressed (so no tween callbacks, including this tracker's, fire),
// read positions, then restore the frame time.
try {
for (var k = 0; k < copies; k++) {
var tk = windowStart + (k / samples) * shutterTime;
tl.time(Math.min(duration, Math.max(0, tk)), true);
state.forEach(function (s) {
var pos = readPosition(s.el);
s.xs[k] = pos.x;
s.ys[k] = pos.y;
});
}
} finally {
tl.time(t0, true);
}
state.forEach(function (s) {
var current = readPosition(s.el);
var minDx = Infinity;
var maxDx = -Infinity;
var minDy = Infinity;
var maxDy = -Infinity;
for (var i = 0; i < copies; i++) {
var d = toFilterSpace(s.el, s.xs[i] - current.x, s.ys[i] - current.y);
var dx = d.dx;
var dy = d.dy;
s.offsets[i].setAttribute("dx", dx.toFixed(3));
s.offsets[i].setAttribute("dy", dy.toFixed(3));
if (dx < minDx) minDx = dx;
if (dx > maxDx) maxDx = dx;
if (dy < minDy) minDy = dy;
if (dy > maxDy) maxDy = dy;
}
// Below half a pixel of smear the average is indistinguishable from
// the source — render sharp instead of paying for the filter.
if (maxDx - minDx < 0.5 && maxDy - minDy < 0.5) {
clearBlur(s);
return;
}
// Grow the filter region to cover every copy AND the sharp instance the
// final composite draws at offset 0 (objectBoundingBox units, 10% padding
// so anti-aliased edges are not clipped). Clamping the bounds through 0
// matters whenever the window sits entirely on one side of the frame time
// — any shutterPhase above 0 or below -shutterAngle — because then no copy
// is at 0 and a region derived from the copies alone cuts the instance off.
var x0 = Math.min(0, minDx);
var x1 = Math.max(0, maxDx);
var y0 = Math.min(0, minDy);
var y1 = Math.max(0, maxDy);
var w = boxSize(s.el, true);
var h = boxSize(s.el, false);
s.filter.setAttribute("x", regionPercent(x0, w, -10));
s.filter.setAttribute("width", regionPercent(x1 - x0, w, 120));
s.filter.setAttribute("y", regionPercent(y0, h, -10));
s.filter.setAttribute("height", regionPercent(y1 - y0, h, 120));
var base = baseFilter(s.el);
s.el.style.filter = (base ? base + " " : "") + "url(#" + s.filterId + ")";
});
}
// tl.eventCallback("onUpdate") is unavailable under the HyperFrames runtime proxy, so a
// tracker tween's onUpdate fires on every seek. Sampling is deferred to a microtask:
// seeking GSAP from inside its own render reads stale tween state, while the microtask
// runs after the seek returns and before the frame is captured or painted.
var _proxy = { t: 0 };
tl.to(
_proxy,
@@ -362,89 +513,11 @@
duration: Math.max(tl.duration(), 0.1),
ease: "none",
onUpdate: function () {
var time = tl.time();
state.forEach(function (s) {
var x = parseFloat(gsap.getProperty(s.el, "x")) || 0;
var y = parseFloat(gsap.getProperty(s.el, "y")) || 0;
var dt = time - s.prevTime;
if (dt > 0.0005) {
var vx = axis !== "y" ? (x - s.prevX) / dt : 0;
var vy = axis !== "x" ? (y - s.prevY) / dt : 0;
var bx = Math.min(Math.abs(vx) * blurScale, blurMax);
var by = Math.min(Math.abs(vy) * blurScale, blurMax);
var bxFinal = axis !== "y" ? bx : Math.max(by * 0.08, 0.4);
var byFinal = axis !== "x" ? by : Math.max(bx * 0.08, 0.4);
var active = bx > 0.3 || by > 0.3;
if (active) {
s.el.style.filter = "url(#" + s.filterId + ")";
s.ghosts.forEach(function (g) {
var dx =
axis !== "y" ? (vx >= 0 ? -bxFinal * g.frac : bxFinal * g.frac) : 0;
var dy =
axis !== "x" ? (vy >= 0 ? -byFinal * g.frac : byFinal * g.frac) : 0;
g.feOff.setAttribute("dx", dx.toFixed(2));
g.feOff.setAttribute("dy", dy.toFixed(2));
var gbx =
axis !== "y"
? (bxFinal * g.frac * 0.5).toFixed(2)
: Math.max(byFinal * g.frac * 0.04, 0.4).toFixed(2);
var gby =
axis !== "x"
? (byFinal * g.frac * 0.5).toFixed(2)
: Math.max(bxFinal * g.frac * 0.04, 0.4).toFixed(2);
g.feGB.setAttribute("stdDeviation", gbx + " " + gby);
});
s.feTopBlur.setAttribute(
"stdDeviation",
(bxFinal * 0.15).toFixed(2) + " " + (byFinal * 0.15).toFixed(2),
);
if (stretchMax > 0) {
var sx = 1 + Math.min(Math.abs(vx) * stretchScale, stretchMax);
var sy = 1 + Math.min(Math.abs(vy) * stretchScale, stretchMax);
var ox = axis !== "y" ? (vx >= 0 ? "100% 50%" : "0% 50%") : "50% 50%";
var oy = axis !== "x" ? (vy >= 0 ? "50% 100%" : "50% 0%") : "50% 50%";
gsap.set(s.el, {
scaleX: axis !== "y" ? sx : 1,
scaleY: axis !== "x" ? sy : 1,
transformOrigin: axis === "x" ? ox : axis === "y" ? oy : "50% 50%",
});
}
} else {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
if (stretchMax > 0) {
gsap.set(s.el, { scaleX: 1, scaleY: 1, transformOrigin: "50% 50%" });
}
}
s.prevX = x;
s.prevY = y;
s.prevTime = time;
} else if (dt < -0.0005) {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
if (stretchMax > 0) {
gsap.set(s.el, { scaleX: 1, scaleY: 1, transformOrigin: "50% 50%" });
}
s.prevX = x;
s.prevY = y;
s.prevTime = time;
}
// dt ≈ 0: double-fire — skip.
if (scheduled) return;
scheduled = true;
Promise.resolve().then(function () {
scheduled = false;
applyShutter();
});
},
},
@@ -469,7 +542,7 @@
attachMotionBlur(
["#s1", "#s2", "#s3", "#s4", "#s5", "#t1", "#t2", "#t3", "#t4", "#t5"],
tl,
{ axis: "x", blurMax: 80, stretchMax: 0 },
{ axis: "x" },
);
window.__timelines = window.__timelines || {};
+273 -163
View File
@@ -1,48 +1,156 @@
<!--
Motion Blur — velocity-driven directional motion blur.
Motion Blur — After Effects-style shutter-based motion blur.
Usage: paste this snippet into your composition, then call
attachMotionBlur() with any element animated by your GSAP timeline.
The snippet hooks into the timeline's onUpdate callback, tracks the
GSAP x/y position of each target frame-by-frame, and applies:
1. Ghost copies of the element at increasing backward offsets with
decreasing opacity — inherently one-sided, no forward blur component.
2. A small Gaussian blur at the current position so the element looks
in-motion (blurry) rather than sharp on top of the trail.
3. An optional scaleX/Y stretch in the direction of travel (off by
default; enable via stretchMax > 0 if you want the effect).
How it works — the same model After Effects uses for the layer Motion Blur
switch (temporal supersampling), synthesised inside the page because the
renderer captures one instant per frame with no shutter:
Both effects clear automatically when the element decelerates to rest.
1. After every timeline update (each rendered frame), the snippet seeks
the timeline to `samplesPerFrame` sub-frame times spread evenly across
the shutter window, reads each target's GSAP x/y at every sample, then
restores the frame time. Seeks run with events suppressed, so no user
callbacks fire and the DOM ends up exactly where the frame left it.
2. An SVG filter stacks one copy of the element per sample (feOffset to
the sampled position relative to the current one) and adds them at
equal weight (feComposite arithmetic, 1/N each, sRGB space). Result:
a box smear along the actual trajectory whose length is speed ×
shutter time, with no decaying one-sided trail. The tails — where
fewer copies overlap — step down evenly at 1/N per copy; wherever the
element travels less than its own size the copies pile up past 1 and
clamp, so the middle of the smear is solid rather than a plateau.
3. The element itself is then composited over that smear, sharp and at
full opacity. No single duplicate ever exceeds 1/N, so a moving edge
resolves as a staircase of ghosts behind a solid frame-time instance.
4. When the sampled positions collapse to the current one (element at
rest), the filter is removed so the element renders sharp.
Shutter window (AE semantics, per frame at time t, frame interval 1/fps):
shutterTime = shutterAngle / 360 / fps
windowStart = t + shutterPhase / 360 / fps
copy k = windowStart + k / N × shutterTime, k = 0..N (N+1 copies)
N counts sub-intervals, so both ends of the window carry a copy.
Defaults (shutterAngle 720, shutterPhase -360) integrate [t - 1/fps, t + 1/fps]:
a solid moving at 2000 px/s at 30 fps smears 133 px, centred on its position.
These defaults are measured, not assumed. On a 1920×1080 / 30 fps After Effects
export of translating text, the outermost duplicate on the trailing side sits
exactly at the previous frame's position and the outermost leading duplicate
exactly at the next frame's, with 8 evenly spaced duplicates in between on each
side (window = 2 frames = 720°, phase -360°, 16 sub-intervals). Reading the ghost
staircase across a stroke gives steps of 0.063 ± 0.002 of the sharp text
intensity — a flat 1/16 per duplicate, with no taper toward the window edges
(triangle weighting scores 1.6 dB worse against the same export). The sharp
instance on top is what keeps total ink above the unblurred frame's.
Requirements:
- Elements must be animated via GSAP x/y (transform), not left/top.
- Call attachMotionBlur() AFTER defining all tweens, before
window.__timelines registration.
- Elements must be animated via GSAP x/y (transform), not left/top. The
element's own scale/rotation is compensated; skew, 3D rotation and
transformed ancestors are not.
- The host must render the timeline for the blur to update — HyperFrames
seeks every frame. A fresh paused timeline that is never seeked shows no
blur on its first frame.
- Call attachMotionBlur() AFTER defining all tweens (the timeline's final
duration must be known), before window.__timelines registration.
- GSAP must be loaded before this snippet executes.
API:
attachMotionBlur(selector, timeline, options?)
Options:
blurScale — directional blur per px/s of velocity (default 0.008)
blurMax — max blur radius on the motion axis in px (default 20)
stretchScale — scaleX/Y added per px/s (default 0.0002)
stretchMax — max stretch factor above 1.0 (default 0, disabled)
axis — "x" | "y" | "both" (default "both")
shutterAngle — degrees of the frame interval the shutter is open
(default 720 = two frames, as measured off the AE
reference; 360 = one frame, 0 disables)
shutterPhase — degrees offset of the window start from the frame
time (default -360 = centred on the frame, one frame
back, like the AE reference)
samplesPerFrame — sub-intervals of the shutter window, so N+1 duplicates
each at 1/N opacity (default 16, max 64)
fps — composition frame rate (default: the root's data-fps,
else 30). Pass it explicitly when rendering with an
fps override (`hyperframes render --fps`).
axis — "x" | "y" | "both" (default "both")
-->
<script>
(function () {
if (!window._hfMbUid) window._hfMbUid = 0;
// Shutter length is measured in frames, so the snippet must know the
// composition frame rate: explicit option, else the root's data-fps, else 30.
function resolveFps(optionFps) {
var explicit = Number(optionFps);
if (explicit > 0) return explicit;
var root = document.querySelector("[data-composition-id][data-fps]");
var rootFps = root ? Number(root.getAttribute("data-fps")) : 0;
if (rootFps > 0) return rootFps;
return 30;
}
function numOption(value, fallback) {
return value !== undefined ? Number(value) : fallback;
}
// objectBoundingBox percentage for a px offset plus padding.
function regionPercent(offset, size, padPercent) {
return ((offset / size) * 100 + padPercent).toFixed(2) + "%";
}
window.attachMotionBlur = function (selector, tl, opts) {
opts = opts || {};
var blurScale = opts.blurScale !== undefined ? opts.blurScale : 0.008;
var blurMax = opts.blurMax !== undefined ? opts.blurMax : 20;
var stretchScale = opts.stretchScale !== undefined ? opts.stretchScale : 0.0002;
var stretchMax = opts.stretchMax !== undefined ? opts.stretchMax : 0;
var shutterAngle = numOption(opts.shutterAngle, 720);
var shutterPhase = numOption(opts.shutterPhase, -360);
var requestedSamples = numOption(opts.samplesPerFrame, 16);
var samples = Number.isFinite(requestedSamples)
? Math.max(2, Math.min(64, Math.round(requestedSamples)))
: 16;
var axis = opts.axis || "both";
var fps = resolveFps(opts.fps);
var useX = axis !== "y";
var useY = axis !== "x";
// Untransformed box size; SVG elements have no offsetWidth, so use the
// same bbox GSAP uses to resolve their percent transforms.
function boxSize(el, horizontal) {
var box = typeof el.offsetWidth === "number" ? el : el.getBBox ? el.getBBox() : null;
var size = box
? horizontal
? box.offsetWidth || box.width
: box.offsetHeight || box.height
: 0;
return size || el.getBoundingClientRect()[horizontal ? "width" : "height"] || 1;
}
// GSAP x/y may carry a % unit and xPercent/yPercent is a separate term;
// resolve both against the element's own box so samples are in px.
function readAxis(el, prop, percentProp, size) {
var px = parseFloat(gsap.getProperty(el, prop, "px")) || 0;
var percent = parseFloat(gsap.getProperty(el, percentProp)) || 0;
return px + (percent * size) / 100;
}
function readPosition(el) {
return {
x: useX ? readAxis(el, "x", "xPercent", boxSize(el, true)) : 0,
y: useY ? readAxis(el, "y", "yPercent", boxSize(el, false)) : 0,
};
}
// CSS filters run in the element's pre-transform space while GSAP x/y are
// post-transform, so a displacement is mapped through the inverse of the
// element's own scale/rotation. Ancestor transforms are not compensated.
function toFilterSpace(el, dx, dy) {
var sx = parseFloat(gsap.getProperty(el, "scaleX")) || 1;
var sy = parseFloat(gsap.getProperty(el, "scaleY")) || 1;
var theta = ((parseFloat(gsap.getProperty(el, "rotation")) || 0) * Math.PI) / 180;
var cos = Math.cos(theta);
var sin = Math.sin(theta);
return { dx: (dx * cos + dy * sin) / sx, dy: (-dx * sin + dy * cos) / sy };
}
var items = Array.isArray(selector) ? selector : [selector];
var targets = items.reduce(function (acc, s) {
@@ -56,15 +164,17 @@
return acc;
}, []);
// One SVG filter per target. Ghost copies placed behind → inherently one-sided.
// Small top Gaussian makes element look blurry at current position, not sharp.
var ns = "http://www.w3.org/2000/svg";
var GHOST_CFG = [
{ frac: 0.25, slope: 0.55 },
{ frac: 0.55, slope: 0.28 },
{ frac: 1.0, slope: 0.1 },
];
// `samples` counts sub-intervals of the shutter window, so there is one copy at
// every interval boundary — both ends of the window included — and each carries
// 1/samples of the source. That is what the reference export shows: the outermost
// copy sits exactly one frame away from the frame time, not half a step short.
var copies = samples + 1;
var weight = String(1 / samples);
// One SVG filter per target: N+1 offset copies of SourceGraphic added at 1/N
// each, then the element itself over the top. Accumulating in sRGB matches
// AE's default (non-linear) 8-bpc working space.
var state = targets.map(function (el) {
var uid = "hf-mb-" + window._hfMbUid++;
var svg = document.createElementNS(ns, "svg");
@@ -72,70 +182,147 @@
var filter = document.createElementNS(ns, "filter");
filter.id = uid;
filter.setAttribute("x", "-110%");
filter.setAttribute("y", "-25%");
filter.setAttribute("width", "260%");
filter.setAttribute("height", "150%");
filter.setAttribute("color-interpolation-filters", "sRGB");
var ghosts = GHOST_CFG.map(function (cfg, gi) {
var offsets = [];
for (var k = 0; k < copies; k++) {
var feOff = document.createElementNS(ns, "feOffset");
feOff.setAttribute("in", "SourceGraphic");
feOff.setAttribute("dx", "0");
feOff.setAttribute("dy", "0");
feOff.setAttribute("result", "go" + gi);
var feGB = document.createElementNS(ns, "feGaussianBlur");
feGB.setAttribute("in", "go" + gi);
feGB.setAttribute("stdDeviation", "0 0");
feGB.setAttribute("result", "gb" + gi);
var feCT = document.createElementNS(ns, "feComponentTransfer");
feCT.setAttribute("in", "gb" + gi);
feCT.setAttribute("result", "gf" + gi);
var feFuncA = document.createElementNS(ns, "feFuncA");
feFuncA.setAttribute("type", "linear");
feFuncA.setAttribute("slope", String(cfg.slope));
feCT.appendChild(feFuncA);
feOff.setAttribute("result", "s" + k);
filter.appendChild(feOff);
filter.appendChild(feGB);
filter.appendChild(feCT);
return { feOff: feOff, feGB: feGB, frac: cfg.frac };
});
var feTopBlur = document.createElementNS(ns, "feGaussianBlur");
feTopBlur.setAttribute("in", "SourceGraphic");
feTopBlur.setAttribute("stdDeviation", "0 0");
feTopBlur.setAttribute("result", "top");
filter.appendChild(feTopBlur);
var feMerge = document.createElementNS(ns, "feMerge");
for (var mi = GHOST_CFG.length - 1; mi >= 0; mi--) {
var mn = document.createElementNS(ns, "feMergeNode");
mn.setAttribute("in", "gf" + mi);
feMerge.appendChild(mn);
offsets.push(feOff);
}
var mnTop = document.createElementNS(ns, "feMergeNode");
mnTop.setAttribute("in", "top");
feMerge.appendChild(mnTop);
filter.appendChild(feMerge);
// Pairwise accumulation: the first composite scales both inputs by 1/N, every
// later one adds the next copy at 1/N to the running sum. The N+1 weights total
// (N+1)/N, so an element wider than its own smear saturates through the middle
// and the filter clamps there. That is the saturation the reference shows, and
// the sharp instance covers the same region regardless.
var accumulated = "s0";
for (var c = 1; c < copies; c++) {
var feComp = document.createElementNS(ns, "feComposite");
feComp.setAttribute("operator", "arithmetic");
feComp.setAttribute("in", accumulated);
feComp.setAttribute("in2", "s" + c);
feComp.setAttribute("k1", "0");
feComp.setAttribute("k2", c === 1 ? weight : "1");
feComp.setAttribute("k3", weight);
feComp.setAttribute("k4", "0");
accumulated = "a" + c;
feComp.setAttribute("result", accumulated);
filter.appendChild(feComp);
}
// The frame-time instance is drawn sharp, at full opacity, over the smear.
// Every blurred copy stays at 1/N, so nothing but the instance the frame is
// actually at reads as solid. Unnamed, so it is the filter output.
var feOver = document.createElementNS(ns, "feComposite");
feOver.setAttribute("operator", "over");
feOver.setAttribute("in", "SourceGraphic");
feOver.setAttribute("in2", accumulated);
filter.appendChild(feOver);
svg.appendChild(filter);
document.body.appendChild(svg);
return {
el: el,
ghosts: ghosts,
feTopBlur: feTopBlur,
filterId: uid,
prevX: parseFloat(gsap.getProperty(el, "x")) || 0,
prevY: parseFloat(gsap.getProperty(el, "y")) || 0,
prevTime: tl.time(),
};
return { el: el, offsets: offsets, filter: filter, filterId: uid, xs: [], ys: [] };
});
// tl.eventCallback("onUpdate") is not available in the HyperFrames renderer —
// the runtime proxies the timeline object. Tween onUpdate fires on every seek.
// The element may carry its own inline filter (authored or tweened by
// GSAP); the blur is appended to that chain and removed from it, never
// written over it.
var OWN_FILTER = /\s*url\(["']?#hf-mb-\d+["']?\)/g;
function baseFilter(el) {
return el.style.filter.replace(OWN_FILTER, "").trim();
}
function clearBlur(s) {
s.el.style.filter = baseFilter(s.el);
}
var scheduled = false;
function applyShutter() {
var shutterTime = shutterAngle / 360 / fps;
if (!(shutterTime > 0)) {
state.forEach(clearBlur);
return;
}
var t0 = tl.time();
var duration = tl.duration();
var windowStart = t0 + shutterPhase / 360 / fps;
// Sample the real trajectory: seek to each sub-frame time with events
// suppressed (so no tween callbacks, including this tracker's, fire),
// read positions, then restore the frame time.
try {
for (var k = 0; k < copies; k++) {
var tk = windowStart + (k / samples) * shutterTime;
tl.time(Math.min(duration, Math.max(0, tk)), true);
state.forEach(function (s) {
var pos = readPosition(s.el);
s.xs[k] = pos.x;
s.ys[k] = pos.y;
});
}
} finally {
tl.time(t0, true);
}
state.forEach(function (s) {
var current = readPosition(s.el);
var minDx = Infinity;
var maxDx = -Infinity;
var minDy = Infinity;
var maxDy = -Infinity;
for (var i = 0; i < copies; i++) {
var d = toFilterSpace(s.el, s.xs[i] - current.x, s.ys[i] - current.y);
var dx = d.dx;
var dy = d.dy;
s.offsets[i].setAttribute("dx", dx.toFixed(3));
s.offsets[i].setAttribute("dy", dy.toFixed(3));
if (dx < minDx) minDx = dx;
if (dx > maxDx) maxDx = dx;
if (dy < minDy) minDy = dy;
if (dy > maxDy) maxDy = dy;
}
// Below half a pixel of smear the average is indistinguishable from
// the source — render sharp instead of paying for the filter.
if (maxDx - minDx < 0.5 && maxDy - minDy < 0.5) {
clearBlur(s);
return;
}
// Grow the filter region to cover every copy AND the sharp instance the
// final composite draws at offset 0 (objectBoundingBox units, 10% padding
// so anti-aliased edges are not clipped). Clamping the bounds through 0
// matters whenever the window sits entirely on one side of the frame time
// — any shutterPhase above 0 or below -shutterAngle — because then no copy
// is at 0 and a region derived from the copies alone cuts the instance off.
var x0 = Math.min(0, minDx);
var x1 = Math.max(0, maxDx);
var y0 = Math.min(0, minDy);
var y1 = Math.max(0, maxDy);
var w = boxSize(s.el, true);
var h = boxSize(s.el, false);
s.filter.setAttribute("x", regionPercent(x0, w, -10));
s.filter.setAttribute("width", regionPercent(x1 - x0, w, 120));
s.filter.setAttribute("y", regionPercent(y0, h, -10));
s.filter.setAttribute("height", regionPercent(y1 - y0, h, 120));
var base = baseFilter(s.el);
s.el.style.filter = (base ? base + " " : "") + "url(#" + s.filterId + ")";
});
}
// tl.eventCallback("onUpdate") is unavailable under the HyperFrames runtime proxy, so a
// tracker tween's onUpdate fires on every seek. Sampling is deferred to a microtask:
// seeking GSAP from inside its own render reads stale tween state, while the microtask
// runs after the seek returns and before the frame is captured or painted.
var _proxy = { t: 0 };
tl.to(
_proxy,
@@ -144,88 +331,11 @@
duration: Math.max(tl.duration(), 0.1),
ease: "none",
onUpdate: function () {
var time = tl.time();
state.forEach(function (s) {
var x = parseFloat(gsap.getProperty(s.el, "x")) || 0;
var y = parseFloat(gsap.getProperty(s.el, "y")) || 0;
var dt = time - s.prevTime;
if (dt > 0.0005) {
var vx = axis !== "y" ? (x - s.prevX) / dt : 0;
var vy = axis !== "x" ? (y - s.prevY) / dt : 0;
var bx = Math.min(Math.abs(vx) * blurScale, blurMax);
var by = Math.min(Math.abs(vy) * blurScale, blurMax);
var bxFinal = axis !== "y" ? bx : Math.max(by * 0.08, 0.4);
var byFinal = axis !== "x" ? by : Math.max(bx * 0.08, 0.4);
var active = bx > 0.3 || by > 0.3;
if (active) {
s.el.style.filter = "url(#" + s.filterId + ")";
s.ghosts.forEach(function (g) {
var dx = axis !== "y" ? (vx >= 0 ? -bxFinal * g.frac : bxFinal * g.frac) : 0;
var dy = axis !== "x" ? (vy >= 0 ? -byFinal * g.frac : byFinal * g.frac) : 0;
g.feOff.setAttribute("dx", dx.toFixed(2));
g.feOff.setAttribute("dy", dy.toFixed(2));
var gbx =
axis !== "y"
? (bxFinal * g.frac * 0.5).toFixed(2)
: Math.max(byFinal * g.frac * 0.04, 0.4).toFixed(2);
var gby =
axis !== "x"
? (byFinal * g.frac * 0.5).toFixed(2)
: Math.max(bxFinal * g.frac * 0.04, 0.4).toFixed(2);
g.feGB.setAttribute("stdDeviation", gbx + " " + gby);
});
s.feTopBlur.setAttribute(
"stdDeviation",
(bxFinal * 0.15).toFixed(2) + " " + (byFinal * 0.15).toFixed(2),
);
if (stretchMax > 0) {
var sx = 1 + Math.min(Math.abs(vx) * stretchScale, stretchMax);
var sy = 1 + Math.min(Math.abs(vy) * stretchScale, stretchMax);
var ox = axis !== "y" ? (vx >= 0 ? "100% 50%" : "0% 50%") : "50% 50%";
var oy = axis !== "x" ? (vy >= 0 ? "50% 100%" : "50% 0%") : "50% 50%";
gsap.set(s.el, {
scaleX: axis !== "y" ? sx : 1,
scaleY: axis !== "x" ? sy : 1,
transformOrigin: axis === "x" ? ox : axis === "y" ? oy : "50% 50%",
});
}
} else {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
if (stretchMax > 0) {
gsap.set(s.el, { scaleX: 1, scaleY: 1, transformOrigin: "50% 50%" });
}
}
s.prevX = x;
s.prevY = y;
s.prevTime = time;
} else if (dt < -0.0005) {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
if (stretchMax > 0) {
gsap.set(s.el, { scaleX: 1, scaleY: 1, transformOrigin: "50% 50%" });
}
s.prevX = x;
s.prevY = y;
s.prevTime = time;
}
// dt ≈ 0: double-fire — skip.
if (scheduled) return;
scheduled = true;
Promise.resolve().then(function () {
scheduled = false;
applyShutter();
});
},
},
@@ -248,7 +358,7 @@
// Call AFTER tweens, BEFORE window.__timelines registration.
// attachMotionBlur adds a tracking tween with onUpdate — must be called after
// tl.set()/tl.to() have established the final timeline duration.
attachMotionBlur("#my-box", tl, { axis: "x" });
attachMotionBlur("#my-box", tl, { shutterAngle: 720, samplesPerFrame: 16 });
window.__timelines = window.__timelines || {};
window.__timelines["my-composition"] = tl;
@@ -3,8 +3,8 @@
"name": "motion-blur",
"type": "hyperframes:component",
"title": "Motion Blur",
"description": "Velocity-driven motion blur — samples element position each frame and applies a one-sided SVG feGaussianBlur ghost trail proportional to speed",
"tags": ["effect", "motion-blur", "velocity", "animation", "physics"],
"description": "After Effects-style motion blur — integrates each element over a shutter window (shutter angle, phase, samples per frame) by sampling the GSAP timeline at sub-frame times and averaging equal-weight SVG copies along the real trajectory",
"tags": ["effect", "motion-blur", "shutter", "after-effects", "velocity", "animation"],
"files": [
{
"path": "motion-blur.html",
+219 -150
View File
@@ -194,7 +194,9 @@
>
<div class="header" data-layout-ignore>
<span class="header-title">Motion Blur — Velocity Showcase</span>
<span class="header-sub">feGaussianBlur + scaleX · blur ∝ velocity</span>
<span class="header-sub"
>17 duplicates at 1/16 · 720° shutter · smear = speed × shutter time</span
>
</div>
<!-- Group 1: 3.0s slow — top: 80 -->
@@ -341,15 +343,80 @@
(function () {
/* ── attachMotionBlur ─────────────────────────────────────── */
if (!window._hfMbUid) window._hfMbUid = 0;
var ns = "http://www.w3.org/2000/svg";
function attachMotionBlur(selector, tl, opts) {
// Shutter length is measured in frames, so the snippet must know the
// composition frame rate: explicit option, else the root's data-fps, else 30.
function resolveFps(optionFps) {
var explicit = Number(optionFps);
if (explicit > 0) return explicit;
var root = document.querySelector("[data-composition-id][data-fps]");
var rootFps = root ? Number(root.getAttribute("data-fps")) : 0;
if (rootFps > 0) return rootFps;
return 30;
}
function numOption(value, fallback) {
return value !== undefined ? Number(value) : fallback;
}
// objectBoundingBox percentage for a px offset plus padding.
function regionPercent(offset, size, padPercent) {
return ((offset / size) * 100 + padPercent).toFixed(2) + "%";
}
window.attachMotionBlur = function (selector, tl, opts) {
opts = opts || {};
var blurScale = opts.blurScale !== undefined ? opts.blurScale : 0.008;
var blurMax = opts.blurMax !== undefined ? opts.blurMax : 20;
var stretchScale = opts.stretchScale !== undefined ? opts.stretchScale : 0.0002;
var stretchMax = opts.stretchMax !== undefined ? opts.stretchMax : 0;
var shutterAngle = numOption(opts.shutterAngle, 720);
var shutterPhase = numOption(opts.shutterPhase, -360);
var requestedSamples = numOption(opts.samplesPerFrame, 16);
var samples = Number.isFinite(requestedSamples)
? Math.max(2, Math.min(64, Math.round(requestedSamples)))
: 16;
var axis = opts.axis || "both";
var fps = resolveFps(opts.fps);
var useX = axis !== "y";
var useY = axis !== "x";
// Untransformed box size; SVG elements have no offsetWidth, so use the
// same bbox GSAP uses to resolve their percent transforms.
function boxSize(el, horizontal) {
var box = typeof el.offsetWidth === "number" ? el : el.getBBox ? el.getBBox() : null;
var size = box
? horizontal
? box.offsetWidth || box.width
: box.offsetHeight || box.height
: 0;
return size || el.getBoundingClientRect()[horizontal ? "width" : "height"] || 1;
}
// GSAP x/y may carry a % unit and xPercent/yPercent is a separate term;
// resolve both against the element's own box so samples are in px.
function readAxis(el, prop, percentProp, size) {
var px = parseFloat(gsap.getProperty(el, prop, "px")) || 0;
var percent = parseFloat(gsap.getProperty(el, percentProp)) || 0;
return px + (percent * size) / 100;
}
function readPosition(el) {
return {
x: useX ? readAxis(el, "x", "xPercent", boxSize(el, true)) : 0,
y: useY ? readAxis(el, "y", "yPercent", boxSize(el, false)) : 0,
};
}
// CSS filters run in the element's pre-transform space while GSAP x/y are
// post-transform, so a displacement is mapped through the inverse of the
// element's own scale/rotation. Ancestor transforms are not compensated.
function toFilterSpace(el, dx, dy) {
var sx = parseFloat(gsap.getProperty(el, "scaleX")) || 1;
var sy = parseFloat(gsap.getProperty(el, "scaleY")) || 1;
var theta = ((parseFloat(gsap.getProperty(el, "rotation")) || 0) * Math.PI) / 180;
var cos = Math.cos(theta);
var sin = Math.sin(theta);
return { dx: (dx * cos + dy * sin) / sx, dy: (-dx * sin + dy * cos) / sy };
}
var items = Array.isArray(selector) ? selector : [selector];
var targets = items.reduce(function (acc, s) {
@@ -363,14 +430,17 @@
return acc;
}, []);
// Ghost trail: copies placed only behind the element → inherently one-sided.
// Top layer: small Gaussian at current position so element looks blurry, not sharp.
var GHOST_CFG = [
{ frac: 0.25, slope: 0.55 },
{ frac: 0.55, slope: 0.28 },
{ frac: 1.0, slope: 0.1 },
];
var ns = "http://www.w3.org/2000/svg";
// `samples` counts sub-intervals of the shutter window, so there is one copy at
// every interval boundary — both ends of the window included — and each carries
// 1/samples of the source. That is what the reference export shows: the outermost
// copy sits exactly one frame away from the frame time, not half a step short.
var copies = samples + 1;
var weight = String(1 / samples);
// One SVG filter per target: N+1 offset copies of SourceGraphic added at 1/N
// each, then the element itself over the top. Accumulating in sRGB matches
// AE's default (non-linear) 8-bpc working space.
var state = targets.map(function (el) {
var uid = "hf-mb-" + window._hfMbUid++;
var svg = document.createElementNS(ns, "svg");
@@ -378,70 +448,147 @@
var filter = document.createElementNS(ns, "filter");
filter.id = uid;
filter.setAttribute("x", "-110%");
filter.setAttribute("y", "-25%");
filter.setAttribute("width", "260%");
filter.setAttribute("height", "150%");
filter.setAttribute("color-interpolation-filters", "sRGB");
var ghosts = GHOST_CFG.map(function (cfg, gi) {
var offsets = [];
for (var k = 0; k < copies; k++) {
var feOff = document.createElementNS(ns, "feOffset");
feOff.setAttribute("in", "SourceGraphic");
feOff.setAttribute("dx", "0");
feOff.setAttribute("dy", "0");
feOff.setAttribute("result", "go" + gi);
var feGB = document.createElementNS(ns, "feGaussianBlur");
feGB.setAttribute("in", "go" + gi);
feGB.setAttribute("stdDeviation", "0 0");
feGB.setAttribute("result", "gb" + gi);
var feCT = document.createElementNS(ns, "feComponentTransfer");
feCT.setAttribute("in", "gb" + gi);
feCT.setAttribute("result", "gf" + gi);
var feFuncA = document.createElementNS(ns, "feFuncA");
feFuncA.setAttribute("type", "linear");
feFuncA.setAttribute("slope", String(cfg.slope));
feCT.appendChild(feFuncA);
feOff.setAttribute("result", "s" + k);
filter.appendChild(feOff);
filter.appendChild(feGB);
filter.appendChild(feCT);
return { feOff: feOff, feGB: feGB, frac: cfg.frac };
});
var feTopBlur = document.createElementNS(ns, "feGaussianBlur");
feTopBlur.setAttribute("in", "SourceGraphic");
feTopBlur.setAttribute("stdDeviation", "0 0");
feTopBlur.setAttribute("result", "top");
filter.appendChild(feTopBlur);
var feMerge = document.createElementNS(ns, "feMerge");
for (var mi = GHOST_CFG.length - 1; mi >= 0; mi--) {
var mn = document.createElementNS(ns, "feMergeNode");
mn.setAttribute("in", "gf" + mi);
feMerge.appendChild(mn);
offsets.push(feOff);
}
var mnTop = document.createElementNS(ns, "feMergeNode");
mnTop.setAttribute("in", "top");
feMerge.appendChild(mnTop);
filter.appendChild(feMerge);
// Pairwise accumulation: the first composite scales both inputs by 1/N, every
// later one adds the next copy at 1/N to the running sum. The N+1 weights total
// (N+1)/N, so an element wider than its own smear saturates through the middle
// and the filter clamps there. That is the saturation the reference shows, and
// the sharp instance covers the same region regardless.
var accumulated = "s0";
for (var c = 1; c < copies; c++) {
var feComp = document.createElementNS(ns, "feComposite");
feComp.setAttribute("operator", "arithmetic");
feComp.setAttribute("in", accumulated);
feComp.setAttribute("in2", "s" + c);
feComp.setAttribute("k1", "0");
feComp.setAttribute("k2", c === 1 ? weight : "1");
feComp.setAttribute("k3", weight);
feComp.setAttribute("k4", "0");
accumulated = "a" + c;
feComp.setAttribute("result", accumulated);
filter.appendChild(feComp);
}
// The frame-time instance is drawn sharp, at full opacity, over the smear.
// Every blurred copy stays at 1/N, so nothing but the instance the frame is
// actually at reads as solid. Unnamed, so it is the filter output.
var feOver = document.createElementNS(ns, "feComposite");
feOver.setAttribute("operator", "over");
feOver.setAttribute("in", "SourceGraphic");
feOver.setAttribute("in2", accumulated);
filter.appendChild(feOver);
svg.appendChild(filter);
document.body.appendChild(svg);
return {
el: el,
ghosts: ghosts,
feTopBlur: feTopBlur,
filterId: uid,
prevX: parseFloat(gsap.getProperty(el, "x")) || 0,
prevY: parseFloat(gsap.getProperty(el, "y")) || 0,
prevTime: tl.time(),
};
return { el: el, offsets: offsets, filter: filter, filterId: uid, xs: [], ys: [] };
});
// tl.eventCallback("onUpdate") is not available in the HyperFrames renderer —
// the runtime proxies the timeline. Tween onUpdate fires on every seek instead.
// The element may carry its own inline filter (authored or tweened by
// GSAP); the blur is appended to that chain and removed from it, never
// written over it.
var OWN_FILTER = /\s*url\(["']?#hf-mb-\d+["']?\)/g;
function baseFilter(el) {
return el.style.filter.replace(OWN_FILTER, "").trim();
}
function clearBlur(s) {
s.el.style.filter = baseFilter(s.el);
}
var scheduled = false;
function applyShutter() {
var shutterTime = shutterAngle / 360 / fps;
if (!(shutterTime > 0)) {
state.forEach(clearBlur);
return;
}
var t0 = tl.time();
var duration = tl.duration();
var windowStart = t0 + shutterPhase / 360 / fps;
// Sample the real trajectory: seek to each sub-frame time with events
// suppressed (so no tween callbacks, including this tracker's, fire),
// read positions, then restore the frame time.
try {
for (var k = 0; k < copies; k++) {
var tk = windowStart + (k / samples) * shutterTime;
tl.time(Math.min(duration, Math.max(0, tk)), true);
state.forEach(function (s) {
var pos = readPosition(s.el);
s.xs[k] = pos.x;
s.ys[k] = pos.y;
});
}
} finally {
tl.time(t0, true);
}
state.forEach(function (s) {
var current = readPosition(s.el);
var minDx = Infinity;
var maxDx = -Infinity;
var minDy = Infinity;
var maxDy = -Infinity;
for (var i = 0; i < copies; i++) {
var d = toFilterSpace(s.el, s.xs[i] - current.x, s.ys[i] - current.y);
var dx = d.dx;
var dy = d.dy;
s.offsets[i].setAttribute("dx", dx.toFixed(3));
s.offsets[i].setAttribute("dy", dy.toFixed(3));
if (dx < minDx) minDx = dx;
if (dx > maxDx) maxDx = dx;
if (dy < minDy) minDy = dy;
if (dy > maxDy) maxDy = dy;
}
// Below half a pixel of smear the average is indistinguishable from
// the source — render sharp instead of paying for the filter.
if (maxDx - minDx < 0.5 && maxDy - minDy < 0.5) {
clearBlur(s);
return;
}
// Grow the filter region to cover every copy AND the sharp instance the
// final composite draws at offset 0 (objectBoundingBox units, 10% padding
// so anti-aliased edges are not clipped). Clamping the bounds through 0
// matters whenever the window sits entirely on one side of the frame time
// — any shutterPhase above 0 or below -shutterAngle — because then no copy
// is at 0 and a region derived from the copies alone cuts the instance off.
var x0 = Math.min(0, minDx);
var x1 = Math.max(0, maxDx);
var y0 = Math.min(0, minDy);
var y1 = Math.max(0, maxDy);
var w = boxSize(s.el, true);
var h = boxSize(s.el, false);
s.filter.setAttribute("x", regionPercent(x0, w, -10));
s.filter.setAttribute("width", regionPercent(x1 - x0, w, 120));
s.filter.setAttribute("y", regionPercent(y0, h, -10));
s.filter.setAttribute("height", regionPercent(y1 - y0, h, 120));
var base = baseFilter(s.el);
s.el.style.filter = (base ? base + " " : "") + "url(#" + s.filterId + ")";
});
}
// tl.eventCallback("onUpdate") is unavailable under the HyperFrames runtime proxy, so a
// tracker tween's onUpdate fires on every seek. Sampling is deferred to a microtask:
// seeking GSAP from inside its own render reads stale tween state, while the microtask
// runs after the seek returns and before the frame is captured or painted.
var _proxy = { t: 0 };
tl.to(
_proxy,
@@ -450,95 +597,17 @@
duration: Math.max(tl.duration(), 0.1),
ease: "none",
onUpdate: function () {
var time = tl.time();
state.forEach(function (s) {
var x = parseFloat(gsap.getProperty(s.el, "x")) || 0;
var y = parseFloat(gsap.getProperty(s.el, "y")) || 0;
var dt = time - s.prevTime;
if (dt > 0.0005) {
var vx = axis !== "y" ? (x - s.prevX) / dt : 0;
var vy = axis !== "x" ? (y - s.prevY) / dt : 0;
var bx = Math.min(Math.abs(vx) * blurScale, blurMax);
var by = Math.min(Math.abs(vy) * blurScale, blurMax);
var bxFinal = axis !== "y" ? bx : Math.max(by * 0.08, 0.4);
var byFinal = axis !== "x" ? by : Math.max(bx * 0.08, 0.4);
var active = bx > 0.3 || by > 0.3;
if (active) {
s.el.style.filter = "url(#" + s.filterId + ")";
s.ghosts.forEach(function (g) {
var dx =
axis !== "y" ? (vx >= 0 ? -bxFinal * g.frac : bxFinal * g.frac) : 0;
var dy =
axis !== "x" ? (vy >= 0 ? -byFinal * g.frac : byFinal * g.frac) : 0;
g.feOff.setAttribute("dx", dx.toFixed(2));
g.feOff.setAttribute("dy", dy.toFixed(2));
var gbx =
axis !== "y"
? (bxFinal * g.frac * 0.5).toFixed(2)
: Math.max(byFinal * g.frac * 0.04, 0.4).toFixed(2);
var gby =
axis !== "x"
? (byFinal * g.frac * 0.5).toFixed(2)
: Math.max(bxFinal * g.frac * 0.04, 0.4).toFixed(2);
g.feGB.setAttribute("stdDeviation", gbx + " " + gby);
});
s.feTopBlur.setAttribute(
"stdDeviation",
(bxFinal * 0.15).toFixed(2) + " " + (byFinal * 0.15).toFixed(2),
);
if (stretchMax > 0) {
var sx = 1 + Math.min(Math.abs(vx) * stretchScale, stretchMax);
var sy = 1 + Math.min(Math.abs(vy) * stretchScale, stretchMax);
var ox = axis !== "y" ? (vx >= 0 ? "100% 50%" : "0% 50%") : "50% 50%";
var oy = axis !== "x" ? (vy >= 0 ? "50% 100%" : "50% 0%") : "50% 50%";
gsap.set(s.el, {
scaleX: axis !== "y" ? sx : 1,
scaleY: axis !== "x" ? sy : 1,
transformOrigin: axis === "x" ? ox : axis === "y" ? oy : "50% 50%",
});
}
} else {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
if (stretchMax > 0) {
gsap.set(s.el, { scaleX: 1, scaleY: 1, transformOrigin: "50% 50%" });
}
}
s.prevX = x;
s.prevY = y;
s.prevTime = time;
} else if (dt < -0.0005) {
s.el.style.filter = "";
s.ghosts.forEach(function (g) {
g.feOff.setAttribute("dx", "0");
g.feOff.setAttribute("dy", "0");
g.feGB.setAttribute("stdDeviation", "0 0");
});
s.feTopBlur.setAttribute("stdDeviation", "0 0");
if (stretchMax > 0) {
gsap.set(s.el, { scaleX: 1, scaleY: 1, transformOrigin: "50% 50%" });
}
s.prevX = x;
s.prevY = y;
s.prevTime = time;
}
// dt ≈ 0: runtime double-fires — skip.
if (scheduled) return;
scheduled = true;
Promise.resolve().then(function () {
scheduled = false;
applyShutter();
});
},
},
0,
);
}
};
/* ── Timeline ─────────────────────────────────────────────── */
var tl = gsap.timeline({ paused: true });
@@ -560,7 +629,7 @@
attachMotionBlur(
["#s1", "#s2", "#s3", "#s4", "#s5", "#t1", "#t2", "#t3", "#t4", "#t5"],
tl,
{ axis: "x", blurMax: 80, stretchMax: 0 },
{ axis: "x" },
);
window.__timelines = window.__timelines || {};
+1 -1
View File
@@ -1637,6 +1637,6 @@
}
],
"catalogArtifact": {
"revision": "b33b552d4f830abfcccb2bfe9495ac6284a7e7c05b3d3f4df630dfd5e753e518"
"revision": "34370735fa1a93099519873178e547c43b14f3bd69491013577515f4bb4f5957"
}
}