Files
hyperframes/packages/studio-server/src/helpers/sourceMutationSplitAndGroup.test.ts
Miguel Ángel a8d44b2596 fix(studio): keep clips on their row through group, ungroup, and composition recovery (#4068)
* fix(studio): keep a split clip on its original track row

Splitting an unauthored clip cloned the DOM node without stamping
data-track-index, so the runtime's positional-index fallback renumbered
it onto a different track on insertion. Both halves now pin to the
element's current resolved track.

Also adds A, matching CapCut, to return from the razor tool to select
while it is armed.

* fix(studio): stop the in-point seek from also firing when A exits the razor tool

Both A bindings register on window in capture phase; the razor's listener
mounts after the playback one, so relying on preventDefault ordering let
the seek fire before the tool switch. Playback now checks activeTool
directly instead.

* fix(studio): stamp the resolved track through group, ungroup, and composition recovery

Same defect class as the split fix: an unauthored clip's track falls back to
its position in document order, and any DOM insertion elsewhere can shift
that position. Group and ungroup move elements into and out of a wrapper;
composition recovery clones a missing element back in. All three now stamp
each element's already-resolved track explicitly, the same way split does,
so none of them can drop a clip onto a different row.

Also closes a gap in the earlier "A" hotkey fix: the razor-armed guard was
unconditionally swallowing Shift+A and Alt+A, which should still seek to the
in-point since only plain A exits the razor tool.

* refactor(studio): reuse the existing patch-target and selection-match types

Simplify pass on the track-stamping fix: resolveGroupChildTracks built its
patch target by hand instead of calling buildDomEditPatchTarget, already used
one function up for the same shape. resolveAuthoredTrack's selection param
was a hand-typed copy of ElementMatchSelection instead of importing it.
Collapses the three duplicated optional-integer track checks added to the
wrap/unwrap/split routes into one isOptionalInteger helper. No behavior
change.

* fix(studio): match an ungroup child with no DOM id by its stable id

findMatchingTimelineElementId had no branch for hfId, the stable content-hash
id every element gets regardless of whether it has an authored id. An
ungroup child identified only by that id fell through every branch and kept
its old track instead of getting the resolved one stamped, leaving it
exposed to the same row-drift this PR closes for every other element.

* refactor(studio): resolve review lint suppressions with real fixes

Three lint suppressions from this branch's earlier commits, resolved instead
of left as noise:

- unwrapElementsFromHtml's complexity: extracted the child-track map build
  into its own named function.
- handlePlaybackKeyDown's complexity: extracted the razor/A-key check into
  its own named function.
- The duplicated param block between UseDomEditSessionParams and
  UseDomSelectionParams: pulled the shared fields into one
  DomEditTimelineParams type both interfaces now extend.

No behavior change.

* fix(studio): fall back to the resolved track when nothing was authored

resolveAuthoredTrack only ever returned authoredTrack, so an implicit or
ungrouped child with no explicit authoredTrack resolved to undefined
instead of its actual resolved track, silently skipping the row-pin for
exactly the unauthored population this change exists to fix. Falls back
to the resolved track, matching the function's own existing comment.

* fix(studio): pin the razor split's resolved track for unauthored clips

buildCutTarget had the identical gap resolveAuthoredTrack did: it only
stamped an explicitly authored track, omitting the field entirely for
an implicit/unauthored clip, so a split of it still drifted onto a new
row. Falls back to the element's resolved track, same as the group and
ungroup paths.

* refactor(studio): share one track-fallback rule between split and group

resolveAuthoredTrack and buildCutTarget each re-implemented "authored
track, else the runtime's resolved track" with a different rounding
rule (one rounded only the authored branch, the other neither). Extract
resolveElementTrack so both flows share the same rule and can't drift
out of sync again.
2026-09-18 09:01:15 -04:00

371 lines
17 KiB
TypeScript

// fallow-ignore-file code-duplication
import { parseHTML } from "linkedom";
import { describe, expect, it } from "vitest";
import {
splitElementInHtml,
unwrapElementsFromHtml,
wrapElementsInHtml,
} from "./sourceMutation.js";
describe("splitElementInHtml — hfId clone isolation", () => {
it("does not copy data-hf-id to the cloned second half", () => {
const source = `<html><body><div data-composition-id="root"><div id="clip1" class="clip" data-start="0" data-duration="10" data-hf-id="hf-abc123"></div></div></body></html>`;
const { html, matched } = splitElementInHtml(source, { id: "clip1" }, 5, "clip2");
expect(matched).toBe(true);
const { document } = parseHTML(html);
const occurrences = (html.match(/data-hf-id="hf-abc123"/g) ?? []).length;
expect(occurrences).toBe(1);
expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).toMatch(/^hf-/);
expect(document.getElementById("clip2")?.getAttribute("data-hf-id")).not.toBe("hf-abc123");
});
});
describe("splitElementInHtml", () => {
const source = `<!DOCTYPE html><html><head><style>#box { position: absolute; top: 100px; background: red; }</style></head><body><div data-composition-id="root"><div id="box" class="clip" data-start="1" data-duration="6">Hello</div></div></body></html>`;
it("splits element at the given time", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('id="box-split"');
expect(result.html).toContain('data-start="3"');
expect(result.html).toContain('data-duration="4"');
});
it("pins both halves to the caller-supplied track so an unauthored clip's split half can't drift to a new row", () => {
// #box has no data-track-index/data-layer — this is the common, unauthored
// case that relies on the runtime's positional-index fallback. Without an
// explicit stamp, splitting only the clone changes DOM sibling order and
// the runtime resolves the clone to a different fallback track.
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split", {
start: 1,
duration: 6,
track: 2,
});
expect(result.matched).toBe(true);
expect(result.html.match(/data-track-index="2"/g)).toHaveLength(2);
});
it("canonicalizes legacy timing attributes on both split halves", () => {
const legacy = source.replace(
'data-start="1" data-duration="6"',
'data-start="1" data-end="7" data-layer="3"',
);
const result = splitElementInHtml(legacy, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).not.toContain("data-end=");
expect(result.html).not.toContain("data-layer=");
expect(result.html.match(/data-track-index="3"/g)).toHaveLength(2);
expect(result.html).toContain('data-duration="2"');
expect(result.html).toContain('data-duration="4"');
});
it("duplicates CSS rules for the new element ID", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toContain("#box-split");
expect(result.html).toContain("background: red");
const cssMatches = result.html.match(/#box-split\s*\{/g);
expect(cssMatches?.length).toBeGreaterThanOrEqual(1);
});
it("deduplicates IDs when the requested newId already exists", () => {
const withExisting = source.replace(
"</div></div>",
'</div><div id="box-split" data-start="5" data-duration="1">Existing</div></div>',
);
const result = splitElementInHtml(withExisting, { id: "box" }, 3, "box-split");
expect(result.matched).toBe(true);
expect(result.html).toContain('id="box-split-2"');
});
it("keeps clip class on the cloned element", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*class="clip"/);
});
it("gives a split composition host a unique composition id", () => {
const composition = source.replace(
'id="box" class="clip"',
'id="box" class="clip" data-composition-id="headline" data-composition-src="headline.html"',
);
const first = splitElementInHtml(composition, { id: "box" }, 3, "box-split");
const second = splitElementInHtml(first.html, { id: "box-split" }, 4, "box-split-2");
const { document } = parseHTML(second.html);
const compositionIds = Array.from(
document.querySelectorAll("[data-composition-id]"),
(element) => element.getAttribute("data-composition-id"),
);
expect(compositionIds).toHaveLength(4);
expect(new Set(compositionIds)).toHaveLength(4);
expect(compositionIds).toEqual(expect.arrayContaining(["root", "headline", "headline-split"]));
});
it("returns matched false for out-of-range split time", () => {
expect(splitElementInHtml(source, { id: "box" }, 0.5, "box-split").matched).toBe(false);
expect(splitElementInHtml(source, { id: "box" }, 7.5, "box-split").matched).toBe(false);
});
it("splits a GSAP element with no authored timing using fallback timing", () => {
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title" class="title">Hi</h1></div></body></html>`;
const result = splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split", {
start: 0,
duration: 6,
});
expect(result.matched).toBe(true);
const original = result.html.match(/<h1[^>]*\bid="title"[^>]*>/);
const clone = result.html.match(/<h1[^>]*\bid="title-split"[^>]*>/);
expect(original?.[0]).toContain('data-start="0"');
expect(original?.[0]).toContain('data-duration="2"');
expect(clone?.[0]).toContain('data-start="2"');
expect(clone?.[0]).toContain('data-duration="4"');
});
it("still rejects a no-timing element when no fallback timing is given", () => {
const gsapSource = `<html><body><div data-composition-id="root"><h1 id="title">Hi</h1></div></body></html>`;
expect(splitElementInHtml(gsapSource, { id: "title" }, 2, "title-split").matched).toBe(false);
});
it("adjusts media playback-start for the second half", () => {
const mediaSource = source.replace(
'id="box" class="clip" data-start="1" data-duration="6"',
'id="box" class="clip" data-start="1" data-duration="6" data-playback-start="0"',
);
const result = splitElementInHtml(mediaSource, { id: "box" }, 3, "box-split");
expect(result.html).toMatch(/id="box-split"[^>]*data-playback-start="2"/);
});
it.each(["audio", "video"])(
"seeds the second %s half with a media in-point when the source starts at zero",
(tag) => {
const mediaSource = `<!DOCTYPE html><html><body><div data-composition-id="root"><${tag} id="media" class="clip" src="asset.mp4" data-start="1" data-duration="6"></${tag}></div></body></html>`;
const result = splitElementInHtml(mediaSource, { id: "media" }, 3, "media-split");
const { document } = parseHTML(result.html);
expect(result.matched).toBe(true);
expect(document.getElementById("media")?.getAttribute("data-media-start")).toBe("0");
expect(document.getElementById("media-split")?.getAttribute("data-media-start")).toBe("2");
},
);
it("advances a zero-based media in-point by playback rate", () => {
const mediaSource = `<!DOCTYPE html><html><body><div data-composition-id="root"><video id="media" class="clip" src="asset.mp4" data-start="1" data-duration="6" data-playback-rate="2"></video></div></body></html>`;
const result = splitElementInHtml(mediaSource, { id: "media" }, 3, "media-split");
const { document } = parseHTML(result.html);
expect(document.getElementById("media-split")?.getAttribute("data-media-start")).toBe("4");
});
it("does not add a media in-point to non-media elements", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split");
expect(result.html).not.toContain("data-media-start");
expect(result.html).not.toContain("data-playback-start");
});
it("stamps a legacy composition offset and advances the second half by playback rate", () => {
const result = splitElementInHtml(source, { id: "box" }, 3, "box-split", {
start: 1,
duration: 6,
playbackStart: 1.5,
playbackRate: 2,
stampPlaybackStart: true,
});
const { document } = parseHTML(result.html);
expect(document.getElementById("box")?.getAttribute("data-playback-start")).toBe("1.5");
expect(document.getElementById("box-split")?.getAttribute("data-playback-start")).toBe("5.5");
});
});
describe("wrapElementsInHtml / unwrapElementsFromHtml", () => {
const FIXTURE = `<!doctype html><html><body><div data-composition-id="main">
<div id="title" class="clip" style="position: absolute; left: 260px; top: 100px">Title</div>
<div id="logo" class="clip" style="position: absolute; left: 300px; top: 200px; transform: translate(10px, 5px)">Logo</div>
<div id="badge" class="clip" style="position: absolute; left: 400px; top: 50px; --hf-studio-offset: 12px">Badge</div>
<div id="outside" class="clip" style="position: absolute; left: 10px; top: 10px">Outside</div>
</div></body></html>`;
const BBOX = { left: 260, top: 50, width: 300, height: 300 };
const REBASES = [
{ target: { id: "title" }, left: 0, top: 50 },
{ target: { id: "logo" }, left: 40, top: 150 },
{ target: { id: "badge" }, left: 140, top: 0 },
];
const TARGETS = [{ id: "title" }, { id: "logo" }, { id: "badge" }];
function leftTop(el: Element): { left: number; top: number } {
const style = el.getAttribute("style") ?? "";
const left = parseFloat(/(?:^|;)\s*left\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
const top = parseFloat(/(?:^|;)\s*top\s*:\s*([\d.]+)px/.exec(style)?.[1] ?? "NaN");
return { left, top };
}
function requireElement(document: Document, selector: string): Element {
const element = document.querySelector(selector);
if (!element) throw new Error(`Expected ${selector} to match`);
return element;
}
it.each([
["Group 1", "group-1"],
[" --HELLO___World!! ", "hello-world"],
["a---b---c", "a-b-c"],
["--123--", "123"],
["", "group"],
[" --- ", "group"],
["你好", "group"],
["-".repeat(100_000) + "Title" + "-".repeat(100_000), "title"],
])("preserves normalized group IDs and collision suffixes (case %#)", (name, id) => {
const source = FIXTURE.replace(
"</body>",
`<div id="${id}"></div><div id="${id}-2"></div></body>`,
);
const result = wrapElementsInHtml(source, TARGETS, name, BBOX, REBASES);
expect(result.matched).toBe(true);
const { document } = parseHTML(result.html);
const group = document.getElementById(`${id}-3`);
expect(group?.getAttribute("data-hf-group")).toBe(name);
expect(Array.from(group?.children ?? []).map((child) => child.id)).toEqual([
"title",
"logo",
"badge",
]);
});
it("wraps members in a data-hf-group div, preserving order and rebasing left/top", () => {
const { html, matched, groupId } = wrapElementsInHtml(
FIXTURE,
TARGETS,
"Group 1",
BBOX,
REBASES,
);
expect(matched).toBe(true);
expect(groupId).toBe("Group 1");
const { document } = parseHTML(html);
const group = requireElement(document, '[data-hf-group="Group 1"]');
expect(leftTop(group)).toEqual({ left: 260, top: 50 });
expect(Array.from(group.children).map((c) => c.id)).toEqual(["title", "logo", "badge"]);
expect(requireElement(document, "#outside").parentElement).toBe(
requireElement(document, '[data-composition-id="main"]'),
);
expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 0, top: 50 });
expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 40, top: 150 });
expect(requireElement(document, "#logo").getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 140, top: 0 });
expect(requireElement(document, "#badge").getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("stamps each member's resolved track so grouping an unauthored clip can't drift it to a new row", () => {
const rebasesWithTrack = [
{ target: { id: "title" }, left: 0, top: 50, track: 1 },
{ target: { id: "logo" }, left: 40, top: 150, track: 3 },
{ target: { id: "badge" }, left: 140, top: 0 },
];
const { html } = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, rebasesWithTrack);
const { document } = parseHTML(html);
expect(requireElement(document, "#title").getAttribute("data-track-index")).toBe("1");
expect(requireElement(document, "#logo").getAttribute("data-track-index")).toBe("3");
expect(requireElement(document, "#badge").hasAttribute("data-track-index")).toBe(false);
});
it("round-trips: unwrap restores original structure and coordinates", () => {
const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html;
const { html, unwrapped } = unwrapElementsFromHtml(wrapped, {
selector: '[data-hf-group="Group 1"]',
});
expect(unwrapped).toBe(true);
const { document } = parseHTML(html);
expect(document.querySelector("[data-hf-group]")).toBeNull();
const main = requireElement(document, '[data-composition-id="main"]');
expect(Array.from(main.children).map((c) => c.id)).toEqual([
"title",
"logo",
"badge",
"outside",
]);
expect(leftTop(requireElement(document, "#title"))).toEqual({ left: 260, top: 100 });
expect(leftTop(requireElement(document, "#logo"))).toEqual({ left: 300, top: 200 });
expect(requireElement(document, "#logo").getAttribute("style")).toContain(
"transform: translate(10px, 5px)",
);
expect(leftTop(requireElement(document, "#badge"))).toEqual({ left: 400, top: 50 });
expect(requireElement(document, "#badge").getAttribute("style")).toContain(
"--hf-studio-offset: 12px",
);
});
it("stamps each child's resolved track on ungroup so it can't drift to a new row when it moves back out of the wrapper", () => {
const wrapped = wrapElementsInHtml(FIXTURE, TARGETS, "Group 1", BBOX, REBASES).html;
const childTracks = [
{ target: { id: "title" }, track: 1 },
{ target: { id: "logo" }, track: 3 },
];
const { html } = unwrapElementsFromHtml(
wrapped,
{ selector: '[data-hf-group="Group 1"]' },
childTracks,
);
const { document } = parseHTML(html);
expect(requireElement(document, "#title").getAttribute("data-track-index")).toBe("1");
expect(requireElement(document, "#logo").getAttribute("data-track-index")).toBe("3");
expect(requireElement(document, "#badge").hasAttribute("data-track-index")).toBe(false);
});
it("rejects members that do not share a single parent", () => {
const split = `<!doctype html><html><body><div data-composition-id="main"><div id="a" style="position:absolute;left:0;top:0"></div><section><div id="b" style="position:absolute;left:0;top:0"></div></section></div></body></html>`;
const result = wrapElementsInHtml(split, [{ id: "a" }, { id: "b" }], "Group 1", BBOX, [
{ target: { id: "a" }, left: 0, top: 0 },
{ target: { id: "b" }, left: 0, top: 0 },
]);
expect(result.matched).toBe(false);
expect(result.error).toMatch(/single parent/);
expect(result.html).toBe(split);
});
it("lifts the group to the topmost member's slot so an interleaved non-member falls below it", () => {
const fixture = `<!doctype html><html><body><div data-composition-id="main"><div id="low" style="position:absolute;left:0;top:0;z-index:2"></div><div id="middle" style="position:absolute;left:0;top:0;z-index:3"></div><div id="high" style="position:absolute;left:0;top:0;z-index:4"></div></div></body></html>`;
const { html, matched } = wrapElementsInHtml(
fixture,
[{ id: "low" }, { id: "high" }],
"Group 1",
{ left: 0, top: 0, width: 10, height: 10 },
[
{ target: { id: "low" }, left: 0, top: 0 },
{ target: { id: "high" }, left: 0, top: 0 },
],
);
expect(matched).toBe(true);
const { document } = parseHTML(html);
const parent = requireElement(document, '[data-composition-id="main"]');
const group = requireElement(document, '[data-hf-group="Group 1"]');
expect(Array.from(group.children).map((c) => c.id)).toEqual(["low", "high"]);
const topChildren = Array.from(parent.children).map(
(c) => c.getAttribute("data-hf-group") ?? c.id,
);
expect(topChildren).toEqual(["middle", "Group 1"]);
expect(group.getAttribute("style")).toMatch(/z-index:\s*4/);
});
it("refuses to unwrap an element without data-hf-group (no silent corruption)", () => {
const html = `<!doctype html><html><body><div data-composition-id="main"><div id="plain" style="position:absolute;left:0;top:0"><span id="kid"></span></div></div></body></html>`;
const result = unwrapElementsFromHtml(html, { id: "plain" });
expect(result.unwrapped).toBe(false);
expect(result.html).toBe(html);
});
});