Merge remote-tracking branch 'origin/main' into docs/pages-show-not-tell

This commit is contained in:
ukimsanov
2026-08-05 05:56:59 -07:00
138 changed files with 9656 additions and 1193 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "hyperframes",
"description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.7.92",
"version": "0.7.94",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "hyperframes",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.7.92",
"version": "0.7.94",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com",
+1 -1
View File
@@ -3,7 +3,7 @@
"name": "hyperframes",
"displayName": "HyperFrames by HeyGen",
"description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames.",
"version": "0.7.92",
"version": "0.7.94",
"author": {
"name": "HeyGen",
"email": "hyperframes@heygen.com"
+11
View File
@@ -162,6 +162,17 @@
"produceDrawElementFrame",
],
},
// External-conflict persistence is the #2990 stack boundary. The coordinator
// consumes these exports in child PR #2991; keep the primitive independently reviewable.
{
"file": "packages/studio/src/utils/externalConflictStorage.ts",
"exports": [
"persistExternalConflictSnapshot",
"persistExternalFailureSnapshot",
"loadExternalConflictSnapshot",
"deleteExternalConflictSnapshot",
],
},
// CLI command files: every command exports a const `examples` per the
// convention documented in CLAUDE.md. This is a namespace barrel, not a
// collision.
+14 -14
View File
@@ -129,7 +129,7 @@ jobs:
- run: corepack enable
- run: corepack prepare pnpm@10.17.1 --activate
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run build
- run: bun run verify:packed-manifests
@@ -148,7 +148,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run lint
# `fallow audit` runs dead-code + complexity + duplication analysis scoped to
@@ -182,7 +182,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- name: Run fallow audit
id: audit
# `bun install` above made `bunx fallow` resolve from node_modules, so
@@ -238,7 +238,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run format:check
typecheck:
@@ -256,7 +256,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run build
- run: bun run --filter '*' typecheck
@@ -283,7 +283,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run test:scripts
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
- run: bun run --cwd packages/core build
@@ -314,7 +314,7 @@ jobs:
sudo apt-get update -qq
sudo apt-get install -y --no-install-recommends ffmpeg
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
- run: bun run --cwd packages/core build
- run: bun run --filter @hyperframes/engine build
@@ -422,7 +422,7 @@ jobs:
node-version: 22
- name: Install dependencies
if: runner.os != 'Windows'
run: bun install --frozen-lockfile --ignore-scripts
run: bash scripts/ci/install-workspace-dependencies.sh --ignore-scripts
- name: Install dependencies
if: runner.os == 'Windows'
run: bun install --frozen-lockfile --ignore-scripts --linker=hoisted
@@ -437,7 +437,7 @@ jobs:
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
# Build workspace deps so the sdk's @hyperframes/parsers + core subpath
# imports resolve via the "node" export condition (dist) under vitest.
- run: bun run --filter '@hyperframes/parsers' build
@@ -459,7 +459,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
# Runtime coverage now imports core modules that consume workspace
# subpaths. Build their dist exports before Vitest resolves them.
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
@@ -480,7 +480,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
# Build workspace deps so the studio vite.config.ts (loaded by Node) can
# resolve @hyperframes/core and @hyperframes/studio-server via the "node"
# export condition (dist).
@@ -523,7 +523,7 @@ jobs:
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: 22
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
# Same reason as studio-load-smoke: vite.config.ts is loaded by Node and
# resolves the workspace packages through their "node" export condition.
- run: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build
@@ -634,7 +634,7 @@ jobs:
with:
node-version: 22
- uses: ./.github/actions/prepare-ffmpeg-bin
- run: bun install --frozen-lockfile
- run: bash scripts/ci/install-workspace-dependencies.sh
- run: bun run build
# Pack the CLI as a tarball (simulates what `npm publish` produces)
@@ -711,7 +711,7 @@ jobs:
sudo apt-get install -y ffmpeg
- uses: ./.github/actions/prepare-ffmpeg-bin
- name: Install dependencies
run: bun install --frozen-lockfile
run: bash scripts/ci/install-workspace-dependencies.sh
- name: Build monorepo
run: bun run build
+3
View File
@@ -343,6 +343,7 @@
"@types/react-dom": "19",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"fake-indexeddb": "^6.2.5",
"postcss": "^8.4.0",
"puppeteer-core": "^25.2.1",
"tailwindcss": "^3.4.0",
@@ -1487,6 +1488,8 @@
"extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
"fake-indexeddb": ["fake-indexeddb@6.2.5", "", {}, "sha512-CGnyrvbhPlWYMngksqrSSUT1BAVP49dZocrHuK0SvtR0D5TMs5wP0o3j7jexDJW01KSadjBp1M/71o/KR3nD1w=="],
"fallow": ["fallow@2.75.0", "", { "dependencies": { "detect-libc": "2.1.2" }, "optionalDependencies": { "@fallow-cli/darwin-arm64": "2.75.0", "@fallow-cli/darwin-x64": "2.75.0", "@fallow-cli/linux-arm64-gnu": "2.75.0", "@fallow-cli/linux-arm64-musl": "2.75.0", "@fallow-cli/linux-x64-gnu": "2.75.0", "@fallow-cli/linux-x64-musl": "2.75.0", "@fallow-cli/win32-arm64-msvc": "2.75.0", "@fallow-cli/win32-x64-msvc": "2.75.0" }, "bin": { "fallow": "bin/fallow", "fallow-lsp": "bin/fallow-lsp", "fallow-mcp": "bin/fallow-mcp" } }, "sha512-0/2cquNI/cDLP/LzcCbkwI4hMzkX4tE0VY3/69n3PBBeqFpbM2oai+2Cb0sB8dXB8MDUGPVoPJjDW5GiUo7a1A=="],
"fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
+70
View File
@@ -8,6 +8,76 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio
{/* New release entries are prepended by `bun run changelog:draft <version> --write`. */}
<Update
label="HyperFrames v0.7.94"
description="Released - 2026-08-05"
tags={["Release", "CLI"]}
>
The CLI now supports explicit RFC 8628 device authorization for attended SSH
and remote-terminal sessions, so users can sign in without copying API keys or
exposing a loopback callback. The flow is fail-closed, validates minted tokens
before atomic persistence, and leaves the existing browser login unchanged.
## Features
- **CLI:** Add device authorization login ([b9233525b](https://github.com/heygen-com/hyperframes/commit/b9233525b2e02ec9329445ab2602ebd557033daf), [#2836](https://github.com/heygen-com/hyperframes/pull/2836))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.93...v0.7.94).
</Update>
<Update
label="HyperFrames v0.7.93"
description="Released - 2026-08-05"
tags={["Release", "Engine", "Studio", "Parsers"]}
>
Studio now preserves and recovers external file edits instead of silently
overwriting them, while logical timeline navigation makes keyboard control more
predictable. The engine and producer also harden remote downloads, ffprobe
invocation, telemetry path redaction, GPU verification, and cross-frame-rate
BeginFrame timing.
## Features
- **Studio:** Model logical timeline navigation ([0f1333c80](https://github.com/heygen-com/hyperframes/commit/0f1333c80d3c18eddf8af69110168cc910d14fec), [#2712](https://github.com/heygen-com/hyperframes/pull/2712))
- **Studio:** Add external conflict recovery UI ([8a0dccfc7](https://github.com/heygen-com/hyperframes/commit/8a0dccfc72224d993efdb0ae8d95fc0e020327d4), [#2992](https://github.com/heygen-com/hyperframes/pull/2992))
- **Studio:** Coordinate external file changes ([a99caad58](https://github.com/heygen-com/hyperframes/commit/a99caad581496f2ad8e132b750ced73be8cf3e51), [#2991](https://github.com/heygen-com/hyperframes/pull/2991))
- **Studio:** Preserve external file conflicts ([b30a23402](https://github.com/heygen-com/hyperframes/commit/b30a23402e2db19b43cb0283e1553048746fb8bb), [#2990](https://github.com/heygen-com/hyperframes/pull/2990))
## Fixes
- **Engine:** Keep BeginFrame time monotonic across frame rates ([b390b71bd](https://github.com/heygen-com/hyperframes/commit/b390b71bde982ffbf55c9ec93f1491d99011bfc3), [#3011](https://github.com/heygen-com/hyperframes/pull/3011))
- **Engine:** Validate remote download integrity ([bbdfee116](https://github.com/heygen-com/hyperframes/commit/bbdfee1166c45e348ef76d2e384a0ad2243ac847), [#2938](https://github.com/heygen-com/hyperframes/pull/2938))
- **Studio:** Reconcile external edits before reload ([ebdd1893c](https://github.com/heygen-com/hyperframes/commit/ebdd1893c4a0d86b984fb627fed0283a981d2713), [#2993](https://github.com/heygen-com/hyperframes/pull/2993))
- **Studio:** Drain pending edits before reload ([471313854](https://github.com/heygen-com/hyperframes/commit/47131385445c0f8ee4203f8d6ff4f5600d8c8b2e), [#2989](https://github.com/heygen-com/hyperframes/pull/2989))
- **Studio:** Make inspector commits transactional ([552419c52](https://github.com/heygen-com/hyperframes/commit/552419c52d16fa5a5e8d15a9263bc5f41e596360), [#2987](https://github.com/heygen-com/hyperframes/pull/2987))
- **Engine:** Distinguish probe failure from a genuinely absent GPU ([f69c4a0e3](https://github.com/heygen-com/hyperframes/commit/f69c4a0e3aa8feb1110b52d538f8ca127481c95d))
- **Studio:** Respect GSAP transform ownership ([cf45c9845](https://github.com/heygen-com/hyperframes/commit/cf45c984549bc41ed4debc99d5283d2a60108cbb), [#2986](https://github.com/heygen-com/hyperframes/pull/2986))
- **Parsers:** Preserve safe GSAP helper defaults ([532f06158](https://github.com/heygen-com/hyperframes/commit/532f06158b3a930b85a289ab0608d720311cd5a1), [#2985](https://github.com/heygen-com/hyperframes/pull/2985))
- **Engine:** Warn once per process about unverified hardware GPU ([6703ea7e0](https://github.com/heygen-com/hyperframes/commit/6703ea7e04701b490c5fb563db4f532fbc8aec89))
- **Engine:** Verify explicit browserGpuMode=hardware instead of trusting it ([131780fe9](https://github.com/heygen-com/hyperframes/commit/131780fe9627ee7fb17cb871ef6a144a6ef02735))
- **Core,producer,skills:** Unicode paths, non-Error rejections, shell callers ([1664fe6ad](https://github.com/heygen-com/hyperframes/commit/1664fe6ad773c76e0dc7314e7f2eb439cd3687d6))
- **Producer:** Drop the ReDoS-prone literal-argv regex for a linear scan ([6c5403f7c](https://github.com/heygen-com/hyperframes/commit/6c5403f7cd5e5b21f301b88d6d9f84451751ba15))
- **Skills,producer:** Terminate ffprobe options in shipped skill scripts ([255cf9291](https://github.com/heygen-com/hyperframes/commit/255cf929150977fa57dc1cadde2e9cf626f5c20c))
- **Core,producer:** Redact bare relative paths and the known input path ([e79ab3ab3](https://github.com/heygen-com/hyperframes/commit/e79ab3ab31a5f789f8c998430f2b58cb95973f07))
- **Core:** Redact any path in telemetry, not an allowlist of roots ([d04569e37](https://github.com/heygen-com/hyperframes/commit/d04569e37f1c63967ce7373557ded4e4f150e7de))
- **Producer,studio Server:** Finish the ffprobe argv sweep, pin the contract ([d0dbf11ef](https://github.com/heygen-com/hyperframes/commit/d0dbf11ef53dd7ca535ed40a10e8f2dad4f12c4d))
- **Cli,core,lint,producer:** Terminate ffprobe options at every call site ([47564ab94](https://github.com/heygen-com/hyperframes/commit/47564ab94cc4bac5de6715169939353c211af689))
## Docs & Examples
- **Studio:** Document timeline keyboard navigation ([7bf425b7a](https://github.com/heygen-com/hyperframes/commit/7bf425b7a90c5a3c8154c66025261ff9e52c8c72), [#3031](https://github.com/heygen-com/hyperframes/pull/3031))
## Internal
- **Studio:** Relax large fixture timeout ([bce2140ff](https://github.com/heygen-com/hyperframes/commit/bce2140ff208e16f83ee0854bca8baf85ad7216e), [#3040](https://github.com/heygen-com/hyperframes/pull/3040))
- **Studio:** Extract live timeline clock ([19b8a1f3c](https://github.com/heygen-com/hyperframes/commit/19b8a1f3c5a55c30b5aa367c9054ca1b31552536), [#2711](https://github.com/heygen-com/hyperframes/pull/2711))
- **Studio:** Pin text-field Backspace routing ([cde5bae4c](https://github.com/heygen-com/hyperframes/commit/cde5bae4c5a6afa167faf827ab1073e66964cd35), [#2988](https://github.com/heygen-com/hyperframes/pull/2988))
- **Producer:** Treat an all-literal probe argv as taking no input ([c81f68b59](https://github.com/heygen-com/hyperframes/commit/c81f68b59225d0acd34acbc419e408125fa51e36))
- **Producer:** Discover ffprobe callers and pin terminator position ([91a7cb1f5](https://github.com/heygen-com/hyperframes/commit/91a7cb1f5b7f9a4bb539384fb50645a8feab3f29))
[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.92...v0.7.93).
</Update>
<Update
label="HyperFrames v0.7.92"
description="Released - 2026-08-04"
+7 -1
View File
@@ -24,7 +24,13 @@ hyperframes cloud render . \
--output renders/intro.mp4
```
For CI or another headless environment, save a long-lived API key instead:
From an attended SSH or headless terminal, use the device flow. Open the displayed URL in any browser and enter the one-time code:
```bash
hyperframes auth login --device
```
Device login requires a TTY and is refused in CI. For unattended agents and CI, save a long-lived API key instead:
```bash
echo "$HEYGEN_API_KEY" | hyperframes auth login --api-key
+18
View File
@@ -42,6 +42,24 @@ canvas element, and step through frames when nothing nudgeable is selected. `J`
and `K` work the same way: with keyframes selected they move between keyframes
and add one, and otherwise they drive playback.
## Timeline navigation
Tab into the timeline before using these commands. Studio keeps one logical timeline target in the Tab order and moves that target without requiring every row or clip to stay mounted.
| Shortcut | Action |
| --- | --- |
| Left / Right arrow | Move to the previous or next target in the focused row |
| Up / Down arrow | Move to the nearest target at the same time in the previous or next logical row |
| Home / End | Move to the start or end of the focused row |
| Command/Ctrl + Home / End | Move to the first or last logical row |
| Page Up / Page Down | Move by one visible page of logical rows |
| Enter / Space | Expand or collapse keyframe property lanes on a focused track row |
| Context Menu or Shift + F10 | Open the focused target's context menu when available |
On an expandable track row, Right arrow expands collapsed keyframe lanes and Left arrow collapses expanded lanes. From a keyframe property row, Left arrow returns to its parent track row. When no disclosure or parent action applies, those arrows continue moving within the row.
If the destination is outside the virtualized viewport, Studio scrolls it into view and restores focus after it mounts.
## Keyframes and recording
| Shortcut | Action |
+12
View File
@@ -49,6 +49,18 @@ Zoom in for keyframes and short edits. Zoom out to understand the complete seque
Use the playhead for the exact edit moment. Use playback and frame stepping to check what happens immediately before and after it.
Tab into the timeline to reach its current logical focus target. Use Left and Right to move within that row. Use Up and Down to move to the nearest target at the same time in the previous or next row.
Home and End move to the start or end of the focused row. Hold Command or Ctrl to move to the first or last logical row. Page Up and Page Down move by one visible page of rows.
On an expandable track row, Right expands collapsed keyframe property lanes and Left collapses expanded lanes. From a keyframe property row, Left returns to its parent track row. Enter or Space toggles the same lanes. Use the Context Menu key or Shift + F10 to open the focused target's menu when it has one.
The timeline keeps only one logical timeline target in the Tab order. Native header controls, such as visibility and keyframe actions, remain separate Tab stops. When the logical target is outside the virtualized viewport, Studio keeps it mounted, scrolls it into view, and then restores focus.
Screen readers receive the timeline as a treegrid. Track rows announce their level and expanded state. Clips and keyframes announce their names, times, and selection state. Easing controls announce their names and times.
See [Keyboard shortcuts](/studio/shortcuts#timeline-navigation) for the complete command table.
## Work with beats
Beat markers provide timing landmarks, especially for music-driven work. Snap edits to them when the audio should drive the cut; ignore them when the story or voiceover needs a different rhythm.
+1 -1
View File
@@ -48,7 +48,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
"test:scripts": "node --import tsx --test scripts/check-tracked-artifacts.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
"generate:catalog-previews": "tsx scripts/generate-catalog-previews.ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/aws-lambda",
"version": "0.7.92",
"version": "0.7.94",
"description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/cli",
"version": "0.7.92",
"version": "0.7.94",
"description": "HyperFrames CLI — create, preview, and render HTML video compositions",
"license": "Apache-2.0",
"repository": {
+8
View File
@@ -9,6 +9,7 @@ export type AuthErrorCode =
| "API_ERROR"
| "UNAUTHENTICATED"
| "OAUTH_NOT_CONFIGURED"
| "DEVICE_AUTH_FAILED"
| "REFRESH_FAILED";
export class AuthError extends Error {
@@ -61,6 +62,13 @@ export const ErrRefreshFailed = (detail?: string) =>
"Run `hyperframes auth login` to re-authenticate.",
);
export const ErrDeviceAuthFailed = (detail: string) =>
new AuthError(
"DEVICE_AUTH_FAILED",
`Device authorization failed: ${detail}`,
"Run `hyperframes auth login --device` to start a new code.",
);
export function isAuthError(err: unknown): err is AuthError {
return err instanceof AuthError;
}
+2
View File
@@ -33,7 +33,9 @@ export type { UserInfo } from "./client.js";
export {
assertOAuthConfiguredOrExit,
persistVerifiedOAuthSession,
refreshTokens,
revokeTokens,
startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
} from "./oauth.js";
+396 -41
View File
@@ -4,10 +4,13 @@ import { setupTempAuthEnv } from "./_test-utils.js";
import { isAuthError } from "./errors.js";
import {
parseTokenResponse,
persistFreshOAuth,
persistVerifiedOAuthSession,
refreshTokens,
resolveClientId,
revokeTokens,
startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
} from "./oauth.js";
import { readStore, writeStore } from "./store.js";
@@ -26,6 +29,45 @@ vi.mock("./browser.js", () => ({
openBrowser: vi.fn(async () => ({ opened: true })),
}));
function tokenFetch(body: Record<string, unknown>): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
}
function deviceAuthorizationResponse(overrides: Record<string, unknown> = {}): Response {
return new Response(
JSON.stringify({
device_code: "secret-device-code",
user_code: "ABCD-2345",
verification_uri: "https://app.heygen.com/oauth/device",
expires_in: 600,
interval: 5,
...overrides,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
function queuedFetch(
responses: Response[],
onRequest?: (url: string | URL | Request, init?: RequestInit) => void,
): typeof fetch {
return (async (url: string | URL | Request, init?: RequestInit) => {
onRequest?.(url, init);
const next = responses.shift();
if (!next) throw new Error("unexpected fetch");
return next;
}) as typeof fetch;
}
async function loginWithTokens(body: Record<string, unknown>) {
await startAuthorizationCodeFlow({ fetchImpl: tokenFetch(body) });
return (await readStore()).credentials;
}
describe("auth/oauth", () => {
let fixture: Awaited<ReturnType<typeof setupTempAuthEnv>>;
@@ -70,7 +112,10 @@ describe("auth/oauth", () => {
});
it("accepts expires_in as a string (some servers serialize as string)", () => {
const tokens = parseTokenResponse({ access_token: "at", expires_in: "1800" });
const tokens = parseTokenResponse({
access_token: "at",
expires_in: "1800",
});
expect(tokens.expires_at).toBeDefined();
});
@@ -103,7 +148,10 @@ describe("auth/oauth", () => {
it("clamps non-positive expires_in to avoid an immediate-refresh loop", () => {
const zero = parseTokenResponse({ access_token: "at", expires_in: 0 });
const negative = parseTokenResponse({ access_token: "at", expires_in: -100 });
const negative = parseTokenResponse({
access_token: "at",
expires_in: -100,
});
// both should resolve to a future time
expect(new Date(zero.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
expect(new Date(negative.expires_at!).getTime()).toBeGreaterThan(Date.now() + 25 * 1000);
@@ -158,11 +206,7 @@ describe("auth/oauth", () => {
expires_at: "2026-01-01T00:00:00Z",
},
});
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
await refreshTokens("keep_me_rt", { fetchImpl });
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
@@ -172,11 +216,7 @@ describe("auth/oauth", () => {
it("preserves an existing api_key when persisting refreshed oauth", async () => {
await writeStore({ api_key: "hg_keep" });
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: "new_at", expires_in: 60 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 60 });
await refreshTokens("old_rt", { fetchImpl });
const { credentials } = await readStore();
expect(credentials.api_key).toBe("hg_keep");
@@ -202,11 +242,7 @@ describe("auth/oauth", () => {
}),
{ mode: 0o600 },
);
const fetchImpl = (async () =>
new Response(JSON.stringify({ access_token: "new_at", expires_in: 3600 }), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
await refreshTokens("keep_me_rt", { fetchImpl });
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
@@ -217,7 +253,9 @@ describe("auth/oauth", () => {
it("throws REFRESH_FAILED on 400/401", async () => {
const fetchImpl = (async () =>
new Response("invalid_grant", { status: 400 })) as unknown as typeof fetch;
new Response("invalid_grant", {
status: 400,
})) as unknown as typeof fetch;
await expect(refreshTokens("bad_rt", { fetchImpl })).rejects.toSatisfy((err) => {
return isAuthError(err) && (err as { code: string }).code === "REFRESH_FAILED";
});
@@ -261,7 +299,10 @@ describe("auth/oauth", () => {
capturedBody = init?.body as string;
return new Response("", { status: 200 });
}) as unknown as typeof fetch;
await revokeTokens("tok", { fetchImpl, token_type_hint: "refresh_token" });
await revokeTokens("tok", {
fetchImpl,
token_type_hint: "refresh_token",
});
expect(capturedBody).toContain("token_type_hint=refresh_token");
});
@@ -296,24 +337,19 @@ describe("auth/oauth", () => {
});
describe("startAuthorizationCodeFlow persistence", () => {
function tokenFetch(body: Record<string, unknown>): typeof fetch {
return (async () =>
new Response(JSON.stringify(body), {
status: 200,
headers: { "content-type": "application/json" },
})) as unknown as typeof fetch;
}
it("overwrites the OAuth block on fresh login (no inherited refresh_token)", async () => {
// Pre-seed a prior session whose refresh_token must NOT leak into
// the new login when the new response omits one.
await writeStore({
oauth: { access_token: "old_at", refresh_token: "OLD_rt_should_not_survive" },
oauth: {
access_token: "old_at",
refresh_token: "OLD_rt_should_not_survive",
},
});
const credentials = await loginWithTokens({
access_token: "new_at",
expires_in: 3600,
});
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
await startAuthorizationCodeFlow({ fetchImpl });
const { credentials } = await readStore();
expect(credentials.oauth?.access_token).toBe("new_at");
// Fresh login is a clean session — the old refresh_token is gone.
expect(credentials.oauth?.refresh_token).toBeUndefined();
@@ -321,10 +357,10 @@ describe("auth/oauth", () => {
it("preserves a co-located api_key across fresh login", async () => {
await writeStore({ api_key: "hg_keep_me" });
const fetchImpl = tokenFetch({ access_token: "new_at", refresh_token: "new_rt" });
await startAuthorizationCodeFlow({ fetchImpl });
const { credentials } = await readStore();
const credentials = await loginWithTokens({
access_token: "new_at",
refresh_token: "new_rt",
});
expect(credentials.api_key).toBe("hg_keep_me");
expect(credentials.oauth?.access_token).toBe("new_at");
expect(credentials.oauth?.refresh_token).toBe("new_rt");
@@ -344,16 +380,335 @@ describe("auth/oauth", () => {
}),
{ mode: 0o600 },
);
const fetchImpl = tokenFetch({ access_token: "new_at", expires_in: 3600 });
await startAuthorizationCodeFlow({ fetchImpl });
const { credentials } = await readStore();
const credentials = await loginWithTokens({
access_token: "new_at",
expires_in: 3600,
});
expect(credentials.oauth?.access_token).toBe("new_at");
expect(credentials.user).toEqual({ email: "jane@example.com", username: "jdoe" });
expect(credentials.user).toEqual({
email: "jane@example.com",
username: "jdoe",
});
// The unknown key is on a hidden slot — assert via the raw file.
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.future_field).toEqual({ keep: true });
});
});
describe("startDeviceAuthorizationFlow", () => {
it("atomically replaces OAuth and identity while preserving foreign fields", async () => {
const path = (await import("./paths.js")).credentialPath();
await fs.writeFile(
path,
JSON.stringify({
api_key: "hg_keep",
oauth: { access_token: "old-at", refresh_token: "old-rt" },
user: {
email: "old@example.com",
username: "old-user",
future_user_field: "keep-user",
},
future_root_field: { keep: true },
}),
{ mode: 0o600 },
);
await persistVerifiedOAuthSession(
{ access_token: "device-at", refresh_token: "device-rt" },
{ email: "new@example.com" },
);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.api_key).toBe("hg_keep");
expect(onDisk.oauth).toMatchObject({
access_token: "device-at",
refresh_token: "device-rt",
});
expect(onDisk.user).toEqual({
email: "new@example.com",
future_user_field: "keep-user",
});
expect(onDisk.future_root_field).toEqual({ keep: true });
});
it("recovers a corrupt credential file before installing a verified session", async () => {
const path = (await import("./paths.js")).credentialPath();
await fs.writeFile(path, "{not-json", { mode: 0o600 });
await persistVerifiedOAuthSession(
{ access_token: "device-at", refresh_token: "device-rt" },
{ email: "new@example.com" },
);
const onDisk = JSON.parse(await fs.readFile(path, "utf8"));
expect(onDisk.oauth).toMatchObject({
access_token: "device-at",
refresh_token: "device-rt",
});
expect(onDisk.user).toEqual({ email: "new@example.com" });
});
it("polls pending and slow_down responses without persisting before identity verification", async () => {
const requests: Array<{ url: string; body: URLSearchParams }> = [];
const responses = [
new Response(
JSON.stringify({
device_code: "secret-device-code",
user_code: "ABCD-2345",
verification_uri: "https://app.heygen.com/oauth/device",
expires_in: 600,
interval: 5,
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
new Response(JSON.stringify({ error: "authorization_pending" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
new Response(JSON.stringify({ error: "slow_down" }), {
status: 400,
headers: { "content-type": "application/json" },
}),
new Response(
JSON.stringify({
access_token: "device-at",
refresh_token: "device-rt",
token_type: "Bearer",
expires_in: 3600,
scope: "openid profile email",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
];
const fetchImpl = queuedFetch(responses, (url, init) => {
requests.push({
url: String(url),
body: new URLSearchParams(String(init?.body ?? "")),
});
});
const sleeps: number[] = [];
const challenges: Array<{ userCode: string; verificationUri: string }> = [];
const tokens = await startDeviceAuthorizationFlow({
fetchImpl,
sleepImpl: async (ms) => {
sleeps.push(ms);
},
now: () => 1_000,
onChallenge: (challenge) => {
challenges.push(challenge);
},
});
expect(tokens.access_token).toBe("device-at");
expect(challenges).toEqual([
{
userCode: "ABCD-2345",
verificationUri: "https://app.heygen.com/oauth/device",
},
]);
expect(sleeps).toEqual([5_000, 5_000, 10_000]);
expect(requests[0]?.body.get("client_id")).toBe(resolveClientId());
expect(requests[1]?.body.get("device_code")).toBe("secret-device-code");
expect(requests[1]?.body.get("grant_type")).toBe(
"urn:ietf:params:oauth:grant-type:device_code",
);
expect((await readStore()).source).toBe("absent");
await persistFreshOAuth(tokens);
expect((await readStore()).credentials.oauth?.access_token).toBe("device-at");
});
it("uses the RFC default interval and presents a safe complete verification URL", async () => {
const responses = [
deviceAuthorizationResponse({
interval: undefined,
verification_uri_complete: "https://app.heygen.com/oauth/device?user_code=ABCD-2345",
}),
new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }),
];
const fetchImpl = queuedFetch(responses);
const sleeps: number[] = [];
const challenges: Array<{
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
}> = [];
await startDeviceAuthorizationFlow({
fetchImpl,
sleepImpl: async (ms) => {
sleeps.push(ms);
},
now: () => 1_000,
onChallenge: (challenge) => {
challenges.push(challenge);
},
});
expect(sleeps).toEqual([5_000]);
expect(challenges).toEqual([
{
userCode: "ABCD-2345",
verificationUri: "https://app.heygen.com/oauth/device",
verificationUriComplete: "https://app.heygen.com/oauth/device?user_code=ABCD-2345",
},
]);
});
it("treats HTTP 429 without an OAuth body as slow_down and honors Retry-After", async () => {
const responses = [
deviceAuthorizationResponse(),
new Response("rate limited", { status: 429, headers: { "retry-after": "20" } }),
new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }),
];
const fetchImpl = queuedFetch(responses);
const sleeps: number[] = [];
await startDeviceAuthorizationFlow({
fetchImpl,
sleepImpl: async (ms) => {
sleeps.push(ms);
},
now: () => 1_000,
});
expect(sleeps).toEqual([5_000, 20_000]);
});
it("canonicalizes an internationalized verification host before displaying it", async () => {
const unicodeUri = "https://rаypal.example/oauth/device";
const responses = [
deviceAuthorizationResponse({ verification_uri: unicodeUri }),
new Response(JSON.stringify({ access_token: "device-at" }), { status: 200 }),
];
const fetchImpl = queuedFetch(responses);
const challenges: Array<{ verificationUri: string }> = [];
await startDeviceAuthorizationFlow({
fetchImpl,
sleepImpl: async () => {},
now: () => 1_000,
onChallenge: (challenge) => {
challenges.push(challenge);
},
});
expect(challenges[0]?.verificationUri).toBe(new URL(unicodeUri).href);
expect(challenges[0]?.verificationUri).not.toBe(unicodeUri);
});
it("times out while reading a slow authorization response body", async () => {
const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => {
const body = new ReadableStream<Uint8Array>({
start(controller) {
init?.signal?.addEventListener("abort", () => controller.error(new Error("aborted")), {
once: true,
});
},
});
return new Response(body, { status: 200 });
}) as typeof fetch;
await expect(
startDeviceAuthorizationFlow({ fetchImpl, requestTimeoutMs: 5 }),
).rejects.toThrow(/request timed out/);
});
it("times out a stalled token poll", async () => {
let call = 0;
const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => {
call += 1;
if (call === 1) {
return deviceAuthorizationResponse({ interval: undefined });
}
return await new Promise<Response>((_resolve, reject) => {
init?.signal?.addEventListener("abort", () => reject(new Error("aborted")), {
once: true,
});
});
}) as typeof fetch;
await expect(
startDeviceAuthorizationFlow({
fetchImpl,
sleepImpl: async () => {},
now: () => 1_000,
requestTimeoutMs: 5,
}),
).rejects.toThrow(/request timed out/);
});
it.each(["access_denied", "expired_token"])(
"fails with a bounded error for %s without echoing the device code",
async (oauthError) => {
const fetchImpl = (async (_url: string | URL | Request, init?: RequestInit) => {
const body = new URLSearchParams(String(init?.body ?? ""));
if (body.has("scope")) {
return new Response(
JSON.stringify({
device_code: "never-log-this-device-code",
user_code: "ABCD-2345",
verification_uri: "https://app.heygen.com/oauth/device",
expires_in: 600,
interval: 5,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response(
JSON.stringify({
error: oauthError,
error_description: "echo never-log-this-device-code",
}),
{ status: 400, headers: { "content-type": "application/json" } },
);
}) as typeof fetch;
await expect(
startDeviceAuthorizationFlow({
fetchImpl,
sleepImpl: async () => {},
now: () => 1_000,
}),
).rejects.not.toThrow(/never-log-this-device-code/);
expect((await readStore()).source).toBe("absent");
},
);
it.each([
["credential-bearing URL", "https://user:pass@app.heygen.com/oauth/device", 600, 5],
["control-character URL", "https://app.heygen.com/oauth/\u001b[31m", 600, 5],
["prefix-parsed expiry", "https://app.heygen.com/oauth/device", "600seconds", 5],
["prefix-parsed interval", "https://app.heygen.com/oauth/device", 600, "5seconds"],
])("rejects an unsafe or malformed %s response", async (_name, uri, expiresIn, interval) => {
const fetchImpl = (async () =>
new Response(
JSON.stringify({
device_code: "never-log-this-device-code",
user_code: "ABCD-2345",
verification_uri: uri,
expires_in: expiresIn,
interval,
}),
{ status: 200, headers: { "content-type": "application/json" } },
)) as typeof fetch;
await expect(startDeviceAuthorizationFlow({ fetchImpl })).rejects.toThrow(
/Device authorization failed/,
);
});
it("rejects an oversized device response without exposing its body", async () => {
const marker = "never-log-this-device-code";
const fetchImpl = (async () =>
new Response(JSON.stringify({ padding: marker.repeat(8_000) }), {
status: 200,
headers: { "content-type": "application/json" },
})) as typeof fetch;
await expect(startDeviceAuthorizationFlow({ fetchImpl })).rejects.not.toThrow(marker);
});
});
});
+405 -1
View File
@@ -34,7 +34,13 @@ import { failCommand } from "../utils/commandResult.js";
* Public client — no `client_secret`.
*/
import { ErrApi, ErrOAuthNotConfigured, ErrRefreshFailed, isAuthError } from "./errors.js";
import {
ErrApi,
ErrDeviceAuthFailed,
ErrOAuthNotConfigured,
ErrRefreshFailed,
isAuthError,
} from "./errors.js";
import { generatePkcePair, generateState } from "./pkce.js";
import { startLoopback } from "./loopback.js";
import { openBrowser } from "./browser.js";
@@ -45,6 +51,7 @@ import {
writeStore,
type Credentials,
type OAuthTokens,
type StoredUserInfo,
} from "./store.js";
import { c } from "../ui/colors.js";
@@ -65,6 +72,13 @@ const DEFAULT_SCOPES = "openid profile email";
const DEFAULT_AUTHORIZE_URL = "https://app.heygen.com/oauth/authorize";
const DEFAULT_TOKEN_URL = "https://api2.heygen.com/v1/oauth/token";
const DEFAULT_REVOKE_URL = "https://api2.heygen.com/v1/oauth/revoke";
const DEFAULT_DEVICE_AUTHORIZATION_URL = "https://api2.heygen.com/v1/oauth/device_authorization";
const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code";
const MAX_DEVICE_FLOW_SECONDS = 30 * 60;
const MIN_DEVICE_POLL_SECONDS = 5;
const MAX_DEVICE_POLL_SECONDS = 60;
const MAX_DEVICE_RESPONSE_BYTES = 64 * 1024;
const DEVICE_REQUEST_TIMEOUT_MS = 15_000;
function authorizeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_AUTHORIZE_URL"] || DEFAULT_AUTHORIZE_URL;
@@ -75,6 +89,9 @@ function tokenEndpoint(): string {
function revokeEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_REVOKE_URL"] || DEFAULT_REVOKE_URL;
}
function deviceAuthorizationEndpoint(): string {
return process.env["HYPERFRAMES_OAUTH_DEVICE_URL"] || DEFAULT_DEVICE_AUTHORIZATION_URL;
}
export interface AuthorizeFlowOptions {
/** Override scopes (default `openid profile email`). */
@@ -95,6 +112,27 @@ export interface RefreshOptions {
fetchImpl?: typeof fetch;
}
export interface DeviceAuthorizationChallenge {
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
}
export interface DeviceAuthorizationFlowOptions {
/** Override scopes (default `openid profile email`). */
scope?: string;
/** Inject a custom fetch (used by tests). */
fetchImpl?: typeof fetch;
/** Inject polling sleep (used by tests). */
sleepImpl?: (ms: number) => Promise<void>;
/** Inject a monotonic-enough clock in epoch milliseconds (used by tests). */
now?: () => number;
/** Bound each authorization-server request, including its response body (default 15s). */
requestTimeoutMs?: number;
/** Present the user code and verification URI without exposing device_code. */
onChallenge?: (challenge: DeviceAuthorizationChallenge) => void | Promise<void>;
}
/** Read the client_id, throwing `ErrOAuthNotConfigured` when unset. */
export function resolveClientId(): string {
const override = process.env["HYPERFRAMES_OAUTH_CLIENT_ID"];
@@ -171,6 +209,177 @@ export async function startAuthorizationCodeFlow(
return { tokens };
}
/**
* RFC 8628 attended device flow. This function deliberately returns an
* unpersisted token set: the command must verify `/v3/users/me` first and only
* then call `persistFreshOAuth`. That ordering prevents a token for the wrong
* account/resource from ever becoming the active shared credential.
*/
export async function startDeviceAuthorizationFlow(
opts: DeviceAuthorizationFlowOptions = {},
): Promise<OAuthTokens> {
const runtime: DeviceFlowRuntime = {
clientId: resolveClientId(),
fetchImpl: opts.fetchImpl ?? fetch,
sleepImpl:
opts.sleepImpl ?? ((ms: number) => new Promise((resolve) => setTimeout(resolve, ms))),
now: opts.now ?? Date.now,
requestTimeoutMs: opts.requestTimeoutMs ?? DEVICE_REQUEST_TIMEOUT_MS,
};
const issuance = await requestDeviceAuthorization(runtime, opts.scope ?? DEFAULT_SCOPES);
await opts.onChallenge?.({
userCode: issuance.userCode,
verificationUri: issuance.verificationUri,
...(issuance.verificationUriComplete
? { verificationUriComplete: issuance.verificationUriComplete }
: {}),
});
return await pollDeviceToken(runtime, issuance);
}
interface DeviceFlowRuntime {
clientId: string;
fetchImpl: typeof fetch;
sleepImpl: (ms: number) => Promise<void>;
now: () => number;
requestTimeoutMs: number;
}
async function requestDeviceAuthorization(
runtime: DeviceFlowRuntime,
scope: string,
): Promise<ParsedDeviceAuthorization> {
return await withDeviceRequestTimeout(
runtime,
"could not reach the authorization server",
async (signal) => {
const response = await runtime.fetchImpl(deviceAuthorizationEndpoint(), {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: new URLSearchParams({ client_id: runtime.clientId, scope }).toString(),
signal,
});
if (!response.ok) {
throw ErrDeviceAuthFailed(`authorization server returned HTTP ${response.status}`);
}
return parseDeviceAuthorizationResponse(await readJsonOrDeviceError(response));
},
);
}
async function pollDeviceToken(
runtime: DeviceFlowRuntime,
issuance: ParsedDeviceAuthorization,
): Promise<OAuthTokens> {
const deadline = runtime.now() + Math.min(issuance.expiresIn, MAX_DEVICE_FLOW_SECONDS) * 1000;
let intervalSeconds = issuance.interval;
while (runtime.now() < deadline) {
const remainingMs = deadline - runtime.now();
if (remainingMs <= 0) break;
await runtime.sleepImpl(Math.min(intervalSeconds * 1000, remainingMs));
const result = await requestDeviceToken(runtime, issuance.deviceCode);
if (result.tokens) return result.tokens;
if (result.slowDown) {
intervalSeconds = Math.min(
Math.max(intervalSeconds + 5, result.retryAfterSeconds ?? 0),
MAX_DEVICE_POLL_SECONDS,
);
}
}
throw ErrDeviceAuthFailed("the code expired");
}
async function requestDeviceToken(
runtime: DeviceFlowRuntime,
deviceCode: string,
): Promise<DevicePollResult> {
return await withDeviceRequestTimeout(
runtime,
"lost contact with the authorization server",
async (signal) => {
const response = await runtime.fetchImpl(tokenEndpoint(), {
method: "POST",
headers: {
"content-type": "application/x-www-form-urlencoded",
accept: "application/json",
},
body: new URLSearchParams({
grant_type: DEVICE_CODE_GRANT_TYPE,
device_code: deviceCode,
client_id: runtime.clientId,
}).toString(),
signal,
});
return await evaluateDevicePollResponse(response, runtime.now());
},
);
}
interface DevicePollResult {
tokens?: OAuthTokens;
slowDown?: boolean;
retryAfterSeconds?: number;
}
async function evaluateDevicePollResponse(
response: Response,
nowMs: number,
): Promise<DevicePollResult> {
if (response.ok) {
return { tokens: parseTokenResponse(await readJsonOrDeviceError(response)) };
}
const error = await readDeviceOAuthError(response);
switch (error) {
case "authorization_pending":
return {};
case "slow_down":
return { slowDown: true, retryAfterSeconds: retryAfterSeconds(response, nowMs) };
case "access_denied":
throw ErrDeviceAuthFailed("access was denied");
case "expired_token":
throw ErrDeviceAuthFailed("the code expired");
default:
if (response.status === 429) {
return { slowDown: true, retryAfterSeconds: retryAfterSeconds(response, nowMs) };
}
throw ErrDeviceAuthFailed(`authorization server returned HTTP ${response.status}`);
}
}
async function withDeviceRequestTimeout<T>(
runtime: DeviceFlowRuntime,
networkError: string,
operation: (signal: AbortSignal) => Promise<T>,
): Promise<T> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), runtime.requestTimeoutMs);
try {
return await operation(controller.signal);
} catch (err) {
if (controller.signal.aborted) {
throw ErrDeviceAuthFailed("authorization server request timed out");
}
if (isAuthError(err)) throw err;
throw ErrDeviceAuthFailed(networkError);
} finally {
clearTimeout(timer);
}
}
function retryAfterSeconds(response: Response, nowMs: number): number | undefined {
const value = response.headers.get("retry-after")?.trim();
if (!value) return undefined;
if (/^\d+$/.test(value)) return Math.min(Number(value), MAX_DEVICE_POLL_SECONDS);
const retryAt = Date.parse(value);
if (!Number.isFinite(retryAt)) return undefined;
return Math.min(Math.max(Math.ceil((retryAt - nowMs) / 1000), 0), MAX_DEVICE_POLL_SECONDS);
}
export async function refreshTokens(
refresh_token: string,
opts: RefreshOptions = {},
@@ -422,6 +631,201 @@ async function persistOAuth(
await writeStore({ ...existing, oauth });
}
/** Persist a verified fresh OAuth login while preserving cross-CLI fields. */
export async function persistFreshOAuth(tokens: OAuthTokens): Promise<void> {
await persistOAuth(tokens, { preserveMissing: false });
}
/**
* Atomically install a verified device session and its identity metadata.
*
* This is intentionally one credential-file rename: if the write fails, the
* previous credential remains intact and the caller can revoke the freshly
* minted tokens without leaving a half-installed session or stale identity.
*/
export async function persistVerifiedOAuthSession(
tokens: OAuthTokens,
user: StoredUserInfo,
): Promise<void> {
let credentials: Credentials = {};
try {
({ credentials } = await readStore());
} catch {
// Match the other fresh-login path: a corrupt prior file must not prevent
// installing a newly verified session.
credentials = {};
}
const next: Credentials = {
...credentials,
oauth: { ...tokens },
};
if (user.email || user.first_name || user.last_name || user.username) {
// Preserve only unknown/foreign user fields from the existing record.
// Assigning every known field (including undefined) prevents identity
// fields from the previous account surviving when the new response omits
// them; serializeUser skips undefined values.
next.user = {
...credentials.user,
email: user.email,
first_name: user.first_name,
last_name: user.last_name,
username: user.username,
};
} else {
delete next.user;
}
await writeStore(next);
}
interface ParsedDeviceAuthorization {
deviceCode: string;
userCode: string;
verificationUri: string;
verificationUriComplete?: string;
expiresIn: number;
interval: number;
}
function parseDeviceAuthorizationResponse(payload: unknown): ParsedDeviceAuthorization {
const data = requireDeviceAuthorizationRecord(payload);
const deviceCode = stringField(data, "device_code");
const userCode = stringField(data, "user_code");
const verificationUri = requiredSafeVerificationUri(data, "verification_uri");
const verificationUriComplete = optionalSafeVerificationUri(data, "verification_uri_complete");
const expiresIn = strictNumericField(data, "expires_in");
const interval = strictNumericField(data, "interval");
requireSafeDeviceCode(deviceCode);
requireSafeDeviceCode(userCode);
const timing = normalizeDeviceAuthorizationTiming(data, expiresIn, interval);
return {
deviceCode,
userCode,
verificationUri,
...(verificationUriComplete ? { verificationUriComplete } : {}),
...timing,
};
}
function requireSafeDeviceCode(value: string | undefined): asserts value is string {
if (!value || !isHeaderSafe(value)) {
throw ErrDeviceAuthFailed("authorization server returned an invalid response");
}
}
function normalizeDeviceAuthorizationTiming(
data: Record<string, unknown>,
expiresIn: number | undefined,
interval: number | undefined,
): Pick<ParsedDeviceAuthorization, "expiresIn" | "interval"> {
if (
!isPositiveNumber(expiresIn) ||
(data["interval"] !== undefined && !isPositiveNumber(interval))
) {
throw ErrDeviceAuthFailed("authorization server returned invalid timing values");
}
return {
expiresIn,
interval: Math.min(
Math.max(Math.ceil(interval ?? MIN_DEVICE_POLL_SECONDS), MIN_DEVICE_POLL_SECONDS),
MAX_DEVICE_POLL_SECONDS,
),
};
}
function requiredSafeVerificationUri(data: Record<string, unknown>, key: string): string {
const value = normalizeSafeVerificationUri(stringField(data, key));
if (!value) throw ErrDeviceAuthFailed("authorization server returned an unsafe verification URL");
return value;
}
function optionalSafeVerificationUri(
data: Record<string, unknown>,
key: string,
): string | undefined {
if (data[key] === undefined) return undefined;
return requiredSafeVerificationUri(data, key);
}
function requireDeviceAuthorizationRecord(payload: unknown): Record<string, unknown> {
if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
throw ErrDeviceAuthFailed("authorization server returned an invalid response");
}
return payload as Record<string, unknown>;
}
function isPositiveNumber(value: number | undefined): value is number {
return value !== undefined && value > 0;
}
function normalizeSafeVerificationUri(value: string | undefined): string | undefined {
if (!value || !isHeaderSafe(value)) return undefined;
try {
const url = new URL(value);
if (url.username || url.password) return undefined;
const allowed =
url.protocol === "https:" ||
(url.protocol === "http:" && ["127.0.0.1", "localhost"].includes(url.hostname));
return allowed ? url.href : undefined;
} catch {
return undefined;
}
}
async function readJsonOrDeviceError(res: Response): Promise<unknown> {
return await readBoundedDeviceJson(res);
}
async function readDeviceOAuthError(res: Response): Promise<string | undefined> {
try {
const payload = await readBoundedDeviceJson(res);
if (!payload || typeof payload !== "object" || Array.isArray(payload)) return undefined;
const error = (payload as Record<string, unknown>)["error"];
return typeof error === "string" ? error : undefined;
} catch {
return undefined;
}
}
function strictNumericField(obj: Record<string, unknown>, key: string): number | undefined {
const value = obj[key];
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
if (typeof value !== "string" || value.trim() === "") return undefined;
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
async function readBoundedDeviceJson(res: Response): Promise<unknown> {
if (!res.body) throw ErrDeviceAuthFailed("authorization server returned no data");
const reader = res.body.getReader();
const chunks: Uint8Array[] = [];
let total = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
if (!value) continue;
total += value.byteLength;
if (total > MAX_DEVICE_RESPONSE_BYTES) {
await reader.cancel();
throw ErrDeviceAuthFailed("authorization server response was too large");
}
chunks.push(value);
}
const body = new Uint8Array(total);
let offset = 0;
for (const chunk of chunks) {
body.set(chunk, offset);
offset += chunk.byteLength;
}
return JSON.parse(new TextDecoder().decode(body));
} catch (err) {
if (isAuthError(err)) throw err;
throw ErrDeviceAuthFailed("authorization server returned non-JSON data");
} finally {
reader.releaseLock();
}
}
async function readJsonOrThrow(res: Response): Promise<unknown> {
try {
return await res.json();
+3 -1
View File
@@ -17,6 +17,7 @@ import { c } from "../ui/colors.js";
export const examples: Example[] = [
["Sign in via browser (OAuth)", "hyperframes auth login"],
["Sign in from SSH/headless terminal", "hyperframes auth login --device"],
["Save an API key (interactive)", "hyperframes auth login --api-key"],
["Save an API key from stdin", "echo $HEYGEN_API_KEY | hyperframes auth login --api-key"],
["Check who you're signed in as", "hyperframes auth status"],
@@ -31,7 +32,7 @@ Manage HeyGen credentials. Credentials live in
${c.accent("~/.heygen/credentials")} and are shared with heygen-cli.
${c.bold("SUBCOMMANDS:")}
${c.accent("login")} ${c.dim("Sign in via browser (default) or --api-key for a long-lived key.")}
${c.accent("login")} ${c.dim("Sign in via browser, --device for SSH, or --api-key for a long-lived key.")}
${c.accent("status")} ${c.dim("Show the active credential's source, type, and identity.")}
${c.accent("refresh")} ${c.dim("Force-refresh the OAuth access token.")}
${c.accent("logout")} ${c.dim("Remove the stored credential (--keep-api-key for OAuth-only).")}
@@ -42,6 +43,7 @@ ${c.bold("ENV VARS:")}
${c.accent("HEYGEN_API_URL")} Override the API base URL (default https://api.heygen.com).
${c.accent("HEYGEN_CONFIG_DIR")} Override the credentials directory (default ~/.heygen).
${c.accent("HYPERFRAMES_OAUTH_CLIENT_ID")} Override the OAuth client_id (for dev/test).
${c.accent("HYPERFRAMES_OAUTH_DEVICE_URL")} Override the RFC 8628 device endpoint (for dev/test).
`;
export default defineCommand({
+179 -18
View File
@@ -18,20 +18,6 @@ const verifyState = vi.hoisted(
},
);
vi.mock("../../auth/index.js", async (orig) => {
const actual = await orig<typeof import("../../auth/index.js")>();
class MockAuthClient {
async getCurrentUser(): Promise<Record<string, unknown>> {
if (verifyState.reject) {
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
throw rej("invalid key");
}
return verifyState.user;
}
}
return { ...actual, AuthClient: MockAuthClient };
});
// Spy on the telemetry the login flow emits, so we can assert the identity is
// attributed on success. login.ts imports these via a dynamic import of
// telemetry/index.js; the mock intercepts it.
@@ -43,16 +29,90 @@ const telemetry = vi.hoisted(() => ({
}));
vi.mock("../../telemetry/index.js", () => telemetry);
describe("auth login --api-key rollback", () => {
const deviceChallenge = vi.hoisted(() => ({
verificationUriComplete: undefined as string | undefined,
}));
const deviceAuth = vi.hoisted(() => ({
start: vi.fn(async (options?: { onChallenge?: (value: unknown) => void }) => {
options?.onChallenge?.({
verificationUri: "https://app.heygen.com/oauth/device",
...(deviceChallenge.verificationUriComplete
? { verificationUriComplete: deviceChallenge.verificationUriComplete }
: {}),
userCode: "ABCD-2345",
});
return {
access_token: "device-at",
refresh_token: "device-rt",
token_type: "Bearer",
};
}),
persist: vi.fn(async () => {}),
revoke: vi.fn(async () => {}),
}));
vi.mock("../../auth/index.js", async (orig) => {
const actual = await orig<typeof import("../../auth/index.js")>();
class MockAuthClient {
async getCurrentUser(): Promise<Record<string, unknown>> {
if (verifyState.reject) {
const { ErrUnauthenticated: rej } = await import("../../auth/errors.js");
throw rej("invalid token");
}
return verifyState.user;
}
}
return {
...actual,
AuthClient: MockAuthClient,
startDeviceAuthorizationFlow: deviceAuth.start,
persistVerifiedOAuthSession: deviceAuth.persist,
revokeTokens: deviceAuth.revoke,
};
});
describe("auth login", () => {
let dir: string;
let envFixture: EnvFixture;
let runtimeEnv: Record<string, string | undefined>;
let stdinTTYDescriptor: PropertyDescriptor | undefined;
let stdoutTTYDescriptor: PropertyDescriptor | undefined;
beforeEach(async () => {
runtimeEnv = Object.fromEntries(
[
"CI",
"SSH_CONNECTION",
"SSH_CLIENT",
"SSH_TTY",
"BROWSER",
"HF_NO_BROWSER",
"CODESPACES",
"GITHUB_CODESPACES",
"REMOTE_CONTAINERS",
"GITPOD_WORKSPACE_ID",
"container",
].map((key) => [key, process.env[key]]),
);
for (const key of Object.keys(runtimeEnv)) delete process.env[key];
stdinTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdin, "isTTY");
stdoutTTYDescriptor = Object.getOwnPropertyDescriptor(process.stdout, "isTTY");
Object.defineProperty(process.stdin, "isTTY", {
configurable: true,
value: true,
});
Object.defineProperty(process.stdout, "isTTY", {
configurable: true,
value: true,
});
envFixture = await setupTempAuthEnv("hf-login-");
dir = envFixture.dir;
verifyState.reject = false;
verifyState.user = { email: "alice@example.com" };
deviceChallenge.verificationUriComplete = undefined;
for (const fn of Object.values(telemetry)) fn.mockClear();
for (const fn of Object.values(deviceAuth)) fn.mockClear();
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
});
@@ -60,16 +120,28 @@ describe("auth login --api-key rollback", () => {
afterEach(async () => {
vi.restoreAllMocks();
await envFixture.restore();
for (const [key, value] of Object.entries(runtimeEnv)) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
if (stdinTTYDescriptor) Object.defineProperty(process.stdin, "isTTY", stdinTTYDescriptor);
else delete (process.stdin as { isTTY?: boolean }).isTTY;
if (stdoutTTYDescriptor) Object.defineProperty(process.stdout, "isTTY", stdoutTTYDescriptor);
else delete (process.stdout as { isTTY?: boolean }).isTTY;
});
async function runLogin(apiKey: string): Promise<void> {
async function runCommand(args: Record<string, unknown>): Promise<void> {
const cmd = (await import("./login.js")).default;
// citty command run only reads `args` here.
await (cmd.run as (ctx: { args: Record<string, unknown> }) => Promise<void>)({
args: { "api-key": apiKey },
args,
});
}
async function runLogin(apiKey: string): Promise<void> {
await runCommand({ "api-key": apiKey });
}
it("removes the rejected key on a failed FIRST login (no prior credential)", async () => {
verifyState.reject = true;
await expect(runLogin("hg_badkey123")).rejects.toThrow(CliRuntimeError);
@@ -128,7 +200,10 @@ describe("auth login --api-key rollback", () => {
});
it("rollback on a rejected key restores the previous user block too", async () => {
await writeStore({ api_key: "hg_prev", user: { email: "prev@example.com" } });
await writeStore({
api_key: "hg_prev",
user: { email: "prev@example.com" },
});
verifyState.reject = true;
await expect(runLogin("hg_badnewkey")).rejects.toThrow(CliRuntimeError);
@@ -198,4 +273,90 @@ describe("auth login --api-key rollback", () => {
expect(onDisk.user).toEqual({ email: "jane@example.com" });
expect(onDisk.future_field).toEqual({ x: 1 });
});
it("requires the explicit --device flag in a remote terminal", async () => {
process.env["SSH_CONNECTION"] = "192.0.2.1 1234 192.0.2.2 22";
await expect(runCommand({})).rejects.toThrow(/Invalid command usage/);
expect(deviceAuth.start).not.toHaveBeenCalled();
expect(console.error).toHaveBeenCalledWith(
expect.stringContaining("hyperframes auth login --device"),
);
});
it.each([
"CODESPACES",
"GITHUB_CODESPACES",
"REMOTE_CONTAINERS",
"GITPOD_WORKSPACE_ID",
"container",
])("requires --device in the %s remote environment", async (name) => {
process.env[name] = "true";
await expect(runCommand({})).rejects.toThrow(/Invalid command usage/);
expect(deviceAuth.start).not.toHaveBeenCalled();
});
it("opens verification_uri_complete without asking the user to re-enter the code", async () => {
deviceChallenge.verificationUriComplete =
"https://app.heygen.com/oauth/device?user_code=ABCD-2345";
await runCommand({ device: true });
expect(console.log).toHaveBeenCalledWith(
expect.stringContaining("https://app.heygen.com/oauth/device?user_code=ABCD-2345"),
);
expect(console.log).not.toHaveBeenCalledWith(expect.stringContaining("Enter code"));
});
it("verifies the device token before persisting it", async () => {
verifyState.user = { email: "device@example.com" };
await runCommand({ device: true });
expect(deviceAuth.start).toHaveBeenCalledOnce();
expect(deviceAuth.persist).toHaveBeenCalledWith(
expect.objectContaining({ access_token: "device-at" }),
expect.objectContaining({ email: "device@example.com" }),
);
expect(deviceAuth.revoke).not.toHaveBeenCalled();
expect(telemetry.trackAuthLoginCompleted).toHaveBeenCalledWith("device", "device@example.com");
});
it("revokes and never persists a device token that identity verification rejects", async () => {
verifyState.reject = true;
await expect(runCommand({ device: true })).rejects.toThrow(CliRuntimeError);
expect(deviceAuth.persist).not.toHaveBeenCalled();
expect(deviceAuth.revoke).toHaveBeenCalledTimes(2);
expect(deviceAuth.revoke).toHaveBeenNthCalledWith(
1,
"device-at",
expect.objectContaining({ token_type_hint: "access_token" }),
);
expect(deviceAuth.revoke).toHaveBeenNthCalledWith(
2,
"device-rt",
expect.objectContaining({ token_type_hint: "refresh_token" }),
);
expect(telemetry.trackAuthLoginFailed).toHaveBeenCalledWith("device", "rejected");
});
it("refuses device authorization in CI", async () => {
process.env["CI"] = "true";
await expect(runCommand({ device: true })).rejects.toThrow(/Invalid command usage/);
expect(deviceAuth.start).not.toHaveBeenCalled();
});
it("does not treat CI=false as an unattended environment", async () => {
process.env["CI"] = "false";
await runCommand({ device: true });
expect(deviceAuth.start).toHaveBeenCalledOnce();
});
it("refuses device authorization when either terminal stream is not a TTY", async () => {
Object.defineProperty(process.stdout, "isTTY", {
configurable: true,
value: undefined,
});
await expect(runCommand({ device: true })).rejects.toThrow(/Invalid command usage/);
expect(deviceAuth.start).not.toHaveBeenCalled();
});
});
+140 -2
View File
@@ -1,4 +1,4 @@
import { failCommand } from "../../utils/commandResult.js";
import { failCommand, failUsage } from "../../utils/commandResult.js";
/**
* `hyperframes auth login` — sign in to HeyGen.
*
@@ -35,8 +35,11 @@ import {
isUserInfoEmpty,
readStore,
refreshTokens,
revokeTokens,
saveUserInfo,
persistVerifiedOAuthSession,
startAuthorizationCodeFlow,
startDeviceAuthorizationFlow,
tryResolveCredential,
userDisplayName,
writeStore,
@@ -63,18 +66,149 @@ export default defineCommand({
type: "string",
description: "API key value, or pass `--api-key` with no value to read from stdin / prompt.",
},
device: {
type: "boolean",
description: "Use an attended device code (for SSH/headless terminals; never for CI).",
},
},
// fallow-ignore-next-line complexity
async run({ args }) {
const inlineKey = args["api-key"];
if (inlineKey !== undefined && args.device) {
console.error(c.error("Choose either --device or --api-key, not both."));
failUsage();
}
if (inlineKey !== undefined) {
await runApiKeyLogin(inlineKey);
return;
}
if (args.device) {
await runDeviceLogin();
return;
}
if (isRemoteOrHeadless()) {
console.error(
c.error(
"Browser callback login is unavailable in this remote/headless terminal. Run `hyperframes auth login --device`.",
),
);
failUsage();
}
await runOAuthLogin();
},
});
function isRemoteOrHeadless(): boolean {
const remoteEnvironment = [
"CODESPACES",
"GITHUB_CODESPACES",
"REMOTE_CONTAINERS",
"GITPOD_WORKSPACE_ID",
"container",
].some(envFlagEnabled);
return Boolean(
process.env["SSH_CONNECTION"] ||
process.env["SSH_CLIENT"] ||
process.env["SSH_TTY"] ||
process.env["BROWSER"] === "none" ||
process.env["HF_NO_BROWSER"] === "1" ||
remoteEnvironment ||
process.stdout.isTTY !== true,
);
}
function envFlagEnabled(name: string): boolean {
const value = process.env[name]?.trim().toLowerCase();
return Boolean(value && value !== "0" && value !== "false" && value !== "no");
}
function assertAttendedDeviceFlow(): void {
if (envFlagEnabled("CI") || process.stdin.isTTY !== true || process.stdout.isTTY !== true) {
console.error(
c.error(
"`--device` requires an attended terminal and is disabled in CI. Use an API key or workload credential for automation.",
),
);
failUsage();
}
}
async function runDeviceLogin(): Promise<void> {
assertAttendedDeviceFlow();
assertOAuthConfiguredOrExit();
const { trackAuthLoginStarted, trackAuthLoginCompleted, trackAuthLoginFailed, identifyUser } =
await import("../../telemetry/index.js");
trackAuthLoginStarted("device");
let tokens;
try {
tokens = await startDeviceAuthorizationFlow({
onChallenge: ({ verificationUri, verificationUriComplete, userCode }) => {
console.log(`Open ${c.accent(verificationUriComplete ?? verificationUri)} in a browser.`);
if (!verificationUriComplete) console.log(`Enter code ${c.bold(userCode)}.`);
console.log(c.dim("Waiting for approval…"));
},
});
} catch (err) {
const message = (err as Error).message || "Device authorization failed.";
trackAuthLoginFailed("device", /expired/i.test(message) ? "flow_timeout" : "flow_error");
console.error(c.error(message));
failCommand();
}
const credential = {
type: "oauth" as const,
access_token: tokens.access_token,
...(tokens.refresh_token ? { refresh_token: tokens.refresh_token } : {}),
source: "file_json" as const,
refreshable: false,
};
let user: UserInfo;
try {
user = await new AuthClient().getCurrentUser(credential);
} catch (err) {
await revokeDeviceTokens(tokens);
trackAuthLoginFailed("device", "rejected");
console.error(
c.error(
`HeyGen could not verify the approved device session; no credential was saved. ${(err as Error).message}`,
),
);
failCommand();
}
try {
await persistVerifiedOAuthSession(tokens, toStoredUserInfo(user));
} catch (err) {
await revokeDeviceTokens(tokens);
trackAuthLoginFailed("device", "flow_error");
console.error(
c.error(
`Could not save the verified device session; it was revoked. ${(err as Error).message}`,
),
);
failCommand();
}
const id = identityKey(user);
if (id) identifyUser(id);
trackAuthLoginCompleted("device", id);
const identity = userDisplayName(toStoredUserInfo(user)) ?? "(unknown user)";
console.log(c.success(`✓ Signed in as ${identity}.`));
}
async function revokeDeviceTokens(tokens: {
access_token: string;
refresh_token?: string;
}): Promise<void> {
await revokeTokens(tokens.access_token, { token_type_hint: "access_token" });
if (tokens.refresh_token) {
await revokeTokens(tokens.refresh_token, {
token_type_hint: "refresh_token",
});
}
}
// fallow-ignore-next-line complexity
async function runOAuthLogin(): Promise<void> {
assertOAuthConfiguredOrExit();
@@ -286,7 +420,11 @@ async function rollback(previous: Credentials): Promise<void> {
async function verifyAndReport(key: string): Promise<UserInfo | null> {
const client = new AuthClient();
try {
const user = await client.getCurrentUser({ type: "api_key", key, source: "file_json" });
const user = await client.getCurrentUser({
type: "api_key",
key,
source: "file_json",
});
// Persist the friendly-display block next to the now-verified api_key
// so `auth status` can show a recognizable identity. Best-effort.
await persistUserInfo(user);
+7 -2
View File
@@ -399,7 +399,8 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
await import("../../../producer/src/services/deterministicFonts.js");
const { prepareAnimatedGifInputs } =
await import("../../../producer/src/services/animatedGifPrep.js");
const { downloadToTemp } = await import("../../../producer/src/utils/urlDownloader.js");
const { downloadToTemp, writeUrlDownloadTelemetry } =
await import("../../../producer/src/utils/urlDownloader.js");
const gifOutputDir = join(project.dir, ".hyperframes", "prepared-assets", "gif");
const gifDownloadDir = join(project.dir, ".hyperframes", "prepared-assets", "downloads");
const prepared = await prepareAnimatedGifInputs(html, {
@@ -408,7 +409,11 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
outputDir: gifOutputDir,
outputSrcPrefix: ".hyperframes/prepared-assets/gif",
cacheDir: gifOutputDir,
sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, downloadToTemp),
sourceAssets: await downloadRemoteGifImageSources(html, gifDownloadDir, (url, destDir) =>
downloadToTemp(url, destDir, undefined, undefined, undefined, {
onTelemetry: writeUrlDownloadTelemetry,
}),
),
});
return injectDeterministicFontFaces(prepared.html);
},
+3 -2
View File
@@ -507,7 +507,8 @@ export function trackBrowserInstall(): void {
// dashboards — a completed sign-in, a browser flow the user abandoned, and a
// rejected key all look identical (i.e. absent). These three events close that
// gap so the sign-in funnel is measurable like the render funnel already is.
// `method` is "oauth" (the default browser PKCE flow) or "api_key". No token,
// `method` is "oauth" (the default browser PKCE flow), "device" (attended
// RFC 8628 flow), or "api_key". No token,
// key, identity, email, or free text is ever attached — only the method and a
// low-cardinality outcome/reason.
//
@@ -516,7 +517,7 @@ export function trackBrowserInstall(): void {
// today (events attribute to the install's anonymousId), but pre-plumbing it
// makes attributing a completed sign-in to a resolved identity later a one-line
// change at the callsite rather than a signature sweep.
export type AuthLoginMethod = "oauth" | "api_key";
export type AuthLoginMethod = "oauth" | "device" | "api_key";
export type AuthLoginFailureReason =
| "flow_error" // OAuth authorization/exchange threw a real error
| "flow_timeout" // OAuth callback wait elapsed (user closed the tab / walked away)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/core",
"version": "0.7.92",
"version": "0.7.94",
"description": "",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/engine",
"version": "0.7.92",
"version": "0.7.94",
"description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)",
"repository": {
"type": "git",
+13 -1
View File
@@ -120,6 +120,7 @@ export {
getCapturePerfSummary,
percentileOf,
prepareCaptureSessionForReuse,
deriveBeginFrameProbeTimeTicks,
type CaptureSession,
isTransientBrowserError,
isMemoryExhaustionError,
@@ -273,7 +274,18 @@ export {
type KeyframeAnalysis,
} from "./utils/ffprobe.js";
export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "./utils/urlDownloader.js";
export {
assertPublicHttpsUrl,
downloadToTemp,
fetchPublicHttpsText,
isHttpUrl,
safeDownloadUrlIdentity,
writeUrlDownloadTelemetry,
type SafeDownloadUrlIdentity,
type UrlDownloadOptions,
type UrlDownloadTelemetry,
type PublicHttpsTextOptions,
} from "./utils/urlDownloader.js";
export {
runFfmpeg,
formatFfmpegError,
@@ -52,6 +52,7 @@ describe("processCompositionAudio", () => {
const tempDirs: string[] = [];
afterEach(() => {
vi.unstubAllGlobals();
runFfmpegMock.mockClear();
extractAudioMetadataMock.mockReset();
extractAudioMetadataMock.mockResolvedValue({
@@ -66,6 +67,44 @@ describe("processCompositionAudio", () => {
}
});
it("classifies an HTML-as-200 audio source as deterministic user input", async () => {
const baseDir = mkdtempSync(join(tmpdir(), "hf-audio-base-"));
const workDir = mkdtempSync(join(tmpdir(), "hf-audio-work-"));
tempDirs.push(baseDir, workDir);
const fetchMock = vi
.fn()
.mockResolvedValue(new Response("<!doctype html><html><body>denied</body></html>"));
vi.stubGlobal("fetch", fetchMock);
const result = await processCompositionAudio(
[
{
id: "remote-voice",
src: "https://cdn.example/voice",
start: 0,
end: 2,
mediaStart: 0,
layer: 0,
volume: 1,
type: "audio",
},
],
baseDir,
workDir,
join(baseDir, "out.m4a"),
2,
);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result.failures).toEqual([
expect.objectContaining({
stage: "download",
owner: "user",
retryable: false,
}),
]);
});
it.each([
{
message: "AbortError: ffprobe operation aborted",
+22 -10
View File
@@ -9,7 +9,12 @@ import { closeSync, existsSync, mkdirSync, mkdtempSync, openSync, rmSync, writeF
import { join, dirname } from "path";
import { parseHTML } from "linkedom";
import { extractAudioMetadata } from "../utils/ffprobe.js";
import { downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import {
downloadToTemp,
isHttpUrl,
UrlDownloadError,
writeUrlDownloadTelemetry,
} from "../utils/urlDownloader.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { formatFfmpegError, runFfmpeg, type RunFfmpegResult } from "../utils/runFfmpeg.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -241,16 +246,23 @@ function probeFailure(message: string, elementId: string): AudioProcessingFailur
};
}
function downloadFailure(message: string, elementId: string): AudioProcessingFailure {
function downloadFailure(error: unknown, elementId: string): AudioProcessingFailure {
const message = error instanceof Error ? error.message : String(error);
const invalidSource =
/(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test(
message,
);
error instanceof UrlDownloadError
? error.kind === "http_not_found" ||
error.kind === "http_rejected" ||
error.kind === "invalid_payload" ||
error.kind === "cancelled"
: /(?:invalid URL|only HTTPS|private\/reserved|HTTP (?:400|401|403|404|405|410|422)\b)/i.test(
message,
);
const retryable = error instanceof UrlDownloadError ? error.retryable : !invalidSource;
return {
stage: "download",
reason: "download_failed",
owner: invalidSource ? "user" : "system",
retryable: !invalidSource,
retryable,
elementId,
detail: boundedDetail(`Download failed for audio element ${elementId}: ${message}`),
};
@@ -712,11 +724,11 @@ export async function processCompositionAudio(
if (isHttpUrl(srcPath)) {
try {
srcPath = await downloadToTemp(srcPath, workDir);
srcPath = await downloadToTemp(srcPath, workDir, undefined, signal, undefined, {
onTelemetry: writeUrlDownloadTelemetry,
});
} catch (err: unknown) {
failures.push(
downloadFailure(err instanceof Error ? err.message : String(err), element.id),
);
failures.push(downloadFailure(err, element.id));
return;
}
}
@@ -241,11 +241,68 @@ describe("resolveBrowserGpuMode", () => {
expect(mode).toBe("software");
});
it("passes 'hardware' through unchanged without probing", async () => {
it("passes 'hardware' through unchanged", async () => {
setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" });
const mode = await resolveBrowserGpuMode("hardware");
expect(mode).toBe("hardware");
});
it("warns when explicit 'hardware' probes to software, but still honours it", async () => {
// heygen-com/hyperframes#2967: `--browser-gpu` inside a container with no
// GPU passthrough rendered 19186 frames on CPU with no diagnostic.
setMockWebGlProbe({
hasWebGL: true,
vendor: "Google Inc. (Google)",
renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))",
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const mode = await resolveBrowserGpuMode("hardware", { platform: "linux" });
expect(mode).toBe("hardware");
const warning = warn.mock.calls.map((call) => String(call[0])).join("\n");
expect(warning).toContain("browserGpuMode=hardware was requested");
expect(warning).toContain("--gpus all");
// Once per process, not once per worker — the render path resolves the
// mode for the probe browser plus every parallel worker.
await resolveBrowserGpuMode("hardware", { platform: "linux" });
await resolveBrowserGpuMode("hardware", { platform: "linux" });
expect(warn).toHaveBeenCalledTimes(1);
});
it("stays quiet when explicit 'hardware' probes to hardware", async () => {
setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" });
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("hardware")).toBe("hardware");
expect(warn).not.toHaveBeenCalled();
});
it("gives non-linux hosts the generic remediation, not the Docker one", async () => {
setMockWebGlProbe({
hasWebGL: true,
vendor: "Google Inc. (Google)",
renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))",
});
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("hardware", { platform: "darwin" })).toBe("hardware");
const warning = String(warn.mock.calls[0]?.[0]);
expect(warning).toContain("host exposes a GPU");
expect(warning).not.toContain("--gpus all");
});
it("does not blame the GPU when the probe itself failed to launch", async () => {
// A probe that could not run is NO evidence about the GPU. Sending this
// operator to `--gpus all` would hide a broken Chrome install behind a
// phantom passthrough problem.
_setPuppeteerForTests({
launch: vi.fn().mockRejectedValue(new Error("spawn ENOENT /bad/chrome")),
} as unknown as PuppeteerNode);
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
expect(await resolveBrowserGpuMode("hardware", { platform: "linux" })).toBe("hardware");
const warning = String(warn.mock.calls[0]?.[0]);
expect(warning).toContain("GPU probe could not run");
expect(warning).toContain("hyperframes doctor");
expect(warning).not.toContain("--gpus all");
});
it("falls back to 'software' when the probe browser cannot launch", async () => {
// No chromePath, env unset, and (in the test env) no system Chrome to find
// → puppeteer.launch will throw → caller catches → software fallback.
@@ -272,31 +329,45 @@ describe("resolveBrowserGpuMode", () => {
expect(second).toBe("software");
// Reset and re-probe to confirm the test-only reset works.
_resetAutoBrowserGpuModeCacheForTests();
const third = await resolveBrowserGpuMode("hardware");
setMockWebGlProbe({ hasWebGL: true, vendor: "NVIDIA", renderer: "NVIDIA GeForce RTX 3070" });
const third = await resolveBrowserGpuMode("auto");
expect(third).toBe("hardware");
});
it("deduplicates concurrent auto-mode probes by caching the in-flight Promise", async () => {
it("deduplicates concurrent probes so only one Chrome launches", async () => {
// Parallel coordinator fires N workers via Promise.all — without Promise-
// level caching, a `--workers 4` render against a no-GPU host would launch
// 4 simultaneous probe Chromes. Verify all concurrent callers get the
// exact same Promise reference (proving the probe runs once, not N times).
const p1 = resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
// 4 simultaneous probe Chromes. Assert the launch count directly rather
// than Promise identity: `"auto"` and `"hardware"` now each adapt the
// shared cached Promise via `.then`, so identity is no longer the
// invariant — "the probe browser starts exactly once" is.
const { launch } = setMockWebGlProbe({
hasWebGL: true,
vendor: "Google Inc. (Google)",
renderer: "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device))",
});
const p2 = resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
});
const p3 = resolveBrowserGpuMode("auto", {
chromePath: "/definitely/not/a/real/chrome/binary",
browserTimeout: 2000,
});
expect(p1).toBe(p2);
expect(p2).toBe(p3);
const results = await Promise.all([p1, p2, p3]);
const results = await Promise.all([
resolveBrowserGpuMode("auto", { browserTimeout: 2000 }),
resolveBrowserGpuMode("auto", { browserTimeout: 2000 }),
resolveBrowserGpuMode("auto", { browserTimeout: 2000 }),
]);
expect(results).toEqual(["software", "software", "software"]);
expect(launch).toHaveBeenCalledTimes(1);
});
it("shares the one probe across mixed 'auto' and 'hardware' callers", async () => {
const { launch } = setMockWebGlProbe({
hasWebGL: true,
vendor: "NVIDIA",
renderer: "NVIDIA GeForce RTX 3070",
});
const results = await Promise.all([
resolveBrowserGpuMode("auto"),
resolveBrowserGpuMode("hardware"),
resolveBrowserGpuMode("auto"),
]);
expect(results).toEqual(["hardware", "hardware", "hardware"]);
expect(launch).toHaveBeenCalledTimes(1);
});
it.each([
+113 -22
View File
@@ -422,7 +422,25 @@ export const _probeBeginFrameSupportForTests = probeBeginFrameSupport;
export const _closeBrowserAfterFailedProbeForTests = closeBrowserAfterFailedProbe;
/**
* Cached *in-flight or resolved* probe Promise for `resolveBrowserGpuMode("auto", ...)`.
* Outcome of the one-shot WebGL probe.
*
* `cause` distinguishes the two ways a probe lands on `"software"`, because
* they need OPPOSITE remediation:
* - `"no-gpu"` the probe ran and Chrome reported a software renderer
* (SwiftShader / llvmpipe). Remediation: GPU passthrough.
* - `"probe-error"` the probe itself failed (Chrome couldn't launch, bad
* executable path, sandbox denied). We have NO evidence
* about the GPU either way; telling the operator to fix GPU
* passthrough would send them chasing the wrong problem.
*/
interface GpuProbeOutcome {
mode: "software" | "hardware";
cause?: "no-gpu" | "probe-error";
}
/**
* Cached *in-flight or resolved* probe Promise, shared by BOTH the `"auto"`
* and explicit `"hardware"` entry points of `resolveBrowserGpuMode`.
*
* Caching the Promise (rather than the resolved value) deduplicates concurrent
* callers the parallel coordinator runs N workers via `Promise.all`, so a
@@ -430,14 +448,13 @@ export const _closeBrowserAfterFailedProbeForTests = closeBrowserAfterFailedProb
* simultaneous probe Chromes. The first call assigns the Promise and every
* other concurrent caller awaits the same one, paying the ~240 ms probe cost
* exactly once per process lifetime.
*
* Exported for tests; production callers go through `resolveBrowserGpuMode`.
*/
let _autoBrowserGpuModeCache: Promise<"software" | "hardware"> | undefined;
let _autoBrowserGpuModeCache: Promise<GpuProbeOutcome> | undefined;
/** Test-only: reset the cached probe result. */
export function _resetAutoBrowserGpuModeCacheForTests(): void {
_autoBrowserGpuModeCache = undefined;
_unverifiedHardwareGpuWarned = false;
}
async function getPuppeteerOrNull(): Promise<PuppeteerNode | null> {
@@ -478,7 +495,7 @@ async function probeAutoBrowserGpuMode(options: {
chromePath?: string;
browserTimeout?: number;
platform?: NodeJS.Platform;
}): Promise<"software" | "hardware"> {
}): Promise<GpuProbeOutcome> {
const platform = options.platform ?? process.platform;
const browserTimeout = options.browserTimeout ?? DEFAULT_CONFIG.browserTimeout;
const executablePath = options.chromePath ?? resolveHeadlessShellPath({});
@@ -486,7 +503,7 @@ async function probeAutoBrowserGpuMode(options: {
if (ppt === null) {
logResolvedBrowserGpuMode("software", "puppeteer unavailable");
return "software";
return { mode: "software", cause: "probe-error" };
}
try {
@@ -497,24 +514,35 @@ async function probeAutoBrowserGpuMode(options: {
});
const resolved = resolveWebGlProbeMode(info);
logResolvedBrowserGpuMode(resolved, describeWebGlProbe(info));
return resolved;
return resolved === "hardware" ? { mode: "hardware" } : { mode: "software", cause: "no-gpu" };
} catch (err) {
logResolvedBrowserGpuMode("software", formatProbeFailure(err));
return "software";
return { mode: "software", cause: "probe-error" };
}
}
/**
* Resolve `browserGpuMode` to a concrete `"software" | "hardware"` answer.
*
* For `"software"` / `"hardware"` this is a pure pass-through. For `"auto"`
* it launches a tiny Chrome with the platform's hardware GPU args, runs a
* one-shot WebGL availability probe, and falls back to `"software"` if
* hardware-mode WebGL is unavailable. The Promise is cached for the process
* lifetime, so concurrent callers (parallel workers) share the same probe.
* For `"software"` this is a pure pass-through. For `"auto"` it launches a
* tiny Chrome with the platform's hardware GPU args, runs a one-shot WebGL
* availability probe, and falls back to `"software"` if hardware-mode WebGL
* is unavailable. The Promise is cached for the process lifetime, so
* concurrent callers (parallel workers) share the same probe.
*
* Any failure (Chrome launch error, navigation timeout, missing canvas API,
* etc.) is treated as a `"software"` fallback. The render path with
* `"hardware"` (an explicit `--browser-gpu` / `PRODUCER_BROWSER_GPU_MODE=
* hardware`) is honoured verbatim — the operator asked for it — but runs the
* SAME probe to VERIFY it, because Chrome's hardware GL args are advisory:
* with no usable GPU in the sandbox (no `/dev/dri`, no NVIDIA container
* runtime, missing EGL/driver libraries) Chrome silently falls back to
* software WebGL and the render just runs at CPU speed. Without this check
* the only trace is a buried `Automatic fallback to software WebGL` browser
* warning heygen-com/hyperframes#2967 rendered 19186 frames on CPU while
* `--browser-gpu` was set and nothing said so. The probe result never
* changes the returned mode; it only makes the fallback loud.
*
* Any probe failure (Chrome launch error, navigation timeout, missing canvas
* API, etc.) is treated as a `"software"` result. The render path with
* SwiftShader always works, so a misclassification toward software is the
* safe failure mode; misclassifying toward hardware would error on the real
* render.
@@ -527,21 +555,84 @@ export function resolveBrowserGpuMode(
platform?: NodeJS.Platform;
} = {},
): Promise<"software" | "hardware"> {
if (mode !== "auto") return Promise.resolve(mode);
if (_autoBrowserGpuModeCache) return _autoBrowserGpuModeCache;
if (mode === "software") return Promise.resolve(mode);
_autoBrowserGpuModeCache = probeAutoBrowserGpuMode(options);
return _autoBrowserGpuModeCache;
_autoBrowserGpuModeCache ??= probeAutoBrowserGpuMode(options);
if (mode === "auto") return _autoBrowserGpuModeCache.then((probed) => probed.mode);
return _autoBrowserGpuModeCache.then((probed) => {
// Warn once per cache lifetime, not once per caller: `createCaptureSession`
// resolves the mode for the probe browser AND every parallel worker, so
// an un-deduplicated warning prints N+1 times and buries itself.
if (probed.mode === "software" && !_unverifiedHardwareGpuWarned) {
_unverifiedHardwareGpuWarned = true;
console.warn(
buildUnverifiedHardwareGpuWarning(options.platform ?? process.platform, probed.cause),
);
}
return "hardware";
});
}
/**
* Single observability surface for the auto-detect outcome. Logged exactly
* Latch for the explicit-hardware-probed-to-software warning: fires once per
* cache lifetime (re-armed by `_resetAutoBrowserGpuModeCacheForTests`).
*/
let _unverifiedHardwareGpuWarned = false;
/**
* Warning text for "you asked for hardware GPU and we could not confirm it".
*
* Splits on `cause` because the two failure shapes need opposite remediation.
* A probe that RAN and saw SwiftShader is a GPU-passthrough problem. A probe
* that could not run tells us nothing about the GPU pointing that operator
* at `--gpus all` would send them chasing a phantom while their Chrome
* install is the actual fault.
*/
function buildUnverifiedHardwareGpuWarning(
platform: NodeJS.Platform | string,
cause: GpuProbeOutcome["cause"],
): string {
if (cause === "probe-error") {
return (
"[hyperframes] browserGpuMode=hardware was requested, but the GPU probe could not run, " +
"so hardware acceleration is UNVERIFIED — if Chrome falls back to software WebGL the " +
"capture will run at CPU speed. Honouring the explicit request anyway.\n" +
" This is a probe failure, not evidence of a missing GPU: see the " +
"`browserGpuMode probe → software (probe failed ...)` line above for the underlying " +
"error, which usually means Chrome could not launch (bad HYPERFRAMES_BROWSER_PATH, " +
"missing shared libraries, or a denied sandbox) rather than a GPU problem.\n" +
" Run `hyperframes doctor` to check the Chrome install."
);
}
const remediation =
platform === "linux"
? "Inside Docker, the container needs GPU passthrough: `--gpus all` with the NVIDIA " +
"Container Toolkit installed, or `--device /dev/dri` for Mesa/AMD/Intel. The image " +
"also needs the matching userspace driver + libEGL. Verify with " +
"`hyperframes render --browser-gpu` and watch for this warning disappearing."
: "Check that the host exposes a GPU to this process and that the graphics drivers are " +
"installed.";
return (
"[hyperframes] browserGpuMode=hardware was requested, but the WebGL probe found no " +
"hardware GPU — Chrome will silently fall back to software WebGL and the capture will " +
"run at CPU speed. Honouring the explicit request anyway.\n" +
` ${remediation}\n` +
" Pass --no-browser-gpu to select deterministic SwiftShader instead of waiting on a " +
"hardware path that is not there."
);
}
/**
* Single observability surface for the GPU probe outcome. Logged exactly
* once per process (the probe runs once); without this line, a regression
* to "always software even with a GPU present" would be invisible in
* production. Goes to stderr to stay out of stdout pipelines.
* production. Goes to stderr to stay out of stdout pipelines. Says "probe"
* rather than "auto" because explicit `browserGpuMode=hardware` runs the
* same probe to verify itself.
*/
function logResolvedBrowserGpuMode(resolved: "hardware" | "software", reason: string): void {
console.error(`[hyperframes] browserGpuMode auto${resolved} (${reason})`);
console.error(`[hyperframes] browserGpuMode probe${resolved} (${reason})`);
}
function createBrowserLaunchFingerprint(
@@ -12,7 +12,10 @@
import { describe, expect, it } from "vitest";
import {
LOCKED_WARMUP_TICKS,
deriveBeginFrameTimelineTicks,
deriveBeginFrameTimeTicks,
driveWarmupTicks,
prepareBeginFrameTimeline,
warmupFrameTimeTicks,
type WarmupTickState,
} from "./frameCapture.js";
@@ -172,3 +175,81 @@ describe("driveWarmupTicks — locked", () => {
expect(warmupFrameTimeTicks(state, 33)).toBe(LOCKED_WARMUP_TICKS * 33);
});
});
describe("deriveBeginFrameTimeTicks", () => {
const warmupIntervalMs = 33;
const state: WarmupTickState = {
running: false,
ticks: LOCKED_WARMUP_TICKS,
};
const expectMonotonicTimeline = (
warmupState: WarmupTickState,
captureIntervalMs: number,
): void => {
const timeline = deriveBeginFrameTimelineTicks(
warmupState,
warmupIntervalMs,
captureIntervalMs,
);
const lastWarmupTick = (warmupState.ticks - 1) * warmupIntervalMs;
expect(timeline.commit).toBeGreaterThan(lastWarmupTick);
expect(timeline.probe).toBeGreaterThan(timeline.commit);
expect(timeline.capture).toBeGreaterThan(timeline.probe);
};
it.each([60, 120, 240, 60_000 / 1001])(
"keeps warmup, commit, probe, and capture monotonic at %ifps",
(fps) => {
expectMonotonicTimeline(state, 1000 / fps);
},
);
it.each([24, 30, 30_000 / 1001, 31, 32])(
"preserves the legacy capture baseline when it is already monotonic at %ifps",
(fps) => {
const captureIntervalMs = 1000 / fps;
expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo(
(LOCKED_WARMUP_TICKS + 10) * captureIntervalMs,
);
},
);
it("keeps an unlocked warmup timeline monotonic", () => {
const unlockedState: WarmupTickState = { running: false, ticks: 7 };
expectMonotonicTimeline(unlockedState, 1000 / 60);
});
it("raises the capture baseline only when the warmup clock is ahead", () => {
const captureIntervalMs = 1000 / 60;
expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo(
LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs,
);
});
it("raises the baseline just above the safe legacy boundary", () => {
const captureIntervalMs = 1000 / 33;
expect(deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs)).toBeCloseTo(
LOCKED_WARMUP_TICKS * warmupIntervalMs + 10 * captureIntervalMs,
);
});
it("wires the canonical capture and commit ticks into session initialization", () => {
const session = {
beginFrameIntervalMs: 1000 / 60,
beginFrameTimeTicks: 0,
};
const prepared = prepareBeginFrameTimeline(session, state, warmupIntervalMs);
expect(session.beginFrameTimeTicks).toBe(prepared.timeline.capture);
expect(prepared.commitParams).toEqual({
frameTimeTicks: prepared.timeline.commit,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
});
expect(prepared.timeline.commit).toBeGreaterThan((LOCKED_WARMUP_TICKS - 1) * warmupIntervalMs);
});
});
+106 -9
View File
@@ -632,6 +632,101 @@ export function warmupFrameTimeTicks(state: WarmupTickState, intervalMs: number)
return state.ticks * intervalMs;
}
const BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS = 10;
const BEGIN_FRAME_COMMIT_LEAD_INTERVALS = 6;
const BEGIN_FRAME_PROBE_LEAD_INTERVALS = 5;
export interface BeginFrameTimelineTicks {
capture: number;
commit: number;
probe: number;
}
export interface PreparedBeginFrameTimeline {
commitParams: {
frameTimeTicks: number;
interval: number;
noDisplayUpdates: false;
};
timeline: BeginFrameTimelineTicks;
}
/**
* Place frame zero after the warmup clock while retaining capture-rate-sized
* headroom for the visual commit and liveness probe ticks that precede it.
*
* The warmup and capture intervals can differ (warmup currently runs at a
* fixed 33ms). Basing both clocks on the capture interval would move time
* backwards whenever the output frame rate is faster than the warmup rate.
*/
export function deriveBeginFrameTimeTicks(
state: WarmupTickState,
warmupIntervalMs: number,
captureIntervalMs: number,
): number {
const legacyCaptureTimeTicks =
(state.ticks + BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS) * captureIntervalMs;
const legacyCommitTimeTicks = deriveBeginFrameCommitTimeTicks(
legacyCaptureTimeTicks,
captureIntervalMs,
);
const lastWarmupTimeTicks = Math.max(0, state.ticks - 1) * warmupIntervalMs;
if (legacyCommitTimeTicks > lastWarmupTimeTicks) return legacyCaptureTimeTicks;
const monotonicCaptureTimeTicks =
warmupFrameTimeTicks(state, warmupIntervalMs) +
BEGIN_FRAME_CAPTURE_HEADROOM_INTERVALS * captureIntervalMs;
return monotonicCaptureTimeTicks;
}
function deriveBeginFrameCommitTimeTicks(
captureTimeTicks: number,
captureIntervalMs: number,
): number {
return captureTimeTicks - BEGIN_FRAME_COMMIT_LEAD_INTERVALS * captureIntervalMs;
}
export function deriveBeginFrameProbeTimeTicks(
captureTimeTicks: number,
captureIntervalMs: number,
): number {
return Math.max(0, captureTimeTicks - BEGIN_FRAME_PROBE_LEAD_INTERVALS * captureIntervalMs);
}
export function deriveBeginFrameTimelineTicks(
state: WarmupTickState,
warmupIntervalMs: number,
captureIntervalMs: number,
): BeginFrameTimelineTicks {
const capture = deriveBeginFrameTimeTicks(state, warmupIntervalMs, captureIntervalMs);
return {
capture,
commit: deriveBeginFrameCommitTimeTicks(capture, captureIntervalMs),
probe: deriveBeginFrameProbeTimeTicks(capture, captureIntervalMs),
};
}
export function prepareBeginFrameTimeline(
session: Pick<CaptureSession, "beginFrameIntervalMs" | "beginFrameTimeTicks">,
state: WarmupTickState,
warmupIntervalMs: number,
): PreparedBeginFrameTimeline {
const timeline = deriveBeginFrameTimelineTicks(
state,
warmupIntervalMs,
session.beginFrameIntervalMs,
);
session.beginFrameTimeTicks = timeline.capture;
return {
timeline,
commitParams: {
frameTimeTicks: timeline.commit,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
},
};
}
export async function driveWarmupTicks(
options: WarmupTickOptions,
state: WarmupTickState,
@@ -2277,10 +2372,16 @@ export async function initializeSession(session: CaptureSession): Promise<void>
warmupState.running = false;
await warmupLoopPromise.catch(() => {});
// Set base frame time ticks past warmup range. Locked mode pins to the
// constant so chunk workers on different hosts compute the same baseline.
const baseTickCount = lockWarmupTicks ? LOCKED_WARMUP_TICKS : warmupState.ticks;
session.beginFrameTimeTicks = (baseTickCount + 10) * session.beginFrameIntervalMs;
// Preserve the legacy baseline when it is already safe. Otherwise continue
// from the clock actually used by warmup, then reserve capture-rate headroom
// for the commit and probe ticks below. Locked mode still produces an
// identical timeline on every host because its driver ends at exactly
// LOCKED_WARMUP_TICKS.
const preparedBeginFrameTimeline = prepareBeginFrameTimeline(
session,
warmupState,
warmupIntervalMs,
);
// drawElement or transparent-background init — runs after page is fully ready.
// IMPORTANT: must stay after beginFrameTimeTicks is set above. The per-frame
@@ -2318,11 +2419,7 @@ export async function initializeSession(session: CaptureSession): Promise<void>
// `-6·interval` the order stays warmup < commit < probe < capture.
await ensureRenderFrameSiblings(page);
const commitCdp = await getCdpSession(page);
await commitCdp.send("HeadlessExperimental.beginFrame", {
frameTimeTicks: session.beginFrameTimeTicks - 6 * session.beginFrameIntervalMs,
interval: session.beginFrameIntervalMs,
noDisplayUpdates: false,
});
await commitCdp.send("HeadlessExperimental.beginFrame", preparedBeginFrameTimeline.commitParams);
session.isInitialized = true;
}
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import { classifyFfmpegSpawnError } from "./videoFrameExtractor.js";
import { UrlDownloadError } from "../utils/urlDownloader.js";
import { classifyFfmpegSpawnError, classifyVideoExtractionError } from "./videoFrameExtractor.js";
describe("classifyFfmpegSpawnError", () => {
it.each(["ENOENT", "EACCES", "ENOEXEC", "UNKNOWN"])(
@@ -18,3 +19,21 @@ describe("classifyFfmpegSpawnError", () => {
});
});
});
describe("classifyVideoExtractionError download integrity", () => {
it("keeps deterministic HTML payloads non-retryable and user-owned as invalid media", () => {
const classified = classifyVideoExtractionError(
new UrlDownloadError("invalid_payload", false, "HTML payload"),
);
expect(classified).toMatchObject({ kind: "invalid_media", retryable: false });
});
it.each(["range_protocol", "length_mismatch", "hash_mismatch"] as const)(
"keeps %s retryable after the downloader's one clean refetch is exhausted",
(kind) => {
expect(
classifyVideoExtractionError(new UrlDownloadError(kind, true, "integrity failure")),
).toMatchObject({ kind: "download_transient", retryable: true });
},
);
});
@@ -28,7 +28,12 @@ import {
isHdrColorSpace as isHdrColorSpaceUtil,
type HdrTransfer,
} from "../utils/hdr.js";
import { downloadToTemp, isHttpUrl, UrlDownloadError } from "../utils/urlDownloader.js";
import {
downloadToTemp,
isHttpUrl,
UrlDownloadError,
writeUrlDownloadTelemetry,
} from "../utils/urlDownloader.js";
import { runFfmpeg } from "../utils/runFfmpeg.js";
import { DEFAULT_CONFIG, type EngineConfig } from "../config.js";
import { unwrapTemplate } from "../utils/htmlTemplate.js";
@@ -342,6 +347,14 @@ export function classifyVideoExtractionError(error: unknown): VideoSourceExtract
diagnostic,
);
}
if (error.kind === "invalid_payload") {
return new VideoSourceExtractionError(
"invalid_media",
false,
"Video source download returned a non-media payload",
diagnostic,
);
}
if (error.retryable) {
return new VideoSourceExtractionError(
"download_transient",
@@ -1424,8 +1437,13 @@ export async function extractAllVideoFrames(
if (isHttpUrl(videoPath)) {
const downloadDir = join(options.outputDir, "_downloads");
mkdirSync(downloadDir, { recursive: true });
videoPath = await downloadToTemp(videoPath, downloadDir, undefined, signal, () =>
recordTransientRetries(1),
videoPath = await downloadToTemp(
videoPath,
downloadDir,
undefined,
signal,
() => recordTransientRetries(1),
{ onTelemetry: writeUrlDownloadTelemetry },
);
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/gcp-cloud-run",
"version": "0.7.92",
"version": "0.7.94",
"description": "Google Cloud Run + Workflows adapter for HyperFrames distributed rendering — request handler, client-side SDK, and Terraform module.",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/lint",
"version": "0.7.92",
"version": "0.7.94",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/parsers",
"version": "0.7.92",
"version": "0.7.94",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/player",
"version": "0.7.92",
"version": "0.7.94",
"description": "Embeddable web component for HyperFrames compositions",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/producer",
"version": "0.7.92",
"version": "0.7.94",
"description": "HTML-to-video rendering engine using Chrome's BeginFrame API",
"repository": {
"type": "git",
@@ -51,6 +51,7 @@ import {
createCaptureSession,
createFrameLookupTable,
createVideoFrameInjector,
deriveBeginFrameProbeTimeTicks,
type EngineConfig,
type ExtractedFrames,
type FrameLookupTable,
@@ -316,7 +317,10 @@ export async function beginFrameSessionNeedsScreenshotFallback(
Number(process.env.PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS) > 0
? Number(process.env.PRODUCER_BEGINFRAME_PROBE_TIMEOUT_MS)
: 30_000;
const probeTick = Math.max(0, session.beginFrameTimeTicks - 5 * session.beginFrameIntervalMs);
const probeTick = deriveBeginFrameProbeTimeTicks(
session.beginFrameTimeTicks,
session.beginFrameIntervalMs,
);
return !(await probe(session.page, timeoutMs, probeTick, session.beginFrameIntervalMs));
}
@@ -85,6 +85,22 @@ describe("discoverMediaFromBrowser", () => {
});
});
function validTestMediaResponse(): Response {
const bytes = new Uint8Array([
0, 0, 0, 24, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d, 0, 0, 0, 0, 0x69, 0x73, 0x6f, 0x6d,
0x6d, 0x70, 0x34, 0x32,
]);
return new Response(bytes, { status: 200 });
}
function validTestImageResponse(): Response {
const png = Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=",
"base64",
);
return new Response(png, { status: 200 });
}
describe("injectSdkPositionEditsRenderScript", () => {
it("injects before </body> when SDK position-edit markers are present", () => {
const html =
@@ -1424,7 +1440,7 @@ describe("localizeRemoteMediaSources", () => {
it("rewrites remote <video> src to _remote_media path when download succeeds", async () => {
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestMediaResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-dl-ok-"));
const html = `<video id="v1" src="https://media-ok.example.com/a/clip.mp4" data-start="0" data-end="10" muted></video>`;
@@ -1447,13 +1463,42 @@ describe("localizeRemoteMediaSources", () => {
expect(remoteMediaAssets.size).toBe(0);
});
it("logs only a safe fingerprint and host for a signed-URL media failure", async () => {
const originalFetch = globalThis.fetch;
const originalWarn = defaultLogger.warn;
const warnings: unknown[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () =>
new Response("<!doctype html><html><body>expired</body></html>", { status: 200 });
defaultLogger.warn = (message, meta) => warnings.push({ message, meta });
try {
const dl = mkdtempSync(join(tmpdir(), "hf-dl-safe-log-"));
const url = "https://cdn.example/private/customer.mp4?X-Amz-Signature=super-secret-signature";
const html = `<video id="v1" src="${url}" data-start="0" data-end="10"></video>`;
const { html: result, remoteMediaAssets } = await localizeRemoteMediaSources(html, dl);
expect(result).toContain(url);
expect(remoteMediaAssets.size).toBe(0);
expect(warnings).toHaveLength(1);
const serialized = JSON.stringify(warnings);
expect(serialized).toContain("cdn.example");
expect(serialized).toContain("urlFingerprint");
expect(serialized).not.toContain("customer.mp4");
expect(serialized).not.toContain("super-secret-signature");
} finally {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = originalFetch;
defaultLogger.warn = originalWarn;
}
});
it("deduplicates: two tags with the same src URL → one download", async () => {
const orig = globalThis.fetch;
let fetchCount = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => {
fetchCount++;
return new Response(new Uint8Array(100), { status: 200 });
return validTestMediaResponse();
};
try {
const dl = mkdtempSync(join(tmpdir(), "hf-dl-dedup-"));
@@ -1479,7 +1524,7 @@ describe("localizeRemoteMediaSources", () => {
it("rewrites src in both double-quoted and single-quoted attributes", async () => {
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestMediaResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-dl-quotes-"));
const html = `<video id="v1" src="https://q.example.com/c/dq.mp4" data-start="0" data-end="10" muted></video>
@@ -1520,7 +1565,7 @@ describe("localizeRemoteImageSources", () => {
it("rewrites remote <img> src to _remote_media path when download succeeds", async () => {
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestImageResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-ok-"));
const html = `<img class="hero" src="https://img-ok.example.com/photo.png" />`;
@@ -1549,7 +1594,7 @@ describe("localizeRemoteImageSources", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => {
fetchCount++;
return new Response(new Uint8Array(100), { status: 200 });
return validTestImageResponse();
};
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-dedup-"));
@@ -1583,7 +1628,7 @@ describe("localizeRemoteImageSources", () => {
it("rewrites both double-quoted and single-quoted src attributes", async () => {
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestImageResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-quotes-"));
const html = `<img src="https://q-img.example.com/dq.png" />
@@ -1607,7 +1652,7 @@ describe("localizeRemoteImageSources", () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => {
fetchCount++;
return new Response(new Uint8Array(100), { status: 200 });
return validTestImageResponse();
};
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-datasrc-"));
@@ -1628,7 +1673,7 @@ describe("localizeRemoteImageSources", () => {
// <img> tags with `class` before `src`. Regex must not assume src position.
const orig = globalThis.fetch;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => new Response(new Uint8Array(100), { status: 200 });
(globalThis as any).fetch = async () => validTestImageResponse();
try {
const dl = mkdtempSync(join(tmpdir(), "hf-img-attr-order-"));
const html = `<img class="kobe-cutout" alt="kobe" src="https://astral.example.com/d828bca.png" />`;
@@ -1798,6 +1843,7 @@ h1 { font-size: 2rem; }`;
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
// The <link> tag should be replaced with an inline <style> containing the @font-face
expect(result).not.toContain(`href="${STYLESHEET_URL}"`);
expect(result).not.toContain(STYLESHEET_URL);
expect(result).not.toContain("<link");
expect(result).toContain("@font-face");
expect(result).toContain("CustomFont");
@@ -1828,6 +1874,57 @@ h1 { font-size: 2rem; }`;
}
});
it("rejects a stylesheet redirect to a private host before the second request", async () => {
const STYLESHEET_URL = "https://styles.example.com/fonts.css";
const orig = globalThis.fetch;
let fetchCount = 0;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () => {
fetchCount++;
return new Response(null, {
status: 302,
headers: { location: "https://169.254.169.254/latest/meta-data/" },
});
};
try {
const dl = mkdtempSync(join(tmpdir(), "hf-ff-private-redirect-"));
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
const { html: result, remoteMediaAssets } = await localizeRemoteFontFaces(html, dl);
expect(fetchCount).toBe(1);
expect(result).toBe(html);
expect(remoteMediaAssets.size).toBe(0);
} finally {
globalThis.fetch = orig;
}
});
it("does not log a signed stylesheet path or query on failure", async () => {
const STYLESHEET_URL =
"https://styles.example.com/private/customer.css?X-Amz-Signature=super-secret";
const originalFetch = globalThis.fetch;
const originalWarn = defaultLogger.warn;
const warnings: unknown[] = [];
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(globalThis as any).fetch = async () =>
new Response(null, { status: 503, statusText: STYLESHEET_URL });
defaultLogger.warn = (message, meta) => warnings.push({ message, meta });
try {
const dl = mkdtempSync(join(tmpdir(), "hf-ff-safe-style-log-"));
const html = `<link rel="stylesheet" href="${STYLESHEET_URL}">`;
await localizeRemoteFontFaces(html, dl);
const serialized = JSON.stringify(warnings);
expect(serialized).toContain("styles.example.com");
expect(serialized).toContain("urlFingerprint");
expect(serialized).not.toContain("customer.css");
expect(serialized).not.toContain("super-secret");
} finally {
globalThis.fetch = originalFetch;
defaultLogger.warn = originalWarn;
}
});
it("keeps <link> tag when external stylesheet has no @font-face rules", async () => {
const STYLESHEET_URL = "https://cdn.example.com/reset.css";
const orig = globalThis.fetch;
+43 -49
View File
@@ -53,7 +53,13 @@ import {
analyzeKeyframeIntervals,
probeMediaProfile,
} from "@hyperframes/engine";
import { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "../utils/urlDownloader.js";
import {
downloadToTemp,
fetchPublicHttpsText,
isHttpUrl,
safeDownloadUrlIdentity,
type UrlDownloadTelemetry,
} from "../utils/urlDownloader.js";
import type { Page } from "puppeteer-core";
import {
injectDeterministicFontFaces,
@@ -66,6 +72,10 @@ import { defaultLogger, type ProducerLogger } from "../logger.js";
import { assertAssetMediaTypeProfile } from "./assetMediaType.js";
import { withMediaProbeSlot } from "../utils/mediaProbeConcurrency.js";
function logRemoteDownloadTelemetry(event: UrlDownloadTelemetry): void {
defaultLogger.info("[Compiler] Remote asset download integrity", { ...event });
}
export interface CompiledComposition {
html: string;
subCompositions: Map<string, string>;
@@ -419,7 +429,9 @@ async function resolveMediaDuration(
if (isHttpUrl(src)) {
if (!existsSync(downloadDir)) mkdirSync(downloadDir, { recursive: true });
try {
filePath = await downloadToTemp(src, downloadDir);
filePath = await downloadToTemp(src, downloadDir, undefined, undefined, undefined, {
onTelemetry: logRemoteDownloadTelemetry,
});
} catch {
// Download failed (e.g. 404 placeholder URL) — skip gracefully.
// The element will get duration 0 and be excluded from the render.
@@ -1306,14 +1318,17 @@ async function downloadAndRewriteUrls(
await Promise.all(
[...urlSet].map(async (url) => {
try {
const localPath = await downloadToTemp(url, remoteDir);
const localPath = await downloadToTemp(url, remoteDir, undefined, undefined, undefined, {
onTelemetry: logRemoteDownloadTelemetry,
});
urlToLocal.set(url, localPath);
} catch (err) {
defaultLogger.warn(
`[Compiler] ${warnLabel} ${url} — using original URL as fallback. ${
err instanceof Error ? err.message : String(err)
}`,
);
const identity = safeDownloadUrlIdentity(url);
defaultLogger.warn(`[Compiler] ${warnLabel} — using original URL as fallback.`, {
urlFingerprint: identity.urlFingerprint,
host: identity.host,
error: err instanceof Error ? err.message : String(err),
});
}
}),
);
@@ -1369,7 +1384,7 @@ export async function localizeRemoteMediaSources(
urlSet,
html,
join(downloadDir, REMOTE_MEDIA_SUBDIR),
"Remote media download failed for",
"Remote media download failed",
"Localized remote media source(s)",
);
}
@@ -1412,7 +1427,7 @@ export async function localizeRemoteImageSources(
urlSet,
html,
join(downloadDir, REMOTE_MEDIA_SUBDIR),
"Remote image download failed for",
"Remote image download failed",
"Localized remote image source(s)",
);
}
@@ -1450,7 +1465,7 @@ export async function localizeRemoteBackgroundImages(
urlSet,
html,
join(downloadDir, REMOTE_MEDIA_SUBDIR),
"Remote background-image download failed for",
"Remote background-image download failed",
"Localized remote background-image(s)",
// Quoted url('..')/url("..") are rewritten by downloadAndRewriteUrls' default
// replaceAll; this handles the unquoted url(https://..) form.
@@ -1492,42 +1507,18 @@ function isGoogleFontsUrl(href: string): boolean {
const MAX_STYLESHEET_BYTES = 2 * 1024 * 1024;
async function fetchExternalStylesheetCss(href: string): Promise<string | null> {
const identity = safeDownloadUrlIdentity(href);
try {
assertPublicHttpsUrl(href);
} catch {
return null;
}
try {
const response = await fetch(href, {
signal: AbortSignal.timeout(15_000),
return await fetchPublicHttpsText(href, {
maxBytes: MAX_STYLESHEET_BYTES,
timeoutMs: 15_000,
});
if (!response.ok) {
defaultLogger.warn(
`[Compiler] External stylesheet fetch failed for ${href} — HTTP ${response.status}`,
);
return null;
}
const contentLength = response.headers.get("content-length");
if (contentLength && parseInt(contentLength, 10) > MAX_STYLESHEET_BYTES) {
defaultLogger.warn(
`[Compiler] External stylesheet too large (${contentLength} bytes): ${href}`,
);
return null;
}
const text = await response.text();
if (text.length > MAX_STYLESHEET_BYTES) {
defaultLogger.warn(
`[Compiler] External stylesheet too large (${text.length} bytes): ${href}`,
);
return null;
}
return text;
} catch (err) {
defaultLogger.warn(
`[Compiler] External stylesheet fetch failed for ${href}${
err instanceof Error ? err.message : String(err)
}`,
);
defaultLogger.warn("[Compiler] External stylesheet fetch failed — preserving link tag.", {
urlFingerprint: identity.urlFingerprint,
host: identity.host,
error: err instanceof Error ? err.message : String(err),
});
return null;
}
}
@@ -1608,11 +1599,14 @@ async function inlineExternalFontStylesheets(html: string): Promise<string> {
if (css === null) continue;
const fontFaceBlocks = extractFontFaceBlocks(css);
if (fontFaceBlocks.length === 0) continue;
const inlineStyle = `<style>/* Inlined from ${href} */\n${fontFaceBlocks.join("\n")}\n</style>`;
const identity = safeDownloadUrlIdentity(href);
const inlineStyle = `<style>/* Inlined external font stylesheet */\n${fontFaceBlocks.join("\n")}\n</style>`;
result = result.replace(fullMatch, inlineStyle);
defaultLogger.info(
`[Compiler] Inlined ${fontFaceBlocks.length} @font-face rule(s) from external stylesheet: ${href}`,
);
defaultLogger.info("[Compiler] Inlined external @font-face rule(s)", {
count: fontFaceBlocks.length,
urlFingerprint: identity.urlFingerprint,
host: identity.host,
});
}
return result;
}
@@ -1672,7 +1666,7 @@ export async function localizeRemoteFontFaces(
urlSet,
processed,
join(downloadDir, REMOTE_MEDIA_SUBDIR),
"Remote font download failed for",
"Remote font download failed",
"Localized remote font face(s)",
(h, url, relPath) => h.replaceAll(`url(${url})`, `url("${relPath}")`),
);
@@ -46,6 +46,12 @@ let createSessionFailUntilAttempt = 0;
let createSessionError: Error | null = null;
let closeCaptureSessionCallCount = 0;
let probeBeginFrameAlive = true;
const beginFrameProbeCalls: Array<{
page: unknown;
timeoutMs: number;
frameTimeTicks: number;
intervalMs: number;
}> = [];
const createdSessions: MockSession[] = [];
const closedSessions: MockSession[] = [];
const durationProbeSessions: MockSession[] = [];
@@ -59,6 +65,7 @@ function resetRetryMocks() {
createSessionError = null;
closeCaptureSessionCallCount = 0;
probeBeginFrameAlive = true;
beginFrameProbeCalls.length = 0;
createdSessions.length = 0;
closedSessions.length = 0;
durationProbeSessions.length = 0;
@@ -137,7 +144,15 @@ mock.module("@hyperframes/engine", () => ({
closeCaptureSessionCallCount++;
closedSessions.push(session);
},
probeBeginFrameLiveness: async () => probeBeginFrameAlive,
probeBeginFrameLiveness: async (
page: unknown,
timeoutMs: number,
frameTimeTicks: number,
intervalMs: number,
) => {
beginFrameProbeCalls.push({ page, timeoutMs, frameTimeTicks, intervalMs });
return probeBeginFrameAlive;
},
// Mirror of the real engine classifier. Canonical tests + pattern list
// live in frameCapture-transientErrors.test.ts — update both if patterns change.
isTransientBrowserError: (error: unknown) => {
@@ -678,6 +693,14 @@ describe("runProbeStage — transient browser error retry (#1687)", () => {
expect(createSessionCallCount).toBe(2);
expect(closedSessions).toEqual([createdSessions[0]]);
expect(capturedCfgs[1]).toMatchObject({ forceScreenshot: true });
expect(beginFrameProbeCalls).toEqual([
{
page: createdSessions[0]?.page,
timeoutMs: 30_000,
frameTimeTicks: 95,
intervalMs: 1,
},
]);
expect(durationProbeSessions).toEqual([createdSessions[1]]);
expect(result.probeSession).toBe(createdSessions[1]);
expect(result.beginFrameStalled).toBe(true);
@@ -36,6 +36,7 @@ import {
type EngineConfig,
closeCaptureSession,
createCaptureSession,
deriveBeginFrameProbeTimeTicks,
getCompositionDuration,
initializeSession,
isTransientBrowserError,
@@ -388,9 +389,9 @@ export async function runProbeStage(input: ProbeStageInput): Promise<ProbeStageR
const livenessStart = Date.now();
// Tick inside the post-warmup cushion: warmup < probe < first capture
// keeps the session's BeginFrame frameTimeTicks monotonic.
const probeTick = Math.max(
0,
probeSession.beginFrameTimeTicks - 5 * probeSession.beginFrameIntervalMs,
const probeTick = deriveBeginFrameProbeTimeTicks(
probeSession.beginFrameTimeTicks,
probeSession.beginFrameIntervalMs,
);
const alive = await probeBeginFrameLiveness(
probeSession.page,
+8 -1
View File
@@ -2,4 +2,11 @@
* Re-exported from @hyperframes/engine.
* @see engine/src/utils/urlDownloader.ts for implementation.
*/
export { assertPublicHttpsUrl, downloadToTemp, isHttpUrl } from "@hyperframes/engine";
export {
downloadToTemp,
fetchPublicHttpsText,
isHttpUrl,
safeDownloadUrlIdentity,
writeUrlDownloadTelemetry,
type UrlDownloadTelemetry,
} from "@hyperframes/engine";
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/sdk",
"version": "0.7.92",
"version": "0.7.94",
"description": "Headless, framework-neutral HyperFrames composition editing engine",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/shader-transitions",
"version": "0.7.92",
"version": "0.7.94",
"description": "WebGL shader transitions for HyperFrames compositions",
"repository": {
"type": "git",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio-server",
"version": "0.7.92",
"version": "0.7.94",
"repository": {
"type": "git",
"url": "https://github.com/heygen-com/hyperframes",
+2 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@hyperframes/studio",
"version": "0.7.92",
"version": "0.7.94",
"description": "",
"repository": {
"type": "git",
@@ -85,6 +85,7 @@
"@types/react-dom": "19",
"@vitejs/plugin-react": "^4.0.0",
"autoprefixer": "^10.4.0",
"fake-indexeddb": "^6.2.5",
"postcss": "^8.4.0",
"puppeteer-core": "^25.2.1",
"tailwindcss": "^3.4.0",
+14 -14
View File
@@ -4,6 +4,7 @@ import { useRenderQueue } from "./components/renders/useRenderQueue";
import { usePlayerStore } from "./player";
import { StudioOverlays } from "./components/StudioOverlays";
import { SaveQueuePausedBanner } from "./components/SaveQueuePausedBanner";
import { ExternalFileConflictBanner } from "./components/ExternalFileConflictBanner";
import { useCaptionStore } from "./captions/store";
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
import { usePersistentEditHistory } from "./hooks/usePersistentEditHistory";
@@ -22,6 +23,7 @@ import type { BlockPreviewInfo } from "./components/sidebar/BlocksTab";
import { useDomEditSession } from "./hooks/useDomEditSession";
import { useSdkSelectionSync } from "./hooks/useSdkSelectionSync";
import { useStudioSdkSessions } from "./hooks/useStudioSdkSessions";
import { useStudioExternalFileChanges } from "./hooks/useStudioExternalFileChanges";
import { useBlockHandlers } from "./hooks/useBlockHandlers";
import { useAppHotkeys } from "./hooks/useAppHotkeys";
import { useClipboard } from "./hooks/useClipboard";
@@ -56,25 +58,19 @@ import { FileManagerProvider } from "./contexts/FileManagerContext";
import { DomEditProvider } from "./contexts/DomEditContext";
import { StudioSplash } from "./components/StudioSplash";
import { useServerConnection } from "./hooks/useServerConnection";
import { useStudioSessionStart } from "./hooks/useStudioSessionStart";
import { useTimelineAddAtPlayhead } from "./hooks/useTimelineAddAtPlayhead";
import {
normalizeStudioCompositionPath,
readStudioUrlStateFromWindow,
resolveMasterCompositionPath,
} from "./utils/studioUrlState";
import { trackStudioSessionStart } from "./telemetry/events";
import { hasFiredSessionStart, markSessionStartFired } from "./telemetry/config";
// fallow-ignore-next-line complexity
export function StudioApp() {
const { projectId, resolving, waitingForServer } = useServerConnection();
const initialUrlStateRef = useRef(readStudioUrlStateFromWindow());
const viewModeValue = useViewModeState();
useEffect(() => {
if (resolving || waitingForServer) return;
if (hasFiredSessionStart()) return;
markSessionStartFired();
trackStudioSessionStart({ has_project: projectId != null });
}, [projectId, resolving, waitingForServer]);
useStudioSessionStart(projectId, resolving, waitingForServer);
const [activeCompPath, setActiveCompPath] = useState<string | null>(null);
const [activeCompPathHydrated, setActiveCompPathHydrated] = useState(
() => initialUrlStateRef.current.activeCompPath == null,
@@ -130,7 +126,6 @@ export function StudioApp() {
const { sdkHandle, editFlowSdkSession } = useStudioSdkSessions(
projectId,
activeCompPath,
domEditSaveTimestampRef,
masterCompPath,
);
useEffect(() => {
@@ -144,20 +139,24 @@ export function StudioApp() {
setActiveCompPathHydrated(true);
}, [activeCompPathHydrated, fileManager.fileTree, fileManager.fileTreeLoaded]);
const previewPersistence = usePreviewPersistence({
projectId,
showToast,
readOptionalProjectFile: fileManager.readOptionalProjectFile,
writeProjectFile: fileManager.writeProjectFile,
recordEdit: editHistory.recordEdit,
previewIframeRef,
activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview: () => setRefreshKey((k) => k + 1),
});
const externalFileChanges = useStudioExternalFileChanges({
projectId,
activeCompPath,
masterCompPath,
fileManager,
previewPersistence,
pendingTimelineEditPathRef,
reloadPreview,
});
const invalidateGsapCacheRef = useRef<() => void>(() => {});
// Stable identity — what the ref indirection is for. An inline arrow re-created
// the memoized timeline handlers (it is in their deps) on every render.
const invalidateGsapCache = useCallback(() => invalidateGsapCacheRef.current(), []);
const timelineEditing = useTimelineEditing({
projectId,
@@ -483,12 +482,13 @@ export function StudioApp() {
})();
}}
/>
{previewPersistence.domEditSaveQueuePaused && (
{previewPersistence.domEditSaveQueuePaused && !externalFileChanges.blocked && (
<SaveQueuePausedBanner
message={previewPersistence.domEditSaveQueuePaused}
onRetry={previewPersistence.resetDomEditSaveQueueBreaker}
/>
)}
<ExternalFileConflictBanner coordinator={externalFileChanges} />
{viewModeValue.viewMode === "storyboard" && (
<StoryboardView
projectId={projectId}
@@ -52,7 +52,7 @@ export function DesignPanelPromoteProvider({
children: ReactNode;
}) {
const targetPath = selection?.sourceFile || activeCompPath || "index.html";
const handle = useSdkSession(projectId, targetPath, persistDeps.domEditSaveTimestampRef);
const handle = useSdkSession(projectId, targetPath);
const rawPersist = useVariablesPersist({
...persistDeps,
sdkSession: handle.session,
@@ -0,0 +1,142 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, describe, expect, it, vi } from "vitest";
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
import type { ExternalFileChangeCoordinatorHandle } from "../hooks/useExternalFileChangeCoordinator";
import { ExternalFileConflictBanner } from "./ExternalFileConflictBanner";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("ExternalFileConflictBanner", () => {
afterEach(() => {
document.body.replaceChildren();
vi.restoreAllMocks();
});
it("keeps destructive choices explicit and exposes both full versions for review", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const conflict = new StudioFileConflictError({
filePath: "index.html",
currentVersion: "v2",
currentContent: "<html>external</html>",
attemptedContent: "<html>studio</html>",
});
const coordinator: ExternalFileChangeCoordinatorHandle = {
blocked: { status: "conflict", generation: 1, error: conflict, payload: {} },
retry: vi.fn(async () => undefined),
useExternalFile: vi.fn(async () => undefined),
keepStudioFile: vi.fn(async () => undefined),
};
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
expect(document.querySelector('[role="alert"]')?.textContent).toContain(
"Preview is paused so neither version is lost",
);
expect(document.body.textContent).toContain("Discard Studio edits and reload file");
expect(document.body.textContent).toContain("Overwrite file with Studio version");
const review = Array.from(document.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Review or export both"),
);
await act(async () => review?.click());
expect(document.querySelector('[role="dialog"]')).not.toBeNull();
expect(Array.from(document.querySelectorAll("textarea"), (field) => field.value)).toEqual([
"<html>external</html>",
"<html>studio</html>",
]);
await act(async () => root.unmount());
});
it("lets authors review and export the local candidate after a drain failure", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const coordinator: ExternalFileChangeCoordinatorHandle = {
blocked: {
status: "failed",
generation: 1,
path: "index.html",
error: new Error("offline"),
payload: {},
studioContent: "<html>recover me</html>",
recovered: false,
},
retry: vi.fn(async () => undefined),
useExternalFile: vi.fn(async () => undefined),
keepStudioFile: vi.fn(async () => undefined),
};
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
const review = Array.from(document.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Review or export Studio draft"),
);
await act(async () => review?.click());
expect(document.querySelector("textarea")?.value).toBe("<html>recover me</html>");
expect(document.body.textContent).toContain("Copy");
expect(document.body.textContent).toContain("Download");
await act(async () => root.unmount());
});
it("does not offer a fake retry after remount and instead offers explicit overwrite", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const keepStudioFile = vi.fn(async () => undefined);
const coordinator: ExternalFileChangeCoordinatorHandle = {
blocked: {
status: "failed",
generation: 1,
path: "index.html",
error: new Error("offline"),
payload: { path: "index.html", version: "v2", content: "external" },
studioContent: "recovered draft",
recovered: true,
},
retry: vi.fn(async () => undefined),
useExternalFile: vi.fn(async () => undefined),
keepStudioFile,
};
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
expect(document.body.textContent).not.toContain("Retry save");
const overwrite = Array.from(document.querySelectorAll("button")).find((button) =>
button.textContent?.includes("Overwrite file with recovered Studio draft"),
);
expect(overwrite).toBeTruthy();
vi.spyOn(window, "confirm").mockReturnValue(true);
await act(async () => overwrite?.click());
expect(keepStudioFile).toHaveBeenCalledOnce();
await act(async () => root.unmount());
});
it("does not offer retry when a failed DOM edit has no recoverable source candidate", async () => {
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
const coordinator: ExternalFileChangeCoordinatorHandle = {
blocked: {
status: "failed",
generation: 1,
path: "index.html",
error: new Error("offline"),
payload: { path: "index.html", version: "v2", content: "external" },
studioContent: null,
recovered: false,
},
retry: vi.fn(async () => undefined),
useExternalFile: vi.fn(async () => undefined),
keepStudioFile: vi.fn(async () => undefined),
};
await act(async () => root.render(<ExternalFileConflictBanner coordinator={coordinator} />));
expect(document.body.textContent).not.toContain("Retry save");
expect(document.body.textContent).toContain("Discard Studio edits and reload file");
await act(async () => root.unmount());
});
});
@@ -0,0 +1,255 @@
import { useRef, useState } from "react";
import type {
ExternalFileChangeBlockedState,
ExternalFileChangeCoordinatorHandle,
} from "../hooks/useExternalFileChangeCoordinator";
import { useDialogBehavior } from "./ui/useDialogBehavior";
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function downloadText(filename: string, content: string): void {
const url = URL.createObjectURL(new Blob([content], { type: "text/html;charset=utf-8" }));
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}
function ConflictReview({
conflict,
onClose,
}: {
conflict: Extract<ExternalFileChangeBlockedState, { status: "conflict" }>;
onClose: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useDialogBehavior({ open: true, onClose, containerRef });
const external = conflict.error.currentContent ?? "(The server did not return file contents.)";
const studio = conflict.error.attemptedContent;
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/70 px-5 backdrop-blur-sm"
onClick={onClose}
>
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="external-conflict-title"
tabIndex={-1}
className="max-h-[88vh] w-full max-w-5xl overflow-auto rounded-xl border border-amber-400/30 bg-neutral-950 p-5 text-neutral-100 shadow-2xl outline-none"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<h2 id="external-conflict-title" className="text-base font-semibold">
Review both versions of {conflict.error.filePath}
</h2>
<p className="mt-1 text-xs text-neutral-400">
Reviewing or exporting does not change either version.
</p>
</div>
<button type="button" onClick={onClose} className="rounded px-2 py-1 text-neutral-400">
Close
</button>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
{[
{ title: "File on disk", content: external, suffix: "external" },
{ title: "Unsaved Studio version", content: studio, suffix: "studio" },
].map((side) => (
<section key={side.suffix} aria-label={side.title}>
<div className="mb-2 flex items-center justify-between gap-2">
<h3 className="text-sm font-medium">{side.title}</h3>
<div className="flex gap-2">
<button
type="button"
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
onClick={() => void navigator.clipboard.writeText(side.content)}
>
Copy
</button>
<button
type="button"
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
onClick={() =>
downloadText(`${conflict.error.filePath}.${side.suffix}.html`, side.content)
}
>
Download
</button>
</div>
</div>
<textarea
readOnly
value={side.content}
className="h-80 w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-3 font-mono text-[11px] leading-relaxed text-neutral-300"
/>
</section>
))}
</div>
</div>
</div>
);
}
function FailedDraftReview({
path,
content,
onClose,
}: {
path: string;
content: string;
onClose: () => void;
}) {
const containerRef = useRef<HTMLDivElement>(null);
useDialogBehavior({ open: true, onClose, containerRef });
return (
<div
className="fixed inset-0 z-[110] flex items-center justify-center bg-black/70 px-5 backdrop-blur-sm"
onClick={onClose}
>
<div
ref={containerRef}
role="dialog"
aria-modal="true"
aria-labelledby="failed-draft-title"
tabIndex={-1}
className="max-h-[88vh] w-full max-w-3xl overflow-auto rounded-xl border border-amber-400/30 bg-neutral-950 p-5 text-neutral-100 shadow-2xl outline-none"
onClick={(event) => event.stopPropagation()}
>
<div className="flex items-start justify-between gap-4">
<div>
<h2 id="failed-draft-title" className="text-base font-semibold">
Recover unsaved Studio draft for {path}
</h2>
<p className="mt-1 text-xs text-neutral-400">
Copy or download this draft before choosing to discard it.
</p>
</div>
<button type="button" onClick={onClose} className="rounded px-2 py-1 text-neutral-400">
Close
</button>
</div>
<div className="mt-4 flex justify-end gap-2">
<button
type="button"
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
onClick={() => void navigator.clipboard.writeText(content)}
>
Copy
</button>
<button
type="button"
className="rounded border border-neutral-700 px-2 py-1 text-[11px]"
onClick={() => downloadText(`${path}.studio.html`, content)}
>
Download
</button>
</div>
<textarea
readOnly
value={content}
className="mt-2 h-80 w-full resize-y rounded border border-neutral-800 bg-neutral-900 p-3 font-mono text-[11px] leading-relaxed text-neutral-300"
/>
</div>
</div>
);
}
export function ExternalFileConflictBanner({
coordinator,
}: {
coordinator: ExternalFileChangeCoordinatorHandle;
}) {
const [reviewing, setReviewing] = useState(false);
const blocked = coordinator.blocked;
if (!blocked) return null;
const conflict = blocked.status === "conflict" ? blocked : null;
const failure = blocked.status === "failed" ? blocked : null;
return (
<>
<div
role="alert"
className="absolute left-1/2 top-14 z-[94] flex max-w-[calc(100vw-32px)] -translate-x-1/2 flex-wrap items-center gap-3 rounded-md border border-amber-400/30 bg-amber-950/95 px-4 py-2 text-[12px] font-medium text-amber-50 shadow-lg"
>
<span>
{conflict
? `${conflict.error.filePath} changed outside Studio. Preview is paused so neither version is lost.`
: `Studio could not safely finish local saves: ${errorMessage(blocked.error)}. Preview is paused.`}
</span>
{conflict && (
<button type="button" onClick={() => setReviewing(true)} className="underline">
Review or export both
</button>
)}
{failure?.studioContent != null && (
<button type="button" onClick={() => setReviewing(true)} className="underline">
Review or export Studio draft
</button>
)}
{failure && !failure.recovered && failure.studioContent != null && (
<button type="button" onClick={() => void coordinator.retry()} className="underline">
Retry save
</button>
)}
<button
type="button"
onClick={() => void coordinator.useExternalFile()}
className="rounded border border-amber-200/30 px-2 py-1"
>
Discard Studio edits and reload file
</button>
{conflict && (
<button
type="button"
onClick={() => {
if (
window.confirm(
"Overwrite the externally changed file with the Studio version? The server will preserve its normal backup before writing.",
)
) {
void coordinator.keepStudioFile();
}
}}
className="rounded border border-red-300/40 px-2 py-1 text-red-100"
>
Overwrite file with Studio version
</button>
)}
{failure?.recovered && failure.studioContent != null && (
<button
type="button"
onClick={() => {
if (
window.confirm(
"Overwrite the file with the recovered Studio draft? The current file will be preserved by the server's normal backup before writing.",
)
) {
void coordinator.keepStudioFile();
}
}}
className="rounded border border-red-300/40 px-2 py-1 text-red-100"
>
Overwrite file with recovered Studio draft
</button>
)}
</div>
{reviewing && conflict && (
<ConflictReview conflict={conflict} onClose={() => setReviewing(false)} />
)}
{reviewing && failure?.studioContent != null && (
<FailedDraftReview
path={failure.path}
content={failure.studioContent}
onClose={() => setReviewing(false)}
/>
)}
</>
);
}
@@ -253,7 +253,7 @@ function Transform3dField({
onCommit={(next) => {
const v = parse(next);
if (v != null && onCommitAnimatedProperty) {
void onCommitAnimatedProperty(ctx.element, prop, v);
return onCommitAnimatedProperty(ctx.element, prop, v);
}
}}
/>
@@ -21,7 +21,7 @@ export function CommitField({
liveCommit?: boolean;
align?: "left" | "right";
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
onCommit: (nextValue: string) => void | Promise<void>;
}) {
const [draft, setDraft] = useState(value);
const valueRef = useRef(value);
@@ -29,6 +29,19 @@ export function CommitField({
const inputRef = useRef<HTMLInputElement>(null);
const focusedRef = useRef(false);
const dirtyRef = useRef(false);
const commitGenerationRef = useRef(0);
const pendingCommitRef = useRef<{
baseline: string;
optimistic: string;
} | null>(null);
const lastValueRef = useRef(value);
if (!Object.is(lastValueRef.current, value)) {
lastValueRef.current = value;
if (!Object.is(pendingCommitRef.current?.optimistic, value)) {
commitGenerationRef.current += 1;
pendingCommitRef.current = null;
}
}
valueRef.current = value;
draftRef.current = draft;
@@ -67,14 +80,34 @@ export function CommitField({
}, 250);
};
const cancelGesture = () => {
commitGenerationRef.current += 1;
clearGestureSettleTimer();
gestureActiveRef.current = false;
gestureTransaction.cancel();
};
const commitDraft = (nextValue: string) => {
const generation = ++commitGenerationRef.current;
setDraft(nextValue);
onPreview?.(nextValue);
if (nextValue !== valueRef.current) onCommit(nextValue);
if (nextValue !== valueRef.current) {
const baseline = valueRef.current;
pendingCommitRef.current = { baseline, optimistic: nextValue };
const rollback = () => {
if (generation !== commitGenerationRef.current) return;
pendingCommitRef.current = null;
// The source write is authoritative. A rejected mutation must not leave
// the field showing an optimistic value that will disappear on seek.
setDraft(baseline);
onPreview?.(baseline);
};
try {
void Promise.resolve(onCommit(nextValue)).then(() => {
if (generation === commitGenerationRef.current) pendingCommitRef.current = null;
}, rollback);
} catch {
rollback();
}
}
};
const cancelGestureFromKeyEvent = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (!gestureActiveRef.current) return false;
@@ -89,6 +122,7 @@ export function CommitField({
const nextDraft = adjustNumericToken(draftRef.current, direction, event);
if (!nextDraft) return;
event.preventDefault();
commitGenerationRef.current += 1;
dirtyRef.current = false;
gestureActiveRef.current = true;
gestureTransaction.preview(nextDraft);
@@ -148,6 +182,7 @@ export function CommitField({
focusedRef.current = true;
}}
onChange={(event) => {
commitGenerationRef.current += 1;
settleGesture();
dirtyRef.current = true;
setDraft(event.target.value);
@@ -90,6 +90,75 @@ describe("FlatRow", () => {
act(() => root.unmount());
});
it("restores its durable value when an async commit rejects", async () => {
let rejectCommit: ((error: Error) => void) | null = null;
const onCommit = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectCommit = reject;
}),
);
const row = (value: string) => (
<FlatRow label="X" value={value} tier="explicitDefault" onCommit={onCommit} />
);
const { host, root } = renderInto(row("22px"));
const input = host.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("expected an input");
act(() => {
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
nativeInputValueSetter?.call(input, "99px");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("focusout", { bubbles: true }));
});
// The parent can echo the preview before persistence settles. That is not a
// durable acknowledgement and must not invalidate the pending rollback.
act(() => root.render(row("99px")));
await act(async () => {
rejectCommit?.(new Error("save failed"));
await Promise.resolve();
});
expect(onCommit).toHaveBeenCalledWith("99px");
expect(input.value).toBe("22px");
act(() => root.unmount());
});
it("does not let an older rejected commit overwrite a newer draft", async () => {
let rejectCommit: ((error: Error) => void) | null = null;
const onCommit = vi.fn(
() =>
new Promise<void>((_resolve, reject) => {
rejectCommit = reject;
}),
);
const { host, root } = renderInto(
<FlatRow label="X" value="22px" tier="explicitDefault" onCommit={onCommit} />,
);
const input = host.querySelector<HTMLInputElement>("input");
if (!input) throw new Error("expected an input");
const nativeInputValueSetter = Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,
"value",
)?.set;
act(() => {
nativeInputValueSetter?.call(input, "99px");
input.dispatchEvent(new Event("input", { bubbles: true }));
input.dispatchEvent(new Event("focusout", { bubbles: true }));
nativeInputValueSetter?.call(input, "100px");
input.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
rejectCommit?.(new Error("old save failed"));
await Promise.resolve();
});
expect(input.value).toBe("100px");
act(() => root.unmount());
});
it("persists a rapid numeric arrow-key burst as one commit", () => {
vi.useFakeTimers();
const onCommit = vi.fn();
@@ -35,7 +35,7 @@ export function FlatRow({
/** Renders a trailing 10px caret-down, for select-backed rows. */
dropdown?: boolean;
onPreview?: (nextValue: string) => void;
onCommit: (nextValue: string) => void;
onCommit: (nextValue: string) => void | Promise<void>;
onReset?: () => void;
}) {
const track = useTrackDesignInput();
@@ -59,7 +59,7 @@ export function FlatRow({
onPreview={onPreview}
onCommit={(nextValue) => {
track("metric", label);
onCommit(nextValue);
return onCommit(nextValue);
}}
/>
</span>
@@ -29,14 +29,14 @@ export function MetricField({
scrub?: boolean;
suffix?: string;
tooltip?: string;
onCommit: (nextValue: string) => void;
onCommit: (nextValue: string) => void | Promise<void>;
}) {
const track = useTrackDesignInput();
const scrubRef = useRef<{ startX: number; startValue: number; pointerId: number } | null>(null);
const commit = useCallback(
(nextValue: string) => {
if (nextValue !== value) track("metric", label);
onCommit(nextValue);
return onCommit(nextValue);
},
[label, onCommit, track, value],
);
@@ -0,0 +1,57 @@
// @vitest-environment happy-dom
import { describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "./domEditingTypes";
import { GsapEditBlockedError } from "../../hooks/gsapEditOutcome";
import { createTransformCommitHandlers } from "./propertyPanelTransformCommit";
describe("createTransformCommitHandlers", () => {
it.each([
[
"position",
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
handlers.commitManualOffset("x", "20px"),
],
[
"size",
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
handlers.commitManualSize("width", "200px"),
],
[
"rotation",
(handlers: ReturnType<typeof createTransformCommitHandlers>) =>
handlers.commitManualRotation("45"),
],
])("propagates blocked %s edits so the field can roll back", async (_name, commit) => {
const blocked = new GsapEditBlockedError("unroll-required");
const onCommitAnimatedProperty = vi.fn().mockRejectedValue(blocked);
const onSetManualOffset = vi.fn();
const onSetManualSize = vi.fn();
const onSetManualRotation = vi.fn();
const element = {
id: "box",
selector: "#box",
element: document.createElement("div"),
boundingBox: { width: 100, height: 100 },
} as unknown as DomEditSelection;
const handlers = createTransformCommitHandlers({
element,
styles: {},
hasGsapAnimation: true,
gsapAnimId: "#box-to-position",
gsapKeyframes: null,
currentPct: 0,
onCommitAnimatedProperty,
onAddKeyframe: undefined,
onSetManualOffset,
onSetManualSize,
onSetManualRotation,
showToast: vi.fn(),
});
await expect(commit(handlers)).rejects.toBe(blocked);
expect(onSetManualOffset).not.toHaveBeenCalled();
expect(onSetManualSize).not.toHaveBeenCalled();
expect(onSetManualRotation).not.toHaveBeenCalled();
});
});
@@ -41,13 +41,13 @@ export function createTransformCommitHandlers({
// Route a transform value into the GSAP animation (or a new keyframe) when the
// element is animated. Returns true when handled, so callers fall through to
// the manual-transform path only for non-animated elements.
const commitAnimatedTransformValue = (
const commitAnimatedTransformValue = async (
property: string,
value: number,
noCallbacksMessage: string,
): boolean => {
): Promise<boolean> => {
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, property, value);
await onCommitAnimatedProperty(element, property, value);
return true;
}
if (gsapKeyframes && gsapAnimId && onAddKeyframe) {
@@ -62,11 +62,11 @@ export function createTransformCommitHandlers({
return false;
};
const commitManualOffset = (axis: "x" | "y", nextValue: string) => {
const commitManualOffset = async (axis: "x" | "y", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null) return;
if (
commitAnimatedTransformValue(
await commitAnimatedTransformValue(
axis,
parsed,
"Cannot edit position — animation callbacks not available",
@@ -74,20 +74,20 @@ export function createTransformCommitHandlers({
)
return;
const current = readStudioPathOffset(element.element);
void Promise.resolve(
await Promise.resolve(
onSetManualOffset(element, {
x: axis === "x" ? parsed : current.x,
y: axis === "y" ? parsed : current.y,
}),
).catch(() => undefined);
);
};
// fallow-ignore-next-line complexity
const commitManualSize = (axis: "width" | "height", nextValue: string) => {
const commitManualSize = async (axis: "width" | "height", nextValue: string) => {
const parsed = parsePxMetricValue(nextValue);
if (parsed == null || parsed <= 0) return;
if (onCommitAnimatedProperty && hasGsapAnimation) {
void onCommitAnimatedProperty(element, axis, parsed);
await onCommitAnimatedProperty(element, axis, parsed);
return;
}
if (hasGsapAnimation) {
@@ -103,26 +103,26 @@ export function createTransformCommitHandlers({
current.height > 0
? current.height
: (parsePxMetricValue(styles.height ?? "") ?? element.boundingBox.height);
void Promise.resolve(
await Promise.resolve(
onSetManualSize(element, {
width: axis === "width" ? parsed : width,
height: axis === "height" ? parsed : height,
}),
).catch(() => undefined);
);
};
const commitManualRotation = (nextValue: string) => {
const commitManualRotation = async (nextValue: string) => {
const parsed = Number.parseFloat(nextValue);
if (!Number.isFinite(parsed)) return;
if (
commitAnimatedTransformValue(
await commitAnimatedTransformValue(
"rotation",
parsed,
"Cannot edit rotation — animation callbacks not available",
)
)
return;
void Promise.resolve(onSetManualRotation(element, { angle: parsed })).catch(() => undefined);
await Promise.resolve(onSetManualRotation(element, { angle: parsed }));
};
return { commitManualOffset, commitManualSize, commitManualRotation };
@@ -71,9 +71,15 @@ export interface PropertyPanelProps {
onProgress?: (progress: BackgroundRemovalProgress) => void;
},
) => Promise<BackgroundRemovalResult>;
onSetManualOffset: (element: DomEditSelection, next: { x: number; y: number }) => void;
onSetManualSize: (element: DomEditSelection, next: { width: number; height: number }) => void;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void;
onSetManualOffset: (
element: DomEditSelection,
next: { x: number; y: number },
) => void | Promise<void>;
onSetManualSize: (
element: DomEditSelection,
next: { width: number; height: number },
) => void | Promise<void>;
onSetManualRotation: (element: DomEditSelection, next: { angle: number }) => void | Promise<void>;
onSetText: (value: string, fieldKey?: string) => void;
onSetTextFieldStyle: (fieldKey: string, property: string, value: string) => void;
onPreviewTextFieldStyle?: (fieldKey: string, property: string, value: string) => void;
@@ -8,6 +8,30 @@ import { useInspectorGestureTransaction } from "./useInspectorGestureTransaction
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useInspectorGestureTransaction", () => {
it("restores the durable baseline when an inspector commit rejects", async () => {
const host = document.createElement("div");
const root = createRoot(host);
const onPreview = vi.fn();
const onCommit = vi.fn().mockRejectedValue(new Error("save failed"));
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
function Probe() {
gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
return null;
}
act(() => root.render(<Probe />));
act(() => {
gesture?.preview(25);
gesture?.settle();
});
expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25]);
await act(async () => Promise.resolve());
expect(onPreview.mock.calls.map(([value]) => value)).toEqual([25, 10]);
act(() => root.unmount());
});
it("keeps a new gesture active when the prior async commit is acknowledged", () => {
const host = document.createElement("div");
const root = createRoot(host);
@@ -34,4 +58,45 @@ describe("useInspectorGestureTransaction", () => {
act(() => root.unmount());
});
it("does not let an older rejected commit roll back a newer successful gesture", async () => {
const host = document.createElement("div");
const root = createRoot(host);
const onPreview = vi.fn();
let rejectFirst: ((error: Error) => void) | null = null;
const onCommit = vi
.fn()
.mockImplementationOnce((value: number) => {
onPreview(value);
return new Promise<void>((_resolve, reject) => {
rejectFirst = reject;
});
})
.mockImplementationOnce((value: number) => {
onPreview(value);
return Promise.resolve();
});
let gesture: ReturnType<typeof useInspectorGestureTransaction<number>> | null = null;
function Probe() {
gesture = useInspectorGestureTransaction({ sourceValue: 10, onPreview, onCommit });
return null;
}
act(() => root.render(<Probe />));
act(() => {
gesture?.preview(20);
gesture?.settle();
gesture?.preview(30);
gesture?.settle();
});
await act(async () => {
rejectFirst?.(new Error("old save failed"));
await Promise.resolve();
});
expect(onCommit.mock.calls.map(([value]) => value)).toEqual([20, 30]);
expect(onPreview).toHaveBeenLastCalledWith(30);
act(() => root.unmount());
});
});
@@ -1,5 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react";
function isPromiseCommit(result: void | Promise<void>): result is Promise<void> {
return Boolean(result && typeof result.then === "function");
}
/** One owner for continuous inspector edits: preview freely, persist once. */
export function useInspectorGestureTransaction<T>({
sourceValue,
@@ -8,44 +12,96 @@ export function useInspectorGestureTransaction<T>({
}: {
sourceValue: T;
onPreview: (value: T) => void;
onCommit: (value: T) => void;
onCommit: (value: T) => void | Promise<void>;
}) {
const sourceRef = useRef(sourceValue);
const activeRef = useRef<{ before: T; latest: T } | null>(null);
const previewRef = useRef(onPreview);
const commitRef = useRef(onCommit);
if (!activeRef.current) sourceRef.current = sourceValue;
const generationRef = useRef(0);
const pendingRef = useRef<{ before: T; latest: T } | null>(null);
const awaitingSourceAckRef = useRef<{ generation: number; value: T } | null>(null);
const lastSourceValueRef = useRef(sourceValue);
if (!Object.is(lastSourceValueRef.current, sourceValue)) {
lastSourceValueRef.current = sourceValue;
const matchesSourceAck = Object.is(awaitingSourceAckRef.current?.value, sourceValue);
const matchesOptimisticValue =
(activeRef.current && Object.is(activeRef.current.latest, sourceValue)) ||
(pendingRef.current && Object.is(pendingRef.current.latest, sourceValue)) ||
matchesSourceAck;
if (matchesSourceAck) awaitingSourceAckRef.current = null;
if (!matchesOptimisticValue) {
generationRef.current += 1;
activeRef.current = null;
pendingRef.current = null;
awaitingSourceAckRef.current = null;
sourceRef.current = sourceValue;
} else {
sourceRef.current = sourceValue;
}
}
previewRef.current = onPreview;
commitRef.current = onCommit;
const begin = useCallback(() => {
if (!activeRef.current) {
generationRef.current += 1;
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
}
}, []);
const preview = useCallback((value: T) => {
if (!activeRef.current) {
generationRef.current += 1;
activeRef.current = { before: sourceRef.current, latest: sourceRef.current };
}
activeRef.current.latest = value;
previewRef.current(value);
}, []);
const rollbackCommit = useCallback((active: { before: T; latest: T }, generation: number) => {
if (generation !== generationRef.current) return;
pendingRef.current = null;
if (awaitingSourceAckRef.current?.generation === generation) {
awaitingSourceAckRef.current = null;
}
sourceRef.current = active.before;
previewRef.current(active.before);
}, []);
const settle = useCallback(() => {
const active = activeRef.current;
activeRef.current = null;
if (active && !Object.is(active.before, active.latest)) {
const generation = ++generationRef.current;
sourceRef.current = active.latest;
// Restore the captured baseline before the persistent commit captures
// rollback state. The commit reapplies `latest` synchronously, so this
// is not visible but a failed save can now correctly restore `before`.
previewRef.current(active.before);
commitRef.current(active.latest);
pendingRef.current = active;
awaitingSourceAckRef.current = { generation, value: active.latest };
try {
const result = commitRef.current(active.latest);
if (isPromiseCommit(result)) {
void result.then(
() => {
if (generation === generationRef.current) pendingRef.current = null;
},
() => rollbackCommit(active, generation),
);
} else if (generation === generationRef.current) {
pendingRef.current = null;
// Synchronous inspector consumers historically restore their preview
// after persisting (color pickers close, curves release the pointer).
// Async source mutations keep the optimistic preview until the write
// resolves so they do not flash back to the baseline while pending.
previewRef.current(active.before);
}
} catch {
rollbackCommit(active, generation);
}
}
}, []);
}, [rollbackCommit]);
const cancel = useCallback(() => {
generationRef.current += 1;
const active = activeRef.current;
activeRef.current = null;
if (active && !Object.is(active.before, active.latest)) {
@@ -66,7 +122,7 @@ export function useInspectorGestureDraft<T>({
}: {
sourceValue: T;
onPreview: (value: T) => void;
onCommit: (value: T) => void;
onCommit: (value: T) => void | Promise<void>;
}) {
const [draft, setDraft] = useState(sourceValue);
const transaction = useInspectorGestureTransaction({
@@ -77,7 +133,7 @@ export function useInspectorGestureDraft<T>({
},
onCommit: (next) => {
setDraft(next);
onCommit(next);
return onCommit(next);
},
});
@@ -8,6 +8,7 @@ import { VIDEO_EXT, IMAGE_EXT } from "../../utils/mediaTypes";
import { TIMELINE_ASSET_MIME } from "../../utils/timelineAssetDrop";
import { ContextMenu } from "./AssetContextMenu";
import { usePlayerStore } from "../../player/store/playerStore";
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
import { basename, ext, truncateMiddle, formatDuration } from "./assetHelpers";
@@ -133,7 +134,7 @@ export function AssetCard({
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
@@ -158,7 +159,7 @@ export function AssetCard({
const clipKey = clip.key ?? clip.id;
setSelectedElementId(clipKey);
// Scroll the timeline so the selected clip is actually visible.
requestClipReveal(clipKey);
requestTimelineFocus(timelineClipFocusId(clipKey));
return;
}
}
@@ -171,7 +172,7 @@ export function AssetCard({
asset,
projectId,
setSelectedElementId,
requestClipReveal,
requestTimelineFocus,
setPreviewAsset,
clearPreviewAsset,
],
@@ -6,6 +6,7 @@ import { usePlayerStore } from "../../player/store/playerStore";
import { useAssetPreviewStore } from "../../utils/assetPreviewStore";
import { findClipForAsset, isPointerClick } from "../../utils/assetClickBehavior";
import { resolveMediaPreviewUrl } from "../../player/components/thumbnailUtils";
import { timelineClipFocusId } from "../../player/components/timelineNavigationIdentity";
export function AudioRow({
projectId,
@@ -43,7 +44,7 @@ export function AudioRow({
// CapCut-style click behavior: drag-threshold gate.
const pointerDownRef = useRef<{ x: number; y: number } | null>(null);
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
const requestClipReveal = usePlayerStore((s) => s.requestClipReveal);
const requestTimelineFocus = usePlayerStore((s) => s.requestTimelineFocus);
const elements = usePlayerStore((s) => s.elements);
const setPreviewAsset = useAssetPreviewStore((s) => s.setPreviewAsset);
const clearPreviewAsset = useAssetPreviewStore((s) => s.clearPreviewAsset);
@@ -67,7 +68,7 @@ export function AudioRow({
const clipKey = clip.key ?? clip.id;
setSelectedElementId(clipKey);
// Scroll the timeline so the selected clip is actually visible.
requestClipReveal(clipKey);
requestTimelineFocus(timelineClipFocusId(clipKey));
return;
}
}
@@ -80,7 +81,7 @@ export function AudioRow({
asset,
projectId,
setSelectedElementId,
requestClipReveal,
requestTimelineFocus,
setPreviewAsset,
clearPreviewAsset,
],
@@ -27,9 +27,13 @@ export function FileManagerProvider({
editingPathRef,
projectIdRef,
saveRafRef,
flushPendingSourceSave,
discardPendingSourceSave,
getPendingSourceCandidate,
importedFontAssetsRef,
readProjectFile,
writeProjectFile,
overwriteExternalConflict,
readOptionalProjectFile,
observeProjectFileVersion,
updateEditingFileContent,
@@ -67,9 +71,13 @@ export function FileManagerProvider({
editingPathRef,
projectIdRef,
saveRafRef,
flushPendingSourceSave,
discardPendingSourceSave,
getPendingSourceCandidate,
importedFontAssetsRef,
readProjectFile,
writeProjectFile,
overwriteExternalConflict,
readOptionalProjectFile,
observeProjectFileVersion,
updateEditingFileContent,
@@ -101,9 +109,13 @@ export function FileManagerProvider({
editingPathRef,
projectIdRef,
saveRafRef,
flushPendingSourceSave,
discardPendingSourceSave,
getPendingSourceCandidate,
importedFontAssetsRef,
readProjectFile,
writeProjectFile,
overwriteExternalConflict,
readOptionalProjectFile,
observeProjectFileVersion,
updateEditingFileContent,
@@ -0,0 +1,17 @@
import { readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";
describe("external file-change subscription ownership", () => {
it("has one subscriber instead of independent Preview and SDK listeners", () => {
const preview = readFileSync(new URL("./usePreviewPersistence.ts", import.meta.url), "utf8");
const sdk = readFileSync(new URL("./useSdkSession.ts", import.meta.url), "utf8");
const coordinator = readFileSync(
new URL("./useExternalFileChangeCoordinator.ts", import.meta.url),
"utf8",
);
expect(preview).not.toContain('hot.on("hf:file-change"');
expect(sdk).not.toContain('hot.on("hf:file-change"');
expect(coordinator.match(/hot\.on\("hf:file-change"/g)).toHaveLength(1);
});
});
@@ -0,0 +1,34 @@
import { describe, expect, it, vi } from "vitest";
import { addExternalFileReloadListener, notifyExternalFileReload } from "./externalFileReloadBus";
describe("external file reload bus", () => {
it("lets the sole watcher reload every SDK owner of the changed path", () => {
const first = vi.fn();
const second = vi.fn();
const removeFirst = addExternalFileReloadListener(first);
const removeSecond = addExternalFileReloadListener(second);
notifyExternalFileReload("scenes/card.html");
expect(first).toHaveBeenCalledWith("scenes/card.html");
expect(second).toHaveBeenCalledWith("scenes/card.html");
removeFirst();
removeSecond();
});
it("isolates a broken listener so later SDK owners still reload", () => {
const broken = vi.fn(() => {
throw new Error("stale owner");
});
const healthy = vi.fn();
const removeBroken = addExternalFileReloadListener(broken);
const removeHealthy = addExternalFileReloadListener(healthy);
expect(() => notifyExternalFileReload("scenes/card.html")).not.toThrow();
expect(broken).toHaveBeenCalledWith("scenes/card.html");
expect(healthy).toHaveBeenCalledWith("scenes/card.html");
removeBroken();
removeHealthy();
});
});
@@ -0,0 +1,18 @@
type ExternalFileReloadListener = (path: string) => void;
const listeners = new Set<ExternalFileReloadListener>();
export function addExternalFileReloadListener(listener: ExternalFileReloadListener): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
}
export function notifyExternalFileReload(path: string): void {
for (const listener of listeners) {
try {
listener(path);
} catch {
// A stale SDK owner must not prevent sibling owners from reloading.
}
}
}
@@ -43,17 +43,43 @@ function renderHookWith(
animations: GsapAnimation[],
onMutation: (mutation: Record<string, unknown>, label: string) => unknown | Promise<unknown>,
onReady: (commit: Commit) => void,
bumpGsapCache = vi.fn(),
onBatch?: (
calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>,
label: string,
) => unknown | Promise<unknown>,
) {
function Harness() {
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
selectedGsapAnimations: animations,
gsapCommitMutation: async (_sel, mutation, options) => {
const gsapCommitMutation = Object.assign(
async (
_sel: DomEditSelection,
mutation: Record<string, unknown>,
options: { label: string },
) => {
await onMutation(mutation, options.label);
},
onBatch
? {
batch: async (
calls: Array<{
selection: DomEditSelection;
mutation: Record<string, unknown>;
options: { label: string };
}>,
options: { label: string },
) => {
await onBatch(calls, options.label);
},
}
: {},
);
const { commitAnimatedProperties } = useAnimatedPropertyCommit({
selectedGsapAnimations: animations,
gsapCommitMutation,
addGsapAnimation: vi.fn(),
convertToKeyframes: vi.fn(),
previewIframeRef: { current: null },
bumpGsapCache: vi.fn(),
bumpGsapCache,
});
onReady(commitAnimatedProperties);
return null;
@@ -87,8 +113,53 @@ describe("useAnimatedPropertyCommit — ownership and rejection propagation", ()
act(() => root.unmount());
});
it("rejects runtime-computed property ownership before sending a mutation", async () => {
const runtimePosition = {
...keyframedAnim,
hasUnresolvedKeyframes: true,
} as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
let commit!: Commit;
const root = renderHookWith(
[runtimePosition],
(mutation) => mutations.push(mutation),
(ready) => (commit = ready),
);
await expect(commit(selection, { x: 50 })).rejects.toMatchObject({
reason: "source-uneditable",
});
expect(mutations).toHaveLength(0);
act(() => root.unmount());
});
it("rejects every property before a mixed-group commit can partially persist", async () => {
const helperOpacity = {
id: "#box-to-visual",
targetSelector: "#box",
propertyGroup: "visual",
method: "to",
properties: { opacity: 0.5 },
provenance: { kind: "helper", fn: "fade", callSite: 1 },
} as unknown as GsapAnimation;
const mutations: Array<Record<string, unknown>> = [];
let commit!: Commit;
const root = renderHookWith(
[helperOpacity],
(mutation) => mutations.push(mutation),
(ready) => (commit = ready),
);
await expect(commit(selection, { x: 50, opacity: 0.8 })).rejects.toMatchObject({
reason: "unroll-required",
});
expect(mutations).toHaveLength(0);
act(() => root.unmount());
});
it("rethrows a persistence failure to the telemetry wrapper", async () => {
const failure = new Error("save failed");
const bumpGsapCache = vi.fn();
let commit!: Commit;
const root = renderHookWith(
[keyframedAnim],
@@ -96,9 +167,11 @@ describe("useAnimatedPropertyCommit — ownership and rejection propagation", ()
throw failure;
},
(ready) => (commit = ready),
bumpGsapCache,
);
await expect(commit(selection, { x: 50 })).rejects.toBe(failure);
expect(bumpGsapCache).toHaveBeenCalledTimes(1);
act(() => root.unmount());
});
});
@@ -107,7 +180,13 @@ function renderCommitHook(
mutations: Array<Record<string, unknown>>,
onReady: (commit: Commit) => void,
) {
return renderHookWith([keyframedAnim], (mutation) => mutations.push(mutation), onReady);
return renderHookWith(
[keyframedAnim],
(mutation) => {
mutations.push(mutation);
},
onReady,
);
}
// Regression (#1808): a "3D transform" / design-panel property edit on an
@@ -160,7 +239,9 @@ describe("commitStaticSet group routing", () => {
) {
return renderHookWith(
[positionSet],
(mutation, label) => committed.push({ mutation, label }),
(mutation, label) => {
committed.push({ mutation, label });
},
onReady,
);
}
@@ -208,7 +289,9 @@ describe("commitStaticSet group routing", () => {
let commit!: Commit;
renderHookWith(
[positionSet, instantSizeHold],
(mutation, label) => committed.push({ mutation, label }),
(mutation, label) => {
committed.push({ mutation, label });
},
(c) => (commit = c),
);
@@ -226,4 +309,56 @@ describe("commitStaticSet group routing", () => {
expect(committed.some(({ mutation }) => mutation.type === "add")).toBe(false);
expect(committed[0]!.mutation.animationId).not.toBe(positionSet.id);
});
it("persists multiple property groups in one atomic batch", async () => {
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
const batches: Array<{
calls: Array<{ mutation: Record<string, unknown>; options: { label: string } }>;
label: string;
}> = [];
let commit!: Commit;
const root = renderHookWith(
[positionSet],
(mutation, label) => committed.push({ mutation, label }),
(ready) => (commit = ready),
vi.fn(),
(calls, label) => batches.push({ calls, label }),
);
await act(async () => {
await commit(selection, { x: 400, width: 500 });
});
expect(committed).toHaveLength(0);
expect(batches).toHaveLength(1);
expect(batches[0]!.label).toBe("Set properties");
expect(batches[0]!.calls.map(({ mutation }) => mutation)).toEqual([
{
type: "update-properties",
animationId: positionSet.id,
properties: { x: 400 },
},
{
type: "add",
targetSelector: "#box",
method: "set",
position: 0,
properties: { width: 500 },
global: true,
},
]);
act(() => root.unmount());
});
it("fails before sending anything when an atomic multi-group batch is unavailable", async () => {
const committed: Array<{ mutation: Record<string, unknown>; label: string }> = [];
let commit!: Commit;
const root = renderStaticHook(committed, (ready) => (commit = ready));
await expect(commit(selection, { x: 400, width: 500 })).rejects.toThrow(
"Atomic GSAP property batch is unavailable",
);
expect(committed).toHaveLength(0);
act(() => root.unmount());
});
});
@@ -24,22 +24,16 @@ import {
import { resolveTweenStart, resolveTweenDuration } from "../utils/globalTimeCompiler";
import { roundTo3 } from "../utils/rounding";
import { commitWholePropertyOffset } from "./gsapWholePropertyOffsetCommit";
import { assertGsapEditPersisted, directEditOutcomeForProperties } from "./gsapEditOutcome";
import {
assertGsapEditPersisted,
directEditOutcomeForProperties,
GsapEditBlockedError,
} from "./gsapEditOutcome";
import type { CommitMutation, CommitMutationCall } from "./gsapScriptCommitTypes";
interface CommitAnimatedPropertyDeps {
selectedGsapAnimations: GsapAnimation[];
gsapCommitMutation:
| ((
selection: DomEditSelection,
mutation: Record<string, unknown>,
options: {
label: string;
coalesceKey?: string;
softReload?: boolean;
skipReload?: boolean;
},
) => Promise<void>)
| null;
gsapCommitMutation: CommitMutation | null;
addGsapAnimation: (
selection: DomEditSelection,
method: "to" | "from" | "set" | "fromTo",
@@ -110,7 +104,7 @@ async function maybeAutoKeyframeSet(
);
}
type Commit = NonNullable<CommitAnimatedPropertyDeps["gsapCommitMutation"]>;
type Commit = CommitMutation;
/** Undo-history label for a static-set commit, from the group it writes. */
const STATIC_SET_LABELS: Partial<Record<ReturnType<typeof classifyPropertyGroup>, string>> = {
@@ -140,6 +134,17 @@ async function commitSetProps(
animations: GsapAnimation[],
commit: Commit,
): Promise<void> {
const call = buildSetPropsCall(selection, setAnim, propEntries, selector);
await commit(call.selection, call.mutation, call.options);
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
}
function buildSetPropsCall(
selection: DomEditSelection,
setAnim: GsapAnimation,
propEntries: [string, number | string][],
selector: string | null,
): CommitMutationCall {
const properties = Object.fromEntries(propEntries);
const numericProps: SetPatchProps = {};
for (const [k, v] of propEntries) {
@@ -155,16 +160,15 @@ async function commitSetProps(
},
}
: undefined;
await commit(
return {
selection,
{ type: "update-properties", animationId: setAnim.id, properties },
{
mutation: { type: "update-properties", animationId: setAnim.id, properties },
options: {
label: staticSetLabel(propEntries),
softReload: true,
...(instantPatch ? { instantPatch } : {}),
},
);
await maybeAutoKeyframeSet(selection, setAnim, animations, commit);
};
}
/**
@@ -180,7 +184,25 @@ async function commitStaticSet(
animations: GsapAnimation[],
commit: Commit,
): Promise<void> {
if (!selector) return;
const calls = planStaticSetCalls(selection, propEntries, selector, animations);
const only = calls[0];
if (!only) return;
if (calls.length === 1) {
await commit(only.selection, only.mutation, only.options);
return;
}
if (!commit.batch) {
throw new Error("Atomic GSAP property batch is unavailable");
}
await commit.batch(calls, {
label: staticSetLabel(propEntries),
softReload: true,
});
}
function groupStaticSetEntries(
propEntries: [string, number | string][],
): Map<string, [string, number | string][]> {
// One commit per PROPERTY GROUP, each into a static write that owns that group —
// never a live tween, and never a foreign-group write (a width edit used to
// merge into the element's position set, producing a mixed write the split
@@ -194,9 +216,22 @@ async function commitStaticSet(
batch.push(entry);
byGroup.set(group, batch);
}
const staticWrites = animations.filter(
(a) => isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
);
return byGroup;
}
function planStaticSetCalls(
selection: DomEditSelection,
propEntries: [string, number | string][],
selector: string | null,
animations: GsapAnimation[],
): CommitMutationCall[] {
const byGroup = groupStaticSetEntries(propEntries);
const staticWrites = selector
? animations.filter(
(a) =>
isInstantHold(a) && tweenTargetsElement(a.targetSelector, selector, selection.element),
)
: [];
// Resolve every group's target BEFORE committing anything, and coalesce
// groups that land on the SAME write into one commit: the snapshot is captured
// once, so if two groups resolved to one legacy mixed write, a first
@@ -212,13 +247,12 @@ async function commitStaticSet(
newSetBatches.push(batch);
}
}
for (const [targetWrite, batch] of byTargetWrite) {
await commitSetProps(selection, targetWrite, batch, selector, animations, commit);
}
// Fresh adds don't reshape existing sets, so their ids can't go stale.
for (const batch of newSetBatches) {
await addGlobalStaticSet(selection, batch, commit);
}
return [
...[...byTargetWrite].map(([targetWrite, batch]) =>
buildSetPropsCall(selection, targetWrite, batch, selector),
),
...newSetBatches.map((batch) => buildGlobalStaticSetCall(selection, batch)),
];
}
/**
@@ -244,11 +278,10 @@ function findGroupOwningStaticWrite(
* the timeline (matches the manual-drag UX). The global-set instant patch applies
* it straight to the element so the first edit shows with no soft-reload flash.
*/
async function addGlobalStaticSet(
function buildGlobalStaticSetCall(
selection: DomEditSelection,
batch: [string, number | string][],
commit: Commit,
): Promise<void> {
): CommitMutationCall {
const numericProps: SetPatchProps = {};
for (const [k, v] of batch) {
if (typeof v === "number") numericProps[k as keyof SetPatchProps] = v;
@@ -257,10 +290,10 @@ async function addGlobalStaticSet(
// selector is the bare class an id-less element yields, which would hold every
// sibling. No one-element form means no write at all (see writeTargetSelector).
const target = writeTargetSelector(selection);
if (!target) return;
await commit(
if (!target) throw new GsapEditBlockedError("no-selector");
return {
selection,
{
mutation: {
type: "add",
targetSelector: target,
method: "set",
@@ -268,7 +301,7 @@ async function addGlobalStaticSet(
properties: Object.fromEntries(batch),
global: true,
},
{
options: {
label: staticSetLabel(batch),
softReload: true,
...(Object.keys(numericProps).length > 0
@@ -280,7 +313,7 @@ async function addGlobalStaticSet(
}
: {}),
},
);
};
}
/** Convert-if-flat, then write ALL props into ONE keyframe at the playhead. */
@@ -418,6 +451,9 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
selector,
primaryProp,
);
if (!anim && !writeTargetSelector(selection)) {
throw new GsapEditBlockedError("no-selector");
}
// Whether the element is animated at all. A 3D edit only creates/edits
// keyframes when it IS — a static element (no keyframes on any of its tweens)
// gets a `tl.set`, never new keyframes (matches manual drag / resize / rotate).
@@ -472,12 +508,15 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
return;
}
// Existing static hold on a NON-animated element — merge the props into the
// same write (maybeAutoKeyframeSet no-ops when nothing else is keyframed).
if (anim && isInstantHold(anim)) {
await commitSetProps(
// Static element (no keyframes anywhere) — persist as a `tl.set`, never
// keyframes (incl. the no-animation case, which creates a fresh set).
// Route the complete property set through the group-aware planner even
// when pickBestAnimation found one existing set: a mixed X+width edit
// must update the position set AND create a size set atomically rather
// than contaminating the first set with a foreign property group.
if (!elementHasKeyframes) {
await commitStaticSet(
selection,
anim,
propEntries,
selector,
selectedGsapAnimations,
@@ -486,11 +525,12 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
return;
}
// Static element (no keyframes anywhere)persist as a `tl.set`, never
// keyframes (incl. the no-animation case, which creates a fresh set).
if (!elementHasKeyframes) {
await commitStaticSet(
// Existing static hold on an otherwise animated elementmerge the props
// into the same write, then auto-keyframe it against the sibling tween.
if (anim && isInstantHold(anim)) {
await commitSetProps(
selection,
anim,
propEntries,
selector,
selectedGsapAnimations,
@@ -509,7 +549,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
// one-element form the edit is dropped rather than written onto every
// class sibling (see writeTargetSelector).
const newTweenTarget = writeTargetSelector(selection);
if (selector && newTweenTarget) {
if (newTweenTarget) {
const template = selectedGsapAnimations.find((a) => !!a.keyframes);
const tStart = template ? (resolveTweenStart(template) ?? 0) : 0;
const tDur = template ? resolveTweenDuration(template) || 1 : 1;
@@ -539,7 +579,7 @@ export function useAnimatedPropertyCommit(deps: CommitAnimatedPropertyDeps) {
);
return;
}
bumpGsapCache();
throw new GsapEditBlockedError("no-selector");
} catch (error) {
bumpGsapCache();
throw error;
@@ -0,0 +1,215 @@
// @vitest-environment happy-dom
import React, { act, useRef, useState } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TextAreaField } from "../components/editor/propertyPanelSections";
import type { DomEditSelection } from "../components/editor/domEditing";
import type { LeftSidebarHandle } from "../components/sidebar/LeftSidebar";
import { usePlayerStore } from "../player/store/playerStore";
import { useAppHotkeys } from "./useAppHotkeys";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const timelineDelete = vi.fn(async () => undefined);
const domDelete = vi.fn(async () => undefined);
const keyframeDelete = vi.fn();
const textCommits = vi.fn();
let root: Root | null = null;
function parentWithTextChild(): DomEditSelection {
const element = document.createElement("section");
element.id = "selected-card";
const child = document.createElement("span");
child.textContent = "Kicker";
element.append(child);
return {
element,
id: "selected-card",
selector: "#selected-card",
selectorIndex: 0,
label: "Selected card",
tagName: "section",
sourceFile: "index.html",
compositionPath: "index.html",
isCompositionHost: false,
isInsideLockedComposition: false,
boundingBox: { x: 0, y: 0, width: 320, height: 180 },
textContent: "Kicker",
dataAttributes: {},
inlineStyles: {},
computedStyles: {},
textFields: [
{
key: "child:0:span",
label: "Content",
value: "Kicker",
tagName: "span",
attributes: [],
inlineStyles: {},
computedStyles: {},
source: "child",
sourceChildIndex: 0,
},
],
capabilities: {
canSelect: true,
canEditStyles: true,
canCrop: false,
canMove: true,
canResize: true,
canApplyManualOffset: true,
canApplyManualSize: true,
canApplyManualRotation: true,
},
};
}
function Harness() {
const [value, setValue] = useState("Kicker");
const [selectionRefreshed, setSelectionRefreshed] = useState(false);
const selectionRef = useRef<DomEditSelection | null>(parentWithTextChild());
const clearSelectionRef = useRef<() => void>(() => undefined);
const saveTimestampRef = useRef(0);
const leftSidebarRef = useRef<LeftSidebarHandle | null>(null);
useAppHotkeys({
handleTimelineElementDelete: timelineDelete,
handleTimelineElementSplit: vi.fn(async () => undefined),
handleDomEditElementDelete: domDelete,
domEditSelectionRef: selectionRef,
clearDomSelectionRef: clearSelectionRef,
editHistory: {
undo: vi.fn(async () => ({ ok: false })),
redo: vi.fn(async () => ({ ok: false })),
state: { undo: [], redo: [] },
},
readOptionalProjectFile: vi.fn(async () => ""),
readProjectFile: vi.fn(async () => ""),
writeProjectFile: vi.fn(async () => undefined),
domEditSaveTimestampRef: saveTimestampRef,
showToast: vi.fn(),
syncHistoryPreviewAfterApply: vi.fn(async () => undefined),
waitForPendingDomEditSaves: vi.fn(async () => undefined),
leftSidebarRef,
handleCopy: vi.fn(() => false),
handlePaste: vi.fn(async () => undefined),
handleCut: vi.fn(async () => false),
onResetKeyframes: vi.fn(() => false),
onDeleteSelectedKeyframes: keyframeDelete,
});
return (
<>
<TextAreaField
label="Content"
value={value}
onCommit={(next) => {
textCommits(next);
setValue(next);
// Text persistence rebuilds the selected parent's text-field model
// from the preview. Model that refresh: it must not transfer keyboard
// ownership away from the still-mounted Content editor.
setSelectionRefreshed(true);
}}
/>
<button type="button" data-testid="canvas" data-selection-refreshed={selectionRefreshed}>
Canvas
</button>
</>
);
}
function setTextareaValue(textarea: HTMLTextAreaElement, value: string) {
const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
if (!setter) throw new Error("expected native textarea value setter");
setter.call(textarea, value);
textarea.dispatchEvent(new Event("input", { bubbles: true }));
textarea.setSelectionRange(value.length, value.length);
}
function pressBackspace(target: HTMLElement): KeyboardEvent {
const event = new KeyboardEvent("keydown", {
key: "Backspace",
bubbles: true,
cancelable: true,
});
target.dispatchEvent(event);
return event;
}
beforeEach(() => {
vi.useFakeTimers();
timelineDelete.mockClear();
domDelete.mockClear();
keyframeDelete.mockClear();
textCommits.mockClear();
usePlayerStore.getState().reset();
usePlayerStore.getState().setElements([
{
id: "selected-card",
tag: "section",
start: 0,
duration: 10,
track: 0,
},
]);
usePlayerStore.getState().setSelectedElementId("selected-card");
});
afterEach(() => {
if (root) act(() => root?.unmount());
root = null;
document.body.innerHTML = "";
usePlayerStore.getState().reset();
vi.useRealTimers();
});
describe("useAppHotkeys text-field ownership", () => {
it("keeps two Backspaces in a child text editor across its scheduled save", () => {
const host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
act(() => root?.render(<Harness />));
const textarea = host.querySelector("textarea");
const canvas = host.querySelector<HTMLButtonElement>('[data-testid="canvas"]');
if (!textarea || !canvas) throw new Error("expected text editor and canvas target");
textarea.focus();
textarea.setSelectionRange(textarea.value.length, textarea.value.length);
const first = pressBackspace(textarea);
act(() => setTextareaValue(textarea, "Kicke"));
act(() => vi.advanceTimersByTime(120));
expect(first.defaultPrevented).toBe(false);
expect(textCommits).toHaveBeenLastCalledWith("Kicke");
expect(canvas.dataset.selectionRefreshed).toBe("true");
expect(document.activeElement).toBe(textarea);
expect(textarea.selectionStart).toBe(5);
expect(textarea.selectionEnd).toBe(5);
const second = pressBackspace(textarea);
act(() => setTextareaValue(textarea, "Kick"));
act(() => vi.advanceTimersByTime(120));
expect(second.defaultPrevented).toBe(false);
expect(textarea.value).toBe("Kick");
expect(textCommits.mock.calls).toEqual([["Kicke"], ["Kick"]]);
expect(document.activeElement).toBe(textarea);
expect(textarea.selectionStart).toBe(4);
expect(textarea.selectionEnd).toBe(4);
expect(timelineDelete).not.toHaveBeenCalled();
expect(domDelete).not.toHaveBeenCalled();
expect(keyframeDelete).not.toHaveBeenCalled();
canvas.focus();
const canvasDelete = pressBackspace(canvas);
expect(canvasDelete.defaultPrevented).toBe(true);
expect(timelineDelete).toHaveBeenCalledTimes(1);
expect(domDelete).not.toHaveBeenCalled();
expect(keyframeDelete).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,61 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { describe, expect, it, vi } from "vitest";
import type { DomEditSelection } from "../components/editor/domEditingTypes";
import {
applyStudioBoxSize,
applyStudioPathOffset,
applyStudioRotation,
readStudioBoxSize,
readStudioPathOffset,
readStudioRotation,
} from "../components/editor/manualEdits";
import { useDomGeometryCommits } from "./useDomGeometryCommits";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
describe("useDomGeometryCommits rollback", () => {
it("restores every optimistic geometry mutation when persistence rejects", async () => {
const element = document.createElement("div");
element.id = "box";
document.body.append(element);
applyStudioPathOffset(element, { x: 10, y: 20 });
applyStudioBoxSize(element, { width: 100, height: 80 });
applyStudioRotation(element, { angle: 15 });
const selection = {
id: "box",
selector: "#box",
element,
} as unknown as DomEditSelection;
const failure = new Error("save failed");
const commitPositionPatchToHtml = vi.fn().mockRejectedValue(failure);
let commits: ReturnType<typeof useDomGeometryCommits> | null = null;
const host = document.createElement("div");
const root = createRoot(host);
function Probe() {
commits = useDomGeometryCommits({
previewIframeRef: { current: null },
showToast: vi.fn(),
commitPositionPatchToHtml,
});
return null;
}
act(() => root.render(<Probe />));
await expect(commits!.handleDomPathOffsetCommit(selection, { x: 50, y: 60 })).rejects.toBe(
failure,
);
await expect(
commits!.handleDomBoxSizeCommit(selection, { width: 200, height: 160 }, { x: 30, y: 40 }),
).rejects.toBe(failure);
await expect(commits!.handleDomRotationCommit(selection, { angle: 45 })).rejects.toBe(failure);
expect(readStudioPathOffset(element)).toEqual({ x: 10, y: 20 });
expect(readStudioBoxSize(element)).toEqual({ width: 100, height: 80 });
expect(readStudioRotation(element)).toEqual({ angle: 15 });
act(() => root.unmount());
});
});
@@ -4,6 +4,12 @@ import {
applyStudioPathOffset,
applyStudioBoxSize,
applyStudioRotation,
captureStudioPathOffset,
captureStudioBoxSize,
captureStudioRotation,
restoreStudioPathOffset,
restoreStudioBoxSize,
restoreStudioRotation,
clearStudioPathOffset,
clearStudioBoxSize,
clearStudioRotation,
@@ -51,10 +57,14 @@ export function useDomGeometryCommits({
showToast(error.message, "error");
return Promise.reject(error);
}
const before = captureStudioPathOffset(selection.element);
applyStudioPathOffset(selection.element, next);
return commitPositionPatchToHtml(selection, buildPathOffsetPatches(selection.element), {
label: "Move layer",
coalesceKey: `path-offset:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioPathOffset(selection.element, before);
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast],
@@ -71,6 +81,8 @@ export function useDomGeometryCommits({
showToast(error.message, "error");
return Promise.reject(error);
}
const beforeSize = captureStudioBoxSize(selection.element);
const beforeOffset = offset ? captureStudioPathOffset(selection.element) : null;
applyStudioBoxSize(selection.element, next);
// Anchored-corner resize (NW/NE/SW) also moves the element to keep the
// opposite corner fixed. Apply the offset and emit BOTH patch sets in a
@@ -86,6 +98,10 @@ export function useDomGeometryCommits({
return commitPositionPatchToHtml(selection, patches, {
label: "Resize layer box",
coalesceKey: `box-size:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioBoxSize(selection.element, beforeSize);
if (beforeOffset) restoreStudioPathOffset(selection.element, beforeOffset);
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast],
@@ -98,10 +114,14 @@ export function useDomGeometryCommits({
showToast(error.message, "error");
return Promise.reject(error);
}
const before = captureStudioRotation(selection.element);
applyStudioRotation(selection.element, next);
return commitPositionPatchToHtml(selection, buildRotationPatches(selection.element), {
label: "Rotate layer",
coalesceKey: `rotation:${getDomEditTargetKey(selection)}`,
}).catch((error) => {
restoreStudioRotation(selection.element, before);
throw error;
});
},
[commitPositionPatchToHtml, previewIframeRef, showToast],
@@ -0,0 +1,131 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { useEditorSave, type EditorSaveHandle } from "./useEditorSave";
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type WriteProjectFile = (path: string, content: string, expectedContent?: string) => Promise<void>;
async function mountEditorSave(writeProjectFile: WriteProjectFile) {
const captured: { handle: EditorSaveHandle | null } = { handle: null };
function Probe() {
captured.handle = useEditorSave({
editingPathRef: { current: "index.html" },
projectIdRef: { current: "project-a" },
readProjectFile: vi.fn(async () => "before"),
writeProjectFile,
recordEdit: vi.fn(async () => undefined),
domEditSaveTimestampRef: { current: 0 },
setRefreshKey: vi.fn(),
showToast: vi.fn(),
});
return null;
}
const root = createRoot(document.createElement("div"));
await act(async () => root.render(<Probe />));
if (!captured.handle) throw new Error("Editor save handle was not mounted");
return {
handle: captured.handle,
unmount: () => act(async () => root.unmount()),
};
}
describe("useEditorSave pending work", () => {
beforeEach(() => {
vi.stubGlobal(
"requestAnimationFrame",
vi.fn(() => 41),
);
vi.stubGlobal("cancelAnimationFrame", vi.fn());
});
afterEach(() => vi.unstubAllGlobals());
it("exposes and flushes the latest rAF-buffered source candidate", async () => {
const writeProjectFile = vi.fn(async () => undefined);
const mounted = await mountEditorSave(writeProjectFile);
act(() => mounted.handle.handleContentChange("studio candidate"));
expect(mounted.handle.getPendingCandidate()).toEqual({
projectId: "project-a",
path: "index.html",
content: "studio candidate",
});
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({ status: "clean" });
expect(writeProjectFile).toHaveBeenCalledWith("index.html", "studio candidate", "before");
await mounted.unmount();
});
it("joins an in-flight source save instead of writing the frozen candidate twice", async () => {
let frame: FrameRequestCallback | null = null;
vi.stubGlobal(
"requestAnimationFrame",
vi.fn((callback: FrameRequestCallback) => {
frame = callback;
return 42;
}),
);
let finishWrite!: () => void;
const writeProjectFile = vi
.fn()
.mockImplementationOnce(
() =>
new Promise<void>((resolve) => {
finishWrite = resolve;
}),
)
.mockResolvedValue(undefined);
const mounted = await mountEditorSave(writeProjectFile);
act(() => mounted.handle.handleContentChange("candidate"));
act(() => frame?.(0));
await vi.waitFor(() => expect(writeProjectFile).toHaveBeenCalledOnce());
const drained = mounted.handle.flushPendingSave();
expect(writeProjectFile).toHaveBeenCalledOnce();
finishWrite();
await expect(drained).resolves.toEqual({ status: "clean" });
expect(writeProjectFile).toHaveBeenCalledOnce();
await mounted.unmount();
});
it("preserves conflict details when flushing a buffered source candidate", async () => {
const conflict = new StudioFileConflictError({
filePath: "index.html",
currentVersion: "external-v2",
currentContent: "external",
attemptedContent: "studio candidate",
});
const mounted = await mountEditorSave(async () => {
throw conflict;
});
act(() => mounted.handle.handleContentChange("studio candidate"));
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({
status: "conflict",
error: conflict,
});
await mounted.unmount();
});
it("discards an rAF-buffered candidate without persisting it", async () => {
const writeProjectFile = vi.fn(async () => undefined);
const mounted = await mountEditorSave(writeProjectFile);
act(() => mounted.handle.handleContentChange("discard me"));
act(() => mounted.handle.discardPendingSave());
expect(mounted.handle.getPendingCandidate()).toBeNull();
await expect(mounted.handle.flushPendingSave()).resolves.toEqual({ status: "clean" });
expect(writeProjectFile).not.toHaveBeenCalled();
expect(cancelAnimationFrame).toHaveBeenCalledWith(41);
await mounted.unmount();
});
});
+113 -39
View File
@@ -2,6 +2,10 @@ import { useCallback, useRef } from "react";
import { saveProjectFilesWithHistory } from "../utils/studioFileHistory";
import type { EditHistoryKind } from "../utils/editHistory";
import { trackStudioEvent } from "../utils/studioTelemetry";
import {
StudioFileConflictError,
type StudioSaveDrainResult,
} from "../utils/studioSaveDiagnostics";
interface RecordEditInput {
label: string;
@@ -21,6 +25,25 @@ interface UseEditorSaveOptions {
showToast: (message: string, tone?: "error" | "info") => void;
}
export interface EditorSaveCandidate {
projectId: string;
path: string;
content: string;
}
export type EditorSaveDrainResult = StudioSaveDrainResult;
export interface EditorSaveHandle {
saveRafRef: React.MutableRefObject<number | null>;
handleContentChange: (content: string) => void;
/** Read by the external-reload reconciliation introduced in stack PR #2993. */
getPendingCandidate: () => EditorSaveCandidate | null;
/** Wired into the external-reload drain by stack PR #2993. */
flushPendingSave: () => Promise<EditorSaveDrainResult>;
/** Used by PR #2993 when the external version wins. */
discardPendingSave: () => void;
}
export function useEditorSave({
editingPathRef,
projectIdRef,
@@ -30,12 +53,70 @@ export function useEditorSave({
domEditSaveTimestampRef,
setRefreshKey,
showToast,
}: UseEditorSaveOptions) {
}: UseEditorSaveOptions): EditorSaveHandle {
const saveRafRef = useRef<number | null>(null);
const refreshRafRef = useRef<number | null>(null);
// One error toast per burst of failures — every keystroke retries the save,
// and error toasts persist until dismissed, so don't stack duplicates.
const lastFailureToastAtRef = useRef(0);
const pendingCandidateRef = useRef<EditorSaveCandidate | null>(null);
const inFlightRef = useRef<Promise<EditorSaveDrainResult> | null>(null);
const inFlightCandidateRef = useRef<EditorSaveCandidate | null>(null);
const reportFailure = useCallback(
(path: string, error: unknown) => {
trackStudioEvent("save_failure", {
source: "code_editor",
error_message: error instanceof Error ? error.message : "unknown",
});
const now = Date.now();
if (now - lastFailureToastAtRef.current > 5000) {
lastFailureToastAtRef.current = now;
showToast(
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
"error",
);
}
},
[showToast],
);
const persistCandidate = useCallback(
(candidate: EditorSaveCandidate): Promise<EditorSaveDrainResult> => {
const task = saveProjectFilesWithHistory({
projectId: candidate.projectId,
label: "Edit source",
kind: "source",
coalesceKey: `source:${candidate.path}`,
files: { [candidate.path]: candidate.content },
readFile: readProjectFile,
writeFile: writeProjectFile,
recordEdit,
})
.then<EditorSaveDrainResult>(() => {
if (pendingCandidateRef.current === candidate) pendingCandidateRef.current = null;
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
return { status: "clean" };
})
.catch<EditorSaveDrainResult>((error: unknown) => {
reportFailure(candidate.path, error);
return error instanceof StudioFileConflictError
? { status: "conflict", error }
: { status: "failed", error };
})
.finally(() => {
if (inFlightRef.current === task) {
inFlightRef.current = null;
inFlightCandidateRef.current = null;
}
});
inFlightRef.current = task;
inFlightCandidateRef.current = candidate;
return task;
},
[readProjectFile, recordEdit, reportFailure, setRefreshKey, writeProjectFile],
);
const handleContentChange = useCallback(
(content: string) => {
@@ -44,53 +125,46 @@ export function useEditorSave({
const path = editingPathRef.current;
if (!path) return;
const candidate = { projectId: pid, path, content };
pendingCandidateRef.current = candidate;
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
saveRafRef.current = requestAnimationFrame(() => {
saveRafRef.current = null;
domEditSaveTimestampRef.current = Date.now();
saveProjectFilesWithHistory({
projectId: pid,
label: "Edit source",
kind: "source",
coalesceKey: `source:${path}`,
files: { [path]: content },
readFile: readProjectFile,
writeFile: writeProjectFile,
recordEdit,
})
.then(() => {
if (refreshRafRef.current != null) cancelAnimationFrame(refreshRafRef.current);
refreshRafRef.current = requestAnimationFrame(() => setRefreshKey((k) => k + 1));
})
.catch((error) => {
trackStudioEvent("save_failure", {
source: "code_editor",
error_message: error instanceof Error ? error.message : "unknown",
});
const now = Date.now();
if (now - lastFailureToastAtRef.current > 5000) {
lastFailureToastAtRef.current = now;
showToast(
`Couldn't save ${path} — your latest edits are NOT persisted. Check the preview server; editing again retries the save.`,
"error",
);
}
});
void persistCandidate(candidate);
});
},
[
domEditSaveTimestampRef,
editingPathRef,
projectIdRef,
readProjectFile,
recordEdit,
setRefreshKey,
showToast,
writeProjectFile,
],
[domEditSaveTimestampRef, editingPathRef, projectIdRef, persistCandidate],
);
const flushPendingSave = useCallback(async (): Promise<EditorSaveDrainResult> => {
if (saveRafRef.current != null) {
cancelAnimationFrame(saveRafRef.current);
saveRafRef.current = null;
}
const candidate = pendingCandidateRef.current;
if (candidate && candidate === inFlightCandidateRef.current && inFlightRef.current) {
return inFlightRef.current;
}
if (candidate) {
domEditSaveTimestampRef.current = Date.now();
return persistCandidate(candidate);
}
return (await inFlightRef.current) ?? { status: "clean" };
}, [domEditSaveTimestampRef, persistCandidate]);
const discardPendingSave = useCallback(() => {
if (saveRafRef.current != null) cancelAnimationFrame(saveRafRef.current);
saveRafRef.current = null;
pendingCandidateRef.current = null;
}, []);
return {
saveRafRef,
handleContentChange,
getPendingCandidate: () => pendingCandidateRef.current,
flushPendingSave,
discardPendingSave,
};
}
@@ -0,0 +1,221 @@
// @vitest-environment happy-dom
import { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
import { markStudioWriteToken, resetStudioWriteTokens } from "../utils/studioFileVersion";
import {
useExternalFileChangeCoordinator,
type ExternalFileChangeCoordinatorHandle,
} from "./useExternalFileChangeCoordinator";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
type HotHandler = (payload?: unknown) => void;
type CoordinatorOptions = Parameters<typeof useExternalFileChangeCoordinator>[0];
const roots: Array<ReturnType<typeof createRoot>> = [];
let handler: HotHandler | null;
async function mountCoordinator(overrides: Partial<CoordinatorOptions> = {}) {
const captured: { handle: ExternalFileChangeCoordinatorHandle | null } = { handle: null };
const defaults: CoordinatorOptions = {
projectId: "project-a",
activeCompPath: "index.html",
pendingTimelineEditPathRef: { current: new Set() },
drainPendingChanges: vi.fn(async () => ({ status: "clean" as const })),
reloadPreview: vi.fn(),
reloadSdkSession: vi.fn(),
persistConflictSnapshot: vi.fn(async () => undefined),
discardPendingChanges: vi.fn(),
overwriteConflict: vi.fn(async () => undefined),
readProjectFile: vi.fn(async () => "external"),
};
const options = { ...defaults, ...overrides };
const root = createRoot(document.createElement("div"));
roots.push(root);
function Probe() {
captured.handle = useExternalFileChangeCoordinator(options);
return null;
}
await act(async () => root.render(<Probe />));
return { captured, options };
}
describe("external file change coordinator", () => {
beforeEach(() => {
handler = null;
resetStudioWriteTokens();
vi.stubGlobal("__HF_STUDIO_HOT_TEST_ADAPTER__", {
on: (_event: string, next: HotHandler) => {
handler = next;
},
off: () => {
handler = null;
},
});
});
afterEach(async () => {
while (roots.length > 0) await act(async () => roots.pop()?.unmount());
vi.unstubAllGlobals();
});
it("drains before reloading Preview and SDK exactly once", async () => {
const order: string[] = [];
const { captured } = await mountCoordinator({
drainPendingChanges: async () => {
order.push("drain");
return { status: "clean" };
},
reloadPreview: () => order.push("preview"),
reloadSdkSession: () => order.push("sdk"),
});
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
expect(order).toEqual(["drain", "preview", "sdk"]);
expect(captured.handle?.blocked).toBeNull();
});
it("suppresses an exact Studio write receipt", async () => {
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
const reloadPreview = vi.fn();
const reloadSdkSession = vi.fn();
await mountCoordinator({ drainPendingChanges, reloadPreview, reloadSdkSession });
markStudioWriteToken("studio-write-1");
await act(async () =>
handler?.({ path: "index.html", content: "studio", writeToken: "studio-write-1" }),
);
expect(drainPendingChanges).not.toHaveBeenCalled();
expect(reloadPreview).not.toHaveBeenCalled();
expect(reloadSdkSession).not.toHaveBeenCalled();
});
it("does not suppress a racing external write by path alone", async () => {
const pendingTimelineEditPathRef = { current: new Set(["index.html"]) };
const drainPendingChanges = vi.fn(async () => ({ status: "clean" as const }));
const reloadPreview = vi.fn();
const reloadSdkSession = vi.fn();
await mountCoordinator({
pendingTimelineEditPathRef,
drainPendingChanges,
reloadPreview,
reloadSdkSession,
});
await act(async () => handler?.({ path: "index.html", content: "agent edit", version: "v2" }));
expect(pendingTimelineEditPathRef.current).not.toContain("index.html");
expect(drainPendingChanges).toHaveBeenCalledOnce();
expect(reloadPreview).toHaveBeenCalledOnce();
expect(reloadSdkSession).toHaveBeenCalledOnce();
});
it("blocks both reloads and retains a complete conflict", async () => {
const conflict = new StudioFileConflictError({
filePath: "index.html",
currentVersion: "v2",
currentContent: "external",
attemptedContent: "studio",
});
const persistConflictSnapshot = vi.fn(async () => undefined);
const { captured, options } = await mountCoordinator({
drainPendingChanges: async () => ({ status: "conflict", error: conflict }),
persistConflictSnapshot,
});
await act(async () => handler?.({ path: "index.html", content: "external", version: "v2" }));
expect(persistConflictSnapshot).toHaveBeenCalledWith("project-a", conflict);
expect(captured.handle?.blocked).toMatchObject({ status: "conflict", error: conflict });
expect(options.reloadPreview).not.toHaveBeenCalled();
expect(options.reloadSdkSession).not.toHaveBeenCalled();
});
it("ignores stale drain completion after a newer generation", async () => {
const drains: Array<(result: { status: "clean" }) => void> = [];
const { options } = await mountCoordinator({
drainPendingChanges: () => new Promise((resolve) => drains.push(resolve)),
});
act(() => {
handler?.({ path: "index.html", content: "first", version: "v2" });
handler?.({ path: "index.html", content: "second", version: "v3" });
});
await act(async () => drains[0]?.({ status: "clean" }));
expect(options.reloadPreview).not.toHaveBeenCalled();
await act(async () => drains[1]?.({ status: "clean" }));
expect(options.reloadPreview).toHaveBeenCalledOnce();
expect(options.reloadSdkSession).toHaveBeenCalledOnce();
});
it("restores a durable unresolved conflict after remount", async () => {
const { captured } = await mountCoordinator({
recoveryFilePath: "index.html",
loadConflictSnapshot: vi.fn(async () => ({
kind: "conflict" as const,
projectId: "project-a",
filePath: "index.html",
externalVersion: "v2",
externalContent: "external",
studioContent: "studio",
createdAt: 100,
})),
});
await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("conflict"));
expect(captured.handle?.blocked).toMatchObject({
error: { currentContent: "external", attemptedContent: "studio" },
});
});
it("retains the final local candidate when a drain fails", async () => {
const failure = new Error("network unavailable");
const persistFailureSnapshot = vi.fn(async () => undefined);
const deleteConflictSnapshot = vi.fn(async () => undefined);
const { captured } = await mountCoordinator({
drainPendingChanges: vi
.fn()
.mockResolvedValueOnce({ status: "failed" as const, error: failure })
.mockResolvedValueOnce({ status: "clean" as const }),
getPendingCandidate: () => ({ path: "index.html", content: "final local candidate" }),
persistFailureSnapshot,
deleteConflictSnapshot,
});
await act(async () => handler?.({ path: "index.html" }));
expect(captured.handle?.blocked).toMatchObject({
status: "failed",
error: failure,
studioContent: "final local candidate",
});
expect(persistFailureSnapshot).toHaveBeenCalledWith(
"project-a",
"index.html",
"final local candidate",
null,
null,
failure,
);
await act(async () => captured.handle?.retry());
expect(deleteConflictSnapshot).toHaveBeenCalledWith("project-a", "index.html");
});
it("restores and overwrites from a durable failed draft", async () => {
const overwriteConflict = vi.fn(async () => undefined);
const { captured } = await mountCoordinator({
recoveryFilePath: "index.html",
overwriteConflict,
loadConflictSnapshot: vi.fn(async () => ({
kind: "failed" as const,
projectId: "project-a",
filePath: "index.html",
externalVersion: "v2",
externalContent: "external",
studioContent: "recover me",
failureMessage: "network unavailable",
createdAt: 100,
})),
});
await vi.waitFor(() => expect(captured.handle?.blocked?.status).toBe("failed"));
expect(captured.handle?.blocked).toMatchObject({
studioContent: "recover me",
recovered: true,
});
await act(async () => captured.handle?.keepStudioFile());
expect(overwriteConflict).toHaveBeenCalledWith(
expect.objectContaining({ attemptedContent: "recover me", currentVersion: "v2" }),
);
});
});
@@ -0,0 +1,429 @@
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from "react";
import { readStudioFileChangePath } from "../components/editor/manualEdits";
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
import type { ExternalConflictSnapshot } from "../utils/externalConflictStorage";
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
import { consumeStudioWriteToken } from "../utils/studioFileVersion";
type ExternalChangeDrainResult =
| { status: "clean" }
| { status: "conflict"; error: StudioFileConflictError }
| { status: "failed"; error: unknown };
export type ExternalFileChangeBlockedState =
| {
status: "conflict";
generation: number;
error: StudioFileConflictError;
payload: unknown;
}
| {
status: "failed";
generation: number;
path: string;
error: unknown;
payload: unknown;
studioContent: string | null;
recovered: boolean;
};
interface ExternalFileChangeCoordinatorOptions {
projectId: string | null;
activeCompPath: string | null;
recoveryFilePath?: string | null;
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
drainPendingChanges: () => Promise<ExternalChangeDrainResult>;
getPendingCandidate?: () => { path: string; content: string } | null;
discardPendingChanges: () => void;
reloadPreview: () => void;
reloadSdkSession: (path: string) => void;
persistConflictSnapshot: (projectId: string, conflict: StudioFileConflictError) => Promise<void>;
persistFailureSnapshot?: (
projectId: string,
filePath: string,
studioContent: string,
externalVersion: string | null,
externalContent: string | null,
error: unknown,
) => Promise<void>;
loadConflictSnapshot?: (
projectId: string,
filePath: string,
) => Promise<ExternalConflictSnapshot | null>;
deleteConflictSnapshot?: (projectId: string, filePath: string) => Promise<void>;
overwriteConflict: (conflict: StudioFileConflictError) => Promise<void>;
readProjectFile: (path: string) => Promise<string>;
onUseExternalFile?: (path: string, content: string) => void;
resetSaveQueues?: () => void;
}
export interface ExternalFileChangeCoordinatorHandle {
blocked: ExternalFileChangeBlockedState | null;
retry: () => Promise<void>;
useExternalFile: () => Promise<void>;
keepStudioFile: () => Promise<void>;
}
interface HotTestAdapter {
on(event: string, handler: (payload?: unknown) => void): void;
off(event: string, handler: (payload?: unknown) => void): void;
}
function testHotAdapter(): HotTestAdapter | null {
const value = (globalThis as { __HF_STUDIO_HOT_TEST_ADAPTER__?: unknown })
.__HF_STUDIO_HOT_TEST_ADAPTER__;
if (!value || typeof value !== "object") return null;
const candidate = value as Partial<HotTestAdapter>;
return typeof candidate.on === "function" && typeof candidate.off === "function"
? (candidate as HotTestAdapter)
: null;
}
function readFileChangeContent(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
if (typeof record.content === "string") return record.content;
return "data" in record ? readFileChangeContent(record.data) : null;
}
function readFileChangeVersion(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
if (typeof record.version === "string") return record.version;
return "data" in record ? readFileChangeVersion(record.data) : null;
}
function readFileChangeWriteToken(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
if (typeof record.writeToken === "string") return record.writeToken;
return "data" in record ? readFileChangeWriteToken(record.data) : null;
}
function eventIdentity(path: string, payload: unknown): string | null {
const version = readFileChangeVersion(payload);
if (version) return `${path}\0${version}`;
const content = readFileChangeContent(payload);
return content == null ? null : `${path}\0${content.length}\0${content}`;
}
export function useExternalFileChangeCoordinator({
projectId,
activeCompPath,
recoveryFilePath = activeCompPath,
pendingTimelineEditPathRef,
drainPendingChanges,
getPendingCandidate,
discardPendingChanges,
reloadPreview,
reloadSdkSession,
persistConflictSnapshot,
persistFailureSnapshot,
loadConflictSnapshot,
deleteConflictSnapshot,
overwriteConflict,
readProjectFile,
onUseExternalFile,
resetSaveQueues,
}: ExternalFileChangeCoordinatorOptions): ExternalFileChangeCoordinatorHandle {
const [blocked, setBlocked] = useState<ExternalFileChangeBlockedState | null>(null);
const generationRef = useRef(0);
const mountedRef = useRef(true);
const lastEventIdentityRef = useRef<string | null>(null);
const blockedRef = useRef(blocked);
const snapshotWriteTailRef = useRef<Promise<void>>(Promise.resolve());
blockedRef.current = blocked;
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
generationRef.current += 1;
};
}, []);
useEffect(() => {
generationRef.current += 1;
setBlocked(null);
lastEventIdentityRef.current = null;
}, [projectId, activeCompPath]);
useEffect(() => {
if (!projectId || !recoveryFilePath || !loadConflictSnapshot) return;
const generation = ++generationRef.current;
let cancelled = false;
void loadConflictSnapshot(projectId, recoveryFilePath)
.then((snapshot) => {
if (cancelled || !snapshot || !mountedRef.current || generation !== generationRef.current) {
return;
}
const payload = {
path: snapshot.filePath,
version: snapshot.externalVersion,
content: snapshot.externalContent,
};
if (snapshot.kind === "failed") {
setBlocked({
status: "failed",
generation,
path: snapshot.filePath,
error: new Error(snapshot.failureMessage),
payload,
studioContent: snapshot.studioContent,
recovered: true,
});
} else {
const error = new StudioFileConflictError({
filePath: snapshot.filePath,
currentVersion: snapshot.externalVersion,
currentContent: snapshot.externalContent,
attemptedContent: snapshot.studioContent,
});
setBlocked({ status: "conflict", generation, error, payload });
}
})
.catch(() => {
// Storage may be unavailable in restricted browser contexts. A failed
// best-effort restore must not create an unhandled rejection or block
// a project that has no known recovery record.
});
return () => {
cancelled = true;
};
}, [loadConflictSnapshot, projectId, recoveryFilePath]);
const reloadAcceptedGeneration = useCallback(
(path: string) => {
reloadPreview();
reloadSdkSession(path);
},
[reloadPreview, reloadSdkSession],
);
const persistSnapshotInOrder = useCallback(async (write: () => Promise<void>) => {
const next = snapshotWriteTailRef.current.catch(() => undefined).then(write);
snapshotWriteTailRef.current = next.then(
() => undefined,
() => undefined,
);
await next;
}, []);
const processChange = useCallback(
// fallow-ignore-next-line complexity
async (payload: unknown, allowDuplicate = false) => {
const path = readStudioFileChangePath(payload);
if (!path || !projectId) return;
const pendingTimelinePaths = pendingTimelineEditPathRef.current;
// The old path-only suppression could drop a real agent/user write that
// raced ahead of the timeline write receipt. Clear the legacy marker but
// decide ownership only from the exact write token/content below.
pendingTimelinePaths.delete(path);
const content = readFileChangeContent(payload);
if (consumeStudioWriteToken(readFileChangeWriteToken(payload))) return;
if (content != null && isSelfWriteEcho(path, content)) return;
const identity = eventIdentity(path, payload);
if (!allowDuplicate && identity != null && identity === lastEventIdentityRef.current) return;
lastEventIdentityRef.current = identity;
const generation = ++generationRef.current;
const result = await drainPendingChanges();
if (!mountedRef.current || generation !== generationRef.current) return;
if (result.status === "clean") {
const previousBlocked = blockedRef.current;
if (previousBlocked?.status === "failed" && deleteConflictSnapshot) {
try {
await deleteConflictSnapshot(projectId, path);
} catch (error) {
if (mountedRef.current && generation === generationRef.current) {
setBlocked({ ...previousBlocked, generation, error });
}
return;
}
}
if (!mountedRef.current || generation !== generationRef.current) return;
setBlocked(null);
reloadAcceptedGeneration(path);
return;
}
if (result.status === "failed") {
const candidate = getPendingCandidate?.();
const studioContent = candidate?.path === path ? candidate.content : null;
let error = result.error;
if (studioContent != null && persistFailureSnapshot) {
try {
await persistSnapshotInOrder(() =>
persistFailureSnapshot(
projectId,
path,
studioContent,
readFileChangeVersion(payload),
content,
result.error,
),
);
} catch (snapshotError) {
error = new Error(
`Studio could not save the edit or its recovery snapshot: ${
snapshotError instanceof Error ? snapshotError.message : String(snapshotError)
}`,
{ cause: result.error },
);
}
}
if (!mountedRef.current || generation !== generationRef.current) return;
setBlocked({
status: "failed",
generation,
path,
error,
payload,
studioContent,
recovered: false,
});
return;
}
try {
await persistSnapshotInOrder(() => persistConflictSnapshot(projectId, result.error));
} catch (error) {
if (!mountedRef.current || generation !== generationRef.current) return;
setBlocked({
status: "failed",
generation,
path,
error,
payload,
studioContent: result.error.attemptedContent,
recovered: false,
});
return;
}
if (!mountedRef.current || generation !== generationRef.current) return;
setBlocked({ status: "conflict", generation, error: result.error, payload });
},
[
projectId,
pendingTimelineEditPathRef,
drainPendingChanges,
deleteConflictSnapshot,
getPendingCandidate,
persistConflictSnapshot,
persistFailureSnapshot,
persistSnapshotInOrder,
reloadAcceptedGeneration,
],
);
useEffect(() => {
const handler = (payload?: unknown) => processChange(payload);
const adapter = testHotAdapter();
if (adapter) {
adapter.on("hf:file-change", handler);
return () => adapter.off("hf:file-change", handler);
}
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
return () => import.meta.hot?.off?.("hf:file-change", handler);
}
const eventSource = new EventSource("/api/events");
eventSource.addEventListener("file-change", handler);
return () => eventSource.close();
}, [processChange]);
const retry = useCallback(async () => {
const current = blockedRef.current;
if (!current || current.status === "conflict" || current.recovered) return;
resetSaveQueues?.();
lastEventIdentityRef.current = null;
await processChange(current.payload, true);
}, [processChange, resetSaveQueues]);
const useExternalFile = useCallback(
// fallow-ignore-next-line complexity
async () => {
const current = blockedRef.current;
if (!current || !projectId || current.generation !== generationRef.current) return;
const path = current.status === "conflict" ? current.error.filePath : current.path;
const external =
current.status === "conflict" && current.error.currentContent != null
? current.error.currentContent
: await readProjectFile(path);
if (current.generation !== generationRef.current) return;
discardPendingChanges();
resetSaveQueues?.();
onUseExternalFile?.(path, external);
await deleteConflictSnapshot?.(projectId, path);
setBlocked(null);
reloadAcceptedGeneration(path);
},
[
deleteConflictSnapshot,
discardPendingChanges,
onUseExternalFile,
projectId,
readProjectFile,
reloadAcceptedGeneration,
resetSaveQueues,
],
);
// fallow-ignore-next-line complexity
const keepStudioFile = useCallback(async () => {
const current = blockedRef.current;
if (!current || !projectId) return;
if (current.generation !== generationRef.current) return;
let conflict: StudioFileConflictError;
if (current.status === "conflict") {
conflict = current.error;
} else {
if (!current.recovered || current.studioContent == null) return;
try {
const currentContent =
readFileChangeContent(current.payload) ?? (await readProjectFile(current.path));
conflict = new StudioFileConflictError({
filePath: current.path,
currentVersion: readFileChangeVersion(current.payload),
currentContent,
attemptedContent: current.studioContent,
});
} catch (error) {
if (current.generation === generationRef.current) setBlocked({ ...current, error });
return;
}
}
try {
await overwriteConflict(conflict);
} catch (error) {
if (current.generation === generationRef.current) {
setBlocked({
status: "failed",
generation: current.generation,
path: conflict.filePath,
error,
payload: current.payload,
studioContent: conflict.attemptedContent,
recovered: current.status === "failed" && current.recovered,
});
}
return;
}
if (current.generation !== generationRef.current) return;
discardPendingChanges();
resetSaveQueues?.();
await deleteConflictSnapshot?.(projectId, conflict.filePath);
setBlocked(null);
reloadAcceptedGeneration(conflict.filePath);
}, [
deleteConflictSnapshot,
discardPendingChanges,
overwriteConflict,
projectId,
readProjectFile,
reloadAcceptedGeneration,
resetSaveQueues,
]);
return { blocked, retry, useExternalFile, keepStudioFile };
}
@@ -20,15 +20,65 @@ vi.mock("./useEditorSave", () => ({
useEditorSave: () => ({
saveRafRef: { current: null },
handleContentChange: vi.fn(),
getPendingCandidate: vi.fn(() => null),
flushPendingSave: vi.fn(async () => ({ status: "clean" as const })),
discardPendingSave: vi.fn(),
}),
}));
import { useFileManager } from "./useFileManager";
import { resetStudioWriteTokens, studioFileContentVersion } from "../utils/studioFileVersion";
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
function useTestFileManager(projectId: string) {
return useFileManager({
projectId,
showToast: vi.fn(),
recordEdit: vi.fn(async () => {}),
domEditSaveTimestampRef: { current: 0 },
setRefreshKey: vi.fn(),
});
}
async function mountTestFileManager(projectId = "project-a") {
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
function Probe() {
captured.manager = useTestFileManager(projectId);
return null;
}
const root = createRoot(document.createElement("div"));
await act(async () => root.render(<Probe />));
const manager = captured.manager;
if (!manager) throw new Error("file manager did not render");
return { manager, root };
}
async function mountOverwriteRequest(response: Response) {
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (init?.method !== "PUT") {
throw new Error("overwrite unexpectedly performed a preflight read");
}
return Promise.resolve(response);
});
vi.stubGlobal("fetch", fetchMock);
return { ...(await mountTestFileManager()), fetchMock };
}
function createOverwriteConflict(currentVersion: string | null, currentContent: string | null) {
return new StudioFileConflictError({
filePath: "index.html",
currentVersion,
currentContent,
attemptedContent: "STUDIO",
});
}
describe("useFileManager project ownership", () => {
afterEach(() => {
resetStudioWriteTokens();
vi.useRealTimers();
vi.unstubAllGlobals();
});
@@ -54,13 +104,7 @@ describe("useFileManager project ownership", () => {
const captured: { manager: ReturnType<typeof useFileManager> | null } = { manager: null };
function Probe({ projectId }: { projectId: string }) {
captured.manager = useFileManager({
projectId,
showToast: vi.fn(),
recordEdit: vi.fn(async () => {}),
domEditSaveTimestampRef: { current: 0 },
setRefreshKey: vi.fn(),
});
captured.manager = useTestFileManager(projectId);
return null;
}
@@ -106,4 +150,92 @@ describe("useFileManager project ownership", () => {
await act(async () => root.unmount());
});
it("uses a fresh write token when a lost response retries a committed save", async () => {
vi.useFakeTimers();
let putAttempt = 0;
const fetchMock = vi.fn((_url: string, init?: RequestInit) => {
if (!init?.method) {
return Promise.resolve({
ok: true,
json: async () => ({ content: "BEFORE", version: "v1" }),
} as Response);
}
putAttempt += 1;
if (putAttempt === 1) return Promise.reject(new TypeError("response lost"));
return Promise.resolve({ ok: true, json: async () => ({ version: "v2" }) } as Response);
});
vi.stubGlobal("fetch", fetchMock);
const { manager, root } = await mountTestFileManager();
await manager.readProjectFile("index.html");
const write = manager.writeProjectFile("index.html", "AFTER");
await vi.runAllTimersAsync();
await write;
const writeTokens = fetchMock.mock.calls
.filter(([, init]) => init?.method === "PUT")
.map(([, init]) => new Headers(init?.headers).get("X-Hyperframes-Write-Token"));
expect(writeTokens).toHaveLength(2);
expect(writeTokens[0]).toBeTruthy();
expect(writeTokens[1]).not.toBe(writeTokens[0]);
await act(async () => root.unmount());
});
it("overwrites the exact external content version with an If-Match precondition", async () => {
const { manager, root, fetchMock } = await mountOverwriteRequest({
ok: true,
json: async () => ({ version: "v3" }),
} as Response);
const conflict = createOverwriteConflict("v2", "EXTERNAL");
await manager.overwriteExternalConflict(conflict);
const [, init] = fetchMock.mock.calls[0] ?? [];
const headers = new Headers(init?.headers);
expect(init).toMatchObject({ method: "PUT", body: "STUDIO" });
expect(headers.get("If-Match")).toBe(await studioFileContentVersion("EXTERNAL"));
expect(headers.get("If-None-Match")).toBeNull();
await act(async () => root.unmount());
});
it("preserves a newer third-party edit when a content-less conflict version is stale", async () => {
const { manager, root, fetchMock } = await mountOverwriteRequest({
ok: false,
status: 409,
json: async () => ({ currentVersion: "v3", currentContent: "THIRD PARTY" }),
} as Response);
const conflict = createOverwriteConflict("v2", null);
await expect(manager.overwriteExternalConflict(conflict)).rejects.toMatchObject({
name: "StudioFileConflictError",
currentVersion: "v3",
currentContent: "THIRD PARTY",
attemptedContent: "STUDIO",
});
const [, init] = fetchMock.mock.calls[0] ?? [];
const headers = new Headers(init?.headers);
expect(headers.get("If-Match")).toBe("v2");
expect(headers.get("If-None-Match")).toBeNull();
await act(async () => root.unmount());
});
it("uses create-only semantics when the conflicted file was deleted", async () => {
const { manager, root, fetchMock } = await mountOverwriteRequest({
ok: true,
json: async () => ({ version: "v1" }),
} as Response);
const conflict = createOverwriteConflict(null, null);
await manager.overwriteExternalConflict(conflict);
const [, init] = fetchMock.mock.calls[0] ?? [];
const headers = new Headers(init?.headers);
expect(headers.get("If-Match")).toBeNull();
expect(headers.get("If-None-Match")).toBe("*");
await act(async () => root.unmount());
});
});
+32 -3
View File
@@ -10,7 +10,11 @@ import {
StudioFileConflictError,
StudioSaveNetworkError,
} from "../utils/studioSaveDiagnostics";
import { createStudioWriteToken, studioExpectedFileVersion } from "../utils/studioFileVersion";
import {
createStudioWriteToken,
markStudioWriteToken,
studioExpectedFileVersion,
} from "../utils/studioFileVersion";
import { useFileTree } from "./useFileTree";
import { useEditorSave } from "./useEditorSave";
@@ -117,8 +121,11 @@ export function useFileManager({
throw await createStudioSaveHttpError(preflight, `Failed to read ${path} before save`);
}
}
const writeToken = createStudioWriteToken();
await retryStudioSave(async () => {
// Each request gets its own receipt identity. If a committed request loses its response,
// the retry can produce a second filesystem receipt that must be suppressed independently.
const writeToken = createStudioWriteToken();
markStudioWriteToken(writeToken);
let response: Response;
try {
response = await fetch(
@@ -191,7 +198,7 @@ export function useFileManager({
// ── Editor save (debounced content change) ──
const { saveRafRef, handleContentChange } = useEditorSave({
const editorSave = useEditorSave({
editingPathRef,
projectIdRef,
readProjectFile,
@@ -201,6 +208,24 @@ export function useFileManager({
setRefreshKey,
showToast,
});
const { saveRafRef, handleContentChange } = editorSave;
const overwriteExternalConflict = useCallback(
async (conflict: StudioFileConflictError) => {
if (conflict.currentContent != null) {
await writeProjectFile(
conflict.filePath,
conflict.attemptedContent,
conflict.currentContent,
);
} else {
fileVersions.set(conflict.filePath, conflict.currentVersion);
await writeProjectFile(conflict.filePath, conflict.attemptedContent);
}
updateEditingFileContent(conflict.filePath, conflict.attemptedContent);
},
[fileVersions, updateEditingFileContent, writeProjectFile],
);
// ── File select ──
@@ -491,11 +516,15 @@ export function useFileManager({
editingPathRef,
projectIdRef,
saveRafRef,
flushPendingSourceSave: editorSave.flushPendingSave,
discardPendingSourceSave: editorSave.discardPendingSave,
getPendingSourceCandidate: editorSave.getPendingCandidate,
importedFontAssetsRef,
// Core I/O
readProjectFile,
writeProjectFile,
overwriteExternalConflict,
readOptionalProjectFile,
observeProjectFileVersion,
updateEditingFileContent,
@@ -0,0 +1,23 @@
import { describe, expect, it, vi } from "vitest";
import { StudioFileConflictError } from "../utils/studioSaveDiagnostics";
import { drainStudioSaveQueues } from "./usePreviewPersistence";
describe("drainStudioSaveQueues", () => {
it("does not erase a pending-field conflict with a clean DOM queue", async () => {
const conflict = new StudioFileConflictError({
filePath: "index.html",
currentVersion: "v2",
currentContent: "external",
attemptedContent: "studio",
});
const waitForDomQueue = vi.fn(async () => ({ status: "clean" as const }));
await expect(
drainStudioSaveQueues(
async () => ({ status: "conflict" as const, error: conflict }),
waitForDomQueue,
),
).resolves.toEqual({ status: "conflict", error: conflict });
expect(waitForDomQueue).not.toHaveBeenCalled();
});
});
@@ -3,12 +3,14 @@ import { useMountEffect } from "./useMountEffect";
import {
installStudioManualEditSeekReapply,
reapplyPositionEditsAfterSeek,
readStudioFileChangePath,
} from "../components/editor/manualEdits";
import { STUDIO_MOTION_PATH } from "../components/editor/studioMotion";
import type { EditHistoryKind } from "../utils/editHistory";
import { createDomEditSaveQueue } from "../utils/domEditSaveQueue";
import { flushStudioPendingEdits } from "../utils/studioPendingEdits";
import { createDomEditSaveQueue, type DomEditSaveDrainResult } from "../utils/domEditSaveQueue";
import {
flushStudioPendingEdits,
type StudioPendingEditsDrainResult,
} from "../utils/studioPendingEdits";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { applyUndoRestoreToPreview, type UndoRestoreFile } from "../utils/gsapUndoRestore";
import { usePlayerStore } from "../player";
@@ -29,19 +31,12 @@ interface RecordEditInput {
}
interface UsePreviewPersistenceParams {
projectId: string | null;
showToast: (message: string, tone?: "error" | "info") => void;
readOptionalProjectFile: (path: string) => Promise<string>;
writeProjectFile: (path: string, content: string) => Promise<void>;
recordEdit: (entry: RecordEditInput) => Promise<void>;
previewIframeRef: React.MutableRefObject<HTMLIFrameElement | null>;
activeCompPathRef: React.MutableRefObject<string | null>;
/** Shared timestamp ref written by any studio save (code tab, timeline, DOM edits).
* Used to suppress file-change echoes so we don't reload after our own saves. */
domEditSaveTimestampRef: React.MutableRefObject<number>;
/** Tracks in-flight timeline edits that patch the iframe DOM directly. File-change
* events for these paths are always suppressed since the preview is already up-to-date. */
pendingTimelineEditPathRef?: React.MutableRefObject<Set<string>>;
/** Called to reload the preview after undo/redo or external file changes. */
reloadPreview: () => void;
}
@@ -71,19 +66,13 @@ function installManualEditReapply(iframe: HTMLIFrameElement): void {
}
}
function shouldReloadForStudioFileChange(
payload: unknown,
pendingTimelineEditPathRef: React.MutableRefObject<Set<string>> | undefined,
domEditSaveTimestampRef: React.MutableRefObject<number>,
): boolean {
const changedPath = readStudioFileChangePath(payload);
if (!changedPath) return false;
const pendingTimelinePaths = pendingTimelineEditPathRef?.current;
if (pendingTimelinePaths?.has(changedPath)) {
pendingTimelinePaths.delete(changedPath);
return false;
}
return Date.now() - domEditSaveTimestampRef.current >= 4000;
export async function drainStudioSaveQueues(
flushPendingFields: () => Promise<StudioPendingEditsDrainResult>,
waitForDomQueue: () => Promise<DomEditSaveDrainResult>,
): Promise<StudioPendingEditsDrainResult | DomEditSaveDrainResult> {
const pending = await flushPendingFields();
if (pending.status !== "clean") return pending;
return waitForDomQueue();
}
// fallow-ignore-next-line complexity
@@ -107,16 +96,13 @@ async function clearLegacyStudioMotionFile(
// ── Hook ──
export function usePreviewPersistence({
projectId,
showToast,
readOptionalProjectFile: _readOptionalProjectFile,
writeProjectFile: _writeProjectFile,
recordEdit: _recordEdit,
previewIframeRef,
activeCompPathRef,
domEditSaveTimestampRef,
reloadPreview,
pendingTimelineEditPathRef,
}: UsePreviewPersistenceParams) {
void _recordEdit;
@@ -152,22 +138,23 @@ export function usePreviewPersistence({
});
}
// Keep a ref to the latest projectId so async save callbacks always read the
// current value, even when the callback was captured in a stale closure.
const projectIdRef = useRef(projectId);
projectIdRef.current = projectId;
// ── Queue / drain helpers ──
const queueDomEditSave = useCallback(<T>(save: () => Promise<T>): Promise<T> => {
return domEditSaveQueueRef.current?.enqueue(save) ?? save();
}, []);
const waitForPendingDomEditSaves = useCallback(async () => {
await flushStudioPendingEdits();
await domEditSaveQueueRef.current?.waitForIdle();
const drainPendingDomEditSaves = useCallback(async () => {
return drainStudioSaveQueues(flushStudioPendingEdits, async () => {
return (await domEditSaveQueueRef.current?.waitForIdle()) ?? { status: "clean" as const };
});
}, []);
const waitForPendingDomEditSaves = useCallback(async (): Promise<void> => {
const result = await drainPendingDomEditSaves();
if (result.status !== "clean") throw result.error;
}, [drainPendingDomEditSaves]);
const resetDomEditSaveQueueBreaker = useCallback(() => {
domEditSaveQueueRef.current?.reset();
setDomEditSaveQueuePaused(null);
@@ -235,35 +222,12 @@ export function usePreviewPersistence({
void clearLegacyStudioMotionFile(_readOptionalProjectFile, _writeProjectFile);
});
// ── Listen for external file changes (HMR / SSE) ──
useMountEffect(() => {
const handler = (payload?: unknown) => {
if (
shouldReloadForStudioFileChange(
payload,
pendingTimelineEditPathRef,
domEditSaveTimestampRef,
)
) {
// fallow-ignore-next-line code-duplication
reloadPreview();
}
};
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
return () => import.meta.hot?.off?.("hf:file-change", handler);
}
// SSE fallback for embedded studio server
const es = new EventSource("/api/events");
es.addEventListener("file-change", handler);
return () => es.close();
});
return {
domTextCommitVersionRef,
domEditSaveQueueRef,
applyStudioManualEditsToPreviewRef,
queueDomEditSave,
drainPendingDomEditSaves,
waitForPendingDomEditSaves,
domEditSaveQueuePaused,
resetDomEditSaveQueueBreaker,
+8 -52
View File
@@ -1,11 +1,11 @@
import { useState, useEffect, useCallback, useRef } from "react";
import type { MutableRefObject } from "react";
import { openComposition } from "@hyperframes/sdk";
import type { Composition } from "@hyperframes/sdk";
import { readStudioFileChangePath } from "../components/editor/manualEdits";
import { isSelfWriteEcho } from "./sdkSelfWriteRegistry";
import { trackStudioEvent } from "../utils/studioTelemetry";
import type { PublishSdkSession } from "../utils/sdkCutover";
import { addExternalFileReloadListener } from "./externalFileReloadBus";
/**
* Read a project file's content, or undefined on a non-2xx (optional read).
@@ -48,22 +48,6 @@ export function shouldReloadSdkSession(payload: unknown, activeCompPath: string
* stale. The session has NO persist queue Studio is the sole file writer; see
* the open effect below.
*/
// Reload-suppression baseline: a file-change within this window of our own SDK
// cutover write is a CANDIDATE echo, but the decision is content-identity based
// (isSelfWriteEcho) not time-only — so an undo write that lands inside the window
// still reloads (its reverted bytes were never registered as a self-write). The
// window only bounds how long a registered self-write stays suppressible.
const SELF_WRITE_SUPPRESS_MS = 2000;
/** Best-effort read of the changed file's content from a file-change payload. */
function readFileChangeContent(payload: unknown): string | null {
if (!payload || typeof payload !== "object") return null;
const record = payload as Record<string, unknown>;
if (typeof record.content === "string") return record.content;
if ("data" in record) return readFileChangeContent(record.data);
return null;
}
/**
* Decide whether a file-change for the active composition should reload the SDK
* session. `content` is the new on-disk bytes (from the payload or a re-read);
@@ -157,7 +141,6 @@ function disposeSdkSession(session: Composition): void {
export function useSdkSession(
projectId: string | null,
activeCompPath: string | null,
domEditSaveTimestampRef?: MutableRefObject<number>,
): SdkSessionHandle {
const [ownedSession, setOwnedSession] = useState<OwnedSdkSession | null>(null);
const ownedSessionRef = useRef<OwnedSdkSession | null>(null);
@@ -171,40 +154,13 @@ export function useSdkSession(
const reloadTokenRef = useRef(reloadToken);
reloadTokenRef.current = reloadToken;
// ── Re-open on external change to the active composition ──
useEffect(() => {
if (!activeCompPath) return;
const compPath = activeCompPath;
const readProjectId = projectId ?? null;
const handler = (payload?: unknown) => {
if (!shouldReloadSdkSession(payload, compPath)) return;
const withinWindow =
!!domEditSaveTimestampRef &&
Date.now() - domEditSaveTimestampRef.current < SELF_WRITE_SUPPRESS_MS;
const decide = (content: string | null) => {
if (shouldReloadOnFileChange(compPath, content, withinWindow)) setReloadToken((t) => t + 1);
};
const payloadContent = readFileChangeContent(payload);
// Prefer payload content; otherwise re-read so the decision is by IDENTITY
// (an undo's reverted bytes won't match a registered self-write → reload).
if (payloadContent != null || readProjectId == null) {
decide(payloadContent);
return;
}
readProjectFileOptional(readProjectId, compPath)
.then((c) => decide(c ?? null))
.catch(() => decide(null));
};
if (import.meta.hot) {
import.meta.hot.on("hf:file-change", handler);
return () => import.meta.hot?.off?.("hf:file-change", handler);
}
// SSE fallback for the embedded studio server.
const es = new EventSource("/api/events");
es.addEventListener("file-change", handler);
return () => es.close();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCompPath, projectId]);
useEffect(
() =>
addExternalFileReloadListener((changedPath) => {
if (changedPath === activeCompPathRef.current) setReloadToken((token) => token + 1);
}),
[],
);
// ── Open / re-open the session ──
useEffect(() => {
@@ -0,0 +1,81 @@
import { useCallback, type MutableRefObject } from "react";
import {
deleteExternalConflictSnapshot,
loadExternalConflictSnapshot,
persistExternalConflictSnapshot,
persistExternalFailureSnapshot,
} from "../utils/externalConflictStorage";
import { notifyExternalFileReload } from "./externalFileReloadBus";
import { useExternalFileChangeCoordinator } from "./useExternalFileChangeCoordinator";
import type { useFileManager } from "./useFileManager";
import type { usePreviewPersistence } from "./usePreviewPersistence";
type FileManager = Pick<
ReturnType<typeof useFileManager>,
| "editingFile"
| "flushPendingSourceSave"
| "discardPendingSourceSave"
| "getPendingSourceCandidate"
| "overwriteExternalConflict"
| "readProjectFile"
| "updateEditingFileContent"
>;
type PreviewPersistence = Pick<
ReturnType<typeof usePreviewPersistence>,
"drainPendingDomEditSaves" | "resetDomEditSaveQueueBreaker"
>;
interface UseStudioExternalFileChangesOptions {
projectId: string | null;
activeCompPath: string | null;
masterCompPath: string | null;
fileManager: FileManager;
previewPersistence: PreviewPersistence;
pendingTimelineEditPathRef: MutableRefObject<Set<string>>;
reloadPreview: () => void;
}
/** Connects the app's save queues, recovery storage, and reload surfaces to one owner. */
export function useStudioExternalFileChanges({
projectId,
activeCompPath,
masterCompPath,
fileManager,
previewPersistence,
pendingTimelineEditPathRef,
reloadPreview,
}: UseStudioExternalFileChangesOptions) {
const { flushPendingSourceSave, discardPendingSourceSave } = fileManager;
const { drainPendingDomEditSaves, resetDomEditSaveQueueBreaker } = previewPersistence;
const drainPendingChanges = useCallback(async () => {
const source = await flushPendingSourceSave();
if (source.status !== "clean") return source;
return drainPendingDomEditSaves();
}, [drainPendingDomEditSaves, flushPendingSourceSave]);
const discardPendingChanges = useCallback(() => {
discardPendingSourceSave();
resetDomEditSaveQueueBreaker();
}, [discardPendingSourceSave, resetDomEditSaveQueueBreaker]);
return useExternalFileChangeCoordinator({
projectId,
activeCompPath,
recoveryFilePath: fileManager.editingFile?.path ?? activeCompPath ?? masterCompPath,
pendingTimelineEditPathRef,
drainPendingChanges,
getPendingCandidate: fileManager.getPendingSourceCandidate,
discardPendingChanges,
reloadPreview,
reloadSdkSession: notifyExternalFileReload,
persistConflictSnapshot: persistExternalConflictSnapshot,
persistFailureSnapshot: persistExternalFailureSnapshot,
loadConflictSnapshot: loadExternalConflictSnapshot,
deleteConflictSnapshot: deleteExternalConflictSnapshot,
overwriteConflict: fileManager.overwriteExternalConflict,
readProjectFile: fileManager.readProjectFile,
onUseExternalFile: fileManager.updateEditingFileContent,
resetSaveQueues: resetDomEditSaveQueueBreaker,
});
}
@@ -1,4 +1,4 @@
import { useEffect, type MutableRefObject } from "react";
import { useEffect } from "react";
import { useSdkSession } from "./useSdkSession";
import { usePreviewVariablesStore } from "./previewVariablesStore";
@@ -17,18 +17,13 @@ import { usePreviewVariablesStore } from "./previewVariablesStore";
export function useStudioSdkSessions(
projectId: string | null,
activeCompPath: string | null,
domEditSaveTimestampRef: MutableRefObject<number>,
masterCompPath: string | null,
) {
// On the master view (no explicit comp) the schema panels target the project's
// resolved main composition — the first `.html` in the tree, not a hardcoded
// "index.html" that may not exist. `null` when the project has no composition
// yet, which correctly leaves the session (and the panels) empty.
const sdkHandle = useSdkSession(
projectId,
activeCompPath ?? masterCompPath,
domEditSaveTimestampRef,
);
const sdkHandle = useSdkSession(projectId, activeCompPath ?? masterCompPath);
const editFlowSdkSession = activeCompPath ? sdkHandle.session : null;
useEffect(() => {
usePreviewVariablesStore.getState().setValues(null);
@@ -0,0 +1,15 @@
import { useEffect } from "react";
import { hasFiredSessionStart, markSessionStartFired } from "../telemetry/config";
import { trackStudioSessionStart } from "../telemetry/events";
export function useStudioSessionStart(
projectId: string | null,
resolving: boolean,
waitingForServer: boolean,
): void {
useEffect(() => {
if (resolving || waitingForServer || hasFiredSessionStart()) return;
markSessionStartFired();
trackStudioSessionStart({ has_project: projectId != null });
}, [projectId, resolving, waitingForServer]);
}
@@ -59,11 +59,15 @@ describe("timeline performance fixture", () => {
expect(Math.max(...perTrack.values())).toBeLessThanOrEqual(128);
});
it.each(PROFILES)("generates an identical 50k %s fixture", (profile) => {
const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
expect(second).toEqual(first);
});
it.each(PROFILES)(
"generates an identical 50k %s fixture",
(profile) => {
const first = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
const second = createTimelinePerformanceFixture({ elementCount: 50_000, profile });
expect(second).toEqual(first);
},
30_000,
);
it.each(PROFILES)("builds the %s 1k scale profile", (profile) => {
const fixture = createTimelinePerformanceFixture({ elementCount: 1_000, profile });
@@ -90,7 +94,7 @@ describe("timeline performance fixture", () => {
usePlayerStore.setState({
isPlaying: true,
requestedSeekTime: 42,
clipRevealRequest: { elementId: "stale", nonce: 7 },
timelineFocus: { id: "stale", projectId: null, sessionEpoch: 0, nonce: 7 },
clipManifest: [],
lintFindingsByElement: new Map([["stale", { count: 1, messages: ["stale"] }]]),
});
@@ -108,7 +112,7 @@ describe("timeline performance fixture", () => {
expect(usePlayerStore.getState()).toMatchObject({
isPlaying: false,
requestedSeekTime: null,
clipRevealRequest: null,
timelineFocus: null,
clipManifest: null,
duration: 600,
timelineReady: true,
@@ -44,6 +44,8 @@ export function LayerDisclosureRow({
>
<button
type="button"
// ponytail: No focus id here; keyboard routing belongs to the enclosing logical row.
tabIndex={-1}
aria-expanded={isExpanded}
aria-controls={lanesId}
aria-label={`${isExpanded ? "Collapse" : "Expand"} ${name} keyframes`}
@@ -244,6 +244,25 @@ describe("Timeline provider boundary", () => {
expect(propertyLane.style.background).toBe("");
expect(propertyLane.style.border).toBe("");
expect(propertyLane.style.borderRadius).toBe("");
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
const semanticRows = treegrid?.querySelectorAll<HTMLElement>('[role="row"]') ?? [];
expect(treegrid?.getAttribute("aria-rowcount")).toBe("3");
expect([...semanticRows].map((row) => row.getAttribute("aria-rowindex"))).toEqual([
"1",
"2",
"3",
]);
expect(semanticRows[0]?.getAttribute("aria-level")).toBe("1");
expect(semanticRows[0]?.getAttribute("aria-expanded")).toBe("true");
expect(semanticRows[1]?.getAttribute("aria-level")).toBe("2");
expect(semanticRows[1]?.textContent).toContain("position");
expect(semanticRows[1]?.querySelector('[role="rowheader"]')?.getAttribute("aria-owns")).toBe(
headerLane.id,
);
expect(semanticRows[1]?.querySelector('[role="gridcell"]')?.getAttribute("aria-owns")).toBe(
propertyLane.id,
);
expect(semanticRows[2]?.hasAttribute("aria-expanded")).toBe(false);
expect(trackHeader.style.width).toBe(`${LABEL_COL_W}px`);
expect(rulerOrigin.style.width).toBe(`${LABEL_COL_W + GUTTER}px`);
expect(playhead.style.left).toBe(`${LABEL_COL_W + GUTTER + 1000 - PLAYHEAD_HEAD_W / 2}px`);
@@ -295,12 +314,12 @@ describe("Timeline provider boundary", () => {
const root = createRoot(host);
act(() => root.render(React.createElement(Timeline)));
const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
expect(rows).toHaveLength(12);
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
expect(rows[0]?.getAttribute("aria-setsize")).toBe("12");
expect(rows[11]?.getAttribute("aria-posinset")).toBe("12");
expect(treegrid?.getAttribute("aria-rowcount")).toBe("12");
expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
expect(rows[11]?.getAttribute("aria-rowindex")).toBe("12");
act(() => root.unmount());
});
@@ -41,10 +41,10 @@ import { useTimelineShiftModifier } from "./useTimelineShiftModifier";
import { useTimelineTicks } from "./useTimelineTicks";
import { getTimelineElementIndexes } from "../lib/timelineElementIndexes";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import { useTimelineRowVirtualization } from "./useTimelineRowVirtualization";
import { useTimelineClipRenderWindow } from "./useTimelineClipRenderWindow";
import { useTimelineActiveClips } from "./useTimelineActiveClips";
import { useTimelineLaneMoveRefresh } from "./useTimelineLaneMoveRefresh";
import { useTimelineLogicalFocus } from "./useTimelineLogicalFocus";
export {
shouldAutoScrollTimeline,
@@ -122,7 +122,6 @@ export const Timeline = memo(function Timeline({
const timelineReady = usePlayerStore((s) => s.timelineReady);
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const selectedElementIds = usePlayerStore((s) => s.selectedElementIds);
const clipRevealRequest = usePlayerStore((s) => s.clipRevealRequest);
const focusedEaseSegment = usePlayerStore((s) => s.focusedEaseSegment);
const gsapAnimations = usePlayerStore((s) => s.gsapAnimations);
const labelMode = useMemo(() => hasKeyframedTimelineClips(gsapAnimations), [gsapAnimations]);
@@ -171,7 +170,6 @@ export const Timeline = memo(function Timeline({
const ppsRef = useRef(100);
const durationRef = useRef(effectiveDuration);
durationRef.current = effectiveDuration;
// Declared before the fitPps derivation so the edit-pin wrappers can close over it.
const fitPpsRef = useRef(100);
const {
pinZoomBeforeEdit,
@@ -269,34 +267,6 @@ export const Timeline = memo(function Timeline({
expandedElements.length,
displayLayout.totalH,
]);
const rowWindow = useTimelineRowVirtualization({
scrollRef,
viewport,
rowGeometry: displayLayout.rowGeometry,
sessionEpoch,
elements: expandedElements,
selectedElementId,
revealElementId: clipRevealRequest?.elementId ?? null,
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
resizingElementIds,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
lastScrollLeftRef,
syncScrollViewport,
});
const { enabled: rowVirtualizationActive, virtualRows, focusedElementId } = rowWindow;
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
useTimelineKeyframeHandlers({
expandedElements,
keyframeCache,
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu,
toggleSelectedKeyframe,
});
const { pps, fitPps, displayContentWidth, displayDuration, zoomModeRef, manualZoomPercentRef } =
useTimelineGeometry({
viewportWidth: viewport.clientWidth,
@@ -313,6 +283,40 @@ export const Timeline = memo(function Timeline({
lastScrollLeftRef,
contentOrigin,
});
const timelineFocus = useTimelineLogicalFocus({
scrollRef,
tracks,
layout: displayLayout,
laneCounts,
selectedElementId,
selectedElementIds,
gsapAnimations,
elements: expandedElements,
pixelsPerSecond: pps,
contentOrigin,
allowHorizontal: zoomMode === "manual",
viewport,
sessionEpoch,
draggedRowKey: draggedClip?.started ? draggedClip.previewTrack : undefined,
resizingElementIds,
clipContextMenuRowKey: clipContextMenu?.element.track,
keyframeContextMenuRowKey: kfContextMenu?.element.track,
lastScrollLeftRef,
syncScrollViewport,
});
const selectedKeyframes = usePlayerStore((s) => s.selectedKeyframes);
const toggleSelectedKeyframe = usePlayerStore((s) => s.toggleSelectedKeyframe);
const { onClickKeyframe, onSelectSegment, onShiftClickKeyframe, onContextMenuKeyframe } =
useTimelineKeyframeHandlers({
expandedElements,
keyframeCache,
onSelectElement,
onSeek,
setSelectedElementId,
setKfContextMenu,
toggleSelectedKeyframe,
});
const { clipIndex, renderTimeRange, pinnedClipIdentities } = useTimelineClipRenderWindow({
tracks,
viewport,
@@ -322,7 +326,7 @@ export const Timeline = memo(function Timeline({
selectedElementId: selectedElementId ?? undefined,
draggedElementId: draggedClip ? getTimelineElementIdentity(draggedClip.element) : undefined,
resizingElementIds,
revealElementId: clipRevealRequest?.elementId,
focusedElementId: timelineFocus.pinnedElementId,
focusedEaseElementId: focusedEaseSegment?.elementId,
clipContextMenuElementId: clipContextMenu
? getTimelineElementIdentity(clipContextMenu.element)
@@ -330,13 +334,6 @@ export const Timeline = memo(function Timeline({
keyframeContextMenuElementId: kfContextMenu
? getTimelineElementIdentity(kfContextMenu.element)
: undefined,
focusedElementId,
scrollRef,
elements: expandedElements,
rowGeometry: displayLayout.rowGeometry,
allowHorizontalReveal: zoomMode === "manual",
rowVirtualizationActive,
sessionEpoch,
});
useTimelineActiveClips({
scrollRef,
@@ -423,7 +420,7 @@ export const Timeline = memo(function Timeline({
displayDuration,
pps,
timeDisplayMode,
rowVirtualizationActive ? renderTimeRange : undefined,
timelineFocus.rowVirtualizationActive ? renderTimeRange : undefined,
);
const getPreviewElement = useCallback(
@@ -469,13 +466,12 @@ export const Timeline = memo(function Timeline({
recordTimelineScroll(e.currentTarget);
syncScrollViewport(e.currentTarget, true);
}}
{...rowWindow.timelineFocusProps}
{...timelineFocus.timelineFocusProps}
onDragOver={assetDrop.handleAssetDragOver}
onDragLeave={assetDrop.handleAssetDragLeave}
onDrop={assetDrop.handleAssetDrop}
onPointerDown={(e) => {
// Let interactive controls (keyframe nav/toggle, caret, inputs) handle
// their own clicks — scrubbing here would preventDefault and eat them.
// Interactive controls own their clicks; scrubbing would preventDefault and eat them.
if (e.target instanceof Element && e.target.closest("button, input, select, a")) return;
if (splitAllAtPointer(e)) return;
handlePointerDown(e);
@@ -502,8 +498,10 @@ export const Timeline = memo(function Timeline({
displayTrackOrder={displayLayout.displayTrackOrder}
rowHeights={displayLayout.displayRowHeights}
rowGeometry={displayLayout.rowGeometry}
virtualRows={virtualRows}
rowsVirtualized={rowVirtualizationActive}
virtualRows={timelineFocus.virtualRows}
logicalRows={timelineFocus.logicalRows}
focusedTargetId={timelineFocus.focusedTargetId}
rowsVirtualized={timelineFocus.rowVirtualizationActive}
clipIndex={clipIndex}
renderTimeRange={renderTimeRange}
pinnedClipIdentities={pinnedClipIdentities}
@@ -522,7 +520,9 @@ export const Timeline = memo(function Timeline({
scrollRef={scrollRef}
// Windowing drops content to mount a row cheaply; unvirtualized it is pure cost.
renderClipContent={
rowVirtualizationActive && viewport.isScrolling ? undefined : renderClipContent
timelineFocus.rowVirtualizationActive && viewport.isScrolling
? undefined
: renderClipContent
}
renderClipOverlay={renderClipOverlay}
playheadRef={playheadRef}
@@ -132,7 +132,7 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 2 }));
const rows = host.querySelectorAll('[role="listitem"]');
const rows = host.querySelectorAll("[data-timeline-row]");
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(16);
@@ -201,16 +201,17 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
await settleUntil(
() =>
(host.querySelector('[role="list"]')?.querySelectorAll('[role="listitem"]').length ?? 0) >
0,
(host.querySelector('[role="treegrid"]')?.querySelectorAll('[role="row"]').length ?? 0) > 0,
);
const list = host.querySelector<HTMLElement>('[role="list"]');
const rows = list?.querySelectorAll('[role="listitem"]') ?? [];
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
const rows = treegrid?.querySelectorAll('[role="row"]') ?? [];
expect(rows.length).toBeGreaterThan(0);
expect(rows.length).toBeLessThanOrEqual(16);
expect(rows[0]?.getAttribute("aria-posinset")).toBe("1");
expect(rows[0]?.getAttribute("aria-setsize")).toBe("1000");
expect(list?.parentElement?.style.height).toBe(
expect(rows[0]?.getAttribute("aria-rowindex")).toBe("1");
expect(treegrid?.getAttribute("aria-rowcount")).toBe("1000");
expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
expect(treegrid?.querySelectorAll('[data-timeline-focus-id][tabindex="0"]')).toHaveLength(1);
expect(treegrid?.parentElement?.style.height).toBe(
`${getTimelineCanvasHeight(Array.from({ length: 1_000 }, () => TRACK_H))}px`,
);
@@ -226,7 +227,44 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
scroller.dispatchEvent(new Event("scroll"));
});
}
expect(list?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
expect(treegrid?.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
expect(document.activeElement).toBe(focusedControl);
act(() => root.unmount());
usePlayerStore.getState().reset();
});
it("keeps focus pinning active after the scroll viewport remounts", async () => {
const [{ Timeline }, { usePlayerStore }] = await Promise.all([
import("./Timeline"),
import("../store/playerStore"),
]);
usePlayerStore.setState({
duration: 60,
timelineReady: true,
elements: clipsByTrack(1_000),
});
const { host, root } = await mountTimeline(React.createElement(Timeline, { sessionEpoch: 9 }));
await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
const firstScroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
await act(async () => usePlayerStore.setState({ timelineReady: false }));
expect(host.querySelector("[data-timeline-scroll-viewport]")).toBeNull();
await act(async () => usePlayerStore.setState({ timelineReady: true }));
await settleUntil(() => host.querySelectorAll("[data-timeline-row]").length > 0);
const scroller = host.querySelector<HTMLElement>("[data-timeline-scroll-viewport]");
const firstRow = host.querySelector<HTMLElement>('[data-timeline-row-key="0"]');
const focusedControl = firstRow?.querySelector<HTMLButtonElement>("button");
expect(scroller).not.toBe(firstScroller);
expect(focusedControl).not.toBeNull();
act(() => focusedControl?.focus());
if (scroller) {
scroller.scrollTop = 500 * 48;
await dispatchScroll(scroller);
}
expect(host.querySelector('[data-timeline-row-key="0"]')).not.toBeNull();
expect(document.activeElement).toBe(focusedControl);
act(() => root.unmount());
@@ -242,6 +280,8 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
usePlayerStore.setState({
duration: 1_000,
timelineReady: true,
timelineProjectId: "project-a",
timelineSessionEpoch: 4,
zoomMode: "manual",
manualZoomPercent: 2_000,
selectedElementId: "clip-490",
@@ -286,10 +326,13 @@ describe("Timeline row virtualization", { timeout: 30_000 }, () => {
expect(host.querySelector('[data-el-id="clip-490"]')).not.toBeNull();
expect(host.querySelectorAll("[data-timeline-grid-cell]").length).toBeLessThan(100);
await act(async () => usePlayerStore.getState().requestClipReveal("clip-300"));
const { timelineClipFocusId } = await import("./timelineNavigationIdentity");
await act(async () =>
usePlayerStore.getState().requestTimelineFocus(timelineClipFocusId("clip-300")),
);
await advanceFrame();
await act(async () => {});
expect(usePlayerStore.getState().clipRevealRequest).toBeNull();
expect(usePlayerStore.getState().timelineFocus?.id).toBe(timelineClipFocusId("clip-300"));
const focusedClip = host.querySelector('[data-el-id="clip-300"]');
expect(document.activeElement).toBe(focusedClip);
await advanceFrame();
@@ -36,6 +36,7 @@ function renderClip({
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
const onClick = vi.fn();
act(() => {
root.render(
@@ -50,7 +51,7 @@ function renderClip({
isComposition={false}
onHoverStart={vi.fn()}
onHoverEnd={vi.fn()}
onClick={vi.fn()}
onClick={onClick}
onDoubleClick={vi.fn()}
>
<div data-custom-content="true" />
@@ -58,7 +59,7 @@ function renderClip({
);
});
return { host, root };
return { host, onClick, root };
}
describe("TimelineClip", () => {
@@ -113,4 +114,18 @@ describe("TimelineClip", () => {
act(() => root.unmount());
});
it("is a roving native button with explicit selection semantics", () => {
const { host, onClick, root } = renderClip({
element: { id: "hero", label: "Hero", tag: "div", start: 1, duration: 2, track: 0 },
isSelected: true,
});
const clip = host.querySelector<HTMLButtonElement>(".timeline-clip")!;
expect(clip.type).toBe("button");
expect(clip.tabIndex).toBe(-1);
expect(clip.getAttribute("aria-pressed")).toBe("true");
act(() => clip.click());
expect(onClick).toHaveBeenCalledOnce();
act(() => root.unmount());
});
});
@@ -3,6 +3,7 @@ import type { TimelineElement } from "../store/playerStore";
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
import type { TimelineEditCapabilities } from "./timelineEditing";
import { isAudioTimelineElement } from "../../utils/timelineInspector";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
interface TimelineClipProps {
el: TimelineElement;
@@ -18,6 +19,7 @@ interface TimelineClipProps {
capabilities: TimelineEditCapabilities;
theme?: TimelineTheme;
isComposition: boolean;
tabIndex?: 0 | -1;
onHoverStart: () => void;
onHoverEnd: () => void;
onPointerDown?: (e: React.PointerEvent) => void;
@@ -43,6 +45,7 @@ export const TimelineClip = memo(function TimelineClip({
capabilities,
theme = defaultTimelineTheme,
isComposition,
tabIndex = -1,
onHoverStart,
onHoverEnd,
onPointerDown,
@@ -82,19 +85,28 @@ export const TimelineClip = memo(function TimelineClip({
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
// Regular cursor over clips (CapCut-style, user preference) — no grab hand.
cursor: "default",
appearance: "none",
color: "inherit",
font: "inherit",
padding: 0,
textAlign: "left",
transform: isDragging ? "translateY(-1px)" : undefined,
};
return (
<div
<button
type="button"
data-clip={isGestureActor ? undefined : "true"}
data-el-id={isGestureActor ? undefined : (el.key ?? el.id)}
data-timeline-focus-id={isGestureActor ? undefined : timelineClipFocusId(el.key ?? el.id)}
data-clip-start={el.start}
data-clip-end={el.start + el.duration}
data-clip-hidden={el.hidden ? "true" : undefined}
data-active={isActive ? "" : undefined}
aria-hidden={isGestureActor ? "true" : undefined}
tabIndex={isGestureActor ? undefined : -1}
tabIndex={isGestureActor ? undefined : tabIndex}
aria-label={`${displayLabel}, ${startLabel} to ${endLabel} seconds`}
aria-pressed={isGestureActor ? undefined : isSelected}
className={clipClassName}
style={style}
title={
@@ -176,6 +188,6 @@ export const TimelineClip = memo(function TimelineClip({
</span>
)}
{children}
</div>
</button>
);
});
@@ -46,7 +46,7 @@ function createTimelineHost() {
return host;
}
function renderDiamonds(onClickKeyframe = vi.fn()) {
function renderDiamonds(onClickKeyframe = vi.fn(), onShiftClickKeyframe = vi.fn()) {
const host = createTimelineHost();
const root = createRoot(host);
act(() => {
@@ -61,19 +61,60 @@ function renderDiamonds(onClickKeyframe = vi.fn()) {
}}
clipWidthPx={200}
clipHeightPx={48}
clipDuration={10}
accentColor="#4ba3d2"
isSelected
currentPercentage={0}
elementId="clip-1"
clipStart={10}
clipDuration={10}
selectedKeyframes={new Set()}
onClickKeyframe={onClickKeyframe}
onShiftClickKeyframe={onShiftClickKeyframe}
/>,
);
});
return { host, root, onClickKeyframe };
return { host, root, onClickKeyframe, onShiftClickKeyframe };
}
it("pins authored keyframes outside the clip to an inspectable boundary marker", () => {
const host = createTimelineHost();
const root = createRoot(host);
act(() => {
root.render(
<TimelineDiamondLane
keyframesData={{
format: "percentage",
keyframes: [
{ percentage: -40, properties: { x: 0 }, propertyGroup: "position" },
{ percentage: -20, properties: { x: 25 }, propertyGroup: "position" },
{ percentage: 5, properties: { x: 50 }, propertyGroup: "position" },
{ percentage: 120, properties: { x: 100 }, propertyGroup: "position" },
],
}}
clipWidthPx={200}
clipHeightPx={48}
accentColor="#4ba3d2"
isSelected
currentPercentage={5}
elementId="clip-1"
clipStart={10}
clipDuration={10}
selectedKeyframes={new Set()}
/>,
);
});
const before = host.querySelectorAll<HTMLButtonElement>('[data-keyframe-outside-clip="before"]');
const after = host.querySelector<HTMLButtonElement>('[data-keyframe-outside-clip="after"]');
expect(Array.from(before, (marker) => marker.style.left)).toEqual(["-35px", "-23px"]);
expect(before[0]?.getAttribute("aria-label")).toBe("position keyframe at 6s (before clip)");
expect(after?.style.left).toBe("201px");
expect(after?.getAttribute("aria-label")).toBe("position keyframe at 22s (after clip)");
expect(host.querySelectorAll('button[aria-label*="keyframe at"]')).toHaveLength(4);
act(() => root.unmount());
});
function renderRetimeLane(
onMoveKeyframe = vi.fn().mockResolvedValue(true),
strict = false,
@@ -261,6 +302,33 @@ describe("TimelineClipDiamonds", () => {
act(() => root.unmount());
});
it("gives keyframes time-based names and native keyboard selection semantics", () => {
const { host, root, onClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
expect(diamond.getAttribute("aria-label")).toBe("Motion keyframe at 15s");
expect(diamond.getAttribute("aria-pressed")).toBe("false");
act(() => diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0 })));
expect(onClickKeyframe).toHaveBeenCalledWith(
"clip-1",
expect.objectContaining({ percentage: 50 }),
);
act(() => root.unmount());
});
it("uses Shift+Space's native click for additive keyframe selection", () => {
const { host, root, onClickKeyframe, onShiftClickKeyframe } = renderDiamonds();
const diamond = host.querySelector<HTMLButtonElement>('button[title="50%"]')!;
act(() =>
diamond.dispatchEvent(new MouseEvent("click", { bubbles: true, detail: 0, shiftKey: true })),
);
expect(onShiftClickKeyframe).toHaveBeenCalledWith(
"clip-1",
expect.objectContaining({ percentage: 50 }),
);
expect(onClickKeyframe).not.toHaveBeenCalled();
act(() => root.unmount());
});
it("publishes retime previews after StrictMode effect replay", () => {
const { diamond, host, root } = renderRetimeLane(undefined, true);
const initialLeft = diamond.style.left;
@@ -1108,10 +1176,9 @@ describe("TimelineClipDiamonds", () => {
// Regression: onClickKeyframe's state updates can re-render the diamond
// button out from under the gesture before the browser auto-synthesizes the
// "click" event that follows a button's pointerdown+pointerup. That orphaned
// click then bubbles to the ancestor clip's onClick, which toggles selection
// off whenever the clip is already selected — the state a diamond click
// always happens in — so every keyframe click immediately deselected its
// own clip. suppressClickRef lets that ancestor ignore the stray click.
// click then bubbles to the ancestor clip's onClick. That stray click can
// replace keyframe focus or collapse a marquee selection, so suppressClickRef
// lets the ancestor ignore it.
it("arms suppressClickRef synchronously on a keyframe click", () => {
const suppressClickRef = { current: false };
const host = createTimelineHost();
@@ -1150,6 +1217,7 @@ describe("TimelineClipDiamonds", () => {
const renderSegmentLane = (lastAmbiguous: boolean, clipWidthPx = 200) => {
const host = createTimelineHost();
const root = createRoot(host);
const onSelectSegment = vi.fn();
const kf = (percentage: number, extra: Record<string, unknown> = {}) => ({
percentage,
tweenPercentage: percentage,
@@ -1182,13 +1250,15 @@ describe("TimelineClipDiamonds", () => {
isSelected
currentPercentage={0}
elementId="clip-1"
clipStart={10}
clipDuration={10}
selectedKeyframes={new Set()}
onSelectSegment={vi.fn()}
onSelectSegment={onSelectSegment}
groupAware
/>,
);
});
return { host, root };
return { host, onSelectSegment, root };
};
it("shows the inline ease button on a colliding merged segment (bulk edit)", () => {
@@ -1201,8 +1271,23 @@ describe("TimelineClipDiamonds", () => {
});
it("shows the inline ease button on single-animation merged segments", () => {
const { host, root } = renderSegmentLane(false);
const { host, onSelectSegment, root } = renderSegmentLane(false);
expect(host.querySelectorAll("[data-keyframe-ease-segment]").length).toBe(2);
const ease = host.querySelector<HTMLButtonElement>("[data-keyframe-ease-button]")!;
expect(ease.getAttribute("aria-label")).toBe("Edit none easing after 10s");
expect(ease.classList.contains("opacity-0")).toBe(true);
act(() => ease.click());
expect(onSelectSegment).toHaveBeenCalledOnce();
expect(usePlayerStore.getState().requestedSeekTime).toBeNull();
act(() => root.unmount());
});
it("ends connectors at the diamond boundaries", () => {
const { host, root } = renderSegmentLane(false);
const connectors = host.querySelectorAll<HTMLElement>("[data-keyframe-connector]");
expect(Array.from(connectors, (connector) => connector.style.left)).toEqual(["11px", "111px"]);
expect(Array.from(connectors, (connector) => connector.style.width)).toEqual(["78px", "78px"]);
act(() => root.unmount());
});
@@ -10,10 +10,10 @@ import {
subscribeTimelineKeyframeRetimePreview,
type TimelineKeyframeRetimeHandle,
} from "./useTimelineKeyframeHandlers";
import { timelineKeyframeFocusId } from "./timelineNavigationIdentity";
import {
DIAMOND_RATIO,
KF_MAX_PCT,
KF_MIN_PCT,
keyframeTimeLabel,
keyframeTarget,
type TimelineClipDiamondsProps,
type TimelineDiamondKeyframe,
@@ -68,13 +68,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
keyframesData,
clipWidthPx,
clipHeightPx,
clipDuration,
beatsActive,
accentColor,
isSelected,
currentPercentage,
elementId,
clipStart = 0,
clipDuration = 0,
selectedKeyframes,
rovingTargetId = null,
onClickKeyframe,
onShiftClickKeyframe,
onContextMenuKeyframe,
@@ -140,9 +142,14 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
// (2.5.8) minimum and still fits the 28px lane. Beat-strip lanes keep the
// shrunken box: 24px there would reach up into the beat strip.
const hitHeight = beatsActive ? diamondSize : 24;
const sorted = keyframesData.keyframes
.filter((kf) => kf.percentage >= KF_MIN_PCT && kf.percentage <= KF_MAX_PCT)
.sort((a, b) => a.percentage - b.percentage);
// Keyframes authored outside the element's visible clip window are parked at
// the boundary rather than hidden: dropping them made the lane's count
// disagree with its diamonds and left users unable to inspect or remove state
// that still affects the clip once it appears.
const sorted = [...keyframesData.keyframes].sort((a, b) => a.percentage - b.percentage);
const beforeClip = sorted.filter((keyframe) => keyframe.percentage < 0);
const afterClip = sorted.filter((keyframe) => keyframe.percentage > 100);
const boundaryStep = Math.max(6, Math.round(diamondSize * 0.55));
// The neighbour clamp bounds a dragged diamond between its immediate siblings
// so a retime can't reorder the tween. Siblings means "keyframes of the SAME
// tween": a merged row interleaves several animations, and two of them
@@ -175,16 +182,30 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
clipPcts: row.map((s) => s.clipPct),
});
}
const centerXOf = (percentage: number) =>
Math.max(0, Math.min(clipWidthPx, (percentage / 100) * clipWidthPx));
const centerXOf = (keyframe: TimelineDiamondKeyframe, percentage = keyframe.percentage) => {
if (percentage < 0) {
const rank = beforeClip.indexOf(keyframe);
return -(beforeClip.length - Math.max(0, rank)) * boundaryStep;
}
if (percentage > 100) {
const rank = afterClip.indexOf(keyframe);
return clipWidthPx + (Math.max(0, rank) + 1) * boundaryStep;
}
return (percentage / 100) * clipWidthPx;
};
// One record per diamond, carrying its own geometry, so the connector and
// button passes below read neighbours as values instead of index lookups.
const markers = sorted.map((keyframe, index) => {
const centerX = centerXOf(keyframe.percentage);
const centerX = centerXOf(keyframe);
// Parked diamonds sit on their own boundary spacing, so the in-clip
// neighbour-gap shrink would only make them unreadable.
if (keyframe.percentage < 0 || keyframe.percentage > 100) {
return { keyframe, centerX, hitWidth: diamondSize, visualSize: diamondSize };
}
const previous = sorted[index - 1];
const next = sorted[index + 1];
const previousGap = previous ? centerX - centerXOf(previous.percentage) : Infinity;
const nextGap = next ? centerXOf(next.percentage) - centerX : Infinity;
const previousGap = previous ? centerX - centerXOf(previous) : Infinity;
const nextGap = next ? centerXOf(next) - centerX : Infinity;
const nearestGap = Math.max(1, Math.min(previousGap, nextGap));
const hitWidth = Math.min(diamondSize, nearestGap);
return {
@@ -226,6 +247,10 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
<TimelineDiamondConnectors
markers={markers}
centerY={centerY}
elementId={elementId}
clipStart={clipStart}
clipDuration={clipDuration}
rovingTargetId={rovingTargetId}
baseColor={baseColor}
baseOpacity={baseOpacity}
groupAware={groupAware}
@@ -237,7 +262,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
{markers.map((marker, i) => {
const kf = marker.keyframe;
const target = keyframeTarget(kf);
const focusId = timelineKeyframeFocusId(elementId, target);
const kfKey = timelineKeyframeSelectionKey(elementId, target);
const boundary = kf.percentage < 0 ? "before" : kf.percentage > 100 ? "after" : null;
// Clamp against this keyframe's own tween, not the whole merged row.
const siblingRow = siblingRows.get(kf.animationId);
const siblingClipPcts = siblingRow?.clipPcts ?? [];
@@ -249,7 +276,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
// The 0% diamond's left half lands in the reserved left gutter (the
// content origin is inset past the label column, Figma-style) so it stays
// fully visible instead of being clipped by the sticky label column.
const leftPx = (renderPct / 100) * clipWidthPx - marker.hitWidth / 2;
const leftPx = centerXOf(kf, renderPct) - marker.hitWidth / 2;
const isKfSelected = selectedKeyframes.has(kfKey);
const atPlayhead = kf === playheadKeyframe;
const isHighlighted = isKfSelected || atPlayhead;
@@ -306,6 +333,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
key={`${kf.animationId ?? i}:${kf.propertyGroup ?? ""}:${kf.tweenPercentage ?? kf.percentage}`}
type="button"
className="absolute"
data-timeline-focus-id={focusId}
data-keyframe-group={groupAware ? kf.propertyGroup : undefined}
data-keyframe-percentage={
groupAware ? (kf.tweenPercentage ?? kf.percentage) : undefined
@@ -313,6 +341,9 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
data-keyframe-at-playhead={String(atPlayhead)}
data-keyframe-selected={String(isKfSelected)}
aria-current={atPlayhead ? "time" : undefined}
data-keyframe-outside-clip={boundary ?? undefined}
tabIndex={focusId === rovingTargetId ? 0 : -1}
aria-label={`${kf.propertyGroup ?? "Motion"} keyframe at ${keyframeTimeLabel(clipStart, clipDuration, kf.percentage)}${boundary ? ` (${boundary} clip)` : ""}`}
aria-pressed={isKfSelected}
style={{
left: leftPx,
@@ -335,6 +366,15 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
onPointerDown={onPointerDown}
onPointerMove={canDrag ? (e) => retimeHandleRef.current?.update(e) : undefined}
onPointerUp={onPointerUp}
// Keyboard activation only (detail 0): pointer presses already
// resolve through the pointerup path above.
onClick={(e) => {
if (e.detail !== 0) return;
e.stopPropagation();
suppressNextClick();
if (e.shiftKey) onShiftClickKeyframe?.(target);
else onClickKeyframe?.(target);
}}
onPointerCancel={
canDrag
? (e) => {
@@ -348,7 +388,7 @@ export const TimelineDiamondLane = memo(function TimelineDiamondLane({
e.stopPropagation();
onContextMenuKeyframe?.(e, target);
}}
title={`${roundPct(kf.percentage)}%`}
title={`${roundPct(kf.percentage)}%${boundary ? ` · ${boundary} clip` : ""}`}
>
<svg
width={marker.visualSize}
@@ -0,0 +1,84 @@
import type { KeyframeCacheEntry, TimelineElement } from "../store/playerStore";
import { CLIP_Y } from "./timelineLayout";
import type { TimelineLaneBaseProps } from "./timelineLaneProps";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
interface TimelineCompactDiamondsProps extends Pick<
TimelineLaneBaseProps,
| "currentTime"
| "selectedKeyframes"
| "onClickKeyframe"
| "onShiftClickKeyframe"
| "onContextMenuKeyframe"
| "onMoveKeyframe"
| "onSelectSegment"
| "suppressClickRef"
> {
element: TimelineElement;
elementId: string;
keyframesData: KeyframeCacheEntry;
pixelsPerSecond: number;
rowHeight: number;
beatsActive: boolean;
accentColor: string;
isSelected: boolean;
rovingTargetId: string | null;
}
/** Inline diamonds shown while the clip's property lanes are collapsed. */
export function TimelineCompactDiamonds({
element,
elementId,
keyframesData,
pixelsPerSecond,
rowHeight,
beatsActive,
accentColor,
isSelected,
currentTime,
selectedKeyframes,
rovingTargetId,
onClickKeyframe,
onShiftClickKeyframe,
onContextMenuKeyframe,
onMoveKeyframe,
onSelectSegment,
suppressClickRef,
}: TimelineCompactDiamondsProps) {
const width = Math.max(element.duration * pixelsPerSecond, 4);
return (
<div
className="absolute pointer-events-none"
style={{
left: element.start * pixelsPerSecond,
top: CLIP_Y,
width,
height: rowHeight - 2 * CLIP_Y,
zIndex: isSelected ? 11 : 6,
}}
>
<TimelineClipDiamonds
keyframesData={keyframesData}
clipWidthPx={width}
clipHeightPx={rowHeight - 2 * CLIP_Y}
beatsActive={beatsActive}
accentColor={accentColor}
isSelected={isSelected}
currentPercentage={
element.duration > 0 ? ((currentTime - element.start) / element.duration) * 100 : 0
}
elementId={elementId}
clipStart={element.start}
clipDuration={element.duration}
selectedKeyframes={selectedKeyframes}
rovingTargetId={rovingTargetId}
onClickKeyframe={(_id, target) => onClickKeyframe?.(element, target)}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
onSelectSegment={onSelectSegment}
suppressClickRef={suppressClickRef}
/>
</div>
);
}
@@ -2,7 +2,8 @@ import { Fragment, useRef } from "react";
import { KEYFRAME_DRAG_THRESHOLD_PX } from "../../components/editor/keyframeDrag";
import { MiniCurveSvg } from "../../components/editor/EaseCurveSection";
import type { TimelineKeyframeTarget } from "./timelineKeyframeIdentity";
import type { TimelineDiamondKeyframe } from "./TimelineClipDiamonds";
import { keyframeTimeLabel, type TimelineDiamondKeyframe } from "./timelineDiamondTypes";
import { timelineEaseFocusId } from "./timelineNavigationIdentity";
/** One diamond's geometry within its row, as computed by the lane. */
export interface TimelineDiamondMarker {
@@ -21,6 +22,10 @@ export interface TimelineDiamondMarker {
export function TimelineDiamondConnectors({
markers,
centerY,
elementId,
clipStart,
clipDuration,
rovingTargetId,
baseColor,
baseOpacity,
groupAware,
@@ -30,6 +35,11 @@ export function TimelineDiamondConnectors({
}: {
markers: readonly TimelineDiamondMarker[];
centerY: number;
elementId: string;
clipStart: number;
clipDuration: number;
/** Focus id of the one timeline control currently in the tab order. */
rovingTargetId: string | null;
baseColor: string;
baseOpacity: number;
groupAware: boolean;
@@ -48,6 +58,7 @@ export function TimelineDiamondConnectors({
if (x2 - x1 < 1) return null;
const connectorLeft = x1 + previous.visualSize / 2;
const connectorWidth = x2 - x1 - previous.visualSize / 2 - marker.visualSize / 2;
const target = keyframeTarget(kf);
return (
<Fragment key={`line-${i}-${previous.keyframe.percentage}-${kf.percentage}`}>
<div
@@ -70,7 +81,14 @@ export function TimelineDiamondConnectors({
width={x2 - x1}
centerY={centerY}
ease={kf.ease ?? globalEase}
target={keyframeTarget(kf)}
target={target}
focusId={timelineEaseFocusId(elementId, target)}
rovingTargetId={rovingTargetId}
afterLabel={keyframeTimeLabel(
clipStart,
clipDuration,
previous.keyframe.percentage,
)}
// connectorWidth is the clear span between the two diamonds'
// edges, so a 24x24 target centred in it overhangs a diamond as
// soon as the span is narrower than 24. The segment wrapper sits
@@ -111,6 +129,9 @@ function SegmentEaseControl({
centerY,
ease,
target,
focusId,
rovingTargetId,
afterLabel,
roomForFullTarget,
onSelectSegment,
}: {
@@ -119,6 +140,11 @@ function SegmentEaseControl({
centerY: number;
ease: string;
target: TimelineKeyframeTarget;
focusId: string;
/** Focus id of the one timeline control currently in the tab order. */
rovingTargetId: string | null;
/** Time label of the keyframe this segment starts at, for the accessible name. */
afterLabel: string;
roomForFullTarget: boolean;
onSelectSegment: (target: TimelineKeyframeTarget) => void;
}) {
@@ -151,7 +177,9 @@ function SegmentEaseControl({
<button
type="button"
data-keyframe-ease-button=""
aria-label={`Edit ${ease} easing`}
data-timeline-focus-id={focusId}
tabIndex={focusId === rovingTargetId ? 0 : -1}
aria-label={`Edit ${ease} easing after ${afterLabel}`}
title={`Edit ${ease} easing`}
// A visible 24x24 badge would collide with the diamonds either side, so
// the WCAG 2.2 (2.5.8) target is met with a centered transparent
@@ -9,6 +9,7 @@ import { getTrackStyle } from "./timelineIcons";
import { defaultTimelineTheme } from "./timelineTheme";
import { TRACK_H, getTimelineRowGeometry } from "./timelineLayout";
import { createTimelineClipIndex } from "../lib/timelineClipIndex";
import { buildTimelineLogicalRows } from "./timelineKeyboardNavigation";
import { usePlayerStore, type TimelineElement } from "../store/playerStore";
import type { MultiDragPreviewInput } from "./timelineMultiDragPreview";
import type { TimelineEditCallbacks } from "./timelineCallbacks";
@@ -26,6 +27,17 @@ afterEach(() => {
const TRACK_A = 1 / 6;
const TRACK_B = 0.5;
/** Every string a screen reader or a sighted user actually reads. */
function visibleText(host: HTMLElement): string {
return host.textContent ?? "";
}
function ariaLabels(host: HTMLElement): string {
return Array.from(host.querySelectorAll("[aria-label]"))
.map((el) => el.getAttribute("aria-label") ?? "")
.join(" ");
}
function element(id: string, track: number): TimelineElement {
return { id, label: id, tag: "div", start: 0, duration: 2, track };
}
@@ -94,6 +106,16 @@ function renderLanes(options: RenderLanesOptions = {}): {
rowGeometry={getTimelineRowGeometry(rowHeights)}
virtualRows={displayTrackOrder.map((_, index) => ({ index, rowKey: index }))}
rowsVirtualized={false}
focusedTargetId={null}
logicalRows={buildTimelineLogicalRows({
tracks,
displayTrackOrder,
laneCounts,
selectedElementId: null,
selectedElementIds: next.selectedElementIds ?? new Set(),
expandedClipIds: new Set(next.expandedClipIds ?? []),
gsapAnimations,
})}
clipIndex={createTimelineClipIndex(tracks)}
renderTimeRange={{ start: 0, end: Number.POSITIVE_INFINITY }}
pinnedClipIdentities={new Set()}
@@ -152,7 +174,11 @@ describe("TimelineLanes track numbering", () => {
expect(visibilityLabels(view.host)).toEqual(["Hide track 1", "Hide track 2"]);
expect(view.host.querySelectorAll("[data-timeline-row]")).toHaveLength(2);
expect(view.host.innerHTML).not.toContain("0.16666666666666666");
// Only what a user reads. The fractional key still identifies the row in
// `id` / `data-` attributes, which is exactly where an opaque sort key
// belongs.
expect(visibleText(view.host)).not.toContain("0.16666666666666666");
expect(ariaLabels(view.host)).not.toContain("0.16666666666666666");
act(() => view.root.unmount());
});
@@ -179,11 +205,12 @@ describe("TimelineLanes track numbering", () => {
onContextMenuLane,
});
// Row children: [sticky header column, time-mapped track content]. The rows
// sit inside the lanes list, which is what carries the virtualization
// positioning context.
const rows = Array.from(view.host.querySelectorAll('[role="listitem"]'));
const secondTrackContent = rows[1]?.children.item(1);
// The lane's own content cell: the track row's second child, after the
// sticky header column.
const secondTrackContent = view.host
.querySelectorAll("[data-timeline-row]")[1]
?.querySelector('[role="row"]')
?.children.item(1);
act(() => {
secondTrackContent?.dispatchEvent(
new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 100 }),
@@ -238,9 +265,40 @@ describe("TimelineLanes disclosure target", () => {
);
const firstIds = idsFor(first.host);
const secondIds = idsFor(second.host);
const cellIdsFor = (host: HTMLElement) =>
new Set(
Array.from(host.querySelectorAll<HTMLElement>("[data-property-group][id]"), (cell) =>
cell.getAttribute("id"),
).filter((id): id is string => id !== null),
);
const ownedIdsFor = (host: HTMLElement) =>
Array.from(host.querySelectorAll("[aria-owns]"), (owner) =>
owner.getAttribute("aria-owns"),
).filter((id): id is string => id !== null);
const firstCellIds = cellIdsFor(first.host);
const secondCellIds = cellIdsFor(second.host);
for (const { host } of [first, second]) {
const treegrid = host.querySelector<HTMLElement>('[role="treegrid"]');
expect(treegrid?.getAttribute("aria-colcount")).toBe("2");
expect(treegrid?.hasAttribute("aria-multiselectable")).toBe(false);
expect(
[...host.querySelectorAll('[role="rowheader"]')].every(
(cell) => cell.getAttribute("aria-colindex") === "1",
),
).toBe(true);
expect(
[...host.querySelectorAll('[role="gridcell"]')].every(
(cell) => cell.getAttribute("aria-colindex") === "2",
),
).toBe(true);
}
expect(firstIds.length).toBeGreaterThan(0);
expect(firstIds.some((id) => secondIds.includes(id))).toBe(false);
expect(firstCellIds.size).toBeGreaterThan(0);
expect([...firstCellIds].some((id) => secondCellIds.has(id))).toBe(false);
expect(ownedIdsFor(first.host).every((id) => firstCellIds.has(id))).toBe(true);
expect(ownedIdsFor(second.host).every((id) => secondCellIds.has(id))).toBe(true);
// Still a legal CSS id selector: the aria-controls lookups above use `#id`.
for (const id of [...firstIds, ...secondIds]) {
expect(id).toMatch(/^[A-Za-z][\w-]*$/);
@@ -1,7 +1,7 @@
import { Fragment, useId } from "react";
import { Fragment, useId, useMemo } from "react";
import { BeatStrip, BeatBackgroundLines } from "./BeatStrip";
import { TimelineClip } from "./TimelineClip";
import { TimelineClipDiamonds } from "./TimelineClipDiamonds";
import { TimelineCompactDiamonds } from "./TimelineCompactDiamonds";
import { TimelinePropertyLanes } from "./TimelinePropertyLanes";
import { TimelineTrackHeader } from "./TimelineTrackHeader";
import { resolveTrackKeyframeClip } from "./useTimelineTrackLayout";
@@ -27,6 +27,9 @@ import { TimelineTrackRow } from "./TimelineTrackRow";
import { isTimelineClipActive } from "./useTimelineActiveClips";
import { queryTimelineClipIndex } from "../lib/timelineClipIndex";
import { getTimelineElementIdentity } from "../lib/timelineElementHelpers";
import type { TimelineLogicalRow } from "./timelineKeyboardNavigation";
import { timelineClipFocusId } from "./timelineNavigationIdentity";
import { useTimelineKeyboardActor } from "./useTimelineKeyboardActor";
interface TimelineLanesProps extends TimelineLaneBaseProps {
/** Live-derived by TimelineCanvas from {@link TimelineLaneBaseProps.draggedClip}. */
@@ -49,6 +52,8 @@ export function TimelineLanes({
displayTrackOrder,
rowGeometry,
virtualRows,
logicalRows,
focusedTargetId,
rowsVirtualized,
clipIndex,
renderTimeRange,
@@ -99,14 +104,20 @@ export function TimelineLanes({
onRazorSplit,
onRazorSplitAll,
}: TimelineLanesProps) {
// Per-INSTANCE, so two timelines on one page (a mini-timeline in a modal
// beside the main one) cannot both mint `...-track-0` and have every caret's
// aria-controls resolve to whichever mounted first. React's useId embeds
// colons, which are legal in an id and in aria-controls but need escaping in
// a CSS `#id` selector, so they come out here and the prefix stays plain.
// ponytail: One per-instance namespace prevents aria-controls and aria-owns
// from resolving into a second timeline that renders the same logical rows.
const lanesIdPrefix = `timeline-lanes${useId().replaceAll(":", "")}`;
const expandedClipIds = usePlayerStore((s) => s.expandedClipIds);
const toggleClipExpanded = usePlayerStore((s) => s.toggleClipExpanded);
const logicalRowsByTrack = useMemo(() => {
const byTrack = new Map<number, TimelineLogicalRow[]>();
for (const logicalRow of logicalRows) {
const trackRows = byTrack.get(logicalRow.physicalTrackKey) ?? [];
trackRows.push(logicalRow);
byTrack.set(logicalRow.physicalTrackKey, trackRows);
}
return byTrack;
}, [logicalRows]);
const toggleClipExpandedTracked = (key: string) => {
const willExpand = !expandedClipIds.has(key);
trackStudioKeyframeLaneExpand({ expanded: willExpand });
@@ -128,10 +139,23 @@ export function TimelineLanes({
},
]
: [];
const keyboard = useTimelineKeyboardActor({
logicalRows,
focusedTargetId,
rowGeometry,
scrollRef,
onToggleRow: (row) => {
if (row.elementId) toggleClipExpandedTracked(row.elementId);
},
});
return (
<div
role="list"
role="treegrid"
aria-label="Timeline tracks"
aria-rowcount={logicalRows.length}
aria-colcount={2}
onFocus={keyboard.onFocus}
onKeyDown={keyboard.onKeyDown}
className={rowsVirtualized ? "absolute inset-0" : undefined}
>
{
@@ -140,6 +164,9 @@ export function TimelineLanes({
const trackNum = displayTrackOrder[row];
if (trackNum === undefined) return null;
const displayNumber = trackDisplayNumber(displayTrackOrder, trackNum);
const trackLogicalRows = logicalRowsByTrack.get(trackNum) ?? [];
const logicalRow = trackLogicalRows[0];
if (!logicalRow) return null;
const rowHeight = rowGeometry.getRowHeight(row);
const els = tracks.find(([t]) => t === trackNum)?.[1] ?? [];
const renderElements = rowsVirtualized
@@ -156,9 +183,7 @@ export function TimelineLanes({
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
// All lanes use the same uniform color — no alternating stripes.
const rowBackground = theme.rowBackground;
// The beat-dot strip occupies the top of this track's lane (active track,
// or the music track when nothing is selected). When shown, keyframe
// diamonds shrink + drop to the bottom half so they don't collide with it.
// Keep diamonds below the beat strip on the active/music track.
const beatStripOnTrack =
(beatAnalysis?.beatTimes?.length ?? 0) >= 2 &&
(selectedElementId
@@ -166,9 +191,7 @@ export function TimelineLanes({
: els.some(isMusicTrack));
const isTrackHidden = els.length > 0 && els.every((element) => element.hidden === true);
const isAudioTrack = els.length > 0 && els.some(isAudioTimelineElement);
// The one keyframed element this track shows lanes for (selected, else
// most lanes). A track can hold several elements; scoping to one keeps
// their keyframes from cramming into a single row.
// Only the selected/most-keyframed clip owns expanded lanes on a shared track.
const keyframeClip = resolveTrackKeyframeClip(
els,
laneCounts,
@@ -178,22 +201,22 @@ export function TimelineLanes({
const keyframeClipKey = keyframeClip?.key ?? keyframeClip?.id;
const keyframeClipExpanded =
keyframeClipKey != null && expandedClipIds.has(keyframeClipKey);
// Minted here because this is the only place that sees BOTH ends of
// the disclosure: the caret in the sticky header and the diamond lanes
// on the canvas. Keyed by display row, not by `trackNum`, which is a
// fractional sort key and would mint ids like `...-0.16666666666666666`.
// Link the sticky caret to the canvas lanes with a stable display-row id.
const lanesId = `${lanesIdPrefix}-track-${row}`;
return (
<TimelineTrackRow
key={rowKey}
index={row}
rowKey={rowKey}
rowCount={displayTrackOrder.length}
logicalRow={logicalRow}
propertyRows={trackLogicalRows.slice(1)}
lanesId={lanesId}
top={rowGeometry.getRowTop(row)}
height={rowHeight}
virtualized={rowsVirtualized}
background={rowBackground}
borderColor={theme.rowBorder}
rovingTargetId={keyboard.rovingTargetId}
>
<TimelineTrackHeader
trackNumber={trackNum}
@@ -224,8 +247,11 @@ export function TimelineLanes({
onToggleTrackHidden={onToggleTrackHidden}
onTogglePropertyGroupKeyframe={onTogglePropertyGroupKeyframe}
onSeek={onSeek}
rovingTargetId={keyboard.rovingTargetId}
/>
<div
role="gridcell"
aria-colindex={2}
style={{
width: trackContentWidth,
marginLeft: contentGutter, // room for a 0% diamond left of t=0
@@ -297,9 +323,8 @@ export function TimelineLanes({
const isSelected =
selectedElementId === elementKey || selectedElementIds.has(elementKey);
const isComposition = !!el.compositionSrc;
// elementKey (el.key ?? el.id) is already unique per clip; do NOT
// fold in the map index, or a splice/reorder remounts every clip
// at/after the change (DOM flash, drag interruption).
// The element identity is already unique per clip. Never fold in the map
// index, or a splice/reorder remounts every clip at/after the change.
const clipKey = elementKey;
const isDraggingClip =
draggedClip?.started === true &&
@@ -307,11 +332,8 @@ export function TimelineLanes({
getTimelineElementIdentity(draggedElement) === elementKey;
if (isDraggingClip) return null;
const previewElement = getPreviewElement(el);
// Passenger of a live multi-drag: slide by the SAME formation
// delta (the grabbed clip's group-clamped delta) via a
// compositor transform on a same-geometry wrapper (absolute
// inset-0 → identical offset parent, so the clip's own
// left/top are preserved), plus the ghost's elevated z/opacity.
// Passenger of a live multi-drag: preserve the formation without changing
// the passenger's timeline data until the owning drag commits.
const isPassenger =
multiDragPreview != null && isMultiDragPassenger(clipKey, multiDragPreview);
const passengerOffsetPx = isPassenger
@@ -336,6 +358,9 @@ export function TimelineLanes({
capabilities={capabilities}
theme={theme}
isComposition={isComposition}
tabIndex={
keyboard.rovingTargetId === timelineClipFocusId(elementKey) ? 0 : -1
}
onHoverStart={() => setHoveredClip(clipKey)}
onHoverEnd={() => setHoveredClip(null)}
onResizeStart={
@@ -473,41 +498,33 @@ export function TimelineLanes({
renderClipContent,
renderClipOverlay,
)}
{!showsLanes && keyframeCache?.get(elementKey) && (
<TimelineClipDiamonds
keyframesData={keyframeCache.get(elementKey)!}
clipWidthPx={Math.max(previewElement.duration * pps, 4)}
clipHeightPx={rowHeight - 2 * CLIP_Y}
clipDuration={previewElement.duration}
beatsActive={beatStripOnTrack}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentPercentage={
previewElement.duration > 0
? ((currentTime - previewElement.start) / previewElement.duration) *
100
: 0
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
onClickKeyframe={(_elId, target) =>
onClickKeyframe?.(previewElement, target)
}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
onSelectSegment={onSelectSegment}
suppressClickRef={suppressClickRef}
/>
)}
</TimelineClip>
);
// Mounted for the track's keyframe clip in BOTH disclosure
// states, so the header caret's aria-controls resolves while
// collapsed too; collapsed just feeds it no animations, so
// the wrapper renders empty. The key is stable across a
// multi-drag: without it the passenger branch below remounts
// this subtree and interrupts the gesture.
const compactKeyframes = keyframeCache?.get(elementKey);
const compactDiamonds = !showsLanes && compactKeyframes && (
<TimelineCompactDiamonds
key={`${clipKey}-diamonds`}
element={previewElement}
elementId={elementKey}
keyframesData={compactKeyframes}
pixelsPerSecond={pps}
rowHeight={rowHeight}
beatsActive={beatStripOnTrack}
accentColor={clipStyle.accent}
isSelected={isSelected}
currentTime={currentTime}
selectedKeyframes={selectedKeyframes}
rovingTargetId={keyboard.rovingTargetId}
onClickKeyframe={onClickKeyframe}
onShiftClickKeyframe={onShiftClickKeyframe}
onContextMenuKeyframe={onContextMenuKeyframe}
onMoveKeyframe={onMoveKeyframe}
onSelectSegment={onSelectSegment}
suppressClickRef={suppressClickRef}
/>
);
// Keep this shell mounted while collapsed so aria-controls stays valid
// and multi-drag cannot remount the subtree mid-gesture.
const propertyLanes = isTrackKeyframeClip && (
<TimelinePropertyLanes
key={`${clipKey}-property-lanes`}
@@ -529,6 +546,7 @@ export function TimelineLanes({
}
elementId={elementKey}
selectedKeyframes={selectedKeyframes}
rovingTargetId={keyboard.rovingTargetId}
onSelectSegment={(target) => onSelectSegment?.(elementKey, target)}
onClickKeyframe={(target) => onClickKeyframe?.(previewElement, target)}
onShiftClickKeyframe={(target) =>
@@ -544,14 +562,12 @@ export function TimelineLanes({
suppressClickRef={suppressClickRef}
/>
);
// Keep one keyed top-level child per element. Returning an
// array here makes React reconcile the outer array by
// position, so a window shift remounts otherwise stable
// clip keys and can tear down focus mid-reveal.
// One keyed child prevents window shifts from remounting focused clips.
if (!isPassenger) {
return (
<Fragment key={clipKey}>
{clip}
{compactDiamonds}
{propertyLanes}
</Fragment>
);
@@ -568,6 +584,7 @@ export function TimelineLanes({
}}
>
{clip}
{compactDiamonds}
{propertyLanes}
</div>
);

Some files were not shown because too many files have changed in this diff Show More