From 58ef24c6d88634ff42f60b0435f3685ccfa2d459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 06:22:47 +0000 Subject: [PATCH 01/20] chore: release v0.9.44 (#7708) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- docs/changelog.mdx | 30 ++++++++++++++++++++++++ gemini-extension.json | 2 +- packages/aws-lambda/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/engine/package.json | 2 +- packages/gcp-cloud-run/package.json | 2 +- packages/inspector/package.json | 2 +- packages/lint/package.json | 2 +- packages/parsers/package.json | 2 +- packages/player/package.json | 2 +- packages/producer/package.json | 2 +- packages/sdk/package.json | 2 +- packages/shader-transitions/package.json | 2 +- packages/share/package.json | 2 +- packages/studio-server/package.json | 2 +- packages/studio/package.json | 2 +- plugin.json | 2 +- releases/v0.9.44.md | 29 +++++++++++++++++++++++ 22 files changed, 79 insertions(+), 20 deletions(-) create mode 100644 releases/v0.9.44.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 039290bcaa..ac6e9b2d84 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 capture for HyperFrames.", - "version": "0.9.43", + "version": "0.9.44", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 4905769f25..d7721d1be4 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -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.9.43", + "version": "0.9.44", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index f551aa3b2b..3513a5b9da 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -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.9.43", + "version": "0.9.44", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com" diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 502b3bb56a..065a3bbbcb 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,36 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio {/* New release entries are prepended by `bun run changelog:draft --write`. */} + +Check now warns when text leaves the safe areas of a 9:16 social video. Previews free a clip's decoder once the playhead leaves it, and a cancelled audio fade no longer crashes the page. Set HYPERFRAMES_OWNER to tell which run started a leftover browser. + +## Features + +- **CLI:** Name a caller's owner id on the browsers and warm check it starts ([798c241e4d0cf](https://github.com/heygen-com/hyperframes/commit/798c241e4d0cf18fc328d90c41823a766a9d8d8e), [#7687](https://github.com/heygen-com/hyperframes/pull/7687)). +- **CLI:** Check warns when text leaves the 9:16 social safe areas ([45882027bd284](https://github.com/heygen-com/hyperframes/commit/45882027bd284a8fbd96ffe854184104304c224f), [#7680](https://github.com/heygen-com/hyperframes/pull/7680)). + +## Fixes + +- **Studio:** Edit clips added to a project outside Studio ([cb072eb047efa](https://github.com/heygen-com/hyperframes/commit/cb072eb047efafe9749ae81613ccbb8036be3e21), [#7686](https://github.com/heygen-com/hyperframes/pull/7686)). +- **Core:** Stop a cancelled audio envelope from crashing the page ([49623307ce86c](https://github.com/heygen-com/hyperframes/commit/49623307ce86c030f8016c3e21b6aa1120806d5c), [#7682](https://github.com/heygen-com/hyperframes/pull/7682)). +- **Core:** Free a preview clip's decoder when the playhead leaves it ([f7b49f12078e1](https://github.com/heygen-com/hyperframes/commit/f7b49f12078e14cdc401808edd4f217df8d24ce4), [#7679](https://github.com/heygen-com/hyperframes/pull/7679)). + +## Performance + +- **Core:** A reloaded preview video resyncs only itself ([92816ed56bde0](https://github.com/heygen-com/hyperframes/commit/92816ed56bde07affff0f6e0a5bce3dd96a96808), [#7684](https://github.com/heygen-com/hyperframes/pull/7684)). + +## Catalog + +- **CLI:** Report an invalid catalog --type as a usage error ([affa03b60fb08](https://github.com/heygen-com/hyperframes/commit/affa03b60fb082c93b1efa9341ac85a106dda61e), [#7678](https://github.com/heygen-com/hyperframes/pull/7678)). +- Publish the hosted HyperFrames MCP to the official MCP registry ([0f6ed7d57bc9c](https://github.com/heygen-com/hyperframes/commit/0f6ed7d57bc9c6d449ce89cbfc3a7948a7427157), [#7676](https://github.com/heygen-com/hyperframes/pull/7676)). + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.9.43...v0.9.44). + + Date: Sat, 14 Nov 2026 08:09:33 +0000 Subject: [PATCH 02/20] fix(core): at full quality a 4K clip plays a copy at the size the preview shows it (#7723) * fix(core): at full quality a 4K clip plays a copy at the size the preview shows it * fix(core): names carry the copy rule, not comments * fix(core): a clip whose copy fails is left held as it was, so its decoder is still freed when idle * fix(core): the copy hold lives with the copy code, keeping core inside its size budget --- packages/core/src/previewProxyBox.ts | 16 ++- packages/core/src/runtime/mediaProxy.test.ts | 139 +++++++++++++++++-- packages/core/src/runtime/mediaProxy.ts | 83 ++++++++--- packages/core/src/runtime/preloadMedia.ts | 3 +- packages/core/src/runtime/proxySrc.ts | 8 +- 5 files changed, 211 insertions(+), 38 deletions(-) diff --git a/packages/core/src/previewProxyBox.ts b/packages/core/src/previewProxyBox.ts index be011b27cc..1b27e536de 100644 --- a/packages/core/src/previewProxyBox.ts +++ b/packages/core/src/previewProxyBox.ts @@ -66,10 +66,20 @@ export function scalePreviewProxyBox(box: PreviewProxyBox, level: PreviewQuality return quantizePreviewProxyBox(box.width * level, box.height * level) ?? box; } +export function isOversizedForPreview(facts: PreviewSourceFacts, box: PreviewProxyBox): boolean { + return box.width * box.height <= HEAVY_PIXEL_SHARE * facts.width * facts.height; +} + export function isHeavyForPreview(facts: PreviewSourceFacts, copyBox: PreviewProxyBox): boolean { return ( - copyBox.width * copyBox.height <= HEAVY_PIXEL_SHARE * facts.width * facts.height || - facts.bitDepth > 8 || - facts.bitrate >= HEAVY_BITRATE + isOversizedForPreview(facts, copyBox) || facts.bitDepth > 8 || facts.bitrate >= HEAVY_BITRATE ); } + +export function wantsLighterCopy( + facts: PreviewSourceFacts, + box: PreviewProxyBox, + level: PreviewQuality, +): boolean { + return level < 1 ? isHeavyForPreview(facts, box) : isOversizedForPreview(facts, box); +} diff --git a/packages/core/src/runtime/mediaProxy.test.ts b/packages/core/src/runtime/mediaProxy.test.ts index af637fa8ca..60ef1fdf08 100644 --- a/packages/core/src/runtime/mediaProxy.test.ts +++ b/packages/core/src/runtime/mediaProxy.test.ts @@ -1044,6 +1044,8 @@ describe("a copy sized to how large the video shows", () => { describe("preview quality: lighter copies of heavy files", () => { const HEAVY = { width: 3840, height: 2160, bitDepth: 8, bitrate: 10_000_000 }; const LIGHT = { width: 1280, height: 720, bitDepth: 8, bitrate: 10_000_000 }; + // Heavy at a lower level's box, not oversized for the full 2048x1218 box. + const MID = { width: 2560, height: 1440, bitDepth: 8, bitrate: 10_000_000 }; // A framed page showing a 1920x1080 stage at full size on a 1x screen: a 2048x1218 box. async function stage(facts: Record, codecMap?: Record) { @@ -1193,19 +1195,135 @@ describe("preview quality: lighter copies of heavy files", () => { }); }); - it("changes nothing at full quality", async () => { - const { video, setPreviewQuality, maybeProxyProactively } = await stage({ - "/video.mp4": HEAVY, + it("changes nothing at full quality for a file not oversized for its box, 10-bit included", async () => { + const { video, maybeProxyProactively } = await stage({ + "/mid.mp4": MID, + "/ten-bit.mp4": { ...MID, bitDepth: 10 }, }); - const el = video(); - maybeProxyProactively(el); - setPreviewQuality(1); + const els = [video("/mid.mp4"), video("/ten-bit.mp4")]; + for (const el of els) maybeProxyProactively(el); await settle(); - expect(isProxied(el)).toBe(false); + expect(els.map(isProxied)).toEqual([false, false]); expect(fetch).not.toHaveBeenCalled(); }); + describe("at full quality, a file with at least twice the pixels it shows", () => { + async function waitingForItsCopy(answer: () => Response) { + vi.useFakeTimers(); + let copyAsks = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => { + if (!String(url).includes("hf-proxy")) return new Response("x", { status: 206 }); + copyAsks += 1; + return answer(); + }), + ); + const staged = await stage({ "/video.mp4": HEAVY }); + const proxySrc = await import("./proxySrc"); + const preload = await import("./preloadMedia"); + const el = staged.video(); + staged.maybeProxyProactively(el); + await vi.advanceTimersByTimeAsync(0); + return { ...staged, el, proxySrc, preload, asks: () => copyAsks }; + } + const making = () => new Response(null, { status: 202, headers: { "Retry-After": "2" } }); + + it("never loads the file while its copy is made, and plays a copy at the shown box", async () => { + let made = false; + const { el, proxySrc } = await waitingForItsCopy(() => + made ? new Response("x", { status: 206 }) : making(), + ); + + expect(el.hasAttribute("src")).toBe(false); + expect(proxySrc.unproxiedSrc(el)).toBe("/video.mp4"); + + made = true; + await vi.advanceTimersByTimeAsync(2_000); + + expect(params(el).get("hf-proxy-box")).toBe("2048x1218"); + expect(params(el).get("hf-proxy-light")).toBe("1"); + expect(proxySrc.unproxiedSrc(el)).toBe("/video.mp4"); + }); + + it("is not given its file back as its clip comes due", async () => { + const { el, preload } = await waitingForItsCopy(making); + + preload.giveBackWhenDue(el); + + expect(el.hasAttribute("src")).toBe(false); + }); + + type Prepare = ( + el: HTMLVideoElement, + proxySrc: typeof import("./proxySrc"), + proactively: (el: HTMLMediaElement) => void, + ) => void; + async function copyFailsAfter(prepare: Prepare) { + vi.useFakeTimers(); + vi.stubGlobal( + "fetch", + vi.fn(async (url: string) => + String(url).includes("hf-proxy") + ? new Response(null, { status: 500 }) + : new Response("x", { status: 206 }), + ), + ); + const staged = await stage({ "/video.mp4": HEAVY }); + const proxySrc = await import("./proxySrc"); + const el = staged.video(); + prepare(el, proxySrc, staged.maybeProxyProactively); + await vi.advanceTimersByTimeAsync(60_000); + return { el, proxySrc }; + } + + it("stays held until due when its copy fails, as it was before the copy", async () => { + const { el, proxySrc } = await copyFailsAfter((el, proxySrc, proactively) => { + proactively(el); + proxySrc.recordOpenedAtInPoint(el, "/video.mp4", null, true); + el.removeAttribute("src"); + }); + + expect(el.getAttribute("src")).toBeNull(); + expect(proxySrc.isHeldUntilDue(el)).toBe(true); + }); + + it("keeps its idle hold when its copy fails", async () => { + const { el, proxySrc } = await copyFailsAfter((el, proxySrc, proactively) => { + proxySrc.recordOpenedAtInPoint(el, "/video.mp4", "/video.mp4", true); + proactively(el); + }); + + expect(el.getAttribute("src")).toBe("/video.mp4"); + expect(proxySrc.holdsWhenIdle(el)).toBe(true); + }); + + it("gets its file back when the copy cannot be made", async () => { + const { el, asks } = await waitingForItsCopy(() => new Response(null, { status: 500 })); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(asks()).toBe(4); + expect(el.getAttribute("src")).toBe("/video.mp4"); + }); + + it("waits for the lower level's copy instead, still not loading the file, when the level drops", async () => { + const { el, setPreviewQuality } = await waitingForItsCopy(making); + + setPreviewQuality(0.5); + await vi.advanceTimersByTimeAsync(2_000); + + const boxes = vi + .mocked(fetch) + .mock.calls.map(([url]) => + new URL(String(url), document.baseURI).searchParams.get("hf-proxy-box"), + ); + expect(boxes.at(-1)).toBe("1024x724"); + expect(el.hasAttribute("src")).toBe(false); + }); + }); + it("swaps a heavy file Chrome can play to a copy at the shown box times the level", async () => { const { video, setPreviewQuality } = await stage({ "/video.mp4": HEAVY }); const el = video(); @@ -1229,7 +1347,7 @@ describe("preview quality: lighter copies of heavy files", () => { }); it("puts the original back at full quality", async () => { - const { video, setPreviewQuality } = await stage({ "/video.mp4": HEAVY }); + const { video, setPreviewQuality } = await stage({ "/video.mp4": MID }); const el = video(); setPreviewQuality(0.5); await settle(); @@ -1245,7 +1363,10 @@ describe("preview quality: lighter copies of heavy files", () => { }); it("lets go of a held still when the original has no src to put back", async () => { - const { video, setPreviewQuality, setProxyDisplayScale } = await stage({ "/video.mp4": HEAVY }); + // Heavy below Full by its bit depth alone, so Full puts the original back at any shown size. + const { video, setPreviewQuality, setProxyDisplayScale } = await stage({ + "/video.mp4": { ...LIGHT, bitDepth: 10 }, + }); const el = video(); el.removeAttribute("src"); el.append(Object.assign(document.createElement("source"), { src: "/video.mp4" })); diff --git a/packages/core/src/runtime/mediaProxy.ts b/packages/core/src/runtime/mediaProxy.ts index 6100df4223..3fd05e9322 100644 --- a/packages/core/src/runtime/mediaProxy.ts +++ b/packages/core/src/runtime/mediaProxy.ts @@ -16,8 +16,15 @@ import { lookupCodecMapEntry, sourceFactsOf, } from "./mediaSourceLookup"; -import { heldPosters, swappedElements, unproxiedSrc } from "./proxySrc"; -import { giveBackWhenDue } from "./preloadMedia"; +import { + heldForCopy, + heldPosters, + inPointOf, + recordOpenedAtInPoint, + swappedElements, + unproxiedSrc, +} from "./proxySrc"; +import { giveBackWhenDue, stopMediaDownload } from "./preloadMedia"; import { restartPlaybackHealth } from "./playbackHealth"; import { failedCopyElements, noteCopy, setCopyReportingLevel } from "./previewCopies"; import { waitForServedProxy } from "./proxyWait"; @@ -25,6 +32,7 @@ import { registerSeekCompletion } from "./adapters/seek-dispatch"; import { formatPreviewProxyBox, isHeavyForPreview, + isOversizedForPreview, isPreviewQuality, PREVIEW_QUALITY_LEVELS, PREVIEW_PROXY_BOX_PARAM, @@ -36,6 +44,7 @@ import { type PreviewProxyBox, type PreviewQuality, type PreviewSourceFacts, + wantsLighterCopy, } from "../previewProxyBox"; /** @@ -83,9 +92,8 @@ const unavailableDiagnosedElements = new WeakSet(); // keep that src, so nothing that reads it (timeline, player mirror) loads a copy still being made. const proxyRequested = new WeakMap(); -// Below 1, heavy files get lighter copies at the shown box times this level; 1 is the preview as it always was. +// Below 1, heavy files get lighter copies at the shown box times this level; at 1, files oversized for their box. let previewQuality: PreviewQuality = 1; -const lightElements = new WeakSet(); // Elements whose next ask for a copy tells the server to forget its failure first. const retryNext = new WeakSet(); @@ -156,7 +164,7 @@ function keepSized(el: HTMLMediaElement, scale: number): void { const lowered = previewQuality < 1; const needed = shown && lowered ? scalePreviewProxyBox(shown, previewQuality) : shown; if (lowered && !needed) return; - if (needed && lowered && copy.light && lightCopyNoLongerNeeded(el, needed)) { + if (needed && copy.light && lightCopyNoLongerNeeded(el, needed)) { return restoreOriginal(el); } if (boxCovers(copy.box, needed, lowered)) return dropUnneededCopy(el); @@ -169,7 +177,7 @@ function dropUnneededCopy(el: HTMLMediaElement): void { function lightCopyNoLongerNeeded(el: HTMLMediaElement, needed: PreviewProxyBox): boolean { const facts = sourceFactsOf(el); - return facts !== null && !isHeavyForPreview(facts, needed); + return facts !== null && !wantsLighterCopy(facts, needed, previewQuality); } function boxCovers(current: PreviewProxyBox, needed: PreviewProxyBox | null, exact: boolean) { @@ -215,7 +223,6 @@ function restoreOriginal(el: HTMLMediaElement): void { for (const state of [boxedElements, pendingUpgrades, proxyRequested, swappedElements]) { state.delete(el); } - lightElements.delete(el); evictMediaSyncState(el); restartPlaybackHealth(); if (original === null) { @@ -386,6 +393,40 @@ function emitUnavailableDiagnostic( console.info(`[hyperframes] ${DIAGNOSTIC_UNAVAILABLE_CODE}: "${asset}" (${reason}): ${note}`); } +/** Holds `el` with no src while its copy is made (readers still see its file); false with no src to hold. */ +function holdForCopy(el: HTMLMediaElement): boolean { + const authored = unproxiedSrc(el); + if (authored === null) return false; + heldForCopy.set(el, inPointOf(el)); + recordOpenedAtInPoint(el, authored, null); + stopMediaDownload(el); + holdFrameCaptures( + new Promise((landed) => { + el.addEventListener("loadeddata", landed, { once: true }); + el.addEventListener("error", landed, { once: true }); + }), + ); + return true; +} + +/** Without a copy (`giveBack` set), a clip held until due stays held; any other gets its src back as the runtime's. */ +function endCopyHold(el: HTMLMediaElement, giveBack: string | null): void { + if (!heldForCopy.has(el)) return; + const before = heldForCopy.get(el); + heldForCopy.delete(el); + if (giveBack === null || el.getAttribute("src") !== null) return; + const src = before ? before.written : giveBack; + recordOpenedAtInPoint(el, before?.authored ?? giveBack, src, before?.holdsWhenIdle); + if (src !== null) el.setAttribute("src", src); +} + +function holdFrameCaptures(until: Promise): void { + registerSeekCompletion( + Promise.race([until, new Promise((cap) => wallSetTimeout(cap, HOLD_CAP_MS))]), + "video-frame", + ); +} + type CopyJob = { originalSrc: string; originalAttr: string | null; @@ -401,10 +442,11 @@ async function makeCopy(el: HTMLMediaElement, job: CopyJob, scale: number | null // Asked once the shown size is known, so the copy is made at that size and only once. const shown = scale === null ? null : shownBox(el, scale); const box = shown && level < 1 ? scalePreviewProxyBox(shown, level) : shown; - if (lightFacts && !(box && isHeavyForPreview(lightFacts, box))) { + if (lightFacts && !(box && wantsLighterCopy(lightFacts, box, level))) { if (proxyRequested.get(el) === originalAttr) proxyRequested.delete(el); return; } + const held = lightFacts && box && isOversizedForPreview(lightFacts, box) && holdForCopy(el); const proxiedSrc = appendProxyParam( originalSrc, variant, @@ -420,9 +462,11 @@ async function makeCopy(el: HTMLMediaElement, job: CopyJob, scale: number | null (fraction) => noteCopy(el, "making", fraction, light), () => isActiveAtPlayhead(el), false, - light ? undefined : () => showStill(el, originalSrc, variant, box), + light && !held ? undefined : () => showStill(el, originalSrc, variant, box), ); - if (!live()) { + const wanted = live(); + if (held) endCopyHold(el, served && wanted ? null : originalAttr); + if (!wanted) { dropStill(el); if (proxyRequested.get(el) === originalAttr) proxyRequested.delete(el); if (lightFacts && el.isConnected && el.getAttribute("src") === originalAttr) { @@ -431,13 +475,13 @@ async function makeCopy(el: HTMLMediaElement, job: CopyJob, scale: number | null return; } if (!served) { + dropStill(el); noteCopy(el, "failed", null, light); emitUnavailableDiagnostic(el, "proxy_playback_failed", originalSrc); return; } noteCopy(el, "ready", null, light); - swappedElements.set(el, unproxiedSrc(el)); - if (lightFacts) lightElements.add(el); + swappedElements.set(el, held ? originalAttr : unproxiedSrc(el)); loadProxy(el, proxiedSrc, originalSrc, variant, box, lightFacts !== undefined); // The shown size may have grown while this copy was being made. if (displayScale !== null) keepSized(el, displayScale); @@ -481,16 +525,14 @@ export function swapToProxy( // A lighter copy waits for no level but its own; a copy the file cannot play without finishes at any level. const live = () => el.isConnected && - el.getAttribute("src") === originalAttr && + (el.getAttribute("src") === originalAttr || + (heldForCopy.has(el) && el.getAttribute("src") === null)) && (!lightFacts || previewQuality === level); const job: CopyJob = { originalSrc, originalAttr, variant, lightFacts, level, live }; const swap = shownScale().then((scale) => makeCopy(el, job, scale)); if (lightFacts) return; // A frame capture holds for the copy as it held for a loading video before, up to the same cap. - registerSeekCompletion( - Promise.race([swap, new Promise((cap) => wallSetTimeout(cap, HOLD_CAP_MS))]), - "video-frame", - ); + holdFrameCaptures(swap); const codecName = entry?.codecName ?? null; const details: Record = { asset: originalSrc, @@ -527,7 +569,7 @@ export function maybeProxyProactively(el: HTMLMediaElement): void { const canPlay = entry?.representativeMime ? el.canPlayType(entry.representativeMime) : ""; const playable = canPlay === "probably" || canPlay === "maybe"; if (entry?.browserHostile && !playable) swapToProxy(el, entry, "proactive"); - else if (previewQuality < 1) lightenIfHeavy(el, entry); + else lightenIfHeavy(el, entry); } function lightenIfHeavy(el: HTMLMediaElement, entry: MediaCodecMapEntry | null): void { @@ -538,12 +580,11 @@ function lightenIfHeavy(el: HTMLMediaElement, entry: MediaCodecMapEntry | null): function applyPreviewQuality(el: HTMLMediaElement): void { if (isRenderMode(el) || !isVideoElement(el)) return; - if (previewQuality === 1 && lightElements.has(el)) return restoreOriginal(el); if (boxedElements.has(el) && displayScale !== null) return keepSized(el, displayScale); - if (previewQuality < 1) lightenIfHeavy(el, codecEntryOf(el)); + lightenIfHeavy(el, codecEntryOf(el)); } -/** The host's preview quality: below 1, heavy videos play lighter copies sized to the shown box times the level. */ +/** The host's preview quality: below 1, heavy videos play lighter copies at the shown box times the level. */ export function setPreviewQuality(level: number): void { if (!isPreviewQuality(level) || level === previewQuality) return; previewQuality = level; diff --git a/packages/core/src/runtime/preloadMedia.ts b/packages/core/src/runtime/preloadMedia.ts index 935f0a1371..fb102ef4be 100644 --- a/packages/core/src/runtime/preloadMedia.ts +++ b/packages/core/src/runtime/preloadMedia.ts @@ -5,6 +5,7 @@ import { isHeldUntilDue, recordOpenedAtInPoint, unproxiedSrc, + heldForCopy, } from "./proxySrc"; import { createRuntimeStartTimeResolver } from "./startResolver"; import { readMediaRenderId } from "./renderFrameSibling"; @@ -171,7 +172,7 @@ function holdUntilDue(el: HTMLMediaElement): void { } export function giveBackWhenDue(el: HTMLMediaElement): void { - if (!isHeldUntilDue(el)) return; + if (!isHeldUntilDue(el) || heldForCopy.has(el)) return; const authored = authoredSrcOf(el)!; const start = readMediaStart(el); const written = start > 0 ? `${authored}#t=${start}` : authored; diff --git a/packages/core/src/runtime/proxySrc.ts b/packages/core/src/runtime/proxySrc.ts index eae5759c8e..31e09254e5 100644 --- a/packages/core/src/runtime/proxySrc.ts +++ b/packages/core/src/runtime/proxySrc.ts @@ -3,10 +3,10 @@ export const swappedElements = new WeakMap(); /** Media elements the runtime opened at their in-point: the src the author wrote, the one written in its * place (null while the clip is held with no src until it is due), and whether it is held again when idle. */ -const openedAtInPoint = new WeakMap< - Element, - { authored: string; written: string | null; holdsWhenIdle: boolean } ->(); +type InPointRecord = { authored: string; written: string | null; holdsWhenIdle: boolean }; +const openedAtInPoint = new WeakMap(); +export const inPointOf = (el: Element) => openedAtInPoint.get(el); +export const heldForCopy = new WeakMap(); export function recordOpenedAtInPoint( el: Element, From fe8d93056cbec121367fc6cfd2dbcab0bc7f17ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 09:45:48 +0000 Subject: [PATCH 03/20] chore: release v0.9.45 (#7729) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- docs/changelog.mdx | 14 ++++++++++++++ gemini-extension.json | 2 +- packages/aws-lambda/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/engine/package.json | 2 +- packages/gcp-cloud-run/package.json | 2 +- packages/inspector/package.json | 2 +- packages/lint/package.json | 2 +- packages/parsers/package.json | 2 +- packages/player/package.json | 2 +- packages/producer/package.json | 2 +- packages/sdk/package.json | 2 +- packages/shader-transitions/package.json | 2 +- packages/share/package.json | 2 +- packages/studio-server/package.json | 2 +- packages/studio/package.json | 2 +- plugin.json | 2 +- releases/v0.9.45.md | 13 +++++++++++++ 22 files changed, 47 insertions(+), 20 deletions(-) create mode 100644 releases/v0.9.45.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index ac6e9b2d84..f6598bcb55 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 capture for HyperFrames.", - "version": "0.9.44", + "version": "0.9.45", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index d7721d1be4..2aac445d8b 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -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.9.44", + "version": "0.9.45", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 3513a5b9da..4280266837 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -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.9.44", + "version": "0.9.45", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com" diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 065a3bbbcb..4979306a4c 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,20 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio {/* New release entries are prepended by `bun run changelog:draft --write`. */} + +A preview of 4K takes uses far less memory at full quality. Each 4K clip now plays a copy at the size the preview shows it, so long edits of 4K footage no longer run out of memory. Exports still use the original files. + +## Fixes + +- **Core:** At full quality a 4K clip plays a copy at the size the preview shows it ([2f0ade181](https://github.com/heygen-com/hyperframes/commit/2f0ade181585323ebb2b53a2e15d68098655c90e), [#7723](https://github.com/heygen-com/hyperframes/pull/7723)). + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.9.44...v0.9.45). + + Date: Sat, 14 Nov 2026 10:28:28 +0000 Subject: [PATCH 04/20] fix(engine): an unrecognised capture failure is unknown, not the author's fault (#7730) * fix(engine): an unrecognised capture failure is unknown, not the author's fault * fix(engine): carry the extractor's disk-full words in a name, not a comment --- .../src/services/captureFailure.test.ts | 20 ++++++++++++++++--- .../engine/src/services/captureFailure.ts | 10 ++++++++-- 2 files changed, 25 insertions(+), 5 deletions(-) diff --git a/packages/engine/src/services/captureFailure.test.ts b/packages/engine/src/services/captureFailure.test.ts index 3ffa9ad87d..730b135ffc 100644 --- a/packages/engine/src/services/captureFailure.test.ts +++ b/packages/engine/src/services/captureFailure.test.ts @@ -25,6 +25,17 @@ describe("classifyCaptureFailure", () => { ], // The timed-out variant of the same call stays protocol_timeout (checked first). ["Protocol error (Page.captureScreenshot): waiting for debugger timed out", "protocol_timeout"], + // A full disk and a struggling browser are the host's failure, never the composition's. + ["The disk filled up while video frames were extracted", "io"], + [ + "[Parallel] Capture failed: Worker 0: net::ERR_INSUFFICIENT_RESOURCES at http://localhost:63145/index.html", + "transient_browser", + ], + [ + "[FrameCapture] window.__hf not ready after 45000ms. Page must expose window.__hf = { duration, seek }.\nState: __hf=true, seek=true, player=true, renderReady=true, duration=241.5", + "transient_browser", + ], + ["something no rule names", "unknown"], ] as const)("classifies %s as %s", (message, kind) => { expect(classifyCaptureFailure(new Error(message)).kind).toBe(kind); }); @@ -81,21 +92,24 @@ describe("classifyCaptureFailure", () => { // the message alone must still classify. expect(classifyCaptureFailure(new Error(`[Parallel] write ${code}`)).kind).toBe("io"); } - expect(classifyCaptureFailure(new Error("write to EPIPEline")).kind).toBe("authoring"); + expect(classifyCaptureFailure(new Error("write to EPIPEline")).kind).toBe("unknown"); }); it("classifies repeated operation text in linear time", () => { const repeatedCopy = "copy".repeat(25_000); - expect(classifyCaptureFailure(new Error(repeatedCopy)).kind).toBe("authoring"); + expect(classifyCaptureFailure(new Error(repeatedCopy)).kind).toBe("unknown"); expect(classifyCaptureFailure(new Error(`${repeatedCopy} failed`)).kind).toBe("io"); - expect(classifyCaptureFailure(new Error("copy\nfailed")).kind).toBe("authoring"); + expect(classifyCaptureFailure(new Error("copy\nfailed")).kind).toBe("unknown"); }); it("marks structural failures fatal but leaves retryable failures non-fatal", () => { expect( isFatalCaptureFailure(new CaptureFailure({ kind: "authoring", message: "bad source" })), ).toBe(true); + expect( + isFatalCaptureFailure(new CaptureFailure({ kind: "unknown", message: "unnamed failure" })), + ).toBe(true); expect( isFatalCaptureFailure( new CaptureFailure({ kind: "protocol_timeout", message: "protocol timeout" }), diff --git a/packages/engine/src/services/captureFailure.ts b/packages/engine/src/services/captureFailure.ts index 5c19250b2d..f4309fd219 100644 --- a/packages/engine/src/services/captureFailure.ts +++ b/packages/engine/src/services/captureFailure.ts @@ -5,7 +5,8 @@ export type CaptureFailureKind = | "memory_exhaustion" | "verification" | "authoring" - | "io"; + | "io" + | "unknown"; export interface CaptureWorkerDiagnostic { workerId: number; @@ -56,6 +57,8 @@ const TRANSIENT_BROWSER_ERROR_PATTERNS = [ /net::ERR_NETWORK_CHANGED/i, /Composition has zero duration[\s\S]*Runtime ready: false/, /Composition setup did not finish/, + /window\.__hf not ready after \d+ms/, + /net::ERR_INSUFFICIENT_RESOURCES/, // CDP can refuse a capture call outright with this exact wording; timed-out // variants of the same call hit PROTOCOL_TIMEOUT_PATTERNS, checked first. // Anchored to this literal reason (not just the CDP method) so an unrelated, @@ -137,6 +140,8 @@ function hasIoOperationFailure(message: string): boolean { */ const BROKEN_PIPE_MESSAGE = /\bwrite (?:EPIPE|EOF|ECONNRESET)\b/; +const EXTRACTOR_DISK_FULL_WORDS = /The disk filled up/i; + function ioError(error: unknown, message: string): boolean { const code = error instanceof Error ? (error as NodeJS.ErrnoException).code : undefined; return ( @@ -147,6 +152,7 @@ function ioError(error: unknown, message: string): boolean { ), ) || BROKEN_PIPE_MESSAGE.test(message) || + EXTRACTOR_DISK_FULL_WORDS.test(message) || hasIoOperationFailure(message) ); } @@ -176,7 +182,7 @@ function classifyKind( } const rule = ORDERED_KIND_RULES.find(([, matches]) => matches(message)); if (rule) return rule[0]; - return ioError(error, message) ? "io" : "authoring"; + return ioError(error, message) ? "io" : "unknown"; } export function classifyCaptureFailure( From b5e9bc6893a7a5b4ab306151c362f46b1b24e4e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 12:10:48 +0000 Subject: [PATCH 05/20] fix(studio-server): an edit to a read-only project file is refused and leaves it unchanged (#7731) * fix(studio-server): an edit to a read-only project file is refused and leaves it unchanged * test(core): read the refused file without a stat check first --- packages/core/src/atomicFile.test.ts | 20 ++++++++++++++ packages/core/src/atomicFile.ts | 9 +++++++ .../src/helpers/applyFileMutations.test.ts | 27 +++++++++++++++++++ .../src/history/blobStore.test.ts | 19 +++++++++++++ .../studio-server/src/history/blobStore.ts | 3 ++- .../src/routes/files.heldFile.test.ts | 20 ++++++++++++-- packages/studio-server/src/routes/files.ts | 6 +++++ 7 files changed, 101 insertions(+), 3 deletions(-) diff --git a/packages/core/src/atomicFile.test.ts b/packages/core/src/atomicFile.test.ts index 83a55b2386..fcb02829cb 100644 --- a/packages/core/src/atomicFile.test.ts +++ b/packages/core/src/atomicFile.test.ts @@ -165,6 +165,26 @@ describe("replaceFileAtomically", () => { }); }); +describe("replaceFileAtomically on a read-only file", () => { + // Root writes through read-only files, so the refusal is the owner's, as every system answers it. + it.skipIf(process.getuid?.() === 0)("refuses it and leaves it as it was", () => { + const dir = mkdtempSync(join(tmpdir(), "atomic-file-readonly-test-")); + const file = join(dir, "index.html"); + writeFileSync(file, "kept"); + fs.chmodSync(file, 0o444); + try { + expect(() => replaceFileAtomically(file, "replaced", 0o444)).toThrow( + expect.objectContaining({ code: expect.stringMatching(/^(EACCES|EPERM)$/) }), + ); + expect(readFileSync(file, "utf-8")).toBe("kept"); + expect(fs.readdirSync(dir)).toEqual(["index.html"]); + } finally { + fs.chmodSync(file, 0o644); + rmSync(dir, { recursive: true, force: true }); + } + }); +}); + describe("createFileAtomically", () => { const dirs: string[] = []; diff --git a/packages/core/src/atomicFile.ts b/packages/core/src/atomicFile.ts index 3416d000f8..079d63918e 100644 --- a/packages/core/src/atomicFile.ts +++ b/packages/core/src/atomicFile.ts @@ -48,12 +48,21 @@ export function replaceFileAtomically( operations: SiblingFileSystem & Pick = fs, ): void { traceWrite(filePath, content); + refuseReadOnly(filePath); // Node fs.rename uses libuv uv_fs_rename; win32 calls MoveFileExW with MOVEFILE_REPLACE_EXISTING. publishSibling(filePath, content, mode, operations, (tempPath) => renameWithRetry(tempPath, filePath, { rename: operations.renameSync }), ); } +export function refuseReadOnly(filePath: string): void { + try { + fs.accessSync(filePath, fs.constants.W_OK); + } catch (error) { + if (errorCode(error) !== "ENOENT") throw error; + } +} + /** Create a file that must not exist yet (EEXIST otherwise); readers never see it empty or partial. */ export function createFileAtomically( filePath: string, diff --git a/packages/studio-server/src/helpers/applyFileMutations.test.ts b/packages/studio-server/src/helpers/applyFileMutations.test.ts index 8808bd7209..048efa9005 100644 --- a/packages/studio-server/src/helpers/applyFileMutations.test.ts +++ b/packages/studio-server/src/helpers/applyFileMutations.test.ts @@ -180,6 +180,33 @@ describe("applyFileMutations", () => { } }); + it.skipIf(process.getuid?.() === 0)( + "changes nothing when one of the files is read-only, the others included", + () => { + resetFileWriteReceipts(); + const projectDir = mkdtempSync(join(tmpdir(), "hf-mutation-readonly-file-")); + const first = join(projectDir, "first.html"); + const locked = join(projectDir, "index.html"); + try { + writeFileSync(first, "first-before", "utf8"); + writeFileSync(locked, "locked-before", "utf8"); + chmodSync(locked, 0o444); + expect(() => + applyFileMutations(projectDir, [ + { sourceFile: "first.html", absPath: first, after: "first-after" }, + { sourceFile: "index.html", absPath: locked, after: "locked-after" }, + ]), + ).toThrow(expect.objectContaining({ code: expect.stringMatching(/^(EACCES|EPERM)$/) })); + expect(readFileSync(first, "utf8")).toBe("first-before"); + expect(readFileSync(locked, "utf8")).toBe("locked-before"); + } finally { + chmodSync(locked, 0o644); + resetFileWriteReceipts(); + rmSync(projectDir, { recursive: true, force: true }); + } + }, + ); + it.skipIf(process.platform === "win32" || process.getuid?.() === 0)( "reports the write error, not a failed rollback, when a later file was never written", () => { diff --git a/packages/studio-server/src/history/blobStore.test.ts b/packages/studio-server/src/history/blobStore.test.ts index 4a900ebaeb..a9f421fca5 100644 --- a/packages/studio-server/src/history/blobStore.test.ts +++ b/packages/studio-server/src/history/blobStore.test.ts @@ -99,6 +99,25 @@ describe("the history store on Windows, while another process briefly holds a fi expect((await store.read(hash)).toString("utf8")).toBe("

before

"); }); + it.skipIf(process.getuid?.() === 0)("leaves a read-only project file as it is", async () => { + const source = join(root, "source.html"); + writeFileSync(source, "

before

"); + const store = await openBlobStore(join(root, "history", "blobs")); + const hash = await store.put(source); + const target = join(root, "index.html"); + writeFileSync(target, "

after

"); + fs.chmodSync(target, 0o444); + try { + await expect(store.writeTo(hash, target)).rejects.toMatchObject({ + code: expect.stringMatching(/^(EACCES|EPERM)$/), + }); + expect(readFileSync(target, "utf8")).toBe("

after

"); + expect(readdirSync(root).sort()).toEqual(["history", "index.html", "source.html"]); + } finally { + fs.chmodSync(target, 0o644); + } + }); + it("writes a file back into the project once the hold clears", async () => { const source = join(root, "source.html"); writeFileSync(source, "

before

"); diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index eeb445e0f4..5ad4ef113d 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -13,7 +13,7 @@ import { } from "node:fs/promises"; import { dirname, join } from "node:path"; import { pipeline } from "node:stream/promises"; -import { atomicTempPath } from "@hyperframes/core/atomic-file"; +import { atomicTempPath, refuseReadOnly } from "@hyperframes/core/atomic-file"; import { isWindowsHold, renameWithRetry, @@ -105,6 +105,7 @@ async function cloneOrCopy( hash: string, beforeReplace?: () => void, ): Promise { + refuseReadOnly(to); await mkdir(dirname(to), { recursive: true }); const temp = atomicTempPath(to); try { diff --git a/packages/studio-server/src/routes/files.heldFile.test.ts b/packages/studio-server/src/routes/files.heldFile.test.ts index 80e8a773b6..62dc7756a9 100644 --- a/packages/studio-server/src/routes/files.heldFile.test.ts +++ b/packages/studio-server/src/routes/files.heldFile.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { Hono } from "hono"; import { type ChildProcess, spawn, spawnSync } from "node:child_process"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { registerFileRoutes } from "./files"; @@ -36,7 +36,13 @@ function fixture(name: string) { headers: { "If-Match": '"any"' }, body: "
edited
", }); - return { file, get, put }; + const remove = (id: string) => + app.request(`http://localhost/projects/demo/file-mutations/remove-elements/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ targets: [{ id }] }), + }); + return { file, get, put, remove }; } const answered = async (response: Response) => ({ @@ -55,6 +61,16 @@ describe.skipIf(process.platform === "win32" || process.getuid?.() === 0)( expect(await answered(await put())).toEqual({ status: 403, why: "permission" }); }); + // fails without the fix: the edit replaced the read-only file (a rename ignores its mode) and answered 200. + it("leaves a read-only file as it is when an element is removed, and answers 403", async () => { + const { file, remove } = fixture("readonly.html"); + writeFileSync(file, '
kept

tag

'); + chmodSync(file, 0o444); + cleanups.push(() => chmodSync(file, 0o644)); + expect(await answered(await remove("gone"))).toEqual({ status: 403, why: "permission" }); + expect(readFileSync(file, "utf8")).toBe('
kept

tag

'); + }); + it("does not answer an optional read of it as a blank file", async () => { const { file, get } = fixture("locked.html"); chmodSync(file, 0o000); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index ff0e6298aa..c7abf56f49 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -2807,6 +2807,12 @@ function heldFileAnswer(c: RouteContext, error: unknown) { } export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { + api.use("/projects/:id/*", async (c, next) => { + await next(); + const refusedWrite = c.error ? heldFileAnswer(c, c.error) : null; + if (refusedWrite) c.res = refusedWrite; + }); + // ── Read ── api.get("/projects/:id/files/*", async (c) => { From 43132411c86b23a55134afaf261d77f3785168cc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 13:40:20 +0000 Subject: [PATCH 06/20] chore: release v0.9.46 (#7732) --- .claude-plugin/plugin.json | 2 +- .codex-plugin/plugin.json | 2 +- .cursor-plugin/plugin.json | 2 +- docs/changelog.mdx | 15 +++++++++++++++ gemini-extension.json | 2 +- packages/aws-lambda/package.json | 2 +- packages/cli/package.json | 2 +- packages/core/package.json | 2 +- packages/engine/package.json | 2 +- packages/gcp-cloud-run/package.json | 2 +- packages/inspector/package.json | 2 +- packages/lint/package.json | 2 +- packages/parsers/package.json | 2 +- packages/player/package.json | 2 +- packages/producer/package.json | 2 +- packages/sdk/package.json | 2 +- packages/shader-transitions/package.json | 2 +- packages/share/package.json | 2 +- packages/studio-server/package.json | 2 +- packages/studio/package.json | 2 +- plugin.json | 2 +- releases/v0.9.46.md | 14 ++++++++++++++ 22 files changed, 49 insertions(+), 20 deletions(-) create mode 100644 releases/v0.9.46.md diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index f6598bcb55..d6ff6d3878 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 capture for HyperFrames.", - "version": "0.9.45", + "version": "0.9.46", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 2aac445d8b..0c9745b415 100644 --- a/.codex-plugin/plugin.json +++ b/.codex-plugin/plugin.json @@ -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.9.45", + "version": "0.9.46", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index 4280266837..b6cfd3b2f4 100644 --- a/.cursor-plugin/plugin.json +++ b/.cursor-plugin/plugin.json @@ -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.9.45", + "version": "0.9.46", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com" diff --git a/docs/changelog.mdx b/docs/changelog.mdx index 4979306a4c..5e55e6db65 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,21 @@ Recent HyperFrames releases, including user-facing features, fixes, and migratio {/* New release entries are prepended by `bun run changelog:draft --write`. */} + +Editing a project file that is read-only is now refused, and the file stays as it was. A capture failure the engine does not recognise is now reported as unknown, not blamed on the project. + +## Fixes + +- **Studio Server:** An edit to a read-only project file is refused and leaves it unchanged ([b5e9bc689](https://github.com/heygen-com/hyperframes/commit/b5e9bc6893a7a5b4ab306151c362f46b1b24e4e1), [#7731](https://github.com/heygen-com/hyperframes/pull/7731)). +- **Engine:** An unrecognised capture failure is unknown, not the author's fault ([ff9c3b761](https://github.com/heygen-com/hyperframes/commit/ff9c3b7619c6c46c3145b287914cd09b377174f9), [#7730](https://github.com/heygen-com/hyperframes/pull/7730)). + +[View the full commit range](https://github.com/heygen-com/hyperframes/compare/v0.9.45...v0.9.46). + + Date: Sat, 14 Nov 2026 13:45:08 +0000 Subject: [PATCH 07/20] fix(cli): check times a line held across a split clip as one hold (#7733) * fix(cli): check times a line held across a split clip as one hold A clip split in two hands its line to a second element at the same place. line_unread timed each element alone, so the first half warned while the same text stayed on screen. * refactor(cli): name the split-clip line hand-off instead of commenting it --- packages/cli/src/utils/craftAudit.test.ts | 23 ++++++++- packages/cli/src/utils/craftAudit.ts | 59 +++++++++++++++++------ 2 files changed, 66 insertions(+), 16 deletions(-) diff --git a/packages/cli/src/utils/craftAudit.test.ts b/packages/cli/src/utils/craftAudit.test.ts index 4d298a4fce..c913686fcf 100644 --- a/packages/cli/src/utils/craftAudit.test.ts +++ b/packages/cli/src/utils/craftAudit.test.ts @@ -26,6 +26,8 @@ interface Shown { block?: number; /** The block's rendered text; defaults to `text`. */ blockText?: string; + /** Top edge in px; defaults to a row per key. */ + top?: number; } function part(shown: Shown): CraftTextPart { @@ -40,7 +42,7 @@ function part(shown: Shown): CraftTextPart { blockText: shown.blockText ?? shown.text, opacity: 1, fontPx, - rect: { left: 100, top: 100 + shown.key * 10, width: 800, height: fontPx * 1.2 }, + rect: { left: 100, top: shown.top ?? 100 + shown.key * 10, width: 800, height: fontPx * 1.2 }, captions: shown.captions ?? false, }; } @@ -218,6 +220,25 @@ describe("line_unread", () => { expect(await detectLineUnread(film(12, shown), 12, CANVAS, probeFor(shown))).toEqual([]); }); + // fails without the fix: each instance of a split clip was timed alone, so its 0.5 s first half warned. + it("times a line held across a split clip's two instances as one hold", async () => { + const line = "Your reminder card holds here"; + const shown: Shown[] = [ + { key: 1, text: line, from: 3, to: 3.5, top: 800 }, + { key: 2, text: line, from: 3.5, to: 8, top: 800 }, + ]; + expect(await detectLineUnread(film(8, shown), 8, CANVAS, probeFor(shown))).toEqual([]); + }); + + it("still times a different line that takes over the same place on its own", async () => { + const shown: Shown[] = [ + { key: 1, text: "Every team gets one shared calendar now", from: 3, to: 3.5, top: 800 }, + { key: 2, text: "Start your free trial today", from: 3.5, to: 8, top: 800 }, + ]; + const findings = await detectLineUnread(film(8, shown), 8, CANVAS, probeFor(shown)); + expect(findings.map((finding) => finding.selector)).toEqual(["#t1"]); + }); + it("skips text too small to be a reading line", async () => { const shown: Shown[] = [ { key: 1, text: "Legal fine print that flashes by", from: 1, to: 1.3, fontPx: 18 }, diff --git a/packages/cli/src/utils/craftAudit.ts b/packages/cli/src/utils/craftAudit.ts index 24dddbf55f..9f335cf5a7 100644 --- a/packages/cli/src/utils/craftAudit.ts +++ b/packages/cli/src/utils/craftAudit.ts @@ -211,7 +211,7 @@ interface CraftRect { } interface BlockTrack { - key: number; + blockKeys: Set; selector: string; sourceFile: string; /** Frame index → what the block showed there. */ @@ -258,20 +258,47 @@ interface Anchor { } function trackBlocks(frames: CraftFrame[], readHeight: number): BlockTrack[] { - const tracks = new Map(); + const tracks: BlockTrack[] = []; + const byKey = new Map(); frames.forEach((frame, index) => { for (const [key, block] of readableBlocks(frame, readHeight)) { - const track = tracks.get(key) ?? { - key, - selector: block.selector, - sourceFile: block.sourceFile, - seen: new Map(), - }; + let track = byKey.get(key) ?? trackContinuedBy(tracks, index, block); + if (!track) { + track = { + blockKeys: new Set(), + selector: block.selector, + sourceFile: block.sourceFile, + seen: new Map(), + }; + tracks.push(track); + } + track.blockKeys.add(key); + byKey.set(key, track); track.seen.set(index, { text: block.text, rect: block.rect }); - tracks.set(key, track); } }); - return [...tracks.values()]; + return tracks; +} + +function trackContinuedBy( + tracks: BlockTrack[], + index: number, + block: BlockFrame, +): BlockTrack | undefined { + return tracks.find((track) => { + const last = track.seen.get(index) ?? track.seen.get(index - 1); + return !!last && last.text === block.text && sameBox(last.rect, block.rect); + }); +} + +const SAME_BOX_OVERLAP = 0.9; + +function sameBox(a: CraftRect, b: CraftRect): boolean { + const width = Math.min(a.right, b.right) - Math.max(a.left, b.left); + const height = Math.min(a.bottom, b.bottom) - Math.max(a.top, b.top); + if (width <= 0 || height <= 0) return false; + const area = (rect: CraftRect) => (rect.right - rect.left) * (rect.bottom - rect.top); + return (width * height) / Math.max(area(a), area(b)) >= SAME_BOX_OVERLAP; } /** Maximal runs of consecutive frame indices. */ @@ -374,8 +401,10 @@ async function refineWindow( canvas: Canvas, ): Promise { const readHeight = T.readHeightAt1080 * (Math.min(canvas.width, canvas.height) / 1080); - const visibleAt = async (time: number) => - readableBlocks(await probe(time), readHeight).has(candidate.track.key); + const visibleAt = async (time: number) => { + const blocks = readableBlocks(await probe(time), readHeight); + return [...candidate.track.blockKeys].some((key) => blocks.has(key)); + }; const window = { ...candidate.window }; for (let step = 0; step < T.refineSteps; step += 1) { if (window.startIn > window.startOut) { @@ -445,7 +474,7 @@ function lineUnreadFinding( words, seconds: round(held), floorSeconds: round(floor), - measured: measuredAround(frames, track.key, candidate.firstIndex, candidate.lastIndex), + measured: measuredAround(frames, track.blockKeys, candidate.firstIndex, candidate.lastIndex), }; } @@ -454,7 +483,7 @@ const MEASURED_SPAN = 3; /** The samples at both ends of the line's run, as the sampler measured them. */ function measuredAround( frames: CraftFrame[], - key: number, + keys: Set, first: number, last: number, ): NonNullable { @@ -466,7 +495,7 @@ function measuredAround( for (const index of [...indices].sort((a, b) => a - b)) { const frame = frames[index]; if (!frame) continue; - const parts = frame.parts.filter((part) => part.block === key && !part.captions); + const parts = frame.parts.filter((part) => keys.has(part.block) && !part.captions); if (parts.length === 0) { out.push({ time: round(frame.time), opacity: null, heightPx: null }); continue; From 32f562bbd00de00b0aa68acd5621fd00a7b4e0de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 14:02:28 +0000 Subject: [PATCH 08/20] feat(core): a folder with index.html is one a step can work on, apart from finding projects (#7734) * feat(core): a folder with index.html is one a step can work on, apart from finding projects isHyperframesProject needs index.html and a marker file, which is right for finding projects on a computer (a website's folder must not be listed) but not for what a step works on. isCompositionFolder says that: any folder whose index.html is the composition. * build(core): raise core's unpacked size budget for the step folder rule --- packages/core/src/index.ts | 2 +- packages/core/src/projectRule.test.ts | 15 ++++++++++++++- packages/core/src/projectRule.ts | 4 ++++ packages/core/src/projects.ts | 4 ++-- scripts/package-size-budgets.json | 2 +- 5 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index de1eba65de..692a937600 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -355,7 +355,7 @@ export { realProjectRoot, resolveWithinProject, } from "./safePath"; -export { isHyperframesProject, PROJECT_MARKER_FILES } from "./projectRule"; +export { isCompositionFolder, isHyperframesProject, PROJECT_MARKER_FILES } from "./projectRule"; export type { HyperframePickerApi, HyperframePickerBoundingBox, diff --git a/packages/core/src/projectRule.test.ts b/packages/core/src/projectRule.test.ts index 9179af04d9..950b385041 100644 --- a/packages/core/src/projectRule.test.ts +++ b/packages/core/src/projectRule.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { isHyperframesProject } from "./projectRule"; +import { isCompositionFolder, isHyperframesProject } from "./projectRule"; describe("isHyperframesProject", () => { it.each(["hyperframes.json", "meta.json", "project.json"])( @@ -14,3 +14,16 @@ describe("isHyperframesProject", () => { expect(isHyperframesProject(["hyperframes.json", "README.md"])).toBe(false); }); }); + +// fails without the fix: a step had only the discovery rule, so a folder holding a composition but no marker file was +// refused by `open` though `snapshot` and `check` worked on it. +describe("isCompositionFolder", () => { + it("counts any folder with index.html, marker or not", () => { + expect(isCompositionFolder(["index.html"])).toBe(true); + expect(isCompositionFolder(["index.html", "hyperframes.json"])).toBe(true); + }); + + it("does not count a folder without index.html", () => { + expect(isCompositionFolder(["hyperframes.json", "assets"])).toBe(false); + }); +}); diff --git a/packages/core/src/projectRule.ts b/packages/core/src/projectRule.ts index 732d37c4d2..c36c0e88d8 100644 --- a/packages/core/src/projectRule.ts +++ b/packages/core/src/projectRule.ts @@ -5,3 +5,7 @@ export function isHyperframesProject(fileNames: Iterable): boolean { const names = new Set(fileNames); return names.has("index.html") && PROJECT_MARKER_FILES.some((name) => names.has(name)); } + +export function isCompositionFolder(fileNames: Iterable): boolean { + return new Set(fileNames).has("index.html"); +} diff --git a/packages/core/src/projects.ts b/packages/core/src/projects.ts index 636f67540d..282e78202b 100644 --- a/packages/core/src/projects.ts +++ b/packages/core/src/projects.ts @@ -3,9 +3,9 @@ import type { Dirent } from "node:fs"; import { readFile, readdir, realpath, stat } from "node:fs/promises"; import { homedir } from "node:os"; import { basename, dirname, isAbsolute, join, relative, sep } from "node:path"; -import { isHyperframesProject, PROJECT_MARKER_FILES } from "./projectRule"; +import { isCompositionFolder, isHyperframesProject, PROJECT_MARKER_FILES } from "./projectRule"; -export { isHyperframesProject, PROJECT_MARKER_FILES }; +export { isCompositionFolder, isHyperframesProject, PROJECT_MARKER_FILES }; export interface FoundProject { path: string; diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 1523d7e3d6..5a9dd37391 100644 --- a/scripts/package-size-budgets.json +++ b/scripts/package-size-budgets.json @@ -9,7 +9,7 @@ }, "@hyperframes/core": { "packed": 1762752, - "unpacked": 4330000 + "unpacked": 4374000 }, "@hyperframes/engine": { "packed": 490452, From 35d145157b277ff9b6b2178736e6b03406678de9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 14:33:10 +0000 Subject: [PATCH 09/20] fix(cli): check no longer reports GPU stalls from its own pixel reads as the film's warnings (#7736) check copies each canvas into a 2D context to see whether it moved. On WebGL that readback makes Chrome log a GL driver Performance note, which check then listed as a console warning from the composition. --- packages/cli/src/utils/checkBrowser.test.ts | 32 +++++++++++++++++++++ packages/cli/src/utils/checkBrowser.ts | 3 ++ 2 files changed, 35 insertions(+) diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index 670f91c793..f4d9665ff7 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -1078,6 +1078,38 @@ it("elevates and deduplicates WebGPU validation warnings while preserving ordina ); }); +// fails without the fix: check's own canvas reads made Chrome log GPU stalls, reported as the film's warnings. +it("does not report Chrome's GPU stall notes for check's own pixel reads as the film's warnings", async () => { + vi.spyOn(Date, "now").mockReturnValue(100); + mountCanvasFixture(); + const page = fakePage(); + const stall = fakeConsoleMessage( + "warn", + "[.WebGL-0x2c84001d4800]GL Driver Message (OpenGL, Performance, GL_CLOSE_PATH_NV, High): GPU stall due to ReadPixels", + ); + const ordinaryWarning = fakeConsoleMessage("warn", "Optional caption font was not loaded"); + page.on = vi.fn( + (event: string, handler: (message: ReturnType) => void) => { + if (event === "console") { + handler(stall); + handler(ordinaryWarning); + } + }, + ); + installSessionMock(page); + + const result = await runBrowserCheck( + PROJECT, + { ...DEFAULT_CHECK_OPTIONS, samples: 1, contrast: false }, + { kind: "none" }, + runAuditGrid, + ); + + expect( + result.runtimeFindings.filter(({ code }) => code === "console_warning").map((f) => f.message), + ).toEqual([ordinaryWarning.text()]); +}); + describe("collectSeekClock", () => { afterEach(() => { Reflect.deleteProperty(window, "__timelines"); diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index c20fd906af..8a0ce38dff 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -869,6 +869,8 @@ function runtimeInfoFindingCode(text: string): string | null { : "media_proxy_fallback"; } +const GL_DRIVER_PERFORMANCE_NOTE = /^\[\.WebGL-[^\]]*\]GL Driver Message \([^,]*, Performance,/; + function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], clock: RuntimeClock): void { followSampleTimeMarkers(page, clock.at); page.on("console", (message) => { @@ -885,6 +887,7 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], clock: Runtime line: location.lineNumber, }); } else if (type === "warn") { + if (GL_DRIVER_PERFORMANCE_NOTE.test(text)) return; const location = message.location(); const webGpuFailure = isWebGpuRuntimeFailure(text); pushRuntimeDraft(drafts, { From e21e64f6ab51f9024f9c0fe0d1acb39553887c99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 14:39:00 +0000 Subject: [PATCH 10/20] fix(registry): caption-highlight clears a short word's highlight when the word ends (#7735) * fix(registry): caption-highlight clears a short word's highlight when the word ends On a word shorter than 0.05 s the 0.15 s fade-in outlived the fade-out, so a frame inside that tail lit the highlight again until the phrase ended. * fix(registry): leave the generated caption-highlight catalog files to the generator --- .../captionLastGroup.chromium.test.ts | 39 +++++++++++++++++++ .../caption-highlight/caption-highlight.html | 3 +- .../components/caption-highlight/demo.html | 3 +- 3 files changed, 43 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/registry/captionLastGroup.chromium.test.ts b/packages/cli/src/registry/captionLastGroup.chromium.test.ts index a602b862af..c84aa36d6c 100644 --- a/packages/cli/src/registry/captionLastGroup.chromium.test.ts +++ b/packages/cli/src/registry/captionLastGroup.chromium.test.ts @@ -69,3 +69,42 @@ describe.runIf(executablePath)("caption-highlight last group in Chromium", () => expect(await lastGroupVisibleAt(LAST_WORD_END_S - 0.05)).toBe(true); }); }); + +describe.runIf(executablePath)("caption-highlight short word in Chromium", () => { + let browser: Browser; + + beforeAll(async () => { + browser = await puppeteer.launch({ executablePath, args: ["--no-sandbox"] }); + }); + + afterAll(async () => { + await browser?.close(); + }); + + function componentWithShortWord(): string { + const demoWord = '{ text: "great", start: 0.3, end: 0.55 }'; + if (!componentHtml.includes(demoWord)) throw new Error("the demo's second word changed"); + return componentHtml + .replace(demoWord, '{ text: "great", start: 0.3, end: 0.34 }') + .replace(/`); + } + + // fails without the fix: a frame inside the 0.01 s tail relit the pill until the phrase ended. + it("clears a 0.04 s word's highlight once the word is over", async () => { + const page = await browser.newPage(); + try { + await page.setContent(componentWithShortWord()); + const opacity = await page.evaluate(() => { + const timeline = ( + window as unknown as { __timelines: Record } + ).__timelines["caption-highlight"]; + for (const time of [0, 0.3, 0.333, 0.367, 0.4, 0.433, 0.445, 0.467, 0.5]) + timeline!.seek(time); + return Number(getComputedStyle(document.getElementById("hl-bg-1")!).opacity); + }); + expect(opacity).toBe(0); + } finally { + await page.close(); + } + }); +}); diff --git a/registry/components/caption-highlight/caption-highlight.html b/registry/components/caption-highlight/caption-highlight.html index 615084e01f..ffaba88c04 100644 --- a/registry/components/caption-highlight/caption-highlight.html +++ b/registry/components/caption-highlight/caption-highlight.html @@ -235,7 +235,8 @@ var bgEl = document.getElementById("hl-bg-" + wi); var wordEl = document.getElementById("hl-w-" + wi); - tl.to(bgEl, { opacity: 1, scaleX: 1, duration: 0.15, ease: "power2.out" }, w.start); + var fadeIn = Math.min(0.15, w.end - w.start); + tl.to(bgEl, { opacity: 1, scaleX: 1, duration: fadeIn, ease: "power2.out" }, w.start); tl.to( wordEl, { filter: "brightness(1.05)", duration: 0.08, ease: "power2.out" }, diff --git a/registry/components/caption-highlight/demo.html b/registry/components/caption-highlight/demo.html index 2236c8b89e..88b1da108e 100644 --- a/registry/components/caption-highlight/demo.html +++ b/registry/components/caption-highlight/demo.html @@ -232,7 +232,8 @@ var bgEl = document.getElementById("hl-bg-" + wi); var wordEl = document.getElementById("hl-w-" + wi); - tl.to(bgEl, { opacity: 1, scaleX: 1, duration: 0.15, ease: "power2.out" }, w.start); + var fadeIn = Math.min(0.15, w.end - w.start); + tl.to(bgEl, { opacity: 1, scaleX: 1, duration: fadeIn, ease: "power2.out" }, w.start); tl.to( wordEl, { filter: "brightness(1.05)", duration: 0.08, ease: "power2.out" }, From 88beb282f26cd9c8eaa6381e477336e1a3971d65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 14:46:32 +0000 Subject: [PATCH 11/20] fix(cli): an unknown flag lists the command's real flags (#7740) The error only said to run --help, so an agent guessed another flag. It now names every long flag the command declares, and --search points to --query. --- docs/packages/cli.mdx | 4 +-- .../src/utils/reject-unknown-flags.test.ts | 33 +++++++++++++++++-- .../cli/src/utils/reject-unknown-flags.ts | 32 ++++++++++++++---- 3 files changed, 59 insertions(+), 10 deletions(-) diff --git a/docs/packages/cli.mdx b/docs/packages/cli.mdx index 092ea058ce..9e2a87ad34 100644 --- a/docs/packages/cli.mdx +++ b/docs/packages/cli.mdx @@ -104,8 +104,8 @@ machine-readable `code` and, when there is a next step, a `hint`: "ok": false, "error": { "code": "unknown_flag", - "message": "Unknown flag: --out", - "hint": "Did you mean --output? Run 'hyperframes render --help' to see its flags." + "message": "Unknown flag: --search", + "hint": "Did you mean --query? Its flags: --type, --tag, --json, --human-friendly, --query, --yes, --on-device. Run 'hyperframes catalog --help' for what each does." } } ``` diff --git a/packages/cli/src/utils/reject-unknown-flags.test.ts b/packages/cli/src/utils/reject-unknown-flags.test.ts index aed4ecac85..7900c11f3b 100644 --- a/packages/cli/src/utils/reject-unknown-flags.test.ts +++ b/packages/cli/src/utils/reject-unknown-flags.test.ts @@ -73,7 +73,9 @@ describe("assertKnownFlags", () => { expect(() => assertKnownFlags(cmd, ["--out", "x"], "render")).toThrow( expect.objectContaining({ code: "unknown_flag", - hint: "Did you mean --output? Run 'hyperframes render --help' to see its flags.", + hint: + "Did you mean --output? Its flags: --output, --gif-loop, --docker, --workers, --json. " + + "Run 'hyperframes render --help' for what each does.", }), ); }); @@ -83,7 +85,8 @@ describe("assertKnownFlags", () => { args: { start: { type: "string" }, end: { type: "string" }, out: { type: "string" } }, } as unknown as CommandDef; const hint = (flag: string) => - `Did you mean --${flag}? Run 'hyperframes measure cuts --help' to see its flags.`; + `Did you mean --${flag}? Its flags: --start, --end, --out. ` + + "Run 'hyperframes measure cuts --help' for what each does."; expect(() => assertKnownFlags(cuts, ["--from", "1"], "measure cuts")).toThrow( expect.objectContaining({ hint: hint("start") }), ); @@ -92,6 +95,32 @@ describe("assertKnownFlags", () => { ); }); + // fails without the fix: the hint only said to run --help, so an agent guessed another flag. + it("lists the command's own flags, not its positionals, when nothing close matches", () => { + const snapshot = { + args: { + dir: { type: "positional" }, + at: { type: "string" }, + frames: { type: "string" }, + outDir: { type: "string" }, + }, + } as unknown as CommandDef; + expect(() => assertKnownFlags(snapshot, [".", "--width", "360"], "snapshot")).toThrow( + expect.objectContaining({ + hint: "Its flags: --at, --frames, --out-dir. Run 'hyperframes snapshot --help' for what each does.", + }), + ); + }); + + it("points --search at the query flag", () => { + const catalog = { + args: { words: { type: "positional" }, query: { type: "string" }, json: { type: "boolean" } }, + } as unknown as CommandDef; + expect(() => assertKnownFlags(catalog, ["--search", "light leak"], "catalog")).toThrow( + expect.objectContaining({ hint: expect.stringMatching(/^Did you mean --query\? /) }), + ); + }); + it("checks each char of a combined short group", () => { expect(ok(["-ow"])).not.toThrow(); // both known aliases expect(ok(["-ox"])).toThrow(/Unknown flag: -x/); // x unknown diff --git a/packages/cli/src/utils/reject-unknown-flags.ts b/packages/cli/src/utils/reject-unknown-flags.ts index 0080320f5c..0fee2b7012 100644 --- a/packages/cli/src/utils/reject-unknown-flags.ts +++ b/packages/cli/src/utils/reject-unknown-flags.ts @@ -14,10 +14,19 @@ const ALWAYS_KNOWN = new Set(["help", "h", "version", "v"]); // A camelCase arg name (`gifLoop`) is passed as `--gif-loop`; a kebab name is // passed as-is. Accept both spellings so the validator matches citty's parsing. +function kebabCase(name: string): string { + return name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); +} + function nameVariants(name: string): string[] { - const kebab = name.replace(/[A-Z]/g, (c) => "-" + c.toLowerCase()); const camel = name.replace(/-([a-z])/g, (_, c: string) => c.toUpperCase()); - return [name, kebab, camel]; + return [name, kebabCase(name), camel]; +} + +function declaredLongFlags(args: ArgsDef | undefined): string[] { + return Object.entries(args ?? {}) + .filter(([, def]) => def?.type !== "positional") + .map(([name]) => `--${kebabCase(name)}`); } // Every spelling of one declared arg: name variants plus aliases. `def` may be undefined. @@ -74,9 +83,15 @@ const FLAG_SYNONYMS: Readonly> = { start: ["from"], end: ["to"], o: ["out", "output"], + search: ["query"], }; -function unknownFlagError(flag: string, known: Set, path: string): CliUsageError { +function unknownFlagError( + flag: string, + known: Set, + declared: string[], + path: string, +): CliUsageError { const command = `hyperframes ${path ? `${path} ` : ""}`.trim(); if (flag === "--json") { return new CliUsageError(`${command} has no JSON output.`, { @@ -89,8 +104,13 @@ function unknownFlagError(flag: string, known: Set, path: string): CliUs const match = FLAG_SYNONYMS[name]?.find((s) => known.has(s)) ?? (flag.startsWith("--") ? closestMatch(name, longNames) : undefined); - const help = `Run 'hyperframes ${path ? `${path} ` : ""}--help' to see its flags.`; - const hint = match ? `Did you mean --${match}? ${help}` : help; + const hint = [ + match && `Did you mean --${match}?`, + declared.length > 0 && `Its flags: ${declared.join(", ")}.`, + `Run 'hyperframes ${path ? `${path} ` : ""}--help' for what each does.`, + ] + .filter(Boolean) + .join(" "); return new CliUsageError(`Unknown flag: ${flag}`, { hint, code: "unknown_flag" }); } @@ -110,7 +130,7 @@ export function assertKnownFlags(cmd: CommandDef, rawArgs: string[], pa for (const tok of rawArgs) { if (tok === "--") break; const bad = unknownFlagIn(tok, known); - if (bad) throw unknownFlagError(bad, known, path); + if (bad) throw unknownFlagError(bad, known, declaredLongFlags(args), path); } } From d26ec25385630f2427079c65bb300f0e12f26661 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 15:28:36 +0000 Subject: [PATCH 12/20] fix(cli): check still reports a film's other GL driver notes (#7745) The filter for check's own readback stalls matched every GL driver Performance note. It now matches only the ReadPixels stall. --- packages/cli/src/utils/checkBrowser.test.ts | 11 ++++++++--- packages/cli/src/utils/checkBrowser.ts | 5 +++-- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index f4d9665ff7..283213d3de 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -1078,8 +1078,8 @@ it("elevates and deduplicates WebGPU validation warnings while preserving ordina ); }); -// fails without the fix: check's own canvas reads made Chrome log GPU stalls, reported as the film's warnings. -it("does not report Chrome's GPU stall notes for check's own pixel reads as the film's warnings", async () => { +// fails without the narrowing: every GL driver Performance note was hidden, not only the ReadPixels stall. +it("hides only the ReadPixels stall check's own reads cause, not the film's other GL driver notes", async () => { vi.spyOn(Date, "now").mockReturnValue(100); mountCanvasFixture(); const page = fakePage(); @@ -1088,10 +1088,15 @@ it("does not report Chrome's GPU stall notes for check's own pixel reads as the "[.WebGL-0x2c84001d4800]GL Driver Message (OpenGL, Performance, GL_CLOSE_PATH_NV, High): GPU stall due to ReadPixels", ); const ordinaryWarning = fakeConsoleMessage("warn", "Optional caption font was not loaded"); + const otherDriverNote = fakeConsoleMessage( + "warn", + "[.WebGL-0x2c84001d4800]GL Driver Message (OpenGL, Performance, GL_CLOSE_PATH_NV, High): Program undefined behavior warning", + ); page.on = vi.fn( (event: string, handler: (message: ReturnType) => void) => { if (event === "console") { handler(stall); + handler(otherDriverNote); handler(ordinaryWarning); } }, @@ -1107,7 +1112,7 @@ it("does not report Chrome's GPU stall notes for check's own pixel reads as the expect( result.runtimeFindings.filter(({ code }) => code === "console_warning").map((f) => f.message), - ).toEqual([ordinaryWarning.text()]); + ).toEqual([otherDriverNote.text(), ordinaryWarning.text()]); }); describe("collectSeekClock", () => { diff --git a/packages/cli/src/utils/checkBrowser.ts b/packages/cli/src/utils/checkBrowser.ts index 8a0ce38dff..1d3de8ca40 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -869,7 +869,8 @@ function runtimeInfoFindingCode(text: string): string | null { : "media_proxy_fallback"; } -const GL_DRIVER_PERFORMANCE_NOTE = /^\[\.WebGL-[^\]]*\]GL Driver Message \([^,]*, Performance,/; +const READ_PIXELS_STALL = + /^\[\.WebGL-[^\]]*\]GL Driver Message \([^)]*\): GPU stall due to ReadPixels/; function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], clock: RuntimeClock): void { followSampleTimeMarkers(page, clock.at); @@ -887,7 +888,7 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], clock: Runtime line: location.lineNumber, }); } else if (type === "warn") { - if (GL_DRIVER_PERFORMANCE_NOTE.test(text)) return; + if (READ_PIXELS_STALL.test(text)) return; const location = message.location(); const webGpuFailure = isWebGpuRuntimeFailure(text); pushRuntimeDraft(drafts, { From b7238744624b49fc2a54cb7cd5c9c73b6004f92e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 15:29:22 +0000 Subject: [PATCH 13/20] fix(studio): HEVC with long-term reference pictures keeps its pixel aspect ratio (#7739) * fix(studio): HEVC with long-term reference pictures keeps its pixel aspect ratio * style(studio): format the HEVC SPS test --- bun.lock | 4 +- packages/studio/package.json | 2 +- .../src/player/lib/mediabunnyHevcSps.test.ts | 169 ++++++++++++++++++ 3 files changed, 172 insertions(+), 3 deletions(-) create mode 100644 packages/studio/src/player/lib/mediabunnyHevcSps.test.ts diff --git a/bun.lock b/bun.lock index e405d8af89..6f906ddf16 100644 --- a/bun.lock +++ b/bun.lock @@ -391,7 +391,7 @@ "cn": "0.2.4", "dockview-react": "8.3.1", "gsap": "^3.13.0", - "mediabunny": "^1.65.2", + "mediabunny": "^1.66.0", }, "devDependencies": { "@hyperframes/producer": "workspace:*", @@ -1889,7 +1889,7 @@ "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], - "mediabunny": ["mediabunny@1.65.2", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-goZpibvo/5hkc/56WVNm93Ini+o7K4y8aXA20c/BN1B+m3L5aHQa8MTvO+S1APg/YI8kMILtrX6QCqUtxLW7bA=="], + "mediabunny": ["mediabunny@1.66.0", "", { "dependencies": { "@types/dom-mediacapture-transform": "^0.1.11", "@types/dom-webcodecs": "0.1.13" } }, "sha512-0Zff/qLkY1ZRyjBEt2vQjLeMigmU/dFKwJLpiPyCiskIel2f2R2kqMxaLYbeHN+rmyz4PVwwLSnU9anjUHHeJQ=="], "meow": ["meow@13.2.0", "", {}, "sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA=="], diff --git a/packages/studio/package.json b/packages/studio/package.json index 74e8392927..fcaf342177 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -123,7 +123,7 @@ "cn": "0.2.4", "dockview-react": "8.3.1", "gsap": "^3.13.0", - "mediabunny": "^1.65.2" + "mediabunny": "^1.66.0" }, "devDependencies": { "@hyperframes/producer": "workspace:*", diff --git a/packages/studio/src/player/lib/mediabunnyHevcSps.test.ts b/packages/studio/src/player/lib/mediabunnyHevcSps.test.ts new file mode 100644 index 0000000000..73e373937b --- /dev/null +++ b/packages/studio/src/player/lib/mediabunnyHevcSps.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + BufferSource, + BufferTarget, + EncodedPacket, + EncodedVideoPacketSource, + Input, + MP4, + Mp4OutputFormat, + Output, +} from "mediabunny"; + +class Bits { + private bits: number[] = []; + u(value: number, width: number) { + for (let i = width - 1; i >= 0; i--) this.bits.push((value >>> i) & 1); + return this; + } + ue(value: number) { + const width = Math.floor(Math.log2(value + 1)); + return this.u(0, width).u(value + 1, width + 1); + } + rbsp(): number[] { + this.u(1, 1); + while (this.bits.length % 8) this.bits.push(0); + const bytes: number[] = []; + for (let i = 0; i < this.bits.length; i += 8) { + bytes.push(this.bits.slice(i, i + 8).reduce((byte, bit) => (byte << 1) | bit, 0)); + } + return bytes; + } +} + +function withEmulationPrevention(rbsp: number[]): number[] { + const out: number[] = []; + let zeros = 0; + for (const byte of rbsp) { + if (zeros >= 2 && byte <= 3) { + out.push(3); + zeros = 0; + } + out.push(byte); + zeros = byte === 0 ? zeros + 1 : 0; + } + return out; +} + +/** A 64x64 SPS with one long-term reference picture and 4:3 pixels, the shape phone HEVC carries. */ +function spsWithLongTermRefs(): number[] { + const b = new Bits() + .u(0, 4) // sps_video_parameter_set_id + .u(0, 3) // sps_max_sub_layers_minus1 + .u(1, 1) // sps_temporal_id_nesting_flag + .u(0, 2) + .u(0, 1) + .u(1, 5) // Main profile + .u(0x60000000, 32) + .u(0b1001, 4) + .u(0, 32) + .u(0, 12) + .u(93, 8) // level 3.1 + .ue(0) // sps_seq_parameter_set_id + .ue(1) // chroma_format_idc 4:2:0 + .ue(64) + .ue(64) + .u(0, 1) // conformance_window_flag + .ue(0) + .ue(0) + .ue(4) // log2_max_pic_order_cnt_lsb_minus4: POC LSBs are 8 bits + .u(1, 1) + .ue(4) + .ue(2) + .ue(0) + .ue(0) + .ue(3) + .ue(0) + .ue(3) + .ue(0) + .ue(0) + .u(0, 1) // scaling_list_enabled_flag + .u(0, 1) + .u(0, 1) + .u(0, 1) // pcm_enabled_flag + .ue(0) // num_short_term_ref_pic_sets + .u(1, 1) // long_term_ref_pics_present_flag + .ue(1) // num_long_term_ref_pics_sps + .u(0, 8) // lt_ref_pic_poc_lsb_sps[0]: fixed-width, 8 bits here + .u(1, 1) + .u(1, 1) + .u(1, 1) + .u(1, 1) // vui_parameters_present_flag + .u(1, 1) // aspect_ratio_info_present_flag + .u(255, 8) // Extended_SAR + .u(4, 16) + .u(3, 16) + .u(0, 9) // the rest of the VUI absent + .u(0, 1); // sps_extension_present_flag + return [0x42, 0x01, ...withEmulationPrevention(b.rbsp())]; +} + +function hvcC(sps: number[]): Uint8Array { + return new Uint8Array([ + 1, + 0x01, + 0x60, + 0, + 0, + 0, + 0x90, + 0, + 0, + 0, + 0, + 0, + 93, + 0xf0, + 0, + 0xfc, + 0xfd, + 0xf8, + 0xf8, + 0, + 0, + 0x0f, + 1, + 0x80 | 33, + 0, + 1, + sps.length >> 8, + sps.length & 0xff, + ...sps, + ]); +} + +async function anamorphicHevcMp4(): Promise { + const output = new Output({ format: new Mp4OutputFormat(), target: new BufferTarget() }); + const video = new EncodedVideoPacketSource("hevc"); + output.addVideoTrack(video); + await output.start(); + await video.add(new EncodedPacket(new Uint8Array(16), "key", 0, 1 / 30), { + decoderConfig: { + codec: "hvc1.1.6.L93.90", + codedWidth: 64, + codedHeight: 64, + description: hvcC(spsWithLongTermRefs()), + }, + }); + await output.finalize(); + return output.target.buffer!; +} + +describe("the HEVC SPS reader studio ships", () => { + afterEach(() => vi.restoreAllMocks()); + + it("keeps the pixel aspect ratio of HEVC that lists long-term reference pictures", async () => { + const errors = vi.spyOn(console, "error").mockImplementation(() => {}); + const input = new Input({ + source: new BufferSource(await anamorphicHevcMp4()), + formats: [MP4], + }); + const track = await input.getPrimaryVideoTrack(); + + expect([await track!.getSquarePixelWidth(), await track!.getSquarePixelHeight()]).toEqual([ + 85, 64, + ]); + expect(errors).not.toHaveBeenCalled(); + input.dispose(); + }); +}); From b746a0ee1389895b861b7177e226df3622d09bd4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 15:47:18 +0000 Subject: [PATCH 14/20] fix: check, render and downloads work on a drive without hard links (#7737) * fix(studio-server): check and render keep their history on a drive without hard links The history lock had its own list of the errors that mean a drive cannot hard-link, and it lacked EISDIR, which Windows returns on a Google Drive folder. Every check report and render record there failed. The lock now uses core's list, the one owner, which also gains ENOSYS from the lock. * fix(engine): downloads and plan blobs publish on a drive without hard links The producer's plan publisher kept a third list of no-hard-link errors without EISDIR, and the downloader had no fallback, so both failed on a Google Drive folder. Both now use core's cannotHardLink; the downloader copies into an exclusively created file, so it still never overwrites. * fix(engine): a download on a drive without hard links publishes atomically Instead of copying into an exclusively created file, the fallback renames the staged download into place under the cache path lock when nothing is there yet, so a killed run never leaves a truncated file to reuse. --- packages/core/src/atomicFile.test.ts | 2 +- packages/core/src/atomicFile.ts | 16 ++++- .../engine/src/utils/urlDownloader.test.ts | 34 +++++++++++ packages/engine/src/utils/urlDownloader.ts | 42 ++++++++++--- .../src/services/distributed/planV2.test.ts | 60 ++++++++++--------- .../services/distributed/planV2Publisher.ts | 10 +--- .../src/history/ownerLock.test.ts | 3 +- .../studio-server/src/history/ownerLock.ts | 8 +-- 8 files changed, 122 insertions(+), 53 deletions(-) diff --git a/packages/core/src/atomicFile.test.ts b/packages/core/src/atomicFile.test.ts index fcb02829cb..0d4d64917c 100644 --- a/packages/core/src/atomicFile.test.ts +++ b/packages/core/src/atomicFile.test.ts @@ -262,7 +262,7 @@ describe("createFileAtomically", () => { }; } - it.each(["EPERM", "ENOTSUP", "EOPNOTSUPP", "EXDEV", "EISDIR", "EACCES"])( + it.each(["EPERM", "ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EXDEV", "EISDIR", "EACCES"])( "writes directly on a volume without hard links (%s)", (code) => { const dir = tempDir(); diff --git a/packages/core/src/atomicFile.ts b/packages/core/src/atomicFile.ts index 079d63918e..bc4933b749 100644 --- a/packages/core/src/atomicFile.ts +++ b/packages/core/src/atomicFile.ts @@ -9,11 +9,23 @@ type DirectFileSystem = SiblingFileSystem & Pick ({ deleteBeforeLstatPath: undefined as string | undefined, deleteInjectedWinnerBeforeLstatPath: undefined as string | undefined, injectRaceAtLinkPath: undefined as string | undefined, + failLinkWith: undefined as string | undefined, deleteBeforeReadPath: undefined as string | undefined, replaceStaleLockAfterObservationPath: undefined as string | undefined, replaceLockOnReleasePath: undefined as string | undefined, @@ -77,6 +78,11 @@ vi.mock("fs", async (importOriginal) => { fsRaceControls.injectRaceAtLinkPath = undefined; actual.writeFileSync(destination, "concurrent-winner"); } + if (fsRaceControls.failLinkWith) { + throw Object.assign(new Error("hard links are not supported"), { + code: fsRaceControls.failLinkWith, + }); + } return Reflect.apply(actual.linkSync, actual, args); }) as typeof actual.linkSync, createReadStream: ((...args: unknown[]) => { @@ -156,6 +162,7 @@ afterEach(() => { fsRaceControls.deleteBeforeLstatPath = undefined; fsRaceControls.deleteInjectedWinnerBeforeLstatPath = undefined; fsRaceControls.injectRaceAtLinkPath = undefined; + fsRaceControls.failLinkWith = undefined; fsRaceControls.deleteBeforeReadPath = undefined; fsRaceControls.replaceStaleLockAfterObservationPath = undefined; fsRaceControls.replaceLockOnReleasePath = undefined; @@ -1527,6 +1534,33 @@ describe("downloadToTemp atomic publication and bounded retry", () => { expect(fetchMock).toHaveBeenCalledTimes(2); }); + // fails without the fix: on a drive without hard links (EISDIR on a Google Drive folder) every download failed. + it("publishes on a drive without hard links", async () => { + const url = "https://cdn.example/no-hard-links.mp4"; + const dir = makeTempDir(); + fsRaceControls.failLinkWith = "EISDIR"; + stubFetch(vi.fn().mockImplementation(() => Promise.resolve(new Response("complete")))); + + const path = await downloadToTemp(url, dir, 1_000); + + expect(readFileSync(path, "utf8")).toBe("complete"); + expect(temporaryDownloadEntries(dir)).toEqual([]); + }); + + it("still never overwrites a race winner on a drive without hard links", async () => { + const url = "https://cdn.example/no-hard-links-race.mp4"; + const dir = makeTempDir(); + const cacheName = `download_${createHash("md5").update(url).digest("hex").slice(0, 12)}.mp4`; + const cachePath = join(dir, cacheName); + fsRaceControls.failLinkWith = "EISDIR"; + fsRaceControls.injectRaceAtLinkPath = cachePath; + stubFetch(vi.fn().mockImplementation(() => Promise.resolve(new Response("complete")))); + + await downloadToTemp(url, dir, 1_000).catch(() => undefined); + + expect(readFileSync(cachePath, "utf8")).toBe("concurrent-winner"); + }); + it("locally refetches when a concurrent race winner disappears before validation", async () => { const url = "https://cdn.example/vanishing-race-winner.mp4"; const dir = makeTempDir(); diff --git a/packages/engine/src/utils/urlDownloader.ts b/packages/engine/src/utils/urlDownloader.ts index 6493cbf1ce..f5427fe957 100644 --- a/packages/engine/src/utils/urlDownloader.ts +++ b/packages/engine/src/utils/urlDownloader.ts @@ -23,7 +23,8 @@ import { isBlockedNetworkHost } from "./networkPolicy.js"; import { dirname, extname, join } from "path"; import { Readable, Transform } from "stream"; import { pipeline } from "stream/promises"; -import { createStagingDir } from "@hyperframes/core/atomic-file"; +import { cannotHardLink, createStagingDir } from "@hyperframes/core/atomic-file"; +import { renameWithRetry } from "@hyperframes/core/rename-retry"; const inFlightDownloads = new Map>(); const signalScopes = new WeakMap(); @@ -967,10 +968,36 @@ async function fetchToPartial( }; } -function syncAndPublishPartial( +async function publishWithoutClobber( partialPath: string, localPath: string, -): Extract { + timeoutMs: number, + signal: AbortSignal | undefined, +): Promise { + try { + linkSync(partialPath, localPath); + unlinkSync(partialPath); + return; + } catch (error) { + if (!cannotHardLink(error)) throw error; + } + const releaseLock = await acquireCachePathLock(localPath, timeoutMs, signal); + try { + if (lstatSync(localPath, { throwIfNoEntry: false })) { + throw Object.assign(new Error(`${localPath} already exists`), { code: "EEXIST" }); + } + renameWithRetry(partialPath, localPath); + } finally { + releaseLock(); + } +} + +async function syncAndPublishPartial( + partialPath: string, + localPath: string, + timeoutMs: number, + signal: AbortSignal | undefined, +): Promise> { // Windows rejects fsync on a read-only handle (EPERM); the partial is ours // and writable, so r+ preserves the same flush semantics cross-platform. const fd = openSync(partialPath, "r+"); @@ -980,11 +1007,10 @@ function syncAndPublishPartial( closeSync(fd); } - // A hard-link publish is atomic and no-clobber: unlike rename(), it cannot - // replace a path that another successful caller has already returned. + // Atomic and no-clobber: a hard link, or a rename under the cache path lock where + // the drive has no hard links, never replaces a path another caller already returned. try { - linkSync(partialPath, localPath); - unlinkSync(partialPath); + await publishWithoutClobber(partialPath, localPath, timeoutMs, signal); return "published"; } catch (error) { const code = (error as NodeJS.ErrnoException).code; @@ -1057,7 +1083,7 @@ async function runDownloadAttempt( throw new UrlDownloadError("cancelled", false, "Download cancelled"); } const integrity = await fetchToPartial(url, partialPath, controller, options, extendTimeout); - let outcome = syncAndPublishPartial(partialPath, localPath); + let outcome = await syncAndPublishPartial(partialPath, localPath, timeoutMs, signal); let publishedIntegrity = integrity; if (outcome === "race_reused") { let inspection: Awaited>; diff --git a/packages/producer/src/services/distributed/planV2.test.ts b/packages/producer/src/services/distributed/planV2.test.ts index e503362469..a4c2657783 100644 --- a/packages/producer/src/services/distributed/planV2.test.ts +++ b/packages/producer/src/services/distributed/planV2.test.ts @@ -1005,35 +1005,39 @@ describe("Plan v2 artifact publisher", () => { }); }); - it("falls back to an atomic copy when hard-linking is unavailable", async () => { - const root = tempPath("hf-plan-v2-publisher-copy-"); - const v1 = createV1Plan(root); - const destination = join(root, "v2"); - const publisher = new LocalPlanV2ArtifactPublisher(destination, { - linkFile() { - throw Object.assign(new Error("cross-device link"), { code: "EXDEV" }); - }, - }); - const manifest = await publishPlanV2FromV1(v1, publisher); - const artifact = manifest.artifacts.find( - (candidate) => candidate.path === "compiled/asset.txt", - ); - if (artifact === undefined) throw new Error("test fixture is missing compiled/asset.txt"); - const sourcePath = join(v1, artifact.path); - const blobPath = join( - destination, - "artifacts", - "sha256", - artifact.sha256.slice(0, 2), - artifact.sha256, - ); + // fails without the fix: EISDIR (Windows on a Google Drive folder) failed the publish instead of copying. + it.each(["EXDEV", "EISDIR", "ENOSYS", "EOPNOTSUPP"])( + "falls back to an atomic copy when hard-linking fails with %s", + async (code) => { + const root = tempPath("hf-plan-v2-publisher-copy-"); + const v1 = createV1Plan(root); + const destination = join(root, "v2"); + const publisher = new LocalPlanV2ArtifactPublisher(destination, { + linkFile() { + throw Object.assign(new Error("hard links are not supported"), { code }); + }, + }); + const manifest = await publishPlanV2FromV1(v1, publisher); + const artifact = manifest.artifacts.find( + (candidate) => candidate.path === "compiled/asset.txt", + ); + if (artifact === undefined) throw new Error("test fixture is missing compiled/asset.txt"); + const sourcePath = join(v1, artifact.path); + const blobPath = join( + destination, + "artifacts", + "sha256", + artifact.sha256.slice(0, 2), + artifact.sha256, + ); - expect(readFileSync(blobPath)).toEqual(readFileSync(sourcePath)); - expect({ dev: statSync(blobPath).dev, ino: statSync(blobPath).ino }).not.toEqual({ - dev: statSync(sourcePath).dev, - ino: statSync(sourcePath).ino, - }); - }); + expect(readFileSync(blobPath)).toEqual(readFileSync(sourcePath)); + expect({ dev: statSync(blobPath).dev, ino: statSync(blobPath).ino }).not.toEqual({ + dev: statSync(sourcePath).dev, + ino: statSync(sourcePath).ino, + }); + }, + ); it("produces byte-identical local CAS output through both publication paths", async () => { const root = tempPath("hf-plan-v2-publisher-parity-"); diff --git a/packages/producer/src/services/distributed/planV2Publisher.ts b/packages/producer/src/services/distributed/planV2Publisher.ts index cc6a00915d..dc6a2bf36e 100644 --- a/packages/producer/src/services/distributed/planV2Publisher.ts +++ b/packages/producer/src/services/distributed/planV2Publisher.ts @@ -11,7 +11,7 @@ import { renameWithRetry } from "@hyperframes/engine"; import { dirname, join } from "node:path"; import { PlanV2IntegrityError } from "./planV2Errors.js"; import { assertPlanV2Sha256, planV2BlobPath } from "./planV2Layout.js"; -import { createStagingDir } from "@hyperframes/core/atomic-file"; +import { cannotHardLink, createStagingDir } from "@hyperframes/core/atomic-file"; export interface PlanV2PublishBlob { readonly sourcePath: string; @@ -33,12 +33,6 @@ export interface PlanV2ArtifactPublisher { abort(): Promise; } -function canFallbackToCopy(error: unknown): boolean { - if (!(error instanceof Error) || !("code" in error)) return false; - const code = error.code; - return code === "EXDEV" || code === "EPERM" || code === "EACCES" || code === "ENOTSUP"; -} - function isRecord(value: unknown): value is Record { return typeof value === "object" && value !== null && !Array.isArray(value); } @@ -108,7 +102,7 @@ export class LocalPlanV2ArtifactPublisher implements PlanV2ArtifactPublisher { try { this.#linkFile(blob.sourcePath, temporaryPath); } catch (error) { - if (!canFallbackToCopy(error)) throw error; + if (!cannotHardLink(error)) throw error; copyFileSync(blob.sourcePath, temporaryPath); } renameWithRetry(temporaryPath, destinationPath); diff --git a/packages/studio-server/src/history/ownerLock.test.ts b/packages/studio-server/src/history/ownerLock.test.ts index 1183cd6eed..e1ba58a96d 100644 --- a/packages/studio-server/src/history/ownerLock.test.ts +++ b/packages/studio-server/src/history/ownerLock.test.ts @@ -95,7 +95,8 @@ function withoutHardLinks(code = "ENOTSUP"): void { } describe("takeHistoryOwnership on a drive without hard links", () => { - it.each(["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"])( + // fails without the fix: EISDIR (Windows on a Google Drive folder) and EXDEV threw instead. + it.each(["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES", "EISDIR", "EXDEV"])( "takes the lock when linking fails with %s", async (code) => { withoutHardLinks(code); diff --git a/packages/studio-server/src/history/ownerLock.ts b/packages/studio-server/src/history/ownerLock.ts index e58336eac0..ee5b81aa09 100644 --- a/packages/studio-server/src/history/ownerLock.ts +++ b/packages/studio-server/src/history/ownerLock.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { linkSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { uptime } from "node:os"; import { join } from "node:path"; -import { replaceFileAtomically } from "@hyperframes/core/atomic-file"; +import { cannotHardLink, replaceFileAtomically } from "@hyperframes/core/atomic-file"; export class HistoryBusyError extends Error { constructor( @@ -16,7 +16,6 @@ export class HistoryBusyError extends Error { } const WINDOWS_BUSY = new Set(["EPERM", "EBUSY", "EACCES"]); -const NO_HARD_LINKS = new Set(["ENOTSUP", "EOPNOTSUPP", "ENOSYS", "EPERM", "EACCES"]); const TAKING_MS = 2_000; const BOOT_SLACK_MS = 60_000; @@ -116,9 +115,8 @@ function claim(file: string): boolean { try { linkSync(draft, file); } catch (error) { - const code = (error as NodeJS.ErrnoException).code ?? ""; - if (code === "EEXIST") return false; - if (!NO_HARD_LINKS.has(code)) throw error; + if ((error as NodeJS.ErrnoException).code === "EEXIST") return false; + if (!cannotHardLink(error)) throw error; return createExclusively(file); } return recordOwnership(file); From cb52dcf646b8c0a413ce80c99fb6719ad452a2c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 16:06:47 +0000 Subject: [PATCH 15/20] fix(cli): an unknown flag's error leaves out hidden rename shims (#7746) * fix(cli): an unknown flag's list leaves out hidden rename shims The list pointed agents at init's --template, --video-legacy and --agent, which only print an error. It now shows the flags --help shows. * fix(cli): an unknown flag never suggests a hidden rename shim The did-you-mean search still used every spelling, so '--templat' on init was pointed at the hidden --template. It now uses the same visible flags the list shows. --- .../src/utils/reject-unknown-flags.test.ts | 31 +++++++++++++++++++ .../cli/src/utils/reject-unknown-flags.ts | 22 +++++-------- 2 files changed, 39 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/utils/reject-unknown-flags.test.ts b/packages/cli/src/utils/reject-unknown-flags.test.ts index 7900c11f3b..31d288461f 100644 --- a/packages/cli/src/utils/reject-unknown-flags.test.ts +++ b/packages/cli/src/utils/reject-unknown-flags.test.ts @@ -112,6 +112,37 @@ describe("assertKnownFlags", () => { ); }); + // fails without the fix: the list sent agents to hidden rename shims that only print an error. + it("leaves hidden flags out of the list, as --help does", () => { + const init = { + args: { + example: { type: "string" }, + template: { type: "string", hidden: true, description: "[renamed] Use --example instead." }, + agent: { type: "boolean", hidden: true }, + }, + } as unknown as CommandDef; + expect(() => assertKnownFlags(init, ["--demo"], "init")).toThrow( + expect.objectContaining({ + hint: "Its flags: --example. Run 'hyperframes init --help' for what each does.", + }), + ); + }); + + // fails without the fix: a typo near a hidden shim was told to use the shim. + it("never suggests a hidden flag for a typo", () => { + const init = { + args: { + example: { type: "string" }, + template: { type: "string", hidden: true, description: "[renamed] Use --example instead." }, + }, + } as unknown as CommandDef; + expect(() => assertKnownFlags(init, ["--templat", "x"], "init")).toThrow( + expect.objectContaining({ + hint: "Its flags: --example. Run 'hyperframes init --help' for what each does.", + }), + ); + }); + it("points --search at the query flag", () => { const catalog = { args: { words: { type: "positional" }, query: { type: "string" }, json: { type: "boolean" } }, diff --git a/packages/cli/src/utils/reject-unknown-flags.ts b/packages/cli/src/utils/reject-unknown-flags.ts index 0fee2b7012..7b98fd0717 100644 --- a/packages/cli/src/utils/reject-unknown-flags.ts +++ b/packages/cli/src/utils/reject-unknown-flags.ts @@ -23,10 +23,10 @@ function nameVariants(name: string): string[] { return [name, kebabCase(name), camel]; } -function declaredLongFlags(args: ArgsDef | undefined): string[] { +function visibleLongFlags(args: ArgsDef | undefined): string[] { return Object.entries(args ?? {}) - .filter(([, def]) => def?.type !== "positional") - .map(([name]) => `--${kebabCase(name)}`); + .filter(([, def]) => def?.type !== "positional" && !(def && "hidden" in def && def.hidden)) + .map(([name]) => kebabCase(name)); } // Every spelling of one declared arg: name variants plus aliases. `def` may be undefined. @@ -86,12 +86,7 @@ const FLAG_SYNONYMS: Readonly> = { search: ["query"], }; -function unknownFlagError( - flag: string, - known: Set, - declared: string[], - path: string, -): CliUsageError { +function unknownFlagError(flag: string, visible: string[], path: string): CliUsageError { const command = `hyperframes ${path ? `${path} ` : ""}`.trim(); if (flag === "--json") { return new CliUsageError(`${command} has no JSON output.`, { @@ -99,14 +94,13 @@ function unknownFlagError( hint: "Run it without --json.", }); } - const longNames = [...known].filter((name) => name.length > 1); const name = flag.replace(/^--?/, ""); const match = - FLAG_SYNONYMS[name]?.find((s) => known.has(s)) ?? - (flag.startsWith("--") ? closestMatch(name, longNames) : undefined); + FLAG_SYNONYMS[name]?.find((s) => visible.includes(s)) ?? + (flag.startsWith("--") ? closestMatch(name, visible) : undefined); const hint = [ match && `Did you mean --${match}?`, - declared.length > 0 && `Its flags: ${declared.join(", ")}.`, + visible.length > 0 && `Its flags: ${visible.map((f) => `--${f}`).join(", ")}.`, `Run 'hyperframes ${path ? `${path} ` : ""}--help' for what each does.`, ] .filter(Boolean) @@ -130,7 +124,7 @@ export function assertKnownFlags(cmd: CommandDef, rawArgs: string[], pa for (const tok of rawArgs) { if (tok === "--") break; const bad = unknownFlagIn(tok, known); - if (bad) throw unknownFlagError(bad, known, declaredLongFlags(args), path); + if (bad) throw unknownFlagError(bad, visibleLongFlags(args), path); } } From a92498b9033a069418940fe54360e172968381e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 16:07:36 +0000 Subject: [PATCH 16/20] fix(studio-server): save an edit Windows briefly holds without freezing the app (#7744) * fix(studio-server): save an edit Windows briefly holds without freezing the app * fix(studio-server): keep later edits behind a held one, one backup per replay * fix(studio-server): send a held cut again, and keep the sync retry for rollbacks --- docs/contributing/testing-local-changes.mdx | 8 + packages/core/src/renameWithRetry.test.ts | 18 +- packages/core/src/renameWithRetry.ts | 28 +++ .../src/helpers/backupJournal.ts | 16 +- .../src/history/blobStore.test.ts | 21 +-- .../studio-server/src/history/blobStore.ts | 15 +- .../src/routes/files.heldFile.test.ts | 159 +++++++++++++++++- .../src/routes/files.heldReplay.test.ts | 84 +++++++++ packages/studio-server/src/routes/files.ts | 56 +++++- .../src/routes/freezeFrame.test.ts | 17 ++ .../studio-server/src/routes/freezeFrame.ts | 12 +- 11 files changed, 399 insertions(+), 35 deletions(-) create mode 100644 packages/studio-server/src/routes/files.heldReplay.test.ts diff --git a/docs/contributing/testing-local-changes.mdx b/docs/contributing/testing-local-changes.mdx index ea911bdfb4..9ce15836e2 100644 --- a/docs/contributing/testing-local-changes.mdx +++ b/docs/contributing/testing-local-changes.mdx @@ -71,6 +71,14 @@ npx ./hyperframes-.tgz --help Run the archive against an isolated project as well as the command you changed. +A `hyperframes preview` started through `npx` keeps its server running when the +`npx` process is stopped. Stop the server by the process listening on its port, +or the next run talks to the old one: + +```bash +kill "$(lsof -ti tcp: -sTCP:LISTEN)" +``` + ## What to verify Test the user-visible outcome, not only the exit code: diff --git a/packages/core/src/renameWithRetry.test.ts b/packages/core/src/renameWithRetry.test.ts index e7627bc926..883b1a1f16 100644 --- a/packages/core/src/renameWithRetry.test.ts +++ b/packages/core/src/renameWithRetry.test.ts @@ -1,6 +1,6 @@ // @vitest-environment node import { describe, expect, it } from "vitest"; -import { renameWithRetry, renameWithRetryAsync } from "./renameWithRetry.js"; +import { catchHeldRename, renameWithRetry, renameWithRetryAsync } from "./renameWithRetry.js"; function failing(code: string, times: number) { let calls = 0; @@ -88,3 +88,19 @@ describe("renameWithRetry", () => { expect(copied).toEqual([]); }); }); + +describe("catchHeldRename", () => { + it("hands back the first hold, even one a caller swallowed, and rides later holds out as before", async () => { + const rename = failing("EBUSY", 2); + const held = await catchHeldRename(async () => { + try { + renameWithRetry("a", "b", { platform: "win32", rename: rename.fn }); + } catch { + // a route that answers a failed write itself + } + renameWithRetry("a", "b", { platform: "win32", rename: rename.fn }); + }); + expect(held).toMatchObject({ code: "EBUSY" }); + expect(rename.calls()).toBe(3); + }); +}); diff --git a/packages/core/src/renameWithRetry.ts b/packages/core/src/renameWithRetry.ts index b6bbf1310f..93677f60c7 100644 --- a/packages/core/src/renameWithRetry.ts +++ b/packages/core/src/renameWithRetry.ts @@ -1,3 +1,4 @@ +import { AsyncLocalStorage } from "node:async_hooks"; import { copyFileSync, renameSync } from "node:fs"; import { copyFile, rename } from "node:fs/promises"; import { removeScratch } from "./removeScratch.js"; @@ -18,6 +19,32 @@ export function isWindowsHold(error: unknown, platform = process.platform): bool return RETRYABLE.has((error as NodeJS.ErrnoException | undefined)?.code ?? ""); } +const holdScope = new AsyncLocalStorage<{ open: boolean; held: unknown }>(); + +class HeldRename { + constructor(readonly error: unknown) {} +} + +export async function catchHeldRename(work: () => Promise): Promise { + const scope: { open: boolean; held: unknown } = { open: true, held: null }; + try { + await holdScope.run(scope, work); + } catch (thrown) { + if (!(thrown instanceof HeldRename)) throw thrown; + } finally { + scope.open = false; + } + return scope.held; +} + +function handOverHold(error: unknown): void { + const scope = holdScope.getStore(); + if (!scope?.open) return; + scope.open = false; + scope.held = error; + throw new HeldRename(error); +} + /** What a refused rename does next: wait and try again, fall back to a copy, or give up with the error. */ function nextStep(error: unknown, attempt: number, options: Options, platform: NodeJS.Platform) { if (!isWindowsHold(error, platform)) return "throw"; @@ -48,6 +75,7 @@ export function renameWithRetry( } catch (error) { const step = nextStep(error, attempt, options, platform); if (step === "throw") throw error; + handOverHold(error); if (step === "wait") { sleepSync(BACKOFF_MS[attempt]!); continue; diff --git a/packages/studio-server/src/helpers/backupJournal.ts b/packages/studio-server/src/helpers/backupJournal.ts index 5199eba3e3..0848fa346f 100644 --- a/packages/studio-server/src/helpers/backupJournal.ts +++ b/packages/studio-server/src/helpers/backupJournal.ts @@ -50,6 +50,8 @@ export function snapshotBeforeWrite( pathKey.length <= 200 ? pathKey : `sha256~${createHash("sha256").update(relativePath).digest("hex")}`; + const [newest] = backupsNewestFirst(backupDir, [pathKey, backupKey]); + if (newest && readFileSync(newest).equals(content)) return { backupPath: newest }; const backupPath = nextBackupPath(backupDir, backupKey); writeFileSync(backupPath, content, { flag: "wx" }); pruneBackups(backupDir, [pathKey, backupKey], options.keepPerFile ?? DEFAULT_KEEP_PER_FILE); @@ -86,17 +88,17 @@ function nextBackupPath(backupDir: string, backupKey: string): string { } } -function pruneBackups(backupDir: string, backupKeys: string[], keepPerFile: number): void { - const keep = Math.max(1, Math.floor(keepPerFile)); +function backupsNewestFirst(backupDir: string, backupKeys: string[]): string[] { const suffix = new RegExp(`-(?:${backupKeys.join("|")})(?:-\\d+)?$`); - const matches = readdirSync(backupDir) + return readdirSync(backupDir) .filter((name) => suffix.test(name)) .map((name) => join(backupDir, name)) - .sort((a, b) => { - return b.localeCompare(a); - }); + .sort((a, b) => b.localeCompare(a)); +} - for (const file of matches.slice(keep)) { +function pruneBackups(backupDir: string, backupKeys: string[], keepPerFile: number): void { + const keep = Math.max(1, Math.floor(keepPerFile)); + for (const file of backupsNewestFirst(backupDir, backupKeys).slice(keep)) { try { unlinkSync(file); } catch { diff --git a/packages/studio-server/src/history/blobStore.test.ts b/packages/studio-server/src/history/blobStore.test.ts index a9f421fca5..6372eea2d0 100644 --- a/packages/studio-server/src/history/blobStore.test.ts +++ b/packages/studio-server/src/history/blobStore.test.ts @@ -13,10 +13,6 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("node:fs", async (importOriginal) => { - const actual = await importOriginal(); - return { ...actual, renameSync: vi.fn(actual.renameSync) }; -}); vi.mock("node:fs/promises", async (importOriginal) => { const actual = await importOriginal(); return { @@ -32,7 +28,6 @@ import * as fsp from "node:fs/promises"; import { onRestoredInPlace, openBlobStore } from "./blobStore.js"; import { serveWhileOpen } from "../helpers/servedFiles.js"; -const { renameSync: realRenameSync } = await vi.importActual("node:fs"); const { rename: realRename, copyFile: realCopyFile, @@ -50,7 +45,7 @@ describe("the history store on Windows, while another process briefly holds a fi }); afterEach(() => { Object.defineProperty(process, "platform", { value: platform, configurable: true }); - vi.mocked(fs.renameSync).mockImplementation(realRenameSync); + vi.mocked(fsp.rename).mockImplementation(realRename); vi.mocked(fsp.rename).mockImplementation(realRename); vi.mocked(fsp.copyFile).mockImplementation(realCopyFile); rmSync(root, { recursive: true, force: true }); @@ -63,7 +58,7 @@ describe("the history store on Windows, while another process briefly holds a fi const store = await openBlobStore(join(root, "history", "blobs")); const hash = await store.put(target); writeFileSync(target, "

after

"); - vi.mocked(fs.renameSync).mockImplementation(() => { + vi.mocked(fsp.rename).mockImplementation(async () => { throw busy(); }); const heard: string[] = []; @@ -126,9 +121,9 @@ describe("the history store on Windows, while another process briefly holds a fi const target = join(root, "index.html"); writeFileSync(target, "

after

"); let refused = 0; - vi.mocked(fs.renameSync).mockImplementation((from, to) => { + vi.mocked(fsp.rename).mockImplementation(async (from, to) => { if (refused++ < 2) throw busy(); - return realRenameSync(from, to); + return realRename(from, to); }); await store.writeTo(hash, target); @@ -163,7 +158,7 @@ describe("a restore of a file the preview is serving", () => { root = mkdtempSync(join(tmpdir(), "hf-blob-served-")); }); afterEach(() => { - vi.mocked(fs.renameSync).mockImplementation(realRenameSync); + vi.mocked(fsp.rename).mockImplementation(realRename); rmSync(root, { recursive: true, force: true }); }); @@ -176,9 +171,9 @@ describe("a restore of a file the preview is serving", () => { await new Promise((resolve) => read.once("data", resolve)); read.pause(); let openAtReplace = true; - vi.mocked(fs.renameSync).mockImplementation((from, to) => { + vi.mocked(fsp.rename).mockImplementation(async (from, to) => { openAtReplace = !read.closed; - return realRenameSync(from, to); + return realRename(from, to); }); await store.writeTo(hash, target); @@ -192,7 +187,7 @@ describe("a restore of a file the preview is serving", () => { const store = await openBlobStore(join(root, "history", "blobs")); const hash = await store.put(target); writeFileSync(target, "

after

"); - vi.mocked(fs.renameSync).mockImplementation(() => { + vi.mocked(fsp.rename).mockImplementation(async () => { throw busy(); }); const heard: string[] = []; diff --git a/packages/studio-server/src/history/blobStore.ts b/packages/studio-server/src/history/blobStore.ts index 5ad4ef113d..a4389a1b57 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -7,6 +7,7 @@ import { open, readdir, readFile, + rename, rm, stat, writeFile, @@ -14,11 +15,7 @@ import { import { dirname, join } from "node:path"; import { pipeline } from "node:stream/promises"; import { atomicTempPath, refuseReadOnly } from "@hyperframes/core/atomic-file"; -import { - isWindowsHold, - renameWithRetry, - renameWithRetryAsync, -} from "@hyperframes/core/rename-retry"; +import { isWindowsHold, renameWithRetryAsync } from "@hyperframes/core/rename-retry"; import { stopServingAndClose } from "../helpers/servedFiles.js"; /** File contents stored once by sha256, text and binary alike. */ @@ -111,9 +108,13 @@ async function cloneOrCopy( try { await copyFile(from, temp, constants.COPYFILE_FICLONE); await stopServingAndClose(to); - beforeReplace?.(); try { - renameWithRetry(temp, to); + await renameWithRetryAsync(temp, to, { + rename: async (from, to) => { + beforeReplace?.(); + await rename(from, to); + }, + }); } catch (error) { if (!isWindowsHold(error)) throw error; await writeInPlace(temp, to, hash); diff --git a/packages/studio-server/src/routes/files.heldFile.test.ts b/packages/studio-server/src/routes/files.heldFile.test.ts index 62dc7756a9..43bbbc4771 100644 --- a/packages/studio-server/src/routes/files.heldFile.test.ts +++ b/packages/studio-server/src/routes/files.heldFile.test.ts @@ -1,10 +1,18 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { Hono } from "hono"; import { type ChildProcess, spawn, spawnSync } from "node:child_process"; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, renameSync: vi.fn(actual.renameSync) }; +}); + +import * as fs from "node:fs"; import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { registerFileRoutes } from "./files"; +import { fileContentVersion } from "../helpers/fileVersion"; import type { StudioApiAdapter } from "../types"; const cleanups: Array<() => void | Promise> = []; @@ -12,7 +20,7 @@ afterEach(async () => { for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); }); -function fixture(name: string) { +function fixture(name: string, onError?: (error: Error) => void) { const project = mkdtempSync(join(tmpdir(), "hf-held-file-")); mkdirSync(join(project, "sub")); const file = join(project, name); @@ -28,6 +36,11 @@ function fixture(name: string) { startRender: () => ({ id: "job", status: "rendering", progress: 0, outputPath: "out.mp4" }), }; const app = new Hono(); + if (onError) + app.onError((error, c) => { + onError(error); + return c.json({ error: "failed" }, 500); + }); registerFileRoutes(app, adapter); const get = (query = "") => app.request(`http://localhost/projects/demo/files/${name}${query}`); const put = () => @@ -42,7 +55,31 @@ function fixture(name: string) { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ targets: [{ id }] }), }); - return { file, get, put, remove }; + const patch = (color: string) => + app.request(`http://localhost/projects/demo/file-mutations/patch-element/${name}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + target: { id: "title" }, + operations: [{ type: "inline-style", property: "color", value: color }], + }), + }); + const cut = (version: string) => + app.request("http://localhost/projects/demo/file-mutations/split-batch", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + transactionToken: "cut", + files: [ + { + path: name, + expectedVersion: version, + targets: [{ target: { id: "a" }, splitTime: 2, elementStart: 0, elementDuration: 4 }], + }, + ], + }), + }); + return { file, get, put, remove, patch, cut }; } const answered = async (response: Response) => ({ @@ -119,3 +156,119 @@ describe.runIf(process.platform === "win32")("a file another program holds open expect(await answered(await put())).toEqual({ status: 403, why: "permission" }); }); }); + +/** Windows refusing every replace of `file` (EBUSY, as a scanner's open handle does) for `ms`. */ +function holdFor(file: string, ms: number) { + const platform = process.platform; + const realRenameSync = vi.mocked(fs.renameSync).getMockImplementation()!; + const releaseAt = Date.now() + ms; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + vi.mocked(fs.renameSync).mockImplementation((from, to) => { + if (to === file && Date.now() < releaseAt) { + throw Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" }); + } + return realRenameSync(from, to); + }); + cleanups.push(() => { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + vi.mocked(fs.renameSync).mockImplementation(realRenameSync); + }); +} + +/** The longest the event loop went without running a 5 ms timer while `work` ran. */ +async function longestStall(work: () => Promise) { + let last = performance.now(); + let longest = 0; + const tick = setInterval(() => { + const now = performance.now(); + longest = Math.max(longest, now - last); + last = now; + }, 5); + try { + await work(); + } finally { + clearInterval(tick); + } + return longest; +} + +describe("an element edit while Windows briefly holds the file", () => { + const edited = '
kept
'; + + it.each([200, 3_000])( + "still saves the edit when the hold clears after %i ms, and reports nothing", + async (ms) => { + const reported: unknown[] = []; + const { file, remove } = fixture("held.html", (error) => reported.push(error)); + writeFileSync(file, `${edited}

tag

`); + holdFor(file, ms); + expect((await remove("gone")).status).toBe(200); + expect(readFileSync(file, "utf8")).not.toContain("tag"); + expect(reported).toEqual([]); + }, + 10_000, + ); + + it("still saves a cut, whose route answers a failed write itself, when the hold clears", async () => { + const { file, cut } = fixture("held.html"); + const before = '
A
'; + writeFileSync(file, before); + holdFor(file, 200); + expect((await cut(fileContentVersion(before))).status).toBe(200); + expect(readFileSync(file, "utf8").match(/data-duration/g)).toHaveLength(2); + }, 10_000); + + it("keeps a later edit to the project behind the held one, so the later edit stands", async () => { + const { file, patch } = fixture("held.html"); + writeFileSync(file, '

A

'); + holdFor(file, 1_000); + const order: string[] = []; + const send = (color: string) => + patch(color).then((response) => { + order.push(color); + return response.status; + }); + const first = send("red"); + await new Promise((resolve) => setTimeout(resolve, 1_050)); + const second = send("blue"); + expect([await first, await second]).toEqual([200, 200]); + expect(order).toEqual(["red", "blue"]); + expect(readFileSync(file, "utf8")).toContain("color: blue"); + }, 10_000); + + // fails without the fix: each retry slept the whole process, up to 640 ms in one go. + it("keeps the app answering while it waits", async () => { + const { file, remove } = fixture("held.html"); + writeFileSync(file, `${edited}

tag

`); + holdFor(file, 1_500); + expect( + await longestStall(async () => expect((await remove("gone")).status).toBe(200)), + ).toBeLessThan(100); + }, 10_000); + + it("answers 423 busy once the hold outlasts the wait, reports it once, and leaves the file as it was", async () => { + const reported: string[] = []; + const { file, remove } = fixture("held.html", (error) => + reported.push(String((error as NodeJS.ErrnoException).code)), + ); + writeFileSync(file, `${edited}

tag

`); + holdFor(file, 60_000); + expect(await answered(await remove("gone"))).toEqual({ status: 423, why: "busy" }); + expect(readFileSync(file, "utf8")).toBe(`${edited}

tag

`); + expect(reported).toEqual(["EBUSY"]); + }, 15_000); + + it.skipIf(process.getuid?.() === 0)( + "refuses a read-only file at once, without waiting", + async () => { + const { file, remove } = fixture("readonly.html"); + writeFileSync(file, `${edited}

tag

`); + chmodSync(file, 0o444); + cleanups.push(() => chmodSync(file, 0o644)); + holdFor(file, 0); + const started = performance.now(); + expect((await remove("gone")).status).toBe(403); + expect(performance.now() - started).toBeLessThan(500); + }, + ); +}); diff --git a/packages/studio-server/src/routes/files.heldReplay.test.ts b/packages/studio-server/src/routes/files.heldReplay.test.ts new file mode 100644 index 0000000000..5b76fd2ab1 --- /dev/null +++ b/packages/studio-server/src/routes/files.heldReplay.test.ts @@ -0,0 +1,84 @@ +// @vitest-environment node +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("node:fs", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, renameSync: vi.fn(actual.renameSync) }; +}); + +import * as fs from "node:fs"; +import { mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createStudioApi } from "../createStudioApi"; +import { fileContentVersion } from "../helpers/fileVersion"; +import { openProjectHistory } from "../history/projectHistory"; +import type { StudioApiAdapter } from "../types"; + +const cleanups: Array<() => unknown> = []; +afterEach(async () => { + for (const cleanup of cleanups.splice(0).reverse()) await cleanup(); +}); + +function tempDir(prefix: string) { + const dir = mkdtempSync(join(tmpdir(), prefix)); + cleanups.push(() => rmSync(dir, { recursive: true, force: true })); + return dir; +} + +/** Windows refusing every replace of `file` (EBUSY) for `ms`. */ +function holdFor(file: string, ms: number) { + const platform = process.platform; + const realRenameSync = vi.mocked(fs.renameSync).getMockImplementation()!; + const releaseAt = Date.now() + ms; + Object.defineProperty(process, "platform", { value: "win32", configurable: true }); + vi.mocked(fs.renameSync).mockImplementation((from, to) => { + if (to === file && Date.now() < releaseAt) { + throw Object.assign(new Error("EBUSY: resource busy or locked"), { code: "EBUSY" }); + } + return realRenameSync(from, to); + }); + cleanups.push(() => { + Object.defineProperty(process, "platform", { value: platform, configurable: true }); + vi.mocked(fs.renameSync).mockImplementation(realRenameSync); + }); +} + +describe("an element edit sent again after Windows held the file", () => { + it("applies once, keeps one backup and leaves one history entry", async () => { + const projectDir = tempDir("hf-held-replay-"); + const index = join(projectDir, "index.html"); + const before = '

A

'; + writeFileSync(index, before); + const history = await openProjectHistory({ + projectDir, + historyRoot: tempDir("hf-held-replay-history-"), + }); + cleanups.push(() => history.close()); + await history.flush(); + const api = createStudioApi({ + listProjects: () => [], + resolveProject: (id: string) => (id === "demo" ? { id, dir: projectDir } : null), + history: () => history, + } as unknown as StudioApiAdapter); + const post = (path: string, body: object) => + api.request(`/projects/demo${path}`, { method: "POST", body: JSON.stringify(body) }); + + holdFor(index, 200); + const patched = await post("/file-mutations/patch-element/index.html", { + target: { id: "title" }, + operations: [{ type: "inline-style", property: "z-index", value: "2" }], + }); + expect(patched.status).toBe(200); + await post("/history/claim", { + label: "Moved Title", + paths: ["index.html"], + overwrote: { "index.html": fileContentVersion(before) }, + }); + await history.flush(); + + expect(readFileSync(index, "utf8").match(/z-index/g)).toHaveLength(1); + expect(readdirSync(join(projectDir, ".hyperframes", "backup"))).toHaveLength(1); + expect(history.list().map((entry) => entry.label)).toEqual(["Moved Title"]); + }); +}); diff --git a/packages/studio-server/src/routes/files.ts b/packages/studio-server/src/routes/files.ts index c7abf56f49..856abf4578 100644 --- a/packages/studio-server/src/routes/files.ts +++ b/packages/studio-server/src/routes/files.ts @@ -31,7 +31,7 @@ import type { StudioApiAdapter } from "../types.js"; import { isAudioFile } from "../helpers/mime.js"; import { lengthIsTimeline } from "../helpers/compositionInputs.js"; import { createFileAtomically, replaceFileAtomically } from "@hyperframes/core/atomic-file"; -import { retryWindowsHold } from "@hyperframes/core/rename-retry"; +import { catchHeldRename, retryWindowsHold } from "@hyperframes/core/rename-retry"; import { generateWaveformCache } from "../helpers/waveform.js"; import { validateUploadedMediaBuffer } from "../helpers/mediaValidation.js"; import { @@ -2797,6 +2797,38 @@ async function processUploadedFiles( const MAX_TEXT_READ_BYTES = 32 * 1024 * 1024; const GIT_BINARY_SNIFF_BYTES = 8000; +const HELD_EDIT_WAITS = [10, 20, 40, 80, 160, 320, 640, 1280, 2560]; +const EDIT_ROUTE = + /\/projects\/[^/]+\/(files|file-mutations|gsap-mutations(-batch)?|gsap-mutation-rollback)\//; +const REPLAYED = "x-hyperframes-held-replay"; +const HELD = "x-hyperframes-held"; + +function isEdit(request: Request): boolean { + return ( + ["POST", "PUT"].includes(request.method) && + EDIT_ROUTE.test(new URL(request.url).pathname) && + !(request.headers.get("content-type") ?? "").startsWith("multipart/") + ); +} + +function keptForReplay(request: Request): () => Request { + const kept = request.clone(); + return () => { + const again = new Request(kept.clone(), { headers: new Headers(kept.headers) }); + again.headers.set(REPLAYED, "1"); + return again; + }; +} + +async function sendAgainOnceFree(api: Hono, replay: () => Request): Promise { + for (const ms of HELD_EDIT_WAITS) { + await new Promise((resolve) => setTimeout(resolve, ms)); + const again = await api.fetch(replay()); + if (!again.headers.has(HELD)) return again; + } + return null; +} + function heldFileAnswer(c: RouteContext, error: unknown) { const code = (error as NodeJS.ErrnoException | null)?.code; if (code === "EBUSY") return c.json({ error: "file in use", why: "busy" }, 423); @@ -2813,6 +2845,28 @@ export function registerFileRoutes(api: Hono, adapter: StudioApiAdapter): void { if (refusedWrite) c.res = refusedWrite; }); + const heldEdits = new Map>(); + api.use("/projects/:id/*", async (c, next) => { + if (!isEdit(c.req.raw)) return next(); + const project = c.req.param("id") ?? ""; + const replayed = c.req.raw.headers.has(REPLAYED); + while (!replayed && heldEdits.has(project)) await heldEdits.get(project); + const replay = replayed ? null : keptForReplay(c.req.raw); + const held = await catchHeldRename(next); + if (held === null || (c.finalized && c.res.ok)) return; + if (!replay) { + c.res = new Response(null, { status: 423, headers: { [HELD]: "1" } }); + return; + } + const waiting = sendAgainOnceFree(api, replay); + heldEdits.set(project, waiting); + const again = await waiting.finally(() => { + if (heldEdits.get(project) === waiting) heldEdits.delete(project); + }); + if (!again) throw held; + c.res = again; + }); + // ── Read ── api.get("/projects/:id/files/*", async (c) => { diff --git a/packages/studio-server/src/routes/freezeFrame.test.ts b/packages/studio-server/src/routes/freezeFrame.test.ts index 92ed21877f..c2ca0a2533 100644 --- a/packages/studio-server/src/routes/freezeFrame.test.ts +++ b/packages/studio-server/src/routes/freezeFrame.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { Hono } from "hono"; import { + chmodSync, existsSync, mkdirSync, mkdtempSync, @@ -173,6 +174,22 @@ describe("freeze-frame route", () => { expect(freezeFiles(dir)).toEqual([]); }); + it.skipIf(process.getuid?.() === 0)( + "removes the still it extracted when the write throws, as for a read-only page", + async () => { + const { dir, post } = setup(pngWriter([])); + chmodSync(join(dir, "index.html"), 0o444); + const res = await post({ + path: "index.html", + expectedVersion: fileContentVersion(html), + target: { id: "talk" }, + playhead: 2.5, + }); + expect(res.status).toBe(500); + expect(freezeFiles(dir)).toEqual([]); + }, + ); + it("keeps a traversal clip id inside assets/freeze, for the ffmpeg output and the still's src", async () => { const evil = html.replace('id="talk"', 'id="../../../../outside/frame"'); const calls: string[][] = []; diff --git a/packages/studio-server/src/routes/freezeFrame.ts b/packages/studio-server/src/routes/freezeFrame.ts index 14d0193e9c..bc22d9ddb4 100644 --- a/packages/studio-server/src/routes/freezeFrame.ts +++ b/packages/studio-server/src/routes/freezeFrame.ts @@ -218,9 +218,15 @@ export function registerFreezeFrameRoutes( return c.json({ error }, status); }; if (!folded) return failAndRemoveStill("Freeze target was not found in the file", 400); - const written = writeFolded(project.dir, absPath, body.path, before, folded.html, { - token: body.transactionToken ?? c.req.header("X-Hyperframes-Write-Token"), - }); + let written: ReturnType; + try { + written = writeFolded(project.dir, absPath, body.path, before, folded.html, { + token: body.transactionToken ?? c.req.header("X-Hyperframes-Write-Token"), + }); + } catch (error) { + rmSync(imagePath, { force: true }); + throw error; + } if ("error" in written) return failAndRemoveStill(written.error, written.status); const { version, writeToken, backupPath } = written; return c.json({ From a3fa250b25c7c42dedaf4b19b5df65d80260cc65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Sat, 14 Nov 2026 16:18:22 +0000 Subject: [PATCH 17/20] fix(producer): a clip whose file name has a space or accent is mixed into the export once (#7747) * fix(producer): a clip whose file name has a space or accent is mixed into the export once * fix(producer): compare a clip's src decoded, keeping the authored spelling either way --- packages/producer/src/services/fileServer.ts | 25 +++------- .../services/render/stages/probeStage.test.ts | 48 +++++++++++++++++++ .../src/services/render/stages/probeStage.ts | 5 +- packages/producer/src/services/servedPath.ts | 14 ++++++ 4 files changed, 71 insertions(+), 21 deletions(-) create mode 100644 packages/producer/src/services/servedPath.ts diff --git a/packages/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 2ba63b715c..5ddeabbbdf 100644 --- a/packages/producer/src/services/fileServer.ts +++ b/packages/producer/src/services/fileServer.ts @@ -32,6 +32,7 @@ import { fpsToNumber, type Fps } from "@hyperframes/core"; import { stripSdrPngColourTags } from "@hyperframes/core/compiler/png-colour-tags"; import { openRangeEnd } from "@hyperframes/studio-server/media-range"; import { getVerifiedHyperframeRuntimeSource } from "./hyperframeRuntimeLoader.js"; +import { servedPath } from "./servedPath.js"; import { getHfEarlyStub } from "../generated/hf-early-stub-inline.js"; import { defaultLogger, type ProducerLogger } from "../logger.js"; import { refusesOutsideLink } from "../utils/paths.js"; @@ -819,25 +820,11 @@ export function createFileServer(options: FileServerOptions): Promise { - try { - return decodeURIComponent(seg); - } catch { - return seg; - } - }) - .join("/"); - - // Resolve against compiledDir first (preferred — overrides project files - // for compositions emitted by the build), then projectDir as fallback. - // Each candidate is rejected if `..` segments push it outside the - // intended root: `path.join` normalizes traversal but does not enforce - // containment, so a request like `GET /../etc/passwd` would otherwise - // be served straight off the filesystem. compiledDir stays lexical: it - // links extracted frame directories in from the work directory. + const relativePath = servedPath(requestPath); + + // compiledDir (the build's output) wins over projectDir. A candidate `..` pushes outside its root is + // rejected: `path.join` does not enforce containment, so `GET /../etc/passwd` would be served. + // compiledDir stays lexical: it links extracted frame directories in from the work directory. let file: ReturnType = null; for (const root of compiledDir ? [compiledDir, projectDir] : [projectDir]) { const candidate = join(root, relativePath); diff --git a/packages/producer/src/services/render/stages/probeStage.test.ts b/packages/producer/src/services/render/stages/probeStage.test.ts index 270155d51d..b3ea9b75ad 100644 --- a/packages/producer/src/services/render/stages/probeStage.test.ts +++ b/packages/producer/src/services/render/stages/probeStage.test.ts @@ -826,6 +826,54 @@ describe("runProbeStage — forceScreenshot threading", () => { expect(mediaPreflightComposition).toBe(input.composition); }); + it.each(["take one.mp4", "café.mp4", "take%20one.mp4", "caf%C3%A9.mp4"])( + "mixes the sound of a clip whose src is written %s once, however the browser spells it", + async (name) => { + resetRetryMocks(); + const timing = { start: 0, end: 5, duration: 5, mediaStart: 0, loop: false, volume: 1 }; + browserMediaResults = [ + { + id: "clip", + tagName: "video", + src: new URL(name, "http://127.0.0.1:0/").href, + hasAudio: true, + muted: false, + ...timing, + }, + ]; + const { runProbeStage } = await import("./probeStage.js"); + const { appendAutoDetectedVideoAudio } = await import("./extractVideosStage.js"); + const input = makeProbeInput({}); + input.composition.duration = 5; + input.composition.videos.push({ + id: "clip", + src: name, + start: 0, + end: 5, + mediaStart: 0, + loop: false, + hasAudio: true, + }); + input.composition.audios.push({ + id: videoAudioId("clip"), + src: name, + start: 0, + end: 5, + mediaStart: 0, + layer: 0, + type: "video", + }); + + await runProbeStage({ ...input, measuresShownBoxes: true }); + appendAutoDetectedVideoAudio(input.composition, [ + { videoId: "clip", metadata: { hasAudio: true } }, + ]); + + expect(input.composition.videos[0]?.src).toBe(name); + expect(input.composition.audios.map((audio) => audio.id)).toEqual([videoAudioId("clip")]); + }, + ); + it("uses selected intrinsic duration only when the variable-bound duration was inferred", async () => { resetRetryMocks(); const media = ( diff --git a/packages/producer/src/services/render/stages/probeStage.ts b/packages/producer/src/services/render/stages/probeStage.ts index 3ae38fb78d..7188a1b0b8 100644 --- a/packages/producer/src/services/render/stages/probeStage.ts +++ b/packages/producer/src/services/render/stages/probeStage.ts @@ -73,6 +73,7 @@ import { type FileServerHandle, VIRTUAL_TIME_SHIM, } from "../../fileServer.js"; +import { servedPath } from "../../servedPath.js"; import type { ProducerLogger } from "../../../logger.js"; import { BROWSER_MEDIA_EPSILON, @@ -668,7 +669,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise v.id === el.id); if (existing) { - const sourceChanged = existing.src !== src; + const sourceChanged = servedPath(existing.src) !== servedPath(src); if (sourceChanged) { existing.src = src; } @@ -718,7 +719,7 @@ export async function runProbeStage(input: ProbeStageInput): Promise a.id === el.id); if (existing) { - const sourceChanged = existing.src !== src; + const sourceChanged = servedPath(existing.src) !== servedPath(src); if (sourceChanged) { existing.src = src; } diff --git a/packages/producer/src/services/servedPath.ts b/packages/producer/src/services/servedPath.ts new file mode 100644 index 0000000000..dbb6b12264 --- /dev/null +++ b/packages/producer/src/services/servedPath.ts @@ -0,0 +1,14 @@ +/** The project-relative path a render file server URL path names: each segment decoded, as the browser encoded it. */ +export function servedPath(urlPath: string): string { + return urlPath + .replace(/^\//, "") + .split("/") + .map((seg) => { + try { + return decodeURIComponent(seg); + } catch { + return seg; + } + }) + .join("/"); +} From 691bb0c0b0a93b1869c0a80c2efcf8571fd4626c Mon Sep 17 00:00:00 2001 From: robinebers Date: Sat, 14 Nov 2026 17:14:01 +0000 Subject: [PATCH 18/20] fix(core): a clip the preview lets go of stays unloaded in Firefox Releasing a clip dropped its src, called load() and set the src again. Firefox treats preload="none" as "metadata" while an explicit load() is pending, so every release fetched the clip's metadata and first frame. The runtime then saw a far clip loading and released it again, in a loop: about 20,700 media requests over one 2:16 film, with 2-4 s stalls at cuts. Setting src already runs the media load algorithm, which ends the fetch, so the release now sets it again without load(). Chrome and Safari behave as before: Blink clears its ignore-preload-none flag when src is set, and WebKit's src set cancels the prepareToPlay that load() queued. To reproduce: in Firefox, give a