fix: harden studio timeline editing and local renders (#463)

* fix: harden studio timeline editing and local renders

* test: cover studio local render fallback

* fix(studio): scale composition hover previews to stage size

* test: normalize studio producer fallback paths

* fix(studio): preserve move surface and retry render fallback
This commit is contained in:
Miguel Ángel
2026-04-24 00:18:37 +02:00
committed by GitHub
parent 21063c66d9
commit 6610b8ad00
15 changed files with 774 additions and 72 deletions
+47 -38
View File
@@ -1,6 +1,7 @@
import { useState, useCallback, useRef, useEffect, useMemo, type ReactNode } from "react";
import { useMountEffect } from "./hooks/useMountEffect";
import { NLELayout } from "./components/nle/NLELayout";
import { TimelineEditorNotice } from "./components/nle/TimelineEditorNotice";
import { SourceEditor } from "./components/editor/SourceEditor";
import { LeftSidebar } from "./components/sidebar/LeftSidebar";
import { RenderQueue } from "./components/renders/RenderQueue";
@@ -28,7 +29,6 @@ import {
getTimelineZoomPercent,
} from "./player/components/timelineZoom";
import {
TIMELINE_TOGGLE_SHORTCUT_LABEL,
getTimelineEditorHintDismissed,
getTimelineToggleTitle,
setTimelineEditorHintDismissed,
@@ -40,6 +40,11 @@ interface EditingFile {
content: string | null;
}
interface AppToast {
message: string;
tone: "error" | "info";
}
// ── Main App ──
export function StudioApp() {
@@ -201,12 +206,14 @@ export function StudioApp() {
}
}, [captionHasSelection, captionEditMode]);
const [globalDragOver, setGlobalDragOver] = useState(false);
const [uploadToast, setUploadToast] = useState<string | null>(null);
const [appToast, setAppToast] = useState<AppToast | null>(null);
const [timelineVisible, setTimelineVisible] = useState(true);
const [timelineEditorHintDismissed, setTimelineEditorHintState] = useState(
getTimelineEditorHintDismissed,
);
const dragCounterRef = useRef(0);
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const lastBlockedTimelineToastAtRef = useRef(0);
const previewHotkeyWindowRef = useRef<Window | null>(null);
const panelDragRef = useRef<{
side: "left" | "right";
@@ -238,6 +245,9 @@ export function StudioApp() {
const toggleTimelineVisibility = useCallback(() => {
setTimelineVisible((visible) => !visible);
}, []);
useMountEffect(() => () => {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
});
const dismissTimelineEditorHint = useCallback(() => {
setTimelineEditorHintState(true);
setTimelineEditorHintDismissed(true);
@@ -380,31 +390,6 @@ export function StudioApp() {
);
const timelineToolbar = (
<div className="border-b border-neutral-800/40 bg-neutral-950/96">
{timelineVisible && timelineElements.length > 0 && !timelineEditorHintDismissed && (
<div className="px-3 pt-3">
<div className="flex items-start justify-between gap-3 rounded-xl border border-studio-accent/20 bg-studio-accent/[0.07] px-3 py-3">
<div className="min-w-0">
<div className="text-[11px] font-semibold text-neutral-100">Timeline editor</div>
<p className="mt-1 text-[11px] leading-5 text-neutral-300">
Drag clips to move timing, and drag clip edges to resize them when handles are
available. Hide the panel anytime and bring it back with{" "}
<span className="font-mono text-[10px] text-studio-accent">
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
</span>
.
</p>
</div>
<button
type="button"
onClick={dismissTimelineEditorHint}
className="flex-shrink-0 rounded-md border border-neutral-700 px-2 py-1 text-[10px] font-medium text-neutral-300 transition-colors hover:border-neutral-500 hover:text-neutral-100"
>
Dismiss
</button>
</div>
</div>
)}
<div className="flex items-center justify-between px-3 py-2">
<div className="text-[10px] font-medium uppercase tracking-[0.16em] text-neutral-500">
Timeline
@@ -855,11 +840,22 @@ export function StudioApp() {
const handleMoveFile = handleRenameFile;
const showUploadToast = useCallback((msg: string) => {
setUploadToast(msg);
setTimeout(() => setUploadToast(null), 4000);
const showToast = useCallback((message: string, tone: AppToast["tone"] = "error") => {
if (toastTimerRef.current) clearTimeout(toastTimerRef.current);
setAppToast({ message, tone });
toastTimerRef.current = setTimeout(() => setAppToast(null), 4000);
}, []);
const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
const now = Date.now();
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
lastBlockedTimelineToastAtRef.current = now;
showToast("This clip cant be moved or resized from the timeline yet.", "info");
},
[showToast],
);
const handleImportFiles = useCallback(
async (files: FileList, dir?: string) => {
const pid = projectIdRef.current;
@@ -879,20 +875,20 @@ export function StudioApp() {
if (res.ok) {
const data = await res.json();
if (data.skipped?.length) {
showUploadToast(`Skipped (too large): ${data.skipped.join(", ")}`);
showToast(`Skipped (too large): ${data.skipped.join(", ")}`);
}
await refreshFileTree();
setRefreshKey((k) => k + 1);
} else if (res.status === 413) {
showUploadToast("Upload rejected: payload too large");
showToast("Upload rejected: payload too large");
} else {
showUploadToast(`Upload failed (${res.status})`);
showToast(`Upload failed (${res.status})`);
}
} catch {
showUploadToast("Upload failed: network error");
showToast("Upload failed: network error");
}
},
[refreshFileTree, showUploadToast],
[refreshFileTree, showToast],
);
const handleLint = useCallback(async () => {
@@ -1157,6 +1153,7 @@ export function StudioApp() {
renderClipContent={renderClipContent}
onMoveElement={handleTimelineElementMove}
onResizeElement={handleTimelineElementResize}
onBlockedEditAttempt={handleBlockedTimelineEdit}
onCompIdToSrcChange={setCompIdToSrc}
onCompositionChange={(compPath) => {
// Sync activeCompPath when user drills down via timeline double-click
@@ -1267,6 +1264,12 @@ export function StudioApp() {
)}
</div>
{timelineElements.length > 0 && !timelineEditorHintDismissed && (
<div className="pointer-events-none absolute bottom-5 left-5 z-[140]">
<TimelineEditorNotice onDismiss={dismissTimelineEditorHint} />
</div>
)}
{/* Lint modal */}
{lintModal !== null && projectId && (
<LintModal findings={lintModal} projectId={projectId} onClose={() => setLintModal(null)} />
@@ -1306,9 +1309,15 @@ export function StudioApp() {
</div>
</div>
)}
{uploadToast && (
<div className="absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg bg-red-900/90 border border-red-700/50 text-sm text-red-200 shadow-lg animate-in fade-in slide-in-from-bottom-2">
{uploadToast}
{appToast && (
<div
className={`absolute bottom-6 left-1/2 -translate-x-1/2 z-[91] px-4 py-2 rounded-lg border text-sm shadow-lg animate-in fade-in slide-in-from-bottom-2 ${
appToast.tone === "error"
? "bg-red-900/90 border-red-700/50 text-red-200"
: "bg-neutral-900/95 border-neutral-700/60 text-neutral-100"
}`}
>
{appToast.message}
</div>
)}
</div>
@@ -2,6 +2,7 @@ import { useState, useCallback, useRef, useEffect, memo, type ReactNode } from "
import { useMountEffect } from "../../hooks/useMountEffect";
import { useTimelinePlayer, PlayerControls, Timeline, usePlayerStore } from "../../player";
import type { TimelineElement } from "../../player";
import type { BlockedTimelineEditIntent } from "../../player/components/timelineEditing";
import { NLEPreview } from "./NLEPreview";
import { CompositionBreadcrumb, type CompositionLevel } from "./CompositionBreadcrumb";
@@ -36,6 +37,7 @@ interface NLELayoutProps {
element: TimelineElement,
updates: Pick<TimelineElement, "start" | "duration" | "playbackStart">,
) => Promise<void> | void;
onBlockedEditAttempt?: (element: TimelineElement, intent: BlockedTimelineEditIntent) => void;
/** Exposes the compIdToSrc map for parent components (e.g., useRenderClipContent) */
onCompIdToSrcChange?: (map: Map<string, string>) => void;
/** Whether the timeline panel is visible (default: true) */
@@ -61,6 +63,7 @@ export const NLELayout = memo(function NLELayout({
renderClipContent,
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
onCompIdToSrcChange,
timelineVisible,
onToggleTimeline,
@@ -392,6 +395,7 @@ export const NLELayout = memo(function NLELayout({
renderClipContent={renderClipContent}
onMoveElement={onMoveElement}
onResizeElement={onResizeElement}
onBlockedEditAttempt={onBlockedEditAttempt}
/>
</div>
{timelineFooter && <div className="flex-shrink-0">{timelineFooter}</div>}
@@ -0,0 +1,156 @@
import { TIMELINE_TOGGLE_SHORTCUT_LABEL } from "../../utils/timelineDiscovery";
interface TimelineEditorNoticeProps {
onDismiss: () => void;
}
export function TimelineEditorNotice({ onDismiss }: TimelineEditorNoticeProps) {
return (
<aside
aria-live="polite"
className="pointer-events-none relative w-[320px] max-w-[calc(100vw-2rem)] overflow-hidden rounded-2xl border border-white/10 bg-[#0f1115]/88 text-neutral-100 shadow-[0_18px_40px_rgba(0,0,0,0.3),0_4px_14px_rgba(0,0,0,0.18)] backdrop-blur-xl"
>
<style>{`
@keyframes hfTimelineNoticeClipNudge {
0%, 100% { transform: translate3d(0, 0, 0); }
20% { transform: translate3d(0, 0, 0); }
52% { transform: translate3d(12px, 0, 0); }
72% { transform: translate3d(12px, 0, 0); }
100% { transform: translate3d(0, 0, 0); }
}
@keyframes hfTimelineNoticePlayheadSweep {
0% { transform: translateX(0); opacity: 0; }
10% { opacity: 1; }
75% { opacity: 1; }
100% { transform: translateX(218px); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.hf-timeline-notice-clip,
.hf-timeline-notice-playhead {
animation: none !important;
}
}
`}</style>
<button
type="button"
onClick={onDismiss}
aria-label="Dismiss timeline editor notice"
className="pointer-events-auto absolute right-3 top-3 z-10 flex h-7 w-7 items-center justify-center rounded-lg text-neutral-500 transition-colors duration-150 hover:bg-white/[0.06] hover:text-neutral-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-studio-accent/50"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.25"
strokeLinecap="round"
aria-hidden="true"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
</button>
<div className="flex items-start gap-3 px-4 py-3.5">
<div className="min-w-0 flex-1">
<div
aria-hidden="true"
className="mb-3 overflow-hidden rounded-[14px] bg-[#0d1117] p-2.5"
>
<div className="relative overflow-hidden rounded-[11px] bg-[#0f141c] px-2.5 pb-2 pt-1.5">
<div className="mb-1.5 flex items-center justify-between pl-6 pr-1 text-[8px] font-medium text-[#7f8796]">
<span>0:00</span>
<span>0:05</span>
<span>0:10</span>
</div>
<div className="pointer-events-none absolute inset-x-0 top-[18px] h-px bg-white/[0.04]" />
<div
className="hf-timeline-notice-playhead pointer-events-none absolute left-[31px] top-[18px] h-[70px] w-0"
style={{
animation:
"hfTimelineNoticePlayheadSweep 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
}}
>
<div
className="absolute top-0 bottom-0"
style={{
left: "50%",
width: 2,
marginLeft: -1,
background: "var(--hf-accent, #3CE6AC)",
boxShadow: "0 0 8px rgba(60,230,172,0.5)",
}}
/>
<div
className="absolute"
style={{ left: "50%", top: 0, transform: "translateX(-50%)" }}
>
<div
style={{
width: 0,
height: 0,
borderLeft: "6px solid transparent",
borderRight: "6px solid transparent",
borderTop: "8px solid var(--hf-accent, #3CE6AC)",
filter: "drop-shadow(0 1px 3px rgba(0,0,0,0.6))",
}}
/>
</div>
</div>
<div className="flex flex-col gap-1.5">
{[0, 1, 2].map((trackIndex) => (
<div
key={trackIndex}
className="relative h-6 overflow-hidden rounded-[10px] bg-white/[0.035]"
>
<div className="absolute inset-y-0 left-[24px] w-px bg-white/[0.035]" />
<div className="absolute inset-y-0 left-[100px] w-px bg-white/[0.035]" />
<div className="absolute inset-y-0 left-[176px] w-px bg-white/[0.035]" />
</div>
))}
</div>
<div className="pointer-events-none absolute inset-x-0 top-[21px] h-[70px]">
<div className="absolute left-[34px] top-[3px] h-[18px] w-[56px] rounded-[9px] bg-white/[0.07]" />
<div
className="hf-timeline-notice-clip absolute left-[82px] top-[27px] h-[18px] w-[110px] rounded-[9px] bg-studio-accent/18 ring-1 ring-inset ring-studio-accent/28"
style={{
animation:
"hfTimelineNoticeClipNudge 2.8s cubic-bezier(0.4, 0, 0.2, 1) infinite",
}}
/>
<div className="absolute left-[52px] top-[51px] h-[18px] w-[72px] rounded-[9px] bg-white/[0.07]" />
</div>
</div>
</div>
<div className="min-w-0 pr-9">
<p className="text-[12px] font-semibold leading-none tracking-tight text-neutral-100">
Timeline editing is on
</p>
<p className="mt-1.5 text-[12px] leading-5 text-neutral-300">
Drag clips to move timing, use{" "}
<span className="font-mono text-[11px] text-studio-accent">Shift</span> + click to
edit a full clip range, and watch for resize handles only on clips Studio can patch
safely. Toggle the timeline with{" "}
<span className="rounded-md border border-white/8 bg-white/[0.04] px-1.5 py-0.5 font-mono text-[11px] text-studio-accent">
{TIMELINE_TOGGLE_SHORTCUT_LABEL}
</span>
.
</p>
</div>
<div className="mt-2 text-[10px] leading-none text-neutral-500">
Dismiss once and it stays hidden.
</div>
</div>
</div>
</aside>
);
}
@@ -0,0 +1,37 @@
import { describe, expect, it } from "vitest";
import { resolveCompositionPreviewScale } from "./CompositionsTab";
describe("resolveCompositionPreviewScale", () => {
it("scales a 16:9 stage to fit the composition card", () => {
expect(
resolveCompositionPreviewScale({
cardWidth: 80,
cardHeight: 45,
stageWidth: 1920,
stageHeight: 1080,
}),
).toBeCloseTo(80 / 1920);
});
it("scales non-16:9 stages against their actual dimensions", () => {
expect(
resolveCompositionPreviewScale({
cardWidth: 80,
cardHeight: 45,
stageWidth: 1280,
stageHeight: 720,
}),
).toBeCloseTo(80 / 1280);
});
it("falls back to the default stage when dimensions are invalid", () => {
expect(
resolveCompositionPreviewScale({
cardWidth: 80,
cardHeight: 45,
stageWidth: 0,
stageHeight: Number.NaN,
}),
).toBeCloseTo(80 / 1920);
});
});
@@ -7,6 +7,27 @@ interface CompositionsTabProps {
onSelect: (comp: string) => void;
}
const DEFAULT_PREVIEW_STAGE = { width: 1920, height: 1080 };
export function resolveCompositionPreviewScale(input: {
cardWidth: number;
cardHeight: number;
stageWidth: number;
stageHeight: number;
}): number {
const safeStageWidth =
Number.isFinite(input.stageWidth) && input.stageWidth > 0
? input.stageWidth
: DEFAULT_PREVIEW_STAGE.width;
const safeStageHeight =
Number.isFinite(input.stageHeight) && input.stageHeight > 0
? input.stageHeight
: DEFAULT_PREVIEW_STAGE.height;
const scaleX = input.cardWidth / safeStageWidth;
const scaleY = input.cardHeight / safeStageHeight;
return Math.min(scaleX, scaleY);
}
function CompCard({
projectId,
comp,
@@ -19,6 +40,7 @@ function CompCard({
onSelect: () => void;
}) {
const [hovered, setHovered] = useState(false);
const [stageSize, setStageSize] = useState(DEFAULT_PREVIEW_STAGE);
const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const handleEnter = () => {
hoverTimer.current = setTimeout(() => setHovered(true), 300);
@@ -33,6 +55,12 @@ function CompCard({
const name = comp.replace(/^compositions\//, "").replace(/\.html$/, "");
const thumbnailUrl = `/api/projects/${projectId}/thumbnail/${comp}?t=2`;
const previewUrl = `/api/projects/${projectId}/preview/comp/${comp}`;
const previewScale = resolveCompositionPreviewScale({
cardWidth: 80,
cardHeight: 45,
stageWidth: stageSize.width,
stageHeight: stageSize.height,
});
return (
<div
@@ -51,10 +79,25 @@ function CompCard({
<iframe
src={previewUrl}
sandbox="allow-scripts allow-same-origin"
className="absolute inset-0 w-[1920px] h-[1080px] border-none pointer-events-none"
className="absolute left-0 top-0 border-none pointer-events-none"
style={{
transformOrigin: "0 0",
transform: `scale(${80 / 1920})`,
width: stageSize.width,
height: stageSize.height,
transform: `scale(${previewScale})`,
}}
onLoad={(e) => {
try {
const iframe = e.currentTarget;
const root = iframe.contentDocument?.querySelector("[data-composition-id]");
const width =
Number(root?.getAttribute("data-width")) || DEFAULT_PREVIEW_STAGE.width;
const height =
Number(root?.getAttribute("data-height")) || DEFAULT_PREVIEW_STAGE.height;
setStageSize({ width, height });
} catch {
setStageSize(DEFAULT_PREVIEW_STAGE);
}
}}
tabIndex={-1}
/>
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
generateTicks,
getTimelineCanvasHeight,
getTimelinePlayheadLeft,
getTimelineScrollLeftForZoomTransition,
shouldAutoScrollTimeline,
@@ -151,3 +152,13 @@ describe("getTimelinePlayheadLeft", () => {
expect(getTimelinePlayheadLeft(4, Number.NaN)).toBe(32);
});
});
describe("getTimelineCanvasHeight", () => {
it("includes bottom scroll buffer below the last track", () => {
expect(getTimelineCanvasHeight(3)).toBeGreaterThan(24 + 3 * 72);
});
it("still keeps ruler space when there are no tracks", () => {
expect(getTimelineCanvasHeight(0)).toBeGreaterThan(24);
});
});
@@ -10,10 +10,14 @@ import { formatTime } from "../lib/time";
import { TimelineClip } from "./TimelineClip";
import { EditPopover } from "./EditModal";
import {
buildClipRangeSelection,
getTimelineEditCapabilities,
resolveBlockedTimelineEditIntent,
resolveTimelineAutoScroll,
resolveTimelineMove,
resolveTimelineResize,
type BlockedTimelineEditIntent,
type TimelineRangeSelection,
} from "./timelineEditing";
import {
defaultTimelineTheme,
@@ -29,6 +33,8 @@ const GUTTER = 32;
const TRACK_H = 72;
const RULER_H = 24;
const CLIP_Y = 3; // vertical inset inside track
const CLIP_HANDLE_W = 18;
const TIMELINE_SCROLL_BUFFER = 24;
interface TrackVisualStyle extends TimelineTrackStyle {
icon: ReactNode;
@@ -130,6 +136,10 @@ export function getTimelinePlayheadLeft(time: number, pixelsPerSecond: number):
return GUTTER + Math.max(0, time) * Math.max(0, pixelsPerSecond);
}
export function getTimelineCanvasHeight(trackCount: number): number {
return RULER_H + Math.max(0, trackCount) * TRACK_H + TIMELINE_SCROLL_BUFFER;
}
/* ── Component ──────────────────────────────────────────────────── */
interface TimelineProps {
/** Called when user seeks via ruler/track click or playhead drag */
@@ -157,6 +167,10 @@ interface TimelineProps {
"start" | "duration" | "playbackStart"
>,
) => Promise<void> | void;
onBlockedEditAttempt?: (
element: import("../store/playerStore").TimelineElement,
intent: BlockedTimelineEditIntent,
) => void;
theme?: Partial<TimelineTheme>;
}
@@ -185,6 +199,14 @@ interface ResizingClipState {
started: boolean;
}
interface BlockedClipState {
element: TimelineElement;
intent: BlockedTimelineEditIntent;
originClientX: number;
originClientY: number;
started: boolean;
}
export const Timeline = memo(function Timeline({
onSeek,
onDrillDown,
@@ -193,6 +215,7 @@ export const Timeline = memo(function Timeline({
onFileDrop,
onMoveElement,
onResizeElement,
onBlockedEditAttempt,
theme: themeOverrides,
}: TimelineProps = {}) {
const theme = useMemo(() => ({ ...defaultTimelineTheme, ...themeOverrides }), [themeOverrides]);
@@ -210,6 +233,11 @@ export const Timeline = memo(function Timeline({
const scrollRef = useRef<HTMLDivElement>(null);
const [hoveredClip, setHoveredClip] = useState<string | null>(null);
const isDragging = useRef(false);
const shiftClickClipRef = useRef<{
element: TimelineElement;
anchorX: number;
anchorY: number;
} | null>(null);
// Range selection (Shift+drag)
const [shiftHeld, setShiftHeld] = useState(false);
useMountEffect(() => {
@@ -227,18 +255,14 @@ export const Timeline = memo(function Timeline({
});
const isRangeSelecting = useRef(false);
const rangeAnchorTime = useRef(0);
const [rangeSelection, setRangeSelection] = useState<{
start: number;
end: number;
anchorX: number;
anchorY: number;
} | null>(null);
const [rangeSelection, setRangeSelection] = useState<TimelineRangeSelection | 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 blockedClipRef = useRef<BlockedClipState | null>(null);
const onMoveElementRef = useRef(onMoveElement);
onMoveElementRef.current = onMoveElement;
const onResizeElementRef = useRef(onResizeElement);
@@ -546,6 +570,7 @@ export const Timeline = memo(function Timeline({
const handleWindowPointerMove = (e: PointerEvent) => {
const drag = draggedClipRef.current;
const resize = resizingClipRef.current;
const blocked = blockedClipRef.current;
if (resize) {
const distance = Math.abs(e.clientX - resize.originClientX);
if (!resize.started && distance < 2) return;
@@ -561,6 +586,8 @@ export const Timeline = memo(function Timeline({
Math.max(resize.element.playbackRate ?? 1, 0.1),
)
: Number.POSITIVE_INFINITY;
const normalizedTag = resize.element.tag.toLowerCase();
const canSeedPlaybackStart = normalizedTag === "audio" || normalizedTag === "video";
const nextResize = resolveTimelineResize(
{
start: resize.element.start,
@@ -569,7 +596,10 @@ export const Timeline = memo(function Timeline({
pixelsPerSecond: ppsRef.current,
minStart: 0,
maxEnd: Math.min(durationRef.current, resize.element.start + sourceRemaining),
playbackStart: resize.element.playbackStart,
playbackStart:
resize.edge === "start" && canSeedPlaybackStart
? (resize.element.playbackStart ?? 0)
: resize.element.playbackStart,
playbackRate: resize.element.playbackRate,
},
resize.edge,
@@ -589,6 +619,23 @@ export const Timeline = memo(function Timeline({
);
return;
}
if (blocked) {
const distance = Math.hypot(
e.clientX - blocked.originClientX,
e.clientY - blocked.originClientY,
);
const threshold = blocked.intent === "move" ? 4 : 2;
if (!blocked.started && distance < threshold) return;
if (!blocked.started) {
blocked.started = true;
blockedClipRef.current = blocked;
suppressClickRef.current = true;
setShowPopover(false);
setRangeSelection(null);
onBlockedEditAttempt?.(blocked.element, blocked.intent);
}
return;
}
if (!drag) return;
const distance = Math.hypot(e.clientX - drag.originClientX, e.clientY - drag.originClientY);
@@ -644,6 +691,14 @@ export const Timeline = memo(function Timeline({
return;
}
const blocked = blockedClipRef.current;
if (blocked) {
blockedClipRef.current = null;
if (!blocked.started) return;
clearSuppressedClick();
return;
}
const drag = draggedClipRef.current;
if (!drag) return;
draggedClipRef.current = null;
@@ -707,6 +762,7 @@ export const Timeline = memo(function Timeline({
return;
}
shiftClickClipRef.current = null;
// Normal click on a clip — let the clip handle it
if ((e.target as HTMLElement).closest("[data-clip]")) return;
(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId);
@@ -740,8 +796,14 @@ export const Timeline = memo(function Timeline({
const handlePointerUp = useCallback(() => {
if (isRangeSelecting.current) {
isRangeSelecting.current = false;
// Show popover if range is meaningful (> 0.2s)
const pendingShiftClick = shiftClickClipRef.current;
shiftClickClipRef.current = null;
setRangeSelection((prev) => {
if (prev && pendingShiftClick && Math.abs(prev.end - prev.start) <= 0.2) {
setShowPopover(true);
return buildClipRangeSelection(pendingShiftClick.element, pendingShiftClick);
}
// Show popover if range is meaningful (> 0.2s)
if (prev && Math.abs(prev.end - prev.start) > 0.2) {
setShowPopover(true);
return prev;
@@ -869,7 +931,7 @@ export const Timeline = memo(function Timeline({
);
}
const totalH = RULER_H + displayTrackOrder.length * TRACK_H;
const totalH = getTimelineCanvasHeight(displayTrackOrder.length);
const draggedElement = draggedClip?.element ?? null;
const activeDraggedElement =
draggedClip?.started === true && draggedElement
@@ -990,7 +1052,7 @@ export const Timeline = memo(function Timeline({
{shiftHeld && !rangeSelection && (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-10">
<span className="text-[9px] font-medium" style={{ color: theme.textSecondary }}>
Drag to select range
Drag or click a clip to edit range
</span>
</div>
)}
@@ -1108,6 +1170,7 @@ export const Timeline = memo(function Timeline({
if (edge === "start" && !capabilities.canTrimStart) return;
if (edge === "end" && !capabilities.canTrimEnd) return;
e.stopPropagation();
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
setResizingClip({
@@ -1121,16 +1184,41 @@ export const Timeline = memo(function Timeline({
});
}}
onPointerDown={(e) => {
if (
e.button !== 0 ||
e.shiftKey ||
!onMoveElement ||
!capabilities.canMove
)
if (e.button !== 0) return;
if (e.shiftKey) {
shiftClickClipRef.current = {
element: el,
anchorX: e.clientX,
anchorY: e.clientY,
};
return;
}
const target = e.currentTarget as HTMLElement;
const rect = target.getBoundingClientRect();
const blockedIntent = resolveBlockedTimelineEditIntent({
width: rect.width,
offsetX: e.clientX - rect.left,
handleWidth: CLIP_HANDLE_W,
capabilities,
});
if (
blockedIntent &&
((blockedIntent === "move" && onMoveElement) ||
(blockedIntent !== "move" && onResizeElement))
) {
blockedClipRef.current = {
element: el,
intent: blockedIntent,
originClientX: e.clientX,
originClientY: e.clientY,
started: false,
};
return;
}
if (!onMoveElement || !capabilities.canMove) return;
blockedClipRef.current = null;
setShowPopover(false);
setRangeSelection(null);
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setDraggedClip({
element: el,
originClientX: e.clientX,
@@ -1270,7 +1358,7 @@ export const Timeline = memo(function Timeline({
Shift
</kbd>
<span className="text-[9px]" style={{ color: theme.textSecondary }}>
+ drag to edit range
+ drag/click to edit range
</span>
</div>
</div>
@@ -147,7 +147,7 @@ export const TimelineClip = memo(function TimelineClip({
top: 0,
bottom: 0,
width: 18,
opacity: showHandles ? 1 : 0,
opacity: showHandles && capabilities.canTrimEnd ? 1 : 0,
pointerEvents: onResizeStart && capabilities.canTrimEnd ? "auto" : "none",
zIndex: 4,
transition: "opacity 120ms ease-out",
@@ -1,5 +1,6 @@
import { describe, expect, it } from "vitest";
import {
buildClipRangeSelection,
buildPromptCopyText,
buildTimelineElementAgentPrompt,
buildTimelineAgentPrompt,
@@ -7,6 +8,7 @@ import {
canOffsetTrimClipStart,
getTimelineEditCapabilities,
hasPatchableTimelineTarget,
resolveBlockedTimelineEditIntent,
resolveTimelineAutoScroll,
resolveTimelineMove,
resolveTimelineResize,
@@ -199,6 +201,14 @@ describe("canOffsetTrimClipStart", () => {
).toBe(true);
});
it("allows front trim for plain audio clips even before media-start exists", () => {
expect(
canOffsetTrimClipStart({
tag: "audio",
}),
).toBe(true);
});
it("blocks front trim for generic motion clips", () => {
expect(
canOffsetTrimClipStart({
@@ -223,6 +233,21 @@ describe("hasPatchableTimelineTarget", () => {
});
describe("getTimelineEditCapabilities", () => {
it("does not disable editable audio just because it spans multiple scenes", () => {
expect(
getTimelineEditCapabilities({
tag: "audio",
duration: 8,
selector: "#voiceover",
sourceDuration: 8,
}),
).toEqual({
canMove: true,
canTrimStart: true,
canTrimEnd: true,
});
});
it("disables move and trims for generic motion clips even when patchable", () => {
expect(
getTimelineEditCapabilities({
@@ -299,6 +324,111 @@ describe("getTimelineEditCapabilities", () => {
});
});
describe("resolveBlockedTimelineEditIntent", () => {
it("returns move when the clip body is blocked", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 80,
handleWidth: 18,
capabilities: {
canMove: false,
canTrimStart: false,
canTrimEnd: false,
},
}),
).toBe("move");
});
it("returns resize-start when the left edge is blocked", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 8,
handleWidth: 18,
capabilities: {
canMove: false,
canTrimStart: false,
canTrimEnd: true,
},
}),
).toBe("resize-start");
});
it("returns resize-end when the right edge is blocked", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 154,
handleWidth: 18,
capabilities: {
canMove: false,
canTrimStart: true,
canTrimEnd: false,
},
}),
).toBe("resize-end");
});
it("does not block the left edge when the clip can still be moved", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 8,
handleWidth: 18,
capabilities: {
canMove: true,
canTrimStart: false,
canTrimEnd: true,
},
}),
).toBe(null);
});
it("does not swallow the full surface of a narrow movable clip", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 12,
offsetX: 6,
handleWidth: 18,
capabilities: {
canMove: true,
canTrimStart: false,
canTrimEnd: false,
},
}),
).toBe(null);
});
it("returns null when the relevant edit is supported", () => {
expect(
resolveBlockedTimelineEditIntent({
width: 160,
offsetX: 8,
handleWidth: 18,
capabilities: {
canMove: true,
canTrimStart: true,
canTrimEnd: true,
},
}),
).toBe(null);
});
});
describe("buildClipRangeSelection", () => {
it("anchors the full clip range at the click position", () => {
expect(
buildClipRangeSelection({ start: 1.25, duration: 3.5 }, { anchorX: 320, anchorY: 180 }),
).toEqual({
start: 1.25,
end: 4.75,
anchorX: 320,
anchorY: 180,
});
});
});
describe("resolveTimelineAutoScroll", () => {
it("does not scroll when the pointer stays away from the edges", () => {
expect(
@@ -420,6 +550,25 @@ describe("resolveTimelineResize", () => {
).toEqual({ start: 1.5, duration: 2.5, playbackStart: 1 });
});
it("can seed front trim from an implicit zero playback start", () => {
expect(
resolveTimelineResize(
{
start: 0,
duration: 8,
originClientX: 100,
pixelsPerSecond: 100,
minStart: 0,
maxEnd: 8,
playbackStart: 0,
playbackRate: 1,
},
"start",
200,
),
).toEqual({ start: 1, duration: 7, playbackStart: 1 });
});
it("prevents extending media left past available source before media-start", () => {
expect(
resolveTimelineResize(
@@ -175,6 +175,15 @@ export interface TimelineEditCapabilities {
canTrimEnd: boolean;
}
export type BlockedTimelineEditIntent = "move" | "resize-start" | "resize-end";
export interface TimelineRangeSelection {
start: number;
end: number;
anchorX: number;
anchorY: number;
}
function isDeterministicTimelineWindow(input: {
tag: string;
compositionSrc?: string;
@@ -207,12 +216,7 @@ export function canOffsetTrimClipStart(input: {
if (input.playbackStartAttr != null) return true;
if (input.playbackStart != null) return true;
const normalizedTag = input.tag.toLowerCase();
if (!["video", "audio"].includes(normalizedTag)) return false;
return (
input.sourceDuration != null &&
Number.isFinite(input.sourceDuration) &&
input.sourceDuration > 0
);
return ["video", "audio"].includes(normalizedTag);
}
export function getTimelineEditCapabilities(input: {
@@ -235,6 +239,41 @@ export function getTimelineEditCapabilities(input: {
};
}
export function resolveBlockedTimelineEditIntent(input: {
width: number;
offsetX: number;
handleWidth: number;
capabilities: TimelineEditCapabilities;
}): BlockedTimelineEditIntent | null {
if (input.capabilities.canMove) {
return null;
}
const safeWidth = Math.max(0, input.width);
const safeOffsetX = clamp(input.offsetX, 0, safeWidth);
const safeHandleWidth = Math.max(0, input.handleWidth);
if (safeOffsetX <= safeHandleWidth && !input.capabilities.canTrimStart) {
return "resize-start";
}
if (safeOffsetX >= Math.max(0, safeWidth - safeHandleWidth) && !input.capabilities.canTrimEnd) {
return "resize-end";
}
return "move";
}
export function buildClipRangeSelection(
clip: { start: number; duration: number },
anchor: { anchorX: number; anchorY: number },
): TimelineRangeSelection {
return {
start: clip.start,
end: clip.start + clip.duration,
anchorX: anchor.anchorX,
anchorY: anchor.anchorY,
};
}
export function buildTimelineAgentPrompt({
rangeStart,
rangeEnd,
@@ -559,7 +559,11 @@ export function useTimelinePlayer() {
// Convert a runtime timeline message (from iframe postMessage) into TimelineElements
const processTimelineMessage = useCallback(
(data: { clips: ClipManifestClip[]; durationInFrames: number }) => {
(data: {
clips: ClipManifestClip[];
durationInFrames: number;
scenes?: Array<{ id: string; label: string; start: number; duration: number }>;
}) => {
if (!data.clips || data.clips.length === 0) {
return;
}
+34 -6
View File
@@ -14,6 +14,7 @@ import type {
ResolvedProject,
RenderJobState,
} from "@hyperframes/core/studio-api";
import { createRetryingModuleLoader, ensureProducerDist } from "./vite.producer";
// ── Shared Puppeteer browser ─────────────────────────────────────────────────
@@ -51,6 +52,19 @@ const THUMBNAIL_CACHE_VERSION = "v2";
function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAdapter {
// Lazy-load the bundler via Vite's SSR module loader
let _bundler: ((dir: string) => Promise<string>) | null = null;
let _producerModulePromise: Promise<{
createRenderJob: (config: {
fps: 24 | 30 | 60;
quality: "draft" | "standard" | "high";
format: string;
}) => unknown;
executeRenderJob: (
job: unknown,
projectDir: string,
outputPath: string,
onProgress?: (job: { progress: number; currentStage?: string }) => void,
) => Promise<void>;
}> | null = null;
const getBundler = async () => {
if (!_bundler) {
try {
@@ -64,6 +78,25 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
return _bundler;
};
const getProducerModule = async () => {
if (!_producerModulePromise) {
_producerModulePromise = createRetryingModuleLoader(async () => {
const { built } = ensureProducerDist({
studioDir: __dirname,
env: process.env,
});
if (built) {
console.warn(
"[Studio] @hyperframes/producer dist missing; building producer package for local renders...",
);
}
const producerPkg = "@hyperframes/producer";
return await import(/* @vite-ignore */ producerPkg);
})();
}
return _producerModulePromise();
};
return {
listProjects() {
const sessionsDir = resolve(dataDir, "../sessions");
@@ -167,12 +200,7 @@ function createViteAdapter(dataDir: string, server: ViteDevServer): StudioApiAda
].find((p) => existsSync(p));
if (systemChrome) process.env.PRODUCER_HEADLESS_SHELL_PATH = systemChrome;
}
// Dynamic import hidden from esbuild's static analysis (vite.config.ts is
// bundled by esbuild at startup; a bare specifier would fail the externalize-deps plugin).
const producerPkg = "@hyperframes/producer";
const { createRenderJob, executeRenderJob } = await import(
/* @vite-ignore */ producerPkg
);
const { createRenderJob, executeRenderJob } = await getProducerModule();
const job = createRenderJob({
fps: opts.fps as 24 | 30 | 60,
quality: opts.quality as "draft" | "standard" | "high",
+86
View File
@@ -0,0 +1,86 @@
import { resolve } from "node:path";
import { describe, expect, it, vi } from "vitest";
import {
createRetryingModuleLoader,
ensureProducerDist,
resolveProducerDistEntry,
resolveWorkspaceRoot,
} from "./vite.producer";
describe("ensureProducerDist", () => {
it("does nothing when the producer dist entry already exists", () => {
const exec = vi.fn();
const result = ensureProducerDist({
studioDir: "/repo/packages/studio",
existsSyncImpl: () => true,
execFileSyncImpl: exec as never,
});
expect(result).toEqual({
built: false,
producerDistEntry: resolve("/repo/packages/producer/dist/index.js"),
});
expect(exec).not.toHaveBeenCalled();
});
it("builds producer when the dist entry is missing", () => {
const exec = vi.fn();
const env = { TEST: "1" } as NodeJS.ProcessEnv;
const result = ensureProducerDist({
studioDir: "/repo/packages/studio",
existsSyncImpl: () => false,
execFileSyncImpl: exec as never,
env,
});
expect(result).toEqual({
built: true,
producerDistEntry: resolve("/repo/packages/producer/dist/index.js"),
});
expect(exec).toHaveBeenCalledWith(
"bun",
["run", "--filter", "@hyperframes/producer", "build"],
{
cwd: resolve("/repo"),
stdio: "pipe",
env,
},
);
});
});
describe("producer path helpers", () => {
it("resolves the producer dist entry relative to studio", () => {
expect(resolveProducerDistEntry("/repo/packages/studio")).toBe(
resolve("/repo/packages/producer/dist/index.js"),
);
});
it("resolves the workspace root relative to studio", () => {
expect(resolveWorkspaceRoot("/repo/packages/studio")).toBe(resolve("/repo"));
});
});
describe("createRetryingModuleLoader", () => {
it("retries after an initial load failure instead of caching the rejection", async () => {
const load = vi
.fn<() => Promise<string>>()
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValueOnce("ok");
const getModule = createRetryingModuleLoader(load);
await expect(getModule()).rejects.toThrow("boom");
await expect(getModule()).resolves.toBe("ok");
expect(load).toHaveBeenCalledTimes(2);
});
it("reuses the same promise after a successful load", async () => {
const load = vi.fn<() => Promise<string>>().mockResolvedValue("ok");
const getModule = createRetryingModuleLoader(load);
await expect(getModule()).resolves.toBe("ok");
await expect(getModule()).resolves.toBe("ok");
expect(load).toHaveBeenCalledTimes(1);
});
});
+47
View File
@@ -0,0 +1,47 @@
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
export function resolveProducerDistEntry(studioDir: string): string {
return resolve(studioDir, "../producer/dist/index.js");
}
export function resolveWorkspaceRoot(studioDir: string): string {
return resolve(studioDir, "../..");
}
export function ensureProducerDist(opts: {
studioDir: string;
existsSyncImpl?: (path: string) => boolean;
execFileSyncImpl?: typeof execFileSync;
env?: NodeJS.ProcessEnv;
}): { built: boolean; producerDistEntry: string } {
const producerDistEntry = resolveProducerDistEntry(opts.studioDir);
const exists = opts.existsSyncImpl ?? existsSync;
if (exists(producerDistEntry)) {
return { built: false, producerDistEntry };
}
const exec = opts.execFileSyncImpl ?? execFileSync;
exec("bun", ["run", "--filter", "@hyperframes/producer", "build"], {
cwd: resolveWorkspaceRoot(opts.studioDir),
stdio: "pipe",
env: opts.env,
});
return { built: true, producerDistEntry };
}
export function createRetryingModuleLoader<T>(load: () => Promise<T>): () => Promise<T> {
let promise: Promise<T> | null = null;
return async () => {
if (!promise) {
promise = load().catch((error) => {
promise = null;
throw error;
});
}
return promise;
};
}