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;