From e3c0c1fa59ace68245bccdde1ef537b1d2feb52e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Thu, 16 Jul 2026 16:10:04 +0000 Subject: [PATCH] fix(media): gate proxying on source eligibility --- packages/core/src/runtime/mediaProxy.test.ts | 40 ++++++++- packages/core/src/runtime/mediaProxy.ts | 13 ++- .../src/helpers/mediaCodecMap.test.ts | 65 ++++++++++++-- .../src/helpers/mediaCodecMap.ts | 16 +++- .../src/helpers/mediaProxyPreview.ts | 5 +- .../studio-server/src/routes/preview.test.ts | 84 ++++++++++++++++++- packages/studio-server/src/routes/preview.ts | 44 +++++++--- 7 files changed, 242 insertions(+), 25 deletions(-) diff --git a/packages/core/src/runtime/mediaProxy.test.ts b/packages/core/src/runtime/mediaProxy.test.ts index fc503c3af8..5499bb816b 100644 --- a/packages/core/src/runtime/mediaProxy.test.ts +++ b/packages/core/src/runtime/mediaProxy.test.ts @@ -89,7 +89,11 @@ describe("maybeProxyProactively", () => { it("swaps when hostile and representativeMime is null (no canPlayType check possible)", () => { window.__HF_MEDIA_CODEC_MAP__ = { - "/video.mp4": { codecName: "prores", browserHostile: true, representativeMime: null }, + "/video.mp4": { + codecName: "prores", + browserHostile: true, + representativeMime: null, + }, }; const el = createVideo("/video.mp4"); stubCanPlayType(el, "probably"); // should not even be consulted @@ -235,6 +239,23 @@ describe("handleMetadataForProxy (reactive trigger)", () => { ); }); + it("does not proxy a mapped browser-safe codec on the metadata path", () => { + window.__HF_MEDIA_CODEC_MAP__ = { "/video.mp4": H264_ENTRY }; + const el = createVideo("/video.mp4"); + markZeroWidth(el); + + handleMetadataForProxy(el); + + expect(isProxied(el)).toBe(false); + expect(el.load).not.toHaveBeenCalled(); + expect(postRuntimeMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + code: "runtime_media_proxy_unavailable", + details: expect.objectContaining({ reason: "browser_safe_codec" }), + }), + ); + }); + it("a second zero-width metadata event does not loop (no second swap)", () => { window.__HF_MEDIA_CODEC_MAP__ = {}; const el = createVideo("/video.mp4"); @@ -385,6 +406,23 @@ describe("handleErrorForProxy (tertiary trigger)", () => { }); describe("swapToProxy", () => { + it("does not poison swap state when the source URL is malformed", () => { + const el = createVideo("/video.mp4"); + Object.defineProperty(el, "currentSrc", { + value: "http://[", + configurable: true, + }); + + swapToProxy(el, HEVC_ENTRY, "reactive"); + + expect(el.load).not.toHaveBeenCalled(); + expect(postRuntimeMessageMock).toHaveBeenCalledWith( + expect.objectContaining({ + code: "runtime_media_proxy_unavailable", + details: expect.objectContaining({ reason: "invalid_source_url" }), + }), + ); + }); it("evicts per-source sync state so the swapped element is treated as a first tick", () => { const el = createVideo("/video.mp4"); // Populate sync state as a real active-clip tick would. diff --git a/packages/core/src/runtime/mediaProxy.ts b/packages/core/src/runtime/mediaProxy.ts index 2b90ff5dd3..02d915c336 100644 --- a/packages/core/src/runtime/mediaProxy.ts +++ b/packages/core/src/runtime/mediaProxy.ts @@ -123,7 +123,7 @@ function lookupCodecMapEntry( if (!pathname.endsWith(key)) continue; if (bestKey === null || key.length > bestKey.length) bestKey = key; } - return bestKey ? map[bestKey] : null; + return bestKey ? (map[bestKey] ?? null) : null; } function appendProxyParam(src: string): string { @@ -136,7 +136,8 @@ type UnavailableReason = | "cross_origin" | "proxy_playback_failed" | "browser_safe_codec" - | "alpha_source"; + | "alpha_source" + | "invalid_source_url"; const UNAVAILABLE_NOTES: Record = { cross_origin: @@ -146,6 +147,7 @@ const UNAVAILABLE_NOTES: Record = { "the file errored but its codec is browser-decodable; an H.264 proxy cannot help (the file itself is likely corrupt)", alpha_source: "the source carries an alpha channel; an H.264 proxy would destroy the transparency, so it is never proxied", + invalid_source_url: "the media source URL is malformed and cannot be proxied", }; function emitUnavailableDiagnostic( @@ -184,15 +186,16 @@ export function swapToProxy( trigger: ProxyTrigger = "reactive", ): void { if (swappedElements.has(el)) return; - swappedElements.add(el); const originalSrc = currentSrcValue(el); let proxiedSrc: string; try { proxiedSrc = appendProxyParam(originalSrc); } catch (err) { swallow("runtime.mediaProxy.swap", err); + emitUnavailableDiagnostic(el, "invalid_source_url", originalSrc); return; } + swappedElements.add(el); // The swapped src points at a different file — sync state (drift offsets, // seek-retry latches, volume tracking) computed against the original // source must not carry over, or the next tick misreads a fresh file's @@ -281,6 +284,10 @@ export function handleMetadataForProxy(el: HTMLMediaElement): void { return; } const entry = lookupCodecMapEntry(key, map); + if (entry && !entry.browserHostile) { + emitUnavailableDiagnostic(el, "browser_safe_codec", src); + return; + } if (entry?.hasAlpha) { emitUnavailableDiagnostic(el, "alpha_source", src); return; diff --git a/packages/studio-server/src/helpers/mediaCodecMap.test.ts b/packages/studio-server/src/helpers/mediaCodecMap.test.ts index a7b55aa158..c4898a2366 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.test.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from "node:os"; import type { FfprobeRunner } from "./mediaMetadata.js"; import { BROWSER_HOSTILE_CODECS, + decideMediaProxyEligibility, createMediaCodecProbeCache, probeAssetCodec, scanProjectMediaCodecMap, @@ -24,13 +25,22 @@ function makeRunner( const entry = codecByPath[filePath]; const normalized = typeof entry === "string" ? { codecName: entry } : entry; const streams = normalized - ? [{ codec_type: "video", codec_name: normalized.codecName, pix_fmt: normalized.pixFmt }] + ? [ + { + codec_type: "video", + codec_name: normalized.codecName, + pix_fmt: normalized.pixFmt, + }, + ] : []; return { status: 0, stdout: JSON.stringify({ streams }), stderr: "" }; }; } -function countingRunner(runner: FfprobeRunner): { runner: FfprobeRunner; calls: () => number } { +function countingRunner(runner: FfprobeRunner): { + runner: FfprobeRunner; + calls: () => number; +} { let calls = 0; return { runner: (command, args, options) => { @@ -122,7 +132,9 @@ describe("probeAssetCodec", () => { const facts = await probeAssetCodec( videoPath, - makeRunner({ [videoPath]: { codecName: "prores", pixFmt: "yuva444p10le" } }), + makeRunner({ + [videoPath]: { codecName: "prores", pixFmt: "yuva444p10le" }, + }), ); expect(facts).toEqual({ @@ -145,6 +157,42 @@ describe("probeAssetCodec", () => { }); }); +describe("decideMediaProxyEligibility", () => { + it("allows only hostile opaque video through the H.264 proxy path", () => { + expect( + decideMediaProxyEligibility({ + codecName: "hevc", + browserHostile: true, + representativeMime: null, + hasAlpha: false, + }), + ).toEqual({ eligible: true }); + }); + + it("rejects alpha and browser-safe sources before transcoding", () => { + expect( + decideMediaProxyEligibility({ + codecName: "prores", + browserHostile: true, + representativeMime: null, + hasAlpha: true, + }), + ).toEqual({ eligible: false, reason: "alpha_source" }); + expect( + decideMediaProxyEligibility({ + codecName: "h264", + browserHostile: false, + representativeMime: null, + hasAlpha: false, + }), + ).toEqual({ eligible: false, reason: "browser_safe_codec" }); + expect(decideMediaProxyEligibility(null)).toEqual({ + eligible: false, + reason: "unknown_codec", + }); + }); +}); + describe("scanProjectMediaCodecMap", () => { it("omits an asset from the map (no throw) when ffprobe is unresolvable", async () => { const project = tmpProject(); @@ -172,9 +220,9 @@ describe("scanProjectMediaCodecMap", () => { }), }); - expect(Object.keys(map).sort()).toEqual(["/assets/my clip.mp4", "/assets/sub/clip.mp4"]); + expect(Object.keys(map)).toEqual(["/assets/sub/clip.mp4"]); expect(map["/assets/sub/clip.mp4"]?.browserHostile).toBe(true); - expect(map["/assets/my clip.mp4"]?.browserHostile).toBe(false); + expect(map["/assets/my clip.mp4"]).toBeUndefined(); }); it("rewrites a sub-composition's ../-traversing src via compSrcPath before resolving (rewriteAssetPath)", async () => { @@ -185,7 +233,12 @@ describe("scanProjectMediaCodecMap", () => { const map = await scanProjectMediaCodecMap( project, - [{ html: videoHtml("../assets/clip.mp4"), compSrcPath: "compositions/scene.html" }], + [ + { + html: videoHtml("../assets/clip.mp4"), + compSrcPath: "compositions/scene.html", + }, + ], { runner: makeRunner({ [join(project, "assets", "clip.mp4")]: "hevc" }) }, ); diff --git a/packages/studio-server/src/helpers/mediaCodecMap.ts b/packages/studio-server/src/helpers/mediaCodecMap.ts index b976ccdfef..515176fc0d 100644 --- a/packages/studio-server/src/helpers/mediaCodecMap.ts +++ b/packages/studio-server/src/helpers/mediaCodecMap.ts @@ -44,6 +44,20 @@ export const BROWSER_HOSTILE_CODECS: Record = { av1: 'video/mp4; codecs="av01.0.08M.08"', }; +export type MediaProxyIneligibilityReason = "alpha_source" | "browser_safe_codec" | "unknown_codec"; + +export type MediaProxyEligibility = + | { eligible: true } + | { eligible: false; reason: MediaProxyIneligibilityReason }; + +/** Single policy gate shared by proactive scans and on-demand proxy routes. */ +export function decideMediaProxyEligibility(facts: AssetCodecFacts | null): MediaProxyEligibility { + if (!facts) return { eligible: false, reason: "unknown_codec" }; + if (facts.hasAlpha) return { eligible: false, reason: "alpha_source" }; + if (!facts.browserHostile) return { eligible: false, reason: "browser_safe_codec" }; + return { eligible: true }; +} + function codecFactsFor(codecName: string, hasAlpha: boolean): AssetCodecFacts { const isHostile = Object.hasOwn(BROWSER_HOSTILE_CODECS, codecName); return { @@ -219,7 +233,7 @@ export async function scanProjectMediaCodecMap( const map: MediaCodecMap = {}; entries.forEach(([, pathname], index) => { const entryFacts = facts[index]; - if (entryFacts) map[pathname] = entryFacts; + if (entryFacts?.browserHostile) map[pathname] = entryFacts; }); return map; } diff --git a/packages/studio-server/src/helpers/mediaProxyPreview.ts b/packages/studio-server/src/helpers/mediaProxyPreview.ts index 209896a183..2155718ea4 100644 --- a/packages/studio-server/src/helpers/mediaProxyPreview.ts +++ b/packages/studio-server/src/helpers/mediaProxyPreview.ts @@ -78,8 +78,8 @@ function injectScriptTagIntoHead(html: string, scriptTag: string): string { * The single shared implementation for every auto-proxy surface — the studio * preview route (via `injectMediaCodecMap` below) and the CLI's composition / * static project servers (via the `./media-proxy-preview` subpath export). - * The map is injected even when empty: the runtime treats map PRESENCE as - * "this surface serves `?hf-proxy=`", gating its reactive/tertiary swaps. + * Empty maps leave HTML untouched, preserving the normal no-hostile-media + * preview path. On-demand proxy requests enforce the same eligibility gate. */ export async function injectMediaCodecMapIntoHtml( html: string, @@ -98,6 +98,7 @@ export async function injectMediaCodecMapIntoHtml( // Best-effort: a scan failure must never block serving the page. return html; } + if (Object.keys(map).length === 0) return html; for (const [rootRelativePathname, facts] of Object.entries(map)) { if (!facts.browserHostile || facts.hasAlpha) continue; resolveProxy(projectDir, resolve(projectDir, rootRelativePathname.replace(/^\/+/, ""))).catch( diff --git a/packages/studio-server/src/routes/preview.test.ts b/packages/studio-server/src/routes/preview.test.ts index 62849d491f..4aa70a9e82 100644 --- a/packages/studio-server/src/routes/preview.test.ts +++ b/packages/studio-server/src/routes/preview.test.ts @@ -672,13 +672,23 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { ) => Promise< Record< string, - { codecName: string; browserHostile: boolean; representativeMime: string | null } + { + codecName: string; + browserHostile: boolean; + representativeMime: string | null; + } > >; async function loadPreviewModule(opts: { resolveProxyImpl?: (projectDir: string, absoluteSourcePath: string) => Promise; scanMapImpl?: ScanMapImpl; + probeAssetCodecImpl?: () => Promise<{ + codecName: string; + browserHostile: boolean; + representativeMime: string | null; + hasAlpha: boolean; + } | null>; }): Promise { vi.resetModules(); const resolveProxy = @@ -699,6 +709,27 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { vi.doMock("../helpers/mediaCodecMap.js", () => ({ scanProjectMediaCodecMap: opts.scanMapImpl ?? (async () => ({})), createMediaCodecProbeCache: () => new Map(), + probeAssetCodec: + opts.probeAssetCodecImpl ?? + (async () => ({ + codecName: "hevc", + browserHostile: true, + representativeMime: null, + hasAlpha: false, + })), + decideMediaProxyEligibility: ( + facts: { + browserHostile: boolean; + hasAlpha: boolean; + } | null, + ) => { + if (!facts) return { eligible: false, reason: "unknown_codec" }; + if (facts.hasAlpha) return { eligible: false, reason: "alpha_source" }; + if (!facts.browserHostile) { + return { eligible: false, reason: "browser_safe_codec" }; + } + return { eligible: true }; + }, })); return import("./preview.js"); } @@ -807,6 +838,39 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { expect(resolveProxyMock).not.toHaveBeenCalled(); }); + it("rejects alpha-bearing and browser-safe sources before transcoding", async () => { + for (const facts of [ + { + codecName: "prores", + browserHostile: true, + representativeMime: null, + hasAlpha: true, + }, + { + codecName: "h264", + browserHostile: false, + representativeMime: null, + hasAlpha: false, + }, + ]) { + const projectDir = createProjectDir(); + writeFileSync(join(projectDir, "clip.mp4"), "video-bytes"); + const resolveProxyMock = vi.fn(async () => "should-not-be-called"); + const { registerPreviewRoutes: register } = await loadPreviewModule({ + resolveProxyImpl: resolveProxyMock, + probeAssetCodecImpl: async () => facts, + }); + const app = new Hono(); + register(app, createAdapter(projectDir)); + + const res = await app.request( + "http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264", + ); + expect(res.status).toBe(422); + expect(resolveProxyMock).not.toHaveBeenCalled(); + } + }); + it("returns 404 without transcoding when the param value is not exactly h264", async () => { const projectDir = createProjectDir(); writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes"); @@ -884,6 +948,18 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { }); describe("__HF_MEDIA_CODEC_MAP__ injection into composition HTML", () => { + it("keeps HTML byte-identical when the scan finds no proxy-eligible media", async () => { + const projectDir = createProjectDir(); + const { registerPreviewRoutes: register } = await loadPreviewModule({ + scanMapImpl: async () => ({}), + }); + const app = new Hono(); + register(app, createAdapter(projectDir)); + + const res = await app.request("http://localhost/projects/demo/preview"); + const html = await res.text(); + expect(html).not.toContain("data-hf-media-codec-map"); + }); it("injects the scanned map naming the hostile fixture, and pre-warms resolveProxy for it", async () => { const projectDir = createProjectDir(); const resolveProxyMock = vi.fn(async () => join(projectDir, ".transcode-cache", "x.mp4")); @@ -924,7 +1000,11 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => { writeFileSync(join(projectDir, "clip.mp4"), "bytes"); const resolveProxyMock = vi.fn(async () => "should-not-be-called"); const scanMapMock = vi.fn(async () => ({ - "/clip.mp4": { codecName: "hevc", browserHostile: true, representativeMime: null }, + "/clip.mp4": { + codecName: "hevc", + browserHostile: true, + representativeMime: null, + }, })); const { registerPreviewRoutes: register } = await loadPreviewModule({ resolveProxyImpl: resolveProxyMock, diff --git a/packages/studio-server/src/routes/preview.ts b/packages/studio-server/src/routes/preview.ts index 314413ae69..985b65a105 100644 --- a/packages/studio-server/src/routes/preview.ts +++ b/packages/studio-server/src/routes/preview.ts @@ -16,6 +16,7 @@ import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js"; import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js"; import { resolveProxy, ProxyTranscodeError } from "../helpers/proxyTranscoder.js"; +import { decideMediaProxyEligibility, probeAssetCodec } from "../helpers/mediaCodecMap.js"; import { isAutoProxyEnabled, injectMediaCodecMap, @@ -61,7 +62,9 @@ function parseStudioMotionManifestContent(content: string): { hasCustomEase: boolean; } { try { - const parsed = JSON.parse(content) as { motions?: Array<{ customEase?: unknown }> }; + const parsed = JSON.parse(content) as { + motions?: Array<{ customEase?: unknown }>; + }; const motions = Array.isArray(parsed.motions) ? parsed.motions : []; return { hasMotion: motions.length > 0, @@ -247,11 +250,13 @@ function variablesEtagSalt(raw: string | undefined): string { * route should 400; otherwise `values` is the override object (or null when * the param is absent) and `raw` feeds the ETag salt. */ -function previewVariablesFromRequest( - rawVariables: string | undefined, -): +function previewVariablesFromRequest(rawVariables: string | undefined): | { error: string } - | { error?: undefined; raw: string | undefined; values: Record | null } { + | { + error?: undefined; + raw: string | undefined; + values: Record | null; + } { const parse = parsePreviewVariablesParam(rawVariables); if (!parse.ok) return { error: parse.error }; return { raw: rawVariables, values: parse.values }; @@ -299,11 +304,17 @@ function resolveProjectMainHtml( ): { html: string; compositionPath: string } | null { const indexPath = join(projectDir, "index.html"); if (existsSync(indexPath)) { - return { html: readFileSync(indexPath, "utf-8"), compositionPath: "index.html" }; + return { + html: readFileSync(indexPath, "utf-8"), + compositionPath: "index.html", + }; } const blockHtmlPath = join(projectDir, `${projectId}.html`); if (existsSync(blockHtmlPath)) { - return { html: readFileSync(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` }; + return { + html: readFileSync(blockHtmlPath, "utf-8"), + compositionPath: `${projectId}.html`, + }; } return null; } @@ -334,7 +345,10 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`; const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch === etag) { - return new Response(null, { status: 304, headers: previewCacheHeaders(etag) }); + return new Response(null, { + status: 304, + headers: previewCacheHeaders(etag), + }); } // Normalize + persist data-hf-id to disk before bundle reads it. Idempotent. @@ -472,7 +486,10 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo const etag = `"comp:v2:${compPath}:${signature}${variablesEtagSalt(vars.raw)}"`; const ifNoneMatch = c.req.header("If-None-Match"); if (ifNoneMatch === etag) { - return new Response(null, { status: 304, headers: previewCacheHeaders(etag) }); + return new Response(null, { + status: 304, + headers: previewCacheHeaders(etag), + }); } const stamped = pinSubCompHfIds(compFile, compPath); @@ -527,12 +544,19 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo ) { return c.text("not found", 404); } + const eligibility = decideMediaProxyEligibility(await probeAssetCodec(file)); + if (!eligibility.eligible) { + return c.text(`media proxy unavailable: ${eligibility.reason}`, 422); + } } const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}${proxyEtagSalt(proxyParam)}"`; const cacheHeaders: Record = isText ? { "Cache-Control": "no-store" } - : { "Cache-Control": "private, max-age=3600, must-revalidate", ETag: etag }; + : { + "Cache-Control": "private, max-age=3600, must-revalidate", + ETag: etag, + }; if (!isText) { const ifNoneMatch = c.req.header("If-None-Match");