mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-10 22:20:14 +00:00
feat: add studio timeline editing (#390)
## Summary Add the actual Studio timeline editing layer on top of the preview/runtime foundation. This PR includes: - drag-to-move clips across time and tracks - left/right resize handles with media-aware trim persistence - edge auto-scroll and edge track creation while dragging - selector-based source patching for `data-start`, `data-duration`, `data-track-index`, `z-index`, and media trim attributes - timeline UI cleanup, theming, hover/drag states, and the `Copy Prompt` action ## Why This PR Is Separate This is the user-facing editing behavior. It depends on the preview/runtime fixes in the base PR, but it is much easier to review once that plumbing is isolated. ## Verification - `bun run --filter @hyperframes/studio test` - `bun run --filter @hyperframes/studio typecheck` - `bunx oxlint packages/studio/src/App.tsx packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/player/components/EditModal.tsx packages/studio/src/player/components/Timeline.tsx packages/studio/src/player/components/TimelineClip.tsx packages/studio/src/player/components/timelineEditing.ts packages/studio/src/player/components/timelineEditing.test.ts packages/studio/src/player/components/timelineTheme.ts packages/studio/src/player/components/timelineTheme.test.ts packages/studio/src/utils/sourcePatcher.ts packages/studio/src/utils/sourcePatcher.test.ts` - `bunx oxfmt --check packages/studio/src/App.tsx packages/studio/src/components/nle/NLELayout.tsx packages/studio/src/player/components/EditModal.tsx packages/studio/src/player/components/Timeline.tsx packages/studio/src/player/components/TimelineClip.tsx packages/studio/src/player/components/timelineEditing.ts packages/studio/src/player/components/timelineEditing.test.ts packages/studio/src/player/components/timelineTheme.ts packages/studio/src/player/components/timelineTheme.test.ts packages/studio/src/utils/sourcePatcher.ts packages/studio/src/utils/sourcePatcher.test.ts` ## Browser Proof - verified timeline drag / resize / trim flows in Studio with `agent-browser` - verified preview hot-refresh behavior without iframe remount flashes ## Stack - depends on #389 - followed by `fix: smooth scrubber end seeking` [result.mp4 <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.com/user-attachments/thumbnails/ca71c177-5042-468d-906f-b353938f40f8.mp4" />](https://app.graphite.com/user-attachments/video/ca71c177-5042-468d-906f-b353938f40f8.mp4)
This commit is contained in:
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||
import { usePlayerStore } from "../store/playerStore";
|
||||
import { formatTime } from "../lib/time";
|
||||
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
||||
|
||||
interface EditPopoverProps {
|
||||
rangeStart: number;
|
||||
@@ -14,7 +15,8 @@ interface EditPopoverProps {
|
||||
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
|
||||
const elements = usePlayerStore((s) => s.elements);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [copiedAgentPrompt, setCopiedAgentPrompt] = useState(false);
|
||||
const [copiedPromptOnly, setCopiedPromptOnly] = useState(false);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -51,27 +53,12 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
||||
});
|
||||
|
||||
const buildClipboardText = useCallback(() => {
|
||||
const elementLines = elementsInRange
|
||||
.map(
|
||||
(el) =>
|
||||
`- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
|
||||
)
|
||||
.join("\n");
|
||||
|
||||
return `Edit the following HyperFrames composition:
|
||||
|
||||
Time range: ${formatTime(start)} — ${formatTime(end)}
|
||||
|
||||
Elements in range:
|
||||
${elementLines || "(none)"}
|
||||
|
||||
User request:
|
||||
${prompt.trim() || "(no prompt provided)"}
|
||||
|
||||
Instructions:
|
||||
Modify only the elements listed above within the specified time range.
|
||||
The composition uses HyperFrames data attributes (data-start, data-duration, data-track-index) and GSAP for animations.
|
||||
Preserve all other elements and timing outside this range.`;
|
||||
return buildTimelineAgentPrompt({
|
||||
rangeStart: start,
|
||||
rangeEnd: end,
|
||||
elements: elementsInRange,
|
||||
prompt,
|
||||
});
|
||||
}, [start, end, elementsInRange, prompt]);
|
||||
|
||||
const handleCopy = useCallback(async () => {
|
||||
@@ -85,13 +72,32 @@ Preserve all other elements and timing outside this range.`;
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopied(true);
|
||||
setCopiedAgentPrompt(true);
|
||||
setTimeout(() => {
|
||||
setCopied(false);
|
||||
setCopiedAgentPrompt(false);
|
||||
onClose();
|
||||
}, 800);
|
||||
}, [buildClipboardText, onClose]);
|
||||
|
||||
const handleCopyPrompt = useCallback(async () => {
|
||||
const promptText = buildPromptCopyText(prompt);
|
||||
if (!promptText) return;
|
||||
try {
|
||||
await navigator.clipboard.writeText(promptText);
|
||||
} catch {
|
||||
const ta = document.createElement("textarea");
|
||||
ta.value = promptText;
|
||||
document.body.appendChild(ta);
|
||||
ta.select();
|
||||
document.execCommand("copy");
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
setCopiedPromptOnly(true);
|
||||
setTimeout(() => {
|
||||
setCopiedPromptOnly(false);
|
||||
}, 800);
|
||||
}, [prompt]);
|
||||
|
||||
const style: React.CSSProperties = {
|
||||
position: "fixed",
|
||||
left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)),
|
||||
@@ -146,17 +152,30 @@ Preserve all other elements and timing outside this range.`;
|
||||
</div>
|
||||
|
||||
{/* Action */}
|
||||
<div className="px-3 pb-3">
|
||||
<div className="grid grid-cols-2 gap-2 px-3 pb-3">
|
||||
<button
|
||||
onClick={handleCopyPrompt}
|
||||
disabled={!buildPromptCopyText(prompt)}
|
||||
className={`py-1.5 text-[11px] font-medium rounded-lg transition-all border ${
|
||||
copiedPromptOnly
|
||||
? "bg-green-500/20 text-green-400 border-green-500/30"
|
||||
: "bg-neutral-800/70 text-neutral-200 border-neutral-700/50 hover:bg-neutral-800"
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
>
|
||||
{copiedPromptOnly ? "Prompt Copied!" : "Copy Prompt"}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
|
||||
copied
|
||||
className={`py-1.5 text-[11px] font-medium rounded-lg transition-all ${
|
||||
copiedAgentPrompt
|
||||
? "bg-green-500/20 text-green-400 border border-green-500/30"
|
||||
: "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
|
||||
}`}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy to Agent"}
|
||||
{!copied && <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>}
|
||||
{copiedAgentPrompt ? "Copied!" : "Copy to Agent"}
|
||||
{!copiedAgentPrompt && (
|
||||
<span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user