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,