Skip to content

Commit 2cf3558

Browse files
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`
1 parent d740f5c commit 2cf3558

7 files changed

Lines changed: 191 additions & 15 deletions

File tree

docs/docs.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@
7676
"guides/rendering",
7777
"guides/hdr",
7878
"guides/performance",
79+
"guides/timeline-editing",
7980
"guides/common-mistakes",
8081
"guides/troubleshooting"
8182
]

docs/guides/timeline-editing.mdx

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
---
2+
title: Timeline Editing
3+
description: "What you can edit in the Studio timeline today, how those edits map back to HTML, and the current limitations."
4+
---
5+
6+
The Studio timeline lets you edit the parts of a HyperFrames composition that can be persisted cleanly back into source HTML.
7+
8+
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.
9+
10+
## What the Timeline Can Do
11+
12+
- **Move clips in time** — drag a clip horizontally to update `data-start`
13+
- **Move clips between rows** — drag a clip vertically to update `data-track-index`
14+
- **Change visual stacking** — top timeline rows render above lower rows, and that ordering is persisted back into inline `z-index`
15+
- **Trim the end of a clip** — drag the right handle to reduce `data-duration`
16+
- **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
17+
18+
## How Timeline Edits Map To Source
19+
20+
The timeline works directly against your HTML:
21+
22+
- horizontal move updates `data-start`
23+
- vertical move updates `data-track-index`
24+
- right trim updates `data-duration`
25+
- media left trim updates `data-start` and `data-media-start` or `data-playback-start`
26+
- changing row order also updates inline `z-index` so the preview matches the timeline
27+
28+
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.
29+
30+
## Current Editing Model By Clip Type
31+
32+
### Generic motion / DOM clips
33+
34+
Examples:
35+
- `div`
36+
- `section`
37+
- `aside`
38+
- GSAP-driven cards, overlays, and text blocks
39+
40+
Supported:
41+
- move the clip later or earlier on the timeline
42+
- move the clip to another row
43+
- trim the end of the clip
44+
45+
Not supported yet:
46+
- true front trim that removes the beginning of the animation itself
47+
48+
### Media clips
49+
50+
Examples:
51+
- `video`
52+
- `audio`
53+
- wrappers backed by `data-media-start` / `data-playback-start`
54+
55+
Supported:
56+
- move the clip later or earlier on the timeline
57+
- move the clip to another row
58+
- trim the end of the clip
59+
- trim the start of the media content itself
60+
61+
## Why Start Trim Is Media-Only
62+
63+
Media clips have a real content-offset model:
64+
65+
- `data-media-start`
66+
- `data-playback-start`
67+
68+
Those attributes let the Studio say:
69+
70+
> Start this clip later on the timeline, and also start reading the media later inside the source.
71+
72+
Generic motion clips do not have an equivalent playback-offset model yet. For a GSAP-driven `section` or `div`, the Studio can:
73+
74+
- move the whole clip later by changing `data-start`
75+
- shorten its visible window by changing `data-duration`
76+
77+
But it cannot yet say:
78+
79+
> Start this animation halfway through its timeline.
80+
81+
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.
82+
83+
<Note>
84+
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.
85+
</Note>
86+
87+
## Stacking Rule
88+
89+
The Studio follows the normal timeline-editor convention:
90+
91+
- the visually top row renders on top
92+
- lower rows render underneath
93+
94+
If you want captions, lower-thirds, or overlays to sit above other content, place them on a visually higher timeline row.
95+
96+
## Current Limitations
97+
98+
- **No true front trim for generic motion clips yet.**
99+
You can move those clips later in time, but you cannot start their internal animation phase partway through.
100+
- **Layering is still driven by row order plus persisted inline `z-index`.**
101+
If a clip already has custom CSS stacking rules outside the Studio flow, keep that in mind when editing manually.
102+
- **Timeline editing is intentionally scoped.**
103+
The Studio currently focuses on move and trim behavior. It does not yet expose full split, slip, slide, ripple, or roll editing semantics.
104+
105+
## Best Practices
106+
107+
- Use **move** when you want an element to start later but still play its full animation.
108+
- Use **right trim** when you want the element to end sooner.
109+
- Use **media left trim** when you want to remove the beginning of a video or audio clip.
110+
- Put overlays and captions on visually higher rows so they render above base footage.

docs/packages/studio.mdx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,11 +195,24 @@ The timeline panel provides a visual representation of your composition's struct
195195

196196
- Each clip appears as a colored bar on its track
197197
- Bar position and width reflect `data-start` and `data-duration`
198-
- Tracks are stacked by `data-track-index` (higher tracks render in front)
198+
- Visually higher rows render in front; lower rows render underneath
199199
- Relative timing references (e.g., `data-start="intro"`) are resolved and displayed as absolute positions
200200

201201
This makes it easy to understand the temporal structure of complex compositions with many overlapping clips.
202202

203+
### Timeline Editing
204+
205+
The timeline supports move and trim actions that persist directly back into your HTML source.
206+
207+
For a full breakdown of:
208+
209+
- what timeline editing can do today
210+
- how each action maps to `data-start`, `data-duration`, `data-track-index`, and `z-index`
211+
- which clip types support start trim
212+
- current limitations and mental models
213+
214+
see [Timeline Editing](/guides/timeline-editing).
215+
203216
### Player Controls
204217

205218
The studio includes a full set of playback controls:

packages/studio/src/player/components/Timeline.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { formatTime } from "../lib/time";
1010
import { TimelineClip } from "./TimelineClip";
1111
import { EditPopover } from "./EditModal";
1212
import {
13+
canOffsetTrimClipStart,
1314
resolveTimelineAutoScroll,
1415
resolveTimelineMove,
1516
resolveTimelineResize,
@@ -1109,6 +1110,7 @@ export const Timeline = memo(function Timeline({
11091110
onHoverEnd={() => setHoveredClip(null)}
11101111
onResizeStart={(edge, e) => {
11111112
if (e.button !== 0 || e.shiftKey || !onResizeElement) return;
1113+
if (edge === "start" && !canOffsetTrimClipStart(el)) return;
11121114
e.stopPropagation();
11131115
setShowPopover(false);
11141116
setRangeSelection(null);

packages/studio/src/player/components/TimelineClip.tsx

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { TimelineTrackStyle } from "./timelineTheme";
44
import { memo, type ReactNode } from "react";
55
import type { TimelineElement } from "../store/playerStore";
66
import { defaultTimelineTheme, getClipHandleOpacity, type TimelineTheme } from "./timelineTheme";
7+
import { canOffsetTrimClipStart } from "./timelineEditing";
78

89
interface TimelineClipProps {
910
el: TimelineElement;
@@ -59,6 +60,7 @@ export const TimelineClip = memo(function TimelineClip({
5960
: isHovered
6061
? theme.clipShadowHover
6162
: theme.clipShadow;
63+
const canTrimStart = canOffsetTrimClipStart(el);
6264
const showHandles = handleOpacity > 0.01;
6365

6466
return (
@@ -109,14 +111,15 @@ export const TimelineClip = memo(function TimelineClip({
109111
top: 0,
110112
bottom: 0,
111113
width: 18,
112-
opacity: showHandles ? 1 : 0,
113-
pointerEvents: onResizeStart ? "auto" : "none",
114+
opacity: showHandles && canTrimStart ? 1 : 0,
115+
pointerEvents: onResizeStart && canTrimStart ? "auto" : "none",
114116
zIndex: 4,
115117
transition: "opacity 120ms ease-out",
116118
cursor: "col-resize",
117-
background: showHandles
118-
? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
119-
: "transparent",
119+
background:
120+
showHandles && canTrimStart
121+
? `linear-gradient(90deg, ${trackStyle.accent}4d 0%, ${trackStyle.accent}22 42%, transparent 100%)`
122+
: "transparent",
120123
}}
121124
>
122125
<div

packages/studio/src/player/components/timelineEditing.test.ts

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { describe, expect, it } from "vitest";
22
import {
3-
buildTrackZIndexMap,
43
buildPromptCopyText,
54
buildTimelineAgentPrompt,
5+
buildTrackZIndexMap,
6+
canOffsetTrimClipStart,
67
resolveTimelineAutoScroll,
78
resolveTimelineMove,
89
resolveTimelineResize,
@@ -154,28 +155,56 @@ describe("resolveTimelineMove", () => {
154155
});
155156

156157
describe("buildTrackZIndexMap", () => {
157-
it("maps sorted tracks onto stable positive z-index values", () => {
158+
it("maps visually higher tracks onto higher z-index values", () => {
158159
expect(buildTrackZIndexMap([-2, -1, 0, 3])).toEqual(
159160
new Map([
160-
[-2, 1],
161-
[-1, 2],
162-
[0, 3],
163-
[3, 4],
161+
[-2, 4],
162+
[-1, 3],
163+
[0, 2],
164+
[3, 1],
164165
]),
165166
);
166167
});
167168

168169
it("deduplicates tracks before assigning z-index values", () => {
169170
expect(buildTrackZIndexMap([-1, 0, -1, 3, 3])).toEqual(
170171
new Map([
171-
[-1, 1],
172+
[-1, 3],
172173
[0, 2],
173-
[3, 3],
174+
[3, 1],
174175
]),
175176
);
176177
});
177178
});
178179

180+
describe("canOffsetTrimClipStart", () => {
181+
it("allows front trim for clips that carry playback offset metadata", () => {
182+
expect(
183+
canOffsetTrimClipStart({
184+
tag: "div",
185+
playbackStartAttr: "media-start",
186+
}),
187+
).toBe(true);
188+
});
189+
190+
it("allows front trim for media clips with source duration metadata", () => {
191+
expect(
192+
canOffsetTrimClipStart({
193+
tag: "video",
194+
sourceDuration: 12,
195+
}),
196+
).toBe(true);
197+
});
198+
199+
it("blocks front trim for generic motion clips", () => {
200+
expect(
201+
canOffsetTrimClipStart({
202+
tag: "section",
203+
}),
204+
).toBe(false);
205+
});
206+
});
207+
179208
describe("resolveTimelineAutoScroll", () => {
180209
it("does not scroll when the pointer stays away from the edges", () => {
181210
expect(

packages/studio/src/player/components/timelineEditing.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,8 @@ export function resolveTimelineMove(
116116

117117
export function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
118118
const uniqueTracks = Array.from(new Set(tracks)).sort((a, b) => a - b);
119-
return new Map(uniqueTracks.map((track, index) => [track, index + 1]));
119+
const maxZIndex = uniqueTracks.length;
120+
return new Map(uniqueTracks.map((track, index) => [track, maxZIndex - index]));
120121
}
121122

122123
export function resolveTimelineResize(
@@ -168,6 +169,23 @@ export interface TimelinePromptElement {
168169
track: number;
169170
}
170171

172+
export function canOffsetTrimClipStart(input: {
173+
tag: string;
174+
playbackStart?: number;
175+
playbackStartAttr?: "media-start" | "playback-start";
176+
sourceDuration?: number;
177+
}): boolean {
178+
if (input.playbackStartAttr != null) return true;
179+
if (input.playbackStart != null) return true;
180+
const normalizedTag = input.tag.toLowerCase();
181+
if (!["video", "audio"].includes(normalizedTag)) return false;
182+
return (
183+
input.sourceDuration != null &&
184+
Number.isFinite(input.sourceDuration) &&
185+
input.sourceDuration > 0
186+
);
187+
}
188+
171189
export function buildTimelineAgentPrompt({
172190
rangeStart,
173191
rangeEnd,

0 commit comments

Comments
 (0)