mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
Merge pull request #2068 from heygen-com/worktree-fix-timeline-zindex-reorder
feat(studio): lane-model timeline — vertical drag restacks via z-index
This commit is contained in:
@@ -16,6 +16,7 @@ function createMockDeps() {
|
||||
onSetPlaybackRate: vi.fn(),
|
||||
onSetColorGrading: vi.fn(),
|
||||
onSetColorGradingCompare: vi.fn(),
|
||||
onSetRootDuration: vi.fn(),
|
||||
onEnablePickMode: vi.fn(),
|
||||
onDisablePickMode: vi.fn(),
|
||||
};
|
||||
@@ -155,6 +156,13 @@ describe("installRuntimeControlBridge", () => {
|
||||
expect(deps.onSetPlaybackRate).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it("dispatches set-root-duration command with numeric seconds", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
handler(makeControlMessage("set-root-duration", { durationSeconds: "18.5" }));
|
||||
expect(deps.onSetRootDuration).toHaveBeenCalledWith(18.5);
|
||||
});
|
||||
|
||||
it("dispatches set-color-grading command with target and grading payload", () => {
|
||||
const deps = createMockDeps();
|
||||
const handler = installRuntimeControlBridge(deps);
|
||||
|
||||
@@ -14,6 +14,7 @@ type BridgeDeps = {
|
||||
onSetNativeMediaSyncDisabled: (disabled: boolean) => void;
|
||||
onSetWebAudioMediaDisabled: (disabled: boolean) => void;
|
||||
onSetPlaybackRate: (rate: number) => void;
|
||||
onSetRootDuration: (durationSeconds: number) => void;
|
||||
onSetColorGrading: (target: HfColorGradingTarget | string | null, grading: unknown) => void;
|
||||
onSetColorGradingCompare: (
|
||||
target: HfColorGradingTarget | string | null,
|
||||
@@ -53,6 +54,7 @@ const CONTROL_HANDLERS: Record<string, ControlHandler> = {
|
||||
"set-web-audio-media-disabled": (data, deps) =>
|
||||
deps.onSetWebAudioMediaDisabled(Boolean(data.disabled)),
|
||||
"set-playback-rate": (data, deps) => deps.onSetPlaybackRate(Number(data.playbackRate ?? 1)),
|
||||
"set-root-duration": (data, deps) => deps.onSetRootDuration(Number(data.durationSeconds ?? 0)),
|
||||
"set-color-grading": (data, deps) =>
|
||||
deps.onSetColorGrading(data.target ?? null, data.grading ?? null),
|
||||
"set-color-grading-compare": (data, deps) =>
|
||||
|
||||
@@ -1857,6 +1857,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
// transport tick. A plain count misses same-count swaps (one sub-comp unloads
|
||||
// as another loads), so the signature keys on id+tag in document order.
|
||||
let clipTreeSignature = "";
|
||||
let liveRootDurationOverrideSeconds = 0;
|
||||
const computeClipTreeSignature = (): string => {
|
||||
let sig = "";
|
||||
for (const el of document.querySelectorAll("[data-start]")) {
|
||||
@@ -1909,6 +1910,30 @@ export function initSandboxRuntimeModular(): void {
|
||||
scheduleRootStageLayoutDiagnostics();
|
||||
};
|
||||
|
||||
const finitePositiveDuration = (value: number): number =>
|
||||
Number.isFinite(value) && value > 0 ? value : 0;
|
||||
|
||||
const growRootDurationLive = (durationSeconds: number) => {
|
||||
const nextDuration = finitePositiveDuration(Number(durationSeconds));
|
||||
if (nextDuration <= 0) return;
|
||||
const rootEl = resolveRootCompositionElement();
|
||||
const rootAttrDuration = finitePositiveDuration(
|
||||
Number.parseFloat(rootEl?.getAttribute("data-duration") ?? ""),
|
||||
);
|
||||
const currentDuration = Math.max(
|
||||
liveRootDurationOverrideSeconds,
|
||||
finitePositiveDuration(clock.getDuration()),
|
||||
rootAttrDuration,
|
||||
);
|
||||
if (nextDuration <= currentDuration) return;
|
||||
|
||||
liveRootDurationOverrideSeconds = nextDuration;
|
||||
rootEl?.setAttribute("data-duration", String(nextDuration));
|
||||
clock.setDuration(nextDuration);
|
||||
postTimeline();
|
||||
postState(true);
|
||||
};
|
||||
|
||||
const runAdapters = (method: "discover" | "pause" | "play", timeSeconds = 0) => {
|
||||
for (const adapter of state.deterministicAdapters) {
|
||||
try {
|
||||
@@ -2202,6 +2227,7 @@ export function initSandboxRuntimeModular(): void {
|
||||
if (state.transportClock) state.transportClock.setRate(state.playbackRate);
|
||||
applyWebAudioRate();
|
||||
},
|
||||
onSetRootDuration: growRootDurationLive,
|
||||
onSetColorGrading: (target, grading) => {
|
||||
colorGrading.setGrading(target, grading);
|
||||
},
|
||||
|
||||
@@ -57,9 +57,76 @@ describe("collectRuntimeTimelinePayload", () => {
|
||||
expect(result.clips[0].id).toBe("text-1");
|
||||
expect(result.clips[0].start).toBe(1);
|
||||
expect(result.clips[0].duration).toBe(3);
|
||||
expect(result.clips[0].track).toBe(0);
|
||||
expect(result.clips[0].kind).toBe("element");
|
||||
});
|
||||
|
||||
it("parses inline z-index for timeline clips", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-duration", "10");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const clip = document.createElement("div");
|
||||
clip.id = "layered";
|
||||
clip.style.zIndex = "11";
|
||||
clip.setAttribute("data-start", "0");
|
||||
clip.setAttribute("data-duration", "4");
|
||||
root.appendChild(clip);
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
expect(result.clips[0].zIndex).toBe(11);
|
||||
});
|
||||
|
||||
it("uses zero z-index sentinel when a timeline clip has no inline z-index", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-duration", "10");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const clip = document.createElement("div");
|
||||
clip.id = "auto-layer";
|
||||
clip.setAttribute("data-start", "0");
|
||||
clip.setAttribute("data-duration", "4");
|
||||
root.appendChild(clip);
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
expect(result.clips[0].zIndex).toBe(0);
|
||||
});
|
||||
|
||||
it("assigns stacking context ids from root and nearest sub-composition", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
root.setAttribute("data-duration", "10");
|
||||
document.body.appendChild(root);
|
||||
|
||||
const rootClip = document.createElement("div");
|
||||
rootClip.id = "root-layer";
|
||||
rootClip.setAttribute("data-start", "0");
|
||||
rootClip.setAttribute("data-duration", "5");
|
||||
root.appendChild(rootClip);
|
||||
|
||||
const scene = document.createElement("div");
|
||||
scene.id = "scene-host";
|
||||
scene.setAttribute("data-composition-id", "scene");
|
||||
scene.setAttribute("data-start", "0");
|
||||
scene.setAttribute("data-duration", "5");
|
||||
root.appendChild(scene);
|
||||
|
||||
const nestedClip = document.createElement("div");
|
||||
nestedClip.id = "nested-layer";
|
||||
nestedClip.setAttribute("data-start", "0");
|
||||
nestedClip.setAttribute("data-duration", "2");
|
||||
scene.appendChild(nestedClip);
|
||||
|
||||
const result = collectRuntimeTimelinePayload(defaultParams);
|
||||
const rootLayer = result.clips.find((clip) => clip.id === "root-layer");
|
||||
const nestedLayer = result.clips.find((clip) => clip.id === "nested-layer");
|
||||
|
||||
expect(rootLayer?.stackingContextId).toBe("main");
|
||||
expect(nestedLayer?.stackingContextId).toBe("scene");
|
||||
});
|
||||
|
||||
it("identifies video clips by tag", () => {
|
||||
const root = document.createElement("div");
|
||||
root.setAttribute("data-composition-id", "main");
|
||||
|
||||
@@ -32,6 +32,19 @@ function parseElementEndAttr(element: Element): number | null {
|
||||
);
|
||||
}
|
||||
|
||||
function readInlineZIndex(element: Element): number {
|
||||
try {
|
||||
const inline = (element as HTMLElement).style?.zIndex;
|
||||
if (inline && inline !== "auto") {
|
||||
const parsed = parseInt(inline, 10);
|
||||
if (Number.isFinite(parsed)) return parsed;
|
||||
}
|
||||
return 0;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function maxDefinedNumber(...values: Array<number | null>): number | null {
|
||||
const finite = values.filter((value): value is number => Number.isFinite(value ?? null));
|
||||
if (finite.length === 0) return null;
|
||||
@@ -434,6 +447,8 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
node.getAttribute("data-track-index") ?? node.getAttribute("data-track") ?? String(i),
|
||||
10,
|
||||
) || 0,
|
||||
zIndex: readInlineZIndex(node),
|
||||
stackingContextId: compositionContext.parentCompositionId ?? rootCompositionId,
|
||||
kind,
|
||||
tagName: tag,
|
||||
compositionId: node.getAttribute("data-composition-id"),
|
||||
@@ -545,6 +560,8 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
|
||||
10,
|
||||
) || gsapTrack,
|
||||
zIndex: readInlineZIndex(el),
|
||||
stackingContextId: rootCompositionIdForGsap,
|
||||
kind: "element",
|
||||
tagName: el.tagName.toLowerCase(),
|
||||
compositionId: el.getAttribute("data-composition-id"),
|
||||
@@ -602,6 +619,8 @@ export function collectRuntimeTimelinePayload(params: {
|
||||
el.getAttribute("data-track-index") ?? el.getAttribute("data-track") ?? "",
|
||||
10,
|
||||
) || overlayTrack,
|
||||
zIndex: readInlineZIndex(el),
|
||||
stackingContextId: rootCompositionIdForGsap,
|
||||
kind: "element",
|
||||
tagName: tag,
|
||||
compositionId: el.getAttribute("data-composition-id"),
|
||||
|
||||
@@ -18,6 +18,7 @@ export type RuntimeBridgeControlAction =
|
||||
| "set-media-output-muted"
|
||||
| "set-native-media-sync-disabled"
|
||||
| "set-web-audio-media-disabled"
|
||||
| "set-root-duration"
|
||||
| "stop-media"
|
||||
| "flash-elements";
|
||||
|
||||
@@ -28,6 +29,7 @@ export type RuntimeBridgeControlMessage = {
|
||||
frame?: number;
|
||||
muted?: boolean;
|
||||
volume?: number;
|
||||
durationSeconds?: number;
|
||||
disabled?: boolean;
|
||||
playbackRate?: number;
|
||||
target?: HfColorGradingTarget | string | null;
|
||||
@@ -51,6 +53,8 @@ export type RuntimeTimelineClip = {
|
||||
start: number;
|
||||
duration: number;
|
||||
track: number;
|
||||
zIndex: number;
|
||||
stackingContextId: string | null;
|
||||
kind: "video" | "audio" | "image" | "element" | "composition";
|
||||
tagName: string | null;
|
||||
compositionId: string | null;
|
||||
|
||||
Reference in New Issue
Block a user