feat(core): GSAP-aware split engine for timeline clip splitting (#1330)

* refactor(studio): extract shared timeline components and deduplicate code

Extract shared utilities to reduce duplication across timeline components:

- PlayheadIndicator: shared playhead rendering (was duplicated in
  TimelineCanvas and TimelineEditorNotice)
- useContextMenuDismiss: outside-click/Escape dismiss pattern (was
  duplicated in ClipContextMenu and KeyframeDiamondContextMenu)
- TimelineCallbacks: shared callback interfaces for drop and edit
  operations (was duplicated in NLELayout and Timeline props)
- useTimelineZoom: consolidated zoom store selectors
- timelineElementSplit: shared canSplitElement, buildPatchTarget, and
  readFileContent utilities
- gsapParser.test-helpers: shared test utilities for parser specs

* feat(core): GSAP-aware split engine for timeline clip splitting

Add splitAnimationsInScript to the GSAP parser — correctly re-times
animations when a timeline clip is split at an arbitrary position:

- Animations before split: kept on original, properties inherited via
  tl.set inserted before other tweens for correct GSAP state recording
- Animations after split: retargeted via AST selector update
- Spanning animations: trimmed on original, continuation added for
  new element with correct position and duration
- Keyframes: classified by total per-keyframe duration
- Reverse iteration prevents stale animation ID collisions

Enhance splitElementInHtml:
- CSS rule duplication via PostCSS for ID-based styles
- Server-side ID deduplication for repeated splits
- Media playback-start adjustment for video/audio

Add split-animations route to gsap-mutations endpoint.
This commit is contained in:
Miguel Ángel
2026-06-10 23:48:16 -04:00
committed by GitHub
parent ab08260201
commit 45d4a71ed0
7 changed files with 671 additions and 145 deletions
@@ -2,8 +2,8 @@ import { describe, expect, it } from "vitest";
import {
removeElementFromHtml,
patchElementInHtml,
probeElementInSource,
splitElementInHtml,
probeElementInSource,
} from "./sourceMutation.js";
describe("removeElementFromHtml", () => {
@@ -467,3 +467,53 @@ describe("splitElementInHtml — hfId clone isolation", () => {
expect(occurrences).toBe(1);
});
});
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("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("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("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"/);
});
});