feat(studio-server): serve H.264 proxies from the preview route (#2590)

* feat(studio-server): serve H.264 proxies from the preview route

Wires the codec manifest and the transcoder into the preview surface: the route
negotiates a proxy via a query param and serves it through the existing range
and ETag machinery, composition HTML carries a codec map for the runtime, and
hostile assets pre-warm so a first play does not wait on a cold transcode.
Exposes the three subpath exports the CLI surfaces consume upstack.

Drops the TEMP fallow entry added with the transcoder: it has real importers now.

* fix(studio-server): publish media proxy exports

* fix(parsers): scan HTML comments linearly
This commit is contained in:
Miguel Ángel
2026-07-16 23:00:48 -04:00
committed by GitHub
parent 9d148d288a
commit 67eab59f44
10 changed files with 763 additions and 43 deletions
-23
View File
@@ -145,29 +145,6 @@
"file": "packages/studio/src/utils/studioHelpers.ts", "file": "packages/studio/src/utils/studioHelpers.ts",
"exports": ["resolveDroppedAssetDimensions"], "exports": ["resolveDroppedAssetDimensions"],
}, },
// TEMP (transparent-proxy stack): proxyTranscoder is carved in below its
// consumers, which land upstack (mediaProxyPreview + the preview route,
// then the CLI surfaces). With no importer yet, a per-PR audit against the
// merge base sees the module's whole export surface as unused. Same shape
// as the drawElementService entry below. Safe to drop once the preview
// weld lands; the stack's final slice greps for zero TEMP entries.
{
"file": "packages/studio-server/src/helpers/proxyTranscoder.ts",
"exports": [
"PROXY_PARAMS_VERSION",
"TRANSCODE_TIMEOUT_MS",
"DEFAULT_PROXY_WAIT_TIMEOUT_MS",
"ProxyTranscodeError",
"FfmpegMissingFilterError",
"ProxyCapacityError",
"ProxySourceOutsideProjectError",
"ProxyWaitTimeoutError",
"waitForProxy",
"getProxyCachePath",
"clearFailedTranscodesForTest",
"resolveProxy",
],
},
// drawElementService is the bottom of the fast-capture Graphite stack // drawElementService is the bottom of the fast-capture Graphite stack
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so // (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
// a per-PR audit diffing against the merge base sees these exports as // a per-PR audit diffing against the merge base sees these exports as
@@ -0,0 +1,22 @@
import { describe, expect, it } from "vitest";
import { maskNonScannableRanges } from "./assetResolution.js";
describe("maskNonScannableRanges", () => {
it("masks complete comments without changing offsets", () => {
const html = '<video src="before.mp4"><!-- <video src="hidden.mp4"> --><video src="after.mp4">';
const masked = maskNonScannableRanges(html);
expect(masked).toHaveLength(html.length);
expect(masked).toContain('<video src="before.mp4">');
expect(masked).not.toContain("hidden.mp4");
expect(masked).toContain('<video src="after.mp4">');
});
it("handles many comment openers in linear scans", () => {
const html = `prefix${"<!--".repeat(10_000)}-->suffix`;
const masked = maskNonScannableRanges(html);
expect(masked).toHaveLength(html.length);
expect(masked).toBe(`prefix${" ".repeat(html.length - 12)}suffix`);
});
});
+18 -1
View File
@@ -64,10 +64,27 @@ function maskRange(src: string, pattern: RegExp): string {
return src.replace(pattern, (m) => " ".repeat(m.length)); return src.replace(pattern, (m) => " ".repeat(m.length));
} }
function maskHtmlComments(src: string): string {
const chunks: string[] = [];
let cursor = 0;
while (true) {
const start = src.indexOf("<!--", cursor);
if (start === -1) break;
const end = src.indexOf("-->", start + 4);
if (end === -1) break;
const afterComment = end + 3;
chunks.push(src.slice(cursor, start), " ".repeat(afterComment - start));
cursor = afterComment;
}
return chunks.length === 0 ? src : chunks.join("") + src.slice(cursor);
}
/** Blanks out comments, `<style>`, and `<script>` bodies so tag-scanning /** Blanks out comments, `<style>`, and `<script>` bodies so tag-scanning
* regexes don't false-positive on commented-out or scripted markup. */ * regexes don't false-positive on commented-out or scripted markup. */
export function maskNonScannableRanges(html: string): string { export function maskNonScannableRanges(html: string): string {
let out = maskRange(html, /<!--[\s\S]*?-->/g); let out = maskHtmlComments(html);
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi); out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi); out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
return out; return out;
+30
View File
@@ -56,6 +56,24 @@
"node": "./dist/helpers/sourceMutation.js", "node": "./dist/helpers/sourceMutation.js",
"import": "./src/helpers/sourceMutation.ts", "import": "./src/helpers/sourceMutation.ts",
"types": "./src/helpers/sourceMutation.ts" "types": "./src/helpers/sourceMutation.ts"
},
"./media-codec-map": {
"bun": "./src/helpers/mediaCodecMap.ts",
"node": "./dist/helpers/mediaCodecMap.js",
"import": "./src/helpers/mediaCodecMap.ts",
"types": "./src/helpers/mediaCodecMap.ts"
},
"./proxy-transcoder": {
"bun": "./src/helpers/proxyTranscoder.ts",
"node": "./dist/helpers/proxyTranscoder.js",
"import": "./src/helpers/proxyTranscoder.ts",
"types": "./src/helpers/proxyTranscoder.ts"
},
"./media-proxy-preview": {
"bun": "./src/helpers/mediaProxyPreview.ts",
"node": "./dist/helpers/mediaProxyPreview.js",
"import": "./src/helpers/mediaProxyPreview.ts",
"types": "./src/helpers/mediaProxyPreview.ts"
} }
}, },
"publishConfig": { "publishConfig": {
@@ -89,6 +107,18 @@
"./source-mutation": { "./source-mutation": {
"import": "./dist/helpers/sourceMutation.js", "import": "./dist/helpers/sourceMutation.js",
"types": "./dist/helpers/sourceMutation.d.ts" "types": "./dist/helpers/sourceMutation.d.ts"
},
"./media-codec-map": {
"import": "./dist/helpers/mediaCodecMap.js",
"types": "./dist/helpers/mediaCodecMap.d.ts"
},
"./proxy-transcoder": {
"import": "./dist/helpers/proxyTranscoder.js",
"types": "./dist/helpers/proxyTranscoder.d.ts"
},
"./media-proxy-preview": {
"import": "./dist/helpers/mediaProxyPreview.js",
"types": "./dist/helpers/mediaProxyPreview.d.ts"
} }
}, },
"main": "./dist/index.js", "main": "./dist/index.js",
@@ -0,0 +1,134 @@
import { resolve } from "node:path";
import type { StudioApiAdapter } from "../types.js";
import {
createMediaCodecProbeCache,
scanProjectMediaCodecMap,
type HtmlSourceLike,
type MediaCodecMap,
type MediaCodecProbeCache,
} from "./mediaCodecMap.js";
import { resolveProxy, PROXY_PARAMS_VERSION } from "./proxyTranscoder.js";
/**
* Transparent-media-proxy wiring shared by `routes/preview.ts`
* (docs/plans/2026-07-14-002-feat-transparent-media-proxies-plan.md, unit U3).
* Split out of the route module to keep it under the repo's 600-line file cap.
*/
/**
* Preview-route-local adapter surface for the auto-proxy feature. Both
* fields are optional so any existing `StudioApiAdapter` value remains
* structurally assignable without editing the shared interface:
* `autoProxy` defaults to true (on) when omitted — a later unit wires the
* CLI `--no-proxy` flag / `hyperframes.json` setting through it;
* `mediaCodecProbeCache` lets a host share one probe cache across
* preview/play/static-server surfaces instead of each constructing its own.
*/
export type PreviewApiAdapter = StudioApiAdapter & {
autoProxy?: boolean;
mediaCodecProbeCache?: MediaCodecProbeCache;
};
export function isAutoProxyEnabled(adapter: PreviewApiAdapter): boolean {
return adapter.autoProxy !== false;
}
/** One probe cache per server instance — construct once in `registerPreviewRoutes`
* and reuse across every request so the mtime-cache benefit in
* `scanProjectMediaCodecMap` actually applies. A host that wants to share the
* cache across other surfaces (play, static project server) can pass its own
* via `adapter.mediaCodecProbeCache`. */
export function resolvePreviewMediaCodecProbeCache(
adapter: PreviewApiAdapter,
): MediaCodecProbeCache {
return adapter.mediaCodecProbeCache ?? createMediaCodecProbeCache();
}
/**
* ETag salt for `?hf-proxy=` asset requests, mirroring `variablesEtagSalt` in
* preview.ts: salted by the raw param value plus the transcoder's params
* version, so a future proxy-recipe change (which bumps `PROXY_PARAMS_VERSION`)
* or a different proxy variant invalidates cached 304s without needing to
* touch the proxy file itself.
*/
export function proxyEtagSalt(raw: string | undefined): string {
if (raw === undefined) return "";
return `:proxy:${raw}:${PROXY_PARAMS_VERSION}`;
}
// Mirrors `injectScriptTagIntoHead` in routes/preview.ts (kept local rather
// than imported to avoid a helpers → routes dependency edge for one
// two-line utility).
function injectScriptTagIntoHead(html: string, scriptTag: string): string {
if (html.includes("</head>")) return html.replace("</head>", `${scriptTag}\n</head>`);
return `${scriptTag}\n${html}`;
}
/**
* Injects `window.__HF_MEDIA_CODEC_MAP__` (the U1 codec-facts scan) into
* served composition HTML, and fire-and-forget pre-warms `resolveProxy` for
* every browser-hostile entry so an element's proactive swap usually hits a
* warm cache (KTD: protects the per-origin connection budget under held
* responses). No second concurrency limiter here — the transcoder's own
* global bound throttles both pre-warm and element-triggered calls.
* Pre-warm failures are swallowed; an actual `?hf-proxy=` request surfaces
* them as a 502. Alpha-bearing entries are never pre-warmed: the runtime
* never proxies them (transparency would be destroyed).
*
* 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).
* 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,
projectDir: string,
htmlSources: HtmlSourceLike[],
probeCache?: MediaCodecProbeCache,
): Promise<string> {
let map: MediaCodecMap;
try {
map = await scanProjectMediaCodecMap(
projectDir,
htmlSources,
probeCache ? { cache: probeCache } : {},
);
} catch {
// 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(
() => {
// Swallowed: the pre-warm is best-effort. A real `?hf-proxy=` request
// for this asset re-attempts the transcode and reports failure (502).
},
);
}
// <-escape prevents a src path containing "</script>" from breaking out of
// the injected tag, mirroring injectPreviewVariables in routes/preview.ts.
const json = JSON.stringify(map)
.replace(/</g, "\\u003c")
.replace(/\u2028/g, "\\u2028")
.replace(/\u2029/g, "\\u2029");
const tag = `<script data-hf-media-codec-map>window.__HF_MEDIA_CODEC_MAP__=${json};</script>`;
return injectScriptTagIntoHead(html, tag);
}
/**
* Adapter-aware wrapper used by the studio preview routes: skipped entirely
* (no scan, no injection) when auto-proxy is off for this adapter.
*/
export async function injectMediaCodecMap(
html: string,
adapter: PreviewApiAdapter,
projectDir: string,
compSrcPath: string,
probeCache: MediaCodecProbeCache,
): Promise<string> {
if (!isAutoProxyEnabled(adapter)) return html;
return injectMediaCodecMapIntoHtml(html, projectDir, [{ html, compSrcPath }], probeCache);
}
+1
View File
@@ -11,6 +11,7 @@ export type {
StudioSelectionTextField, StudioSelectionTextField,
} from "./types.js"; } from "./types.js";
export { isSafePath, walkDir } from "./helpers/safePath.js"; export { isSafePath, walkDir } from "./helpers/safePath.js";
export type { PreviewApiAdapter } from "./helpers/mediaProxyPreview.js";
export { getMimeType, MIME_TYPES } from "./helpers/mime.js"; export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
export { export {
consumeFileWriteReceipt, consumeFileWriteReceipt,
+6 -1
View File
@@ -440,7 +440,12 @@ function walkFiles(dir: string, filter: (name: string) => boolean): string[] {
for (const entry of readdirSync(dir, { withFileTypes: true })) { for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = join(dir, entry.name); const full = join(dir, entry.name);
if (entry.isDirectory()) { if (entry.isDirectory()) {
if (entry.name === "node_modules" || entry.name === ".thumbnails" || entry.name === "renders") if (
entry.name === "node_modules" ||
entry.name === ".thumbnails" ||
entry.name === "renders" ||
entry.name === ".transcode-cache"
)
continue; continue;
results.push(...walkFiles(full, filter)); results.push(...walkFiles(full, filter));
} else if (filter(entry.name)) { } else if (filter(entry.name)) {
@@ -24,8 +24,8 @@ function createProjectDir(): string {
function createAdapter( function createAdapter(
projectDir: string, projectDir: string,
overrides: Partial<StudioApiAdapter> = {}, overrides: Partial<StudioApiAdapter> & { autoProxy?: boolean } = {},
): StudioApiAdapter { ): StudioApiAdapter & { autoProxy?: boolean } {
return { return {
listProjects: () => [], listProjects: () => [],
resolveProject: async (id: string) => ({ id, dir: projectDir }), resolveProject: async (id: string) => ({ id, dir: projectDir }),
@@ -637,3 +637,439 @@ describe("sub-composition preview attribute integrity", () => {
expect(JSON.parse(decoded)).toEqual(JSON.parse(decls)); expect(JSON.parse(decoded)).toEqual(JSON.parse(decls));
}); });
}); });
// ── U3: ?hf-proxy=h264 negotiation + __HF_MEDIA_CODEC_MAP__ injection ───────
// (docs/plans/2026-07-14-002-feat-transparent-media-proxies-plan.md)
//
// Both helpers preview.ts depends on (proxyTranscoder's resolveProxy,
// mediaCodecMap's scanProjectMediaCodecMap) are mocked here rather than
// exercised for real: their own behavior (ffmpeg spawning/caching, ffprobe
// codec detection) is already covered by proxyTranscoder.test.ts and
// mediaCodecMap.test.ts. This suite only tests preview.ts's own wiring —
// the route branches, ETag salting, 404/502 mapping, and injection point.
describe("hf-proxy negotiation and media codec map injection (U3)", () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock("../helpers/proxyTranscoder.js");
vi.doUnmock("../helpers/mediaCodecMap.js");
});
class FakeProxyTranscodeError extends Error {
readonly exitCode: number | null;
readonly stderrTail: string;
constructor(message: string, exitCode: number | null, stderrTail: string) {
super(message);
this.name = "ProxyTranscodeError";
this.exitCode = exitCode;
this.stderrTail = stderrTail;
}
}
class FakeProxyCapacityError extends FakeProxyTranscodeError {}
type ScanMapImpl = (
projectDir: string,
htmlSources: Array<{ html: string; compSrcPath?: string }>,
options?: unknown,
) => Promise<
Record<
string,
{
codecName: string;
browserHostile: boolean;
representativeMime: string | null;
}
>
>;
async function loadPreviewModule(opts: {
resolveProxyImpl?: (projectDir: string, absoluteSourcePath: string) => Promise<string>;
scanMapImpl?: ScanMapImpl;
probeAssetCodecImpl?: () => Promise<{
codecName: string;
browserHostile: boolean;
representativeMime: string | null;
hasAlpha: boolean;
} | null>;
}): Promise<typeof import("./preview.js")> {
vi.resetModules();
const resolveProxy =
opts.resolveProxyImpl ??
(async () => {
throw new FakeProxyTranscodeError(
"no resolveProxy impl configured for this test",
null,
"",
);
});
vi.doMock("../helpers/proxyTranscoder.js", () => ({
resolveProxy,
ProxyTranscodeError: FakeProxyTranscodeError,
ProxyCapacityError: FakeProxyCapacityError,
PROXY_PARAMS_VERSION: "v1",
getProxyCachePath: () => "",
}));
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");
}
describe("?hf-proxy=h264 on the static asset route", () => {
it("serves proxy bytes with Accept-Ranges on a full request, and a 206 range slice on a Range request", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
const resolveProxyMock = vi.fn(async () => {
const proxyPath = join(projectDir, "proxy.mp4");
writeFileSync(proxyPath, "0123456789proxybytes");
return proxyPath;
});
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
});
const app = new Hono();
register(app, createAdapter(projectDir));
const full = await app.request(
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
);
expect(full.status).toBe(200);
expect(full.headers.get("Accept-Ranges")).toBe("bytes");
expect(full.headers.get("Content-Type")).toBe("video/mp4");
expect(await full.text()).toBe("0123456789proxybytes");
expect(resolveProxyMock).toHaveBeenCalledTimes(1);
expect(resolveProxyMock).toHaveBeenCalledWith(projectDir, join(projectDir, "clip.mp4"));
const ranged = await app.request(
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
{
headers: { Range: "bytes=0-9" },
},
);
expect(ranged.status).toBe(206);
expect(await ranged.text()).toBe("0123456789");
expect(ranged.headers.get("Content-Range")).toBe("bytes 0-9/20");
});
it("honors If-None-Match on a repeat request with a 304, without re-invoking resolveProxy", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
const resolveProxyMock = vi.fn(async () => {
const proxyPath = join(projectDir, "proxy.mp4");
writeFileSync(proxyPath, "proxy-bytes");
return proxyPath;
});
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
});
const app = new Hono();
register(app, createAdapter(projectDir));
const first = await app.request(
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
);
expect(first.status).toBe(200);
const etag = first.headers.get("ETag");
expect(etag).toBeTruthy();
expect(resolveProxyMock).toHaveBeenCalledTimes(1);
const second = await app.request(
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
{ headers: { "If-None-Match": etag! } },
);
expect(second.status).toBe(304);
// The 304 shortcut never needs the proxy — no second transcode call.
expect(resolveProxyMock).toHaveBeenCalledTimes(1);
});
it("returns 404 without transcoding when the asset is missing", async () => {
const projectDir = createProjectDir();
const resolveProxyMock = vi.fn(async () => "should-not-be-called");
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
});
const app = new Hono();
register(app, createAdapter(projectDir));
const res = await app.request(
"http://localhost/projects/demo/preview/does-not-exist.mp4?hf-proxy=h264",
);
expect(res.status).toBe(404);
expect(resolveProxyMock).not.toHaveBeenCalled();
});
it("returns 404 without transcoding when the asset is not a video", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "notes.txt"), "just text");
const resolveProxyMock = vi.fn(async () => "should-not-be-called");
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
});
const app = new Hono();
register(app, createAdapter(projectDir));
const res = await app.request(
"http://localhost/projects/demo/preview/notes.txt?hf-proxy=h264",
);
expect(res.status).toBe(404);
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");
const resolveProxyMock = vi.fn(async () => "should-not-be-called");
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
});
const app = new Hono();
register(app, createAdapter(projectDir));
for (const value of ["vp9", "H264", ""]) {
const res = await app.request(
`http://localhost/projects/demo/preview/clip.mp4?hf-proxy=${value}`,
);
expect(res.status).toBe(404);
}
expect(resolveProxyMock).not.toHaveBeenCalled();
});
it("maps a ProxyTranscodeError to a 502 carrying the error message", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
const resolveProxyMock = vi.fn(async () => {
throw new FakeProxyTranscodeError("ffmpeg exited with code 1", 1, "unsupported codec");
});
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
});
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(502);
expect(await res.text()).toBe("ffmpeg exited with code 1");
});
it("maps a full proxy queue to a retryable 503", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: async () => {
throw new FakeProxyCapacityError("media proxy queue is full", null, "");
},
});
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(503);
expect(res.headers.get("Retry-After")).toBe("5");
});
it("rejects a path-traversal attempt through the proxied path (404, no transcode)", async () => {
const projectDir = createProjectDir();
const resolveProxyMock = vi.fn(async () => "should-not-be-called");
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
});
const app = new Hono();
register(app, createAdapter(projectDir));
const res = await app.request(
"http://localhost/projects/demo/preview/..%2f..%2f..%2fetc%2fpasswd?hf-proxy=h264",
);
expect(res.status).toBe(404);
expect(resolveProxyMock).not.toHaveBeenCalled();
});
it("404s the param when auto-proxy is disabled for the adapter, without transcoding", async () => {
const projectDir = createProjectDir();
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
const resolveProxyMock = vi.fn(async () => "should-not-be-called");
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
});
const app = new Hono();
register(app, createAdapter(projectDir, { autoProxy: false }));
const res = await app.request(
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
);
expect(res.status).toBe(404);
expect(resolveProxyMock).not.toHaveBeenCalled();
});
});
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"));
const scanMapMock = vi.fn(async () => ({
"/videos/hevc.mp4": {
codecName: "hevc",
browserHostile: true,
representativeMime: 'video/mp4; codecs="hvc1.1.6.L120.B0"',
},
}));
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
scanMapImpl: scanMapMock,
});
const app = new Hono();
register(app, createAdapter(projectDir));
const res = await app.request("http://localhost/projects/demo/preview");
expect(res.status).toBe(200);
const html = await res.text();
expect(html).toContain("window.__HF_MEDIA_CODEC_MAP__");
expect(html).toContain("/videos/hevc.mp4");
expect(html).not.toContain("h264.mp4");
expect(scanMapMock).toHaveBeenCalled();
// Pre-warm: fire-and-forget resolveProxy for the hostile entry.
await Promise.resolve();
await Promise.resolve();
expect(resolveProxyMock).toHaveBeenCalledWith(
projectDir,
join(projectDir, "/videos/hevc.mp4"),
);
});
it("escapes script terminators and JavaScript line separators in codec-map keys", async () => {
const projectDir = createProjectDir();
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: async () => join(projectDir, ".transcode-cache", "x.mp4"),
scanMapImpl: async () => ({
"/videos/</script>\u2028\u2029.mp4": {
codecName: "hevc",
browserHostile: true,
representativeMime: null,
},
}),
});
const app = new Hono();
register(app, createAdapter(projectDir));
const html = await (await app.request("http://localhost/projects/demo/preview")).text();
const injected = /<script data-hf-media-codec-map>([\s\S]*?)<\/script>/.exec(html)?.[1];
expect(injected).toContain("\\u003c/script>");
expect(injected).toContain("\\u2028");
expect(injected).toContain("\\u2029");
expect(injected).not.toContain("</script>");
});
it("does not inject the codec map (and 404s the proxy param) when auto-proxy is disabled", async () => {
const projectDir = createProjectDir();
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,
},
}));
const { registerPreviewRoutes: register } = await loadPreviewModule({
resolveProxyImpl: resolveProxyMock,
scanMapImpl: scanMapMock,
});
const app = new Hono();
register(app, createAdapter(projectDir, { autoProxy: false }));
const res = await app.request("http://localhost/projects/demo/preview");
expect(res.status).toBe(200);
const html = await res.text();
expect(html).not.toContain("__HF_MEDIA_CODEC_MAP__");
expect(scanMapMock).not.toHaveBeenCalled();
const proxyRes = await app.request(
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
);
expect(proxyRes.status).toBe(404);
expect(resolveProxyMock).not.toHaveBeenCalled();
});
});
});
+105 -15
View File
@@ -18,6 +18,19 @@ import {
import { ensureHfIds } from "@hyperframes/parsers/hf-ids"; import { ensureHfIds } from "@hyperframes/parsers/hf-ids";
import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js"; import { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.js";
import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js"; import { isVariablesPayload, VARIABLES_PAYLOAD_ERROR } from "../helpers/variablesPayload.js";
import {
resolveProxy,
ProxyCapacityError,
ProxyTranscodeError,
} from "../helpers/proxyTranscoder.js";
import { decideMediaProxyEligibility, probeAssetCodec } from "../helpers/mediaCodecMap.js";
import {
isAutoProxyEnabled,
injectMediaCodecMap,
proxyEtagSalt,
resolvePreviewMediaCodecProbeCache,
type PreviewApiAdapter,
} from "../helpers/mediaProxyPreview.js";
const PROJECT_SIGNATURE_META = "hyperframes-project-signature"; const PROJECT_SIGNATURE_META = "hyperframes-project-signature";
const GSAP_CDN_VERSION = "3.15.0"; const GSAP_CDN_VERSION = "3.15.0";
@@ -52,7 +65,9 @@ function parseStudioMotionManifestContent(content: string): {
hasCustomEase: boolean; hasCustomEase: boolean;
} { } {
try { 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 : []; const motions = Array.isArray(parsed.motions) ? parsed.motions : [];
return { return {
hasMotion: motions.length > 0, hasMotion: motions.length > 0,
@@ -238,11 +253,13 @@ function variablesEtagSalt(raw: string | undefined): string {
* route should 400; otherwise `values` is the override object (or null when * route should 400; otherwise `values` is the override object (or null when
* the param is absent) and `raw` feeds the ETag salt. * the param is absent) and `raw` feeds the ETag salt.
*/ */
function previewVariablesFromRequest( function previewVariablesFromRequest(rawVariables: string | undefined):
rawVariables: string | undefined,
):
| { error: string } | { error: string }
| { error?: undefined; raw: string | undefined; values: Record<string, unknown> | null } { | {
error?: undefined;
raw: string | undefined;
values: Record<string, unknown> | null;
} {
const parse = parsePreviewVariablesParam(rawVariables); const parse = parsePreviewVariablesParam(rawVariables);
if (!parse.ok) return { error: parse.error }; if (!parse.ok) return { error: parse.error };
return { raw: rawVariables, values: parse.values }; return { raw: rawVariables, values: parse.values };
@@ -290,21 +307,32 @@ function resolveProjectMainHtml(
): { html: string; compositionPath: string } | null { ): { html: string; compositionPath: string } | null {
const indexPath = join(projectDir, "index.html"); const indexPath = join(projectDir, "index.html");
if (existsSync(indexPath)) { 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`); const blockHtmlPath = join(projectDir, `${projectId}.html`);
if (existsSync(blockHtmlPath)) { if (existsSync(blockHtmlPath)) {
return { html: readFileSync(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` }; return {
html: readFileSync(blockHtmlPath, "utf-8"),
compositionPath: `${projectId}.html`,
};
} }
return null; return null;
} }
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void { export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void {
const previewCacheHeaders = (etag: string) => ({ const previewCacheHeaders = (etag: string) => ({
"Cache-Control": "private, no-cache", "Cache-Control": "private, no-cache",
ETag: etag, ETag: etag,
}); });
// One probe cache per server instance (this function runs once per
// registered API), reused across every preview request so the mtime-cache
// benefit in scanProjectMediaCodecMap actually applies.
const mediaCodecProbeCache = resolvePreviewMediaCodecProbeCache(adapter);
// Bundled composition preview // Bundled composition preview
// fallow-ignore-next-line complexity // fallow-ignore-next-line complexity
api.get("/projects/:id/preview", async (c) => { api.get("/projects/:id/preview", async (c) => {
@@ -320,7 +348,10 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`; const etag = `"preview:${signature}${variablesEtagSalt(vars.raw)}"`;
const ifNoneMatch = c.req.header("If-None-Match"); const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) { 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. // Normalize + persist data-hf-id to disk before bundle reads it. Idempotent.
@@ -370,6 +401,13 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
mainCompositionPath, mainCompositionPath,
); );
if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables); if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables);
bundled = await injectMediaCodecMap(
bundled,
adapter,
project.dir,
mainCompositionPath,
mediaCodecProbeCache,
);
return c.html(bundled, 200, previewCacheHeaders(etag)); return c.html(bundled, 200, previewCacheHeaders(etag));
} catch { } catch {
// Re-read disk on bundle failure so we serve the latest file content, // Re-read disk on bundle failure so we serve the latest file content,
@@ -389,6 +427,13 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
if (previewVariables) { if (previewVariables) {
fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables); fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables);
} }
fallbackAugmented = await injectMediaCodecMap(
fallbackAugmented,
adapter,
project.dir,
fallback.compositionPath,
mediaCodecProbeCache,
);
return c.html(fallbackAugmented, 200, previewCacheHeaders(etag)); return c.html(fallbackAugmented, 200, previewCacheHeaders(etag));
} }
return c.text("not found", 404); return c.text("not found", 404);
@@ -443,7 +488,10 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
const etag = `"comp:v2:${compPath}:${signature}${variablesEtagSalt(vars.raw)}"`; const etag = `"comp:v2:${compPath}:${signature}${variablesEtagSalt(vars.raw)}"`;
const ifNoneMatch = c.req.header("If-None-Match"); const ifNoneMatch = c.req.header("If-None-Match");
if (ifNoneMatch === etag) { 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); const stamped = pinSubCompHfIds(compFile, compPath);
@@ -461,6 +509,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath)); html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath); html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
if (previewVariables) html = injectPreviewVariables(html, previewVariables); if (previewVariables) html = injectPreviewVariables(html, previewVariables);
html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache);
return c.html(html, 200, previewCacheHeaders(etag)); return c.html(html, 200, previewCacheHeaders(etag));
}); });
@@ -483,10 +532,33 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
const contentType = getMimeType(subPath); const contentType = getMimeType(subPath);
const isText = /\.(html|css|js|json|svg|txt|md|cube)$/i.test(subPath); const isText = /\.(html|css|js|json|svg|txt|md|cube)$/i.test(subPath);
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}"`; // `?hf-proxy=h264` (per the KTD's `?variables=`-style negotiation): the
// param value must be exactly "h264" (matching play/staticProjectServer),
// only a video asset can be proxied, and only when auto-proxy is enabled
// for this adapter/project. Checked BEFORE any transcode or 304 shortcut
// so a bogus/disabled request never spawns ffmpeg.
const proxyParam = c.req.query("hf-proxy");
if (proxyParam !== undefined) {
if (
proxyParam !== "h264" ||
!contentType.startsWith("video/") ||
!isAutoProxyEnabled(adapter)
) {
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<string, string> = isText const cacheHeaders: Record<string, string> = isText
? { "Cache-Control": "no-store" } ? { "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) { if (!isText) {
const ifNoneMatch = c.req.header("If-None-Match"); const ifNoneMatch = c.req.header("If-None-Match");
@@ -495,9 +567,27 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
} }
} }
// Resolve to the cached proxy (transcoding on miss) only after the 404/304
// shortcuts above — the source's own mtime+size already salts the etag,
// so a 304 never needs to await a transcode at all.
let servedPath = file;
let servedContentType = contentType;
if (proxyParam !== undefined) {
try {
servedPath = await resolveProxy(project.dir, file);
} catch (err) {
if (err instanceof ProxyCapacityError) {
return c.text(err.message, 503, { "Retry-After": "5" });
}
const message = err instanceof ProxyTranscodeError ? err.message : "proxy transcode failed";
return c.text(message, 502);
}
servedContentType = "video/mp4";
}
const buffer: Buffer = isText const buffer: Buffer = isText
? Buffer.from(readFileSync(file, "utf-8"), "utf-8") ? Buffer.from(readFileSync(file, "utf-8"), "utf-8")
: readFileSync(file); : readFileSync(servedPath);
const totalSize = buffer.length; const totalSize = buffer.length;
// Support byte-range requests so browsers can seek audio/video elements. // Support byte-range requests so browsers can seek audio/video elements.
@@ -513,7 +603,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
status: 206, status: 206,
headers: { headers: {
...cacheHeaders, ...cacheHeaders,
"Content-Type": contentType, "Content-Type": servedContentType,
"Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`, "Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`,
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Content-Length": String(chunkSize), "Content-Length": String(chunkSize),
@@ -525,7 +615,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
return new Response(new Uint8Array(buffer), { return new Response(new Uint8Array(buffer), {
headers: { headers: {
...cacheHeaders, ...cacheHeaders,
"Content-Type": contentType, "Content-Type": servedContentType,
"Accept-Ranges": "bytes", "Accept-Ranges": "bytes",
"Content-Length": String(totalSize), "Content-Length": String(totalSize),
}, },
+9 -1
View File
@@ -4,6 +4,9 @@ export default defineConfig({
entry: { entry: {
index: "src/index.ts", index: "src/index.ts",
"helpers/screenshotClip": "src/helpers/screenshotClip.ts", "helpers/screenshotClip": "src/helpers/screenshotClip.ts",
"helpers/mediaCodecMap": "src/helpers/mediaCodecMap.ts",
"helpers/proxyTranscoder": "src/helpers/proxyTranscoder.ts",
"helpers/mediaProxyPreview": "src/helpers/mediaProxyPreview.ts",
"helpers/manualEditsRenderScript": "src/helpers/manualEditsRenderScript.ts", "helpers/manualEditsRenderScript": "src/helpers/manualEditsRenderScript.ts",
"helpers/studioMotionRenderScript": "src/helpers/studioMotionRenderScript.ts", "helpers/studioMotionRenderScript": "src/helpers/studioMotionRenderScript.ts",
"helpers/draftMarkers": "src/helpers/draftMarkers.ts", "helpers/draftMarkers": "src/helpers/draftMarkers.ts",
@@ -15,7 +18,12 @@ export default defineConfig({
target: "node22", target: "node22",
platform: "node", platform: "node",
bundle: true, bundle: true,
splitting: false, // Split shared chunks so every entry (index + helper subpaths) imports ONE
// copy of stateful modules — proxyTranscoder's in-flight dedupe, transcode
// semaphore, and negative cache must be process-global, not per-entry.
// With splitting off, each entry inlined its own copy and a pre-warm from
// media-proxy-preview couldn't dedupe against a route's proxy-transcoder.
splitting: true,
sourcemap: true, sourcemap: true,
clean: true, clean: true,
dts: true, dts: true,