mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2597 from heygen-com/carve/hevc-base-docs-lint
feat(lint): warn on HEVC video assets that browsers cannot preview
This commit is contained in:
@@ -157,6 +157,19 @@ npx hyperframes render --video-bitrate 10M --output controlled.mp4
|
||||
|
||||
**Tip**: The default `standard` preset (CRF 18) is visually lossless at 1080p — most people cannot distinguish it from the source. Use `--quality draft` for faster iteration, or `--quality high` / `--crf 10` when file size is no concern.
|
||||
|
||||
## Input Video Codecs
|
||||
|
||||
Video assets referenced by a composition (a `<video src="...">` clip) are decoded by FFmpeg, not by the browser: the pipeline pre-extracts every input video into frame images and injects them during capture, so the render never depends on what the capture browser can play. Any codec your FFmpeg build decodes works as an input, including:
|
||||
|
||||
- H.264 / AVC
|
||||
- **HEVC / H.265, 8-bit and 10-bit** (`hvc1` and `hev1`): common for storage-optimized asset libraries; renders identically on macOS and Linux, no hardware decoder required
|
||||
- VP8 / VP9 and ProRes 4444 (with alpha; see [Transparent Video](#transparent-video))
|
||||
- HDR sources (HLG / PQ) are tone-mapped for SDR renders; see the [HDR guide](/guides/hdr)
|
||||
|
||||
The one caveat is **live preview**: `preview`, `play`, Studio, and published player pages play the file in a real browser, so playback there depends on that browser's codec support. Chrome (107+), Edge, and Safari hardware-decode HEVC on most modern machines; Firefox does not. If an HEVC asset shows a black frame in preview while rendering fine, generate an H.264 proxy for authoring (for example with `ffmpeg -i asset.mp4 -c:v libx264 -crf 18 proxy.mp4`, or via the media-use skill) and swap the original back in for the final render, or just keep the HEVC source, since the rendered output is unaffected.
|
||||
|
||||
`hyperframes lint` emits an info-level `hevc_preview_codec` note when a composition references an HEVC video, as a reminder of this preview-only limitation.
|
||||
|
||||
## Animated GIF
|
||||
|
||||
Use GIF when the output needs to autoplay inline in GitHub PRs, READMEs, issue reports, and docs pages:
|
||||
|
||||
@@ -135,6 +135,18 @@ If your issue is about a specific coding mistake (animations not working, video
|
||||
|
||||
See [Rendering: Options](/guides/rendering#options) for all available flags.
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Video asset plays black in preview but renders fine (HEVC/H.265)">
|
||||
Rendering decodes video assets with FFmpeg, so HEVC (H.265) inputs render correctly on every platform. Live preview is different: `preview`, `play`, Studio, and published player pages play the file in your browser, and not every browser decodes HEVC (Chrome 107+, Edge, and Safari do on most modern hardware; Firefox does not).
|
||||
|
||||
If an HEVC asset shows a black or frozen frame in preview:
|
||||
|
||||
1. Confirm the render itself is fine: `npx hyperframes render` output will contain the video.
|
||||
2. For authoring, generate an H.264 proxy (`ffmpeg -i asset.mp4 -c:v libx264 -crf 18 proxy.mp4`) and point the composition at it while you iterate.
|
||||
3. Swap the HEVC original back before the final render, or keep the proxy; both render correctly.
|
||||
|
||||
`npx hyperframes lint` flags HEVC assets with an info-level `hevc_preview_codec` note. See [Rendering: Input Video Codecs](/guides/rendering#input-video-codecs).
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
## System Diagnostics
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
// 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;
|
||||
// Bounds concurrent ffprobe child processes for compositions referencing many videos.
|
||||
const PROBE_CONCURRENCY = 8;
|
||||
|
||||
// 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 = new Array<boolean>(entries.length).fill(false);
|
||||
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;
|
||||
isHevc[index] = await probeIsHevc(ffprobePath, entry[0]);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
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.",
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -30,7 +30,7 @@
|
||||
"files": 11
|
||||
},
|
||||
"hyperframes-core": {
|
||||
"hash": "627ad77d778ec13d",
|
||||
"hash": "546d4b1a0f0a8440",
|
||||
"files": 17
|
||||
},
|
||||
"hyperframes-creative": {
|
||||
@@ -46,7 +46,7 @@
|
||||
"files": 10
|
||||
},
|
||||
"media-use": {
|
||||
"hash": "675a735177b23d1b",
|
||||
"hash": "36630c2bb9d281da",
|
||||
"files": 133
|
||||
},
|
||||
"motion-graphics": {
|
||||
|
||||
@@ -105,3 +105,5 @@ Video elements must be muted and inline. Audio must be a separate `<audio>` elem
|
||||
- For volume fades/ducking, animate `volume` on the timeline (`tl.to("#bgm", { volume: 0, duration: 1 }, "outro")`) rather than swapping `data-volume`. The runtime probes the timeline's volume keyframes and applies them identically in preview and render; `data-volume` is the static baseline for elements no tween touches.
|
||||
|
||||
For media duration: `<video>` and `<audio>` can omit `data-duration` if the media's intrinsic length is known and you want the full clip. Otherwise provide `data-duration` explicitly.
|
||||
|
||||
Input codecs: render decodes video via FFmpeg (frames are pre-extracted and injected), so HEVC/H.265 assets (8/10-bit) render correctly everywhere; only live preview depends on the browser's codec support (`lint` emits an info-level `hevc_preview_codec` note; use an H.264 proxy for authoring if preview shows black).
|
||||
|
||||
@@ -392,6 +392,12 @@ removal, upscale, lipsync, translate). Run the tool, then register the output
|
||||
with `resolve --from <output> --type <type>` so it joins the ledger + global
|
||||
cache.
|
||||
|
||||
HEVC/H.265 sources need no conversion for **render** (FFmpeg pre-decodes all
|
||||
input video); only live preview depends on the browser's codec support. If an
|
||||
HEVC asset previews black, make an H.264 authoring proxy (`ffmpeg -i in.mp4
|
||||
-c:v libx264 -crf 18 proxy.mp4`), register it with `resolve --from`, and keep
|
||||
either file for the final render.
|
||||
|
||||
## CLI tools used (what to run, and how to enable each)
|
||||
|
||||
`resolve` auto-cascades; each provider shells one CLI. HeyGen is the
|
||||
|
||||
Reference in New Issue
Block a user