mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(media): alpha-capable authoring proxies (#2598)
* feat(media): alpha-capable authoring proxies Alpha sources were refused a proxy before the codec map ever asked whether the browser could decode them, so a ProRes 4444 alpha file (which no browser previews at all) rendered black forever, while an alpha WebM (which previews fine) was already covered by the browser-safe check on the next line. The alpha veto earned nothing and cost the one case that needed help. Alpha is now a target-codec choice rather than a veto: alpha sources transcode to VP9 + yuva420p in WebM, everything else keeps the existing H.264/MP4 path byte for byte. Only files no browser can preview are proxied, which is the rule the runtime already followed everywhere else. WebM cannot carry AAC, so the VP9 path uses Opus and drops the MP4-only faststart flag. PROXY_PARAMS_VERSION moves to v3 so clients stop serving the previously cached proxies. Safari does not decode VP9 alpha and still shows black for alpha sources, as it does today: this is better on Chromium and Firefox and no worse anywhere. * fix(media): infer proxy variant for rescue * fix(media): preserve alpha proxy hardening after restack
This commit is contained in:
@@ -59,10 +59,20 @@ const mediaMocks = vi.hoisted(() => ({
|
|||||||
representativeMime: "video/mp4",
|
representativeMime: "video/mp4",
|
||||||
hasAlpha: false,
|
hasAlpha: false,
|
||||||
})),
|
})),
|
||||||
decideMediaProxyEligibility: vi.fn(
|
decideMediaProxyEligibility: vi.fn((facts: { browserHostile: boolean } | null) =>
|
||||||
(facts: { hasAlpha: boolean; browserHostile: boolean } | null) =>
|
facts?.browserHostile ? { eligible: true } : { eligible: false, reason: "browser_safe_codec" },
|
||||||
facts?.hasAlpha ? { eligible: false, reason: "alpha_source" } : { eligible: true },
|
|
||||||
),
|
),
|
||||||
|
isProxyVariant: (value: string) => value === "h264" || value === "vp9",
|
||||||
|
isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp9",
|
||||||
|
proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp9" : "h264"),
|
||||||
|
resolveProxyVariantRequest: (request: "auto" | "h264" | "vp9", facts: { hasAlpha?: boolean }) => {
|
||||||
|
const expected = facts.hasAlpha ? "vp9" : "h264";
|
||||||
|
return request === "auto" || request === expected ? expected : null;
|
||||||
|
},
|
||||||
|
PROXY_VARIANT_CONFIG: {
|
||||||
|
h264: { extension: ".mp4", contentType: "video/mp4" },
|
||||||
|
vp9: { extension: ".webm", contentType: "video/webm" },
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({
|
vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({
|
||||||
@@ -109,7 +119,7 @@ afterEach(() => {
|
|||||||
dir = undefined;
|
dir = undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects direct proxy requests for alpha sources", async () => {
|
it("serves direct VP9 proxy requests for alpha sources", async () => {
|
||||||
const project = tmpProject();
|
const project = tmpProject();
|
||||||
writeFileSync(join(project.dir, "clip.mov"), "alpha-prores");
|
writeFileSync(join(project.dir, "clip.mov"), "alpha-prores");
|
||||||
mediaMocks.probeAssetCodec.mockResolvedValueOnce({
|
mediaMocks.probeAssetCodec.mockResolvedValueOnce({
|
||||||
@@ -118,17 +128,20 @@ it("rejects direct proxy requests for alpha sources", async () => {
|
|||||||
representativeMime: "video/quicktime",
|
representativeMime: "video/quicktime",
|
||||||
hasAlpha: true,
|
hasAlpha: true,
|
||||||
});
|
});
|
||||||
mediaMocks.decideMediaProxyEligibility.mockReturnValueOnce({
|
const proxyPath = join(project.dir, "proxy.webm");
|
||||||
eligible: false,
|
writeFileSync(proxyPath, "vp9-alpha-proxy");
|
||||||
reason: "alpha_source",
|
mocks.resolveProxy.mockResolvedValueOnce(proxyPath);
|
||||||
});
|
|
||||||
const app = await buildApp(project, true);
|
const app = await buildApp(project, true);
|
||||||
|
|
||||||
const res = await app.request("/composition/clip.mov?hf-proxy=h264");
|
const res = await app.request("/composition/clip.mov?hf-proxy=auto");
|
||||||
|
|
||||||
expect(res.status).toBe(422);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.text()).toContain("alpha_source");
|
expect(res.headers.get("content-type")).toBe("video/webm");
|
||||||
expect(mocks.resolveProxy).not.toHaveBeenCalled();
|
expect(mocks.resolveProxy).toHaveBeenCalledWith(
|
||||||
|
project.dir,
|
||||||
|
join(project.dir, "clip.mov"),
|
||||||
|
"vp9",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
async function buildApp(project: ProjectDir, autoProxy: boolean): Promise<Hono> {
|
async function buildApp(project: ProjectDir, autoProxy: boolean): Promise<Hono> {
|
||||||
@@ -163,7 +176,11 @@ describe("registerCompositionRoute", () => {
|
|||||||
|
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.text()).toBe("transcoded-h264-bytes");
|
expect(await res.text()).toBe("transcoded-h264-bytes");
|
||||||
expect(mocks.resolveProxy).toHaveBeenCalledWith(project.dir, join(project.dir, "clip.mp4"));
|
expect(mocks.resolveProxy).toHaveBeenCalledWith(
|
||||||
|
project.dir,
|
||||||
|
join(project.dir, "clip.mp4"),
|
||||||
|
"h264",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves ?hf-proxy=h264 for a .mov hostile asset as Content-Type video/mp4 (the proxy IS mp4)", async () => {
|
it("serves ?hf-proxy=h264 for a .mov hostile asset as Content-Type video/mp4 (the proxy IS mp4)", async () => {
|
||||||
|
|||||||
@@ -44,7 +44,10 @@ import {
|
|||||||
} from "@hyperframes/studio-server/proxy-transcoder";
|
} from "@hyperframes/studio-server/proxy-transcoder";
|
||||||
import {
|
import {
|
||||||
decideMediaProxyEligibility,
|
decideMediaProxyEligibility,
|
||||||
|
isProxyVariantRequest,
|
||||||
probeAssetCodec,
|
probeAssetCodec,
|
||||||
|
resolveProxyVariantRequest,
|
||||||
|
PROXY_VARIANT_CONFIG,
|
||||||
} from "@hyperframes/studio-server/media-codec-map";
|
} from "@hyperframes/studio-server/media-codec-map";
|
||||||
|
|
||||||
export default defineCommand({
|
export default defineCommand({
|
||||||
@@ -72,7 +75,7 @@ export default defineCommand({
|
|||||||
proxy: {
|
proxy: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
description:
|
description:
|
||||||
"Auto-transcode browser-hostile video codecs (HEVC, ProRes, AV1) to a cached H.264 proxy for preview (default: on; overrides hyperframes.json's media.autoProxy)",
|
"Auto-transcode browser-hostile video codecs (HEVC, ProRes, AV1) to a cached authoring proxy for preview (default: on; overrides hyperframes.json's media.autoProxy)",
|
||||||
negativeDescription: "Disable auto-proxying of browser-hostile video codecs",
|
negativeDescription: "Disable auto-proxying of browser-hostile video codecs",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -192,8 +195,8 @@ export default defineCommand({
|
|||||||
* Registers the `/composition/*` route: serves composition HTML (runtime +
|
* Registers the `/composition/*` route: serves composition HTML (runtime +
|
||||||
* `__HF_MEDIA_CODEC_MAP__` injected) and asset files, with byte-Range support
|
* `__HF_MEDIA_CODEC_MAP__` injected) and asset files, with byte-Range support
|
||||||
* (`play` previously did a whole-file `readFileSync`, so seeking/duration
|
* (`play` previously did a whole-file `readFileSync`, so seeking/duration
|
||||||
* probing on media elements never worked) and a `?hf-proxy=h264` branch that
|
* probing on media elements never worked) and a `?hf-proxy=` branch that
|
||||||
* serves the cached H.264 authoring proxy for a browser-hostile video asset
|
* serves the alpha-aware authoring proxy for a browser-hostile video asset
|
||||||
* (per docs/plans/2026-07-14-002-feat-transparent-media-proxies-plan.md,
|
* (per docs/plans/2026-07-14-002-feat-transparent-media-proxies-plan.md,
|
||||||
* unit U4). Exported standalone (rather than inlined in `run()`) so tests can
|
* unit U4). Exported standalone (rather than inlined in `run()`) so tests can
|
||||||
* exercise it via `app.request(...)` without booting a real HTTP listener,
|
* exercise it via `app.request(...)` without booting a real HTTP listener,
|
||||||
@@ -228,19 +231,28 @@ export async function registerCompositionRoute(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const contentType = assetContentType(filePath);
|
const contentType = assetContentType(filePath);
|
||||||
if (ctx.req.query("hf-proxy") === "h264") {
|
const proxyParam = ctx.req.query("hf-proxy");
|
||||||
|
if (proxyParam !== undefined && isProxyVariantRequest(proxyParam)) {
|
||||||
// Opt-out (or a non-video asset) 404s the param without attempting a
|
// Opt-out (or a non-video asset) 404s the param without attempting a
|
||||||
// transcode; a missing asset already 404'd above.
|
// transcode; a missing asset already 404'd above.
|
||||||
if (!autoProxy || !contentType.startsWith("video/")) return ctx.text("Not found", 404);
|
if (!autoProxy || !contentType.startsWith("video/")) return ctx.text("Not found", 404);
|
||||||
try {
|
try {
|
||||||
const eligibility = decideMediaProxyEligibility(await probeAssetCodec(filePath));
|
const facts = await probeAssetCodec(filePath);
|
||||||
|
const eligibility = decideMediaProxyEligibility(facts);
|
||||||
if (!eligibility.eligible) {
|
if (!eligibility.eligible) {
|
||||||
return ctx.text(`Media proxy unavailable: ${eligibility.reason}`, 422);
|
return ctx.text(`Media proxy unavailable: ${eligibility.reason}`, 422);
|
||||||
}
|
}
|
||||||
const proxyPath = await resolveProxy(project.dir, filePath);
|
if (!facts) return ctx.text("Media proxy unavailable: unknown_codec", 422);
|
||||||
// The proxy IS an mp4 regardless of the source's extension (.mov,
|
const proxyVariant = resolveProxyVariantRequest(proxyParam, facts);
|
||||||
// .mkv, ...) — serve its real type, matching the preview route.
|
if (!proxyVariant) {
|
||||||
return buildRangeResponse(proxyPath, "video/mp4", ctx.req.header("Range"));
|
return ctx.text("Media proxy variant does not match asset", 422);
|
||||||
|
}
|
||||||
|
const proxyPath = await resolveProxy(project.dir, filePath, proxyVariant);
|
||||||
|
return buildRangeResponse(
|
||||||
|
proxyPath,
|
||||||
|
PROXY_VARIANT_CONFIG[proxyVariant].contentType,
|
||||||
|
ctx.req.header("Range"),
|
||||||
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ProxyCapacityError) {
|
if (err instanceof ProxyCapacityError) {
|
||||||
return ctx.text(`Proxy transcode deferred: ${err.message}`, 503, {
|
return ctx.text(`Proxy transcode deferred: ${err.message}`, 503, {
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ export default defineCommand({
|
|||||||
proxy: {
|
proxy: {
|
||||||
type: "boolean",
|
type: "boolean",
|
||||||
description:
|
description:
|
||||||
"Auto-transcode browser-hostile video codecs (HEVC, ProRes, AV1) to a cached H.264 proxy for preview (default: on; overrides hyperframes.json's media.autoProxy)",
|
"Auto-transcode browser-hostile video codecs (HEVC, ProRes, AV1) to a cached authoring proxy for preview (default: on; overrides hyperframes.json's media.autoProxy)",
|
||||||
negativeDescription: "Disable auto-proxying of browser-hostile video codecs",
|
negativeDescription: "Disable auto-proxying of browser-hostile video codecs",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ vi.mock("./staticProjectServer.js", () => ({
|
|||||||
vi.mock("@hyperframes/studio-server/media-codec-map", async (importOriginal) => ({
|
vi.mock("@hyperframes/studio-server/media-codec-map", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("@hyperframes/studio-server/media-codec-map")>()),
|
...(await importOriginal<typeof import("@hyperframes/studio-server/media-codec-map")>()),
|
||||||
scanProjectMediaCodecMap: mocks.scanProjectMediaCodecMap,
|
scanProjectMediaCodecMap: mocks.scanProjectMediaCodecMap,
|
||||||
|
proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp9" : "h264"),
|
||||||
}));
|
}));
|
||||||
vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({
|
vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({
|
||||||
resolveProxy: mocks.resolveProxy,
|
resolveProxy: mocks.resolveProxy,
|
||||||
@@ -341,7 +342,7 @@ it("surfaces the runtime's media-proxy-fallback console.info line as an info fin
|
|||||||
const fallbackMessage = fakeConsoleMessage(
|
const fallbackMessage = fakeConsoleMessage(
|
||||||
"info",
|
"info",
|
||||||
'[hyperframes] runtime_media_proxy_fallback: "assets/clip.mp4" uses a codec (hevc) this browser can\'t decode; ' +
|
'[hyperframes] runtime_media_proxy_fallback: "assets/clip.mp4" uses a codec (hevc) this browser can\'t decode; ' +
|
||||||
"auto-swapped to an H.264 proxy for this preview only. Render output is unaffected.",
|
"auto-swapped to an authoring proxy for this preview only. Render output is unaffected.",
|
||||||
);
|
);
|
||||||
const unrelatedInfo = fakeConsoleMessage("info", "[hyperframes] render runtime fps 30");
|
const unrelatedInfo = fakeConsoleMessage("info", "[hyperframes] render runtime fps 30");
|
||||||
const authorInfo = fakeConsoleMessage("info", "debug runtime_media_proxy_probe");
|
const authorInfo = fakeConsoleMessage("info", "debug runtime_media_proxy_probe");
|
||||||
@@ -455,7 +456,11 @@ describe("preResolveHostileMediaProxies", () => {
|
|||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
expect(mocks.resolveProxy).toHaveBeenCalledTimes(1);
|
expect(mocks.resolveProxy).toHaveBeenCalledTimes(1);
|
||||||
expect(mocks.resolveProxy).toHaveBeenCalledWith(projectDir, join(projectDir, "clip.mp4"));
|
expect(mocks.resolveProxy).toHaveBeenCalledWith(
|
||||||
|
projectDir,
|
||||||
|
join(projectDir, "clip.mp4"),
|
||||||
|
"h264",
|
||||||
|
);
|
||||||
expect(settled).toBe(false); // still waiting on the hostile entry's transcode
|
expect(settled).toBe(false); // still waiting on the hostile entry's transcode
|
||||||
|
|
||||||
resolveTranscode?.();
|
resolveTranscode?.();
|
||||||
@@ -500,10 +505,10 @@ describe("preResolveHostileMediaProxies", () => {
|
|||||||
it("does not pre-resolve a hostile asset rejected by the shared proxy policy", async () => {
|
it("does not pre-resolve a hostile asset rejected by the shared proxy policy", async () => {
|
||||||
const projectDir = mkProjectDir();
|
const projectDir = mkProjectDir();
|
||||||
mocks.scanProjectMediaCodecMap.mockResolvedValue({
|
mocks.scanProjectMediaCodecMap.mockResolvedValue({
|
||||||
"/alpha.mov": {
|
"/alpha.webm": {
|
||||||
codecName: "prores",
|
codecName: "vp9",
|
||||||
browserHostile: true,
|
browserHostile: true,
|
||||||
representativeMime: null,
|
representativeMime: 'video/webm; codecs="vp09.00.10.08"',
|
||||||
hasAlpha: true,
|
hasAlpha: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { serveStaticProjectHtml } from "./staticProjectServer.js";
|
|||||||
import { resolveAutoProxy } from "./projectConfig.js";
|
import { resolveAutoProxy } from "./projectConfig.js";
|
||||||
import {
|
import {
|
||||||
decideMediaProxyEligibility,
|
decideMediaProxyEligibility,
|
||||||
|
proxyVariantFor,
|
||||||
scanProjectMediaCodecMap,
|
scanProjectMediaCodecMap,
|
||||||
} from "@hyperframes/studio-server/media-codec-map";
|
} from "@hyperframes/studio-server/media-codec-map";
|
||||||
import { resolveProxy } from "@hyperframes/studio-server/proxy-transcoder";
|
import { resolveProxy } from "@hyperframes/studio-server/proxy-transcoder";
|
||||||
@@ -115,15 +116,19 @@ export async function preResolveHostileMediaProxies(
|
|||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const hostilePathnames = Object.entries(codecMap)
|
const hostileEntries = Object.entries(codecMap).filter(
|
||||||
.filter(([, facts]) => decideMediaProxyEligibility(facts).eligible)
|
([, facts]) => decideMediaProxyEligibility(facts).eligible,
|
||||||
.map(([pathname]) => pathname);
|
);
|
||||||
if (hostilePathnames.length === 0) return;
|
if (hostileEntries.length === 0) return;
|
||||||
|
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
hostilePathnames.map((pathname) =>
|
hostileEntries.map(([pathname, facts]) =>
|
||||||
resolveProxy(projectDir, resolve(projectDir, pathname.replace(/^\/+/, ""))),
|
resolveProxy(
|
||||||
|
projectDir,
|
||||||
|
resolve(projectDir, pathname.replace(/^\/+/, "")),
|
||||||
|
proxyVariantFor(facts),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
const failed = results.filter((result) => result.status === "rejected").length;
|
const failed = results.filter((result) => result.status === "rejected").length;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface ProjectConfigPaths {
|
|||||||
export interface ProjectConfigMedia {
|
export interface ProjectConfigMedia {
|
||||||
/**
|
/**
|
||||||
* Auto-transcode browser-hostile video codecs (e.g. HEVC) to a cached
|
* Auto-transcode browser-hostile video codecs (e.g. HEVC) to a cached
|
||||||
* H.264 proxy for supported preview surfaces. Render always uses the
|
* alpha-aware authoring proxy for supported preview surfaces. Render always uses the
|
||||||
* original file regardless of this setting. Default true.
|
* original file regardless of this setting. Default true.
|
||||||
*/
|
*/
|
||||||
autoProxy?: boolean;
|
autoProxy?: boolean;
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({
|
|||||||
|
|
||||||
vi.mock("@hyperframes/studio-server/media-codec-map", () => ({
|
vi.mock("@hyperframes/studio-server/media-codec-map", () => ({
|
||||||
scanProjectMediaCodecMap: mocks.scanProjectMediaCodecMap,
|
scanProjectMediaCodecMap: mocks.scanProjectMediaCodecMap,
|
||||||
|
proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp9" : "h264"),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const { bakeMediaProxies, PROXY_ARCHIVE_PREFIX } = await import("./publishProxyBake.js");
|
const { bakeMediaProxies, PROXY_ARCHIVE_PREFIX } = await import("./publishProxyBake.js");
|
||||||
@@ -60,10 +61,10 @@ const { bakeMediaProxies, PROXY_ARCHIVE_PREFIX } = await import("./publishProxyB
|
|||||||
const PROJECT_DIR = resolve("/project");
|
const PROJECT_DIR = resolve("/project");
|
||||||
|
|
||||||
const tempDirs: string[] = [];
|
const tempDirs: string[] = [];
|
||||||
function tmpProxyFile(content: string): string {
|
function tmpProxyFile(content: string, extension = ".mp4"): string {
|
||||||
const dir = mkdtempSync(join(tmpdir(), "hf-publish-proxy-bake-"));
|
const dir = mkdtempSync(join(tmpdir(), "hf-publish-proxy-bake-"));
|
||||||
tempDirs.push(dir);
|
tempDirs.push(dir);
|
||||||
const path = join(dir, "proxy.mp4");
|
const path = join(dir, `proxy${extension}`);
|
||||||
writeFileSync(path, content, "utf-8");
|
writeFileSync(path, content, "utf-8");
|
||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,11 @@ describe("bakeMediaProxies", () => {
|
|||||||
expect(html).toContain(proxyEntries[0]!);
|
expect(html).toContain(proxyEntries[0]!);
|
||||||
expect(html).not.toContain('src="clip.mp4"');
|
expect(html).not.toContain('src="clip.mp4"');
|
||||||
|
|
||||||
expect(mocks.resolveProxy).toHaveBeenCalledWith(PROJECT_DIR, join(PROJECT_DIR, "clip.mp4"));
|
expect(mocks.resolveProxy).toHaveBeenCalledWith(
|
||||||
|
PROJECT_DIR,
|
||||||
|
join(PROJECT_DIR, "clip.mp4"),
|
||||||
|
"h264",
|
||||||
|
);
|
||||||
expect(mocks.waitForProxy).toHaveBeenCalledWith(expect.any(Promise), 15 * 60 * 1000);
|
expect(mocks.waitForProxy).toHaveBeenCalledWith(expect.any(Promise), 15 * 60 * 1000);
|
||||||
expect(manifest).toEqual({ proxied: ["/clip.mp4"], skippedAlpha: [], failed: [] });
|
expect(manifest).toEqual({ proxied: ["/clip.mp4"], skippedAlpha: [], failed: [] });
|
||||||
});
|
});
|
||||||
@@ -191,7 +196,7 @@ describe("bakeMediaProxies", () => {
|
|||||||
expect(html).not.toContain("assets/my%20clip.mp4");
|
expect(html).not.toContain("assets/my%20clip.mp4");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports an alpha-bearing hostile asset as skipped while keeping HTML on the original", async () => {
|
it("bakes an alpha-bearing hostile asset as a VP9 WebM proxy", async () => {
|
||||||
mocks.scanProjectMediaCodecMap.mockResolvedValue({
|
mocks.scanProjectMediaCodecMap.mockResolvedValue({
|
||||||
"/clip.mov": {
|
"/clip.mov": {
|
||||||
codecName: "prores",
|
codecName: "prores",
|
||||||
@@ -200,6 +205,8 @@ describe("bakeMediaProxies", () => {
|
|||||||
hasAlpha: true,
|
hasAlpha: true,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
const proxyPath = tmpProxyFile("PROXY_VP9_ALPHA_BYTES", ".webm");
|
||||||
|
mocks.resolveProxy.mockResolvedValue(proxyPath);
|
||||||
const fileContents = new Map<string, Buffer>([
|
const fileContents = new Map<string, Buffer>([
|
||||||
["index.html", indexHtml(`<video src="clip.mov" muted></video>`)],
|
["index.html", indexHtml(`<video src="clip.mov" muted></video>`)],
|
||||||
["clip.mov", Buffer.from("ORIGINAL_PRORES_4444_BYTES", "utf-8")],
|
["clip.mov", Buffer.from("ORIGINAL_PRORES_4444_BYTES", "utf-8")],
|
||||||
@@ -207,12 +214,18 @@ describe("bakeMediaProxies", () => {
|
|||||||
|
|
||||||
const manifest = await bakeMediaProxies(PROJECT_DIR, fileContents);
|
const manifest = await bakeMediaProxies(PROJECT_DIR, fileContents);
|
||||||
|
|
||||||
expect(mocks.resolveProxy).not.toHaveBeenCalled();
|
expect(mocks.resolveProxy).toHaveBeenCalledWith(
|
||||||
expect([...fileContents.keys()].some((k) => k.startsWith(`${PROXY_ARCHIVE_PREFIX}/`))).toBe(
|
PROJECT_DIR,
|
||||||
false,
|
join(PROJECT_DIR, "clip.mov"),
|
||||||
|
"vp9",
|
||||||
);
|
);
|
||||||
expect(fileContents.get("index.html")?.toString("utf-8")).toContain('src="clip.mov"');
|
const proxyEntries = [...fileContents.keys()].filter((key) =>
|
||||||
expect(manifest).toEqual({ proxied: [], skippedAlpha: ["/clip.mov"], failed: [] });
|
key.startsWith(`${PROXY_ARCHIVE_PREFIX}/`),
|
||||||
|
);
|
||||||
|
expect(proxyEntries).toEqual([`${PROXY_ARCHIVE_PREFIX}/proxy.webm`]);
|
||||||
|
expect(fileContents.get(proxyEntries[0]!)?.toString("utf-8")).toBe("PROXY_VP9_ALPHA_BYTES");
|
||||||
|
expect(fileContents.get("index.html")?.toString("utf-8")).toContain(proxyEntries[0]!);
|
||||||
|
expect(manifest).toEqual({ proxied: ["/clip.mov"], skippedAlpha: [], failed: [] });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns deterministic manifest ordering across concurrent transcodes", async () => {
|
it("returns deterministic manifest ordering across concurrent transcodes", async () => {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
* Publish-time proxy baking (U6 of
|
* Publish-time proxy baking (U6 of
|
||||||
* docs/plans/2026-07-14-002-feat-transparent-media-proxies-plan.md).
|
* docs/plans/2026-07-14-002-feat-transparent-media-proxies-plan.md).
|
||||||
*
|
*
|
||||||
* Published pages are static (no server), so the on-demand `?hf-proxy=h264`
|
* Published pages are static (no server), so the on-demand `?hf-proxy=`
|
||||||
* negotiation the preview/play surfaces use (U3/U4) isn't possible there.
|
* negotiation the preview/play surfaces use (U3/U4) isn't possible there.
|
||||||
* Instead this scans the archive's HTML entries for local `<video src>`
|
* Instead this scans the archive's HTML entries for local `<video src>`
|
||||||
* references to browser-hostile codecs (HEVC, ProRes, ...), transcodes each
|
* references to browser-hostile codecs (HEVC, ProRes, ...), transcodes each
|
||||||
@@ -20,8 +20,8 @@
|
|||||||
* `cloud render` never calls this: it uses `createPublishArchive` directly,
|
* `cloud render` never calls this: it uses `createPublishArchive` directly,
|
||||||
* which has no baking hook (R2 in the plan).
|
* which has no baking hook (R2 in the plan).
|
||||||
*
|
*
|
||||||
* Alpha-bearing sources remain explicit skips because H.264 would destroy
|
* Alpha-bearing sources bake as VP9/WebM so transparency survives. A failed
|
||||||
* transparency. A failed opaque-hostile transcode aborts publish with a
|
* hostile transcode aborts publish with a
|
||||||
* structured manifest rather than silently shipping an unplayable asset.
|
* structured manifest rather than silently shipping an unplayable asset.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
resolveLocalAssetCandidates,
|
resolveLocalAssetCandidates,
|
||||||
} from "@hyperframes/parsers/asset-resolution";
|
} from "@hyperframes/parsers/asset-resolution";
|
||||||
import {
|
import {
|
||||||
|
proxyVariantFor,
|
||||||
scanProjectMediaCodecMap,
|
scanProjectMediaCodecMap,
|
||||||
type HtmlSourceLike,
|
type HtmlSourceLike,
|
||||||
} from "@hyperframes/studio-server/media-codec-map";
|
} from "@hyperframes/studio-server/media-codec-map";
|
||||||
@@ -51,6 +52,7 @@ export const PROXY_ARCHIVE_PREFIX = "_proxy";
|
|||||||
|
|
||||||
export interface ProxyBakeManifest {
|
export interface ProxyBakeManifest {
|
||||||
proxied: string[];
|
proxied: string[];
|
||||||
|
/** @deprecated Alpha sources are proxied as VP9; retained as an always-empty compatibility field. */
|
||||||
skippedAlpha: string[];
|
skippedAlpha: string[];
|
||||||
failed: Array<{ path: string; error: string }>;
|
failed: Array<{ path: string; error: string }>;
|
||||||
}
|
}
|
||||||
@@ -75,11 +77,11 @@ function isHtmlEntry(path: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mutates `fileContents` in place: adds a `_proxy/<hash>.mp4` entry for every
|
* Mutates `fileContents` in place: adds a variant-specific `_proxy/<hash>` entry for every
|
||||||
* browser-hostile local video asset referenced from the archive's HTML, and
|
* browser-hostile local video asset referenced from the archive's HTML, and
|
||||||
* rewrites those HTML entries' matching `<video src>` attributes to point at
|
* rewrites those HTML entries' matching `<video src>` attributes to point at
|
||||||
* the proxy. Returns a structured manifest; throws ProxyBakeError when any
|
* the proxy. Returns a structured manifest; throws ProxyBakeError when any
|
||||||
* required opaque proxy cannot be prepared.
|
* required proxy cannot be prepared.
|
||||||
*/
|
*/
|
||||||
export async function bakeMediaProxies(
|
export async function bakeMediaProxies(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
@@ -97,17 +99,7 @@ export async function bakeMediaProxies(
|
|||||||
|
|
||||||
const codecMap = await scanProjectMediaCodecMap(absProjectDir, htmlSources);
|
const codecMap = await scanProjectMediaCodecMap(absProjectDir, htmlSources);
|
||||||
const hostileEntries = Object.entries(codecMap).filter(([, facts]) => facts.browserHostile);
|
const hostileEntries = Object.entries(codecMap).filter(([, facts]) => facts.browserHostile);
|
||||||
const hostilePathnames: string[] = [];
|
if (hostileEntries.length === 0) return manifest;
|
||||||
for (const [pathname, facts] of hostileEntries) {
|
|
||||||
if (facts.hasAlpha) {
|
|
||||||
// Alpha sources are never proxied: an H.264 proxy would destroy the
|
|
||||||
// transparency (e.g. ProRes 4444 alpha). Keep the original in place.
|
|
||||||
manifest.skippedAlpha.push(pathname);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
hostilePathnames.push(pathname);
|
|
||||||
}
|
|
||||||
if (hostilePathnames.length === 0) return manifest;
|
|
||||||
|
|
||||||
// Absolute source path -> archive path of its baked proxy. Built by
|
// Absolute source path -> archive path of its baked proxy. Built by
|
||||||
// resolving each map key back to an absolute path the same way
|
// resolving each map key back to an absolute path the same way
|
||||||
@@ -117,11 +109,11 @@ export async function bakeMediaProxies(
|
|||||||
const proxyByAbsolutePath = new Map<string, string>();
|
const proxyByAbsolutePath = new Map<string, string>();
|
||||||
|
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
hostilePathnames.map(async (pathname) => {
|
hostileEntries.map(async ([pathname, facts]) => {
|
||||||
const absoluteSourcePath = resolve(absProjectDir, pathname.replace(/^\/+/, ""));
|
const absoluteSourcePath = resolve(absProjectDir, pathname.replace(/^\/+/, ""));
|
||||||
try {
|
try {
|
||||||
const proxyPath = await waitForProxy(
|
const proxyPath = await waitForProxy(
|
||||||
resolveProxy(absProjectDir, absoluteSourcePath),
|
resolveProxy(absProjectDir, absoluteSourcePath, proxyVariantFor(facts)),
|
||||||
TRANSCODE_TIMEOUT_MS,
|
TRANSCODE_TIMEOUT_MS,
|
||||||
);
|
);
|
||||||
const archivePath = `${PROXY_ARCHIVE_PREFIX}/${basename(proxyPath)}`;
|
const archivePath = `${PROXY_ARCHIVE_PREFIX}/${basename(proxyPath)}`;
|
||||||
|
|||||||
@@ -46,14 +46,13 @@ const mocks = vi.hoisted(() => {
|
|||||||
>
|
>
|
||||||
>(async () => ({})),
|
>(async () => ({})),
|
||||||
probeAssetCodec: vi.fn(async () => ({
|
probeAssetCodec: vi.fn(async () => ({
|
||||||
codecName: "prores",
|
codecName: "hevc",
|
||||||
pixelFormat: "yuva444p10le",
|
hasAlpha: false,
|
||||||
hasAlpha: true,
|
|
||||||
browserHostile: true,
|
browserHostile: true,
|
||||||
representativeMime: null,
|
representativeMime: null,
|
||||||
})),
|
})),
|
||||||
decideMediaProxyEligibility: vi.fn<
|
decideMediaProxyEligibility: vi.fn<
|
||||||
() => { eligible: true } | { eligible: false; reason: "alpha_source" }
|
() => { eligible: true } | { eligible: false; reason: "browser_safe_codec" }
|
||||||
>(() => ({ eligible: true })),
|
>(() => ({ eligible: true })),
|
||||||
ProxyTranscodeError: FakeProxyTranscodeError,
|
ProxyTranscodeError: FakeProxyTranscodeError,
|
||||||
ProxyCapacityError: FakeProxyCapacityError,
|
ProxyCapacityError: FakeProxyCapacityError,
|
||||||
@@ -70,6 +69,17 @@ vi.mock("@hyperframes/studio-server/proxy-transcoder", () => ({
|
|||||||
vi.mock("@hyperframes/studio-server/media-codec-map", () => ({
|
vi.mock("@hyperframes/studio-server/media-codec-map", () => ({
|
||||||
probeAssetCodec: mocks.probeAssetCodec,
|
probeAssetCodec: mocks.probeAssetCodec,
|
||||||
decideMediaProxyEligibility: mocks.decideMediaProxyEligibility,
|
decideMediaProxyEligibility: mocks.decideMediaProxyEligibility,
|
||||||
|
isProxyVariant: (value: string) => value === "h264" || value === "vp9",
|
||||||
|
isProxyVariantRequest: (value: string) => value === "auto" || value === "h264" || value === "vp9",
|
||||||
|
proxyVariantFor: (facts: { hasAlpha?: boolean }) => (facts.hasAlpha ? "vp9" : "h264"),
|
||||||
|
resolveProxyVariantRequest: (request: "auto" | "h264" | "vp9", facts: { hasAlpha?: boolean }) => {
|
||||||
|
const expected = facts.hasAlpha ? "vp9" : "h264";
|
||||||
|
return request === "auto" || request === expected ? expected : null;
|
||||||
|
},
|
||||||
|
PROXY_VARIANT_CONFIG: {
|
||||||
|
h264: { extension: ".mp4", contentType: "video/mp4" },
|
||||||
|
vp9: { extension: ".webm", contentType: "video/webm" },
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The shared injection helper ships as a self-contained dist bundle (its copy
|
// The shared injection helper ships as a self-contained dist bundle (its copy
|
||||||
@@ -276,7 +286,11 @@ describe("serveStaticProjectHtml transparent media proxies", () => {
|
|||||||
const res = await fetch(`${server.url}clip.mp4?hf-proxy=h264`);
|
const res = await fetch(`${server.url}clip.mp4?hf-proxy=h264`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.text()).toBe("transcoded-h264-bytes");
|
expect(await res.text()).toBe("transcoded-h264-bytes");
|
||||||
expect(mocks.resolveProxy).toHaveBeenCalledWith(projectDir, join(projectDir, "clip.mp4"));
|
expect(mocks.resolveProxy).toHaveBeenCalledWith(
|
||||||
|
projectDir,
|
||||||
|
join(projectDir, "clip.mp4"),
|
||||||
|
"h264",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["mxf", "mts", "m2ts", "ts", "mkv", "m4v"])(
|
it.each(["mxf", "mts", "m2ts", "ts", "mkv", "m4v"])(
|
||||||
@@ -293,25 +307,34 @@ describe("serveStaticProjectHtml transparent media proxies", () => {
|
|||||||
const res = await fetch(`${server.url}clip.${extension}?hf-proxy=h264`);
|
const res = await fetch(`${server.url}clip.${extension}?hf-proxy=h264`);
|
||||||
expect(res.status).toBe(200);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.text()).toBe("transcoded-h264-bytes");
|
expect(await res.text()).toBe("transcoded-h264-bytes");
|
||||||
expect(mocks.resolveProxy).toHaveBeenCalledWith(projectDir, sourcePath);
|
expect(mocks.resolveProxy).toHaveBeenCalledWith(projectDir, sourcePath, "h264");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("rejects an alpha-bearing video before attempting a static-server proxy transcode", async () => {
|
it("serves an alpha-bearing video through a VP9 WebM proxy", async () => {
|
||||||
const projectDir = mk();
|
const projectDir = mk();
|
||||||
writeFileSync(join(projectDir, "clip.mov"), "prores-4444-alpha-bytes");
|
writeFileSync(join(projectDir, "clip.mov"), "prores-4444-alpha-bytes");
|
||||||
mocks.decideMediaProxyEligibility.mockReturnValueOnce({
|
mocks.probeAssetCodec.mockResolvedValueOnce({
|
||||||
eligible: false,
|
codecName: "prores",
|
||||||
reason: "alpha_source",
|
hasAlpha: true,
|
||||||
|
browserHostile: true,
|
||||||
|
representativeMime: null,
|
||||||
});
|
});
|
||||||
|
const proxyPath = join(projectDir, "proxy.webm");
|
||||||
|
writeFileSync(proxyPath, "vp9-alpha-proxy");
|
||||||
|
mocks.resolveProxy.mockResolvedValueOnce(proxyPath);
|
||||||
server = await serveStaticProjectHtml(projectDir, "<html></html>");
|
server = await serveStaticProjectHtml(projectDir, "<html></html>");
|
||||||
|
|
||||||
const res = await fetch(`${server.url}clip.mov?hf-proxy=h264`);
|
const res = await fetch(`${server.url}clip.mov?hf-proxy=auto`);
|
||||||
|
|
||||||
expect(res.status).toBe(422);
|
expect(res.status).toBe(200);
|
||||||
expect(await res.text()).toContain("alpha_source");
|
expect(res.headers.get("content-type")).toBe("video/webm");
|
||||||
expect(mocks.probeAssetCodec).toHaveBeenCalledWith(join(projectDir, "clip.mov"));
|
expect(mocks.probeAssetCodec).toHaveBeenCalledWith(join(projectDir, "clip.mov"));
|
||||||
expect(mocks.resolveProxy).not.toHaveBeenCalled();
|
expect(mocks.resolveProxy).toHaveBeenCalledWith(
|
||||||
|
projectDir,
|
||||||
|
join(projectDir, "clip.mov"),
|
||||||
|
"vp9",
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("answers 502 when the proxy transcode fails", async () => {
|
it("answers 502 when the proxy transcode fails", async () => {
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import {
|
|||||||
} from "@hyperframes/studio-server/proxy-transcoder";
|
} from "@hyperframes/studio-server/proxy-transcoder";
|
||||||
import {
|
import {
|
||||||
decideMediaProxyEligibility,
|
decideMediaProxyEligibility,
|
||||||
|
isProxyVariantRequest,
|
||||||
probeAssetCodec,
|
probeAssetCodec,
|
||||||
|
resolveProxyVariantRequest,
|
||||||
|
PROXY_VARIANT_CONFIG,
|
||||||
|
type ProxyVariantRequest,
|
||||||
} from "@hyperframes/studio-server/media-codec-map";
|
} from "@hyperframes/studio-server/media-codec-map";
|
||||||
|
|
||||||
export interface StaticProjectServer {
|
export interface StaticProjectServer {
|
||||||
@@ -32,10 +36,11 @@ function serveFileWithRange(
|
|||||||
filePath: string,
|
filePath: string,
|
||||||
rangeHeader: string | undefined,
|
rangeHeader: string | undefined,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
|
contentType = getMimeType(filePath),
|
||||||
) {
|
) {
|
||||||
const size = statSync(filePath).size;
|
const size = statSync(filePath).size;
|
||||||
const headers: Record<string, string> = {
|
const headers: Record<string, string> = {
|
||||||
"Content-Type": getMimeType(filePath),
|
"Content-Type": contentType,
|
||||||
"Accept-Ranges": "bytes",
|
"Accept-Ranges": "bytes",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -79,17 +84,34 @@ function serveFileWithRange(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serves `?hf-proxy=h264` for a media request: 404s (no transcode attempted)
|
* Serves an alpha-aware `?hf-proxy=` variant for a media request: 404s (no transcode attempted)
|
||||||
* when auto-proxying is off or the asset isn't a video, resolves+serves the
|
* when auto-proxying is off or the asset isn't a video, resolves+serves the
|
||||||
* cached H.264 proxy with Range support on success, and answers 502 on a
|
* cached proxy with Range support on success, and answers 502 on a
|
||||||
* transcode failure (never a silent black frame). Shared by every one of
|
* transcode failure (never a silent black frame). Shared by every one of
|
||||||
* `serveStaticProjectHtml`'s seven callers (check/snapshot/validate/compare/
|
* `serveStaticProjectHtml`'s seven callers (check/snapshot/validate/compare/
|
||||||
* grade-compare/motionShot/layout) — see the KTD in
|
* grade-compare/motionShot/layout) — see the KTD in
|
||||||
* docs/plans/2026-07-14-002-feat-transparent-media-proxies-plan.md.
|
* docs/plans/2026-07-14-002-feat-transparent-media-proxies-plan.md.
|
||||||
*/
|
*/
|
||||||
|
/** Map a transcode failure to a response; never a silent black frame. */
|
||||||
|
function writeProxyError(err: unknown, res: ServerResponse): void {
|
||||||
|
if (err instanceof ProxyCapacityError) {
|
||||||
|
res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "1" });
|
||||||
|
res.end(`Proxy transcode deferred: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (err instanceof ProxyTranscodeError) {
|
||||||
|
res.writeHead(502, { "Content-Type": "text/plain" });
|
||||||
|
res.end(`Proxy transcode failed: ${err.message}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(500);
|
||||||
|
res.end();
|
||||||
|
}
|
||||||
|
|
||||||
async function serveProxyRequest(
|
async function serveProxyRequest(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
request: ProxyVariantRequest,
|
||||||
autoProxy: boolean,
|
autoProxy: boolean,
|
||||||
rangeHeader: string | undefined,
|
rangeHeader: string | undefined,
|
||||||
res: ServerResponse,
|
res: ServerResponse,
|
||||||
@@ -101,29 +123,30 @@ async function serveProxyRequest(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const eligibility = decideMediaProxyEligibility(await probeAssetCodec(filePath));
|
const facts = await probeAssetCodec(filePath);
|
||||||
|
const eligibility = decideMediaProxyEligibility(facts);
|
||||||
if (!eligibility.eligible) {
|
if (!eligibility.eligible) {
|
||||||
res.writeHead(422, { "Content-Type": "text/plain" });
|
res.writeHead(422, { "Content-Type": "text/plain" });
|
||||||
res.end(`media proxy unavailable: ${eligibility.reason}`);
|
res.end(`media proxy unavailable: ${eligibility.reason}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const proxyPath = await resolveProxy(projectDir, filePath);
|
if (!facts) {
|
||||||
|
res.writeHead(422, { "Content-Type": "text/plain" });
|
||||||
|
res.end("media proxy unavailable: unknown_codec");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const variant = resolveProxyVariantRequest(request, facts);
|
||||||
|
if (!variant) {
|
||||||
|
res.writeHead(422, { "Content-Type": "text/plain" });
|
||||||
|
res.end("media proxy variant does not match asset");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const proxyPath = await resolveProxy(projectDir, filePath, variant);
|
||||||
// The await above can span a whole transcode; the client may be gone.
|
// The await above can span a whole transcode; the client may be gone.
|
||||||
if (res.writableEnded || res.destroyed) return;
|
if (res.writableEnded || res.destroyed) return;
|
||||||
serveFileWithRange(proxyPath, rangeHeader, res);
|
serveFileWithRange(proxyPath, rangeHeader, res, PROXY_VARIANT_CONFIG[variant].contentType);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ProxyCapacityError) {
|
writeProxyError(err, res);
|
||||||
res.writeHead(503, { "Content-Type": "text/plain", "Retry-After": "1" });
|
|
||||||
res.end(`Proxy transcode deferred: ${err.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (err instanceof ProxyTranscodeError) {
|
|
||||||
res.writeHead(502, { "Content-Type": "text/plain" });
|
|
||||||
res.end(`Proxy transcode failed: ${err.message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
res.writeHead(500);
|
|
||||||
res.end();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,9 +179,10 @@ export async function serveStaticProjectHtml(
|
|||||||
|
|
||||||
const queryIndex = url.indexOf("?");
|
const queryIndex = url.indexOf("?");
|
||||||
const pathOnly = queryIndex === -1 ? url : url.slice(0, queryIndex);
|
const pathOnly = queryIndex === -1 ? url : url.slice(0, queryIndex);
|
||||||
const wantsProxy =
|
const proxyParam =
|
||||||
queryIndex !== -1 &&
|
queryIndex === -1 ? null : new URLSearchParams(url.slice(queryIndex + 1)).get("hf-proxy");
|
||||||
new URLSearchParams(url.slice(queryIndex + 1)).get("hf-proxy") === "h264";
|
const proxyRequest =
|
||||||
|
proxyParam !== null && isProxyVariantRequest(proxyParam) ? proxyParam : null;
|
||||||
|
|
||||||
const requestPath = decodeURIComponent(pathOnly).replace(/^\//, "");
|
const requestPath = decodeURIComponent(pathOnly).replace(/^\//, "");
|
||||||
for (const root of roots) {
|
for (const root of roots) {
|
||||||
@@ -166,8 +190,15 @@ export async function serveStaticProjectHtml(
|
|||||||
const rel = relative(root, filePath);
|
const rel = relative(root, filePath);
|
||||||
if (rel.startsWith("..") || isAbsolute(rel)) continue; // traversal guard; try next root
|
if (rel.startsWith("..") || isAbsolute(rel)) continue; // traversal guard; try next root
|
||||||
if (existsSync(filePath)) {
|
if (existsSync(filePath)) {
|
||||||
if (wantsProxy) {
|
if (proxyRequest) {
|
||||||
void serveProxyRequest(projectDir, filePath, autoProxy, req.headers.range, res);
|
void serveProxyRequest(
|
||||||
|
projectDir,
|
||||||
|
filePath,
|
||||||
|
proxyRequest,
|
||||||
|
autoProxy,
|
||||||
|
req.headers.range,
|
||||||
|
res,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
serveFileWithRange(filePath, req.headers.range, res);
|
serveFileWithRange(filePath, req.headers.range, res);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -49,8 +49,12 @@ function stubCanPlayType(el: HTMLVideoElement, result: string): void {
|
|||||||
el.canPlayType = vi.fn(() => result) as unknown as HTMLVideoElement["canPlayType"];
|
el.canPlayType = vi.fn(() => result) as unknown as HTMLVideoElement["canPlayType"];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function proxyVariant(el: HTMLMediaElement): string | null {
|
||||||
|
return new URL(el.src, document.baseURI).searchParams.get("hf-proxy");
|
||||||
|
}
|
||||||
|
|
||||||
function isProxied(el: HTMLMediaElement): boolean {
|
function isProxied(el: HTMLMediaElement): boolean {
|
||||||
return new URL(el.src, document.baseURI).searchParams.get("hf-proxy") === "h264";
|
return proxyVariant(el) !== null;
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -170,7 +174,7 @@ describe("maybeProxyProactively", () => {
|
|||||||
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
|
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("never swaps an alpha-bearing hostile entry; emits the unavailable diagnostic instead", () => {
|
it("swaps an alpha-bearing hostile entry to a VP9 proxy", () => {
|
||||||
window.__HF_MEDIA_CODEC_MAP__ = {
|
window.__HF_MEDIA_CODEC_MAP__ = {
|
||||||
"/video.mov": {
|
"/video.mov": {
|
||||||
codecName: "prores",
|
codecName: "prores",
|
||||||
@@ -184,14 +188,8 @@ describe("maybeProxyProactively", () => {
|
|||||||
|
|
||||||
maybeProxyProactively(el);
|
maybeProxyProactively(el);
|
||||||
|
|
||||||
expect(isProxied(el)).toBe(false);
|
expect(proxyVariant(el)).toBe("vp9");
|
||||||
expect(el.load).not.toHaveBeenCalled();
|
expect(el.load).toHaveBeenCalledTimes(1);
|
||||||
expect(postRuntimeMessageMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
code: "runtime_media_proxy_unavailable",
|
|
||||||
details: expect.objectContaining({ reason: "alpha_source" }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("is a no-op when the render-frame sibling image signals render mode", () => {
|
it("is a no-op when the render-frame sibling image signals render mode", () => {
|
||||||
@@ -224,6 +222,7 @@ describe("handleMetadataForProxy (reactive trigger)", () => {
|
|||||||
handleMetadataForProxy(el);
|
handleMetadataForProxy(el);
|
||||||
|
|
||||||
expect(isProxied(el)).toBe(true);
|
expect(isProxied(el)).toBe(true);
|
||||||
|
expect(proxyVariant(el)).toBe("auto");
|
||||||
expect(el.load).toHaveBeenCalledTimes(1);
|
expect(el.load).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -238,7 +237,7 @@ describe("handleMetadataForProxy (reactive trigger)", () => {
|
|||||||
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
|
expect(postRuntimeMessageMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("skips a MAPPED entry with hasAlpha (alpha sources are never proxied) and diagnoses instead", () => {
|
it("swaps a mapped alpha entry to a VP9 proxy", () => {
|
||||||
window.__HF_MEDIA_CODEC_MAP__ = {
|
window.__HF_MEDIA_CODEC_MAP__ = {
|
||||||
"/video.mov": {
|
"/video.mov": {
|
||||||
codecName: "prores",
|
codecName: "prores",
|
||||||
@@ -252,14 +251,8 @@ describe("handleMetadataForProxy (reactive trigger)", () => {
|
|||||||
|
|
||||||
handleMetadataForProxy(el);
|
handleMetadataForProxy(el);
|
||||||
|
|
||||||
expect(isProxied(el)).toBe(false);
|
expect(proxyVariant(el)).toBe("vp9");
|
||||||
expect(el.load).not.toHaveBeenCalled();
|
expect(el.load).toHaveBeenCalledTimes(1);
|
||||||
expect(postRuntimeMessageMock).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
code: "runtime_media_proxy_unavailable",
|
|
||||||
details: expect.objectContaining({ reason: "alpha_source" }),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does not proxy a mapped browser-safe codec on the metadata path", () => {
|
it("does not proxy a mapped browser-safe codec on the metadata path", () => {
|
||||||
|
|||||||
@@ -15,9 +15,8 @@ export type MediaCodecMapEntry = {
|
|||||||
codecName: string;
|
codecName: string;
|
||||||
browserHostile: boolean;
|
browserHostile: boolean;
|
||||||
representativeMime: string | null;
|
representativeMime: string | null;
|
||||||
/** Source carries an alpha channel — never proxy it (H.264 would destroy
|
/** Source carries an alpha channel and therefore needs a VP9/WebM proxy.
|
||||||
* the transparency, e.g. ProRes 4444 alpha). Optional so pre-alpha-aware
|
* Optional so pre-alpha-aware maps stay assignable; absent means "no alpha detected". */
|
||||||
* maps stay assignable; absent means "no alpha detected". */
|
|
||||||
hasAlpha?: boolean;
|
hasAlpha?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -28,9 +27,8 @@ declare global {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const PROXY_QUERY_PARAM = "hf-proxy";
|
const PROXY_QUERY_PARAM = "hf-proxy";
|
||||||
const PROXY_QUERY_VALUE = "h264";
|
|
||||||
|
|
||||||
/** Fired whenever an element is swapped to its H.264 proxy (any trigger). */
|
/** Fired whenever an element is swapped to its authoring proxy (any trigger). */
|
||||||
const DIAGNOSTIC_FALLBACK_CODE = "runtime_media_proxy_fallback";
|
const DIAGNOSTIC_FALLBACK_CODE = "runtime_media_proxy_fallback";
|
||||||
/** Fired when the runtime detects an undecodable video but cannot (or already
|
/** Fired when the runtime detects an undecodable video but cannot (or already
|
||||||
* did) proxy it — a remote asset, or the proxy URL itself failing. */
|
* did) proxy it — a remote asset, or the proxy URL itself failing. */
|
||||||
@@ -163,9 +161,9 @@ function lookupCodecMapEntry(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function appendProxyParam(src: string): string {
|
function appendProxyParam(src: string, entry: MediaCodecMapEntry | null): string {
|
||||||
const url = new URL(src, document.baseURI);
|
const url = new URL(src, document.baseURI);
|
||||||
url.searchParams.set(PROXY_QUERY_PARAM, PROXY_QUERY_VALUE);
|
url.searchParams.set(PROXY_QUERY_PARAM, entry ? (entry.hasAlpha ? "vp9" : "h264") : "auto");
|
||||||
return url.href;
|
return url.href;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,17 +171,14 @@ type UnavailableReason =
|
|||||||
| "cross_origin"
|
| "cross_origin"
|
||||||
| "proxy_playback_failed"
|
| "proxy_playback_failed"
|
||||||
| "browser_safe_codec"
|
| "browser_safe_codec"
|
||||||
| "alpha_source"
|
|
||||||
| "invalid_source_url";
|
| "invalid_source_url";
|
||||||
|
|
||||||
const UNAVAILABLE_NOTES: Record<UnavailableReason, string> = {
|
const UNAVAILABLE_NOTES: Record<UnavailableReason, string> = {
|
||||||
cross_origin:
|
cross_origin:
|
||||||
"video reports zero decodable width but its source is cross-origin; no local proxy can be served for it",
|
"video reports zero decodable width but its source is cross-origin; no local proxy can be served for it",
|
||||||
proxy_playback_failed: "the H.264 proxy itself failed to decode; render output is unaffected",
|
proxy_playback_failed: "the authoring proxy itself failed to decode; render output is unaffected",
|
||||||
browser_safe_codec:
|
browser_safe_codec:
|
||||||
"the file errored but its codec is browser-decodable; an H.264 proxy cannot help (the file itself is likely corrupt)",
|
"the file errored but its codec is browser-decodable; a 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",
|
invalid_source_url: "the media source URL is malformed and cannot be proxied",
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -213,7 +208,7 @@ function emitUnavailableDiagnostic(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Swap `el` to its H.264 proxy URL, evict stale per-source sync state, and
|
* Swap `el` to its alpha-aware proxy URL, evict stale per-source sync state, and
|
||||||
* emit the one-time diagnostic + console line. Safe to call from any of the
|
* emit the one-time diagnostic + console line. Safe to call from any of the
|
||||||
* three triggers (proactive/reactive/tertiary); a no-op if already swapped.
|
* three triggers (proactive/reactive/tertiary); a no-op if already swapped.
|
||||||
*/
|
*/
|
||||||
@@ -226,7 +221,7 @@ export function swapToProxy(
|
|||||||
const originalSrc = currentSrcValue(el);
|
const originalSrc = currentSrcValue(el);
|
||||||
let proxiedSrc: string;
|
let proxiedSrc: string;
|
||||||
try {
|
try {
|
||||||
proxiedSrc = appendProxyParam(originalSrc);
|
proxiedSrc = appendProxyParam(originalSrc, entry);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
swallow("runtime.mediaProxy.swap", err);
|
swallow("runtime.mediaProxy.swap", err);
|
||||||
emitUnavailableDiagnostic(el, "invalid_source_url", originalSrc);
|
emitUnavailableDiagnostic(el, "invalid_source_url", originalSrc);
|
||||||
@@ -246,7 +241,7 @@ export function swapToProxy(
|
|||||||
asset: originalSrc,
|
asset: originalSrc,
|
||||||
codecName,
|
codecName,
|
||||||
trigger,
|
trigger,
|
||||||
note: "render output is unaffected; only this preview element was swapped to an H.264 proxy",
|
note: "render output is unaffected; only this preview element was swapped to an authoring proxy",
|
||||||
};
|
};
|
||||||
postRuntimeMessage({
|
postRuntimeMessage({
|
||||||
source: "hf-preview",
|
source: "hf-preview",
|
||||||
@@ -258,7 +253,7 @@ export function swapToProxy(
|
|||||||
// matches on (packages/cli/src/utils/checkBrowser.ts); keep it in the text.
|
// matches on (packages/cli/src/utils/checkBrowser.ts); keep it in the text.
|
||||||
console.info(
|
console.info(
|
||||||
`[hyperframes] ${DIAGNOSTIC_FALLBACK_CODE}: "${originalSrc}" uses a codec (${codecName ?? "unknown"}) this browser can't decode; ` +
|
`[hyperframes] ${DIAGNOSTIC_FALLBACK_CODE}: "${originalSrc}" uses a codec (${codecName ?? "unknown"}) this browser can't decode; ` +
|
||||||
"auto-swapped to an H.264 proxy for this preview only. Render output is unaffected.",
|
"auto-swapped to an authoring proxy for this preview only. Render output is unaffected.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -279,12 +274,6 @@ export function maybeProxyProactively(el: HTMLMediaElement): void {
|
|||||||
if (key === null) return;
|
if (key === null) return;
|
||||||
const entry = lookupCodecMapEntry(key, map);
|
const entry = lookupCodecMapEntry(key, map);
|
||||||
if (!entry || !entry.browserHostile) return;
|
if (!entry || !entry.browserHostile) return;
|
||||||
if (entry.hasAlpha) {
|
|
||||||
// Alpha sources are never proxied (transparency would be destroyed);
|
|
||||||
// say so instead of silently leaving a possibly-undecodable element.
|
|
||||||
emitUnavailableDiagnostic(el, "alpha_source", currentSrcValue(el));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const canPlay = entry.representativeMime ? el.canPlayType(entry.representativeMime) : "";
|
const canPlay = entry.representativeMime ? el.canPlayType(entry.representativeMime) : "";
|
||||||
if (canPlay === "probably" || canPlay === "maybe") return;
|
if (canPlay === "probably" || canPlay === "maybe") return;
|
||||||
swapToProxy(el, entry, "proactive");
|
swapToProxy(el, entry, "proactive");
|
||||||
@@ -302,7 +291,7 @@ export function maybeProxyProactively(el: HTMLMediaElement): void {
|
|||||||
* auto-proxying is enabled and served, so its absence means a `?hf-proxy=`
|
* auto-proxying is enabled and served, so its absence means a `?hf-proxy=`
|
||||||
* request would 404 — never swap there. When the map is present but has no
|
* request would 404 — never swap there. When the map is present but has no
|
||||||
* entry for this key, swapping stays allowed (unlisted-asset rescue). A
|
* entry for this key, swapping stays allowed (unlisted-asset rescue). A
|
||||||
* mapped entry with alpha is never proxied.
|
* mapped alpha entries select the VP9/WebM proxy variant.
|
||||||
*/
|
*/
|
||||||
export function handleMetadataForProxy(el: HTMLMediaElement): void {
|
export function handleMetadataForProxy(el: HTMLMediaElement): void {
|
||||||
if (isRenderMode(el)) return;
|
if (isRenderMode(el)) return;
|
||||||
@@ -325,10 +314,6 @@ export function handleMetadataForProxy(el: HTMLMediaElement): void {
|
|||||||
emitUnavailableDiagnostic(el, "browser_safe_codec", src);
|
emitUnavailableDiagnostic(el, "browser_safe_codec", src);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (entry?.hasAlpha) {
|
|
||||||
emitUnavailableDiagnostic(el, "alpha_source", src);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
swapToProxy(el, entry, "reactive");
|
swapToProxy(el, entry, "reactive");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -338,7 +323,7 @@ export function handleMetadataForProxy(el: HTMLMediaElement): void {
|
|||||||
* it and `loadedmetadata` never fires. Same guards and once-per-element
|
* it and `loadedmetadata` never fires. Same guards and once-per-element
|
||||||
* behavior as the reactive path, plus one extra skip: an entry the scan
|
* behavior as the reactive path, plus one extra skip: an entry the scan
|
||||||
* mapped as browser-SAFE that still errors is a corrupt-but-safe file — an
|
* mapped as browser-SAFE that still errors is a corrupt-but-safe file — an
|
||||||
* H.264 proxy of a broken source can't help, so only diagnose.
|
* proxy of a broken source can't help, so only diagnose.
|
||||||
*/
|
*/
|
||||||
export function handleErrorForProxy(el: HTMLMediaElement): void {
|
export function handleErrorForProxy(el: HTMLMediaElement): void {
|
||||||
if (isRenderMode(el)) return;
|
if (isRenderMode(el)) return;
|
||||||
@@ -360,9 +345,5 @@ export function handleErrorForProxy(el: HTMLMediaElement): void {
|
|||||||
emitUnavailableDiagnostic(el, "browser_safe_codec", src);
|
emitUnavailableDiagnostic(el, "browser_safe_codec", src);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (entry?.hasAlpha) {
|
|
||||||
emitUnavailableDiagnostic(el, "alpha_source", src);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
swapToProxy(el, entry, "tertiary");
|
swapToProxy(el, entry, "tertiary");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
decideMediaProxyEligibility,
|
decideMediaProxyEligibility,
|
||||||
createMediaCodecProbeCache,
|
createMediaCodecProbeCache,
|
||||||
probeAssetCodec,
|
probeAssetCodec,
|
||||||
|
proxyVariantFor,
|
||||||
|
resolveProxyVariantRequest,
|
||||||
scanProjectMediaCodecMap,
|
scanProjectMediaCodecMap,
|
||||||
} from "./mediaCodecMap.js";
|
} from "./mediaCodecMap.js";
|
||||||
|
|
||||||
@@ -171,7 +173,7 @@ describe("probeAssetCodec", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("decideMediaProxyEligibility", () => {
|
describe("decideMediaProxyEligibility", () => {
|
||||||
it("allows only hostile opaque video through the H.264 proxy path", () => {
|
it("allows browser-hostile video with or without alpha", () => {
|
||||||
expect(
|
expect(
|
||||||
decideMediaProxyEligibility({
|
decideMediaProxyEligibility({
|
||||||
codecName: "hevc",
|
codecName: "hevc",
|
||||||
@@ -180,9 +182,6 @@ describe("decideMediaProxyEligibility", () => {
|
|||||||
hasAlpha: false,
|
hasAlpha: false,
|
||||||
}),
|
}),
|
||||||
).toEqual({ eligible: true });
|
).toEqual({ eligible: true });
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects alpha and browser-safe sources before transcoding", () => {
|
|
||||||
expect(
|
expect(
|
||||||
decideMediaProxyEligibility({
|
decideMediaProxyEligibility({
|
||||||
codecName: "prores",
|
codecName: "prores",
|
||||||
@@ -190,7 +189,18 @@ describe("decideMediaProxyEligibility", () => {
|
|||||||
representativeMime: null,
|
representativeMime: null,
|
||||||
hasAlpha: true,
|
hasAlpha: true,
|
||||||
}),
|
}),
|
||||||
).toEqual({ eligible: false, reason: "alpha_source" });
|
).toEqual({ eligible: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects browser-safe sources even when they carry alpha", () => {
|
||||||
|
expect(
|
||||||
|
decideMediaProxyEligibility({
|
||||||
|
codecName: "vp8",
|
||||||
|
browserHostile: false,
|
||||||
|
representativeMime: "video/webm",
|
||||||
|
hasAlpha: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ eligible: false, reason: "browser_safe_codec" });
|
||||||
expect(
|
expect(
|
||||||
decideMediaProxyEligibility({
|
decideMediaProxyEligibility({
|
||||||
codecName: "h264",
|
codecName: "h264",
|
||||||
@@ -204,6 +214,43 @@ describe("decideMediaProxyEligibility", () => {
|
|||||||
reason: "unknown_codec",
|
reason: "unknown_codec",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not recycle an alpha VP9 source into the same proxy codec", () => {
|
||||||
|
expect(
|
||||||
|
decideMediaProxyEligibility({
|
||||||
|
codecName: "vp9",
|
||||||
|
browserHostile: true,
|
||||||
|
representativeMime: 'video/webm; codecs="vp09.00.10.08"',
|
||||||
|
hasAlpha: true,
|
||||||
|
}),
|
||||||
|
).toEqual({ eligible: false, reason: "proxy_target_codec" });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("proxyVariantFor", () => {
|
||||||
|
it("uses VP9 for alpha and H.264 otherwise", () => {
|
||||||
|
const facts = {
|
||||||
|
codecName: "prores",
|
||||||
|
browserHostile: true,
|
||||||
|
representativeMime: null,
|
||||||
|
hasAlpha: true,
|
||||||
|
};
|
||||||
|
expect(proxyVariantFor(facts)).toBe("vp9");
|
||||||
|
expect(proxyVariantFor({ ...facts, hasAlpha: false })).toBe("h264");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveProxyVariantRequest", () => {
|
||||||
|
it("infers the alpha-aware variant for an unlisted runtime rescue", () => {
|
||||||
|
const facts = {
|
||||||
|
codecName: "prores",
|
||||||
|
browserHostile: true,
|
||||||
|
representativeMime: null,
|
||||||
|
hasAlpha: true,
|
||||||
|
};
|
||||||
|
expect(resolveProxyVariantRequest("auto", facts)).toBe("vp9");
|
||||||
|
expect(resolveProxyVariantRequest("h264", facts)).toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("scanProjectMediaCodecMap", () => {
|
describe("scanProjectMediaCodecMap", () => {
|
||||||
|
|||||||
@@ -22,9 +22,8 @@ export interface AssetCodecFacts {
|
|||||||
* when no representative mime exists (ProRes: browsers never decode it, so
|
* when no representative mime exists (ProRes: browsers never decode it, so
|
||||||
* the runtime always proxies rather than probing `canPlayType`). */
|
* the runtime always proxies rather than probing `canPlayType`). */
|
||||||
representativeMime: string | null;
|
representativeMime: string | null;
|
||||||
/** Source carries an alpha channel (ffprobe pix_fmt). Alpha sources are
|
/** Source carries an alpha channel (ffprobe pix_fmt). Alpha sources use a
|
||||||
* never proxied — an H.264 proxy would destroy the transparency (e.g.
|
* VP9/WebM proxy so their transparency is preserved. */
|
||||||
* ProRes 4444 alpha). */
|
|
||||||
hasAlpha: boolean;
|
hasAlpha: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -48,7 +47,41 @@ export const BROWSER_HOSTILE_CODECS: Record<string, string | null> = {
|
|||||||
vp9: 'video/webm; codecs="vp09.00.10.08"',
|
vp9: 'video/webm; codecs="vp09.00.10.08"',
|
||||||
};
|
};
|
||||||
|
|
||||||
export type MediaProxyIneligibilityReason = "alpha_source" | "browser_safe_codec" | "unknown_codec";
|
export type ProxyVariant = "h264" | "vp9";
|
||||||
|
export type ProxyVariantRequest = ProxyVariant | "auto";
|
||||||
|
|
||||||
|
export const PROXY_VARIANT_CONFIG: Record<
|
||||||
|
ProxyVariant,
|
||||||
|
{ extension: ".mp4" | ".webm"; contentType: "video/mp4" | "video/webm" }
|
||||||
|
> = {
|
||||||
|
h264: { extension: ".mp4", contentType: "video/mp4" },
|
||||||
|
vp9: { extension: ".webm", contentType: "video/webm" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isProxyVariant(value: string): value is ProxyVariant {
|
||||||
|
return Object.hasOwn(PROXY_VARIANT_CONFIG, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isProxyVariantRequest(value: string): value is ProxyVariantRequest {
|
||||||
|
return value === "auto" || isProxyVariant(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function proxyVariantFor(facts: AssetCodecFacts): ProxyVariant {
|
||||||
|
return facts.hasAlpha ? "vp9" : "h264";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveProxyVariantRequest(
|
||||||
|
request: ProxyVariantRequest,
|
||||||
|
facts: AssetCodecFacts,
|
||||||
|
): ProxyVariant | null {
|
||||||
|
const expected = proxyVariantFor(facts);
|
||||||
|
return request === "auto" || request === expected ? expected : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MediaProxyIneligibilityReason =
|
||||||
|
| "browser_safe_codec"
|
||||||
|
| "proxy_target_codec"
|
||||||
|
| "unknown_codec";
|
||||||
|
|
||||||
export type MediaProxyEligibility =
|
export type MediaProxyEligibility =
|
||||||
| { eligible: true }
|
| { eligible: true }
|
||||||
@@ -57,8 +90,10 @@ export type MediaProxyEligibility =
|
|||||||
/** Single policy gate shared by proactive scans and on-demand proxy routes. */
|
/** Single policy gate shared by proactive scans and on-demand proxy routes. */
|
||||||
export function decideMediaProxyEligibility(facts: AssetCodecFacts | null): MediaProxyEligibility {
|
export function decideMediaProxyEligibility(facts: AssetCodecFacts | null): MediaProxyEligibility {
|
||||||
if (!facts) return { eligible: false, reason: "unknown_codec" };
|
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" };
|
if (!facts.browserHostile) return { eligible: false, reason: "browser_safe_codec" };
|
||||||
|
if (facts.hasAlpha && facts.codecName === "vp9") {
|
||||||
|
return { eligible: false, reason: "proxy_target_codec" };
|
||||||
|
}
|
||||||
return { eligible: true };
|
return { eligible: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { resolve } from "node:path";
|
|||||||
import type { StudioApiAdapter } from "../types.js";
|
import type { StudioApiAdapter } from "../types.js";
|
||||||
import {
|
import {
|
||||||
createMediaCodecProbeCache,
|
createMediaCodecProbeCache,
|
||||||
|
proxyVariantFor,
|
||||||
scanProjectMediaCodecMap,
|
scanProjectMediaCodecMap,
|
||||||
type HtmlSourceLike,
|
type HtmlSourceLike,
|
||||||
type MediaCodecMap,
|
type MediaCodecMap,
|
||||||
@@ -72,8 +73,7 @@ function injectScriptTagIntoHead(html: string, scriptTag: string): string {
|
|||||||
* responses). No second concurrency limiter here — the transcoder's own
|
* responses). No second concurrency limiter here — the transcoder's own
|
||||||
* global bound throttles both pre-warm and element-triggered calls.
|
* global bound throttles both pre-warm and element-triggered calls.
|
||||||
* Pre-warm failures are swallowed; an actual `?hf-proxy=` request surfaces
|
* Pre-warm failures are swallowed; an actual `?hf-proxy=` request surfaces
|
||||||
* them as a 502. Alpha-bearing entries are never pre-warmed: the runtime
|
* them as a 502. Alpha-bearing entries pre-warm their VP9/WebM variant.
|
||||||
* never proxies them (transparency would be destroyed).
|
|
||||||
*
|
*
|
||||||
* The single shared implementation for every auto-proxy surface — the studio
|
* The single shared implementation for every auto-proxy surface — the studio
|
||||||
* preview route (via `injectMediaCodecMap` below) and the CLI's composition /
|
* preview route (via `injectMediaCodecMap` below) and the CLI's composition /
|
||||||
@@ -100,13 +100,15 @@ export async function injectMediaCodecMapIntoHtml(
|
|||||||
}
|
}
|
||||||
if (Object.keys(map).length === 0) return html;
|
if (Object.keys(map).length === 0) return html;
|
||||||
for (const [rootRelativePathname, facts] of Object.entries(map)) {
|
for (const [rootRelativePathname, facts] of Object.entries(map)) {
|
||||||
if (!facts.browserHostile || facts.hasAlpha) continue;
|
if (!facts.browserHostile) continue;
|
||||||
resolveProxy(projectDir, resolve(projectDir, rootRelativePathname.replace(/^\/+/, ""))).catch(
|
resolveProxy(
|
||||||
() => {
|
projectDir,
|
||||||
// Swallowed: the pre-warm is best-effort. A real `?hf-proxy=` request
|
resolve(projectDir, rootRelativePathname.replace(/^\/+/, "")),
|
||||||
// for this asset re-attempts the transcode and reports failure (502).
|
proxyVariantFor(facts),
|
||||||
},
|
).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
|
// <-escape prevents a src path containing "</script>" from breaking out of
|
||||||
// the injected tag, mirroring injectPreviewVariables in routes/preview.ts.
|
// the injected tag, mirroring injectPreviewVariables in routes/preview.ts.
|
||||||
|
|||||||
@@ -49,6 +49,26 @@ describe("cleanupProxyCache", () => {
|
|||||||
expect(existsSync(newest)).toBe(true);
|
expect(existsSync(newest)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("counts and evicts WebM proxies alongside MP4 proxies", () => {
|
||||||
|
const cache = cacheDir();
|
||||||
|
const now = 1_800_000_000_000;
|
||||||
|
const webm = join(cache, "alpha.webm");
|
||||||
|
const mp4 = join(cache, "opaque.mp4");
|
||||||
|
writeEntry(webm, 6, now - 2_000);
|
||||||
|
writeEntry(mp4, 6, now - 1_000);
|
||||||
|
|
||||||
|
const result = cleanupProxyCache(cache, {
|
||||||
|
now,
|
||||||
|
maxBytes: 6,
|
||||||
|
maxIdleMs: 10_000,
|
||||||
|
minSweepIntervalMs: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.bytesBefore).toBe(12);
|
||||||
|
expect(result.removed).toEqual([webm]);
|
||||||
|
expect(result.bytesAfter).toBe(6);
|
||||||
|
});
|
||||||
|
|
||||||
it("preserves in-flight entries and removes stale temporary files", () => {
|
it("preserves in-flight entries and removes stale temporary files", () => {
|
||||||
const cache = cacheDir();
|
const cache = cacheDir();
|
||||||
const now = 1_800_000_000_000;
|
const now = 1_800_000_000_000;
|
||||||
|
|||||||
@@ -1,9 +1,13 @@
|
|||||||
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
import { existsSync, readdirSync, statSync, unlinkSync } from "node:fs";
|
||||||
import { join } from "node:path";
|
import { extname, join } from "node:path";
|
||||||
|
import { PROXY_VARIANT_CONFIG } from "./mediaCodecMap.js";
|
||||||
|
|
||||||
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 * 1024;
|
const DEFAULT_MAX_BYTES = 10 * 1024 * 1024 * 1024;
|
||||||
const DEFAULT_STALE_TEMP_MS = 60 * 60 * 1000;
|
const DEFAULT_STALE_TEMP_MS = 60 * 60 * 1000;
|
||||||
const DEFAULT_MIN_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
const DEFAULT_MIN_SWEEP_INTERVAL_MS = 5 * 60 * 1000;
|
||||||
|
const PROXY_EXTENSIONS: ReadonlySet<string> = new Set(
|
||||||
|
Object.values(PROXY_VARIANT_CONFIG).map(({ extension }) => extension),
|
||||||
|
);
|
||||||
|
|
||||||
export interface ProxyCacheCleanupOptions {
|
export interface ProxyCacheCleanupOptions {
|
||||||
maxBytes?: number;
|
maxBytes?: number;
|
||||||
@@ -76,7 +80,7 @@ function readCacheInventory(
|
|||||||
};
|
};
|
||||||
if (dirent.name.startsWith(".tmp-")) {
|
if (dirent.name.startsWith(".tmp-")) {
|
||||||
if (now - stat.mtimeMs >= staleTempMs) staleTemps.push(entry);
|
if (now - stat.mtimeMs >= staleTempMs) staleTemps.push(entry);
|
||||||
} else if (dirent.name.endsWith(".mp4")) {
|
} else if (PROXY_EXTENSIONS.has(extname(dirent.name))) {
|
||||||
entries.push(entry);
|
entries.push(entry);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,6 +157,41 @@ describe("resolveProxy", () => {
|
|||||||
expect(cacheDirEntries).toEqual([expectedCachePath.split("/").at(-1)]);
|
expect(cacheDirEntries).toEqual([expectedCachePath.split("/").at(-1)]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("uses VP9 alpha-safe args and a distinct WebM cache path", async () => {
|
||||||
|
const { spawn, calls } = createSpawnSpy();
|
||||||
|
const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
|
||||||
|
const projectDir = tmpProject();
|
||||||
|
const sourcePath = join(projectDir, "alpha.mov");
|
||||||
|
writeFileSync(sourcePath, "source-bytes");
|
||||||
|
|
||||||
|
const h264Path = getProxyCachePath(projectDir, sourcePath, "h264");
|
||||||
|
const vp9Path = getProxyCachePath(projectDir, sourcePath, "vp9");
|
||||||
|
const resultPromise = resolveProxy(projectDir, sourcePath, "vp9");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(vp9Path).not.toBe(h264Path);
|
||||||
|
expect(vp9Path).toMatch(/\.webm$/);
|
||||||
|
expect(h264Path).toMatch(/\.mp4$/);
|
||||||
|
const args = calls[0]!.args;
|
||||||
|
expect(args).toContain("libvpx-vp9");
|
||||||
|
expect(args).toContain("yuva420p");
|
||||||
|
expect(args).toContain("libopus");
|
||||||
|
expect(args[args.indexOf("-b:v") + 1]).toBe("0");
|
||||||
|
expect(args[args.indexOf("-crf") + 1]).toBe("23");
|
||||||
|
expect(args[args.indexOf("-deadline") + 1]).toBe("good");
|
||||||
|
expect(args[args.indexOf("-auto-alt-ref") + 1]).toBe("0");
|
||||||
|
expect(args[args.indexOf("-metadata:s:v:0") + 1]).toBe("alpha_mode=1");
|
||||||
|
expect(args[args.indexOf("-ac") + 1]).toBe("2");
|
||||||
|
expect(args).toContain("-row-mt");
|
||||||
|
expect(args).toContain("-cpu-used");
|
||||||
|
expect(args).not.toContain("-movflags");
|
||||||
|
expect(args).not.toContain("+faststart");
|
||||||
|
expect(args[args.indexOf("-vf") + 1]).toContain("format=yuva420p");
|
||||||
|
|
||||||
|
succeed(calls[0]!, "fake-vp9-bytes");
|
||||||
|
await expect(resultPromise).resolves.toBe(vp9Path);
|
||||||
|
});
|
||||||
|
|
||||||
it("returns without spawning on a cache hit", async () => {
|
it("returns without spawning on a cache hit", async () => {
|
||||||
const { spawn, calls } = createSpawnSpy();
|
const { spawn, calls } = createSpawnSpy();
|
||||||
const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
|
const { resolveProxy, getProxyCachePath } = await loadModule(spawn, FFMPEG_PATH);
|
||||||
@@ -201,6 +236,24 @@ describe("resolveProxy", () => {
|
|||||||
await result;
|
await result;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("preserves alpha on HDR-tagged VP9 proxies by bypassing the opaque tonemap chain", async () => {
|
||||||
|
const { spawn, calls } = createSpawnSpy();
|
||||||
|
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, true);
|
||||||
|
const projectDir = tmpProject();
|
||||||
|
const sourcePath = join(projectDir, "hdr-alpha.mov");
|
||||||
|
writeFileSync(sourcePath, "source-bytes");
|
||||||
|
|
||||||
|
const result = resolveProxy(projectDir, sourcePath, "vp9");
|
||||||
|
await flush();
|
||||||
|
|
||||||
|
expect(calls).toHaveLength(1);
|
||||||
|
const filter = calls[0]!.args[calls[0]!.args.indexOf("-vf") + 1];
|
||||||
|
expect(filter).toContain("format=yuva420p");
|
||||||
|
expect(filter).not.toContain("tonemap=");
|
||||||
|
succeed(calls[0]!, "fake-vp9-alpha-bytes");
|
||||||
|
await result;
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects HDR proxying with a typed actionable error when ffmpeg lacks zscale", async () => {
|
it("rejects HDR proxying with a typed actionable error when ffmpeg lacks zscale", async () => {
|
||||||
const { spawn, calls } = createSpawnSpy();
|
const { spawn, calls } = createSpawnSpy();
|
||||||
const { resolveProxy, FfmpegMissingFilterError } = await loadModule(spawn, FFMPEG_PATH, true);
|
const { resolveProxy, FfmpegMissingFilterError } = await loadModule(spawn, FFMPEG_PATH, true);
|
||||||
|
|||||||
@@ -13,11 +13,12 @@ import { basename, dirname, isAbsolute, join, relative, sep } from "node:path";
|
|||||||
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
||||||
import { probeMediaMetadata } from "./mediaMetadata.js";
|
import { probeMediaMetadata } from "./mediaMetadata.js";
|
||||||
import { cleanupProxyCache } from "./proxyCache.js";
|
import { cleanupProxyCache } from "./proxyCache.js";
|
||||||
|
import { PROXY_VARIANT_CONFIG, type ProxyVariant } from "./mediaCodecMap.js";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Transcodes browser-hostile local video sources (HEVC, ProRes, ...) into a
|
* Transcodes browser-hostile local video sources (HEVC, ProRes, ...) into a
|
||||||
* cached, seekable H.264 authoring proxy. Consumed by the preview/play/static
|
* cached, seekable authoring proxy. Consumed by the preview/play/static
|
||||||
* project routes (U3/U4) to serve a `?hf-proxy=h264` request; never used on
|
* project routes (U3/U4) to serve a `?hf-proxy=` request; never used on
|
||||||
* the render path (render always sees the original file).
|
* the render path (render always sees the original file).
|
||||||
*
|
*
|
||||||
* IMPORTANT — request-lifecycle detachment: nothing here accepts or wires an
|
* IMPORTANT — request-lifecycle detachment: nothing here accepts or wires an
|
||||||
@@ -31,7 +32,7 @@ import { cleanupProxyCache } from "./proxyCache.js";
|
|||||||
* entry still lands for the next request.
|
* entry still lands for the next request.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export const PROXY_PARAMS_VERSION = "v2";
|
export const PROXY_PARAMS_VERSION = "v3";
|
||||||
|
|
||||||
const CACHE_DIR_NAME = ".transcode-cache";
|
const CACHE_DIR_NAME = ".transcode-cache";
|
||||||
|
|
||||||
@@ -158,16 +159,22 @@ function canonicalizeProxySource(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildProxyCacheKey(source: CanonicalProxySource): string {
|
function buildProxyCacheKey(source: CanonicalProxySource, variant: ProxyVariant): string {
|
||||||
const stat = statSync(source.sourcePath);
|
const stat = statSync(source.sourcePath);
|
||||||
return createHash("sha256")
|
return createHash("sha256")
|
||||||
.update(`${source.relativePath}\0${stat.mtimeMs}\0${stat.size}\0${PROXY_PARAMS_VERSION}`)
|
.update(
|
||||||
|
`${source.relativePath}\0${stat.mtimeMs}\0${stat.size}\0${PROXY_PARAMS_VERSION}\0${variant}`,
|
||||||
|
)
|
||||||
.digest("hex");
|
.digest("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCanonicalProxyCachePath(source: CanonicalProxySource): string {
|
function getCanonicalProxyCachePath(source: CanonicalProxySource, variant: ProxyVariant): string {
|
||||||
const key = buildProxyCacheKey(source);
|
const key = buildProxyCacheKey(source, variant);
|
||||||
return join(source.projectDir, CACHE_DIR_NAME, `${key}.mp4`);
|
return join(
|
||||||
|
source.projectDir,
|
||||||
|
CACHE_DIR_NAME,
|
||||||
|
`${key}${PROXY_VARIANT_CONFIG[variant].extension}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -175,8 +182,15 @@ function getCanonicalProxyCachePath(source: CanonicalProxySource): string {
|
|||||||
* transcoding anything. Route handlers use this to check cache state (e.g.
|
* transcoding anything. Route handlers use this to check cache state (e.g.
|
||||||
* for ETag/If-None-Match) before deciding whether to await a transcode.
|
* for ETag/If-None-Match) before deciding whether to await a transcode.
|
||||||
*/
|
*/
|
||||||
export function getProxyCachePath(projectDir: string, absoluteSourcePath: string): string {
|
export function getProxyCachePath(
|
||||||
return getCanonicalProxyCachePath(canonicalizeProxySource(projectDir, absoluteSourcePath));
|
projectDir: string,
|
||||||
|
absoluteSourcePath: string,
|
||||||
|
variant: ProxyVariant = "h264",
|
||||||
|
): string {
|
||||||
|
return getCanonicalProxyCachePath(
|
||||||
|
canonicalizeProxySource(projectDir, absoluteSourcePath),
|
||||||
|
variant,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- global concurrency limiter -------------------------------------------
|
// --- global concurrency limiter -------------------------------------------
|
||||||
@@ -290,31 +304,35 @@ export function clearFailedTranscodesForTest(): void {
|
|||||||
failedTranscodes.clear();
|
failedTranscodes.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runFfmpeg(sourcePath: string, outputPath: string): Promise<void> {
|
async function runFfmpeg(
|
||||||
|
sourcePath: string,
|
||||||
|
outputPath: string,
|
||||||
|
variant: ProxyVariant,
|
||||||
|
): Promise<void> {
|
||||||
const metadata = await probeMediaMetadata(sourcePath);
|
const metadata = await probeMediaMetadata(sourcePath);
|
||||||
const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true });
|
const ffmpegPath = findFfBinary("ffmpeg", { configuredMustExist: true });
|
||||||
if (!ffmpegPath) {
|
if (!ffmpegPath) {
|
||||||
throw new FfmpegUnavailableError();
|
throw new FfmpegUnavailableError();
|
||||||
}
|
}
|
||||||
if (metadata.color.isHdr) await ensureHdrFilters(ffmpegPath);
|
// The HDR tonemap filters discard alpha. VP9 is the alpha-preserving proxy
|
||||||
|
// variant, so retain its source color values instead of making it opaque.
|
||||||
|
if (metadata.color.isHdr && variant !== "vp9") await ensureHdrFilters(ffmpegPath);
|
||||||
const evenScale = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
|
const evenScale = "scale=trunc(iw/2)*2:trunc(ih/2)*2";
|
||||||
const videoFilter = metadata.color.isHdr
|
const pixelFormat = variant === "vp9" ? "yuva420p" : "yuv420p";
|
||||||
? [
|
const videoFilter =
|
||||||
"zscale=t=linear:npl=100",
|
metadata.color.isHdr && variant !== "vp9"
|
||||||
"tonemap=hable:desat=0",
|
? [
|
||||||
"zscale=p=bt709:t=bt709:m=bt709:r=tv",
|
"zscale=t=linear:npl=100",
|
||||||
evenScale,
|
"tonemap=hable:desat=0",
|
||||||
"format=yuv420p",
|
"zscale=p=bt709:t=bt709:m=bt709:r=tv",
|
||||||
].join(",")
|
evenScale,
|
||||||
: [evenScale, "format=yuv420p"].join(",");
|
`format=${pixelFormat}`,
|
||||||
|
].join(",")
|
||||||
|
: [evenScale, `format=${pixelFormat}`].join(",");
|
||||||
|
|
||||||
return new Promise((resolvePromise, reject) => {
|
return new Promise((resolvePromise, reject) => {
|
||||||
const args = [
|
const commonArgs = ["-y", "-i", sourcePath, "-vf", videoFilter];
|
||||||
"-y",
|
const h264Args = [
|
||||||
"-i",
|
|
||||||
sourcePath,
|
|
||||||
"-vf",
|
|
||||||
videoFilter,
|
|
||||||
"-c:v",
|
"-c:v",
|
||||||
"libx264",
|
"libx264",
|
||||||
"-profile:v",
|
"-profile:v",
|
||||||
@@ -335,8 +353,38 @@ async function runFfmpeg(sourcePath: string, outputPath: string): Promise<void>
|
|||||||
"aac",
|
"aac",
|
||||||
"-movflags",
|
"-movflags",
|
||||||
"+faststart",
|
"+faststart",
|
||||||
outputPath,
|
|
||||||
];
|
];
|
||||||
|
const vp9Args = [
|
||||||
|
"-c:v",
|
||||||
|
"libvpx-vp9",
|
||||||
|
"-b:v",
|
||||||
|
"0",
|
||||||
|
"-crf",
|
||||||
|
"23",
|
||||||
|
"-deadline",
|
||||||
|
"good",
|
||||||
|
"-pix_fmt",
|
||||||
|
"yuva420p",
|
||||||
|
"-colorspace",
|
||||||
|
"bt709",
|
||||||
|
"-color_primaries",
|
||||||
|
"bt709",
|
||||||
|
"-color_trc",
|
||||||
|
"bt709",
|
||||||
|
"-row-mt",
|
||||||
|
"1",
|
||||||
|
"-cpu-used",
|
||||||
|
"4",
|
||||||
|
"-auto-alt-ref",
|
||||||
|
"0",
|
||||||
|
"-metadata:s:v:0",
|
||||||
|
"alpha_mode=1",
|
||||||
|
"-ac",
|
||||||
|
"2",
|
||||||
|
"-c:a",
|
||||||
|
"libopus",
|
||||||
|
];
|
||||||
|
const args = [...commonArgs, ...(variant === "vp9" ? vp9Args : h264Args), outputPath];
|
||||||
|
|
||||||
// Hard ceiling so a hung ffmpeg can never permanently occupy one of the
|
// Hard ceiling so a hung ffmpeg can never permanently occupy one of the
|
||||||
// global transcode slots: the child is killed and the slot released via
|
// global transcode slots: the child is killed and the slot released via
|
||||||
@@ -372,7 +420,11 @@ async function runFfmpeg(sourcePath: string, outputPath: string): Promise<void>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function transcodeToCache(absoluteSourcePath: string, cachePath: string): Promise<string> {
|
async function transcodeToCache(
|
||||||
|
absoluteSourcePath: string,
|
||||||
|
cachePath: string,
|
||||||
|
variant: ProxyVariant,
|
||||||
|
): Promise<string> {
|
||||||
await acquireSlot();
|
await acquireSlot();
|
||||||
try {
|
try {
|
||||||
// Another caller may have finished (or a pre-warm beat us) while queued.
|
// Another caller may have finished (or a pre-warm beat us) while queued.
|
||||||
@@ -382,7 +434,7 @@ async function transcodeToCache(absoluteSourcePath: string, cachePath: string):
|
|||||||
mkdirSync(cacheDir, { recursive: true });
|
mkdirSync(cacheDir, { recursive: true });
|
||||||
const tempPath = join(cacheDir, `.tmp-${randomUUID()}-${basename(cachePath)}`);
|
const tempPath = join(cacheDir, `.tmp-${randomUUID()}-${basename(cachePath)}`);
|
||||||
try {
|
try {
|
||||||
await runFfmpeg(absoluteSourcePath, tempPath);
|
await runFfmpeg(absoluteSourcePath, tempPath, variant);
|
||||||
renameSync(tempPath, cachePath);
|
renameSync(tempPath, cachePath);
|
||||||
maintainProxyCache(cacheDir);
|
maintainProxyCache(cacheDir);
|
||||||
return cachePath;
|
return cachePath;
|
||||||
@@ -397,7 +449,7 @@ async function transcodeToCache(absoluteSourcePath: string, cachePath: string):
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Resolves the cached H.264 proxy for `absoluteSourcePath`, transcoding it at
|
* Resolves the cached proxy variant for `absoluteSourcePath`, transcoding it at
|
||||||
* most once per cache key. Concurrent calls for the same key (including a
|
* most once per cache key. Concurrent calls for the same key (including a
|
||||||
* pre-warm call racing an element-triggered one) share one ffmpeg child and
|
* pre-warm call racing an element-triggered one) share one ffmpeg child and
|
||||||
* one promise; calls for different keys queue through the global concurrency
|
* one promise; calls for different keys queue through the global concurrency
|
||||||
@@ -407,9 +459,10 @@ async function transcodeToCache(absoluteSourcePath: string, cachePath: string):
|
|||||||
export async function resolveProxy(
|
export async function resolveProxy(
|
||||||
projectDir: string,
|
projectDir: string,
|
||||||
absoluteSourcePath: string,
|
absoluteSourcePath: string,
|
||||||
|
variant: ProxyVariant = "h264",
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const source = canonicalizeProxySource(projectDir, absoluteSourcePath);
|
const source = canonicalizeProxySource(projectDir, absoluteSourcePath);
|
||||||
const cachePath = getCanonicalProxyCachePath(source);
|
const cachePath = getCanonicalProxyCachePath(source, variant);
|
||||||
if (existsSync(cachePath)) {
|
if (existsSync(cachePath)) {
|
||||||
markCacheEntryUsed(cachePath);
|
markCacheEntryUsed(cachePath);
|
||||||
maintainProxyCache(dirname(cachePath));
|
maintainProxyCache(dirname(cachePath));
|
||||||
@@ -425,7 +478,7 @@ export async function resolveProxy(
|
|||||||
const existing = inFlight.get(cachePath);
|
const existing = inFlight.get(cachePath);
|
||||||
if (existing) return existing;
|
if (existing) return existing;
|
||||||
|
|
||||||
const promise = transcodeToCache(source.sourcePath, cachePath)
|
const promise = transcodeToCache(source.sourcePath, cachePath, variant)
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
if (
|
if (
|
||||||
err instanceof ProxyTranscodeError &&
|
err instanceof ProxyTranscodeError &&
|
||||||
|
|||||||
@@ -683,7 +683,11 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
async function loadPreviewModule(opts: {
|
async function loadPreviewModule(opts: {
|
||||||
resolveProxyImpl?: (projectDir: string, absoluteSourcePath: string) => Promise<string>;
|
resolveProxyImpl?: (
|
||||||
|
projectDir: string,
|
||||||
|
absoluteSourcePath: string,
|
||||||
|
variant?: "h264" | "vp9",
|
||||||
|
) => Promise<string>;
|
||||||
scanMapImpl?: ScanMapImpl;
|
scanMapImpl?: ScanMapImpl;
|
||||||
probeAssetCodecImpl?: () => Promise<{
|
probeAssetCodecImpl?: () => Promise<{
|
||||||
codecName: string;
|
codecName: string;
|
||||||
@@ -727,12 +731,26 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
|||||||
} | null,
|
} | null,
|
||||||
) => {
|
) => {
|
||||||
if (!facts) return { eligible: false, reason: "unknown_codec" };
|
if (!facts) return { eligible: false, reason: "unknown_codec" };
|
||||||
if (facts.hasAlpha) return { eligible: false, reason: "alpha_source" };
|
|
||||||
if (!facts.browserHostile) {
|
if (!facts.browserHostile) {
|
||||||
return { eligible: false, reason: "browser_safe_codec" };
|
return { eligible: false, reason: "browser_safe_codec" };
|
||||||
}
|
}
|
||||||
return { eligible: true };
|
return { eligible: true };
|
||||||
},
|
},
|
||||||
|
isProxyVariant: (value: string) => value === "h264" || value === "vp9",
|
||||||
|
isProxyVariantRequest: (value: string) =>
|
||||||
|
value === "auto" || value === "h264" || value === "vp9",
|
||||||
|
proxyVariantFor: (facts: { hasAlpha: boolean }) => (facts.hasAlpha ? "vp9" : "h264"),
|
||||||
|
resolveProxyVariantRequest: (
|
||||||
|
request: "auto" | "h264" | "vp9",
|
||||||
|
facts: { hasAlpha: boolean },
|
||||||
|
) => {
|
||||||
|
const expected = facts.hasAlpha ? "vp9" : "h264";
|
||||||
|
return request === "auto" || request === expected ? expected : null;
|
||||||
|
},
|
||||||
|
PROXY_VARIANT_CONFIG: {
|
||||||
|
h264: { extension: ".mp4", contentType: "video/mp4" },
|
||||||
|
vp9: { extension: ".webm", contentType: "video/webm" },
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
return import("./preview.js");
|
return import("./preview.js");
|
||||||
}
|
}
|
||||||
@@ -761,7 +779,11 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
|||||||
expect(full.headers.get("Content-Type")).toBe("video/mp4");
|
expect(full.headers.get("Content-Type")).toBe("video/mp4");
|
||||||
expect(await full.text()).toBe("0123456789proxybytes");
|
expect(await full.text()).toBe("0123456789proxybytes");
|
||||||
expect(resolveProxyMock).toHaveBeenCalledTimes(1);
|
expect(resolveProxyMock).toHaveBeenCalledTimes(1);
|
||||||
expect(resolveProxyMock).toHaveBeenCalledWith(projectDir, join(projectDir, "clip.mp4"));
|
expect(resolveProxyMock).toHaveBeenCalledWith(
|
||||||
|
projectDir,
|
||||||
|
join(projectDir, "clip.mp4"),
|
||||||
|
"h264",
|
||||||
|
);
|
||||||
|
|
||||||
const ranged = await app.request(
|
const ranged = await app.request(
|
||||||
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
|
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
|
||||||
@@ -841,40 +863,78 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
|||||||
expect(resolveProxyMock).not.toHaveBeenCalled();
|
expect(resolveProxyMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects alpha-bearing and browser-safe sources before transcoding", async () => {
|
it("serves an alpha asset as a VP9 WebM proxy", async () => {
|
||||||
for (const facts of [
|
const projectDir = createProjectDir();
|
||||||
{
|
writeFileSync(join(projectDir, "clip.mov"), "alpha-video-bytes");
|
||||||
|
const resolveProxyMock = vi.fn(async () => {
|
||||||
|
const proxyPath = join(projectDir, "proxy.webm");
|
||||||
|
writeFileSync(proxyPath, "vp9-alpha-proxy");
|
||||||
|
return proxyPath;
|
||||||
|
});
|
||||||
|
const { registerPreviewRoutes: register } = await loadPreviewModule({
|
||||||
|
resolveProxyImpl: resolveProxyMock,
|
||||||
|
probeAssetCodecImpl: async () => ({
|
||||||
codecName: "prores",
|
codecName: "prores",
|
||||||
browserHostile: true,
|
browserHostile: true,
|
||||||
representativeMime: null,
|
representativeMime: null,
|
||||||
hasAlpha: true,
|
hasAlpha: true,
|
||||||
},
|
}),
|
||||||
{
|
});
|
||||||
|
const app = new Hono();
|
||||||
|
register(app, createAdapter(projectDir));
|
||||||
|
|
||||||
|
const res = await app.request("http://localhost/projects/demo/preview/clip.mov?hf-proxy=vp9");
|
||||||
|
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.headers.get("Content-Type")).toBe("video/webm");
|
||||||
|
expect(resolveProxyMock).toHaveBeenCalledWith(
|
||||||
|
projectDir,
|
||||||
|
join(projectDir, "clip.mov"),
|
||||||
|
"vp9",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a proxy variant that disagrees with the asset facts", async () => {
|
||||||
|
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,
|
||||||
|
});
|
||||||
|
const app = new Hono();
|
||||||
|
register(app, createAdapter(projectDir));
|
||||||
|
|
||||||
|
const res = await app.request("http://localhost/projects/demo/preview/clip.mp4?hf-proxy=vp9");
|
||||||
|
|
||||||
|
expect(res.status).toBe(422);
|
||||||
|
expect(resolveProxyMock).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects browser-safe sources before transcoding", async () => {
|
||||||
|
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 () => ({
|
||||||
codecName: "h264",
|
codecName: "h264",
|
||||||
browserHostile: false,
|
browserHostile: false,
|
||||||
representativeMime: null,
|
representativeMime: null,
|
||||||
hasAlpha: false,
|
hasAlpha: false,
|
||||||
},
|
}),
|
||||||
]) {
|
});
|
||||||
const projectDir = createProjectDir();
|
const app = new Hono();
|
||||||
writeFileSync(join(projectDir, "clip.mp4"), "video-bytes");
|
register(app, createAdapter(projectDir));
|
||||||
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(
|
const res = await app.request(
|
||||||
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
|
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264",
|
||||||
);
|
);
|
||||||
expect(res.status).toBe(422);
|
|
||||||
expect(resolveProxyMock).not.toHaveBeenCalled();
|
expect(res.status).toBe(422);
|
||||||
}
|
expect(resolveProxyMock).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 404 without transcoding when the param value is not exactly h264", async () => {
|
it("returns 404 without transcoding when the param value is not a proxy variant", async () => {
|
||||||
const projectDir = createProjectDir();
|
const projectDir = createProjectDir();
|
||||||
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
|
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
|
||||||
const resolveProxyMock = vi.fn(async () => "should-not-be-called");
|
const resolveProxyMock = vi.fn(async () => "should-not-be-called");
|
||||||
@@ -885,7 +945,7 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
|||||||
const app = new Hono();
|
const app = new Hono();
|
||||||
register(app, createAdapter(projectDir));
|
register(app, createAdapter(projectDir));
|
||||||
|
|
||||||
for (const value of ["vp9", "H264", ""]) {
|
for (const value of ["H264", ""]) {
|
||||||
const res = await app.request(
|
const res = await app.request(
|
||||||
`http://localhost/projects/demo/preview/clip.mp4?hf-proxy=${value}`,
|
`http://localhost/projects/demo/preview/clip.mp4?hf-proxy=${value}`,
|
||||||
);
|
);
|
||||||
@@ -1013,6 +1073,7 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
|||||||
expect(resolveProxyMock).toHaveBeenCalledWith(
|
expect(resolveProxyMock).toHaveBeenCalledWith(
|
||||||
projectDir,
|
projectDir,
|
||||||
join(projectDir, "/videos/hevc.mp4"),
|
join(projectDir, "/videos/hevc.mp4"),
|
||||||
|
"h264",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,14 @@ import {
|
|||||||
ProxyCapacityError,
|
ProxyCapacityError,
|
||||||
ProxyTranscodeError,
|
ProxyTranscodeError,
|
||||||
} from "../helpers/proxyTranscoder.js";
|
} from "../helpers/proxyTranscoder.js";
|
||||||
import { decideMediaProxyEligibility, probeAssetCodec } from "../helpers/mediaCodecMap.js";
|
import {
|
||||||
|
decideMediaProxyEligibility,
|
||||||
|
isProxyVariantRequest,
|
||||||
|
probeAssetCodec,
|
||||||
|
resolveProxyVariantRequest,
|
||||||
|
PROXY_VARIANT_CONFIG,
|
||||||
|
type ProxyVariant,
|
||||||
|
} from "../helpers/mediaCodecMap.js";
|
||||||
import {
|
import {
|
||||||
isAutoProxyEnabled,
|
isAutoProxyEnabled,
|
||||||
injectMediaCodecMap,
|
injectMediaCodecMap,
|
||||||
@@ -532,27 +539,34 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
|||||||
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);
|
||||||
|
|
||||||
// `?hf-proxy=h264` (per the KTD's `?variables=`-style negotiation): the
|
// `?hf-proxy=` follows the asset's alpha-aware proxy variant. The
|
||||||
// param value must be exactly "h264" (matching play/staticProjectServer),
|
// param value must be recognized (matching play/staticProjectServer),
|
||||||
// only a video asset can be proxied, and only when auto-proxy is enabled
|
// 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
|
// for this adapter/project. Checked BEFORE any transcode or 304 shortcut
|
||||||
// so a bogus/disabled request never spawns ffmpeg.
|
// so a bogus/disabled request never spawns ffmpeg.
|
||||||
const proxyParam = c.req.query("hf-proxy");
|
const proxyParam = c.req.query("hf-proxy");
|
||||||
|
let proxyVariant: ProxyVariant | undefined;
|
||||||
if (proxyParam !== undefined) {
|
if (proxyParam !== undefined) {
|
||||||
if (
|
if (
|
||||||
proxyParam !== "h264" ||
|
!isProxyVariantRequest(proxyParam) ||
|
||||||
!contentType.startsWith("video/") ||
|
!contentType.startsWith("video/") ||
|
||||||
!isAutoProxyEnabled(adapter)
|
!isAutoProxyEnabled(adapter)
|
||||||
) {
|
) {
|
||||||
return c.text("not found", 404);
|
return c.text("not found", 404);
|
||||||
}
|
}
|
||||||
const eligibility = decideMediaProxyEligibility(await probeAssetCodec(file));
|
const facts = await probeAssetCodec(file);
|
||||||
|
const eligibility = decideMediaProxyEligibility(facts);
|
||||||
if (!eligibility.eligible) {
|
if (!eligibility.eligible) {
|
||||||
return c.text(`media proxy unavailable: ${eligibility.reason}`, 422);
|
return c.text(`media proxy unavailable: ${eligibility.reason}`, 422);
|
||||||
}
|
}
|
||||||
|
if (!facts) return c.text("media proxy unavailable: unknown_codec", 422);
|
||||||
|
proxyVariant = resolveProxyVariantRequest(proxyParam, facts) ?? undefined;
|
||||||
|
if (!proxyVariant) {
|
||||||
|
return c.text("media proxy variant does not match asset", 422);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}${proxyEtagSalt(proxyParam)}"`;
|
const etag = `"${stat.mtimeMs.toString(36)}-${stat.size.toString(36)}${proxyEtagSalt(proxyVariant)}"`;
|
||||||
const cacheHeaders: Record<string, string> = isText
|
const cacheHeaders: Record<string, string> = isText
|
||||||
? { "Cache-Control": "no-store" }
|
? { "Cache-Control": "no-store" }
|
||||||
: {
|
: {
|
||||||
@@ -572,9 +586,9 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
|||||||
// so a 304 never needs to await a transcode at all.
|
// so a 304 never needs to await a transcode at all.
|
||||||
let servedPath = file;
|
let servedPath = file;
|
||||||
let servedContentType = contentType;
|
let servedContentType = contentType;
|
||||||
if (proxyParam !== undefined) {
|
if (proxyVariant !== undefined) {
|
||||||
try {
|
try {
|
||||||
servedPath = await resolveProxy(project.dir, file);
|
servedPath = await resolveProxy(project.dir, file, proxyVariant);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ProxyCapacityError) {
|
if (err instanceof ProxyCapacityError) {
|
||||||
return c.text(err.message, 503, { "Retry-After": "5" });
|
return c.text(err.message, 503, { "Retry-After": "5" });
|
||||||
@@ -582,7 +596,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
|||||||
const message = err instanceof ProxyTranscodeError ? err.message : "proxy transcode failed";
|
const message = err instanceof ProxyTranscodeError ? err.message : "proxy transcode failed";
|
||||||
return c.text(message, 502);
|
return c.text(message, 502);
|
||||||
}
|
}
|
||||||
servedContentType = "video/mp4";
|
servedContentType = PROXY_VARIANT_CONFIG[proxyVariant].contentType;
|
||||||
}
|
}
|
||||||
|
|
||||||
const buffer: Buffer = isText
|
const buffer: Buffer = isText
|
||||||
|
|||||||
Reference in New Issue
Block a user