docs(skills): one owner doc for everyday edits and a hyperframes-studio skill (#4125)

* docs(skills): one owner doc for everyday edits and a hyperframes-studio skill

Agents got contradictory advice on volume fades and were blocked from splits
and copies by the Ask-agent guardrails.

- creator-editing-recipes.md is the single owner: adds align-to-event,
  copy-a-group and swap-media recipes and the one volume rule (use the lane)
- variables-and-media, data-attributes (skill and CLI copy), the gsap adapter
  and tracks-and-clips now defer to it
- Ask-agent guardrails allow the neighbour edits a split, retime, copy or swap
  requires and point at the owner doc
- new hyperframes-studio skill: sub-compositions for every scene, one caption
  track, one element kind per track, safe zones
- drift test lints every html example in the owner doc with the real linter
  and fails on any doc teaching a volume tween

* refactor(skills): drop unused tween allowlist from recipes drift test

* docs(skills): name the track kind attribute in the studio skill

* docs(skills): stop claiming lint rules that do not exist yet and fix track numbering

Review fixes:

- studio skill: drop the severity claim and the preview-overlay claim,
  group tracks by data-track-kind instead of numeric ranges that clashed
  with the recipes
- recipes: audio data-start is root time only in the root composition;
  scene-local inside a sub-composition file
- guardrails: timeline edits are exempt from the element-only and timing
  lines; the pointer names the skill reference, not a repo path
- montage doc defers to the volume lane; skill count is 21 everywhere
- drift test also scans the montage doc

* fix(skills): the ducking generator emits a volume lane and captions defer to the studio safe zones

- audio-duck.mjs prints a data-automation lane (clip-local times from the
  target data-start) instead of a timeline volume tween; --json adds the lane
- operations.md shows the generated lane; the drift test scans it
- embedded-captions safe-zone lines point at the studio skill, which owns
  the values

* chore(skills): regenerate the skills manifest after rebase

* docs(skills): remotion translation docs point volume ramps at the automation lane

* docs(skills): studio safe zones use the action-safe 90 and title-safe 80 boxes

* docs(skills): caption docs defer to the studio safe boxes

* chore(skills): regenerate the skills manifest after rebase

* fix(skills): studio skill names the safe margins; guardrail test matches the timeline-edit wording
This commit is contained in:
Miguel Ángel
2026-09-18 23:19:40 -04:00
committed by GitHub
parent 34644a1993
commit 2db126d4c8
28 changed files with 357 additions and 101 deletions
+1
View File
@@ -21,6 +21,7 @@
"./skills/hyperframes-creative",
"./skills/hyperframes-keyframes",
"./skills/hyperframes-registry",
"./skills/hyperframes-studio",
"./skills/media-use"
]
},
+1 -1
View File
@@ -8,7 +8,7 @@ This repo ships AI agent skills via [vercel-labs/skills](https://github.com/verc
```bash
npx hyperframes skills update # default: installs/refreshes the core set — workflows install on demand
npx hyperframes skills # all 20 published skills at once
npx hyperframes skills # all 21 published skills at once
npx skills add heygen-com/hyperframes # interactive picker (terminal only; repo-internal skills are excluded by default)
```
+2 -2
View File
@@ -4,11 +4,11 @@ Open-source video rendering framework: write HTML, render video.
## Skills
This repo ships 20 AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover. **Default to the core set**: the `/hyperframes` router installs each creation workflow on demand; install all 20 only when the user explicitly asks for the full set.
This repo ships 21 AI agent skills via [vercel-labs/skills](https://github.com/vercel-labs/skills). Install them before writing compositions — they encode framework-specific patterns that generic docs don't cover. **Default to the core set**: the `/hyperframes` router installs each creation workflow on demand; install all 21 only when the user explicitly asks for the full set.
```bash
npx hyperframes skills update # default: installs/refreshes the core set — workflows install on demand
npx hyperframes skills # all 20 published skills at once — only on explicit request
npx hyperframes skills # all 21 published skills at once — only on explicit request
npx skills add heygen-com/hyperframes # interactive picker (terminal only; --all also pulls the 6 repo-internal skills under .claude/skills)
npx skills add heygen-com/hyperframes --skill <name> # just one (bare name, no leading slash)
```
+3 -3
View File
@@ -41,7 +41,7 @@ Install the HyperFrames skills, then describe the video you want:
npx skills add heygen-com/hyperframes
```
> The picker opens with nothing pre-selected — the **Core Skills** group is all you need: the `/hyperframes` router installs each creation workflow on demand. Agents and non-interactive runs should use `npx hyperframes skills update` instead — it installs exactly the core set, whereas `skills add --all` installs all 20 published skills. The six repo-internal skills under `.claude/skills` / `.agents/skills` are excluded by default. For the full published set use `npx hyperframes skills`.
> The picker opens with nothing pre-selected — the **Core Skills** group is all you need: the `/hyperframes` router installs each creation workflow on demand. Agents and non-interactive runs should use `npx hyperframes skills update` instead — it installs exactly the core set, whereas `skills add --all` installs all 21 published skills. The six repo-internal skills under `.claude/skills` / `.agents/skills` are excluded by default. For the full published set use `npx hyperframes skills`.
>
> `skills add` resolves the skills.sh registry blob, which can lag `main` by hours. `npx hyperframes skills update` installs from the current `main`, so reach for it when you need the newest copy of a skill.
@@ -53,9 +53,9 @@ The skills teach agents the HyperFrames production loop: plan the video, write v
## Skills
HyperFrames ships 20 skills agents load on demand. Read `/hyperframes` first — it's the router and capability map; it picks a workflow for any "make me a…" request — video, deck, or composition port — and points to the domain skills below.
HyperFrames ships 21 skills agents load on demand. Read `/hyperframes` first — it's the router and capability map; it picks a workflow for any "make me a…" request — video, deck, or composition port — and points to the domain skills below.
Default to the **core set** — the router installs each creation workflow on demand. `npx hyperframes skills update` installs exactly that from anywhere; the interactive picker (`npx skills add heygen-com/hyperframes`) lists it as the "Core Skills" group, nothing pre-selected. The picker is interactive-only — a non-interactive or agent run without `--skill` installs all 20. Use `npx skills add heygen-com/hyperframes --all` to install all 20 deliberately (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).
Default to the **core set** — the router installs each creation workflow on demand. `npx hyperframes skills update` installs exactly that from anywhere; the interactive picker (`npx skills add heygen-com/hyperframes`) lists it as the "Core Skills" group, nothing pre-selected. The picker is interactive-only — a non-interactive or agent run without `--skill` installs all 21. Use `npx skills add heygen-com/hyperframes --all` to install all 20 deliberately (skips the picker), or `npx skills add heygen-com/hyperframes --skill <name>` for just one (bare name, no leading `/`).
Installs stay lean after that: `npx hyperframes init` keeps the **core set** fresh (the router, the `hyperframes-*` domain skills, and `media-use` — plus whatever is already installed; `/figma` stays on demand) and never expands a partial install; the creation workflows install **on demand** — the router runs `npx hyperframes skills update <workflow>` before entering one. Nothing re-pulls the full set behind your back.
+1 -1
View File
@@ -49,7 +49,7 @@
"player:perf": "bun run --filter @hyperframes/player perf",
"format:check": "oxfmt --check .",
"knip": "knip",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts && vitest run scripts/catalog/",
"test:scripts": "node --import tsx --test scripts/animejs-v4-guidance.test.mjs scripts/check-tracked-artifacts.test.mjs scripts/check-no-main-deletions.test.mjs scripts/check-docs-snippet-motion.test.mjs scripts/registry-target-paths.test.mjs scripts/check-workspace-contracts.test.mjs scripts/check-package-cycles.test.mjs scripts/check-cli-process-ownership.test.mjs scripts/check-large-files.test.mjs scripts/package-subpaths.test.mjs scripts/validate-release-channel.test.mjs scripts/publish-workflow.test.mjs scripts/install-workspace-dependencies.test.mjs scripts/draft-changelog.test.ts scripts/set-version.test.ts scripts/release-prepare.test.ts scripts/cli-options.test.ts scripts/changelog-weekly.test.ts scripts/claude-plugin-compression.test.ts scripts/catalog-payload-assets.test.ts scripts/host-registry-assets.test.ts scripts/catalog-preview-temp.test.ts scripts/catalog-hosted-files.test.ts scripts/player-cdn-pin.test.ts scripts/studio-runtime-smoke.test.mjs scripts/verify-packed-manifests.test.mjs scripts/lint-skills.test.mjs scripts/creator-editing-recipes.test.mjs packages/gcp-cloud-run/check-dockerfile-workspaces.test.mjs packages/core/scripts/writeGeneratedFile.test.ts && vitest run scripts/catalog/",
"typecheck:scripts": "tsc --noEmit -p scripts/tsconfig.json",
"test:skills": "node --test 'skills/**/*.test.mjs'",
"generate:previews": "tsx scripts/generate-template-previews.ts",
+1 -1
View File
@@ -11,7 +11,7 @@ Core attributes for controlling element timing and behavior.
## Media
- `data-media-start="2"` — Media playback offset / trim point (seconds)
- `data-volume="0.8"` — Audio/video gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB)
- `data-volume="0.8"` — Audio/video gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB). For fades and ducking use the `data-automation` volume lane (`hyperframes-core` skill, `creator-editing-recipes.md`)
- `data-has-audio="true"` — Indicates video has an audio track
## Composition
+1
View File
@@ -156,6 +156,7 @@ export const FALLBACK_CORE_SKILLS: readonly string[] = [
"hyperframes-creative",
"hyperframes-keyframes",
"hyperframes-registry",
"hyperframes-studio",
"media-use",
];
@@ -1103,7 +1103,7 @@ describe("patch builders and prompt builder", () => {
expect(prompt).toContain("Computed styles (browser-resolved):");
expect(prompt).toContain("Target HTML:");
expect(prompt).toContain("Guardrails:");
expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning.");
expect(prompt).toContain("Do not modify other elements' data-* attributes or positioning");
});
it("uses an absolute source path in copied agent prompts when provided", () => {
@@ -78,9 +78,10 @@ describe("buildElementAgentPrompt", () => {
"Text: Hello world",
"",
"Guardrails:",
"- Make a targeted change to this element only.",
"- Preserve the rest of the composition and its timing.",
"- Do not modify other elements' data-* attributes or positioning.",
"- Make a targeted change to this element only, unless the request is a timeline edit.",
"- Preserve the rest of the composition and its timing, except what a timeline edit changes.",
"- Do not modify other elements' data-* attributes or positioning, except where the requested timeline edit requires it (split, retime, reorder, copy a group, swap media).",
"- For timeline edits (trim, split, speed, volume, copy, swap), follow the creator-editing-recipes reference of the hyperframes-core skill and use its exact attribute forms.",
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
].join("\n"),
);
@@ -160,9 +161,10 @@ describe("buildPickerAgentPrompt", () => {
// Both end on the identical guardrails block, byte for byte.
const guardrails = [
"Guardrails:",
"- Make a targeted change to this element only.",
"- Preserve the rest of the composition and its timing.",
"- Do not modify other elements' data-* attributes or positioning.",
"- Make a targeted change to this element only, unless the request is a timeline edit.",
"- Preserve the rest of the composition and its timing, except what a timeline edit changes.",
"- Do not modify other elements' data-* attributes or positioning, except where the requested timeline edit requires it (split, retime, reorder, copy a group, swap media).",
"- For timeline edits (trim, split, speed, volume, copy, swap), follow the creator-editing-recipes reference of the hyperframes-core skill and use its exact attribute forms.",
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
].join("\n");
expect(domPrompt.endsWith(guardrails)).toBe(true);
@@ -28,9 +28,10 @@ export interface AgentPromptElementInfo {
const GUARDRAIL_LINES = [
"Guardrails:",
"- Make a targeted change to this element only.",
"- Preserve the rest of the composition and its timing.",
"- Do not modify other elements' data-* attributes or positioning.",
"- Make a targeted change to this element only, unless the request is a timeline edit.",
"- Preserve the rest of the composition and its timing, except what a timeline edit changes.",
"- Do not modify other elements' data-* attributes or positioning, except where the requested timeline edit requires it (split, retime, reorder, copy a group, swap media).",
"- For timeline edits (trim, split, speed, volume, copy, swap), follow the creator-editing-recipes reference of the hyperframes-core skill and use its exact attribute forms.",
"- Prefer existing inline styles or existing CSS rules for this element over adding unrelated selectors.",
];
+75
View File
@@ -0,0 +1,75 @@
import assert from "node:assert/strict";
import { readFile } from "node:fs/promises";
import test from "node:test";
import { lintHyperframeHtml } from "../packages/lint/src/index.ts";
const OWNER = "skills/hyperframes-core/references/creator-editing-recipes.md";
const STUDIO_SKILL = "skills/hyperframes-studio/SKILL.md";
const VOLUME_TWEEN = /\.(?:to|from|fromTo|set)\(\s*["'`]#[\w-]+["'`]\s*,\s*\{[^}]*\bvolume\s*:/;
const TWEEN_SCANNED = [
"skills/hyperframes-core/references/variables-and-media.md",
"skills/hyperframes-core/references/data-attributes.md",
"skills/hyperframes-animation/adapters/gsap.md",
"skills/hyperframes-audio/SKILL.md",
"skills/hyperframes-audio/references/attributes.md",
"skills/music-to-video/references/montage.md",
"skills/media-use/references/operations.md",
"packages/cli/src/docs/data-attributes.md",
];
const read = (path) => readFile(new URL(`../${path}`, import.meta.url), "utf8");
const htmlBlocks = (md) => [...md.matchAll(/```html\n([\s\S]*?)```/g)].map((m) => m[1]);
const wrap = (fragment) =>
fragment.includes("<html")
? fragment
: `<html><body><div id="root" data-composition-id="main" data-start="0" data-duration="120" data-width="1920" data-height="1080">${fragment}</div><script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script><script>window.__timelines = window.__timelines || {};</script></body></html>`;
test("every html example in the owner doc lints with no errors and no volume double-automation", async () => {
const blocks = htmlBlocks(await read(OWNER));
assert.ok(blocks.length >= 10, "expected the owner doc to keep its worked examples");
for (const [i, block] of blocks.entries()) {
const { findings } = await lintHyperframeHtml(wrap(block), { filePath: "index.html" });
const bad = findings.filter(
(f) => f.severity === "error" || f.code.startsWith("audio_volume_"),
);
assert.deepEqual(
bad.map((f) => `${f.code}: ${f.message}`),
[],
`example ${i + 1} in ${OWNER}`,
);
}
});
test("no other doc teaches a timeline tween as the way to fade volume", async () => {
for (const path of TWEEN_SCANNED) {
const lines = (await read(path)).split("\n");
const hit = lines.findIndex((l) => VOLUME_TWEEN.test(l));
assert.equal(hit, -1, `${path}:${hit + 1} teaches a volume tween; point at ${OWNER}`);
}
});
test("the Studio skill holds conventions only and points at the owner doc for edits", async () => {
const skill = await read(STUDIO_SKILL);
assert.match(skill, /creator-editing-recipes\.md/);
assert.equal(htmlBlocks(skill).length, 0, "a recipe restated in the Studio skill will drift");
});
test("the Remotion translation docs do not claim volume ramps are unsupported", async () => {
for (const path of [
"skills/remotion-to-hyperframes/references/limitations.md",
"skills/remotion-to-hyperframes/references/media.md",
]) {
assert.doesNotMatch(await read(path), /static `data-volume` only/, path);
}
});
test("the Studio skill's safe boxes equal the preview's", async () => {
const src = await read("packages/studio/src/utils/previewSafeMargins.ts");
const action = src.match(/ACTION_SAFE_PERCENT = (\d+)/)?.[1];
const title = src.match(/TITLE_SAFE_PERCENT = (\d+)/)?.[1];
const skill = await read(STUDIO_SKILL);
assert.match(skill, new RegExp(`Action-safe\\s*\\|\\s*${action}%`));
assert.match(skill, new RegExp(`Title-safe\\s*\\|\\s*${title}%`));
});
+11 -7
View File
@@ -2,7 +2,7 @@
"source": "heygen-com/hyperframes",
"skills": {
"embedded-captions": {
"hash": "4023b0b051ba1e75",
"hash": "f1b298aa899d86df",
"files": 142
},
"faceless-explainer": {
@@ -18,11 +18,11 @@
"files": 4
},
"hyperframes": {
"hash": "d0a114a8f894758e",
"hash": "ffacb7af2a96d788",
"files": 26
},
"hyperframes-animation": {
"hash": "4225f299e6ff9a0e",
"hash": "492f6f39edf2a8e5",
"files": 121
},
"hyperframes-audio": {
@@ -34,7 +34,7 @@
"files": 11
},
"hyperframes-core": {
"hash": "3e29cc2454953b05",
"hash": "054fe5577b064927",
"files": 11
},
"hyperframes-creative": {
@@ -49,8 +49,12 @@
"hash": "51e6dba95a8dfb45",
"files": 12
},
"hyperframes-studio": {
"hash": "b063eaa9eb1e4b9b",
"files": 1
},
"media-use": {
"hash": "4531919cdbda573e",
"hash": "8f21655cd07a62ea",
"files": 158
},
"motion-graphics": {
@@ -58,7 +62,7 @@
"files": 23
},
"music-to-video": {
"hash": "7fad41aa135aea23",
"hash": "e69f4646ef354be9",
"files": 169
},
"pr-to-video": {
@@ -70,7 +74,7 @@
"files": 30
},
"remotion-to-hyperframes": {
"hash": "65222f2d894e8776",
"hash": "c8eb6f48889f5c06",
"files": 77
},
"slideshow": {
@@ -121,9 +121,8 @@ Chunk at natural pauses ≥ 250ms. A caption spanning a breath-break feels wrong
### 17. Safe zones per platform, always
- 9:16 TikTok/IG/Shorts: caption zone `y ∈ [12%, 78%]` (bottom 22% is UI)
- 16:9 broadcast: title-safe = center 80%
- TV export: 5% margin on all sides
- Use the safe boxes in `/hyperframes-studio` (Safe zones) for wide and vertical
framings; that skill owns the values.
Bake into the layout solver. Never eyeball.
+1 -1
View File
@@ -17,7 +17,7 @@ with only the climax(es) promoted to embed. Rail is not a fallback — it's the
- **Lower third, horizontally centered.** Landscape (16:9): baseline ~80–120px above the
bottom edge. Portrait (9:16): lower-middle, ~ 600–700px from the bottom (clear of platform UI).
- **Title-safe margins:** keep text within ~90% width / inside any letterbox-pillarbox bars
- **Title-safe margins:** keep text within the title-safe box (80% of the frame, `/hyperframes-studio` Safe zones) / inside any letterbox-pillarbox bars
(see the letterbox probe). Never flush to the frame edge.
- **One caption group on screen at a time.** No accumulation, no cascade — that's embed-track behaviour.
- It rides above the subject; the matte does **not** occlude it (rail = in front).
@@ -61,7 +61,7 @@ HyperFrames is stricter than vanilla GSAP. Animate only:
- **Compositor-cheap**: `opacity`, `x`, `y`, `scale`, `scaleX`, `scaleY`, `rotation`, `rotationX`, `rotationY`, `skewX`, `skewY`, `transformOrigin`
- **Visual fills**: `color`, `backgroundColor`, `borderColor`, `borderRadius`
- **CSS variables**: `"--hue": 180` etc.
- **Media `volume`** (on `<audio>` / `<video>`): animate for fades/ducking, e.g. `tl.to("#bgm", { volume: 0, duration: 1 }, "outro")`. The runtime probes these keyframes from the timeline and drives them in both preview and render (they match). This sets the _author_ volume; `data-volume` is the static baseline when no tween touches the element.
- **Media `volume`** (on `<audio>` / `<video>`): do not tween it for fades or ducking; use the `data-automation` volume lane (`creator-editing-recipes.md` in `hyperframes-core`). A lane wins over a tween on the same element.
- **DOM text `innerText`** (for numeric counters): tween it directly, e.g. `tl.to(el, { innerText: 100, snap: { innerText: 1 } })` — `snap` keeps it integer; the GSAP inspector recognizes it as a counter. Equivalent to the `onUpdate`-proxy form in `../rules/counting-dynamic-scale.md`; prefer that proxy form for locale formatting (`toLocaleString`) or suffix logic, and pair it with a separate transform-scale tween when the number should grow.
**Avoid** (use the transform alias instead):
@@ -338,6 +338,8 @@ Timeline math: distinct tracks overlap by 0.5s with opposing opacity envelopes.
Timeline math: lane `t` is clip-local authored time: fade-in 0–1, duck down 2–2.2, hold 2.2–3, duck up 3–3.2, fade-out 4–5. Source math: source selection still uses core attributes. Audio follows: the explicit down-hold-up envelope affects this separate audio track. Owner: `/hyperframes-audio`. Limit: automation is not source retiming.
**One rule for volume over time: use the lane.** `lint` accepts a timeline tween on `volume` too, but when a track has both, the lane wins and the tween is ignored (`audio_volume_double_automation`). Never add a lane to a track that already has a `volume` tween, and never add a tween to a track that has a lane; edit the one that exists. To ramp 0.1 to 0.5 over ten seconds, write `{"t":0,"v":0.1},{"t":10,"v":0.5}`. `t` is seconds from the clip's own start, so a ramp past `data-duration` never finishes: check the clip's length before choosing the times. `data-volume` stays as the static level of the clip and combines with nothing else you author here.
## Audio alignment
```html
@@ -364,3 +366,74 @@ Timeline math: lane `t` is clip-local authored time: fade-in 0–1, duck down 2
```
Timeline math: picture and sound share start/duration. Source math: both consume four source seconds. Audio follows: identical timing, range, and rate on the separate audio track. Owner: `/hyperframes-core` + `/hyperframes-audio`. Limit: no waveform auto-sync or drift correction.
## Align a sound to an on-screen event
```html
<audio
id="sfx-click-3"
src="click.mp3"
data-start="7.48"
data-duration="0.07"
data-track-index="103"
data-volume="0.85"
></audio>
```
Timeline math: an audio element in the root composition has `data-start` in absolute root time; audio inside a scene file uses scene-local time and the host's `data-start` is added for you. An event inside a sub-composition happens at the host's `data-start` plus the event's local time in that sub-composition's own timeline, so `data-start = host start + local time`. Move only the audio's `data-start`; leave the picture alone. Source math: if the sound's transient is not at the file's first sample, subtract that lead-in from `data-start` (or trim it with `data-media-start`). Audio follows: nothing links audio to picture, so re-derive after every retime of the host. Owner: `/hyperframes-core`. Limit: no waveform auto-sync; for a beat grid use `hyperframes beats` and place each start on a beat time.
## Copy a group of clips to another time
```html
<audio
id="sfx-click-0"
src="click.mp3"
data-start="1.6"
data-duration="0.07"
data-track-index="100"
></audio>
<audio
id="sfx-type-0"
src="typenew.mp3"
data-start="7.8"
data-duration="0.57"
data-track-index="109"
></audio>
<audio
id="sfx-click-0-copy"
src="click.mp3"
data-start="41.6"
data-duration="0.07"
data-track-index="186"
></audio>
<audio
id="sfx-type-0-copy"
src="typenew.mp3"
data-start="47.8"
data-duration="0.57"
data-track-index="187"
></audio>
```
Timeline math: pick the clips first and say which ones you picked (by id) if the request does not match the file exactly; then add one `delta` to every member's `data-start`, so relative spacing is preserved (here `delta = 40`). Give each copy a new unique `id` and the next unused `data-track-index`; keep `src`, `data-duration`, `data-media-start`, `data-volume` and any `data-automation` as they are. Leave the originals untouched. Check the copies still end inside the composition's duration. Owner: `/hyperframes-core`. Limit: copies of a `<video>` or a sub-composition host follow the same rule, and a copied sub-composition needs its own host `id`.
## Swap a media file
```html
<video
id="hero"
src="assets/product-v2.mp4"
data-start="2"
data-duration="4"
data-media-start="0"
data-track-index="0"
muted
playsinline
></video>
```
Timeline math: change only `src`. Source math: reset `data-media-start` to the offset you want in the NEW file, and set `data-duration` no longer than the new file's remaining length (probe it with `ffprobe`). Audio follows: a separate `<audio>` that pointed at the old file needs the same `src` swap. Keep `id`, `data-start`, `data-track-index` and any `data-automation` so nothing else moves. Run `lint`: `audio_src_not_found` and `media_src_kind_mismatch` catch a wrong path or kind. Owner: `/hyperframes-core`. Limit: a still swapped for a video (or the reverse) is a tag change, not a swap.
## Split a section and change its speed
Timeline math: a section that is a sub-composition or a group of clips has no `data-playback-rate` of its own to set; split it by giving each half its own host or clips and shift everything after the cut by the length change. New length of a part = old length / rate. Every later `data-start` (clips, audio, root-timeline tweens) moves by the same delta. Source math: `<video>` and `<audio>` parts use `data-playback-rate` (0.1 to 5, constant) per the constant-speed recipe above, with matching audio. Limit: a speed ramp (a rate that changes within one clip) is not in the format; approximate with several constant-rate parts or preprocess a derived asset through `/media-use`. Say which you did, and do not invent a rate attribute.
@@ -26,15 +26,15 @@ The root should be `position: relative`, have explicit pixel dimensions, and hid
**Nesting is allowed.** A timed element inside a wrapper is still timed, and a timed ancestor clamps its descendants: a child cannot be visible while its timed ancestor is hidden. Direct children of the root get automatic layout (see "Root-level clips get automatic layout" below); nested ones do not, so give them their own positioning.
| Attribute | Required | Meaning |
| ------------------ | ----------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `id` | Yes on `<video>`/`<audio>`, else recommended | `lint` errors with `media_missing_id` on media without one, and an id-less `<audio>` is never mixed, so the render is **silent**. Elsewhere it is a warning (`studio_missing_editable_id`): Studio needs a stable edit target, and timeline targets reference it. |
| `data-start` | Yes | Start time in seconds, or a supported clip-time reference. This attribute is what marks the element as timed. |
| `data-duration` | Required for `div`, `img`, and sub-compositions | Duration in seconds. Video/audio can default to media duration when known. Without any resolvable duration the element has no end and stays visible for the rest of the composition. |
| `data-track-index` | No | Studio timeline lane, display only. The render never reads it, and clips on one track may overlap in time. Absent, the parser defaults it and Studio lays out one lane per clip. Two `<audio>` elements on the same index that overlap in time raise a `lint` warning. |
| `data-media-start` | No | Offset into the media source, in seconds. |
| `data-volume` | No | Static audio gain, default `1` (0 dB). `0` is silence and values above `1` boost, up to `3.98` (+12 dB) — Studio's fader writes this. For fades, animate `volume` on the timeline instead (see `variables-and-media.md`); a tween's own values replace this baseline entirely. |
| `data-has-audio` | No (`<video>` only) | `"true"` to declare the video carries an audio track when auto-detection would miss it. |
| Attribute | Required | Meaning |
| ------------------ | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `id` | Yes on `<video>`/`<audio>`, else recommended | `lint` errors with `media_missing_id` on media without one, and an id-less `<audio>` is never mixed, so the render is **silent**. Elsewhere it is a warning (`studio_missing_editable_id`): Studio needs a stable edit target, and timeline targets reference it. |
| `data-start` | Yes | Start time in seconds, or a supported clip-time reference. This attribute is what marks the element as timed. |
| `data-duration` | Required for `div`, `img`, and sub-compositions | Duration in seconds. Video/audio can default to media duration when known. Without any resolvable duration the element has no end and stays visible for the rest of the composition. |
| `data-track-index` | No | Studio timeline lane, display only. The render never reads it, and clips on one track may overlap in time. Absent, the parser defaults it and Studio lays out one lane per clip. Two `<audio>` elements on the same index that overlap in time raise a `lint` warning. |
| `data-media-start` | No | Offset into the media source, in seconds. |
| `data-volume` | No | Static audio gain, default `1` (0 dB). `0` is silence and values above `1` boost, up to `3.98` (+12 dB) — Studio's fader writes this. For fades and ducking, use the `data-automation` volume lane (see `creator-editing-recipes.md`). |
| `data-has-audio` | No (`<video>` only) | `"true"` to declare the video carries an audio track when auto-detection would miss it. |
**The visibility window is half-open: `[start, start + duration)`.** A clip shows while `start ≤ t < start + duration` and is hidden at exactly `t = start + duration`. Land an animation's resolved end state slightly **before** `data-duration`, not on it, or its last frame is never rendered. Two clips can therefore be authored back to back (`b.start === a.start + a.duration`) with no overlapping frame.
@@ -28,11 +28,7 @@ The one place the value carries meaning: two `<audio>` elements that share a tra
## Picking a Track Index
Purely a readability choice for whoever opens the file in Studio. Common patterns:
- **Track 0** — base video (e.g. an A-roll).
- **Track 1+** — visual scenes, overlays, captions.
- **Higher tracks (e.g. 10+)**: audio clips, separated from visual tracks.
Purely a readability choice for whoever opens the file in Studio. Common patterns, owned by `/hyperframes-studio` (one caption track, one element kind per track).
When adding a clip to an existing composition, set its `data-start`/`data-duration` against the clips around it. You do not need to hunt for a free lane, and you never need to renumber tracks after a retime.
@@ -100,7 +100,7 @@ Video elements must be muted and inline. Audio must be a separate `<audio>` elem
- **Never** add `crossorigin` to `<video>`/`<audio>`. `lint` rejects it unconditionally (`media_crossorigin_breaks_preview`, error) because a media host without `Access-Control-Allow-Origin` then fails silently in preview while renders still work, hiding the bug. There is no suppression, so this holds even for the canvas/WebGL/WebAudio readback case.
- **Every `<audio>` needs an `id`.** The mixer selects `audio[id][src]`, so an id-less `<audio>` is never mixed and the render is **silent**. `lint` catches it as `media_missing_id`.
- Audio always lives on a separate `<audio>` element — even if its source file is the same as a `<video>`. The `<video>` is muted; the `<audio>` carries sound.
- For volume fades, ducking and ramps, write a `data-automation` volume lane on the `<audio>` (`data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":0.1},{"t":10,"v":0.5}]}]}'`, `t` in seconds from the clip start; details in `hyperframes-audio`). Studio draws and edits that lane, and preview and render apply it identically. A GSAP `volume` tween on the same clip is ignored once a lane exists (`lint`: `audio_volume_double_automation`), so never author both; `data-volume` is the static baseline for a clip with no lane.
- For volume fades and ducking, use the `data-automation` volume lane; the exact form is in `creator-editing-recipes.md`. `data-volume` is the static baseline. A timeline `volume` tween is ignored when a lane is present.
For media duration: `<video>` and `<audio>` can omit `data-duration` if the media's intrinsic length is known and you want the full clip. Otherwise provide `data-duration` explicitly.
+75
View File
@@ -0,0 +1,75 @@
---
name: hyperframes-studio
description: >
Use when building or editing a HyperFrames project that people open in
Studio: how the timeline should be laid out so it reads well (one caption
track, one element kind per track, every scene a sub-composition) and where
captions and key content may sit (safe zones). Don't use for how to perform an
individual edit (split, trim, retime, volume, copy, swap): that is
`creator-editing-recipes.md` in `/hyperframes-core`.
---
# HyperFrames Studio conventions
Studio draws one timeline row per top-level element. A project that follows the
rules below opens as a short, readable timeline; one that does not opens as a wall
of unlabeled rows the user cannot edit. These are conventions for what to build.
For how to change a clip, follow `/hyperframes-core` `references/creator-editing-recipes.md`
and never invent a different form of the same edit.
## 1. Every scene is a sub-composition
The root composition holds only timed hosts, media and audio. Any scene with nested
structure (a div containing children, a title with a subtitle, a chart) is its own
file loaded with `data-composition-src`, wiring in
`references/sub-compositions.md`.
Nested markup left inside the root does not become a row of its own. It hides inside
one opaque row that cannot be trimmed or moved part by part.
Author as if a structure lint rejects any violation.
## 2. One caption track
- All captions live on one track: a single sub-composition host (one `data-track-index`)
marked `data-track-kind="captions"` that carries every caption group in order.
- Never one row per caption group, and never captions mixed onto a track with
another kind.
- Word-timing rules are unchanged: see `/embedded-captions` and the `caption_*` lint rules.
## 3. One element kind per track
Group by kind so each row is one thing the user can select, mute or drag as a set.
| Kind | `data-track-kind` |
| --------------------------------------- | ---------------------- |
| Base video / A-roll | `video` (from the tag) |
| Scenes, overlays, graphics | `graphics` |
| Captions | `captions` |
| Audio (voiceover, music, sound effects) | `audio` (from the tag) |
Put `data-track-kind` on sub-composition hosts. Video and audio kinds come from the tag, so
`<video>` and `<audio>` need no attribute. Give each kind its own `data-track-index`; the number
is display only; it never changes what renders on top. Use CSS for
layering.
## 4. Safe zones
Any ruler or safe-box overlay lives in the preview pane, never inside the composition, so
do not add guide elements to the HTML.
Both framings (wide and vertical) use the same two safe boxes, Premiere's defaults. The preview
toggle draws them with a tick at the midpoint of every edge. Source:
`ACTION_SAFE_PERCENT` and `TITLE_SAFE_PERCENT` in `packages/studio/src/utils/previewSafeMargins.ts`.
| Box | Share of the frame | Inset on every edge |
| ----------- | ------------------ | ------------------- |
| Action-safe | 90% | 5% |
| Title-safe | 80% | 10% |
- Safe margins: everything visible stays inside the action-safe box (90%), and captions and key content stay inside the title-safe box (80%).
- Two-up and 50/50 layouts keep each half's content inside the title-safe box.
## Checking your work
Run `hyperframes lint` and fix every finding. Then open the project in Studio and
check that the timeline shows a base row, one row per scene host, one caption row and the audio rows.
+9 -8
View File
@@ -100,14 +100,15 @@ Use the bare name without `/`. If the command fails, surface the error; do not r
Creator edit phrases are cross-domain requests. Load every skill named in the matching row:
| Creator request | Required domains |
| -------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| “cut this footage”, hard cut, trim, splice, reorder, or use a source range | `/general-video` + `/hyperframes-core`; core owns `data-start`, `data-duration`, `data-media-start`, and track layout. |
| zoom in here, punch-in / punch-out, smooth multi-state zoom or reframe, Ken Burns, or camera move | `/general-video` + `/hyperframes-core` + `/hyperframes-keyframes`; animate the inner visual/crop wrapper, not the timed clip. |
| match cut or whip pan camera transition | `/general-video` + `/hyperframes-animation` + `/hyperframes-keyframes` + `/hyperframes-registry`; search/install a transition primitive before hand-authoring. |
| fade, crossfade, track gain/volume, automation, duck/carve, audio effects, or one effect across several tracks | `/general-video` + `/hyperframes-core` + `/hyperframes-audio`; core places clips, audio mixes placed tracks — including a submix bus over a group of them. |
| picture and sound edits that combine cuts with camera motion or mixing | `/general-video` + `/hyperframes-core` + `/hyperframes-keyframes` when there is visual motion + `/hyperframes-audio` when sound is faded, mixed, ducked, automated, or processed. |
| source or generate media, or preprocess an unsupported speed ramp/mid-source freeze | `/media-use`; sourcing/generation/preprocessing only, never placed-track mixing. |
| Creator request | Required domains |
| ------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| “cut this footage”, hard cut, trim, splice, reorder, or use a source range | `/general-video` + `/hyperframes-core`; core owns `data-start`, `data-duration`, `data-media-start`, and track layout. |
| zoom in here, punch-in / punch-out, smooth multi-state zoom or reframe, Ken Burns, or camera move | `/general-video` + `/hyperframes-core` + `/hyperframes-keyframes`; animate the inner visual/crop wrapper, not the timed clip. |
| match cut or whip pan camera transition | `/general-video` + `/hyperframes-animation` + `/hyperframes-keyframes` + `/hyperframes-registry`; search/install a transition primitive before hand-authoring. |
| fade, crossfade, track gain/volume, automation, duck/carve, audio effects, or one effect across several tracks | `/general-video` + `/hyperframes-core` + `/hyperframes-audio`; core places clips, audio mixes placed tracks — including a submix bus over a group of them. |
| picture and sound edits that combine cuts with camera motion or mixing | `/general-video` + `/hyperframes-core` + `/hyperframes-keyframes` when there is visual motion + `/hyperframes-audio` when sound is faded, mixed, ducked, automated, or processed. |
| lay out a project so it reads well in Studio: caption track, tracks per element kind, sub-compositions, safe zones | `/hyperframes-studio` + `/hyperframes-core`; studio owns the layout conventions, core owns each edit. |
| source or generate media, or preprocess an unsupported speed ramp/mid-source freeze | `/media-use`; sourcing/generation/preprocessing only, never placed-track mixing. |
Constant `data-playback-rate` is render-safe for picture and pitch-preserved
sound. It does not make source speed ramps keyframeable; preprocess ramps.
+7 -7
View File
@@ -145,9 +145,10 @@ Use `--plan` first when you want to inspect the kept segment JSON before encodin
## Ducking (declare in-composition / bake for export)
B1, declare ducking in the composition. `audio-duck.mjs` emits GSAP volume
keyframes. Paste them into the composition timeline, the source file stays
untouched.
B1, declare ducking in the composition. `audio-duck.mjs` emits a volume lane
as a `data-automation` attribute. Add it to the background `<audio>` element;
the source file stays untouched. Lane times are clip-local, so pass
`--composition` to let the script subtract the element's `data-start`.
```bash
node <SKILL_DIR>/scripts/audio-duck.mjs \
@@ -156,10 +157,9 @@ node <SKILL_DIR>/scripts/audio-duck.mjs \
--composition index.html
```
```js
// auto-duck: #bgm under narration (generated; base volume 0.6)
tl.to("#bgm", { volume: 0.15, duration: 0.15 }, 3.42);
tl.to("#bgm", { volume: 0.6, duration: 0.4 }, 9.87);
```html
<!-- auto-duck: #bgm under narration; add to its <audio> element -->
data-automation='{"version":1,"lanes":[{"target":"volume","points":[{"t":0,"v":0.6},{"t":3.42,"v":0.6},{"t":3.57,"v":0.15},{"t":9.87,"v":0.15},{"t":10.27,"v":0.6}]}]}'
```
B2, bake ducking only for exported or standalone files.
+19 -27
View File
@@ -3,7 +3,7 @@
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { parseArgs } from "node:util";
import { duckKeyframes, speechSpans } from "./lib/duck.mjs";
import { duckKeyframes, duckLane, speechSpans } from "./lib/duck.mjs";
import { track } from "./lib/telemetry.mjs";
const { values: args } = parseArgs({
@@ -25,7 +25,7 @@ const { values: args } = parseArgs({
});
if (args.help) {
console.log(`media-use audio-duck — generate GSAP volume ducking keyframes
console.log(`media-use audio-duck — generate a volume ducking lane (data-automation)
Usage:
node audio-duck.mjs --meta audio_meta.json --target "#bgm"
@@ -40,8 +40,8 @@ Options:
--sequential Place multi-line meta back to back at composition time
--gap Extra seconds between sequential lines (default: 0)
--offsets Explicit placement, "l1=0,l2=3.4" (voice id = start seconds)
--composition Read target data-volume from this HTML file
--json Output { spans, keyframes }
--composition Read target data-volume and data-start from this HTML file
--json Output { spans, keyframes, lane }
--help, -h Show this help`);
process.exit(0);
}
@@ -59,7 +59,7 @@ function run() {
if (!args.meta || !args.target) throw new Error("--meta and --target are required");
const meta = JSON.parse(readFileSync(resolve(args.meta), "utf8"));
const target = args.target;
const baseVolume = readBaseVolume(args.composition, target);
const { baseVolume, clipStart } = readTargetAttrs(args.composition, target);
const offsets = args.offsets
? Object.fromEntries(
args.offsets.split(",").map((pair) => {
@@ -81,41 +81,33 @@ function run() {
baseVolume,
});
const lane = duckLane(keyframes, { clipStart, baseVolume });
if (args.json) {
console.log(JSON.stringify({ spans, keyframes }));
console.log(JSON.stringify({ spans, keyframes, lane }));
return;
}
console.log(
`// auto-duck: ${target} under narration (generated; base volume ${fmt(baseVolume)})`,
);
for (const keyframe of keyframes) {
console.log(
`tl.to(${JSON.stringify(target)}, { volume: ${fmt(keyframe.volume)}, duration: ${fmt(
keyframe.duration,
)} }, ${fmt(keyframe.time)});`,
);
}
console.log(`<!-- auto-duck: ${target} under narration; add to its <audio> element -->`);
console.log(`data-automation='${JSON.stringify(lane)}'`);
}
function readBaseVolume(composition, target) {
if (!composition || !target.startsWith("#")) return 1;
function readTargetAttrs(composition, target) {
if (!composition || !target.startsWith("#")) return { baseVolume: 1, clipStart: 0 };
const id = target.slice(1);
const html = readFileSync(resolve(composition), "utf8");
// ponytail: regex is enough here because this only reads one attribute from
// one user-authored composition element, not arbitrary HTML.
const tag = html.match(new RegExp(`<[^>]*\\bid=["']${escapeRegExp(id)}["'][^>]*>`, "i"))?.[0];
const raw = tag?.match(/\bdata-volume=["']([^"']+)["']/i)?.[1];
const volume = Number(raw);
return Number.isFinite(volume) ? volume : 1;
const attr = (name) => Number(tag?.match(new RegExp(`\\b${name}=["']([^"']+)["']`, "i"))?.[1]);
const volume = attr("data-volume");
const start = attr("data-start");
return {
baseVolume: Number.isFinite(volume) ? volume : 1,
clipStart: Number.isFinite(start) ? start : 0,
};
}
function escapeRegExp(value) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function fmt(n) {
return Number(n)
.toFixed(3)
.replace(/\.?0+$/, "");
}
+15
View File
@@ -63,6 +63,21 @@ export function duckKeyframes(
return keyframes.sort((a, b) => a.time - b.time);
}
/** Volume lane for `data-automation`: composition-time keyframes as clip-local ramps. */
export function duckLane(keyframes, { clipStart = 0, baseVolume = 1 } = {}) {
const start = finiteOr(clipStart, 0);
const points = [{ t: 0, v: round3(finiteOr(baseVolume, 1)) }];
const push = (t, v) => {
if (t > points.at(-1).t) points.push({ t: round3(t), v });
};
for (const kf of keyframes) {
const t = Math.max(0, kf.time - start);
push(t, points.at(-1).v);
push(t + kf.duration, kf.volume);
}
return { version: 1, lanes: [{ target: "volume", points }] };
}
function mergeIntervals(intervals, mergeGap) {
const sorted = intervals
.map((range) => ({ start: round3(range.start), end: round3(range.end) }))
+23 -1
View File
@@ -5,7 +5,7 @@ import { join, dirname } from "node:path";
import { tmpdir } from "node:os";
import { fileURLToPath } from "node:url";
import { test } from "node:test";
import { duckKeyframes, speechSpans } from "./duck.mjs";
import { duckKeyframes, duckLane, speechSpans } from "./duck.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const SCRIPT = join(HERE, "..", "audio-duck.mjs");
@@ -77,6 +77,28 @@ test("duckKeyframes shapes attack and release from base volume", () => {
);
});
test("duckLane turns composition-time keyframes into clip-local ramps", () => {
const keyframes = [
{ time: 3, volume: 0.15, duration: 0.15 },
{ time: 5, volume: 0.6, duration: 0.4 },
];
assert.deepEqual(duckLane(keyframes, { clipStart: 1, baseVolume: 0.6 }), {
version: 1,
lanes: [
{
target: "volume",
points: [
{ t: 0, v: 0.6 },
{ t: 2, v: 0.6 },
{ t: 2.15, v: 0.15 },
{ t: 4, v: 0.15 },
{ t: 4.4, v: 0.6 },
],
},
],
});
});
test("--json spans match --merge-gap semantics exactly", () => {
const dir = mkdtempSync(join(tmpdir(), "media-use-duck-"));
try {
+2 -3
View File
@@ -50,7 +50,6 @@ bed. Use when the user wants their footage present but the message must stay rea
## Deferred hook (not v1)
A clip that should play **its own sound** (interview cut, lyric clip) needs a sibling
`<audio>` mounted at the **root** by the assembler, with the BGM ducked under it (timeline
volume automation: `tl.to("#el-bgm",{volume:0.15,…},clipStart)` … `tl.to(…,{volume:0.9,…},
clipEnd)`). The frame-worker mounts no audio. Keep clips muted in v1; wire clip-audio +
`<audio>` mounted at the **root** by the assembler, with the BGM ducked under it (a
`data-automation` volume lane on the BGM, see `creator-editing-recipes.md` in `hyperframes-core`). The frame-worker mounts no audio. Keep clips muted in v1; wire clip-audio +
ducking only when the user asks.
@@ -31,9 +31,10 @@ Remotion accepts a function for `volume`:
<Audio src={...} volume={(f) => interpolate(f, [0, 30], [0, 1])} />
```
HF supports static `data-volume` only. Translation: bake the ramp into
the audio file at translation time using `ffmpeg afade`, OR drop the
ramp with a note. The dropped-ramp path produces audibly different
HF ramps volume with a `data-automation` volume lane on the `<audio>`
(form in `hyperframes-core/references/creator-editing-recipes.md`).
Translation: write the lane from the `interpolate` points, OR bake the ramp
into the audio file with `ffmpeg afade`. The dropped-ramp path produces audibly different
output but visually-identical video, so SSIM passes — just flag it.
### `<Loop>` with stateful children
@@ -120,10 +121,9 @@ a `TRANSLATION_NOTES.md` next to the output:
The following Remotion patterns were translated with caveats:
- `<Audio volume={(f) => ...}>` (line 15): volume ramp dropped — added
static `data-volume="0.5"`. To preserve the ramp, run
`ffmpeg -i music.wav -af "afade=t=in:st=0:d=1" music.faded.wav` and
swap the source file.
- `<Audio volume={(f) => ...}>` (line 15): volume ramp written as a
`data-automation` volume lane (see `creator-editing-recipes.md`); the
ffmpeg `afade` bake-in is the alternative.
- `<HeavyChart>` (line 30): translated as inline HTML. The original
React.lazy boundary was dropped — bundle size unchanged because HF
serves a single HTML file.
@@ -44,9 +44,9 @@ didn't specify trim.
<Audio src={staticFile("music.wav")} volume={(f) => interpolate(f, [0, 30], [0, 1])} />
```
HF supports static `data-volume` only for now. Volume ramps need to be
applied to the audio file at translation time (with ffmpeg `afade`) or the
ramp is dropped with a translation note.
Volume ramps become a `data-automation` volume lane on the `<audio>`
(form in `hyperframes-core/references/creator-editing-recipes.md`); `data-volume`
is the static baseline.
### Trim / playbackRate