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
1 change: 1 addition & 0 deletions docs/docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@
"guides/rendering",
"guides/hdr",
"guides/performance",
"guides/timeline-editing",
"guides/common-mistakes",
"guides/troubleshooting"
]
Expand Down
110 changes: 110 additions & 0 deletions docs/guides/timeline-editing.mdx
Original file line number Diff line number Diff line change
@@ -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.

<Note>
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.
</Note>

## 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.
15 changes: 14 additions & 1 deletion docs/packages/studio.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 2 additions & 0 deletions packages/studio/src/player/components/Timeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { formatTime } from "../lib/time";
import { TimelineClip } from "./TimelineClip";
import { EditPopover } from "./EditModal";
import {
canOffsetTrimClipStart,
resolveTimelineAutoScroll,
resolveTimelineMove,
resolveTimelineResize,
Expand Down Expand Up @@ -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);
Expand Down
13 changes: 8 additions & 5 deletions packages/studio/src/player/components/TimelineClip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 (
Expand Down Expand Up @@ -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",
}}
>
<div
Expand Down
45 changes: 37 additions & 8 deletions packages/studio/src/player/components/timelineEditing.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import { describe, expect, it } from "vitest";
import {
buildTrackZIndexMap,
buildPromptCopyText,
buildTimelineAgentPrompt,
buildTrackZIndexMap,
canOffsetTrimClipStart,
resolveTimelineAutoScroll,
resolveTimelineMove,
resolveTimelineResize,
Expand Down Expand Up @@ -154,28 +155,56 @@ describe("resolveTimelineMove", () => {
});

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],
]),
);
});

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(
Expand Down
20 changes: 19 additions & 1 deletion packages/studio/src/player/components/timelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ export function resolveTimelineMove(

export function buildTrackZIndexMap(tracks: number[]): Map<number, number> {
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(
Expand Down Expand Up @@ -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,
Expand Down
Loading