Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ import { formatTime } from "../lib/time";
import { TimelineClip } from "./TimelineClip";
import { EditPopover } from "./EditModal";
import {
canOffsetTrimClipStart,
buildTimelineElementAgentPrompt,
getTimelineEditCapabilities,
resolveTimelineAutoScroll,
resolveTimelineMove,
resolveTimelineResize,
Expand Down Expand Up @@ -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<string | null>(null);
const [viewportWidth, setViewportWidth] = useState(0);
const roRef = useRef<ResizeObserver | null>(null);

Expand Down Expand Up @@ -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 ? (
<button
type="button"
onPointerDown={(e) => e.stopPropagation()}
onClick={async (e) => {
e.stopPropagation();
const text = buildTimelineElementAgentPrompt(element);
try {
await navigator.clipboard.writeText(text);
} catch {
const ta = document.createElement("textarea");
ta.value = text;
document.body.appendChild(ta);
ta.select();
document.execCommand("copy");
document.body.removeChild(ta);
}
setCopiedAgentElementKey(elementKey);
window.setTimeout(() => {
setCopiedAgentElementKey((current) => (current === elementKey ? null : current));
}, 900);
}}
className="absolute bottom-2 right-2 z-[5] rounded-md px-1.5 py-0.5 text-[10px] font-medium leading-none transition-colors"
style={{
color: copiedAgentElementKey === elementKey ? "#86efac" : clipStyle.label,
background:
copiedAgentElementKey === elementKey ? "rgba(34,197,94,0.16)" : `${clipStyle.accent}1e`,
boxShadow:
copiedAgentElementKey === elementKey
? "inset 0 0 0 1px rgba(34,197,94,0.28)"
: `inset 0 0 0 1px ${clipStyle.accent}33`,
}}
>
{copiedAgentElementKey === elementKey ? "Copied!" : "Copy to Agent"}
</button>
) : null;
return (
<>
{renderClipOverlay?.(element)}
Expand Down Expand Up @@ -941,6 +983,7 @@ export const Timeline = memo(function Timeline({
</div>
)}
</div>
{agentButton}
</>
);
};
Expand Down Expand Up @@ -1082,6 +1125,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}`;
Expand Down Expand Up @@ -1110,7 +1154,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);
Expand All @@ -1125,7 +1170,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();
Expand Down
21 changes: 11 additions & 10 deletions packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand All @@ -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={
Expand All @@ -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",
}}
Expand Down Expand Up @@ -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",
}}
>
<div
Expand Down
110 changes: 110 additions & 0 deletions packages/studio/src/player/components/timelineEditing.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import { describe, expect, it } from "vitest";
import {
buildPromptCopyText,
buildTimelineElementAgentPrompt,
buildTimelineAgentPrompt,
buildTrackZIndexMap,
canOffsetTrimClipStart,
getTimelineEditCapabilities,
hasPatchableTimelineTarget,
resolveTimelineAutoScroll,
resolveTimelineMove,
resolveTimelineResize,
Expand Down Expand Up @@ -205,6 +208,97 @@ describe("canOffsetTrimClipStart", () => {
});
});

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("disables move and trims for generic motion clips even when patchable", () => {
expect(
getTimelineEditCapabilities({
tag: "section",
duration: 2,
selector: ".feature-card",
}),
).toEqual({
canMove: false,
canTrimStart: false,
canTrimEnd: false,
});
});

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("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({
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(
Expand Down Expand Up @@ -273,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(
Expand Down
Loading
Loading