mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
fix(render): allow explicit entry without index (#2307)
* fix(render): allow explicit entry without index * fix(render): unify explicit composition handling * test(cli): allow explicit render integration under CI load
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
// fallow-ignore-file code-duplication
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const producerState = vi.hoisted(() => ({
|
||||
createdJobs: [] as Array<Record<string, unknown>>,
|
||||
@@ -1065,4 +1068,40 @@ describe("render fps arg definition", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("render command explicit composition", () => {
|
||||
it("renders an explicit composition from a project with no index.html", async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), "hf-render-explicit-"));
|
||||
const outputPath = join(projectDir, "out.mp4");
|
||||
writeFileSync(
|
||||
join(projectDir, "standalone.html"),
|
||||
`<html><body>
|
||||
<div data-composition-id="standalone" data-width="1920" data-height="1080" data-duration="1"></div>
|
||||
<script>window.__timelines = { standalone: gsap.timeline({ paused: true }) };</script>
|
||||
</body></html>`,
|
||||
);
|
||||
vi.useFakeTimers();
|
||||
|
||||
try {
|
||||
const command = (await import("./render.js")).default;
|
||||
await command.run?.({
|
||||
args: {
|
||||
dir: projectDir,
|
||||
composition: "standalone.html",
|
||||
output: outputPath,
|
||||
quiet: true,
|
||||
quality: "standard",
|
||||
format: "mp4",
|
||||
},
|
||||
} as never);
|
||||
|
||||
expect(producerState.createdJobs.at(-1)).toMatchObject({
|
||||
entryFile: "standalone.html",
|
||||
});
|
||||
} finally {
|
||||
vi.clearAllTimers();
|
||||
rmSync(projectDir, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
});
|
||||
|
||||
// Variables-helper tests live in `../utils/variables.test.ts`.
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "../utils/variables.js";
|
||||
import {
|
||||
parseGifLoopArg,
|
||||
hasExplicitCompositionArg,
|
||||
resolveBrowserTimeoutMsArg,
|
||||
resolveCompositionEntryArg,
|
||||
resolveDefaultFpsArg,
|
||||
@@ -388,12 +389,14 @@ export default defineCommand({
|
||||
// fallow-ignore-next-line complexity
|
||||
async run({ args }) {
|
||||
// ── Resolve project ────────────────────────────────────────────────────
|
||||
const project = resolveProject(args.dir);
|
||||
const hasExplicitComposition = hasExplicitCompositionArg(args.composition);
|
||||
const project = resolveProject(args.dir, { requireIndex: !hasExplicitComposition });
|
||||
|
||||
// ── Resolve composition entry file ─────────────────────────────────────
|
||||
// Needed early: fps default below must read the actual render target, not
|
||||
// always index.html.
|
||||
const entryFile = resolveCompositionEntryArg(args.composition, project.dir, statSync);
|
||||
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
|
||||
|
||||
// ── Validate fps ───────────────────────────────────────────────────────
|
||||
// Accept either integer (`30`) or ffmpeg-style rational (`30000/1001`).
|
||||
@@ -691,7 +694,7 @@ export default defineCommand({
|
||||
preparedBatch = batchModule.prepareBatchRender({
|
||||
batchPath,
|
||||
outputTemplate: batchOutputTemplate,
|
||||
indexPath: project.indexPath,
|
||||
indexPath: renderTarget,
|
||||
strictVariables: args["strict-variables"] ?? false,
|
||||
quiet: quiet || batchJson,
|
||||
json: batchJson,
|
||||
@@ -708,7 +711,6 @@ export default defineCommand({
|
||||
// deck-native path. Best-effort — never block a render on this probe.
|
||||
if (!quiet) {
|
||||
try {
|
||||
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
|
||||
const { slideshowIslandRegex } = await import("@hyperframes/core/slideshow");
|
||||
if (slideshowIslandRegex("i").test(readFileSync(renderTarget, "utf8"))) {
|
||||
console.log(
|
||||
@@ -807,7 +809,9 @@ export default defineCommand({
|
||||
|
||||
// ── Pre-render lint ──────────────────────────────────────────────────
|
||||
{
|
||||
const explicitEntry = args.composition && args.composition !== "." ? entryFile : undefined;
|
||||
// lintProject's explicit-entry contract is an absolute source path;
|
||||
// entryFile is project-relative for the producer.
|
||||
const explicitEntry = entryFile ? renderTarget : undefined;
|
||||
const lintResult = await lintProject(project.dir, explicitEntry);
|
||||
if (!quiet && (lintResult.totalErrors > 0 || lintResult.totalWarnings > 0)) {
|
||||
console.log("");
|
||||
@@ -842,7 +846,6 @@ export default defineCommand({
|
||||
if (outputResolution) {
|
||||
let resolutionIssue: { message: string; kind: OutputResolutionIssueKind } | undefined;
|
||||
try {
|
||||
const renderTarget = entryFile ? resolve(project.dir, entryFile) : project.indexPath;
|
||||
resolutionIssue = await checkRenderResolutionPreflight(
|
||||
readFileSync(renderTarget, "utf8"),
|
||||
outputResolution,
|
||||
@@ -935,7 +938,7 @@ export default defineCommand({
|
||||
// ── Validate --variables against data-composition-variables ─────────
|
||||
const strictVariables = args["strict-variables"] ?? false;
|
||||
if (variables && Object.keys(variables).length > 0) {
|
||||
const issues = validateVariablesAgainstProject(project.indexPath, variables);
|
||||
const issues = validateVariablesAgainstProject(renderTarget, variables);
|
||||
reportVariableIssues(issues, { strict: strictVariables, quiet });
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,18 @@ describe("resolveProjectOrThrow", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a directory without index.html when an explicit entry will be resolved", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-explicit-entry-project-"));
|
||||
try {
|
||||
const project = resolveProjectOrThrow(dir, { requireIndex: false });
|
||||
expect(project.dir).toBe(dir);
|
||||
expect(project.indexPath).toBe(join(dir, "index.html"));
|
||||
expect(project.name).toBe(basename(dir));
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("accepts a directory with index.html", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "hf-valid-project-"));
|
||||
try {
|
||||
|
||||
@@ -9,6 +9,10 @@ export interface ProjectDir {
|
||||
indexPath: string;
|
||||
}
|
||||
|
||||
export interface ResolveProjectOptions {
|
||||
requireIndex?: boolean;
|
||||
}
|
||||
|
||||
export class InvalidProjectError extends Error {
|
||||
readonly title: string;
|
||||
readonly hint?: string;
|
||||
@@ -23,7 +27,10 @@ export class InvalidProjectError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir {
|
||||
export function resolveProjectOrThrow(
|
||||
dirArg: string | undefined,
|
||||
options: ResolveProjectOptions = {},
|
||||
): ProjectDir {
|
||||
const trimmed = dirArg?.trim();
|
||||
if (trimmed === "#") {
|
||||
throw new InvalidProjectError(
|
||||
@@ -40,7 +47,7 @@ export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir {
|
||||
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
|
||||
throw new InvalidProjectError("Not a directory: " + dir);
|
||||
}
|
||||
if (!existsSync(indexPath)) {
|
||||
if (options.requireIndex !== false && !existsSync(indexPath)) {
|
||||
throw new InvalidProjectError(
|
||||
"No composition found in " + dir,
|
||||
"No index.html file found.",
|
||||
@@ -51,9 +58,12 @@ export function resolveProjectOrThrow(dirArg: string | undefined): ProjectDir {
|
||||
return { dir, name, indexPath };
|
||||
}
|
||||
|
||||
export function resolveProject(dirArg: string | undefined): ProjectDir {
|
||||
export function resolveProject(
|
||||
dirArg: string | undefined,
|
||||
options: ResolveProjectOptions = {},
|
||||
): ProjectDir {
|
||||
try {
|
||||
return resolveProjectOrThrow(dirArg);
|
||||
return resolveProjectOrThrow(dirArg, options);
|
||||
} catch (err) {
|
||||
if (err instanceof InvalidProjectError) {
|
||||
// Self-exit (not a throw) so the cli.ts wrapper never sees it — report
|
||||
|
||||
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import {
|
||||
MAX_PAGE_NAVIGATION_TIMEOUT_SECONDS,
|
||||
hasExplicitCompositionArg,
|
||||
parseBrowserTimeoutMsArg,
|
||||
parseCompositionEntryArg,
|
||||
parseGifLoopArg,
|
||||
@@ -104,6 +105,17 @@ describe("parseBrowserTimeoutMsArg", () => {
|
||||
});
|
||||
|
||||
describe("parseCompositionEntryArg", () => {
|
||||
it("uses one sentinel classifier for default and explicit composition values", () => {
|
||||
expect([undefined, "", " ", ".", "./"].map(hasExplicitCompositionArg)).toEqual([
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
false,
|
||||
]);
|
||||
expect(hasExplicitCompositionArg("./compositions/intro.html")).toBe(true);
|
||||
});
|
||||
|
||||
const PROJECT = "/proj";
|
||||
const stat = makeStat({
|
||||
[resolve(PROJECT, "index.html")]: "file",
|
||||
|
||||
@@ -128,6 +128,15 @@ export type CompositionEntryParseResult =
|
||||
| { ok: true; value: string | undefined }
|
||||
| { ok: false; error: CompositionEntryParseError };
|
||||
|
||||
function normalizeCompositionEntryArg(raw: string | undefined): string | undefined {
|
||||
const trimmed = raw?.trim().replace(/^\.\//, "") || undefined;
|
||||
return !trimmed || trimmed === "." ? undefined : trimmed;
|
||||
}
|
||||
|
||||
export function hasExplicitCompositionArg(raw: string | undefined): boolean {
|
||||
return normalizeCompositionEntryArg(raw) !== undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate `--composition <path>` into a project-relative
|
||||
* entry file (or `undefined` for the index.html default).
|
||||
@@ -146,11 +155,11 @@ export function parseCompositionEntryArg(
|
||||
projectDir: string,
|
||||
stat: (path: string) => Stats,
|
||||
): CompositionEntryParseResult {
|
||||
const trimmed = raw?.trim().replace(/^\.\//, "") || undefined;
|
||||
const trimmed = normalizeCompositionEntryArg(raw);
|
||||
// Normalize the project-root shorthands to "no entry override" so the
|
||||
// producer falls back to index.html instead of statSync-ing the dir
|
||||
// and later blowing up with EISDIR inside readFileSync().
|
||||
if (!trimmed || trimmed === ".") return { ok: true, value: undefined };
|
||||
if (!trimmed) return { ok: true, value: undefined };
|
||||
|
||||
const absProjectDir = resolve(projectDir);
|
||||
const entryPath = resolve(absProjectDir, trimmed);
|
||||
|
||||
Reference in New Issue
Block a user