fix: gate studio timeline actions by capability (#415)

## Summary
- gate timeline actions to clips Studio can control deterministically
- disable direct move/trim for generic GSAP-timed DOM clips
- add an in-clip `Copy to Agent` fallback for unsupported edits

## Why
Studio should only advertise timeline actions it can round-trip to source HTML with deterministic meaning.

This PR now follows that stricter rule:
- direct move/end-trim are only exposed for clips with a deterministic timeline window
- start trim is only exposed for clips with a real content-offset model
- unsupported motion clips now offer `Copy to Agent` so users still have a fast path to request source-level timing changes

In practice this means generic GSAP-authored DOM clips no longer pretend Studio can rewrite their visible timing just by patching `data-start` / `data-duration`.

## What changed
- added `hasPatchableTimelineTarget()` and `getTimelineEditCapabilities()` in `timelineEditing.ts`
- tightened deterministic-window detection so only media, images, and composition hosts keep direct move/end-trim controls
- kept wrapped media clips editable by recognizing real media metadata even when the host tag is a `div`
- updated `TimelineClip` / `Timeline` to guard interactions with the shared capability model
- added `buildTimelineElementAgentPrompt()` and a `Copy to Agent` fallback button for unsupported clips
- added focused tests for capability derivation and the agent-prompt helper

## Verification
### Automated
- `bun test packages/studio/src/player/components/timelineEditing.test.ts packages/studio/src/player/components/Timeline.test.ts packages/studio/src/player/store/playerStore.test.ts packages/studio/src/utils/sourcePatcher.test.ts`
- `bun run --filter @hyperframes/studio typecheck`
- `bunx oxlint packages/studio/src/player/components/Timeline.tsx packages/studio/src/player/components/timelineEditing.ts packages/studio/src/player/components/timelineEditing.test.ts packages/studio/src/player/components/TimelineClip.tsx`
- `bunx oxfmt --check packages/studio/src/player/components/Timeline.tsx packages/studio/src/player/components/timelineEditing.ts packages/studio/src/player/components/timelineEditing.test.ts packages/studio/src/player/components/TimelineClip.tsx`

### Browser
Verified in Studio with live browser automation against `http://127.0.0.1:4175/#project/timeline-edit-playground`:
- generic GSAP-timed clips (`feature-card`, `title-card`, `prompt-card`) show `Copy to Agent` and no direct move/trim affordances
- wrapped media (`media-card`) still exposes direct controls and remains draggable
- the local playground timings were realigned to match the authored GSAP positions, so preview visibility now matches the timeline windows during manual testing

Recording artifacts used during verification:
- `/tmp/timeline-capabilities-proof/capabilities-flow.webm`
- `/tmp/timeline-capabilities-proof/capabilities-agent-flow.webm`
This commit is contained in:
Miguel Ángel
2026-04-22 18:44:17 +02:00
committed by GitHub
parent c46abf9fa2
commit 5a4dd8bec1
4 changed files with 258 additions and 13 deletions
@@ -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",
}}
>
<div