mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +00:00
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:
@@ -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>
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user