mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-04 07:19:52 +00:00
Merge pull request #2845 from heygen-com/fix/studio-subcomp-clip-timing
fix(studio): sub-composition clip timing and expanded rows
This commit is contained in:
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
|
||||
import { usePlayerStore, type KeyframeCacheEntry } from "../player/store/playerStore";
|
||||
import { idFromSelector, toClipKeyframes } from "./gsapShared";
|
||||
import { idFromSelector, resolveClipTimingBasis, toClipKeyframes } from "./gsapShared";
|
||||
import { deduplicateKeyframes, synthesizeFlatTweenKeyframes } from "./gsapTweenSynth";
|
||||
|
||||
export function updateKeyframeCacheFromParsed(
|
||||
@@ -13,7 +13,7 @@ export function updateKeyframeCacheFromParsed(
|
||||
selectionId: string | undefined,
|
||||
mutation: Record<string, unknown>,
|
||||
): void {
|
||||
const { setKeyframeCache, elements } = usePlayerStore.getState();
|
||||
const { setKeyframeCache, elements, domClipChildren } = usePlayerStore.getState();
|
||||
const idsWithKeyframes = new Set<string>();
|
||||
const merged = new Map<string, KeyframeCacheEntry>();
|
||||
const sourceAnimations = new Map<string, GsapAnimation[]>();
|
||||
@@ -31,16 +31,16 @@ export function updateKeyframeCacheFromParsed(
|
||||
sourceAnimations.set(id, [...(sourceAnimations.get(id) ?? []), anim]);
|
||||
|
||||
// Convert tween-relative percentages to clip-relative so diamonds
|
||||
// render at the correct position within the timeline clip.
|
||||
const timelineEl = elements.find(
|
||||
(el) => el.domId === id || (el.key ?? el.id) === `${targetPath}#${id}`,
|
||||
);
|
||||
const clipKeyframes = toClipKeyframes(
|
||||
kfSource,
|
||||
anim,
|
||||
timelineEl?.start ?? 0,
|
||||
timelineEl?.duration ?? 1,
|
||||
// render at the correct position within the timeline clip. The basis comes
|
||||
// from the shared resolver, so this writer agrees with the AST load on both
|
||||
// the sub-comp host fallback and the tween's own time frame.
|
||||
const { elStart, elDuration } = resolveClipTimingBasis(
|
||||
id,
|
||||
targetPath,
|
||||
elements,
|
||||
domClipChildren,
|
||||
);
|
||||
const clipKeyframes = toClipKeyframes(kfSource, anim, elStart, elDuration);
|
||||
|
||||
const existing = merged.get(id);
|
||||
if (existing) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
idSelector,
|
||||
isInstantHold,
|
||||
parsePercentageKeyframes,
|
||||
resolveClipTimingBasis,
|
||||
resolveEditableTweenDuration,
|
||||
toClipKeyframes,
|
||||
toClipPercentage,
|
||||
@@ -169,6 +170,149 @@ describe("toClipKeyframes", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveClipTimingBasis", () => {
|
||||
// Measured on v-product-promo: `captions-comp` mounts at 1.5s for 12.5s, and the
|
||||
// six tweens inside it resolve to 0..12.5 — composition-local, not main-timeline
|
||||
// absolute. Subtracting the host's 1.5 mount from a 0s tween cached pct -12.
|
||||
const host = { id: "captions-comp", domId: "captions-comp", start: 1.5, duration: 12.5 };
|
||||
const children = [{ id: "line", hostId: "captions-comp" }];
|
||||
|
||||
it("gives a sub-composition inner element the host window in the tween's own frame", () => {
|
||||
expect(resolveClipTimingBasis("line", "captions.html", [host], children)).toEqual({
|
||||
elStart: 0,
|
||||
elDuration: 12.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("leaves a root-composition element on the main timeline", () => {
|
||||
const box = { id: "box", domId: "box", start: 3, duration: 2 };
|
||||
expect(resolveClipTimingBasis("box", "index.html", [box], [])).toEqual({
|
||||
elStart: 3,
|
||||
elDuration: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("rebases an expanded sub-comp child by its host mount", () => {
|
||||
// Expanded children carry host-ABSOLUTE display starts; the tweens they own are
|
||||
// still composition-local, so the basis is the child's local start.
|
||||
const pill = { id: "pill", domId: "pill", start: 8, duration: 4, expandedParentStart: 6 };
|
||||
expect(resolveClipTimingBasis("pill", "scene.html", [pill], [])).toEqual({
|
||||
elStart: 2,
|
||||
elDuration: 4,
|
||||
});
|
||||
});
|
||||
|
||||
it("rebases by the parent composition clip when the child is not expanded", () => {
|
||||
const parent = { id: "scene-comp", domId: "scene-comp", start: 5, duration: 10 };
|
||||
const pill = {
|
||||
id: "pill",
|
||||
domId: "pill",
|
||||
start: 7,
|
||||
duration: 3,
|
||||
parentCompositionId: "scene-comp",
|
||||
};
|
||||
expect(resolveClipTimingBasis("pill", "scene.html", [parent, pill], [])).toEqual({
|
||||
elStart: 2,
|
||||
elDuration: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("treats a child whose parent composition is missing as starting at 0", () => {
|
||||
// The mount is unknowable, so the only safe frame is the child's own. The
|
||||
// old `?? 0` handed back `start` unchanged, which is a main-timeline value
|
||||
// masquerading as a composition-local one and caches negative percentages.
|
||||
const pill = {
|
||||
id: "pill",
|
||||
domId: "pill",
|
||||
start: 7,
|
||||
duration: 3,
|
||||
parentCompositionId: "not-in-elements",
|
||||
};
|
||||
expect(resolveClipTimingBasis("pill", "scene.html", [pill], [])).toEqual({
|
||||
elStart: 0,
|
||||
elDuration: 3,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a main-timeline clip's own start when it names no parent", () => {
|
||||
const box = { id: "box", domId: "box", start: 4, duration: 2 };
|
||||
expect(resolveClipTimingBasis("box", "index.html", [box], [])).toEqual({
|
||||
elStart: 4,
|
||||
elDuration: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to a unit window when neither the element nor a host resolves", () => {
|
||||
expect(resolveClipTimingBasis("ghost", "index.html", [], [])).toEqual({
|
||||
elStart: 0,
|
||||
elDuration: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("sub-composition keyframe percentages", () => {
|
||||
const host = { id: "captions-comp", domId: "captions-comp", start: 1.5, duration: 12.5 };
|
||||
const children = [{ id: "line", hostId: "captions-comp" }];
|
||||
const basis = () => resolveClipTimingBasis("line", "captions.html", [host], children);
|
||||
const inner = (resolvedStart: number, duration?: number) =>
|
||||
({
|
||||
id: `t-${resolvedStart}`,
|
||||
method: "to",
|
||||
targetSelector: "#line",
|
||||
vars: {},
|
||||
resolvedStart,
|
||||
duration,
|
||||
}) as unknown as GsapAnimation;
|
||||
const percentages = (animation: GsapAnimation) => {
|
||||
const { elStart, elDuration } = basis();
|
||||
return toClipKeyframes(
|
||||
[{ percentage: 0 }, { percentage: 100 }],
|
||||
animation,
|
||||
elStart,
|
||||
elDuration,
|
||||
).map((row) => row.percentage);
|
||||
};
|
||||
|
||||
it("puts a tween on the host's first frame at 0%, never below zero", () => {
|
||||
// A clip-relative percentage can never be negative; this one cached -12.
|
||||
expect(percentages(inner(0))).toEqual([0, 100]);
|
||||
});
|
||||
|
||||
it("puts the last tween's end keyframe at 100%", () => {
|
||||
expect(percentages(inner(12.1, 0.4))).toEqual([96.8, 100]);
|
||||
});
|
||||
|
||||
it("keeps every measured tween of the fixture inside 0..100", () => {
|
||||
for (const start of [0, 3.2, 3.5, 7.7, 8, 12.1]) {
|
||||
for (const percentage of percentages(inner(start, 0.4))) {
|
||||
expect(percentage).toBeGreaterThanOrEqual(0);
|
||||
expect(percentage).toBeLessThanOrEqual(100);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps a root-composition tween at the head of its own clip", () => {
|
||||
const box = { id: "box", domId: "box", start: 3, duration: 2 };
|
||||
const { elStart, elDuration } = resolveClipTimingBasis("box", "index.html", [box], []);
|
||||
const rows = toClipKeyframes([{ percentage: 0 }], inner(3, 2), elStart, elDuration);
|
||||
expect(rows[0]!.percentage).toBe(0);
|
||||
});
|
||||
|
||||
it("passes tween percentages through for a zero-length clip", () => {
|
||||
expect(toClipKeyframes([{ percentage: 40 }], inner(0, 0.4), 0, 0)[0]!.percentage).toBe(40);
|
||||
});
|
||||
|
||||
it("round-trips a clip percentage through the basis it was written with", () => {
|
||||
// The drag commit converts a dropped clip-% back to a time with this basis
|
||||
// (useTimelineEditCallbacks) and compares it against the tween's own
|
||||
// resolvedStart, so the basis has to be in the tween's frame on both sides.
|
||||
const { elStart, elDuration } = basis();
|
||||
const absTime = elStart + (40 / 100) * elDuration;
|
||||
expect(absTime).toBe(5);
|
||||
expect(toClipPercentage(absTime, elStart, elDuration, 0)).toBe(40);
|
||||
});
|
||||
});
|
||||
|
||||
describe("idFromSelector", () => {
|
||||
it("round-trips every shape idSelector emits", () => {
|
||||
for (const id of ["hero-word", "el_1", "01-hook-hero-word", "my.class", "1box", '1"x']) {
|
||||
|
||||
@@ -259,6 +259,67 @@ export function toAbsoluteTime(tweenPos: number, tweenDur: number, percentage: n
|
||||
return tweenPos + (percentage / 100) * tweenDur;
|
||||
}
|
||||
|
||||
/**
|
||||
* Timing basis for an element's keyframes, expressed in the TWEEN's own time
|
||||
* frame. Sub-composition internals (e.g. pills inside a scene) aren't timeline
|
||||
* clips themselves — they're derived at expand time — so they're absent from
|
||||
* `elements`. Without a basis, elDuration defaulted to 1 and clip-relative
|
||||
* keyframe percentages blew past 100% (rendering off the clip). Fall back to the
|
||||
* sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
||||
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
||||
*
|
||||
* `elStart` is the clip's start in the frame the tween's own times are measured
|
||||
* in. A sub-composition tween's resolvedStart is composition-local while a
|
||||
* timeline element's start is main-timeline absolute, so passing the raw element
|
||||
* start subtracted two different frames from each other: a host mounted at 1.5s
|
||||
* cached its 0s tween at -12%, and a clip-relative percentage can never be
|
||||
* negative. The composition's mount is `expandedParentStart` for an expanded
|
||||
* child, the parent composition clip's start otherwise, and 0 for a
|
||||
* root-composition element, whose start already IS the tween frame.
|
||||
*/
|
||||
export function resolveClipTimingBasis(
|
||||
elementId: string,
|
||||
sourceFile: string,
|
||||
elements: ReadonlyArray<{
|
||||
domId?: string;
|
||||
key?: string;
|
||||
id: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
expandedParentStart?: number;
|
||||
parentCompositionId?: string | null;
|
||||
}>,
|
||||
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
||||
): { elStart: number; elDuration: number } {
|
||||
const direct = elements.find(
|
||||
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
||||
);
|
||||
if (direct) {
|
||||
const parentId = direct.parentCompositionId;
|
||||
const parent = parentId
|
||||
? elements.find((el) => el.domId === parentId || el.id === parentId)
|
||||
: undefined;
|
||||
const mount = direct.expandedParentStart ?? parent?.start;
|
||||
if (mount !== undefined) return { elStart: direct.start - mount, elDuration: direct.duration };
|
||||
// No parent composition named, so this IS a main-timeline clip and its own
|
||||
// start is already the basis.
|
||||
if (!parentId) return { elStart: direct.start, elDuration: direct.duration };
|
||||
// It named a parent we cannot find, so the mount is unknowable. Its tweens
|
||||
// are still composition-local, so treat its own window as the frame rather
|
||||
// than subtracting nothing and handing back a main-timeline start, which is
|
||||
// exactly the mixed-frame subtraction this function exists to prevent.
|
||||
return { elStart: 0, elDuration: direct.duration };
|
||||
}
|
||||
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
||||
const host = hostId
|
||||
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
||||
: undefined;
|
||||
// The inner element is not a clip of its own: the host's window IS the frame
|
||||
// its tweens are timed in, so the start in that frame is 0, not the host's
|
||||
// main-timeline mount.
|
||||
return { elStart: 0, elDuration: host?.duration ?? 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* An absolute time as a percentage of a timeline clip, at the one precision every
|
||||
* keyframe-cache writer must share. 0.001% keeps a beat-snapped keyframe centered
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Reading a composition file's GSAP tweens into the keyframe cache: fetch,
|
||||
* selector -> element id resolution, and the clip-relative timing basis.
|
||||
* Reading a composition file's GSAP tweens into the keyframe cache: fetch and
|
||||
* selector -> element id resolution.
|
||||
* Split from useGsapTweenCache to keep that file under the 600-line limit.
|
||||
*/
|
||||
import type { GsapAnimation, GsapKeyframesData, ParsedGsap } from "@hyperframes/core/gsap-parser";
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
elementCacheKeys,
|
||||
writeGsapAnimationsForElement,
|
||||
} from "./gsapKeyframeCacheHelpers";
|
||||
import { idFromSelector, toClipKeyframes } from "./gsapShared";
|
||||
import { idFromSelector, resolveClipTimingBasis, toClipKeyframes } from "./gsapShared";
|
||||
import {
|
||||
deduplicateKeyframes,
|
||||
isStaticPositionHold,
|
||||
@@ -103,37 +103,6 @@ export async function fetchParsedAnimations(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clip-relative timing basis for an element. Sub-composition internals (e.g. pills
|
||||
* inside a scene) aren't timeline clips themselves — they're derived at expand time
|
||||
* — so they're absent from `elements`. Without a basis, elDuration defaulted to 1
|
||||
* and clip-relative keyframe percentages blew past 100% (rendering off the clip).
|
||||
* Fall back to the sub-comp HOST's bounds, resolved via domClipChildren (the host's
|
||||
* data-composition-src is stripped in the rendered DOM, so we can't query it).
|
||||
*/
|
||||
export function resolveClipTimingBasis(
|
||||
elementId: string,
|
||||
sourceFile: string,
|
||||
elements: ReadonlyArray<{
|
||||
domId?: string;
|
||||
key?: string;
|
||||
id: string;
|
||||
start: number;
|
||||
duration: number;
|
||||
}>,
|
||||
domClipChildren: ReadonlyArray<{ id: string; hostId: string }>,
|
||||
): { elStart: number; elDuration: number } {
|
||||
const direct = elements.find(
|
||||
(el) => el.domId === elementId || (el.key ?? el.id) === `${sourceFile}#${elementId}`,
|
||||
);
|
||||
if (direct) return { elStart: direct.start, elDuration: direct.duration };
|
||||
const hostId = domClipChildren.find((c) => c.id === elementId)?.hostId;
|
||||
const host = hostId
|
||||
? elements.find((el) => el.domId === hostId || (el.key ?? el.id) === `index.html#${hostId}`)
|
||||
: undefined;
|
||||
return { elStart: host?.start ?? 0, elDuration: host?.duration ?? 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Read one composition file's tweens into the keyframe cache. Split out of the
|
||||
* hook so the effect can run it per file without re-nesting the whole body.
|
||||
|
||||
@@ -7,24 +7,17 @@ import {
|
||||
pruneKeyframeCacheToFiles,
|
||||
writeGsapAnimationsForElement,
|
||||
} from "./gsapKeyframeCacheHelpers";
|
||||
import { toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
||||
import { resolveClipTimingBasis, toAbsoluteTime, toClipPercentage } from "./gsapShared";
|
||||
import {
|
||||
deduplicateKeyframes,
|
||||
isStaticPositionHold,
|
||||
synthesizeFlatTweenKeyframes,
|
||||
} from "./gsapTweenSynth";
|
||||
import {
|
||||
fetchParsedAnimations,
|
||||
populateKeyframeCacheFromAst,
|
||||
resolveClipTimingBasis,
|
||||
} from "./keyframeCacheAstLoad";
|
||||
import { fetchParsedAnimations, populateKeyframeCacheFromAst } from "./keyframeCacheAstLoad";
|
||||
|
||||
// Re-exported so callers keep importing the GSAP cache surface from one module.
|
||||
export {
|
||||
fetchParsedAnimations,
|
||||
resolveClipTimingBasis,
|
||||
resolveSelectorElementIds,
|
||||
} from "./keyframeCacheAstLoad";
|
||||
export { resolveClipTimingBasis } from "./gsapShared";
|
||||
export { fetchParsedAnimations, resolveSelectorElementIds } from "./keyframeCacheAstLoad";
|
||||
|
||||
/** The selected element's identity for matching tweens to it. */
|
||||
export interface GsapElementTarget {
|
||||
|
||||
@@ -143,6 +143,51 @@ describe("buildExpandedElements", () => {
|
||||
expect(child.sourceFile).toBe("c.html"); // C's file, not b.html or a.html
|
||||
});
|
||||
|
||||
it("keeps the middle host's row when drilling two levels deep", () => {
|
||||
// A embeds B; C lives in B. Drilling into B must leave BOTH host rows
|
||||
// standing: sparing only the top-level one drops B's row, and its keyframe
|
||||
// lane goes with it because diamonds render per row.
|
||||
const elements = [
|
||||
el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
||||
el({ id: "B", domId: "B", start: 12, duration: 4, track: 1, compositionSrc: "b.html" }),
|
||||
];
|
||||
const manifest = [
|
||||
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
||||
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
||||
clip({ id: "C", start: 13, duration: 2 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["B", "A"],
|
||||
["C", "B"],
|
||||
]);
|
||||
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
||||
const rows = out.map((e) => e.domId ?? e.id);
|
||||
expect(rows).toContain("B");
|
||||
// The child sits under its own host, not under the top-level row.
|
||||
expect(rows.indexOf("C")).toBeGreaterThan(rows.indexOf("B"));
|
||||
});
|
||||
|
||||
it("still drills a host that exists only in the manifest, without a row for it", () => {
|
||||
// Same shape, but B has no store element, so there is no row to spare. The
|
||||
// children stay anchored to the top-level row rather than vanishing.
|
||||
const elements = [
|
||||
el({ id: "A", domId: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
||||
];
|
||||
const manifest = [
|
||||
clip({ id: "A", start: 10, duration: 8, compositionSrc: "a.html" }),
|
||||
clip({ id: "B", start: 12, duration: 4, compositionSrc: "b.html" }),
|
||||
clip({ id: "C", start: 13, duration: 2 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["B", "A"],
|
||||
["C", "B"],
|
||||
]);
|
||||
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "A", "B");
|
||||
expect(out.map((e) => e.domId ?? e.id)).toEqual(["A", "C"]);
|
||||
});
|
||||
|
||||
// Regression: an expanded child must share one identity (`key`) with the flat
|
||||
// store element for the same DOM id. Before the fix the child key fell back to
|
||||
// the colon form (`index.html:eyebrow:N`) while the store/selection used the
|
||||
@@ -240,8 +285,112 @@ describe("buildExpandedElements", () => {
|
||||
expect(pills[0]!.duration).toBe(6);
|
||||
expect(pills[0]!.sourceFile).toBe("scene.html");
|
||||
expect(pills.map((pill) => pill.stackingContextId)).toEqual(["css:0.0", "css:0.1", "css:0.1"]);
|
||||
// The host row is replaced by its children.
|
||||
expect(out.some((e) => e.domId === "scene-host")).toBe(false);
|
||||
// The host row survives the expansion; its children are added under it.
|
||||
expect(out.some((e) => e.id === "scene-host")).toBe(true);
|
||||
});
|
||||
|
||||
// fallow-ignore-next-line code-duplication
|
||||
it("keeps the host row and appends its children directly below it", () => {
|
||||
const elements = [
|
||||
el({
|
||||
id: "s3",
|
||||
domId: "s3",
|
||||
key: "index.html#s3",
|
||||
start: 16,
|
||||
duration: 7,
|
||||
compositionSrc: "stats.html",
|
||||
}),
|
||||
el({ id: "outro", start: 23, duration: 3, track: 1 }),
|
||||
];
|
||||
const manifest = [
|
||||
clip({ id: "s3", start: 16, duration: 7, compositionSrc: "stats.html" }),
|
||||
clip({ id: "stat-1", start: 16.5, duration: 5 }),
|
||||
clip({ id: "stat-2", start: 16.9, duration: 5 }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["stat-1", "s3"],
|
||||
["stat-2", "s3"],
|
||||
]);
|
||||
|
||||
const out = buildExpandedElements(elements, manifest, parentMap, "s3", "s3");
|
||||
|
||||
const hostIndex = out.findIndex((e) => e.id === "s3");
|
||||
expect(hostIndex).toBeGreaterThanOrEqual(0);
|
||||
// Host row untouched (same key → same keyframe lane), children nested under it.
|
||||
expect(out[hostIndex]!.key).toBe("index.html#s3");
|
||||
expect(out[hostIndex]!.track).toBe(0);
|
||||
expect(out[hostIndex + 1]!.domId).toBe("stat-1");
|
||||
expect(out[hostIndex + 2]!.domId).toBe("stat-2");
|
||||
// Exactly one more row than the old substitution behaviour (host + 2 children + outro).
|
||||
expect(out).toHaveLength(4);
|
||||
});
|
||||
|
||||
it("keeps the host row present at every playhead position (keyframe lane repro)", () => {
|
||||
// Live repro with no drag at all: seek 0 gave 3 diamonds, seek 7.68 gave 0,
|
||||
// seek 0.2 gave 3. Diamonds render per row from keyframeCache.get(elementKey),
|
||||
// so the whole lane went with the host row whenever the paused drill-in
|
||||
// substituted it for its children.
|
||||
const elements = [
|
||||
el({ id: "scene", domId: "scene", key: "index.html#scene", start: 0, duration: 12 }),
|
||||
];
|
||||
const manifest = [
|
||||
clip({ id: "scene", start: 0, duration: 12, compositionSrc: "scene.html" }),
|
||||
clip({ id: "headline", start: 0, duration: 12 }),
|
||||
];
|
||||
const parentMap = new Map([["headline", "scene"]]);
|
||||
|
||||
for (const currentTime of [0, 7.68, 0.2]) {
|
||||
const rawId = resolveTimelineExpansionRawId({
|
||||
selectedElementId: null,
|
||||
isPlaying: false,
|
||||
currentTime,
|
||||
manifest,
|
||||
parentMap,
|
||||
});
|
||||
const rows = rawId
|
||||
? buildExpandedElements(elements, manifest, parentMap, rawId, rawId)
|
||||
: elements;
|
||||
expect(rows.map((row) => row.key)).toContain("index.html#scene");
|
||||
}
|
||||
});
|
||||
|
||||
// Regression: DOM-only children were synthesized against the TOP-LEVEL element
|
||||
// instead of the sub-comp host they actually live in, so every child row read
|
||||
// the whole top-level window rather than its host's.
|
||||
it("spans DOM-only children over their nested host's window, not the top-level one", () => {
|
||||
const elements = [
|
||||
el({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
||||
];
|
||||
const manifest = [
|
||||
clip({ id: "scene-host", start: 0, duration: 20, compositionSrc: "scene.html" }),
|
||||
clip({ id: "sub-host", start: 5, duration: 6, compositionSrc: "sub.html" }),
|
||||
];
|
||||
const parentMap = new Map([
|
||||
["sub-host", "scene-host"],
|
||||
["pill-1", "sub-host"],
|
||||
]);
|
||||
const domClipChildren = [
|
||||
{
|
||||
id: "pill-1",
|
||||
parentId: "sub-host",
|
||||
hostId: "sub-host",
|
||||
label: "pill-1",
|
||||
stackingContextId: "css:0.0",
|
||||
},
|
||||
];
|
||||
|
||||
const out = buildExpandedElements(
|
||||
elements,
|
||||
manifest,
|
||||
parentMap,
|
||||
"scene-host",
|
||||
"sub-host",
|
||||
domClipChildren,
|
||||
);
|
||||
const pill = out.find((e) => e.domId === "pill-1")!;
|
||||
expect(pill.start).toBe(5);
|
||||
expect(pill.duration).toBe(6);
|
||||
expect(pill.sourceFile).toBe("sub.html");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -209,7 +209,13 @@ function buildChildElements(
|
||||
function domSiblingClips(
|
||||
domClipChildren: DomClipChild[],
|
||||
siblingParentId: string,
|
||||
host: TimelineElement,
|
||||
host: {
|
||||
id: string | null;
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
compositionSrc?: string | null;
|
||||
},
|
||||
): ClipManifestClip[] {
|
||||
return domClipChildren
|
||||
.filter((c) => c.parentId === siblingParentId)
|
||||
@@ -243,20 +249,23 @@ export function buildExpandedElements(
|
||||
const topLevelElement = elements.find((el) => el.id === topLevelId || el.domId === topLevelId);
|
||||
if (!topLevelElement) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
// The sub-comp host the children actually live in: top-level host for 1-level
|
||||
// nesting, a nested host for deeper nesting. Its start/file anchor edits.
|
||||
const parentHost = manifest.find((c) => c.id === siblingParentId);
|
||||
|
||||
// Prefer real manifest children; fall back to DOM-only sub-comp children
|
||||
// (groups/pills) that have no data-start and thus never enter the manifest.
|
||||
// Those are synthesized against the host they actually live in, not the
|
||||
// top-level element, or every child row reads the whole top-level window.
|
||||
const siblings = (() => {
|
||||
const fromManifest = manifest.filter(
|
||||
(c) => c.id != null && parentMap.get(c.id) === siblingParentId,
|
||||
);
|
||||
if (fromManifest.length > 0) return fromManifest;
|
||||
return domSiblingClips(domClipChildren, siblingParentId, topLevelElement);
|
||||
return domSiblingClips(domClipChildren, siblingParentId, parentHost ?? topLevelElement);
|
||||
})();
|
||||
if (siblings.length === 0) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
// The sub-comp host the children actually live in: top-level host for 1-level
|
||||
// nesting, a nested host for deeper nesting. Its start/file anchor edits.
|
||||
const parentHost = manifest.find((c) => c.id === siblingParentId);
|
||||
const editBasis = {
|
||||
start: parentHost?.start ?? topLevelElement.start,
|
||||
sourceFile: parentHost?.compositionSrc ?? topLevelElement.compositionSrc ?? undefined,
|
||||
@@ -275,9 +284,39 @@ export function buildExpandedElements(
|
||||
);
|
||||
if (expanded.length === 0) return filterToTopLevel(elements, parentMap);
|
||||
|
||||
// Every host between the drilled one and the top level owns a row, so the
|
||||
// drill has to spare all of them, not just the top. A middle host is still a
|
||||
// host: dropping its row drops its keyframe lane with it.
|
||||
const drillPath = new Set<string>();
|
||||
for (let cursor: string | undefined = siblingParentId; cursor; ) {
|
||||
if (drillPath.has(cursor)) break;
|
||||
drillPath.add(cursor);
|
||||
if (cursor === topLevelId) break;
|
||||
cursor = parentMap.get(cursor);
|
||||
}
|
||||
// Children hang under the DEEPEST host on that path, so anchor them there
|
||||
// when it has a row of its own and fall back to the top-level row when it
|
||||
// does not (a host that lives only in the manifest never had one).
|
||||
const anchorsChildren = (el: TimelineElement): boolean =>
|
||||
drillPath.has(siblingParentId) && elements.some((e) => (e.domId ?? e.id) === siblingParentId)
|
||||
? (el.domId ?? el.id) === siblingParentId
|
||||
: (el.key ?? el.id) === parentKey;
|
||||
|
||||
// ADDITIVE drill-in: the host row stays and its children are appended under
|
||||
// it. Expansion is also triggered by the playhead alone (paused auto-expand),
|
||||
// so substituting the host row made it vanish on an ordinary seek, and with
|
||||
// it the host's keyframe lane, since diamonds render per row from
|
||||
// `keyframeCache.get(elementKey)`. The synthetic fractional lanes above sit
|
||||
// strictly between the host's lane and the next integer, so the children have
|
||||
// their own rows without the host having to give up its own.
|
||||
return elements
|
||||
.filter((el) => (el.key ?? el.id) === parentKey || !parentMap.has(el.domId ?? el.id))
|
||||
.flatMap((el) => ((el.key ?? el.id) === parentKey ? expanded : [el]));
|
||||
.filter(
|
||||
(el) =>
|
||||
(el.key ?? el.id) === parentKey ||
|
||||
drillPath.has(el.domId ?? el.id) ||
|
||||
!parentMap.has(el.domId ?? el.id),
|
||||
)
|
||||
.flatMap((el) => (anchorsChildren(el) ? [el, ...expanded] : [el]));
|
||||
}
|
||||
|
||||
export function useExpandedTimelineElements(): TimelineElement[] {
|
||||
|
||||
Reference in New Issue
Block a user