diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 8de32b78ef..f3f932b3f3 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -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-to-video capture for HyperFrames.", - "version": "0.7.52", + "version": "0.7.53", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 8ec23b2c9e..a2f82af212 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "hyperframes", - "version": "0.7.52", + "version": "0.7.53", "description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.", "author": { "name": "HeyGen", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index cc13850366..aa8a208699 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://cursor.com/schemas/cursor-plugin/plugin.json", "name": "hyperframes", "displayName": "HyperFrames by HeyGen", - "version": "0.7.52", + "version": "0.7.53", "description": "Write HTML, render video. Compositions, Tailwind v4 styles, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website-to-video capture for HyperFrames.", "author": { "name": "HeyGen", diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ed4b23197..cc38804287 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -235,6 +235,36 @@ jobs: - run: bun run --cwd packages/core build:hyperframes-runtime - run: bun run --filter '!@hyperframes/producer' test + producer-source-tests: + name: "Producer: ${{ matrix.lane }} tests" + needs: changes + if: needs.changes.outputs.code == 'true' + runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + lane: [unit, integration] + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + lfs: true + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + - name: Install FFmpeg for integration tests + if: matrix.lane == 'integration' + run: | + 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: bun run --filter '@hyperframes/{parsers,lint,studio-server}' build + - run: bun run --cwd packages/core build + - run: bun run --filter @hyperframes/engine build + - run: bun run producer:test:${{ matrix.lane }} + # Tests under skills/**/*.test.mjs are bare `node --test` files with only # `node:` built-in imports. They aren't part of any workspace package, and # the main `Test` job's `code` path filter excludes `skills/**`, so without diff --git a/bun.lock b/bun.lock index c6eb53154d..4339fb118a 100644 --- a/bun.lock +++ b/bun.lock @@ -22,7 +22,7 @@ }, "packages/aws-lambda": { "name": "@hyperframes/aws-lambda", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@aws-sdk/client-s3": "^3.700.0", "@aws-sdk/client-sfn": "^3.700.0", @@ -54,7 +54,7 @@ }, "packages/cli": { "name": "@hyperframes/cli", - "version": "0.7.45", + "version": "0.7.48", "bin": { "hyperframes": "./dist/cli.js", }, @@ -103,7 +103,7 @@ }, "packages/core": { "name": "@hyperframes/core", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@chenglou/pretext": "^0.0.5", "@hyperframes/lint": "workspace:*", @@ -128,7 +128,7 @@ }, "packages/engine": { "name": "@hyperframes/engine", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@hono/node-server": "^1.13.0", "@hyperframes/core": "workspace:^", @@ -146,7 +146,7 @@ }, "packages/gcp-cloud-run": { "name": "@hyperframes/gcp-cloud-run", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@google-cloud/storage": "^7.14.0", "@google-cloud/workflows": "^4.2.0", @@ -166,7 +166,7 @@ }, "packages/lint": { "name": "@hyperframes/lint", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@hyperframes/parsers": "workspace:*", "htmlparser2": "^10.1.0", @@ -183,7 +183,7 @@ }, "packages/parsers": { "name": "@hyperframes/parsers", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@babel/parser": "^7.27.0", "acorn": "^8.17.0", @@ -203,7 +203,7 @@ }, "packages/player": { "name": "@hyperframes/player", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@hyperframes/core": "workspace:*", }, @@ -218,7 +218,7 @@ }, "packages/producer": { "name": "@hyperframes/producer", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@fontsource/archivo-black": "^5.2.8", "@fontsource/eb-garamond": "^5.2.7", @@ -258,11 +258,12 @@ "esbuild": "^0.25.12", "tsx": "^4.21.0", "typescript": "^5.7.2", + "vitest": "^3.2.4", }, }, "packages/sdk": { "name": "@hyperframes/sdk", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", @@ -289,7 +290,7 @@ }, "packages/shader-transitions": { "name": "@hyperframes/shader-transitions", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "html2canvas": "^1.4.1", }, @@ -301,7 +302,7 @@ }, "packages/studio": { "name": "@hyperframes/studio", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@codemirror/autocomplete": "^6.20.1", "@codemirror/commands": "^6.10.3", @@ -349,7 +350,7 @@ }, "packages/studio-server": { "name": "@hyperframes/studio-server", - "version": "0.7.45", + "version": "0.7.48", "dependencies": { "@hyperframes/core": "workspace:*", "@hyperframes/parsers": "workspace:*", diff --git a/docs/changelog.mdx b/docs/changelog.mdx index ddb0851332..f2e0cd6953 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,42 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio {/* New release entries are prepended by `bun run changelog:draft --write`. */} + +This release makes studio manual editing safe for color-graded compositions: edits no longer bake runtime opacity transients into tween bounds (the "element disappears after editing" bug), resize and rotate commit clean per-group values, selecting an element no longer rewrites or destroys its authored clip-path, and the crop tool draws in the element's rotated frame. The GSAP writers also stop accumulating duplicate keys in saved compositions. + +## Fixes + +- **Studio:** Draw the crop UI in the element's rotated frame ([39f33bd3a](https://github.com/heygen-com/hyperframes/commit/39f33bd3a3bcd2ea20980bca1e871bbc9c6592f3)) +- **Studio:** Address review findings on graded-element editing ([5d1cafff8](https://github.com/heygen-com/hyperframes/commit/5d1cafff8225177ee1ae99da85a0ce0ce4e5babd)) +- **Studio:** Crop restore value is owned by the lifted element's gesture ([b7fddd548](https://github.com/heygen-com/hyperframes/commit/b7fddd548acf1d268294a7fe71adc9a946ccaa40)) +- **Core:** Make timing masks safe for source loaders ([179c942cd](https://github.com/heygen-com/hyperframes/commit/179c942cd2bb3fb37236368fa33b16c4c1ad58a0)) +- **Lint:** Anchor the opacity-zero probe so 0.98 stops matching ([fe5296672](https://github.com/heygen-com/hyperframes/commit/fe529667283ad07a2f3cd8440e285ad3d0fc8937)) +- **Parsers:** Never emit duplicate vars keys in either writer ([90d77d016](https://github.com/heygen-com/hyperframes/commit/90d77d016ffb75264491bfebb4c227072904d63d)) +- **Studio:** Route panel property commits to a group-owning set ([066c3dae3](https://github.com/heygen-com/hyperframes/commit/066c3dae37bbe3397cfc35642076dd30da9743f3)) +- **Studio:** Crop tool stands down for clips it cannot edit ([cbfb6ba94](https://github.com/heygen-com/hyperframes/commit/cbfb6ba943ea458bb79f89bbc0611cd81c817831)) +- **Studio:** Correct gesture commits for scaled and graded elements ([3525c7ff5](https://github.com/heygen-com/hyperframes/commit/3525c7ff52139a5d63093f7a8863e7fb8ff980e6)) +- **Studio:** Stop tween re-inits from baking runtime opacity transients ([5cc14c222](https://github.com/heygen-com/hyperframes/commit/5cc14c2221bb6262ddd0631c9f2df1891f266217)) +- **Core:** Capture authored inline opacity at parse time and follow source geometry ([3147c8e06](https://github.com/heygen-com/hyperframes/commit/3147c8e063a72d999447aca8bb0528480bdcd043)) +- **Product Launch:** Hoist approved frame videos during assembly ([2aadf450e](https://github.com/heygen-com/hyperframes/commit/2aadf450e718497ac4b0edb7286cf4c8f3da06bc), [#2226](https://github.com/heygen-com/hyperframes/pull/2226)) +- **Hooks:** Scope pre-commit build check to target repo, fix stale symlinks ([522d7c93b](https://github.com/heygen-com/hyperframes/commit/522d7c93b7acb16c6ade1376040237ff615dca7b), [#2175](https://github.com/heygen-com/hyperframes/pull/2175)) +- **Lint:** Parse HTML structure without regex ([05c3b5503](https://github.com/heygen-com/hyperframes/commit/05c3b5503faf5da1efe5d64985b63db3f762f765), [#2223](https://github.com/heygen-com/hyperframes/pull/2223)) +- **Telemetry:** Expose stalled render stages ([87618eef4](https://github.com/heygen-com/hyperframes/commit/87618eef4c65aff78f99f94246c60114a579af0f), [#2220](https://github.com/heygen-com/hyperframes/pull/2220)) +- **Lint:** Flag digit-leading element ids ([d51fa7eba](https://github.com/heygen-com/hyperframes/commit/d51fa7eba2e369fd6212cdc59ebaef71e2024f7e), [#2222](https://github.com/heygen-com/hyperframes/pull/2222)) +- **CLI:** Correct strict warning hint ([5f22209a8](https://github.com/heygen-com/hyperframes/commit/5f22209a82afbdf3c2a8d1a788b94e9aeeaac689), [#2221](https://github.com/heygen-com/hyperframes/pull/2221)) + +## Internal + +- Address review nits ([67cfae258](https://github.com/heygen-com/hyperframes/commit/67cfae2587e15b0dd6a064d9fdbd4790831905cf)) +- **Producer:** Gate source tests by execution lane ([9e7b11998](https://github.com/heygen-com/hyperframes/commit/9e7b11998cb2727b05337f9133eae4647c979644)) +- **Repo:** Forbid tracked generated artifacts ([585aa9f6b](https://github.com/heygen-com/hyperframes/commit/585aa9f6b24561fe45ea298787a291390ba8eea2)) + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.7.52...v0.7.53). + + { }); describe("seekCompositionTimeline", () => { - it("keeps the existing raced double-frame settle as the default", async () => { + it("keeps the raced double-frame settle and adds a bounded font wait by default", async () => { const { page, evaluate, waitForFunction } = fakeSeekPage(); await seekCompositionTimeline(page, 1.25); expect(waitForFunction).not.toHaveBeenCalled(); - expect(evaluate).toHaveBeenCalledTimes(2); + expect(evaluate).toHaveBeenCalledTimes(3); expect(evaluate).toHaveBeenNthCalledWith(1, expect.any(Function), 1.25, false); expect(evaluate.mock.calls[1]?.[0]).toContain("window.setTimeout(finish, 100)"); + // Post-seek font settle: a seek can reveal glyphs whose unicode-range + // subsets only start loading after the next layout (CJK snapshot reports). + expect(evaluate).toHaveBeenNthCalledWith(3, expect.any(Function), 500); + }); + + it("waitForFontsMs: 0 disables the post-seek font wait", async () => { + const { page, evaluate } = fakeSeekPage(); + + await seekCompositionTimeline(page, 1.25, { waitForFontsMs: 0 }); + + expect(evaluate).toHaveBeenCalledTimes(2); }); it("prefers renderSeek so the runtime synchronizes clip visibility", async () => { @@ -128,6 +139,7 @@ describe("seekCompositionTimeline", () => { fallbackToBridgeAndTimelines: true, waitForPreferredSeekTargetMs: 500, animationFrameSettle: "none", + waitForFontsMs: 0, settleMs: 150, }); await vi.advanceTimersByTimeAsync(150); diff --git a/packages/cli/src/capture/captureCompositionFrame.ts b/packages/cli/src/capture/captureCompositionFrame.ts index 2b0d164094..49c9af67b4 100644 --- a/packages/cli/src/capture/captureCompositionFrame.ts +++ b/packages/cli/src/capture/captureCompositionFrame.ts @@ -6,6 +6,7 @@ import { resolveCompositionViewportFromHtml } from "../utils/compositionViewport const SHADER_TRANSITIONS_TIMEOUT_MS = 90_000; const CAPTURE_SETTLE_MS = 1500; const PREFERRED_SEEK_TARGET_WAIT_MS = 500; +const DEFAULT_POST_SEEK_FONT_WAIT_MS = 500; // The audit-grade seek tuning shared by check and the deprecated inspect/layout: // bridge+timeline fallback, ordered double-rAF settle, bounded font wait, sleep. @@ -257,8 +258,13 @@ export async function seekCompositionTimeline( ); } - if (options.waitForFontsMs !== undefined) { - await waitForCompositionFonts(page, options.waitForFontsMs); + // On by default: every seek caller here is a visual-audit path (snapshot, + // check, compare, validate, layout) and a seek that reveals unrequested + // glyphs must not screenshot before their font subsets load. Costs one + // evaluate + one rAF when nothing is loading. Pass 0 to disable. + const waitForFontsMs = options.waitForFontsMs ?? DEFAULT_POST_SEEK_FONT_WAIT_MS; + if (waitForFontsMs > 0) { + await waitForCompositionFonts(page, waitForFontsMs); } if (options.settleMs !== undefined) { const settleMs = Math.max(0, options.settleMs); @@ -299,12 +305,31 @@ export async function waitForCompositionFonts( .evaluate((ms: number) => { const fonts = Reflect.get(document, "fonts"); if (typeof fonts !== "object" || fonts === null) return Promise.resolve(); - const ready = Reflect.get(fonts, "ready"); - if (!ready) return Promise.resolve(); - return Promise.race([ - Promise.resolve(ready).then(() => undefined), - new Promise((resolve) => setTimeout(resolve, ms)), - ]); + // A seek can reveal glyphs whose font faces were never requested — + // CJK @font-face splits into unicode-range subsets that only load when + // first laid out. `fonts.ready` is already-resolved at that moment, so + // awaiting it immediately races the load request and screenshots blank + // glyphs (wild reports: Traditional Chinese / Microsoft YaHei check + // snapshots). Force a synchronous layout so pending subsets actually + // start loading, give the loader one frame to flip `fonts.status`, + // THEN await readiness. + void document.body?.offsetHeight; + return new Promise((resolveWait) => { + const deadline = setTimeout(resolveWait, ms); + requestAnimationFrame(() => { + const status = Reflect.get(fonts, "status"); + const ready = Reflect.get(fonts, "ready"); + if (status !== "loading" || !ready) { + clearTimeout(deadline); + resolveWait(); + return; + } + Promise.resolve(ready).then(() => { + clearTimeout(deadline); + resolveWait(); + }); + }); + }); }, timeoutMs) .catch(() => {}); } diff --git a/packages/cli/src/commands/preview.ts b/packages/cli/src/commands/preview.ts index 6edeec423a..b3f6cd5ed8 100644 --- a/packages/cli/src/commands/preview.ts +++ b/packages/cli/src/commands/preview.ts @@ -50,6 +50,7 @@ interface BrowserLaunchOptions { browserPath?: string; userDataDir?: string; remoteDebuggingPort?: number; + browserNoGpu?: boolean; } interface StudioLaunchOptions extends BrowserLaunchOptions { @@ -142,6 +143,12 @@ export default defineCommand({ type: "string", description: "Chromium remote debugging port (requires --browser-path and --user-data-dir)", }, + "browser-no-gpu": { + type: "boolean", + default: false, + description: + "Launch the opened browser with --disable-gpu (requires --browser-path). For hosts where hardware acceleration crashes the graphics driver (e.g. NVIDIA Xid resets); with the system default browser use --no-open instead.", + }, }, async run({ args }) { const startPort = parseInt(args.port ?? "3002", 10); @@ -239,6 +246,14 @@ export default defineCommand({ const noOpen = !args.open; const browserPath = args["browser-path"] as string | undefined; + const browserNoGpu = !!args["browser-no-gpu"]; + if (browserNoGpu && !browserPath) { + clack.log.error( + "--browser-no-gpu requires --browser-path (the system default browser cannot receive Chromium flags — use --no-open on GPU-unstable hosts)", + ); + process.exitCode = 1; + return; + } const userDataDir = args["user-data-dir"] as string | undefined; let remoteDebuggingPort: number | undefined; try { @@ -258,6 +273,7 @@ export default defineCommand({ browserPath, userDataDir, remoteDebuggingPort, + browserNoGpu, }); } @@ -269,6 +285,7 @@ export default defineCommand({ browserPath, userDataDir, remoteDebuggingPort, + browserNoGpu, }); } @@ -280,6 +297,7 @@ export default defineCommand({ browserPath, userDataDir, remoteDebuggingPort, + browserNoGpu, }); }, }); @@ -641,6 +659,7 @@ function openStudioBrowser(url: string, projectName: string, options?: BrowserLa browserPath: options?.browserPath, userDataDir: options?.userDataDir, remoteDebuggingPort: options?.remoteDebuggingPort, + disableGpu: options?.browserNoGpu, }); } diff --git a/packages/cli/src/utils/openBrowser.test.ts b/packages/cli/src/utils/openBrowser.test.ts index 3de1f307b4..7dbe859d4f 100644 --- a/packages/cli/src/utils/openBrowser.test.ts +++ b/packages/cli/src/utils/openBrowser.test.ts @@ -158,3 +158,16 @@ describe("validateRemoteDebuggingPortDeps", () => { ).toBe("--remote-debugging-port requires --browser-path"); }); }); + +describe("buildBrowserArgs --disable-gpu", () => { + it("appends --disable-gpu before the url when disableGpu is set", () => { + expect(buildBrowserArgs("http://localhost:3002", { disableGpu: true })).toEqual([ + "--disable-gpu", + "http://localhost:3002", + ]); + }); + + it("omits --disable-gpu by default", () => { + expect(buildBrowserArgs("http://localhost:3002", {})).toEqual(["http://localhost:3002"]); + }); +}); diff --git a/packages/cli/src/utils/openBrowser.ts b/packages/cli/src/utils/openBrowser.ts index 1bb3ad9b33..0cde28af9d 100644 --- a/packages/cli/src/utils/openBrowser.ts +++ b/packages/cli/src/utils/openBrowser.ts @@ -4,6 +4,14 @@ export interface OpenBrowserOptions { browserPath?: string; userDataDir?: string; remoteDebuggingPort?: number; + /** + * Launch the browser with --disable-gpu. For hosts where hardware + * acceleration crashes the graphics driver (wild report: auto-opened + * preview triggered NVIDIA Xid 32 resets on NixOS). Only effective with + * `browserPath` — the `open`-package fallback launches the system default + * browser with no way to pass Chromium flags. + */ + disableGpu?: boolean; } export function parseRemoteDebuggingPort(value: string | undefined): number | undefined { @@ -52,6 +60,9 @@ export function buildBrowserArgs(url: string, options: OpenBrowserOptions): stri if (options.remoteDebuggingPort !== undefined && options.userDataDir) { args.push(`--remote-debugging-port=${options.remoteDebuggingPort}`); } + if (options.disableGpu) { + args.push("--disable-gpu"); + } args.push(url); return args; } diff --git a/packages/core/package.json b/packages/core/package.json index 40f63d40b0..b7662086a5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.7.52", + "version": "0.7.53", "description": "", "repository": { "type": "git", diff --git a/packages/core/src/colorGrading.ts b/packages/core/src/colorGrading.ts index d71ce17764..d5411e20a3 100644 --- a/packages/core/src/colorGrading.ts +++ b/packages/core/src/colorGrading.ts @@ -1,5 +1,17 @@ export const HF_COLOR_GRADING_ATTR = "data-color-grading"; +// Runtime <-> studio contract attributes. The runtime grading engine writes +// them; studio editing/soft-reload code reads them. Single owner — never +// re-declare these literals elsewhere. +/** Set on a graded source while its pixels render on the grading canvas. */ +export const COLOR_GRADING_SOURCE_HIDDEN_ATTR = "data-hf-color-grading-source-hidden"; +/** + * The element's AUTHORED inline opacity, stamped at document parse time before + * any animation engine mutates it ("" = authored none; attribute absent = + * never captured). See installAuthoredOpacityCapture in the runtime. + */ +export const COLOR_GRADING_AUTHORED_OPACITY_ATTR = "data-hf-authored-opacity"; + export const HF_COLOR_GRADING_CANVAS_ID_PREFIX = "__hf_color_grading_"; export const HF_COLOR_GRADING_COLOR_SPACE = "rec709"; diff --git a/packages/core/src/compiler/timingCompiler.test.ts b/packages/core/src/compiler/timingCompiler.test.ts index cab293ebbd..aeb624370e 100644 --- a/packages/core/src/compiler/timingCompiler.test.ts +++ b/packages/core/src/compiler/timingCompiler.test.ts @@ -168,6 +168,22 @@ describe("compileTimingAttrs", () => { expect(compiled).toContain('id="hf-video-0"'); expect(compiled).toContain('data-end="2"'); }); + + it("preserves inert regions when compiled output is compiled again", () => { + const html = [ + '', + '', + '