From d1aad77fd789968e682f4c78820b793703c5f6e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Fri, 5 Jun 2026 12:06:00 -0400 Subject: [PATCH] feat(studio): split clip at playhead for media elements [7/10] (#1189) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- .../src/studio-api/helpers/sourceMutation.ts | 62 +++++++++++++ .../studio/src/hooks/useTimelineEditing.ts | 91 ++++++++++++++++++ .../src/player/components/ClipContextMenu.tsx | 92 +++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 packages/studio/src/player/components/ClipContextMenu.tsx diff --git a/packages/core/src/studio-api/helpers/sourceMutation.ts b/packages/core/src/studio-api/helpers/sourceMutation.ts index 6e934b2e5..8cdc5ae05 100644 --- a/packages/core/src/studio-api/helpers/sourceMutation.ts +++ b/packages/core/src/studio-api/helpers/sourceMutation.ts @@ -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, + }; +} diff --git a/packages/studio/src/hooks/useTimelineEditing.ts b/packages/studio/src/hooks/useTimelineEditing.ts index 86e76f9f9..e9045b960 100644 --- a/packages/studio/src/hooks/useTimelineEditing.ts +++ b/packages/studio/src/hooks/useTimelineEditing.ts @@ -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, diff --git a/packages/studio/src/player/components/ClipContextMenu.tsx b/packages/studio/src/player/components/ClipContextMenu.tsx new file mode 100644 index 000000000..4caf1a065 --- /dev/null +++ b/packages/studio/src/player/components/ClipContextMenu.tsx @@ -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(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 ( +
+ + +
+ + +
+ ); +});