From 2cf3558f8ea67c21ded943d3cd02b4415e608aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Miguel=20=C3=81ngel?= Date: Wed, 22 Apr 2026 17:11:15 +0200 Subject: [PATCH] fix(studio): only expose front trim for offsettable clips (#413) ## Summary - hide the leading trim handle for timeline clips that cannot offset their own content - keep leading trim available for media clips backed by playback offset metadata or source duration - map visual row priority like a normal timeline editor: top timeline rows render above lower rows ## Why This Is Needed Generic GSAP/DOM timeline clips do not have a playback-offset model like media clips do. That means a left trim affordance on those clips is misleading today: - users reasonably expect front trim to remove the beginning of the animation - the current model can only shorten the clip window, not start the motion halfway through Instead of exposing a control that implies unsupported behavior, this PR keeps true front trim only on clips that can actually offset their content. The PR also fixes the stacking convention so the timeline matches normal editor expectations: - visually higher track row = higher render priority - visually lower track row = lower render priority ## Current Flow By Element Type ### Generic motion / DOM clips Examples: `section`, `div`, `aside`, GSAP-driven cards and overlays. Current supported flow: - drag the whole clip horizontally to change `data-start` - right-trim to shorten the end of the clip window - move between tracks to change `data-track-index` Not supported yet: - true front trim that removes the beginning of the animation itself Behavior after this PR: - no interactive left trim handle is shown - right trim still works - horizontal move still works ### Media clips Examples: `video` / `audio` clips, or wrappers carrying `data-media-start` / `data-playback-start`. Current supported flow: - drag the whole clip horizontally to change `data-start` - left trim advances clip start and playback offset together - right trim shortens `data-duration` Behavior after this PR: - both left and right trim handles remain available - left trim persists `data-start` plus `data-media-start` / `data-playback-start` - right trim persists `data-duration` ## Z-Index Rule This PR now follows the normal timeline-editor convention: - top visual row on the timeline = highest `z-index` - lower visual rows = lower `z-index` Concretely, because Studio renders tracks in ascending numeric order from top to bottom, lower numeric track values now map to higher `z-index` values. ## Validation ### 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` ### Browser verification Verified with `agent-browser` on `timeline-edit-playground`: - generic motion clips no longer expose an interactive left trim handle - media clips still expose both trim handles - left trim on `media-card` persisted `data-start` and `data-media-start` - right trim on `media-card` persisted `data-duration` only - moving `title-card` from the bottom row to the top row persisted the highest `z-index` for the top-row clips - recordings: - `/tmp/trim-fix-artifacts/trim-flow.webm` - `/tmp/trim-fix-artifacts/z-index-flow.webm` --- docs/docs.json | 1 + docs/guides/timeline-editing.mdx | 110 ++++++++++++++++++ docs/packages/studio.mdx | 15 ++- .../studio/src/player/components/Timeline.tsx | 2 + .../src/player/components/TimelineClip.tsx | 13 ++- .../player/components/timelineEditing.test.ts | 45 +++++-- .../src/player/components/timelineEditing.ts | 20 +++- 7 files changed, 191 insertions(+), 15 deletions(-) create mode 100644 docs/guides/timeline-editing.mdx diff --git a/docs/docs.json b/docs/docs.json index 6ecc1e00b..7b029f2fe 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -76,6 +76,7 @@ "guides/rendering", "guides/hdr", "guides/performance", + "guides/timeline-editing", "guides/common-mistakes", "guides/troubleshooting" ] diff --git a/docs/guides/timeline-editing.mdx b/docs/guides/timeline-editing.mdx new file mode 100644 index 000000000..0c91f7362 --- /dev/null +++ b/docs/guides/timeline-editing.mdx @@ -0,0 +1,110 @@ +--- +title: Timeline Editing +description: "What you can edit in the Studio timeline today, how those edits map back to HTML, and the current limitations." +--- + +The Studio timeline lets you edit the parts of a HyperFrames composition that can be persisted cleanly back into source HTML. + +It is not a separate project format or hidden binary state. Every supported timeline action updates the same `data-*` attributes and inline styles that your composition already uses. + +## What the Timeline Can Do + +- **Move clips in time** — drag a clip horizontally to update `data-start` +- **Move clips between rows** — drag a clip vertically to update `data-track-index` +- **Change visual stacking** — top timeline rows render above lower rows, and that ordering is persisted back into inline `z-index` +- **Trim the end of a clip** — drag the right handle to reduce `data-duration` +- **Trim the start of media clips** — drag the left handle on clips backed by media offsets to advance the clip start and playback offset together + +## How Timeline Edits Map To Source + +The timeline works directly against your HTML: + +- horizontal move updates `data-start` +- vertical move updates `data-track-index` +- right trim updates `data-duration` +- media left trim updates `data-start` and `data-media-start` or `data-playback-start` +- changing row order also updates inline `z-index` so the preview matches the timeline + +This means timeline editing stays inspectable and versionable. If you open the file after a move or trim, you can see the exact attributes that changed. + +## Current Editing Model By Clip Type + +### Generic motion / DOM clips + +Examples: +- `div` +- `section` +- `aside` +- GSAP-driven cards, overlays, and text blocks + +Supported: +- move the clip later or earlier on the timeline +- move the clip to another row +- trim the end of the clip + +Not supported yet: +- true front trim that removes the beginning of the animation itself + +### Media clips + +Examples: +- `video` +- `audio` +- wrappers backed by `data-media-start` / `data-playback-start` + +Supported: +- move the clip later or earlier on the timeline +- move the clip to another row +- trim the end of the clip +- trim the start of the media content itself + +## Why Start Trim Is Media-Only + +Media clips have a real content-offset model: + +- `data-media-start` +- `data-playback-start` + +Those attributes let the Studio say: + +> Start this clip later on the timeline, and also start reading the media later inside the source. + +Generic motion clips do not have an equivalent playback-offset model yet. For a GSAP-driven `section` or `div`, the Studio can: + +- move the whole clip later by changing `data-start` +- shorten its visible window by changing `data-duration` + +But it cannot yet say: + +> Start this animation halfway through its timeline. + +That is why generic motion clips do **not** show an interactive left trim handle. The control is hidden instead of implying behavior the runtime cannot currently represent truthfully. + + + A useful mental model is: **move** changes when a clip starts, **right trim** changes when it ends, and **left trim** only appears when the clip can actually skip the beginning of its own content. + + +## Stacking Rule + +The Studio follows the normal timeline-editor convention: + +- the visually top row renders on top +- lower rows render underneath + +If you want captions, lower-thirds, or overlays to sit above other content, place them on a visually higher timeline row. + +## Current Limitations + +- **No true front trim for generic motion clips yet.** + You can move those clips later in time, but you cannot start their internal animation phase partway through. +- **Layering is still driven by row order plus persisted inline `z-index`.** + If a clip already has custom CSS stacking rules outside the Studio flow, keep that in mind when editing manually. +- **Timeline editing is intentionally scoped.** + The Studio currently focuses on move and trim behavior. It does not yet expose full split, slip, slide, ripple, or roll editing semantics. + +## Best Practices + +- Use **move** when you want an element to start later but still play its full animation. +- Use **right trim** when you want the element to end sooner. +- Use **media left trim** when you want to remove the beginning of a video or audio clip. +- Put overlays and captions on visually higher rows so they render above base footage. diff --git a/docs/packages/studio.mdx b/docs/packages/studio.mdx index 0c1abe051..2a02d1a8f 100644 --- a/docs/packages/studio.mdx +++ b/docs/packages/studio.mdx @@ -195,11 +195,24 @@ The timeline panel provides a visual representation of your composition's struct - Each clip appears as a colored bar on its track - Bar position and width reflect `data-start` and `data-duration` -- Tracks are stacked by `data-track-index` (higher tracks render in front) +- Visually higher rows render in front; lower rows render underneath - Relative timing references (e.g., `data-start="intro"`) are resolved and displayed as absolute positions This makes it easy to understand the temporal structure of complex compositions with many overlapping clips. +### Timeline Editing + +The timeline supports move and trim actions that persist directly back into your HTML source. + +For a full breakdown of: + +- what timeline editing can do today +- how each action maps to `data-start`, `data-duration`, `data-track-index`, and `z-index` +- which clip types support start trim +- current limitations and mental models + +see [Timeline Editing](/guides/timeline-editing). + ### Player Controls The studio includes a full set of playback controls: diff --git a/packages/studio/src/player/components/Timeline.tsx b/packages/studio/src/player/components/Timeline.tsx index 08ab25d41..4c94e8228 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 { + canOffsetTrimClipStart, resolveTimelineAutoScroll, resolveTimelineMove, resolveTimelineResize, @@ -1109,6 +1110,7 @@ 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; e.stopPropagation(); setShowPopover(false); setRangeSelection(null); diff --git a/packages/studio/src/player/components/TimelineClip.tsx b/packages/studio/src/player/components/TimelineClip.tsx index 8d87cef0c..100588844 100644 --- a/packages/studio/src/player/components/TimelineClip.tsx +++ b/packages/studio/src/player/components/TimelineClip.tsx @@ -4,6 +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"; interface TimelineClipProps { el: TimelineElement; @@ -59,6 +60,7 @@ export const TimelineClip = memo(function TimelineClip({ : isHovered ? theme.clipShadowHover : theme.clipShadow; + const canTrimStart = canOffsetTrimClipStart(el); const showHandles = handleOpacity > 0.01; return ( @@ -109,14 +111,15 @@ export const TimelineClip = memo(function TimelineClip({ top: 0, bottom: 0, width: 18, - opacity: showHandles ? 1 : 0, - pointerEvents: onResizeStart ? "auto" : "none", + opacity: showHandles && canTrimStart ? 1 : 0, + pointerEvents: onResizeStart && canTrimStart ? "auto" : "none", zIndex: 4, transition: "opacity 120ms ease-out", cursor: "col-resize", - background: showHandles - ? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)` - : "transparent", + background: + showHandles && canTrimStart + ? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)` + : "transparent", }} >
{ }); describe("buildTrackZIndexMap", () => { - it("maps sorted tracks onto stable positive z-index values", () => { + it("maps visually higher tracks onto higher z-index values", () => { expect(buildTrackZIndexMap([-2, -1, 0, 3])).toEqual( new Map([ - [-2, 1], - [-1, 2], - [0, 3], - [3, 4], + [-2, 4], + [-1, 3], + [0, 2], + [3, 1], ]), ); }); @@ -168,14 +169,42 @@ describe("buildTrackZIndexMap", () => { it("deduplicates tracks before assigning z-index values", () => { expect(buildTrackZIndexMap([-1, 0, -1, 3, 3])).toEqual( new Map([ - [-1, 1], + [-1, 3], [0, 2], - [3, 3], + [3, 1], ]), ); }); }); +describe("canOffsetTrimClipStart", () => { + it("allows front trim for clips that carry playback offset metadata", () => { + expect( + canOffsetTrimClipStart({ + tag: "div", + playbackStartAttr: "media-start", + }), + ).toBe(true); + }); + + it("allows front trim for media clips with source duration metadata", () => { + expect( + canOffsetTrimClipStart({ + tag: "video", + sourceDuration: 12, + }), + ).toBe(true); + }); + + it("blocks front trim for generic motion clips", () => { + expect( + canOffsetTrimClipStart({ + tag: "section", + }), + ).toBe(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 efd214ebe..8e592fe5e 100644 --- a/packages/studio/src/player/components/timelineEditing.ts +++ b/packages/studio/src/player/components/timelineEditing.ts @@ -116,7 +116,8 @@ export function resolveTimelineMove( export function buildTrackZIndexMap(tracks: number[]): Map { const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b); - return new Map(uniqueTracks.map((track, index) => [track, index + 1])); + const maxZIndex = uniqueTracks.length; + return new Map(uniqueTracks.map((track, index) => [track, maxZIndex - index])); } export function resolveTimelineResize( @@ -168,6 +169,23 @@ export interface TimelinePromptElement { track: number; } +export function canOffsetTrimClipStart(input: { + tag: string; + playbackStart?: number; + playbackStartAttr?: "media-start" | "playback-start"; + sourceDuration?: number; +}): boolean { + 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 + ); +} + export function buildTimelineAgentPrompt({ rangeStart, rangeEnd,