mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
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:
co-authored by
Claude Sonnet 5.5
parent
d5526bd480
commit
ffc327b44c
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -23,7 +23,8 @@ export type RightPanelTab =
|
||||
| "renders"
|
||||
| "block-params"
|
||||
| "slideshow"
|
||||
| "variables";
|
||||
| "variables"
|
||||
| "motionBlur";
|
||||
export interface AgentModalAnchorPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user