From 732b3a6150318d7e10827ce515a72ae728a6254b Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 15 Sep 2026 17:04:14 -0400 Subject: [PATCH 1/2] fix(studio): scope untimed timeline elements to their enclosing clip An untimed element nested inside an authored clip (an off-screen source image or bare canvas a scene's own script draws from) inherited the ROOT composition's full duration instead of its enclosing clip's own window, in both the runtime clip-manifest builder and the Studio frontend's DOM fallback. Both now walk up to the nearest ancestor that defines a time window - a sub-composition or a plain authored clip - instead of only recognizing sub-compositions. --- packages/core/src/runtime/timeline.test.ts | 30 ++++ packages/core/src/runtime/timeline.ts | 4 + .../studio/src/player/lib/timelineDOM.test.ts | 26 +++ packages/studio/src/player/lib/timelineDOM.ts | 161 +++++++++++++----- .../src/player/lib/timelineElementHelpers.ts | 7 +- 5 files changed, 180 insertions(+), 48 deletions(-) diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index 3eeb95069c..7acb24de07 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -990,4 +990,34 @@ describe("collectRuntimeTimelinePayload", () => { const result = collectRuntimeTimelinePayload(defaultParams); expect(result.clips.find((c) => c.id === "my-script")).toBeUndefined(); }); + + it("scopes an untimed img nested in a plain authored clip to that clip's window", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "root"); + root.setAttribute("data-duration", "50.3666"); + document.body.appendChild(root); + + // A scene clip authored with data-start/data-duration but no + // data-composition-id of its own — the shape build.mjs-style pipelines + // emit for each unit/scene. + const scene = document.createElement("div"); + scene.id = "u04"; + scene.className = "clip"; + scene.setAttribute("data-start", "8.5233"); + scene.setAttribute("data-duration", "4.7867"); + root.appendChild(scene); + + // The scene's own script draws from this off-screen source image; it + // carries no timing of its own. + const img = document.createElement("img"); + img.id = "u04-mosaicsrc"; + img.setAttribute("src", "mosaic.png"); + scene.appendChild(img); + + const result = collectRuntimeTimelinePayload(defaultParams); + const clip = result.clips.find((c) => c.id === "u04-mosaicsrc"); + + expect(clip?.start).toBeCloseTo(8.5233, 3); + expect(clip?.duration).toBeCloseTo(4.7867, 3); + }); }); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index b02dcb0308..0ced3b85de 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -223,6 +223,10 @@ export function collectRuntimeTimelinePayload(params: { if (!parentCompositionId && cursor !== root) { parentCompositionId = compositionId; } + } + // A plain authored clip (`data-start` with no `data-composition-id`) + // bounds its untimed descendants the same way a sub-composition does. + if (compositionId || cursor.hasAttribute("data-start")) { if (inheritedStart == null) { inheritedStart = startResolver.resolveStartForElement(cursor, 0); } diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts index b33c81244b..55dc81bb3c 100644 --- a/packages/studio/src/player/lib/timelineDOM.test.ts +++ b/packages/studio/src/player/lib/timelineDOM.test.ts @@ -386,6 +386,32 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => { expect(layers).toEqual([]); }); + + it("scopes an untimed child of a clip to that clip's own window, not the full root duration", () => { + const doc = makeDoc(` +
+
+
+ + +
+
+ `); + + const layers = createImplicitTimelineLayersFromDOM(doc, 50.37); + const ground = layers.find((l) => l.domId === "ground"); + const mosaicsrc = layers.find((l) => l.domId === "u04-mosaicsrc"); + const mosaic = layers.find((l) => l.domId === "u04-mosaic"); + + // A root-level orphan with no enclosing clip still falls back to the + // full timeline — there is no narrower scope to give it. + expect(ground).toMatchObject({ start: 0, duration: 50.37 }); + + // Children of an authored clip inherit ITS window: they can only ever + // be on screen while u04 is, so their row must not claim the whole film. + expect(mosaicsrc).toMatchObject({ start: 8.53, duration: 4.77 }); + expect(mosaic).toMatchObject({ start: 8.53, duration: 4.77 }); + }); }); describe("mergeTimelineElementsPreservingDowngrades — genuine removal vs transient downgrade", () => { diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index 0131ffe604..bdcf4bf0fe 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -13,6 +13,8 @@ import type { ClipManifestClip } from "./playbackTypes"; import { resolveCssStackingContextId } from "@hyperframes/core/runtime/stacking-context"; import { readClipTiming } from "@hyperframes/core/composition-contract"; import { groupInfoFor } from "./timelineGroupInfo"; +import { clampNumber } from "../../utils/studioHelpers"; +import { withSelectorIndexPass } from "../../utils/sourceScopedSelectorIndex"; import { resolveMediaElement, applyMediaMetadataFromElement, @@ -203,10 +205,113 @@ export function createTimelineElementFromManifestClip(params: { return entry; } +interface ImplicitLayerScope { + readonly container: Element; + readonly start: number; + readonly duration: number; +} + +/** Default an unset/zero duration to "rest of the root window," capped at rootDuration. */ +function clampClipDurationToRoot( + start: number, + duration: number | null, + rootDuration: number, +): number { + let dur = duration ?? 0; + if (dur <= 0) dur = Math.max(0, rootDuration - start); + if (Number.isFinite(rootDuration) && rootDuration > 0) { + dur = clampNumber(dur, 0, Math.max(0, rootDuration - start)); + } + return dur; +} + +/** + * Every container an implicit layer can be scoped to: the composition root, + * plus every authored `[data-start]` clip, each with its own start/duration. + */ +function collectImplicitLayerScopes( + doc: Document, + rootComp: Element, + rootDuration: number, + timedNodes: readonly Element[], +): ImplicitLayerScope[] { + const scopes: ImplicitLayerScope[] = [{ container: rootComp, start: 0, duration: rootDuration }]; + for (const el of timedNodes) { + if (el === rootComp || isTimelineIgnoredElement(el)) continue; + const timing = readClipTiming(el); + if (timing.start == null) continue; + const duration = clampClipDurationToRoot(timing.start, timing.duration, rootDuration); + if (duration <= 0) continue; + scopes.push({ container: el, start: timing.start, duration }); + } + return scopes; +} + +// Guards only against two distinct elements sharing a duplicate `id` across +// sibling clips (invalid HTML some compositions still ship) emitting a row +// under the same key — each candidate otherwise visits exactly one scope. +// fallow-ignore-next-line complexity +function buildImplicitLayers( + scopes: readonly ImplicitLayerScope[], + doc: Document, + existingKeys: ReadonlySet, + existingElementsLength: number, + maxTrack: number, +): TimelineElement[] { + const seenKeys = new Set(); + const layers: TimelineElement[] = []; + + for (const scope of scopes) { + for (const child of Array.from(scope.container.children)) { + if (!isImplicitTimelineLayerCandidate(scope.container, child)) continue; + + const selector = getTimelineElementSelector(child); + if (!selector) continue; + const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector); + const sourceFile = getTimelineElementSourceFile(child); + const label = getImplicitTimelineLayerLabel(child); + const identity = buildTimelineElementIdentity({ + preferredId: child.id || null, + label, + fallbackIndex: existingElementsLength + layers.length, + domId: child.id || undefined, + selector, + selectorIndex, + sourceFile, + }); + if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue; + if (seenKeys.has(identity.key)) continue; + seenKeys.add(identity.key); + + layers.push({ + domId: child.id || undefined, + hfId: child.getAttribute("data-hf-id") || undefined, + zIndex: readTimelineElementZIndex(child), + duration: scope.duration, + id: identity.id, + key: identity.key, + label, + selector, + selectorIndex, + sourceFile, + stackingContextId: resolveCssStackingContextId(child), + start: scope.start, + tag: child.tagName.toLowerCase(), + timingSource: "implicit", + track: maxTrack + 1 + layers.length, + }); + } + } + + return layers; +} + +/** `timedNodes` lets a caller that already queried `[data-start]` reuse it; others omit it. */ export function createImplicitTimelineLayersFromDOM( doc: Document, rootDuration: number, existingElements: readonly TimelineElement[] = [], + timedNodes?: readonly Element[], ): TimelineElement[] { if (!Number.isFinite(rootDuration) || rootDuration <= 0) return []; const rootComp = doc.querySelector("[data-composition-id]"); @@ -217,47 +322,12 @@ export function createImplicitTimelineLayersFromDOM( (max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0), -1, ); - const layers: TimelineElement[] = []; - - for (const child of Array.from(rootComp.children)) { - if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue; - - const selector = getTimelineElementSelector(child); - if (!selector) continue; - const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector); - const sourceFile = getTimelineElementSourceFile(child); - const label = getImplicitTimelineLayerLabel(child); - const identity = buildTimelineElementIdentity({ - preferredId: child.id || null, - label, - fallbackIndex: existingElements.length + layers.length, - domId: child.id || undefined, - selector, - selectorIndex, - sourceFile, - }); - if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue; + const nodes = timedNodes ?? Array.from(doc.querySelectorAll("[data-start]")); + const scopes = collectImplicitLayerScopes(doc, rootComp, rootDuration, nodes); - layers.push({ - domId: child.id || undefined, - hfId: child.getAttribute("data-hf-id") || undefined, - zIndex: readTimelineElementZIndex(child), - duration: rootDuration, - id: identity.id, - key: identity.key, - label, - selector, - selectorIndex, - sourceFile, - stackingContextId: resolveCssStackingContextId(child), - start: 0, - tag: child.tagName.toLowerCase(), - timingSource: "implicit", - track: maxTrack + 1 + layers.length, - }); - } - - return layers; + return withSelectorIndexPass(doc, () => + buildImplicitLayers(scopes, doc, existingKeys, existingElements.length, maxTrack), + ); } /** @@ -281,11 +351,7 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel if (Number.isFinite(rootDuration) && rootDuration > 0 && start >= rootDuration) return; const tagLower = el.tagName.toLowerCase(); - let dur = timing.duration ?? 0; - if (dur <= 0) dur = Math.max(0, rootDuration - start); - if (Number.isFinite(rootDuration) && rootDuration > 0) { - dur = Math.min(dur, Math.max(0, rootDuration - start)); - } + const dur = clampClipDurationToRoot(start, timing.duration, rootDuration); if (!Number.isFinite(dur) || dur <= 0) return; const track = timing.trackSource === "default" ? trackCounter++ : timing.trackIndex; @@ -399,7 +465,10 @@ export function parseTimelineFromDOM(doc: Document, rootDuration: number): Timel els.push(entry); }); - return [...els, ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els)]; + return [ + ...els, + ...createImplicitTimelineLayersFromDOM(doc, rootDuration, els, Array.from(nodes)), + ]; } // --------------------------------------------------------------------------- diff --git a/packages/studio/src/player/lib/timelineElementHelpers.ts b/packages/studio/src/player/lib/timelineElementHelpers.ts index f1a5d6f96b..ca8c8c70b9 100644 --- a/packages/studio/src/player/lib/timelineElementHelpers.ts +++ b/packages/studio/src/player/lib/timelineElementHelpers.ts @@ -472,10 +472,13 @@ export function createTimelineDomNodeResolver(doc: Document) { // Implicit layer detection // --------------------------------------------------------------------------- -export function isImplicitTimelineLayerCandidate(root: Element, el: Element): el is HTMLElement { +export function isImplicitTimelineLayerCandidate( + container: Element, + el: Element, +): el is HTMLElement { if (!isHtmlElement(el)) return false; if (isTimelineIgnoredElement(el)) return false; - if (el.parentElement !== root) return false; + if (el.parentElement !== container) return false; const tagName = el.tagName.toLowerCase(); if (IMPLICIT_TIMELINE_LAYER_SKIP_TAGS.has(tagName)) return false; if (el.hasAttribute("data-start") || el.hasAttribute("data-track-index")) return false; From 117afe31b51e3f7f73a138fad2e83f382533493d Mon Sep 17 00:00:00 2001 From: Miguel Angel Simon Sierra Date: Tue, 15 Sep 2026 17:34:06 -0400 Subject: [PATCH 2/2] fix(core): lock inherited start and duration to the same ancestor An untimed element's inherited start and duration each locked independently at the first ancestor that resolved one, so a clip authored with data-end (no data-duration) let the start lock there while the duration search kept climbing to an unrelated farther ancestor - a window neither clip actually has. Both now lock together at one ancestor, and duration resolution delegates to the existing resolveDurationForElement instead of re-deriving a narrower copy of it. Also renames a Studio test whose name implied a width regression that never existed (the pre-fix behavior was no row at all, not a full-width one), and splits per-candidate row construction out of the scope loop so the loop body reads at a glance. --- packages/core/src/runtime/timeline.test.ts | 34 ++++++ packages/core/src/runtime/timeline.ts | 21 ++-- .../studio/src/player/lib/timelineDOM.test.ts | 2 +- packages/studio/src/player/lib/timelineDOM.ts | 100 +++++++++++------- 4 files changed, 106 insertions(+), 51 deletions(-) diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index 7acb24de07..ef512a5c53 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -1020,4 +1020,38 @@ describe("collectRuntimeTimelinePayload", () => { expect(clip?.start).toBeCloseTo(8.5233, 3); expect(clip?.duration).toBeCloseTo(4.7867, 3); }); + + it("locks start and duration to the SAME ancestor when the nearest one uses data-end", () => { + const root = document.createElement("div"); + root.setAttribute("data-composition-id", "root"); + root.setAttribute("data-duration", "50"); + document.body.appendChild(root); + + // clipA's real window is 30..35; clipB nests inside it and expresses its + // (narrower) window as data-end instead of data-duration. + const clipA = document.createElement("div"); + clipA.id = "clipA"; + clipA.setAttribute("data-start", "30"); + clipA.setAttribute("data-duration", "5"); + root.appendChild(clipA); + + const clipB = document.createElement("div"); + clipB.id = "clipB"; + clipB.setAttribute("data-start", "31"); + clipB.setAttribute("data-end", "35"); + clipA.appendChild(clipB); + + const img = document.createElement("img"); + img.id = "untimed"; + img.setAttribute("src", "x.png"); + clipB.appendChild(img); + + const result = collectRuntimeTimelinePayload(defaultParams); + const clip = result.clips.find((c) => c.id === "untimed"); + + // clipB's own window (31..35), not clipA's start paired with clipA's + // duration and not a window that runs past either ancestor. + expect(clip?.start).toBeCloseTo(31, 3); + expect(clip?.duration).toBeCloseTo(4, 3); + }); }); diff --git a/packages/core/src/runtime/timeline.ts b/packages/core/src/runtime/timeline.ts index 0ced3b85de..59e3804104 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -224,18 +224,15 @@ export function collectRuntimeTimelinePayload(params: { parentCompositionId = compositionId; } } - // A plain authored clip (`data-start` with no `data-composition-id`) - // bounds its untimed descendants the same way a sub-composition does. - if (compositionId || cursor.hasAttribute("data-start")) { - if (inheritedStart == null) { - inheritedStart = startResolver.resolveStartForElement(cursor, 0); - } - if (inheritedDuration == null) { - inheritedDuration = - parseNum(cursor.getAttribute("data-duration")) ?? - resolveTimelineDurationSeconds(compositionId) ?? - null; - } + // A plain authored clip bounds its untimed descendants like a + // sub-composition does. Start and duration lock together at the SAME + // ancestor — independent locks let a `data-end`-only clip's start pair + // with a farther ancestor's duration, a window neither one actually has. + if (inheritedStart == null && (compositionId || cursor.hasAttribute("data-start"))) { + inheritedStart = startResolver.resolveStartForElement(cursor, 0); + inheritedDuration = + startResolver.resolveDurationForElement(cursor) ?? + resolveTimelineDurationSeconds(compositionId); } cursor = cursor.parentElement; } diff --git a/packages/studio/src/player/lib/timelineDOM.test.ts b/packages/studio/src/player/lib/timelineDOM.test.ts index 55dc81bb3c..8d5da87689 100644 --- a/packages/studio/src/player/lib/timelineDOM.test.ts +++ b/packages/studio/src/player/lib/timelineDOM.test.ts @@ -387,7 +387,7 @@ describe("createImplicitTimelineLayersFromDOM — hfId from data-hf-id", () => { expect(layers).toEqual([]); }); - it("scopes an untimed child of a clip to that clip's own window, not the full root duration", () => { + it("emits a row for a clip's untimed children, scoped to that clip's own window", () => { const doc = makeDoc(`
diff --git a/packages/studio/src/player/lib/timelineDOM.ts b/packages/studio/src/player/lib/timelineDOM.ts index bdcf4bf0fe..12ad8a66e3 100644 --- a/packages/studio/src/player/lib/timelineDOM.ts +++ b/packages/studio/src/player/lib/timelineDOM.ts @@ -247,9 +247,55 @@ function collectImplicitLayerScopes( return scopes; } -// Guards only against two distinct elements sharing a duplicate `id` across -// sibling clips (invalid HTML some compositions still ship) emitting a row -// under the same key — each candidate otherwise visits exactly one scope. +/** Null when `child` resolves no selector — the candidate can't be identified as a layer. */ +function buildImplicitLayerEntry( + child: HTMLElement, + scope: ImplicitLayerScope, + doc: Document, + fallbackIndex: number, + track: number, +): { layer: TimelineElement; key: string; id: string } | null { + const selector = getTimelineElementSelector(child); + if (!selector) return null; + + const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector); + const sourceFile = getTimelineElementSourceFile(child); + const label = getImplicitTimelineLayerLabel(child); + const identity = buildTimelineElementIdentity({ + preferredId: child.id || null, + label, + fallbackIndex, + domId: child.id || undefined, + selector, + selectorIndex, + sourceFile, + }); + + return { + key: identity.key, + id: identity.id, + layer: { + domId: child.id || undefined, + hfId: child.getAttribute("data-hf-id") || undefined, + zIndex: readTimelineElementZIndex(child), + duration: scope.duration, + id: identity.id, + key: identity.key, + label, + selector, + selectorIndex, + sourceFile, + stackingContextId: resolveCssStackingContextId(child), + start: scope.start, + tag: child.tagName.toLowerCase(), + timingSource: "implicit", + track, + }, + }; +} + +// Drops (not renames) a candidate whose key already exists — a duplicate `id` +// across sibling clips, or a match already in existingKeys. // fallow-ignore-next-line complexity function buildImplicitLayers( scopes: readonly ImplicitLayerScope[], @@ -265,41 +311,19 @@ function buildImplicitLayers( for (const child of Array.from(scope.container.children)) { if (!isImplicitTimelineLayerCandidate(scope.container, child)) continue; - const selector = getTimelineElementSelector(child); - if (!selector) continue; - const selectorIndex = getTimelineElementSelectorIndex(doc, child, selector); - const sourceFile = getTimelineElementSourceFile(child); - const label = getImplicitTimelineLayerLabel(child); - const identity = buildTimelineElementIdentity({ - preferredId: child.id || null, - label, - fallbackIndex: existingElementsLength + layers.length, - domId: child.id || undefined, - selector, - selectorIndex, - sourceFile, - }); - if (existingKeys.has(identity.key) || existingKeys.has(identity.id)) continue; - if (seenKeys.has(identity.key)) continue; - seenKeys.add(identity.key); - - layers.push({ - domId: child.id || undefined, - hfId: child.getAttribute("data-hf-id") || undefined, - zIndex: readTimelineElementZIndex(child), - duration: scope.duration, - id: identity.id, - key: identity.key, - label, - selector, - selectorIndex, - sourceFile, - stackingContextId: resolveCssStackingContextId(child), - start: scope.start, - tag: child.tagName.toLowerCase(), - timingSource: "implicit", - track: maxTrack + 1 + layers.length, - }); + const entry = buildImplicitLayerEntry( + child, + scope, + doc, + existingElementsLength + layers.length, + maxTrack + 1 + layers.length, + ); + if (!entry) continue; + if (existingKeys.has(entry.key) || existingKeys.has(entry.id)) continue; + if (seenKeys.has(entry.key)) continue; + seenKeys.add(entry.key); + + layers.push(entry.layer); } }