Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions packages/core/src/runtime/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
});
19 changes: 10 additions & 9 deletions packages/core/src/runtime/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
26 changes: 26 additions & 0 deletions packages/studio/src/player/lib/timelineDOM.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(`
<div data-composition-id="root">
<div id="ground"></div>
<div id="u04" class="clip" data-start="8.53" data-duration="4.77">
<canvas id="u04-mosaic"></canvas>
<img id="u04-mosaicsrc" src="assets/mosaic.png" />
</div>
</div>
`);

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", () => {
Expand Down
177 changes: 135 additions & 42 deletions packages/studio/src/player/lib/timelineDOM.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -203,63 +205,155 @@ 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,
selector,
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<string>,
existingElementsLength: number,
maxTrack: number,
): TimelineElement[] {
const seenKeys = new Set<string>();
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.
Expand All @@ -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;
Expand Down Expand Up @@ -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)),
];
}

// ---------------------------------------------------------------------------
Expand Down
7 changes: 5 additions & 2 deletions packages/studio/src/player/lib/timelineElementHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading