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,