diff --git a/docs/guides/motion-blur.mdx b/docs/guides/motion-blur.mdx index dd248e157d4..88c7091e638 100644 --- a/docs/guides/motion-blur.mdx +++ b/docs/guides/motion-blur.mdx @@ -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. diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index 1d5cfbef164..95a6627d08d 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index 18c1912b016..c7550930033 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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" diff --git a/packages/core/src/motionBlurSettings.test.ts b/packages/core/src/motionBlurSettings.test.ts new file mode 100644 index 00000000000..2f3e1f03984 --- /dev/null +++ b/packages/core/src/motionBlurSettings.test.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) => 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); + } + }); +}); diff --git a/packages/core/src/motionBlurSettings.ts b/packages/core/src/motionBlurSettings.ts new file mode 100644 index 00000000000..c02f946785a --- /dev/null +++ b/packages/core/src/motionBlurSettings.ts @@ -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 { + 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, + }; +} diff --git a/packages/core/src/runtime/captureMotionBlur/index.ts b/packages/core/src/runtime/captureMotionBlur/index.ts index 460effbf72f..1f3fb179bcc 100644 --- a/packages/core/src/runtime/captureMotionBlur/index.ts +++ b/packages/core/src/runtime/captureMotionBlur/index.ts @@ -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`. */ diff --git a/packages/studio/src/components/StudioRightPanels.tsx b/packages/studio/src/components/StudioRightPanels.tsx index 6055d9eec7c..f0fbf0d6669 100644 --- a/packages/studio/src/components/StudioRightPanels.tsx +++ b/packages/studio/src/components/StudioRightPanels.tsx @@ -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} /> + + + = { renders: FilmSlate, variables: BracketsCurly, slideshow: Presentation, + motionBlur: Aperture, }; /** diff --git a/packages/studio/src/components/dock/dockAccessibility.test.ts b/packages/studio/src/components/dock/dockAccessibility.test.ts index 7e96b58d8fc..a1d554a48bf 100644 --- a/packages/studio/src/components/dock/dockAccessibility.test.ts +++ b/packages/studio/src/components/dock/dockAccessibility.test.ts @@ -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"); }); }); diff --git a/packages/studio/src/components/dock/dockLayout.ts b/packages/studio/src/components/dock/dockLayout.ts index 883b3425638..94886d93aea 100644 --- a/packages/studio/src/components/dock/dockLayout.ts +++ b/packages/studio/src/components/dock/dockLayout.ts @@ -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) { diff --git a/packages/studio/src/components/dock/panelRegistry.ts b/packages/studio/src/components/dock/panelRegistry.ts index 303f31eedeb..f532fc42c0a 100644 --- a/packages/studio/src/components/dock/panelRegistry.ts +++ b/packages/studio/src/components/dock/panelRegistry.ts @@ -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; export function isPanelId(value: unknown): value is PanelId { diff --git a/packages/studio/src/components/panels/MotionBlurPanel.test.tsx b/packages/studio/src/components/panels/MotionBlurPanel.test.tsx new file mode 100644 index 00000000000..c887a60bb05 --- /dev/null +++ b/packages/studio/src/components/panels/MotionBlurPanel.test.tsx @@ -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 = "") => + `
`; + +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 = {}; +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( + , + ); + }); + 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 = + '
'; + 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 = + '
'; + 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([]); + }); +}); diff --git a/packages/studio/src/components/panels/MotionBlurPanel.tsx b/packages/studio/src/components/panels/MotionBlurPanel.tsx new file mode 100644 index 00000000000..80029ba4100 --- /dev/null +++ b/packages/studio/src/components/panels/MotionBlurPanel.tsx @@ -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(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) => { + 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 ( +

+ Open a composition to set up motion blur. +

+ ); + } + 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 ( +
+ Motion blur +
+ {BLUR_LEVELS.map(({ level, label }) => ( + + ))} +
+ {locked ? ( +

+ Motion blur is read from the main composition. Open it to change this setting. +

+ ) : null} +
+ ); +}); diff --git a/packages/studio/src/components/panels/motionBlurPanelModel.test.ts b/packages/studio/src/components/panels/motionBlurPanelModel.test.ts new file mode 100644 index 00000000000..3b57e166e9a --- /dev/null +++ b/packages/studio/src/components/panels/motionBlurPanelModel.test.ts @@ -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 = "") => + `
`; + +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("", { + 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 = + '
'; + 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); + } + }); +}); diff --git a/packages/studio/src/components/panels/motionBlurPanelModel.ts b/packages/studio/src/components/panels/motionBlurPanelModel.ts new file mode 100644 index 00000000000..35460fc4ebb --- /dev/null +++ b/packages/studio/src/components/panels/motionBlurPanelModel.ts @@ -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>; +} + +/** 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>; + +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 = { ...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 = {}; + 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 { + return level === "off" ? { enabled: false } : { enabled: true, ...LEVEL_SETTINGS[level] }; +} diff --git a/packages/studio/src/hooks/usePanelLayout.test.ts b/packages/studio/src/hooks/usePanelLayout.test.ts index 1e26c0b6d07..30390f55a98 100644 --- a/packages/studio/src/hooks/usePanelLayout.test.ts +++ b/packages/studio/src/hooks/usePanelLayout.test.ts @@ -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); }); diff --git a/packages/studio/src/utils/studioHelpers.ts b/packages/studio/src/utils/studioHelpers.ts index 2a5c7be16e9..20a77cc8df7 100644 --- a/packages/studio/src/utils/studioHelpers.ts +++ b/packages/studio/src/utils/studioHelpers.ts @@ -23,7 +23,8 @@ export type RightPanelTab = | "renders" | "block-params" | "slideshow" - | "variables"; + | "variables" + | "motionBlur"; export interface AgentModalAnchorPoint { x: number; y: number; diff --git a/packages/studio/src/utils/studioUrlState.ts b/packages/studio/src/utils/studioUrlState.ts index c8ded6b6a0c..e4b5b3ab6e5 100644 --- a/packages/studio/src/utils/studioUrlState.ts +++ b/packages/studio/src/utils/studioUrlState.ts @@ -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)