Skip to content
Merged
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
3 changes: 2 additions & 1 deletion docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -916,7 +916,8 @@
"pages": [
"reference/html-schema",
"reference/color-grading",
"reference/audio-effects"
"reference/audio-effects",
"reference/speed-ramps"
]
},
{
Expand Down
2 changes: 1 addition & 1 deletion docs/reference/html-schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,7 @@ Audio has no visual lifecycle.
| Attribute | Applies to | Meaning |
| --- | --- | --- |
| `data-media-start` / `data-playback-start` | Video, audio, nested composition | Offset into the source file, used by trim and split. Two groups of readers disagree, so the right name depends on the element. **Read only `data-media-start`:** the timing compiler, the HTML parser, `hyperframes validate` (which only inspects `<audio>`), and the engine's audio mixer (which feeds ffmpeg `-ss`). **Read `data-playback-start` first, falling back to `data-media-start`:** the runtime player, Studio (which also writes it), and `hyperframes snapshot`. Because the audio mixer reads only `data-media-start`, a `<video>` authored with just `data-playback-start` renders a trimmed picture over untrimmed audio. Set the name by kind: **`<video>` / `<audio>` → `data-media-start`**; **nested composition → `data-playback-start`** — composition hosts are inspected only by the playback-start-first readers (the media-start-only ones are all `<video>`/`<audio>`-scoped). `data-media-start` still works there as a fallback, but `data-playback-start` is the canonical name Studio writes and normalises to for new composition hosts, so the other name works until an edit rewrites it (it is the [child-timeline offset](/concepts/compositions)). |
| `data-playback-rate` | Video, audio, nested composition | Playback multiplier from `0.1` to `5` |
| `data-playback-rate` | Video, audio, nested composition | Playback multiplier from `0.1` to `10`. A `rate` lane in `data-automation` overrides it; see [Speed ramps](/reference/speed-ramps) |
| `data-volume` | Video and audio | Static gain. `1` is 0 dB, `0` is silence, and values above `1` boost up to `3.98` (+12 dB) |
| `data-has-audio="true"` | Video | Declares that the video contributes audio |

Expand Down
66 changes: 66 additions & 0 deletions docs/reference/speed-ramps.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
---
title: "Speed ramps"
sidebarTitle: "Speed ramps"
description: "Change a clip's playback speed over time with a rate lane in data-automation. One curve drives the preview, the render and Studio."
---

A speed ramp changes how fast a `<video>` or `<audio>` clip plays while it
plays: slow into a beat, fast out of it. It is a `rate` lane in
[`data-automation`](/reference/audio-effects#the-four-attributes), the same
attribute that carries volume envelopes.

```html
<video
src="clip.mp4"
data-start="0"
data-duration="4"
data-track-index="0"
data-automation='{"version":1,"lanes":[{"target":"rate","points":[{"t":0,"v":1},{"t":2,"v":4},{"t":4,"v":0.5}]}]}'
></video>
```

## The lane

| Field | Meaning |
| --- | --- |
| `target` | `"rate"` |
| `points[].t` | Seconds from the start of the clip, not from the start of the composition |
| `points[].v` | Speed multiplier. `1` is normal, `2` is twice as fast. Clamped to `0.1` to `10` |
| `points[].curve` | Optional, `-1` to `1`. Bends the segment leaving this point |

Speed moves between points on a logarithmic scale, so going from `0.5` to `2`
passes through `1` at the midpoint. A lane wins over the constant
`data-playback-rate` on the same element. Without a lane, the constant applies.

## What stays the same everywhere

The source frame shown at each moment comes from one function, `sourceTimeAt`,
which the preview and the renderer both use, so the render shows the frames the
preview showed. Set `data-duration` on the clip to fix how long it holds the
timeline; without it the length follows from the source and the speed.

Pitch is kept. The preview leaves the browser's default pitch correction on.
The render time-stretches audio in short constant-speed slices (a quarter
second, coarser on clips longer than a minute), which approximates the curve
while keeping pitch, so the sound follows the ramp closely rather than exactly.

## Presets

Studio's **Speed preset** menu draws a ready curve over the clip: Montage,
Hero, Bullet, Jump cut, Flash in and Flash out. An agent can write the same
points by hand.

## Editing in Studio

Select a clip and open its Media section. The **Speed** slider sets the
constant rate. The **A** toggle next to it automates speed, which shows a rate
lane on the clip in the timeline that you edit like the volume lane.

## Limits

- Only `<video>` and `<audio>` clips carry a rate lane.
- Speeds outside `0.1` to `10` are clamped.
- The viewer's own transport speed is a separate control that multiplies with
the clip's speed, and it is narrower: the Studio speed menu offers `0.25` to
`2`, the player's menu offers up to `4`, and the runtime clamps a transport
request to `0.1` to `5`.
38 changes: 35 additions & 3 deletions packages/cli/src/commands/snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { describe, expect, it, vi } from "vitest";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { tmpdir } from "node:os";
import { sourceTimeAt } from "@hyperframes/core";

const snapshotState = vi.hoisted(() => ({
openSettledPage: vi.fn(async () => {
Expand Down Expand Up @@ -29,7 +30,7 @@ import snapshotCommand, {
requireSnapshotFfmpeg,
resolveSnapshotVideoClipStart,
resolveSnapshotVideoFrameTime,
resolveSnapshotVideoPlaybackRate,
resolveSnapshotVideoRateSpec,
tailFrameTime,
} from "./snapshot.js";

Expand Down Expand Up @@ -274,9 +275,40 @@ describe("resolveSnapshotVideoClipStart", () => {
});
});

describe("resolveSnapshotVideoPlaybackRate", () => {
describe("resolveSnapshotVideoRateSpec", () => {
it("prefers the authored data-playback-rate over the browser default", () => {
expect(resolveSnapshotVideoPlaybackRate({ authoredRate: "1.8", defaultRate: 1 })).toBe(1.8);
expect(resolveSnapshotVideoRateSpec({ authoredRate: "1.8", defaultRate: 1 })).toBe(1.8);
});

it("falls back to the browser default when the authored rate is invalid", () => {
expect(resolveSnapshotVideoRateSpec({ authoredRate: "abc", defaultRate: 2 })).toBe(2);
expect(resolveSnapshotVideoRateSpec({ authoredRate: "0", defaultRate: 2 })).toBe(2);
});

it("allows rates up to the shared 10x bound", () => {
expect(resolveSnapshotVideoRateSpec({ authoredRate: "8", defaultRate: 1 })).toBe(8);
});

it("maps a frame through a rate lane instead of the constant", () => {
const lane = JSON.stringify({
version: 1,
lanes: [
{
target: "rate",
points: [
{ t: 0, v: 1 },
{ t: 2, v: 3 },
],
},
],
});
const spec = resolveSnapshotVideoRateSpec({
authoredRate: "1",
authoredAutomation: lane,
defaultRate: 1,
});
expect(typeof spec).toBe("object");
expect(sourceTimeAt(spec, 2)).toBeCloseTo(3.641, 2);
});
});

Expand Down
36 changes: 26 additions & 10 deletions packages/cli/src/commands/snapshot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ import {
seekCompositionTimeline,
type ZoomTarget,
} from "../capture/captureCompositionFrame.js";
import {
readElementRateSpec,
sourceTimeAt,
timeAtSourceTime,
type RateSpec,
} from "@hyperframes/core";
import { resolveProject } from "../utils/project.js";
import {
definitiveEntryMismatchComposition,
Expand Down Expand Up @@ -110,16 +116,22 @@ export function resolveSnapshotVideoClipStart(input: {
return input.runtimeResolvedStart ?? input.authoredStart;
}

/** Match runtime/render timing: authored data-playback-rate wins over the
* browser default, then the effective rate is clamped to the supported range. */
export function resolveSnapshotVideoPlaybackRate(input: {
/** Match runtime/render timing: a `rate` lane in data-automation wins, then the authored
* data-playback-rate, then the browser default, all through the runtime's own reader. */
export function resolveSnapshotVideoRateSpec(input: {
authoredRate: string | undefined;
authoredAutomation?: string | undefined;
defaultRate: number;
}): number {
}): RateSpec {
const authoredRate = Number.parseFloat(input.authoredRate ?? "");
const rawRate =
Number.isFinite(authoredRate) && authoredRate > 0 ? authoredRate : input.defaultRate;
return Number.isFinite(rawRate) && rawRate > 0 ? Math.max(0.1, Math.min(5, rawRate)) : 1;
const attrs: Record<string, string | undefined> = {
"data-playback-rate":
Number.isFinite(authoredRate) && authoredRate > 0
? input.authoredRate
: String(input.defaultRate),
"data-automation": input.authoredAutomation,
};
return readElementRateSpec({ getAttribute: (name) => attrs[name] ?? null });
}

export function requireSnapshotFfmpeg(ffmpegPath: string | undefined): string {
Expand Down Expand Up @@ -454,6 +466,7 @@ async function captureSnapshots(
src: v.currentSrc || v.src,
authoredStart,
authoredRate: v.dataset.playbackRate,
authoredAutomation: v.dataset.automation,
defaultRate: v.defaultPlaybackRate,
runtimeResolvedStart:
runtimeResolvedStart !== undefined && Number.isFinite(runtimeResolvedStart)
Expand All @@ -469,13 +482,16 @@ async function captureSnapshots(
});
const active = candidates.flatMap((candidate) => {
const start = resolveSnapshotVideoClipStart(candidate);
const playbackRate = resolveSnapshotVideoPlaybackRate(candidate);
const playbackRate = resolveSnapshotVideoRateSpec(candidate);
const duration =
candidate.authoredDuration ??
(candidate.srcDuration > 0
? Math.max(0, (candidate.srcDuration - candidate.mediaStart) / playbackRate)
? Math.max(
0,
timeAtSourceTime(playbackRate, candidate.srcDuration - candidate.mediaStart),
)
: Number.POSITIVE_INFINITY);
let relTime = (time - start) * playbackRate + candidate.mediaStart;
let relTime = sourceTimeAt(playbackRate, time - start) + candidate.mediaStart;
if (
candidate.loop &&
candidate.srcDuration > candidate.mediaStart &&
Expand Down
6 changes: 6 additions & 0 deletions packages/core/package-subpaths.json
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,12 @@
"types": "./dist/audioGain.d.ts",
"environments": ["browser", "bun", "node"]
},
"./speed-ramp": {
"source": "./src/speedRamp.ts",
"runtime": "./dist/speedRamp.js",
"types": "./dist/speedRamp.d.ts",
"environments": ["browser", "bun", "node"]
},
"./color-grading": {
"source": "./src/colorGrading.ts",
"runtime": "./dist/colorGrading.js",
Expand Down
10 changes: 10 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,12 @@
"import": "./src/audioGain.ts",
"types": "./src/audioGain.ts"
},
"./speed-ramp": {
"bun": "./src/speedRamp.ts",
"node": "./dist/speedRamp.js",
"import": "./src/speedRamp.ts",
"types": "./src/speedRamp.ts"
},
"./color-grading": {
"bun": "./src/colorGrading.ts",
"node": "./dist/colorGrading.js",
Expand Down Expand Up @@ -540,6 +546,10 @@
"import": "./dist/audioGain.js",
"types": "./dist/audioGain.d.ts"
},
"./speed-ramp": {
"import": "./dist/speedRamp.js",
"types": "./dist/speedRamp.d.ts"
},
"./color-grading": {
"import": "./dist/colorGrading.js",
"types": "./dist/colorGrading.d.ts"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -250,8 +250,8 @@ describe("FlatMediaSection — volume/rate/media-start", () => {
rateTrack.dispatchEvent(new MouseEvent("pointerdown", { bubbles: true, clientX: 100 }));
rateTrack.dispatchEvent(new MouseEvent("pointerup", { bubbles: true, clientX: 100 }));
});
// min=25, max=300, ratio=1.0 -> raw=300 -> commit(300) -> 300/100=3 -> "3"
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "3");
// the speed slider is log-scaled 0.1x..10x, so the far end of the track is 10x
expect(onSetAttribute).toHaveBeenCalledWith("playback-rate", "10");
act(() => root.unmount());
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ import {
import { FlatSelectRow, FlatSlider } from "./propertyPanelFlatPrimitives";
import { FlatToggle } from "./propertyPanelFlatToggle";
import { AutomationToggle } from "./propertyPanelFxControls";
import { RATE_RANGE } from "@hyperframes/core/audio-automation";
import { type SpeedPresetId } from "@hyperframes/core/speed-ramp";
import { SPEED_PRESET_OPTIONS, type RateBinding } from "./useVolumeAutomation";
import { fromUnit, toUnit } from "../../player/components/automationLaneGeometry";
import {
AUDIO_GAIN_FADER_MAX,
AUDIO_GAIN_FADER_MIN,
Expand All @@ -36,6 +40,7 @@ export function FlatMediaSection({
onRemoveVolumeAutomation,
onCommitVolumeAt,
automatedVolumeValue,
rate,
}: {
projectDir: string | null;
element: DomEditSelection;
Expand All @@ -49,6 +54,8 @@ export function FlatMediaSection({
onRemoveVolumeAutomation?: () => void;
onCommitVolumeAt?: (v: number) => void;
automatedVolumeValue?: number;
/** Speed lane binding and presets; absent outside the Studio panel. */
rate?: RateBinding;
onRemoveBackground?: (
inputPath: string,
options: {
Expand Down Expand Up @@ -77,7 +84,9 @@ export function FlatMediaSection({
Number.parseFloat(
element.dataAttributes["media-start"] ?? element.dataAttributes["playback-start"] ?? "0",
) || 0;
const playbackRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
const constantRate = Number.parseFloat(element.dataAttributes["playback-rate"] ?? "1") || 1;
const playbackRate =
rate?.automated && rate.automatedValue !== undefined ? rate.automatedValue : constantRate;
const sourceDuration =
Number.parseFloat(element.dataAttributes["source-duration"] ?? "") ||
(el as HTMLMediaElement).duration ||
Expand Down Expand Up @@ -259,17 +268,42 @@ export function FlatMediaSection({
}
/>
</div>
<FlatSlider
label="Rate"
value={playbackRate * 100}
min={25}
max={300}
tier={playbackRate === 1 ? "default" : "explicitCustom"}
displayValue={`${formatNumericValue(playbackRate)}x`}
onCommit={(next) =>
void onSetAttribute("playback-rate", formatNumericValue(next / 100))
}
/>
<div className="flex items-center gap-1">
<div className="min-w-0 flex-1">
<FlatSlider
label="Speed"
value={Math.round(toUnit(RATE_RANGE, playbackRate) * 1000)}
min={0}
max={1000}
tier={playbackRate === 1 ? "default" : "explicitCustom"}
displayValue={`${formatNumericValue(playbackRate)}x`}
onCommit={(next) => {
const speed = fromUnit(RATE_RANGE, next / 1000);
if (rate?.automated) {
rate.onCommitAt(speed);
} else {
void onSetAttribute("playback-rate", formatNumericValue(speed));
}
}}
/>
</div>
<AutomationToggle
paramKey="rate"
label="Speed"
automated={Boolean(rate?.automated)}
onAutomate={rate ? () => rate.onAutomate() : undefined}
onRemoveAutomation={rate ? () => rate.onRemoveAutomation() : undefined}
/>
</div>
{rate?.canApplyPreset && (
<FlatSelectRow
label="Speed preset"
value=""
options={[{ value: "", label: "Choose…" }, ...SPEED_PRESET_OPTIONS]}
tier="default"
onChange={(id) => id && rate.onApplyPreset(id as SpeedPresetId)}
/>
)}
<FlatSlider
label="Media start"
value={Math.round(mediaStart * 100)}
Expand Down
Loading
Loading