mirror of
https://github.com/heygen-com/hyperframes.git
synced 2026-09-03 04:38:33 +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:
+281
-14
@@ -5,7 +5,7 @@ import { SourceEditor } from "./components/editor/SourceEditor";
|
|||||||
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
|
||||||
import { RenderQueue } from "./components/renders/RenderQueue";
|
import { RenderQueue } from "./components/renders/RenderQueue";
|
||||||
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
import { useRenderQueue } from "./components/renders/useRenderQueue";
|
||||||
import { CompositionThumbnail, VideoThumbnail } from "./player";
|
import { CompositionThumbnail, VideoThumbnail, usePlayerStore } from "./player";
|
||||||
import { AudioWaveform } from "./player/components/AudioWaveform";
|
import { AudioWaveform } from "./player/components/AudioWaveform";
|
||||||
import type { TimelineElement } from "./player";
|
import type { TimelineElement } from "./player";
|
||||||
import { LintModal } from "./components/LintModal";
|
import { LintModal } from "./components/LintModal";
|
||||||
@@ -18,6 +18,11 @@ import { CaptionTimeline } from "./captions/components/CaptionTimeline";
|
|||||||
import { useCaptionStore } from "./captions/store";
|
import { useCaptionStore } from "./captions/store";
|
||||||
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
import { useCaptionSync } from "./captions/hooks/useCaptionSync";
|
||||||
import { parseCaptionComposition } from "./captions/parser";
|
import { parseCaptionComposition } from "./captions/parser";
|
||||||
|
import { applyPatchByTarget, readAttributeByTarget } from "./utils/sourcePatcher";
|
||||||
|
import {
|
||||||
|
buildTrackZIndexMap,
|
||||||
|
formatTimelineAttributeNumber,
|
||||||
|
} from "./player/components/timelineEditing";
|
||||||
|
|
||||||
interface EditingFile {
|
interface EditingFile {
|
||||||
path: string;
|
path: string;
|
||||||
@@ -186,7 +191,7 @@ export function StudioApp() {
|
|||||||
}, [captionHasSelection, captionEditMode]);
|
}, [captionHasSelection, captionEditMode]);
|
||||||
const [globalDragOver, setGlobalDragOver] = useState(false);
|
const [globalDragOver, setGlobalDragOver] = useState(false);
|
||||||
const [uploadToast, setUploadToast] = useState<string | null>(null);
|
const [uploadToast, setUploadToast] = useState<string | null>(null);
|
||||||
const [timelineVisible, setTimelineVisible] = useState(false);
|
const [timelineVisible, setTimelineVisible] = useState(true);
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
const panelDragRef = useRef<{
|
const panelDragRef = useRef<{
|
||||||
side: "left" | "right";
|
side: "left" | "right";
|
||||||
@@ -198,6 +203,19 @@ export function StudioApp() {
|
|||||||
const activePreviewUrl = activeCompPath
|
const activePreviewUrl = activeCompPath
|
||||||
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
? `/api/projects/${projectId}/preview/comp/${activeCompPath}`
|
||||||
: null;
|
: null;
|
||||||
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
||||||
|
const pixelsPerSecond = usePlayerStore((s) => s.pixelsPerSecond);
|
||||||
|
const setZoomMode = usePlayerStore((s) => s.setZoomMode);
|
||||||
|
const setPixelsPerSecond = usePlayerStore((s) => s.setPixelsPerSecond);
|
||||||
|
const timelineElements = usePlayerStore((s) => s.elements);
|
||||||
|
const timelineDuration = usePlayerStore((s) => s.duration);
|
||||||
|
const effectiveTimelineDuration = useMemo(() => {
|
||||||
|
const maxEnd =
|
||||||
|
timelineElements.length > 0
|
||||||
|
? Math.max(...timelineElements.map((element) => element.start + element.duration))
|
||||||
|
: 0;
|
||||||
|
return Math.max(timelineDuration, maxEnd);
|
||||||
|
}, [timelineDuration, timelineElements]);
|
||||||
|
|
||||||
const renderClipContent = useCallback(
|
const renderClipContent = useCallback(
|
||||||
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
|
(el: TimelineElement, style: { clip: string; label: string }): ReactNode => {
|
||||||
@@ -222,9 +240,10 @@ export function StudioApp() {
|
|||||||
previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
|
previewUrl={`/api/projects/${pid}/preview/comp/${compSrc}`}
|
||||||
label={el.id || el.tag}
|
label={el.id || el.tag}
|
||||||
labelColor={style.label}
|
labelColor={style.label}
|
||||||
|
accentColor={style.clip}
|
||||||
|
selector={el.selector}
|
||||||
seekTime={0}
|
seekTime={0}
|
||||||
duration={el.duration}
|
duration={el.duration}
|
||||||
selector={el.selector}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -237,13 +256,20 @@ export function StudioApp() {
|
|||||||
previewUrl={activePreviewUrl}
|
previewUrl={activePreviewUrl}
|
||||||
label={el.id || el.tag}
|
label={el.id || el.tag}
|
||||||
labelColor={style.label}
|
labelColor={style.label}
|
||||||
|
accentColor={style.clip}
|
||||||
|
selector={el.selector}
|
||||||
seekTime={el.start}
|
seekTime={el.start}
|
||||||
duration={el.duration}
|
duration={el.duration}
|
||||||
selector={el.selector}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const htmlPreviewEligible =
|
||||||
|
el.duration > 0 &&
|
||||||
|
effectiveTimelineDuration > 0 &&
|
||||||
|
el.duration < effectiveTimelineDuration * 0.92 &&
|
||||||
|
!/(backdrop|background|overlay|scrim|mask)/i.test(el.id);
|
||||||
|
|
||||||
// Audio clips — waveform visualization
|
// Audio clips — waveform visualization
|
||||||
if (el.tag === "audio") {
|
if (el.tag === "audio") {
|
||||||
const audioUrl = el.src
|
const audioUrl = el.src
|
||||||
@@ -270,24 +296,69 @@ export function StudioApp() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTML scene elements — render from the master preview at the scene's time
|
if (htmlPreviewEligible) {
|
||||||
if (el.tag === "div" && el.duration > 0) {
|
|
||||||
const previewUrl = `/api/projects/${pid}/preview`;
|
|
||||||
return (
|
return (
|
||||||
<CompositionThumbnail
|
<CompositionThumbnail
|
||||||
previewUrl={previewUrl}
|
previewUrl={`/api/projects/${pid}/preview`}
|
||||||
label={el.id || el.tag}
|
label={el.id || el.tag}
|
||||||
labelColor={style.label}
|
labelColor={style.label}
|
||||||
|
accentColor={style.clip}
|
||||||
|
selector={el.selector}
|
||||||
seekTime={el.start}
|
seekTime={el.start}
|
||||||
duration={el.duration}
|
duration={el.duration}
|
||||||
selector={el.selector}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
[compIdToSrc, activePreviewUrl],
|
[compIdToSrc, activePreviewUrl, effectiveTimelineDuration],
|
||||||
|
);
|
||||||
|
const timelineToolbar = (
|
||||||
|
<div className="flex items-center justify-between px-3 py-2 border-b border-neutral-800/40 bg-neutral-950/96">
|
||||||
|
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
|
||||||
|
Timeline
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setZoomMode("fit")}
|
||||||
|
className={`h-7 px-2.5 rounded-md border text-[11px] font-medium transition-colors ${
|
||||||
|
zoomMode === "fit"
|
||||||
|
? "border-studio-accent/30 bg-studio-accent/10 text-studio-accent"
|
||||||
|
: "border-neutral-800 text-neutral-400 hover:border-neutral-700 hover:text-neutral-200"
|
||||||
|
}`}
|
||||||
|
title="Fit timeline to width"
|
||||||
|
>
|
||||||
|
Fit
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setZoomMode("manual");
|
||||||
|
setPixelsPerSecond(Math.max(20, Math.round(pixelsPerSecond * 0.8)));
|
||||||
|
}}
|
||||||
|
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||||
|
title="Zoom out"
|
||||||
|
>
|
||||||
|
-
|
||||||
|
</button>
|
||||||
|
<div className="min-w-[58px] text-center text-[10px] font-medium tabular-nums text-neutral-500">
|
||||||
|
{zoomMode === "fit" ? "Auto" : `${Math.round(pixelsPerSecond)} px/s`}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setZoomMode("manual");
|
||||||
|
setPixelsPerSecond(Math.min(2000, Math.round(pixelsPerSecond * 1.25)));
|
||||||
|
}}
|
||||||
|
className="h-7 w-7 rounded-md border border-neutral-800 text-neutral-400 transition-colors hover:border-neutral-700 hover:text-neutral-200"
|
||||||
|
title="Zoom in"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
const [lintModal, setLintModal] = useState<LintFinding[] | null>(null);
|
||||||
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
|
const [consoleErrors, setConsoleErrors] = useState<LintFinding[] | null>(null);
|
||||||
@@ -381,6 +452,195 @@ export function StudioApp() {
|
|||||||
}, 600);
|
}, 600);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleTimelineElementMove = useCallback(
|
||||||
|
async (element: TimelineElement, updates: Pick<TimelineElement, "start" | "track">) => {
|
||||||
|
const pid = projectIdRef.current;
|
||||||
|
if (!pid) throw new Error("No active project");
|
||||||
|
|
||||||
|
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||||
|
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to read ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { content?: string };
|
||||||
|
const originalContent = data.content;
|
||||||
|
if (typeof originalContent !== "string") {
|
||||||
|
throw new Error(`Missing file contents for ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchTarget = element.domId
|
||||||
|
? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
|
||||||
|
: element.selector
|
||||||
|
? { selector: element.selector, selectorIndex: element.selectorIndex }
|
||||||
|
: null;
|
||||||
|
if (!patchTarget) {
|
||||||
|
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedTargetPath = targetPath || "index.html";
|
||||||
|
const relevantElements = timelineElements
|
||||||
|
.map((timelineElement) =>
|
||||||
|
(timelineElement.key ?? timelineElement.id) === (element.key ?? element.id)
|
||||||
|
? { ...timelineElement, start: updates.start, track: updates.track }
|
||||||
|
: timelineElement,
|
||||||
|
)
|
||||||
|
.filter(
|
||||||
|
(timelineElement) =>
|
||||||
|
(timelineElement.sourceFile || activeCompPath || "index.html") === resolvedTargetPath,
|
||||||
|
);
|
||||||
|
const trackZIndices = buildTrackZIndexMap(
|
||||||
|
relevantElements.map((timelineElement) => timelineElement.track),
|
||||||
|
);
|
||||||
|
|
||||||
|
let patchedContent = applyPatchByTarget(originalContent, patchTarget, {
|
||||||
|
type: "attribute",
|
||||||
|
property: "start",
|
||||||
|
value: formatTimelineAttributeNumber(updates.start),
|
||||||
|
});
|
||||||
|
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||||
|
type: "attribute",
|
||||||
|
property: "track-index",
|
||||||
|
value: String(updates.track),
|
||||||
|
});
|
||||||
|
for (const timelineElement of relevantElements) {
|
||||||
|
const elementTarget = timelineElement.domId
|
||||||
|
? {
|
||||||
|
id: timelineElement.domId,
|
||||||
|
selector: timelineElement.selector,
|
||||||
|
selectorIndex: timelineElement.selectorIndex,
|
||||||
|
}
|
||||||
|
: timelineElement.selector
|
||||||
|
? {
|
||||||
|
selector: timelineElement.selector,
|
||||||
|
selectorIndex: timelineElement.selectorIndex,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
if (!elementTarget) continue;
|
||||||
|
const nextZIndex = trackZIndices.get(timelineElement.track);
|
||||||
|
if (nextZIndex == null) continue;
|
||||||
|
patchedContent = applyPatchByTarget(patchedContent, elementTarget, {
|
||||||
|
type: "inline-style",
|
||||||
|
property: "z-index",
|
||||||
|
value: String(nextZIndex),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patchedContent === originalContent) {
|
||||||
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveResponse = await fetch(
|
||||||
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
body: patchedContent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!saveResponse.ok) {
|
||||||
|
throw new Error(`Failed to save ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingPathRef.current === targetPath) {
|
||||||
|
setEditingFile({ path: targetPath, content: patchedContent });
|
||||||
|
}
|
||||||
|
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
},
|
||||||
|
[activeCompPath, timelineElements],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleTimelineElementResize = useCallback(
|
||||||
|
async (
|
||||||
|
element: TimelineElement,
|
||||||
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||||
|
) => {
|
||||||
|
const pid = projectIdRef.current;
|
||||||
|
if (!pid) throw new Error("No active project");
|
||||||
|
|
||||||
|
const targetPath = element.sourceFile || activeCompPath || "index.html";
|
||||||
|
const response = await fetch(`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Failed to read ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await response.json()) as { content?: string };
|
||||||
|
const originalContent = data.content;
|
||||||
|
if (typeof originalContent !== "string") {
|
||||||
|
throw new Error(`Missing file contents for ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const patchTarget = element.domId
|
||||||
|
? { id: element.domId, selector: element.selector, selectorIndex: element.selectorIndex }
|
||||||
|
: element.selector
|
||||||
|
? { selector: element.selector, selectorIndex: element.selectorIndex }
|
||||||
|
: null;
|
||||||
|
if (!patchTarget) {
|
||||||
|
throw new Error(`Timeline element ${element.id} is missing a patchable target`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackStartAttrName =
|
||||||
|
element.playbackStartAttr === "playback-start" ? "playback-start" : "media-start";
|
||||||
|
const currentPlaybackStartValue =
|
||||||
|
readAttributeByTarget(originalContent, patchTarget, "playback-start") ??
|
||||||
|
readAttributeByTarget(originalContent, patchTarget, "media-start");
|
||||||
|
const currentPlaybackStart =
|
||||||
|
currentPlaybackStartValue != null ? parseFloat(currentPlaybackStartValue) : undefined;
|
||||||
|
const trimDelta = updates.start - element.start;
|
||||||
|
const fallbackPlaybackStart =
|
||||||
|
updates.playbackStart == null &&
|
||||||
|
trimDelta !== 0 &&
|
||||||
|
Number.isFinite(currentPlaybackStart) &&
|
||||||
|
currentPlaybackStart != null
|
||||||
|
? Math.max(0, currentPlaybackStart + trimDelta * Math.max(element.playbackRate ?? 1, 0.1))
|
||||||
|
: undefined;
|
||||||
|
const nextPlaybackStart = updates.playbackStart ?? fallbackPlaybackStart;
|
||||||
|
|
||||||
|
let patchedContent = originalContent;
|
||||||
|
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||||
|
type: "attribute",
|
||||||
|
property: "start",
|
||||||
|
value: formatTimelineAttributeNumber(updates.start),
|
||||||
|
});
|
||||||
|
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||||
|
type: "attribute",
|
||||||
|
property: "duration",
|
||||||
|
value: formatTimelineAttributeNumber(updates.duration),
|
||||||
|
});
|
||||||
|
if (nextPlaybackStart != null) {
|
||||||
|
patchedContent = applyPatchByTarget(patchedContent, patchTarget, {
|
||||||
|
type: "attribute",
|
||||||
|
property: playbackStartAttrName,
|
||||||
|
value: formatTimelineAttributeNumber(nextPlaybackStart),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (patchedContent === originalContent) {
|
||||||
|
throw new Error(`Unable to patch timeline element ${element.id} in ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveResponse = await fetch(
|
||||||
|
`/api/projects/${pid}/files/${encodeURIComponent(targetPath)}`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: { "Content-Type": "text/plain" },
|
||||||
|
body: patchedContent,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (!saveResponse.ok) {
|
||||||
|
throw new Error(`Failed to save ${targetPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editingPathRef.current === targetPath) {
|
||||||
|
setEditingFile({ path: targetPath, content: patchedContent });
|
||||||
|
}
|
||||||
|
|
||||||
|
setRefreshKey((k) => k + 1);
|
||||||
|
},
|
||||||
|
[activeCompPath],
|
||||||
|
);
|
||||||
|
|
||||||
// ── File Management Handlers ──
|
// ── File Management Handlers ──
|
||||||
|
|
||||||
const refreshFileTree = useCallback(async () => {
|
const refreshFileTree = useCallback(async () => {
|
||||||
@@ -783,12 +1043,14 @@ export function StudioApp() {
|
|||||||
{/* Left resize handle */}
|
{/* Left resize handle */}
|
||||||
{!leftCollapsed && (
|
{!leftCollapsed && (
|
||||||
<div
|
<div
|
||||||
className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
|
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
|
||||||
style={{ touchAction: "none" }}
|
style={{ touchAction: "none" }}
|
||||||
onPointerDown={(e) => handlePanelResizeStart("left", e)}
|
onPointerDown={(e) => handlePanelResizeStart("left", e)}
|
||||||
onPointerMove={handlePanelResizeMove}
|
onPointerMove={handlePanelResizeMove}
|
||||||
onPointerUp={handlePanelResizeEnd}
|
onPointerUp={handlePanelResizeEnd}
|
||||||
/>
|
>
|
||||||
|
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Center: Preview */}
|
{/* Center: Preview */}
|
||||||
@@ -797,7 +1059,10 @@ export function StudioApp() {
|
|||||||
projectId={projectId}
|
projectId={projectId}
|
||||||
refreshKey={refreshKey}
|
refreshKey={refreshKey}
|
||||||
activeCompositionPath={activeCompPath}
|
activeCompositionPath={activeCompPath}
|
||||||
|
timelineToolbar={timelineToolbar}
|
||||||
renderClipContent={renderClipContent}
|
renderClipContent={renderClipContent}
|
||||||
|
onMoveElement={handleTimelineElementMove}
|
||||||
|
onResizeElement={handleTimelineElementResize}
|
||||||
onCompIdToSrcChange={setCompIdToSrc}
|
onCompIdToSrcChange={setCompIdToSrc}
|
||||||
onCompositionChange={(compPath) => {
|
onCompositionChange={(compPath) => {
|
||||||
// Sync activeCompPath when user drills down via timeline double-click
|
// Sync activeCompPath when user drills down via timeline double-click
|
||||||
@@ -878,12 +1143,14 @@ export function StudioApp() {
|
|||||||
{!rightCollapsed && (
|
{!rightCollapsed && (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className="w-1 flex-shrink-0 bg-neutral-800 hover:bg-studio-accent cursor-col-resize transition-colors active:bg-studio-accent/80"
|
className="group w-2 flex-shrink-0 cursor-col-resize flex items-center justify-center"
|
||||||
style={{ touchAction: "none" }}
|
style={{ touchAction: "none" }}
|
||||||
onPointerDown={(e) => handlePanelResizeStart("right", e)}
|
onPointerDown={(e) => handlePanelResizeStart("right", e)}
|
||||||
onPointerMove={handlePanelResizeMove}
|
onPointerMove={handlePanelResizeMove}
|
||||||
onPointerUp={handlePanelResizeEnd}
|
onPointerUp={handlePanelResizeEnd}
|
||||||
/>
|
>
|
||||||
|
<div className="h-[52px] w-px bg-white/12 transition-colors group-hover:bg-white/18 group-active:bg-white/24" />
|
||||||
|
</div>
|
||||||
<div
|
<div
|
||||||
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
|
className="flex flex-col border-l border-neutral-800 bg-neutral-900 flex-shrink-0"
|
||||||
style={{ width: rightWidth }}
|
style={{ width: rightWidth }}
|
||||||
|
|||||||
@@ -27,6 +27,15 @@ interface NLELayoutProps {
|
|||||||
element: TimelineElement,
|
element: TimelineElement,
|
||||||
style: { clip: string; label: string },
|
style: { clip: string; label: string },
|
||||||
) => ReactNode;
|
) => ReactNode;
|
||||||
|
/** Persist timeline move actions back into source HTML */
|
||||||
|
onMoveElement?: (
|
||||||
|
element: TimelineElement,
|
||||||
|
updates: Pick<TimelineElement, "start" | "track">,
|
||||||
|
) => Promise<void> | void;
|
||||||
|
onResizeElement?: (
|
||||||
|
element: TimelineElement,
|
||||||
|
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
|
||||||
|
) => Promise<void> | void;
|
||||||
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
|
||||||
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
onCompIdToSrcChange?: (map: Map<string, string>) => void;
|
||||||
/** Whether the timeline panel is visible (default: true) */
|
/** Whether the timeline panel is visible (default: true) */
|
||||||
@@ -50,6 +59,8 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
onIframeRef,
|
onIframeRef,
|
||||||
onCompositionChange,
|
onCompositionChange,
|
||||||
renderClipContent,
|
renderClipContent,
|
||||||
|
onMoveElement,
|
||||||
|
onResizeElement,
|
||||||
onCompIdToSrcChange,
|
onCompIdToSrcChange,
|
||||||
timelineVisible,
|
timelineVisible,
|
||||||
onToggleTimeline,
|
onToggleTimeline,
|
||||||
@@ -379,6 +390,8 @@ export const NLELayout = memo(function NLELayout({
|
|||||||
onSeek={seek}
|
onSeek={seek}
|
||||||
onDrillDown={handleDrillDown}
|
onDrillDown={handleDrillDown}
|
||||||
renderClipContent={renderClipContent}
|
renderClipContent={renderClipContent}
|
||||||
|
onMoveElement={onMoveElement}
|
||||||
|
onResizeElement={onResizeElement}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useState, useCallback, useMemo, useRef } from "react";
|
|||||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||||
import { usePlayerStore } from "../store/playerStore";
|
import { usePlayerStore } from "../store/playerStore";
|
||||||
import { formatTime } from "../lib/time";
|
import { formatTime } from "../lib/time";
|
||||||
|
import { buildPromptCopyText, buildTimelineAgentPrompt } from "./timelineEditing";
|
||||||
|
|
||||||
interface EditPopoverProps {
|
interface EditPopoverProps {
|
||||||
rangeStart: number;
|
rangeStart: number;
|
||||||
@@ -14,7 +15,8 @@ interface EditPopoverProps {
|
|||||||
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
|
export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }: EditPopoverProps) {
|
||||||
const elements = usePlayerStore((s) => s.elements);
|
const elements = usePlayerStore((s) => s.elements);
|
||||||
const [prompt, setPrompt] = useState("");
|
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 popoverRef = useRef<HTMLDivElement>(null);
|
||||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
@@ -51,27 +53,12 @@ export function EditPopover({ rangeStart, rangeEnd, anchorX, anchorY, onClose }:
|
|||||||
});
|
});
|
||||||
|
|
||||||
const buildClipboardText = useCallback(() => {
|
const buildClipboardText = useCallback(() => {
|
||||||
const elementLines = elementsInRange
|
return buildTimelineAgentPrompt({
|
||||||
.map(
|
rangeStart: start,
|
||||||
(el) =>
|
rangeEnd: end,
|
||||||
`- #${el.id} (${el.tag}) — ${formatTime(el.start)} to ${formatTime(el.start + el.duration)}, track ${el.track}`,
|
elements: elementsInRange,
|
||||||
)
|
prompt,
|
||||||
.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.`;
|
|
||||||
}, [start, end, elementsInRange, prompt]);
|
}, [start, end, elementsInRange, prompt]);
|
||||||
|
|
||||||
const handleCopy = useCallback(async () => {
|
const handleCopy = useCallback(async () => {
|
||||||
@@ -85,13 +72,32 @@ Preserve all other elements and timing outside this range.`;
|
|||||||
document.execCommand("copy");
|
document.execCommand("copy");
|
||||||
document.body.removeChild(ta);
|
document.body.removeChild(ta);
|
||||||
}
|
}
|
||||||
setCopied(true);
|
setCopiedAgentPrompt(true);
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
setCopied(false);
|
setCopiedAgentPrompt(false);
|
||||||
onClose();
|
onClose();
|
||||||
}, 800);
|
}, 800);
|
||||||
}, [buildClipboardText, onClose]);
|
}, [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 = {
|
const style: React.CSSProperties = {
|
||||||
position: "fixed",
|
position: "fixed",
|
||||||
left: Math.max(8, Math.min(anchorX - 160, window.innerWidth - 336)),
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Action */}
|
{/* 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
|
<button
|
||||||
onClick={handleCopy}
|
onClick={handleCopy}
|
||||||
className={`w-full py-1.5 text-[11px] font-medium rounded-lg transition-all ${
|
className={`py-1.5 text-[11px] font-medium rounded-lg transition-all ${
|
||||||
copied
|
copiedAgentPrompt
|
||||||
? "bg-green-500/20 text-green-400 border border-green-500/30"
|
? "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"
|
: "bg-studio-accent/15 text-studio-accent border border-studio-accent/25 hover:bg-studio-accent/25"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{copied ? "Copied!" : "Copy to Agent"}
|
{copiedAgentPrompt ? "Copied!" : "Copy to Agent"}
|
||||||
{!copied && <span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>}
|
{!copiedAgentPrompt && (
|
||||||
|
<span className="text-[9px] text-studio-accent/50 ml-1.5">Cmd+Enter</span>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,21 @@
|
|||||||
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
|
import { useRef, useMemo, useCallback, useState, memo, type ReactNode } from "react";
|
||||||
import { usePlayerStore, liveTime } from "../store/playerStore";
|
import { usePlayerStore, liveTime, type TimelineElement } from "../store/playerStore";
|
||||||
import { useMountEffect } from "../../hooks/useMountEffect";
|
import { useMountEffect } from "../../hooks/useMountEffect";
|
||||||
import { formatTime } from "../lib/time";
|
import { formatTime } from "../lib/time";
|
||||||
import { TimelineClip } from "./TimelineClip";
|
import { TimelineClip } from "./TimelineClip";
|
||||||
import { EditPopover } from "./EditModal";
|
import { EditPopover } from "./EditModal";
|
||||||
|
import {
|
||||||
|
resolveTimelineAutoScroll,
|
||||||
|
resolveTimelineMove,
|
||||||
|
resolveTimelineResize,
|
||||||
|
} from "./timelineEditing";
|
||||||
|
import {
|
||||||
|
defaultTimelineTheme,
|
||||||
|
getRenderedTimelineElement,
|
||||||
|
getTimelineTrackStyle,
|
||||||
|
type TimelineTrackStyle,
|
||||||
|
type TimelineTheme,
|
||||||
|
} from "./timelineTheme";
|
||||||
|
|
||||||
/* ── Layout ─────────────────────────────────────────────────────── */
|
/* ── Layout ─────────────────────────────────────────────────────── */
|
||||||
const GUTTER = 32;
|
const GUTTER = 32;
|
||||||
@@ -11,17 +23,7 @@ const TRACK_H = 72;
|
|||||||
const RULER_H = 24;
|
const RULER_H = 24;
|
||||||
const CLIP_Y = 3; // vertical inset inside track
|
const CLIP_Y = 3; // vertical inset inside track
|
||||||
|
|
||||||
/* ── Vibrant Color System (Figma-inspired, dark-mode adapted) ──── */
|
interface TrackVisualStyle extends TimelineTrackStyle {
|
||||||
interface TrackStyle {
|
|
||||||
/** Clip solid background */
|
|
||||||
clip: string;
|
|
||||||
/** Dark text color for label on clip */
|
|
||||||
label: string;
|
|
||||||
/** Track row tint (very subtle) */
|
|
||||||
row: string;
|
|
||||||
/** Gutter icon circle background */
|
|
||||||
gutter: string;
|
|
||||||
/** SVG icon paths (viewBox 0 0 24 24) */
|
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -46,84 +48,29 @@ const IconText = <TimelineIcon src={`${ICON_BASE}/text.svg`} />;
|
|||||||
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
|
const IconComposition = <TimelineIcon src={`${ICON_BASE}/composition.svg`} />;
|
||||||
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
|
const IconAudio = <TimelineIcon src={`${ICON_BASE}/audio.svg`} />;
|
||||||
|
|
||||||
const STYLES: Record<string, TrackStyle> = {
|
const ICONS: Record<string, ReactNode> = {
|
||||||
video: {
|
video: IconImage,
|
||||||
clip: "#1F6AFF",
|
audio: IconMusic,
|
||||||
label: "#DBEAFE",
|
img: IconImage,
|
||||||
row: "rgba(31,106,255,0.04)",
|
div: IconComposition,
|
||||||
gutter: "#1F6AFF",
|
span: IconCaptions,
|
||||||
icon: IconImage,
|
p: IconText,
|
||||||
},
|
h1: IconText,
|
||||||
audio: {
|
section: IconComposition,
|
||||||
clip: "#00C4FF",
|
sfx: IconAudio,
|
||||||
label: "#013A4B",
|
|
||||||
row: "rgba(0,196,255,0.04)",
|
|
||||||
gutter: "#00C4FF",
|
|
||||||
icon: IconMusic,
|
|
||||||
},
|
|
||||||
img: {
|
|
||||||
clip: "#8B5CF6",
|
|
||||||
label: "#EDE9FE",
|
|
||||||
row: "rgba(139,92,246,0.04)",
|
|
||||||
gutter: "#8B5CF6",
|
|
||||||
icon: IconImage,
|
|
||||||
},
|
|
||||||
div: {
|
|
||||||
clip: "#68B200",
|
|
||||||
label: "#1A2B03",
|
|
||||||
row: "rgba(104,178,0,0.04)",
|
|
||||||
gutter: "#68B200",
|
|
||||||
icon: IconComposition,
|
|
||||||
},
|
|
||||||
span: {
|
|
||||||
clip: "#F3A6FF",
|
|
||||||
label: "#8D00A3",
|
|
||||||
row: "rgba(243,166,255,0.04)",
|
|
||||||
gutter: "#F3A6FF",
|
|
||||||
icon: IconCaptions,
|
|
||||||
},
|
|
||||||
p: {
|
|
||||||
clip: "#35C838",
|
|
||||||
label: "#024A03",
|
|
||||||
row: "rgba(53,200,56,0.04)",
|
|
||||||
gutter: "#35C838",
|
|
||||||
icon: IconText,
|
|
||||||
},
|
|
||||||
h1: {
|
|
||||||
clip: "#35C838",
|
|
||||||
label: "#024A03",
|
|
||||||
row: "rgba(53,200,56,0.04)",
|
|
||||||
gutter: "#35C838",
|
|
||||||
icon: IconText,
|
|
||||||
},
|
|
||||||
section: {
|
|
||||||
clip: "#68B200",
|
|
||||||
label: "#1A2B03",
|
|
||||||
row: "rgba(104,178,0,0.04)",
|
|
||||||
gutter: "#68B200",
|
|
||||||
icon: IconComposition,
|
|
||||||
},
|
|
||||||
sfx: {
|
|
||||||
clip: "#FF8C42",
|
|
||||||
label: "#512000",
|
|
||||||
row: "rgba(255,140,66,0.04)",
|
|
||||||
gutter: "#FF8C42",
|
|
||||||
icon: IconAudio,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULT: TrackStyle = {
|
function getStyle(tag: string): TrackVisualStyle {
|
||||||
clip: "#6B7280",
|
const trackStyle = getTimelineTrackStyle(tag);
|
||||||
label: "#F3F4F6",
|
const normalized = tag.toLowerCase();
|
||||||
row: "rgba(107,114,128,0.03)",
|
const icon =
|
||||||
gutter: "#6B7280",
|
normalized.startsWith("h") && normalized.length === 2 && "123456".includes(normalized[1] ?? "")
|
||||||
icon: IconComposition,
|
? ICONS.h1
|
||||||
};
|
: (ICONS[normalized] ?? IconComposition);
|
||||||
|
return {
|
||||||
function getStyle(tag: string): TrackStyle {
|
...trackStyle,
|
||||||
const t = tag.toLowerCase();
|
icon,
|
||||||
if (t.startsWith("h") && t.length === 2 && "123456".includes(t[1])) return STYLES.h1;
|
};
|
||||||
return STYLES[t] ?? DEFAULT;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Tick Generation ────────────────────────────────────────────── */
|
/* ── Tick Generation ────────────────────────────────────────────── */
|
||||||
@@ -167,6 +114,44 @@ interface TimelineProps {
|
|||||||
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
renderClipOverlay?: (element: import("../store/playerStore").TimelineElement) => ReactNode;
|
||||||
/** Called when files are dropped onto the empty timeline */
|
/** Called when files are dropped onto the empty timeline */
|
||||||
onFileDrop?: (files: File[]) => void;
|
onFileDrop?: (files: File[]) => void;
|
||||||
|
/** Persist a clip move back into source HTML */
|
||||||
|
onMoveElement?: (
|
||||||
|
element: import("../store/playerStore").TimelineElement,
|
||||||
|
updates: Pick<import("../store/playerStore").TimelineElement, "start" | "track">,
|
||||||
|
) => Promise<void> | void;
|
||||||
|
onResizeElement?: (
|
||||||
|
element: import("../store/playerStore").TimelineElement,
|
||||||
|
updates: Pick<
|
||||||
|
import("../store/playerStore").TimelineElement,
|
||||||
|
"start" | "duration" | "playbackStart"
|
||||||
|
>,
|
||||||
|
) => Promise<void> | void;
|
||||||
|
theme?: Partial<TimelineTheme>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface DraggedClipState {
|
||||||
|
element: TimelineElement;
|
||||||
|
originClientX: number;
|
||||||
|
originClientY: number;
|
||||||
|
originScrollLeft: number;
|
||||||
|
originScrollTop: number;
|
||||||
|
pointerClientX: number;
|
||||||
|
pointerClientY: number;
|
||||||
|
pointerOffsetX: number;
|
||||||
|
pointerOffsetY: number;
|
||||||
|
previewStart: number;
|
||||||
|
previewTrack: number;
|
||||||
|
started: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ResizingClipState {
|
||||||
|
element: TimelineElement;
|
||||||
|
edge: "start" | "end";
|
||||||
|
originClientX: number;
|
||||||
|
previewStart: number;
|
||||||
|
previewDuration: number;
|
||||||
|
previewPlaybackStart?: number;
|
||||||
|
started: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Timeline = memo(function Timeline({
|
export const Timeline = memo(function Timeline({
|
||||||
@@ -175,12 +160,17 @@ export const Timeline = memo(function Timeline({
|
|||||||
renderClipContent,
|
renderClipContent,
|
||||||
renderClipOverlay,
|
renderClipOverlay,
|
||||||
onFileDrop,
|
onFileDrop,
|
||||||
|
onMoveElement,
|
||||||
|
onResizeElement,
|
||||||
|
theme: themeOverrides,
|
||||||
}: TimelineProps = {}) {
|
}: TimelineProps = {}) {
|
||||||
|
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
|
||||||
const elements = usePlayerStore((s) => s.elements);
|
const elements = usePlayerStore((s) => s.elements);
|
||||||
const duration = usePlayerStore((s) => s.duration);
|
const duration = usePlayerStore((s) => s.duration);
|
||||||
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
const timelineReady = usePlayerStore((s) => s.timelineReady);
|
||||||
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
|
||||||
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
const setSelectedElementId = usePlayerStore((s) => s.setSelectedElementId);
|
||||||
|
const updateElement = usePlayerStore((s) => s.updateElement);
|
||||||
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
const zoomMode = usePlayerStore((s) => s.zoomMode);
|
||||||
const manualPps = usePlayerStore((s) => s.pixelsPerSecond);
|
const manualPps = usePlayerStore((s) => s.pixelsPerSecond);
|
||||||
const playheadRef = useRef<HTMLDivElement>(null);
|
const playheadRef = useRef<HTMLDivElement>(null);
|
||||||
@@ -211,6 +201,17 @@ export const Timeline = memo(function Timeline({
|
|||||||
anchorX: number;
|
anchorX: number;
|
||||||
anchorY: number;
|
anchorY: number;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
|
const [draggedClip, setDraggedClip] = useState<DraggedClipState | null>(null);
|
||||||
|
const draggedClipRef = useRef<DraggedClipState | null>(null);
|
||||||
|
draggedClipRef.current = draggedClip;
|
||||||
|
const [resizingClip, setResizingClip] = useState<ResizingClipState | null>(null);
|
||||||
|
const resizingClipRef = useRef<ResizingClipState | null>(null);
|
||||||
|
resizingClipRef.current = resizingClip;
|
||||||
|
const onMoveElementRef = useRef(onMoveElement);
|
||||||
|
onMoveElementRef.current = onMoveElement;
|
||||||
|
const onResizeElementRef = useRef(onResizeElement);
|
||||||
|
onResizeElementRef.current = onResizeElement;
|
||||||
|
const suppressClickRef = useRef(false);
|
||||||
const [showPopover, setShowPopover] = useState(false);
|
const [showPopover, setShowPopover] = useState(false);
|
||||||
const [viewportWidth, setViewportWidth] = useState(0);
|
const [viewportWidth, setViewportWidth] = useState(0);
|
||||||
const roRef = useRef<ResizeObserver | null>(null);
|
const roRef = useRef<ResizeObserver | null>(null);
|
||||||
@@ -249,6 +250,38 @@ export const Timeline = memo(function Timeline({
|
|||||||
return Number.isFinite(result) ? result : safeDur;
|
return Number.isFinite(result) ? result : safeDur;
|
||||||
}, [elements, duration]);
|
}, [elements, duration]);
|
||||||
|
|
||||||
|
const tracks = useMemo(() => {
|
||||||
|
const map = new Map<number, typeof elements>();
|
||||||
|
for (const el of elements) {
|
||||||
|
const list = map.get(el.track) ?? [];
|
||||||
|
list.push(el);
|
||||||
|
map.set(el.track, list);
|
||||||
|
}
|
||||||
|
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
||||||
|
}, [elements]);
|
||||||
|
|
||||||
|
const trackStyles = useMemo(() => {
|
||||||
|
const map = new Map<number, TrackVisualStyle>();
|
||||||
|
for (const [trackNum, els] of tracks) {
|
||||||
|
map.set(trackNum, getStyle(els[0]?.tag ?? ""));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [tracks]);
|
||||||
|
|
||||||
|
const trackOrder = useMemo(() => tracks.map(([trackNum]) => trackNum), [tracks]);
|
||||||
|
const trackOrderRef = useRef(trackOrder);
|
||||||
|
trackOrderRef.current = trackOrder;
|
||||||
|
const displayTrackOrder = useMemo(() => {
|
||||||
|
if (
|
||||||
|
!draggedClip?.started ||
|
||||||
|
trackOrder.length === 0 ||
|
||||||
|
trackOrder.includes(draggedClip.previewTrack)
|
||||||
|
) {
|
||||||
|
return trackOrder;
|
||||||
|
}
|
||||||
|
return [...trackOrder, draggedClip.previewTrack].sort((a, b) => a - b);
|
||||||
|
}, [draggedClip, trackOrder]);
|
||||||
|
|
||||||
// Calculate effective pixels per second
|
// Calculate effective pixels per second
|
||||||
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
|
// In fit mode, use clientWidth (excludes scrollbar) with a small padding
|
||||||
const fitPps =
|
const fitPps =
|
||||||
@@ -290,6 +323,106 @@ export const Timeline = memo(function Timeline({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const dragScrollRaf = useRef(0);
|
const dragScrollRaf = useRef(0);
|
||||||
|
const clipDragScrollRaf = useRef(0);
|
||||||
|
const clipDragPointerRef = useRef<{ clientX: number; clientY: number } | null>(null);
|
||||||
|
|
||||||
|
const updateDraggedClipPreview = useCallback(
|
||||||
|
(drag: DraggedClipState, clientX: number, clientY: number) => {
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
const nextMove = resolveTimelineMove(
|
||||||
|
{
|
||||||
|
start: drag.element.start,
|
||||||
|
track: drag.element.track,
|
||||||
|
duration: drag.element.duration,
|
||||||
|
originClientX: drag.originClientX,
|
||||||
|
originClientY: drag.originClientY,
|
||||||
|
originScrollLeft: drag.originScrollLeft,
|
||||||
|
originScrollTop: drag.originScrollTop,
|
||||||
|
currentScrollLeft: scroll?.scrollLeft ?? drag.originScrollLeft,
|
||||||
|
currentScrollTop: scroll?.scrollTop ?? drag.originScrollTop,
|
||||||
|
pixelsPerSecond: ppsRef.current,
|
||||||
|
trackHeight: TRACK_H,
|
||||||
|
maxStart: Math.max(0, durationRef.current - drag.element.duration),
|
||||||
|
trackOrder: trackOrderRef.current,
|
||||||
|
},
|
||||||
|
clientX,
|
||||||
|
clientY,
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...drag,
|
||||||
|
started: true,
|
||||||
|
pointerClientX: clientX,
|
||||||
|
pointerClientY: clientY,
|
||||||
|
previewStart: nextMove.start,
|
||||||
|
previewTrack: nextMove.track,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const stopClipDragAutoScroll = useCallback(() => {
|
||||||
|
clipDragPointerRef.current = null;
|
||||||
|
if (clipDragScrollRaf.current) {
|
||||||
|
cancelAnimationFrame(clipDragScrollRaf.current);
|
||||||
|
clipDragScrollRaf.current = 0;
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const stepClipDragAutoScroll = useCallback(() => {
|
||||||
|
clipDragScrollRaf.current = 0;
|
||||||
|
const drag = draggedClipRef.current;
|
||||||
|
const pointer = clipDragPointerRef.current;
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!drag || !pointer || !scroll) return;
|
||||||
|
|
||||||
|
const rect = scroll.getBoundingClientRect();
|
||||||
|
const delta = resolveTimelineAutoScroll(rect, pointer.clientX, pointer.clientY);
|
||||||
|
if (delta.x === 0 && delta.y === 0) return;
|
||||||
|
|
||||||
|
const maxScrollLeft = Math.max(0, scroll.scrollWidth - scroll.clientWidth);
|
||||||
|
const maxScrollTop = Math.max(0, scroll.scrollHeight - scroll.clientHeight);
|
||||||
|
const nextScrollLeft = Math.max(0, Math.min(maxScrollLeft, scroll.scrollLeft + delta.x));
|
||||||
|
const nextScrollTop = Math.max(0, Math.min(maxScrollTop, scroll.scrollTop + delta.y));
|
||||||
|
const didScroll = nextScrollLeft !== scroll.scrollLeft || nextScrollTop !== scroll.scrollTop;
|
||||||
|
|
||||||
|
if (!didScroll) return;
|
||||||
|
|
||||||
|
scroll.scrollLeft = nextScrollLeft;
|
||||||
|
scroll.scrollTop = nextScrollTop;
|
||||||
|
setDraggedClip((prev) =>
|
||||||
|
prev ? updateDraggedClipPreview(prev, pointer.clientX, pointer.clientY) : prev,
|
||||||
|
);
|
||||||
|
|
||||||
|
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
|
||||||
|
}, [updateDraggedClipPreview]);
|
||||||
|
|
||||||
|
const syncClipDragAutoScroll = useCallback(
|
||||||
|
(clientX: number, clientY: number) => {
|
||||||
|
clipDragPointerRef.current = { clientX, clientY };
|
||||||
|
const scroll = scrollRef.current;
|
||||||
|
if (!scroll) return;
|
||||||
|
const rect = scroll.getBoundingClientRect();
|
||||||
|
const delta = resolveTimelineAutoScroll(rect, clientX, clientY);
|
||||||
|
if (delta.x === 0 && delta.y === 0) {
|
||||||
|
if (clipDragScrollRaf.current) {
|
||||||
|
cancelAnimationFrame(clipDragScrollRaf.current);
|
||||||
|
clipDragScrollRaf.current = 0;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!clipDragScrollRaf.current) {
|
||||||
|
clipDragScrollRaf.current = requestAnimationFrame(stepClipDragAutoScroll);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[stepClipDragAutoScroll],
|
||||||
|
);
|
||||||
|
const updateDraggedClipPreviewRef = useRef(updateDraggedClipPreview);
|
||||||
|
updateDraggedClipPreviewRef.current = updateDraggedClipPreview;
|
||||||
|
const syncClipDragAutoScrollRef = useRef(syncClipDragAutoScroll);
|
||||||
|
syncClipDragAutoScrollRef.current = syncClipDragAutoScroll;
|
||||||
|
const stopClipDragAutoScrollRef = useRef(stopClipDragAutoScroll);
|
||||||
|
stopClipDragAutoScrollRef.current = stopClipDragAutoScroll;
|
||||||
|
|
||||||
const seekFromX = useCallback(
|
const seekFromX = useCallback(
|
||||||
(clientX: number) => {
|
(clientX: number) => {
|
||||||
@@ -336,6 +469,158 @@ export const Timeline = memo(function Timeline({
|
|||||||
[seekFromX],
|
[seekFromX],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useMountEffect(() => {
|
||||||
|
const clearSuppressedClick = () => {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
suppressClickRef.current = false;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowPointerMove = (e: PointerEvent) => {
|
||||||
|
const drag = draggedClipRef.current;
|
||||||
|
const resize = resizingClipRef.current;
|
||||||
|
if (resize) {
|
||||||
|
const distance = Math.abs(e.clientX - resize.originClientX);
|
||||||
|
if (!resize.started && distance < 2) return;
|
||||||
|
|
||||||
|
setShowPopover(false);
|
||||||
|
setRangeSelection(null);
|
||||||
|
|
||||||
|
const sourceRemaining =
|
||||||
|
resize.element.sourceDuration != null
|
||||||
|
? Math.max(
|
||||||
|
0,
|
||||||
|
(resize.element.sourceDuration - (resize.element.playbackStart ?? 0)) /
|
||||||
|
Math.max(resize.element.playbackRate ?? 1, 0.1),
|
||||||
|
)
|
||||||
|
: Number.POSITIVE_INFINITY;
|
||||||
|
const nextResize = resolveTimelineResize(
|
||||||
|
{
|
||||||
|
start: resize.element.start,
|
||||||
|
duration: resize.element.duration,
|
||||||
|
originClientX: resize.originClientX,
|
||||||
|
pixelsPerSecond: ppsRef.current,
|
||||||
|
minStart: 0,
|
||||||
|
maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
|
||||||
|
playbackStart: resize.element.playbackStart,
|
||||||
|
playbackRate: resize.element.playbackRate,
|
||||||
|
},
|
||||||
|
resize.edge,
|
||||||
|
e.clientX,
|
||||||
|
);
|
||||||
|
|
||||||
|
setResizingClip((prev) =>
|
||||||
|
prev
|
||||||
|
? {
|
||||||
|
...prev,
|
||||||
|
started: true,
|
||||||
|
previewStart: nextResize.start,
|
||||||
|
previewDuration: nextResize.duration,
|
||||||
|
previewPlaybackStart: nextResize.playbackStart,
|
||||||
|
}
|
||||||
|
: prev,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!drag) return;
|
||||||
|
|
||||||
|
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
|
||||||
|
if (!drag.started && distance < 4) return;
|
||||||
|
|
||||||
|
setShowPopover(false);
|
||||||
|
setRangeSelection(null);
|
||||||
|
|
||||||
|
setDraggedClip((prev) =>
|
||||||
|
prev ? updateDraggedClipPreviewRef.current(prev, e.clientX, e.clientY) : prev,
|
||||||
|
);
|
||||||
|
syncClipDragAutoScrollRef.current(e.clientX, e.clientY);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleWindowPointerUp = () => {
|
||||||
|
stopClipDragAutoScrollRef.current();
|
||||||
|
const resize = resizingClipRef.current;
|
||||||
|
if (resize) {
|
||||||
|
resizingClipRef.current = null;
|
||||||
|
setResizingClip(null);
|
||||||
|
|
||||||
|
if (!resize.started) return;
|
||||||
|
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
clearSuppressedClick();
|
||||||
|
|
||||||
|
const hasChanged =
|
||||||
|
resize.previewStart !== resize.element.start ||
|
||||||
|
resize.previewDuration !== resize.element.duration ||
|
||||||
|
resize.previewPlaybackStart !== resize.element.playbackStart;
|
||||||
|
if (!hasChanged) return;
|
||||||
|
|
||||||
|
updateElement(resize.element.key ?? resize.element.id, {
|
||||||
|
start: resize.previewStart,
|
||||||
|
duration: resize.previewDuration,
|
||||||
|
playbackStart: resize.previewPlaybackStart,
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.resolve(
|
||||||
|
onResizeElementRef.current?.(resize.element, {
|
||||||
|
start: resize.previewStart,
|
||||||
|
duration: resize.previewDuration,
|
||||||
|
playbackStart: resize.previewPlaybackStart,
|
||||||
|
}),
|
||||||
|
).catch((error) => {
|
||||||
|
updateElement(resize.element.key ?? resize.element.id, {
|
||||||
|
start: resize.element.start,
|
||||||
|
duration: resize.element.duration,
|
||||||
|
playbackStart: resize.element.playbackStart,
|
||||||
|
});
|
||||||
|
console.error("[Timeline] Failed to persist clip resize", error);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const drag = draggedClipRef.current;
|
||||||
|
if (!drag) return;
|
||||||
|
draggedClipRef.current = null;
|
||||||
|
setDraggedClip(null);
|
||||||
|
|
||||||
|
if (!drag.started) return;
|
||||||
|
|
||||||
|
suppressClickRef.current = true;
|
||||||
|
clearSuppressedClick();
|
||||||
|
|
||||||
|
const hasChanged =
|
||||||
|
drag.previewStart !== drag.element.start || drag.previewTrack !== drag.element.track;
|
||||||
|
if (!hasChanged) return;
|
||||||
|
|
||||||
|
updateElement(drag.element.key ?? drag.element.id, {
|
||||||
|
start: drag.previewStart,
|
||||||
|
track: drag.previewTrack,
|
||||||
|
});
|
||||||
|
|
||||||
|
Promise.resolve(
|
||||||
|
onMoveElementRef.current?.(drag.element, {
|
||||||
|
start: drag.previewStart,
|
||||||
|
track: drag.previewTrack,
|
||||||
|
}),
|
||||||
|
).catch((error) => {
|
||||||
|
updateElement(drag.element.key ?? drag.element.id, {
|
||||||
|
start: drag.element.start,
|
||||||
|
track: drag.element.track,
|
||||||
|
});
|
||||||
|
console.error("[Timeline] Failed to persist clip move", error);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("pointermove", handleWindowPointerMove);
|
||||||
|
window.addEventListener("pointerup", handleWindowPointerUp);
|
||||||
|
window.addEventListener("pointercancel", handleWindowPointerUp);
|
||||||
|
return () => {
|
||||||
|
stopClipDragAutoScrollRef.current();
|
||||||
|
window.removeEventListener("pointermove", handleWindowPointerMove);
|
||||||
|
window.removeEventListener("pointerup", handleWindowPointerUp);
|
||||||
|
window.removeEventListener("pointercancel", handleWindowPointerUp);
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
const handlePointerDown = useCallback(
|
const handlePointerDown = useCallback(
|
||||||
(e: React.PointerEvent) => {
|
(e: React.PointerEvent) => {
|
||||||
if (e.button !== 0) return;
|
if (e.button !== 0) return;
|
||||||
@@ -402,26 +687,21 @@ export const Timeline = memo(function Timeline({
|
|||||||
cancelAnimationFrame(dragScrollRaf.current);
|
cancelAnimationFrame(dragScrollRaf.current);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const tracks = useMemo(() => {
|
|
||||||
const map = new Map<number, typeof elements>();
|
|
||||||
for (const el of elements) {
|
|
||||||
const list = map.get(el.track) ?? [];
|
|
||||||
list.push(el);
|
|
||||||
map.set(el.track, list);
|
|
||||||
}
|
|
||||||
return Array.from(map.entries()).sort(([a], [b]) => a - b);
|
|
||||||
}, [elements]);
|
|
||||||
|
|
||||||
// Determine dominant style per track (from first element)
|
|
||||||
const trackStyles = useMemo(() => {
|
|
||||||
const map = new Map<number, TrackStyle>();
|
|
||||||
for (const [trackNum, els] of tracks) {
|
|
||||||
map.set(trackNum, getStyle(els[0]?.tag ?? ""));
|
|
||||||
}
|
|
||||||
return map;
|
|
||||||
}, [tracks]);
|
|
||||||
|
|
||||||
const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
|
const { major, minor } = useMemo(() => generateTicks(effectiveDuration), [effectiveDuration]);
|
||||||
|
const getPreviewElement = useCallback(
|
||||||
|
(element: TimelineElement): TimelineElement => {
|
||||||
|
if (resizingClip?.element.id === element.id) {
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
start: resizingClip.previewStart,
|
||||||
|
duration: resizingClip.previewDuration,
|
||||||
|
playbackStart: resizingClip.previewPlaybackStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return element;
|
||||||
|
},
|
||||||
|
[resizingClip],
|
||||||
|
);
|
||||||
|
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
|
|
||||||
@@ -522,14 +802,92 @@ export const Timeline = memo(function Timeline({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalH = RULER_H + tracks.length * TRACK_H;
|
const totalH = RULER_H + displayTrackOrder.length * TRACK_H;
|
||||||
|
const draggedElement = draggedClip?.element ?? null;
|
||||||
|
const activeDraggedElement =
|
||||||
|
draggedClip?.started === true && draggedElement
|
||||||
|
? getRenderedTimelineElement({
|
||||||
|
element: draggedElement,
|
||||||
|
draggedElementId: draggedElement.id,
|
||||||
|
previewStart: draggedClip.previewStart,
|
||||||
|
previewTrack: draggedClip.previewTrack,
|
||||||
|
})
|
||||||
|
: null;
|
||||||
|
const activeDraggedPosition =
|
||||||
|
draggedClip?.started === true && activeDraggedElement && scrollRef.current
|
||||||
|
? {
|
||||||
|
left:
|
||||||
|
draggedClip.pointerClientX -
|
||||||
|
scrollRef.current.getBoundingClientRect().left +
|
||||||
|
scrollRef.current.scrollLeft -
|
||||||
|
draggedClip.pointerOffsetX,
|
||||||
|
top:
|
||||||
|
draggedClip.pointerClientY -
|
||||||
|
scrollRef.current.getBoundingClientRect().top +
|
||||||
|
scrollRef.current.scrollTop -
|
||||||
|
draggedClip.pointerOffsetY,
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{renderClipOverlay?.(element)}
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
renderClipContent
|
||||||
|
? "absolute inset-0 overflow-hidden"
|
||||||
|
: "flex flex-col justify-center overflow-hidden flex-1 min-w-0 px-6"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{renderClipContent?.(element, clipStyle) ?? (
|
||||||
|
<div className="flex h-full min-h-0 flex-col justify-between py-3">
|
||||||
|
<div className="flex items-start">
|
||||||
|
<span
|
||||||
|
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em] leading-none"
|
||||||
|
style={{
|
||||||
|
color: clipStyle.label,
|
||||||
|
background: `${clipStyle.accent}26`,
|
||||||
|
boxShadow: `inset 0 0 0 1px ${clipStyle.accent}33`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{element.tag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className="text-[14px] font-semibold truncate leading-none tracking-[-0.02em]"
|
||||||
|
style={{ color: theme.textPrimary }}
|
||||||
|
>
|
||||||
|
{element.id || element.tag}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<span
|
||||||
|
className="max-w-full truncate rounded-md px-1.5 py-0.5 text-[10px] font-medium tabular-nums leading-none"
|
||||||
|
style={{
|
||||||
|
color: theme.textSecondary,
|
||||||
|
background: "rgba(255,255,255,0.04)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{formatTime(element.start)} {"\u2192"}{" "}
|
||||||
|
{formatTime(element.start + element.duration)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={setContainerRef}
|
ref={setContainerRef}
|
||||||
aria-label="Timeline"
|
aria-label="Timeline"
|
||||||
className={`border-t border-neutral-800/50 bg-[#0a0a0b] select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
className={`border-t select-none h-full overflow-hidden ${shiftHeld ? "cursor-crosshair" : "cursor-default"}`}
|
||||||
style={{ touchAction: "pan-x pan-y" }}
|
style={{
|
||||||
|
touchAction: "pan-x pan-y",
|
||||||
|
background: theme.shellBackground,
|
||||||
|
borderColor: theme.shellBorder,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
@@ -555,7 +913,7 @@ export const Timeline = memo(function Timeline({
|
|||||||
y1={RULER_H}
|
y1={RULER_H}
|
||||||
x2={x}
|
x2={x}
|
||||||
y2={totalH}
|
y2={totalH}
|
||||||
stroke="rgba(255,255,255,0.035)"
|
stroke={theme.tickMinor}
|
||||||
strokeWidth="1"
|
strokeWidth="1"
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -564,20 +922,20 @@ export const Timeline = memo(function Timeline({
|
|||||||
|
|
||||||
{/* Ruler */}
|
{/* Ruler */}
|
||||||
<div
|
<div
|
||||||
className="relative border-b border-neutral-800/40 overflow-hidden"
|
className="relative overflow-hidden"
|
||||||
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
|
style={{ height: RULER_H, marginLeft: GUTTER, width: trackContentWidth }}
|
||||||
>
|
>
|
||||||
{/* Shift hint */}
|
{/* Shift hint */}
|
||||||
{shiftHeld && !rangeSelection && (
|
{shiftHeld && !rangeSelection && (
|
||||||
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
|
||||||
<span className="text-[9px] text-studio-accent/60 font-medium">
|
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
|
||||||
Drag to select range
|
Drag to select range
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{minor.map((t) => (
|
{minor.map((t) => (
|
||||||
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
|
<div key={`m-${t}`} className="absolute bottom-0" style={{ left: t * pps }}>
|
||||||
<div className="w-px h-[3px] bg-neutral-700/40" />
|
<div className="w-px h-[3px]" style={{ background: theme.tickMinor }} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{major.map((t) => (
|
{major.map((t) => (
|
||||||
@@ -586,36 +944,49 @@ export const Timeline = memo(function Timeline({
|
|||||||
className="absolute bottom-0 flex flex-col items-center"
|
className="absolute bottom-0 flex flex-col items-center"
|
||||||
style={{ left: t * pps }}
|
style={{ left: t * pps }}
|
||||||
>
|
>
|
||||||
<span className="text-[9px] text-neutral-500 font-mono tabular-nums leading-none mb-0.5">
|
<span
|
||||||
|
className="text-[9px] font-mono tabular-nums leading-none mb-0.5"
|
||||||
|
style={{ color: theme.tickText }}
|
||||||
|
>
|
||||||
{formatTime(t)}
|
{formatTime(t)}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-px h-[5px] bg-neutral-600/60" />
|
<div className="w-px h-[5px]" style={{ background: theme.tickMajor }} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tracks */}
|
{/* Tracks */}
|
||||||
{tracks.map(([trackNum, els]) => {
|
{displayTrackOrder.map((trackNum) => {
|
||||||
const ts = trackStyles.get(trackNum) ?? DEFAULT;
|
const els = tracks.find(([currentTrack]) => currentTrack === trackNum)?.[1] ?? [];
|
||||||
|
const ts = trackStyles.get(trackNum) ?? getStyle("");
|
||||||
|
const isPendingTrack =
|
||||||
|
draggedClip?.started === true && !trackOrder.includes(trackNum) && els.length === 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={trackNum}
|
key={trackNum}
|
||||||
className="relative flex"
|
className="relative flex"
|
||||||
style={{ height: TRACK_H, backgroundColor: ts.row }}
|
style={{
|
||||||
|
height: TRACK_H,
|
||||||
|
background: theme.rowBackground,
|
||||||
|
borderBottom: `1px solid ${theme.rowBorder}`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{/* Gutter: colored icon badge (Figma Motion Cut style) */}
|
|
||||||
<div
|
<div
|
||||||
className="flex-shrink-0 flex items-center justify-center"
|
className="flex-shrink-0 flex items-center justify-center"
|
||||||
style={{ width: GUTTER }}
|
style={{
|
||||||
|
width: GUTTER,
|
||||||
|
background: theme.gutterBackground,
|
||||||
|
borderRight: `1px solid ${theme.gutterBorder}`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-center"
|
className="flex items-center justify-center"
|
||||||
style={{
|
style={{
|
||||||
width: 20,
|
width: 18,
|
||||||
height: 20,
|
height: 18,
|
||||||
borderRadius: 6,
|
borderRadius: 6,
|
||||||
backgroundColor: ts.gutter,
|
backgroundColor: ts.iconBackground,
|
||||||
border: "1px solid rgba(255,255,255,0.35)",
|
border: `1px solid ${theme.gutterBorder}`,
|
||||||
color: "#fff",
|
color: "#fff",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -625,64 +996,98 @@ export const Timeline = memo(function Timeline({
|
|||||||
|
|
||||||
{/* Clips */}
|
{/* Clips */}
|
||||||
<div style={{ width: trackContentWidth }} className="relative">
|
<div style={{ width: trackContentWidth }} className="relative">
|
||||||
|
{isPendingTrack && (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center"
|
||||||
|
style={{
|
||||||
|
paddingLeft: 16,
|
||||||
|
color: ts.label,
|
||||||
|
fontSize: 11,
|
||||||
|
letterSpacing: "0.08em",
|
||||||
|
textTransform: "uppercase",
|
||||||
|
background: `linear-gradient(90deg, ${ts.accent}14, transparent 28%)`,
|
||||||
|
boxShadow: `inset 0 0 0 1px ${ts.accent}24`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
New track
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{els.map((el, i) => {
|
{els.map((el, i) => {
|
||||||
const clipStyle = getStyle(el.tag);
|
const clipStyle = getStyle(el.tag);
|
||||||
const isSelected = selectedElementId === el.id;
|
const elementKey = el.key ?? el.id;
|
||||||
|
const isSelected = selectedElementId === elementKey;
|
||||||
const isComposition = !!el.compositionSrc;
|
const isComposition = !!el.compositionSrc;
|
||||||
const clipKey = `${el.id}-${i}`;
|
const clipKey = `${elementKey}-${i}`;
|
||||||
const isHovered = hoveredClip === clipKey;
|
const isHovered = hoveredClip === clipKey;
|
||||||
const hasCustomContent = !!renderClipContent;
|
const hasCustomContent = !!renderClipContent;
|
||||||
const clipWidthPx = Math.max(el.duration * pps, 4);
|
const isDragging =
|
||||||
|
draggedClip?.started === true &&
|
||||||
|
(draggedElement?.key ?? draggedElement?.id) === elementKey;
|
||||||
|
if (isDragging) return null;
|
||||||
|
const previewElement = getPreviewElement(el);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TimelineClip
|
<TimelineClip
|
||||||
key={clipKey}
|
key={clipKey}
|
||||||
el={el}
|
el={previewElement}
|
||||||
pps={pps}
|
pps={pps}
|
||||||
clipY={CLIP_Y}
|
clipY={CLIP_Y}
|
||||||
isSelected={isSelected}
|
isSelected={isSelected}
|
||||||
isHovered={isHovered}
|
isHovered={isHovered}
|
||||||
|
isDragging={false}
|
||||||
hasCustomContent={hasCustomContent}
|
hasCustomContent={hasCustomContent}
|
||||||
style={clipStyle}
|
theme={theme}
|
||||||
|
trackStyle={clipStyle}
|
||||||
isComposition={isComposition}
|
isComposition={isComposition}
|
||||||
onHoverStart={() => setHoveredClip(clipKey)}
|
onHoverStart={() => setHoveredClip(clipKey)}
|
||||||
onHoverEnd={() => setHoveredClip(null)}
|
onHoverEnd={() => setHoveredClip(null)}
|
||||||
|
onResizeStart={(edge, e) => {
|
||||||
|
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
|
||||||
|
e.stopPropagation();
|
||||||
|
setShowPopover(false);
|
||||||
|
setRangeSelection(null);
|
||||||
|
setResizingClip({
|
||||||
|
element: el,
|
||||||
|
edge,
|
||||||
|
originClientX: e.clientX,
|
||||||
|
previewStart: el.start,
|
||||||
|
previewDuration: el.duration,
|
||||||
|
previewPlaybackStart: el.playbackStart,
|
||||||
|
started: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
onPointerDown={(e) => {
|
||||||
|
if (e.button !== 0 || e.shiftKey || !onMoveElement) return;
|
||||||
|
setShowPopover(false);
|
||||||
|
setRangeSelection(null);
|
||||||
|
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
|
||||||
|
setDraggedClip({
|
||||||
|
element: el,
|
||||||
|
originClientX: e.clientX,
|
||||||
|
originClientY: e.clientY,
|
||||||
|
originScrollLeft: scrollRef.current?.scrollLeft ?? 0,
|
||||||
|
originScrollTop: scrollRef.current?.scrollTop ?? 0,
|
||||||
|
pointerClientX: e.clientX,
|
||||||
|
pointerClientY: e.clientY,
|
||||||
|
pointerOffsetX: e.clientX - rect.left,
|
||||||
|
pointerOffsetY: e.clientY - rect.top,
|
||||||
|
previewStart: el.start,
|
||||||
|
previewTrack: el.track,
|
||||||
|
started: false,
|
||||||
|
});
|
||||||
|
}}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
setSelectedElementId(isSelected ? null : el.id);
|
if (suppressClickRef.current) return;
|
||||||
|
setSelectedElementId(isSelected ? null : elementKey);
|
||||||
}}
|
}}
|
||||||
onDoubleClick={(e) => {
|
onDoubleClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
if (suppressClickRef.current) return;
|
||||||
if (isComposition && onDrillDown) onDrillDown(el);
|
if (isComposition && onDrillDown) onDrillDown(el);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{renderClipOverlay?.(el)}
|
{renderClipChildren(previewElement, clipStyle)}
|
||||||
<div
|
|
||||||
className={
|
|
||||||
renderClipContent
|
|
||||||
? "absolute inset-0 overflow-hidden rounded-[4px]"
|
|
||||||
: "flex items-center overflow-hidden flex-1 min-w-0"
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{renderClipContent?.(el, clipStyle) ?? (
|
|
||||||
<>
|
|
||||||
<span
|
|
||||||
className="text-[10px] font-semibold truncate px-1.5 leading-none"
|
|
||||||
style={{ color: clipStyle.label }}
|
|
||||||
>
|
|
||||||
{el.id || el.tag}
|
|
||||||
</span>
|
|
||||||
{clipWidthPx > 60 && (
|
|
||||||
<span
|
|
||||||
className="text-[9px] font-mono tabular-nums pr-1.5 ml-auto flex-shrink-0 leading-none opacity-70"
|
|
||||||
style={{ color: clipStyle.label }}
|
|
||||||
>
|
|
||||||
{el.duration.toFixed(1)}s
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</TimelineClip>
|
</TimelineClip>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -691,6 +1096,41 @@ export const Timeline = memo(function Timeline({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
||||||
|
{activeDraggedElement && activeDraggedPosition && (
|
||||||
|
<div
|
||||||
|
className="absolute pointer-events-none"
|
||||||
|
style={{
|
||||||
|
top: activeDraggedPosition.top,
|
||||||
|
left: activeDraggedPosition.left,
|
||||||
|
width: Math.max(activeDraggedElement.duration * pps, 4),
|
||||||
|
height: TRACK_H - CLIP_Y * 2,
|
||||||
|
zIndex: 40,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<TimelineClip
|
||||||
|
el={{ ...activeDraggedElement, start: 0 }}
|
||||||
|
pps={pps}
|
||||||
|
clipY={0}
|
||||||
|
isSelected={
|
||||||
|
selectedElementId === (activeDraggedElement.key ?? activeDraggedElement.id)
|
||||||
|
}
|
||||||
|
isHovered={false}
|
||||||
|
isDragging={true}
|
||||||
|
hasCustomContent={!!renderClipContent}
|
||||||
|
theme={theme}
|
||||||
|
trackStyle={getStyle(activeDraggedElement.tag)}
|
||||||
|
isComposition={!!activeDraggedElement.compositionSrc}
|
||||||
|
onHoverStart={() => {}}
|
||||||
|
onHoverEnd={() => {}}
|
||||||
|
onResizeStart={() => {}}
|
||||||
|
onClick={() => {}}
|
||||||
|
onDoubleClick={() => {}}
|
||||||
|
>
|
||||||
|
{renderClipChildren(activeDraggedElement, getStyle(activeDraggedElement.tag))}
|
||||||
|
</TimelineClip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Range selection highlight */}
|
{/* Range selection highlight */}
|
||||||
{rangeSelection && (
|
{rangeSelection && (
|
||||||
<div
|
<div
|
||||||
@@ -746,11 +1186,22 @@ export const Timeline = memo(function Timeline({
|
|||||||
{/* Keyboard shortcut hint — always visible */}
|
{/* Keyboard shortcut hint — always visible */}
|
||||||
{!showPopover && !rangeSelection && (
|
{!showPopover && !rangeSelection && (
|
||||||
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
<div className="absolute bottom-2 right-3 pointer-events-none z-20">
|
||||||
<div className="flex items-center gap-1.5 px-2 py-1 rounded-md bg-neutral-800/50 border border-neutral-700/20">
|
<div
|
||||||
<kbd className="text-[9px] font-mono text-neutral-500 bg-neutral-700/40 px-1 py-0.5 rounded">
|
className="flex items-center gap-1.5 px-2 py-1 rounded-md border"
|
||||||
|
style={{
|
||||||
|
background: "rgba(17,23,35,0.84)",
|
||||||
|
borderColor: theme.gutterBorder,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<kbd
|
||||||
|
className="text-[9px] font-mono px-1 py-0.5 rounded"
|
||||||
|
style={{ color: theme.textSecondary, background: "rgba(255,255,255,0.06)" }}
|
||||||
|
>
|
||||||
Shift
|
Shift
|
||||||
</kbd>
|
</kbd>
|
||||||
<span className="text-[9px] text-neutral-600">+ drag to edit range</span>
|
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
|
||||||
|
+ drag to edit range
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import type { TimelineTrackStyle } from "./timelineTheme";
|
||||||
// TimelineClip — Visual clip component for the NLE timeline.
|
// TimelineClip — Visual clip component for the NLE timeline.
|
||||||
|
|
||||||
import { memo, type ReactNode } from "react";
|
import { memo, type ReactNode } from "react";
|
||||||
import type { TimelineElement } from "../store/playerStore";
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
|
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
|
||||||
|
|
||||||
interface TimelineClipProps {
|
interface TimelineClipProps {
|
||||||
el: TimelineElement;
|
el: TimelineElement;
|
||||||
@@ -9,11 +11,15 @@ interface TimelineClipProps {
|
|||||||
clipY: number;
|
clipY: number;
|
||||||
isSelected: boolean;
|
isSelected: boolean;
|
||||||
isHovered: boolean;
|
isHovered: boolean;
|
||||||
|
isDragging?: boolean;
|
||||||
hasCustomContent: boolean;
|
hasCustomContent: boolean;
|
||||||
style: { clip: string; label: string };
|
theme?: TimelineTheme;
|
||||||
|
trackStyle: TimelineTrackStyle;
|
||||||
isComposition: boolean;
|
isComposition: boolean;
|
||||||
onHoverStart: () => void;
|
onHoverStart: () => void;
|
||||||
onHoverEnd: () => void;
|
onHoverEnd: () => void;
|
||||||
|
onPointerDown?: (e: React.PointerEvent) => void;
|
||||||
|
onResizeStart?: (edge: "start" | "end", e: React.PointerEvent) => void;
|
||||||
onClick: (e: React.MouseEvent) => void;
|
onClick: (e: React.MouseEvent) => void;
|
||||||
onDoubleClick: (e: React.MouseEvent) => void;
|
onDoubleClick: (e: React.MouseEvent) => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
@@ -25,43 +31,62 @@ export const TimelineClip = memo(function TimelineClip({
|
|||||||
clipY,
|
clipY,
|
||||||
isSelected,
|
isSelected,
|
||||||
isHovered,
|
isHovered,
|
||||||
|
isDragging = false,
|
||||||
hasCustomContent,
|
hasCustomContent,
|
||||||
style,
|
theme = defaultTimelineTheme,
|
||||||
|
trackStyle,
|
||||||
isComposition,
|
isComposition,
|
||||||
onHoverStart,
|
onHoverStart,
|
||||||
onHoverEnd,
|
onHoverEnd,
|
||||||
|
onPointerDown,
|
||||||
|
onResizeStart,
|
||||||
onClick,
|
onClick,
|
||||||
onDoubleClick,
|
onDoubleClick,
|
||||||
children,
|
children,
|
||||||
}: TimelineClipProps) {
|
}: TimelineClipProps) {
|
||||||
const leftPx = el.start * pps;
|
const leftPx = el.start * pps;
|
||||||
const widthPx = Math.max(el.duration * pps, 4);
|
const widthPx = Math.max(el.duration * pps, 4);
|
||||||
|
const handleOpacity = getClipHandleOpacity({ isHovered, isSelected, isDragging });
|
||||||
|
const borderColor = isSelected
|
||||||
|
? theme.clipBorderActive
|
||||||
|
: isHovered
|
||||||
|
? theme.clipBorderHover
|
||||||
|
: theme.clipBorder;
|
||||||
|
const boxShadow = isDragging
|
||||||
|
? theme.clipShadowDragging
|
||||||
|
: isSelected
|
||||||
|
? theme.clipShadowActive
|
||||||
|
: isHovered
|
||||||
|
? theme.clipShadowHover
|
||||||
|
: theme.clipShadow;
|
||||||
|
const showHandles = handleOpacity > 0.01;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-clip="true"
|
data-clip="true"
|
||||||
className={hasCustomContent ? "absolute" : "absolute flex items-center"}
|
className={
|
||||||
|
hasCustomContent ? "absolute overflow-hidden" : "absolute flex items-center overflow-hidden"
|
||||||
|
}
|
||||||
style={{
|
style={{
|
||||||
left: leftPx,
|
left: leftPx,
|
||||||
width: widthPx,
|
width: widthPx,
|
||||||
top: clipY,
|
top: clipY,
|
||||||
bottom: clipY,
|
bottom: clipY,
|
||||||
borderRadius: 5,
|
borderRadius: theme.clipRadius,
|
||||||
backgroundColor: hasCustomContent ? (isComposition ? "#111" : style.clip) : style.clip,
|
background: isSelected
|
||||||
|
? `linear-gradient(180deg, rgba(255,255,255,0.03), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}22, transparent 28%), ${theme.clipBackgroundActive}`
|
||||||
|
: `linear-gradient(180deg, rgba(255,255,255,0.02), rgba(255,255,255,0)), linear-gradient(120deg, ${trackStyle.accent}1e, transparent 28%), ${theme.clipBackground}`,
|
||||||
backgroundImage:
|
backgroundImage:
|
||||||
isComposition && !hasCustomContent
|
isComposition && !hasCustomContent
|
||||||
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.08) 3px, rgba(255,255,255,0.08) 6px)`
|
? `repeating-linear-gradient(135deg, transparent, transparent 3px, rgba(255,255,255,0.05) 3px, rgba(255,255,255,0.05) 6px)`
|
||||||
: undefined,
|
: undefined,
|
||||||
border: isSelected
|
border: `1px solid ${borderColor}`,
|
||||||
? `2px solid rgba(255,255,255,0.9)`
|
boxShadow,
|
||||||
: `1px solid rgba(255,255,255,${isHovered ? 0.3 : 0.15})`,
|
transition:
|
||||||
boxShadow: isSelected
|
"border-color 120ms ease-out, box-shadow 140ms ease-out, background 140ms ease-out",
|
||||||
? `0 0 0 1px ${style.clip}, 0 2px 8px rgba(0,0,0,0.4)`
|
zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1,
|
||||||
: isHovered
|
cursor: "grab",
|
||||||
? "0 1px 4px rgba(0,0,0,0.3)"
|
transform: isDragging ? "translateY(-1px)" : undefined,
|
||||||
: "none",
|
|
||||||
transition: "border-color 120ms, box-shadow 120ms",
|
|
||||||
zIndex: isSelected ? 10 : isHovered ? 5 : 1,
|
|
||||||
}}
|
}}
|
||||||
title={
|
title={
|
||||||
isComposition
|
isComposition
|
||||||
@@ -70,9 +95,80 @@ export const TimelineClip = memo(function TimelineClip({
|
|||||||
}
|
}
|
||||||
onPointerEnter={onHoverStart}
|
onPointerEnter={onHoverStart}
|
||||||
onPointerLeave={onHoverEnd}
|
onPointerLeave={onHoverEnd}
|
||||||
|
onPointerDown={onPointerDown}
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
onDoubleClick={onDoubleClick}
|
onDoubleClick={onDoubleClick}
|
||||||
>
|
>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
role="presentation"
|
||||||
|
onPointerDown={(e) => onResizeStart?.("start", e)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 18,
|
||||||
|
opacity: showHandles ? 1 : 0,
|
||||||
|
pointerEvents: onResizeStart ? "auto" : "none",
|
||||||
|
zIndex: 4,
|
||||||
|
transition: "opacity 120ms ease-out",
|
||||||
|
cursor: "col-resize",
|
||||||
|
background: showHandles
|
||||||
|
? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
left: 6,
|
||||||
|
top: 7,
|
||||||
|
bottom: 7,
|
||||||
|
width: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: theme.handleColor,
|
||||||
|
boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
|
||||||
|
opacity: handleOpacity,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
role="presentation"
|
||||||
|
onPointerDown={(e) => onResizeStart?.("end", e)}
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
bottom: 0,
|
||||||
|
width: 18,
|
||||||
|
opacity: showHandles ? 1 : 0,
|
||||||
|
pointerEvents: onResizeStart ? "auto" : "none",
|
||||||
|
zIndex: 4,
|
||||||
|
transition: "opacity 120ms ease-out",
|
||||||
|
cursor: "col-resize",
|
||||||
|
background: showHandles
|
||||||
|
? `linear-gradient(270deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
|
||||||
|
: "transparent",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "absolute",
|
||||||
|
right: 6,
|
||||||
|
top: 7,
|
||||||
|
bottom: 7,
|
||||||
|
width: 3,
|
||||||
|
borderRadius: 999,
|
||||||
|
background: theme.handleColor,
|
||||||
|
boxShadow: `0 0 0 1px ${trackStyle.accent}38, 0 0 12px ${trackStyle.accent}18`,
|
||||||
|
opacity: handleOpacity,
|
||||||
|
pointerEvents: "none",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,310 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
buildTrackZIndexMap,
|
||||||
|
buildPromptCopyText,
|
||||||
|
buildTimelineAgentPrompt,
|
||||||
|
resolveTimelineAutoScroll,
|
||||||
|
resolveTimelineMove,
|
||||||
|
resolveTimelineResize,
|
||||||
|
type TimelinePromptElement,
|
||||||
|
} from "./timelineEditing";
|
||||||
|
|
||||||
|
describe("resolveTimelineMove", () => {
|
||||||
|
it("moves timing based on horizontal drag and snaps to centiseconds", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineMove(
|
||||||
|
{
|
||||||
|
start: 1.25,
|
||||||
|
track: 2,
|
||||||
|
duration: 2,
|
||||||
|
originClientX: 100,
|
||||||
|
originClientY: 200,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
trackHeight: 72,
|
||||||
|
maxStart: 8,
|
||||||
|
trackOrder: [0, 1, 2, 3, 4],
|
||||||
|
},
|
||||||
|
245,
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 2.7, track: 2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves layers based on vertical drag and clamps to the allowed range", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineMove(
|
||||||
|
{
|
||||||
|
start: 2,
|
||||||
|
track: 1,
|
||||||
|
duration: 3,
|
||||||
|
originClientX: 200,
|
||||||
|
originClientY: 200,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
trackHeight: 72,
|
||||||
|
maxStart: 10,
|
||||||
|
trackOrder: [0, 1, 5, 9],
|
||||||
|
},
|
||||||
|
150,
|
||||||
|
390,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 1.5, track: 9 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents moving before zero or past the last valid start", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineMove(
|
||||||
|
{
|
||||||
|
start: 0.2,
|
||||||
|
track: 0,
|
||||||
|
duration: 4,
|
||||||
|
originClientX: 300,
|
||||||
|
originClientY: 200,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
trackHeight: 72,
|
||||||
|
maxStart: 6,
|
||||||
|
trackOrder: [0, 10, 20],
|
||||||
|
},
|
||||||
|
-100,
|
||||||
|
-200,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 0, track: -1 });
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveTimelineMove(
|
||||||
|
{
|
||||||
|
start: 5.8,
|
||||||
|
track: 10,
|
||||||
|
duration: 4,
|
||||||
|
originClientX: 300,
|
||||||
|
originClientY: 200,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
trackHeight: 72,
|
||||||
|
maxStart: 6,
|
||||||
|
trackOrder: [0, 10, 20],
|
||||||
|
},
|
||||||
|
500,
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 6, track: 10 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new top track when dragged past the first row threshold", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineMove(
|
||||||
|
{
|
||||||
|
start: 1,
|
||||||
|
track: 0,
|
||||||
|
duration: 2,
|
||||||
|
originClientX: 100,
|
||||||
|
originClientY: 200,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
trackHeight: 72,
|
||||||
|
maxStart: 8,
|
||||||
|
trackOrder: [0, 10, 20],
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
150,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 1, track: -1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("creates a new bottom track when dragged past the last row threshold", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineMove(
|
||||||
|
{
|
||||||
|
start: 1,
|
||||||
|
track: 20,
|
||||||
|
duration: 2,
|
||||||
|
originClientX: 100,
|
||||||
|
originClientY: 200,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
trackHeight: 72,
|
||||||
|
maxStart: 8,
|
||||||
|
trackOrder: [0, 10, 20],
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
250,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 1, track: 21 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accounts for scroll displacement while dragging", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineMove(
|
||||||
|
{
|
||||||
|
start: 1,
|
||||||
|
track: 0,
|
||||||
|
duration: 2,
|
||||||
|
originClientX: 100,
|
||||||
|
originClientY: 200,
|
||||||
|
originScrollLeft: 0,
|
||||||
|
originScrollTop: 0,
|
||||||
|
currentScrollLeft: 100,
|
||||||
|
currentScrollTop: 144,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
trackHeight: 72,
|
||||||
|
maxStart: 8,
|
||||||
|
trackOrder: [0, 1, 2, 3],
|
||||||
|
},
|
||||||
|
100,
|
||||||
|
200,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 2, track: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTrackZIndexMap", () => {
|
||||||
|
it("maps sorted tracks onto stable positive z-index values", () => {
|
||||||
|
expect(buildTrackZIndexMap([-2, -1, 0, 3])).toEqual(
|
||||||
|
new Map([
|
||||||
|
[-2, 1],
|
||||||
|
[-1, 2],
|
||||||
|
[0, 3],
|
||||||
|
[3, 4],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("deduplicates tracks before assigning z-index values", () => {
|
||||||
|
expect(buildTrackZIndexMap([-1, 0, -1, 3, 3])).toEqual(
|
||||||
|
new Map([
|
||||||
|
[-1, 1],
|
||||||
|
[0, 2],
|
||||||
|
[3, 3],
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTimelineAutoScroll", () => {
|
||||||
|
it("does not scroll when the pointer stays away from the edges", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineAutoScroll(
|
||||||
|
{
|
||||||
|
left: 100,
|
||||||
|
top: 100,
|
||||||
|
right: 500,
|
||||||
|
bottom: 400,
|
||||||
|
},
|
||||||
|
300,
|
||||||
|
250,
|
||||||
|
),
|
||||||
|
).toEqual({ x: 0, y: 0 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls upward and leftward near the top-left edge", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineAutoScroll(
|
||||||
|
{
|
||||||
|
left: 100,
|
||||||
|
top: 100,
|
||||||
|
right: 500,
|
||||||
|
bottom: 400,
|
||||||
|
},
|
||||||
|
110,
|
||||||
|
120,
|
||||||
|
),
|
||||||
|
).toEqual({ x: -9, y: -6 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("scrolls downward and rightward near the bottom-right edge", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineAutoScroll(
|
||||||
|
{
|
||||||
|
left: 100,
|
||||||
|
top: 100,
|
||||||
|
right: 500,
|
||||||
|
bottom: 400,
|
||||||
|
},
|
||||||
|
490,
|
||||||
|
380,
|
||||||
|
),
|
||||||
|
).toEqual({ x: 9, y: 6 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildTimelineAgentPrompt", () => {
|
||||||
|
it("includes the selected range, elements, and user request", () => {
|
||||||
|
const elements: TimelinePromptElement[] = [
|
||||||
|
{ id: "title", tag: "div", start: 1, duration: 3, track: 0 },
|
||||||
|
{ id: "music", tag: "audio", start: 0, duration: 8, track: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const text = buildTimelineAgentPrompt({
|
||||||
|
rangeStart: 1,
|
||||||
|
rangeEnd: 4,
|
||||||
|
elements,
|
||||||
|
prompt: "Move the title later and lower the music",
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(text).toContain("Time range: 0:01 — 0:04");
|
||||||
|
expect(text).toContain("#title (div)");
|
||||||
|
expect(text).toContain("#music (audio)");
|
||||||
|
expect(text).toContain("Move the title later and lower the music");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveTimelineResize", () => {
|
||||||
|
it("shrinks clip duration from the right edge", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineResize(
|
||||||
|
{
|
||||||
|
start: 1,
|
||||||
|
duration: 3,
|
||||||
|
originClientX: 100,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
minStart: 0,
|
||||||
|
maxEnd: 10,
|
||||||
|
},
|
||||||
|
"end",
|
||||||
|
40,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 1, duration: 2.4, playbackStart: undefined });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("trims media from the left edge by advancing playback start and clip start", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineResize(
|
||||||
|
{
|
||||||
|
start: 1,
|
||||||
|
duration: 3,
|
||||||
|
originClientX: 100,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
minStart: 0,
|
||||||
|
maxEnd: 10,
|
||||||
|
playbackStart: 0.5,
|
||||||
|
playbackRate: 1,
|
||||||
|
},
|
||||||
|
"start",
|
||||||
|
150,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 1.5, duration: 2.5, playbackStart: 1 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prevents extending media left past available source before media-start", () => {
|
||||||
|
expect(
|
||||||
|
resolveTimelineResize(
|
||||||
|
{
|
||||||
|
start: 1,
|
||||||
|
duration: 3,
|
||||||
|
originClientX: 100,
|
||||||
|
pixelsPerSecond: 100,
|
||||||
|
minStart: 0,
|
||||||
|
maxEnd: 10,
|
||||||
|
playbackStart: 0.2,
|
||||||
|
playbackRate: 1,
|
||||||
|
},
|
||||||
|
"start",
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
).toEqual({ start: 0.8, duration: 3.2, playbackStart: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("buildPromptCopyText", () => {
|
||||||
|
it("returns a trimmed prompt for the copy-prompt action", () => {
|
||||||
|
expect(buildPromptCopyText(" Tighten the headline timing ")).toBe(
|
||||||
|
"Tighten the headline timing",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { formatTime } from "../lib/time";
|
||||||
|
|
||||||
|
const TIME_PRECISION = 100;
|
||||||
|
|
||||||
|
function roundToCentiseconds(value: number): number {
|
||||||
|
return Math.round(value * TIME_PRECISION) / TIME_PRECISION;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.min(Math.max(value, min), max);
|
||||||
|
}
|
||||||
|
|
||||||
|
const EDGE_TRACK_CREATE_THRESHOLD = 0.55;
|
||||||
|
const AUTO_SCROLL_EDGE_ZONE = 40;
|
||||||
|
const AUTO_SCROLL_MAX_SPEED = 12;
|
||||||
|
|
||||||
|
export interface TimelineMoveInput {
|
||||||
|
start: number;
|
||||||
|
track: number;
|
||||||
|
duration: number;
|
||||||
|
originClientX: number;
|
||||||
|
originClientY: number;
|
||||||
|
originScrollLeft?: number;
|
||||||
|
originScrollTop?: number;
|
||||||
|
currentScrollLeft?: number;
|
||||||
|
currentScrollTop?: number;
|
||||||
|
pixelsPerSecond: number;
|
||||||
|
trackHeight: number;
|
||||||
|
maxStart: number;
|
||||||
|
trackOrder: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineResizeInput {
|
||||||
|
start: number;
|
||||||
|
duration: number;
|
||||||
|
originClientX: number;
|
||||||
|
pixelsPerSecond: number;
|
||||||
|
minStart: number;
|
||||||
|
maxEnd: number;
|
||||||
|
minDuration?: number;
|
||||||
|
playbackStart?: number;
|
||||||
|
playbackRate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineAutoScrollBounds {
|
||||||
|
left: number;
|
||||||
|
top: number;
|
||||||
|
right: number;
|
||||||
|
bottom: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimelineAutoScroll(
|
||||||
|
bounds: TimelineAutoScrollBounds,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
const getAxisDelta = (start: number, end: number, pointer: number) => {
|
||||||
|
if (pointer < start + AUTO_SCROLL_EDGE_ZONE) {
|
||||||
|
const proximity = Math.max(0, 1 - (pointer - start) / AUTO_SCROLL_EDGE_ZONE);
|
||||||
|
return -Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
|
||||||
|
}
|
||||||
|
if (pointer > end - AUTO_SCROLL_EDGE_ZONE) {
|
||||||
|
const proximity = Math.max(0, 1 - (end - pointer) / AUTO_SCROLL_EDGE_ZONE);
|
||||||
|
return Math.round(AUTO_SCROLL_MAX_SPEED * proximity);
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: getAxisDelta(bounds.left, bounds.right, clientX),
|
||||||
|
y: getAxisDelta(bounds.top, bounds.bottom, clientY),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimelineMove(
|
||||||
|
input: TimelineMoveInput,
|
||||||
|
clientX: number,
|
||||||
|
clientY: number,
|
||||||
|
): { start: number; track: number } {
|
||||||
|
const scrollDeltaX = (input.currentScrollLeft ?? 0) - (input.originScrollLeft ?? 0);
|
||||||
|
const scrollDeltaY = (input.currentScrollTop ?? 0) - (input.originScrollTop ?? 0);
|
||||||
|
const deltaTime =
|
||||||
|
(clientX - input.originClientX + scrollDeltaX) / Math.max(input.pixelsPerSecond, 1);
|
||||||
|
const trackDeltaRaw =
|
||||||
|
(clientY - input.originClientY + scrollDeltaY) / Math.max(input.trackHeight, 1);
|
||||||
|
const deltaTrack = Math.round(trackDeltaRaw);
|
||||||
|
const currentTrackIndex = Math.max(0, input.trackOrder.indexOf(input.track));
|
||||||
|
const desiredTrackIndex = currentTrackIndex + deltaTrack;
|
||||||
|
const nextTrackIndex = clamp(desiredTrackIndex, 0, Math.max(0, input.trackOrder.length - 1));
|
||||||
|
const minTrack = Math.min(...input.trackOrder);
|
||||||
|
const maxTrack = Math.max(...input.trackOrder);
|
||||||
|
let nextTrack = input.trackOrder[nextTrackIndex] ?? input.track;
|
||||||
|
|
||||||
|
const startedOnFirstTrack = currentTrackIndex === 0;
|
||||||
|
const startedOnLastTrack = currentTrackIndex === input.trackOrder.length - 1;
|
||||||
|
|
||||||
|
if (
|
||||||
|
startedOnFirstTrack &&
|
||||||
|
desiredTrackIndex < 0 &&
|
||||||
|
currentTrackIndex + trackDeltaRaw <= -EDGE_TRACK_CREATE_THRESHOLD
|
||||||
|
) {
|
||||||
|
nextTrack = minTrack - 1;
|
||||||
|
} else if (
|
||||||
|
startedOnLastTrack &&
|
||||||
|
desiredTrackIndex > input.trackOrder.length - 1 &&
|
||||||
|
currentTrackIndex + trackDeltaRaw >= input.trackOrder.length - 1 + EDGE_TRACK_CREATE_THRESHOLD
|
||||||
|
) {
|
||||||
|
nextTrack = maxTrack + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: clamp(roundToCentiseconds(input.start + deltaTime), 0, Math.max(0, input.maxStart)),
|
||||||
|
track: nextTrack,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
|
||||||
|
const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b);
|
||||||
|
return new Map(uniqueTracks.map((track, index) => [track, index + 1]));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveTimelineResize(
|
||||||
|
input: TimelineResizeInput,
|
||||||
|
edge: "start" | "end",
|
||||||
|
clientX: number,
|
||||||
|
): { start: number; duration: number; playbackStart?: number } {
|
||||||
|
const minDuration = Math.max(0.05, input.minDuration ?? 0.1);
|
||||||
|
const deltaTime = (clientX - input.originClientX) / Math.max(input.pixelsPerSecond, 1);
|
||||||
|
|
||||||
|
if (edge === "end") {
|
||||||
|
const nextDuration = clamp(
|
||||||
|
roundToCentiseconds(input.duration + deltaTime),
|
||||||
|
minDuration,
|
||||||
|
Math.max(minDuration, input.maxEnd - input.start),
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
start: input.start,
|
||||||
|
duration: nextDuration,
|
||||||
|
playbackStart: input.playbackStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const playbackRate = Math.max(0.1, input.playbackRate ?? 1);
|
||||||
|
const maxLeftExtensionFromMedia =
|
||||||
|
input.playbackStart != null ? input.playbackStart / playbackRate : Number.POSITIVE_INFINITY;
|
||||||
|
const minDelta = -Math.min(input.start - input.minStart, maxLeftExtensionFromMedia);
|
||||||
|
const maxDelta = input.duration - minDuration;
|
||||||
|
const clampedDelta = clamp(deltaTime, minDelta, maxDelta);
|
||||||
|
const nextStart = roundToCentiseconds(input.start + clampedDelta);
|
||||||
|
const nextDuration = roundToCentiseconds(input.duration - clampedDelta);
|
||||||
|
const nextPlaybackStart =
|
||||||
|
input.playbackStart != null
|
||||||
|
? roundToCentiseconds(Math.max(0, input.playbackStart + clampedDelta * playbackRate))
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
start: nextStart,
|
||||||
|
duration: nextDuration,
|
||||||
|
playbackStart: nextPlaybackStart,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelinePromptElement {
|
||||||
|
id: string;
|
||||||
|
tag: string;
|
||||||
|
start: number;
|
||||||
|
duration: number;
|
||||||
|
track: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildTimelineAgentPrompt({
|
||||||
|
rangeStart,
|
||||||
|
rangeEnd,
|
||||||
|
elements,
|
||||||
|
prompt,
|
||||||
|
}: {
|
||||||
|
rangeStart: number;
|
||||||
|
rangeEnd: number;
|
||||||
|
elements: TimelinePromptElement[];
|
||||||
|
prompt: string;
|
||||||
|
}): string {
|
||||||
|
const start = Math.min(rangeStart, rangeEnd);
|
||||||
|
const end = Math.max(rangeStart, rangeEnd);
|
||||||
|
const elementLines = elements
|
||||||
|
.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.`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildPromptCopyText(prompt: string): string {
|
||||||
|
return prompt.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatTimelineAttributeNumber(value: number): string {
|
||||||
|
return Number(roundToCentiseconds(value).toFixed(2)).toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
getClipHandleOpacity,
|
||||||
|
getRenderedTimelineElement,
|
||||||
|
getTimelineTrackStyle,
|
||||||
|
} from "./timelineTheme";
|
||||||
|
|
||||||
|
describe("getTimelineTrackStyle", () => {
|
||||||
|
it("reuses heading styles for heading tags", () => {
|
||||||
|
expect(getTimelineTrackStyle("h2").accent).toBe(getTimelineTrackStyle("h1").accent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for unknown tags", () => {
|
||||||
|
expect(getTimelineTrackStyle("custom-tag").accent).toBe("#3CE6AC");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getClipHandleOpacity", () => {
|
||||||
|
it("hides handles at rest", () => {
|
||||||
|
expect(getClipHandleOpacity({ isHovered: false, isSelected: false, isDragging: false })).toBe(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("prioritizes dragging over hover and selection", () => {
|
||||||
|
expect(getClipHandleOpacity({ isHovered: true, isSelected: true, isDragging: true })).toBe(
|
||||||
|
0.95,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getRenderedTimelineElement", () => {
|
||||||
|
it("keeps non-dragged clips unchanged", () => {
|
||||||
|
const element = { id: "a", tag: "div", start: 1, duration: 2, track: 0 };
|
||||||
|
expect(
|
||||||
|
getRenderedTimelineElement({
|
||||||
|
element,
|
||||||
|
draggedElementId: "b",
|
||||||
|
previewStart: 2,
|
||||||
|
previewTrack: 1,
|
||||||
|
}),
|
||||||
|
).toEqual(element);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("moves the actual dragged clip to the preview position", () => {
|
||||||
|
const element = { id: "a", tag: "div", start: 1, duration: 2, track: 0 };
|
||||||
|
expect(
|
||||||
|
getRenderedTimelineElement({
|
||||||
|
element,
|
||||||
|
draggedElementId: "a",
|
||||||
|
previewStart: 2.4,
|
||||||
|
previewTrack: 3,
|
||||||
|
}),
|
||||||
|
).toEqual({ ...element, start: 2.4, track: 3 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
import type { TimelineElement } from "../store/playerStore";
|
||||||
|
|
||||||
|
export interface TimelineTrackStyle {
|
||||||
|
clip: string;
|
||||||
|
accent: string;
|
||||||
|
label: string;
|
||||||
|
iconBackground: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimelineTheme {
|
||||||
|
shellBackground: string;
|
||||||
|
shellBorder: string;
|
||||||
|
rulerBorder: string;
|
||||||
|
rowBackground: string;
|
||||||
|
rowBorder: string;
|
||||||
|
gutterBackground: string;
|
||||||
|
gutterBorder: string;
|
||||||
|
textPrimary: string;
|
||||||
|
textSecondary: string;
|
||||||
|
tickText: string;
|
||||||
|
tickMajor: string;
|
||||||
|
tickMinor: string;
|
||||||
|
clipBackground: string;
|
||||||
|
clipBackgroundActive: string;
|
||||||
|
clipBorder: string;
|
||||||
|
clipBorderHover: string;
|
||||||
|
clipBorderActive: string;
|
||||||
|
clipShadow: string;
|
||||||
|
clipShadowHover: string;
|
||||||
|
clipShadowActive: string;
|
||||||
|
clipShadowDragging: string;
|
||||||
|
handleColor: string;
|
||||||
|
panelResizeSeam: string;
|
||||||
|
panelResizeActive: string;
|
||||||
|
clipRadius: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TIMELINE_TEAL = "#3CE6AC";
|
||||||
|
const TIMELINE_TEAL_LABEL = "#E9FFF6";
|
||||||
|
const TIMELINE_TEAL_ICON_BACKGROUND = "rgba(60,230,172,0.12)";
|
||||||
|
|
||||||
|
function createTrackStyle(): TimelineTrackStyle {
|
||||||
|
return {
|
||||||
|
clip: TIMELINE_TEAL,
|
||||||
|
accent: TIMELINE_TEAL,
|
||||||
|
label: TIMELINE_TEAL_LABEL,
|
||||||
|
iconBackground: TIMELINE_TEAL_ICON_BACKGROUND,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const TRACK_STYLES: Record<string, TimelineTrackStyle> = {
|
||||||
|
video: createTrackStyle(),
|
||||||
|
audio: createTrackStyle(),
|
||||||
|
img: createTrackStyle(),
|
||||||
|
div: createTrackStyle(),
|
||||||
|
span: createTrackStyle(),
|
||||||
|
p: createTrackStyle(),
|
||||||
|
h1: createTrackStyle(),
|
||||||
|
section: createTrackStyle(),
|
||||||
|
sfx: createTrackStyle(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const DEFAULT_TRACK_STYLE: TimelineTrackStyle = createTrackStyle();
|
||||||
|
|
||||||
|
export const defaultTimelineTheme: TimelineTheme = {
|
||||||
|
shellBackground: "#0A0E15",
|
||||||
|
shellBorder: "rgba(255,255,255,0.05)",
|
||||||
|
rulerBorder: "rgba(255,255,255,0.045)",
|
||||||
|
rowBackground: "#0A0E15",
|
||||||
|
rowBorder: "rgba(255,255,255,0.05)",
|
||||||
|
gutterBackground: "#0D121B",
|
||||||
|
gutterBorder: "rgba(255,255,255,0.05)",
|
||||||
|
textPrimary: "#E8EDF5",
|
||||||
|
textSecondary: "#8391A8",
|
||||||
|
tickText: "rgba(131,145,168,0.92)",
|
||||||
|
tickMajor: "rgba(255,255,255,0.13)",
|
||||||
|
tickMinor: "rgba(255,255,255,0.08)",
|
||||||
|
clipBackground: "linear-gradient(180deg, rgba(20,25,34,0.98), rgba(14,18,27,0.98))",
|
||||||
|
clipBackgroundActive: "linear-gradient(180deg, rgba(24,30,40,0.99), rgba(15,20,29,0.99))",
|
||||||
|
clipBorder: "rgba(255,255,255,0.07)",
|
||||||
|
clipBorderHover: "rgba(255,255,255,0.11)",
|
||||||
|
clipBorderActive: "rgba(255,255,255,0.14)",
|
||||||
|
clipShadow: "inset 0 1px 0 rgba(255,255,255,0.03), 0 6px 18px rgba(0,0,0,0.18)",
|
||||||
|
clipShadowHover: "inset 0 1px 0 rgba(255,255,255,0.035), 0 8px 20px rgba(0,0,0,0.2)",
|
||||||
|
clipShadowActive:
|
||||||
|
"inset 0 1px 0 rgba(255,255,255,0.04), 0 10px 24px rgba(0,0,0,0.22), 0 0 0 1px rgba(255,255,255,0.035)",
|
||||||
|
clipShadowDragging:
|
||||||
|
"inset 0 1px 0 rgba(255,255,255,0.04), 0 18px 36px rgba(0,0,0,0.34), 0 8px 16px rgba(0,0,0,0.18), 0 0 0 1px rgba(255,255,255,0.04)",
|
||||||
|
handleColor: "rgba(255,255,255,0.11)",
|
||||||
|
panelResizeSeam: "rgba(255,255,255,0.12)",
|
||||||
|
panelResizeActive: "rgba(255,255,255,0.24)",
|
||||||
|
clipRadius: "11px 15px 13px 9px / 10px 14px 12px 10px",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getTimelineTrackStyle(tag: string): TimelineTrackStyle {
|
||||||
|
const normalized = tag.toLowerCase();
|
||||||
|
if (
|
||||||
|
normalized.startsWith("h") &&
|
||||||
|
normalized.length === 2 &&
|
||||||
|
"123456".includes(normalized[1] ?? "")
|
||||||
|
) {
|
||||||
|
return TRACK_STYLES.h1;
|
||||||
|
}
|
||||||
|
return TRACK_STYLES[normalized] ?? DEFAULT_TRACK_STYLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getClipHandleOpacity({
|
||||||
|
isHovered,
|
||||||
|
isSelected,
|
||||||
|
isDragging,
|
||||||
|
}: {
|
||||||
|
isHovered: boolean;
|
||||||
|
isSelected: boolean;
|
||||||
|
isDragging: boolean;
|
||||||
|
}): number {
|
||||||
|
if (isDragging) return 0.95;
|
||||||
|
if (isSelected) return 0.82;
|
||||||
|
if (isHovered) return 0.76;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getRenderedTimelineElement({
|
||||||
|
element,
|
||||||
|
draggedElementId,
|
||||||
|
previewStart,
|
||||||
|
previewTrack,
|
||||||
|
}: {
|
||||||
|
element: TimelineElement;
|
||||||
|
draggedElementId: string | null;
|
||||||
|
previewStart: number | null;
|
||||||
|
previewTrack: number | null;
|
||||||
|
}): TimelineElement {
|
||||||
|
if (element.id !== draggedElementId || previewStart === null || previewTrack === null) {
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...element,
|
||||||
|
start: previewStart,
|
||||||
|
track: previewTrack,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,5 +1,69 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { mergeTimelineElementsPreservingDowngrades } from "./useTimelinePlayer";
|
import {
|
||||||
|
buildStandaloneRootTimelineElement,
|
||||||
|
mergeTimelineElementsPreservingDowngrades,
|
||||||
|
resolveStandaloneRootCompositionSrc,
|
||||||
|
} from "./useTimelinePlayer";
|
||||||
|
|
||||||
|
describe("buildStandaloneRootTimelineElement", () => {
|
||||||
|
it("includes selector and source metadata for standalone composition fallback clips", () => {
|
||||||
|
expect(
|
||||||
|
buildStandaloneRootTimelineElement({
|
||||||
|
compositionId: "hero",
|
||||||
|
tagName: "DIV",
|
||||||
|
rootDuration: 8,
|
||||||
|
iframeSrc: "http://127.0.0.1:4173/api/projects/demo/preview/comp/scenes/hero.html?_t=123",
|
||||||
|
selector: '[data-composition-id="hero"]',
|
||||||
|
}),
|
||||||
|
).toEqual({
|
||||||
|
id: "hero",
|
||||||
|
key: 'scenes/hero.html:[data-composition-id="hero"]:0',
|
||||||
|
tag: "div",
|
||||||
|
start: 0,
|
||||||
|
duration: 8,
|
||||||
|
track: 0,
|
||||||
|
compositionSrc: "scenes/hero.html",
|
||||||
|
selector: '[data-composition-id="hero"]',
|
||||||
|
selectorIndex: undefined,
|
||||||
|
sourceFile: "scenes/hero.html",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid fallback durations", () => {
|
||||||
|
expect(
|
||||||
|
buildStandaloneRootTimelineElement({
|
||||||
|
compositionId: "hero",
|
||||||
|
tagName: "div",
|
||||||
|
rootDuration: 0,
|
||||||
|
iframeSrc: "http://localhost/preview/comp/hero.html",
|
||||||
|
}),
|
||||||
|
).toBe(null);
|
||||||
|
expect(
|
||||||
|
buildStandaloneRootTimelineElement({
|
||||||
|
compositionId: "hero",
|
||||||
|
tagName: "div",
|
||||||
|
rootDuration: Number.NaN,
|
||||||
|
iframeSrc: "http://localhost/preview/comp/hero.html",
|
||||||
|
}),
|
||||||
|
).toBe(null);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("resolveStandaloneRootCompositionSrc", () => {
|
||||||
|
it("extracts the composition path from a preview iframe url", () => {
|
||||||
|
expect(
|
||||||
|
resolveStandaloneRootCompositionSrc(
|
||||||
|
"http://127.0.0.1:4173/api/projects/demo/preview/comp/scenes/hero.html?_t=123",
|
||||||
|
),
|
||||||
|
).toBe("scenes/hero.html");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined for non-composition preview urls", () => {
|
||||||
|
expect(
|
||||||
|
resolveStandaloneRootCompositionSrc("http://127.0.0.1:4173/api/projects/demo/preview"),
|
||||||
|
).toBe(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
describe("mergeTimelineElementsPreservingDowngrades", () => {
|
||||||
it("preserves missing current elements when a shorter manifest arrives", () => {
|
it("preserves missing current elements when a shorter manifest arrives", () => {
|
||||||
|
|||||||
@@ -137,14 +137,28 @@ function parseTimelineFromDOM(doc: Document, rootDuration: number): TimelineElem
|
|||||||
const trackStr = el.getAttribute("data-track-index");
|
const trackStr = el.getAttribute("data-track-index");
|
||||||
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
const track = trackStr != null ? parseInt(trackStr, 10) : trackCounter++;
|
||||||
const compId = el.getAttribute("data-composition-id");
|
const compId = el.getAttribute("data-composition-id");
|
||||||
|
const selector = getTimelineElementSelector(el);
|
||||||
|
const sourceFile = getTimelineElementSourceFile(el);
|
||||||
|
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
||||||
|
const id = el.id || compId || el.className?.split(" ")[0] || tagLower;
|
||||||
const entry: TimelineElement = {
|
const entry: TimelineElement = {
|
||||||
id: el.id || compId || el.className?.split(" ")[0] || tagLower,
|
id,
|
||||||
|
key: buildTimelineElementKey({
|
||||||
|
id,
|
||||||
|
fallbackIndex: els.length,
|
||||||
|
domId: el.id || undefined,
|
||||||
|
selector,
|
||||||
|
selectorIndex,
|
||||||
|
sourceFile,
|
||||||
|
}),
|
||||||
tag: tagLower,
|
tag: tagLower,
|
||||||
start,
|
start,
|
||||||
duration: dur,
|
duration: dur,
|
||||||
track: isNaN(track) ? 0 : track,
|
track: isNaN(track) ? 0 : track,
|
||||||
selector: getTimelineElementSelector(el),
|
domId: el.id || undefined,
|
||||||
sourceFile: getTimelineElementSourceFile(el),
|
selector,
|
||||||
|
selectorIndex,
|
||||||
|
sourceFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
const mediaEl = resolveMediaElement(el);
|
const mediaEl = resolveMediaElement(el);
|
||||||
@@ -199,6 +213,38 @@ function getTimelineElementSourceFile(el: Element): string | undefined {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTimelineElementSelectorIndex(
|
||||||
|
doc: Document,
|
||||||
|
el: Element,
|
||||||
|
selector: string | undefined,
|
||||||
|
): number | undefined {
|
||||||
|
if (!selector || selector.startsWith("#") || selector.startsWith("[data-composition-id=")) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const matches = Array.from(doc.querySelectorAll(selector));
|
||||||
|
const matchIndex = matches.indexOf(el);
|
||||||
|
return matchIndex >= 0 ? matchIndex : undefined;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTimelineElementKey(params: {
|
||||||
|
id: string;
|
||||||
|
fallbackIndex: number;
|
||||||
|
domId?: string;
|
||||||
|
selector?: string;
|
||||||
|
selectorIndex?: number;
|
||||||
|
sourceFile?: string;
|
||||||
|
}): string {
|
||||||
|
const scope = params.sourceFile ?? "index.html";
|
||||||
|
if (params.domId) return `${scope}#${params.domId}`;
|
||||||
|
if (params.selector) return `${scope}:${params.selector}:${params.selectorIndex ?? 0}`;
|
||||||
|
return `${scope}:${params.id}:${params.fallbackIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
function findTimelineDomNode(doc: Document, id: string): Element | null {
|
||||||
return (
|
return (
|
||||||
doc.getElementById(id) ??
|
doc.getElementById(id) ??
|
||||||
@@ -208,6 +254,43 @@ function findTimelineDomNode(doc: Document, id: string): Element | null {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function resolveStandaloneRootCompositionSrc(iframeSrc: string): string | undefined {
|
||||||
|
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
|
||||||
|
return compPathMatch ? decodeURIComponent(compPathMatch[1]) : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildStandaloneRootTimelineElement(params: {
|
||||||
|
compositionId: string;
|
||||||
|
tagName: string;
|
||||||
|
rootDuration: number;
|
||||||
|
iframeSrc: string;
|
||||||
|
selector?: string;
|
||||||
|
selectorIndex?: number;
|
||||||
|
}): TimelineElement | null {
|
||||||
|
if (!Number.isFinite(params.rootDuration) || params.rootDuration <= 0) return null;
|
||||||
|
|
||||||
|
const compositionSrc = resolveStandaloneRootCompositionSrc(params.iframeSrc);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: params.compositionId,
|
||||||
|
key: buildTimelineElementKey({
|
||||||
|
id: params.compositionId,
|
||||||
|
fallbackIndex: 0,
|
||||||
|
selector: params.selector,
|
||||||
|
selectorIndex: params.selectorIndex,
|
||||||
|
sourceFile: compositionSrc,
|
||||||
|
}),
|
||||||
|
tag: params.tagName.toLowerCase() || "div",
|
||||||
|
start: 0,
|
||||||
|
duration: params.rootDuration,
|
||||||
|
track: 0,
|
||||||
|
compositionSrc,
|
||||||
|
selector: params.selector,
|
||||||
|
selectorIndex: params.selectorIndex,
|
||||||
|
sourceFile: compositionSrc,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePreviewViewport(doc: Document, win: Window): void {
|
function normalizePreviewViewport(doc: Document, win: Window): void {
|
||||||
if (doc.documentElement) {
|
if (doc.documentElement) {
|
||||||
doc.documentElement.style.overflow = "hidden";
|
doc.documentElement.style.overflow = "hidden";
|
||||||
@@ -486,10 +569,11 @@ export function useTimelinePlayer() {
|
|||||||
const filtered = data.clips.filter(
|
const filtered = data.clips.filter(
|
||||||
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
(clip) => !clip.parentCompositionId || !clipCompositionIds.has(clip.parentCompositionId),
|
||||||
);
|
);
|
||||||
const els: TimelineElement[] = filtered.map((clip) => {
|
const els: TimelineElement[] = filtered.map((clip, index) => {
|
||||||
let hostEl: Element | null = null;
|
let hostEl: Element | null = null;
|
||||||
|
const id = clip.id || clip.label || clip.tagName || "element";
|
||||||
const entry: TimelineElement = {
|
const entry: TimelineElement = {
|
||||||
id: clip.id || clip.label || clip.tagName || "element",
|
id,
|
||||||
tag: clip.tagName || clip.kind,
|
tag: clip.tagName || clip.kind,
|
||||||
start: clip.start,
|
start: clip.start,
|
||||||
duration: clip.duration,
|
duration: clip.duration,
|
||||||
@@ -504,7 +588,13 @@ export function useTimelinePlayer() {
|
|||||||
/* cross-origin */
|
/* cross-origin */
|
||||||
}
|
}
|
||||||
if (hostEl) {
|
if (hostEl) {
|
||||||
|
const iframeDoc = iframeRef.current?.contentDocument;
|
||||||
|
entry.domId = hostEl.id || undefined;
|
||||||
entry.selector = getTimelineElementSelector(hostEl);
|
entry.selector = getTimelineElementSelector(hostEl);
|
||||||
|
entry.selectorIndex =
|
||||||
|
iframeDoc && entry.selector
|
||||||
|
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
||||||
|
: undefined;
|
||||||
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
||||||
applyMediaMetadataFromElement(entry, hostEl);
|
applyMediaMetadataFromElement(entry, hostEl);
|
||||||
}
|
}
|
||||||
@@ -539,10 +629,24 @@ export function useTimelinePlayer() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (hostEl) {
|
if (hostEl) {
|
||||||
|
const iframeDoc = iframeRef.current?.contentDocument;
|
||||||
|
entry.domId = hostEl.id || undefined;
|
||||||
entry.selector = getTimelineElementSelector(hostEl);
|
entry.selector = getTimelineElementSelector(hostEl);
|
||||||
|
entry.selectorIndex =
|
||||||
|
iframeDoc && entry.selector
|
||||||
|
? getTimelineElementSelectorIndex(iframeDoc, hostEl, entry.selector)
|
||||||
|
: undefined;
|
||||||
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
entry.sourceFile = getTimelineElementSourceFile(hostEl);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
entry.key = buildTimelineElementKey({
|
||||||
|
id,
|
||||||
|
fallbackIndex: index,
|
||||||
|
domId: entry.domId,
|
||||||
|
selector: entry.selector,
|
||||||
|
selectorIndex: entry.selectorIndex,
|
||||||
|
sourceFile: entry.sourceFile,
|
||||||
|
});
|
||||||
return entry;
|
return entry;
|
||||||
});
|
});
|
||||||
const rawDuration = data.durationInFrames / 30;
|
const rawDuration = data.durationInFrames / 30;
|
||||||
@@ -654,14 +758,28 @@ export function useTimelinePlayer() {
|
|||||||
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
const track = trackStr != null ? parseInt(trackStr, 10) : 0;
|
||||||
const compSrc =
|
const compSrc =
|
||||||
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
el.getAttribute("data-composition-src") || el.getAttribute("data-composition-file");
|
||||||
|
const selector = getTimelineElementSelector(el);
|
||||||
|
const sourceFile = getTimelineElementSourceFile(el);
|
||||||
|
const selectorIndex = getTimelineElementSelectorIndex(doc, el, selector);
|
||||||
|
const id = el.id || compId;
|
||||||
const entry: TimelineElement = {
|
const entry: TimelineElement = {
|
||||||
id: el.id || compId,
|
id,
|
||||||
|
key: buildTimelineElementKey({
|
||||||
|
id,
|
||||||
|
fallbackIndex: missing.length,
|
||||||
|
domId: el.id || undefined,
|
||||||
|
selector,
|
||||||
|
selectorIndex,
|
||||||
|
sourceFile,
|
||||||
|
}),
|
||||||
tag: el.tagName.toLowerCase(),
|
tag: el.tagName.toLowerCase(),
|
||||||
start,
|
start,
|
||||||
duration: dur,
|
duration: dur,
|
||||||
track: isNaN(track) ? 0 : track,
|
track: isNaN(track) ? 0 : track,
|
||||||
selector: getTimelineElementSelector(el),
|
domId: el.id || undefined,
|
||||||
sourceFile: getTimelineElementSourceFile(el),
|
selector,
|
||||||
|
selectorIndex,
|
||||||
|
sourceFile,
|
||||||
};
|
};
|
||||||
if (compSrc) {
|
if (compSrc) {
|
||||||
entry.compositionSrc = compSrc;
|
entry.compositionSrc = compSrc;
|
||||||
@@ -771,26 +889,18 @@ export function useTimelinePlayer() {
|
|||||||
const rootComp = doc.querySelector("[data-composition-id]");
|
const rootComp = doc.querySelector("[data-composition-id]");
|
||||||
const rootDuration = adapter.getDuration();
|
const rootDuration = adapter.getDuration();
|
||||||
if (rootComp && rootDuration > 0) {
|
if (rootComp && rootDuration > 0) {
|
||||||
const rootId = rootComp.getAttribute("data-composition-id") || "composition";
|
const fallbackElement = buildStandaloneRootTimelineElement({
|
||||||
// Derive compositionSrc from the iframe URL for thumbnail rendering.
|
compositionId: rootComp.getAttribute("data-composition-id") || "composition",
|
||||||
// URL pattern: /api/projects/{id}/preview/comp/{path}
|
tagName: (rootComp as HTMLElement).tagName || "div",
|
||||||
const iframeSrc = iframe?.src || "";
|
rootDuration,
|
||||||
const compPathMatch = iframeSrc.match(/\/preview\/comp\/(.+?)(?:\?|$)/);
|
iframeSrc: iframe?.src || "",
|
||||||
const compositionSrc = compPathMatch
|
selector: getTimelineElementSelector(rootComp),
|
||||||
? decodeURIComponent(compPathMatch[1])
|
});
|
||||||
: undefined;
|
if (fallbackElement) {
|
||||||
// Always show the root composition as a single clip — guarantees
|
// Always show the root composition as a single clip — guarantees
|
||||||
// the timeline is never empty when a valid composition is loaded.
|
// the timeline is never empty when a valid composition is loaded.
|
||||||
syncTimelineElements([
|
syncTimelineElements([fallbackElement]);
|
||||||
{
|
}
|
||||||
id: rootId,
|
|
||||||
tag: (rootComp as HTMLElement).tagName?.toLowerCase() || "div",
|
|
||||||
start: 0,
|
|
||||||
duration: rootDuration,
|
|
||||||
track: 0,
|
|
||||||
compositionSrc,
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The runtime will also postMessage the full timeline after all compositions load.
|
// The runtime will also postMessage the full timeline after all compositions load.
|
||||||
|
|||||||
@@ -132,6 +132,19 @@ describe("usePlayerStore", () => {
|
|||||||
usePlayerStore.getState().updateElement("nonexistent", { start: 10 });
|
usePlayerStore.getState().updateElement("nonexistent", { start: 10 });
|
||||||
expect(usePlayerStore.getState().elements[0].start).toBe(0);
|
expect(usePlayerStore.getState().elements[0].start).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers the stable element key when duplicate ids exist", () => {
|
||||||
|
usePlayerStore.getState().setElements([
|
||||||
|
{ id: "headline", key: "a", tag: "div", start: 0, duration: 5, track: 0 },
|
||||||
|
{ id: "headline", key: "b", tag: "div", start: 5, duration: 5, track: 1 },
|
||||||
|
]);
|
||||||
|
|
||||||
|
usePlayerStore.getState().updateElement("b", { start: 9 });
|
||||||
|
|
||||||
|
const elements = usePlayerStore.getState().elements;
|
||||||
|
expect(elements[0].start).toBe(0);
|
||||||
|
expect(elements[1].start).toBe(9);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("setZoomMode", () => {
|
describe("setZoomMode", () => {
|
||||||
|
|||||||
@@ -2,12 +2,16 @@ import { create } from "zustand";
|
|||||||
|
|
||||||
export interface TimelineElement {
|
export interface TimelineElement {
|
||||||
id: string;
|
id: string;
|
||||||
|
key?: string;
|
||||||
tag: string;
|
tag: string;
|
||||||
start: number;
|
start: number;
|
||||||
duration: number;
|
duration: number;
|
||||||
track: number;
|
track: number;
|
||||||
|
domId?: string;
|
||||||
/** Best-effort selector used when patching source HTML back from timeline edits */
|
/** Best-effort selector used when patching source HTML back from timeline edits */
|
||||||
selector?: string;
|
selector?: string;
|
||||||
|
/** Zero-based occurrence index for non-unique selectors */
|
||||||
|
selectorIndex?: number;
|
||||||
/** Source composition file that owns this element, when known */
|
/** Source composition file that owns this element, when known */
|
||||||
sourceFile?: string;
|
sourceFile?: string;
|
||||||
src?: string;
|
src?: string;
|
||||||
@@ -86,7 +90,9 @@ export const usePlayerStore = create<PlayerState>((set) => ({
|
|||||||
setSelectedElementId: (id) => set({ selectedElementId: id }),
|
setSelectedElementId: (id) => set({ selectedElementId: id }),
|
||||||
updateElement: (elementId, updates) =>
|
updateElement: (elementId, updates) =>
|
||||||
set((state) => ({
|
set((state) => ({
|
||||||
elements: state.elements.map((el) => (el.id === elementId ? { ...el, ...updates } : el)),
|
elements: state.elements.map((el) =>
|
||||||
|
(el.key ?? el.id) === elementId ? { ...el, ...updates } : el,
|
||||||
|
),
|
||||||
})),
|
})),
|
||||||
// Resets project-specific state when switching compositions.
|
// Resets project-specific state when switching compositions.
|
||||||
// playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved
|
// playbackRate, zoomMode, and pixelsPerSecond are intentionally preserved
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { applyPatchByTarget, readAttributeByTarget, type PatchOperation } from "./sourcePatcher";
|
||||||
|
|
||||||
|
describe("applyPatchByTarget", () => {
|
||||||
|
it("updates a composition host by data-composition-id selector", () => {
|
||||||
|
const html = `<div data-composition-id="intro" data-start="0" data-track-index="1"></div>`;
|
||||||
|
const op: PatchOperation = { type: "attribute", property: "start", value: "2.5" };
|
||||||
|
|
||||||
|
expect(applyPatchByTarget(html, { selector: '[data-composition-id="intro"]' }, op)).toContain(
|
||||||
|
'data-start="2.5"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates a class-based layer when the clip has no DOM id", () => {
|
||||||
|
const html = `<div class="headline clip" data-start="0" data-track-index="1"></div>`;
|
||||||
|
const op: PatchOperation = { type: "attribute", property: "track-index", value: "3" };
|
||||||
|
|
||||||
|
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toContain(
|
||||||
|
'data-track-index="3"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates inline z-index by selector when the clip has no DOM id", () => {
|
||||||
|
const html = `<div class="headline clip" style="position: absolute; opacity: 1" data-start="0"></div>`;
|
||||||
|
const op: PatchOperation = { type: "inline-style", property: "z-index", value: "3" };
|
||||||
|
|
||||||
|
expect(applyPatchByTarget(html, { selector: ".headline" }, op)).toContain(
|
||||||
|
'style="position: absolute; opacity: 1; z-index: 3"',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("updates media timing attributes by selector", () => {
|
||||||
|
const html = `<video class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></video>`;
|
||||||
|
|
||||||
|
const withDuration = applyPatchByTarget(
|
||||||
|
html,
|
||||||
|
{ selector: ".hero" },
|
||||||
|
{
|
||||||
|
type: "attribute",
|
||||||
|
property: "duration",
|
||||||
|
value: "1.1",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const withMediaStart = applyPatchByTarget(
|
||||||
|
withDuration,
|
||||||
|
{ selector: ".hero" },
|
||||||
|
{
|
||||||
|
type: "attribute",
|
||||||
|
property: "media-start",
|
||||||
|
value: "0.7",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(withMediaStart).toContain('data-duration="1.1"');
|
||||||
|
expect(withMediaStart).toContain('data-media-start="0.7"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads media timing attributes by selector", () => {
|
||||||
|
const html = `<div class="hero clip" data-start="0.2" data-duration="1.4" data-media-start="0.4"></div>`;
|
||||||
|
|
||||||
|
expect(readAttributeByTarget(html, { selector: ".hero" }, "media-start")).toBe("0.4");
|
||||||
|
expect(readAttributeByTarget(html, { selector: ".hero" }, "duration")).toBe("1.4");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("patches the correct duplicate selector occurrence", () => {
|
||||||
|
const html = [
|
||||||
|
`<div class="headline clip" data-start="0"></div>`,
|
||||||
|
`<div class="headline clip" data-start="1"></div>`,
|
||||||
|
].join("");
|
||||||
|
|
||||||
|
const patched = applyPatchByTarget(
|
||||||
|
html,
|
||||||
|
{ selector: ".headline", selectorIndex: 1 },
|
||||||
|
{
|
||||||
|
type: "attribute",
|
||||||
|
property: "start",
|
||||||
|
value: "2.5",
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(patched).toContain(`<div class="headline clip" data-start="0"></div>`);
|
||||||
|
expect(patched).toContain(`<div class="headline clip" data-start="2.5"></div>`);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,12 @@ export interface PatchOperation {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PatchTarget {
|
||||||
|
id?: string | null;
|
||||||
|
selector?: string;
|
||||||
|
selectorIndex?: number;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Find which source file contains an element by its ID.
|
* Find which source file contains an element by its ID.
|
||||||
*/
|
*/
|
||||||
@@ -73,6 +79,11 @@ function patchInlineStyle(html: string, elementId: string, prop: string, value:
|
|||||||
if (!match) return html;
|
if (!match) return html;
|
||||||
|
|
||||||
const tag = match[1];
|
const tag = match[1];
|
||||||
|
return patchInlineStyleInTag(html, tag, prop, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchInlineStyleInTag(html: string, tag: string, prop: string, value: string): string {
|
||||||
|
if (!tag) return html;
|
||||||
|
|
||||||
// Check if there's an existing style attribute
|
// Check if there's an existing style attribute
|
||||||
const styleMatch = /\bstyle="([^"]*)"/.exec(tag);
|
const styleMatch = /\bstyle="([^"]*)"/.exec(tag);
|
||||||
@@ -102,6 +113,120 @@ function patchInlineStyle(html: string, elementId: string, prop: string, value:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function patchInlineStyleByTarget(
|
||||||
|
html: string,
|
||||||
|
target: PatchTarget,
|
||||||
|
prop: string,
|
||||||
|
value: string,
|
||||||
|
): string {
|
||||||
|
const match = findTagByTarget(html, target);
|
||||||
|
if (!match) return html;
|
||||||
|
const newTag = patchInlineStyleInTag(match.tag, match.tag, prop, value);
|
||||||
|
return replaceTagAtMatch(html, match, newTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TagMatch {
|
||||||
|
tag: string;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceTagAtMatch(html: string, match: TagMatch, newTag: string): string {
|
||||||
|
return `${html.slice(0, match.start)}${newTag}${html.slice(match.end)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTagByTarget(html: string, target: PatchTarget): TagMatch | null {
|
||||||
|
if (target.id) {
|
||||||
|
const idPattern = new RegExp(`(<[^>]*\\bid="${escapeRegex(target.id)}"[^>]*)>`, "i");
|
||||||
|
const match = idPattern.exec(html);
|
||||||
|
if (match?.index != null) {
|
||||||
|
return {
|
||||||
|
tag: match[1],
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[1].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!target.selector) return null;
|
||||||
|
|
||||||
|
const compositionIdMatch = target.selector.match(/^\[data-composition-id="([^"]+)"\]$/);
|
||||||
|
if (compositionIdMatch) {
|
||||||
|
const compId = compositionIdMatch[1];
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`(<[^>]*\\bdata-composition-id="${escapeRegex(compId)}"[^>]*)>`,
|
||||||
|
"i",
|
||||||
|
);
|
||||||
|
const match = pattern.exec(html);
|
||||||
|
if (match?.index != null) {
|
||||||
|
return {
|
||||||
|
tag: match[1],
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[1].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const classMatch = target.selector.match(/^\.([a-zA-Z0-9_-]+)$/);
|
||||||
|
if (classMatch) {
|
||||||
|
const cls = classMatch[1];
|
||||||
|
const pattern = new RegExp(
|
||||||
|
`(<[^>]*\\bclass=(["'])[^"']*\\b${escapeRegex(cls)}\\b[^"']*\\2[^>]*)>`,
|
||||||
|
"gi",
|
||||||
|
);
|
||||||
|
const selectorIndex = target.selectorIndex ?? 0;
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
let currentIndex = 0;
|
||||||
|
while ((match = pattern.exec(html)) !== null) {
|
||||||
|
if (currentIndex === selectorIndex && match.index != null) {
|
||||||
|
return {
|
||||||
|
tag: match[1],
|
||||||
|
start: match.index,
|
||||||
|
end: match.index + match[1].length,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
currentIndex += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAttributeByTarget(
|
||||||
|
html: string,
|
||||||
|
target: PatchTarget,
|
||||||
|
attr: string,
|
||||||
|
): string | undefined {
|
||||||
|
const match = findTagByTarget(html, target);
|
||||||
|
if (!match) return undefined;
|
||||||
|
|
||||||
|
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
||||||
|
const valueMatch = new RegExp(`\\b${fullAttr}="([^"]*)"`).exec(match.tag);
|
||||||
|
return valueMatch?.[1];
|
||||||
|
}
|
||||||
|
|
||||||
|
function patchAttributeByTarget(
|
||||||
|
html: string,
|
||||||
|
target: PatchTarget,
|
||||||
|
attr: string,
|
||||||
|
value: string,
|
||||||
|
): string {
|
||||||
|
const match = findTagByTarget(html, target);
|
||||||
|
if (!match) return html;
|
||||||
|
|
||||||
|
const fullAttr = attr.startsWith("data-") ? attr : `data-${attr}`;
|
||||||
|
const attrPattern = new RegExp(`\\b${fullAttr}="[^"]*"`);
|
||||||
|
const tag = match.tag;
|
||||||
|
|
||||||
|
if (attrPattern.test(tag)) {
|
||||||
|
const newTag = tag.replace(attrPattern, `${fullAttr}="${value}"`);
|
||||||
|
return replaceTagAtMatch(html, match, newTag);
|
||||||
|
}
|
||||||
|
|
||||||
|
const newTag = tag + ` ${fullAttr}="${value}"`;
|
||||||
|
return replaceTagAtMatch(html, match, newTag);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Apply an attribute change to an element in the HTML source.
|
* Apply an attribute change to an element in the HTML source.
|
||||||
*/
|
*/
|
||||||
@@ -151,3 +276,21 @@ export function applyPatch(html: string, elementId: string, op: PatchOperation):
|
|||||||
return html;
|
return html;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function applyPatchByTarget(html: string, target: PatchTarget, op: PatchOperation): string {
|
||||||
|
if (target.id) {
|
||||||
|
const patchedById = applyPatch(html, target.id, op);
|
||||||
|
if (patchedById !== html || !target.selector) {
|
||||||
|
return patchedById;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (op.type) {
|
||||||
|
case "inline-style":
|
||||||
|
return patchInlineStyleByTarget(html, target, op.property, op.value);
|
||||||
|
case "attribute":
|
||||||
|
return patchAttributeByTarget(html, target, op.property, op.value);
|
||||||
|
default:
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user