feat(studio): motion blur panel with Off / Subtle / Strong (#6112)

* feat(studio): motion blur panel with Off / Subtle / Strong

One three-way control in the right dock. Subtle is the default blur
(240 deg shutter, samples sized to the smear); Strong is 720 deg and 64
samples. Writes the root data-motion-blur attributes through the SDK so
renders match and undo works; reads live attributes from the preview.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(studio): motion blur panel edits the file's own root

The edits were worked out against the preview DOM but written to the file,
so a stale preview (undo inside the hold, a slow reload) could save a level
other than the one picked. They are now computed inside the mutation from
the candidate's root. While locked to a sub-composition no level is shown.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

* fix(studio): shorten the motion blur tab so the dock strip fits

Five tabs overflowed the default dock width by 10px and clipped a label
(Studio: load smoke). The tab is now titled Blur.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Vance Ingalls
2026-10-23 17:29:42 -07:00
committed by GitHub
co-authored by Claude Sonnet 5.5
parent d5526bd480
commit ffc327b44c
18 changed files with 810 additions and 26 deletions
+4
View File
@@ -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.
+6
View File
@@ -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",
+10
View File
@@ -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",
@@ -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"
@@ -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
View File
@@ -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,
};
}
@@ -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";
@@ -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`. */
@@ -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";
@@ -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}
@@ -1,5 +1,6 @@
import { useCallback, useSyncExternalStore } from "react";
import {
Aperture,
BracketsCurly,
ChartBarHorizontal,
Code,
@@ -29,6 +30,7 @@ const TAB_ICONS: Record<PanelId, Icon> = {
renders: FilmSlate,
variables: BracketsCurly,
slideshow: Presentation,
motionBlur: Aperture,
};
/**
@@ -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");
});
});
@@ -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) {
@@ -12,6 +12,7 @@ export const PANEL_IDS = [
"renders",
"variables",
"slideshow",
"motionBlur",
] as const;
export type PanelId = (typeof PANEL_IDS)[number];
@@ -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 {
@@ -0,0 +1,177 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { openComposition } from "@hyperframes/sdk";
import { MotionBlurPanel } from "./MotionBlurPanel";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const page = (attrs = "") =>
`<!DOCTYPE html><html><body><div data-composition-id="main" data-hf-root data-duration="5" data-width="1920" data-height="1080"${attrs}></div></body></html>`;
const shell = {
activeCompPath: null as string | null,
previewIframeRef: { current: null as HTMLIFrameElement | null },
showToast: vi.fn(),
};
vi.mock("../../contexts/StudioContext", () => ({
useStudioShellContext: () => shell,
useStudioPlaybackContext: () => ({ refreshKey: 0 }),
}));
const files: Record<string, string> = {};
const writeProjectFile = vi.fn(async (path: string, content: string) => {
files[path] = content;
});
vi.mock("../../contexts/FileManagerContext", () => ({
useFileManagerContext: () => ({
readProjectFile: async (path: string) => files[path] ?? "",
writeProjectFile,
compositions: ["index.html", "scene.html"],
}),
}));
let root: Root | null = null;
const recordEdit = vi.fn(async () => {});
function cleanup() {
act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
}
afterEach(() => {
cleanup();
shell.activeCompPath = null;
shell.previewIframeRef.current = null;
vi.clearAllMocks();
});
async function mount(attrs = "") {
files["index.html"] = page(attrs);
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const session = await openComposition(files["index.html"], { history: false });
await act(async () => {
root?.render(
<MotionBlurPanel
sdkSession={session}
publishSdkSession={vi.fn()}
reloadPreview={vi.fn()}
recordEdit={recordEdit}
/>,
);
});
return host;
}
const settle = () => act(async () => void (await new Promise((r) => setTimeout(r, 0))));
const click = async (el: Element | null) => {
await act(async () => (el as HTMLElement).click());
await settle();
};
const radio = (host: HTMLElement, label: string) =>
[...host.querySelectorAll('[role="radio"]')].find((el) => el.textContent === label) ?? null;
const checked = (host: HTMLElement) =>
[...host.querySelectorAll('[role="radio"]')]
.filter((el) => el.getAttribute("aria-checked") === "true")
.map((el) => el.textContent);
describe("MotionBlurPanel", () => {
it("offers Off, Subtle and Strong and nothing else", async () => {
const host = await mount();
expect([...host.querySelectorAll('[role="radio"]')].map((el) => el.textContent)).toEqual([
"Off",
"Subtle",
"Strong",
]);
expect(
host.querySelectorAll('[role="slider"], [role="switch"], [role="combobox"], input'),
).toHaveLength(0);
});
it("shows the level the root is at", async () => {
expect(checked(await mount())).toEqual(["Off"]);
cleanup();
expect(checked(await mount(' data-motion-blur="on"'))).toEqual(["Subtle"]);
cleanup();
expect(
checked(
await mount(
' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"',
),
),
).toEqual(["Strong"]);
});
it("Subtle writes data-motion-blur and leaves the default shutter off the root", async () => {
const host = await mount(
' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"',
);
await click(radio(host, "Subtle"));
const html = files["index.html"] ?? "";
expect(html).toContain('data-motion-blur="on"');
expect(html).not.toContain("data-motion-blur-shutter");
expect(html).not.toContain("data-motion-blur-samples");
expect(recordEdit).toHaveBeenCalledTimes(1);
});
it("Strong writes the maximum shutter and samples", async () => {
const host = await mount();
await click(radio(host, "Strong"));
const html = files["index.html"] ?? "";
expect(html).toContain('data-motion-blur="on"');
expect(html).toContain('data-motion-blur-shutter="720"');
expect(html).toContain('data-motion-blur-samples="64"');
});
it("Off removes data-motion-blur and leaves every other setting on the root", async () => {
const host = await mount(
' data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64" data-motion-blur-tier="filter" data-motion-blur-events="on"',
);
await click(radio(host, "Off"));
const html = files["index.html"] ?? "";
expect(html).not.toContain('data-motion-blur="on"');
for (const kept of ['shutter="720"', 'samples="64"', 'tier="filter"', 'events="on"']) {
expect(html).toContain(`data-motion-blur-${kept}`);
}
});
it("writes nothing when the chosen level is already set", async () => {
const host = await mount(' data-motion-blur="on"');
await click(radio(host, "Subtle"));
expect(writeProjectFile).not.toHaveBeenCalled();
});
it("follows the live preview over the session, so an undo or an outside edit shows", async () => {
const doc = document.implementation.createHTMLDocument("preview");
doc.body.innerHTML =
'<div data-composition-id="main" data-motion-blur="on" data-motion-blur-shutter="720"></div>';
shell.previewIframeRef.current = { contentDocument: doc } as unknown as HTMLIFrameElement;
expect(checked(await mount(' data-motion-blur="on"'))).toEqual(["Strong"]);
});
it("writes the level picked even when the preview and the file disagree", async () => {
const doc = document.implementation.createHTMLDocument("preview");
doc.body.innerHTML =
'<div data-composition-id="main" data-motion-blur="on" data-motion-blur-shutter="720" data-motion-blur-samples="64"></div>';
shell.previewIframeRef.current = { contentDocument: doc } as unknown as HTMLIFrameElement;
const host = await mount();
await click(radio(host, "Subtle"));
expect(files["index.html"]).toContain('data-motion-blur="on"');
expect(files["index.html"]).not.toContain("data-motion-blur-samples");
});
it("is read-only for a sub-composition, which blur does not read", async () => {
shell.activeCompPath = "scene.html";
const host = await mount();
expect(host.textContent).toContain("read from the main composition");
expect(radio(host, "Strong")?.hasAttribute("disabled")).toBe(true);
expect(checked(host)).toEqual([]);
});
});
@@ -0,0 +1,163 @@
import { memo, useCallback, useEffect, useMemo, useReducer, useRef, useState } from "react";
import type { Composition } from "@hyperframes/sdk";
import type { MotionBlurSettings } from "@hyperframes/core/motion-blur-settings";
import { useStudioPlaybackContext, useStudioShellContext } from "../../contexts/StudioContext";
import { useFileManagerContext } from "../../contexts/FileManagerContext";
import {
useVariablesPersist,
type UseVariablesPersistParams,
} from "../../hooks/useVariablesPersist";
import { resolveMasterCompositionPath } from "../../utils/studioUrlState";
import { Button } from "../ui";
import {
BLUR_LEVELS,
changedEdits,
findCompositionRoot,
levelOf,
patchForLevel,
readPreviewRootAttributes,
sameAttributes,
settingsOf,
withEdits,
type BlurLevel,
type RootAttributes,
} from "./motionBlurPanelModel";
export interface MotionBlurPanelProps {
sdkSession: Composition | null;
publishSdkSession?: UseVariablesPersistParams["publishSdkSession"];
reloadPreview: () => void;
recordEdit: UseVariablesPersistParams["recordEdit"];
}
const POLL_MS = 500;
/** After a write the preview is reloading: keep what was just written instead of the old document. */
const SETTLE_MS = 1500;
/** Thrown from the edit when the file already holds the level, so nothing is written or recorded for undo. */
class NothingToChange extends Error {}
/** The preview's own root attributes, polled; `hold` pins an optimistic value until the preview catches up. */
function useLivePreviewAttributes(
previewIframeRef: { current: HTMLIFrameElement | null },
refreshKey: number,
) {
const [live, setLive] = useState<RootAttributes | null>(null);
const holdUntil = useRef(0);
useEffect(() => {
const read = () => {
const found = readPreviewRootAttributes(previewIframeRef.current);
if (found && Date.now() >= holdUntil.current) {
setLive((current) => (sameAttributes(current, found) ? current : found));
}
};
read();
const timer = setInterval(read, POLL_MS);
return () => clearInterval(timer);
}, [previewIframeRef, refreshKey]);
const hold = useCallback((next: (current: RootAttributes | null) => RootAttributes) => {
holdUntil.current = Date.now() + SETTLE_MS;
setLive(next);
}, []);
return [live, hold] as const;
}
export const MotionBlurPanel = memo(function MotionBlurPanel({
sdkSession,
publishSdkSession,
reloadPreview,
recordEdit,
}: MotionBlurPanelProps) {
const { activeCompPath, previewIframeRef, showToast } = useStudioShellContext();
const { refreshKey } = useStudioPlaybackContext();
const { readProjectFile, writeProjectFile, compositions } = useFileManagerContext();
const [, rerender] = useReducer((n: number) => n + 1, 0);
const [live, hold] = useLivePreviewAttributes(previewIframeRef, refreshKey);
useEffect(() => sdkSession?.on("change", rerender), [sdkSession]);
const root = sdkSession ? findCompositionRoot(sdkSession.getRootElements()) : null;
// The preview's own root is the truth after an undo or an outside edit, which the shared session does not follow.
const attributes = live ?? root?.attributes ?? null;
const settings = useMemo(() => (attributes ? settingsOf(attributes) : null), [attributes]);
const isMain =
activeCompPath == null || activeCompPath === resolveMasterCompositionPath(compositions);
const persist = useVariablesPersist({
sdkSession,
activeCompPath,
readProjectFile,
writeProjectFile,
recordEdit,
reloadPreview,
publishSdkSession,
});
const apply = useCallback(
async (patch: Partial<MotionBlurSettings>) => {
if (!attributes) return;
// The write lands on the file's own root, so the edits are worked out there, not from the preview.
let applied: Array<{ name: string; value: string | null }> = [];
try {
await persist("Edit motion blur", (session) => {
const target = findCompositionRoot(session.getRootElements());
if (!target) return;
const next = { ...settingsOf(target.attributes), ...patch };
applied = changedEdits(target.attributes, next);
if (applied.length === 0) throw new NothingToChange();
for (const e of applied) {
session.dispatch({
type: "setAttribute",
target: target.id,
name: e.name,
value: e.value,
});
}
});
hold((current) => withEdits(current ?? attributes, applied));
rerender();
} catch (err) {
if (err instanceof NothingToChange) return;
showToast(err instanceof Error ? err.message : String(err), "error");
}
},
[attributes, persist, hold, showToast],
);
if (!settings) {
return (
<p className="p-3 text-[10px] leading-relaxed text-neutral-500">
Open a composition to set up motion blur.
</p>
);
}
const locked = !isMain;
// A sub-composition's own root is not the setting being edited, so no level is shown while locked.
const current = locked ? null : levelOf(settings);
return (
<div className="flex h-full flex-col gap-3 overflow-y-auto p-3 text-[11px] text-neutral-300">
<span className="text-[12px] font-medium text-neutral-100">Motion blur</span>
<div role="radiogroup" aria-label="Motion blur" className="flex gap-1.5">
{BLUR_LEVELS.map(({ level, label }) => (
<Button
key={level}
role="radio"
aria-checked={current === level}
size="sm"
variant={current === level ? "secondary" : "ghost"}
className="flex-1"
disabled={locked}
onClick={() => void apply(patchForLevel(level as BlurLevel))}
>
{label}
</Button>
))}
</div>
{locked ? (
<p className="text-[10px] leading-relaxed text-amber-400/90">
Motion blur is read from the main composition. Open it to change this setting.
</p>
) : null}
</div>
);
});
@@ -0,0 +1,143 @@
// @vitest-environment happy-dom
import { describe, expect, it } from "vitest";
import { openComposition } from "@hyperframes/sdk";
import {
changedEdits,
findCompositionRoot,
readPreviewRootAttributes,
sameAttributes,
levelOf,
patchForLevel,
settingsOf,
withEdits,
} from "./motionBlurPanelModel";
const html = (attrs = "") =>
`<!DOCTYPE html><html><body><div data-composition-id="main" data-hf-root data-duration="5" data-width="1920" data-height="1080"${attrs}></div></body></html>`;
async function rootOf(attrs?: string) {
const session = await openComposition(html(attrs), { history: false });
const root = findCompositionRoot(session.getRootElements());
if (!root) throw new Error("no root");
return root;
}
describe("findCompositionRoot and settingsOf", () => {
it("finds the composition root and reads its settings", async () => {
const root = await rootOf(' data-motion-blur="on" data-motion-blur-shutter="360"');
expect(settingsOf(root.attributes)).toMatchObject({
enabled: true,
shutterDeg: 360,
tier: "auto",
});
});
it("reads defaults from a root that has no blur attributes", async () => {
expect(settingsOf((await rootOf()).attributes)).toMatchObject({
enabled: false,
shutterDeg: 240,
});
});
it("is null for a document with nothing in it", async () => {
const session = await openComposition("<!DOCTYPE html><html><body></body></html>", {
history: false,
});
expect(findCompositionRoot(session.getRootElements())).toBeNull();
});
});
describe("changedEdits", () => {
it("lists only the attributes that would change", async () => {
const root = await rootOf(' data-motion-blur="on"');
const base = settingsOf(root.attributes);
expect(changedEdits(root.attributes, base)).toEqual([]);
expect(changedEdits(root.attributes, { ...base, shutterDeg: 360, tier: "filter" })).toEqual([
{ name: "data-motion-blur-shutter", value: "360" },
{ name: "data-motion-blur-tier", value: "filter" },
]);
expect(changedEdits(root.attributes, { ...base, enabled: false })).toEqual([
{ name: "data-motion-blur", value: null },
]);
});
});
describe("withEdits and sameAttributes", () => {
it("applies sets and removals without touching the input", () => {
const before = { "data-motion-blur": "on", "data-motion-blur-shutter": "360" };
const after = withEdits(before, [
{ name: "data-motion-blur-shutter", value: null },
{ name: "data-motion-blur-tier", value: "filter" },
]);
expect(after).toEqual({ "data-motion-blur": "on", "data-motion-blur-tier": "filter" });
expect(before).toEqual({ "data-motion-blur": "on", "data-motion-blur-shutter": "360" });
});
it("compares attribute sets by content", () => {
expect(sameAttributes({ a: "1" }, { a: "1" })).toBe(true);
expect(sameAttributes({ a: "1" }, { a: "2" })).toBe(false);
expect(sameAttributes({ a: "1" }, {})).toBe(false);
expect(sameAttributes(null, {})).toBe(false);
expect(sameAttributes(null, null)).toBe(true);
});
});
describe("readPreviewRootAttributes", () => {
it("reads only the blur attributes from the preview's composition root", () => {
const doc = document.implementation.createHTMLDocument("p");
doc.body.innerHTML =
'<div data-composition-id="main" data-width="1920" data-motion-blur="on" data-motion-blur-shutter="360"></div>';
const frame = { contentDocument: doc } as unknown as HTMLIFrameElement;
expect(readPreviewRootAttributes(frame)).toEqual({
"data-motion-blur": "on",
"data-motion-blur-shutter": "360",
});
});
it("is null when there is no preview, no root, or the document cannot be read", () => {
expect(readPreviewRootAttributes(null)).toBeNull();
const empty = document.implementation.createHTMLDocument("p");
expect(
readPreviewRootAttributes({ contentDocument: empty } as unknown as HTMLIFrameElement),
).toBeNull();
const blocked = {
get contentDocument(): never {
throw new Error("cross-origin");
},
} as unknown as HTMLIFrameElement;
expect(readPreviewRootAttributes(blocked)).toBeNull();
});
});
describe("levelOf and patchForLevel", () => {
const on = (shutterDeg: number, enabled = true) => ({
enabled,
shutterDeg,
samples: null,
events: false,
tier: "auto" as const,
});
it("reads off, subtle and strong from the root's settings", () => {
expect(levelOf(on(240, false))).toBe("off");
expect(levelOf(on(240))).toBe("subtle");
expect(levelOf(on(180))).toBe("subtle");
expect(levelOf(on(360))).toBe("subtle");
expect(levelOf(on(480))).toBe("subtle");
expect(levelOf(on(481))).toBe("strong");
expect(levelOf(on(720))).toBe("strong");
});
it("sets Subtle to the default blur and Strong to the maximum, and leaves the rest alone for Off", () => {
expect(patchForLevel("subtle")).toEqual({ enabled: true, shutterDeg: 240, samples: null });
expect(patchForLevel("strong")).toEqual({ enabled: true, shutterDeg: 720, samples: 64 });
expect(patchForLevel("off")).toEqual({ enabled: false });
});
it("reads back as the level that was chosen", () => {
for (const level of ["subtle", "strong"] as const) {
expect(levelOf({ ...on(300), ...patchForLevel(level) })).toBe(level);
}
});
});
@@ -0,0 +1,98 @@
/** What the Motion blur panel reads from and writes to a composition root, and how it describes the preview. */
import type { ElementSnapshot } from "@hyperframes/sdk";
import {
MOTION_BLUR_ATTR,
MOTION_BLUR_LIMITS,
type MotionBlurSettings,
motionBlurAttributeEdits,
readMotionBlurSettings,
} from "@hyperframes/core/motion-blur-settings";
export interface CompositionRoot {
id: string;
attributes: Readonly<Record<string, string>>;
}
/** The composition's own root: the top-level element that names a composition, else the first. */
export function findCompositionRoot(roots: readonly ElementSnapshot[]): CompositionRoot | null {
const root = roots.find((el) => "data-composition-id" in el.attributes) ?? roots[0];
return root ? { id: root.id, attributes: root.attributes } : null;
}
export type RootAttributes = Readonly<Record<string, string>>;
export const settingsOf = (attributes: RootAttributes): MotionBlurSettings =>
readMotionBlurSettings((name) => attributes[name] ?? null);
/** Only the attributes whose value would change, so a no-op edit writes nothing. */
export function changedEdits(
attributes: RootAttributes,
next: MotionBlurSettings,
): Array<{ name: string; value: string | null }> {
return Object.entries(motionBlurAttributeEdits(next))
.filter(([name, value]) => (attributes[name] ?? null) !== value)
.map(([name, value]) => ({ name, value }));
}
/** `attributes` with `edits` applied: a value sets, null removes. */
export function withEdits(
attributes: RootAttributes,
edits: ReadonlyArray<{ name: string; value: string | null }>,
): RootAttributes {
const next: Record<string, string> = { ...attributes };
for (const { name, value } of edits) {
if (value === null) delete next[name];
else next[name] = value;
}
return next;
}
/** The blur attributes on the live preview's composition root: what actually runs, after an undo or an outside edit too. */
export function readPreviewRootAttributes(iframe: HTMLIFrameElement | null): RootAttributes | null {
try {
const root = iframe?.contentDocument?.querySelector("[data-composition-id]");
if (!root) return null;
const found: Record<string, string> = {};
for (const name of Object.values(MOTION_BLUR_ATTR)) {
const value = root.getAttribute(name);
if (value !== null) found[name] = value;
}
return found;
} catch {
return null;
}
}
export const sameAttributes = (a: RootAttributes | null, b: RootAttributes | null): boolean =>
a === b ||
(a !== null && b !== null && Object.keys({ ...a, ...b }).every((name) => a[name] === b[name]));
export type BlurLevel = "off" | "subtle" | "strong";
export const BLUR_LEVELS: ReadonlyArray<{ level: BlurLevel; label: string }> = [
{ level: "off", label: "Off" },
{ level: "subtle", label: "Subtle" },
{ level: "strong", label: "Strong" },
];
/** Subtle is the default blur; Strong opens the shutter and the samples as far as they go. */
const LEVEL_SETTINGS = {
subtle: { shutterDeg: MOTION_BLUR_LIMITS.shutterDeg.default, samples: null },
strong: {
shutterDeg: MOTION_BLUR_LIMITS.shutterDeg.max,
samples: MOTION_BLUR_LIMITS.samples.max,
},
} as const;
/** Halfway between the two shutters: above it a root reads as Strong, so a hand-set 360 reads as Subtle. */
const STRONG_ABOVE = (LEVEL_SETTINGS.subtle.shutterDeg + LEVEL_SETTINGS.strong.shutterDeg) / 2;
export function levelOf(settings: MotionBlurSettings): BlurLevel {
if (!settings.enabled) return "off";
return settings.shutterDeg > STRONG_ABOVE ? "strong" : "subtle";
}
/** What choosing `level` changes; Off leaves the shutter, samples and every other setting where they were. */
export function patchForLevel(level: BlurLevel): Partial<MotionBlurSettings> {
return level === "off" ? { enabled: false } : { enabled: true, ...LEVEL_SETTINGS[level] };
}
@@ -105,7 +105,7 @@ describe("usePanelLayout over the dock", () => {
act(() => view.layout.setRightCollapsed(true));
const hidden = vi.mocked(controller.setGroupVisible).mock.calls;
expect(hidden.map(([id]) => id).sort()).toEqual(
["design", "layers", "renders", "slideshow", "variables"].sort(),
["design", "layers", "motionBlur", "renders", "slideshow", "variables"].sort(),
);
expect(hidden.every(([, visible]) => visible === false)).toBe(true);
});
+2 -1
View File
@@ -23,7 +23,8 @@ export type RightPanelTab =
| "renders"
| "block-params"
| "slideshow"
| "variables";
| "variables"
| "motionBlur";
export interface AgentModalAnchorPoint {
x: number;
y: number;
+8 -1
View File
@@ -28,7 +28,14 @@ export interface StudioUrlState {
selection: StudioUrlSelectionState | null;
}
const VALID_TABS: RightPanelTab[] = ["layers", "design", "renders", "slideshow", "variables"];
const VALID_TABS: RightPanelTab[] = [
"layers",
"design",
"renders",
"slideshow",
"variables",
"motionBlur",
];
/**
* The composition auto-open and a schema-level panel (Variables / Slideshow)