From adb8989f70044736deea540bd989ec939f5f34e9 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Fri, 18 Sep 2026 19:28:32 -0400 Subject: [PATCH 1/3] feat(engine): render speed ramps with the same source-time mapping as the preview The frame extractor, frame lookup table, coverage check, compile-time duration and audio mixer read a clip's rate lane through sourceTimeAt, so a rendered frame at t shows the source frame the preview shows. Audio bakes a ramp as consecutive source slices stretched by their mean rate, keeping pitch. The compile-time attribute reader now handles single-quoted and entity-encoded JSON attributes. Adds a speed-ramp regression fixture. --- .../core/src/compiler/timingCompiler.test.ts | 26 ++++ packages/core/src/compiler/timingCompiler.ts | 36 ++++-- packages/core/src/index.ts | 3 + packages/core/src/runtime/playbackRate.ts | 5 + packages/core/src/speedRamp.test.ts | 26 ++++ packages/core/src/speedRamp.ts | 20 +++ .../engine/src/services/audioMixer.test.ts | 89 +++++++++++++ packages/engine/src/services/audioMixer.ts | 79 +++++++++--- .../engine/src/services/audioMixer.types.ts | 6 +- .../src/services/videoFrameExtractor.test.ts | 121 +++++++++++++++++- .../src/services/videoFrameExtractor.ts | 90 ++++++++----- .../producer/src/services/htmlCompiler.ts | 3 +- .../src/services/render/videoFrameCoverage.ts | 14 +- packages/producer/tests/shard-schedule.json | 1 + packages/producer/tests/speed-ramp/meta.json | 15 +++ .../tests/speed-ramp/output/compiled.html | 3 + .../tests/speed-ramp/output/output.mp4 | 3 + .../tests/speed-ramp/src/assets/counter.mp4 | 3 + .../tests/speed-ramp/src/assets/sweep.wav | Bin 0 -> 256078 bytes .../producer/tests/speed-ramp/src/index.html | 22 ++++ 20 files changed, 501 insertions(+), 64 deletions(-) create mode 100644 packages/producer/tests/speed-ramp/meta.json create mode 100644 packages/producer/tests/speed-ramp/output/compiled.html create mode 100644 packages/producer/tests/speed-ramp/output/output.mp4 create mode 100644 packages/producer/tests/speed-ramp/src/assets/counter.mp4 create mode 100644 packages/producer/tests/speed-ramp/src/assets/sweep.wav create mode 100644 packages/producer/tests/speed-ramp/src/index.html diff --git a/packages/core/src/compiler/timingCompiler.test.ts b/packages/core/src/compiler/timingCompiler.test.ts index b9ec7bcc02..30f5bbc01c 100644 --- a/packages/core/src/compiler/timingCompiler.test.ts +++ b/packages/core/src/compiler/timingCompiler.test.ts @@ -468,3 +468,29 @@ describe("shouldClampResolvedMediaDuration", () => { expect(shouldClampResolvedMediaDuration("audio", 5, 1)).toBe(true); }); }); + +describe("rate lane in compiled media", () => { + const lane = { + version: 1, + lanes: [ + { + target: "rate", + points: [ + { t: 0, v: 1 }, + { t: 2, v: 3 }, + ], + }, + ], + }; + const encodings = [ + ["single-quoted JSON", `data-automation='${JSON.stringify(lane)}'`], + ["entity-encoded JSON", `data-automation="${JSON.stringify(lane).replace(/"/g, """)}"`], + ]; + + it.each(encodings)("hands a %s rate lane to the unresolved element", (_name, attr) => { + const { unresolved } = compileTimingAttrs( + ``, + ); + expect(unresolved[0]?.playbackRate).toMatchObject({ target: "rate" }); + }); +}); diff --git a/packages/core/src/compiler/timingCompiler.ts b/packages/core/src/compiler/timingCompiler.ts index 30c0525cc3..2874a45190 100644 --- a/packages/core/src/compiler/timingCompiler.ts +++ b/packages/core/src/compiler/timingCompiler.ts @@ -21,9 +21,10 @@ import { parseNumeric } from "@hyperframes/parsers/composition-contract"; import { parseStrictFiniteTimingNumber, - readElementPlaybackRate, + readElementRateSpec, readMediaStart, } from "../runtime/playbackRate.js"; +import type { RateSpec } from "../speedRamp.js"; // ── Types ──────────────────────────────────────────────────────────────── export interface UnresolvedElement { @@ -34,7 +35,7 @@ export interface UnresolvedElement { end?: number; duration?: number; mediaStart: number; - playbackRate: number; + playbackRate: RateSpec; compositionSrc?: string; } @@ -50,7 +51,7 @@ export interface ResolvedMediaElement { start: number; duration: number; mediaStart: number; - playbackRate: number; + playbackRate: RateSpec; loop: boolean; } @@ -91,8 +92,25 @@ function getAttr(tag: string, attr: string): string | null { // made compileTag believe a Studio-stamped `data-hf-id`-only element already // had an `id`, so it skipped its `hf-video-N` injection — leaving the element // with no real `el.id`, which the render pipeline keys off of (blank wash). - const match = tag.match(new RegExp(`(? { + return { + getAttribute: (name) => { + const raw = getAttr(tag, name); + return raw && name === "data-automation" + ? raw + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/&/g, "&") + : raw; + }, + }; } function hasAttr(tag: string, attr: string): boolean { @@ -228,9 +246,9 @@ function compileTag( startStr = "0"; } const start = parseNumeric(startStr); - const attrReader = { getAttribute: (name: string) => getAttr(result, name) }; + const attrReader = tagAttrReader(result); const mediaStart = readMediaStart(attrReader); - const playbackRate = readElementPlaybackRate(attrReader); + const playbackRate = readElementRateSpec(attrReader); // 1. Compute data-end from data-start + data-duration. Skip relative id-refs. if (!hasAttr(result, "data-end")) { @@ -374,7 +392,7 @@ export function extractResolvedMedia(html: string): ResolvedMediaElement[] { const isVideo = /^