Skip to content
Open
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: 3 additions & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -825,13 +825,15 @@
"pages": [
"reference/html-schema",
"reference/color-grading",
"reference/audio-effects"
"reference/audio-effects",
"reference/cli-ledger"
]
},
{
"group": "Rendering paths",
"pages": [
"guides/rendering",
"guides/offline-deterministic-renders",
"deploy/overview",
"deploy/cloud",
"guides/deploy"
Expand Down
100 changes: 100 additions & 0 deletions docs/guides/offline-deterministic-renders.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
---
title: "Offline & deterministic renders"
sidebarTitle: "Offline renders"
description: "Make a composition render byte-identically with the network unplugged: inventory assets, vendor remotes, and gate CI on zero network references."
---

A HyperFrames render is a function of the composition and its assets — nothing
else. Two things quietly break that:

- **Remote assets.** A CDN `<script>`, a hotlinked image, or a Google Fonts
stylesheet means render output depends on a third-party server being up,
fast, and serving the same bytes as yesterday.
- **Runtime nondeterminism.** `Date.now()`, unseeded `Math.random()`, and
render-time `fetch()` calls produce different frames on different runs.

The composition contract already bans the second category. This guide is about
the first: getting to — and staying at — **zero network references**.

## The workflow

<Steps>
<Step title="Inventory: hyperframes ledger">
```bash
npx hyperframes ledger --json
```

The ledger scans every HTML file and classifies each declared asset as
`remote`, `local`, `data`, or `missing` — scripts, stylesheets, fonts,
images, audio, video, iframes, and text tracks, including URLs inside
`<style>` blocks and inline styles. It is purely static (no browser, no
network), so it is safe anywhere, CI included.
</Step>
<Step title="Localize: hyperframes vendor">
```bash
npx hyperframes vendor
```

Every remote `http(s)` asset is downloaded into `assets/vendor/`, every
reference is rewritten to a relative path, and `vendor-manifest.json`
records the source URL, size, and SHA-256 of each download. Your
composition now carries its own dependencies.
</Step>
<Step title="Enforce: --strict-offline">
```bash
npx hyperframes ledger --strict-offline
```

Exits non-zero while any remote reference remains. Put it in CI right
before `hyperframes render` so a stray CDN reference fails the pipeline
instead of the render.
</Step>
</Steps>

## Example

A fresh composition that pulls GSAP from jsDelivr:

```bash
$ npx hyperframes ledger
◆ Asset ledger for my-video
1 HTML file scanned, 6 asset references

4 local 1 remote 1 data 0 missing

remote script https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js
index.html (src)

Run hyperframes vendor to download remote assets and rewrite references…

$ npx hyperframes vendor
↓ https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js → assets/vendor/gsap.min-8c6a2d81f0.js

◆ Vendored 1 asset(s) into assets/vendor, rewrote 1 reference(s).
0 remote references remain — project renders offline.

$ npx hyperframes ledger --strict-offline && npx hyperframes render
```

The `<script>` tag now reads
`src="assets/vendor/gsap.min-8c6a2d81f0.js"` — same GSAP, no network.

## What vendoring will not fix

- **Remote iframes.** A live embedded page can never be deterministic.
`vendor` leaves iframes alone and `--strict-offline` keeps failing until you
replace the embed with captured media (`hyperframes capture`).
- **Fonts hidden behind remote CSS.** A Google Fonts stylesheet is vendored as
the CSS file it is, but the font binaries it references are not crawled.
Self-host fonts with `@font-face` for text-critical typography.
- **Runtime-constructed URLs.** The ledger reads declared markup, not script
behavior. `hyperframes check` (headless Chrome) remains the runtime gate.

## Lint nudge

`hyperframes lint` flags remote `<script src>` tags with an info-level
`remote_script_not_vendored` finding. CDN scripts are fine while iterating;
vendor before you care about reproducibility.

See the [ledger & vendor CLI reference](/reference/cli-ledger) for flags,
JSON schemas, and exit codes.
127 changes: 127 additions & 0 deletions docs/reference/cli-ledger.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
---
title: "Asset ledger & vendor CLI"
sidebarTitle: "Ledger & vendor"
description: "Inventory every declared asset, download remote references locally, and gate renders on being fully offline."
---

Deterministic renders must not depend on the network at capture time. These two
commands make that property inspectable and enforceable:

- `hyperframes ledger` builds a static, agent-readable **asset graph** of every
declared reference — scripts, stylesheets, fonts, images, audio, video,
iframes, text tracks — and classifies each as `remote`, `local`, `data`, or
`missing`. No browser, no network.
- `hyperframes vendor` **downloads** the remote references, rewrites the HTML
to relative paths, and records provenance, so the project renders offline.

For the workflow view (why and when), see
[Offline & deterministic renders](/guides/offline-deterministic-renders).

## `hyperframes ledger [dir]`

```bash
npx hyperframes ledger # human-readable summary
npx hyperframes ledger ./my-video --json # machine-readable asset graph
npx hyperframes ledger --strict-offline # exit 1 if any remote ref remains
```

The scan is purely static: every `.html` file under the project directory
(excluding `node_modules`, dot-directories, `dist`, `coverage`) is parsed for
declared URLs in tag attributes (`src`, `href`, `srcset`, `poster`), inline
`style=""` attributes, and `<style>` blocks (`url(…)`, `@import`,
`@font-face src`). URLs constructed at runtime inside scripts are out of
scope, as are assets nested inside remote stylesheets.

Classification:

| Status | Meaning |
| --------- | ------------------------------------------------------------------------ |
| `remote` | `http(s)://`, protocol-relative `//…`, or any non-inline URL scheme |
| `local` | Resolves to a file on disk (relative to the declaring file or the root) |
| `data` | Inline `data:` URI — self-contained, render-safe |
| `missing` | Looks like a local path but no file exists |

Commented-out markup, `blob:`/`javascript:`/`about:` values, and unresolved
templating placeholders (`{{ token }}`, `__UPPER__`) are skipped.

### JSON shape

```json
{
"ok": true,
"strictOffline": false,
"files": ["index.html", "scenes/intro.html"],
"counts": { "total": 14, "remote": 2, "local": 11, "data": 1, "missing": 0 },
"remoteUrls": ["https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"],
"assets": [
{
"kind": "script",
"url": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js",
"rawUrl": "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js",
"status": "remote",
"file": "index.html",
"via": "src"
}
]
}
```

`via` records where the reference was declared: `src`, `href`, `srcset`,
`poster`, `css-url`, `css-import`, or `style-attr`. Local assets carry a
`localPath` with the resolved project-relative file.

### Exit codes

| Condition | Exit code |
| --------------------------------------------- | --------- |
| Default | `0` |
| `--strict-offline` and ≥1 remote reference | `1` |
| Invalid project directory | `1` |

## `hyperframes vendor [dir]`

```bash
npx hyperframes vendor # download into assets/vendor/
npx hyperframes vendor --out assets/third-party # custom output directory
npx hyperframes vendor --dry-run # list, download nothing
npx hyperframes vendor --strict-offline # exit 1 if remotes remain after
```

For every unique remote URL in the ledger, `vendor`:

1. Downloads it (only `http:`/`https:` are ever fetched; protocol-relative URLs
are fetched over `https:`). Each file is saved as
`<basename>-<sha256-prefix><ext>` so re-runs are stable and names never
collide.
2. Rewrites every reference in every HTML file to a path **relative to the
declaring file** (`assets/vendor/gsap.min-1a2b3c4d5e.js` from `index.html`,
`../assets/vendor/…` from `scenes/intro.html`). The rewrite is text-level,
so your formatting is preserved and URLs mentioned in inline scripts are
localized too.
3. Writes `vendor-manifest.json` into the output directory recording `url`,
`file`, `bytes`, `sha256`, and `contentType` per asset — the provenance
record for review and licensing checks.

Remote **iframes** are never vendored: a live page cannot be made
deterministic by downloading it. They stay remote and still fail
`--strict-offline`, which is the point — replace them with captured media.

Google Fonts stylesheets are downloaded as declared, but font binaries nested
inside a remote CSS response are not crawled. Prefer self-hosted `@font-face`
files (see [media guide](/guides/media)) for text-critical fonts.

### Exit codes

| Condition | Exit code |
| ------------------------------------------------ | --------- |
| All downloads succeeded, no strict violation | `0` |
| Any download failed | `1` |
| `--strict-offline` and remote references remain | `1` |

## Lint integration

`hyperframes lint` reports an info-level `remote_script_not_vendored` finding
for every remote `<script src>`, pointing at `hyperframes vendor`. CDN scripts
remain the documented quick-start path — the enforcing gate is
`hyperframes ledger --strict-offline`, typically in CI just before
`hyperframes render`.
2 changes: 2 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,8 @@ const commandLoaders = {
publish: () => import("./commands/publish.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
ledger: () => import("./commands/ledger.js").then((m) => m.default),
vendor: () => import("./commands/vendor.js").then((m) => m.default),
check: () => import("./commands/check.js").then((m) => m.default),
beats: () => import("./commands/beats.js").then((m) => m.default),
"normalize-audio": () => import("./commands/normalize-audio.js").then((m) => m.default),
Expand Down
53 changes: 53 additions & 0 deletions packages/cli/src/commands/_offlineAssetsTestKit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/**
* Shared fixtures/helpers for the ledger + vendor command tests. Underscore
* prefix (like _examples.ts) keeps it out of vitest's *.test.ts glob.
*/
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { vi } from "vitest";
import type { CommandDef } from "citty";

export const GSAP_URL = "https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js";

/** Invoke a citty command's run() with a context whose `args` we control. */
export function makeRunner(
command: CommandDef,
): (args: Record<string, unknown>) => Promise<unknown> {
return (args) =>
(command.run as (ctx: { args: Record<string, unknown> }) => Promise<unknown>)({ args });
}

/** Last console.log payload parsed as JSON (the --json output). */
export function lastJsonOutput(): Record<string, unknown> {
const calls = vi.mocked(console.log).mock.calls;
const last = calls[calls.length - 1]?.[0];
return JSON.parse(String(last));
}

/**
* A minimal project: one jsDelivr CDN script (remote) + one local image.
* Returns the temp project dir; callers rmSync it in afterEach.
*/
export function makeFixtureProject(prefix: string, extraBody = ""): string {
const dir = mkdtempSync(join(tmpdir(), prefix));
mkdirSync(join(dir, "assets", "img"), { recursive: true });
writeFileSync(join(dir, "assets", "img", "logo.png"), "png");
writeFileSync(
join(dir, "index.html"),
`<!DOCTYPE html>
<html><head>
<script src="${GSAP_URL}"></script>
</head><body>
<img src="assets/img/logo.png">
${extraBody}
</body></html>`,
);
return dir;
}

/** Silence console and reset spies for one test. */
export function spyOnConsole(): void {
vi.spyOn(console, "log").mockImplementation(() => {});
vi.spyOn(console, "error").mockImplementation(() => {});
}
78 changes: 78 additions & 0 deletions packages/cli/src/commands/ledger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { consumeCommandResult } from "../utils/commandResult.js";
import {
lastJsonOutput,
makeFixtureProject,
makeRunner,
spyOnConsole,
} from "./_offlineAssetsTestKit.js";

// withMeta just annotates the object; identity keeps the assertions simple.
vi.mock("../utils/updateCheck.js", () => ({ withMeta: (o: unknown) => o }));
// resolveProject reports invalid-dir failures to telemetry; keep tests silent.
vi.mock("../telemetry/events.js", () => ({ trackCommandFailure: () => {} }));

import ledgerCommand from "./ledger.js";

const run = makeRunner(ledgerCommand);

describe("ledger command", () => {
let dir: string;

beforeEach(() => {
consumeCommandResult();
spyOnConsole();
dir = makeFixtureProject("hf-ledger-cmd-", ` <img src="assets/img/missing.png">`);
});

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

it("--json reports the classified asset graph and exits 0", async () => {
await run({ dir, json: true, "strict-offline": false });
expect(consumeCommandResult().exitCode).toBe(0);

const output = lastJsonOutput();
expect(output.ok).toBe(true);
expect(output.files).toEqual(["index.html"]);
expect(output.counts).toMatchObject({ total: 3, remote: 1, local: 1, missing: 1, data: 0 });
expect(output.remoteUrls).toEqual([
"https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js",
]);
});

it("--strict-offline exits 1 while a remote reference remains", async () => {
await run({ dir, json: true, "strict-offline": true });
expect(consumeCommandResult().exitCode).toBe(1);
expect(lastJsonOutput().ok).toBe(false);
});

it("--strict-offline exits 0 once the project is fully local", async () => {
writeFileSync(join(dir, "index.html"), `<img src="assets/img/logo.png">`);
await run({ dir, json: true, "strict-offline": true });
expect(consumeCommandResult().exitCode).toBe(0);
expect(lastJsonOutput().ok).toBe(true);
});

it("human-readable output prints counts and the vendor hint", async () => {
await run({ dir, json: false, "strict-offline": false });
expect(consumeCommandResult().exitCode).toBe(0);
const printed = vi
.mocked(console.log)
.mock.calls.map((call) => call.join(" "))
.join("\n");
expect(printed).toContain("Asset ledger");
expect(printed).toContain("hyperframes vendor");
});

it("errors surface as JSON with exit 1", async () => {
await run({ dir: join(dir, "does-not-exist"), json: true, "strict-offline": false });
expect(consumeCommandResult().exitCode).toBe(1);
expect(lastJsonOutput().ok).toBe(false);
});
});
Loading
Loading