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
37 changes: 36 additions & 1 deletion docs/packages/cli.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ npm install -g hyperframes
| You want to | Use |
| ------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Start a project | [`init`](#init), [`add`](#add), [`catalog`](#catalog) |
| Bring in source material | [`capture`](#capture), [`transcribe`](#transcribe), [`tts`](#tts), [`remove-background`](#remove-background), [`media-treatment`](#media-treatment), [`beats`](#beats) |
| Bring in source material | [`capture`](#capture), [`transcribe`](#transcribe), [`tts`](#tts), [`remove-background`](#remove-background), [`media-treatment`](#media-treatment), [`beats`](#beats), [`normalize-audio`](#normalize-audio) |
| Look at it, or share it | [`preview`](#preview), [`present`](#present-and-play), [`play`](#present-and-play), [`publish`](#publish) |
| Find problems | [`lint`](#lint), [`check`](#check), [`snapshot`](#snapshot), [`keyframes`](#keyframes), [`compare`](#compare-and-grade-compare), [`grade-compare`](#compare-and-grade-compare) |
| Make a file | [`render`](#render), [`benchmark`](#benchmark) |
Expand Down Expand Up @@ -428,6 +428,30 @@ Needs a local Chrome, the same one `render` uses. Run
within a frame or two — a different headless-Chrome audio sample rate can shift
a beat slightly.

### `normalize-audio`

Match one authored audio clip to a reference using the exact local media bytes
and FFmpeg's integrated EBU R128 loudness measurement.

```bash
# Measure only (the default)
npx hyperframes normalize-audio --reference target-audio --target user-audio

# Persist the computed gain on #user-audio
npx hyperframes normalize-audio --reference target-audio --target user-audio --write

# Agent-readable result
npx hyperframes normalize-audio --reference target-audio --target user-audio --json
```

`--reference` and `--target` are `<audio>` element ids, with or without `#`.
The reference stays unchanged; the command accounts for both clips' current
`data-volume` and writes the target's absolute matched gain. It measures
`data-media-start` and `data-duration`, rejects remote or out-of-project sources,
and refuses a result that exceeds Studio's +12 dB ceiling or would clip. Dry-run
is deliberate: inspect the measurement before passing `--write`. Use
`--tolerance <LU>` to change the default 0.5 LU no-op threshold.

## Look at it

### `preview`
Expand All @@ -438,6 +462,8 @@ Start a live preview server with hot reload.
npx hyperframes preview [dir]
npx hyperframes preview --port 4567
npx hyperframes preview --background # keep running after the command exits
npx hyperframes preview --foreground # stay attached in a non-interactive shell
npx hyperframes preview --status --json # inspect a managed preview from an agent
npx hyperframes preview --list # every running preview
```

Expand All @@ -446,6 +472,8 @@ npx hyperframes preview --list # every running preview
| `--port` | Server port (default 3002) |
| `--open` / `--no-open` | Open a browser, or leave it closed |
| `--background` | Keep an embedded preview running after the command exits |
| `--foreground` | Keep the preview attached even when the shell is non-interactive |
| `--json` | Emit one versioned JSON result for managed start, status, stop, list, and kill-all operations |
| `--browser-gpu` / `--no-browser-gpu` | Hardware GPU for Studio thumbnails and frame capture, or deterministic SwiftShader (default: auto-detect) |
| `--proxy` / `--no-proxy` | Auto-transcode browser-hostile codecs (HEVC, ProRes, AV1) to a cached authoring proxy (default: on) |
| `--browser-path` | Open a specific browser. `--user-data-dir`, `--remote-debugging-port`, and `--browser-no-gpu` require it. |
Expand All @@ -455,6 +483,13 @@ background preview, `--list` and `--kill-all` act on all of them, and
`--force-new` starts a second server for a project that already has one. Each
exits straight after.

Bare `preview` chooses the safest lifecycle for its caller: it stays in the
foreground in a human interactive terminal, while a non-interactive or agent
shell starts a managed background preview. Re-running the command for the same
project reuses the healthy preview. Every start or status result includes the
exact Studio project URL as well as the underlying server URL, so agents can
hand off the intended project without guessing from the port.

To read a running Studio from a script: `--selection` prints the selected
element and `--context` prints the agent-readable context, both with `--json`.
Narrow the context with `--context-fields` (`server`, `selection`, `lint`,
Expand Down
24 changes: 23 additions & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,33 @@ Start the live preview studio in your browser:

```bash
npx hyperframes preview
# Studio running at http://localhost:3002
# Studio: http://localhost:3002/#project/my-video
# Server: http://localhost:3002

npx hyperframes preview --port 4567
```

In an interactive terminal, the preview stays attached until you press
Ctrl+C. In a non-interactive shell such as a coding-agent session, the same
command starts a managed preview that survives after the command returns. Use
`--background` or `--foreground` to choose explicitly, and manage persistent
previews with `--status`, `--stop`, `--list`, and `--kill-all`. Add `--json` to
managed lifecycle commands for machine-readable output. `--foreground --json`
prints the ready-session envelope once, then remains attached until stopped.

### `normalize-audio`

Measure two local authored audio clips with integrated LUFS and match the target
to the unchanged reference. The command is a dry run unless `--write` is passed:

```bash
npx hyperframes normalize-audio --reference target-audio --target user-audio
npx hyperframes normalize-audio --reference target-audio --target user-audio --write
```

It updates only the target element's `data-volume` and refuses unsafe boosts
that exceed Studio's +12 dB ceiling or would clip.

### `render`

Render a composition to MP4. Run from the project directory; the positional
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ const commandLoaders = {
lint: () => import("./commands/lint.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),
inspect: () => import("./commands/inspect.js").then((m) => m.default),
keyframes: () => import("./commands/keyframes.js").then((m) => m.default),
layout: () => import("./commands/layout.js").then((m) => m.default),
Expand Down
11 changes: 11 additions & 0 deletions packages/cli/src/commands/coreSkillContent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,4 +125,15 @@ describe("media treatment routing documentation", () => {
expect(template).toContain("do not improvise equivalent CSS/SVG filters or overlays");
}
});

it("gives agents a process-owned preview lifecycle in new project instructions", () => {
for (const file of ["AGENTS.md", "CLAUDE.md"]) {
const template = read("packages", "cli", "src", "templates", "_shared", file);
expect(template).toContain("npx hyperframes preview --background");
expect(template).toContain("npx hyperframes preview --status");
expect(template).toContain("npx hyperframes preview --stop");
expect(template).toContain("leaving refreshes at `ERR_CONNECTION_TIMED_OUT`");
expect(template).not.toContain("run_in_background: true");
}
});
});
133 changes: 133 additions & 0 deletions packages/cli/src/commands/normalize-audio.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
import {
audioNormalizationPlan,
audioTags,
parseEbur128Summary,
resolveLocalAudioPath,
updateAudioVolume,
} from "./normalize-audio.js";

const PROJECT = "/tmp/example-project";

describe("audioTags", () => {
it("reads quoted attributes without treating a quoted > as the end of the tag", () => {
const html = `<audio id='reference' title="a > b" src="assets/ref.mp4" data-volume="1"></audio>`;
expect(audioTags(html)).toEqual([
expect.objectContaining({ id: "reference", src: "assets/ref.mp4", volume: 1 }),
]);
});

it("rejects duplicate ids instead of normalizing an arbitrary element", () => {
const html = `<audio id="voice" src="a.wav"></audio><audio id="voice" src="b.wav"></audio>`;
expect(() => audioTags(html)).toThrow(/duplicate audio id "voice"/i);
});

it("ignores audio-like text in comments, scripts, and styles", () => {
const html = `
<!-- <audio id="comment" src="comment.wav"></audio> -->
<script>const example = '<audio id="script" src="script.wav"></audio>';</script>
<style>.demo::after { content: '<audio id="style" src="style.wav">'; }</style>
<audio id="real" src="real.wav"></audio>
`;
expect(audioTags(html).map((tag) => tag.id)).toEqual(["real"]);
});

it.each([
[`<audio id="bad" src="a.wav" data-volume="loud"></audio>`, /data-volume/i],
[`<audio id="bad" src="a.wav" data-media-start="-1"></audio>`, /data-media-start/i],
[`<audio id="bad" src="a.wav" data-duration="0"></audio>`, /data-duration/i],
])("rejects an invalid authored number", (html, expected) => {
expect(() => audioTags(html)).toThrow(expected);
});
});

describe("resolveLocalAudioPath", () => {
it("resolves a local source and strips query and fragment suffixes", () => {
expect(resolveLocalAudioPath(PROJECT, "assets/voice%20one.wav?v=2#clip")).toBe(
resolve(PROJECT, "assets/voice one.wav"),
);
});

it.each(["https://cdn.example.com/a.wav", "/etc/passwd", "../outside.wav", "data:x"])(
"rejects a non-project source: %s",
(src) => expect(() => resolveLocalAudioPath(PROJECT, src)).toThrow(/local project file/i),
);
});

describe("parseEbur128Summary", () => {
it("takes the final integrated loudness and true-peak summary", () => {
const stderr = `
[Parsed_ebur128_0] t: 1.0 I: -18.2 LUFS
[Parsed_ebur128_0] Summary:
Integrated loudness:
I: -15.5 LUFS
Threshold: -25.5 LUFS
True peak:
Peak: -3.2 dBFS
`;
expect(parseEbur128Summary(stderr)).toEqual({ integratedLufs: -15.5, truePeakDbfs: -3.2 });
});

it("fails when FFmpeg did not produce a usable summary", () => {
expect(() => parseEbur128Summary("no audio stream")).toThrow(/integrated loudness/i);
});
});

describe("audioNormalizationPlan", () => {
it("preserves the reference and attenuates the louder target", () => {
const plan = audioNormalizationPlan(
{ id: "target-audio", volume: 1, integratedLufs: -15.5, truePeakDbfs: -3.2 },
{ id: "user-audio", volume: 1, integratedLufs: -11.7, truePeakDbfs: -0.2 },
);

expect(plan.gainDb).toBeCloseTo(-3.8, 6);
expect(plan.volume).toBeCloseTo(0.645654, 5);
expect(plan.projectedLufs).toBeCloseTo(-15.5, 6);
expect(plan.projectedTruePeakDbfs).toBeCloseTo(-4, 6);
});

it("includes the reference's authored gain in the target", () => {
const plan = audioNormalizationPlan(
{ id: "reference", volume: 2, integratedLufs: -20, truePeakDbfs: -8 },
{ id: "target", volume: 0.5, integratedLufs: -18, truePeakDbfs: -6 },
);

expect(plan.referenceLufs).toBeCloseTo(-13.9794, 4);
expect(plan.volume).toBeCloseTo(1.588656, 6);
expect(plan.projectedLufs).toBeCloseTo(plan.referenceLufs, 6);
});

it("refuses a gain beyond Studio's +12 dB ceiling", () => {
expect(() =>
audioNormalizationPlan(
{ id: "reference", volume: 1, integratedLufs: -5, truePeakDbfs: -1 },
{ id: "target", volume: 1, integratedLufs: -30, truePeakDbfs: -30 },
),
).toThrow(/\+12 dB/i);
});

it("refuses a boost that would clip", () => {
expect(() =>
audioNormalizationPlan(
{ id: "reference", volume: 1, integratedLufs: -10, truePeakDbfs: -1 },
{ id: "target", volume: 1, integratedLufs: -15, truePeakDbfs: -2 },
),
).toThrow(/clip/i);
});
});

describe("updateAudioVolume", () => {
it("updates only the selected audio element and preserves surrounding source", () => {
const html = `<!doctype html>\n<audio id="ref" src="a.wav" data-volume="1"></audio>\n<audio data-volume='1' id='target' src='b.wav'></audio>\n`;
expect(updateAudioVolume(html, "target", 0.645654)).toBe(
`<!doctype html>\n<audio id="ref" src="a.wav" data-volume="1"></audio>\n<audio data-volume='0.645654' id='target' src='b.wav'></audio>\n`,
);
});

it("adds data-volume when it is absent", () => {
expect(updateAudioVolume(`<audio id="target" src="b.wav" />`, "target", 2)).toBe(
`<audio id="target" src="b.wav" data-volume="2" />`,
);
});
});
Loading
Loading