diff --git a/bun.lock b/bun.lock index 88eb689d1..87744835e 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.4.31", + "version": "0.5.0-alpha.14", "bin": { "hyperframes": "./dist/cli.js", }, @@ -62,7 +62,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.4.31", + "version": "0.5.0-alpha.14", "dependencies": { "@chenglou/pretext": "^0.0.5", "postcss": "^8.5.8", @@ -89,7 +89,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.4.31", + "version": "0.5.0-alpha.14", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -107,7 +107,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.4.31", + "version": "0.5.0-alpha.14", "devDependencies": { "@types/bun": "^1.1.0", "gsap": "^3.12.5", @@ -119,7 +119,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.4.31", + "version": "0.5.0-alpha.14", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -158,7 +158,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.4.31", + "version": "0.5.0-alpha.14", "dependencies": { "html2canvas": "^1.4.1", }, @@ -170,7 +170,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.4.31", + "version": "0.5.0-alpha.14", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -186,6 +186,7 @@ "@hyperframes/player": "workspace:*", "@phosphor-icons/react": "^2.1.10", "codemirror": "^6.0.1", + "mediabunny": "^1.43.0", "motion": "^12.38.0", }, "devDependencies": { @@ -750,6 +751,10 @@ "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/dom-mediacapture-transform": ["@types/dom-mediacapture-transform@0.1.11", "", { "dependencies": { "@types/dom-webcodecs": "*" } }, "sha512-Y2p+nGf1bF2XMttBnsVPHUWzRRZzqUoJAKmiP10b5umnO6DDrWI0BrGDJy1pOHoOULVmGSfFNkQrAlC5dcj6nQ=="], + + "@types/dom-webcodecs": ["@types/dom-webcodecs@0.1.13", "", {}, "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ=="], + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/jsdom": ["@types/jsdom@28.0.1", "", { "dependencies": { "@types/node": "24.12.0", "@types/tough-cookie": "4.0.5", "parse5": "7.3.0", "undici-types": "7.24.5" } }, "sha512-GJq2QE4TAZ5ajSoCasn5DOFm8u1mI3tIFvM5tIq3W5U/RTB6gsHwc6Yhpl91X9VSDOUVblgXmG+2+sSvFQrdlw=="], @@ -1234,6 +1239,8 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + "mediabunny": ["mediabunny@1.43.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-QGj6PcXXQvhs+29Pl25z3THv17g31k18zHUrJ25UQc5KWVN0vMKbMbB8KuHXNcseALIhbs6nh+wskimaeH/EEw=="], + "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], diff --git a/docs/contributing.mdx b/docs/contributing.mdx index 8029fa01b..0dc7845f4 100644 --- a/docs/contributing.mdx +++ b/docs/contributing.mdx @@ -53,6 +53,14 @@ bun run build # Build all packages bun run --filter '*' typecheck # Type-check all packages ``` +### Studio Editing Work + +If you are changing Studio's visual editing surface, read +[Studio Manual DOM Editing](/contributing/studio-manual-dom-editing) before +editing code. The inspector intentionally exposes only interactions it can +persist safely back to HTML, so changes should preserve the capability gates, +source patching model, and documented limitations. + ### Running Tests diff --git a/docs/contributing/studio-manual-dom-editing.mdx b/docs/contributing/studio-manual-dom-editing.mdx new file mode 100644 index 000000000..e2a343afe --- /dev/null +++ b/docs/contributing/studio-manual-dom-editing.mdx @@ -0,0 +1,315 @@ +--- +title: Studio Manual DOM Editing +description: What the Studio manual DOM editing inspector ships today, including capabilities, UX, and constraints. +--- + +This page documents the current manual DOM editing surface in HyperFrames Studio. It reflects the implementation that ships in the Studio inspector today, not the earlier design draft that explored third-party transform engines. + +## What Shipped + +Studio now supports a direct DOM editing workflow inside the preview: + +- select supported elements directly in the preview +- see an editor-owned overlay around the current selection +- move and resize supported elements on canvas when geometry is safe +- detach eligible layout-controlled layers with an explicit `Make movable` action +- edit style properties from the right-side `Design` inspector +- edit text layers for safe text-bearing selections, including empty text values +- add and remove child text layers for multi-text selections +- edit solid fills, gradients, project-asset image fills, external image fills, opacity, radius, flex metadata, typography, and blend mode +- drill into nested compositions from master view instead of pretending every inner node is editable in place +- generate an element-scoped `Ask agent` prompt bundle from the right inspector + +The important rule is conservative: Studio only exposes interactions it can round-trip back to authored HTML with deterministic behavior. + +## Current User Experience + +### Preview selection + +- Single click selects a patchable element in the preview. +- The selection overlay is rendered in Studio chrome, not injected into authored content. +- The overlay is cleared when: + - the `Inspector` panel is closed + - the user clicks an empty area in the preview + - the underlying element disappears after a source refresh + +### Overlay behavior + +The overlay provides: + +- selection bounds +- drag behavior for supported elements +- a resize handle when width and height are safely patchable +- blocked-drag feedback for unsupported movement + +The overlay intentionally does not include a floating action toolbar. `Ask agent` lives in the right inspector header, and style controls live in the `Design` panel. + +The current implementation uses Studio-owned pointer handling in `DomEditOverlay.tsx`. It does **not** use `Moveable`. + +### Inspector behavior + +The `Design` panel currently includes: + +- `Layout` + - X / Y / W / H fields + - wheel and arrow-key numeric scrubbing + - `Make movable` for block-ish layout-controlled layers that can be detached safely +- `Flex` + - direction, justify, align, gap, clip content +- `Radius` + - slider + live readout +- `Blending` + - opacity slider + live readout + - blend mode +- `Fill` + - solid color + - multi-stop gradient editing + - project asset image fills + - inline image upload into the project assets list + - external image URL fill + - text color +- `Color picker` + - viewport-clamped floating picker + - saturation / brightness crosshair + - hue and alpha sliders + - hex input +- `Text` + - direct text layer editing when the selection is safe to patch + - add / remove text layers for child text selections + - font size, weight, and family controls +- `Selection colors` + - a summary of detected colors for the current selection + +The inspector is intentionally split from `Renders` with a `Design / Renders` tab control in the right panel. Switching to `Renders` does not mean the header-level `Inspector` panel is closed. + +## What Counts As Editable + +Studio builds a `DomEditSelection` and `DomEditCapabilities` object for each selection. + +### Selection requirements + +A node is only useful to Studio if it can be identified with a stable patch target, for example: + +- `id` +- stable selector +- selector index scoped to the correct source file +- composition host mapping when master view is involved + +### Move support + +Move is allowed only when the selected element: + +- has a stable patch target +- is `absolute` or `fixed` +- has `left` and `top` values that resolve to pixel values +- is not transform-driven (`transform: none`) + +### Resize support + +Resize is allowed only when move is already allowed and Studio can also safely patch pixel `width` and/or `height`. + +### Detach from layout support + +Some block-ish layers are selectable and style-editable, but cannot be moved directly because flex, grid, or normal document flow owns their position. + +For those layers, Studio can expose `Make movable` instead of silently converting on drag. The action measures the current visual rect relative to the composition root and writes conservative inline geometry: + +- `position: absolute` +- `left`, `top`, `width`, and `height` in pixels +- `margin: 0` + +The UI explains that this detaches the layer from flex/grid flow and preserves the current visual position. Inline text nodes are not detached directly. + +### Text editing support + +Text editing is allowed only for safe text-bearing selections: + +- supported text-bearing tags such as `div`, `span`, `p`, `strong`, and headings +- self text selections or leaf child text layers +- empty text values after a user clears the content +- not a composition host + +For multi-text selections, Studio shows a text-layer list. Users can select a specific text layer, edit content live, change size, weight, and font family, add a sibling text layer, or remove the active layer. + +### Unsupported examples + +Studio intentionally withholds direct geometry editing for: + +- flex/grid children whose position is emergent from layout, unless the user chooses `Make movable` +- transform-driven geometry +- nested composition internals while the user is still in master view +- nodes without a stable patch target +- inline text spans as geometry targets + +When geometry is blocked but style edits are still safe, the inspector shows the selection and the reason direct geometry editing is unavailable. + +If the user tries to drag a blocked layer, Studio shows a toast. Layout-owned layers point users to `Make movable`; transform-driven or unsafe targets explain that direct move/resize is limited to absolute or fixed pixel geometry with no transform-driven layout. + +## Nested Composition Rules + +Nested compositions are handled explicitly. + +### In master view + +- clicking content inside a nested composition maps back to the composition host +- supported composition hosts can move as a whole when their host geometry is safe +- Studio does not expose direct inner-node geometry edits from the master preview +- double click drills into the subcomposition + +### After drill-down + +- Studio resolves selections inside that composition normally +- direct move/resize becomes available again if the selected inner node meets the capability rules +- text, fill, gradient, image, radius, opacity, and typography edits apply to the selected inner node + +This keeps Studio honest about what it can patch safely from the current editing context. + +## Source Patching Model + +Studio still uses authored HTML as the source of truth. + +The manual DOM editing flow patches source through the existing patch pipeline in `packages/studio/src/utils/sourcePatcher.ts`. + +Current patch types used by the inspector include: + +- inline style patches +- attribute patches for timeline-linked editing paths +- text-content patches +- detach-from-layout style patches + +The flow is: + +1. user selects or manipulates an element in the preview +2. Studio resolves a stable target +3. the preview is updated optimistically for interaction feedback +4. the patch is written back to source +5. the preview refreshes and selection is reattached + +## Gradient Editing + +The current gradient editor is a structured Studio control, not a raw CSS text field. + +It supports: + +- `linear`, `radial`, and `conic` gradients +- repeating variants +- multiple stops +- stop insertion by clicking the preview strip +- stop removal +- angle control +- radial shape and size controls +- radial/conic center controls + +The editor still serializes back to CSS `background-image`, but the inspector works with a parsed gradient model instead of forcing the user to type raw gradient syntax. + +## Image Fill Editing + +The image fill editor is no longer just a raw `background-image` input. + +It supports: + +- selecting an existing project image asset +- uploading an image from the fill panel, which also adds it to the Assets tab +- previewing the selected project asset in the panel +- entering an external URL when the image is not a project asset + +Studio serializes project asset selections back to `background-image: url(...)`, and rewrites asset URLs so nested subcomposition previews still resolve the image correctly. + +## Color Editing + +The color editor is a custom Studio popover instead of the native browser color dialog. + +It supports: + +- opening from the whole color row +- staying inside the viewport near the clicked color +- saturation / brightness picking with visible crosshair guides +- hue and alpha controls with visible handles +- a current color swatch, readout, and hex input + +The picker writes CSS `rgb(...)` or `rgba(...)` values and preserves alpha through edits. + +## Numeric Scrubbing + +Numeric layout/detail inputs support lightweight design-tool-style nudging: + +- mouse wheel over the focused field +- `ArrowUp` / `ArrowDown` +- `Shift` for larger steps +- `Alt` for finer steps + +This is currently used across the numeric commit fields in the inspector, including layout metrics and other numeric text inputs that parse cleanly as values plus units. + +## Files That Own The Feature + +The main implementation lives in: + +- `packages/studio/src/App.tsx` + - overall inspector wiring + - selection lifecycle + - preview hit testing + - persistence hooks + - detach-from-layout commit flow +- `packages/studio/src/components/editor/DomEditOverlay.tsx` + - overlay box, drag, resize, blocked-drag feedback +- `packages/studio/src/components/editor/PropertyPanel.tsx` + - right-side inspector UI +- `packages/studio/src/components/editor/domEditing.ts` + - selection resolution + - capability gating + - text field modeling + - prompt generation +- `packages/studio/src/components/editor/colorValue.ts` + - color parsing, HSV conversion, and CSS color serialization +- `packages/studio/src/components/editor/floatingPanel.ts` + - viewport-safe floating panel placement for color picking +- `packages/studio/src/components/editor/fontAssets.ts` + - imported font asset helpers +- `packages/studio/src/components/editor/fontCatalog.ts` + - Google font catalog metadata and stylesheet URLs +- `packages/studio/src/components/editor/gradientValue.ts` + - gradient parsing, serialization, and stop editing helpers +- `packages/studio/src/utils/sourcePatcher.ts` + - source patch persistence + +Supporting Studio shell changes also landed in: + +- `packages/studio/src/components/nle/NLELayout.tsx` +- `packages/studio/src/components/nle/NLEPreview.tsx` +- `packages/studio/src/components/sidebar/CompositionsTab.tsx` +- `packages/studio/src/components/sidebar/LeftSidebar.tsx` +- `packages/studio/src/player/components/Player.tsx` +- `packages/studio/src/player/components/Timeline.tsx` +- `packages/studio/src/player/components/TimelineClip.tsx` +- `packages/studio/src/player/hooks/useTimelinePlayer.ts` +- `packages/studio/src/utils/mediaTypes.ts` + +## Current Constraints + +This feature is intentionally **not** a full general-purpose visual builder. + +Still out of scope today: + +- rotation +- arbitrary transforms +- snapping and alignment guides +- multi-select +- marquee selection +- freeform editing of every DOM node regardless of layout model +- editing nested subcomposition internals directly from the master preview without drill-down +- automatic conversion to absolute positioning on drag without user confirmation +- direct geometry editing of inline text spans + +## Bottom Line + +Studio manual DOM editing is now a narrow, deterministic visual editing layer over authored HTML. + +It does **not** try to make the whole DOM freely editable. Instead it: + +- keeps source HTML as the source of truth +- exposes only patchable interactions +- uses a Studio-owned overlay layer for direct manipulation +- gives users a real inspector for safe style and text edits +- treats nested compositions as drill-down boundaries instead of flattening them into an unsafe editing surface + +That tradeoff is the reason the current feature feels reliable instead of deceptive. diff --git a/docs/docs.json b/docs/docs.json index 50d08880b..245ddac43 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -201,7 +201,8 @@ "pages": [ "contributing", "contributing/release-channels", - "contributing/testing-local-changes" + "contributing/testing-local-changes", + "contributing/studio-manual-dom-editing" ] } ] diff --git a/packages/cli/package.json b/packages/cli/package.json index c1f00c03a..6e38aa99f 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/cli", - "version": "0.4.41", + "version": "0.5.0-alpha.14", "description": "HyperFrames CLI — create, preview, and render HTML video compositions", "repository": { "type": "git", diff --git a/packages/cli/src/server/fileWatcher.ts b/packages/cli/src/server/fileWatcher.ts index ffe0620a9..747077f8b 100644 --- a/packages/cli/src/server/fileWatcher.ts +++ b/packages/cli/src/server/fileWatcher.ts @@ -9,6 +9,13 @@ export interface ProjectWatcher { } const WATCHED_EXTENSIONS = new Set([".html", ".css", ".js", ".json"]); +const IGNORED_SEGMENTS = new Set([ + ".git", + ".hyperframes", + ".thumbnails", + "node_modules", + "renders", +]); const DEBOUNCE_MS = 300; export function createProjectWatcher(projectDir: string): ProjectWatcher { @@ -19,6 +26,8 @@ export function createProjectWatcher(projectDir: string): ProjectWatcher { try { watcher = watch(projectDir, { recursive: true }, (_event, filename) => { if (!filename) return; + const segments = filename.split(/[\\/]/).filter(Boolean); + if (segments.some((segment) => IGNORED_SEGMENTS.has(segment))) return; const ext = "." + filename.split(".").pop()?.toLowerCase(); if (!WATCHED_EXTENSIONS.has(ext)) return; diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index 0f4d06024..c55e35757 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -260,23 +260,34 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { await new Promise((r) => setTimeout(r, 200)); let clip: { x: number; y: number; width: number; height: number } | undefined; if (opts.selector) { - clip = await page.evaluate((selector: string) => { - const el = document.querySelector(selector); - if (!(el instanceof HTMLElement)) return undefined; - const rect = el.getBoundingClientRect(); - if (rect.width < 4 || rect.height < 4) return undefined; - const pad = 8; - const x = Math.max(0, rect.left - pad); - const y = Math.max(0, rect.top - pad); - const maxWidth = window.innerWidth - x; - const maxHeight = window.innerHeight - y; - return { - x, - y, - width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)), - height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)), - }; - }, opts.selector); + clip = await page.evaluate( + (selector: string, selectorIndex: number | undefined) => { + const matches = Array.from(document.querySelectorAll(selector)).filter( + (el): el is HTMLElement => el instanceof HTMLElement, + ); + const safeIndex = Math.max( + 0, + Math.min(matches.length - 1, Math.floor(selectorIndex ?? 0)), + ); + const el = matches[safeIndex] ?? null; + if (!(el instanceof HTMLElement)) return undefined; + const rect = el.getBoundingClientRect(); + if (rect.width < 4 || rect.height < 4) return undefined; + const pad = 8; + const x = Math.max(0, rect.left - pad); + const y = Math.max(0, rect.top - pad); + const maxWidth = window.innerWidth - x; + const maxHeight = window.innerHeight - y; + return { + x, + y, + width: Math.max(1, Math.min(rect.width + pad * 2, maxWidth)), + height: Math.max(1, Math.min(rect.height + pad * 2, maxHeight)), + }; + }, + opts.selector, + opts.selectorIndex, + ); } const screenshot = (await page.screenshot( opts.format === "png" @@ -330,6 +341,30 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { return serve(); }); + const api = createStudioApi(adapter); + let recentApiWriteAt = 0; + + watcher.addListener((relativePath) => { + if (Date.now() - recentApiWriteAt < 3000) return; + void api + .fetch( + new Request( + `http://studio/projects/${encodeURIComponent(project.id)}/history/adopt-external`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + paths: [relativePath], + actor: { type: "external" }, + }), + }, + ), + ) + .catch((error) => { + console.warn("[studio] Failed to adopt external file edit:", error); + }); + }); + app.get("/api/events", (c) => { return streamSSE(c, async (stream) => { const listener = () => { @@ -345,10 +380,12 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { // Mount the shared studio API at /api. // Use fetch() forwarding (not .route()) so the sub-app sees paths without // the /api prefix — the shared module's path extraction uses c.req.path. - const api = createStudioApi(adapter); app.all("/api/*", async (c) => { const url = new URL(c.req.url); url.pathname = url.pathname.slice(4); // Strip "/api" prefix + if (/^\/projects\/[^/]+\/(?:edits|history\/(?:undo|redo|record-applied))/.test(url.pathname)) { + recentApiWriteAt = Date.now(); + } const forwardReq = new Request(url.toString(), { method: c.req.method, headers: c.req.raw.headers, diff --git a/packages/core/package.json b/packages/core/package.json index 35570678a..3a3c6ea56 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.4.41", + "version": "0.5.0-alpha.14", "description": "", "repository": { "type": "git", @@ -30,10 +30,18 @@ "types": "./src/compiler/index.ts" }, "./runtime": "./dist/hyperframe.runtime.iife.js", + "./runtime/lottie-readiness": { + "import": "./src/runtime/adapters/lottieReadiness.ts", + "types": "./src/runtime/adapters/lottieReadiness.ts" + }, "./studio-api": { "import": "./src/studio-api/index.ts", "types": "./src/studio-api/index.ts" }, + "./studio-history": { + "import": "./src/studio-api/history/editHistory.ts", + "types": "./src/studio-api/history/editHistory.ts" + }, "./text": { "import": "./src/text/index.ts", "types": "./src/text/index.ts" @@ -61,10 +69,18 @@ "types": "./dist/compiler/index.d.ts" }, "./runtime": "./dist/hyperframe.runtime.iife.js", + "./runtime/lottie-readiness": { + "import": "./dist/runtime/adapters/lottieReadiness.js", + "types": "./dist/runtime/adapters/lottieReadiness.d.ts" + }, "./studio-api": { "import": "./dist/studio-api/index.js", "types": "./dist/studio-api/index.d.ts" }, + "./studio-history": { + "import": "./dist/studio-api/history/editHistory.js", + "types": "./dist/studio-api/history/editHistory.d.ts" + }, "./text": { "import": "./dist/text/index.js", "types": "./dist/text/index.d.ts" diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index 34e139a01..d730daa57 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -3,7 +3,11 @@ import { join, resolve, isAbsolute, sep } from "path"; import { parseHTML } from "linkedom"; import { transformSync } from "esbuild"; import { compileHtml, type MediaDurationProber } from "./htmlCompiler"; -import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths"; +import { + rewriteAssetPaths, + rewriteCssAssetUrls, + rewriteInlineStyleAssetUrls, +} from "./rewriteSubCompPaths"; import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping"; import { validateHyperframeHtmlContract } from "./staticGuard"; @@ -538,6 +542,17 @@ export async function bundleToSingleHtml( el.setAttribute(attr, val); }, ); + const styledEls = innerRoot + ? innerRoot.querySelectorAll("[style]") + : contentDoc.querySelectorAll("[style]"); + rewriteInlineStyleAssetUrls( + styledEls, + src, + (el: Element) => el.getAttribute("style"), + (el: Element, val: string) => { + el.setAttribute("style", val); + }, + ); if (innerRoot) { const innerW = innerRoot.getAttribute("data-width"); diff --git a/packages/core/src/compiler/rewriteSubCompPaths.test.ts b/packages/core/src/compiler/rewriteSubCompPaths.test.ts index d2048a195..7de4f42f0 100644 --- a/packages/core/src/compiler/rewriteSubCompPaths.test.ts +++ b/packages/core/src/compiler/rewriteSubCompPaths.test.ts @@ -1,5 +1,9 @@ import { describe, expect, it } from "vitest"; -import { rewriteAssetPath, rewriteCssAssetUrls } from "./rewriteSubCompPaths.js"; +import { + rewriteAssetPath, + rewriteCssAssetUrls, + rewriteInlineStyleAssetUrls, +} from "./rewriteSubCompPaths.js"; describe("rewriteAssetPath", () => { it("rewrites `../` against the sub-composition dir", () => { @@ -36,4 +40,19 @@ describe("rewriteAssetPath", () => { expect(out).not.toMatch(/\\/); expect(out).not.toMatch(/:\\/); }); + + it("rewrites CSS urls inside inline style attributes", () => { + const elements = [{ style: `background-image: url("../cover.png")` }]; + + rewriteInlineStyleAssetUrls( + elements, + "compositions/scene.html", + (el) => el.style, + (el, value) => { + el.style = value; + }, + ); + + expect(elements[0]?.style).toBe(`background-image: url("cover.png")`); + }); }); diff --git a/packages/core/src/compiler/rewriteSubCompPaths.ts b/packages/core/src/compiler/rewriteSubCompPaths.ts index 23ea579e9..72bc1ba23 100644 --- a/packages/core/src/compiler/rewriteSubCompPaths.ts +++ b/packages/core/src/compiler/rewriteSubCompPaths.ts @@ -96,6 +96,28 @@ export function rewriteAssetPaths( } } +/** + * Rewrite CSS url(...) references inside inline style attributes. + */ +export function rewriteInlineStyleAssetUrls( + elements: Iterable, + compSrcPath: string, + getStyle: (el: T) => string | null | undefined, + setStyle: (el: T, value: string) => void, +): void { + const compDir = dirname(compSrcPath); + if (!compDir || compDir === ".") return; + + for (const el of elements) { + const style = getStyle(el); + if (!style) continue; + const rewritten = rewriteCssAssetUrls(style, compSrcPath); + if (rewritten !== style) { + setStyle(el, rewritten); + } + } +} + /** * Rewrite CSS url(...) references in a sub-composition's inline styles so * ../foo.woff2 remains valid after the CSS is hoisted into the root document. diff --git a/packages/core/src/lint/rules/captions.test.ts b/packages/core/src/lint/rules/captions.test.ts index 3c0a59cbc..9ae3c02c3 100644 --- a/packages/core/src/lint/rules/captions.test.ts +++ b/packages/core/src/lint/rules/captions.test.ts @@ -50,6 +50,28 @@ describe("caption rules", () => { expect(finding).toBeUndefined(); }); + it("does not warn for generic GSAP opacity exits in non-caption loops", () => { + const html = ` + +
+ +
+`; + const result = lintHyperframeHtml(html); + const finding = result.findings.find((f) => f.code === "caption_exit_missing_hard_kill"); + expect(finding).toBeUndefined(); + }); + it("warns when caption group has nowrap without max-width", () => { const html = ` diff --git a/packages/core/src/lint/rules/captions.ts b/packages/core/src/lint/rules/captions.ts index f8e0f531a..94f09f7f4 100644 --- a/packages/core/src/lint/rules/captions.ts +++ b/packages/core/src/lint/rules/captions.ts @@ -12,7 +12,8 @@ export const captionRules: Array<(ctx: LintContext) => HyperframeLintFinding[]> content, ); const hasCaptionLoop = - /forEach|\.forEach\s*\(/.test(content) && /createElement|caption|group|cg-/.test(content); + /forEach|\.forEach\s*\(/.test(content) && + /karaoke|caption[-_]?(?:group|word|line|block)|cg-/.test(content); if (hasCaptionLoop && hasExitTween && !hasHardKill) { findings.push({ code: "caption_exit_missing_hard_kill", diff --git a/packages/core/src/runtime/adapters/hfMotion.test.ts b/packages/core/src/runtime/adapters/hfMotion.test.ts new file mode 100644 index 000000000..ddd34ce3a --- /dev/null +++ b/packages/core/src/runtime/adapters/hfMotion.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + createHfMotionAdapter, + parseHfMotionAttribute, + parseHfMotionVarsAttribute, +} from "./hfMotion"; + +describe("hf motion adapter", () => { + const originalRequestAnimationFrame = window.requestAnimationFrame; + const originalCancelAnimationFrame = window.cancelAnimationFrame; + + afterEach(() => { + document.body.innerHTML = ""; + window.requestAnimationFrame = originalRequestAnimationFrame; + window.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + it("has correct name", () => { + expect(createHfMotionAdapter().name).toBe("hf-motion"); + }); + + it("parses the v1 data-hf-motion contract", () => { + expect( + parseHfMotionAttribute( + "v=1;preset=fade-up;start=0;duration=0.6;ease=outCubic;x=0;y=32;opacity=0:1;scale=1:1", + ), + ).toEqual({ + version: 1, + preset: "fade-up", + start: 0, + duration: 0.6, + ease: "outCubic", + x: { from: 0, to: 0 }, + y: { from: 32, to: 0 }, + opacity: { from: 0, to: 1 }, + scale: { from: 1, to: 1 }, + }); + }); + + it("parses extended and custom easing values", () => { + expect( + parseHfMotionAttribute( + "v=1;preset=fade-up;start=0;duration=0.6;ease=outBack;x=0;y=32;opacity=0:1;scale=1:1", + )?.ease, + ).toBe("outBack"); + expect( + parseHfMotionAttribute( + "v=1;preset=fade-up;start=0;duration=0.6;ease=cubic-bezier(0.2,1.25,0.4,1);x=0;y=32;opacity=0:1;scale=1:1", + )?.ease, + ).toBe("bezier(0.2,1.25,0.4,1)"); + expect( + parseHfMotionAttribute( + "v=1;preset=fade-up;start=0;duration=0.6;ease=bezier(1.2,0,0.4,1);x=0;y=32;opacity=0:1;scale=1:1", + ), + ).toBeNull(); + }); + + it("rejects malformed motion values without throwing", () => { + expect(parseHfMotionAttribute("v=2;preset=fade-up;duration=0.6")).toBeNull(); + expect(parseHfMotionAttribute("v=1;preset=fade-up;duration=-1")).toBeNull(); + expect(parseHfMotionAttribute("v=1;preset= + + `); + + const root = document.getElementById("clip") as HTMLElement; + const layers = collectDomEditLayerItems(root, { + activeCompositionPath: "compositions/manual.html", + isMasterView: false, + }); + + expect(layers.map((layer) => [layer.label, layer.tagName, layer.depth])).toEqual([ + ["Clip", "section", 0], + ["Card", "div", 1], + ["Title", "h1", 2], + ["Copy", "p", 2], + ]); + expect(layers[0]?.childCount).toBe(1); + expect( + countDomEditChildLayers(root, { + activeCompositionPath: "compositions/manual.html", + isMasterView: false, + }), + ).toBe(3); + expect(getDomEditLayerKey(layers[2]!)).toBe("compositions/manual.html:.title:0"); + }); + + it("handles SVG className objects when counting nested timeline layers", () => { + const document = createDocument(` +
+ + + +
+ `); + + const svg = document.querySelector("svg") as HTMLElement; + Object.defineProperty(svg, "className", { + configurable: true, + value: { baseVal: "icon" }, + }); + + const root = document.getElementById("clip") as HTMLElement; + + expect(() => + countDomEditChildLayers(root, { + activeCompositionPath: "compositions/manual.html", + isMasterView: false, + }), + ).not.toThrow(); + + const layers = collectDomEditLayerItems(root, { + activeCompositionPath: "compositions/manual.html", + isMasterView: false, + }); + + expect(layers.map((layer) => layer.selector)).toContain(".icon"); + }); +}); + +describe("patch builders and prompt builder", () => { + it("builds move patch operations for left/top", () => { + expect(buildDomEditMovePatchOperations(140.4, 82.1)).toEqual([ + { type: "inline-style", property: "left", value: "140px" }, + { type: "inline-style", property: "top", value: "82px" }, + ]); + }); + + it("builds resize patch operations for width/height", () => { + expect(buildDomEditResizePatchOperations(301.6, 210.1)).toEqual([ + { type: "inline-style", property: "width", value: "302px" }, + { type: "inline-style", property: "height", value: "210px" }, + ]); + }); + + it("builds style patch operations", () => { + expect(buildDomEditStylePatchOperation("background-color", "rgb(15, 23, 42)")).toEqual({ + type: "inline-style", + property: "background-color", + value: "rgb(15, 23, 42)", + }); + }); + + it("builds an agent prompt with source and selector context", () => { + const selection = { + element: {} as HTMLElement, + id: "editable-card", + selector: "#editable-card", + selectorIndex: undefined, + sourceFile: "index.html", + compositionPath: "index.html", + compositionSrc: undefined, + isCompositionHost: false, + label: "Drag me first", + tagName: "div", + boundingBox: { x: 108, y: 112, width: 380, height: 196 }, + textContent: "Drag me first", + dataAttributes: {}, + inlineStyles: { + left: "108px", + top: "112px", + width: "380px", + height: "196px", + }, + computedStyles: { + position: "absolute", + left: "108px", + top: "112px", + width: "380px", + height: "196px", + color: "rgb(248, 250, 252)", + }, + textFields: [ + { + key: "self:0:div", + label: "Content", + value: "Drag me first", + tagName: "div", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "self", + }, + ], + capabilities: { + canSelect: true, + canEditStyles: true, + canMove: true, + canResize: true, + }, + } satisfies DomEditSelection; + + const prompt = buildElementAgentPrompt({ + selection, + currentTime: 1.25, + tagSnippet: `
; source=self; text="Drag me first"'); + expect(prompt).toContain("Inline styles:"); + expect(prompt).toContain("Computed styles (browser-resolved):"); + expect(prompt).toContain("Target HTML:"); + expect(prompt).toContain("Guardrails:"); + expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning."); + }); + + it("uses an absolute source path in copied agent prompts when provided", () => { + const selection = { + element: {} as HTMLElement, + id: "editable-card", + selector: "#editable-card", + selectorIndex: undefined, + sourceFile: "index.html", + compositionPath: "index.html", + compositionSrc: undefined, + isCompositionHost: false, + label: "Drag me first", + tagName: "div", + boundingBox: { x: 108, y: 112, width: 380, height: 196 }, + textContent: "Drag me first", + dataAttributes: {}, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canMove: true, + canResize: true, + canDetachFromLayout: false, + }, + } satisfies DomEditSelection; + + const prompt = buildElementAgentPrompt({ + selection, + currentTime: 1.25, + sourceFilePath: "/tmp/hf-studio-project/index.html", + }); + + expect(prompt).toContain("Source file: /tmp/hf-studio-project/index.html"); + expect(prompt).not.toContain("Source file: index.html"); + }); + + it("includes motion ownership and runtime guardrails in motion-aware agent prompts", () => { + const selection = { + element: {} as HTMLElement, + id: "headline", + selector: "#headline", + selectorIndex: undefined, + sourceFile: "index.html", + compositionPath: "index.html", + compositionSrc: undefined, + isCompositionHost: false, + label: "Headline", + tagName: "div", + boundingBox: { x: 100, y: 120, width: 500, height: 180 }, + textContent: "Launch", + dataAttributes: { + "hf-motion": + "v=1;preset=fade-up;start=0;duration=0.6;ease=outCubic;x=0;y=32;opacity=0:1;scale=1:1", + }, + inlineStyles: {}, + computedStyles: {}, + textFields: [], + capabilities: { + canSelect: true, + canEditStyles: true, + canMove: true, + canResize: true, + canDetachFromLayout: false, + }, + } satisfies DomEditSelection; + + const prompt = buildElementAgentPrompt({ + selection, + currentTime: 0.35, + sourceFilePath: "/tmp/hf-studio-project/index.html", + motionContext: { + version: 1, + state: "mixed", + summary: "Multiple motion owners detected; external owners are read-only.", + hfMotionAttribute: selection.dataAttributes["hf-motion"], + owners: [ + { label: "HF Motion", state: "Editable", editable: true }, + { label: "GSAP", state: "Detected", editable: false }, + { label: "Mixed", state: "Mixed", editable: false }, + ], + curveTracks: [ + { key: "y", label: "Y", from: 32, to: 0, unit: "px", active: true }, + { key: "opacity", label: "Opacity", from: 0, to: 1, unit: "", active: true }, + ], + instructions: [ + "Patch the existing runtime library only when the target is static and element-specific.", + "Use an outer layout wrapper when transform ownership is mixed.", + ], + }, + }); + + expect(prompt).toContain("## HyperFrames element edit request v2"); + expect(prompt).toContain("Motion context:"); + expect(prompt).toContain("Authored HF Motion:"); + expect(prompt).toContain("- HF Motion: Editable"); + expect(prompt).toContain("- GSAP: Detected"); + expect(prompt).toContain("- Y: 32px -> 0px"); + expect(prompt).toContain("Patch the existing runtime library only when"); + expect(prompt).toContain("Use an outer layout wrapper"); + }); + + it("serializes child text fields back into HTML", () => { + expect( + serializeDomEditTextFields([ + { + key: "child:0:strong", + label: "Text 1", + value: "Headline <1>", + tagName: "strong", + attributes: [], + inlineStyles: { + "font-size": "22px", + }, + computedStyles: {}, + source: "child", + }, + { + key: "child:1:span", + label: "Text 2", + value: "Details & more", + tagName: "span", + attributes: [], + inlineStyles: {}, + computedStyles: {}, + source: "child", + }, + ]), + ).toBe( + 'Headline <1>Details & more', + ); + }); +}); diff --git a/packages/studio/src/components/editor/domEditing.ts b/packages/studio/src/components/editor/domEditing.ts new file mode 100644 index 000000000..77608d810 --- /dev/null +++ b/packages/studio/src/components/editor/domEditing.ts @@ -0,0 +1,962 @@ +import { formatTime } from "../../player/lib/time"; +import type { PatchOperation, PatchTarget } from "../../utils/sourcePatcher"; +import type { MotionAgentContext } from "./motionEditing"; + +const CURATED_STYLE_PROPERTIES = [ + "position", + "display", + "top", + "left", + "right", + "bottom", + "inset", + "width", + "height", + "gap", + "justify-content", + "align-items", + "flex-direction", + "font-size", + "font-weight", + "font-family", + "color", + "background-color", + "background-image", + "opacity", + "mix-blend-mode", + "border-radius", + "border-color", + "outline-color", + "overflow", + "box-shadow", + "z-index", + "transform", +] as const; + +export interface DomEditCapabilities { + canSelect: boolean; + canEditStyles: boolean; + canMove: boolean; + canResize: boolean; + canDetachFromLayout: boolean; + reasonIfDisabled?: string; +} + +export interface DomEditTextField { + key: string; + label: string; + value: string; + tagName: string; + attributes: Array<{ name: string; value: string }>; + inlineStyles: Record; + computedStyles: Record; + source: "self" | "child"; +} + +export interface DomEditSelection extends PatchTarget { + element: HTMLElement; + label: string; + tagName: string; + sourceFile: string; + compositionPath: string; + compositionSrc?: string; + isCompositionHost: boolean; + boundingBox: { x: number; y: number; width: number; height: number }; + textContent: string | null; + dataAttributes: Record; + inlineStyles: Record; + computedStyles: Record; + textFields: DomEditTextField[]; + capabilities: DomEditCapabilities; +} + +export interface DomEditLayerItem { + key: string; + element: HTMLElement; + label: string; + tagName: string; + depth: number; + childCount: number; + id?: string; + selector?: string; + selectorIndex?: number; + sourceFile: string; +} + +export interface DomEditContextOptions { + activeCompositionPath: string | null; + isMasterView: boolean; + preferClipAncestor?: boolean; +} + +function isHtmlElement(value: unknown): value is HTMLElement { + return ( + typeof value === "object" && + value !== null && + "nodeType" in value && + typeof (value as { nodeType?: unknown }).nodeType === "number" && + (value as { nodeType: number }).nodeType === 1 + ); +} + +function parsePx(value: string | undefined): number | null { + if (!value) return null; + const trimmed = value.trim(); + if (!trimmed.endsWith("px")) return null; + const parsed = parseFloat(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +function isIdentityTransform(value: string | undefined): boolean { + const transform = (value ?? "none").trim(); + if (!transform || transform === "none") return true; + + const matrix = transform.match(/^matrix\(([^)]+)\)$/i); + if (matrix) { + const values = matrix[1].split(",").map((part) => Number.parseFloat(part.trim())); + if (values.length !== 6 || values.some((part) => !Number.isFinite(part))) return false; + return ( + Math.abs(values[0] - 1) < 0.0001 && + Math.abs(values[1]) < 0.0001 && + Math.abs(values[2]) < 0.0001 && + Math.abs(values[3] - 1) < 0.0001 && + Math.abs(values[4]) < 0.0001 && + Math.abs(values[5]) < 0.0001 + ); + } + + const matrix3d = transform.match(/^matrix3d\(([^)]+)\)$/i); + if (!matrix3d) return false; + const values = matrix3d[1].split(",").map((part) => Number.parseFloat(part.trim())); + if (values.length !== 16 || values.some((part) => !Number.isFinite(part))) return false; + const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]; + return values.every((part, index) => Math.abs(part - identity[index]) < 0.0001); +} + +function isClipClassName(className: string | undefined): boolean { + return Boolean(className?.split(/\s+/).includes("clip")); +} + +function isInlineTextTag(tagName: string | undefined): boolean { + return Boolean( + tagName && + ["a", "b", "em", "i", "small", "span", "strong", "sub", "sup"].includes(tagName.toLowerCase()), + ); +} + +function isBlockishTag(tagName: string | undefined): boolean { + return Boolean( + tagName && + [ + "article", + "aside", + "canvas", + "div", + "figure", + "footer", + "header", + "img", + "main", + "section", + "svg", + "video", + ].includes(tagName.toLowerCase()), + ); +} + +function isBlockishDisplay(display: string | undefined): boolean { + return Boolean( + display && + [ + "block", + "flex", + "flow-root", + "grid", + "inline-block", + "inline-flex", + "inline-grid", + "list-item", + "table", + ].includes(display), + ); +} + +function isTextBearingTag(tagName: string): boolean { + return ["div", "span", "p", "strong", "h1", "h2", "h3", "h4", "h5", "h6"].includes(tagName); +} + +function getCuratedComputedStyles(el: HTMLElement): Record { + const styles: Record = {}; + const computed = el.ownerDocument.defaultView?.getComputedStyle(el); + if (!computed) return styles; + + for (const prop of CURATED_STYLE_PROPERTIES) { + const value = computed.getPropertyValue(prop); + if (value) styles[prop] = value; + } + + return styles; +} + +function findClosestByAttribute(el: HTMLElement, attributeNames: string[]): HTMLElement | null { + let current: HTMLElement | null = el; + while (current) { + const candidate = current; + if (attributeNames.some((attribute) => candidate.hasAttribute(attribute))) { + return candidate; + } + current = current.parentElement; + } + return null; +} + +function getCompositionHost(el: HTMLElement): HTMLElement | null { + return findClosestByAttribute(el, ["data-composition-src", "data-composition-file"]); +} + +function getSourceFileForElement( + el: HTMLElement, + activeCompositionPath: string | null, +): { sourceFile: string; compositionPath: string } { + const ownerRoot = findClosestByAttribute(el, ["data-composition-id"]); + const sourceFile = + ownerRoot?.getAttribute("data-composition-file") ?? + ownerRoot?.getAttribute("data-composition-src") ?? + activeCompositionPath ?? + "index.html"; + + return { + sourceFile, + compositionPath: sourceFile, + }; +} + +function getSelectionCandidate(startEl: HTMLElement, options: DomEditContextOptions): HTMLElement { + if (options.preferClipAncestor) { + const clipAncestor = startEl.closest(".clip"); + if (isHtmlElement(clipAncestor)) { + return clipAncestor; + } + } + + if (!options.isMasterView) return startEl; + + const compositionHost = getCompositionHost(startEl); + if (compositionHost && compositionHost !== startEl) { + return compositionHost; + } + + return startEl; +} + +function getPreferredClassSelector(el: HTMLElement): string | undefined { + const classes = getElementClassName(el) + .split(/\s+/) + .map((value) => value.trim()) + .filter(Boolean); + if (classes.length === 0) return undefined; + const preferred = + classes.find((value) => value !== "clip" && !value.startsWith("__hf-")) ?? classes[0]; + return preferred ? `.${preferred}` : undefined; +} + +function getElementClassName(el: HTMLElement): string { + return typeof el.className === "string" ? el.className : (el.getAttribute("class") ?? ""); +} + +function humanizeIdentifier(value: string): string { + return ( + value + .replace(/\.html$/i, "") + .replace(/^compositions\//i, "") + .split("/") + .at(-1) + ?.replace(/[-_]+/g, " ") + .replace(/\b\w/g, (char) => char.toUpperCase()) ?? value + ); +} + +function buildStableSelector(el: HTMLElement): string | undefined { + if (el.id) return `#${el.id}`; + + const compositionId = el.getAttribute("data-composition-id"); + if (compositionId) return `[data-composition-id="${compositionId}"]`; + + return getPreferredClassSelector(el); +} + +function getSelectorIndex( + doc: Document, + el: HTMLElement, + selector: string | undefined, + sourceFile: string, + activeCompositionPath: string | null, +): number | undefined { + if (!selector?.startsWith(".")) return undefined; + + const candidates = Array.from(doc.querySelectorAll(selector)).filter( + (candidate): candidate is HTMLElement => + isHtmlElement(candidate) && + getSourceFileForElement(candidate, activeCompositionPath).sourceFile === sourceFile, + ); + const index = candidates.indexOf(el); + return index >= 0 ? index : undefined; +} + +export function getDomEditLayerKey( + target: Pick, +): string { + const selectorIndex = target.selectorIndex ?? 0; + return `${target.sourceFile}:${target.id ?? target.selector ?? "layer"}:${selectorIndex}`; +} + +function buildElementLabel(el: HTMLElement): string { + const compositionId = el.getAttribute("data-composition-id"); + if (compositionId && compositionId !== "main") { + return humanizeIdentifier(compositionId); + } + + const compositionSrc = + el.getAttribute("data-composition-src") ?? el.getAttribute("data-composition-file"); + if (compositionSrc) { + return humanizeIdentifier(compositionSrc); + } + + if (el.id) return humanizeIdentifier(el.id); + + const preferredClass = getPreferredClassSelector(el); + if (preferredClass) { + return humanizeIdentifier(preferredClass.replace(/^\./, "")); + } + + const text = (el.textContent ?? "").trim().replace(/\s+/g, " "); + if (text) return text.length > 40 ? `${text.slice(0, 39)}…` : text; + return el.tagName.toLowerCase(); +} + +const DOM_LAYER_IGNORED_TAGS = new Set([ + "base", + "br", + "link", + "meta", + "script", + "source", + "style", + "template", + "track", + "wbr", +]); + +function isInspectableLayerElement(el: HTMLElement): boolean { + const tagName = el.tagName.toLowerCase(); + if (DOM_LAYER_IGNORED_TAGS.has(tagName)) return false; + + const computed = el.ownerDocument.defaultView?.getComputedStyle(el); + if (computed?.display === "none" || computed?.visibility === "hidden") return false; + + return true; +} + +function getDomLayerPatchTarget( + el: HTMLElement, + activeCompositionPath: string | null, +): Pick | null { + if (!isInspectableLayerElement(el)) return null; + + const selector = buildStableSelector(el); + if (!selector) return null; + + const { sourceFile } = getSourceFileForElement(el, activeCompositionPath); + return { + id: el.id || undefined, + selector, + selectorIndex: getSelectorIndex( + el.ownerDocument, + el, + selector, + sourceFile, + activeCompositionPath, + ), + sourceFile, + }; +} + +function getDirectLayerChildren(el: HTMLElement, options: DomEditContextOptions): HTMLElement[] { + return Array.from(el.children).filter( + (child): child is HTMLElement => + isHtmlElement(child) && getDomLayerPatchTarget(child, options.activeCompositionPath) !== null, + ); +} + +export function countDomEditChildLayers( + root: HTMLElement | null | undefined, + options: DomEditContextOptions, + maxCount = 99, +): number { + if (!root) return 0; + + let count = 0; + const visit = (el: HTMLElement) => { + for (const child of Array.from(el.children)) { + if (!isHtmlElement(child)) continue; + if (getDomLayerPatchTarget(child, options.activeCompositionPath)) { + count += 1; + if (count >= maxCount) return; + } + visit(child); + if (count >= maxCount) return; + } + }; + + visit(root); + return count; +} + +export function collectDomEditLayerItems( + root: HTMLElement | null | undefined, + options: DomEditContextOptions, + maxItems = 80, +): DomEditLayerItem[] { + if (!root) return []; + + const items: DomEditLayerItem[] = []; + const visit = (el: HTMLElement, depth: number) => { + if (items.length >= maxItems) return; + + const target = getDomLayerPatchTarget(el, options.activeCompositionPath); + if (target) { + items.push({ + key: getDomEditLayerKey(target), + element: el, + label: buildElementLabel(el), + tagName: el.tagName.toLowerCase(), + depth, + childCount: getDirectLayerChildren(el, options).length, + id: target.id ?? undefined, + selector: target.selector ?? undefined, + selectorIndex: target.selectorIndex, + sourceFile: target.sourceFile, + }); + } + + const nextDepth = target ? depth + 1 : depth; + for (const child of Array.from(el.children)) { + if (!isHtmlElement(child)) continue; + visit(child, nextDepth); + if (items.length >= maxItems) return; + } + }; + + visit(root, 0); + return items; +} + +function getDataAttributes(el: HTMLElement): Record { + const attrs: Record = {}; + for (const attr of el.attributes) { + if (attr.name.startsWith("data-")) { + attrs[attr.name.slice(5)] = attr.value; + } + } + return attrs; +} + +function getInlineStyles(el: HTMLElement): Record { + const styles: Record = {}; + for (const property of CURATED_STYLE_PROPERTIES) { + const value = el.style.getPropertyValue(property); + if (value) styles[property] = value; + } + return styles; +} + +function isEditableTextLeaf(el: HTMLElement): boolean { + return isTextBearingTag(el.tagName.toLowerCase()) && el.children.length === 0; +} + +function getTextFieldLabel( + _tagName: string, + index: number, + total: number, + source: "self" | "child", +): string { + if (source === "self" || total === 1) return "Content"; + return `Text ${index + 1}`; +} + +function buildTextField( + el: HTMLElement, + index: number, + total: number, + source: "self" | "child", +): DomEditTextField { + const tagName = el.tagName.toLowerCase(); + const key = el.getAttribute("data-hf-text-key") ?? `${source}:${index}:${tagName}`; + return { + key, + label: getTextFieldLabel(tagName, index, total, source), + value: el.textContent ?? "", + tagName, + attributes: Array.from(el.attributes) + .filter((attribute) => attribute.name !== "style") + .map((attribute) => ({ + name: attribute.name, + value: attribute.value, + })), + inlineStyles: getInlineStyles(el), + computedStyles: getCuratedComputedStyles(el), + source, + }; +} + +function collectDomEditTextFields(el: HTMLElement): DomEditTextField[] { + const childFields = Array.from(el.children).filter(isHtmlElement).filter(isEditableTextLeaf); + if (childFields.length > 0) { + return childFields.map((child, index) => + buildTextField(child, index, childFields.length, "child"), + ); + } + + if (isEditableTextLeaf(el)) { + return [buildTextField(el, 0, 1, "self")]; + } + + return []; +} + +function escapeHtmlText(value: string): string { + return value.replace(/&/g, "&").replace(//g, ">"); +} + +function serializeTextFieldStyle(field: DomEditTextField): string { + const entries = Object.entries(field.inlineStyles).filter(([, value]) => Boolean(value)); + if (entries.length === 0) return ""; + return entries.map(([key, value]) => `${key}: ${value}`).join("; "); +} + +export function serializeDomEditTextFields(fields: DomEditTextField[]): string { + return fields + .filter((field) => field.source === "child") + .map((field) => { + const attrs = [ + ...field.attributes.filter((attribute) => attribute.name !== "data-hf-text-key"), + { name: "data-hf-text-key", value: field.key }, + ] + .map((attribute) => ` ${attribute.name}="${attribute.value.replace(/"/g, """)}"`) + .join(""); + const style = serializeTextFieldStyle(field); + const styleAttr = style ? ` style="${style.replace(/"/g, """)}"` : ""; + return `<${field.tagName}${attrs}${styleAttr}>${escapeHtmlText(field.value)}`; + }) + .join(""); +} + +export function buildDefaultDomEditTextField(base?: Partial): DomEditTextField { + return { + key: `child:new:${Date.now()}`, + label: "Text", + value: "New text", + tagName: "span", + attributes: [], + inlineStyles: { + "font-family": base?.computedStyles?.["font-family"] ?? "inherit", + "font-size": base?.computedStyles?.["font-size"] ?? "16px", + "font-weight": base?.computedStyles?.["font-weight"] ?? "400", + color: base?.computedStyles?.color ?? "inherit", + }, + computedStyles: {}, + source: "child", + }; +} + +export function resolveDomEditCapabilities(args: { + selector?: string; + tagName?: string; + className?: string; + inlineStyles: Record; + computedStyles: Record; + hasStudioOwnedMotion?: boolean; + isCompositionHost: boolean; + isMasterView: boolean; +}): DomEditCapabilities { + if (!args.selector) { + return { + canSelect: false, + canEditStyles: false, + canMove: false, + canResize: false, + canDetachFromLayout: false, + reasonIfDisabled: "Studio could not resolve a stable patch target for this element.", + }; + } + + const position = args.computedStyles.position; + const left = parsePx(args.inlineStyles.left) ?? parsePx(args.computedStyles.left); + const top = parsePx(args.inlineStyles.top) ?? parsePx(args.computedStyles.top); + const width = parsePx(args.inlineStyles.width) ?? parsePx(args.computedStyles.width); + const height = parsePx(args.inlineStyles.height) ?? parsePx(args.computedStyles.height); + const hasTransformDrivenGeometry = + !args.hasStudioOwnedMotion && !isIdentityTransform(args.computedStyles.transform); + + const canMove = + (position === "absolute" || position === "fixed") && + left != null && + top != null && + !hasTransformDrivenGeometry; + + const canResize = canMove && (width != null || height != null); + const isBlockishLayer = + args.isCompositionHost || + isClipClassName(args.className) || + isBlockishTag(args.tagName) || + isBlockishDisplay(args.computedStyles.display); + const canDetachFromLayout = + !canMove && + !hasTransformDrivenGeometry && + isBlockishLayer && + (!isInlineTextTag(args.tagName) || isClipClassName(args.className)); + const reasonIfDisabled = !canMove + ? canDetachFromLayout + ? "This layer is controlled by layout." + : "Direct move/resize is limited to absolute or fixed elements with px geometry and no transform-driven layout." + : undefined; + + if (args.isCompositionHost && args.isMasterView) { + return { + canSelect: true, + canEditStyles: false, + canMove, + canResize, + canDetachFromLayout, + reasonIfDisabled, + }; + } + + return { + canSelect: true, + canEditStyles: true, + canMove, + canResize, + canDetachFromLayout, + reasonIfDisabled, + }; +} + +export function resolveDomEditSelection( + startEl: HTMLElement | null, + options: DomEditContextOptions, +): DomEditSelection | null { + if (!startEl) return null; + const doc = startEl.ownerDocument; + + let current: HTMLElement | null = getSelectionCandidate(startEl, options); + while (current && current !== doc.body && current !== doc.documentElement) { + const selector = buildStableSelector(current); + if (!selector) { + current = current.parentElement; + continue; + } + + const { sourceFile, compositionPath } = getSourceFileForElement( + current, + options.activeCompositionPath, + ); + const selectorIndex = getSelectorIndex( + doc, + current, + selector, + sourceFile, + options.activeCompositionPath, + ); + const compositionSrc = + current.getAttribute("data-composition-src") ?? + current.getAttribute("data-composition-file") ?? + undefined; + const inlineStyles = getInlineStyles(current); + const computedStyles = getCuratedComputedStyles(current); + const textFields = collectDomEditTextFields(current); + const capabilities = resolveDomEditCapabilities({ + selector, + tagName: current.tagName.toLowerCase(), + className: getElementClassName(current), + inlineStyles, + computedStyles, + hasStudioOwnedMotion: current.hasAttribute("data-hf-motion"), + isCompositionHost: Boolean(compositionSrc), + isMasterView: options.isMasterView, + }); + const rect = current.getBoundingClientRect(); + + return { + element: current, + id: current.id || undefined, + selector, + selectorIndex, + sourceFile, + compositionPath, + compositionSrc, + isCompositionHost: Boolean(compositionSrc), + label: buildElementLabel(current), + tagName: current.tagName.toLowerCase(), + boundingBox: { + x: rect.left, + y: rect.top, + width: rect.width, + height: rect.height, + }, + textContent: current.textContent?.trim() || null, + dataAttributes: getDataAttributes(current), + inlineStyles, + computedStyles, + textFields, + capabilities, + }; + } + + return null; +} + +export function refreshDomEditSelection( + selection: DomEditSelection, + activeCompositionPath: string | null, +): DomEditSelection | null { + const doc = selection.element.ownerDocument; + const nextElement = findElementForSelection(doc, selection, activeCompositionPath); + return nextElement + ? resolveDomEditSelection(nextElement, { + activeCompositionPath, + isMasterView: !activeCompositionPath || activeCompositionPath === "index.html", + }) + : null; +} + +export function findElementForSelection( + doc: Document, + selection: Pick, + activeCompositionPath: string | null = null, +): HTMLElement | null { + if (selection.id) { + const byId = doc.getElementById(selection.id); + if ( + isHtmlElement(byId) && + (!selection.sourceFile || + getSourceFileForElement(byId, activeCompositionPath).sourceFile === selection.sourceFile) + ) { + return byId; + } + } + + if (!selection.selector) return null; + + if (selection.selector.startsWith(".") && selection.selectorIndex != null) { + const matches = Array.from(doc.querySelectorAll(selection.selector)).filter( + (candidate): candidate is HTMLElement => + isHtmlElement(candidate) && + (!selection.sourceFile || + getSourceFileForElement(candidate, activeCompositionPath).sourceFile === + selection.sourceFile), + ); + return matches[selection.selectorIndex] ?? null; + } + + const matches = Array.from(doc.querySelectorAll(selection.selector)).filter( + (candidate): candidate is HTMLElement => + isHtmlElement(candidate) && + (!selection.sourceFile || + getSourceFileForElement(candidate, activeCompositionPath).sourceFile === + selection.sourceFile), + ); + return matches[0] ?? null; +} + +export function buildDomEditMovePatchOperations(left: number, top: number): PatchOperation[] { + return [ + { type: "inline-style", property: "left", value: `${Math.round(left)}px` }, + { type: "inline-style", property: "top", value: `${Math.round(top)}px` }, + ]; +} + +export function buildDomEditResizePatchOperations(width: number, height: number): PatchOperation[] { + return [ + { type: "inline-style", property: "width", value: `${Math.round(width)}px` }, + { type: "inline-style", property: "height", value: `${Math.round(height)}px` }, + ]; +} + +export function buildDomEditDetachPatchOperations(rect: { + left: number; + top: number; + width: number; + height: number; +}): PatchOperation[] { + return [ + { type: "inline-style", property: "position", value: "absolute" }, + { type: "inline-style", property: "left", value: `${Math.round(rect.left)}px` }, + { type: "inline-style", property: "top", value: `${Math.round(rect.top)}px` }, + { type: "inline-style", property: "width", value: `${Math.round(rect.width)}px` }, + { type: "inline-style", property: "height", value: `${Math.round(rect.height)}px` }, + { type: "inline-style", property: "margin", value: "0" }, + { type: "inline-style", property: "right", value: "auto" }, + { type: "inline-style", property: "bottom", value: "auto" }, + ]; +} + +export function buildDomEditStylePatchOperation(property: string, value: string): PatchOperation { + return { + type: "inline-style", + property, + value, + }; +} + +export function buildDomEditTextPatchOperation(value: string): PatchOperation { + return { + type: "text-content", + property: "text", + value, + }; +} + +function formatBoundingBox(bounds: DomEditSelection["boundingBox"]): string { + return `x=${Math.round(bounds.x)}, y=${Math.round(bounds.y)}, width=${Math.round(bounds.width)}, height=${Math.round(bounds.height)}`; +} + +function formatStyleBlock(styles: Record): string { + return Object.entries(styles) + .filter(([, value]) => value && value !== "initial") + .map(([key, value]) => `${key}: ${value}`) + .join("\n"); +} + +function formatTextFields(fields: DomEditTextField[]): string { + return fields + .map( + (field) => + `- key=${field.key}; tag=<${field.tagName}>; source=${field.source}; text="${field.value.replace(/"/g, '\\"')}"`, + ) + .join("\n"); +} + +function formatMotionNumber(value: number): string { + const normalized = Math.abs(value) < 0.0001 ? 0 : value; + const rounded = Math.round(normalized * 1000) / 1000; + return Number.isInteger(rounded) + ? `${rounded}` + : rounded.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); +} + +function formatMotionTrackValue(value: number, unit: string): string { + return `${formatMotionNumber(value)}${unit}`; +} + +function formatMotionContext(context: MotionAgentContext): string[] { + const lines = ["Motion context:", `State: ${context.state}`, `Summary: ${context.summary}`]; + + if (context.hfMotionAttribute) { + lines.push(`Authored HF Motion: ${context.hfMotionAttribute}`); + } + + if (context.owners.length > 0) { + lines.push("Runtime owners:"); + for (const owner of context.owners) { + lines.push(`- ${owner.label}: ${owner.state}${owner.editable ? " (editable)" : ""}`); + } + } + + if (context.curveTracks.length > 0) { + lines.push("Motion curve tracks:"); + for (const track of context.curveTracks) { + lines.push( + `- ${track.label}: ${formatMotionTrackValue(track.from, track.unit)} -> ${formatMotionTrackValue( + track.to, + track.unit, + )}${track.active ? "" : " (constant)"}`, + ); + } + } + + if (context.instructions.length > 0) { + lines.push("Motion edit guidance:"); + for (const instruction of context.instructions) { + lines.push(`- ${instruction}`); + } + } + + return lines; +} + +export function buildElementAgentPrompt({ + selection, + currentTime, + tagSnippet, + userInstruction, + sourceFilePath, + motionContext, +}: { + selection: DomEditSelection; + currentTime: number; + tagSnippet?: string; + userInstruction?: string; + sourceFilePath?: string; + motionContext?: MotionAgentContext; +}): string { + const displayedSourceFile = sourceFilePath?.trim() || selection.sourceFile; + const schemaVersion = motionContext ? 2 : 1; + const lines = [ + `## HyperFrames element edit request v${schemaVersion}`, + `Schema version: ${schemaVersion}`, + "", + userInstruction?.trim() || "Edit this selected HyperFrames element.", + "", + `Composition: ${selection.compositionPath}`, + `Playback time: ${formatTime(currentTime)}`, + `Source file: ${displayedSourceFile}`, + `DOM id: ${selection.id ?? "(none)"}`, + `Selector: ${selection.selector ?? "(none)"}`, + `Selector index: ${selection.selectorIndex ?? 0}`, + `Tag: <${selection.tagName}>`, + `Bounds: ${formatBoundingBox(selection.boundingBox)}`, + ]; + + if (selection.textContent) { + lines.push(`Text: ${selection.textContent}`); + } + + const textFieldsBlock = formatTextFields(selection.textFields); + if (textFieldsBlock) { + lines.push("", "Text fields:", textFieldsBlock); + } + + const inlineStyleBlock = formatStyleBlock(selection.inlineStyles); + if (inlineStyleBlock) { + lines.push("", "Inline styles:", inlineStyleBlock); + } + + const computedStyleBlock = formatStyleBlock(selection.computedStyles); + if (computedStyleBlock) { + lines.push("", "Computed styles (browser-resolved):", computedStyleBlock); + } + + if (tagSnippet) { + lines.push("", "Target HTML:", tagSnippet); + } + + if (motionContext) { + lines.push("", ...formatMotionContext(motionContext)); + } + + lines.push( + "", + "Guardrails:", + "- Make a targeted change to this element only.", + "- Preserve the rest of the composition and its timing.", + "- Do not modify other elements' data-* attributes or positioning.", + "- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.", + ); + + return lines.join("\n"); +} + +export function isTextEditableSelection(selection: DomEditSelection): boolean { + return selection.textFields.length > 0 && !selection.isCompositionHost; +} diff --git a/packages/studio/src/components/editor/floatingPanel.test.ts b/packages/studio/src/components/editor/floatingPanel.test.ts new file mode 100644 index 000000000..98145d693 --- /dev/null +++ b/packages/studio/src/components/editor/floatingPanel.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; +import { resolveFloatingPanelPosition } from "./floatingPanel"; + +describe("resolveFloatingPanelPosition", () => { + it("places the panel below the anchor when there is space", () => { + expect( + resolveFloatingPanelPosition( + { left: 100, top: 100, right: 220, bottom: 140, width: 120, height: 40 }, + { width: 800, height: 600 }, + { width: 280, height: 220 }, + ), + ).toMatchObject({ top: 148, placement: "bottom" }); + }); + + it("places the panel above the anchor when the bottom would be clipped", () => { + expect( + resolveFloatingPanelPosition( + { left: 100, top: 500, right: 220, bottom: 540, width: 120, height: 40 }, + { width: 800, height: 600 }, + { width: 280, height: 220 }, + ), + ).toMatchObject({ top: 272, placement: "top" }); + }); + + it("clamps the panel horizontally inside the viewport", () => { + expect( + resolveFloatingPanelPosition( + { left: 760, top: 100, right: 800, bottom: 140, width: 40, height: 40 }, + { width: 800, height: 600 }, + { width: 280, height: 220 }, + ).left, + ).toBe(508); + }); +}); diff --git a/packages/studio/src/components/editor/floatingPanel.ts b/packages/studio/src/components/editor/floatingPanel.ts new file mode 100644 index 000000000..695d32274 --- /dev/null +++ b/packages/studio/src/components/editor/floatingPanel.ts @@ -0,0 +1,54 @@ +export interface FloatingRect { + left: number; + top: number; + right: number; + bottom: number; + width: number; + height: number; +} + +export interface FloatingSize { + width: number; + height: number; +} + +export interface FloatingPosition { + left: number; + top: number; + placement: "top" | "bottom"; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +export function resolveFloatingPanelPosition( + anchor: FloatingRect, + viewport: FloatingSize, + panel: FloatingSize, + options?: { offset?: number; margin?: number }, +): FloatingPosition { + const offset = options?.offset ?? 8; + const margin = options?.margin ?? 12; + const maxLeft = Math.max(margin, viewport.width - panel.width - margin); + const preferredLeft = anchor.left + anchor.width / 2 - panel.width / 2; + const left = clamp(preferredLeft, margin, maxLeft); + const belowTop = anchor.bottom + offset; + const aboveTop = anchor.top - panel.height - offset; + const fitsBelow = belowTop + panel.height <= viewport.height - margin; + const fitsAbove = aboveTop >= margin; + + if (fitsBelow || !fitsAbove) { + return { + left, + top: clamp(belowTop, margin, Math.max(margin, viewport.height - panel.height - margin)), + placement: "bottom", + }; + } + + return { + left, + top: clamp(aboveTop, margin, Math.max(margin, viewport.height - panel.height - margin)), + placement: "top", + }; +} diff --git a/packages/studio/src/components/editor/fontAssets.ts b/packages/studio/src/components/editor/fontAssets.ts new file mode 100644 index 000000000..579a0f35a --- /dev/null +++ b/packages/studio/src/components/editor/fontAssets.ts @@ -0,0 +1,32 @@ +export interface ImportedFontAsset { + family: string; + path: string; + url: string; +} + +const FONT_EXT_RE = /\.(eot|otf|ttc|ttf|woff2?)$/i; +const FONT_STYLE_SUFFIX_RE = + /\s+(thin|extralight|extra light|light|regular|roman|medium|semibold|semi bold|bold|extrabold|extra bold|black|italic|oblique|variable)$/i; + +export function cssString(value: string): string { + return JSON.stringify(value); +} + +export function fontFamilyFromAssetPath(path: string): string { + const fileName = decodeURIComponent(path.split(/[\\/]/).pop() ?? path).replace(FONT_EXT_RE, ""); + let family = fileName + .replace(/[_-]+/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .replace(/\s+/g, " ") + .trim(); + + while (FONT_STYLE_SUFFIX_RE.test(family)) { + family = family.replace(FONT_STYLE_SUFFIX_RE, "").trim(); + } + + return family || fileName; +} + +export function importedFontFaceCss(asset: ImportedFontAsset, url: string = asset.url): string { + return `@font-face { font-family: ${cssString(asset.family)}; src: url(${cssString(url)}); font-display: swap; }`; +} diff --git a/packages/studio/src/components/editor/fontCatalog.ts b/packages/studio/src/components/editor/fontCatalog.ts new file mode 100644 index 000000000..c8691fc31 --- /dev/null +++ b/packages/studio/src/components/editor/fontCatalog.ts @@ -0,0 +1,126 @@ +export const POPULAR_GOOGLE_FONT_FAMILIES = [ + "ABeeZee", + "Abel", + "Abril Fatface", + "Alegreya", + "Alegreya Sans", + "Anton", + "Archivo", + "Archivo Black", + "Arimo", + "Assistant", + "Barlow", + "Barlow Condensed", + "Bebas Neue", + "Bitter", + "Bricolage Grotesque", + "Cabin", + "Cardo", + "Catamaran", + "Caveat", + "Chivo", + "Cormorant Garamond", + "Crimson Text", + "Dancing Script", + "DM Sans", + "DM Serif Display", + "Domine", + "EB Garamond", + "Exo 2", + "Figtree", + "Fira Code", + "Fira Sans", + "Fraunces", + "Fredoka", + "IBM Plex Mono", + "IBM Plex Sans", + "IBM Plex Serif", + "Inconsolata", + "Instrument Sans", + "Instrument Serif", + "Inter", + "JetBrains Mono", + "Josefin Sans", + "Jost", + "Kanit", + "Karla", + "Lato", + "League Gothic", + "Lexend", + "Libre Baskerville", + "Libre Franklin", + "Lora", + "Manrope", + "Merriweather", + "Montserrat", + "Mukta", + "Mulish", + "Newsreader", + "Noto Sans", + "Noto Sans JP", + "Noto Serif", + "Nunito", + "Nunito Sans", + "Open Sans", + "Oswald", + "Outfit", + "Overpass", + "Pacifico", + "Pathway Extreme", + "Permanent Marker", + "Playfair Display", + "Plus Jakarta Sans", + "Poppins", + "Prata", + "PT Sans", + "PT Serif", + "Public Sans", + "Quicksand", + "Raleway", + "Red Hat Display", + "Roboto", + "Roboto Condensed", + "Roboto Mono", + "Roboto Serif", + "Rubik", + "Schibsted Grotesk", + "Signika", + "Source Code Pro", + "Source Sans 3", + "Source Serif 4", + "Space Grotesk", + "Space Mono", + "Spectral", + "Sora", + "Syne", + "Teko", + "Titillium Web", + "Ubuntu", + "Ubuntu Mono", + "Unbounded", + "Urbanist", + "Varela Round", + "Work Sans", + "Young Serif", + "Zilla Slab", +] as const; + +export const COMMON_LOCAL_FONT_FAMILIES = [ + "TT Norms Pro", + "SF Pro Display", + "SF Pro Text", + "Avenir", + "Avenir Next", + "Helvetica Neue", + "Arial", + "Georgia", + "Times New Roman", + "Menlo", + "Monaco", + "Courier New", +] as const; + +export function googleFontStylesheetUrl(family: string): string { + const encodedFamily = encodeURIComponent(family.trim()).replace(/%20/g, "+"); + return `https://fonts.googleapis.com/css2?family=${encodedFamily}:wght@300;400;500;600;700;800;900&display=swap`; +} diff --git a/packages/studio/src/components/editor/gradientValue.test.ts b/packages/studio/src/components/editor/gradientValue.test.ts new file mode 100644 index 000000000..4a5dc6ca4 --- /dev/null +++ b/packages/studio/src/components/editor/gradientValue.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, it } from "vitest"; +import { + buildDefaultGradientModel, + insertGradientStop, + parseGradient, + serializeGradient, +} from "./gradientValue"; + +describe("parseGradient", () => { + it("parses linear gradients", () => { + expect( + parseGradient("linear-gradient(135deg, rgba(15, 23, 42, 0.58), rgba(255, 255, 255, 0.04))"), + ).toMatchObject({ + kind: "linear", + repeating: false, + angle: 135, + stops: [ + { color: "rgba(15, 23, 42, 0.58)", position: 0 }, + { color: "rgba(255, 255, 255, 0.04)", position: 100 }, + ], + }); + }); + + it("parses radial gradients", () => { + expect( + parseGradient("radial-gradient(circle closest-side at 20% 35%, #ff0000 10%, #0000ff 90%)"), + ).toMatchObject({ + kind: "radial", + shape: "circle", + radialSize: "closest-side", + centerX: 20, + centerY: 35, + }); + }); + + it("parses conic gradients", () => { + expect( + parseGradient("conic-gradient(from 45deg at 40% 60%, #111111 0%, #ffffff 100%)"), + ).toMatchObject({ + kind: "conic", + angle: 45, + centerX: 40, + centerY: 60, + }); + }); + + it("parses repeating gradients", () => { + expect( + parseGradient("repeating-linear-gradient(90deg, #000000 0%, #ffffff 50%)"), + ).toMatchObject({ + kind: "linear", + repeating: true, + angle: 90, + }); + }); +}); + +describe("serializeGradient", () => { + it("serializes default gradient models", () => { + expect(serializeGradient(buildDefaultGradientModel("rgba(60, 230, 172, 0.18)"))).toBe( + "linear-gradient(135deg, rgba(60, 230, 172, 0.18) 0%, rgba(255, 255, 255, 0.04) 100%)", + ); + }); + + it("round-trips parsed gradients", () => { + const parsed = parseGradient( + "repeating-conic-gradient(from 90deg at 25% 75%, rgba(0, 0, 0, 0.5) 0%, rgba(255, 255, 255, 0.1) 100%)", + ); + expect(parsed).not.toBeNull(); + expect(serializeGradient(parsed!)).toBe( + "repeating-conic-gradient(from 90deg at 25% 75%, rgba(0, 0, 0, 0.5) 0%, rgba(255, 255, 255, 0.1) 100%)", + ); + }); +}); + +describe("insertGradientStop", () => { + it("inserts a stop at the clicked position with an interpolated color", () => { + const parsed = parseGradient("linear-gradient(90deg, #000000 0%, #ffffff 100%)"); + expect(parsed).not.toBeNull(); + + expect(insertGradientStop(parsed!, 25)).toMatchObject({ + stops: [ + { color: "#000000", position: 0 }, + { color: "#404040", position: 25 }, + { color: "#ffffff", position: 100 }, + ], + }); + }); +}); diff --git a/packages/studio/src/components/editor/gradientValue.ts b/packages/studio/src/components/editor/gradientValue.ts new file mode 100644 index 000000000..a8983f5f9 --- /dev/null +++ b/packages/studio/src/components/editor/gradientValue.ts @@ -0,0 +1,445 @@ +export type GradientKind = "linear" | "radial" | "conic"; + +export type RadialSizeKeyword = + | "closest-side" + | "closest-corner" + | "farthest-side" + | "farthest-corner"; + +export interface GradientStop { + color: string; + position: number; +} + +export interface GradientModel { + kind: GradientKind; + repeating: boolean; + angle: number; + centerX: number; + centerY: number; + shape: "circle" | "ellipse"; + radialSize: RadialSizeKeyword; + stops: GradientStop[]; +} + +const RADIAL_SIZE_KEYWORDS: RadialSizeKeyword[] = [ + "closest-side", + "closest-corner", + "farthest-side", + "farthest-corner", +]; + +function isWhitespace(char: string | undefined): boolean { + return char === " " || char === "\n" || char === "\r" || char === "\t" || char === "\f"; +} + +function isDigit(char: string | undefined): boolean { + return char != null && char >= "0" && char <= "9"; +} + +function isSimpleNumber(value: string): boolean { + if (!value) return false; + let index = value[0] === "-" ? 1 : 0; + let digits = 0; + + while (isDigit(value[index])) { + index += 1; + digits += 1; + } + + if (value[index] === ".") { + index += 1; + while (isDigit(value[index])) { + index += 1; + digits += 1; + } + } + + return digits > 0 && index === value.length; +} + +function parseCssNumber(value: string | undefined): number | null { + if (!value) return null; + const trimmed = value.trim(); + if (!isSimpleNumber(trimmed)) return null; + const parsed = Number(trimmed); + return Number.isFinite(parsed) ? parsed : null; +} + +function splitCssWhitespace(value: string): string[] { + const tokens: string[] = []; + let current = ""; + + for (const char of value) { + if (isWhitespace(char)) { + if (current) { + tokens.push(current); + current = ""; + } + continue; + } + current += char; + } + + if (current) tokens.push(current); + return tokens; +} + +function hasCssWord(value: string, word: string): boolean { + return splitCssWhitespace(value.toLowerCase()).includes(word); +} + +function parsePercentToken(value: string | undefined, fallback: number): number { + if (!value?.endsWith("%")) return fallback; + const parsed = parseCssNumber(value.slice(0, -1)); + return parsed == null ? fallback : clamp(parsed, 0, 100); +} + +function parseAngleToken(value: string | undefined): number | null { + const trimmed = value?.trim().toLowerCase(); + if (!trimmed?.endsWith("deg")) return null; + return parseCssNumber(trimmed.slice(0, -3)); +} + +function trailingPercentStart(value: string): number | null { + if (!value.endsWith("%")) return null; + const withoutUnit = value.slice(0, -1).trimEnd(); + let start = withoutUnit.length; + + while (start > 0 && (isDigit(withoutUnit[start - 1]) || withoutUnit[start - 1] === ".")) { + start -= 1; + } + + if (start > 0 && withoutUnit[start - 1] === "-") { + start -= 1; + } + + const token = withoutUnit.slice(start); + if (!isSimpleNumber(token)) return null; + if (start === 0 || !isWhitespace(withoutUnit[start - 1])) return null; + return start; +} + +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +function round(value: number): number { + return Math.round(value * 100) / 100; +} + +function parsePercent(value: string | undefined, fallback: number): number { + const parsed = parseCssNumber(value); + return parsed == null ? fallback : clamp(parsed, 0, 100); +} + +function parseColorStop(raw: string): { color: string; position: number | null } { + const trimmed = raw.trim(); + const percentStart = trailingPercentStart(trimmed); + if (percentStart == null) return { color: trimmed, position: null }; + + const withoutUnit = trimmed.slice(0, -1).trimEnd(); + return { + color: withoutUnit.slice(0, percentStart).trim(), + position: parsePercent(withoutUnit.slice(percentStart), 0), + }; +} + +function normalizeStops(stops: Array<{ color: string; position: number | null }>): GradientStop[] { + if (stops.length === 0) { + return [ + { color: "rgba(60, 230, 172, 0.18)", position: 0 }, + { color: "rgba(255, 255, 255, 0.04)", position: 100 }, + ]; + } + + if (stops.length === 1) { + return [ + { color: stops[0].color, position: 0 }, + { color: stops[0].color, position: 100 }, + ]; + } + + const result = stops.map((stop, index) => ({ + color: stop.color, + position: stop.position ?? (index / (stops.length - 1)) * 100, + })); + + return result.map((stop) => ({ + color: stop.color, + position: round(clamp(stop.position, 0, 100)), + })); +} + +function splitGradientArgs(value: string): string[] { + const parts: string[] = []; + let current = ""; + let depth = 0; + + for (const char of value) { + if (char === "(") depth += 1; + if (char === ")") depth = Math.max(0, depth - 1); + + if (char === "," && depth === 0) { + if (current.trim()) parts.push(current.trim()); + current = ""; + continue; + } + + current += char; + } + + if (current.trim()) parts.push(current.trim()); + return parts; +} + +function directionToAngle(value: string): number | null { + const normalized = value.trim().toLowerCase(); + const map: Record = { + "to top": 0, + "to top right": 45, + "to right top": 45, + "to right": 90, + "to bottom right": 135, + "to right bottom": 135, + "to bottom": 180, + "to bottom left": 225, + "to left bottom": 225, + "to left": 270, + "to top left": 315, + "to left top": 315, + }; + return normalized in map ? map[normalized] : null; +} + +function parseLinearArgs(parts: string[]): GradientModel { + const first = parts[0] ?? ""; + const angleFromDirection = directionToAngle(first); + const parsedAngle = parseAngleToken(first); + const firstIsAngle = parsedAngle != null; + const angle = parsedAngle ?? angleFromDirection ?? 180; + const stopParts = firstIsAngle || angleFromDirection != null ? parts.slice(1) : parts; + + return { + kind: "linear", + repeating: false, + angle, + centerX: 50, + centerY: 50, + shape: "ellipse", + radialSize: "farthest-corner", + stops: normalizeStops(stopParts.map(parseColorStop)), + }; +} + +function parseRadialArgs(parts: string[]): GradientModel { + const first = parts[0] ?? ""; + const firstLower = first.toLowerCase(); + const hasConfig = + hasCssWord(firstLower, "at") || + hasCssWord(firstLower, "circle") || + hasCssWord(firstLower, "ellipse") || + firstLower.includes("closest-") || + firstLower.includes("farthest-"); + const config = hasConfig ? first : ""; + const stopParts = hasConfig ? parts.slice(1) : parts; + const configLower = config.toLowerCase(); + const configTokens = splitCssWhitespace(configLower); + const atIndex = configTokens.indexOf("at"); + + const shape = hasCssWord(configLower, "circle") ? "circle" : "ellipse"; + const radialSize = + RADIAL_SIZE_KEYWORDS.find((keyword) => configTokens.includes(keyword)) ?? "farthest-corner"; + + return { + kind: "radial", + repeating: false, + angle: 180, + centerX: parsePercentToken(configTokens[atIndex + 1], 50), + centerY: parsePercentToken(configTokens[atIndex + 2], 50), + shape, + radialSize, + stops: normalizeStops(stopParts.map(parseColorStop)), + }; +} + +function parseConicArgs(parts: string[]): GradientModel { + const first = parts[0] ?? ""; + const firstLower = first.toLowerCase(); + const hasConfig = hasCssWord(firstLower, "from") || hasCssWord(firstLower, "at"); + const config = hasConfig ? first : ""; + const stopParts = hasConfig ? parts.slice(1) : parts; + const configTokens = splitCssWhitespace(config.toLowerCase()); + const fromIndex = configTokens.indexOf("from"); + const atIndex = configTokens.indexOf("at"); + const angle = parseAngleToken(configTokens[fromIndex + 1]); + + return { + kind: "conic", + repeating: false, + angle: angle ?? 0, + centerX: parsePercentToken(configTokens[atIndex + 1], 50), + centerY: parsePercentToken(configTokens[atIndex + 2], 50), + shape: "ellipse", + radialSize: "farthest-corner", + stops: normalizeStops(stopParts.map(parseColorStop)), + }; +} + +export function buildDefaultGradientModel(fallbackColor?: string): GradientModel { + return { + kind: "linear", + repeating: false, + angle: 135, + centerX: 50, + centerY: 50, + shape: "ellipse", + radialSize: "farthest-corner", + stops: normalizeStops([ + { + color: + fallbackColor && fallbackColor !== "transparent" + ? fallbackColor + : "rgba(60, 230, 172, 0.18)", + position: 0, + }, + { color: "rgba(255, 255, 255, 0.04)", position: 100 }, + ]), + }; +} + +export function parseGradient(value: string | undefined): GradientModel | null { + if (!value || value === "none") return null; + const trimmed = value.trim(); + const openParenIndex = trimmed.indexOf("("); + if (openParenIndex <= 0 || !trimmed.endsWith(")")) return null; + + const functionName = trimmed.slice(0, openParenIndex).toLowerCase(); + const kindByFunctionName: Record = { + "linear-gradient": { kind: "linear", repeating: false }, + "radial-gradient": { kind: "radial", repeating: false }, + "conic-gradient": { kind: "conic", repeating: false }, + "repeating-linear-gradient": { kind: "linear", repeating: true }, + "repeating-radial-gradient": { kind: "radial", repeating: true }, + "repeating-conic-gradient": { kind: "conic", repeating: true }, + }; + const parsedFunction = kindByFunctionName[functionName]; + if (!parsedFunction) return null; + + const { kind, repeating } = parsedFunction; + const parts = splitGradientArgs(trimmed.slice(openParenIndex + 1, -1)); + + const parsed = + kind === "linear" + ? parseLinearArgs(parts) + : kind === "radial" + ? parseRadialArgs(parts) + : parseConicArgs(parts); + + return { ...parsed, repeating }; +} + +function formatStop(stop: GradientStop): string { + return `${stop.color} ${round(stop.position)}%`; +} + +export function serializeGradient(model: GradientModel): string { + const fn = `${model.repeating ? "repeating-" : ""}${model.kind}-gradient`; + const stops = model.stops.map(formatStop).join(", "); + + if (model.kind === "linear") { + return `${fn}(${round(model.angle)}deg, ${stops})`; + } + + if (model.kind === "radial") { + return `${fn}(${model.shape} ${model.radialSize} at ${round(model.centerX)}% ${round( + model.centerY, + )}%, ${stops})`; + } + + return `${fn}(from ${round(model.angle)}deg at ${round(model.centerX)}% ${round( + model.centerY, + )}%, ${stops})`; +} + +function blendChannel(start: number, end: number, ratio: number): number { + return Math.round(start + (end - start) * ratio); +} + +function formatHex(channel: number): string { + return channel.toString(16).padStart(2, "0"); +} + +export function interpolateGradientStopColor(model: GradientModel, position: number): string { + const clampedPosition = clamp(position, 0, 100); + const sortedStops = [...model.stops].sort((a, b) => a.position - b.position); + const exact = sortedStops.find((stop) => Math.abs(stop.position - clampedPosition) < 0.001); + if (exact) return exact.color; + + const right = sortedStops.find((stop) => stop.position > clampedPosition) ?? sortedStops.at(-1); + const left = + [...sortedStops].reverse().find((stop) => stop.position < clampedPosition) ?? sortedStops[0]; + if (!left || !right) return sortedStops[0]?.color ?? "rgba(255, 255, 255, 1)"; + if (left === right) return left.color; + + const leftColor = left.color; + const rightColor = right.color; + const leftParsed = leftColor ? parseColorString(leftColor) : null; + const rightParsed = rightColor ? parseColorString(rightColor) : null; + if (!leftParsed || !rightParsed) return left.color; + + const ratio = (clampedPosition - left.position) / Math.max(1, right.position - left.position); + const red = blendChannel(leftParsed.red, rightParsed.red, ratio); + const green = blendChannel(leftParsed.green, rightParsed.green, ratio); + const blue = blendChannel(leftParsed.blue, rightParsed.blue, ratio); + const alpha = round(leftParsed.alpha + (rightParsed.alpha - leftParsed.alpha) * ratio); + + if (alpha >= 1) { + return `#${formatHex(red)}${formatHex(green)}${formatHex(blue)}`.toUpperCase(); + } + + return `rgba(${red}, ${green}, ${blue}, ${alpha})`; +} + +export function insertGradientStop(model: GradientModel, position: number): GradientModel { + const clampedPosition = round(clamp(position, 0, 100)); + const color = interpolateGradientStopColor(model, clampedPosition); + const nextStops = [...model.stops, { color, position: clampedPosition }].sort( + (a, b) => a.position - b.position, + ); + return { + ...model, + stops: nextStops, + }; +} + +function parseColorString( + value: string, +): { red: number; green: number; blue: number; alpha: number } | null { + const trimmed = value.trim().toLowerCase(); + if (trimmed === "transparent") { + return { red: 0, green: 0, blue: 0, alpha: 0 }; + } + + const hex = trimmed.match(/^#([0-9a-f]{6})$/i); + if (hex) { + return { + red: Number.parseInt(hex[1].slice(0, 2), 16), + green: Number.parseInt(hex[1].slice(2, 4), 16), + blue: Number.parseInt(hex[1].slice(4, 6), 16), + alpha: 1, + }; + } + + const rgba = trimmed.match( + /^rgba?\(\s*([0-9.]+)\s*,\s*([0-9.]+)\s*,\s*([0-9.]+)(?:\s*,\s*([0-9.]+))?\s*\)$/i, + ); + if (!rgba) return null; + + return { + red: Number.parseFloat(rgba[1]), + green: Number.parseFloat(rgba[2]), + blue: Number.parseFloat(rgba[3]), + alpha: rgba[4] != null ? Number.parseFloat(rgba[4]) : 1, + }; +} diff --git a/packages/studio/src/components/editor/motionEditing.test.ts b/packages/studio/src/components/editor/motionEditing.test.ts new file mode 100644 index 000000000..34129d39f --- /dev/null +++ b/packages/studio/src/components/editor/motionEditing.test.ts @@ -0,0 +1,514 @@ +import { describe, expect, it } from "vitest"; +import { Window } from "happy-dom"; +import { + buildMotionAgentContext, + buildMotionCurveModel, + buildDomEditMotionPatchOperations, + buildDomEditMotionPatchOperation, + buildMotionVarsAttribute, + buildMotionVarsId, + clampMotionDraftToTimeline, + detectMotionOwnership, + formatMotionBezierEase, + MOTION_EASE_OPTIONS, + parseMotionDraft, + parseMotionBezierEase, + parseMotionVarsAttribute, + sampleMotionEase, + serializeMotionVarsAttribute, + serializeMotionDraft, + type MotionDraft, +} from "./motionEditing"; + +describe("motionEditing", () => { + it("serializes the default fade-up preset into the data-hf-motion contract", () => { + const draft: MotionDraft = { + preset: "fade-up", + direction: "up", + start: 0, + duration: 0.6, + ease: "outCubic", + distance: 32, + }; + + expect(serializeMotionDraft(draft)).toBe( + "v=1;preset=fade-up;start=0;duration=0.6;ease=outCubic;x=0;y=32;opacity=0:1;scale=1:1", + ); + }); + + it("serializes custom two-keyframe tracks into the data-hf-motion contract", () => { + const draft: MotionDraft = { + preset: "fade-up", + direction: "up", + start: 0.1, + duration: 1.2, + ease: "inOutCubic", + distance: 32, + keyframes: { + x: { from: -24, to: 12 }, + y: { from: 48, to: 4 }, + opacity: { from: 0.25, to: 0.9 }, + scale: { from: 0.8, to: 1.1 }, + }, + }; + + expect(serializeMotionDraft(draft)).toBe( + "v=1;preset=fade-up;start=0.1;duration=1.2;ease=inOutCubic;x=-24:12;y=48:4;opacity=0.25:0.9;scale=0.8:1.1", + ); + }); + + it("serializes and parses custom cubic-bezier easing", () => { + const draft: MotionDraft = { + preset: "fade-up", + direction: "up", + start: 0, + duration: 0.6, + ease: "bezier(0.2,1.25,0.4,1)", + distance: 32, + }; + + expect(serializeMotionDraft(draft)).toContain("ease=bezier(0.2,1.25,0.4,1)"); + expect(parseMotionDraft(serializeMotionDraft(draft))?.ease).toBe("bezier(0.2,1.25,0.4,1)"); + expect(parseMotionBezierEase("cubic-bezier(0.2, 1.25, 0.4, 1)")).toEqual({ + x1: 0.2, + y1: 1.25, + x2: 0.4, + y2: 1, + }); + }); + + it("exposes a broad deterministic easing catalog", () => { + expect(MOTION_EASE_OPTIONS.map((option) => option.value)).toEqual( + expect.arrayContaining([ + "linear", + "ease", + "outSine", + "inOutQuad", + "outCubic", + "inOutQuart", + "outExpo", + "inOutCirc", + "outBack", + "outBounce", + "outElastic", + ]), + ); + expect(formatMotionBezierEase({ x1: 0.16, y1: 1, x2: 0.3, y2: 1 })).toBe( + "bezier(0.16,1,0.3,1)", + ); + expect(sampleMotionEase("outBack", 0.5)).toBeGreaterThan(1); + expect(sampleMotionEase("bezier(0,0,1,1)", 0.5)).toBeCloseTo(0.5, 4); + }); + + it("serializes slide direction and distance into x/y offsets", () => { + const draft: MotionDraft = { + preset: "slide", + direction: "left", + start: 0.25, + duration: 0.75, + ease: "linear", + distance: 48, + }; + + expect(serializeMotionDraft(draft)).toBe( + "v=1;preset=slide;start=0.25;duration=0.75;ease=linear;x=48;y=0;opacity=1:1;scale=1:1", + ); + }); + + it("serializes pop as opacity plus scale without travel", () => { + const draft: MotionDraft = { + preset: "pop", + direction: "up", + start: 0, + duration: 0.4, + ease: "outCubic", + distance: 32, + }; + + expect(serializeMotionDraft(draft)).toBe( + "v=1;preset=pop;start=0;duration=0.4;ease=outCubic;x=0;y=0;opacity=0:1;scale=0.92:1", + ); + }); + + it("clamps motion timing to the timeline duration", () => { + const draft: MotionDraft = { + preset: "fade-up", + direction: "up", + start: 5, + duration: 16.05, + ease: "outCubic", + distance: 32, + keyframes: { + x: { from: 0, to: 0 }, + y: { from: 44, to: 0 }, + opacity: { from: 0, to: 1 }, + scale: { from: 1, to: 1 }, + }, + }; + + expect(clampMotionDraftToTimeline(draft, 3)).toEqual({ + ...draft, + start: 2.95, + duration: 0.05, + }); + }); + + it("clamps duration against the remaining timeline window", () => { + expect( + clampMotionDraftToTimeline( + { + preset: "slide", + direction: "right", + start: 2.2, + duration: 2, + ease: "linear", + distance: 24, + }, + 3, + ), + ).toMatchObject({ + start: 2.2, + duration: 0.8, + }); + }); + + it("parses authored motion back into editable controls", () => { + expect( + parseMotionDraft( + "v=1;preset=fade-up;start=0.15;duration=0.8;ease=inOutCubic;x=0;y=40;opacity=0:1;scale=1:1", + ), + ).toEqual({ + preset: "fade-up", + direction: "up", + start: 0.15, + duration: 0.8, + ease: "inOutCubic", + distance: 40, + keyframes: { + x: { from: 0, to: 0 }, + y: { from: 40, to: 0 }, + opacity: { from: 0, to: 1 }, + scale: { from: 1, to: 1 }, + }, + }); + }); + + it("parses custom two-keyframe tracks back into editable controls", () => { + expect( + parseMotionDraft( + "v=1;preset=slide;start=0.25;duration=0.75;ease=linear;x=-20:10;y=4:-4;opacity=0.2:1;scale=0.75:1.25", + ), + ).toEqual({ + preset: "slide", + direction: "right", + start: 0.25, + duration: 0.75, + ease: "linear", + distance: 20, + keyframes: { + x: { from: -20, to: 10 }, + y: { from: 4, to: -4 }, + opacity: { from: 0.2, to: 1 }, + scale: { from: 0.75, to: 1.25 }, + }, + }); + }); + + it("builds set and clear patch operations", () => { + const draft: MotionDraft = { + preset: "fade-up", + direction: "up", + start: 0, + duration: 0.6, + ease: "outCubic", + distance: 32, + }; + + expect(buildDomEditMotionPatchOperation(draft)).toEqual({ + type: "attribute", + property: "hf-motion", + value: "v=1;preset=fade-up;start=0;duration=0.6;ease=outCubic;x=0;y=32;opacity=0:1;scale=1:1", + }); + expect(buildDomEditMotionPatchOperation(null)).toEqual({ + type: "attribute", + property: "hf-motion", + value: null, + }); + expect(buildDomEditMotionPatchOperations(draft, "headline")).toEqual([ + { + type: "attribute", + property: "hf-motion", + value: + "v=1;preset=fade-up;start=0;duration=0.6;ease=outCubic;x=0;y=32;opacity=0:1;scale=1:1", + }, + { + type: "attribute", + property: "hf-motion-vars", + value: "v=1;id=headline;lanes=x,y,scale,rotate,opacity;owner=hf;driver=hf", + }, + ]); + expect(buildDomEditMotionPatchOperations(null, "headline")).toEqual([ + { type: "attribute", property: "hf-motion", value: null }, + { type: "attribute", property: "hf-motion-vars", value: null }, + ]); + }); + + it("serializes and parses canonical motion variable lanes", () => { + expect(buildMotionVarsId("#Hero Title")).toBe("hero-title"); + expect(buildMotionVarsAttribute("hero-title")).toBe( + "v=1;id=hero-title;lanes=x,y,scale,rotate,opacity;owner=hf;driver=hf", + ); + expect( + parseMotionVarsAttribute( + "v=1;id=hero-title;lanes=x,y,scale,rotate,opacity;owner=hf;driver=hf", + ), + ).toEqual({ + version: 1, + id: "hero-title", + lanes: ["x", "y", "scale", "rotate", "opacity"], + owner: "hf", + driver: "hf", + }); + expect(parseMotionVarsAttribute("v=1;id=hero;lanes=x,bad;owner=hf;driver=hf")).toBeNull(); + }); + + it("serializes external runtime variable lanes through the shared contract", () => { + const serialized = serializeMotionVarsAttribute({ + version: 1, + id: "#Hero Title", + lanes: ["x", "opacity"], + owner: "gsap", + driver: "runtime", + }); + + expect(serialized).toBe("v=1;id=hero-title;lanes=x,opacity;owner=gsap;driver=runtime"); + expect(parseMotionVarsAttribute(serialized)).toEqual({ + version: 1, + id: "hero-title", + lanes: ["x", "opacity"], + owner: "gsap", + driver: "runtime", + }); + }); + + it("maps fade-up into curve tracks with start and end markers", () => { + const model = buildMotionCurveModel({ + preset: "fade-up", + direction: "up", + start: 0.25, + duration: 0.75, + ease: "outCubic", + distance: 40, + }); + + expect(model.windowEnd).toBe(1); + expect(model.markers).toEqual([ + { label: "Start", time: 0.25, percent: 25 }, + { label: "End", time: 1, percent: 100 }, + ]); + expect(model.tracks).toMatchObject([ + { key: "x", from: 0, to: 0, unit: "px", active: false }, + { key: "y", from: 40, to: 0, unit: "px", active: true }, + { key: "opacity", from: 0, to: 1, unit: "", active: true }, + { key: "scale", from: 1, to: 1, unit: "", active: false }, + ]); + }); + + it("maps pop into opacity and scale curve tracks", () => { + const model = buildMotionCurveModel({ + preset: "pop", + direction: "left", + start: 0, + duration: 0.4, + ease: "outCubic", + distance: 80, + }); + + expect(model.tracks.map((track) => [track.key, track.from, track.to, track.active])).toEqual([ + ["x", 0, 0, false], + ["y", 0, 0, false], + ["opacity", 0, 1, true], + ["scale", 0.92, 1, true], + ]); + }); + + it("maps custom keyframes into editable curve tracks", () => { + const model = buildMotionCurveModel({ + preset: "fade-up", + direction: "up", + start: 0, + duration: 1, + ease: "linear", + distance: 32, + keyframes: { + x: { from: -8, to: 16 }, + y: { from: 24, to: 0 }, + opacity: { from: 0.25, to: 1 }, + scale: { from: 0.9, to: 1.05 }, + }, + }); + + expect(model.tracks.map((track) => [track.key, track.from, track.to, track.active])).toEqual([ + ["x", -8, 16, true], + ["y", 24, 0, true], + ["opacity", 0.25, 1, true], + ["scale", 0.9, 1.05, true], + ]); + }); + + it("detects editable HyperFrames motion ownership", () => { + const window = new Window(); + const element = window.document.createElement("div"); + element.setAttribute("data-hf-motion", "v=1;preset=fade-up"); + window.document.body.appendChild(element); + + expect( + detectMotionOwnership({ + element: element as HTMLElement, + dataAttributes: { "hf-motion": "v=1;preset=fade-up" }, + computedStyles: {}, + }), + ).toMatchObject({ + state: "editable", + badges: [{ kind: "hf-motion", label: "HF Motion", state: "Editable" }], + }); + }); + + it("detects variable-owned HyperFrames motion as editable", () => { + const window = new Window(); + const element = window.document.createElement("div"); + element.setAttribute("data-hf-motion-vars", buildMotionVarsAttribute("headline")); + window.document.body.appendChild(element); + + const report = detectMotionOwnership({ + element: element as HTMLElement, + dataAttributes: { "hf-motion-vars": buildMotionVarsAttribute("headline") }, + computedStyles: {}, + }); + + expect(report.state).toBe("editable"); + expect(report.badges).toContainEqual( + expect.objectContaining({ + kind: "hf-motion", + label: "HF Motion Vars", + state: "Editable", + }), + ); + }); + + it("detects external canonical variable lanes without exposing unsafe editing", () => { + const window = new Window(); + const element = window.document.createElement("div"); + element.setAttribute( + "data-hf-motion-vars", + "v=1;id=headline;lanes=x,y,scale,rotate,opacity;owner=gsap;driver=runtime", + ); + window.document.body.appendChild(element); + + const report = detectMotionOwnership({ + element: element as HTMLElement, + dataAttributes: { + "hf-motion-vars": + "v=1;id=headline;lanes=x,y,scale,rotate,opacity;owner=gsap;driver=runtime", + }, + computedStyles: {}, + }); + + expect(report).toMatchObject({ + state: "detected", + editable: false, + badges: [{ kind: "gsap", label: "GSAP Vars", state: "Detected", editable: false }], + }); + }); + + it("detects read-only external runtime ownership and mixed ownership", () => { + const window = new Window(); + const element = window.document.createElement("div") as HTMLElement; + window.document.body.appendChild(element); + Object.defineProperty(element, "getAnimations", { + value: () => [{ constructor: { name: "Animation" } }], + }); + Object.assign(window, { + __timelines: { + main: { + getChildren: () => [{ targets: () => [element] }], + }, + }, + __hfAnime: [{ animatables: [{ target: element }] }], + }); + + const report = detectMotionOwnership({ + element, + dataAttributes: {}, + computedStyles: { + "animation-name": "pulse", + "animation-duration": "1s", + }, + }); + + expect(report.state).toBe("mixed"); + expect(report.badges.map((badge) => badge.label)).toEqual([ + "CSS", + "WAAPI", + "GSAP", + "anime.js", + "Mixed", + ]); + expect(report.badges.filter((badge) => badge.state === "Editable")).toHaveLength(0); + }); + + it("detects Three ownership for time-driven canvases", () => { + const window = new Window(); + const canvas = window.document.createElement("canvas") as HTMLElement; + window.document.body.appendChild(canvas); + Object.assign(window, { __hfThreeTime: 0 }); + + expect( + detectMotionOwnership({ + element: canvas, + dataAttributes: {}, + computedStyles: {}, + }).badges, + ).toMatchObject([{ kind: "three", label: "Three", state: "Detected" }]); + }); + + it("builds motion-aware agent context with curve and runtime guardrails", () => { + const ownership = detectMotionOwnership({ + dataAttributes: { "hf-motion": "v=1;preset=fade-up" }, + computedStyles: { + "animation-name": "pulse", + "animation-duration": "1s", + }, + }); + + const context = buildMotionAgentContext({ + hfMotionAttribute: + "v=1;preset=fade-up;start=0;duration=0.6;ease=outCubic;x=0;y=32;opacity=0:1;scale=1:1", + draft: parseMotionDraft( + "v=1;preset=fade-up;start=0;duration=0.6;ease=outCubic;x=0;y=32;opacity=0:1;scale=1:1", + ), + ownership, + }); + + expect(context.version).toBe(1); + expect(context.owners.map((owner) => `${owner.label}:${owner.state}`)).toEqual([ + "HF Motion:Editable", + "CSS:Detected", + "Mixed:Mixed", + ]); + expect(context.curveTracks).toContainEqual({ + key: "y", + label: "Y", + from: 32, + to: 0, + unit: "px", + active: true, + }); + expect( + context.instructions.some((item) => item.includes("Patch the existing runtime library")), + ).toBe(true); + expect(context.instructions.some((item) => item.includes("Use an outer layout wrapper"))).toBe( + true, + ); + }); +}); diff --git a/packages/studio/src/components/editor/motionEditing.ts b/packages/studio/src/components/editor/motionEditing.ts new file mode 100644 index 000000000..34f3950d2 --- /dev/null +++ b/packages/studio/src/components/editor/motionEditing.ts @@ -0,0 +1,1240 @@ +import type { PatchOperation } from "../../utils/sourcePatcher"; + +export type MotionPreset = "fade-up" | "slide" | "pop"; +export type MotionDirection = "up" | "down" | "left" | "right"; +export type MotionEasePreset = + | "linear" + | "ease" + | "inSine" + | "outSine" + | "inOutSine" + | "inQuad" + | "outQuad" + | "inOutQuad" + | "inCubic" + | "outCubic" + | "inOutCubic" + | "inQuart" + | "outQuart" + | "inOutQuart" + | "inQuint" + | "outQuint" + | "inOutQuint" + | "inExpo" + | "outExpo" + | "inOutExpo" + | "inCirc" + | "outCirc" + | "inOutCirc" + | "inBack" + | "outBack" + | "inOutBack" + | "inBounce" + | "outBounce" + | "inOutBounce" + | "inElastic" + | "outElastic" + | "inOutElastic"; +export type MotionEase = MotionEasePreset | `bezier(${string})`; +export type MotionCurveTrackKey = "x" | "y" | "opacity" | "scale"; +export type MotionVariableLane = "x" | "y" | "scale" | "rotate" | "opacity"; +export type MotionVariableOwner = "hf" | "gsap" | "anime" | "css" | "waapi"; +export type MotionVariableDriver = "hf" | "runtime"; +export type MotionOwnershipKind = + | "hf-motion" + | "css" + | "waapi" + | "gsap" + | "animejs" + | "three" + | "mixed" + | "unsafe"; +export type MotionOwnershipState = "Editable" | "Detected" | "Mixed" | "Unsafe"; + +export interface MotionKeyframeRange { + from: number; + to: number; +} + +export interface MotionKeyframes { + x: MotionKeyframeRange; + y: MotionKeyframeRange; + opacity: MotionKeyframeRange; + scale: MotionKeyframeRange; +} + +export interface MotionDraft { + preset: MotionPreset; + direction: MotionDirection; + start: number; + duration: number; + ease: MotionEase; + distance: number; + keyframes?: MotionKeyframes; +} + +export interface MotionCurveMarker { + label: string; + time: number; + percent: number; +} + +export interface MotionCurveTrack { + key: MotionCurveTrackKey; + label: string; + from: number; + to: number; + unit: "px" | ""; + active: boolean; + points: Array<{ percent: number; value: number }>; +} + +export interface MotionCurveModel { + start: number; + duration: number; + end: number; + windowEnd: number; + ease: MotionEase; + markers: MotionCurveMarker[]; + tracks: MotionCurveTrack[]; +} + +export interface MotionOwnershipBadge { + kind: MotionOwnershipKind; + label: string; + state: MotionOwnershipState; + editable: boolean; + description: string; +} + +export interface MotionOwnershipReport { + state: "none" | "editable" | "detected" | "mixed" | "unsafe"; + badges: MotionOwnershipBadge[]; + editable: boolean; + summary: string; +} + +export interface MotionOwnershipInput { + element?: HTMLElement | null; + dataAttributes?: Record; + computedStyles?: Record; +} + +export interface MotionAgentOwner { + label: string; + state: MotionOwnershipState; + editable: boolean; +} + +export interface MotionAgentCurveTrack { + key: MotionCurveTrackKey; + label: string; + from: number; + to: number; + unit: "px" | ""; + active: boolean; +} + +export interface MotionAgentContext { + version: 1; + state: MotionOwnershipReport["state"]; + summary: string; + hfMotionAttribute?: string; + owners: MotionAgentOwner[]; + curveTracks: MotionAgentCurveTrack[]; + instructions: string[]; +} + +export interface MotionEaseOption { + label: string; + value: MotionEasePreset; + group: "Basic" | "Sine" | "Quad" | "Cubic" | "Quart" | "Quint" | "Expo" | "Circ" | "Back" | "FX"; +} + +export interface MotionBezierEase { + x1: number; + y1: number; + x2: number; + y2: number; +} + +export interface MotionVarsConfig { + version: 1; + id: string; + lanes: MotionVariableLane[]; + owner: MotionVariableOwner; + driver: MotionVariableDriver; +} + +export type MotionEditConfidence = "safe" | "needs-import" | "read-only" | "unsafe"; + +export interface MotionVariableLanePatch { + lane: MotionVariableLane; + keyframes: MotionKeyframeRange; +} + +export interface MotionVariableSourcePatch { + confidence: "safe"; + operations: PatchOperation[]; +} + +export interface MotionVariableAdapterEditContract { + detect: (target: TTarget) => MotionOwnershipReport; + canImport: (target: TTarget) => MotionEditConfidence; + importToVariableLanes: (target: TTarget) => MotionVariableSourcePatch | null; + canPatch: (target: TTarget) => MotionEditConfidence; + patchVariableLane: ( + target: TTarget, + lane: MotionVariableLane, + keyframes: MotionKeyframeRange, + ) => MotionVariableSourcePatch | null; +} + +const MOTION_VERSION = 1; +const DEFAULT_START = 0; +const DEFAULT_DISTANCE = 32; +const DEFAULT_DURATION = 0.6; +const MIN_MOTION_DURATION = 0.05; +const POP_DURATION = 0.4; +const POP_SCALE_FROM = 0.92; +const SUPPORTED_PRESETS = new Set(["fade-up", "slide", "pop"]); +const SUPPORTED_DIRECTIONS = new Set(["up", "down", "left", "right"]); +const MOTION_VAR_LANES: MotionVariableLane[] = ["x", "y", "scale", "rotate", "opacity"]; +const SUPPORTED_MOTION_VAR_LANES = new Set(MOTION_VAR_LANES); +const SUPPORTED_MOTION_VAR_OWNERS = new Set([ + "hf", + "gsap", + "anime", + "css", + "waapi", +]); +const SUPPORTED_MOTION_VAR_DRIVERS = new Set(["hf", "runtime"]); +export const DEFAULT_CUSTOM_MOTION_BEZIER: MotionBezierEase = { + x1: 0.16, + y1: 1, + x2: 0.3, + y2: 1, +}; +export const MOTION_EASE_OPTIONS: MotionEaseOption[] = [ + { label: "Linear", value: "linear", group: "Basic" }, + { label: "CSS Ease", value: "ease", group: "Basic" }, + { label: "In Sine", value: "inSine", group: "Sine" }, + { label: "Out Sine", value: "outSine", group: "Sine" }, + { label: "In Out Sine", value: "inOutSine", group: "Sine" }, + { label: "In Quad", value: "inQuad", group: "Quad" }, + { label: "Out Quad", value: "outQuad", group: "Quad" }, + { label: "In Out Quad", value: "inOutQuad", group: "Quad" }, + { label: "In Cubic", value: "inCubic", group: "Cubic" }, + { label: "Out Cubic", value: "outCubic", group: "Cubic" }, + { label: "In Out Cubic", value: "inOutCubic", group: "Cubic" }, + { label: "In Quart", value: "inQuart", group: "Quart" }, + { label: "Out Quart", value: "outQuart", group: "Quart" }, + { label: "In Out Quart", value: "inOutQuart", group: "Quart" }, + { label: "In Quint", value: "inQuint", group: "Quint" }, + { label: "Out Quint", value: "outQuint", group: "Quint" }, + { label: "In Out Quint", value: "inOutQuint", group: "Quint" }, + { label: "In Expo", value: "inExpo", group: "Expo" }, + { label: "Out Expo", value: "outExpo", group: "Expo" }, + { label: "In Out Expo", value: "inOutExpo", group: "Expo" }, + { label: "In Circ", value: "inCirc", group: "Circ" }, + { label: "Out Circ", value: "outCirc", group: "Circ" }, + { label: "In Out Circ", value: "inOutCirc", group: "Circ" }, + { label: "In Back", value: "inBack", group: "Back" }, + { label: "Out Back", value: "outBack", group: "Back" }, + { label: "In Out Back", value: "inOutBack", group: "Back" }, + { label: "In Bounce", value: "inBounce", group: "FX" }, + { label: "Out Bounce", value: "outBounce", group: "FX" }, + { label: "In Out Bounce", value: "inOutBounce", group: "FX" }, + { label: "In Elastic", value: "inElastic", group: "FX" }, + { label: "Out Elastic", value: "outElastic", group: "FX" }, + { label: "In Out Elastic", value: "inOutElastic", group: "FX" }, +]; +const SUPPORTED_EASES = new Set(MOTION_EASE_OPTIONS.map((option) => option.value)); + +interface ParsedMotionAttribute { + preset: MotionPreset; + start: number; + duration: number; + ease: MotionEase; + keyframes: MotionKeyframes; +} + +export function buildDefaultMotionDraft( + preset: MotionPreset = "fade-up", + base?: Partial, +): MotionDraft { + const draft = { + preset, + direction: base?.direction && SUPPORTED_DIRECTIONS.has(base.direction) ? base.direction : "up", + start: normalizeFiniteNumber(base?.start, DEFAULT_START, 0), + duration: + preset === "pop" + ? normalizeFiniteNumber(base?.duration, POP_DURATION, MIN_MOTION_DURATION) + : normalizeFiniteNumber(base?.duration, DEFAULT_DURATION, MIN_MOTION_DURATION), + ease: base?.ease ? (normalizeMotionEase(base.ease) ?? "outCubic") : "outCubic", + distance: normalizeFiniteNumber(base?.distance, DEFAULT_DISTANCE, 0), + }; + + if (!base?.keyframes) return draft; + return { + ...draft, + keyframes: normalizeMotionKeyframes(base.keyframes, getPresetKeyframes(draft)), + }; +} + +export function clampMotionDraftToTimeline( + draft: MotionDraft, + timelineDuration: number | null | undefined, +): MotionDraft { + const normalized = buildDefaultMotionDraft(draft.preset, draft); + if (timelineDuration == null || !Number.isFinite(timelineDuration) || timelineDuration <= 0) { + return normalized; + } + + const maxTimelineTime = Math.max(MIN_MOTION_DURATION, timelineDuration); + const maxStart = Math.max(0, maxTimelineTime - MIN_MOTION_DURATION); + const start = roundMotionNumber(Math.min(maxStart, Math.max(0, normalized.start))); + const durationMax = Math.max(MIN_MOTION_DURATION, maxTimelineTime - start); + const duration = roundMotionNumber( + Math.min(durationMax, Math.max(MIN_MOTION_DURATION, normalized.duration)), + ); + + return { + ...normalized, + start, + duration, + }; +} + +export function serializeMotionDraft(draft: MotionDraft): string { + const normalized = buildDefaultMotionDraft(draft.preset, draft); + const keyframes = resolveMotionKeyframes(normalized); + + return [ + `v=${MOTION_VERSION}`, + `preset=${normalized.preset}`, + `start=${formatNumber(normalized.start)}`, + `duration=${formatNumber(normalized.duration)}`, + `ease=${normalized.ease}`, + `x=${formatPositionRange(keyframes.x)}`, + `y=${formatPositionRange(keyframes.y)}`, + `opacity=${formatRange(keyframes.opacity)}`, + `scale=${formatRange(keyframes.scale)}`, + ].join(";"); +} + +export function parseMotionDraft(value: string | null | undefined): MotionDraft | null { + const parsed = parseMotionAttribute(value); + if (!parsed) return null; + const { x, y } = parsed.keyframes; + const dominantOffset = + Math.max(Math.abs(x.from), Math.abs(x.to)) >= Math.max(Math.abs(y.from), Math.abs(y.to)) + ? Math.abs(x.from) >= Math.abs(x.to) + ? x.from + : x.to + : Math.abs(y.from) >= Math.abs(y.to) + ? y.from + : y.to; + const direction = resolveDirection(x.from, y.from); + + return buildDefaultMotionDraft(parsed.preset, { + direction, + start: parsed.start, + duration: parsed.duration, + ease: parsed.ease, + distance: Math.abs(dominantOffset), + keyframes: parsed.keyframes, + }); +} + +export function buildDomEditMotionPatchOperation(draft: MotionDraft | null): PatchOperation { + return { + type: "attribute", + property: "hf-motion", + value: draft ? serializeMotionDraft(draft) : null, + }; +} + +export function buildMotionVarsId(seed: string | null | undefined): string { + const normalized = (seed ?? "") + .trim() + .toLowerCase() + .replace(/^[.#]+/, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 64); + return normalized || "layer"; +} + +export function buildMotionVarsAttribute(id: string): string { + return serializeMotionVarsAttribute({ + version: 1, + id, + lanes: MOTION_VAR_LANES, + owner: "hf", + driver: "hf", + }); +} + +export function serializeMotionVarsAttribute(config: MotionVarsConfig): string { + const id = buildMotionVarsId(config.id); + const lanes: MotionVariableLane[] = []; + for (const lane of config.lanes) { + if (!SUPPORTED_MOTION_VAR_LANES.has(lane)) continue; + if (!lanes.includes(lane)) lanes.push(lane); + } + const owner = SUPPORTED_MOTION_VAR_OWNERS.has(config.owner) ? config.owner : "hf"; + const driver = SUPPORTED_MOTION_VAR_DRIVERS.has(config.driver) ? config.driver : "hf"; + return [ + `v=${MOTION_VERSION}`, + `id=${id}`, + `lanes=${lanes.length > 0 ? lanes : MOTION_VAR_LANES}`, + `owner=${owner}`, + `driver=${driver}`, + ].join(";"); +} + +export function parseMotionVarsAttribute( + value: string | null | undefined, +): MotionVarsConfig | null { + if (!value) return null; + const parts = new Map(); + for (const rawPart of value.split(";")) { + const part = rawPart.trim(); + if (!part) continue; + const separatorIndex = part.indexOf("="); + if (separatorIndex <= 0) return null; + const key = part.slice(0, separatorIndex).trim(); + const partValue = part.slice(separatorIndex + 1).trim(); + if (!key || !partValue) return null; + parts.set(key, partValue); + } + + if (parts.get("v") !== `${MOTION_VERSION}`) return null; + const id = parts.get("id"); + if (!id || buildMotionVarsId(id) !== id) return null; + const lanes: MotionVariableLane[] = []; + for (const rawLane of parts.get("lanes")?.split(",") ?? []) { + const lane = rawLane.trim() as MotionVariableLane; + if (!SUPPORTED_MOTION_VAR_LANES.has(lane)) return null; + if (!lanes.includes(lane)) lanes.push(lane); + } + if (lanes.length === 0) return null; + + const owner = parts.get("owner") as MotionVariableOwner | undefined; + if (!owner || !SUPPORTED_MOTION_VAR_OWNERS.has(owner)) return null; + const driver = parts.get("driver") as MotionVariableDriver | undefined; + if (!driver || !SUPPORTED_MOTION_VAR_DRIVERS.has(driver)) return null; + + return { + version: 1, + id, + lanes, + owner, + driver, + }; +} + +export function buildDomEditMotionPatchOperations( + draft: MotionDraft | null, + motionVarsId: string, +): PatchOperation[] { + return [ + buildDomEditMotionPatchOperation(draft), + { + type: "attribute", + property: "hf-motion-vars", + value: draft ? buildMotionVarsAttribute(motionVarsId) : null, + }, + ]; +} + +export function buildMotionCurveModel(draft: MotionDraft): MotionCurveModel { + const normalized = buildDefaultMotionDraft(draft.preset, draft); + const keyframes = resolveMotionKeyframes(normalized); + const start = roundMotionNumber(normalized.start); + const duration = roundMotionNumber(normalized.duration); + const end = roundMotionNumber(start + duration); + const windowEnd = Math.max(end, duration, 0.001); + const startPercent = roundMotionNumber((start / windowEnd) * 100); + + const tracks: MotionCurveTrack[] = [ + buildCurveTrack("x", "X", keyframes.x.from, keyframes.x.to, "px", startPercent), + buildCurveTrack("y", "Y", keyframes.y.from, keyframes.y.to, "px", startPercent), + buildCurveTrack( + "opacity", + "Opacity", + keyframes.opacity.from, + keyframes.opacity.to, + "", + startPercent, + ), + buildCurveTrack("scale", "Scale", keyframes.scale.from, keyframes.scale.to, "", startPercent), + ]; + + return { + start, + duration, + end, + windowEnd, + ease: normalized.ease, + markers: [ + { label: "Start", time: start, percent: startPercent }, + { label: "End", time: end, percent: 100 }, + ], + tracks, + }; +} + +export function buildMotionAgentContext({ + hfMotionAttribute, + draft, + ownership, +}: { + hfMotionAttribute?: string; + draft: MotionDraft | null; + ownership: MotionOwnershipReport; +}): MotionAgentContext { + const model = draft ? buildMotionCurveModel(draft) : null; + return { + version: 1, + state: ownership.state, + summary: ownership.summary, + hfMotionAttribute: hfMotionAttribute?.trim() || undefined, + owners: ownership.badges.map((badge) => ({ + label: badge.label, + state: badge.state, + editable: badge.editable, + })), + curveTracks: + model?.tracks.map((track) => ({ + key: track.key, + label: track.label, + from: track.from, + to: track.to, + unit: track.unit, + active: track.active, + })) ?? [], + instructions: [ + "Patch the existing runtime library only when the target is static, element-specific, and not shared with unrelated layers.", + "Do not blindly rewrite dynamic selectors, unresolved variables, shared timelines, function-valued properties, or arbitrary Three.js scene graph mutations.", + "Use HF Motion when the requested result should become Studio-owned and deterministic.", + "Use an outer layout wrapper when transform ownership is mixed or layout positioning would be corrupted.", + "After editing, reload Studio, select this element, seek the preview, and confirm the animation persists in source.", + ], + }; +} + +export function detectMotionOwnership(input: MotionOwnershipInput): MotionOwnershipReport { + const element = input.element ?? null; + const badges: MotionOwnershipBadge[] = []; + const motionVars = parseMotionVarsAttribute( + input.dataAttributes?.["hf-motion-vars"] ?? element?.getAttribute("data-hf-motion-vars"), + ); + const hasHfMotion = Boolean( + input.dataAttributes?.["hf-motion"] || element?.hasAttribute("data-hf-motion"), + ); + + if (hasHfMotion || motionVars?.owner === "hf") { + badges.push({ + kind: "hf-motion", + label: motionVars ? "HF Motion Vars" : "HF Motion", + state: "Editable", + editable: true, + description: motionVars + ? "Studio-authored motion uses canonical CSS variable lanes." + : "Studio-authored deterministic motion.", + }); + } + + if (motionVars && motionVars.owner !== "hf") { + const variableOwner = getMotionVariableOwnerBadge(motionVars.owner); + badges.push({ + kind: variableOwner.kind, + label: `${variableOwner.label} Vars`, + state: "Detected", + editable: false, + description: + "Canonical CSS variable lanes detected. Direct source patching still requires safe importer confidence.", + }); + } + + if (hasCssMotion(input)) { + badges.push({ + kind: "css", + label: "CSS", + state: "Detected", + editable: false, + description: "CSS animation or transition detected. Read-only for now.", + }); + } + + if (hasWaapiMotion(element)) { + badges.push({ + kind: "waapi", + label: "WAAPI", + state: "Detected", + editable: false, + description: "Web Animations API motion detected. Read-only for now.", + }); + } + + if (hasGsapMotion(element)) { + badges.push({ + kind: "gsap", + label: "GSAP", + state: "Detected", + editable: false, + description: "Registered GSAP timeline targets this element. Read-only for now.", + }); + } + + if (hasAnimeMotion(element)) { + badges.push({ + kind: "animejs", + label: "anime.js", + state: "Detected", + editable: false, + description: "Registered anime.js instance targets this element. Read-only for now.", + }); + } + + if (hasThreeMotion(element)) { + badges.push({ + kind: "three", + label: "Three", + state: "Detected", + editable: false, + description: "Three.js time bridge detected. Read-only for now.", + }); + } + + const ownerCount = badges.length; + if (ownerCount > 1) { + badges.push({ + kind: "mixed", + label: "Mixed", + state: "Mixed", + editable: false, + description: "Multiple motion owners touch this layer; Studio will not rewrite them.", + }); + } + + if (ownerCount === 0) { + return { + state: "none", + badges: [], + editable: false, + summary: "No runtime-owned motion detected.", + }; + } + + const hasEditableOwner = badges.some((badge) => badge.kind === "hf-motion"); + const state = ownerCount > 1 ? "mixed" : hasEditableOwner ? "editable" : "detected"; + return { + state, + badges, + editable: state === "editable", + summary: + state === "editable" + ? "Studio-authored motion is editable." + : state === "mixed" + ? "Multiple motion owners detected; external owners are read-only." + : "External runtime motion is detected and read-only.", + }; +} + +function getMotionVariableOwnerBadge(owner: MotionVariableOwner): { + kind: MotionOwnershipKind; + label: string; +} { + switch (owner) { + case "gsap": + return { kind: "gsap", label: "GSAP" }; + case "anime": + return { kind: "animejs", label: "anime.js" }; + case "css": + return { kind: "css", label: "CSS" }; + case "waapi": + return { kind: "waapi", label: "WAAPI" }; + case "hf": + default: + return { kind: "hf-motion", label: "HF Motion" }; + } +} + +function parseMotionAttribute(value: string | null | undefined): ParsedMotionAttribute | null { + if (!value) return null; + const parts = new Map(); + for (const rawPart of value.split(";")) { + const part = rawPart.trim(); + if (!part) continue; + const separatorIndex = part.indexOf("="); + if (separatorIndex <= 0) return null; + parts.set(part.slice(0, separatorIndex).trim(), part.slice(separatorIndex + 1).trim()); + } + + if (parts.get("v") !== `${MOTION_VERSION}`) return null; + const preset = parts.get("preset") as MotionPreset | undefined; + if (!preset || !SUPPORTED_PRESETS.has(preset)) return null; + const ease = normalizeMotionEase(parts.get("ease") ?? "outCubic"); + if (!ease) return null; + + const start = parseFiniteNumber(parts.get("start") ?? "0"); + const duration = parseFiniteNumber(parts.get("duration")); + const x = parseRange(parts.get("x"), { from: 0, to: 0 }, 0); + const y = parseRange(parts.get("y"), { from: 0, to: 0 }, 0); + const opacity = parseRange(parts.get("opacity"), { from: 1, to: 1 }); + const scale = parseRange(parts.get("scale"), { from: 1, to: 1 }); + if ( + start == null || + start < 0 || + duration == null || + duration <= 0 || + !x || + !y || + !opacity || + !scale + ) { + return null; + } + + return { + preset, + start, + duration, + ease, + keyframes: normalizeMotionKeyframes({ x, y, opacity, scale }, getPresetKeyframes({ preset })), + }; +} + +function getPresetOffsets(draft: MotionDraft): { x: number; y: number } { + if (draft.preset === "pop") return { x: 0, y: 0 }; + switch (draft.direction) { + case "down": + return { x: 0, y: -draft.distance }; + case "left": + return { x: draft.distance, y: 0 }; + case "right": + return { x: -draft.distance, y: 0 }; + case "up": + default: + return { x: 0, y: draft.distance }; + } +} + +function getPresetKeyframes( + draft: Pick & Partial, +): MotionKeyframes { + const presetDraft = { + preset: draft.preset, + direction: + draft.direction && SUPPORTED_DIRECTIONS.has(draft.direction) ? draft.direction : "up", + start: 0, + duration: draft.preset === "pop" ? POP_DURATION : DEFAULT_DURATION, + ease: "outCubic", + distance: normalizeFiniteNumber(draft.distance, DEFAULT_DISTANCE, 0), + } satisfies MotionDraft; + const offsets = getPresetOffsets(presetDraft); + return { + x: { from: offsets.x, to: 0 }, + y: { from: offsets.y, to: 0 }, + opacity: { from: draft.preset === "slide" ? 1 : 0, to: 1 }, + scale: { from: draft.preset === "pop" ? POP_SCALE_FROM : 1, to: 1 }, + }; +} + +function resolveMotionKeyframes(draft: MotionDraft): MotionKeyframes { + return normalizeMotionKeyframes(draft.keyframes, getPresetKeyframes(draft)); +} + +function normalizeMotionKeyframes( + keyframes: Partial | undefined, + fallback: MotionKeyframes, +): MotionKeyframes { + return { + x: normalizeRange(keyframes?.x, fallback.x), + y: normalizeRange(keyframes?.y, fallback.y), + opacity: normalizeRange(keyframes?.opacity, fallback.opacity, 0, 1), + scale: normalizeRange(keyframes?.scale, fallback.scale, 0), + }; +} + +function normalizeRange( + range: Partial | undefined, + fallback: MotionKeyframeRange, + min = Number.NEGATIVE_INFINITY, + max = Number.POSITIVE_INFINITY, +): MotionKeyframeRange { + return { + from: clampFiniteNumber(range?.from, fallback.from, min, max), + to: clampFiniteNumber(range?.to, fallback.to, min, max), + }; +} + +function buildCurveTrack( + key: MotionCurveTrackKey, + label: string, + from: number, + to: number, + unit: "px" | "", + startPercent: number, +): MotionCurveTrack { + const roundedFrom = roundMotionNumber(from); + const roundedTo = roundMotionNumber(to); + return { + key, + label, + from: roundedFrom, + to: roundedTo, + unit, + active: Math.abs(roundedFrom - roundedTo) > 0.0001, + points: [ + { percent: startPercent, value: roundedFrom }, + { percent: 100, value: roundedTo }, + ], + }; +} + +function resolveDirection(x: number, y: number): MotionDirection { + if (Math.abs(x) >= Math.abs(y) && Math.abs(x) > 0) { + return x > 0 ? "left" : "right"; + } + if (Math.abs(y) > 0) { + return y > 0 ? "up" : "down"; + } + return "up"; +} + +export function normalizeMotionEase(value: string): MotionEase | null { + const trimmed = value.trim(); + if (isMotionEasePreset(trimmed)) return trimmed; + const bezier = parseMotionBezierEase(trimmed); + return bezier ? formatMotionBezierEase(bezier) : null; +} + +function isMotionEasePreset(value: string): value is MotionEasePreset { + return SUPPORTED_EASES.has(value); +} + +export function isMotionBezierEase(value: string): value is `bezier(${string})` { + return parseMotionBezierEase(value) != null; +} + +export function parseMotionBezierEase(value: string): MotionBezierEase | null { + const trimmed = value.trim(); + const bezierPrefix = "bezier("; + const cssPrefix = "cubic-bezier("; + const prefix = trimmed.startsWith(bezierPrefix) + ? bezierPrefix + : trimmed.startsWith(cssPrefix) + ? cssPrefix + : null; + if (!prefix || !trimmed.endsWith(")")) return null; + + const body = trimmed.slice(prefix.length, -1); + const parts = body.split(",").map((part) => parseFiniteNumber(part)); + if (parts.length !== 4 || parts.some((part) => part == null)) return null; + const [x1, y1, x2, y2] = parts; + if (x1 == null || y1 == null || x2 == null || y2 == null) return null; + if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1) return null; + if (y1 < -4 || y1 > 4 || y2 < -4 || y2 > 4) return null; + return { x1, y1, x2, y2 }; +} + +export function formatMotionBezierEase(bezier: MotionBezierEase): MotionEase { + return `bezier(${formatNumber(bezier.x1)},${formatNumber(bezier.y1)},${formatNumber( + bezier.x2, + )},${formatNumber(bezier.y2)})`; +} + +export function getMotionEaseLabel(ease: MotionEase): string { + const option = MOTION_EASE_OPTIONS.find((entry) => entry.value === ease); + if (option) return option.label; + const bezier = parseMotionBezierEase(ease); + return bezier ? `Custom ${formatMotionBezierEase(bezier).replace("bezier", "")}` : "Out Cubic"; +} + +export function sampleMotionEase(ease: MotionEase, progress: number): number { + const t = clampFiniteNumber(progress, 0, 0, 1); + const bezier = parseMotionBezierEase(ease); + if (bezier) return cubicBezierProgress(t, bezier); + + const c1 = 1.70158; + const c2 = c1 * 1.525; + const c3 = c1 + 1; + const c4 = (2 * Math.PI) / 3; + const c5 = (2 * Math.PI) / 4.5; + + switch (ease) { + case "ease": + return cubicBezierProgress(t, { x1: 0.25, y1: 0.1, x2: 0.25, y2: 1 }); + case "inSine": + return 1 - Math.cos((t * Math.PI) / 2); + case "outSine": + return Math.sin((t * Math.PI) / 2); + case "inOutSine": + return -(Math.cos(Math.PI * t) - 1) / 2; + case "inQuad": + return t * t; + case "outQuad": + return 1 - (1 - t) * (1 - t); + case "inOutQuad": + return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + case "inCubic": + return t * t * t; + case "outCubic": + return 1 - Math.pow(1 - t, 3); + case "inOutCubic": + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + case "inQuart": + return t * t * t * t; + case "outQuart": + return 1 - Math.pow(1 - t, 4); + case "inOutQuart": + return t < 0.5 ? 8 * Math.pow(t, 4) : 1 - Math.pow(-2 * t + 2, 4) / 2; + case "inQuint": + return Math.pow(t, 5); + case "outQuint": + return 1 - Math.pow(1 - t, 5); + case "inOutQuint": + return t < 0.5 ? 16 * Math.pow(t, 5) : 1 - Math.pow(-2 * t + 2, 5) / 2; + case "inExpo": + return t === 0 ? 0 : Math.pow(2, 10 * t - 10); + case "outExpo": + return t === 1 ? 1 : 1 - Math.pow(2, -10 * t); + case "inOutExpo": + return t === 0 + ? 0 + : t === 1 + ? 1 + : t < 0.5 + ? Math.pow(2, 20 * t - 10) / 2 + : (2 - Math.pow(2, -20 * t + 10)) / 2; + case "inCirc": + return 1 - Math.sqrt(1 - Math.pow(t, 2)); + case "outCirc": + return Math.sqrt(1 - Math.pow(t - 1, 2)); + case "inOutCirc": + return t < 0.5 + ? (1 - Math.sqrt(1 - Math.pow(2 * t, 2))) / 2 + : (Math.sqrt(1 - Math.pow(-2 * t + 2, 2)) + 1) / 2; + case "inBack": + return c3 * t * t * t - c1 * t * t; + case "outBack": + return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2); + case "inOutBack": + return t < 0.5 + ? (Math.pow(2 * t, 2) * ((c2 + 1) * 2 * t - c2)) / 2 + : (Math.pow(2 * t - 2, 2) * ((c2 + 1) * (t * 2 - 2) + c2) + 2) / 2; + case "inBounce": + return 1 - sampleOutBounce(1 - t); + case "outBounce": + return sampleOutBounce(t); + case "inOutBounce": + return t < 0.5 ? (1 - sampleOutBounce(1 - 2 * t)) / 2 : (1 + sampleOutBounce(2 * t - 1)) / 2; + case "inElastic": + return t === 0 + ? 0 + : t === 1 + ? 1 + : -Math.pow(2, 10 * t - 10) * Math.sin((t * 10 - 10.75) * c4); + case "outElastic": + return t === 0 ? 0 : t === 1 ? 1 : Math.pow(2, -10 * t) * Math.sin((t * 10 - 0.75) * c4) + 1; + case "inOutElastic": + return t === 0 + ? 0 + : t === 1 + ? 1 + : t < 0.5 + ? -(Math.pow(2, 20 * t - 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + : (Math.pow(2, -20 * t + 10) * Math.sin((20 * t - 11.125) * c5)) / 2 + 1; + case "linear": + default: + return t; + } +} + +function cubicBezierProgress(progress: number, bezier: MotionBezierEase): number { + if (progress <= 0 || progress >= 1) return progress; + + let t = progress; + for (let index = 0; index < 8; index += 1) { + const x = cubicBezierAxis(t, bezier.x1, bezier.x2) - progress; + const dx = cubicBezierAxisDerivative(t, bezier.x1, bezier.x2); + if (Math.abs(x) < 0.000001) break; + if (Math.abs(dx) < 0.000001) { + t = solveBezierTByBisection(progress, bezier); + break; + } + t = clampFiniteNumber(t - x / dx, t, 0, 1); + } + + return cubicBezierAxis(t, bezier.y1, bezier.y2); +} + +function solveBezierTByBisection(progress: number, bezier: MotionBezierEase): number { + let lower = 0; + let upper = 1; + let t = progress; + for (let index = 0; index < 16; index += 1) { + t = (lower + upper) / 2; + const x = cubicBezierAxis(t, bezier.x1, bezier.x2); + if (Math.abs(x - progress) < 0.000001) break; + if (x < progress) { + lower = t; + } else { + upper = t; + } + } + return t; +} + +function cubicBezierAxis(t: number, p1: number, p2: number): number { + const inverseT = 1 - t; + return 3 * inverseT * inverseT * t * p1 + 3 * inverseT * t * t * p2 + t * t * t; +} + +function cubicBezierAxisDerivative(t: number, p1: number, p2: number): number { + const inverseT = 1 - t; + return 3 * inverseT * inverseT * p1 + 6 * inverseT * t * (p2 - p1) + 3 * t * t * (1 - p2); +} + +function sampleOutBounce(progress: number): number { + const n1 = 7.5625; + const d1 = 2.75; + if (progress < 1 / d1) return n1 * progress * progress; + if (progress < 2 / d1) { + const shifted = progress - 1.5 / d1; + return n1 * shifted * shifted + 0.75; + } + if (progress < 2.5 / d1) { + const shifted = progress - 2.25 / d1; + return n1 * shifted * shifted + 0.9375; + } + const shifted = progress - 2.625 / d1; + return n1 * shifted * shifted + 0.984375; +} + +function parseFiniteNumber(value: string | undefined): number | null { + if (value == null || value.trim() === "") return null; + const parsed = Number.parseFloat(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseRange( + value: string | undefined, + fallback: MotionKeyframeRange, + singleValueTo?: number, +): MotionKeyframeRange | null { + if (value == null || value.trim() === "") return fallback; + const parts = value.split(":"); + if (parts.length > 2) return null; + const from = parseFiniteNumber(parts[0]); + if (from == null) return null; + const to = + parts.length === 2 ? parseFiniteNumber(parts[1]) : singleValueTo != null ? singleValueTo : from; + if (to == null) return null; + return { from, to }; +} + +function normalizeFiniteNumber(value: number | undefined, fallback: number, min: number): number { + return value != null && Number.isFinite(value) ? Math.max(min, value) : fallback; +} + +function clampFiniteNumber(value: number | undefined, fallback: number, min: number, max: number) { + if (value == null || !Number.isFinite(value)) return fallback; + return Math.min(max, Math.max(min, value)); +} + +function formatPositionRange(range: MotionKeyframeRange): string { + return Math.abs(range.to) < 0.0001 + ? formatNumber(range.from) + : `${formatNumber(range.from)}:${formatNumber(range.to)}`; +} + +function formatRange(range: MotionKeyframeRange): string { + return `${formatNumber(range.from)}:${formatNumber(range.to)}`; +} + +function formatNumber(value: number): string { + const normalized = Math.abs(value) < 0.0001 ? 0 : value; + const rounded = Math.round(normalized * 1000) / 1000; + return Number.isInteger(rounded) + ? `${rounded}` + : rounded.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); +} + +function roundMotionNumber(value: number): number { + const normalized = Math.abs(value) < 0.0001 ? 0 : value; + return Math.round(normalized * 1000) / 1000; +} + +function hasCssMotion(input: MotionOwnershipInput): boolean { + const animationName = getMotionStyleValue(input, "animation-name"); + if (animationName && animationName !== "none") return true; + + const animationDuration = getMotionStyleValue(input, "animation-duration"); + if (hasNonZeroCssTime(animationDuration)) return true; + + const transitionProperty = getMotionStyleValue(input, "transition-property"); + const transitionDuration = getMotionStyleValue(input, "transition-duration"); + return Boolean( + transitionProperty && transitionProperty !== "none" && hasNonZeroCssTime(transitionDuration), + ); +} + +function getMotionStyleValue(input: MotionOwnershipInput, property: string): string | undefined { + const supplied = input.computedStyles?.[property]; + if (supplied) return supplied.trim(); + const element = input.element; + if (!element) return undefined; + try { + return element.ownerDocument.defaultView + ?.getComputedStyle(element) + .getPropertyValue(property) + .trim(); + } catch { + return undefined; + } +} + +function hasNonZeroCssTime(value: string | undefined): boolean { + if (!value) return false; + return value.split(",").some((part) => { + const trimmed = part.trim(); + if (!trimmed) return false; + const parsed = Number.parseFloat(trimmed); + if (!Number.isFinite(parsed) || parsed <= 0) return false; + return trimmed.endsWith("ms") ? parsed > 0 : parsed > 0; + }); +} + +function hasWaapiMotion(element: HTMLElement | null): boolean { + const getAnimations = element?.getAnimations; + if (typeof getAnimations !== "function") return false; + try { + return getAnimations.call(element).some((animation: Animation) => { + const constructorName = animation.constructor?.name ?? ""; + return constructorName !== "CSSAnimation" && constructorName !== "CSSTransition"; + }); + } catch { + return false; + } +} + +function hasGsapMotion(element: HTMLElement | null): boolean { + const runtimeWindow = getOwnerWindow(element) as GsapMotionWindow | null; + const timelines = runtimeWindow?.__timelines; + if (!element || !timelines) return false; + + for (const timeline of Object.values(timelines)) { + const children = getGsapChildren(timeline); + if (children.some((child) => targetMatchesElement(resolveGsapTargets(child), element))) { + return true; + } + } + return false; +} + +function hasAnimeMotion(element: HTMLElement | null): boolean { + const runtimeWindow = getOwnerWindow(element) as AnimeMotionWindow | null; + if (!element || !Array.isArray(runtimeWindow?.__hfAnime)) return false; + return runtimeWindow.__hfAnime.some((instance) => + targetMatchesElement(resolveAnimeTargets(instance), element), + ); +} + +function hasThreeMotion(element: HTMLElement | null): boolean { + const runtimeWindow = getOwnerWindow(element) as ThreeMotionWindow | null; + return Boolean( + element?.tagName.toLowerCase() === "canvas" && + runtimeWindow && + "__hfThreeTime" in runtimeWindow, + ); +} + +function getOwnerWindow(element: HTMLElement | null): Window | null { + return element?.ownerDocument.defaultView ?? (typeof window !== "undefined" ? window : null); +} + +function getGsapChildren(timeline: unknown): unknown[] { + if (!timeline || typeof timeline !== "object") return []; + const getChildren = (timeline as { getChildren?: (...args: boolean[]) => unknown }).getChildren; + if (typeof getChildren !== "function") return []; + try { + const children = getChildren.call(timeline, true, true, true); + return Array.isArray(children) ? children : []; + } catch { + return []; + } +} + +function resolveGsapTargets(child: unknown): unknown { + if (!child || typeof child !== "object") return null; + const targets = (child as { targets?: () => unknown }).targets; + if (typeof targets === "function") { + try { + return targets.call(child); + } catch { + return null; + } + } + return ( + (child as { vars?: { targets?: unknown }; _targets?: unknown }).vars?.targets ?? + (child as { _targets?: unknown })._targets ?? + null + ); +} + +function resolveAnimeTargets(instance: unknown): unknown { + if (!instance || typeof instance !== "object") return null; + const typed = instance as { + animatables?: Array<{ target?: unknown }>; + animations?: Array<{ animatable?: { target?: unknown }; target?: unknown }>; + targets?: unknown; + _targets?: unknown; + }; + if (Array.isArray(typed.animatables)) { + return typed.animatables.map((animatable) => animatable.target); + } + if (Array.isArray(typed.animations)) { + return typed.animations.map((animation) => animation.animatable?.target ?? animation.target); + } + return typed.targets ?? typed._targets ?? null; +} + +function targetMatchesElement(targets: unknown, element: HTMLElement): boolean { + if (!targets) return false; + if (targets === element) return true; + if (typeof targets === "string") { + try { + return element.matches(targets); + } catch { + return false; + } + } + if (Array.isArray(targets)) { + return targets.some((target) => targetMatchesElement(target, element)); + } + if ( + typeof NodeList !== "undefined" && + targets instanceof NodeList && + Array.from(targets).includes(element) + ) { + return true; + } + return false; +} + +interface GsapMotionWindow extends Window { + __timelines?: Record; +} + +interface AnimeMotionWindow extends Window { + __hfAnime?: unknown[]; +} + +interface ThreeMotionWindow extends Window { + __hfThreeTime?: number; +} diff --git a/packages/studio/src/components/nle/NLELayout.test.ts b/packages/studio/src/components/nle/NLELayout.test.ts new file mode 100644 index 000000000..b7d142bd8 --- /dev/null +++ b/packages/studio/src/components/nle/NLELayout.test.ts @@ -0,0 +1,55 @@ +import { Window } from "happy-dom"; +import { beforeAll, describe, expect, it } from "vitest"; + +let resolveTimelineCompositionSource: typeof import("./NLELayout").resolveTimelineCompositionSource; + +beforeAll(async () => { + const window = new Window(); + Object.assign(globalThis, { + HTMLElement: window.HTMLElement, + HTMLDivElement: window.HTMLDivElement, + HTMLIFrameElement: window.HTMLIFrameElement, + HTMLImageElement: window.HTMLImageElement, + customElements: window.customElements, + document: window.document, + CSSStyleSheet: window.CSSStyleSheet, + ResizeObserver: class { + observe() {} + disconnect() {} + }, + }); + ({ resolveTimelineCompositionSource } = await import("./NLELayout")); +}); + +describe("resolveTimelineCompositionSource", () => { + const map = new Map([ + ["hook", "compositions/hook.html"], + ["motion", "compositions/motion.html"], + ["closing", "compositions/closing.html"], + ]); + + it("keeps an existing composition source", () => { + expect( + resolveTimelineCompositionSource( + { id: "scene-hook", compositionSrc: "compositions/custom.html" }, + map, + ), + ).toBe("compositions/custom.html"); + }); + + it("maps scene-prefixed timeline clip ids back to composition paths", () => { + expect(resolveTimelineCompositionSource({ id: "scene-motion" }, map)).toBe( + "compositions/motion.html", + ); + }); + + it("maps suffixed host ids back to composition paths", () => { + expect(resolveTimelineCompositionSource({ id: "closing-host" }, map)).toBe( + "compositions/closing.html", + ); + }); + + it("returns undefined for unrelated timeline elements", () => { + expect(resolveTimelineCompositionSource({ id: "avatar-video" }, map)).toBeUndefined(); + }); +}); diff --git a/packages/studio/src/components/nle/NLELayout.tsx b/packages/studio/src/components/nle/NLELayout.tsx index 79757b081..99bd223c8 100644 --- a/packages/studio/src/components/nle/NLELayout.tsx +++ b/packages/studio/src/components/nle/NLELayout.tsx @@ -1,5 +1,4 @@ import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "react"; -import { useMountEffect } from "../../hooks/useMountEffect"; import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player"; import type { TimelineElement } from "../../player"; import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing"; @@ -9,6 +8,11 @@ import { TIMELINE_TOGGLE_SHORTCUT_LABEL, getTimelineToggleTitle, } from "../../utils/timelineDiscovery"; +import { + getCompositionLabel, + getCompositionPreviewUrl, + parseCompositionSourceMap, +} from "../../utils/compositionPaths"; interface NLELayoutProps { projectId: string; @@ -30,7 +34,7 @@ interface NLELayoutProps { /** Custom clip content renderer for timeline (thumbnails, waveforms, etc.) */ renderClipContent?: ( element: TimelineElement, - style: { clip: string; label: string }, + style: { clip: string; label: string; accent?: string }, ) => ReactNode; onFileDrop?: ( files: File[], @@ -51,6 +55,17 @@ interface NLELayoutProps { updates: Pick, ) => Promise | void; onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void; + /** Mirror timeline clip selection into the parent editor selection model. */ + onSelectTimelineElement?: (element: TimelineElement | null) => void; + /** Enable the preview inspector for an explicit timeline clip. */ + onInspectTimelineElement?: (element: TimelineElement) => void; + inspectedTimelineElementId?: string | null; + /** Descendant layer counts for clips that can expose nested DOM layers. */ + timelineLayerChildCounts?: ReadonlyMap; + /** Timeline clips with custom thumbnail content enabled. */ + thumbnailedTimelineElementIds?: Set; + /** Toggle thumbnail content for an explicit timeline clip. */ + onToggleTimelineElementThumbnail?: (element: TimelineElement) => void; /** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */ onCompIdToSrcChange?: (map: Map) => void; /** Whether the timeline panel is visible (default: true) */ @@ -63,6 +78,31 @@ const MIN_TIMELINE_H = 100; const DEFAULT_TIMELINE_H = 220; const MIN_PREVIEW_H = 120; +export function resolveTimelineCompositionSource( + element: Pick, + compIdToSrc: ReadonlyMap, +): string | undefined { + if (element.compositionSrc) return element.compositionSrc; + + const candidates = new Set(); + const addCandidate = (value: string) => { + if (value) candidates.add(value); + }; + addCandidate(element.id); + addCandidate(element.id.replace(/^(scene|composition|comp)-/, "")); + addCandidate(element.id.replace(/-(host|comp|layer|mount)$/, "")); + addCandidate( + element.id.replace(/^(scene|composition|comp)-/, "").replace(/-(host|comp|layer|mount)$/, ""), + ); + + for (const candidate of candidates) { + const src = compIdToSrc.get(candidate); + if (src) return src; + } + + return undefined; +} + export const NLELayout = memo(function NLELayout({ projectId, portrait, @@ -80,6 +120,12 @@ export const NLELayout = memo(function NLELayout({ onMoveElement, onResizeElement, onBlockedEditAttempt, + onSelectTimelineElement, + onInspectTimelineElement, + inspectedTimelineElementId, + timelineLayerChildCounts, + thumbnailedTimelineElementIds, + onToggleTimelineElementThumbnail, onCompIdToSrcChange, timelineVisible, onToggleTimeline, @@ -103,7 +149,9 @@ export const NLELayout = memo(function NLELayout({ usePlayerStore.getState().reset(); } - // Refresh the existing iframe in place when source files change. + // Refresh the current iframe in place after project file writes. Direct + // project/composition navigation still remounts through NLEPreview's stable + // project/directUrl key. const prevRefreshKeyRef = useRef(refreshKey); useEffect(() => { if (refreshKey === prevRefreshKeyRef.current) return; @@ -119,25 +167,23 @@ export const NLELayout = memo(function NLELayout({ // Composition ID → actual file path mapping, built from the raw index.html const [compIdToSrc, setCompIdToSrc] = useState>(new Map()); - useMountEffect(() => { + // eslint-disable-next-line no-restricted-syntax + useEffect(() => { + let cancelled = false; fetch(`/api/projects/${projectId}/files/index.html`) .then((r) => r.json()) .then((data: { content?: string }) => { + if (cancelled) return; const html = data.content || ""; - const map = new Map(); - const re = - /data-composition-id=["']([^"']+)["'][^>]*data-composition-src=["']([^"']+)["']|data-composition-src=["']([^"']+)["'][^>]*data-composition-id=["']([^"']+)["']/g; - let match; - while ((match = re.exec(html)) !== null) { - const id = match[1] || match[4]; - const src = match[2] || match[3]; - if (id && src) map.set(id, src); - } + const map = parseCompositionSourceMap(html); setCompIdToSrc(map); onCompIdToSrcChange?.(map); }) .catch(() => {}); - }); + return () => { + cancelled = true; + }; + }, [projectId, onCompIdToSrcChange]); // Patch elements with compositionSrc whenever elements or compIdToSrc change. // The runtime strips data-composition-src from the DOM after loading, so elements @@ -155,8 +201,7 @@ export const NLELayout = memo(function NLELayout({ let patched = false; const updated = elements.map((el) => { if (el.compositionSrc) return el; - // Try exact match, then strip common suffixes (-host, -comp, -layer) - const src = map.get(el.id) ?? map.get(el.id.replace(/-(host|comp|layer)$/, "")); + const src = resolveTimelineCompositionSource(el, map); if (src) { patched = true; return { ...el, compositionSrc: src }; @@ -184,7 +229,7 @@ export const NLELayout = memo(function NLELayout({ // Composition drill-down stack const [compositionStack, setCompositionStack] = useState([ - { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` }, + { id: "master", label: "Master", previewUrl: getCompositionPreviewUrl(projectId, null) }, ]); // Wrap setCompositionStack to auto-notify parent on composition change @@ -209,6 +254,10 @@ export const NLELayout = memo(function NLELayout({ const currentLevel = compositionStack[compositionStack.length - 1]; const directUrl = compositionStack.length > 1 ? currentLevel.previewUrl : undefined; + useEffect(() => { + onIframeRef?.(iframeRef.current); + }, [compositionStack.length, onIframeRef, refreshKey, iframeRef]); + // Save master seek position before drilling down so we can restore it on back-navigation. // saveSeekPosition() sets pendingSeekRef in useTimelinePlayer which onIframeLoad reads. const masterSeekRef = useRef(0); @@ -260,12 +309,8 @@ export const NLELayout = memo(function NLELayout({ return prev.slice(0, -1); } // Extract a clean label from the path (strip directories and extension) - const label = - resolvedPath - .split("/") - .pop() - ?.replace(/\.html$/, "") || resolvedPath; - const previewUrl = `/api/projects/${projectId}/preview/comp/${resolvedPath}`; + const label = getCompositionLabel(resolvedPath); + const previewUrl = getCompositionPreviewUrl(projectId, resolvedPath); return [...prev, { id: resolvedPath, label, previewUrl }]; }); }, @@ -294,13 +339,13 @@ export const NLELayout = memo(function NLELayout({ usePlayerStore.getState().setElements([]); updateCompositionStack((prev) => (prev.length > 1 ? [prev[0]] : prev)); } else if (activeCompositionPath && activeCompositionPath.startsWith("compositions/")) { - const label = activeCompositionPath.replace(/^compositions\//, "").replace(/\.html$/, ""); - const previewUrl = `/api/projects/${projectId}/preview/comp/${activeCompositionPath}`; + const label = getCompositionLabel(activeCompositionPath); + const previewUrl = getCompositionPreviewUrl(projectId, activeCompositionPath); usePlayerStore.getState().setElements([]); updateCompositionStack((prev) => { if (prev[prev.length - 1]?.id === activeCompositionPath) return prev; return [ - { id: "master", label: "Master", previewUrl: `/api/projects/${projectId}/preview` }, + { id: "master", label: "Master", previewUrl: getCompositionPreviewUrl(projectId, null) }, { id: activeCompositionPath, label, previewUrl }, ]; }); @@ -359,7 +404,6 @@ export const NLELayout = memo(function NLELayout({ onIframeLoad={onIframeLoad} portrait={portrait} directUrl={directUrl} - refreshKey={refreshKey} /> {previewOverlay}
@@ -405,6 +449,12 @@ export const NLELayout = memo(function NLELayout({ { + const window = new Window(); + Object.assign(globalThis, { + HTMLElement: window.HTMLElement, + HTMLDivElement: window.HTMLDivElement, + HTMLIFrameElement: window.HTMLIFrameElement, + HTMLImageElement: window.HTMLImageElement, + customElements: window.customElements, + document: window.document, + CSSStyleSheet: window.CSSStyleSheet, + ResizeObserver: class { + observe() {} + disconnect() {} + }, + }); + ({ getPreviewPlayerKey } = await import("./NLEPreview")); +}); describe("getPreviewPlayerKey", () => { - it("keeps the same player identity when only refreshKey changes", () => { + it("uses the project id for master preview identity", () => { expect( getPreviewPlayerKey({ projectId: "timeline-edit-playground", - refreshKey: 1, }), - ).toBe( - getPreviewPlayerKey({ - projectId: "timeline-edit-playground", - refreshKey: 2, - }), - ); + ).toBe("timeline-edit-playground"); }); it("switches identity when drilling into a different directUrl", () => { diff --git a/packages/studio/src/components/nle/NLEPreview.tsx b/packages/studio/src/components/nle/NLEPreview.tsx index dab649edc..b415c685f 100644 --- a/packages/studio/src/components/nle/NLEPreview.tsx +++ b/packages/studio/src/components/nle/NLEPreview.tsx @@ -7,7 +7,6 @@ interface NLEPreviewProps { onIframeLoad: () => void; portrait?: boolean; directUrl?: string; - refreshKey?: number; } export function getPreviewPlayerKey({ @@ -16,25 +15,29 @@ export function getPreviewPlayerKey({ }: { projectId: string; directUrl?: string; - refreshKey?: number; }): string { return directUrl ?? projectId; } +/** + * Manages the composition preview identity. + * Routine source refreshes happen inside the existing iframe via + * useTimelinePlayer.refreshPlayer(); this only remounts when the project or + * drilled-in composition URL changes. + */ export const NLEPreview = memo(function NLEPreview({ projectId, iframeRef, onIframeLoad, portrait, directUrl, - refreshKey, }: NLEPreviewProps) { - const playerKey = getPreviewPlayerKey({ projectId, directUrl, refreshKey }); + const playerKey = getPreviewPlayerKey({ projectId, directUrl }); return (
diff --git a/packages/studio/src/components/renders/RenderQueue.tsx b/packages/studio/src/components/renders/RenderQueue.tsx index 8a6e69909..097232e7a 100644 --- a/packages/studio/src/components/renders/RenderQueue.tsx +++ b/packages/studio/src/components/renders/RenderQueue.tsx @@ -41,31 +41,36 @@ function FormatInfoTooltip({ format }: { format: "mp4" | "webm" | "mov" }) { return (
- - - - - + + + + + + {open && ( -
-

{info.label}

-

{info.desc}

-
+
+

{info.label}

+

{info.desc}

+
{(["mp4", "mov", "webm"] as const) .filter((f) => f !== format) .map((f) => ( -

- {FORMAT_INFO[f].label} +

+ {FORMAT_INFO[f].label} {" — "} {FORMAT_INFO[f].desc}

@@ -101,39 +106,72 @@ function FormatExportButton({ const showQuality = format !== "mov"; return ( -
- +
+
+
+
+ Export +
+
+ Render the current composition. +
+
+ +
{showQuality && ( - +
+
+ Quality +
+
+ {QUALITY_OPTIONS.map((option) => ( + + ))} +
+
)} - +
+
+ Format +
+
+ {(["mp4", "mov", "webm"] as const).map((option) => ( + + ))} +
+
); @@ -160,26 +198,13 @@ export const RenderQueue = memo(function RenderQueue({ const completedCount = jobs.filter((j) => j.status !== "rendering").length; return ( -
- {/* Header — no title, already shown in header button */} -
-
- {completedCount > 0 && ( - - )} - -
-
+
+ {/* Job list */}
{jobs.length === 0 ? ( -
+
-

No renders yet

+
+

No renders yet

+

+ Exports will appear here with progress, downloads, and history. +

+
) : ( - jobs.map((job) => ( - onDelete(job.id)} - /> - )) +
+
+
+ History +
+ {completedCount > 0 && ( + + )} +
+ {jobs.map((job) => ( + onDelete(job.id)} + /> + ))} +
)}
diff --git a/packages/studio/src/components/sidebar/AssetsTab.tsx b/packages/studio/src/components/sidebar/AssetsTab.tsx index eccbf9d88..4272c543d 100644 --- a/packages/studio/src/components/sidebar/AssetsTab.tsx +++ b/packages/studio/src/components/sidebar/AssetsTab.tsx @@ -2,6 +2,7 @@ import { memo, useState, useCallback, useRef } from "react"; import { VideoFrameThumbnail } from "../ui/VideoFrameThumbnail"; import { MEDIA_EXT, IMAGE_EXT, VIDEO_EXT, AUDIO_EXT } from "../../utils/mediaTypes"; import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop"; +import { copyTextToClipboard } from "../../utils/clipboard"; interface AssetsTabProps { projectId: string; @@ -298,12 +299,10 @@ export const AssetsTab = memo(function AssetsTab({ ); const handleCopyPath = useCallback(async (path: string) => { - try { - await navigator.clipboard.writeText(path); + const copied = await copyTextToClipboard(path); + if (copied) { setCopiedPath(path); setTimeout(() => setCopiedPath(null), 1500); - } catch { - // ignore } }, []); diff --git a/packages/studio/src/components/sidebar/CompositionsTab.test.ts b/packages/studio/src/components/sidebar/CompositionsTab.test.ts index 25549f1ea..ce2ef1fe3 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.test.ts +++ b/packages/studio/src/components/sidebar/CompositionsTab.test.ts @@ -1,37 +1,56 @@ import { describe, expect, it } from "vitest"; -import { resolveCompositionPreviewScale } from "./CompositionsTab"; - -describe("resolveCompositionPreviewScale", () => { - it("scales a 16:9 stage to fit the composition card", () => { - expect( - resolveCompositionPreviewScale({ - cardWidth: 80, - cardHeight: 45, - stageWidth: 1920, - stageHeight: 1080, - }), - ).toBeCloseTo(80 / 1920); - }); - - it("scales non-16:9 stages against their actual dimensions", () => { - expect( - resolveCompositionPreviewScale({ - cardWidth: 80, - cardHeight: 45, - stageWidth: 1280, - stageHeight: 720, - }), - ).toBeCloseTo(80 / 1280); - }); - - it("falls back to the default stage when dimensions are invalid", () => { - expect( - resolveCompositionPreviewScale({ - cardWidth: 80, - cardHeight: 45, - stageWidth: 0, - stageHeight: Number.NaN, - }), - ).toBeCloseTo(80 / 1920); +import { + DEFAULT_COMPOSITION_THUMBNAIL_MODE, + getCompositionDisplayName, + getCompositionThumbnailTimeSeconds, + parseCompositionDimensionsFromHtml, + parseCompositionDurationFromHtml, +} from "./CompositionsTab"; + +describe("getCompositionDisplayName", () => { + it("strips the composition directory and html extension", () => { + expect(getCompositionDisplayName("compositions/intro-card.html")).toBe("intro-card"); + }); + + it("keeps bare composition names readable", () => { + expect(getCompositionDisplayName("index.html")).toBe("index"); + }); +}); + +describe("DEFAULT_COMPOSITION_THUMBNAIL_MODE", () => { + it("loads composition thumbnails by default", () => { + expect(DEFAULT_COMPOSITION_THUMBNAIL_MODE).toBe("visible"); + }); +}); + +describe("composition thumbnail timing", () => { + it("uses the 3s frame for compositions at least 3 seconds long", () => { + expect(getCompositionThumbnailTimeSeconds(4.3)).toBe(3); + expect(getCompositionThumbnailTimeSeconds(3)).toBe(3); + }); + + it("uses the midpoint for compositions shorter than 3 seconds", () => { + expect(getCompositionThumbnailTimeSeconds(2)).toBe(1); + expect(getCompositionThumbnailTimeSeconds(1.5)).toBe(0.75); + }); + + it("falls back to 3s when duration is unavailable", () => { + expect(getCompositionThumbnailTimeSeconds(null)).toBe(3); + }); +}); + +describe("composition source metadata parsing", () => { + it("reads duration and dimensions from composition attributes", () => { + const html = + '
'; + expect(parseCompositionDurationFromHtml(html)).toBe(2.5); + expect(parseCompositionDimensionsFromHtml(html)).toEqual({ width: 960, height: 540 }); + }); + + it("falls back to 16:9 dimensions when omitted", () => { + expect(parseCompositionDimensionsFromHtml("
")).toEqual({ + width: 1920, + height: 1080, + }); }); }); diff --git a/packages/studio/src/components/sidebar/CompositionsTab.tsx b/packages/studio/src/components/sidebar/CompositionsTab.tsx index 12dc558da..bac00ef4e 100644 --- a/packages/studio/src/components/sidebar/CompositionsTab.tsx +++ b/packages/studio/src/components/sidebar/CompositionsTab.tsx @@ -1,136 +1,250 @@ -import { memo, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState } from "react"; +import { buildCompositionThumbnailUrl } from "../../player/components/CompositionThumbnail"; +import { hashThumbnailSource } from "../../thumbnails/sourceHash"; +import { getStudioThumbnailService } from "../../thumbnails/studioThumbnailService"; +import { shouldRequestThumbnail, type ThumbnailMode } from "../../thumbnails/thumbnailMode"; +import { getCompositionPreviewUrl } from "../../utils/compositionPaths"; interface CompositionsTabProps { - projectId: string; compositions: string[]; activeComposition: string | null; onSelect: (comp: string) => void; + projectId?: string; + resolveSourceContent?: (path: string) => Promise; +} + +const COMPOSITION_THUMBNAIL_WIDTH = 160; +const COMPOSITION_THUMBNAIL_HEIGHT = 90; +const DEFAULT_COMPOSITION_THUMBNAIL_TIME_SECONDS = 3; +const DEFAULT_COMPOSITION_WIDTH = 1920; +const DEFAULT_COMPOSITION_HEIGHT = 1080; +export const DEFAULT_COMPOSITION_THUMBNAIL_MODE: ThumbnailMode = "visible"; + +interface CompositionSourceMetadata { + sourceHash: string; + duration: number | null; + width: number; + height: number; +} + +interface MiniPlayerApi { + seek?: (timeSeconds: number) => void; + renderSeek?: (timeSeconds: number) => void; + play?: () => void; + pause?: () => void; + getTime?: () => number; + getDuration?: () => number; +} + +interface HyperframesPlayerElement extends HTMLElement { + iframeElement: HTMLIFrameElement; +} + +export function getCompositionDisplayName(comp: string): string { + const name = comp.replace(/^compositions\//, "").replace(/\.html$/, ""); + return name || "index"; } -const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 }; - -export function resolveCompositionPreviewScale(input: { - cardWidth: number; - cardHeight: number; - stageWidth: number; - stageHeight: number; -}): number { - const safeStageWidth = - Number.isFinite(input.stageWidth) && input.stageWidth > 0 - ? input.stageWidth - : DEFAULT_PREVIEW_STAGE.width; - const safeStageHeight = - Number.isFinite(input.stageHeight) && input.stageHeight > 0 - ? input.stageHeight - : DEFAULT_PREVIEW_STAGE.height; - const scaleX = input.cardWidth / safeStageWidth; - const scaleY = input.cardHeight / safeStageHeight; - return Math.min(scaleX, scaleY); +export function parseCompositionDurationFromHtml(html: string): number | null { + return parseNumericCompositionAttribute(html, "data-duration"); +} + +export function parseCompositionDimensionsFromHtml(html: string): { + width: number; + height: number; +} { + return { + width: parseNumericCompositionAttribute(html, "data-width") ?? DEFAULT_COMPOSITION_WIDTH, + height: parseNumericCompositionAttribute(html, "data-height") ?? DEFAULT_COMPOSITION_HEIGHT, + }; +} + +export function getCompositionThumbnailTimeSeconds(duration: number | null | undefined): number { + if (!Number.isFinite(duration) || duration == null || duration <= 0) { + return DEFAULT_COMPOSITION_THUMBNAIL_TIME_SECONDS; + } + const time = + duration < DEFAULT_COMPOSITION_THUMBNAIL_TIME_SECONDS + ? duration / 2 + : DEFAULT_COMPOSITION_THUMBNAIL_TIME_SECONDS; + return Math.round(time * 100) / 100; } function CompCard({ - projectId, comp, isActive, onSelect, + projectId, + resolveSourceContent, }: { - projectId: string; comp: string; isActive: boolean; onSelect: () => void; + projectId?: string; + resolveSourceContent?: (path: string) => Promise; }) { + const name = getCompositionDisplayName(comp); + const cardRef = useRef(null); const [hovered, setHovered] = useState(false); - const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE); - const hoverTimer = useRef | null>(null); - const handleEnter = () => { - hoverTimer.current = setTimeout(() => setHovered(true), 300); - }; - const handleLeave = () => { - if (hoverTimer.current) { - clearTimeout(hoverTimer.current); - hoverTimer.current = null; - } - setHovered(false); - }; - const name = comp.replace(/^compositions\//, "").replace(/\.html$/, ""); - const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`; - const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`; - const previewScale = resolveCompositionPreviewScale({ - cardWidth: 80, - cardHeight: 45, - stageWidth: stageSize.width, - stageHeight: stageSize.height, + const [isVisible, setIsVisible] = useState(false); + const [thumbnailUrl, setThumbnailUrl] = useState(null); + const [thumbnailFailed, setThumbnailFailed] = useState(false); + const [sourceMetadata, setSourceMetadata] = useState(null); + const shouldLoadThumbnail = shouldRequestThumbnail(DEFAULT_COMPOSITION_THUMBNAIL_MODE, { + hovered, + visible: isVisible, }); + useEffect(() => { + const node = cardRef.current; + if (!node || typeof IntersectionObserver === "undefined") { + setIsVisible(true); + return; + } + + const observer = new IntersectionObserver( + ([entry]) => { + setIsVisible(entry?.isIntersecting ?? false); + }, + { rootMargin: "96px 0px" }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + useEffect(() => { + if (!shouldLoadThumbnail || !projectId) { + return; + } + + const controller = new AbortController(); + let cancelled = false; + setThumbnailFailed(false); + + void (async () => { + const sourceContent = await readCompositionSourceContent({ + projectId, + path: comp, + signal: controller.signal, + resolveSourceContent, + }); + if (controller.signal.aborted || cancelled) return; + + const dimensions = parseCompositionDimensionsFromHtml(sourceContent); + const metadata = { + sourceHash: hashThumbnailSource(sourceContent), + duration: parseCompositionDurationFromHtml(sourceContent), + width: dimensions.width, + height: dimensions.height, + }; + setSourceMetadata(metadata); + if (controller.signal.aborted || cancelled) return; + + const thumbnailTime = getCompositionThumbnailTimeSeconds(metadata.duration); + const previewUrl = getCompositionPreviewUrl(projectId, comp); + const thumbnailRequestUrl = buildCompositionThumbnailUrl({ + previewUrl, + seekTime: thumbnailTime, + duration: 0, + origin: window.location.origin, + }); + const objectUrl = await getStudioThumbnailService().getThumbnailUrl({ + key: { + projectId, + sourcePath: comp, + sourceHash: metadata.sourceHash, + kind: "composition", + timeSeconds: thumbnailTime, + width: COMPOSITION_THUMBNAIL_WIDTH, + height: COMPOSITION_THUMBNAIL_HEIGHT, + devicePixelRatio: window.devicePixelRatio || 1, + version: 3, + }, + url: thumbnailRequestUrl, + priority: hovered ? "hover" : "idle", + signal: controller.signal, + }); + if (!cancelled && objectUrl) setThumbnailUrl(objectUrl); + })().catch((error: unknown) => { + if (!cancelled && !isAbortError(error)) setThumbnailFailed(true); + }); + + return () => { + cancelled = true; + controller.abort(); + }; + }, [comp, hovered, projectId, resolveSourceContent, shouldLoadThumbnail]); + return ( -
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setHovered(true)} + onBlur={() => setHovered(false)} className={`w-full text-left px-2 py-1.5 flex items-center gap-2.5 transition-colors cursor-pointer ${ isActive ? "bg-studio-accent/10 border-l-2 border-studio-accent" : "border-l-2 border-transparent hover:bg-neutral-800/50" }`} > -
- {/* Live iframe preview on hover */} - {hovered && ( -