From 17d5fa16739257b05230b9c07b18483050419c39 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 23:07:49 -0400 Subject: [PATCH 1/7] feat(studio): edit a clip's speed ramp with a rate lane and presets --- packages/core/package-subpaths.json | 6 ++ packages/core/package.json | 10 +++ .../propertyPanelFlatMediaSection.test.tsx | 4 +- .../editor/propertyPanelFlatMediaSection.tsx | 58 ++++++++++++---- .../editor/useVolumeAutomation.test.tsx | 61 +++++++++++++++++ .../components/editor/useVolumeAutomation.ts | 67 ++++++++++++++----- 6 files changed, 176 insertions(+), 30 deletions(-) diff --git a/packages/core/package-subpaths.json b/packages/core/package-subpaths.json index d3153d5762..6a390f486a 100644 --- a/packages/core/package-subpaths.json +++ b/packages/core/package-subpaths.json @@ -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", diff --git a/packages/core/package.json b/packages/core/package.json index def644a583..69288f6abf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -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", @@ -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" diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx index c15a46b03f..4aa0bcdd9d 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.test.tsx @@ -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()); }); diff --git a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx index 52aacdf70b..f5bc6ba58f 100644 --- a/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx +++ b/packages/studio/src/components/editor/propertyPanelFlatMediaSection.tsx @@ -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, @@ -36,6 +40,7 @@ export function FlatMediaSection({ onRemoveVolumeAutomation, onCommitVolumeAt, automatedVolumeValue, + rate, }: { projectDir: string | null; element: DomEditSelection; @@ -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: { @@ -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 || @@ -259,17 +268,42 @@ export function FlatMediaSection({ } /> - - void onSetAttribute("playback-rate", formatNumericValue(next / 100)) - } - /> +
+
+ { + const speed = fromUnit(RATE_RANGE, next / 1000); + if (rate?.automated) { + rate.onCommitAt(speed); + } else { + void onSetAttribute("playback-rate", formatNumericValue(speed)); + } + }} + /> +
+ rate.onAutomate() : undefined} + onRemoveAutomation={rate ? () => rate.onRemoveAutomation() : undefined} + /> +
+ {rate && ( + id && rate.onApplyPreset(id as SpeedPresetId)} + /> + )} { expect(writtenAutomation(onSetAttributeQuiet).lanes[0].points[0].v).toBe(1); }); }); + +describe("useVolumeAutomation speed lane", () => { + const rateLane = JSON.stringify({ + version: 1, + lanes: [ + { + target: "rate", + points: [ + { t: 0, v: 1 }, + { t: 4, v: 4 }, + ], + }, + ], + }); + + it("seeds a rate lane at the clip's constant rate so automating does not change the speed", () => { + const { binding, onSetAttributeQuiet } = bind({ "playback-rate": "2" }); + binding.rate.onAutomate(); + expect(writtenAutomation(onSetAttributeQuiet).lanes).toEqual([ + { target: "rate", points: [{ t: 0, v: 2 }] }, + ]); + }); + + it("reports the lane's geometric value at the playhead", () => { + // 1x to 4x over 4s: halfway is 2x, not the linear 2.5x + const { binding } = bind({ automation: rateLane, start: "0", duration: "4" }, 2); + expect(binding.rate.automated).toBe(true); + expect(binding.rate.automatedValue).toBeCloseTo(2, 5); + }); + + it("writes a keyframe on the rate lane and leaves the volume lane alone", () => { + const both = JSON.stringify({ + version: 1, + lanes: [ + { target: "volume", points: [{ t: 0, v: 0.5 }] }, + { target: "rate", points: [{ t: 0, v: 1 }] }, + ], + }); + const { binding, onSetAttributeQuiet } = bind( + { automation: both, start: "0", duration: "4" }, + 2, + ); + binding.rate.onCommitAt(3); + const lanes = writtenAutomation(onSetAttributeQuiet).lanes; + expect(lanes.find((l: { target: string }) => l.target === "volume").points).toEqual([ + { t: 0, v: 0.5 }, + ]); + expect(lanes.find((l: { target: string }) => l.target === "rate").points).toContainEqual({ + t: 2, + v: 3, + }); + }); + + it("stretches a preset over the clip's duration", () => { + const { binding, onSetAttributeQuiet } = bind({ start: "0", duration: "8" }); + binding.rate.onApplyPreset("flash-out"); + const lane = writtenAutomation(onSetAttributeQuiet).lanes[0]; + expect(lane.target).toBe("rate"); + expect(lane.points.at(-1).t).toBe(8); + }); +}); diff --git a/packages/studio/src/components/editor/useVolumeAutomation.ts b/packages/studio/src/components/editor/useVolumeAutomation.ts index b7a7c24156..0bc0b25be8 100644 --- a/packages/studio/src/components/editor/useVolumeAutomation.ts +++ b/packages/studio/src/components/editor/useVolumeAutomation.ts @@ -1,13 +1,15 @@ /** - * The volume lane's state and edits for the media section. + * The media section's lane state and edits: volume and speed. * - * Volume lives in a different panel section from the FX chain, but is automated - * the same way, so it reads and writes through the same helper the FX group uses + * Both live in a different panel section from the FX chain but are automated + * the same way, so they read and write through the same helper the FX group uses * rather than a second interpretation of the attribute. */ import { HF_AUDIO_AUTOMATION_DATA_KEY, + RATE_TARGET, + resolveAutomationRange, sampleAutomationLane, VOLUME_TARGET, } from "@hyperframes/core/audio-automation"; @@ -16,30 +18,46 @@ import { automationAttrValue, HF_AUDIO_AUTOMATION_ATTR, readPanelAutomation, + withLane, withoutLane, withPointAt, withSeededLane, } from "./propertyPanelAutomation"; import { deriveElementTiming } from "./propertyPanelFlatTimingDerivation"; +import { SPEED_PRESETS, speedPresetLane, type SpeedPresetId } from "@hyperframes/core/speed-ramp"; import { clampNumber } from "../../utils/studioHelpers"; +export interface LaneBinding { + automated: boolean; + onAutomate: () => void; + onRemoveAutomation: () => void; + /** Write `v` as a keyframe at the playhead instead of the disabled fallback. */ + onCommitAt: (v: number) => void; + /** The envelope's own value at the playhead, so the slider tracks it live. */ + automatedValue: number | undefined; +} + +export type RateBinding = LaneBinding & { onApplyPreset: (id: SpeedPresetId) => void }; + export interface VolumeAutomationBinding { volumeAutomated: boolean; onAutomateVolume: () => void; onRemoveVolumeAutomation: () => void; - /** Write `v` as a keyframe at the playhead instead of the disabled fallback. */ onCommitVolumeAt: (v: number) => void; - /** The envelope's own value at the playhead, so the slider tracks it live. */ automatedVolumeValue: number | undefined; + /** Speed: the `rate` lane, plus the CapCut-style presets that seed it. */ + rate: RateBinding; } +export const SPEED_PRESET_OPTIONS = SPEED_PRESETS.map(({ id, label }) => ({ value: id, label })); + export function useVolumeAutomation( element: DomEditSelection, currentTime: number, onSetAttributeQuiet: (attr: string, value: string | null) => void | Promise, ): VolumeAutomationBinding { - // The chain is not needed to resolve a volume lane — volume is always a valid - // target — so this deliberately does not parse it. + // The chain is not needed to resolve a volume or rate lane: both are always valid + // targets, so this deliberately does not parse it. const automation = readPanelAutomation( element.dataAttributes?.[HF_AUDIO_AUTOMATION_DATA_KEY], undefined, @@ -55,21 +73,38 @@ export function useVolumeAutomation( // playing track, while the same click on an effect parameter did not. void onSetAttributeQuiet(HF_AUDIO_AUTOMATION_ATTR, automationAttrValue(next) || null); }; + const laneBinding = (target: string, seed: number): LaneBinding => { + const lane = automation.lanes.find((l) => l.target === target); + return { + automated: lane !== undefined, + automatedValue: lane + ? sampleAutomationLane(lane, clipTimeSec, resolveAutomationRange(target, undefined)?.scale) + : undefined, + // Seeded at the level the control already shows, so automating does not change it. + onAutomate: () => write(withSeededLane(automation, target, seed)), + onRemoveAutomation: () => write(withoutLane(automation, target)), + onCommitAt: (v: number) => write(withPointAt(automation, target, clipTimeSec, v)), + }; + }; // `??` alone would let an empty `data-volume` through as Number("") === 0, so // automating the track would seed its lane at silence. The engine reads the same // empty value as unity. const raw = element.dataAttributes?.["volume"]; const parsed = raw ? Number(raw) : 1; const current = Number.isFinite(parsed) ? parsed : 1; - const volumeLane = automation.lanes.find((lane) => lane.target === VOLUME_TARGET); + const volume = laneBinding(VOLUME_TARGET, current); + const rateAttr = Number.parseFloat(element.dataAttributes?.["playback-rate"] ?? ""); + const rate = laneBinding(RATE_TARGET, Number.isFinite(rateAttr) && rateAttr > 0 ? rateAttr : 1); return { - volumeAutomated: volumeLane !== undefined, - automatedVolumeValue: volumeLane ? sampleAutomationLane(volumeLane, clipTimeSec) : undefined, - // Seeded at the level the slider already shows, so automating the track does - // not change how loud it is. - onAutomateVolume: () => - write(withSeededLane(automation, VOLUME_TARGET, Number.isFinite(current) ? current : 1)), - onRemoveVolumeAutomation: () => write(withoutLane(automation, VOLUME_TARGET)), - onCommitVolumeAt: (v: number) => write(withPointAt(automation, VOLUME_TARGET, clipTimeSec, v)), + volumeAutomated: volume.automated, + automatedVolumeValue: volume.automatedValue, + onAutomateVolume: volume.onAutomate, + onRemoveVolumeAutomation: volume.onRemoveAutomation, + onCommitVolumeAt: volume.onCommitAt, + rate: { + ...rate, + onApplyPreset: (id) => + write(withLane(automation, speedPresetLane(id, elDuration > 0 ? elDuration : 1))), + }, }; } From 226dadde780c9b5fbc67376af5c96b6f5f03e081 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 01:14:03 -0400 Subject: [PATCH 2/7] fix(studio): keep the live preview attribute in sync with what gets persisted --- .../hooks/useDomEditAttributeCommits.test.ts | 23 +++++++++++++++++++ .../src/hooks/useDomEditAttributeCommits.ts | 17 +++++++++++--- packages/studio/src/utils/sourcePatcher.ts | 2 +- 3 files changed, 38 insertions(+), 4 deletions(-) create mode 100644 packages/studio/src/hooks/useDomEditAttributeCommits.test.ts diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.test.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.test.ts new file mode 100644 index 0000000000..6382a35161 --- /dev/null +++ b/packages/studio/src/hooks/useDomEditAttributeCommits.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { resolveOptimisticAttributeValue } from "./useDomEditAttributeCommits"; + +describe("resolveOptimisticAttributeValue", () => { + it("removes an HTML boolean attribute when the value is the literal string false", () => { + expect(resolveOptimisticAttributeValue("loop", "false")).toBeNull(); + expect(resolveOptimisticAttributeValue("muted", "false")).toBeNull(); + }); + + it("keeps the literal string false for a non-boolean attribute, matching what persist() writes", () => { + expect(resolveOptimisticAttributeValue("data-example", "false")).toBe("false"); + }); + + it("always removes on null regardless of attribute", () => { + expect(resolveOptimisticAttributeValue("loop", null)).toBeNull(); + expect(resolveOptimisticAttributeValue("data-example", null)).toBeNull(); + }); + + it("passes any other value through unchanged", () => { + expect(resolveOptimisticAttributeValue("data-example", "true")).toBe("true"); + expect(resolveOptimisticAttributeValue("loop", "true")).toBe("true"); + }); +}); diff --git a/packages/studio/src/hooks/useDomEditAttributeCommits.ts b/packages/studio/src/hooks/useDomEditAttributeCommits.ts index 1ab929dd39..d420185bd5 100644 --- a/packages/studio/src/hooks/useDomEditAttributeCommits.ts +++ b/packages/studio/src/hooks/useDomEditAttributeCommits.ts @@ -1,5 +1,5 @@ import { useCallback, useRef } from "react"; -import type { PatchOperation } from "../utils/sourcePatcher"; +import { HTML_BOOLEAN_ATTRIBUTES, type PatchOperation } from "../utils/sourcePatcher"; import { findElementForSelection, getDomEditTargetKey, @@ -55,6 +55,14 @@ function resolveFullAttrName(attr: string, prefixData: boolean | undefined): str return prefixData && !attr.startsWith("data-") ? `data-${attr}` : attr; } +// Matches sourcePatcher's own boolean handling: "false" means "remove" only +// for HTML_BOOLEAN_ATTRIBUTES (loop, muted, ...), so the live preview node +// ends up holding what persist() actually writes to disk. +export function resolveOptimisticAttributeValue(attr: string, value: string | null): string | null { + if (value === null) return null; + return value === "false" && HTML_BOOLEAN_ATTRIBUTES.has(attr) ? null : value; +} + function setOrRemovePreviewAttribute( el: HTMLElement, fullAttr: string, @@ -393,8 +401,11 @@ export function useDomEditAttributeCommits({ }, apply: () => { if (!editedElement) return; - const nextValue = value === null || value === "false" ? null : value; - setOrRemovePreviewAttribute(editedElement, attr, nextValue); + setOrRemovePreviewAttribute( + editedElement, + attr, + resolveOptimisticAttributeValue(attr, value), + ); }, persist: () => persistDomEditOperations(domEditSelection, [op], { diff --git a/packages/studio/src/utils/sourcePatcher.ts b/packages/studio/src/utils/sourcePatcher.ts index fd799632ff..44c9843a69 100644 --- a/packages/studio/src/utils/sourcePatcher.ts +++ b/packages/studio/src/utils/sourcePatcher.ts @@ -420,7 +420,7 @@ function findMatchingClosingTagIndex(html: string, tagName: string, contentStart return -1; } -const HTML_BOOLEAN_ATTRIBUTES = new Set([ +export const HTML_BOOLEAN_ATTRIBUTES = new Set([ "loop", "muted", "autoplay", From 8aa6a30285212eb1a51957c669b52fcc3188ef67 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 08:23:50 -0400 Subject: [PATCH 3/7] fix(cli): snapshot picks the source frame through a rate lane and up to 10x hyperframes snapshot clamped the constant playback rate at 5 and ignored a rate lane in data-automation, so a sped-up or ramped clip showed the wrong frame. It now reads the rate through the runtime's own reader and maps time with the shared source-time function. --- packages/cli/src/commands/snapshot.test.ts | 38 ++++++++++++++++++++-- packages/cli/src/commands/snapshot.ts | 36 ++++++++++++++------ 2 files changed, 61 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/commands/snapshot.test.ts b/packages/cli/src/commands/snapshot.test.ts index 42022c4945..7a1f56561c 100644 --- a/packages/cli/src/commands/snapshot.test.ts +++ b/packages/cli/src/commands/snapshot.test.ts @@ -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 () => { @@ -29,7 +30,7 @@ import snapshotCommand, { requireSnapshotFfmpeg, resolveSnapshotVideoClipStart, resolveSnapshotVideoFrameTime, - resolveSnapshotVideoPlaybackRate, + resolveSnapshotVideoRateSpec, tailFrameTime, } from "./snapshot.js"; @@ -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); }); }); diff --git a/packages/cli/src/commands/snapshot.ts b/packages/cli/src/commands/snapshot.ts index 24561425c9..4a8fb8f7be 100644 --- a/packages/cli/src/commands/snapshot.ts +++ b/packages/cli/src/commands/snapshot.ts @@ -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, @@ -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 = { + "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 { @@ -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) @@ -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 && From 2d7ced411ce5e39fadd464fd7f05251a43742fe5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 19 Sep 2026 07:53:46 -0400 Subject: [PATCH 4/7] docs(skills): teach speed ramps as a rate lane in one reference page Adds the speed-ramps reference, corrects the 0.1 to 5 rate range to 0.1 to 10 and the 'speed ramps are not supported' lines in the skills, and regenerates the skills manifest. --- docs/docs.json | 3 +- docs/reference/html-schema.mdx | 2 +- docs/reference/speed-ramps.mdx | 62 +++++++++++++++++++ skills-manifest.json | 8 +-- skills/hyperframes-audio/SKILL.md | 7 ++- .../references/creator-editing-recipes.md | 2 +- skills/hyperframes-keyframes/SKILL.md | 4 +- skills/hyperframes/SKILL.md | 4 +- 8 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 docs/reference/speed-ramps.mdx diff --git a/docs/docs.json b/docs/docs.json index a0edc083f2..bedd182a00 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -916,7 +916,8 @@ "pages": [ "reference/html-schema", "reference/color-grading", - "reference/audio-effects" + "reference/audio-effects", + "reference/speed-ramps" ] }, { diff --git a/docs/reference/html-schema.mdx b/docs/reference/html-schema.mdx index e11aa8d93f..db42909ec6 100644 --- a/docs/reference/html-schema.mdx +++ b/docs/reference/html-schema.mdx @@ -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 `