feat(studio): split clip at playhead for media elements [7/10] (#1189)

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* test(producer): regenerate heygen-promo-preview-assets and style-9-prod baselines

Baselines regenerated inside Dockerfile.test on the devbox to match
the current runtime init.ts changes. Both pass the full regression
harness with the videoStreamDurationSeconds PSNR fix.

* feat(studio): design panel integration, timeline polish, feature flag

* fix(studio): rotation-aware drag + auto-keyframing for resize and rotation

U1: stripGsapTranslateFromTransform now rotates the offset vector by the
element's CSS rotation angle before subtracting from m41/m42. Fixes
elements drifting from cursor during drag when rotated.

U2+U3: Add tryGsapResizeIntercept and tryGsapRotationIntercept to the
runtime bridge. Resize and rotation handle changes now create keyframes
via the same async pipeline as position drag. CSS path guards prevent
double-persistence for GSAP-animated elements.

* fix(studio): counter-rotate drag offset for css-rotated elements

CSS compose order is translate → rotate → transform. The drag offset
(in pre-rotation translate space) was added directly to GSAP x/y
(in post-rotation transform space). Now counter-rotates the offset
by the element's CSS --hf-studio-rotation angle before adding.

* feat(studio): add 'delete all keyframes' to diamond context menu

* fix(studio): include all animated properties in every keyframe commit

Position, resize, and rotation intercepts now read ALL animated
property values from gsap.getProperty() at commit time and include
them in the keyframe. Prevents other properties from jumping to
interpolated values between surrounding keyframes when only one
property (e.g., width) was explicitly changed.

* feat(core): spring physics solver + runtime fixes + spring ease editor

* feat(core): spring physics solver + runtime fixes + spring ease editor

Revert totalTime nudge that caused black first frames in from() tweens.
Keep stale CSS offset cleanup. Regenerate baselines for offset cleanup.

* ci: trigger regression run

* feat(studio): add split clip feature with timeline context menu and hotkey

Add splitElementInHtml to core source mutation helpers — clones an element
at the split time, adjusts data-start/data-duration/data-media-start for
both halves, and inserts the clone after the original.

Wire through: split-element API endpoint, handleTimelineElementSplit in
useTimelineEditing, clip context menu (right-click → Split at Xs), toolbar
split button, and S keyboard shortcut.

Edge cases: locked/implicit clips blocked, media trim offset adjusted by
playback rate, unique ID generation with collision avoidance, undo via
edit history.
This commit is contained in:
Miguel Ángel
2026-06-05 12:06:00 -04:00
committed by GitHub
parent a5211954ea
commit d1aad77fd7
3 changed files with 245 additions and 0 deletions
@@ -212,3 +212,65 @@ export function probeElementInSource(source: string, target: SourceMutationTarge
const el = findTargetElement(document, target);
return el != null && isHTMLElement(el);
}
export interface SplitElementResult {
html: string;
matched: boolean;
newId: string | null;
}
export function splitElementInHtml(
source: string,
target: SourceMutationTarget,
splitTime: number,
newId: string,
): SplitElementResult {
const { document, wrappedFragment } = parseSourceDocument(source);
const el = findTargetElement(document, target);
if (!el || !isHTMLElement(el)) return { html: source, matched: false, newId: null };
const start = parseFloat(el.getAttribute("data-start") ?? "0") || 0;
const duration = parseFloat(el.getAttribute("data-duration") ?? "0") || 0;
if (duration <= 0 || splitTime <= start || splitTime >= start + duration) {
return { html: source, matched: false, newId: null };
}
const firstDuration = splitTime - start;
const secondDuration = duration - firstDuration;
const clone = el.cloneNode(true) as HTMLElement;
clone.setAttribute("id", newId);
clone.setAttribute("data-start", String(Math.round(splitTime * 1000) / 1000));
clone.setAttribute("data-duration", String(Math.round(secondDuration * 1000) / 1000));
// Adjust media trim offset for the second half
const playbackStartAttr = el.hasAttribute("data-playback-start")
? "data-playback-start"
: el.hasAttribute("data-media-start")
? "data-media-start"
: null;
if (playbackStartAttr) {
const currentTrim = parseFloat(el.getAttribute(playbackStartAttr) ?? "0") || 0;
const rate = parseFloat(el.getAttribute("data-playback-rate") ?? "1") || 1;
clone.setAttribute(
playbackStartAttr,
String(Math.round((currentTrim + firstDuration * rate) * 1000) / 1000),
);
}
// Trim the original element's duration
el.setAttribute("data-duration", String(Math.round(firstDuration * 1000) / 1000));
// Insert clone after original
if (el.nextSibling) {
el.parentElement!.insertBefore(clone, el.nextSibling);
} else {
el.parentElement!.appendChild(clone);
}
return {
html: wrappedFragment ? document.body.innerHTML || "" : document.toString(),
matched: true,
newId,
};
}
@@ -466,10 +466,101 @@ export function useTimelineEditing({
[showToast],
);
const handleTimelineElementSplit = useCallback(
async (element: TimelineElement, splitTime: number) => {
const pid = projectIdRef.current;
if (!pid) return;
if (
element.timelineLocked ||
element.timingSource === "implicit" ||
!element.duration ||
!Number.isFinite(element.duration)
) {
showToast("This clip cannot be split.", "error");
return;
}
if (splitTime <= element.start || splitTime >= element.start + element.duration) {
showToast("Playhead must be inside the clip to split.", "error");
return;
}
const patchTarget = buildPatchTarget(element);
if (!patchTarget) {
showToast("Clip is missing a patchable target.", "error");
return;
}
const targetPath = element.sourceFile || activeCompPath || "index.html";
try {
const originalContent = await readFileContent(pid, targetPath);
const existingIds = collectHtmlIds(originalContent);
const baseId = element.domId || "clip";
let newId = `${baseId}-split`;
let suffix = 2;
while (existingIds.includes(newId)) {
newId = `${baseId}-split-${suffix++}`;
}
const response = await fetch(
`/api/projects/${pid}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: patchTarget, splitTime, newId }),
},
);
if (!response.ok) {
throw new Error("Split request failed");
}
const data = (await response.json()) as {
ok?: boolean;
changed?: boolean;
content?: string;
};
if (!data.ok || !data.changed) {
showToast("Failed to split clip — playhead may be outside the clip.", "error");
return;
}
const patchedContent = typeof data.content === "string" ? data.content : originalContent;
domEditSaveTimestampRef.current = Date.now();
await saveProjectFilesWithHistory({
projectId: pid,
label: "Split timeline clip",
kind: "timeline",
files: { [targetPath]: patchedContent },
readFile: async () => originalContent,
writeFile: writeProjectFile,
recordEdit,
});
reloadPreview();
const label = getTimelineElementLabel(element);
showToast(`Split ${label} at ${splitTime.toFixed(2)}s`, "info");
} catch (error) {
const message = error instanceof Error ? error.message : "Failed to split timeline clip";
showToast(message, "error");
}
},
[
activeCompPath,
recordEdit,
showToast,
writeProjectFile,
domEditSaveTimestampRef,
reloadPreview,
],
);
return {
handleTimelineElementMove,
handleTimelineElementResize,
handleTimelineElementDelete,
handleTimelineElementSplit,
handleTimelineAssetDrop,
handleTimelineFileDrop,
handleBlockedTimelineEdit,
@@ -0,0 +1,92 @@
import { memo, useCallback, useEffect, useRef } from "react";
import type { TimelineElement } from "../store/playerStore";
interface ClipContextMenuProps {
x: number;
y: number;
element: TimelineElement;
currentTime: number;
onClose: () => void;
onSplit: (element: TimelineElement, splitTime: number) => void;
onDelete: (element: TimelineElement) => void;
}
export const ClipContextMenu = memo(function ClipContextMenu({
x,
y,
element,
currentTime,
onClose,
onSplit,
onDelete,
}: ClipContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const dismiss = useCallback(
(e: MouseEvent | KeyboardEvent) => {
if (e instanceof KeyboardEvent && e.key !== "Escape") return;
if (e instanceof MouseEvent && menuRef.current?.contains(e.target as Node)) return;
onClose();
},
[onClose],
);
useEffect(() => {
document.addEventListener("mousedown", dismiss);
document.addEventListener("keydown", dismiss);
return () => {
document.removeEventListener("mousedown", dismiss);
document.removeEventListener("keydown", dismiss);
};
}, [dismiss]);
const adjustedX = Math.min(x, window.innerWidth - 200);
const adjustedY = Math.min(y, window.innerHeight - 200);
const canSplit = currentTime > element.start && currentTime < element.start + element.duration;
const splitLabel = canSplit
? `Split at ${currentTime.toFixed(2)}s`
: "Split (move playhead inside clip)";
return (
<div
ref={menuRef}
className="fixed z-50 bg-neutral-900 border border-neutral-700 rounded-md shadow-lg py-1 min-w-[180px]"
style={{ left: adjustedX, top: adjustedY }}
>
<button
type="button"
className={`w-full flex items-center justify-between px-3 py-1.5 text-xs text-left ${
canSplit
? "text-neutral-300 hover:bg-neutral-800 cursor-pointer"
: "text-neutral-600 cursor-not-allowed"
}`}
disabled={!canSplit}
onClick={() => {
if (canSplit) {
onSplit(element, currentTime);
onClose();
}
}}
>
<span>{splitLabel}</span>
<span className="text-neutral-500 text-[10px] ml-3">S</span>
</button>
<div className="my-1 border-t border-neutral-700/60" />
<button
type="button"
className="w-full flex items-center justify-between px-3 py-1.5 text-xs text-red-400 hover:bg-neutral-800 cursor-pointer text-left"
onClick={() => {
onDelete(element);
onClose();
}}
>
<span>Delete</span>
<span className="text-neutral-500 text-[10px] ml-3"></span>
</button>
</div>
);
});