diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 039290bcaab..d6ff6d3878c 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.46", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json index 4905769f25c..0c9745b415a 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.46", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/.cursor-plugin/plugin.json b/.cursor-plugin/plugin.json index f551aa3b2bb..b6cfd3b2f45 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.46", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com" diff --git a/bun.lock b/bun.lock index e405d8af894..6f906ddf16b 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/docs/changelog.mdx b/docs/changelog.mdx index 502b3bb56a9..5e55e6db65d 100644 --- a/docs/changelog.mdx +++ b/docs/changelog.mdx @@ -8,6 +8,65 @@ 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). + + + +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). + + + +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). + + .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/docs/packages/cli.mdx b/docs/packages/cli.mdx index 092ea058ce5..9e2a87ad345 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/gemini-extension.json b/gemini-extension.json index 56fef5512ed..aa56c0eeb61 100644 --- a/gemini-extension.json +++ b/gemini-extension.json @@ -1,5 +1,5 @@ { "name": "hyperframes", - "version": "0.9.43", + "version": "0.9.46", "description": "HyperFrames by HeyGen. Write HTML, render video. Compositions, GSAP and runtime adapter animations, captions, voiceovers, audio-reactive visuals, and website capture for HyperFrames." } diff --git a/packages/aws-lambda/package.json b/packages/aws-lambda/package.json index 7005610d0aa..4b0e0f83ef0 100644 --- a/packages/aws-lambda/package.json +++ b/packages/aws-lambda/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/aws-lambda", - "version": "0.9.43", + "version": "0.9.46", "description": "AWS Lambda adapter for HyperFrames distributed rendering — handler, client-side SDK, and CDK construct.", "repository": { "type": "git", diff --git a/packages/cli/package.json b/packages/cli/package.json index 59c29f725d6..1c2a2f18a98 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/cli", - "version": "0.9.43", + "version": "0.9.46", "description": "Make videos with code and AI agents: write HTML, render MP4. Open source.", "keywords": [ "ai-agents", diff --git a/packages/cli/src/registry/captionLastGroup.chromium.test.ts b/packages/cli/src/registry/captionLastGroup.chromium.test.ts index a602b862afa..c84aa36d6ce 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/packages/cli/src/utils/checkBrowser.test.ts b/packages/cli/src/utils/checkBrowser.test.ts index 670f91c7931..283213d3dec 100644 --- a/packages/cli/src/utils/checkBrowser.test.ts +++ b/packages/cli/src/utils/checkBrowser.test.ts @@ -1078,6 +1078,43 @@ it("elevates and deduplicates WebGPU validation warnings while preserving ordina ); }); +// 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(); + 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"); + 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); + } + }, + ); + 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([otherDriverNote.text(), 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 c20fd906afd..1d3de8ca40b 100644 --- a/packages/cli/src/utils/checkBrowser.ts +++ b/packages/cli/src/utils/checkBrowser.ts @@ -869,6 +869,9 @@ function runtimeInfoFindingCode(text: string): string | null { : "media_proxy_fallback"; } +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); page.on("console", (message) => { @@ -885,6 +888,7 @@ function wireRuntimeListeners(page: Page, drafts: RuntimeDraft[], clock: Runtime line: location.lineNumber, }); } else if (type === "warn") { + if (READ_PIXELS_STALL.test(text)) return; const location = message.location(); const webGpuFailure = isWebGpuRuntimeFailure(text); pushRuntimeDraft(drafts, { diff --git a/packages/cli/src/utils/craftAudit.test.ts b/packages/cli/src/utils/craftAudit.test.ts index 4d298a4fce2..c913686fcfd 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 24dddbf55fb..9f335cf5a7a 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; diff --git a/packages/cli/src/utils/reject-unknown-flags.test.ts b/packages/cli/src/utils/reject-unknown-flags.test.ts index aed4ecac859..31d288461fb 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,63 @@ 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.", + }), + ); + }); + + // 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" } }, + } 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 0080320f5c3..7b98fd0717d 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 visibleLongFlags(args: ArgsDef | undefined): string[] { + return Object.entries(args ?? {}) + .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. @@ -74,9 +83,10 @@ 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, visible: string[], path: string): CliUsageError { const command = `hyperframes ${path ? `${path} ` : ""}`.trim(); if (flag === "--json") { return new CliUsageError(`${command} has no JSON output.`, { @@ -84,13 +94,17 @@ function unknownFlagError(flag: string, known: Set, path: string): CliUs 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); - const help = `Run 'hyperframes ${path ? `${path} ` : ""}--help' to see its flags.`; - const hint = match ? `Did you mean --${match}? ${help}` : help; + FLAG_SYNONYMS[name]?.find((s) => visible.includes(s)) ?? + (flag.startsWith("--") ? closestMatch(name, visible) : undefined); + const hint = [ + match && `Did you mean --${match}?`, + visible.length > 0 && `Its flags: ${visible.map((f) => `--${f}`).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 +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, path); + if (bad) throw unknownFlagError(bad, visibleLongFlags(args), path); } } diff --git a/packages/core/package.json b/packages/core/package.json index ab35a8d1823..b69fc2d16fc 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/core", - "version": "0.9.43", + "version": "0.9.46", "description": "", "repository": { "type": "git", diff --git a/packages/core/src/atomicFile.test.ts b/packages/core/src/atomicFile.test.ts index 83a55b23864..0d4d64917cc 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[] = []; @@ -242,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 3416d000f8f..bc4933b7493 100644 --- a/packages/core/src/atomicFile.ts +++ b/packages/core/src/atomicFile.ts @@ -9,11 +9,23 @@ type DirectFileSystem = 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, @@ -64,7 +85,7 @@ export function createFileAtomically( try { operations.linkSync(tempPath, filePath); } catch (error) { - if (!NO_HARD_LINKS.has(errorCode(error))) throw error; + if (!cannotHardLink(error)) throw error; createDirectly(filePath, content, operations); } try { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index de1eba65dec..692a937600c 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/previewProxyBox.ts b/packages/core/src/previewProxyBox.ts index be011b27cc6..1b27e536de7 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/projectRule.test.ts b/packages/core/src/projectRule.test.ts index 9179af04d98..950b385041b 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 732d37c4d2b..c36c0e88d82 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 636f67540dc..282e78202bf 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/packages/core/src/renameWithRetry.test.ts b/packages/core/src/renameWithRetry.test.ts index e7627bc9268..883b1a1f168 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 b6bbf1310f7..93677f60c76 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/core/src/runtime/entry.test.ts b/packages/core/src/runtime/entry.test.ts index 341950d378f..755cb98c2f1 100644 --- a/packages/core/src/runtime/entry.test.ts +++ b/packages/core/src/runtime/entry.test.ts @@ -4,6 +4,7 @@ import { mediaElementTime } from "./mediaClock"; import { HF_COLOR_GRADING_ATTR, serializeHfColorGrading } from "../colorGrading"; import { PLAYER_FRAME_ATTR, + PLAYER_FRAME_NAME, STUDIO_PREVIEW_LAZY_ATTR, STUDIO_PREVIEW_CAPTURE_MODE, STUDIO_PREVIEW_MARK_META, @@ -76,6 +77,7 @@ function resetRuntimeGlobals(): void { delete win.__hyperframeRuntimeBootstrapped; delete win.__hfFirstPassHidden; delete (document as { readyState?: unknown }).readyState; + window.name = ""; } // The first evaluation transforms the whole runtime graph. Paying it while the file loads keeps @@ -488,9 +490,9 @@ describe("runtime entry", () => { srcWrites.observe(now, { attributeFilter: ["src"] }); window.__player?.seek(7.6); expect(armed(now, soon, later)).toEqual([false, false, true]); - // The two that left are reloaded empty with src dropped for the reload, ending any fetch in flight. + // The two that left are released: now by setting its src again, soon (no src) by reloading empty. expect([now.preload, soon.preload]).toEqual(["none", "none"]); - expect(loadsOf(now, soon, later)).toEqual([2, 2, 1]); + expect(loadsOf(now, soon, later)).toEqual([1, 2, 1]); expect(srcWrites.takeRecords()).toHaveLength(2); expect(now.getAttribute("src")).toBe("now.mp4"); }); @@ -528,39 +530,45 @@ describe("runtime entry", () => { if (userAgent === "safari") asSafari(); }; - it("in the player on Safari a file keeps the take on screen and the next one loading, and frees the rest", async () => { - spyLoad(); - inPlayerFrame("safari"); - const [first, second, third] = takesOfOneFile(); - - await evaluateRuntime(); - expect([first, second, third].map((take) => armed(...take))).toEqual([ - [true, true], - [true, true], - [false, false], - ]); - - window.__player?.seek(3.5); - const video = second![0]!; - Object.defineProperty(video, "readyState", { value: 4, configurable: true }); - video.dispatchEvent(new Event("seeked")); - expect([first, second, third].map((take) => armed(...take))).toEqual([ - [false, false], - [true, true], - [true, true], - ]); - }); + // Chromium and Firefox keep a paused clip's request open with its unread bytes, and every + // request to one host shares one HTTP/2 or HTTP/3 window: with each take loaded, the window + // fills and the next take's request is never answered. + it.each(["safari", "chrome"] as const)( + "in the player on %s a file keeps the take on screen and the next one loading, and frees the rest", + async (userAgent) => { + spyLoad(); + inPlayerFrame(userAgent); + const [first, second, third] = takesOfOneFile(); + + await evaluateRuntime(); + expect([first, second, third].map((take) => armed(...take))).toEqual([ + [true, true], + [true, true], + [false, false], + ]); + + window.__player?.seek(3.5); + const video = second![0]!; + Object.defineProperty(video, "readyState", { value: 4, configurable: true }); + video.dispatchEvent(new Event("seeked")); + expect([first, second, third].map((take) => armed(...take))).toEqual([ + [false, false], + [true, true], + [true, true], + ]); + }, + ); - it("in the player on Chromium every take of a file loads at parse", async () => { + it("holds back the takes after the next in a player frame the composition cannot read, as with an opaque sandbox", async () => { spyLoad(); - inPlayerFrame("chrome"); + window.name = PLAYER_FRAME_NAME; const takes = takesOfOneFile(); await evaluateRuntime(); expect(takes.map((take) => armed(...take))).toEqual([ [true, true], [true, true], - [true, true], + [false, false], ]); }); @@ -1095,30 +1103,21 @@ describe("runtime entry", () => { expect([first.preload, later.preload, untrimmed.preload]).toEqual(["", "none", ""]); }); - it("gives each parsed clip preload none in the player's frame, where no clip fetches ahead of the first take", async () => { - const frame = document.createElement("iframe"); - frame.setAttribute(PLAYER_FRAME_ATTR, ""); - vi.spyOn(window, "frameElement", "get").mockReturnValue(frame); - asSafari(); - const root = mountRoot(); - timed(root, "div", "0"); - await evaluateRuntime(); - const [first, later] = videos(root, "0", "5"); - await Promise.resolve(); - expect([first.preload, later.preload]).toEqual(["", "none"]); - }); - - it("leaves Chromium loading every clip at parse in the player's frame", async () => { - const frame = document.createElement("iframe"); - frame.setAttribute(PLAYER_FRAME_ATTR, ""); - vi.spyOn(window, "frameElement", "get").mockReturnValue(frame); - const root = mountRoot(); - timed(root, "div", "0"); - await evaluateRuntime(); - const [first, later] = videos(root, "0", "5"); - await Promise.resolve(); - expect([first.preload, later.preload]).toEqual(["", ""]); - }); + it.each(["safari", "chrome"] as const)( + "gives each parsed clip preload none in the player's frame on %s, where no clip fetches ahead of the first take", + async (userAgent) => { + const frame = document.createElement("iframe"); + frame.setAttribute(PLAYER_FRAME_ATTR, ""); + vi.spyOn(window, "frameElement", "get").mockReturnValue(frame); + if (userAgent === "safari") asSafari(); + const root = mountRoot(); + timed(root, "div", "0"); + await evaluateRuntime(); + const [first, later] = videos(root, "0", "5"); + await Promise.resolve(); + expect([first.preload, later.preload]).toEqual(["", "none"]); + }, + ); it("leaves a frame the player did not mark loading every clip at parse", async () => { vi.spyOn(window, "frameElement", "get").mockReturnValue(document.createElement("iframe")); diff --git a/packages/core/src/runtime/mediaProxy.test.ts b/packages/core/src/runtime/mediaProxy.test.ts index af637fa8ca6..60ef1fdf089 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 6100df4223f..3fd05e9322b 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.test.ts b/packages/core/src/runtime/preloadMedia.test.ts index 7511f8894ac..54b888be67b 100644 --- a/packages/core/src/runtime/preloadMedia.test.ts +++ b/packages/core/src/runtime/preloadMedia.test.ts @@ -108,14 +108,21 @@ describe("initial media preload", () => { }); describe("releasing a clip's download", () => { - it("reloads a src clip with its src dropped, then puts the src back", () => { + const srcWrites = (el: Element) => { + const observer = new MutationObserver(() => {}); + observer.observe(el, { attributeFilter: ["src"] }); + return () => observer.takeRecords().length; + }; + + it("reloads a src clip by setting its src again, not by load(), after which Firefox fetches metadata whatever the preload", () => { const video = document.createElement("video"); video.setAttribute("src", "a.mp4"); - const atLoad: (string | null)[] = []; - video.load = () => atLoad.push(video.getAttribute("src")); + video.load = vi.fn(); + const writes = srcWrites(video); releaseMedia(video); - expect(atLoad).toEqual([null]); + expect(writes()).toBeGreaterThan(0); + expect(video.load).not.toHaveBeenCalled(); expect(video.getAttribute("src")).toBe("a.mp4"); }); @@ -127,9 +134,11 @@ describe("releasing a clip's download", () => { value: { length: 1, start: () => 0, end: () => 6.67 }, }); video.load = vi.fn(); + const writes = srcWrites(video); releaseMedia(video); expect(video.load).not.toHaveBeenCalled(); + expect(writes()).toBe(0); expect(video.getAttribute("src")).toBe("a.mp4"); }); @@ -141,9 +150,10 @@ describe("releasing a clip's download", () => { value: { length: 1, start: () => 0, end: () => 6.67 }, }); video.load = vi.fn(); + const writes = srcWrites(video); releaseMedia(video, true); - expect(video.load).toHaveBeenCalledTimes(1); + expect(writes()).toBeGreaterThan(0); expect(video.getAttribute("src")).toBe("a.mp4"); }); @@ -158,9 +168,10 @@ describe("releasing a clip's download", () => { value: { length: 1, start: () => from, end: () => to }, }); video.load = vi.fn(); + const writes = srcWrites(video); releaseMedia(video); - expect(video.load).toHaveBeenCalledTimes(1); + expect(writes()).toBeGreaterThan(0); }); it("leaves a clip on children, their order and its markup untouched", () => { diff --git a/packages/core/src/runtime/preloadMedia.ts b/packages/core/src/runtime/preloadMedia.ts index 935f0a13716..33d1e053154 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"; @@ -98,8 +99,10 @@ export function releaseMedia(media: HTMLMediaElement, whenBuffered = false): voi if (media.querySelector("source") || (!whenBuffered && wholeFileBuffered(media))) return; if (holdsWhenIdle(media)) return holdUntilDue(media); const src = media.getAttribute("src"); - stopMediaDownload(media); - if (src !== null) media.setAttribute("src", src); + if (src === null) return stopMediaDownload(media); + cancelPlaybackStart(media); + media.removeAttribute("src"); + media.setAttribute("src", src); } export function lengthIsAuthored(media: Element): boolean { @@ -171,7 +174,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 eae5759c8e1..31e09254e56 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, diff --git a/packages/core/src/studioPreviewMark.ts b/packages/core/src/studioPreviewMark.ts index 101843de728..859105eb758 100644 --- a/packages/core/src/studioPreviewMark.ts +++ b/packages/core/src/studioPreviewMark.ts @@ -1,5 +1,3 @@ -import { isWebKit } from "./runtime/mediaClock.js"; - export const STUDIO_PREVIEW_MARK_META = "hyperframes-studio-preview"; export const PREVIEW_SEEK_HOLD_ATTR = "data-hf-preview-seek-hold"; export const PREVIEW_VIDEO_FRAME_ATTR = "data-hf-preview-video-frame"; @@ -18,9 +16,14 @@ export function getLogicalOpacity(el: Element, computed: CSSStyleDeclaration): s return computed.opacity; } export const PLAYER_FRAME_ATTR = "data-hf-player-frame"; +export const PLAYER_FRAME_NAME = "hyperframes-player"; export function inPlayerFrame(doc: Document): boolean { - return isWebKit() && doc.defaultView?.frameElement?.hasAttribute(PLAYER_FRAME_ATTR) === true; + const frame = doc.defaultView; + return ( + frame?.name === PLAYER_FRAME_NAME || + frame?.frameElement?.hasAttribute(PLAYER_FRAME_ATTR) === true + ); } export function defersMediaIn(doc: Document): boolean { diff --git a/packages/engine/package.json b/packages/engine/package.json index 15836c08df2..c9757e8a062 100644 --- a/packages/engine/package.json +++ b/packages/engine/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/engine", - "version": "0.9.43", + "version": "0.9.46", "description": "Seekable web page to video rendering engine (Puppeteer + FFmpeg)", "repository": { "type": "git", diff --git a/packages/engine/src/services/captureFailure.test.ts b/packages/engine/src/services/captureFailure.test.ts index 3ffa9ad87d5..730b135ffc0 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 5c19250b2dd..f4309fd2195 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( diff --git a/packages/engine/src/utils/urlDownloader.test.ts b/packages/engine/src/utils/urlDownloader.test.ts index 737145b1e5c..7ca33b7d327 100644 --- a/packages/engine/src/utils/urlDownloader.test.ts +++ b/packages/engine/src/utils/urlDownloader.test.ts @@ -29,6 +29,7 @@ const fsRaceControls = vi.hoisted(() => ({ 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 6493cbf1cec..f5427fe957c 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/gcp-cloud-run/package.json b/packages/gcp-cloud-run/package.json index 7a246454be8..fafd896cae7 100644 --- a/packages/gcp-cloud-run/package.json +++ b/packages/gcp-cloud-run/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/gcp-cloud-run", - "version": "0.9.43", + "version": "0.9.46", "description": "Google Cloud Run + Workflows adapter for HyperFrames distributed rendering — request handler, client-side SDK, and Terraform module.", "repository": { "type": "git", diff --git a/packages/inspector/package.json b/packages/inspector/package.json index 8069734e095..0271ed85230 100644 --- a/packages/inspector/package.json +++ b/packages/inspector/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/inspector", - "version": "0.9.43", + "version": "0.9.46", "repository": { "type": "git", "url": "https://github.com/heygen-com/hyperframes", diff --git a/packages/lint/package.json b/packages/lint/package.json index 4270ef0a96d..2dc51a8898f 100644 --- a/packages/lint/package.json +++ b/packages/lint/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/lint", - "version": "0.9.43", + "version": "0.9.46", "repository": { "type": "git", "url": "https://github.com/heygen-com/hyperframes", diff --git a/packages/parsers/package.json b/packages/parsers/package.json index 03598b7e884..9bf288b2439 100644 --- a/packages/parsers/package.json +++ b/packages/parsers/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/parsers", - "version": "0.9.43", + "version": "0.9.46", "repository": { "type": "git", "url": "https://github.com/heygen-com/hyperframes", diff --git a/packages/player/package.json b/packages/player/package.json index de4f802b4c8..37638af94f3 100644 --- a/packages/player/package.json +++ b/packages/player/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/player", - "version": "0.9.43", + "version": "0.9.46", "description": "Embeddable web component for HyperFrames compositions", "repository": { "type": "git", diff --git a/packages/player/src/hyperframes-player.test.ts b/packages/player/src/hyperframes-player.test.ts index 56f84e3a48b..c4c049178d8 100644 --- a/packages/player/src/hyperframes-player.test.ts +++ b/packages/player/src/hyperframes-player.test.ts @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach, afterEach, type MockInstance } from "vitest"; import { mediaElementTime } from "@hyperframes/core/runtime/media-clock"; +import { PLAYER_FRAME_NAME } from "@hyperframes/core/studio-preview-mark"; import { formatTime, formatSpeed, SPEED_PRESETS } from "./controls.js"; // Install a stubbed contentDocument getter on the given iframe element. The @@ -3669,6 +3670,11 @@ describe("HyperframesPlayer retained runtime data", () => { expect(player.iframeElement.sandbox.contains("allow-same-origin")).toBe(true); }); + it("names its frame, the one mark a composition in an opaque sandbox can still read", () => { + player.setAttribute("sandbox-origin", "opaque"); + expect(player.iframeElement.name).toBe(PLAYER_FRAME_NAME); + }); + it("treats every non-null sandbox-origin value as restrictive", () => { player.setAttribute("sandbox-origin", "opaqu"); expect(player.iframeElement.sandbox.contains("allow-same-origin")).toBe(false); diff --git a/packages/player/src/iframe-dom.ts b/packages/player/src/iframe-dom.ts index f9665eebc8a..c5ab73ceb84 100644 --- a/packages/player/src/iframe-dom.ts +++ b/packages/player/src/iframe-dom.ts @@ -4,7 +4,7 @@ * Keeps constructor boilerplate out of the web component class body. */ -import { PLAYER_FRAME_ATTR } from "@hyperframes/core/studio-preview-mark"; +import { PLAYER_FRAME_ATTR, PLAYER_FRAME_NAME } from "@hyperframes/core/studio-preview-mark"; // Cached Constructable Stylesheet shared across all player instances. let _sharedSheet: CSSStyleSheet | null = null; @@ -52,6 +52,7 @@ export function createCompositionIframe(): { iframe.referrerPolicy = "no-referrer"; iframe.title = "HyperFrames Composition"; iframe.setAttribute(PLAYER_FRAME_ATTR, ""); + iframe.name = PLAYER_FRAME_NAME; container.appendChild(iframe); return { container, iframe }; diff --git a/packages/producer/package.json b/packages/producer/package.json index f0be5e48da7..a3231ce5dbd 100644 --- a/packages/producer/package.json +++ b/packages/producer/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/producer", - "version": "0.9.43", + "version": "0.9.46", "description": "HTML-to-video rendering engine using Chrome's BeginFrame API", "repository": { "type": "git", diff --git a/packages/producer/src/services/distributed/planV2.test.ts b/packages/producer/src/services/distributed/planV2.test.ts index e503362469a..a4c26577830 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 cc6a00915da..dc6a2bf36ea 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/producer/src/services/fileServer.ts b/packages/producer/src/services/fileServer.ts index 2ba63b715c0..5ddeabbbdfb 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 270155d51dc..b3ea9b75adb 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 3ae38fb78dc..7188a1b0b80 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 00000000000..dbb6b122648 --- /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("/"); +} diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 1c18fd03fa6..48699bd59a7 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/sdk", - "version": "0.9.43", + "version": "0.9.46", "description": "Headless, framework-neutral HyperFrames composition editing engine", "repository": { "type": "git", diff --git a/packages/shader-transitions/package.json b/packages/shader-transitions/package.json index 01cc203ddbc..6569a4d945c 100644 --- a/packages/shader-transitions/package.json +++ b/packages/shader-transitions/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/shader-transitions", - "version": "0.9.43", + "version": "0.9.46", "description": "WebGL shader transitions for HyperFrames compositions", "repository": { "type": "git", diff --git a/packages/share/package.json b/packages/share/package.json index 048a47a4734..aec59aea763 100644 --- a/packages/share/package.json +++ b/packages/share/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/share", - "version": "0.9.43", + "version": "0.9.46", "description": "Shared review identity, version upload and sharing state for HyperFrames hosts", "license": "Apache-2.0", "repository": { diff --git a/packages/studio-server/package.json b/packages/studio-server/package.json index 6ca9b4a3ad9..2b9d78236f2 100644 --- a/packages/studio-server/package.json +++ b/packages/studio-server/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/studio-server", - "version": "0.9.43", + "version": "0.9.46", "repository": { "type": "git", "url": "https://github.com/heygen-com/hyperframes", diff --git a/packages/studio-server/src/helpers/applyFileMutations.test.ts b/packages/studio-server/src/helpers/applyFileMutations.test.ts index 8808bd7209c..048efa9005f 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/helpers/backupJournal.ts b/packages/studio-server/src/helpers/backupJournal.ts index 5199eba3e33..0848fa346fc 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 4a900ebaebc..6372eea2d07 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[] = []; @@ -99,6 +94,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

"); @@ -107,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); @@ -144,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 }); }); @@ -157,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); @@ -173,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 eeb445e0f4f..a4389a1b575 100644 --- a/packages/studio-server/src/history/blobStore.ts +++ b/packages/studio-server/src/history/blobStore.ts @@ -7,18 +7,15 @@ import { open, readdir, readFile, + rename, rm, stat, writeFile, } from "node:fs/promises"; import { dirname, join } from "node:path"; import { pipeline } from "node:stream/promises"; -import { atomicTempPath } from "@hyperframes/core/atomic-file"; -import { - isWindowsHold, - renameWithRetry, - renameWithRetryAsync, -} from "@hyperframes/core/rename-retry"; +import { atomicTempPath, refuseReadOnly } from "@hyperframes/core/atomic-file"; +import { isWindowsHold, renameWithRetryAsync } from "@hyperframes/core/rename-retry"; import { stopServingAndClose } from "../helpers/servedFiles.js"; /** File contents stored once by sha256, text and binary alike. */ @@ -105,14 +102,19 @@ async function cloneOrCopy( hash: string, beforeReplace?: () => void, ): Promise { + refuseReadOnly(to); await mkdir(dirname(to), { recursive: true }); const temp = atomicTempPath(to); 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/history/ownerLock.test.ts b/packages/studio-server/src/history/ownerLock.test.ts index 1183cd6eed5..e1ba58a96dd 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 e58336eac07..ee5b81aa090 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); diff --git a/packages/studio-server/src/routes/files.heldFile.test.ts b/packages/studio-server/src/routes/files.heldFile.test.ts index 80e8a773b6a..43bbbc4771d 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"; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; + +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 = () => @@ -36,7 +49,37 @@ 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 }] }), + }); + 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) => ({ @@ -55,6 +98,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); @@ -103,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 00000000000..5b76fd2ab19 --- /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 ff0e6298aaf..856abf45788 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); @@ -2807,6 +2839,34 @@ 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; + }); + + 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 92ed21877f7..c2ca0a25338 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 14d0193e9c3..bc22d9ddb49 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({ diff --git a/packages/studio/package.json b/packages/studio/package.json index 95520e0f1fa..fcaf3421776 100644 --- a/packages/studio/package.json +++ b/packages/studio/package.json @@ -1,6 +1,6 @@ { "name": "@hyperframes/studio", - "version": "0.9.43", + "version": "0.9.46", "description": "", "repository": { "type": "git", @@ -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 00000000000..73e373937b5 --- /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(); + }); +}); diff --git a/plugin.json b/plugin.json index e448580aeec..ee19fd7e132 100644 --- a/plugin.json +++ b/plugin.json @@ -2,7 +2,7 @@ "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "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.46", "author": { "name": "HeyGen", "email": "hyperframes@heygen.com", diff --git a/registry/components/caption-highlight/caption-highlight.html b/registry/components/caption-highlight/caption-highlight.html index 615084e01f7..ffaba88c04a 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 2236c8b89e1..88b1da108ec 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" }, diff --git a/releases/v0.9.44.md b/releases/v0.9.44.md new file mode 100644 index 00000000000..c8d52be8792 --- /dev/null +++ b/releases/v0.9.44.md @@ -0,0 +1,29 @@ +# HyperFrames v0.9.44 + +Released on 2026-11-14. + +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)). + +## Full changelog + +https://github.com/heygen-com/hyperframes/compare/v0.9.43...v0.9.44 diff --git a/releases/v0.9.45.md b/releases/v0.9.45.md new file mode 100644 index 00000000000..811f793a168 --- /dev/null +++ b/releases/v0.9.45.md @@ -0,0 +1,13 @@ +# HyperFrames v0.9.45 + +Released on 2026-11-14. + +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)). + +## Full changelog + +https://github.com/heygen-com/hyperframes/compare/v0.9.44...v0.9.45 diff --git a/releases/v0.9.46.md b/releases/v0.9.46.md new file mode 100644 index 00000000000..38199910c41 --- /dev/null +++ b/releases/v0.9.46.md @@ -0,0 +1,14 @@ +# HyperFrames v0.9.46 + +Released on 2026-11-14. + +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)). + +## Full changelog + +https://github.com/heygen-com/hyperframes/compare/v0.9.45...v0.9.46 diff --git a/scripts/package-size-budgets.json b/scripts/package-size-budgets.json index 1523d7e3d6c..a35b4aa6e0f 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, @@ -32,7 +32,7 @@ "unpacked": 885314 }, "@hyperframes/player": { - "packed": 157145, + "packed": 166688, "unpacked": 541601 }, "@hyperframes/producer": {