Files
hyperframes/packages/studio-server/src/helpers/mediaCodecMap.ts
T
Miguel Ángel d65eefad5e feat(cli): init --keep-original copies a clip as is, without transcoding (#5612)
* feat(cli): init --keep-original copies a clip as is, without transcoding

`hyperframes init --keep-original` copies the --video file under its own
name and wires it into the project the way a web-safe clip is wired today:
no transcode and no prompt, interactive or not. Default behaviour is unchanged.

Theora leaves init's web-safe codecs and joins the preview proxy's
browser-hostile codecs, so init converts it and the preview proxy serves an
H.264 copy instead of a blank picture.

* fix(studio-server): keep the codec table's comment share at its base level
2026-10-14 18:19:17 +00:00

387 lines
15 KiB
TypeScript

import { scanHtmlOpeningTags, decodeAuthoredAttribute } from "@hyperframes/parsers";
import { statSync } from "node:fs";
import { realpath } from "./safePath.js";
import { sep } from "node:path";
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
import {
cleanAssetUrl,
isRemoteOrInlineUrl,
isUnresolvedAssetPlaceholder,
resolveExistingLocalAsset as resolveAsset,
} from "@hyperframes/parsers/asset-resolution";
import { pixelFormatHasAlpha, probeMediaMetadata, type FfprobeRunner } from "./mediaMetadata.js";
/**
* One reusable answer to "what codec is this asset, and is it browser-hostile?",
* built on top of `mediaMetadata.ts`'s ffprobe-backed prober so studio-server
* probes each asset once instead of running a second prober.
*/
export interface AssetCodecFacts {
codecName: string;
browserHostile: boolean;
/** Coarse `canPlayType()` input; `null` when not applicable (safe codec) or
* when no representative mime exists (ProRes: browsers never decode it, so
* the runtime always proxies rather than probing `canPlayType`). */
representativeMime: string | null;
/** Source carries an alpha channel (ffprobe pix_fmt). Alpha sources use a
* VP8/WebM proxy so their transparency is preserved across Chromium builds. */
hasAlpha: boolean;
}
/** Server-root-relative URL pathname -> that asset's codec facts. */
export type MediaCodecMap = Record<string, AssetCodecFacts>;
export interface BrowserHostileCodec {
/** Coarse `canPlayType()` input; `null` when no representative mime exists
* (ProRes: browsers never decode it, so the runtime always proxies rather
* than probing `canPlayType`). */
representativeMime: string | null;
/**
* Whether the server may transcode before any browser asks. True only where
* no cross-platform decode exists, so some client is sure to need the
* substitute; false where the first `?hf-proxy=` request can do it lazily.
*/
prewarm: boolean;
}
/**
* Browser-hostile codec table v1. One exported constant so extending it is a
* one-line change. `ffprobe` cannot emit exact RFC 6381 codec strings, so
* these `representativeMime` values are deliberately coarse (a false
* positive costs one proxy transcode, never correctness; a false negative is
* rescued by the runtime's reactive zero-videoWidth swap).
*/
export const BROWSER_HOSTILE_CODECS: Record<string, BrowserHostileCodec> = {
// HEVC decode is platform-bound, not absent: macOS Chrome answers
// canPlayType with "probably" and keeps the source, while Chrome on
// Windows/Linux and Firefox everywhere need the substitute.
hevc: { representativeMime: 'video/mp4; codecs="hvc1.1.6.L120.B0"', prewarm: true },
prores: { representativeMime: null, prewarm: true },
theora: { representativeMime: 'video/ogg; codecs="theora"', prewarm: true },
// Every mainstream engine decodes AV1 and VP9, so canPlayType keeps the
// original and only the rare browser that cannot pays for a transcode.
av1: { representativeMime: 'video/mp4; codecs="av01.0.08M.08"', prewarm: false },
vp9: { representativeMime: 'video/webm; codecs="vp09.00.10.08"', prewarm: false },
};
/** `Object.hasOwn` rather than a bare index: a codec named `constructor` or
* `toString` would otherwise resolve against `Object.prototype`. */
function hostileCodecEntry(codecName: string): BrowserHostileCodec | undefined {
return Object.hasOwn(BROWSER_HOSTILE_CODECS, codecName)
? BROWSER_HOSTILE_CODECS[codecName]
: undefined;
}
/** The pre-warm gate: true only for codecs with no cross-platform browser
* decode, so some client will ask. See `BrowserHostileCodec.prewarm`. */
export function shouldPrewarmProxy(facts: AssetCodecFacts): boolean {
return hostileCodecEntry(facts.codecName)?.prewarm === true;
}
// Per process, and deliberately in one unit — a call to `resolveProxy` — so
// the pair reads as a ratio. Summarised at exit on stderr, in the same
// `[hyperframes:<area>] {json}` shape as `writeUrlDownloadTelemetry`.
const proxyDemand = { prewarmsRequested: 0, proxyRequests: 0 };
/** Snapshot of this process's pre-warm demand counters. */
export function mediaProxyDemand(): { prewarmsRequested: number; proxyRequests: number } {
return { ...proxyDemand };
}
function writeDemandLine(event: "prewarm_requested" | "summary"): void {
try {
process.stderr.write(
`[hyperframes:media-proxy] ${JSON.stringify({ event, ...proxyDemand })}\n`,
);
} catch {
// Observability must never change proxy correctness.
}
}
/** Mirrors `isGpuProbeDebugEnabled` in packages/engine/src/utils/gpuEncoder.ts. */
function isMediaProxyDebugEnabled(): boolean {
const value = process.env.HYPERFRAMES_DEBUG_MEDIA_PROXY;
return value === "1" || value === "true";
}
// Registered on the first pre-warm rather than at import, so a process that
// never pre-warms adds no handler and prints nothing. Sync-only, mirroring the
// `process.on("exit")` shutdown hooks in packages/cli/src/cli.ts:331 and
// packages/cli/src/commands/preview.ts:1329.
let summaryHookInstalled = false;
/**
* One proxy asked for before any browser wanted it. "Requested", not
* "started": a warm cache makes `resolveProxy` a no-op and this counter cannot
* see that, so it is an upper bound on transcodes, not a measure of CPU. The
* number it does answer exactly is the one that decides policy — a nonzero
* count beside `proxyRequests: 0` means nothing ever redeemed the pre-warm.
*
* The per-asset line is debug-only: a composition with fifty hostile clips
* would otherwise print fifty JSON lines into a clack-formatted terminal on
* every re-render. The exit summary carries the same numbers unconditionally.
*/
export function recordProxyPrewarm(): void {
proxyDemand.prewarmsRequested++;
if (!summaryHookInstalled) {
summaryHookInstalled = true;
process.once("exit", () => writeDemandLine("summary"));
}
if (isMediaProxyDebugEnabled()) writeDemandLine("prewarm_requested");
}
/** One proxy resolved for a browser that asked, counted on the path that calls
* `resolveProxy` so it shares a unit with `prewarmsRequested`. A 304 does not
* count; an unconditional Range refill still does, so read it as zero versus
* nonzero. Never logged per event, only in the exit summary. */
export function recordProxyRequest(): void {
proxyDemand.proxyRequests++;
}
export type ProxyVariant = "h264" | "vp8";
export type ProxyVariantRequest = ProxyVariant | "auto";
export const PROXY_VARIANT_CONFIG: Record<
ProxyVariant,
{ extension: ".mp4" | ".webm"; contentType: "video/mp4" | "video/webm" }
> = {
h264: { extension: ".mp4", contentType: "video/mp4" },
vp8: { extension: ".webm", contentType: "video/webm" },
};
export function isProxyVariant(value: string): value is ProxyVariant {
return Object.hasOwn(PROXY_VARIANT_CONFIG, value);
}
export function isProxyVariantRequest(value: string): value is ProxyVariantRequest {
return value === "auto" || isProxyVariant(value);
}
export function proxyVariantFor(facts: AssetCodecFacts): ProxyVariant {
return facts.hasAlpha ? "vp8" : "h264";
}
export function resolveProxyVariantRequest(
request: ProxyVariantRequest,
facts: AssetCodecFacts,
): ProxyVariant | null {
const expected = proxyVariantFor(facts);
return request === "auto" || request === expected ? expected : null;
}
export type MediaProxyIneligibilityReason = "browser_safe_codec" | "unknown_codec";
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 {
if (!facts) return { eligible: false, reason: "unknown_codec" };
if (!facts.browserHostile) return { eligible: false, reason: "browser_safe_codec" };
return { eligible: true };
}
function codecFactsFor(codecName: string, hasAlpha: boolean): AssetCodecFacts {
const hostile = hostileCodecEntry(codecName);
return {
codecName,
browserHostile: hostile !== undefined,
representativeMime: hostile?.representativeMime ?? null,
hasAlpha,
};
}
/** `undefined` when ffprobe itself failed: no answer about the file, so nothing to cache. */
async function probeCodecFacts(
filePath: string,
runner?: FfprobeRunner,
): Promise<AssetCodecFacts | null | undefined> {
const metadata = runner
? await probeMediaMetadata(filePath, runner)
: await probeMediaMetadata(filePath);
if (metadata.probeError) return undefined;
if (metadata.kind !== "video") return null;
const codecName = metadata.color.codecName;
if (!codecName) return null;
return codecFactsFor(codecName, pixelFormatHasAlpha(metadata.color.pixelFormat));
}
interface CachedAssetProbe {
/** The file a symlinked path pointed at, so retargeting the link is a miss. */
target: string;
mtimeMs: number;
size: number;
facts: AssetCodecFacts | null;
}
/** Per (path, mtime) probe cache. Construct one per project/server lifetime
* and reuse it across scans; a fresh instance defeats the caching benefit. */
export type MediaCodecProbeCache = Map<string, CachedAssetProbe>;
export function createMediaCodecProbeCache(): MediaCodecProbeCache {
return new Map();
}
// Shared by every route and scan in this process that does not pass its own cache.
const defaultProbeCache: MediaCodecProbeCache = new Map();
const MAX_PROBE_CACHE_ENTRIES = 512;
function rememberProbeResult(
cache: MediaCodecProbeCache,
filePath: string,
result: CachedAssetProbe,
): void {
if (!cache.has(filePath) && cache.size >= MAX_PROBE_CACHE_ENTRIES) {
const oldest = cache.keys().next().value;
if (oldest) cache.delete(oldest);
}
// Refresh insertion order so frequently used assets remain resident.
cache.delete(filePath);
cache.set(filePath, result);
}
/**
* Codec facts for one video, cached per path until its target, mtime or size changes; a hit skips
* `runner`. Never throws: no video stream or a failed probe gives `null`, and a failed probe is not cached.
*/
export async function probeAssetCodec(
filePath: string,
runner?: FfprobeRunner,
cache: MediaCodecProbeCache = defaultProbeCache,
): Promise<AssetCodecFacts | null> {
let stat: ReturnType<typeof statSync>;
let target: string;
try {
stat = statSync(filePath);
target = realpath(filePath);
} catch {
return null;
}
const cached = cache.get(filePath);
if (
cached &&
cached.target === target &&
cached.mtimeMs === stat.mtimeMs &&
cached.size === stat.size
) {
rememberProbeResult(cache, filePath, cached);
return cached.facts;
}
const facts = await probeCodecFacts(filePath, runner);
if (facts === undefined) return null;
rememberProbeResult(cache, filePath, { target, mtimeMs: stat.mtimeMs, size: stat.size, facts });
return facts;
}
/** Structurally compatible with `packages/lint/src/hevcPreviewLint.ts`'s
* (unexported) `HtmlSourceLike`. */
export interface HtmlSourceLike {
html: string;
compSrcPath?: string;
}
/**
* Resolve a `<video src>` reference to an existing local file.
*
* `rootRelativePathname` is the map key format PINNED by this plan's Key
* Technical Decisions: project-root-relative URL pathname, percent-decoded,
* query-string-stripped, forward-slash separated, leading-slash prefixed
* (e.g. "/assets/videos/clip.mp4"). This must match what the runtime derives
* via `new URL(el.currentSrc || el.src, document.baseURI).pathname`, because
* server-side scanning resolves filesystem paths while the DOM sees served
* URLs — a documented prior source of this exact class of bug.
*/
function resolveExistingLocalAsset(
projectDir: string,
url: string,
): { resolvedPath: string; rootRelativePathname: string } | null {
const asset = resolveAsset(projectDir, url);
if (!asset) return null;
return {
resolvedPath: asset.resolved,
rootRelativePathname: `/${asset.rootRelativePath.split(sep).join("/")}`,
};
}
/**
* Collects local `<video src>` references, resolved to their absolute path
* and deduped by that path, keyed by the pinned root-relative URL pathname.
*/
// fallow-ignore-next-line complexity
function collectLocalVideoAssets(
projectDir: string,
htmlSources: HtmlSourceLike[],
): Map<string, string> {
const candidates = new Map<string, string>();
for (const { html, compSrcPath } of htmlSources) {
for (const tag of scanHtmlOpeningTags(html)) {
if (tag.name !== "video" || !tag.closed) continue;
const attribute = tag.attributes.find((attr) => attr.name === "src");
if (attribute?.kind !== "value") continue;
const rawSrc = decodeAuthoredAttribute(attribute.value);
// Placeholder check runs on the RAW value: cleanAssetUrl() splits on ?/# and would chop inside a ${...} token.
if (isUnresolvedAssetPlaceholder(rawSrc)) continue;
const src = cleanAssetUrl(rawSrc);
if (!src || isRemoteOrInlineUrl(src)) continue;
const rootRelativeSrc = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
const resolved = resolveExistingLocalAsset(projectDir, rootRelativeSrc);
if (!resolved) continue;
candidates.set(resolved.resolvedPath, resolved.rootRelativePathname);
}
}
return candidates;
}
// Bounds concurrent ffprobe child processes for projects referencing many
// videos, mirroring `PROBE_CONCURRENCY` in `hevcPreviewLint.ts`.
const PROBE_CONCURRENCY = 8;
export interface ScanProjectMediaCodecMapOptions {
/** Persisted across calls by the caller for the mtime-cache benefit;
* defaults to a shared module-level cache when omitted. */
cache?: MediaCodecProbeCache;
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(
projectDir: string,
htmlSources: HtmlSourceLike[],
options: ScanProjectMediaCodecMapOptions = {},
): Promise<MediaCodecMap> {
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);
let nextIndex = 0;
const workerCount = Math.min(PROBE_CONCURRENCY, entries.length);
await Promise.all(
Array.from({ length: workerCount }, async () => {
while (nextIndex < entries.length) {
const index = nextIndex++;
const entry = entries[index];
if (!entry) break;
facts[index] = await probeAssetCodec(entry[0], options.runner, cache);
}
}),
);
const map: MediaCodecMap = {};
entries.forEach(([, pathname], index) => {
const entryFacts = facts[index];
if (entryFacts?.browserHostile) map[pathname] = entryFacts;
});
return map;
}