From 3e006f07ff0735a989e1f12c92bc028b41fb50c5 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 10 Oct 2026 00:50:48 -0700 Subject: [PATCH 1/9] fix(core): preview plays a split sub-composition's videos from its in-point --- packages/core/src/mediaTiming.ts | 31 +++++- .../init.subCompositionPlacement.test.ts | 62 +++++++++++- packages/core/src/runtime/init.ts | 43 ++++---- packages/core/src/runtime/media.ts | 18 +++- .../core/src/runtime/startResolver.test.ts | 30 ++++++ packages/core/src/runtime/startResolver.ts | 97 ++++++++----------- 6 files changed, 199 insertions(+), 82 deletions(-) diff --git a/packages/core/src/mediaTiming.ts b/packages/core/src/mediaTiming.ts index db129fe1700..80ec9805e65 100644 --- a/packages/core/src/mediaTiming.ts +++ b/packages/core/src/mediaTiming.ts @@ -1,3 +1,5 @@ +import { readMediaOffsetSeconds } from "@hyperframes/parsers/media-duration"; + export const MEDIA_START_BASIS_ATTR = "data-hf-media-start-basis"; export type MediaStartBasis = "local" | "global"; @@ -20,8 +22,8 @@ export function resolveAbsoluteMediaStartSeconds(input: { } /** The one rule for a media element's root-timeline start; the runtime and the CLI both call it. - * With no literal start, an auto-injected start, or a host at t<=0 there is nothing for the basis - * to disambiguate, so the ordinary start resolution applies. */ + * `hostStart` is the composition's origin. With no literal start, an auto-injected start, or an + * origin at 0 there is nothing for the basis to disambiguate, so ordinary resolution applies. */ export function resolveMediaStartSeconds( input: MediaStartInput & { ordinaryStart: () => number }, ): number { @@ -48,5 +50,28 @@ export interface MediaStartInput { function hasLiteralNestedStart( input: MediaStartInput, ): input is MediaStartInput & { authoredStart: number } { - return !input.hasAutoStart && input.authoredStart != null && input.hostStart > 0; + return !input.hasAutoStart && input.authoredStart != null && input.hostStart !== 0; +} + +/** Main-timeline time of a sub-composition's local t=0: its host's start minus its in-point. */ +export function compositionOriginSeconds( + hostStart: number, + host: Pick, +): number { + return hostStart - readMediaOffsetSeconds((name) => host.getAttribute(name)); +} + +export interface HostSlot { + start: number; + end: number; + origin: number; +} + +/** An in-point (origin before the host's start) cuts the head; the host's end cuts the tail. */ +export function cutToHostSlot( + span: { start: number; end: number }, + host: HostSlot, +): { start: number; end: number } { + const start = host.origin < host.start ? Math.max(span.start, host.start) : span.start; + return { start, end: Math.max(start, Math.min(span.end, host.end)) }; } diff --git a/packages/core/src/runtime/init.subCompositionPlacement.test.ts b/packages/core/src/runtime/init.subCompositionPlacement.test.ts index a5fc0bac14d..8e544dd1299 100644 --- a/packages/core/src/runtime/init.subCompositionPlacement.test.ts +++ b/packages/core/src/runtime/init.subCompositionPlacement.test.ts @@ -3,7 +3,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initSandboxRuntimeModular } from "./init"; import type { RuntimeTimelineLike } from "./types"; import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers"; -import { resetRuntimeFixtureDom } from "./runtimeSeekFixture.test-helpers"; +import { + createMockTimeline, + resetRuntimeFixtureDom, + stubDuration, +} from "./runtimeSeekFixture.test-helpers"; // Pins where a running runtime places a sub-composition's timeline, against what a fresh load does. describe("runtime sub-composition placement", () => { @@ -225,4 +229,60 @@ describe("runtime sub-composition placement", () => { expect(remove).not.toHaveBeenCalledWith(scene); }); + + /** What Studio's split writes for a 12 s scene cut at 5 s and 9 s, each half mounting its own copy. */ + function loadSplitScene() { + const videos = [ + ["v1", 0, 1], + ["v2", 4, 5], + ["v3", 8, 2], + ] + .map( + ([name, start, mediaStart]) => + ``, + ) + .join(""); + const hosts = [ + ["scene", 0, 5, 0], + ["scene-split", 5, 4, 5], + ["scene-split-split", 9, 3, 9], + ] + .map( + ([id, start, duration, inPoint]) => + `
${videos}
`, + ) + .join(""); + document.body.innerHTML = `
${hosts}
`; + for (const video of document.querySelectorAll("video")) { + stubDuration(video, 30); + Object.defineProperty(video, "currentTime", { + value: -1, + writable: true, + configurable: true, + }); + video.play = vi.fn(() => Promise.resolve()); + video.pause = vi.fn(); + } + window.__timelines = { main: createMockTimeline(12) }; + initSandboxRuntimeModular(); + } + + const sourceTimeOf = (host: string, video: string) => + document.querySelector(`#${host} .${video}`)!.currentTime; + + it("shifts a split scene's videos by each half's in-point and cuts them to that half", () => { + loadSplitScene(); + window.__player?.renderSeek(4.5); + expect(sourceTimeOf("scene", "v2")).toBe(5.5); + // The second half's v2 lands at 4-8 but is cut until that half starts at 5. + expect(sourceTimeOf("scene-split", "v2")).toBe(-1); + + window.__player?.renderSeek(5); + expect(sourceTimeOf("scene-split", "v2")).toBe(6); + window.__player?.renderSeek(8.5); + expect(sourceTimeOf("scene-split", "v3")).toBe(2.5); + window.__player?.renderSeek(10); + expect(sourceTimeOf("scene-split-split", "v3")).toBe(4); + }); }); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index 6dd1ac75b75..edff799910c 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -137,7 +137,7 @@ import { } from "./timelineRebindPolicy"; import { installStudioCustomEase } from "./customEase"; import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds } from "./playbackRate"; -import { MEDIA_START_BASIS_ATTR } from "../mediaTiming"; +import { cutToHostSlot, MEDIA_START_BASIS_ATTR, type HostSlot } from "../mediaTiming"; import { settleFirstFrameCompositionReadiness } from "../compositionReadiness"; import { AUTHORED_DURATION_ATTR, AUTHORED_END_ATTR } from "./authoredTiming"; import { @@ -978,6 +978,16 @@ export function initSandboxRuntimeModular(): void { return { compositionRoot, inheritedStart, inheritedDuration }; }; + const resolveMediaHostSlot = (element: Element): HostSlot | null => { + const { inheritedStart, inheritedDuration } = resolveMediaCompositionContext(element); + if (inheritedStart == null) return null; + return { + start: inheritedStart, + end: inheritedDuration ? inheritedStart + inheritedDuration : Infinity, + origin: timingResolverFor(true).resolveHostStartForElement(element), + }; + }; + // Single owner: `createRuntimeStartTimeResolver` (startResolver.ts). The clip // manifest resolves media starts through the same method, so what the studio // draws and what the transport plays cannot drift apart. @@ -2941,6 +2951,7 @@ export function initSandboxRuntimeModular(): void { explicitDuration, }); }, + resolveHostSlot: resolveMediaHostSlot, }); const resolveMediaClipIndex = (): MediaClipIndex => { @@ -4815,27 +4826,21 @@ export function initSandboxRuntimeModular(): void { const gen = webAudio.startGeneration(); for (const rawEl of webAudioMediaIn(document)) { if (isSilencedByHidden(rawEl)) continue; - const compStart = resolveAbsoluteMediaStartSeconds(rawEl); - if (!Number.isFinite(compStart)) continue; - const mediaStart = readElementPlaybackStart(rawEl); + const authoredStart = resolveAbsoluteMediaStartSeconds(rawEl); + if (!Number.isFinite(authoredStart)) continue; const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? ""); const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1; const durationAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration); - let clipDuration = - durationAttr != null && durationAttr > 0 ? durationAttr : Number.POSITIVE_INFINITY; - const compositionRoot = rawEl.closest("[data-composition-id]"); - if (compositionRoot) { - const inheritedStart = resolveStartForElement(compositionRoot, 0); - const inheritedDuration = resolveDurationForElement(compositionRoot, { - includeAuthoredTimingAttrs: true, - }); - if (inheritedDuration != null && inheritedDuration > 0) { - clipDuration = Math.min( - clipDuration, - Math.max(0, inheritedStart + inheritedDuration - compStart), - ); - } - } + const authored = { + start: authoredStart, + end: authoredStart + (durationAttr != null && durationAttr > 0 ? durationAttr : Infinity), + }; + const slot = resolveMediaHostSlot(rawEl); + const { start: compStart, end: clipEnd } = slot ? cutToHostSlot(authored, slot) : authored; + const mediaStart = + readElementPlaybackStart(rawEl) + + sourceTimeAt(readElementRateSpec(rawEl), compStart - authoredStart); + const clipDuration = clipEnd - compStart; // Decided BEFORE the transport is asked, because the two verdicts want // two different fallback chains — and only one of them is the chain // that existed before (#3458). diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 7c7c548e512..09af2033808 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -10,6 +10,7 @@ import { clampAudioGain } from "../audioGain.js"; import { isMemberGroupHidden } from "../audioGroups.js"; import { findInjectedRenderFrame } from "./renderFrameSibling.js"; import { registerSeekCompletion } from "./adapters/seek-dispatch.js"; +import { cutToHostSlot, type HostSlot } from "../mediaTiming.js"; export { readElementPlaybackRate, readElementRateSpec, @@ -101,6 +102,8 @@ export type RuntimeMediaClip = { export function refreshRuntimeMediaCache(params?: { resolveStartSeconds?: (element: Element) => number; resolveDurationSeconds?: (element: HTMLVideoElement | HTMLAudioElement) => number | null; + /** The sub-composition slot that cuts the clip; a head cut advances its media offset. */ + resolveHostSlot?: (element: HTMLVideoElement | HTMLAudioElement) => HostSlot | null; shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean; /** * Build clips for exactly these elements instead of scanning the document for @@ -131,11 +134,11 @@ export function refreshRuntimeMediaCache(params?: { const videoClips: RuntimeMediaClip[] = []; let maxMediaEnd = 0; for (const el of timedMediaEls) { - const start = params?.resolveStartSeconds + let start = params?.resolveStartSeconds ? params.resolveStartSeconds(el) : Number.parseFloat(el.dataset.start ?? "0"); if (!Number.isFinite(start)) continue; - const mediaStart = readElementPlaybackStart(el); + let mediaStart = readElementPlaybackStart(el); const playbackRate = readElementPlaybackRate(el); const rate = readElementRateSpec(el); const loop = el.loop; @@ -148,13 +151,20 @@ export function refreshRuntimeMediaCache(params?: { duration = Math.max(0, timeAtSourceTime(rate, sourceDuration - mediaStart)); } const hasKnownDuration = Number.isFinite(duration) && duration >= 0; - const end = hasKnownDuration ? start + duration : Number.POSITIVE_INFINITY; + let end = hasKnownDuration ? start + duration : Number.POSITIVE_INFINITY; + const slot = params?.resolveHostSlot?.(el); + const kept = slot ? cutToHostSlot({ start, end }, slot) : { start, end }; + if (kept.start !== start || kept.end !== end) { + mediaStart += sourceTimeAt(rate, kept.start - start); + ({ start, end } = kept); + duration = end - start; + } const volumeRaw = Number.parseFloat(el.dataset.volume ?? ""); const clip: RuntimeMediaClip = { el, start, mediaStart, - duration: hasKnownDuration ? duration : Number.POSITIVE_INFINITY, + duration: Number.isFinite(end) ? duration : Number.POSITIVE_INFINITY, end, volume: Number.isFinite(volumeRaw) ? volumeRaw : null, playbackRate, diff --git a/packages/core/src/runtime/startResolver.test.ts b/packages/core/src/runtime/startResolver.test.ts index 8b42c9b3908..0740e703e95 100644 --- a/packages/core/src/runtime/startResolver.test.ts +++ b/packages/core/src/runtime/startResolver.test.ts @@ -452,3 +452,33 @@ describe("documentRef", () => { expect(resolver.resolveStartForElement(dependent)).toBe(2); }); }); + +describe("a host's in-point", () => { + afterEach(() => { + document.body.innerHTML = ""; + }); + + it("shifts what a host holds by the host's in-point, through nested hosts", () => { + document.body.innerHTML = + `
` + + `
` + + `
` + + `
` + + `
` + + `
` + + `
` + + `
`; + const resolver = createRuntimeStartTimeResolver({}); + const media = (id: string) => + resolver.resolveMediaStartForElement(document.getElementById(id)!); + + // The half starts at 5 with in-point 5: local t plays at 5 + t - 5. + expect([media("v2"), media("v3")]).toEqual([4, 8]); + expect(resolver.resolveHostStartForElement(document.getElementById("v2")!)).toBe(0); + // Root-time media keeps its authored time, even where the in-point puts local 0 before 0. + expect(media("legacy")).toBe(3); + // Outer at 5 with in-point 1 puts the inner host at 5 - 1 + 2 = 6. + expect(resolver.resolveStartForElement(document.getElementById("inner")!)).toBe(6); + expect(media("deep")).toBe(6); + }); +}); diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index f331d659909..e49bcd06747 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -12,6 +12,7 @@ import { import { isMediaElement } from "./domRealm"; import { parseStartExpression } from "./startExpression"; import { + compositionOriginSeconds, isRootGlobalMediaStart, MEDIA_START_BASIS_ATTR, resolveMediaStartSeconds, @@ -106,15 +107,47 @@ export function createRuntimeStartTimeResolver(params: { return null; }; + // A mounted composition root without its own `data-start` takes its timing from the host it was + // loaded into: the host may use a different id than the file, or none (an anonymous host). + const inheritedTimingHost = (element: Element): Element | null => { + const parent = element.parentElement; + if (!parent || !element.hasAttribute("data-composition-id")) return null; + if (parseStartExpression(element.getAttribute("data-start"))) return null; + return parent.hasAttribute("data-composition-src") || + parent.hasAttribute("data-composition-id") || + parent.hasAttribute("data-composition-file") + ? parent + : null; + }; + + const resolveCompositionOrigin = (compositionRoot: Element, fallback: number): number => + compositionOriginSeconds( + resolveStartForElementInternal(compositionRoot, fallback), + inheritedTimingHost(compositionRoot) ?? compositionRoot, + ); + const resolveHostOffsetForElement = (element: Element, fallback: number): number => { - if (element.hasAttribute("data-composition-id")) { - const parentComposition = element.parentElement?.closest("[data-composition-id]"); - if (!parentComposition) return 0; - return resolveStartForElementInternal(parentComposition, fallback); + const compositionRoot = element.hasAttribute("data-composition-id") + ? element.parentElement?.closest("[data-composition-id]") + : element.closest("[data-composition-id]"); + return compositionRoot ? resolveCompositionOrigin(compositionRoot, fallback) : 0; + }; + + const computeStart = (element: Element, fallback: number): number => { + const expression = parseStartExpression(element.getAttribute("data-start")); + if (!expression) { + const host = inheritedTimingHost(element); + return host ? resolveStartForElementInternal(host, fallback) : fallback; } - const compositionRoot = element.closest("[data-composition-id]"); - if (!compositionRoot) return 0; - return resolveStartForElementInternal(compositionRoot, fallback); + if (expression.kind === "absolute") { + // Negative when a host's in-point is past its start; the host's slot cuts that part. + return resolveHostOffsetForElement(element, fallback) + Math.max(0, expression.value); + } + const target = findReferenceTarget(expression.refId); + if (!target) return fallback; + const targetStart = resolveStartForElementInternal(target, 0); + const targetDuration = resolveDurationForElement(target) ?? 0; + return Math.max(0, targetStart + targetDuration + expression.offset); }; const resolveStartForElementInternal = (element: Element, fallback: number): number => { @@ -127,53 +160,7 @@ export function createRuntimeStartTimeResolver(params: { } visiting.add(element); try { - const expression = parseStartExpression(element.getAttribute("data-start")); - if (!expression) { - // If this element is a loaded composition inner root (has data-composition-id - // but no data-start), walk up to the host parent which carries the actual - // timing. This happens when the host uses a different data-composition-id - // than the loaded file — e.g. host="montage" but file has "scene-10", or - // when the host itself has no data-composition-id at all (an "anonymous" - // host) and the composition's own id was restored onto the inlined wrapper. - // Check data-composition-src (runtime, not yet inlined), data-composition-id - // (bundled/compiled host with its own id), and data-composition-file (the - // marker every inlined host gets, compiled or bundled, once - // data-composition-src is stripped — covers the anonymous-host case). - if (element.hasAttribute("data-composition-id")) { - const parent = element.parentElement; - if ( - parent && - (parent.hasAttribute("data-composition-src") || - parent.hasAttribute("data-composition-id") || - parent.hasAttribute("data-composition-file")) - ) { - const parentStart = resolveStartForElementInternal(parent, fallback); - startCache.set(element, parentStart); - return parentStart; - } - } - startCache.set(element, fallback); - return fallback; - } - if (expression.kind === "absolute") { - const absolute = Math.max(0, expression.value); - const resolved = Math.max(0, resolveHostOffsetForElement(element, fallback) + absolute); - startCache.set(element, resolved); - return resolved; - } - const target = findReferenceTarget(expression.refId); - if (!target) { - startCache.set(element, fallback); - return fallback; - } - const targetStart = resolveStartForElementInternal(target, 0); - const targetDuration = resolveDurationForElement(target); - if (targetDuration == null || targetDuration <= 0) { - const unresolved = Math.max(0, targetStart + expression.offset); - startCache.set(element, unresolved); - return unresolved; - } - const resolved = Math.max(0, targetStart + targetDuration + expression.offset); + const resolved = computeStart(element, fallback); startCache.set(element, resolved); return resolved; } finally { @@ -196,7 +183,7 @@ export function createRuntimeStartTimeResolver(params: { const compositionRoot = element.closest("[data-composition-id]"); return { authoredStart: parseStrictFiniteTimingNumber(element.getAttribute("data-start")), - hostStart: compositionRoot ? resolveStartForElementInternal(compositionRoot, 0) : 0, + hostStart: compositionRoot ? resolveCompositionOrigin(compositionRoot, 0) : 0, hasAutoStart: element.hasAttribute("data-hf-auto-start"), basis: element.getAttribute(MEDIA_START_BASIS_ATTR), }; From 37a855f6a35d04f84de8f995bf8c81931556670c Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 10 Oct 2026 00:54:09 -0700 Subject: [PATCH 2/9] fix(cli): timeline shows a split sub-composition's clips from its in-point --- .../timeline/describeProject.inPoint.test.ts | 39 +++++++++++++++++++ packages/cli/src/timeline/describeProject.ts | 32 ++++++++++++--- 2 files changed, 65 insertions(+), 6 deletions(-) create mode 100644 packages/cli/src/timeline/describeProject.inPoint.test.ts diff --git a/packages/cli/src/timeline/describeProject.inPoint.test.ts b/packages/cli/src/timeline/describeProject.inPoint.test.ts new file mode 100644 index 00000000000..515344f93ed --- /dev/null +++ b/packages/cli/src/timeline/describeProject.inPoint.test.ts @@ -0,0 +1,39 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { beforeAll, describe, expect, it, onTestFinished } from "vitest"; +import { ensureDOMParser } from "../utils/dom.js"; +import { describeProject } from "./describeProject.js"; + +// What Studio's split writes when a 12 s scene clip is cut at 5 s and then at 9 s. +const SPLIT_INDEX = `
+
+
`; +const SCENE = ``; + +beforeAll(ensureDOMParser); + +describe("a sub-composition's in-point", () => { + it("shifts each split half's videos by its in-point and cuts them to that half", async () => { + const dir = mkdtempSync(join(tmpdir(), "hf-timeline-inpoint-")); + onTestFinished(() => rmSync(dir, { recursive: true, force: true })); + mkdirSync(join(dir, "compositions")); + writeFileSync(join(dir, "index.html"), SPLIT_INDEX); + writeFileSync(join(dir, "compositions", "scene.html"), SCENE); + + const timeline = await describeProject(join(dir, "index.html")); + const videos = timeline.tracks.find((t) => t.kind === "video")!.rows; + + expect(videos.map((r) => [r.host, r.id, r.absStart, r.absEnd])).toEqual([ + ["scene", "v1", 0, 4], + ["scene", "v2", 4, 5], + ["scene-split", "v2", 5, 8], + ["scene-split", "v3", 8, 9], + ["scene-split-split", "v3", 9, 12], + ]); + }); +}); diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts index 1475a6e7b94..d9c30db9989 100644 --- a/packages/cli/src/timeline/describeProject.ts +++ b/packages/cli/src/timeline/describeProject.ts @@ -24,7 +24,12 @@ import { type StructureNode, type TrackKind, } from "@hyperframes/parsers"; -import { resolveMediaStartSeconds } from "@hyperframes/core/media-timing"; +import { + compositionOriginSeconds, + cutToHostSlot, + resolveMediaStartSeconds, + type HostSlot, +} from "@hyperframes/core/media-timing"; import { extractAudioMetadata, extractMediaMetadata, @@ -133,7 +138,7 @@ interface DocScope { startCache: Map; /** Sub-composition files must stay inside the project. */ projectDir: string; - /** Main-timeline start of this document's root: 0 for index.html, the host's start for a sub-composition. */ + /** Main-timeline time of this document's local t=0: 0 for index.html, see `compositionOriginSeconds`. */ origin: number; /** Project-relative path of this document, with `/` separators. */ file: string; @@ -277,7 +282,16 @@ async function resolveRowTiming(scope: DocScope, node: DomNode, depth: number): const authored = resolveReferencedDuration(scope.doc, el, scope.startCache, new Set()); const host = el.getAttribute("data-composition-src"); const absStart = mainTimelineStart(scope, el, start); - const children = host && depth === 0 ? await readSubComposition(host, scope, absStart) : []; + // Untrimmed hosts keep their children's authored extent so an overlap past the end stays visible. + const trimmed = el.hasAttribute("data-playback-start") || el.hasAttribute("data-media-start"); + const children = + host && depth === 0 + ? await readSubComposition(host, scope, { + start: absStart, + end: trimmed ? absStart + (authored ?? Infinity) : Infinity, + origin: compositionOriginSeconds(absStart, el), + }) + : []; const kind = el.tagName.toLowerCase(); const duration = MEDIA_TAG.test(kind) ? await resolveMediaRowDuration(scope, el, kind as MediaTag, authored) @@ -333,7 +347,7 @@ async function describeRow(scope: DocScope, node: DomNode, depth: number): Promi async function readSubComposition( src: string, parent: DocScope, - origin: number, + slot: HostSlot, ): Promise { const authored = resolve(parent.dir, src); const file = realFileInside(parent.projectDir, authored); @@ -349,7 +363,7 @@ async function readSubComposition( dir: dirname(file), startCache: new Map(), projectDir: parent.projectDir, - origin, + origin: slot.origin, file: relative(parent.projectDir, authored).split(sep).join("/"), withProbeSlot: parent.withProbeSlot, measure: parent.measure, @@ -359,7 +373,13 @@ async function readSubComposition( const rows = await Promise.all( topLevelElements(toNode(root)).map((node) => describeRow(scope, node, 1)), ); - return rows.sort(byStart); + return rows.flatMap((row) => cutRow(row, slot)).sort(byStart); +} + +function cutRow(row: ClipDraft, slot: HostSlot): ClipDraft[] { + const kept = cutToHostSlot({ start: row.absStart, end: row.absEnd }, slot); + if (kept.end === kept.start && row.absEnd > row.absStart) return []; + return [{ ...row, absStart: roundMs(kept.start), absEnd: roundMs(kept.end) }]; } /** The file's real path when it is a regular file inside the project (symlinks resolved), else null. */ From f97d75d1f591707c93b49196029449b707ac3748 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 10 Oct 2026 09:46:46 -0700 Subject: [PATCH 3/9] fix(core): split halves cut nested media and keep each clip's loop, ramp and fades --- packages/cli/src/timeline/describeProject.ts | 4 +- packages/core/src/mediaTiming.ts | 12 ++-- .../init.subCompositionPlacement.test.ts | 71 ++++++++++++++++++- packages/core/src/runtime/init.test.ts | 39 ++++++++++ packages/core/src/runtime/init.ts | 39 +++++----- packages/core/src/runtime/media.ts | 30 ++++---- .../core/src/runtime/startResolver.test.ts | 13 ++++ packages/core/src/runtime/startResolver.ts | 22 +++++- .../src/runtime/webAudioTransport.test.ts | 10 +++ .../core/src/runtime/webAudioTransport.ts | 23 +++--- 10 files changed, 205 insertions(+), 58 deletions(-) diff --git a/packages/cli/src/timeline/describeProject.ts b/packages/cli/src/timeline/describeProject.ts index d9c30db9989..8b0b826314b 100644 --- a/packages/cli/src/timeline/describeProject.ts +++ b/packages/cli/src/timeline/describeProject.ts @@ -26,7 +26,7 @@ import { } from "@hyperframes/parsers"; import { compositionOriginSeconds, - cutToHostSlot, + cutToHostSlots, resolveMediaStartSeconds, type HostSlot, } from "@hyperframes/core/media-timing"; @@ -377,7 +377,7 @@ async function readSubComposition( } function cutRow(row: ClipDraft, slot: HostSlot): ClipDraft[] { - const kept = cutToHostSlot({ start: row.absStart, end: row.absEnd }, slot); + const kept = cutToHostSlots({ start: row.absStart, end: row.absEnd }, [slot]); if (kept.end === kept.start && row.absEnd > row.absStart) return []; return [{ ...row, absStart: roundMs(kept.start), absEnd: roundMs(kept.end) }]; } diff --git a/packages/core/src/mediaTiming.ts b/packages/core/src/mediaTiming.ts index 80ec9805e65..56c0a26e4ec 100644 --- a/packages/core/src/mediaTiming.ts +++ b/packages/core/src/mediaTiming.ts @@ -67,11 +67,13 @@ export interface HostSlot { origin: number; } -/** An in-point (origin before the host's start) cuts the head; the host's end cuts the tail. */ -export function cutToHostSlot( +/** Each enclosing host cuts the span: an in-point (origin before its start) the head, its end the tail. */ +export function cutToHostSlots( span: { start: number; end: number }, - host: HostSlot, + hosts: readonly HostSlot[], ): { start: number; end: number } { - const start = host.origin < host.start ? Math.max(span.start, host.start) : span.start; - return { start, end: Math.max(start, Math.min(span.end, host.end)) }; + return hosts.reduce((kept, host) => { + const start = host.origin < host.start ? Math.max(kept.start, host.start) : kept.start; + return { start, end: Math.max(start, Math.min(kept.end, host.end)) }; + }, span); } diff --git a/packages/core/src/runtime/init.subCompositionPlacement.test.ts b/packages/core/src/runtime/init.subCompositionPlacement.test.ts index 8e544dd1299..b78d9dde201 100644 --- a/packages/core/src/runtime/init.subCompositionPlacement.test.ts +++ b/packages/core/src/runtime/init.subCompositionPlacement.test.ts @@ -3,6 +3,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { initSandboxRuntimeModular } from "./init"; import type { RuntimeTimelineLike } from "./types"; import { STUDIO_MANUAL_EDIT_GESTURE_ATTR } from "../editing/draftMarkers"; +import { sourceTimeAt } from "../speedRamp"; +import { readElementRateSpec } from "./playbackRate"; import { createMockTimeline, resetRuntimeFixtureDom, @@ -253,9 +255,14 @@ describe("runtime sub-composition placement", () => { `data-start="${start}" data-duration="${duration}" data-playback-start="${inPoint}">
${videos}
`, ) .join(""); - document.body.innerHTML = `
${hosts}
`; + mountInRoot(hosts); + } + + /** A 12 s root around `markup`; every video's source runs `sourceSeconds` and has not been seeked (-1). */ + function mountInRoot(markup: string, sourceSeconds = 30) { + document.body.innerHTML = `
${markup}
`; for (const video of document.querySelectorAll("video")) { - stubDuration(video, 30); + stubDuration(video, sourceSeconds); Object.defineProperty(video, "currentTime", { value: -1, writable: true, @@ -268,6 +275,17 @@ describe("runtime sub-composition placement", () => { initSandboxRuntimeModular(); } + /** The half after a split at 5 s: it starts at 5 with in-point 5, so scene time t plays at t. */ + function loadSecondHalf(inner: string, sourceSeconds?: number) { + mountInRoot( + `
${inner}
`, + sourceSeconds, + ); + } + + const video = (id: string) => document.getElementById(id) as HTMLVideoElement; + const sourceTimeOf = (host: string, video: string) => document.querySelector(`#${host} .${video}`)!.currentTime; @@ -285,4 +303,53 @@ describe("runtime sub-composition placement", () => { window.__player?.renderSeek(10); expect(sourceTimeOf("scene-split-split", "v3")).toBe(4); }); + + it("keeps a video in a composition nested inside the half silent until the half starts", () => { + loadSecondHalf( + `
` + + `
`, + ); + window.__player?.renderSeek(1); + expect(video("deep").currentTime).toBe(-1); + window.__player?.renderSeek(6); + expect(video("deep").currentTime).toBe(6); + }); + + it("loops a straddling clip over its whole file, not from the cut", () => { + loadSecondHalf( + ``, + 4, + ); + const at = (t: number) => (window.__player?.renderSeek(t), video("loop").currentTime); + expect([at(5), at(6), at(7.5)]).toEqual([3, 0, 1.5]); + }); + + it("follows a straddling clip's speed ramp from the clip's own start", () => { + const ramp = JSON.stringify({ + version: 1, + lanes: [ + { + target: "rate", + points: [ + { t: 0, v: 1 }, + { t: 8, v: 2 }, + ], + }, + ], + }); + loadSecondHalf( + ``, + ); + window.__player?.renderSeek(7); + expect(video("ramp").currentTime).toBeCloseTo( + sourceTimeAt(readElementRateSpec(video("ramp")), 5), + 6, + ); + }); + + it("times a straddling clip's fade-in from the clip's own start", () => { + loadSecondHalf(``); + window.__player?.renderSeek(5); + expect(video("fade").volume).toBeCloseTo(0.5, 6); + }); }); diff --git a/packages/core/src/runtime/init.test.ts b/packages/core/src/runtime/init.test.ts index eafa8260d98..b8c844f2992 100644 --- a/packages/core/src/runtime/init.test.ts +++ b/packages/core/src/runtime/init.test.ts @@ -5316,6 +5316,45 @@ describe("initSandboxRuntimeModular", () => { expect(decodeSpy).toHaveBeenCalledWith(audio); }); + it("holds a decoded clip a split half cut until that half starts, timed from the clip's own start", async () => { + const audio = mountAudio("https://cdn.example.com/track.mp3", { + "data-start": "2", + "data-duration": "4", + }); + // A half at 2 s with in-point 3 puts the clip's own t=0 at 1 s; the half cuts it to 2-5 s. + const half = document.createElement("div"); + for (const [name, value] of Object.entries({ + "data-composition-id": "half", + "data-composition-file": "half.html", + "data-start": "2", + "data-duration": "4", + "data-playback-start": "3", + })) + half.setAttribute(name, value); + audio.replaceWith(half); + half.appendChild(audio); + vi.spyOn(console, "info").mockImplementation(() => {}); + const buffer = {} as AudioBuffer; + vi.spyOn(WebAudioTransport.prototype, "decodeAudioElement").mockResolvedValue(buffer); + const schedule = vi + .spyOn(WebAudioTransport.prototype, "schedulePlayback") + .mockResolvedValue(null); + + await startPlayback(); + + await vi.waitFor(() => expect(schedule).toHaveBeenCalled()); + const [el, decoded, clipStart, mediaStart, , , , , clipDuration, windowStart] = + schedule.mock.calls[0]!; + expect([el, decoded, clipStart, mediaStart, clipDuration, windowStart]).toEqual([ + audio, + buffer, + 1, + 0, + 4, + 2, + ]); + }); + it("leaves the element audible on native output when decode also fails", async () => { const audio = mountAudio("https://cdn.example.com/track.mp3"); vi.spyOn(console, "info").mockImplementation(() => {}); diff --git a/packages/core/src/runtime/init.ts b/packages/core/src/runtime/init.ts index edff799910c..80ffa6d0147 100644 --- a/packages/core/src/runtime/init.ts +++ b/packages/core/src/runtime/init.ts @@ -137,7 +137,7 @@ import { } from "./timelineRebindPolicy"; import { installStudioCustomEase } from "./customEase"; import { parseStrictFiniteTimingNumber, resolveMediaElementDurationSeconds } from "./playbackRate"; -import { cutToHostSlot, MEDIA_START_BASIS_ATTR, type HostSlot } from "../mediaTiming"; +import { cutToHostSlots, MEDIA_START_BASIS_ATTR } from "../mediaTiming"; import { settleFirstFrameCompositionReadiness } from "../compositionReadiness"; import { AUTHORED_DURATION_ATTR, AUTHORED_END_ATTR } from "./authoredTiming"; import { @@ -978,15 +978,8 @@ export function initSandboxRuntimeModular(): void { return { compositionRoot, inheritedStart, inheritedDuration }; }; - const resolveMediaHostSlot = (element: Element): HostSlot | null => { - const { inheritedStart, inheritedDuration } = resolveMediaCompositionContext(element); - if (inheritedStart == null) return null; - return { - start: inheritedStart, - end: inheritedDuration ? inheritedStart + inheritedDuration : Infinity, - origin: timingResolverFor(true).resolveHostStartForElement(element), - }; - }; + const resolveMediaHostSlots = (element: Element) => + timingResolverFor(true).resolveHostSlotsForElement(element); // Single owner: `createRuntimeStartTimeResolver` (startResolver.ts). The clip // manifest resolves media starts through the same method, so what the studio @@ -2951,7 +2944,7 @@ export function initSandboxRuntimeModular(): void { explicitDuration, }); }, - resolveHostSlot: resolveMediaHostSlot, + resolveHostSlots: resolveMediaHostSlots, }); const resolveMediaClipIndex = (): MediaClipIndex => { @@ -4826,21 +4819,20 @@ export function initSandboxRuntimeModular(): void { const gen = webAudio.startGeneration(); for (const rawEl of webAudioMediaIn(document)) { if (isSilencedByHidden(rawEl)) continue; - const authoredStart = resolveAbsoluteMediaStartSeconds(rawEl); - if (!Number.isFinite(authoredStart)) continue; + const compStart = resolveAbsoluteMediaStartSeconds(rawEl); + if (!Number.isFinite(compStart)) continue; + const mediaStart = readElementPlaybackStart(rawEl); const volumeAttr = Number.parseFloat(rawEl.dataset.volume ?? ""); const vol = Number.isFinite(volumeAttr) ? volumeAttr : 1; const durationAttr = parseStrictFiniteTimingNumber(rawEl.dataset.duration); - const authored = { - start: authoredStart, - end: authoredStart + (durationAttr != null && durationAttr > 0 ? durationAttr : Infinity), - }; - const slot = resolveMediaHostSlot(rawEl); - const { start: compStart, end: clipEnd } = slot ? cutToHostSlot(authored, slot) : authored; - const mediaStart = - readElementPlaybackStart(rawEl) + - sourceTimeAt(readElementRateSpec(rawEl), compStart - authoredStart); - const clipDuration = clipEnd - compStart; + const kept = cutToHostSlots( + { + start: compStart, + end: compStart + (durationAttr != null && durationAttr > 0 ? durationAttr : Infinity), + }, + resolveMediaHostSlots(rawEl), + ); + const clipDuration = kept.end - compStart; // Decided BEFORE the transport is asked, because the two verdicts want // two different fallback chains — and only one of them is the chain // that existed before (#3458). @@ -4903,6 +4895,7 @@ export function initSandboxRuntimeModular(): void { gen, state.playbackRate, clipDuration, + kept.start, ); }); }); diff --git a/packages/core/src/runtime/media.ts b/packages/core/src/runtime/media.ts index 09af2033808..544b2b49a82 100644 --- a/packages/core/src/runtime/media.ts +++ b/packages/core/src/runtime/media.ts @@ -10,7 +10,7 @@ import { clampAudioGain } from "../audioGain.js"; import { isMemberGroupHidden } from "../audioGroups.js"; import { findInjectedRenderFrame } from "./renderFrameSibling.js"; import { registerSeekCompletion } from "./adapters/seek-dispatch.js"; -import { cutToHostSlot, type HostSlot } from "../mediaTiming.js"; +import { cutToHostSlots, type HostSlot } from "../mediaTiming.js"; export { readElementPlaybackRate, readElementRateSpec, @@ -78,6 +78,8 @@ export function resolveRuntimeMediaClipDuration(params: { export type RuntimeMediaClip = { el: HTMLVideoElement | HTMLAudioElement; start: number; + /** The clip's own t=0 when a host's in-point cut its head; source time, lanes and fades run from it. */ + origin?: number; mediaStart: number; duration: number; end: number; @@ -102,8 +104,7 @@ export type RuntimeMediaClip = { export function refreshRuntimeMediaCache(params?: { resolveStartSeconds?: (element: Element) => number; resolveDurationSeconds?: (element: HTMLVideoElement | HTMLAudioElement) => number | null; - /** The sub-composition slot that cuts the clip; a head cut advances its media offset. */ - resolveHostSlot?: (element: HTMLVideoElement | HTMLAudioElement) => HostSlot | null; + resolveHostSlots?: (element: HTMLVideoElement | HTMLAudioElement) => HostSlot[]; shouldIncludeElement?: (element: HTMLVideoElement | HTMLAudioElement) => boolean; /** * Build clips for exactly these elements instead of scanning the document for @@ -138,7 +139,7 @@ export function refreshRuntimeMediaCache(params?: { ? params.resolveStartSeconds(el) : Number.parseFloat(el.dataset.start ?? "0"); if (!Number.isFinite(start)) continue; - let mediaStart = readElementPlaybackStart(el); + const mediaStart = readElementPlaybackStart(el); const playbackRate = readElementPlaybackRate(el); const rate = readElementRateSpec(el); const loop = el.loop; @@ -152,10 +153,9 @@ export function refreshRuntimeMediaCache(params?: { } const hasKnownDuration = Number.isFinite(duration) && duration >= 0; let end = hasKnownDuration ? start + duration : Number.POSITIVE_INFINITY; - const slot = params?.resolveHostSlot?.(el); - const kept = slot ? cutToHostSlot({ start, end }, slot) : { start, end }; + const origin = start; + const kept = cutToHostSlots({ start, end }, params?.resolveHostSlots?.(el) ?? []); if (kept.start !== start || kept.end !== end) { - mediaStart += sourceTimeAt(rate, kept.start - start); ({ start, end } = kept); duration = end - start; } @@ -163,6 +163,7 @@ export function refreshRuntimeMediaCache(params?: { const clip: RuntimeMediaClip = { el, start, + origin, mediaStart, duration: Number.isFinite(end) ? duration : Number.POSITIVE_INFINITY, end, @@ -333,6 +334,7 @@ export function syncRuntimeMedia(params: { const { el } = clip; if (!el.isConnected) continue; const clipRate = clip.rate ?? clip.playbackRate; + const origin = clip.origin ?? clip.start; const isNonLoopVideo = el.tagName === "VIDEO" && !clip.loop; const inWindow = isInClipWindow(params.timeSeconds, clip.start, clip.end); const dueIn = clip.start - params.timeSeconds; @@ -355,7 +357,7 @@ export function syncRuntimeMedia(params: { (params.timeSeconds >= clip.end || sameInstant(params.timeSeconds, clip.end)) && isClipVisibleAt(params.timeSeconds, clip.start, clip.end, params.getCompositionDuration()); let relTime = - sourceTimeAt(clipRate, Math.max(0, Math.min(params.timeSeconds, clip.end) - clip.start)) + + sourceTimeAt(clipRate, Math.max(0, Math.min(params.timeSeconds, clip.end) - origin)) + clip.mediaStart; const isHeldVideoTail = isTerminalVideo || @@ -409,7 +411,7 @@ export function syncRuntimeMedia(params: { // different position than it renders, or ran it off the end entirely on a // trimmed clip. The FX lanes on this same feature use clip-local elapsed; // there is one time base, and this is it. - const laneGain = elementVolumeLaneGain(el, params.timeSeconds - clip.start); + const laneGain = elementVolumeLaneGain(el, params.timeSeconds - origin); if (laneGain !== null) { authorVolume = clampAudioGain(laneGain); } else if (clip.volumeKeyframes && clip.volumeKeyframes.length > 0) { @@ -420,7 +422,7 @@ export function syncRuntimeMedia(params: { // `relTime` is a position inside the media SOURCE — it carries `mediaStart` // and the playback rate — so it only coincides with the envelope's time base // for an untrimmed clip playing at 1x from t=0. - const elapsedInClip = params.timeSeconds - clip.start; + const elapsedInClip = params.timeSeconds - origin; authorVolume = clampAudioGain(interpolateVolumeGain(clip.volumeKeyframes, elapsedInClip)); } else if (params.isWebAudioRouted?.(el)) { authorVolume = fallbackAuthorVolume; @@ -454,7 +456,11 @@ export function syncRuntimeMedia(params: { // Clip-local fade on top of the resolved level, matching render's afade-after-volume. const fades = clip.fades ?? NO_FADES; if (fades.fadeIn > 0 || fades.fadeOut > 0) { - authorVolume *= fadeGain(params.timeSeconds - clip.start, clip.duration, fades); + authorVolume *= fadeGain( + params.timeSeconds - origin, + clip.duration + clip.start - origin, + fades, + ); } // A data-hidden ancestor is silent in the export (audioMixer.ts drops @@ -482,7 +488,7 @@ export function syncRuntimeMedia(params: { // was overridden after init.ts set it. if (el.preload !== "auto") el.preload = "auto"; // Per-element rate × global transport rate - const baseRate = rateAt(clipRate, params.timeSeconds - clip.start) * params.playbackRate; + const baseRate = rateAt(clipRate, params.timeSeconds - origin) * params.playbackRate; // Drift correction — three tiers: // // 1. Hard sync (0.5s): first tick, timeline jumps (scrub), catastrophic diff --git a/packages/core/src/runtime/startResolver.test.ts b/packages/core/src/runtime/startResolver.test.ts index 0740e703e95..b523d618c81 100644 --- a/packages/core/src/runtime/startResolver.test.ts +++ b/packages/core/src/runtime/startResolver.test.ts @@ -481,4 +481,17 @@ describe("a host's in-point", () => { expect(resolver.resolveStartForElement(document.getElementById("inner")!)).toBe(6); expect(media("deep")).toBe(6); }); + + it("places a relative start where the same local time written as a number lands", () => { + // A host at 0 with in-point 5 puts local 0 at -5. + document.body.innerHTML = + `
` + + `
` + + `
`; + const resolver = createRuntimeStartTimeResolver({}); + const media = (id: string) => + resolver.resolveMediaStartForElement(document.getElementById(id)!); + + expect([media("relative"), media("numeric")]).toEqual([-1, -1]); + }); }); diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index e49bcd06747..cd0f4a9d7ae 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -13,6 +13,7 @@ import { isMediaElement } from "./domRealm"; import { parseStartExpression } from "./startExpression"; import { compositionOriginSeconds, + type HostSlot, isRootGlobalMediaStart, MEDIA_START_BASIS_ATTR, resolveMediaStartSeconds, @@ -34,6 +35,7 @@ export function createRuntimeStartTimeResolver(params: { resolveDurationForElement: (element: Element) => number | null; resolveMediaStartForElement: (element: Element) => number; resolveHostStartForElement: (element: Element) => number; + resolveHostSlotsForElement: (element: Element) => HostSlot[]; isRootGlobalMediaStartForElement: (element: Element) => boolean; } { const timelineRegistry = params.timelineRegistry ?? {}; @@ -147,7 +149,9 @@ export function createRuntimeStartTimeResolver(params: { if (!target) return fallback; const targetStart = resolveStartForElementInternal(target, 0); const targetDuration = resolveDurationForElement(target) ?? 0; - return Math.max(0, targetStart + targetDuration + expression.offset); + // Clamped at local 0, as a numeric start is, once an in-point puts local 0 before 0. + const floor = Math.min(0, resolveHostOffsetForElement(element, 0)); + return Math.max(floor, targetStart + targetDuration + expression.offset); }; const resolveStartForElementInternal = (element: Element, fallback: number): number => { @@ -197,6 +201,21 @@ export function createRuntimeStartTimeResolver(params: { }); }; + const resolveHostSlotsForElement = (element: Element): HostSlot[] => { + const slots: HostSlot[] = []; + let root = element.closest("[data-composition-id]"); + for (; root; root = root.parentElement?.closest("[data-composition-id]") ?? null) { + const start = resolveStartForElementInternal(root, 0); + const duration = resolveDurationForElement(root); + slots.push({ + start, + end: duration ? start + duration : Infinity, + origin: resolveCompositionOrigin(root, 0), + }); + } + return slots; + }; + const isRootGlobalMediaStartForElement = (element: Element): boolean => isMediaElement(element) && isRootGlobalMediaStart(mediaStartInput(element)); @@ -206,6 +225,7 @@ export function createRuntimeStartTimeResolver(params: { resolveDurationForElement: (element: Element) => resolveDurationForElement(element), resolveMediaStartForElement, resolveHostStartForElement: (element: Element) => resolveHostOffsetForElement(element, 0), + resolveHostSlotsForElement, isRootGlobalMediaStartForElement, }; } diff --git a/packages/core/src/runtime/webAudioTransport.test.ts b/packages/core/src/runtime/webAudioTransport.test.ts index 2c01d95f9f7..dcb384c6c62 100644 --- a/packages/core/src/runtime/webAudioTransport.test.ts +++ b/packages/core/src/runtime/webAudioTransport.test.ts @@ -500,6 +500,16 @@ describe("WebAudioTransport", () => { expect(mock.startFn).toHaveBeenCalledWith(108, 1.5, 4); }); + it("holds a clip whose head an in-point cut until its window opens, at that point's source", async () => { + const { transport, mock, gen } = setupTransport(100); + // The clip's own t=0 is 1, its window opens at 2 and ends at 5: from comp time 0, wait 2 s, then + // play source 1 to 4; from comp time 1.5, the same. + await transport.schedulePlayback(mockEl, mockBuffer, 1, 0, () => 0, 1, gen, 1, 4, 2); + expect(mock.startFn).toHaveBeenLastCalledWith(102, 1, 3); + await transport.schedulePlayback(mockEl, mockBuffer, 1, 0, () => 1.5, 1, gen, 1, 4, 2); + expect(mock.startFn).toHaveBeenLastCalledWith(100.5, 1, 3); + }); + it("does not schedule a clip whose window has already elapsed", async () => { const { transport, mock, gen } = setupTransport(100); // elapsed=15 > clipDuration=10 → nothing to play diff --git a/packages/core/src/runtime/webAudioTransport.ts b/packages/core/src/runtime/webAudioTransport.ts index 52766f4a7ee..d02770764f0 100644 --- a/packages/core/src/runtime/webAudioTransport.ts +++ b/packages/core/src/runtime/webAudioTransport.ts @@ -69,22 +69,17 @@ function startBoundedSource( globalRate: number; mediaRate: number; clipDuration: number; + headCut: number; }, ): boolean { - const { elapsed, mediaStart, scheduledAt, globalRate, mediaRate, clipDuration } = opts; + const { elapsed, mediaStart, scheduledAt, globalRate, mediaRate, clipDuration, headCut } = opts; const hasBound = Number.isFinite(clipDuration) && clipDuration > 0; - const clipSourceLen = clipDuration * mediaRate; - if (elapsed >= 0) { - const sourceElapsed = elapsed * mediaRate; - const remaining = clipSourceLen - sourceElapsed; - if (hasBound && remaining <= 0) return false; - if (hasBound) node.start(0, sourceElapsed + mediaStart, remaining); - else node.start(0, sourceElapsed + mediaStart); - return true; - } - const delay = -elapsed / globalRate; - if (hasBound) node.start(scheduledAt + delay, mediaStart, clipSourceLen); - else node.start(scheduledAt + delay, mediaStart); + const from = Math.max(elapsed, headCut, 0); + const remaining = (clipDuration - from) * mediaRate; + if (hasBound && remaining <= 0) return false; + const when = from > elapsed ? scheduledAt + (from - elapsed) / globalRate : 0; + if (hasBound) node.start(when, from * mediaRate + mediaStart, remaining); + else node.start(when, from * mediaRate + mediaStart); return true; } @@ -667,6 +662,7 @@ export class WebAudioTransport { generation: number, rate = 1, clipDuration = Number.POSITIVE_INFINITY, + windowStart = compositionStart, ): Promise { if (!this._ctx || !this._masterGain) return null; if (generation !== this._playGeneration) return null; @@ -715,6 +711,7 @@ export class WebAudioTransport { globalRate: safeRate, mediaRate, clipDuration, + headCut: windowStart - compositionStart, }) ) { // Playhead already past the clip end — discard the nodes we built. From b67013dca816ce85a04e03f6eb6801ce5482a300 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 10 Oct 2026 11:19:38 -0700 Subject: [PATCH 4/9] fix(core): a scene's media plays past a shorter root animation again --- .../init.subCompositionPlacement.test.ts | 17 ++++ packages/core/src/runtime/startResolver.ts | 94 +++++++++---------- 2 files changed, 60 insertions(+), 51 deletions(-) diff --git a/packages/core/src/runtime/init.subCompositionPlacement.test.ts b/packages/core/src/runtime/init.subCompositionPlacement.test.ts index b78d9dde201..bb68709ccf5 100644 --- a/packages/core/src/runtime/init.subCompositionPlacement.test.ts +++ b/packages/core/src/runtime/init.subCompositionPlacement.test.ts @@ -304,6 +304,23 @@ describe("runtime sub-composition placement", () => { expect(sourceTimeOf("scene-split-split", "v3")).toBe(4); }); + it("lets a scene's video play past a shorter root animation when the root authors no length", () => { + document.body.innerHTML = + `
` + + `
` + + `
`; + stubDuration(video("long"), 10); + Object.defineProperty(video("long"), "currentTime", { value: -1, writable: true }); + video("long").play = vi.fn(() => Promise.resolve()); + video("long").pause = vi.fn(); + // The root's own timeline is a 2 s title fade. + window.__timelines = { main: createMockTimeline(2) }; + initSandboxRuntimeModular(); + + const at = (t: number) => (window.__player?.renderSeek(t), video("long").currentTime); + expect([at(1), at(5), at(8)]).toEqual([1, 5, 8]); + }); + it("keeps a video in a composition nested inside the half silent until the half starts", () => { loadSecondHalf( `
` + diff --git a/packages/core/src/runtime/startResolver.ts b/packages/core/src/runtime/startResolver.ts index cd0f4a9d7ae..0fe6a180970 100644 --- a/packages/core/src/runtime/startResolver.ts +++ b/packages/core/src/runtime/startResolver.ts @@ -53,60 +53,51 @@ export function createRuntimeStartTimeResolver(params: { ); }; - const resolveDurationForElement = (element: Element): number | null => { - const cached = durationCache.get(element); - if (cached !== undefined) return cached; - let resolved: number | null = null; - const durationTiming = resolveAuthoredTimingWindow({ + const authoredAttr = (element: Element, name: string) => + includeAuthoredTimingAttrs ? element.getAttribute(name) : null; + + const resolveAuthoredDuration = (element: Element): number | null => + resolveAuthoredTimingWindow({ start: 0, duration: element.getAttribute("data-duration"), - authoredDuration: includeAuthoredTimingAttrs - ? element.getAttribute("data-hf-authored-duration") - : null, - }); - if (durationTiming?.duration != null && durationTiming.duration > 0) { - resolved = durationTiming.duration; + authoredDuration: authoredAttr(element, "data-hf-authored-duration"), + })?.duration ?? + resolveAuthoredTimingWindow({ + start: resolveStartForElementInternal(element, 0), + end: element.getAttribute("data-end"), + authoredEnd: authoredAttr(element, "data-hf-authored-end"), + })?.duration ?? + null; + + const positive = (value: number | null | undefined): number | null => + value != null && Number.isFinite(value) && value > 0 ? value : null; + + const resolveRegisteredTimelineDuration = (element: Element): number | null => { + const timeline = timelineRegistry[element.getAttribute("data-composition-id") ?? ""]; + if (!timeline || typeof timeline.duration !== "function") return null; + try { + return positive(Number(timeline.duration())); + } catch (err) { + // ignore broken timeline impls + swallow("runtime.startResolver.site1", err); + return null; } - if (resolved == null || resolved <= 0) { - const start = resolveStartForElementInternal(element, 0); - const endTiming = resolveAuthoredTimingWindow({ - start, - end: element.getAttribute("data-end"), - authoredEnd: includeAuthoredTimingAttrs - ? element.getAttribute("data-hf-authored-end") + }; + + const resolveDurationForElement = (element: Element): number | null => { + const cached = durationCache.get(element); + if (cached !== undefined) return cached; + const resolved = + resolveAuthoredDuration(element) ?? + positive( + isMediaElement(element) + ? resolveNaturalMediaTimelineDuration(element, element.duration) : null, - }); - if (endTiming?.duration != null && endTiming.duration > 0) { - resolved = endTiming.duration; - } - } - if ((resolved == null || resolved <= 0) && isMediaElement(element)) { - resolved = resolveNaturalMediaTimelineDuration(element, element.duration); - } - if (resolved == null || resolved <= 0) resolved = resolveTimedImageDurationSeconds(element); - if (resolved == null || resolved <= 0) { - const compositionId = element.getAttribute("data-composition-id"); - if (compositionId) { - const timeline = timelineRegistry[compositionId] ?? null; - if (timeline && typeof timeline.duration === "function") { - try { - const timelineDuration = Number(timeline.duration()); - if (Number.isFinite(timelineDuration) && timelineDuration > 0) { - resolved = timelineDuration; - } - } catch (err) { - // ignore broken timeline impls - swallow("runtime.startResolver.site1", err); - } - } - } - } - if (resolved != null && Number.isFinite(resolved) && resolved > 0) { - durationCache.set(element, resolved); - return resolved; - } - durationCache.set(element, null); - return null; + ) ?? + positive(resolveTimedImageDurationSeconds(element)) ?? + resolveRegisteredTimelineDuration(element); + durationCache.set(element, resolved); + return resolved; }; // A mounted composition root without its own `data-start` takes its timing from the host it was @@ -201,12 +192,13 @@ export function createRuntimeStartTimeResolver(params: { }); }; + // Only an authored length cuts, as in the render; a GSAP length would cut what still plays. const resolveHostSlotsForElement = (element: Element): HostSlot[] => { const slots: HostSlot[] = []; let root = element.closest("[data-composition-id]"); for (; root; root = root.parentElement?.closest("[data-composition-id]") ?? null) { const start = resolveStartForElementInternal(root, 0); - const duration = resolveDurationForElement(root); + const duration = resolveAuthoredDuration(root); slots.push({ start, end: duration ? start + duration : Infinity, From 595fa1e8928e35b0b0e54b93e47f563ad6195409 Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Sat, 10 Oct 2026 13:33:35 -0700 Subject: [PATCH 5/9] fix(core): only a host with an in-point shifts or cuts what it holds --- .../timeline/describeProject.inPoint.test.ts | 26 +- packages/cli/src/timeline/describeProject.ts | 22 +- packages/core/src/mediaTiming.ts | 12 +- .../src/runtime/init.noInPointParity.test.ts | 475 ++++++++++++++++++ .../init.subCompositionPlacement.test.ts | 10 + packages/core/src/runtime/init.test.ts | 24 + packages/core/src/runtime/init.ts | 23 +- packages/core/src/runtime/startResolver.ts | 29 +- 8 files changed, 580 insertions(+), 41 deletions(-) create mode 100644 packages/core/src/runtime/init.noInPointParity.test.ts diff --git a/packages/cli/src/timeline/describeProject.inPoint.test.ts b/packages/cli/src/timeline/describeProject.inPoint.test.ts index 515344f93ed..2fde62feeee 100644 --- a/packages/cli/src/timeline/describeProject.inPoint.test.ts +++ b/packages/cli/src/timeline/describeProject.inPoint.test.ts @@ -18,7 +18,7 @@ const SCENE = `