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
4 changes: 4 additions & 0 deletions docs/guides/motion-blur.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,10 @@ That is all. The same attribute drives Studio preview and `hyperframes render`.
| `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-tier` | `auto` | `capture` or `filter` forces one of the two ways blur is drawn (below). |

## In Studio

The **Blur** tab in Studio's right dock sets it with one control: **Off**, **Subtle** or **Strong**. Subtle is the default blur (240° shutter, samples sized to the smear). Strong opens the shutter to 720° and the samples to 64. The panel writes the attributes above to the composition root, so a render uses the same setting, and Undo works. Off removes `data-motion-blur` and leaves the other attributes where they are.

## What it blurs

Elements that move quickly in 2D: position, scale and rotation changes from GSAP, CSS animations and transitions, and the Web Animations API. Slow movers and static content are left sharp. A mover's glow and shadow travel with it, and the background behind it stays where it is.
Expand Down
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,12 @@
"types": "./dist/htmlInCanvas.d.ts",
"environments": ["browser", "bun", "node"]
},
"./motion-blur-settings": {
"source": "./src/motionBlurSettings.ts",
"runtime": "./dist/motionBlurSettings.js",
"types": "./dist/motionBlurSettings.d.ts",
"environments": ["browser", "bun", "node"]
},
"./gsap-cdn": {
"source": "./src/gsapCdn.ts",
"runtime": "./dist/gsapCdn.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@
"import": "./src/htmlInCanvas.ts",
"types": "./src/htmlInCanvas.ts"
},
"./motion-blur-settings": {
"bun": "./src/motionBlurSettings.ts",
"node": "./dist/motionBlurSettings.js",
"import": "./src/motionBlurSettings.ts",
"types": "./src/motionBlurSettings.ts"
},
"./gsap-cdn": {
"bun": "./src/gsapCdn.ts",
"node": "./dist/gsapCdn.js",
Expand Down Expand Up @@ -624,6 +630,10 @@
"import": "./dist/htmlInCanvas.js",
"types": "./dist/htmlInCanvas.d.ts"
},
"./motion-blur-settings": {
"import": "./dist/motionBlurSettings.js",
"types": "./dist/motionBlurSettings.d.ts"
},
"./gsap-cdn": {
"import": "./dist/gsapCdn.js",
"types": "./dist/gsapCdn.d.ts"
Expand Down
107 changes: 107 additions & 0 deletions packages/core/src/motionBlurSettings.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { describe, expect, it } from "vitest";
import {
MOTION_BLUR_ATTR,
type MotionBlurSettings,
motionBlurAttributeEdits,
readMotionBlurSettings,
} from "./motionBlurSettings";

const read = (attrs: Record<string, string>) => readMotionBlurSettings((n) => attrs[n] ?? null);

describe("readMotionBlurSettings", () => {
it("is off, 240 degrees, auto samples, auto tier and no events when nothing is set", () => {
expect(read({})).toEqual({
enabled: false,
shutterDeg: 240,
samples: null,
events: false,
tier: "auto",
});
});

it("reads each attribute", () => {
expect(
read({
"data-motion-blur": "on",
"data-motion-blur-shutter": "360",
"data-motion-blur-samples": "48",
"data-motion-blur-events": "on",
"data-motion-blur-tier": "capture",
}),
).toEqual({ enabled: true, shutterDeg: 360, samples: 48, events: true, tier: "capture" });
});

it("clamps the shutter and the samples, and rounds the samples", () => {
expect(read({ "data-motion-blur-shutter": "9000" }).shutterDeg).toBe(720);
expect(read({ "data-motion-blur-shutter": "-5" }).shutterDeg).toBe(0);
expect(read({ "data-motion-blur-samples": "1000" }).samples).toBe(64);
expect(read({ "data-motion-blur-samples": "0" }).samples).toBe(2);
expect(read({ "data-motion-blur-samples": "10.6" }).samples).toBe(11);
});

it("falls back to the defaults for text that is not a number or a known tier", () => {
const s = read({
"data-motion-blur-shutter": "wide",
"data-motion-blur-samples": "",
"data-motion-blur-tier": "sideways",
});
expect(s).toMatchObject({ shutterDeg: 240, samples: null, tier: "auto" });
});

it("is only on for exactly on", () => {
expect(read({ "data-motion-blur": "true" }).enabled).toBe(false);
expect(read({ "data-motion-blur": "off" }).enabled).toBe(false);
});
});

describe("motionBlurAttributeEdits", () => {
const custom: MotionBlurSettings = {
enabled: true,
shutterDeg: 360,
samples: 48,
events: true,
tier: "filter",
};

it("sets what differs from the defaults and removes what does not", () => {
expect(motionBlurAttributeEdits(custom)).toEqual({
"data-motion-blur": "on",
"data-motion-blur-shutter": "360",
"data-motion-blur-samples": "48",
"data-motion-blur-events": "on",
"data-motion-blur-tier": "filter",
});
expect(
motionBlurAttributeEdits({
enabled: false,
shutterDeg: 240,
samples: null,
events: false,
tier: "auto",
}),
).toEqual({
"data-motion-blur": null,
"data-motion-blur-shutter": null,
"data-motion-blur-samples": null,
"data-motion-blur-events": null,
"data-motion-blur-tier": null,
});
});

it("writes only attributes the reader knows", () => {
expect(Object.keys(motionBlurAttributeEdits(custom)).sort()).toEqual(
Object.values(MOTION_BLUR_ATTR).sort(),
);
});

it("round-trips: what it writes reads back as the same settings", () => {
for (const s of [
custom,
{ ...custom, shutterDeg: 240, samples: null, tier: "auto" as const },
]) {
const written = motionBlurAttributeEdits(s);
const back = readMotionBlurSettings((n) => written[n] ?? null);
expect(back).toEqual(s);
}
});
});
65 changes: 65 additions & 0 deletions packages/core/src/motionBlurSettings.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
/** The attributes that configure motion blur on a composition root: names, ranges, defaults, parsing. */

export const MOTION_BLUR_ATTR = {
enable: "data-motion-blur",
shutter: "data-motion-blur-shutter",
samples: "data-motion-blur-samples",
events: "data-motion-blur-events",
tier: "data-motion-blur-tier",
} as const;

export const MOTION_BLUR_LIMITS = {
shutterDeg: { min: 0, max: 720, default: 240 },
samples: { min: 2, max: 64 },
} as const;

export type MotionBlurTier = "auto" | "capture" | "filter";

export interface MotionBlurSettings {
enabled: boolean;
shutterDeg: number;
/** A fixed count per frame; null sizes it to each smear, never under 32. */
samples: number | null;
events: boolean;
tier: MotionBlurTier;
}

const clamp = (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x));

function number(value: string | null): number | null {
if (value === null || value.trim() === "") return null;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}

export function readMotionBlurSettings(attr: (name: string) => string | null): MotionBlurSettings {
const shutter = number(attr(MOTION_BLUR_ATTR.shutter));
const samples = number(attr(MOTION_BLUR_ATTR.samples));
const tier = attr(MOTION_BLUR_ATTR.tier);
const { shutterDeg, samples: bounds } = MOTION_BLUR_LIMITS;
return {
enabled: attr(MOTION_BLUR_ATTR.enable) === "on",
shutterDeg: clamp(shutter ?? shutterDeg.default, shutterDeg.min, shutterDeg.max),
samples: samples === null ? null : Math.round(clamp(samples, bounds.min, bounds.max)),
events: attr(MOTION_BLUR_ATTR.events) === "on",
tier: tier === "capture" || tier === "filter" ? tier : "auto",
};
}

/**
* The attribute writes that make `settings` true on a root: a value to set, or null to remove.
* A setting at its default is removed, so a root carries only what the author changed.
*/
export function motionBlurAttributeEdits(
settings: MotionBlurSettings,
): Record<string, string | null> {
const { shutterDeg } = MOTION_BLUR_LIMITS;
return {
[MOTION_BLUR_ATTR.enable]: settings.enabled ? "on" : null,
[MOTION_BLUR_ATTR.shutter]:
settings.shutterDeg === shutterDeg.default ? null : String(settings.shutterDeg),
[MOTION_BLUR_ATTR.samples]: settings.samples === null ? null : String(settings.samples),
[MOTION_BLUR_ATTR.events]: settings.events ? "on" : null,
[MOTION_BLUR_ATTR.tier]: settings.tier === "auto" ? null : settings.tier,
};
}
24 changes: 5 additions & 19 deletions packages/core/src/runtime/captureMotionBlur/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
} from "../../pageMotionBlur";
import { swallow } from "../diagnostics";
import { createCompositor, type MoverFrame } from "./composite";
import { readMotionBlurSettings } from "../../motionBlurSettings";
import { discoverMovers } from "./discover";
import { createFilterTier } from "./filterTier";
import { readMoverFrames } from "./frames";
Expand Down Expand Up @@ -89,26 +90,11 @@ const PAINT_TIMEOUT_MS = 2000;
const MAX_FAILURES = 3;
const LOG_EVERY = 25;

const clamp = (x: number, lo: number, hi: number): number => Math.min(hi, Math.max(lo, x));

function tierAttribute(value: string | null): Config["tier"] {
return value === "capture" || value === "filter" ? value : "auto";
}

function readConfig(root: HTMLElement): Config | null {
if (root.getAttribute("data-motion-blur") !== "on") return null;
const num = (name: string, fallback: number): number => {
const v = Number(root.getAttribute(name));
return root.hasAttribute(name) && Number.isFinite(v) ? v : fallback;
};
return {
shutterDeg: clamp(num("data-motion-blur-shutter", 240), 0, 720),
samples: root.hasAttribute("data-motion-blur-samples")
? Math.round(clamp(num("data-motion-blur-samples", 16), 2, 64))
: null,
events: root.getAttribute("data-motion-blur-events") === "on",
tier: tierAttribute(root.getAttribute("data-motion-blur-tier")),
};
const settings = readMotionBlurSettings((name) => root.getAttribute(name));
if (!settings.enabled) return null;
const { shutterDeg, samples, events, tier } = settings;
return { shutterDeg, samples, events, tier };
}

/** Must run before composition scripts: see `installAcceleratedCanvasTracker`. */
Expand Down
9 changes: 9 additions & 0 deletions packages/studio/src/components/StudioRightPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { BlockParamsPanel } from "./editor/BlockParamsPanel";
import { RenderQueuePanel } from "./renders/RenderQueuePanel";
import { SlideshowPanel } from "./panels/SlideshowPanel";
import { VariablesPanel } from "./panels/VariablesPanel";
import { MotionBlurPanel } from "./panels/MotionBlurPanel";
import { Dock } from "./dock/Dock";
import { useDockLayoutStore } from "./dock/dockLayoutStore";
import type { RenderJob } from "./renders/useRenderQueue";
Expand Down Expand Up @@ -300,6 +301,14 @@ export const StudioRightPanels = memo(function StudioRightPanels({
recordEdit={recordEdit}
/>
</Dock.Panel>
<Dock.Panel id="motionBlur">
<MotionBlurPanel
sdkSession={sdkSession}
publishSdkSession={publishSdkSession}
reloadPreview={reloadPreview}
recordEdit={recordEdit}
/>
</Dock.Panel>
<Dock.Panel id="slideshow">
<SlideshowPanel
scenes={slideshowScenes}
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/components/dock/DockTab.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useCallback, useSyncExternalStore } from "react";
import {
Aperture,
BracketsCurly,
ChartBarHorizontal,
Code,
Expand Down Expand Up @@ -29,6 +30,7 @@ const TAB_ICONS: Record<PanelId, Icon> = {
renders: FilmSlate,
variables: BracketsCurly,
slideshow: Presentation,
motionBlur: Aperture,
};

/**
Expand Down
6 changes: 3 additions & 3 deletions packages/studio/src/components/dock/dockAccessibility.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,9 @@ describe("dock tab strips", () => {
it("jump to the first and last tab with Home and End", () => {
const tabs = tabsOf("design");
press(tabs[0] as HTMLElement, "End");
expect(api.getPanel("design")?.group.activePanel?.id).toBe("variables");
expect(document.activeElement).toBe(tabs[3]);
press(tabs[3] as HTMLElement, "Home");
expect(api.getPanel("design")?.group.activePanel?.id).toBe("motionBlur");
expect(document.activeElement).toBe(tabs[4]);
press(tabs[4] as HTMLElement, "Home");
expect(api.getPanel("design")?.group.activePanel?.id).toBe("design");
});
});
2 changes: 1 addition & 1 deletion packages/studio/src/components/dock/dockLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const MIN_SIDE_W = 200;
const MIN_SIDE_W_FLOOR = 120;
const DEFAULT_TIMELINE_H = 360;
const DEFAULT_LEFT = ["compositions", "assets", "code", "catalog"] as const;
const DEFAULT_RIGHT = ["design", "layers", "renders", "variables"] as const;
const DEFAULT_RIGHT = ["design", "layers", "renders", "variables", "motionBlur"] as const;

/** Preferred side widths; when they overflow the preview's floor the right yields first, then the left. */
export function defaultSideWidths(viewportWidth: number) {
Expand Down
6 changes: 6 additions & 0 deletions packages/studio/src/components/dock/panelRegistry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ export const PANEL_IDS = [
"renders",
"variables",
"slideshow",
"motionBlur",
] as const;

export type PanelId = (typeof PANEL_IDS)[number];
Expand Down Expand Up @@ -56,6 +57,11 @@ export const PANEL_DEFINITIONS = {
renders: { title: "Renders", zone: "right", reopen: { near: "design", direction: "within" } },
variables: { title: "Variables", zone: "right", reopen: { near: "design", direction: "within" } },
slideshow: { title: "Slideshow", zone: "right", reopen: { near: "design", direction: "within" } },
motionBlur: {
title: "Blur",
zone: "right",
reopen: { near: "design", direction: "within" },
},
} as const satisfies Record<PanelId, PanelDefinition>;

export function isPanelId(value: unknown): value is PanelId {
Expand Down
Loading
Loading