mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-10-30 09:16:55 +00:00
feat(core): lighter preview copies of heavy videos below full quality (#6365)
* feat(core): tell which video files deserve a lighter preview copy, and at which size * feat(studio-server): serve lighter copies of heavy playable files, a keyframe every second * feat(core): the preview plays lighter copies of heavy videos below full quality * style: shorten two comments * style: shorten a comment * style: shorten a comment * fix(core): format the preview quality files and split the copy sizing for the complexity gate * style: format the studio-server preview files * fix(core): type the copy job's level as a preview quality * chore(producer): raise the package size budget for the preview runtime growth
This commit is contained in:
@@ -268,11 +268,20 @@ export {
|
||||
type ToneMapSourceColour,
|
||||
} from "./hdrToneMap";
|
||||
export {
|
||||
HEAVY_BITRATE,
|
||||
HEAVY_PIXEL_SHARE,
|
||||
PREVIEW_PROXY_BOX_PARAM,
|
||||
PREVIEW_PROXY_LIGHT_PARAM,
|
||||
PREVIEW_QUALITY_LEVELS,
|
||||
formatPreviewProxyBox,
|
||||
isHeavyForPreview,
|
||||
isPreviewQuality,
|
||||
parsePreviewProxyBox,
|
||||
quantizePreviewProxyBox,
|
||||
scalePreviewProxyBox,
|
||||
type PreviewProxyBox,
|
||||
type PreviewQuality,
|
||||
type PreviewSourceFacts,
|
||||
} from "./previewProxyBox";
|
||||
|
||||
// Inline scripts
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parsePreviewProxyBox, quantizePreviewProxyBox } from "./previewProxyBox";
|
||||
import {
|
||||
isHeavyForPreview,
|
||||
isPreviewQuality,
|
||||
parsePreviewProxyBox,
|
||||
quantizePreviewProxyBox,
|
||||
scalePreviewProxyBox,
|
||||
type PreviewSourceFacts,
|
||||
} from "./previewProxyBox";
|
||||
|
||||
describe("preview proxy box", () => {
|
||||
it("rounds each side up to the next rung", () => {
|
||||
@@ -28,3 +35,34 @@ describe("preview proxy box", () => {
|
||||
expect(parsePreviewProxyBox("")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("lighter copies of files Chrome can play", () => {
|
||||
const full: PreviewSourceFacts = { width: 1920, height: 1080, bitDepth: 8, bitrate: 10_000_000 };
|
||||
const box = { width: 1218, height: 724 };
|
||||
const shown = { width: 1448, height: 862 };
|
||||
|
||||
it("multiplies the shown box by the level, on the ladder", () => {
|
||||
expect(scalePreviewProxyBox(box, 1)).toEqual(box);
|
||||
expect(scalePreviewProxyBox(box, 0.5)).toEqual({ width: 724, height: 362 });
|
||||
expect(scalePreviewProxyBox(box, 0.125)).toEqual({ width: 256, height: 256 });
|
||||
});
|
||||
|
||||
it("calls a file heavy for a much larger source, 10-bit or deeper, or 40 Mbit/s and up", () => {
|
||||
expect(isHeavyForPreview(full, shown)).toBe(false);
|
||||
expect(isHeavyForPreview({ ...full, width: 3840, height: 2160 }, shown)).toBe(true);
|
||||
expect(isHeavyForPreview({ ...full, bitDepth: 10 }, shown)).toBe(true);
|
||||
expect(isHeavyForPreview({ ...full, bitrate: 39_999_999 }, shown)).toBe(false);
|
||||
expect(isHeavyForPreview({ ...full, bitrate: 40_000_000 }, shown)).toBe(true);
|
||||
});
|
||||
|
||||
it("calls a source heavy when the copy box has half its pixels or fewer", () => {
|
||||
const half = { width: 1448, height: 724 };
|
||||
expect(isHeavyForPreview({ ...full, width: 2896, height: 1448 }, half)).toBe(true);
|
||||
expect(isHeavyForPreview({ ...full, width: 1448, height: 1000 }, half)).toBe(false);
|
||||
});
|
||||
|
||||
it("knows the four levels and nothing else", () => {
|
||||
expect([1, 0.5, 0.25, 0.125].every(isPreviewQuality)).toBe(true);
|
||||
expect([0, 0.75, 2, "1", null].some(isPreviewQuality)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/** Query parameter carrying the largest box, in device pixels, a preview shows a proxied video at. */
|
||||
export const PREVIEW_PROXY_BOX_PARAM = "hf-proxy-box";
|
||||
|
||||
export const PREVIEW_PROXY_LIGHT_PARAM = "hf-proxy-light";
|
||||
|
||||
export type PreviewProxyBox = { width: number; height: number };
|
||||
|
||||
// 256 * 2^(k/4), even: nearby boxes share one cached copy, and a rung overshoots by at most ~19%.
|
||||
@@ -33,3 +35,32 @@ export function parsePreviewProxyBox(raw: string): PreviewProxyBox | null {
|
||||
const height = Number(match[2]);
|
||||
return RUNGS.includes(width) && RUNGS.includes(height) ? { width, height } : null;
|
||||
}
|
||||
|
||||
export const PREVIEW_QUALITY_LEVELS = [1, 0.5, 0.25, 0.125] as const;
|
||||
export type PreviewQuality = (typeof PREVIEW_QUALITY_LEVELS)[number];
|
||||
|
||||
export function isPreviewQuality(value: unknown): value is PreviewQuality {
|
||||
return PREVIEW_QUALITY_LEVELS.some((level) => level === value);
|
||||
}
|
||||
|
||||
export type PreviewSourceFacts = {
|
||||
width: number;
|
||||
height: number;
|
||||
bitDepth: number;
|
||||
bitrate: number;
|
||||
};
|
||||
|
||||
export const HEAVY_PIXEL_SHARE = 0.5;
|
||||
export const HEAVY_BITRATE = 40_000_000;
|
||||
|
||||
export function scalePreviewProxyBox(box: PreviewProxyBox, level: PreviewQuality): PreviewProxyBox {
|
||||
return quantizePreviewProxyBox(box.width * level, box.height * level) ?? box;
|
||||
}
|
||||
|
||||
export function isHeavyForPreview(facts: PreviewSourceFacts, copyBox: PreviewProxyBox): boolean {
|
||||
return (
|
||||
copyBox.width * copyBox.height <= HEAVY_PIXEL_SHARE * facts.width * facts.height ||
|
||||
facts.bitDepth > 8 ||
|
||||
facts.bitrate >= HEAVY_BITRATE
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type BridgeDeps = {
|
||||
onSetRuntimeData?: (channel: string, payload: unknown, requestId?: number) => void;
|
||||
onClearRuntimeData?: (channel: string, requestId?: number) => void;
|
||||
onSetDisplayScale?: (scale: number) => void;
|
||||
onSetPreviewQuality?: (level: number) => void;
|
||||
getCanonicalFps: () => number;
|
||||
};
|
||||
|
||||
@@ -76,6 +77,7 @@ const CONTROL_HANDLERS = new Map<string, ControlHandler>(
|
||||
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
|
||||
"set-idle-heartbeat": (data, deps) => deps.onSetIdleHeartbeat(Boolean(data.slow)),
|
||||
"set-display-scale": (data, deps) => deps.onSetDisplayScale?.(Number(data.scale)),
|
||||
"set-preview-quality": (data, deps) => deps.onSetPreviewQuality?.(Number(data.level)),
|
||||
"set-root-duration": (data, deps) => deps.onSetRootDuration(Number(data.durationSeconds ?? 0)),
|
||||
"set-play-range": (data, deps) =>
|
||||
deps.onSetPlayRange(
|
||||
|
||||
@@ -95,6 +95,7 @@ import {
|
||||
handleMetadataForProxy,
|
||||
isRenderMode,
|
||||
maybeProxyProactively,
|
||||
setPreviewQuality,
|
||||
setProxyDisplayScale,
|
||||
} from "./mediaProxy";
|
||||
import {
|
||||
@@ -5973,6 +5974,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
setProxyDisplayScale(scale);
|
||||
setPreviewRasterScale(scale);
|
||||
},
|
||||
onSetPreviewQuality: setPreviewQuality,
|
||||
onSetIdleHeartbeat: (slow) => {
|
||||
slowIdleHeartbeat = slow;
|
||||
wakeTransport();
|
||||
|
||||
@@ -869,3 +869,133 @@ describe("a copy sized to how large the video shows", () => {
|
||||
expect(boxOf(el)).toBe("2436x4096");
|
||||
});
|
||||
});
|
||||
|
||||
describe("preview quality: lighter copies of heavy files", () => {
|
||||
const HEAVY = { width: 3840, height: 2160, bitDepth: 8, bitrate: 10_000_000 };
|
||||
const LIGHT = { width: 1280, height: 720, bitDepth: 8, bitrate: 10_000_000 };
|
||||
|
||||
// A framed page showing a 1920x1080 stage at full size on a 1x screen: a 2048x1218 box.
|
||||
async function stage(facts: Record<string, typeof HEAVY>, codecMap?: Record<string, unknown>) {
|
||||
vi.resetModules();
|
||||
vi.spyOn(window, "parent", "get").mockReturnValue({} as Window);
|
||||
const mod = await import("./mediaProxy");
|
||||
window.__HF_MEDIA_SOURCE_FACTS__ = facts;
|
||||
if (codecMap) window.__HF_MEDIA_CODEC_MAP__ = codecMap as never;
|
||||
Object.defineProperty(document.documentElement, "clientWidth", {
|
||||
configurable: true,
|
||||
value: 1920,
|
||||
});
|
||||
Object.defineProperty(document.documentElement, "clientHeight", {
|
||||
configurable: true,
|
||||
value: 1080,
|
||||
});
|
||||
vi.spyOn(window, "devicePixelRatio", "get").mockReturnValue(1);
|
||||
const video = (src = "/video.mp4") => {
|
||||
const el = createVideo(src);
|
||||
stubCanPlayType(el, "probably");
|
||||
el.getBoundingClientRect = () => ({ width: 1920, height: 1080 }) as DOMRect;
|
||||
return el;
|
||||
};
|
||||
mod.setProxyDisplayScale(1);
|
||||
return { ...mod, video };
|
||||
}
|
||||
|
||||
const params = (el: HTMLMediaElement) => new URL(el.src, document.baseURI).searchParams;
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as { __HF_MEDIA_SOURCE_FACTS__?: unknown }).__HF_MEDIA_SOURCE_FACTS__;
|
||||
});
|
||||
|
||||
it("changes nothing at full quality", async () => {
|
||||
const { video, setPreviewQuality, maybeProxyProactively } = await stage({
|
||||
"/video.mp4": HEAVY,
|
||||
});
|
||||
const el = video();
|
||||
maybeProxyProactively(el);
|
||||
setPreviewQuality(1);
|
||||
await settle();
|
||||
|
||||
expect(isProxied(el)).toBe(false);
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("swaps a heavy file Chrome can play to a copy at the shown box times the level", async () => {
|
||||
const { video, setPreviewQuality } = await stage({ "/video.mp4": HEAVY });
|
||||
const el = video();
|
||||
|
||||
setPreviewQuality(0.5);
|
||||
await settle();
|
||||
|
||||
expect(params(el).get("hf-proxy")).toBe("auto");
|
||||
expect(params(el).get("hf-proxy-box")).toBe("1024x724");
|
||||
expect(params(el).get("hf-proxy-light")).toBe("1");
|
||||
});
|
||||
|
||||
it("leaves a file alone that is not heavy at that box", async () => {
|
||||
const { video, setPreviewQuality } = await stage({ "/video.mp4": LIGHT });
|
||||
const el = video();
|
||||
|
||||
setPreviewQuality(0.5);
|
||||
await settle();
|
||||
|
||||
expect(isProxied(el)).toBe(false);
|
||||
});
|
||||
|
||||
it("puts the original back at full quality", async () => {
|
||||
const { video, setPreviewQuality } = await stage({ "/video.mp4": HEAVY });
|
||||
const el = video();
|
||||
setPreviewQuality(0.5);
|
||||
await settle();
|
||||
expect(isProxied(el)).toBe(true);
|
||||
|
||||
setPreviewQuality(1);
|
||||
|
||||
expect(el.getAttribute("src")).toBe("/video.mp4");
|
||||
expect(el.load).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("lightens a video that appears after the level was lowered", async () => {
|
||||
const { video, setPreviewQuality, maybeProxyProactively } = await stage({
|
||||
"/video.mp4": HEAVY,
|
||||
});
|
||||
setPreviewQuality(0.25);
|
||||
const el = video();
|
||||
|
||||
maybeProxyProactively(el);
|
||||
await settle();
|
||||
|
||||
expect(params(el).get("hf-proxy-box")).toBe("512x362");
|
||||
});
|
||||
|
||||
it("sizes the copy a file cannot play without by the level too, and resizes it as the level moves", async () => {
|
||||
const { video, setPreviewQuality, maybeProxyProactively } = await stage(
|
||||
{ "/video.mp4": HEAVY },
|
||||
{ "/video.mp4": HEVC_ENTRY },
|
||||
);
|
||||
const el = video();
|
||||
stubCanPlayType(el, "");
|
||||
maybeProxyProactively(el);
|
||||
await settle();
|
||||
expect(params(el).get("hf-proxy-box")).toBe("2048x1218");
|
||||
expect(params(el).has("hf-proxy-light")).toBe(false);
|
||||
|
||||
setPreviewQuality(0.5);
|
||||
await settle();
|
||||
expect(params(el).get("hf-proxy-box")).toBe("1024x724");
|
||||
expect(params(el).has("hf-proxy-light")).toBe(false);
|
||||
|
||||
setPreviewQuality(1);
|
||||
await settle();
|
||||
expect(params(el).get("hf-proxy-box")).toBe("2048x1218");
|
||||
});
|
||||
|
||||
it("ignores a level that is not one of the four", async () => {
|
||||
const { video, setPreviewQuality } = await stage({ "/video.mp4": HEAVY });
|
||||
const el = video();
|
||||
|
||||
setPreviewQuality(0.7);
|
||||
await settle();
|
||||
|
||||
expect(isProxied(el)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,9 +9,15 @@ import { waitForServedProxy } from "./proxyWait";
|
||||
import { registerSeekCompletion } from "./adapters/seek-dispatch";
|
||||
import {
|
||||
formatPreviewProxyBox,
|
||||
isHeavyForPreview,
|
||||
isPreviewQuality,
|
||||
PREVIEW_PROXY_BOX_PARAM,
|
||||
PREVIEW_PROXY_LIGHT_PARAM,
|
||||
quantizePreviewProxyBox,
|
||||
scalePreviewProxyBox,
|
||||
type PreviewProxyBox,
|
||||
type PreviewQuality,
|
||||
type PreviewSourceFacts,
|
||||
} from "../previewProxyBox";
|
||||
|
||||
/**
|
||||
@@ -34,6 +40,7 @@ export type MediaCodecMapEntry = {
|
||||
declare global {
|
||||
interface Window {
|
||||
__HF_MEDIA_CODEC_MAP__?: Record<string, MediaCodecMapEntry>;
|
||||
__HF_MEDIA_SOURCE_FACTS__?: Record<string, PreviewSourceFacts>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,7 +52,7 @@ const DIAGNOSTIC_FALLBACK_CODE = "runtime_media_proxy_fallback";
|
||||
* did) proxy it — a remote asset, or the proxy URL itself failing. */
|
||||
const DIAGNOSTIC_UNAVAILABLE_CODE = "runtime_media_proxy_unavailable";
|
||||
|
||||
type ProxyTrigger = "proactive" | "reactive" | "tertiary";
|
||||
type ProxyTrigger = "proactive" | "reactive" | "tertiary" | "light";
|
||||
|
||||
// `swappedElements` gates every trigger, so a second undecodable-video signal never re-swaps.
|
||||
|
||||
@@ -63,6 +70,10 @@ function currentSrcValue(el: HTMLMediaElement): string {
|
||||
return el.currentSrc || el.src;
|
||||
}
|
||||
|
||||
// Below 1, heavy files get lighter copies at the shown box times this level; 1 is the preview as it always was.
|
||||
let previewQuality: PreviewQuality = 1;
|
||||
const lightElements = new WeakSet<HTMLMediaElement>();
|
||||
|
||||
// How large the host shows this document, in its CSS pixels per ours (player fit x page zoom).
|
||||
// A top-level page shows itself at 1; a framed one waits for its host to say.
|
||||
let displayScale: number | null = window.parent === window ? 1 : null;
|
||||
@@ -72,7 +83,13 @@ const displayScaleKnown = new Promise<void>((arrived) => (displayScaleArrived =
|
||||
const DISPLAY_SCALE_WAIT_MS = 1000;
|
||||
|
||||
// Elements on a size-bounded copy: the source, the copy's URL and the box it was made for.
|
||||
type BoxedCopy = { originalSrc: string; variant: string; href: string; box: PreviewProxyBox };
|
||||
type BoxedCopy = {
|
||||
originalSrc: string;
|
||||
variant: string;
|
||||
href: string;
|
||||
box: PreviewProxyBox;
|
||||
light: boolean;
|
||||
};
|
||||
const boxedElements = new Map<HTMLMediaElement, BoxedCopy>();
|
||||
// The larger copy each element waits for; a newer ask replaces it and the older wait stops.
|
||||
const pendingUpgrades = new WeakMap<HTMLMediaElement, string>();
|
||||
@@ -106,6 +123,19 @@ export function setProxyDisplayScale(scale: number): void {
|
||||
for (const el of boxedElements.keys()) keepSized(el, scale);
|
||||
}
|
||||
|
||||
function codecEntryOf(el: HTMLMediaElement): MediaCodecMapEntry | null {
|
||||
const map = window.__HF_MEDIA_CODEC_MAP__;
|
||||
const key = deriveCodecMapKey(el);
|
||||
return map && key !== null ? lookupCodecMapEntry(key, map) : null;
|
||||
}
|
||||
|
||||
function sourceFactsOf(el: HTMLMediaElement): PreviewSourceFacts | null {
|
||||
const facts = window.__HF_MEDIA_SOURCE_FACTS__;
|
||||
const key = deriveCodecMapKey(el);
|
||||
return facts && key !== null ? lookupCodecMapEntry(key, facts) : null;
|
||||
}
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
function keepSized(el: HTMLMediaElement, scale: number): void {
|
||||
const copy = boxedElements.get(el);
|
||||
if (!copy) return;
|
||||
@@ -113,32 +143,68 @@ function keepSized(el: HTMLMediaElement, scale: number): void {
|
||||
boxedElements.delete(el);
|
||||
return;
|
||||
}
|
||||
const needed = shownBox(el, scale);
|
||||
if (needed && needed.width <= copy.box.width && needed.height <= copy.box.height) return;
|
||||
// Never smaller on either side than the copy it replaces.
|
||||
const box = needed && {
|
||||
width: Math.max(needed.width, copy.box.width),
|
||||
height: Math.max(needed.height, copy.box.height),
|
||||
const shown = shownBox(el, scale);
|
||||
const lowered = previewQuality < 1;
|
||||
const needed = shown && lowered ? scalePreviewProxyBox(shown, previewQuality) : shown;
|
||||
if (lowered && !needed) return;
|
||||
if (needed && lowered && copy.light && lightCopyNoLongerNeeded(el, needed)) {
|
||||
return restoreOriginal(el);
|
||||
}
|
||||
if (boxCovers(copy.box, needed, lowered)) return;
|
||||
requestBox(el, copy, lowered || !needed ? needed : growOnly(needed, copy.box));
|
||||
}
|
||||
|
||||
function lightCopyNoLongerNeeded(el: HTMLMediaElement, needed: PreviewProxyBox): boolean {
|
||||
const facts = sourceFactsOf(el);
|
||||
return facts !== null && !isHeavyForPreview(facts, needed);
|
||||
}
|
||||
|
||||
function boxCovers(current: PreviewProxyBox, needed: PreviewProxyBox | null, exact: boolean) {
|
||||
if (exact) return needed?.width === current.width && needed.height === current.height;
|
||||
return needed !== null && needed.width <= current.width && needed.height <= current.height;
|
||||
}
|
||||
|
||||
function growOnly(needed: PreviewProxyBox, current: PreviewProxyBox): PreviewProxyBox {
|
||||
return {
|
||||
width: Math.max(needed.width, current.width),
|
||||
height: Math.max(needed.height, current.height),
|
||||
};
|
||||
const href = appendProxyParam(copy.originalSrc, copy.variant, box);
|
||||
}
|
||||
|
||||
function requestBox(el: HTMLMediaElement, copy: BoxedCopy, box: PreviewProxyBox | null): void {
|
||||
const href = appendProxyParam(copy.originalSrc, copy.variant, box, copy.light);
|
||||
if (pendingUpgrades.get(el) === href) return;
|
||||
pendingUpgrades.set(el, href);
|
||||
const wanted = () =>
|
||||
pendingUpgrades.get(el) === href && el.isConnected && el.getAttribute("src") === copy.href;
|
||||
void waitForServedProxy(href, wanted).then((served) => {
|
||||
if (served && wanted()) loadProxy(el, href, copy.originalSrc, copy.variant, box);
|
||||
if (served && wanted()) loadProxy(el, href, copy.originalSrc, copy.variant, box, copy.light);
|
||||
if (pendingUpgrades.get(el) === href) pendingUpgrades.delete(el);
|
||||
});
|
||||
}
|
||||
|
||||
/** Puts a video that only got a lighter copy back on its original. */
|
||||
function restoreOriginal(el: HTMLMediaElement): void {
|
||||
const original = swappedElements.get(el) ?? null;
|
||||
for (const state of [boxedElements, pendingUpgrades, proxyRequested, swappedElements]) {
|
||||
state.delete(el);
|
||||
}
|
||||
lightElements.delete(el);
|
||||
evictMediaSyncState(el);
|
||||
if (original === null) el.removeAttribute("src");
|
||||
else el.setAttribute("src", original);
|
||||
el.load();
|
||||
}
|
||||
|
||||
function loadProxy(
|
||||
el: HTMLMediaElement,
|
||||
href: string,
|
||||
originalSrc: string,
|
||||
variant: string,
|
||||
box: PreviewProxyBox | null,
|
||||
light = false,
|
||||
): void {
|
||||
if (box) boxedElements.set(el, { originalSrc, variant, href, box });
|
||||
if (box) boxedElements.set(el, { originalSrc, variant, href, box, light });
|
||||
else boxedElements.delete(el);
|
||||
// Sync state measured on the previous file would read the new file's buffering as drift.
|
||||
evictMediaSyncState(el);
|
||||
@@ -202,10 +268,7 @@ export function deriveCodecMapKey(el: HTMLMediaElement): string | null {
|
||||
* ending in `"/notfoo.mp4"`, since that would require the literal substring
|
||||
* `"/foo.mp4"` to appear where the last `/` already fell a segment later).
|
||||
*/
|
||||
function lookupLongestExactSuffix(
|
||||
pathname: string,
|
||||
map: Record<string, MediaCodecMapEntry>,
|
||||
): MediaCodecMapEntry | null {
|
||||
function lookupLongestExactSuffix<T>(pathname: string, map: Record<string, T>): T | null {
|
||||
let bestKey: string | null = null;
|
||||
for (const key of Object.keys(map)) {
|
||||
if (!pathname.endsWith(key)) continue;
|
||||
@@ -214,10 +277,7 @@ function lookupLongestExactSuffix(
|
||||
return bestKey ? (map[bestKey] ?? null) : null;
|
||||
}
|
||||
|
||||
function lookupNormalizedSuffix(
|
||||
pathname: string,
|
||||
map: Record<string, MediaCodecMapEntry>,
|
||||
): MediaCodecMapEntry | null {
|
||||
function lookupNormalizedSuffix<T>(pathname: string, map: Record<string, T>): T | null {
|
||||
// Case-insensitive filesystems and Unicode-normalizing filesystems can
|
||||
// serve an authored URL whose spelling differs from the canonical path
|
||||
// ffprobe returned (Clip.mp4 vs clip.mp4, NFC vs NFD). Exact matching stays
|
||||
@@ -226,7 +286,7 @@ function lookupNormalizedSuffix(
|
||||
// spellings never select the wrong asset.
|
||||
const normalizedPathname = pathname.normalize("NFC").toLowerCase();
|
||||
let normalizedBestLength = -1;
|
||||
let normalizedBest: MediaCodecMapEntry | null = null;
|
||||
let normalizedBest: T | null = null;
|
||||
let ambiguous = false;
|
||||
for (const [key, entry] of Object.entries(map)) {
|
||||
const normalizedKey = key.normalize("NFC").toLowerCase();
|
||||
@@ -242,10 +302,7 @@ function lookupNormalizedSuffix(
|
||||
return ambiguous ? null : normalizedBest;
|
||||
}
|
||||
|
||||
function lookupCodecMapEntry(
|
||||
pathname: string,
|
||||
map: Record<string, MediaCodecMapEntry>,
|
||||
): MediaCodecMapEntry | null {
|
||||
function lookupCodecMapEntry<T>(pathname: string, map: Record<string, T>): T | null {
|
||||
return (
|
||||
map[pathname] ??
|
||||
lookupLongestExactSuffix(pathname, map) ??
|
||||
@@ -253,10 +310,16 @@ function lookupCodecMapEntry(
|
||||
);
|
||||
}
|
||||
|
||||
function appendProxyParam(src: string, variant: string, box: PreviewProxyBox | null): string {
|
||||
function appendProxyParam(
|
||||
src: string,
|
||||
variant: string,
|
||||
box: PreviewProxyBox | null,
|
||||
light = false,
|
||||
): string {
|
||||
const url = new URL(src, document.baseURI);
|
||||
url.searchParams.set(PROXY_QUERY_PARAM, variant);
|
||||
if (box) url.searchParams.set(PREVIEW_PROXY_BOX_PARAM, formatPreviewProxyBox(box));
|
||||
if (light && box) url.searchParams.set(PREVIEW_PROXY_LIGHT_PARAM, "1");
|
||||
return url.href;
|
||||
}
|
||||
|
||||
@@ -300,6 +363,49 @@ function emitUnavailableDiagnostic(
|
||||
console.info(`[hyperframes] ${DIAGNOSTIC_UNAVAILABLE_CODE}: "${asset}" (${reason}): ${note}`);
|
||||
}
|
||||
|
||||
type CopyJob = {
|
||||
originalSrc: string;
|
||||
originalAttr: string | null;
|
||||
variant: string;
|
||||
lightFacts?: PreviewSourceFacts;
|
||||
level: PreviewQuality;
|
||||
live: () => boolean;
|
||||
};
|
||||
|
||||
// fallow-ignore-next-line complexity
|
||||
async function makeCopy(el: HTMLMediaElement, job: CopyJob, scale: number | null) {
|
||||
const { originalSrc, originalAttr, variant, lightFacts, level, live } = job;
|
||||
// Asked once the shown size is known, so the copy is made at that size and only once.
|
||||
const shown = scale === null ? null : shownBox(el, scale);
|
||||
const box = shown && level < 1 ? scalePreviewProxyBox(shown, level) : shown;
|
||||
if (lightFacts && !(box && isHeavyForPreview(lightFacts, box))) {
|
||||
if (proxyRequested.get(el) === originalAttr) proxyRequested.delete(el);
|
||||
return;
|
||||
}
|
||||
const proxiedSrc = appendProxyParam(originalSrc, variant, box, lightFacts !== undefined);
|
||||
const served = await waitForServedProxy(proxiedSrc, live);
|
||||
if (!live()) {
|
||||
if (proxyRequested.get(el) === originalAttr) proxyRequested.delete(el);
|
||||
if (lightFacts && el.isConnected && el.getAttribute("src") === originalAttr) {
|
||||
applyPreviewQuality(el);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (!served) {
|
||||
emitUnavailableDiagnostic(el, "proxy_playback_failed", originalSrc);
|
||||
return;
|
||||
}
|
||||
swappedElements.set(el, originalAttr);
|
||||
if (lightFacts) lightElements.add(el);
|
||||
loadProxy(el, proxiedSrc, originalSrc, variant, box, lightFacts !== undefined);
|
||||
// The shown size may have grown while this copy was being made.
|
||||
if (displayScale !== null) keepSized(el, displayScale);
|
||||
return new Promise((landed) => {
|
||||
el.addEventListener("loadeddata", landed, { once: true });
|
||||
el.addEventListener("error", landed, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Swap `el` to its alpha-aware proxy URL, evict stale per-source sync state, and
|
||||
* emit the one-time diagnostic + console line. Safe to call from any of the
|
||||
@@ -309,6 +415,7 @@ export function swapToProxy(
|
||||
el: HTMLMediaElement,
|
||||
entry: MediaCodecMapEntry | null = null,
|
||||
trigger: ProxyTrigger = "reactive",
|
||||
lightFacts?: PreviewSourceFacts,
|
||||
): void {
|
||||
if (swappedElements.has(el)) return;
|
||||
if (proxyRequested.has(el) && proxyRequested.get(el) === el.getAttribute("src")) return;
|
||||
@@ -323,29 +430,15 @@ export function swapToProxy(
|
||||
}
|
||||
const originalAttr = el.getAttribute("src");
|
||||
proxyRequested.set(el, originalAttr);
|
||||
const live = () => el.isConnected && el.getAttribute("src") === originalAttr;
|
||||
const swap = shownScale().then(async (scale) => {
|
||||
// Asked once the shown size is known, so the copy is made at that size and only once.
|
||||
const box = scale === null ? null : shownBox(el, scale);
|
||||
const proxiedSrc = appendProxyParam(originalSrc, variant, box);
|
||||
const served = await waitForServedProxy(proxiedSrc, live);
|
||||
if (!live()) {
|
||||
if (proxyRequested.get(el) === originalAttr) proxyRequested.delete(el);
|
||||
return;
|
||||
}
|
||||
if (!served) {
|
||||
emitUnavailableDiagnostic(el, "proxy_playback_failed", originalSrc);
|
||||
return;
|
||||
}
|
||||
swappedElements.set(el, originalAttr);
|
||||
loadProxy(el, proxiedSrc, originalSrc, variant, box);
|
||||
// The shown size may have grown while this copy was being made.
|
||||
if (displayScale !== null) keepSized(el, displayScale);
|
||||
return new Promise((landed) => {
|
||||
el.addEventListener("loadeddata", landed, { once: true });
|
||||
el.addEventListener("error", landed, { once: true });
|
||||
});
|
||||
});
|
||||
const level = previewQuality;
|
||||
// A lighter copy waits for no level but its own; a copy the file cannot play without finishes at any level.
|
||||
const live = () =>
|
||||
el.isConnected &&
|
||||
el.getAttribute("src") === originalAttr &&
|
||||
(!lightFacts || previewQuality === level);
|
||||
const job: CopyJob = { originalSrc, originalAttr, variant, lightFacts, level, live };
|
||||
const swap = shownScale().then((scale) => makeCopy(el, job, scale));
|
||||
if (lightFacts) return;
|
||||
// A frame capture holds for the copy as it held for a loading video before, up to the same cap.
|
||||
registerSeekCompletion(
|
||||
Promise.race([swap, new Promise((cap) => setTimeout(cap, HOLD_CAP_MS))]),
|
||||
@@ -383,15 +476,31 @@ export function maybeProxyProactively(el: HTMLMediaElement): void {
|
||||
if (isRenderMode(el)) return;
|
||||
if (!isVideoElement(el)) return;
|
||||
if (swappedElements.has(el)) return;
|
||||
const map = window.__HF_MEDIA_CODEC_MAP__;
|
||||
if (!map) return;
|
||||
const key = deriveCodecMapKey(el);
|
||||
if (key === null) return;
|
||||
const entry = lookupCodecMapEntry(key, map);
|
||||
if (!entry || !entry.browserHostile) return;
|
||||
const canPlay = entry.representativeMime ? el.canPlayType(entry.representativeMime) : "";
|
||||
if (canPlay === "probably" || canPlay === "maybe") return;
|
||||
swapToProxy(el, entry, "proactive");
|
||||
const entry = codecEntryOf(el);
|
||||
const canPlay = entry?.representativeMime ? el.canPlayType(entry.representativeMime) : "";
|
||||
const playable = canPlay === "probably" || canPlay === "maybe";
|
||||
if (entry?.browserHostile && !playable) swapToProxy(el, entry, "proactive");
|
||||
else if (previewQuality < 1) lightenIfHeavy(el, entry);
|
||||
}
|
||||
|
||||
function lightenIfHeavy(el: HTMLMediaElement, entry: MediaCodecMapEntry | null): void {
|
||||
if (swappedElements.has(el) || proxyRequested.has(el)) return;
|
||||
const facts = sourceFactsOf(el);
|
||||
if (facts) swapToProxy(el, entry, "light", facts);
|
||||
}
|
||||
|
||||
function applyPreviewQuality(el: HTMLMediaElement): void {
|
||||
if (isRenderMode(el) || !isVideoElement(el)) return;
|
||||
if (previewQuality === 1 && lightElements.has(el)) return restoreOriginal(el);
|
||||
if (boxedElements.has(el) && displayScale !== null) return keepSized(el, displayScale);
|
||||
if (previewQuality < 1) lightenIfHeavy(el, codecEntryOf(el));
|
||||
}
|
||||
|
||||
/** The host's preview quality: below 1, heavy videos play lighter copies sized to the shown box times the level. */
|
||||
export function setPreviewQuality(level: number): void {
|
||||
if (!isPreviewQuality(level) || level === previewQuality) return;
|
||||
previewQuality = level;
|
||||
for (const el of document.querySelectorAll("video")) applyPreviewQuality(el);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -22,6 +22,7 @@ type RuntimeBridgeControlActionBase =
|
||||
| "set-web-audio-media-disabled"
|
||||
| "set-idle-heartbeat"
|
||||
| "set-display-scale"
|
||||
| "set-preview-quality"
|
||||
| "set-root-duration"
|
||||
| "set-play-range"
|
||||
| "stop-media"
|
||||
@@ -41,6 +42,7 @@ type RuntimeBridgeControlMessageBase = {
|
||||
disabled?: boolean;
|
||||
slow?: boolean;
|
||||
scale?: number;
|
||||
level?: number;
|
||||
playbackRate?: number;
|
||||
target?: HfColorGradingTarget | string | null;
|
||||
grading?: RuntimeJson;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
probeAssetCodec,
|
||||
proxyVariantFor,
|
||||
resolveProxyVariantRequest,
|
||||
scanProjectMedia,
|
||||
scanProjectMediaCodecMap,
|
||||
shouldPrewarmProxy,
|
||||
type AssetCodecFacts,
|
||||
@@ -24,7 +25,14 @@ const FAKE_FFPROBE_PATH = process.execPath;
|
||||
function makeRunner(
|
||||
codecByPath: Record<
|
||||
string,
|
||||
string | { codecName: string; pixFmt?: string; tags?: Record<string, string> }
|
||||
| string
|
||||
| {
|
||||
codecName: string;
|
||||
pixFmt?: string;
|
||||
tags?: Record<string, string>;
|
||||
size?: [number, number];
|
||||
bitRate?: string;
|
||||
}
|
||||
>,
|
||||
): FfprobeRunner {
|
||||
return (_command, args) => {
|
||||
@@ -38,6 +46,9 @@ function makeRunner(
|
||||
codec_name: normalized.codecName,
|
||||
pix_fmt: normalized.pixFmt,
|
||||
tags: normalized.tags,
|
||||
width: normalized.size?.[0],
|
||||
height: normalized.size?.[1],
|
||||
bit_rate: normalized.bitRate,
|
||||
},
|
||||
]
|
||||
: [];
|
||||
@@ -328,6 +339,68 @@ describe("decideMediaProxyEligibility", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("a lighter copy of a file Chrome can play", () => {
|
||||
const playable = {
|
||||
codecName: "h264",
|
||||
browserHostile: false,
|
||||
representativeMime: null,
|
||||
hasAlpha: false,
|
||||
source: { width: 3840, height: 2160, bitDepth: 8, bitrate: 10_000_000 },
|
||||
};
|
||||
|
||||
it("is allowed only for the box it is asked at, and only when the file is heavy there", () => {
|
||||
expect(decideMediaProxyEligibility(playable)).toEqual({
|
||||
eligible: false,
|
||||
reason: "browser_safe_codec",
|
||||
});
|
||||
expect(decideMediaProxyEligibility(playable, { width: 724, height: 362 })).toEqual({
|
||||
eligible: true,
|
||||
});
|
||||
expect(
|
||||
decideMediaProxyEligibility(
|
||||
{ ...playable, source: { ...playable.source, width: 1920, height: 1080 } },
|
||||
{ width: 2048, height: 1218 },
|
||||
),
|
||||
).toEqual({ eligible: false, reason: "browser_safe_codec" });
|
||||
});
|
||||
|
||||
it("reads the size, bit depth and bitrate off the probe, and keeps them with the codec facts", async () => {
|
||||
const project = tmpProject();
|
||||
const videoPath = join(project, "cam.mov");
|
||||
writeFileSync(videoPath, "fake video bytes");
|
||||
|
||||
const facts = await probeAssetCodec(
|
||||
videoPath,
|
||||
makeRunner({
|
||||
[videoPath]: {
|
||||
codecName: "hevc",
|
||||
pixFmt: "yuv420p10le",
|
||||
size: [3840, 2160],
|
||||
bitRate: "60000000",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(facts?.source).toEqual({ width: 3840, height: 2160, bitDepth: 10, bitrate: 60_000_000 });
|
||||
});
|
||||
|
||||
it("scans the sizes of the playable videos too, while the codec map keeps only the hostile ones", async () => {
|
||||
const project = tmpProject();
|
||||
writeFileSync(join(project, "cam.mp4"), "fake video bytes");
|
||||
writeFileSync(join(project, "broll.mov"), "fake video bytes");
|
||||
|
||||
const scan = await scanProjectMedia(project, [{ html: videoHtml("cam.mp4", "broll.mov") }], {
|
||||
runner: makeRunner({
|
||||
[join(project, "cam.mp4")]: { codecName: "h264", size: [1920, 1080] },
|
||||
[join(project, "broll.mov")]: { codecName: "hevc", size: [3840, 2160] },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(Object.keys(scan.codecMap)).toEqual(["/broll.mov"]);
|
||||
expect(Object.keys(scan.sourceFacts).sort()).toEqual(["/broll.mov", "/cam.mp4"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("proxyVariantFor", () => {
|
||||
it("uses VP8 for alpha and H.264 otherwise", () => {
|
||||
const facts = {
|
||||
|
||||
@@ -9,6 +9,11 @@ import {
|
||||
isUnresolvedAssetPlaceholder,
|
||||
resolveExistingLocalAsset as resolveAsset,
|
||||
} from "@hyperframes/parsers/asset-resolution";
|
||||
import {
|
||||
isHeavyForPreview,
|
||||
type PreviewProxyBox,
|
||||
type PreviewSourceFacts,
|
||||
} from "@hyperframes/core";
|
||||
import { probeMediaMetadata, type FfprobeRunner } from "./mediaMetadata.js";
|
||||
|
||||
/**
|
||||
@@ -27,6 +32,8 @@ export interface AssetCodecFacts {
|
||||
/** Source carries an alpha channel (`MediaMetadata.hasAlpha`). Alpha sources use a
|
||||
* VP8/WebM proxy so their transparency is preserved across Chromium builds. */
|
||||
hasAlpha: boolean;
|
||||
/** Size, bit depth and bitrate, for deciding whether a playable file deserves a lighter copy. */
|
||||
source?: PreviewSourceFacts;
|
||||
}
|
||||
|
||||
/** Server-root-relative URL pathname -> that asset's codec facts. */
|
||||
@@ -176,20 +183,34 @@ export type MediaProxyEligibility =
|
||||
| { eligible: true }
|
||||
| { eligible: false; reason: MediaProxyIneligibilityReason };
|
||||
|
||||
/** Single policy gate shared by proactive scans and on-demand proxy routes. */
|
||||
export function decideMediaProxyEligibility(facts: AssetCodecFacts | null): MediaProxyEligibility {
|
||||
/**
|
||||
* Single policy gate shared by proactive scans and on-demand proxy routes. A file Chrome can play is
|
||||
* eligible only for a lighter copy (`lightBox`, the box that request asks for) and only when it is heavy there.
|
||||
*/
|
||||
export function decideMediaProxyEligibility(
|
||||
facts: AssetCodecFacts | null,
|
||||
lightBox?: PreviewProxyBox,
|
||||
): MediaProxyEligibility {
|
||||
if (!facts) return { eligible: false, reason: "unknown_codec" };
|
||||
if (!facts.browserHostile) return { eligible: false, reason: "browser_safe_codec" };
|
||||
return { eligible: true };
|
||||
if (facts.browserHostile) return { eligible: true };
|
||||
if (lightBox && facts.source && isHeavyForPreview(facts.source, lightBox)) {
|
||||
return { eligible: true };
|
||||
}
|
||||
return { eligible: false, reason: "browser_safe_codec" };
|
||||
}
|
||||
|
||||
function codecFactsFor(codecName: string, hasAlpha: boolean): AssetCodecFacts {
|
||||
function codecFactsFor(
|
||||
codecName: string,
|
||||
hasAlpha: boolean,
|
||||
source?: PreviewSourceFacts,
|
||||
): AssetCodecFacts {
|
||||
const hostile = hostileCodecEntry(codecName);
|
||||
return {
|
||||
codecName,
|
||||
browserHostile: hostile !== undefined,
|
||||
representativeMime: hostile?.representativeMime ?? null,
|
||||
hasAlpha,
|
||||
...(source && { source }),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -205,7 +226,7 @@ async function probeCodecFacts(
|
||||
if (metadata.kind !== "video") return null;
|
||||
const codecName = metadata.color.codecName;
|
||||
if (!codecName) return null;
|
||||
return codecFactsFor(codecName, metadata.hasAlpha === true);
|
||||
return codecFactsFor(codecName, metadata.hasAlpha === true, metadata.source);
|
||||
}
|
||||
|
||||
interface CachedAssetProbe {
|
||||
@@ -347,20 +368,12 @@ export interface ScanProjectMediaCodecMapOptions {
|
||||
runner?: FfprobeRunner;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scans a project's composition HTML for local `<video src>` references and
|
||||
* returns the injection map: root-relative URL pathname -> codec facts.
|
||||
* Best-effort throughout — a video whose codec can't be determined (missing
|
||||
* ffprobe, probe error, no video stream) is simply omitted, never thrown.
|
||||
*/
|
||||
export async function scanProjectMediaCodecMap(
|
||||
async function probeProjectAssets(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSourceLike[],
|
||||
options: ScanProjectMediaCodecMapOptions = {},
|
||||
): Promise<MediaCodecMap> {
|
||||
options: ScanProjectMediaCodecMapOptions,
|
||||
): Promise<Array<[string, AssetCodecFacts | null]>> {
|
||||
const candidates = collectLocalVideoAssets(projectDir, htmlSources);
|
||||
if (candidates.size === 0) return {};
|
||||
|
||||
const cache = options.cache ?? defaultProbeCache;
|
||||
const entries = [...candidates.entries()]; // [resolvedPath, rootRelativePathname]
|
||||
const facts = new Array<AssetCodecFacts | null>(entries.length).fill(null);
|
||||
@@ -376,11 +389,35 @@ export async function scanProjectMediaCodecMap(
|
||||
}
|
||||
}),
|
||||
);
|
||||
return entries.map(([, pathname], index) => [pathname, facts[index] ?? null]);
|
||||
}
|
||||
|
||||
const map: MediaCodecMap = {};
|
||||
entries.forEach(([, pathname], index) => {
|
||||
const entryFacts = facts[index];
|
||||
if (entryFacts?.browserHostile) map[pathname] = entryFacts;
|
||||
});
|
||||
return map;
|
||||
/** What a scan of a project's local videos finds: the hostile ones for the proxy map, and every readable one's size. */
|
||||
export interface ProjectMediaScan {
|
||||
codecMap: MediaCodecMap;
|
||||
/** Root-relative URL pathname -> size, bit depth and bitrate. */
|
||||
sourceFacts: Record<string, PreviewSourceFacts>;
|
||||
}
|
||||
|
||||
/** Scans composition HTML for local `<video src>` references; a video that can't be probed is omitted. */
|
||||
export async function scanProjectMedia(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSourceLike[],
|
||||
options: ScanProjectMediaCodecMapOptions = {},
|
||||
): Promise<ProjectMediaScan> {
|
||||
const scan: ProjectMediaScan = { codecMap: {}, sourceFacts: {} };
|
||||
for (const [pathname, facts] of await probeProjectAssets(projectDir, htmlSources, options)) {
|
||||
if (facts?.browserHostile) scan.codecMap[pathname] = facts;
|
||||
if (facts?.source) scan.sourceFacts[pathname] = facts.source;
|
||||
}
|
||||
return scan;
|
||||
}
|
||||
|
||||
/** The injection map alone: root-relative URL pathname -> codec facts of the hostile videos. */
|
||||
export async function scanProjectMediaCodecMap(
|
||||
projectDir: string,
|
||||
htmlSources: HtmlSourceLike[],
|
||||
options: ScanProjectMediaCodecMapOptions = {},
|
||||
): Promise<MediaCodecMap> {
|
||||
return (await scanProjectMedia(projectDir, htmlSources, options)).codecMap;
|
||||
}
|
||||
|
||||
@@ -120,6 +120,33 @@ describe("probeMediaMetadata", () => {
|
||||
expect(flac.kind).toBe("audio");
|
||||
});
|
||||
|
||||
it("reads the size, bit depth and bitrate a lighter copy is judged by", async () => {
|
||||
const probe = (stream: object, format: object = {}) =>
|
||||
probeMediaMetadata("/tmp/clip.mp4", () => ({
|
||||
status: 0,
|
||||
stdout: JSON.stringify({ streams: [{ codec_type: "video", ...stream }], format }),
|
||||
stderr: "",
|
||||
}));
|
||||
|
||||
expect(
|
||||
(await probe({ width: 3840, height: 2160, pix_fmt: "yuv420p10le", bit_rate: "9" })).source,
|
||||
).toEqual({
|
||||
width: 3840,
|
||||
height: 2160,
|
||||
bitDepth: 10,
|
||||
bitrate: 9,
|
||||
});
|
||||
expect(
|
||||
(await probe({ width: 1920, height: 1080, pix_fmt: "yuv420p" }, { bit_rate: "55000000" }))
|
||||
.source,
|
||||
).toEqual({ width: 1920, height: 1080, bitDepth: 8, bitrate: 55_000_000 });
|
||||
expect(
|
||||
(await probe({ width: 1920, height: 1080, pix_fmt: "yuv420p", bits_per_raw_sample: "12" }))
|
||||
.source?.bitDepth,
|
||||
).toBe(12);
|
||||
expect((await probe({ pix_fmt: "yuv420p" })).source).toBeUndefined();
|
||||
});
|
||||
|
||||
it("ignores attached cover art and reads the real video stream", async () => {
|
||||
const metadata = await probeMediaMetadata("/tmp/clip.mp4", () => ({
|
||||
status: 0,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { extname } from "node:path";
|
||||
import {
|
||||
firstFrameColourArgs,
|
||||
parseFirstFrameColour,
|
||||
type PreviewSourceFacts,
|
||||
type ToneMapSourceColour,
|
||||
} from "@hyperframes/core";
|
||||
import { findFfBinary } from "@hyperframes/parsers/ff-binaries";
|
||||
@@ -77,6 +78,7 @@ export interface MediaMetadata {
|
||||
/** Video stream carries alpha (pix_fmt or the VP8/VP9 alpha_mode tag). */
|
||||
hasAlpha?: boolean;
|
||||
durationSeconds?: number;
|
||||
source?: PreviewSourceFacts;
|
||||
probeError?: string;
|
||||
}
|
||||
|
||||
@@ -89,6 +91,9 @@ interface FfprobeStream {
|
||||
color_transfer?: string;
|
||||
color_primaries?: string;
|
||||
bits_per_raw_sample?: string;
|
||||
width?: number;
|
||||
height?: number;
|
||||
bit_rate?: string;
|
||||
disposition?: { attached_pic?: number };
|
||||
tags?: Record<string, string | undefined>;
|
||||
}
|
||||
@@ -192,6 +197,27 @@ export async function probeFirstFrameColour(
|
||||
return result.status === 0 ? parseFirstFrameColour(String(result.stderr)) : {};
|
||||
}
|
||||
|
||||
function bitDepthOf(stream: FfprobeStream): number {
|
||||
const raw = Number(stream.bits_per_raw_sample);
|
||||
if (Number.isFinite(raw) && raw > 0) return raw;
|
||||
const suffixed = /(\d{1,3})(?:le|be)$/.exec(stream.pix_fmt ?? "");
|
||||
return suffixed ? Number(suffixed[1]) : 8;
|
||||
}
|
||||
|
||||
function sourceFactsOf(
|
||||
stream: FfprobeStream | undefined,
|
||||
formatBitrate: string | undefined,
|
||||
): PreviewSourceFacts | undefined {
|
||||
if (!stream?.width || !stream.height) return undefined;
|
||||
const bitrate = Number(stream.bit_rate ?? formatBitrate);
|
||||
return {
|
||||
width: stream.width,
|
||||
height: stream.height,
|
||||
bitDepth: bitDepthOf(stream),
|
||||
bitrate: Number.isFinite(bitrate) ? bitrate : 0,
|
||||
};
|
||||
}
|
||||
|
||||
export async function probeMediaMetadata(
|
||||
filePath: string,
|
||||
runner: FfprobeRunner = execFileRunner,
|
||||
@@ -216,7 +242,7 @@ export async function probeMediaMetadata(
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample:stream_disposition=attached_pic:stream_tags=alpha_mode:format=duration",
|
||||
"stream=codec_type,codec_name,profile,pix_fmt,color_space,color_transfer,color_primaries,bits_per_raw_sample,width,height,bit_rate:stream_disposition=attached_pic:stream_tags=alpha_mode:format=duration,bit_rate",
|
||||
"-of",
|
||||
"json",
|
||||
"--",
|
||||
@@ -235,7 +261,7 @@ export async function probeMediaMetadata(
|
||||
try {
|
||||
const parsed = JSON.parse(String(result.stdout || "{}")) as {
|
||||
streams?: FfprobeStream[];
|
||||
format?: { duration?: string };
|
||||
format?: { duration?: string; bit_rate?: string };
|
||||
};
|
||||
const stream = parsed.streams?.find((item) => {
|
||||
if (kind === "image") return item.codec_type === "video";
|
||||
@@ -247,6 +273,7 @@ export async function probeMediaMetadata(
|
||||
metadata.hasAlpha = stream !== undefined && videoStreamHasAlpha(stream);
|
||||
const duration = Number(parsed.format?.duration);
|
||||
if (Number.isFinite(duration) && duration > 0) metadata.durationSeconds = duration;
|
||||
metadata.source = sourceFactsOf(stream, parsed.format?.bit_rate);
|
||||
}
|
||||
return metadata;
|
||||
} catch {
|
||||
|
||||
@@ -23,7 +23,10 @@ function hostile(codecName: string, representativeMime: string | null): AssetCod
|
||||
return { codecName, browserHostile: true, representativeMime, hasAlpha: false };
|
||||
}
|
||||
|
||||
async function loadHelper(map: MediaCodecMap): Promise<{
|
||||
async function loadHelper(
|
||||
map: MediaCodecMap,
|
||||
sourceFacts: Record<string, unknown> = {},
|
||||
): Promise<{
|
||||
injectMediaCodecMapIntoHtml: typeof import("./mediaProxyPreview.js").injectMediaCodecMapIntoHtml;
|
||||
mediaProxyDemand: typeof import("./mediaCodecMap.js").mediaProxyDemand;
|
||||
resolveProxy: ReturnType<typeof vi.fn>;
|
||||
@@ -35,7 +38,7 @@ async function loadHelper(map: MediaCodecMap): Promise<{
|
||||
// replaced, so this asserts the shipped policy rather than a copy of it.
|
||||
vi.doMock("./mediaCodecMap.js", async () => ({
|
||||
...(await vi.importActual<typeof import("./mediaCodecMap.js")>("./mediaCodecMap.js")),
|
||||
scanProjectMediaCodecMap: async () => map,
|
||||
scanProjectMedia: async () => ({ codecMap: map, sourceFacts }),
|
||||
}));
|
||||
const { injectMediaCodecMapIntoHtml } = await import("./mediaProxyPreview.js");
|
||||
const { mediaProxyDemand } = await import("./mediaCodecMap.js");
|
||||
@@ -55,6 +58,33 @@ afterEach(() => {
|
||||
for (const dir of dirs.splice(0)) rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("injectMediaCodecMapIntoHtml source facts", () => {
|
||||
const facts = { "/videos/cam.mp4": { width: 3840, height: 2160, bitDepth: 10, bitrate: 0 } };
|
||||
|
||||
it("hands the page the sizes of its videos, and no codec map when none is hostile", async () => {
|
||||
const { injectMediaCodecMapIntoHtml } = await loadHelper({}, facts);
|
||||
|
||||
const html = await injectMediaCodecMapIntoHtml(
|
||||
"<html><head></head><body></body></html>",
|
||||
tmpProject(),
|
||||
[{ html: "<html></html>" }],
|
||||
);
|
||||
|
||||
expect(html).toContain("window.__HF_MEDIA_SOURCE_FACTS__=");
|
||||
expect(html).toContain('"/videos/cam.mp4"');
|
||||
expect(html).not.toContain("__HF_MEDIA_CODEC_MAP__");
|
||||
});
|
||||
|
||||
it("leaves the page untouched when there is nothing to say about its videos", async () => {
|
||||
const { injectMediaCodecMapIntoHtml } = await loadHelper({}, {});
|
||||
const page = "<html><head></head><body></body></html>";
|
||||
|
||||
expect(await injectMediaCodecMapIntoHtml(page, tmpProject(), [{ html: "<html></html>" }])).toBe(
|
||||
page,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("injectMediaCodecMapIntoHtml pre-warm", () => {
|
||||
it("pre-warms the codec with no cross-platform decode and leaves the widely-decoded one lazy", async () => {
|
||||
const projectDir = tmpProject();
|
||||
|
||||
@@ -6,10 +6,10 @@ import {
|
||||
createMediaCodecProbeCache,
|
||||
proxyVariantFor,
|
||||
recordProxyPrewarm,
|
||||
scanProjectMediaCodecMap,
|
||||
scanProjectMedia,
|
||||
shouldPrewarmProxy,
|
||||
type HtmlSourceLike,
|
||||
type MediaCodecMap,
|
||||
type ProjectMediaScan,
|
||||
type MediaCodecProbeCache,
|
||||
} from "./mediaCodecMap.js";
|
||||
import { resolveProxy, PROXY_PARAMS_VERSION } from "./proxyTranscoder.js";
|
||||
@@ -97,18 +97,16 @@ export async function injectMediaCodecMapIntoHtml(
|
||||
signal?: AbortSignal,
|
||||
box?: PreviewProxyBox,
|
||||
): Promise<string> {
|
||||
let map: MediaCodecMap;
|
||||
let scan: ProjectMediaScan;
|
||||
try {
|
||||
map = await scanProjectMediaCodecMap(
|
||||
projectDir,
|
||||
htmlSources,
|
||||
probeCache ? { cache: probeCache } : {},
|
||||
);
|
||||
scan = await scanProjectMedia(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;
|
||||
const { codecMap: map, sourceFacts } = scan;
|
||||
const hasMap = Object.keys(map).length > 0;
|
||||
if (!hasMap && Object.keys(sourceFacts).length === 0) return html;
|
||||
for (const [rootRelativePathname, facts] of Object.entries(map)) {
|
||||
if (!prewarm || !shouldPrewarmProxy(facts)) continue;
|
||||
recordProxyPrewarm();
|
||||
@@ -125,11 +123,17 @@ export async function injectMediaCodecMapIntoHtml(
|
||||
}
|
||||
// <-escape prevents a src path containing "</script>" from breaking out of
|
||||
// the injected tag, mirroring injectPreviewVariables in helpers/previewVariables.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>`;
|
||||
const safeJson = (value: unknown): string =>
|
||||
JSON.stringify(value)
|
||||
.replace(/</g, "\\u003c")
|
||||
.replace(/\u2028/g, "\\u2028")
|
||||
.replace(/\u2029/g, "\\u2029");
|
||||
// An empty map stays out: the runtime reads a present map as leave to rescue unlisted files.
|
||||
const assignments = [
|
||||
...(hasMap ? [`window.__HF_MEDIA_CODEC_MAP__=${safeJson(map)};`] : []),
|
||||
`window.__HF_MEDIA_SOURCE_FACTS__=${safeJson(sourceFacts)};`,
|
||||
];
|
||||
const tag = `<script data-hf-media-codec-map>${assignments.join("")}</script>`;
|
||||
return insertBeforeCloseTag(html, "head", `${tag}\n`) ?? `${tag}\n${html}`;
|
||||
}
|
||||
|
||||
|
||||
@@ -451,6 +451,22 @@ describe("resolveProxy", () => {
|
||||
await expect(resultPromise).resolves.toBe(vp8Path);
|
||||
});
|
||||
|
||||
it("puts a keyframe every second into a copy, so scrubbing lands close", async () => {
|
||||
const { spawn, calls } = createSpawnSpy();
|
||||
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH);
|
||||
const projectDir = tmpProject();
|
||||
const sourcePath = join(projectDir, "clip.mov");
|
||||
writeFileSync(sourcePath, "source-bytes");
|
||||
|
||||
const result = resolveProxy(projectDir, sourcePath);
|
||||
await flush();
|
||||
|
||||
const args = calls[0]!.args;
|
||||
expect(args[args.indexOf("-force_key_frames") + 1]).toBe("expr:gte(t,n_forced*1)");
|
||||
succeed(calls[0]!);
|
||||
await result;
|
||||
});
|
||||
|
||||
it("decodes a VP9 alpha source with libvpx-vp9 so the VP8 proxy keeps its alpha", async () => {
|
||||
const { spawn, calls } = createSpawnSpy();
|
||||
const { resolveProxy } = await loadModule(spawn, FFMPEG_PATH, false, null, "vp9");
|
||||
|
||||
@@ -35,7 +35,7 @@ import { mkdirWithinProject, realpath, realProjectRoot } from "./safePath.js";
|
||||
* dropped from the queue or its ffmpeg is killed.
|
||||
*/
|
||||
|
||||
export const PROXY_PARAMS_VERSION = "v6";
|
||||
export const PROXY_PARAMS_VERSION = "v7";
|
||||
|
||||
export const CACHE_DIR_NAME = ".transcode-cache";
|
||||
|
||||
@@ -595,6 +595,7 @@ async function encodeInChunks(
|
||||
for (const piece of pieces) if (existsSync(piece)) unlinkSync(piece);
|
||||
}
|
||||
|
||||
const KEYFRAME_EVERY_SECOND_ARGS = ["-force_key_frames", "expr:gte(t,n_forced*1)"];
|
||||
// Fixed recipe; changing either array means bumping PROXY_PARAMS_VERSION.
|
||||
const H264_ARGS = [
|
||||
"-c:v",
|
||||
@@ -717,6 +718,7 @@ async function runFfmpeg(
|
||||
...(range ? ["-t", String(range.lengthSeconds)] : []),
|
||||
"-vf",
|
||||
videoFilter,
|
||||
...KEYFRAME_EVERY_SECOND_ARGS,
|
||||
...(keepsAlpha ? [...VP8_ARGS, ...vp8Threads(range ? gate.size : 1)] : H264_ARGS),
|
||||
target,
|
||||
],
|
||||
|
||||
@@ -1390,6 +1390,7 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
||||
browserHostile: boolean;
|
||||
representativeMime: string | null;
|
||||
hasAlpha: boolean;
|
||||
source?: { width: number; height: number; bitDepth: number; bitrate: number };
|
||||
} | null>;
|
||||
}): Promise<typeof import("./preview.js")> {
|
||||
vi.resetModules();
|
||||
@@ -1424,7 +1425,10 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
||||
...(await vi.importActual<typeof import("../helpers/mediaCodecMap.js")>(
|
||||
"../helpers/mediaCodecMap.js",
|
||||
)),
|
||||
scanProjectMediaCodecMap: opts.scanMapImpl ?? (async () => ({})),
|
||||
scanProjectMedia: async (...args: Parameters<ScanMapImpl>) => ({
|
||||
codecMap: await (opts.scanMapImpl ?? (async () => ({})))(...args),
|
||||
sourceFacts: {},
|
||||
}),
|
||||
createMediaCodecProbeCache: () => new Map(),
|
||||
probeAssetCodec:
|
||||
opts.probeAssetCodecImpl ??
|
||||
@@ -1434,18 +1438,6 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
||||
representativeMime: null,
|
||||
hasAlpha: false,
|
||||
})),
|
||||
decideMediaProxyEligibility: (
|
||||
facts: {
|
||||
browserHostile: boolean;
|
||||
hasAlpha: boolean;
|
||||
} | null,
|
||||
) => {
|
||||
if (!facts) return { eligible: false, reason: "unknown_codec" };
|
||||
if (!facts.browserHostile) {
|
||||
return { eligible: false, reason: "browser_safe_codec" };
|
||||
}
|
||||
return { eligible: true };
|
||||
},
|
||||
isProxyVariant: (value: string) => value === "h264" || value === "vp8",
|
||||
isProxyVariantRequest: (value: string) =>
|
||||
value === "auto" || value === "h264" || value === "vp8",
|
||||
@@ -1578,9 +1570,72 @@ describe("hf-proxy negotiation and media codec map injection (U3)", () => {
|
||||
expect(sized.headers.get("ETag")).not.toBe(full.headers.get("ETag"));
|
||||
});
|
||||
|
||||
describe("a lighter copy of a file Chrome can play", () => {
|
||||
const playable = (width: number, height: number) => async () => ({
|
||||
codecName: "h264",
|
||||
browserHostile: false,
|
||||
representativeMime: null,
|
||||
hasAlpha: false,
|
||||
source: { width, height, bitDepth: 8, bitrate: 10_000_000 },
|
||||
});
|
||||
|
||||
it("is made at the asked box when the file is heavy there", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(join(projectDir, "clip.mp4"), "original-bytes");
|
||||
const resolveProxyMock = vi.fn(async () => {
|
||||
const proxyPath = join(projectDir, "light.mp4");
|
||||
writeFileSync(proxyPath, "light-bytes");
|
||||
return proxyPath;
|
||||
});
|
||||
const { registerPreviewRoutes: register } = await loadPreviewModule({
|
||||
resolveProxyImpl: resolveProxyMock,
|
||||
probeAssetCodecImpl: playable(3840, 2160),
|
||||
});
|
||||
const app = new Hono();
|
||||
register(app, createAdapter(projectDir));
|
||||
|
||||
const res = await app.request(
|
||||
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264&hf-proxy-light=1&hf-proxy-box=724x362",
|
||||
);
|
||||
|
||||
expect(res.status).toBe(200);
|
||||
expect(await res.text()).toBe("light-bytes");
|
||||
expect(resolveProxyMock).toHaveBeenCalledWith(
|
||||
projectDir,
|
||||
join(projectDir, "clip.mp4"),
|
||||
"h264",
|
||||
{ width: 724, height: 362 },
|
||||
);
|
||||
});
|
||||
|
||||
it("is refused when the file is not heavy at that box, and without the ask", async () => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(join(projectDir, "clip.mp4"), "original-bytes");
|
||||
const resolveProxyMock = vi.fn(async () => "should-not-be-called");
|
||||
const { registerPreviewRoutes: register } = await loadPreviewModule({
|
||||
resolveProxyImpl: resolveProxyMock,
|
||||
probeAssetCodecImpl: playable(1920, 1080),
|
||||
});
|
||||
const app = new Hono();
|
||||
register(app, createAdapter(projectDir));
|
||||
|
||||
const small = await app.request(
|
||||
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264&hf-proxy-light=1&hf-proxy-box=2048x1218",
|
||||
);
|
||||
const unasked = await app.request(
|
||||
"http://localhost/projects/demo/preview/clip.mp4?hf-proxy=h264&hf-proxy-box=724x362",
|
||||
);
|
||||
|
||||
expect(small.status).toBe(422);
|
||||
expect(unasked.status).toBe(422);
|
||||
expect(resolveProxyMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["a size off the ladder", "?hf-proxy=h264&hf-proxy-box=1000x2000"],
|
||||
["a size without a copy", "?hf-proxy-box=1448x2048"],
|
||||
["a lighter copy without a size", "?hf-proxy=h264&hf-proxy-light=1"],
|
||||
])("refuses %s before making anything", async (_name, query) => {
|
||||
const projectDir = createProjectDir();
|
||||
writeFileSync(join(projectDir, "clip.mp4"), "original-hevc-bytes");
|
||||
|
||||
@@ -18,7 +18,11 @@ import {
|
||||
STUDIO_PREVIEW_MARK_META,
|
||||
STUDIO_PREVIEW_ERRORS,
|
||||
} from "@hyperframes/core/studio-preview-mark";
|
||||
import { PREVIEW_PROXY_BOX_PARAM, parsePreviewProxyBox } from "@hyperframes/core";
|
||||
import {
|
||||
PREVIEW_PROXY_BOX_PARAM,
|
||||
PREVIEW_PROXY_LIGHT_PARAM,
|
||||
parsePreviewProxyBox,
|
||||
} from "@hyperframes/core";
|
||||
import {
|
||||
GSAP_BASE_URL_META,
|
||||
gsapCdnDist,
|
||||
@@ -697,7 +701,12 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
const proxyParam = c.req.query("hf-proxy");
|
||||
const boxParam = c.req.query(PREVIEW_PROXY_BOX_PARAM);
|
||||
const proxyBox = boxParam === undefined ? undefined : parsePreviewProxyBox(boxParam);
|
||||
if (proxyBox === null || (proxyBox && proxyParam === undefined)) {
|
||||
const lightRequested = c.req.query(PREVIEW_PROXY_LIGHT_PARAM) !== undefined;
|
||||
if (
|
||||
proxyBox === null ||
|
||||
(proxyBox && proxyParam === undefined) ||
|
||||
(lightRequested && !proxyBox)
|
||||
) {
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
let proxyVariant: ProxyVariant | undefined;
|
||||
@@ -710,7 +719,7 @@ export function registerPreviewRoutes(api: Hono, adapter: PreviewApiAdapter): vo
|
||||
return c.text("not found", 404);
|
||||
}
|
||||
const facts = await probeAssetCodec(file, undefined, mediaCodecProbeCache);
|
||||
const eligibility = decideMediaProxyEligibility(facts);
|
||||
const eligibility = decideMediaProxyEligibility(facts, lightRequested ? proxyBox : undefined);
|
||||
if (!eligibility.eligible) {
|
||||
return c.text(`media proxy unavailable: ${eligibility.reason}`, 422);
|
||||
}
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
"unpacked": 541601
|
||||
},
|
||||
"@hyperframes/producer": {
|
||||
"packed": 781312,
|
||||
"packed": 828932,
|
||||
"unpacked": 2840135
|
||||
},
|
||||
"@hyperframes/sdk": {
|
||||
|
||||
Reference in New Issue
Block a user