diff --git a/.gitignore b/.gitignore index 24a7d2c79..62fba393e 100644 --- a/.gitignore +++ b/.gitignore @@ -62,7 +62,8 @@ tmp/ packages/core/src/generated/ packages/producer/src/services/fontData.generated.ts -# Test artifacts +# Local proof / test artifacts +qa-artifacts/ my-video/ examples/ packages/studio/data/ @@ -99,3 +100,5 @@ ab-test/ compositions/ video-6-2-patched/ claude-design-hyperframes-video/ +.claude/worktrees/ +.claude/ 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 57affc5a0..ff52859db 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -222,7 +222,8 @@ "contributing", "contributing/catalog", "contributing/release-channels", - "contributing/testing-local-changes" + "contributing/testing-local-changes", + "contributing/studio-manual-dom-editing" ] }, { diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 4d005ebdd..f2f6cc8bf 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -35,15 +35,14 @@ npx hyperframes ## Agent-Friendly by Default -The CLI is **agent-friendly by default**: commands support explicit flags and parseable output so automation can run reliably. +The CLI is **non-interactive by default** — designed so AI agents (Claude Code, Gemini CLI, Codex, Cursor) can drive every command without prompts or interactive UI. -- Inputs can be passed via flags (for example, `--example`, `--video`, `--output`) +- All inputs are passed via flags (e.g., `--example`, `--video`, `--output`) - Missing required flags fail fast with a clear error and usage example - Output is plain text suitable for parsing +- No interactive prompts, spinners, or selection menus -Interactivity is command-specific. For example, `init` uses prompts on TTY by default; pass `--non-interactive` to force non-interactive mode. - -`--human-friendly` is also command-specific (for example, `catalog`). It is not a global flag on every command. +Add `--human-friendly` to any command to enable the interactive terminal UI with prompts, spinners, and selection menus. @@ -56,11 +55,9 @@ Interactivity is command-specific. For example, `init` uses prompts on TTY by de ```bash - # Command-specific interactive flow - npx hyperframes init my-video - - # Interactive picker supported by catalog - npx hyperframes catalog --human-friendly + # Interactive prompts, spinners, and selection menus + npx hyperframes init --human-friendly + npx hyperframes upgrade ``` @@ -158,14 +155,14 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ # Include Tailwind CSS browser-runtime support npx hyperframes init my-video --example blank --tailwind - # Human mode — interactive prompts on TTY by default - npx hyperframes init my-video + # Human mode — interactive prompts + npx hyperframes init --human-friendly ``` | Flag | Description | |------|-------------| - | `--example, -e` | Example to scaffold (required in non-interactive mode, prompted on TTY by default) | - | `--resolution` | Canvas preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k` (3840×2160), `portrait-4k` (2160×3840). Aliases: `1080p`, `4k`, `uhd`. Default: keep template dimensions. | + | `--example, -e` | Example to scaffold (required in default mode, interactive in `--human-friendly`) | + | `--resolution` | Canvas preset: `landscape` (1920×1080), `portrait` (1080×1920), `landscape-4k` (3840×2160), `portrait-4k` (2160×3840), `square` (1080×1080), `square-4k` (2160×2160). Aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `square-1080p`, `4k-square`. Default: keep template dimensions. | | `--video, -V` | Path to a video file (MP4, WebM, MOV) | | `--audio, -a` | Path to an audio file (MP3, WAV, M4A) | | `--tailwind` | Add Tailwind CSS browser-runtime support to scaffolded HTML | @@ -173,6 +170,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | `--skip-transcribe` | Skip automatic whisper transcription | | `--model` | Whisper model for transcription (e.g. `small.en`, `medium.en`, `large-v3`) | | `--language` | Language code for transcription (e.g. `en`, `es`, `ja`). Filters non-target speech. | + | `--human-friendly` | Enable interactive terminal UI with prompts | | Example | Description | |----------|-------------| @@ -182,7 +180,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | `swiss-grid` | Structured grid layout | | `vignelli` | Bold typography with red accents | - In non-interactive mode, `--example` is required — the CLI errors with a usage example if missing. In interactive mode (default on TTY), you choose the example interactively. Pass `--non-interactive` to require `--example` via flag. When `--video` or `--audio` is provided, the CLI automatically transcribes the audio with Whisper and patches captions into the composition (use `--skip-transcribe` to disable). + In default (agent) mode, `--example` is required — the CLI errors with a usage example if missing. In `--human-friendly` mode, you choose interactively. When `--video` or `--audio` is provided, the CLI automatically transcribes the audio with Whisper and patches captions into the composition (use `--skip-transcribe` to disable). `--tailwind` injects the pinned Tailwind v4 browser runtime into scaffolded HTML and exposes a `window.__tailwindReady` promise that renders wait on before capturing frame 0. Use the `/tailwind` skill when editing these projects so agents follow v4 CSS-first patterns instead of v3 `tailwind.config.js` and `@tailwind` directive patterns. The browser runtime is still intended for scaffolded projects and quick iteration; for fully offline or locked-down production renders, compile Tailwind to CSS and include the stylesheet directly. @@ -612,7 +610,7 @@ This is suppressed in CI environments, non-TTY shells, and when `HYPERFRAMES_NO_ | `--quality` | draft, standard, high | standard | Encoding quality preset (drives CRF/bitrate) | | `--crf` | 0-51 | — | Override encoder CRF (lower = higher quality). Mutually exclusive with `--video-bitrate` | | `--video-bitrate` | e.g. `10M`, `5000k` | — | Target video bitrate. Mutually exclusive with `--crf` | - | `--resolution` | landscape, portrait, landscape-4k, portrait-4k (aliases: `1080p`, `4k`, `uhd`) | — | Output resolution preset. Supersamples a smaller composition via Chrome `deviceScaleFactor` so the screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not supported with `--hdr`. See [4K Rendering](/guides/4k-rendering) | + | `--resolution` | landscape, portrait, landscape-4k, portrait-4k, square, square-4k (aliases: `1080p`, `4k`, `uhd`, `1080p-square`, `square-1080p`, `4k-square`) | — | Output resolution preset. Supersamples a smaller composition via Chrome `deviceScaleFactor` so the screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not supported with `--hdr`. See [4K Rendering](/guides/4k-rendering) | | `--hdr` | — | off | Force HDR output even if no HDR sources are detected. MP4 only. See [HDR Rendering](/guides/hdr) | | `--sdr` | — | off | Force SDR output even if HDR sources are detected | | `--workers` | 1-8 | 4 | Parallel render workers | diff --git a/docs/packages/core.mdx b/docs/packages/core.mdx index 56c32b4cd..22e41c798 100644 --- a/docs/packages/core.mdx +++ b/docs/packages/core.mdx @@ -51,7 +51,7 @@ import type { TimelineElementType, // "video" | "image" | "text" | "audio" | "composition" CompositionSpec, CompositionVariable, - CanvasResolution, // "landscape" | "portrait" + CanvasResolution, // "landscape" | "portrait" | "landscape-4k" | "portrait-4k" | "square" | "square-4k" Orientation, // "16:9" | "9:16" FrameAdapter, FrameAdapterContext, diff --git a/packages/cli/package.json b/packages/cli/package.json index 39f7ae7f5..3f602e5e1 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/cli", - "version": "0.5.7", + "version": "0.6.0-alpha.13", "description": "HyperFrames CLI — create, preview, and render HTML video compositions", "repository": { "type": "git", diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 81b83b69f..29f4a09fc 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -5,14 +5,7 @@ // `hyperframes --version` near-instant (~10ms vs ~80ms). import { VERSION } from "./version.js"; -const argv = process.argv.slice(2); -const commandArg = argv[0]; -const rootVersionRequested = - commandArg === "--version" || - commandArg === "-V" || - (commandArg === undefined && (argv.includes("--version") || argv.includes("-V"))); - -if (rootVersionRequested) { +if (process.argv.includes("--version") || process.argv.includes("-V")) { console.log(VERSION); process.exit(0); } @@ -71,8 +64,8 @@ const main = defineCommand({ // Telemetry — lazy-loaded, captured references for exit handlers // --------------------------------------------------------------------------- -const cliCommandArg = process.argv[2]; -const command = cliCommandArg && cliCommandArg in subCommands ? cliCommandArg : "unknown"; +const commandArg = process.argv[2]; +const command = commandArg && commandArg in subCommands ? commandArg : "unknown"; const hasJsonFlag = process.argv.includes("--json"); // Captured references — populated when the lazy imports resolve. diff --git a/packages/cli/src/commands/benchmark.ts b/packages/cli/src/commands/benchmark.ts index d98648033..c18f714c8 100644 --- a/packages/cli/src/commands/benchmark.ts +++ b/packages/cli/src/commands/benchmark.ts @@ -14,11 +14,10 @@ import { c } from "../ui/colors.js"; import { formatBytes, formatDuration, errorBox } from "../ui/format.js"; import * as clack from "@clack/prompts"; import { withMeta } from "../utils/updateCheck.js"; -import { fpsToFfmpegArg } from "@hyperframes/core"; interface BenchmarkConfig { label: string; - fps: import("@hyperframes/core").Fps; + fps: 24 | 30 | 60; quality: "draft" | "standard" | "high"; workers: number; } @@ -36,14 +35,12 @@ interface ConfigResult { avgSize: number | null; } -const FPS_30: import("@hyperframes/core").Fps = { num: 30, den: 1 }; -const FPS_60: import("@hyperframes/core").Fps = { num: 60, den: 1 }; const DEFAULT_CONFIGS: BenchmarkConfig[] = [ - { label: "30fps \u00B7 draft \u00B7 2w", fps: FPS_30, quality: "draft", workers: 2 }, - { label: "30fps \u00B7 standard \u00B7 2w", fps: FPS_30, quality: "standard", workers: 2 }, - { label: "30fps \u00B7 high \u00B7 2w", fps: FPS_30, quality: "high", workers: 2 }, - { label: "30fps \u00B7 standard \u00B7 4w", fps: FPS_30, quality: "standard", workers: 4 }, - { label: "60fps \u00B7 standard \u00B7 4w", fps: FPS_60, quality: "standard", workers: 4 }, + { label: "30fps \u00B7 draft \u00B7 2w", fps: 30, quality: "draft", workers: 2 }, + { label: "30fps \u00B7 standard \u00B7 2w", fps: 30, quality: "standard", workers: 2 }, + { label: "30fps \u00B7 high \u00B7 2w", fps: 30, quality: "high", workers: 2 }, + { label: "30fps \u00B7 standard \u00B7 4w", fps: 30, quality: "standard", workers: 4 }, + { label: "60fps \u00B7 standard \u00B7 4w", fps: 60, quality: "standard", workers: 4 }, ]; export default defineCommand({ @@ -165,10 +162,7 @@ export default defineCommand({ withMeta({ results: results.map((r) => ({ config: r.config.label, - // Emit fps in the on-the-wire form so the JSON payload is - // round-trippable through `parseFps` (e.g. "30000/1001" stays - // exact; integer fps stays integer). - fps: fpsToFfmpegArg(r.config.fps), + fps: r.config.fps, quality: r.config.quality, workers: r.config.workers, avgTimeMs: r.avgTime, diff --git a/packages/cli/src/commands/init.test.ts b/packages/cli/src/commands/init.test.ts index 392b0a936..16ebc8391 100644 --- a/packages/cli/src/commands/init.test.ts +++ b/packages/cli/src/commands/init.test.ts @@ -132,46 +132,6 @@ describe("hyperframes init flag rename", () => { expect(injected).not.toContain("setTimeout"); }); - it("-v works as the short alias for --video", () => { - const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); - const target = join(dir, "proj"); - try { - const res = runInit([ - target, - "--example", - "blank", - "--non-interactive", - "--skip-skills", - "-v", - "missing.mp4", - ]); - expect(res.status).toBe(1); - expect(res.stderr).toContain("Video file not found: missing.mp4"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - - it("-V prints a migration error instead of version fast-path", () => { - const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); - const target = join(dir, "proj"); - try { - const res = runInit([ - target, - "--example", - "blank", - "--non-interactive", - "--skip-skills", - "-V", - "missing.mp4", - ]); - expect(res.status).toBe(1); - expect(res.stderr).toContain("The -V short flag no longer maps to --video"); - } finally { - rmSync(dir, { recursive: true, force: true }); - } - }); - it("--template prints a rename hint and exits non-zero", () => { const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); const target = join(dir, "proj"); @@ -247,6 +207,38 @@ describe("applyResolutionPreset", () => { }); }); + it("swaps to square dimensions for square", () => { + withFixture((dir) => { + const file = join(dir, "index.html"); + writeFileSync(file, sampleHtml, "utf-8"); + + applyResolutionPreset(dir, "square"); + const out = readFileSync(file, "utf-8"); + + expect(out).toContain('data-width="1080"'); + expect(out).toContain('data-height="1080"'); + expect(out).toContain('data-resolution="square"'); + expect(out).toContain("width: 1080px"); + expect(out).toContain("height: 1080px"); + }); + }); + + it("swaps to square-4k dimensions", () => { + withFixture((dir) => { + const file = join(dir, "index.html"); + writeFileSync(file, sampleHtml, "utf-8"); + + applyResolutionPreset(dir, "square-4k"); + const out = readFileSync(file, "utf-8"); + + expect(out).toContain('data-width="2160"'); + expect(out).toContain('data-height="2160"'); + expect(out).toContain('data-resolution="square-4k"'); + expect(out).toContain("width: 2160px"); + expect(out).toContain("height: 2160px"); + }); + }); + it("scaffolds a 4k project end-to-end via --resolution 4k", () => { const dir = mkdtempSync(join(tmpdir(), "hf-init-test-")); const target = join(dir, "proj"); diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index c61fdbc7e..8e48104da 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -510,9 +510,11 @@ async function scaffoldProject( ): Promise { mkdirSync(destDir, { recursive: true }); - // Use bundled template if available, otherwise fetch from GitHub + // Use bundled template if available, otherwise fetch from GitHub. + // Check for index.html inside the dir — an empty directory left by the + // build toolchain should not prevent the remote fetch fallback. const templateDir = getStaticTemplateDir(templateId); - if (existsSync(templateDir)) { + if (existsSync(join(templateDir, "index.html"))) { cpSync(templateDir, destDir, { recursive: true }); } else { await fetchRemoteTemplate(templateId, destDir); @@ -588,13 +590,7 @@ export default defineCommand({ video: { type: "string", description: "Path to a video file (MP4, WebM, MOV)", - alias: "v", - }, - "video-legacy": { - type: "string", - description: "[renamed] Use --video (or -v) instead of -V.", alias: "V", - hidden: true, }, audio: { type: "string", @@ -630,7 +626,7 @@ export default defineCommand({ resolution: { type: "string", description: - "Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd. Default: keep template dimensions (typically 1920x1080).", + "Canvas resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. Default: keep template dimensions (typically 1920x1080).", }, }, async run({ args }) { @@ -644,14 +640,6 @@ export default defineCommand({ ); process.exit(1); } - if (args["video-legacy"] !== undefined) { - console.error( - c.error( - `The -V short flag no longer maps to --video. Use --video (or -v). Example:\n npx hyperframes init ${args.name ?? "my-video"} --video "${args["video-legacy"]}"`, - ), - ); - process.exit(1); - } const exampleFlag = args.example; const videoFlag = args.video; const audioFlag = args.audio; @@ -670,7 +658,8 @@ export default defineCommand({ console.error( c.error( `Invalid --resolution: "${args.resolution}". ` + - `Use one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (or aliases 1080p, 4k, uhd).`, + `Use one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k ` + + `(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`, ), ); process.exit(1); diff --git a/packages/cli/src/commands/render.test.ts b/packages/cli/src/commands/render.test.ts index 57cc05425..28a5fc7ac 100644 --- a/packages/cli/src/commands/render.test.ts +++ b/packages/cli/src/commands/render.test.ts @@ -68,7 +68,7 @@ describe("renderLocal browser GPU config", () => { setEnv("PRODUCER_BROWSER_GPU_MODE", "hardware"); await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -86,7 +86,7 @@ describe("renderLocal browser GPU config", () => { it("forwards browserGpuMode='auto' into producer config (probe-then-choose)", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -104,7 +104,7 @@ describe("renderLocal browser GPU config", () => { it("passes an explicit hardware override for default local browser GPU", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -137,7 +137,7 @@ describe("renderLocal browser GPU config", () => { it("forwards parsed --variables payload to createRenderJob", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -152,7 +152,7 @@ describe("renderLocal browser GPU config", () => { it("forwards format: png-sequence through to createRenderJob", async () => { await renderLocal("/tmp/project", "/tmp/frames", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "png-sequence", gpu: false, @@ -166,7 +166,7 @@ describe("renderLocal browser GPU config", () => { it("omits variables from createRenderJob when not provided", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -180,7 +180,7 @@ describe("renderLocal browser GPU config", () => { it("forwards entryFile to createRenderJob when --composition is set", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -195,7 +195,7 @@ describe("renderLocal browser GPU config", () => { it("omits entryFile from createRenderJob when --composition is not set", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -209,7 +209,7 @@ describe("renderLocal browser GPU config", () => { it("forwards outputResolution to createRenderJob when --resolution is set", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -224,7 +224,7 @@ describe("renderLocal browser GPU config", () => { it("omits outputResolution from createRenderJob by default", async () => { await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -245,7 +245,7 @@ describe("renderLocal browser GPU config", () => { }); await renderLocal("/tmp/project", "/tmp/out.mp4", { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, diff --git a/packages/cli/src/commands/render.ts b/packages/cli/src/commands/render.ts index 5e05ff1ad..4df0b0d81 100644 --- a/packages/cli/src/commands/render.ts +++ b/packages/cli/src/commands/render.ts @@ -51,43 +51,12 @@ import { validateVariables, formatVariableValidationIssue, normalizeResolutionFlag, - parseFps, - fpsToNumber, - fpsToFfmpegArg, type VariableValidationIssue, type CanvasResolution, - type Fps, - type FpsParseResult, } from "@hyperframes/core"; +const VALID_FPS = new Set([24, 30, 60]); const VALID_QUALITY = new Set(["draft", "standard", "high"]); - -/** - * Map a {@link FpsParseResult} failure reason to a human-friendly - * error-box message. The empty / undefined / default-fallthrough case - * shouldn't be reachable from the CLI flag (citty supplies a default of - * "30") but the branch exists so this helper can be reused by other - * fps-accepting CLI surfaces in the future. - */ -function formatFpsParseError( - input: string, - reason: Exclude["reason"], -): string { - switch (reason) { - case "empty": - return "Frame rate must not be empty."; - case "not-a-number": - return `Got "${input}". Frame rate must be an integer (e.g. 30) or a rational (e.g. 30000/1001 for NTSC).`; - case "non-positive": - return `Got "${input}". Frame rate must be greater than zero.`; - case "out-of-range": - return `Got "${input}". Frame rate must be in the range 1–240.`; - case "invalid-fraction": - return `Got "${input}". Rational frame rates must be two positive integers separated by '/' (e.g. 30000/1001).`; - case "ambiguous-decimal": - return `Got "${input}". Decimal frame rates are ambiguous — use the exact rational form instead (e.g. 30000/1001 for 29.97).`; - } -} const VALID_FORMAT = new Set(["mp4", "webm", "mov", "png-sequence"]); // `png-sequence` writes a directory of frames rather than a single muxed file, // so its "extension" is empty — the auto-output path becomes a directory name. @@ -126,10 +95,7 @@ export default defineCommand({ fps: { type: "string", alias: "f", - description: - "Frame rate. Accepts integer (24, 25, 30, 50, 60, 120, 240) or " + - "ffmpeg-style rational (30000/1001 for NTSC 29.97, 24000/1001 for " + - "23.976, 60000/1001 for 59.94). Range 1-240.", + description: "Frame rate: 24, 30, 60", default: "30", }, quality: { @@ -220,7 +186,7 @@ export default defineCommand({ resolution: { type: "string", description: - "Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.", + "Output resolution preset: landscape (1920x1080), portrait (1080x1920), landscape-4k (3840x2160), portrait-4k (2160x3840), square (1080x1080), square-4k (2160x2160). Aliases: 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square. The composition is unchanged — Chrome renders at higher DPR (deviceScaleFactor) so the captured screenshot lands at the requested dimensions. Aspect ratio must match the composition; the scale must be an integer multiple. Not yet supported with --hdr.", }, }, async run({ args }) { @@ -228,17 +194,12 @@ export default defineCommand({ const project = resolveProject(args.dir); // ── Validate fps ─────────────────────────────────────────────────────── - // Accept either integer (`30`) or ffmpeg-style rational (`30000/1001`). - // The whitelist-based validator was replaced with a sane numeric range so - // legitimate framerates (NTSC trio, PAL, 120/240 slow-mo) work without - // CLI gymnastics. The exact rational survives end-to-end into FFmpeg's - // `-r` / `-framerate` flags via `fpsToFfmpegArg`. - const fpsParse = parseFps(args.fps ?? "30"); - if (!fpsParse.ok) { - errorBox("Invalid fps", formatFpsParseError(args.fps ?? "30", fpsParse.reason)); + const fpsRaw = parseInt(args.fps ?? "30", 10); + if (!VALID_FPS.has(fpsRaw)) { + errorBox("Invalid fps", `Got "${args.fps ?? "30"}". Must be 24, 30, or 60.`); process.exit(1); } - const fps: Fps = fpsParse.value; + const fps = fpsRaw as 24 | 30 | 60; // ── Validate quality ─────────────────────────────────────────────────── const qualityRaw = args.quality ?? "standard"; @@ -263,7 +224,8 @@ export default defineCommand({ if (!outputResolution) { errorBox( "Invalid resolution", - `Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k (or aliases 1080p, 4k, uhd).`, + `Got "${args.resolution}". Must be one of: landscape, portrait, landscape-4k, portrait-4k, square, square-4k ` + + `(or aliases 1080p, 4k, uhd, 1080p-square, square-1080p, 4k-square).`, ); process.exit(1); } @@ -393,9 +355,7 @@ export default defineCommand({ console.log( c.accent("\u25C6") + " Rendering " + c.accent(nameLabel) + c.dim(" \u2192 " + outputPath), ); - console.log( - c.dim(" " + fpsToFfmpegArg(fps) + "fps \u00B7 " + quality + " \u00B7 " + workerLabel), - ); + console.log(c.dim(" " + fps + "fps \u00B7 " + quality + " \u00B7 " + workerLabel)); if (outputResolution) { // Don't claim "supersampled" — when the composition is already at the // target dimensions, the DPR resolves to 1 and no supersampling @@ -562,7 +522,7 @@ export default defineCommand({ }); interface RenderOptions { - fps: Fps; + fps: 24 | 30 | 60; quality: "draft" | "standard" | "high"; format: "mp4" | "webm" | "mov" | "png-sequence"; workers?: number; @@ -906,7 +866,7 @@ async function renderDocker( // Track metrics (no job object available from Docker — use a minimal stub) trackRenderComplete({ durationMs: elapsed, - fps: fpsToNumber(options.fps), + fps: options.fps, quality: options.quality, workers: options.workers, docker: true, @@ -1005,7 +965,7 @@ function handleRenderError( ): never { const message = error instanceof Error ? error.message : String(error); trackRenderError({ - fps: fpsToNumber(options.fps), + fps: options.fps, quality: options.quality, docker, workers: options.workers, @@ -1042,7 +1002,7 @@ function trackRenderMetrics( trackRenderComplete({ durationMs: elapsedMs, - fps: fpsToNumber(options.fps), + fps: options.fps, quality: options.quality, workers: options.workers ?? perf?.workers, docker, diff --git a/packages/cli/src/server/studioServer.ts b/packages/cli/src/server/studioServer.ts index b13b701ec..a9ea27fca 100644 --- a/packages/cli/src/server/studioServer.ts +++ b/packages/cli/src/server/studioServer.ts @@ -13,6 +13,7 @@ import { createProjectWatcher, type ProjectWatcher } from "./fileWatcher.js"; import { loadRuntimeSource } from "./runtimeSource.js"; import { VERSION as version } from "../version.js"; import { + createStudioManualEditsRenderBodyScript, createStudioApi, createProjectSignature, getMimeType, @@ -23,6 +24,8 @@ import { import { getElementScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip"; import type { ScreenshotClip } from "@hyperframes/core/studio-api/screenshot-clip"; +const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; + // ── Path resolution ───────────────────────────────────────────────────────── function resolveDistDir(): string { @@ -78,6 +81,38 @@ function resolveRuntimePath(): string { return builtPath; } +function readStudioManualEditManifestContent(projectDir: string): string { + const manifestPath = join(projectDir, STUDIO_MANUAL_EDITS_PATH); + if (!existsSync(manifestPath)) return ""; + try { + return readFileSync(manifestPath, "utf-8"); + } catch { + return ""; + } +} + +async function applyStudioManualEditsToThumbnailPage( + page: import("puppeteer-core").Page, + manifestContent: string, + activeCompositionPath: string, +): Promise { + const script = createStudioManualEditsRenderBodyScript(manifestContent, { + activeCompositionPath, + }); + if (!script) return; + await page.addScriptTag({ content: script }); +} + +async function reapplyStudioManualEditsToThumbnailPage( + page: import("puppeteer-core").Page, +): Promise { + await page.evaluate(() => { + const apply = (window as Window & { __hfStudioManualEditsApply?: () => number }) + .__hfStudioManualEditsApply; + if (typeof apply === "function") apply(); + }); +} + // ── Shared thumbnail browser (singleton per process) ──────────────────────── // One browser instance is reused across all composition thumbnail requests. // Spawning a new Puppeteer process per request adds 2-5s overhead and causes @@ -212,13 +247,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { // Continue without — acquireBrowser will try its own resolution } + const manifestContent = readStudioManualEditManifestContent(opts.project.dir); + const manualEditsRenderScript = createStudioManualEditsRenderBodyScript(manifestContent); const job = createRenderJob({ - // opts.fps is already an Fps rational — see vite-config-studio - // adapter for the same convention. - fps: opts.fps, + fps: opts.fps as 24 | 30 | 60, quality: opts.quality as "draft" | "standard" | "high", format: opts.format, outputResolution: opts.outputResolution, + ...(manualEditsRenderScript ? { renderBodyScripts: [manualEditsRenderScript] } : {}), }); const startTime = Date.now(); const onProgress = (j: { progress: number; currentStage?: string }) => { @@ -275,11 +311,14 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { win.__timeline.seek(t); } }, opts.seekTime); + const manifestContent = readStudioManualEditManifestContent(opts.project.dir); + await applyStudioManualEditsToThumbnailPage(page, manifestContent, opts.compPath); // Let the seek render settle. await new Promise((r) => setTimeout(r, 200)); + await reapplyStudioManualEditsToThumbnailPage(page); let clip: ScreenshotClip | undefined; if (opts.selector) { - clip = await page.evaluate(getElementScreenshotClip, opts.selector); + clip = await page.evaluate(getElementScreenshotClip, opts.selector, opts.selectorIndex); } const screenshot = (await page.screenshot( opts.format === "png" @@ -335,8 +374,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer { app.get("/api/events", (c) => { return streamSSE(c, async (stream) => { - const listener = () => { - stream.writeSSE({ event: "file-change", data: "{}" }).catch(() => {}); + const listener = (path: string) => { + stream.writeSSE({ event: "file-change", data: JSON.stringify({ path }) }).catch(() => {}); }; watcher.addListener(listener); while (true) { diff --git a/packages/cli/src/utils/dockerRunArgs.test.ts b/packages/cli/src/utils/dockerRunArgs.test.ts index 8b8e09e9d..981bc24c4 100644 --- a/packages/cli/src/utils/dockerRunArgs.test.ts +++ b/packages/cli/src/utils/dockerRunArgs.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildDockerRunArgs, type DockerRenderOptions } from "./dockerRunArgs.js"; const BASE: DockerRenderOptions = { - fps: { num: 30, den: 1 }, + fps: 30, quality: "standard", format: "mp4", gpu: false, @@ -151,7 +151,7 @@ describe("buildDockerRunArgs", () => { const args = buildDockerRunArgs({ ...FIXED_INPUT, options: { - fps: { num: 60, den: 1 }, + fps: 60, quality: "high", format: "webm", workers: 8, @@ -240,29 +240,6 @@ describe("buildDockerRunArgs", () => { expect(args).not.toContain("--composition"); }); - it("forwards rational --fps verbatim (NTSC 30000/1001)", () => { - // Regression for the fps fraction-syntax feature: the rational form must - // survive the host → container hop as a single `30000/1001` argument so - // the in-container CLI re-parses it as exact NTSC, not 29.97 decimal. - const args = buildDockerRunArgs({ - ...FIXED_INPUT, - options: { ...BASE, fps: { num: 30000, den: 1001 } }, - }); - const fpsIdx = args.indexOf("--fps"); - expect(fpsIdx).toBeGreaterThanOrEqual(0); - expect(args[fpsIdx + 1]).toBe("30000/1001"); - }); - - it("forwards integer --fps as a bare integer string", () => { - const args = buildDockerRunArgs({ - ...FIXED_INPUT, - options: { ...BASE, fps: { num: 60, den: 1 } }, - }); - const fpsIdx = args.indexOf("--fps"); - expect(fpsIdx).toBeGreaterThanOrEqual(0); - expect(args[fpsIdx + 1]).toBe("60"); - }); - it("forwards --resolution to the container when outputResolution is set", () => { const args = buildDockerRunArgs({ ...FIXED_INPUT, diff --git a/packages/cli/src/utils/dockerRunArgs.ts b/packages/cli/src/utils/dockerRunArgs.ts index 986a75034..8630d3bd4 100644 --- a/packages/cli/src/utils/dockerRunArgs.ts +++ b/packages/cli/src/utils/dockerRunArgs.ts @@ -7,8 +7,6 @@ * a test in `dockerRunArgs.test.ts` — that combination is what catches * silent-drop regressions like the one that lost `--hdr` historically. */ -import { fpsToFfmpegArg, type Fps } from "@hyperframes/core"; - export interface DockerRunArgsInput { imageTag: string; /** Absolute host path to the project directory (mounted read-only at /project). */ @@ -21,13 +19,7 @@ export interface DockerRunArgsInput { } export interface DockerRenderOptions { - /** - * Frame rate as an exact rational; see `Fps` in @hyperframes/core. The - * docker-run arg builder serializes this back to a `--fps` string - * (`"30"` or `"30000/1001"`) which the in-container CLI re-parses with - * `parseFps`, so the rational survives the host → container hop. - */ - fps: Fps; + fps: 24 | 30 | 60; quality: "draft" | "standard" | "high"; format: "mp4" | "webm" | "mov" | "png-sequence"; workers?: number; @@ -62,7 +54,7 @@ export function buildDockerRunArgs(input: DockerRunArgsInput): string[] { "--output", `/output/${outputFilename}`, "--fps", - fpsToFfmpegArg(options.fps), + String(options.fps), "--quality", options.quality, "--format", diff --git a/packages/core/package.json b/packages/core/package.json index e29665941..ca77373c1 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.5.7", + "version": "0.6.0-alpha.13", "description": "", "repository": { "type": "git", @@ -42,6 +42,14 @@ "import": "./src/studio-api/helpers/screenshotClip.ts", "types": "./src/studio-api/helpers/screenshotClip.ts" }, + "./studio-api/manual-edits-render-script": { + "import": "./src/studio-api/helpers/manualEditsRenderScript.ts", + "types": "./src/studio-api/helpers/manualEditsRenderScript.ts" + }, + "./studio-api/studio-motion-render-script": { + "import": "./src/studio-api/helpers/studioMotionRenderScript.ts", + "types": "./src/studio-api/helpers/studioMotionRenderScript.ts" + }, "./text": { "import": "./src/text/index.ts", "types": "./src/text/index.ts" @@ -81,6 +89,14 @@ "import": "./dist/studio-api/helpers/screenshotClip.js", "types": "./dist/studio-api/helpers/screenshotClip.d.ts" }, + "./studio-api/manual-edits-render-script": { + "import": "./dist/studio-api/helpers/manualEditsRenderScript.js", + "types": "./dist/studio-api/helpers/manualEditsRenderScript.d.ts" + }, + "./studio-api/studio-motion-render-script": { + "import": "./dist/studio-api/helpers/studioMotionRenderScript.js", + "types": "./dist/studio-api/helpers/studioMotionRenderScript.d.ts" + }, "./text": { "import": "./dist/text/index.js", "types": "./dist/text/index.d.ts" diff --git a/packages/core/src/compiler/htmlBundler.test.ts b/packages/core/src/compiler/htmlBundler.test.ts index 665ff9811..4cf5a75d7 100644 --- a/packages/core/src/compiler/htmlBundler.test.ts +++ b/packages/core/src/compiler/htmlBundler.test.ts @@ -367,9 +367,11 @@ describe("bundleToSingleHtml", () => { const host = document.querySelector("#scene-host"); expect(host?.getAttribute("data-composition-id")).toBe("scene"); + expect(host?.getAttribute("data-composition-file")).toBe("compositions/scene.html"); expect(host?.getAttribute("data-start")).toBe("intro"); expect(host?.getAttribute("data-width")).toBe("1920"); expect(host?.querySelector(".title")?.textContent).toBe("Scene"); + expect(host?.querySelector(".title")?.closest("[data-composition-file]")).toBe(host); expect( Array.from(host?.children ?? []).some( (child) => child.getAttribute("data-composition-id") === "scene", diff --git a/packages/core/src/compiler/htmlBundler.ts b/packages/core/src/compiler/htmlBundler.ts index def7c8463..1ccbed894 100644 --- a/packages/core/src/compiler/htmlBundler.ts +++ b/packages/core/src/compiler/htmlBundler.ts @@ -7,7 +7,11 @@ import { parseHTMLContent, stripEmbeddedRuntimeScripts, } from "./htmlDocument"; -import { rewriteAssetPaths, rewriteCssAssetUrls } from "./rewriteSubCompPaths"; +import { + rewriteAssetPaths, + rewriteCssAssetUrls, + rewriteInlineStyleAssetUrls, +} from "./rewriteSubCompPaths"; import { scopeCssToComposition, wrapScopedCompositionScript } from "./compositionScoping"; import { validateHyperframeHtmlContract } from "./staticGuard"; import { getHyperframeRuntimeScript } from "../generated/runtime-inline"; @@ -556,18 +560,31 @@ 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"); const innerH = innerRoot.getAttribute("data-height"); if (innerW && !hostEl.getAttribute("data-width")) hostEl.setAttribute("data-width", innerW); if (innerH && !hostEl.getAttribute("data-height")) hostEl.setAttribute("data-height", innerH); + innerRoot.setAttribute("data-composition-file", src); for (const child of [...innerRoot.querySelectorAll("style, script")]) child.remove(); hostEl.innerHTML = compId ? innerRoot.innerHTML || "" : innerRoot.outerHTML || ""; } else { for (const child of [...contentDoc.querySelectorAll("style, script")]) child.remove(); hostEl.innerHTML = contentDoc.body.innerHTML || ""; } + hostEl.setAttribute("data-composition-file", src); hostEl.removeAttribute("data-composition-src"); } 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/core.types.ts b/packages/core/src/core.types.ts index fd3cce545..266231d5e 100644 --- a/packages/core/src/core.types.ts +++ b/packages/core/src/core.types.ts @@ -4,46 +4,19 @@ export type ExecutionMode = "planning" | "design" | "execution" | null; // ── Frame rate ────────────────────────────────────────────────────────────── -/** - * Frame rate as an exact rational. Carrying `{num, den}` end-to-end (rather - * than collapsing to `29.97`) lets us pass NTSC / drop-frame rates straight - * through to FFmpeg via `-r 30000/1001` without any decimal round-trip. - * - * Integer fps is represented with `den: 1` (e.g. `{ num: 30, den: 1 }`). - * - * Use {@link fpsToNumber} when arithmetic forces a decimal (e.g. `setTimeout` - * intervals) and {@link fpsToFfmpegArg} when emitting FFmpeg `-r` / - * `-framerate` strings. - */ export interface Fps { num: number; den: number; } -/** - * Decimal value of an {@link Fps} rational. Used at sites that need a - * `number` for arithmetic (frame-index → time, frame intervals, telemetry - * payloads) where the small precision loss of the decimal is acceptable. - */ export function fpsToNumber(fps: Fps): number { return fps.num / fps.den; } -/** - * FFmpeg-style fps argument. Returns `"30"` for integer fps and `"30000/1001"` - * for rationals — both forms are accepted verbatim by FFmpeg's `-r` and - * `-framerate` flags. We keep integer fps as a bare integer so existing - * snapshot tests / log output don't churn for the common case. - */ export function fpsToFfmpegArg(fps: Fps): string { return fps.den === 1 ? String(fps.num) : `${fps.num}/${fps.den}`; } -/** - * Discriminated parse result for {@link parseFps}. Lets the CLI / route - * validation own its own error UX without losing the structured failure - * reason. - */ export type FpsParseResult = | { ok: true; value: Fps } | { @@ -57,23 +30,6 @@ export type FpsParseResult = | "ambiguous-decimal"; }; -/** - * Parse a user-supplied fps spec into an {@link Fps} rational. - * - * Accepted forms: - * - integer string `"30"` → `{ num: 30, den: 1 }` - * - integer number `30` → `{ num: 30, den: 1 }` - * - rational string `"30000/1001"` → `{ num: 30000, den: 1001 }` (exact NTSC) - * - * Rejected: - * - empty / non-numeric input - * - decimals like `"29.97"` — callers must spell rationals with `/` so the - * exact denominator is unambiguous (FFmpeg treats `29.97` as a slightly - * different framerate than `30000/1001`). - * - division by zero, negative or zero numerator - * - decimal value outside `[1, 240]` — defensive bounds for "human" fps - * ranges (24, 25, 30, 50, 60, 120, 240, plus the NTSC trio). - */ export function parseFps(input: string | number): FpsParseResult { if (typeof input === "number") { if (!Number.isFinite(input)) return { ok: false, reason: "not-a-number" }; @@ -103,11 +59,7 @@ export function parseFps(input: string | number): FpsParseResult { return { ok: true, value: { num, den } }; } - // Integer-only path — reject `"29.97"` so users are explicit about the - // exact rational they want. if (!/^-?\d+$/.test(raw)) { - // Allow caller to differentiate "29.97" from "abc" if they want; both - // are user errors but the message can be friendlier for decimals. if (/^-?\d*\.\d+$/.test(raw)) return { ok: false, reason: "ambiguous-decimal" }; return { ok: false, reason: "not-a-number" }; } @@ -117,11 +69,6 @@ export function parseFps(input: string | number): FpsParseResult { return { ok: true, value: { num: n, den: 1 } }; } -/** - * Convenience wrapper around {@link parseFps} for callsites that want the - * default-30-fps fallback when input is `undefined`. Does NOT swallow parse - * errors — those still surface via the discriminated result. - */ export function parseFpsWithDefault(input: string | number | undefined): FpsParseResult { if (input === undefined || input === "") return { ok: true, value: { num: 30, den: 1 } }; return parseFps(input); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 3f4f58447..ef6dd6cee 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -11,8 +11,6 @@ export type { TimelineElementType, MediaElementType, CanvasResolution, - Fps, - FpsParseResult, MediaFile, CompositionAPI, PlayerAPI, @@ -37,13 +35,15 @@ export type { } from "./core.types"; export { + type Fps, + fpsToNumber, + fpsToFfmpegArg, + type FpsParseResult, + parseFps, + parseFpsWithDefault, CANVAS_DIMENSIONS, VALID_CANVAS_RESOLUTIONS, normalizeResolutionFlag, - parseFps, - parseFpsWithDefault, - fpsToNumber, - fpsToFfmpegArg, TIMELINE_COLORS, DEFAULT_DURATIONS, COMPOSITION_VARIABLE_TYPES, 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/parsers/htmlParser.test.ts b/packages/core/src/parsers/htmlParser.test.ts index e02c5fd3f..841d26006 100644 --- a/packages/core/src/parsers/htmlParser.test.ts +++ b/packages/core/src/parsers/htmlParser.test.ts @@ -197,6 +197,21 @@ describe("parseHtml", () => { expect(result.resolution).toBe("portrait"); }); + it("keeps explicit portrait resolution even when dimensions are square", () => { + const html = ` + + +
+
Hello
+
+ + + `; + const result = parseHtml(html); + + expect(result.resolution).toBe("portrait"); + }); + it("defaults to portrait when no resolution info is available", () => { const html = ` @@ -290,7 +305,7 @@ describe("parseHtml", () => { expect(result.resolution).toBe("square"); }); - it("infers square-4k from equal width/height ≥ 2160", () => { + it("infers square-4k from equal width/height >= 2160", () => { const html = ` diff --git a/packages/core/src/parsers/htmlParser.ts b/packages/core/src/parsers/htmlParser.ts index 554621cd3..f7d906349 100644 --- a/packages/core/src/parsers/htmlParser.ts +++ b/packages/core/src/parsers/htmlParser.ts @@ -147,8 +147,8 @@ function parseResolutionFromHtml(doc: Document): CanvasResolution | null { function resolveResolutionFromDimensions(width: number, height: number): CanvasResolution { const longSide = Math.max(width, height); // UHD cutoff is the long side of the 4K presets (3840 for `landscape-4k` / - // `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. ≥ 2560) - // would silently misclassify QHD/1440p (2560×1440) as 4K, which is the + // `portrait-4k`, 2160 for `square-4k`). A looser threshold (e.g. >= 2560) + // would silently misclassify QHD/1440p (2560x1440) as 4K, which is the // wrong default for a common authoring resolution closer to 1080p than to // UHD. Authors who genuinely want the 4K preset can still set // `data-resolution="..."` explicitly. diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index d45fb9552..32e8d0841 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initSandboxRuntimeModular } from "./init"; import type { RuntimeTimelineLike } from "./types"; @@ -45,6 +45,31 @@ function createPaddableMockTimeline(duration: number): RuntimeTimelineLike { return timeline; } +function createManualRaf() { + let now = 0; + let nextId = 0; + const callbacks = new Map(); + return { + requestAnimationFrame: (callback: FrameRequestCallback) => { + nextId += 1; + callbacks.set(nextId, callback); + return nextId; + }, + cancelAnimationFrame: (id: number) => { + callbacks.delete(id); + }, + step: (milliseconds: number) => { + now += milliseconds; + const pending = Array.from(callbacks.entries()); + callbacks.clear(); + for (const [, callback] of pending) { + callback(now); + } + }, + now: () => now, + }; +} + describe("initSandboxRuntimeModular", () => { const originalRequestAnimationFrame = window.requestAnimationFrame; const originalCancelAnimationFrame = window.cancelAnimationFrame; @@ -67,6 +92,7 @@ describe("initSandboxRuntimeModular", () => { delete (window as Window & { __player?: unknown }).__player; delete (window as Window & { __playerReady?: boolean }).__playerReady; delete (window as Window & { __renderReady?: boolean }).__renderReady; + vi.restoreAllMocks(); window.requestAnimationFrame = originalRequestAnimationFrame; window.cancelAnimationFrame = originalCancelAnimationFrame; }); @@ -283,32 +309,67 @@ describe("initSandboxRuntimeModular", () => { expect(video.currentTime).toBe(0); }); - it("allows external code to reassign delegated __player methods", () => { + it("plays scheduled child timelines without a captured root timeline when audio has failed", () => { + const raf = createManualRaf(); + vi.spyOn(performance, "now").mockImplementation(() => raf.now()); + window.requestAnimationFrame = raf.requestAnimationFrame as typeof window.requestAnimationFrame; + window.cancelAnimationFrame = raf.cancelAnimationFrame as typeof window.cancelAnimationFrame; + const root = document.createElement("div"); root.setAttribute("data-composition-id", "main"); root.setAttribute("data-root", "true"); root.setAttribute("data-start", "0"); + root.setAttribute("data-duration", "4"); root.setAttribute("data-width", "1920"); root.setAttribute("data-height", "1080"); document.body.appendChild(root); + const child = document.createElement("div"); + child.setAttribute("data-composition-id", "scene"); + child.setAttribute("data-start", "0"); + child.setAttribute("data-duration", "4"); + root.appendChild(child); + + const audio = document.createElement("audio"); + audio.setAttribute("data-start", "0"); + audio.setAttribute("data-duration", "4"); + Object.defineProperty(audio, "error", { + value: { code: 4, message: "format error" }, + configurable: true, + }); + Object.defineProperty(audio, "networkState", { + value: HTMLMediaElement.NETWORK_NO_SOURCE, + configurable: true, + }); + Object.defineProperty(audio, "readyState", { + value: HTMLMediaElement.HAVE_NOTHING, + configurable: true, + }); + Object.defineProperty(audio, "paused", { value: true, configurable: true }); + Object.defineProperty(audio, "currentTime", { value: 0, writable: true, configurable: true }); + audio.load = () => {}; + audio.play = vi.fn(() => Promise.reject(new Error("format error"))); + root.appendChild(audio); + + const childTimeline = createMockTimeline(4); (window as Window & { __timelines?: Record }).__timelines = { - main: createMockTimeline(10), + scene: childTimeline, }; initSandboxRuntimeModular(); const player = ( window as Window & { - __player?: { renderSeek: (timeSeconds: number) => void }; + __player?: { play: () => void; getTime: () => number; isPlaying: () => boolean }; } ).__player; expect(player).toBeDefined(); - if (!player) return; - const original = player.renderSeek; - expect(() => { - player.renderSeek = (t: number) => original(t); - }).not.toThrow(); + player?.play(); + raf.step(1_000); + + expect(player?.isPlaying()).toBe(true); + expect(player?.getTime()).toBeCloseTo(1, 1); + expect(childTimeline.time()).toBeCloseTo(1, 1); }); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 8316d8b53..8f41999a6 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -933,6 +933,15 @@ export function initSandboxRuntimeModular(): void { if (typeof state.capturedTimeline.timeScale === "function") { state.capturedTimeline.timeScale(state.playbackRate); } + const boundDuration = getSafeTimelineDurationSeconds(state.capturedTimeline, 0); + if (boundDuration > 0) { + try { + clock.setDuration(boundDuration); + } catch { + // clock not yet initialized — duration will be set during TransportClock setup + } + state.capturedTimeline.pause(); + } if (resolution.diagnostics) { postRuntimeMessage({ source: "hf-preview", @@ -1333,7 +1342,7 @@ export function initSandboxRuntimeModular(): void { timeSeconds: state.currentTime, playing: state.isPlaying, playbackRate: state.playbackRate, - outputMuted: state.mediaOutputMuted, + outputMuted: state.mediaOutputMuted || webAudio.isActive(), userMuted: state.bridgeMuted, userVolume: state.bridgeVolume, forceSync, @@ -1488,6 +1497,7 @@ export function initSandboxRuntimeModular(): void { .then(() => loadInlineTemplateCompositions(compositionLoaderParams)) .finally(() => { externalCompositionsReady = true; + bindRootTimelineIfAvailable(); runAdapters("discover", state.currentTime); bindMediaMetadataListeners(); installAssetFailureDiagnostics(); @@ -1627,7 +1637,6 @@ export function initSandboxRuntimeModular(): void { onSetPlaybackRate: (rate) => { applyPlaybackRate(rate); if (state.transportClock) state.transportClock.setRate(state.playbackRate); - webAudio.setRate(state.playbackRate); }, onEnablePickMode: () => picker.enablePickMode(), onDisablePickMode: () => picker.disablePickMode(), @@ -1683,6 +1692,49 @@ export function initSandboxRuntimeModular(): void { let transportTickCount = 0; let inTransportTick = false; + const seekRuntimeTimeline = ( + timeline: RuntimeTimelineLike, + timeSeconds: number, + swallowLabel: string, + ) => { + try { + timeline.pause(); + if (typeof timeline.totalTime === "function") { + timeline.totalTime(timeSeconds, false); + } else { + timeline.seek(timeSeconds, false); + } + } catch (err) { + swallow(swallowLabel, err); + } + }; + + const seekStandaloneRegisteredTimelines = (timeSeconds: number) => { + const timelines = (window.__timelines ?? {}) as Record; + const rootCompositionId = + resolveRootCompositionElement()?.getAttribute("data-composition-id") ?? null; + for (const [compositionId, timeline] of Object.entries(timelines)) { + if (!timeline || compositionId === rootCompositionId) continue; + const node = document.querySelector(`[data-composition-id="${CSS.escape(compositionId)}"]`); + if (!node) continue; + const start = resolveStartForElement(node, 0); + if (!Number.isFinite(start)) continue; + const authoredDuration = resolveDurationForElement(node, { + includeAuthoredTimingAttrs: true, + }); + const timelineDuration = getTimelineDurationSeconds(timeline); + const duration = + authoredDuration != null && authoredDuration > 0 ? authoredDuration : timelineDuration; + const localTime = Math.max( + 0, + duration != null && duration > 0 + ? Math.min(duration, timeSeconds - start) + : timeSeconds - start, + ); + seekRuntimeTimeline(timeline, localTime, "runtime.init.transport.childTimeline"); + } + }; + const seekTimelineAndAdapters = (t: number) => { const tl = state.capturedTimeline; if (tl) { @@ -1702,6 +1754,8 @@ export function initSandboxRuntimeModular(): void { // at absolute `t` would clobber their offset-relative position. // Play/pause propagation for siblings happens in the player.play() // and player.pause() overrides via the adapter layer. + } else { + seekStandaloneRegisteredTimelines(t); } for (const adapter of state.deterministicAdapters) { try { @@ -1780,7 +1834,7 @@ export function initSandboxRuntimeModular(): void { if (!rawEl.paused) { clock.attachAudioSource({ el: rawEl, compositionStart: start, mediaStart }); foundActive = true; - } else if (rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { + } else if (!rawEl.error && rawEl.readyState < HTMLMediaElement.HAVE_FUTURE_DATA) { // Audio is buffering — freeze visuals at last known position // instead of falling through to monotonic (which runs ahead). clock.attachAudioSource({ currentTimeSeconds: state.currentTime }); @@ -1871,8 +1925,12 @@ export function initSandboxRuntimeModular(): void { state.currentTime = 0; seekTimelineAndAdapters(0); } + } else { + const rootEl = resolveRootCompositionElement(); + const declaredDur = Number(rootEl?.getAttribute("data-duration") ?? 0); + if (declaredDur > 0) clock.setDuration(declaredDur); } - tl.pause(); + if (tl) tl.pause(); if (!clock.play()) return; state.isPlaying = true; state.mediaForceSyncNextTick = true; @@ -1901,7 +1959,6 @@ export function initSandboxRuntimeModular(): void { clock.now(), vol * state.bridgeVolume, gen, - state.playbackRate, ); }); } diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 11d770659..5d76dbc94 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -198,13 +198,26 @@ export function syncRuntimeMedia(params: { const offsetJumped = !firstTickOfClip && Math.abs(offset - prevOffset!) > 0.5; const catastrophicDrift = drift > 3; const hardSync = drift > 0.5 && (firstTickOfClip || offsetJumped || catastrophicDrift); + // Playing video elements use the browser's native decoder pipeline for + // timing. Seeking a playing video resets the decoder, causing a ~150ms + // freeze while it re-buffers — during which the monotonic clock advances, + // creating a perpetual seek→freeze→drift→seek stutter loop. Skip strict + // and force sync for playing videos; only hard sync (>0.5s) warrants + // the decoder-reset cost. + const isPlayingVideo = el.tagName === "VIDEO" && !el.paused; // Only apply strict sync when offset has stabilized (not growing). // During initial buffering, offset grows ~16ms/tick as the timeline // advances while media stays at 0. Accumulated drift from pause/play // toggling shows up as a stable, non-zero offset (delta near 0). const offsetStabilized = prevOffset !== undefined && Math.abs(offset - prevOffset) < 0.004; let strictSync = false; - if (!hardSync && !firstTickOfClip && offsetStabilized && drift > STRICT_DRIFT_THRESHOLD) { + if ( + !isPlayingVideo && + !hardSync && + !firstTickOfClip && + offsetStabilized && + drift > STRICT_DRIFT_THRESHOLD + ) { const samples = (strictDriftSamples.get(el) ?? 0) + 1; strictDriftSamples.set(el, samples); if (samples >= STRICT_REQUIRED_SAMPLES) { @@ -214,7 +227,8 @@ export function syncRuntimeMedia(params: { } else if (drift <= STRICT_DRIFT_THRESHOLD) { strictDriftSamples.set(el, 0); } - if (hardSync || strictSync || (params.forceSync && drift > 0.02)) { + const forceSync = !isPlayingVideo && params.forceSync && drift > 0.02; + if (hardSync || strictSync || forceSync) { try { el.currentTime = relTime; } catch (err) { diff --git a/packages/core/src/runtime/picker.ts b/packages/core/src/runtime/picker.ts index 733c01a40..6f12cd118 100644 --- a/packages/core/src/runtime/picker.ts +++ b/packages/core/src/runtime/picker.ts @@ -58,12 +58,28 @@ export function createPickerModule(deps: PickerModuleDeps): PickerModule { }); } + function isEffectivelyHidden(el: HTMLElement): boolean { + const win = el.ownerDocument.defaultView; + if (!win) return false; + let current: HTMLElement | null = el; + while (current && current !== document.body && current !== document.documentElement) { + const computed = win.getComputedStyle(current); + if (computed.display === "none" || computed.visibility === "hidden") return true; + if (computed.pointerEvents === "none") return true; + const opacity = Number.parseFloat(computed.opacity); + if (Number.isFinite(opacity) && opacity <= 0.01) return true; + current = current.parentElement; + } + return false; + } + function isPickableElement(el: Element | null): el is Element { if (!el || el === document.body || el === document.documentElement) return false; const tag = el.tagName.toLowerCase(); if (tag === "script" || tag === "style" || tag === "link" || tag === "meta") return false; if (el.classList.contains("__hf-pick-highlight")) return false; if (el.closest(PICKER_IGNORE_SELECTOR)) return false; + if (isEffectivelyHidden(el as HTMLElement)) return false; return true; } diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 1dc6caf8f..d34ac7593 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -1,49 +1,6 @@ import { describe, it, expect, vi } from "vitest"; import { WebAudioTransport } from "./webAudioTransport"; -function createMockAudioContext(currentTime = 100) { - const startFn = vi.fn(); - const sourceNode = { - buffer: null as AudioBuffer | null, - playbackRate: { value: 1 }, - start: startFn, - stop: vi.fn(), - disconnect: vi.fn(), - connect: vi.fn(), - }; - const gainNode = { - gain: { value: 1 }, - connect: vi.fn(), - disconnect: vi.fn(), - }; - const masterGain = { - gain: { value: 1 }, - connect: vi.fn(), - }; - const ctx = { - currentTime, - state: "running", - resume: vi.fn(), - createBufferSource: vi.fn(() => sourceNode), - createGain: vi.fn(() => gainNode), - destination: {}, - close: vi.fn(), - }; - return { ctx, sourceNode, gainNode, masterGain, startFn }; -} - -function setupTransport(currentTime = 100) { - const transport = new WebAudioTransport(); - const mock = createMockAudioContext(currentTime); - (transport as unknown as { _ctx: unknown })._ctx = mock.ctx; - (transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain; - const gen = transport.startGeneration(); - return { transport, mock, gen }; -} - -const mockBuffer = {} as AudioBuffer; -const mockEl = { muted: false } as HTMLMediaElement; - describe("WebAudioTransport", () => { it("tracks play generation for async race prevention", () => { const transport = new WebAudioTransport(); @@ -125,6 +82,45 @@ describe("WebAudioTransport", () => { }); describe("schedulePlayback timing", () => { + function createMockAudioContext(currentTime = 100) { + const startFn = vi.fn(); + const sourceNode = { + buffer: null as AudioBuffer | null, + start: startFn, + connect: vi.fn(), + }; + const gainNode = { + gain: { value: 1 }, + connect: vi.fn(), + }; + const masterGain = { + gain: { value: 1 }, + connect: vi.fn(), + }; + const ctx = { + currentTime, + state: "running", + resume: vi.fn(), + createBufferSource: vi.fn(() => sourceNode), + createGain: vi.fn(() => gainNode), + destination: {}, + close: vi.fn(), + }; + return { ctx, sourceNode, gainNode, masterGain, startFn }; + } + + function setupTransport(currentTime = 100) { + const transport = new WebAudioTransport(); + const mock = createMockAudioContext(currentTime); + (transport as unknown as { _ctx: unknown })._ctx = mock.ctx; + (transport as unknown as { _masterGain: unknown })._masterGain = mock.masterGain; + const gen = transport.startGeneration(); + return { transport, mock, gen }; + } + + const mockBuffer = {} as AudioBuffer; + const mockEl = { muted: false } as HTMLMediaElement; + it("starts in-progress clips immediately with correct buffer offset", async () => { const { transport, mock, gen } = setupTransport(100); @@ -165,120 +161,4 @@ describe("WebAudioTransport", () => { expect(mock.startFn).toHaveBeenCalledWith(0, 0); }); }); - - describe("playback rate", () => { - it("sets sourceNode.playbackRate.value when rate is provided", async () => { - const { transport, mock, gen } = setupTransport(100); - - await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); - - expect(mock.sourceNode.playbackRate.value).toBe(2); - }); - - it("defaults rate to 1 when not provided", async () => { - const { transport, mock, gen } = setupTransport(100); - - await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen); - - expect(mock.sourceNode.playbackRate.value).toBe(1); - }); - - it("scales delay by rate for future clips so they fire at the right wallclock", async () => { - const { transport, mock, gen } = setupTransport(100); - - // compStart=10, compositionTime=2, rate=2 → 8s of comp time = 4s wallclock - await transport.schedulePlayback(mockEl, mockBuffer, 10, 0, 2, 1, gen, 2); - - expect(mock.startFn).toHaveBeenCalledWith(104, 0); - }); - - it("keeps in-progress buffer offset at elapsed + mediaStart regardless of rate", async () => { - const { transport, mock, gen } = setupTransport(100); - - await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); - - expect(mock.startFn).toHaveBeenCalledWith(0, 3); - }); - - it("setRate updates active sources in place", async () => { - const { transport, mock, gen } = setupTransport(100); - - await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1); - expect(mock.sourceNode.playbackRate.value).toBe(1); - - transport.setRate(2); - - expect(mock.sourceNode.playbackRate.value).toBe(2); - }); - - it("setRate before any sources are scheduled does not throw", () => { - const transport = new WebAudioTransport(); - expect(() => transport.setRate(2)).not.toThrow(); - }); - - it("setRate is a no-op when the rate is unchanged", async () => { - const { transport, mock, gen } = setupTransport(100); - await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); - - mock.ctx.currentTime = 100.5; - const timeBefore = transport.getTime(); - transport.setRate(2); - const timeAfter = transport.getTime(); - - expect(timeAfter).toBe(timeBefore); - // No re-anchor, so the next 0.5s of wallclock still maps to 1s of comp time. - mock.ctx.currentTime = 101; - expect(transport.getTime()).toBeCloseTo(10, 10); - }); - - it("setRate clamps non-finite or non-positive values to 1", async () => { - const { transport, mock, gen } = setupTransport(100); - await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); - expect(mock.sourceNode.playbackRate.value).toBe(2); - - transport.setRate(Number.NaN); - expect(mock.sourceNode.playbackRate.value).toBe(1); - - transport.setRate(2); - transport.setRate(0); - expect(mock.sourceNode.playbackRate.value).toBe(1); - - transport.setRate(2); - transport.setRate(-1); - expect(mock.sourceNode.playbackRate.value).toBe(1); - }); - - it("getTime advances at the configured rate", async () => { - const { transport, mock, gen } = setupTransport(100); - - await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 2); - - // At schedule time, ctx.currentTime=100, compositionTime=8. - expect(transport.getTime()).toBeCloseTo(8, 10); - - // Advance the audio-context clock by 0.5 wallclock seconds; at rate=2, - // composition time should have advanced 1s. - mock.ctx.currentTime = 100.5; - expect(transport.getTime()).toBeCloseTo(9, 10); - }); - - it("getTime tracks composition time after a mid-playback setRate", async () => { - const { transport, mock, gen } = setupTransport(100); - - await transport.schedulePlayback(mockEl, mockBuffer, 5, 0, 8, 1, gen, 1); - expect(transport.getTime()).toBeCloseTo(8, 10); - - // 0.5s passes at rate=1 → composition time = 8.5 - mock.ctx.currentTime = 100.5; - expect(transport.getTime()).toBeCloseTo(8.5, 10); - - // Bump rate to 2 — composition time should NOT jump. - transport.setRate(2); - expect(transport.getTime()).toBeCloseTo(8.5, 10); - - // Another 0.5s wallclock at rate=2 → composition time = 9.5 - mock.ctx.currentTime = 101; - expect(transport.getTime()).toBeCloseTo(9.5, 10); - }); - }); }); diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index baa1113bc..14b591f33 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -1,10 +1,5 @@ import { swallow } from "./diagnostics"; -function normalizeRate(rate: number): number { - if (!Number.isFinite(rate) || rate <= 0) return 1; - return rate; -} - export type ScheduledSource = { el: HTMLMediaElement; sourceNode: AudioBufferSourceNode; @@ -12,7 +7,6 @@ export type ScheduledSource = { compositionStart: number; mediaStart: number; scheduledAt: number; - priorMuted: boolean; }; export class WebAudioTransport { @@ -20,12 +14,7 @@ export class WebAudioTransport { private _bufferCache = new Map(); private _activeSources: ScheduledSource[] = []; private _masterGain: GainNode | null = null; - // Composition-time reference frame: at AudioContext time `_rateAnchorCtx`, - // composition time was `_rateAnchorComp`, and time has been advancing at - // `_rate` composition-seconds per wallclock-second since. - private _rateAnchorCtx = 0; - private _rateAnchorComp = 0; - private _rate = 1; + private _scheduleOffset = 0; private _paused = true; private _playGeneration = 0; @@ -46,7 +35,7 @@ export class WebAudioTransport { getTime(): number { if (!this._ctx || this._paused) return -1; - return this._rateAnchorComp + (this._ctx.currentTime - this._rateAnchorCtx) * this._rate; + return this._ctx.currentTime - this._scheduleOffset; } async decodeAudioElement(el: HTMLMediaElement): Promise { @@ -83,7 +72,6 @@ export class WebAudioTransport { compositionTime: number, volume: number, generation: number, - rate = 1, ): Promise { if (!this._ctx || !this._masterGain) return null; if (generation !== this._playGeneration) return null; @@ -94,11 +82,8 @@ export class WebAudioTransport { } if (generation !== this._playGeneration) return null; - const safeRate = normalizeRate(rate); - const sourceNode = this._ctx.createBufferSource(); sourceNode.buffer = buffer; - sourceNode.playbackRate.value = safeRate; const gainNode = this._ctx.createGain(); gainNode.gain.value = volume; @@ -107,18 +92,15 @@ export class WebAudioTransport { const elapsed = compositionTime - compositionStart; const scheduledAt = this._ctx.currentTime; - this._rate = safeRate; - this._rateAnchorCtx = scheduledAt; - this._rateAnchorComp = compositionTime; + this._scheduleOffset = scheduledAt - compositionTime; if (elapsed >= 0) { sourceNode.start(0, elapsed + mediaStart); } else { - const delay = -elapsed / safeRate; + const delay = -elapsed; sourceNode.start(scheduledAt + delay, mediaStart); } - const priorMuted = el.muted; el.muted = true; const scheduled: ScheduledSource = { @@ -128,7 +110,6 @@ export class WebAudioTransport { compositionStart, mediaStart, scheduledAt, - priorMuted, }; this._activeSources.push(scheduled); this._paused = false; @@ -139,29 +120,6 @@ export class WebAudioTransport { } } - /** - * Rebases the composition-time reference frame before swapping rate so - * `getTime()` stays continuous across the change. Sources scheduled to - * start in the future keep their original wallclock start time — callers - * that need rate-correct future starts should `stopAll()` and reschedule. - */ - setRate(rate: number): void { - const safeRate = normalizeRate(rate); - if (safeRate === this._rate) return; - if (this._ctx && !this._paused) { - this._rateAnchorComp = this.getTime(); - this._rateAnchorCtx = this._ctx.currentTime; - } - this._rate = safeRate; - for (const source of this._activeSources) { - try { - source.sourceNode.playbackRate.value = safeRate; - } catch (err) { - swallow("webAudioTransport.setRate", err); - } - } - } - stopAll(): void { for (const source of this._activeSources) { try { @@ -171,7 +129,10 @@ export class WebAudioTransport { } catch { // already stopped } - source.el.muted = source.priorMuted; + // Keep the element muted — syncRuntimeMedia will unmute on the next + // tick if appropriate. Restoring priorMuted here races with el.play() + // in the media sync loop, briefly producing audio from both the HTML + // element and the Web Audio buffer on pause/resume transitions. } this._activeSources = []; this._paused = true; diff --git a/packages/core/src/studio-api/createStudioApi.ts b/packages/core/src/studio-api/createStudioApi.ts index a7a94bb62..04492aad4 100644 --- a/packages/core/src/studio-api/createStudioApi.ts +++ b/packages/core/src/studio-api/createStudioApi.ts @@ -7,6 +7,7 @@ import { registerLintRoutes } from "./routes/lint.js"; import { registerRenderRoutes } from "./routes/render.js"; import { registerThumbnailRoutes } from "./routes/thumbnail.js"; import { registerWaveformRoutes } from "./routes/waveform.js"; +import { registerFontRoutes } from "./routes/fonts.js"; /** * Create a Hono sub-app with all studio API routes. @@ -24,6 +25,7 @@ export function createStudioApi(adapter: StudioApiAdapter): Hono { registerRenderRoutes(api, adapter); registerThumbnailRoutes(api, adapter); registerWaveformRoutes(api, adapter); + registerFontRoutes(api); return api; } diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts new file mode 100644 index 000000000..3e41a15f2 --- /dev/null +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.test.ts @@ -0,0 +1,382 @@ +import { describe, expect, it } from "vitest"; +import { Window } from "happy-dom"; +import { createStudioManualEditsRenderBodyScript } from "./manualEditsRenderScript"; + +function runScript( + window: Window, + script: string, + getComputedStyle: typeof window.getComputedStyle = window.getComputedStyle.bind(window), + timers: { + setInterval?: typeof globalThis.setInterval; + clearInterval?: typeof globalThis.clearInterval; + } = {}, +): void { + const execute = new Function( + "window", + "document", + "HTMLElement", + "getComputedStyle", + "setInterval", + "clearInterval", + script, + ); + execute( + window, + window.document, + window.HTMLElement, + getComputedStyle, + timers.setInterval ?? + (((callback: TimerHandler) => { + void callback; + return 0 as never; + }) as typeof globalThis.setInterval), + timers.clearInterval ?? globalThis.clearInterval, + ); +} + +describe("createStudioManualEditsRenderBodyScript", () => { + it("returns null for an empty manifest", () => { + expect(createStudioManualEditsRenderBodyScript("")).toBeNull(); + }); + + it("applies manual edits and reapplies them after render seeks", () => { + const window = new Window(); + window.document.body.innerHTML = '
'; + const card = window.document.getElementById("card"); + if (!(card instanceof window.HTMLElement)) { + throw new Error("card fixture missing"); + } + + let seekCalls = 0; + ( + window as unknown as { + __hf: { seek: (time: number) => void }; + } + ).__hf = { + seek: () => { + seekCalls += 1; + card.style.removeProperty("translate"); + }, + }; + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "path-offset", + target: { sourceFile: "index.html", id: "card" }, + x: 12, + y: 24, + }, + { + kind: "box-size", + target: { sourceFile: "index.html", id: "card" }, + width: 120, + height: 64, + }, + { + kind: "rotation", + target: { sourceFile: "index.html", id: "card" }, + angle: 15, + }, + ], + }), + ); + if (!script) throw new Error("script fixture missing"); + + const computedStyle = (element: Element) => + ({ + display: element === card ? "block" : "block", + flexDirection: "row", + }) as CSSStyleDeclaration; + + const intervalCallbacks: Array<() => void> = []; + runScript(window, script, computedStyle, { + setInterval: ((callback: TimerHandler) => { + if (typeof callback === "function") intervalCallbacks.push(callback as () => void); + return 0 as never; + }) as typeof globalThis.setInterval, + }); + + expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x"); + expect(card.style.getPropertyValue("width")).toBe("120px"); + expect(card.style.getPropertyValue("height")).toBe("64px"); + expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation"); + expect(card.style.getPropertyValue("transform-origin")).toBe("center center"); + + ( + window as unknown as { + __hf: { seek: (time: number) => void }; + } + ).__hf.seek(1); + + expect(seekCalls).toBe(1); + expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x"); + + ( + window as unknown as { + __hf: { seek: (time: number) => void }; + } + ).__hf.seek = () => { + card.style.removeProperty("rotate"); + }; + intervalCallbacks.forEach((callback) => callback()); + ( + window as unknown as { + __hf: { seek: (time: number) => void }; + } + ).__hf.seek(2); + expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation"); + + ( + window as unknown as { + __player: { renderSeek: (time: number) => void }; + } + ).__player = { + renderSeek: () => { + card.style.removeProperty("rotate"); + }, + }; + intervalCallbacks.forEach((callback) => callback()); + ( + window as unknown as { + __player: { renderSeek: (time: number) => void }; + } + ).__player.renderSeek(3); + expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation"); + }); + + it("applies render edits to the matching source file target", () => { + const window = new Window(); + window.document.body.innerHTML = ` +
+
+
+
+
+
+ `; + const cards = Array.from(window.document.getElementsByTagName("*")).filter( + (element): element is HTMLElement => + element instanceof window.HTMLElement && element.id === "card", + ); + const rootCard = cards[0]; + const nestedCard = cards[1]; + if (!rootCard || !nestedCard) { + throw new Error("source-scoped render fixture missing"); + } + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "rotation", + target: { sourceFile: "scenes/nested.html", id: "card" }, + angle: 21, + }, + ], + }), + ); + if (!script) throw new Error("script fixture missing"); + + runScript(window, script); + + expect(rootCard.style.getPropertyValue("rotate")).toBe(""); + expect(nestedCard.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation"); + }); + + it("applies render edits inside composition-file hosts without composition ids", () => { + const window = new Window(); + window.document.body.innerHTML = ` +
+
+
+
+
+
+ `; + const cards = Array.from(window.document.getElementsByTagName("*")).filter( + (element): element is HTMLElement => + element instanceof window.HTMLElement && element.id === "card", + ); + const rootCard = cards[0]; + const nestedCard = cards[1]; + if (!rootCard || !nestedCard) { + throw new Error("anonymous composition render fixture missing"); + } + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "path-offset", + target: { sourceFile: "scenes/anonymous.html", id: "card" }, + x: 12, + y: 24, + }, + ], + }), + ); + if (!script) throw new Error("script fixture missing"); + + runScript(window, script); + + expect(rootCard.style.getPropertyValue("translate")).toBe(""); + expect(nestedCard.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x"); + }); + + it("uses the active composition path as the unscoped document fallback", () => { + const window = new Window(); + window.document.body.innerHTML = `
`; + const card = window.document.getElementById("card"); + if (!(card instanceof window.HTMLElement)) { + throw new Error("card fixture missing"); + } + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "path-offset", + target: { sourceFile: "compositions/scene-2.html", id: "card" }, + x: 12, + y: 24, + }, + ], + }), + { activeCompositionPath: "compositions/scene-2.html" }, + ); + if (!script) throw new Error("script fixture missing"); + + runScript(window, script); + + expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x"); + }); + + it("preserves computed transform longhands as render edit bases", () => { + const window = new Window(); + window.document.body.innerHTML = `
`; + const card = window.document.getElementById("card"); + if (!(card instanceof window.HTMLElement)) { + throw new Error("card fixture missing"); + } + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "path-offset", + target: { sourceFile: "index.html", id: "card" }, + x: 12, + y: 24, + }, + { + kind: "rotation", + target: { sourceFile: "index.html", id: "card" }, + angle: 15, + }, + ], + }), + ); + if (!script) throw new Error("script fixture missing"); + + const computedStyle = (element: Element) => + ({ + getPropertyValue: (property: string) => { + if (element !== card) return ""; + if (property === "translate") return "10px 20px"; + if (property === "rotate") return "8deg"; + return ""; + }, + }) as CSSStyleDeclaration; + + runScript(window, script, computedStyle); + + expect(card.style.getPropertyValue("translate")).toContain("calc(10px +"); + expect(card.style.getPropertyValue("translate")).toContain("calc(20px +"); + expect(card.style.getPropertyValue("rotate")).toContain("8deg"); + expect(card.style.getPropertyValue("rotate")).toContain("--hf-studio-rotation"); + expect(card.style.getPropertyValue("transform-origin")).toBe("center center"); + }); + + it("does not compound stale studio variables during render reapply", () => { + const window = new Window(); + window.document.body.innerHTML = ` +
+ `; + const card = window.document.getElementById("card"); + if (!(card instanceof window.HTMLElement)) { + throw new Error("card fixture missing"); + } + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "path-offset", + target: { sourceFile: "index.html", id: "card" }, + x: 12, + y: 24, + }, + { + kind: "rotation", + target: { sourceFile: "index.html", id: "card" }, + angle: 15, + }, + ], + }), + ); + if (!script) throw new Error("script fixture missing"); + + runScript(window, script); + + expect(card.style.getPropertyValue("translate")).toBe( + "var(--hf-studio-offset-x, 0px) var(--hf-studio-offset-y, 0px)", + ); + expect(card.style.getPropertyValue("rotate")).toBe("var(--hf-studio-rotation, 0deg)"); + }); + + it("exposes a render reapply hook for thumbnails after layout settles", () => { + const window = new Window(); + window.document.body.innerHTML = `
`; + const card = window.document.getElementById("card"); + if (!(card instanceof window.HTMLElement)) { + throw new Error("card fixture missing"); + } + + const script = createStudioManualEditsRenderBodyScript( + JSON.stringify({ + version: 1, + edits: [ + { + kind: "path-offset", + target: { sourceFile: "index.html", id: "card" }, + x: 12, + y: 24, + }, + ], + }), + ); + if (!script) throw new Error("script fixture missing"); + + runScript(window, script); + card.style.removeProperty("translate"); + + ( + window as unknown as { + __hfStudioManualEditsApply?: () => number; + } + ).__hfStudioManualEditsApply?.(); + + expect(card.style.getPropertyValue("translate")).toContain("--hf-studio-offset-x"); + }); +}); diff --git a/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts new file mode 100644 index 000000000..444bb9f0b --- /dev/null +++ b/packages/core/src/studio-api/helpers/manualEditsRenderScript.ts @@ -0,0 +1,371 @@ +export interface StudioManualEditsRenderScriptOptions { + activeCompositionPath?: string | null; +} + +export const STUDIO_MANUAL_EDITS_PATH = ".hyperframes/studio-manual-edits.json"; + +export function createStudioManualEditsRenderBodyScript( + manifestContent: string, + options: StudioManualEditsRenderScriptOptions = {}, +): string | null { + if (!manifestContent.trim()) return null; + return `(${studioManualEditsRenderRuntime.toString()})(${JSON.stringify(manifestContent)}, ${JSON.stringify(options.activeCompositionPath ?? null)});`; +} + +function studioManualEditsRenderRuntime( + manifestContent: string, + activeCompositionPath: string | null, +): void { + const OFFSET_X_PROP = "--hf-studio-offset-x"; + const OFFSET_Y_PROP = "--hf-studio-offset-y"; + const WIDTH_PROP = "--hf-studio-width"; + const HEIGHT_PROP = "--hf-studio-height"; + const ROTATION_PROP = "--hf-studio-rotation"; + const PATH_OFFSET_ATTR = "data-hf-studio-path-offset"; + const BOX_SIZE_ATTR = "data-hf-studio-box-size"; + const ROTATION_ATTR = "data-hf-studio-rotation"; + const ORIGINAL_TRANSLATE_ATTR = "data-hf-studio-original-translate"; + const ORIGINAL_ROTATE_ATTR = "data-hf-studio-original-rotate"; + const WRAPPED_SEEK_PROP = "__hfStudioManualEditsWrapped"; + const ROTATION_TRANSFORM_ORIGIN = "center center"; + + const finiteNumber = (value: unknown): number | null => + typeof value === "number" && Number.isFinite(value) ? value : null; + + const objectRecord = (value: unknown): Record | null => + value && typeof value === "object" ? (value as Record) : null; + + const runtimeWindow = window as Window & { + __hf?: { seek?: (time: number) => unknown }; + __hfStudioManualEditsApply?: () => number; + __player?: { renderSeek?: (time: number) => unknown }; + }; + + const parsedManifest = (() => { + try { + return objectRecord(JSON.parse(manifestContent)); + } catch { + return null; + } + })(); + const manifestEdits = Array.isArray(parsedManifest?.edits) ? parsedManifest.edits : []; + if (manifestEdits.length === 0) return; + + const sourceFileForElement = (element: HTMLElement): string => { + let current: HTMLElement | null = element; + while (current) { + const sourceFile = + current.getAttribute("data-composition-file") ?? + current.getAttribute("data-composition-src"); + if (sourceFile) return sourceFile; + current = current.parentElement; + } + return activeCompositionPath ?? "index.html"; + }; + + const elementMatchesSourceFile = (element: HTMLElement, sourceFile: string): boolean => + sourceFileForElement(element) === sourceFile; + + const styleUsesStudioOffset = (value: string): boolean => + value.includes(OFFSET_X_PROP) || value.includes(OFFSET_Y_PROP); + + const styleUsesStudioRotation = (value: string): boolean => value.includes(ROTATION_PROP); + + const splitTopLevelWhitespace = (value: string): string[] => { + const parts: string[] = []; + let depth = 0; + let current = ""; + for (const char of value.trim()) { + if (char === "(") depth += 1; + if (char === ")") depth = Math.max(0, depth - 1); + if (/\s/.test(char) && depth === 0) { + if (current) parts.push(current); + current = ""; + } else { + current += char; + } + } + if (current) parts.push(current); + return parts; + }; + + const composeTranslate = (element: HTMLElement, x: string, y: string): string => { + const original = element.getAttribute(ORIGINAL_TRANSLATE_ATTR)?.trim(); + if (!original || original === "none") return `${x} ${y}`; + + const parts = splitTopLevelWhitespace(original); + if (parts.length === 1) return `calc(${parts[0]} + ${x}) ${y}`; + if (parts.length === 2) return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y})`; + if (parts.length === 3) { + return `calc(${parts[0]} + ${x}) calc(${parts[1]} + ${y}) ${parts[2]}`; + } + return `${x} ${y}`; + }; + + const readStyleOrComputed = (element: HTMLElement, property: string): string => { + try { + return ( + element.style.getPropertyValue(property) || + getComputedStyle(element).getPropertyValue(property) + ); + } catch { + return element.style.getPropertyValue(property); + } + }; + + const readTransformLonghandBase = ( + element: HTMLElement, + property: "translate" | "rotate", + ): string => { + const value = readStyleOrComputed(element, property).trim(); + return value === "none" ? "" : value; + }; + + const preparePathOffsetBase = (element: HTMLElement): void => { + const currentTranslate = readTransformLonghandBase(element, "translate"); + const hasMarker = element.hasAttribute(PATH_OFFSET_ATTR); + const wasResetByAnimation = !styleUsesStudioOffset(currentTranslate); + if (!hasMarker) { + element.setAttribute(ORIGINAL_TRANSLATE_ATTR, wasResetByAnimation ? currentTranslate : ""); + } else if (wasResetByAnimation) { + element.setAttribute(ORIGINAL_TRANSLATE_ATTR, currentTranslate); + } + }; + + const prepareRotationBase = (element: HTMLElement): void => { + const currentRotate = readTransformLonghandBase(element, "rotate"); + const hasMarker = element.hasAttribute(ROTATION_ATTR); + const wasResetByAnimation = !styleUsesStudioRotation(currentRotate); + if (!hasMarker) { + element.setAttribute(ORIGINAL_ROTATE_ATTR, wasResetByAnimation ? currentRotate : ""); + } else if (wasResetByAnimation) { + element.setAttribute(ORIGINAL_ROTATE_ATTR, currentRotate); + } + }; + + const querySelectorCandidates = (selector: string): HTMLElement[] => { + const isCandidate = (element: Element): element is HTMLElement => + element instanceof HTMLElement; + + const className = selector.match(/^\.([A-Za-z0-9_-]+)$/)?.[1]; + if (className) { + return Array.from(document.getElementsByTagName("*")).filter( + (element): element is HTMLElement => + isCandidate(element) && element.classList.contains(className), + ); + } + + if (/^[A-Za-z][A-Za-z0-9-]*$/.test(selector)) { + return Array.from(document.getElementsByTagName(selector)).filter(isCandidate); + } + + return Array.from(document.querySelectorAll(selector)).filter(isCandidate); + }; + + const resolveTarget = (edit: Record): HTMLElement | null => { + const targetRecord = objectRecord(edit.target); + if (!targetRecord) return null; + + const sourceFile = typeof targetRecord.sourceFile === "string" ? targetRecord.sourceFile : ""; + if (!sourceFile) return null; + + const id = typeof targetRecord.id === "string" ? targetRecord.id : ""; + if (id) { + const byId = document.getElementById(id); + if (byId instanceof HTMLElement && elementMatchesSourceFile(byId, sourceFile)) return byId; + + const matchesById = [ + document.documentElement, + ...Array.from(document.getElementsByTagName("*")), + ].filter( + (element): element is HTMLElement => + element instanceof HTMLElement && + element.id === id && + elementMatchesSourceFile(element, sourceFile), + ); + if (matchesById[0]) return matchesById[0]; + } + + const selector = typeof targetRecord.selector === "string" ? targetRecord.selector : ""; + if (!selector) return null; + + try { + const matches = querySelectorCandidates(selector).filter((element) => + elementMatchesSourceFile(element, sourceFile), + ); + const selectorIndex = finiteNumber(targetRecord.selectorIndex) ?? 0; + return matches[Math.max(0, Math.floor(selectorIndex))] ?? null; + } catch { + return null; + } + }; + + const roundRotationAngle = (angle: number): number => Math.round(angle * 10) / 10; + + const isSimpleRotateAngle = (value: string): boolean => + /^-?(?:\d+(?:\.\d+)?|\.\d+)(?:deg|rad|turn|grad)$/.test(value.trim()); + + const composeRotation = (element: HTMLElement, rotationValue: string): string => { + const original = element.getAttribute(ORIGINAL_ROTATE_ATTR)?.trim(); + if (!original || original === "none" || !isSimpleRotateAngle(original)) { + return rotationValue; + } + return `calc(${original} + ${rotationValue})`; + }; + + const applyPathOffset = (element: HTMLElement, edit: Record): void => { + const x = finiteNumber(edit.x); + const y = finiteNumber(edit.y); + if (x == null || y == null) return; + preparePathOffsetBase(element); + element.setAttribute(PATH_OFFSET_ATTR, "true"); + element.style.setProperty(OFFSET_X_PROP, `${Math.round(x)}px`); + element.style.setProperty(OFFSET_Y_PROP, `${Math.round(y)}px`); + element.style.setProperty( + "translate", + composeTranslate(element, `var(${OFFSET_X_PROP}, 0px)`, `var(${OFFSET_Y_PROP}, 0px)`), + ); + }; + + const readParentFlexBasisPixels = ( + element: HTMLElement, + size: { width: number; height: number }, + ): number | null => { + const parent = element.parentElement; + if (!parent) return null; + const styles = getComputedStyle(parent); + if (styles.display !== "flex" && styles.display !== "inline-flex") return null; + return Math.round( + Math.max(1, styles.flexDirection.startsWith("column") ? size.height : size.width), + ); + }; + + const applyBoxSize = (element: HTMLElement, edit: Record): void => { + const width = finiteNumber(edit.width); + const height = finiteNumber(edit.height); + if (width == null || height == null || width <= 0 || height <= 0) return; + + const rounded = { + width: Math.round(Math.max(1, width)), + height: Math.round(Math.max(1, height)), + }; + element.setAttribute(BOX_SIZE_ATTR, "true"); + element.style.setProperty(WIDTH_PROP, `${rounded.width}px`); + element.style.setProperty(HEIGHT_PROP, `${rounded.height}px`); + element.style.setProperty("box-sizing", "border-box"); + element.style.setProperty("width", `${rounded.width}px`); + element.style.setProperty("height", `${rounded.height}px`); + element.style.setProperty("min-width", "0px"); + element.style.setProperty("min-height", "0px"); + element.style.setProperty("max-width", "none"); + element.style.setProperty("max-height", "none"); + + const flexBasis = readParentFlexBasisPixels(element, rounded); + if (flexBasis != null) { + element.style.setProperty("flex-basis", `${flexBasis}px`); + element.style.setProperty("flex-grow", "0"); + element.style.setProperty("flex-shrink", "0"); + } + if (getComputedStyle(element).display === "inline") { + element.style.setProperty("display", "inline-block"); + } + }; + + const applyRotation = (element: HTMLElement, edit: Record): void => { + const angle = finiteNumber(edit.angle); + if (angle == null) return; + prepareRotationBase(element); + element.setAttribute(ROTATION_ATTR, "true"); + element.style.setProperty(ROTATION_PROP, `${roundRotationAngle(angle)}deg`); + element.style.setProperty("transform-origin", ROTATION_TRANSFORM_ORIGIN); + element.style.setProperty("rotate", composeRotation(element, `var(${ROTATION_PROP}, 0deg)`)); + }; + + const applyManifest = (): number => { + let applied = 0; + for (const edit of manifestEdits) { + const editRecord = objectRecord(edit); + if (!editRecord) continue; + const element = resolveTarget(editRecord); + if (!element) continue; + if (editRecord.kind === "path-offset") applyPathOffset(element, editRecord); + if (editRecord.kind === "box-size") applyBoxSize(element, editRecord); + if (editRecord.kind === "rotation") applyRotation(element, editRecord); + applied += 1; + } + return applied; + }; + runtimeWindow.__hfStudioManualEditsApply = applyManifest; + + const markWrapped = (fn: (time: number) => unknown): void => { + try { + Object.defineProperty(fn, WRAPPED_SEEK_PROP, { + configurable: false, + enumerable: false, + value: true, + }); + } catch { + try { + (fn as unknown as Record)[WRAPPED_SEEK_PROP] = true; + } catch { + // Ignore non-extensible functions. + } + } + }; + + const isWrapped = (fn: (time: number) => unknown): boolean => + Boolean((fn as unknown as Record)[WRAPPED_SEEK_PROP]); + + const wrapFunction = ( + get: () => ((time: number) => unknown) | undefined, + set: (fn: (time: number) => unknown) => void, + ): boolean => { + const fn = get(); + if (!fn) return false; + const seek = fn as (time: number) => unknown; + if (isWrapped(seek)) { + applyManifest(); + return true; + } + + const wrappedSeek = function (this: unknown, time: number): unknown { + const result = seek.call(this, time); + applyManifest(); + return result; + }; + markWrapped(wrappedSeek); + set(wrappedSeek); + applyManifest(); + return true; + }; + + const wrapSeekFunctions = (): boolean => { + const wrappedHfSeek = wrapFunction( + () => runtimeWindow.__hf?.seek, + (fn) => { + if (runtimeWindow.__hf) runtimeWindow.__hf.seek = fn; + }, + ); + const wrappedPlayerRenderSeek = wrapFunction( + () => runtimeWindow.__player?.renderSeek, + (fn) => { + if (runtimeWindow.__player) runtimeWindow.__player.renderSeek = fn; + }, + ); + return wrappedHfSeek || wrappedPlayerRenderSeek; + }; + + if (document.readyState === "loading") { + document.addEventListener("DOMContentLoaded", () => applyManifest(), { once: true }); + } else { + applyManifest(); + } + + wrapSeekFunctions(); + let remainingSeekWrapAttempts = 120; + const seekWrapInterval = setInterval(() => { + wrapSeekFunctions(); + remainingSeekWrapAttempts -= 1; + if (remainingSeekWrapAttempts <= 0) clearInterval(seekWrapInterval); + }, 50); +} diff --git a/packages/core/src/studio-api/helpers/projectSignature.ts b/packages/core/src/studio-api/helpers/projectSignature.ts index dc5fd4a48..4062a1f14 100644 --- a/packages/core/src/studio-api/helpers/projectSignature.ts +++ b/packages/core/src/studio-api/helpers/projectSignature.ts @@ -28,6 +28,10 @@ const SIGNATURE_EXCLUDED_DIRS = new Set([ "renders", ]); const MAX_SIGNATURE_TEXT_BYTES = 2_000_000; +const STUDIO_SIGNATURE_MANIFEST_PATHS = [ + ".hyperframes/studio-manual-edits.json", + ".hyperframes/studio-motion.json", +] as const; interface ProjectSignatureFile { file: string; @@ -93,6 +97,31 @@ function collectProjectSignatureFiles( } } +function collectProjectSignatureManifestFiles( + projectDir: string, + files: ProjectSignatureFile[], +): void { + const seen = new Set(files.map((entry) => entry.file)); + for (const manifestPath of STUDIO_SIGNATURE_MANIFEST_PATHS) { + const file = resolve(projectDir, manifestPath); + if (seen.has(file) || !isPathWithin(projectDir, file)) continue; + let stat: ReturnType; + try { + stat = lstatSync(file); + } catch { + continue; + } + if (stat.isSymbolicLink() || !stat.isFile()) continue; + files.push({ + file, + mtimeMs: stat.mtimeMs, + size: stat.size, + textContentEligible: isTextContentEligible(file, stat.size), + }); + seen.add(file); + } +} + function createProjectFingerprint(projectDir: string, files: ProjectSignatureFile[]): string { const hash = createHash("sha256"); for (const entry of files) { @@ -108,10 +137,14 @@ function createProjectFingerprint(projectDir: string, files: ProjectSignatureFil return hash.digest("hex").slice(0, 24); } +/** + * Creates a stable preview cache-busting signature for project source plus Studio manifests. + */ export function createProjectSignature(projectDir: string): string { const normalizedProjectDir = resolve(projectDir); const files: ProjectSignatureFile[] = []; collectProjectSignatureFiles(normalizedProjectDir, normalizedProjectDir, files); + collectProjectSignatureManifestFiles(normalizedProjectDir, files); files.sort((a, b) => a.file.localeCompare(b.file)); const fingerprint = createProjectFingerprint(normalizedProjectDir, files); diff --git a/packages/core/src/studio-api/helpers/screenshotClip.ts b/packages/core/src/studio-api/helpers/screenshotClip.ts index 715132daf..a1db59033 100644 --- a/packages/core/src/studio-api/helpers/screenshotClip.ts +++ b/packages/core/src/studio-api/helpers/screenshotClip.ts @@ -5,8 +5,15 @@ export interface ScreenshotClip { height: number; } -export function getElementScreenshotClip(selector: string): ScreenshotClip | undefined { - const el = document.querySelector(selector); +export function getElementScreenshotClip( + selector: string, + selectorIndex?: number, +): ScreenshotClip | 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; diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index cefda05ce..2be500ebd 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -17,6 +17,20 @@ function parseSourceDocument(source: string): { document: Document; wrappedFragm }; } +function querySelectorAllWithTemplates(root: Document | Element, selector: string): Element[] { + const matches = Array.from(root.querySelectorAll(selector)); + if (matches.length > 0) return matches; + // querySelectorAll doesn't traverse