mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-11-07 15:27:14 +00:00
* 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.
371 lines
17 KiB
TypeScript
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);
|
|
});
|
|
});
|