feat(lint): flag HEVC video assets with info-level preview note

This commit is contained in:
Miguel Angel Simon Sierra
2026-07-16 18:36:12 -04:00
parent 5f2819b1e7
commit 664c39db9e
4 changed files with 456 additions and 65 deletions
+202
View File
@@ -0,0 +1,202 @@
// fallow-ignore-file code-duplication
import { execFile, execSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
import {
cleanAssetUrl,
isRemoteOrInlineUrl,
maskNonScannableRanges,
resolveExistingLocalAsset,
} from "./assetResolution.js";
import type { HyperframeLintFinding } from "./types.js";
/** Structurally compatible with `project.ts`'s (unexported) `HtmlSource` —
* duplicated as a shape, not imported, to avoid a circular import between
* this file and `project.ts` (which imports `lintHevcPreviewCodec` below). */
interface HtmlSourceLike {
html: string;
compSrcPath?: string;
}
const FFPROBE_PATH_ENV = "HYPERFRAMES_FFPROBE_PATH";
const PROBE_TIMEOUT_MS = 4000;
// Minimal PATH/env-based ffprobe resolution, duplicated from
// packages/cli/src/browser/ffmpeg.ts (findFFprobe). packages/lint must not
// depend on packages/cli (the CLI depends on @hyperframes/lint, which would
// create an import cycle) or packages/engine (too heavy for a lint check),
// so only the ffprobe-lookup half is re-implemented here — no ffmpeg lookup,
// no install-hint text, no Linux-distro detection.
function chooseBestFfprobeCandidate(candidates: string[]): string | undefined {
const normalized = candidates.map((s) => s.trim()).filter(Boolean);
if (normalized.length === 0) return undefined;
const preferredExe = normalized.find((c) => c.toLowerCase().endsWith("ffprobe.exe"));
if (preferredExe) return preferredExe;
const exact = normalized.find((c) => c.toLowerCase().endsWith("ffprobe"));
if (exact) return exact;
const nonShellShim = normalized.find((c) => {
const lower = c.toLowerCase();
return !lower.endsWith(".cmd") && !lower.endsWith(".bat");
});
return nonShellShim ?? normalized[0];
}
function findFFprobeOnPath(): string | undefined {
try {
const cmd = process.platform === "win32" ? "where ffprobe" : "which ffprobe";
const output = execSync(cmd, {
encoding: "utf-8",
stdio: ["pipe", "pipe", "pipe"],
timeout: 5000,
});
const candidate = chooseBestFfprobeCandidate(output.split(/\r?\n/));
return candidate ? resolve(candidate) : undefined;
} catch {
return undefined;
}
}
const COMMON_BIN_DIRS =
process.platform === "win32"
? []
: ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/snap/bin"];
function findFFprobeInCommonDirs(): string | undefined {
for (const dir of COMMON_BIN_DIRS) {
const candidate = `${dir}/ffprobe`;
if (existsSync(candidate)) return candidate;
}
return undefined;
}
function findFFprobe(): string | undefined {
const configured = process.env[FFPROBE_PATH_ENV]?.trim();
if (configured) return existsSync(configured) ? resolve(configured) : undefined;
return findFFprobeOnPath() ?? findFFprobeInCommonDirs();
}
function execFileAsync(file: string, args: string[]): Promise<string> {
return new Promise((resolvePromise, reject) => {
execFile(file, args, { timeout: PROBE_TIMEOUT_MS }, (error, stdout) => {
if (error) reject(error);
else resolvePromise(stdout.toString());
});
});
}
function hasHevcStream(json: unknown): boolean {
if (typeof json !== "object" || json === null) return false;
const streams = Reflect.get(json, "streams");
if (!Array.isArray(streams)) return false;
return streams.some((stream) => {
if (typeof stream !== "object" || stream === null) return false;
return Reflect.get(stream, "codec_name") === "hevc";
});
}
// Best-effort: any failure (ffprobe missing, times out, non-video file,
// unparsable output) resolves to "not HEVC" rather than throwing. This rule
// must never fail lint/check just because ffprobe isn't installed.
async function probeIsHevc(ffprobePath: string, filePath: string): Promise<boolean> {
try {
const stdout = await execFileAsync(ffprobePath, [
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=codec_name",
"-of",
"json",
filePath,
]);
return hasHevcStream(JSON.parse(stdout));
} catch {
return false;
}
}
/**
* Collects local `<video src>` references, resolved to their absolute path
* and deduped by that path — this is both the candidate set AND the in-run
* probe cache for `lintHevcPreviewCodec` below: the same file referenced
* twice only ends up as one map entry, so it's only probed once.
*
* Files that don't resolve to an existing local asset are skipped here —
* `missing_local_asset` already reports those, and hevc_preview_codec never
* probes a file that doesn't exist.
*/
// fallow-ignore-next-line complexity
export function collectLocalVideoCandidates(
projectDir: string,
htmlSources: HtmlSourceLike[],
): Map<string, string> {
const candidates = new Map<string, string>();
const videoSrcRe = /<video\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/gi;
for (const { html, compSrcPath } of htmlSources) {
const scannable = maskNonScannableRanges(html);
const re = new RegExp(videoSrcRe.source, videoSrcRe.flags);
let match: RegExpExecArray | null;
while ((match = re.exec(scannable)) !== null) {
const src = cleanAssetUrl(match[1] ?? "");
if (!src) continue;
if (isRemoteOrInlineUrl(src)) continue;
if (/^__[A-Z_]+__$/.test(src)) continue;
const rootRelative = compSrcPath ? rewriteAssetPath(compSrcPath, src) : src;
const resolvedAsset = resolveExistingLocalAsset(projectDir, rootRelative);
if (!resolvedAsset) continue;
if (!candidates.has(resolvedAsset.resolved)) candidates.set(resolvedAsset.resolved, src);
}
}
return candidates;
}
/**
* INFO-only finding: a locally referenced `<video>` file is encoded as
* HEVC/H.265. The render pipeline pre-decodes video with FFmpeg (never the
* browser decoder) so rendering is unaffected, but live preview and the
* embeddable player play the file directly in-browser, where HEVC support
* varies. Never escalated beyond "info" — this must not fail lint or check.
*
* `candidates` maps each unique resolved file path to a display src string
* (already deduped by the caller, so each file is probed exactly once here);
* files missing from disk are the caller's responsibility to have excluded —
* `missing_local_asset` covers those and this rule never probes them.
*/
export async function lintHevcPreviewCodec(
candidates: Map<string, string>,
): Promise<HyperframeLintFinding[]> {
if (candidates.size === 0) return [];
const ffprobePath = findFFprobe();
if (!ffprobePath) return [];
const entries = [...candidates.entries()];
const isHevc = await Promise.all(
entries.map(([resolvedPath]) => probeIsHevc(ffprobePath, resolvedPath)),
);
const hevcSrcs = entries.filter((_, i) => isHevc[i]).map(([, src]) => src);
if (hevcSrcs.length === 0) return [];
const unique = [...new Set(hevcSrcs)];
return [
{
code: "hevc_preview_codec",
severity: "info",
message:
`Video file(s) use the HEVC/H.265 codec: ${unique.join(", ")}. ` +
"The render pipeline pre-decodes video with FFmpeg and never uses the browser's video decoder, so these render correctly. " +
"Live preview/player playback requires a browser with HEVC support. " +
"If preview playback fails, generate an H.264 proxy (e.g. via the media-use skill) and reference that instead.",
fixHint:
unique.length === 1
? `Generate an H.264 proxy for "${unique[0]}" (e.g. via the media-use skill) if it fails to play in preview.`
: "Generate H.264 proxies for these files (e.g. via the media-use skill) if they fail to play in preview.",
},
];
}