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
+75
View File
@@ -0,0 +1,75 @@
import { existsSync } from "node:fs";
import { isAbsolute, posix, relative, resolve } from "node:path";
import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
/**
* Shared local-asset resolution helpers used by both the project-level lint
* rules (`project.ts`) and the HEVC preview-codec check
* (`hevcPreviewLint.ts`). Split out so the latter doesn't need to import from
* `project.ts` (which imports it back to run the rule) — that would be a
* circular import within the package.
*/
export function isRemoteOrInlineUrl(url: string): boolean {
return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
}
export function cleanAssetUrl(url: string): string {
return url.trim().split(/[?#]/, 1)[0] ?? "";
}
export function isWithinProjectRoot(projectDir: string, candidate: string): boolean {
const projectRoot = resolve(projectDir);
const relativePath = relative(projectRoot, candidate);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
function addCandidate(candidates: string[], candidate: string): void {
if (!candidates.includes(candidate)) candidates.push(candidate);
}
export function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
const cleanUrl = cleanAssetUrl(url);
const projectRoot = resolve(projectDir);
const candidates: string[] = [];
for (const variant of decodeUrlPathVariants(cleanUrl)) {
const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
const resolved = resolve(projectRoot, projectRelative);
if (isWithinProjectRoot(projectRoot, resolved)) {
addCandidate(candidates, resolved);
continue;
}
const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
const clamped = normalized.replace(/^(\.\.\/)+/, "");
if (clamped && !clamped.startsWith("..")) {
addCandidate(candidates, resolve(projectRoot, clamped));
}
}
return candidates;
}
export function resolveExistingLocalAsset(
projectDir: string,
url: string,
): { resolved: string; rootRelativePath: string } | null {
const projectRoot = resolve(projectDir);
const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
if (!resolved) return null;
return { resolved, rootRelativePath: relative(projectRoot, resolved) };
}
function maskRange(src: string, pattern: RegExp): string {
return src.replace(pattern, (m) => " ".repeat(m.length));
}
/** 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);
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
return out;
}
+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.",
},
];
}
+168 -1
View File
@@ -1,10 +1,17 @@
import { describe, it, expect, afterEach } from "vitest";
import { describe, it, expect, afterEach, beforeEach, vi } from "vitest";
import { ChildProcess, execFile } from "node:child_process";
import { mkdirSync, mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import type { HyperframeLintFinding } from "./types.js";
import { lintProject } from "./project.js";
// Keep project lint tests independent of the host's ffprobe installation.
vi.mock("node:child_process", () => {
const mocked = { ChildProcess: class {}, execFile: vi.fn(), execSync: vi.fn() };
return { ...mocked, default: mocked };
});
function tmpProject(name: string): string {
return mkdtempSync(join(tmpdir(), `hf-lint-test-${name}-`));
}
@@ -235,3 +242,163 @@ describe("template shell style sources", () => {
expect(findings.some((finding) => finding.code === "texture_mask_asset_not_found")).toBe(true);
});
});
describe("hevc_preview_codec", () => {
interface ProbeStream {
codec_name: string;
codec_tag_string: string;
}
const mockExecFile = vi.mocked(execFile);
// Any real file works as a stand-in "ffprobe" path — execFile itself is
// mocked below, so it's never actually spawned.
const FAKE_FFPROBE_PATH = process.execPath;
function mockFfprobeStreams(streamsByFile: Record<string, ProbeStream[]>): void {
mockExecFile.mockImplementation((_file, args, _options, callback) => {
const filePath = args[args.length - 1] ?? "";
callback(
null,
Buffer.from(JSON.stringify({ streams: streamsByFile[filePath] ?? [] })),
Buffer.alloc(0),
);
return new ChildProcess();
});
}
function videoHtml(...videoSrcs: string[]): string {
const videoTags = videoSrcs
.map(
(src, i) =>
`<video id="v${i}" class="clip" src="${src}" muted data-start="${i * 5}" data-duration="5"></video>`,
)
.join("\n ");
return `<html><body>
<div data-composition-id="main" data-width="1920" data-height="1080" data-start="0" data-duration="10">
${videoTags}
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js"></script>
<script>window.__timelines = window.__timelines || {}; window.__timelines["main"] = gsap.timeline({ paused: true });</script>
</body></html>`;
}
function makeVideoProject(
videoSrc: string,
writeVideoFile = true,
): { project: string; videoAbsPath: string } {
const project = makeProject(videoHtml(videoSrc));
const videoAbsPath = join(project, videoSrc);
if (writeVideoFile) writeFileSync(videoAbsPath, "fake video bytes");
return { project, videoAbsPath };
}
async function hevcFindings(project: string): Promise<HyperframeLintFinding[]> {
const { results } = await lintProject(project);
return results.flatMap((r) => r.result.findings).filter((f) => f.code === "hevc_preview_codec");
}
beforeEach(() => {
process.env.HYPERFRAMES_FFPROBE_PATH = FAKE_FFPROBE_PATH;
mockExecFile.mockReset();
});
afterEach(() => {
delete process.env.HYPERFRAMES_FFPROBE_PATH;
mockExecFile.mockReset();
});
it("flags an HEVC video with exactly one info finding naming the file", async () => {
const { project, videoAbsPath } = makeVideoProject("clip.mp4");
mockFfprobeStreams({
[videoAbsPath]: [{ codec_name: "hevc", codec_tag_string: "hvc1" }],
});
const result = await lintProject(project);
const findings = result.results
.flatMap((entry) => entry.result.findings)
.filter((finding) => finding.code === "hevc_preview_codec");
expect(findings).toHaveLength(1);
expect(findings[0]?.severity).toBe("info");
expect(findings[0]?.code).toBe("hevc_preview_codec");
expect(findings[0]?.message).toContain("clip.mp4");
expect(findings[0]?.message).toContain("requires a browser with HEVC support");
expect(result.totalErrors).toBe(0);
expect(result.results[0]?.result.ok).toBe(true);
});
it('flags an hev1-tagged HEVC video the same way (ffprobe reports codec_name "hevc" regardless of the container fourcc)', async () => {
const { project, videoAbsPath } = makeVideoProject("clip-hev1.mp4");
mockFfprobeStreams({
[videoAbsPath]: [{ codec_name: "hevc", codec_tag_string: "hev1" }],
});
const findings = await hevcFindings(project);
expect(findings).toHaveLength(1);
expect(findings[0]?.message).toContain("clip-hev1.mp4");
});
it("does not flag an H.264 video", async () => {
const { project, videoAbsPath } = makeVideoProject("clip.mp4");
mockFfprobeStreams({
[videoAbsPath]: [{ codec_name: "h264", codec_tag_string: "avc1" }],
});
const findings = await hevcFindings(project);
expect(findings).toHaveLength(0);
});
it("does not flag anything, and lint completes normally, when ffprobe cannot be resolved", async () => {
const { project } = makeVideoProject("clip.mp4");
process.env.HYPERFRAMES_FFPROBE_PATH = join(project, "missing-ffprobe");
const { results, totalErrors } = await lintProject(project);
const findings = results.flatMap((r) => r.result.findings);
expect(findings.some((f) => f.code === "hevc_preview_codec")).toBe(false);
expect(mockExecFile).not.toHaveBeenCalled();
expect(totalErrors).toBe(0);
});
it("silently skips the finding when ffprobe errors or times out", async () => {
const { project } = makeVideoProject("clip.mp4");
mockExecFile.mockImplementation((_file, _args, _options, callback) => {
callback(new Error("ffprobe timed out"), Buffer.alloc(0), Buffer.alloc(0));
return new ChildProcess();
});
const { results, totalErrors } = await lintProject(project);
const findings = results.flatMap((entry) => entry.result.findings);
expect(findings.some((finding) => finding.code === "hevc_preview_codec")).toBe(false);
expect(totalErrors).toBe(0);
});
it("does not probe or flag a missing video file — missing_local_asset covers it instead", async () => {
const { project } = makeVideoProject("missing.mp4", false);
const { results } = await lintProject(project);
const findings = results.flatMap((r) => r.result.findings);
expect(findings.some((f) => f.code === "hevc_preview_codec")).toBe(false);
expect(findings.some((f) => f.code === "missing_local_asset")).toBe(true);
expect(mockExecFile).not.toHaveBeenCalled();
});
it("probes the same HEVC file once when referenced twice (per-run cache)", async () => {
const project = makeProject(videoHtml("clip.mp4", "clip.mp4"));
const videoAbsPath = join(project, "clip.mp4");
writeFileSync(videoAbsPath, "fake video bytes");
mockFfprobeStreams({
[videoAbsPath]: [{ codec_name: "hevc", codec_tag_string: "hvc1" }],
});
const findings = await hevcFindings(project);
expect(findings).toHaveLength(1);
expect(mockExecFile).toHaveBeenCalledTimes(1);
});
});
+11 -64
View File
@@ -1,10 +1,18 @@
export { shouldBlockRender } from "./shouldBlockRender.js";
import { existsSync, readFileSync, readdirSync } from "node:fs";
import { dirname, extname, isAbsolute, join, posix, relative, resolve } from "node:path";
import { decodeUrlPathVariants } from "@hyperframes/parsers/composition";
import { dirname, extname, join, relative, resolve } from "node:path";
import { rewriteAssetPath } from "@hyperframes/parsers/asset-paths";
import { checkSubCompositionUsability } from "@hyperframes/parsers/sub-composition-validity";
import { parseHTML } from "linkedom";
import {
cleanAssetUrl,
isRemoteOrInlineUrl,
isWithinProjectRoot,
maskNonScannableRanges,
resolveExistingLocalAsset,
resolveLocalAssetCandidates,
} from "./assetResolution.js";
import { collectLocalVideoCandidates, lintHevcPreviewCodec } from "./hevcPreviewLint.js";
import { lintHyperframeHtml } from "./hyperframeLinter.js";
import type { HyperframeLintFinding, HyperframeLintResult } from "./types.js";
import type { ParsableDocumentLike } from "@hyperframes/parsers/sub-composition-validity";
@@ -113,57 +121,6 @@ function collectCssSources(projectDir: string, html: string, compSrcPath?: strin
return sources;
}
function isRemoteOrInlineUrl(url: string): boolean {
return /^(https?:|data:|blob:|\/\/|#)/i.test(url);
}
function cleanAssetUrl(url: string): string {
return url.trim().split(/[?#]/, 1)[0] ?? "";
}
function isWithinProjectRoot(projectDir: string, candidate: string): boolean {
const projectRoot = resolve(projectDir);
const relativePath = relative(projectRoot, candidate);
return relativePath === "" || (!relativePath.startsWith("..") && !isAbsolute(relativePath));
}
function addCandidate(candidates: string[], candidate: string): void {
if (!candidates.includes(candidate)) candidates.push(candidate);
}
function resolveLocalAssetCandidates(projectDir: string, url: string): string[] {
const cleanUrl = cleanAssetUrl(url);
const projectRoot = resolve(projectDir);
const candidates: string[] = [];
for (const variant of decodeUrlPathVariants(cleanUrl)) {
const projectRelative = variant.startsWith("/") ? variant.slice(1) : variant;
const resolved = resolve(projectRoot, projectRelative);
if (isWithinProjectRoot(projectRoot, resolved)) {
addCandidate(candidates, resolved);
continue;
}
const normalized = posix.normalize(projectRelative.replace(/\\/g, "/"));
const clamped = normalized.replace(/^(\.\.\/)+/, "");
if (clamped && !clamped.startsWith("..")) {
addCandidate(candidates, resolve(projectRoot, clamped));
}
}
return candidates;
}
function resolveExistingLocalAsset(
projectDir: string,
url: string,
): { resolved: string; rootRelativePath: string } | null {
const projectRoot = resolve(projectDir);
const resolved = resolveLocalAssetCandidates(projectRoot, url).find(existsSync);
if (!resolved) return null;
return { resolved, rootRelativePath: relative(projectRoot, resolved) };
}
function resolveCssAssetCandidates(
projectDir: string,
url: string,
@@ -255,6 +212,7 @@ export async function lintProject(
...(!entryFile ? lintMultipleRootCompositions(projectDir) : []),
...lintDuplicateAudioTracks(allHtmlSources),
...lintMissingOrEmptySubComposition(projectDir, rootHtml),
...(await lintHevcPreviewCodec(collectLocalVideoCandidates(projectDir, allHtmlSources))),
];
if (projectFindings.length > 0) {
for (const finding of projectFindings) {
@@ -348,17 +306,6 @@ function lintAudioSrcNotFound(
return findings;
}
function maskRange(src: string, pattern: RegExp): string {
return src.replace(pattern, (m) => " ".repeat(m.length));
}
function maskNonScannableRanges(html: string): string {
let out = maskRange(html, /<!--[\s\S]*?-->/g);
out = maskRange(out, /<style\b[^>]*>[\s\S]*?<\/style\b[^>]*>/gi);
out = maskRange(out, /<script\b[^>]*>[\s\S]*?<\/script\b[^>]*>/gi);
return out;
}
// fallow-ignore-next-line complexity
function lintMissingLocalAsset(
projectDir: string,