mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-13 07:40:06 +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",
|
||||
"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
|
||||
// (#1917): its consumers (frameCapture in #1919) land two PRs upstack, so
|
||||
// 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));
|
||||
}
|
||||
|
||||
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
|
||||
* regexes don't false-positive on commented-out or scripted markup. */
|
||||
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, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
|
||||
return out;
|
||||
|
||||
@@ -56,6 +56,24 @@
|
||||
"node": "./dist/helpers/sourceMutation.js",
|
||||
"import": "./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": {
|
||||
@@ -89,6 +107,18 @@
|
||||
"./source-mutation": {
|
||||
"import": "./dist/helpers/sourceMutation.js",
|
||||
"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",
|
||||
|
||||
@@ -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,
|
||||
} from "./types.js";
|
||||
export { isSafePath, walkDir } from "./helpers/safePath.js";
|
||||
export type { PreviewApiAdapter } from "./helpers/mediaProxyPreview.js";
|
||||
export { getMimeType, MIME_TYPES } from "./helpers/mime.js";
|
||||
export {
|
||||
consumeFileWriteReceipt,
|
||||
|
||||
@@ -440,7 +440,12 @@ function walkFiles(dir: string, filter: (name: string) => boolean): string[] {
|
||||
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
||||
const full = join(dir, entry.name);
|
||||
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;
|
||||
results.push(...walkFiles(full, filter));
|
||||
} else if (filter(entry.name)) {
|
||||
|
||||
@@ -24,8 +24,8 @@ function createProjectDir(): string {
|
||||
|
||||
function createAdapter(
|
||||
projectDir: string,
|
||||
overrides: Partial<StudioApiAdapter> = {},
|
||||
): StudioApiAdapter {
|
||||
overrides: Partial<StudioApiAdapter> & { autoProxy?: boolean } = {},
|
||||
): StudioApiAdapter & { autoProxy?: boolean } {
|
||||
return {
|
||||
listProjects: () => [],
|
||||
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));
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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 { persistHfIdsIfNeeded, stampFileHfIds } from "../helpers/hfIdPersist.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 GSAP_CDN_VERSION = "3.15.0";
|
||||
@@ -52,7 +65,9 @@ function parseStudioMotionManifestContent(content: string): {
|
||||
hasCustomEase: boolean;
|
||||
} {
|
||||
try {
|
||||
const parsed = JSON.parse(content) as { motions?: Array<{ customEase?: unknown }> };
|
||||
const parsed = JSON.parse(content) as {
|
||||
motions?: Array<{ customEase?: unknown }>;
|
||||
};
|
||||
const motions = Array.isArray(parsed.motions) ? parsed.motions : [];
|
||||
return {
|
||||
hasMotion: motions.length > 0,
|
||||
@@ -238,11 +253,13 @@ function variablesEtagSalt(raw: string | undefined): string {
|
||||
* route should 400; otherwise `values` is the override object (or null when
|
||||
* the param is absent) and `raw` feeds the ETag salt.
|
||||
*/
|
||||
function previewVariablesFromRequest(
|
||||
rawVariables: string | undefined,
|
||||
):
|
||||
function previewVariablesFromRequest(rawVariables: string | undefined):
|
||||
| { error: string }
|
||||
| { error?: undefined; raw: string | undefined; values: Record<string, unknown> | null } {
|
||||
| {
|
||||
error?: undefined;
|
||||
raw: string | undefined;
|
||||
values: Record<string, unknown> | null;
|
||||
} {
|
||||
const parse = parsePreviewVariablesParam(rawVariables);
|
||||
if (!parse.ok) return { error: parse.error };
|
||||
return { raw: rawVariables, values: parse.values };
|
||||
@@ -290,21 +307,32 @@ function resolveProjectMainHtml(
|
||||
): { html: string; compositionPath: string } | null {
|
||||
const indexPath = join(projectDir, "index.html");
|
||||
if (existsSync(indexPath)) {
|
||||
return { html: readFileSync(indexPath, "utf-8"), compositionPath: "index.html" };
|
||||
return {
|
||||
html: readFileSync(indexPath, "utf-8"),
|
||||
compositionPath: "index.html",
|
||||
};
|
||||
}
|
||||
const blockHtmlPath = join(projectDir, `${projectId}.html`);
|
||||
if (existsSync(blockHtmlPath)) {
|
||||
return { html: readFileSync(blockHtmlPath, "utf-8"), compositionPath: `${projectId}.html` };
|
||||
return {
|
||||
html: readFileSync(blockHtmlPath, "utf-8"),
|
||||
compositionPath: `${projectId}.html`,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): void {
|
||||
export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): void {
|
||||
const previewCacheHeaders = (etag: string) => ({
|
||||
"Cache-Control": "private, no-cache",
|
||||
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
|
||||
// fallow-ignore-next-line complexity
|
||||
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 ifNoneMatch = c.req.header("If-None-Match");
|
||||
if (ifNoneMatch === etag) {
|
||||
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: previewCacheHeaders(etag),
|
||||
});
|
||||
}
|
||||
|
||||
// Normalize + persist data-hf-id to disk before bundle reads it. Idempotent.
|
||||
@@ -370,6 +401,13 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
mainCompositionPath,
|
||||
);
|
||||
if (previewVariables) bundled = injectPreviewVariables(bundled, previewVariables);
|
||||
bundled = await injectMediaCodecMap(
|
||||
bundled,
|
||||
adapter,
|
||||
project.dir,
|
||||
mainCompositionPath,
|
||||
mediaCodecProbeCache,
|
||||
);
|
||||
return c.html(bundled, 200, previewCacheHeaders(etag));
|
||||
} catch {
|
||||
// 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) {
|
||||
fallbackAugmented = injectPreviewVariables(fallbackAugmented, previewVariables);
|
||||
}
|
||||
fallbackAugmented = await injectMediaCodecMap(
|
||||
fallbackAugmented,
|
||||
adapter,
|
||||
project.dir,
|
||||
fallback.compositionPath,
|
||||
mediaCodecProbeCache,
|
||||
);
|
||||
return c.html(fallbackAugmented, 200, previewCacheHeaders(etag));
|
||||
}
|
||||
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 ifNoneMatch = c.req.header("If-None-Match");
|
||||
if (ifNoneMatch === etag) {
|
||||
return new Response(null, { status: 304, headers: previewCacheHeaders(etag) });
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
headers: previewCacheHeaders(etag),
|
||||
});
|
||||
}
|
||||
|
||||
const stamped = pinSubCompHfIds(compFile, compPath);
|
||||
@@ -461,6 +509,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
html = ensureHfIds(await transformPreviewHtml(html, adapter, project, compPath));
|
||||
html = injectStudioPreviewAugmentations(html, adapter, project.dir, compPath);
|
||||
if (previewVariables) html = injectPreviewVariables(html, previewVariables);
|
||||
html = await injectMediaCodecMap(html, adapter, project.dir, compPath, mediaCodecProbeCache);
|
||||
return c.html(html, 200, previewCacheHeaders(etag));
|
||||
});
|
||||
|
||||
@@ -483,10 +532,33 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
const contentType = getMimeType(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
|
||||
? { "Cache-Control": "no-store" }
|
||||
: { "Cache-Control": "private, max-age=3600, must-revalidate", ETag: etag };
|
||||
: {
|
||||
"Cache-Control": "private, max-age=3600, must-revalidate",
|
||||
ETag: etag,
|
||||
};
|
||||
|
||||
if (!isText) {
|
||||
const ifNoneMatch = c.req.header("If-None-Match");
|
||||
@@ -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
|
||||
? Buffer.from(readFileSync(file, "utf-8"), "utf-8")
|
||||
: readFileSync(file);
|
||||
: readFileSync(servedPath);
|
||||
const totalSize = buffer.length;
|
||||
|
||||
// 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,
|
||||
headers: {
|
||||
...cacheHeaders,
|
||||
"Content-Type": contentType,
|
||||
"Content-Type": servedContentType,
|
||||
"Content-Range": `bytes ${start}-${safeEnd}/${totalSize}`,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(chunkSize),
|
||||
@@ -525,7 +615,7 @@ export function registerPreviewRoutes(api: Hono, adapter: StudioApiAdapter): voi
|
||||
return new Response(new Uint8Array(buffer), {
|
||||
headers: {
|
||||
...cacheHeaders,
|
||||
"Content-Type": contentType,
|
||||
"Content-Type": servedContentType,
|
||||
"Accept-Ranges": "bytes",
|
||||
"Content-Length": String(totalSize),
|
||||
},
|
||||
|
||||
@@ -4,6 +4,9 @@ export default defineConfig({
|
||||
entry: {
|
||||
index: "src/index.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/studioMotionRenderScript": "src/helpers/studioMotionRenderScript.ts",
|
||||
"helpers/draftMarkers": "src/helpers/draftMarkers.ts",
|
||||
@@ -15,7 +18,12 @@ export default defineConfig({
|
||||
target: "node22",
|
||||
platform: "node",
|
||||
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,
|
||||
clean: true,
|
||||
dts: true,
|
||||
|
||||
Reference in New Issue
Block a user