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
@@ -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;
}