mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-11 06:30:03 +00:00
feat(lint): flag HEVC video assets with info-level preview note
This commit is contained in:
@@ -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.",
|
||||
},
|
||||
];
|
||||
}
|
||||
Reference in New Issue
Block a user