Merge pull request #2111 from heygen-com/feat/timeline-multiselect

feat(studio): timeline multi-select (marquee) + relative group time editing
This commit is contained in:
Miguel Ángel
2026-07-09 17:38:37 -04:00
committed by GitHub
39 changed files with 3686 additions and 240 deletions
@@ -209,6 +209,67 @@ describe("edit history", () => {
expect(state.undo[0].files["index.html"].after).toBe("c");
});
it("folds a slow GSAP follow-up into the timing edit via a per-entry coalesceMs override", () => {
const timing = buildEditHistoryEntry({
projectId: "project-1",
label: "Resize timeline clip",
kind: "timeline",
coalesceKey: "timeline-resize:clip",
files: { "index.html": { before: "orig", after: "timing" } },
now: 0,
id: "timing",
});
// The server GSAP rewrite lands ~2s later, past the 300ms default window, but the
// follow-up carries a large coalesceMs so undo still collapses to a single step.
const gsap = buildEditHistoryEntry({
projectId: "project-1",
label: "Resize timeline clip",
kind: "timeline",
coalesceKey: "timeline-resize:clip",
coalesceMs: 10_000,
files: { "index.html": { before: "timing", after: "timing+gsap" } },
now: 2000,
id: "gsap",
});
const state = pushEditHistoryEntry(
pushEditHistoryEntry(createEmptyEditHistory(), timing),
gsap,
);
expect(state.undo).toHaveLength(1);
expect(state.undo[0].files["index.html"].before).toBe("orig");
expect(state.undo[0].files["index.html"].after).toBe("timing+gsap");
});
it("does not merge a slow follow-up without the coalesceMs override", () => {
const timing = buildEditHistoryEntry({
projectId: "project-1",
label: "Resize timeline clip",
kind: "timeline",
coalesceKey: "timeline-resize:clip",
files: { "index.html": { before: "orig", after: "timing" } },
now: 0,
id: "timing",
});
const late = buildEditHistoryEntry({
projectId: "project-1",
label: "Resize timeline clip",
kind: "timeline",
coalesceKey: "timeline-resize:clip",
files: { "index.html": { before: "timing", after: "timing+gsap" } },
now: 2000,
id: "late",
});
const state = pushEditHistoryEntry(
pushEditHistoryEntry(createEmptyEditHistory(), timing),
late,
);
expect(state.undo).toHaveLength(2);
});
it("coalesces entries with the same coalesceKey within the window (prop: format)", () => {
const first = buildEditHistoryEntry({
projectId: "project-1",
+7 -1
View File
@@ -13,6 +13,8 @@ export interface EditHistoryEntry {
label: string;
kind: EditHistoryKind;
coalesceKey?: string;
/** Per-entry coalesce window override (ms). Falls back to the reducer default. */
coalesceMs?: number;
createdAt: number;
files: Record<string, EditHistoryFileSnapshot>;
}
@@ -35,6 +37,7 @@ export interface BuildEditHistoryEntryInput {
label: string;
kind?: EditHistoryKind;
coalesceKey?: string;
coalesceMs?: number;
now: number;
files: Record<string, { before: string; after: string }>;
}
@@ -99,6 +102,7 @@ export function buildEditHistoryEntry(input: BuildEditHistoryEntryInput): EditHi
label: input.label,
kind: input.kind ?? "manual",
coalesceKey: input.coalesceKey,
coalesceMs: input.coalesceMs,
createdAt: input.now,
files,
};
@@ -111,7 +115,9 @@ export function pushEditHistoryEntry(
): EditHistoryState {
if (Object.keys(entry.files).length === 0) return state;
const coalesceMs = options?.coalesceMs ?? DEFAULT_COALESCE_MS;
// The incoming entry's own window wins so a caller can guarantee a merge even when a
// slow async step (e.g. a server GSAP rewrite) sits between the two records.
const coalesceMs = entry.coalesceMs ?? options?.coalesceMs ?? DEFAULT_COALESCE_MS;
const maxEntries = options?.maxEntries ?? DEFAULT_MAX_ENTRIES;
const previous = state.undo[state.undo.length - 1];
let undo = state.undo;
+45
View File
@@ -196,6 +196,51 @@ export async function sdkTimingPersist(
}
}
export async function sdkTimingBatchPersist(
changes: Array<{
hfId: string;
timingUpdate: { start?: number; duration?: number; trackIndex?: number };
}>,
targetPath: string,
sdkSession: Composition | null | undefined,
deps: CutoverDeps,
options?: CutoverOptions,
): Promise<boolean> {
const timingSrc = deps.readProjectFile;
for (const change of changes) {
void recordResolverParity(
sdkSession,
change.hfId,
"setTiming",
timingSrc ? () => timingSrc(targetPath) : undefined,
);
}
if (!STUDIO_SDK_CUTOVER_ENABLED) return false;
if (!sdkSession || wrongCompositionFile(deps, targetPath)) return false;
if (changes.some((change) => !sdkSession.getElement(change.hfId))) return false;
try {
const serializedBefore = sdkSession.serialize();
sdkSession.batch(() => {
for (const change of changes) sdkSession.setTiming(change.hfId, change.timingUpdate);
});
const after = sdkSession.serialize();
if (after === serializedBefore) return false;
const undoBefore = await captureOnDiskBefore(deps, targetPath, serializedBefore);
await persistSdkSerialize(after, targetPath, undoBefore, deps, options);
trackStudioEvent("sdk_cutover_success", {
hfId: changes[0]?.hfId ?? null,
opCount: changes.length,
});
return true;
} catch (err) {
trackStudioEvent("sdk_cutover_fallback", {
hfId: changes[0]?.hfId ?? null,
error: String(err),
});
return false;
}
}
type SdkGsapTweenOp =
| { kind: "add"; target: string; spec: GsapTweenSpec }
| { kind: "set"; animationId: string; properties: Partial<GsapTweenSpec> }
@@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest";
import {
findMatchingTimelineElementId,
findTimelineIdByAncestor,
resolveTimelineIdForSelection,
resolveTimelineSelectionSeekTime,
} from "./studioHelpers";
@@ -72,3 +73,33 @@ describe("findTimelineIdByAncestor", () => {
expect(findTimelineIdByAncestor(child, [], "index.html")).toBe(null);
});
});
describe("resolveTimelineIdForSelection", () => {
const el = (over: Record<string, unknown>) =>
({ id: "x", start: 0, duration: 1, track: 0, tag: "div", ...over }) as never;
it("resolves an ancestor clip against activeCompPath when the selection has no sourceFile", () => {
// #card (a clip in a sub-composition) > .leaf (selected, not itself a clip)
const card = document.createElement("div");
card.id = "card";
const leaf = document.createElement("span");
leaf.className = "leaf";
card.appendChild(leaf);
const els = [
el({
id: "card",
domId: "card",
key: "comps/panel.html#card",
sourceFile: "comps/panel.html",
}),
];
const selection = { element: leaf } as never;
// Falling back to the active comp matches; the old index.html-only fallback would miss.
expect(resolveTimelineIdForSelection(selection, els, "comps/panel.html")).toBe(
"comps/panel.html#card",
);
expect(resolveTimelineIdForSelection(selection, els, null)).toBe(null);
});
});
@@ -215,6 +215,28 @@ export function findTimelineIdByAncestor(
return null;
}
/**
* Resolve the timeline element id for a DOM selection: direct match first, then
* nearest clip ancestor. The ancestor lookup resolves against the selection's own
* source file, falling back to the active composition path, then index.html — so a
* sub-composition selection with no explicit sourceFile resolves against the comp
* currently open, not always the root file.
*/
export function resolveTimelineIdForSelection(
selection: DomEditSelection,
elements: TimelineElement[],
activeCompPath: string | null,
): string | null {
return (
findMatchingTimelineElementId(selection, elements) ??
findTimelineIdByAncestor(
selection.element,
elements,
selection.sourceFile || activeCompPath || "index.html",
)
);
}
export function resolveTimelineSelectionSeekTime(
currentTime: number,
element: Pick<TimelineElement, "start" | "duration"> | null | undefined,