Skip to content
Draft
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
4 changes: 2 additions & 2 deletions docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ thin with counts on it was cut short.
| Flag | Description |
| ------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--output, -o` | Output directory. Default `./capture`, then `./capture-2/`, `./capture-3/`, … if that name is taken. |
| `--timeout` | Page load timeout in ms (default 120000) |
| `--timeout` | Page load timeout in ms, or a duration such as `30s` or `2m` (default 120000) |
| `--capture-budget` | Budget in ms for everything after the page loads (default 120000). Cooperative — it stops new work, but cannot interrupt work already running. |
| `--skip-assets` | Do not download images and SVGs |
| `--skip-vision` | Do not run AI image captioning |
Expand Down Expand Up @@ -1588,7 +1588,7 @@ npx hyperframes grade-compare --for frame.png --luts warm.cube,cool.cube
| `--project` | Base directory for relative paths |
| `--out` | Output PNG (default `<project>/grade-compare.png`) |
| `--baseline` / `--no-baseline` | Include the ungraded frame as an `original` cell (on) |
| `--timeout` | Render-ready timeout (default 5000 ms) |
| `--timeout` | Render-ready timeout in ms, or a duration such as `30s` (default 5000 ms) |
| `--json` | Machine-readable results |

### Deprecated: `validate`, `inspect`, and `layout`
Expand Down
9 changes: 8 additions & 1 deletion docs/packages/player.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,9 @@ import "@hyperframes/player";
Set `autoplay muted` only when playback should start without a user gesture.

Add or remove `controls` at any time. When shown, the controls reflect the current
playback state, position and speed.
playback state, position and speed. Clicking or dragging the scrubber keeps a
playing film playing from the new spot, as a `<video>`'s own controls do, and
leaves a paused film paused.

## Attributes

Expand Down Expand Up @@ -93,6 +95,7 @@ const player = document.querySelector("hyperframes-player");
player.play();
player.pause();
player.seek(2.5);
player.seek(2.5, { keepPlaying: true });

player.currentTime = 5;
player.playbackRate = 1.5;
Expand All @@ -102,6 +105,10 @@ console.log(player.duration, player.paused, player.ready);
console.log(player.compositionWidth, player.compositionHeight);
```

`seek()` and setting `currentTime` pause at the new time. Pass `{ keepPlaying: true }`
to keep a playing film playing from there, as setting a `<video>`'s `currentTime`
does; a paused film stays paused.

In Studio previews, scrubbing keeps the last complete picture visible while video
layers decode at the requested time. Rapid seeks keep only the latest queued
destination, and the captions and video layers appear together once it is ready.
Expand Down
4 changes: 2 additions & 2 deletions docs/reference/lint-rules.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -916,9 +916,9 @@ Source: the project lint pass in `packages/lint/src/project.ts`, `packages/lint/

### `duplicate_audio_track`

**Warning.** Two audible elements overlap on the same track index, so their audio doubles up.
**Warning.** Two audible clips overlap in time on the same `data-track-index`. Both play at once: the render mixes every audible clip whatever its track, and `data-track-index` only picks the Studio lane, so Studio draws the two over each other in that lane. An overlap like this is either an accidental duplicate or two sounds sharing one lane.

**Fix:** Use non-overlapping time windows or different track indices.
**Fix:** If both should sound (a whoosh into a hit), give one its own `data-track-index`; the mix does not change. If one is a leftover duplicate, remove it or move it so the windows do not overlap.

### `texture_mask_asset_not_found`

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/scripts/build-copy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ async function main() {
// Skills bundled into the published CLI. Branches don't all carry the same
// skills/ tree (it gets restructured), so each entry is existsSync-guarded:
// a missing skill dir warns + skips instead of crashing the build.
for (const skill of ["hyperframes", "hyperframes-cli", "gsap", "media-use"]) {
for (const skill of ["hyperframes", "hyperframes-cli", "media-use"]) {
const src = join(REPO_ROOT, "skills", skill);
if (!existsSync(src)) {
console.warn(`[build-copy] skill not found, skipping: skills/${skill}`);
Expand Down
16 changes: 16 additions & 0 deletions packages/cli/src/buildCopy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { existsSync, readFileSync } from "node:fs";
import { describe, expect, it } from "vitest";

const copySource = readFileSync(new URL("../scripts/build-copy.mjs", import.meta.url), "utf-8");

describe("build-copy bundled skills", () => {
it("names only skills that exist in the repo's skills/ tree", () => {
const list = copySource.match(/for \(const skill of \[([^\]]+)\]\)/)?.[1] ?? "";
const skills = [...list.matchAll(/"([^"]+)"/g)].map((m) => m[1]);
expect(skills.length).toBeGreaterThan(0);
const missing = skills.filter(
(name) => !existsSync(new URL(`../../../skills/${name}/SKILL.md`, import.meta.url)),
);
expect(missing).toEqual([]);
});
});
31 changes: 31 additions & 0 deletions packages/cli/src/commands/capture.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,37 @@ describe("capture command — vision control", () => {
);
});

it.each([
["1s", 1_000],
["2m", 120_000],
["45000", 45_000],
[undefined, 120_000],
])("passes --timeout %s into capture as %i ms", async (timeout, expectedMs) => {
vi.spyOn(console, "log").mockImplementation(() => {});
await captureCommand.run!({
args: { url: "https://example.com", output: "/tmp/hf-timeout", timeout, json: true },
} as never);
expect(captureWebsiteMock).toHaveBeenCalledWith(
expect.objectContaining({ timeout: expectedMs }),
undefined,
);
});

it("warns and keeps the default page-load timeout when --timeout is not a duration", async () => {
vi.spyOn(console, "log").mockImplementation(() => {});
const error = vi.spyOn(console, "error").mockImplementation(() => {});
await captureCommand.run!({
args: { url: "https://example.com", output: "/tmp/hf-timeout", timeout: "abc", json: true },
} as never);
expect(captureWebsiteMock).toHaveBeenCalledWith(
expect.objectContaining({ timeout: 120_000 }),
undefined,
);
expect(error).toHaveBeenCalledWith(
expect.stringContaining('--timeout "abc" is not a duration'),
);
});

it.each([
[true, 0],
[false, 1],
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/capture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import { diag } from "../ui/diagnostics.js";
import type { CapturePhaseProgress } from "../capture/types.js";
import { parseCaptureDeadline } from "../capture/captureWatchdog.js";
import { jsonFailure } from "../utils/jsonError.js";
import { parseTimeoutMs } from "../utils/timeoutArg.js";

const CAPTURE_PHASE_PREFIX = "HYPERFRAMES_CAPTURE_PHASE ";
const DEFAULT_CAPTURE_TIMEOUT_MS = 120_000;

function emitCapturePhase(event: CapturePhaseProgress): void {
diag.notice(`${CAPTURE_PHASE_PREFIX}${JSON.stringify(event)}`);
Expand Down Expand Up @@ -94,7 +96,8 @@ export default defineCommand({
},
timeout: {
type: "string",
description: "Page load timeout in ms (default: 120000)",
description:
"Page load timeout: a number of ms, or a duration such as 30s or 2m (default: 120000)",
},
"capture-budget": {
type: "string",
Expand Down Expand Up @@ -236,7 +239,7 @@ export default defineCommand({
skipAssets: args["skip-assets"] as boolean,
skipVision: args["skip-vision"] as boolean,
maxScreenshots,
timeout: args.timeout ? parseInt(args.timeout as string) : undefined,
timeout: parseTimeoutMs(args.timeout, DEFAULT_CAPTURE_TIMEOUT_MS),
postNavigationBudgetMs: captureBudgetMs,
captureDeadlineMs: parseCaptureDeadline(process.env.HYPERFRAMES_CAPTURE_DEADLINE_MS),
json: isJson,
Expand Down
27 changes: 27 additions & 0 deletions packages/cli/src/commands/grade-compare.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,33 @@ describe("parseGradeCompareArgs", () => {
parseGradeCompareArgs({ for: "frame.png", grades: "grades.json", luts: "a.cube" }),
).toThrow("Exactly one of --grades or --luts is required");
});

it.each([
["1s", 1_000],
["1500ms", 1_500],
["8000", 8_000],
[undefined, 5_000],
])("reads --timeout %s as %i ms", (timeout, expectedMs) => {
const parsed = parseGradeCompareArgs({ for: "frame.png", grades: "grades.json", timeout });
expect(parsed.timeoutMs).toBe(expectedMs);
});

it("warns and keeps the 5000 ms default when --timeout is not a duration", () => {
const error = vi.spyOn(console, "error").mockImplementation(() => {});
try {
const parsed = parseGradeCompareArgs({
for: "frame.png",
grades: "grades.json",
timeout: "abc",
});
expect(parsed.timeoutMs).toBe(5_000);
expect(error).toHaveBeenCalledWith(
expect.stringContaining('--timeout "abc" is not a duration'),
);
} finally {
error.mockRestore();
}
});
});

describe("buildGradeCompareHtml", () => {
Expand Down
7 changes: 5 additions & 2 deletions packages/cli/src/commands/grade-compare.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import { serveStaticProjectHtml } from "../utils/staticProjectServer.js";
import { withMeta } from "../utils/updateCheck.js";
import { failCaught } from "../utils/jsonError.js";
import { CliUsageError } from "../utils/commandResult.js";
import { parseTimeoutMs } from "../utils/timeoutArg.js";

const COMPOSITION_ID = "grade-compare";
const COMPOSITION_DURATION = "1";
Expand All @@ -40,6 +41,7 @@ const MAX_COLUMNS = 4;
const GRID_PADDING = 16;
const LABEL_HEIGHT = 32;
const FFMPEG_EXTRACT_TIMEOUT_MS = 30_000;
const DEFAULT_RENDER_READY_TIMEOUT_MS = 5_000;

export interface GradeCompareCell {
label: string;
Expand Down Expand Up @@ -289,7 +291,7 @@ export function parseGradeCompareArgs(args: {
? { kind: "grades", path: resolveFromBase(projectDir, gradesArg) }
: { kind: "luts", value: lutsArg ?? "" },
json: args.json === true,
timeoutMs: Number.parseInt(readOptionalString(args.timeout) ?? "", 10) || 5000,
timeoutMs: parseTimeoutMs(args.timeout, DEFAULT_RENDER_READY_TIMEOUT_MS),
};
}

Expand Down Expand Up @@ -612,7 +614,8 @@ export default defineCommand({
},
timeout: {
type: "string",
description: "Render-ready timeout in ms before capture (default: 5000)",
description:
"Render-ready timeout before capture: a number of ms, or a duration such as 30s (default: 5000)",
},
baseline: {
type: "boolean",
Expand Down
9 changes: 5 additions & 4 deletions packages/cli/src/commands/init.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import {
} from "@hyperframes/parsers";
import { decodableAudioStream } from "@hyperframes/parsers/media-streams";
import { cliSourced } from "../utils/sourceTag.js";
import { shellArg } from "../utils/shellArg.js";

function resolveScaffoldTemplateId(exampleFlag: string | undefined, hasMediaFile: boolean): string {
const example = exampleFlag === "agent" ? "blank" : exampleFlag;
Expand Down Expand Up @@ -1044,7 +1045,7 @@ export default defineCommand({
console.log();
console.log(` ${c.accent("2.")} Open this project with your AI coding agent:`);
console.log(
` ${c.accent(`cd ${name}`)} then start ${c.accent("Claude Code")}, ${c.accent("Cursor")}, or your preferred agent`,
` ${c.accent(`cd ${shellArg(name)}`)} then start ${c.accent("Claude Code")}, ${c.accent("Cursor")}, or your preferred agent`,
);
console.log();
console.log(` ${c.accent("3.")} Try a starter prompt:`);
Expand All @@ -1056,13 +1057,13 @@ export default defineCommand({
);
console.log();
console.log(` ${c.accent("4.")} Preview in the browser:`);
console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npm run dev")}`);
console.log(` ${c.accent(`cd ${shellArg(name)}`)} && ${c.accent("npm run dev")}`);
console.log();
console.log(` ${c.accent("5.")} Check the composition:`);
console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npm run check")}`);
console.log(` ${c.accent(`cd ${shellArg(name)}`)} && ${c.accent("npm run check")}`);
console.log();
console.log(` ${c.accent("6.")} Render to MP4 when ready:`);
console.log(` ${c.accent(`cd ${name}`)} && ${c.accent("npm run render")}`);
console.log(` ${c.accent(`cd ${shellArg(name)}`)} && ${c.accent("npm run render")}`);
console.log();
console.log(` ${c.dim(`Full docs: ${cliSourced("https://hyperframes.heygen.com")}`)}`);
return;
Expand Down
3 changes: 2 additions & 1 deletion packages/cli/src/commands/layout-audit.browser.js
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,8 @@
return `${selectorFor(parent)} > ${element.tagName.toLowerCase()}:nth-of-type(${index})`;
}

function uniqueSelectorFor(element) {
function uniqueSelectorFor(subject) {
const element = authoredElement(subject);
const preferred = selectorFor(element);
try {
if (document.querySelectorAll(preferred).length === 1) return preferred;
Expand Down
4 changes: 2 additions & 2 deletions packages/cli/src/commands/layout-audit.browser.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1456,7 +1456,7 @@ describe("layout-audit.browser coordinate-frame findings", () => {
["flags", "", true],
["clears", "data-layout-allow-overflow", false],
])(
"%s a graded video's canvas overflowing a clipping wrapper, per the video's own flag",
"%s a graded video's canvas overflowing a clipping wrapper by the video's selector and flag",
(_, flag, flagged) => {
document.body.innerHTML = `
<div id="root" data-composition-id="main" data-width="1920" data-height="1080">
Expand All @@ -1476,7 +1476,7 @@ describe("layout-audit.browser coordinate-frame findings", () => {
installAuditScript();

const overflow = runAudit().filter((issue) => issue.code === "container_overflow");
expect(overflow.map((issue) => issue.selector)).toEqual(flagged ? ["#art"] : []);
expect(overflow.map((issue) => issue.selector)).toEqual(flagged ? ["#clip"] : []);
},
);

Expand Down
2 changes: 2 additions & 0 deletions packages/cli/src/commands/render/plan.ts
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ export interface RenderPlan {
renderTarget: string;
fps: Fps;
quality: RenderQuality;
qualityPreset: keyof typeof QUALITY_ALIASES;
authoringSkill?: string;
invalidAuthoringSkill?: string;
/** Which resolution step provided authoringSkill: an explicit --skill flag, or the project's own config. */
Expand Down Expand Up @@ -638,6 +639,7 @@ export function createRenderPlan(args: RenderCommandArgs, now = new Date()): Ren
renderTarget,
fps,
quality,
qualityPreset: qualityRaw as keyof typeof QUALITY_ALIASES,
authoringSkill,
invalidAuthoringSkill,
authoringSkillSource,
Expand Down
45 changes: 45 additions & 0 deletions packages/cli/src/commands/render/present.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createRenderPlan, type RenderCommandArgs } from "./plan.js";
import { presentRenderPlan } from "./present.js";

describe("presentRenderPlan", () => {
let projectDir: string;
let printed: string[];

beforeEach(() => {
projectDir = mkdtempSync(join(tmpdir(), "hf-render-present-"));
writeFileSync(
join(projectDir, "index.html"),
'<main data-composition-id="main" data-width="1920" data-height="1080" data-fps="30"></main>',
);
printed = [];
vi.spyOn(console, "log").mockImplementation((line?: unknown) => {
printed.push(String(line ?? ""));
});
vi.spyOn(console, "error").mockImplementation(() => {});
});

afterEach(() => {
vi.restoreAllMocks();
rmSync(projectDir, { recursive: true, force: true });
});

async function settingsLine(args: Omit<RenderCommandArgs, "dir">): Promise<string | undefined> {
await presentRenderPlan(createRenderPlan({ dir: projectDir, workers: "2", ...args }));
return printed.find((line) => line.includes("2 workers"));
}

it.each([
[{}, "looks (CRF 16)"],
[{ quality: "draft" }, "draft"],
[{ quality: "standard" }, "standard"],
[{ quality: "delivery" }, "delivery"],
[{ crf: "20" }, "looks (CRF 20)"],
[{ "video-bitrate": "10M" }, "looks (10M)"],
])("names the quality it encodes with for %o", async (args, label) => {
expect(await settingsLine(args)).toContain(`30fps · ${label} · 2 workers`);
});
});
8 changes: 7 additions & 1 deletion packages/cli/src/commands/render/present.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function presentRenderSummary(plan: RenderPlan): void {
c.accent("\u25C6") + " Rendering " + c.accent(nameLabel) + c.dim(" \u2192 " + plan.outputPath),
);
console.log(
c.dim(` ${fpsToFfmpegArg(plan.fps)}fps \u00B7 ${plan.quality} \u00B7 ${workerLabel}`),
c.dim(` ${fpsToFfmpegArg(plan.fps)}fps \u00B7 ${qualityLabel(plan)} \u00B7 ${workerLabel}`),
);
if (plan.outputResolution) {
console.log(c.dim(" Output resolution: " + plan.outputResolution));
Expand All @@ -50,6 +50,12 @@ function presentRenderSummary(plan: RenderPlan): void {
console.log("");
}

function qualityLabel(plan: Pick<RenderPlan, "qualityPreset" | "crf" | "videoBitrate">): string {
if (plan.crf !== undefined) return `${plan.qualityPreset} (CRF ${plan.crf})`;
if (plan.videoBitrate) return `${plan.qualityPreset} (${plan.videoBitrate})`;
return plan.qualityPreset;
}

/** The GPU line's accelerated modes, or undefined when neither is. */
function formatGpuModes(plan: RenderPlan): string | undefined {
const modes: string[] = [];
Expand Down
6 changes: 4 additions & 2 deletions packages/cli/src/docs/rendering.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Rendering

Render compositions to MP4 with `npx hyperframes render`.
Render compositions to MP4 (or another `--format`) with `npx hyperframes render`.

## Local Mode (default)

Expand All @@ -16,7 +16,9 @@ Requires: Docker installed and running.

- `-f, --fps` — 1-240 or a rational such as 30000/1001 (default: root `data-fps`, else 30)
- `-q, --quality` — draft, looks, delivery, or standard/high (default: looks)
- `-w, --workers` — Parallel workers 1-8 (default: auto)
- `--format` — Output format: `mp4`, `webm`, `mov`, `gif`, `png-sequence`, or `hls` (default: `mp4`). MOV and WebM keep transparency; `png-sequence` writes RGBA frames to a directory; `hls` writes a VOD playlist directory (H.264/AAC, SDR only, no GPU encoding)
- `--gif-loop` — GIF loop count, `0` for infinite (`0`-`65535`). Only used with `--format gif`
- `-w, --workers` — Parallel workers 1-24 or `auto`; higher values are capped at 24 (default: auto)
- `--crf` — Override encoder CRF (mutually exclusive with `--video-bitrate`)
- `--video-bitrate` — Target video bitrate such as `10M` (mutually exclusive with `--crf`)
- `--keyframe-interval` — MP4 only: a keyframe at least every N seconds (default: encoder's choice)
Expand Down
Loading
Loading