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