fix(studio): continuation of #2277 (#2286)

* feat(studio): timeline collision and placement model

What: new pure module timelineCollision — zone-aware drop placement
(clampTrackToZone, resolveZoneDropPlacement, resolveInsertRow,
resolvePlacement, lane/overlap predicates) with its full test suite.

Why: the no-overlap core of the NLE clip-drag engine; plain functions, no
DOM, no React, no store writes.

How: new files only; type-only imports from the existing playerStore.
First runtime consumer arrives with the drag-engine PRs.

Test plan: bunx vitest run timelineCollision.test.ts; tsc --noEmit; fallow
audit clean (all exports test-consumed).

* feat(studio): timeline magnetic snapping

What: new pure module timelineSnapping — snap-target collection and
pixel-threshold time snapping (collectTimelineSnapTargets, snapTimelineTime,
snapMoveToTargets) with tests.

Why: the magnet math for clip drags/trims, reviewable standalone.

How: new files only; type-only playerStore imports; consumers land with the
drag engine.

Test plan: bunx vitest run timelineSnapping.test.ts; tsc --noEmit; fallow
audit clean.

* feat(studio): multi-clip drag preview math

What: new pure module timelineMultiDragPreview — group-drag passenger
offsets and clamped group deltas (isMultiDragActive, multiDragDeltaSeconds,
multiDragPassengerOffsetPx, clampGroupMoveDelta) with tests.

Why: the group-drag math, standalone and DOM-free.

How: new files only; consumed later by TimelineLanes.

Test plan: bunx vitest run timelineMultiDragPreview.test.ts; tsc --noEmit;
fallow audit clean.

* feat(studio): timeline z-stacking sync model

What: new pure module timelineStackingSync — lane order ↔ z-index
reconciliation (laneIsAbove, computeStackingPatches) with tests.

Why: the single source of truth for how timeline lane order maps to canvas
stacking; the ordering rules and tie-breaks live here.

How: new files only; consumed later by timelineZones and the stacking-sync
hook.

Test plan: bunx vitest run timelineStackingSync.test.ts; tsc --noEmit;
fallow audit clean.

* feat(studio): timeline lane-zone model

What: new pure module timelineZones — visual/audio track-zone
classification (classifyZone) and normalizeToZones, which re-packs lanes
into zone-consistent rows; tests cover the stacking/zones interaction.

Why: completes the z-model started in the stacking-sync PR.

How: new files; consumes isAudioTimelineElement (leaf-helpers PR) and
computeStackingPatches (stacking-sync PR); type-only playerStore imports.

Test plan: bunx vitest run timelineZones.test.ts; tsc --noEmit; fallow
audit clean.

* feat(studio): asset click policy and canvas nudge gate

What: two small pure modules with tests — assetClickBehavior (click vs
double-click policy for sidebar assets) and canvasNudgeGate (debounce gate
for arrow-key canvas nudges).

Why: policy dependencies of the upcoming asset card and nudge hook,
reviewable as plain decision tables.

How: new files only.

Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit
clean.

* test(studio): characterization suites for resize commit and razor history

What: two test-only suites pinning CURRENT behavior before the NLE swap:
anchoredResizeReleaseShift.test.ts (manual-offset resize release commits)
and useRazorSplit.history.test.tsx (razor split undo/redo history).

Why: regression tripwires — the later glue-swap PRs must keep these green.

How: test files only; they import existing main modules unchanged and pass
against them as-is.

Test plan: bunx vitest run on both suites; fallow audit clean.

---------

Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
This commit is contained in:
Miguel Ángel
2026-07-12 00:19:36 -04:00
committed by GitHub
co-authored by ukimsanov
parent 8e5b18f740
commit c6a508a9bc
2 changed files with 285 additions and 0 deletions
@@ -0,0 +1,112 @@
// @vitest-environment jsdom
import { afterEach, describe, expect, it } from "vitest";
import {
applyStudioBoxSize,
applyStudioPathOffset,
readStudioBoxSize,
reapplyPositionEditsAfterSeek,
} from "./manualEditsDom";
import { buildBoxSizePatches, buildPathOffsetPatches } from "./manualEditsDomPatches";
import { createManualOffsetDragMember, applyManualOffsetDragCommit } from "./manualOffsetDrag";
import type { PatchOperation } from "../../utils/sourcePatcher";
import { splitTopLevelWhitespace } from "./manualEditsStyleHelpers";
/**
* Center-anchored corner resize (CapCut model): the element scales about its
* CENTER, which must stay planted across the whole gesture — including after
* release, on every corner and at any rotation.
*
* This file lands here with ONLY the persist round-trip test, which exercises the
* real apply → persist → reload symbols that already exist at this point in the
* stack. The two center-anchor CONVERGENCE tests (the release-shift root cause)
* drive the exported `computeNextResizeAnchor` accumulator, which is extracted from
* the resize pointermove branch of `useDomEditOverlayGestures.ts`. That gesture code
* lands later in the stack (with the canvas glue swap), so those two tests are added
* to this file at that point — importing the real production helper rather than a
* test-local copy, so they can never pass against a stand-in that drifts from the
* shipped math.
*/
afterEach(() => {
document.body.innerHTML = "";
});
/** Apply a built PatchOperation[] to a live element, mirroring sourcePatcher's
* inline-style / attribute application — i.e. what the persisted source carries
* when it is re-parsed into the DOM on the next preview load. */
function applyPatchesToElement(el: HTMLElement, ops: PatchOperation[]): void {
for (const op of ops) {
if (op.type === "inline-style") {
if (op.value === null) el.style.removeProperty(op.property);
else el.style.setProperty(op.property, op.value);
} else if (op.type === "attribute") {
if (op.value === null) el.removeAttribute(op.property);
else el.setAttribute(op.property, op.value);
}
}
}
/** Net translate applied to an element, resolving the studio offset var()
* expression to its px value so we compare the actually-rendered translation. */
function resolvedTranslatePx(el: HTMLElement): { x: number; y: number } {
const raw = el.style.getPropertyValue("translate").trim();
if (!raw || raw === "none") return { x: 0, y: 0 };
const vx = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-x")) || 0;
const vy = Number.parseFloat(el.style.getPropertyValue("--hf-studio-offset-y")) || 0;
const parts = splitTopLevelWhitespace(raw);
const parseAxis = (part: string, varVal: number): number => {
if (part && part.includes("--hf-studio-offset")) return varVal;
const n = Number.parseFloat(part);
return Number.isFinite(n) ? n : 0;
};
return {
x: parseAxis(parts[0] ?? "", vx),
y: parseAxis(parts[1] ?? "", vy),
};
}
describe("center-anchored corner resize — no shift after release", () => {
it("net translate after persist+reload equals the committed anchor offset (non-GSAP)", () => {
// The committed offset flows through the real apply → persist → reload chain
// unchanged (this hop was proved clean; the shift is upstream in the anchor
// loop, tested with the gesture code, not in persistence).
const el = document.createElement("div");
el.style.setProperty("width", "200px");
el.style.setProperty("height", "100px");
document.body.appendChild(el);
const anchorDx = -30;
const anchorDy = -18;
const finalSize = { width: 240, height: 130 };
applyStudioBoxSize(el, finalSize);
const memberResult = createManualOffsetDragMember({
key: "k",
selection: { element: el } as never,
element: el,
rect: { left: 0, top: 0, width: 240, height: 130, editScaleX: 1, editScaleY: 1 },
});
expect(memberResult.ok).toBe(true);
if (!memberResult.ok) return;
const finalOffset = applyManualOffsetDragCommit(memberResult.member, anchorDx, anchorDy);
applyStudioBoxSize(el, finalSize);
const patches = buildBoxSizePatches(el);
applyStudioPathOffset(el, finalOffset);
patches.push(...buildPathOffsetPatches(el));
expect(resolvedTranslatePx(el)).toEqual({ x: anchorDx, y: anchorDy });
// Persist → fresh element re-parsed from source → reload re-stamp.
const reloaded = document.createElement("div");
reloaded.style.setProperty("width", "200px");
reloaded.style.setProperty("height", "100px");
document.body.appendChild(reloaded);
applyPatchesToElement(reloaded, patches);
reapplyPositionEditsAfterSeek(reloaded.ownerDocument);
expect(resolvedTranslatePx(reloaded)).toEqual({ x: anchorDx, y: anchorDy });
expect(readStudioBoxSize(reloaded)).toEqual(finalSize);
});
});
@@ -0,0 +1,173 @@
// @vitest-environment happy-dom
import React, { act } from "react";
import { createRoot } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { TimelineElement } from "../player";
import { useRazorSplit } from "./useRazorSplit";
import { createPersistentEditHistoryStore } from "./usePersistentEditHistory";
import { createMemoryEditHistoryStorage } from "../utils/editHistoryStorage";
import { createEmptyEditHistory } from "../utils/editHistory";
(globalThis as unknown as { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
const ORIGINAL = `<div class="clip" id="clip1" data-start="0" data-duration="4">hi</div>`;
const SPLIT =
`<div class="clip" id="clip1" data-start="0" data-duration="2">hi</div>` +
`<div class="clip" id="clip1-split" data-start="2" data-duration="2">hi</div>`;
const element: TimelineElement = {
id: "clip1",
tag: "div",
start: 0,
duration: 4,
track: 0,
domId: "clip1",
sourceFile: "index.html",
timingSource: "authored",
};
type Split = (element: TimelineElement, splitTime: number) => Promise<void>;
interface Harness {
disk: Record<string, string>;
store: ReturnType<typeof createPersistentEditHistoryStore>;
splitRef: { current: Split | undefined };
root: ReturnType<typeof createRoot>;
expected: string;
}
const SPLIT_GSAP = SPLIT.replace(
"</div>",
"</div><script>window.__timelines={};const tl=gsap.timeline({paused:true});" +
'tl.set("#clip1-split",{x:0},2);window.__timelines["c"]=tl;</script>',
);
function mountRazorSplit(opts: { gsap?: boolean } = {}): Harness {
const disk: Record<string, string> = { "index.html": ORIGINAL };
const finalContent = opts.gsap ? SPLIT_GSAP : SPLIT;
const storage = createMemoryEditHistoryStorage();
const store = createPersistentEditHistoryStore({
projectId: "p1",
storage,
initialState: createEmptyEditHistory(),
now: (() => {
let t = 1000;
return () => (t += 10);
})(),
onChange: () => {},
});
// Faithful stand-in for the studio-server file-mutation endpoints: the server
// writes the split to disk itself, then returns the patched content.
const fetchMock = vi.fn(async (url: string, init?: RequestInit) => {
const u = String(url);
if (u.includes("/gsap-mutations/")) {
if (opts.gsap) {
// Mirror the server: rewrites the GSAP script for the new id, writes to
// disk, returns the final content.
disk["index.html"] = SPLIT_GSAP;
return new Response(JSON.stringify({ ok: true, after: SPLIT_GSAP }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
// The fixture has no GSAP script — mirror the server's 400 response.
return new Response(JSON.stringify({ error: "no GSAP script found in file" }), {
status: 400,
headers: { "Content-Type": "application/json" },
});
}
if (u.includes("/file-mutations/split-element/")) {
disk["index.html"] = SPLIT;
return new Response(
JSON.stringify({ ok: true, changed: true, content: SPLIT, newId: "clip1-split" }),
{ status: 200, headers: { "Content-Type": "application/json" } },
);
}
if (u.includes("/files/")) {
return new Response(JSON.stringify({ content: disk["index.html"] }), {
status: 200,
headers: { "Content-Type": "application/json" },
});
}
void init;
throw new Error(`unexpected fetch: ${u}`);
});
vi.stubGlobal("fetch", fetchMock);
const splitRef: { current: Split | undefined } = { current: undefined };
function Component() {
const { handleRazorSplit } = useRazorSplit({
projectId: "p1",
activeCompPath: "index.html",
showToast: () => {},
writeProjectFile: async (path, content) => {
disk[path] = content;
},
recordEdit: store.recordEdit,
domEditSaveTimestampRef: { current: 0 },
reloadPreview: () => {},
});
splitRef.current = handleRazorSplit;
return null;
}
const host = document.createElement("div");
document.body.append(host);
const root = createRoot(host);
act(() => {
root.render(<Component />);
});
return { disk, store, splitRef, root, expected: finalContent };
}
async function undoViaDisk(harness: Harness) {
return harness.store.undo({
readFile: async (path) => harness.disk[path],
writeFile: async (path, content) => {
harness.disk[path] = content;
},
});
}
afterEach(() => {
document.body.innerHTML = "";
vi.unstubAllGlobals();
});
describe("useRazorSplit — split is undoable via edit history", () => {
for (const gsap of [false, true]) {
describe(gsap ? "with GSAP rewrite" : "plain HTML split", () => {
let harness: Harness;
beforeEach(() => {
harness = mountRazorSplit({ gsap });
});
afterEach(() => {
act(() => harness.root.unmount());
});
it("records a single 'Split timeline clip' history entry that undo restores", async () => {
await act(async () => {
await harness.splitRef.current!(element, 2);
});
// The split reached disk.
expect(harness.disk["index.html"]).toBe(harness.expected);
// The split must be the top of the undo stack — not a prior/other entry.
expect(harness.store.snapshot().canUndo).toBe(true);
expect(harness.store.snapshot().undoLabel).toBe("Split timeline clip");
// Undo restores the exact pre-split file.
const result = await undoViaDisk(harness);
expect(result.ok).toBe(true);
expect(result.label).toBe("Split timeline clip");
expect(harness.disk["index.html"]).toBe(ORIGINAL);
});
});
}
});