From 0209e94918b1adc20da53f5bff36f6486dc5cf47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 12:06:35 -0400 Subject: [PATCH 1/2] fix: gate studio timeline actions by capability --- .../studio/src/player/components/Timeline.tsx | 14 ++++- .../src/player/components/TimelineClip.tsx | 21 ++++--- .../player/components/timelineEditing.test.ts | 62 +++++++++++++++++++ .../src/player/components/timelineEditing.ts | 28 +++++++++ 4 files changed, 112 insertions(+), 13 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 4c94e822..e19446a3 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -10,7 +10,7 @@ import { formatTime } from "../lib/time"; import { TimelineClip } from "./TimelineClip"; import { EditPopover } from "./EditModal"; import { - canOffsetTrimClipStart, + getTimelineEditCapabilities, resolveTimelineAutoScroll, resolveTimelineMove, resolveTimelineResize, @@ -1082,6 +1082,7 @@ export const Timeline = memo(function Timeline({ {els.map((el, i) => { const clipStyle = getStyle(el.tag); const elementKey = el.key ?? el.id; + const capabilities = getTimelineEditCapabilities(el); const isSelected = selectedElementId === elementKey; const isComposition = !!el.compositionSrc; const clipKey = `${elementKey}-${i}`; @@ -1110,7 +1111,8 @@ export const Timeline = memo(function Timeline({ onHoverEnd={() => setHoveredClip(null)} onResizeStart={(edge, e) => { if (e.button !== 0 || e.shiftKey || !onResizeElement) return; - if (edge === "start" && !canOffsetTrimClipStart(el)) return; + if (edge === "start" && !capabilities.canTrimStart) return; + if (edge === "end" && !capabilities.canTrimEnd) return; e.stopPropagation(); setShowPopover(false); setRangeSelection(null); @@ -1125,7 +1127,13 @@ export const Timeline = memo(function Timeline({ }); }} onPointerDown={(e) => { - if (e.button !== 0 || e.shiftKey || !onMoveElement) return; + if ( + e.button !== 0 || + e.shiftKey || + !onMoveElement || + !capabilities.canMove + ) + return; setShowPopover(false); setRangeSelection(null); const rect = (e.currentTarget as HTMLElement).getBoundingClientRect(); diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index 10058884..e908811e 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -4,7 +4,7 @@ import type { TimelineTrackStyle } from "./timelineTheme"; import { memo, type ReactNode } from "react"; import type { TimelineElement } from "../store/playerStore"; import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme"; -import { canOffsetTrimClipStart } from "./timelineEditing"; +import { getTimelineEditCapabilities } from "./timelineEditing"; interface TimelineClipProps { el: TimelineElement; @@ -60,7 +60,7 @@ export const TimelineClip = memo(function TimelineClip({ : isHovered ? theme.clipShadowHover : theme.clipShadow; - const canTrimStart = canOffsetTrimClipStart(el); + const capabilities = getTimelineEditCapabilities(el); const showHandles = handleOpacity > 0.01; return ( @@ -87,7 +87,7 @@ export const TimelineClip = memo(function TimelineClip({ transition: "border-color 120ms ease-out, box-shadow 140ms ease-out, background 140ms ease-out", zIndex: isDragging ? 20 : isSelected ? 10 : isHovered ? 5 : 1, - cursor: "grab", + cursor: capabilities.canMove ? "grab" : "default", transform: isDragging ? "translateY(-1px)" : undefined, }} title={ @@ -111,13 +111,13 @@ export const TimelineClip = memo(function TimelineClip({ top: 0, bottom: 0, width: 18, - opacity: showHandles && canTrimStart ? 1 : 0, - pointerEvents: onResizeStart && canTrimStart ? "auto" : "none", + opacity: showHandles && capabilities.canTrimStart ? 1 : 0, + pointerEvents: onResizeStart && capabilities.canTrimStart ? "auto" : "none", zIndex: 4, transition: "opacity 120ms ease-out", cursor: "col-resize", background: - showHandles && canTrimStart + showHandles && capabilities.canTrimStart ? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)` : "transparent", }} @@ -148,13 +148,14 @@ export const TimelineClip = memo(function TimelineClip({ bottom: 0, width: 18, opacity: showHandles ? 1 : 0, - pointerEvents: onResizeStart ? "auto" : "none", + pointerEvents: onResizeStart && capabilities.canTrimEnd ? "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", + background: + showHandles && capabilities.canTrimEnd + ? `linear-gradient(270deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)` + : "transparent", }} >
{ }); }); +describe("hasPatchableTimelineTarget", () => { + it("returns true when the clip has a DOM id", () => { + expect(hasPatchableTimelineTarget({ domId: "hero-card" })).toBe(true); + }); + + it("returns true when the clip has a selector", () => { + expect(hasPatchableTimelineTarget({ selector: ".hero-card" })).toBe(true); + }); + + it("returns false when the clip has no stable patch target", () => { + expect(hasPatchableTimelineTarget({})).toBe(false); + }); +}); + +describe("getTimelineEditCapabilities", () => { + it("allows move and end trim for generic patchable motion clips", () => { + expect( + getTimelineEditCapabilities({ + tag: "section", + duration: 2, + selector: ".feature-card", + }), + ).toEqual({ + canMove: true, + canTrimStart: false, + canTrimEnd: true, + }); + }); + + it("allows move and both trims for patchable media clips with offset support", () => { + expect( + getTimelineEditCapabilities({ + tag: "video", + duration: 2, + selector: "#media-card", + playbackStartAttr: "media-start", + sourceDuration: 10, + }), + ).toEqual({ + canMove: true, + canTrimStart: true, + canTrimEnd: true, + }); + }); + + it("disables all timeline edits for clips without a patchable target", () => { + expect( + getTimelineEditCapabilities({ + tag: "video", + duration: 2, + sourceDuration: 10, + }), + ).toEqual({ + canMove: false, + canTrimStart: false, + canTrimEnd: false, + }); + }); +}); + describe("resolveTimelineAutoScroll", () => { it("does not scroll when the pointer stays away from the edges", () => { expect( diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index 8e592fe5..f9265d73 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -169,6 +169,16 @@ export interface TimelinePromptElement { track: number; } +export interface TimelineEditCapabilities { + canMove: boolean; + canTrimStart: boolean; + canTrimEnd: boolean; +} + +export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean { + return Boolean(input.domId || input.selector); +} + export function canOffsetTrimClipStart(input: { tag: string; playbackStart?: number; @@ -186,6 +196,24 @@ export function canOffsetTrimClipStart(input: { ); } +export function getTimelineEditCapabilities(input: { + tag: string; + duration: number; + domId?: string; + selector?: string; + playbackStart?: number; + playbackStartAttr?: "media-start" | "playback-start"; + sourceDuration?: number; +}): TimelineEditCapabilities { + const canPatch = hasPatchableTimelineTarget(input); + const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0; + return { + canMove: canPatch, + canTrimEnd: canPatch && hasFiniteDuration, + canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input), + }; +} + export function buildTimelineAgentPrompt({ rangeStart, rangeEnd, From 770dd5e5cf75aa58f804f4bbc5f429952dddcdae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 12:40:11 -0400 Subject: [PATCH 2/2] fix: route unsupported timeline edits to agent prompts --- .../studio/src/player/components/Timeline.tsx | 43 ++++++++++++++ .../player/components/timelineEditing.test.ts | 54 ++++++++++++++++- .../src/player/components/timelineEditing.ts | 59 ++++++++++++++++++- 3 files changed, 151 insertions(+), 5 deletions(-) diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index e19446a3..d68b256f 100644 --- a/packages/studio/src/player/components/Timeline.tsx +++ b/packages/studio/src/player/components/Timeline.tsx @@ -10,6 +10,7 @@ import { formatTime } from "../lib/time"; import { TimelineClip } from "./TimelineClip"; import { EditPopover } from "./EditModal"; import { + buildTimelineElementAgentPrompt, getTimelineEditCapabilities, resolveTimelineAutoScroll, resolveTimelineMove, @@ -245,6 +246,7 @@ export const Timeline = memo(function Timeline({ onResizeElementRef.current = onResizeElement; const suppressClickRef = useRef(false); const [showPopover, setShowPopover] = useState(false); + const [copiedAgentElementKey, setCopiedAgentElementKey] = useState(null); const [viewportWidth, setViewportWidth] = useState(0); const roRef = useRef(null); @@ -896,6 +898,46 @@ export const Timeline = memo(function Timeline({ } : null; const renderClipChildren = (element: TimelineElement, clipStyle: TrackVisualStyle) => { + const capabilities = getTimelineEditCapabilities(element); + const elementKey = element.key ?? element.id; + const needsAgentFallback = + !capabilities.canMove && !capabilities.canTrimStart && !capabilities.canTrimEnd; + const agentButton = needsAgentFallback ? ( + + ) : null; return ( <> {renderClipOverlay?.(element)} @@ -941,6 +983,7 @@ export const Timeline = memo(function Timeline({
)} + {agentButton} ); }; diff --git a/packages/studio/src/player/components/timelineEditing.test.ts b/packages/studio/src/player/components/timelineEditing.test.ts index 524dd379..703a117f 100644 --- a/packages/studio/src/player/components/timelineEditing.test.ts +++ b/packages/studio/src/player/components/timelineEditing.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { buildPromptCopyText, + buildTimelineElementAgentPrompt, buildTimelineAgentPrompt, buildTrackZIndexMap, canOffsetTrimClipStart, @@ -222,7 +223,7 @@ describe("hasPatchableTimelineTarget", () => { }); describe("getTimelineEditCapabilities", () => { - it("allows move and end trim for generic patchable motion clips", () => { + it("disables move and trims for generic motion clips even when patchable", () => { expect( getTimelineEditCapabilities({ tag: "section", @@ -230,9 +231,9 @@ describe("getTimelineEditCapabilities", () => { selector: ".feature-card", }), ).toEqual({ - canMove: true, + canMove: false, canTrimStart: false, - canTrimEnd: true, + canTrimEnd: false, }); }); @@ -252,6 +253,37 @@ describe("getTimelineEditCapabilities", () => { }); }); + it("treats wrapped media clips with media metadata as deterministic", () => { + expect( + getTimelineEditCapabilities({ + tag: "div", + duration: 2, + selector: "#media-card", + playbackStartAttr: "media-start", + sourceDuration: 10, + }), + ).toEqual({ + canMove: true, + canTrimStart: true, + canTrimEnd: true, + }); + }); + + it("allows move and end trim for patchable composition hosts", () => { + expect( + getTimelineEditCapabilities({ + tag: "div", + duration: 3, + selector: '[data-composition-id="intro"]', + compositionSrc: "compositions/intro.html", + }), + ).toEqual({ + canMove: true, + canTrimStart: false, + canTrimEnd: true, + }); + }); + it("disables all timeline edits for clips without a patchable target", () => { expect( getTimelineEditCapabilities({ @@ -335,6 +367,22 @@ describe("buildTimelineAgentPrompt", () => { }); }); +describe("buildTimelineElementAgentPrompt", () => { + it("includes the clip context and guidance for agent-based edits", () => { + expect( + buildTimelineElementAgentPrompt({ + id: "feature-card", + tag: "section", + start: 1.4, + duration: 1.6, + track: 1, + sourceFile: "index.html", + selector: "#feature-card", + }), + ).toContain("If this clip is animated with GSAP"); + }); +}); + describe("resolveTimelineResize", () => { it("shrinks clip duration from the right edge", () => { expect( diff --git a/packages/studio/src/player/components/timelineEditing.ts b/packages/studio/src/player/components/timelineEditing.ts index f9265d73..ebff9253 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -175,6 +175,25 @@ export interface TimelineEditCapabilities { canTrimEnd: boolean; } +function isDeterministicTimelineWindow(input: { + tag: string; + compositionSrc?: string; + playbackStartAttr?: "media-start" | "playback-start"; + sourceDuration?: number; +}): boolean { + if (input.compositionSrc) return true; + if (input.playbackStartAttr != null) return true; + if ( + input.sourceDuration != null && + Number.isFinite(input.sourceDuration) && + input.sourceDuration > 0 + ) { + return true; + } + const normalizedTag = input.tag.toLowerCase(); + return ["video", "audio", "img"].includes(normalizedTag); +} + export function hasPatchableTimelineTarget(input: { domId?: string; selector?: string }): boolean { return Boolean(input.domId || input.selector); } @@ -201,15 +220,17 @@ export function getTimelineEditCapabilities(input: { duration: number; domId?: string; selector?: string; + compositionSrc?: string; playbackStart?: number; playbackStartAttr?: "media-start" | "playback-start"; sourceDuration?: number; }): TimelineEditCapabilities { const canPatch = hasPatchableTimelineTarget(input); const hasFiniteDuration = Number.isFinite(input.duration) && input.duration > 0; + const hasDeterministicWindow = isDeterministicTimelineWindow(input); return { - canMove: canPatch, - canTrimEnd: canPatch && hasFiniteDuration, + canMove: canPatch && hasDeterministicWindow, + canTrimEnd: canPatch && hasFiniteDuration && hasDeterministicWindow, canTrimStart: canPatch && hasFiniteDuration && canOffsetTrimClipStart(input), }; } @@ -254,6 +275,40 @@ export function buildPromptCopyText(prompt: string): string { return prompt.trim(); } +export function buildTimelineElementAgentPrompt(element: { + id: string; + tag: string; + start: number; + duration: number; + track: number; + sourceFile?: string; + selector?: string; + compositionSrc?: string; +}): string { + const lines = [ + "Studio cannot directly move or resize this timeline clip because its visible timing is not fully controlled by patchable HTML timing attributes.", + "", + "Please update the source so the clip's actual visible timing stays consistent with the authored timeline.", + "", + "Clip:", + `- id: ${element.id}`, + `- tag: ${element.tag}`, + `- time: ${formatTime(element.start)} to ${formatTime(element.start + element.duration)}`, + `- track: ${element.track}`, + ]; + + if (element.sourceFile) lines.push(`- source file: ${element.sourceFile}`); + if (element.selector) lines.push(`- selector: ${element.selector}`); + if (element.compositionSrc) lines.push(`- composition src: ${element.compositionSrc}`); + + lines.push( + "", + "If this clip is animated with GSAP or another JS timeline, update the authored animation timing there as well instead of only changing data-start/data-duration.", + ); + + return lines.join("\n"); +} + export function formatTimelineAttributeNumber(value: number): string { return Number(roundToCentiseconds(value).toFixed(2)).toString(); }