mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-05 00:56:23 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user