Files
hyperframes/docs/catalog/components/motion-blur.mdx
miga-heygenandMiguel Ángel 9aa60a844f 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>
2026-09-17 00:51:03 +00:00

445 lines
22 KiB
Plaintext
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
title: "Motion Blur"
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";
## Install
<InstallCommand command="npx hyperframes add motion-blur" item="motion-blur" />
That writes one file: `compositions/components/motion-blur.html`.
<iframe
className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800"
title="motion-blur preview"
loading="lazy"
srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@latest/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/components/motion-blur.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/motion-blur.png");document.body.appendChild(p)});<\/script></body></html>`}
/>
## Source
<Accordion title="motion-blur.html">
```html
<!--
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.
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:
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. 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:
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 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) {
if (typeof s === "string") {
document.querySelectorAll(s).forEach(function (el) {
acc.push(el);
});
} else if (s instanceof Element) {
acc.push(s);
}
return acc;
}, []);
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");
svg.setAttribute("style", "position:absolute;width:0;height:0;overflow:hidden;");
var filter = document.createElementNS(ns, "filter");
filter.id = uid;
filter.setAttribute("color-interpolation-filters", "sRGB");
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", "s" + k);
filter.appendChild(feOff);
offsets.push(feOff);
}
// 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, 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,
{
t: 1,
duration: Math.max(tl.duration(), 0.1),
ease: "none",
onUpdate: function () {
if (scheduled) return;
scheduled = true;
Promise.resolve().then(function () {
scheduled = false;
applyShutter();
});
},
},
0,
);
};
})();
</script>
<!--
Timeline integration example:
const tl = gsap.timeline({ paused: true });
tl.fromTo("#my-box", { x: -100 }, { x: 1700, duration: 1.2, ease: "power3.inOut" }, 0.5);
// Extend to data-duration so seeks past the last tween reach the blur callback.
tl.set(document.body, {}, DATA_DURATION);
// 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, { shutterAngle: 720, samplesPerFrame: 16 });
window.__timelines = window.__timelines || {};
window.__timelines["my-composition"] = tl;
-->
```
</Accordion>
## Usage
Paste the snippet into your composition, then call `attachMotionBlur()` after your GSAP tweens and before registering `window.__timelines`.
```html
<!-- Extend the timeline to data-duration before calling attachMotionBlur -->
tl.set(document.body, {}, DATA_DURATION);
attachMotionBlur("#my-box", tl, {
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 || {};
window.__timelines["my-composition"] = tl;
```
## How it works
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. **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.
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 |
| --- | --- | --- |
| `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` `shutter` `after-effects` `velocity` `animation`.
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
- [Build a richer composition](/go-further)