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
35 changes: 35 additions & 0 deletions docs/packages/player.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,41 @@ player.addEventListener("timeupdate", (event) => {
| `error` | `{ message }` | Load or runtime error; a video adds its `MediaError` `code` |
| `playbackerror` | `{ source, error }` | The browser blocked playback, for example unmuted autoplay |

## Custom icons and loading mark

Put an element with a `slot` attribute inside the player to replace a control glyph or the loading mark. Anything you leave out keeps the default.

| Slot | Shown when | Default size |
| ------------------- | ----------------------------------------------------------- | ------------ |
| `play-icon` | Paused | 24 px |
| `pause-icon` | Playing | 24 px |
| `volume-high-icon` | Unmuted, volume 0.5 or higher | 20 px |
| `volume-low-icon` | Unmuted, volume below 0.5 | 20 px |
| `volume-muted-icon` | Muted | 20 px |
| `loading-mark` | Preparing shader transitions, loading assets, and buffering | 78 px |

```html title="index.html"
<hyperframes-player src="/compositions/intro.html" controls>
<svg slot="play-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5v14l11-7z" />
</svg>
<svg slot="pause-icon" width="24" height="24" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M6 5h4v14H6zM14 5h4v14h-4z" />
</svg>
<img slot="loading-mark" src="/brand-mark.svg" width="78" height="78" alt="" />
</hyperframes-player>
```

Glyphs take the control bar's color through `currentColor`, and you set their size. Put `slot` on the `<svg>` or `<img>` itself, as a direct child of the player; a wrapper element needs `display: contents` to keep the glyph centered. A custom mark replaces the HyperFrames light sweep and glow, so add your own CSS animation if you want motion.

Until the element is defined, its children show as plain content. Hide them:

```css title="styles.css"
hyperframes-player:not(:defined) > [slot] {
display: none;
}
```

## Advanced: iframe access

The composition runs inside an `<iframe>` in the player's Shadow DOM. For most
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