diff --git a/packages/core/src/runtime/timeline.test.ts b/packages/core/src/runtime/timeline.test.ts index 3eeb95069c..ef512a5c53 100644 --- a/packages/core/src/runtime/timeline.test.ts +++ b/packages/core/src/runtime/timeline.test.ts @@ -990,4 +990,68 @@ 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); + }); + + 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 b02dcb0308..59e3804104 100644 --- a/packages/core/src/runtime/timeline.ts +++ b/packages/core/src/runtime/timeline.ts @@ -223,15 +223,16 @@ export function collectRuntimeTimelinePayload(params: { if (!parentCompositionId && cursor !== root) { parentCompositionId = compositionId; } - 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 b33c81244b..8d5da87689 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("emits a row for a clip's untimed children, scoped to that clip's own window", () => { + 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..12ad8a66e3 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,46 +205,80 @@ export function createTimelineElementFromManifestClip(params: { return entry; } -export function createImplicitTimelineLayersFromDOM( - doc: Document, - rootDuration: number, - existingElements: readonly TimelineElement[] = [], -): TimelineElement[] { - if (!Number.isFinite(rootDuration) || rootDuration <= 0) return []; - const rootComp = doc.querySelector("[data-composition-id]"); - if (!rootComp) return []; +interface ImplicitLayerScope { + readonly container: Element; + readonly start: number; + readonly duration: number; +} - const existingKeys = new Set(existingElements.map(getTimelineElementIdentity)); - const maxTrack = existingElements.reduce( - (max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0), - -1, - ); - const layers: TimelineElement[] = []; +/** 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; +} - for (const child of Array.from(rootComp.children)) { - if (!isImplicitTimelineLayerCandidate(rootComp, child)) continue; +/** + * 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; +} - 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; +/** 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, + }); - layers.push({ + return { + key: identity.key, + id: identity.id, + layer: { domId: child.id || undefined, hfId: child.getAttribute("data-hf-id") || undefined, zIndex: readTimelineElementZIndex(child), - duration: rootDuration, + duration: scope.duration, id: identity.id, key: identity.key, label, @@ -250,16 +286,74 @@ export function createImplicitTimelineLayersFromDOM( selectorIndex, sourceFile, stackingContextId: resolveCssStackingContextId(child), - start: 0, + start: scope.start, tag: child.tagName.toLowerCase(), timingSource: "implicit", - track: maxTrack + 1 + layers.length, - }); + 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[], + 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 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); + } } 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]"); + if (!rootComp) return []; + + const existingKeys = new Set(existingElements.map(getTimelineElementIdentity)); + const maxTrack = existingElements.reduce( + (max, element) => Math.max(max, Number.isFinite(element.track) ? element.track : 0), + -1, + ); + const nodes = timedNodes ?? Array.from(doc.querySelectorAll("[data-start]")); + const scopes = collectImplicitLayerScopes(doc, rootComp, rootDuration, nodes); + + return withSelectorIndexPass(doc, () => + buildImplicitLayers(scopes, doc, existingKeys, existingElements.length, maxTrack), + ); +} + /** * Parse [data-start] elements from a Document into TimelineElement[]. * Shared helper — used by onIframeLoad fallback, handleMessage, and enrichMissingCompositions. @@ -281,11 +375,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 +489,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;