Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions packages/cli/src/commands/render.test.ts
Original file line number Diff line number Diff line change
@@ -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>>,
Expand Down Expand Up @@ -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`.
15 changes: 9 additions & 6 deletions packages/cli/src/commands/render.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
} from "../utils/variables.js";
import {
parseGifLoopArg,
hasExplicitCompositionArg,
resolveBrowserTimeoutMsArg,
resolveCompositionEntryArg,
resolveDefaultFpsArg,
Expand Down Expand Up @@ -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`).
Expand Down Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -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("");
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 });
}

Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/utils/project.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
18 changes: 14 additions & 4 deletions packages/cli/src/utils/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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(
Expand All @@ -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.",
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions packages/cli/src/utils/renderArgs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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",
Expand Down
13 changes: 11 additions & 2 deletions packages/cli/src/utils/renderArgs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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);
Expand Down
Loading