Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guides/motion-blur.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ That is all. The same attribute drives the browser editor preview and `hyperfram
| `data-motion-blur` | none | `on` enables blur. `off` opts out, on the root or on any subtree. |
| `data-motion-blur-shutter` | `240` | Shutter angle in degrees, 0–720. Larger is a longer smear. |
| `data-motion-blur-samples` | at least 32 | A fixed number of samples per frame, 2–64. Left unset, each mover gets at least 32, and about one per 2 px of smear when that is more, so a fast move never shows steps. |
| `data-motion-blur-events` | off | `on` runs timeline callbacks (`onUpdate` and similar) while blur looks for movers and at each sample. Needed when the motion is set from a callback, such as a `render(t)` function, which blur cannot otherwise see. |
| `data-motion-blur-events` | off | `on` runs timeline callbacks (`onUpdate` and similar) while blur looks for movers, and at each sample when a callback changes an element's inline style or its box. Needed when the motion is set from a callback, such as a `render(t)` function, which blur cannot otherwise see. |
| `data-motion-blur-tier` | `auto` | `capture` or `filter` forces one of the two ways blur is drawn (below). |

## In the browser editor
Expand Down
80 changes: 79 additions & 1 deletion packages/core/src/runtime/adapters/gsap.steps.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
// @vitest-environment happy-dom
import gsap from "gsap";
import { describe, expect, it } from "vitest";
import { createGsapAdapter, rerenderGsapTimelineAt, seekGsapTimeline } from "./gsap";
import {
createGsapAdapter,
holdFrameMarks,
holdLandingMarks,
rerenderGsapTimelineAt,
seekGsapTimeline,
} from "./gsap";
import type { GsapStructureKey } from "./gsap";
import type { RuntimeTimelineLike } from "../types";

Expand Down Expand Up @@ -771,3 +777,75 @@ describe("rerenderGsapTimelineAt with a structure key", () => {
}
});
});

describe("holdLandingMarks", () => {
const FPS = 30;
// Motion blur's pose reads seek silently across the shutter, then land back on the frame.
function readsAround(timeline: gsap.core.Timeline, t: number, render: boolean, hold: boolean) {
const tl = timeline as unknown as RuntimeTimelineLike;
const key: GsapStructureKey | undefined = render
? undefined
: { owner: timeline, version: "1" };
const put = hold ? holdLandingMarks(tl, key) : () => {};
for (const at of [t - 0.011, t - 0.004, t + 0.004, t + 0.011])
seekGsapTimeline(tl, at, true, key);
seekGsapTimeline(tl, t, true, key);
if (render) rerenderGsapTimelineAt(timeline, t);
put();
}
function play(timeline: gsap.core.Timeline, render: boolean, hold: boolean, frames: number) {
for (let frame = 0; frame <= frames; frame++) {
const t = frame / FPS;
seekGsapTimeline(timeline as unknown as RuntimeTimelineLike, t, false);
readsAround(timeline, t, render, hold);
}
}

it.each([false, true])(
"fires a call on a frame once across silent reads around it (render %s)",
(render) => {
const run = (hold: boolean) => {
let fires = 0;
let on = false;
const timeline = gsap.timeline({ paused: true });
timeline.to({}, { duration: 2 }, 0);
timeline.call(() => ((on = !on), fires++), [], 1);
play(timeline, render, hold, 45);
return { fires, on };
};
expect(run(false).fires).toBeGreaterThan(1);
expect(run(true)).toEqual({ fires: 1, on: true });
},
);

it("lands a render frame with the root drawn after its scenes, as the frame's own seek does", () => {
const shared = { x: 0 };
const scene = gsap.timeline({ paused: true });
scene.fromTo(shared, { x: 0 }, { x: 100, duration: 2, ease: "none" }, 0);
const root = gsap.timeline({ paused: true });
root.fromTo(shared, { x: 1000 }, { x: 2000, duration: 2, ease: "none" }, 0);
const tl = (timeline: gsap.core.Timeline) => timeline as unknown as RuntimeTimelineLike;
const seekBoth = (t: number, silent: boolean) => {
seekGsapTimeline(tl(scene), t, silent);
seekGsapTimeline(tl(root), t, silent);
};
seekBoth(0.5, false);
expect(shared.x).toBe(1250);
const land = holdFrameMarks({
scenes: [tl(scene)],
root: tl(root),
structureOf: () => undefined,
});
for (const at of [0.489, 0.496, 0.504, 0.511]) seekBoth(at, true);
land(() => seekBoth(0.5, true));
expect(shared.x).toBe(1250);
});

it("keeps two same-time sets in authored order after silent reads around a render frame", () => {
const value = { v: 0 };
const timeline = gsap.timeline({ paused: true });
timeline.to({}, { duration: 2 }, 0).set(value, { v: 1 }, 1).set(value, { v: 2 }, 1);
play(timeline, true, true, FPS);
expect(value.v).toBe(2);
});
});
47 changes: 47 additions & 0 deletions packages/core/src/runtime/adapters/gsap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,53 @@ function stepsAt(
return steps;
}

export function holdLandingMarks(
timeline: { getChildren?: RuntimeTimelineLike["getChildren"] },
structure?: GsapStructureKey,
): () => void {
const placed = structure ? placedLandingMarks(timeline, structure) : null;
const marks = placed
? [...placed.always, ...placed.points.map((point) => point.mark)]
: childrenWithLandingMarksIn(timeline);
const held = marks.map((mark) => [mark, mark.ratio, mark._zTime, mark._act] as const);
return () => {
for (const [mark, ratio, zTime, active] of held) {
mark.ratio = ratio;
mark._zTime = zTime;
mark._act = active;
}
};
}

export function holdFrameMarks({
scenes,
root,
structureOf,
}: {
scenes: RuntimeTimelineLike[];
root: RuntimeTimelineLike | null;
structureOf: (timeline: RuntimeTimelineLike) => GsapStructureKey | undefined;
}): (landBack: () => void) => void {
const rootLandsLast = root ? [...scenes, root] : scenes;
const marks = rootLandsLast.map((timeline) => holdLandingMarks(timeline, structureOf(timeline)));
return (landBack) => {
landBack();
for (const timeline of rootLandsLast) {
if (timeline.totalTime && !structureOf(timeline)) {
rerenderGsapTimelineAt(
{
totalTime: timeline.totalTime.bind(timeline),
totalDuration: timeline.totalDuration?.bind(timeline),
getChildren: timeline.getChildren?.bind(timeline),
},
Number(timeline.totalTime()),
);
}
}
for (const put of marks) put();
};
}

function landingMarksOf(
timeline: { getChildren?: RuntimeTimelineLike["getChildren"] },
structure: GsapStructureKey | undefined,
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/runtime/captureMotionBlur/discover.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ export interface DiscoverOptions {
/** Seek the composition (timeline + adapters) to a time. */
seek: (t: number) => void;
onStep?: (i: number, steps: number) => void;
onStepBoxes?: (i: number, boxes: ReadonlyArray<DOMRect | undefined>) => void;
}

export interface Discovery {
Expand Down Expand Up @@ -187,6 +188,7 @@ export function discoverMovers({
fps,
seek,
onStep,
onStepBoxes,
}: DiscoverOptions): Discovery {
seek(0);
const all = [...scene.querySelectorAll("*")].filter(isHtmlElement);
Expand Down Expand Up @@ -230,6 +232,7 @@ export function discoverMovers({
dynamic.add(el);
rects[j] = el.getBoundingClientRect();
});
onStepBoxes?.(i, rects);
const away = fresh.filter(
(j) => edgeShift(baseRects[j], rects[j]) / framesPerStep >= MIN_SPEED,
);
Expand Down Expand Up @@ -270,3 +273,51 @@ export function discoverMovers({
const translationOnly = movers.length > 0 && movers.every((el) => !notPlain.has(el));
return { movers, dynamic, pads, translationOnly };
}

export interface ScanOptions extends Omit<DiscoverOptions, "seek"> {
seek: (t: number, suppressEvents: boolean) => void;
events: boolean;
}

/** Every changing element's box, at 1/64 px, as one number per step. */
function boxesKey(boxes: ReadonlyArray<DOMRect | undefined>): number {
let h = 0x811c9dc5;
const mix = (v: number): void => {
h = Math.imul(h ^ v, 0x01000193);
};
boxes.forEach((b, j) => {
if (!b) return;
for (const v of [j, b.left, b.top, b.width, b.height]) mix(Math.round(v * 64));
});
return h;
}

/** Discovery, and whether pose reads need callbacks: when they change an element's style or box. */
export function scanMovers({
seek,
events,
onStep,
...scan
}: ScanOptions): Discovery & { readEvents: boolean } {
const quiet = (t: number): void => seek(t, true);
if (!events) return { ...discoverMovers({ ...scan, seek: quiet, onStep }), readEvents: false };
// Loud first: run after the silent scan, it starts at the end and fires a one-off call once more.
const loud: number[] = [];
const found = discoverMovers({
...scan,
seek: (t) => seek(t, false),
onStep,
onStepBoxes: (i, boxes) => {
loud[i] = boxesKey(boxes);
},
});
let differs = false;
discoverMovers({
...scan,
seek: quiet,
onStepBoxes: (i, boxes) => {
differs ||= boxesKey(boxes) !== loud[i];
},
});
return { ...found, readEvents: differs };
}
13 changes: 9 additions & 4 deletions packages/core/src/runtime/captureMotionBlur/filterTier.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import { swallow } from "../diagnostics";
import { discoverMovers } from "./discover";
import { scanMovers } from "./discover";
import { planFilter, type FilterPlan } from "./filterPlan";
import { readMoverFrames } from "./frames";
import { createPoseReader } from "./pose";
Expand Down Expand Up @@ -105,6 +105,8 @@ export function createFilterTier(
const width = Number(root.getAttribute("data-width")) || root.offsetWidth;
const height = Number(root.getAttribute("data-height")) || root.offsetHeight;
const seek = (t: number): void => host.seek(t, !config.events);
let readEvents = false;
const readSeek = (t: number): void => host.seek(t, !readEvents);
let defs: SVGSVGElement | null = null;
let movers: HTMLElement[] = [];
let pads = new Map<HTMLElement, number>();
Expand All @@ -121,13 +123,15 @@ export function createFilterTier(
function discover(restoreTo: number): void {
scanned = host.timeline();
const s0 = performance.now();
const found = discoverMovers({
const found = scanMovers({
scene: root,
durationSeconds: host.durationSeconds(),
fps: host.fps,
seek,
seek: host.seek,
events: config.events,
});
seek(restoreTo);
readEvents = found.readEvents;
movers = found.movers;
pads = found.pads;
reader = createPoseReader(root, found.dynamic);
Expand Down Expand Up @@ -178,7 +182,8 @@ export function createFilterTier(
const read = readMoverFrames({
movers,
reader,
seek,
seek: readSeek,
restore: readEvents ? undefined : host.holdFrame?.(),
t,
reads: engineMode ? RENDER_READS : PREVIEW_READS,
shutterSeconds: config.shutterDeg / 360 / host.fps,
Expand Down
3 changes: 2 additions & 1 deletion packages/core/src/runtime/captureMotionBlur/frames.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export interface FrameRequest {
movers: HTMLElement[];
reader: ReturnType<typeof createPoseReader>;
seek: (t: number) => void;
restore?: (t: number) => void;
t: number;
/** Pose reads across the shutter; the taps between them are interpolated. */
reads: number;
Expand All @@ -40,7 +41,7 @@ export function readMoverFrames(req: FrameRequest): { els: HTMLElement[]; frames
r.reset();
visible.forEach((el, i) => poses[i]?.push(r.pose(el)));
}
seek(t);
(req.restore ?? seek)(t);
r.reset();
const frames = visible.map((el, i) => {
const pad = req.pads?.get(el) ?? 0;
Expand Down
Loading
Loading