Skip to content
Open
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 @@ -211,6 +211,7 @@
"pages": [
"studio/canvas",
"studio/timeline",
"studio/trim-tools",
"studio/animation",
"studio/captions"
]
Expand Down
4 changes: 4 additions & 0 deletions docs/studio/shortcuts.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ implemented by the current Studio player, canvas, and timeline.
| --- | --- |
| V | Selection tool |
| B | Toggle the razor tool |
| T | Ripple trim tool |
| Shift + T | Roll edit tool |
| Y | Slip tool |
| Shift + Y | Slide tool |
| S | Split the selected eligible clip at the playhead |
| N | Toggle timeline snapping |
| Command/Ctrl + Z | Undo |
Expand Down
6 changes: 6 additions & 0 deletions docs/studio/timeline.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ Drag the left or right edge.
- Trimming the left edge changes when the clip begins.
- Trimming the right edge changes when it ends.

That leaves a gap or an overlap where the clip used to reach. To decide what
happens to the neighbours instead of repairing the track afterwards, use one of
the four trim tools: ripple, roll, slip, or slide. See
[Trim with ripple, roll, slip, and slide](/studio/trim-tools).

Trimming does not automatically rewrite the story. Play the surrounding section to check entrances, exits, media continuity, and narration.

## Split a clip
Expand Down Expand Up @@ -79,6 +84,7 @@ Move, trim, split, and many other timeline actions participate in Studio history

## Related topics

- [Trim with ripple, roll, slip, and slide](/studio/trim-tools)
- [Edit the frame](/studio/canvas)
- [Edit animation and keyframes](/studio/animation)
- [Use Studio keyboard shortcuts](/studio/shortcuts)
153 changes: 153 additions & 0 deletions docs/studio/trim-tools.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
---
title: "Trim with ripple, roll, slip, and slide"
description: "Use the four NLE trim tools on the HyperFrames Studio timeline, and know which one to reach for."
---

Dragging a clip edge with the selection tool changes that clip and leaves a gap
or an overlap behind. The four trim tools each decide what happens to the
neighbours, so you can fix timing without repairing the track afterwards.

<Frame caption="The timeline toolbar: selection, razor, then ripple, roll, slip, and slide.">
<img
src="https://static.heygen.ai/hyperframes-oss/docs/images/studio/trim-tools-toolbar-v1.png"
alt="The Studio timeline toolbar with the four trim tools"
/>
</Frame>

One tool is active at a time. Press its key again, or press **V**, to go back to
the selection tool.

| Tool | Key | Where you grab | What moves |
| --- | --- | --- | --- |
| Ripple | T | A clip edge | The clip's length, and every later clip on the track |
| Roll | Shift + T | The cut between two clips | Only those two. Nothing downstream moves |
| Slip | Y | Inside the clip | Which part of the source plays. Nothing on the track moves |
| Slide | Shift + Y | Inside the clip | The clip's position. Its two neighbours absorb it |

<Note>
All four work on the grabbed clip's own track. Rippling a video clip does not
ripple the audio underneath it; move the audio yourself, or select both.
</Note>

## Ripple

Drag a clip edge. The clip gets longer or shorter, and everything after it on the
track shifts by the same amount, so no gap opens and nothing overlaps. The video
gets longer or shorter by exactly what you trimmed.

<Frame caption="Ripple trimming a clip's out point earlier. The two clips after it follow.">
<video
className="aspect-video w-full object-cover"
src="https://static.heygen.ai/hyperframes-oss/docs/images/studio/trim-ripple-v1.mp4#t=0.1"
poster="https://static.heygen.ai/hyperframes-oss/docs/images/studio/trim-ripple-v1.jpg"
controls
muted
playsInline
loop
/>
</Frame>

Reach for ripple when the clip is too long or too short and everything after it
should simply move up. It is the tool that changes the total running time.

## Roll

Drag the cut between two clips. One grows by exactly what the other gives up, so
the cut moves and nothing else does. The video stays the same length.

<Frame caption="Rolling a cut to the right. The clip before it grows, the clip after it shrinks, and the third clip never moves.">
<video
className="aspect-video w-full object-cover"
src="https://static.heygen.ai/hyperframes-oss/docs/images/studio/trim-roll-v1.mp4#t=0.1"
poster="https://static.heygen.ai/hyperframes-oss/docs/images/studio/trim-roll-v1.jpg"
controls
muted
playsInline
loop
/>
</Frame>

Reach for roll when the cut is in the wrong place but the section is the right
length. If a clip further down the track moves, you used ripple by mistake.

Roll needs a clip on both sides of the cut. Grab an edge with nothing across it
and Studio says so rather than starting a gesture that would do nothing.

## Slip

Slip is sliding a photo behind a picture frame. The frame is the clip: where it
sits on the timeline and how long it lasts. The photo is the media. Slipping
never moves the frame, it moves the photo behind it, so you see a different part
of the same source in the same hole.

<Frame caption="Slipping a video clip. The picture changes while the clip stays exactly where it was.">
<video
className="aspect-video w-full object-cover"
src="https://static.heygen.ai/hyperframes-oss/docs/images/studio/trim-slip-v1.mp4#t=0.1"
poster="https://static.heygen.ai/hyperframes-oss/docs/images/studio/trim-slip-v1.jpg"
controls
muted
playsInline
loop
/>
</Frame>

Reach for slip when the timing is right but you are showing the wrong moment: the
shot starts a beat too early, or the take you want is a second later in the file.

Slip writes `data-playback-start`, the clip's in point. Only clips with a source
to re-point can be slipped: video, audio, images, and nested compositions. Text
and shapes are generated at render time and have no in point, so Studio refuses
and tells you why.

## Slide

Drag the clip along its track. It keeps its own length and its own media, and the
clips either side give up and take on exactly the time it travelled.

<Frame caption="Sliding a clip to the right. The clip before it stretches, the clip after it shrinks, and the clip itself is untouched.">
<video
className="aspect-video w-full object-cover"
src="https://static.heygen.ai/hyperframes-oss/docs/images/studio/trim-slide-v1.mp4#t=0.1"
poster="https://static.heygen.ai/hyperframes-oss/docs/images/studio/trim-slide-v1.jpg"
controls
muted
playsInline
loop
/>
</Frame>

Reach for slide when a moment lands at the wrong time but the shot itself is
right and the section must stay the same length. The clip you grabbed never
changes duration, which is how you tell slide from roll at a glance.

## Which one do I want

Ask what must stay fixed:

- **The total running time can change** and everything after should follow: ripple.
- **The running time must not change** and the cut is in the wrong place: roll.
- **Nothing on the track should move** and the picture is wrong: slip.
- **Nothing downstream should move** and the clip is at the wrong moment: slide.

## Snapping and undo

Trims respect timeline snapping, so an edge lands on the playhead, a neighbouring
boundary, or a beat rather than a hundredth of a second either side. Press **N**
to toggle it.

Every clip a trim touches is written in one step, so a single undo puts all of
them back together rather than one at a time.

## When a tool refuses

A tool tells you why instead of running a gesture with no effect:

- a roll with no clip across the cut;
- a slip on generated pixels, which have no in point to move;
- a ripple or a slide blocked by a locked clip on the track.

## Related topics

- [Edit timing on the timeline](/studio/timeline)
- [Use Studio keyboard shortcuts](/studio/shortcuts)
4 changes: 2 additions & 2 deletions packages/studio/src/components/TimelineToolbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,15 +63,15 @@ describe("TimelineToolbar — adaptive thumbnails", () => {
it("keeps a user-controlled hidden mode as the rollback path", () => {
const { host, root } = renderToolbar();
const button = host.querySelector<HTMLButtonElement>(
'button[aria-label="Hide thumbnails labels only"]',
'button[aria-label="Hide thumbnails, labels only"]',
);
if (!button) throw new Error("thumbnail toggle not rendered");

act(() => button.click());

expect(usePlayerStore.getState().thumbnailMode).toBe("hidden");
expect(button.getAttribute("aria-label")).toBe(
"Show thumbnailsposters stay visible; richer previews appear on interaction",
"Show thumbnails: posters stay visible, richer previews appear on interaction",
);
act(() => root.unmount());
});
Expand Down
31 changes: 26 additions & 5 deletions packages/studio/src/components/TimelineToolbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import { useTimelineZoom } from "../player/components/useTimelineZoom";
import { usePlayerStore, type TimelineElement } from "../player";
import { Tooltip } from "./ui";
import { Scissors } from "../icons/SystemIcons";
import { TRIM_TOOL_ICONS } from "../icons/TrimToolIcons";
import { TIMELINE_TRIM_TOOLS } from "../player/components/timelineTrimTools";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { canSplitElement } from "../utils/timelineElementSplit";
Expand Down Expand Up @@ -204,7 +206,7 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
</svg>
</button>
</Tooltip>
<Tooltip label="Razor tool (B) Shift+click splits all tracks">
<Tooltip label="Razor tool (B): Shift+click splits all tracks">
<button
type="button"
onClick={() => setActiveTool("razor")}
Expand All @@ -215,6 +217,25 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
<Scissors size={16} />
</button>
</Tooltip>
{/* Trim tools: one per NLE edit operation, so grabbing the same pixel
can mean ripple or roll without a hidden modifier. */}
{TIMELINE_TRIM_TOOLS.map((tool) => {
const Icon = TRIM_TOOL_ICONS[tool.mode];
const active = activeTool === tool.mode;
return (
<Tooltip key={tool.mode} label={`${tool.label} (${tool.shortcut}): ${tool.hint}`}>
<button
type="button"
onClick={() => setActiveTool(active ? "select" : tool.mode)}
aria-label={tool.label}
aria-pressed={active}
className={active ? flatActive : flatIdle}
>
<Icon size={16} />
</button>
</Tooltip>
);
})}
{/* Divider: tool-mode | editing-actions */}
<div aria-hidden="true" className="mx-1 h-4 w-px bg-neutral-800" />
<Tooltip label={timelineSnapEnabled ? "Snapping on (N)" : "Snapping off (N)"}>
Expand Down Expand Up @@ -426,16 +447,16 @@ export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineTool
<Tooltip
label={
thumbnailsVisible
? "Hide thumbnails labels only"
: "Show thumbnailsposters stay visible; richer previews appear on interaction"
? "Hide thumbnails, labels only"
: "Show thumbnails: posters stay visible, richer previews appear on interaction"
}
>
<button
type="button"
aria-label={
thumbnailsVisible
? "Hide thumbnails labels only"
: "Show thumbnailsposters stay visible; richer previews appear on interaction"
? "Hide thumbnails, labels only"
: "Show thumbnails: posters stay visible, richer previews appear on interaction"
}
aria-pressed={thumbnailsVisible}
onClick={() => setThumbnailMode(thumbnailsVisible ? "hidden" : "adaptive")}
Expand Down
12 changes: 12 additions & 0 deletions packages/studio/src/hooks/useAppHotkeys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { isTypingTarget } from "../utils/typingTarget";
import { isEditableTarget } from "../utils/timelineDiscovery";
import { shouldIgnoreHistoryShortcut } from "../utils/studioHelpers";
import { canSplitElement } from "../utils/timelineElementSplit";
import { TRIM_TOOL_KEYS } from "../player/components/timelineTrimTools";
import { trackStudioEvent } from "../utils/studioTelemetry";
import { serializeStudioFileMutations } from "../utils/studioFileMutationCoordinator";

Expand Down Expand Up @@ -296,6 +297,17 @@ export function dispatchPlainKey(event: KeyboardEvent, key: string, cb: HotkeyCa
return;
}

// Trim tools, paired by what they act on: T/⇧T move an edit point (ripple,
// roll), Y/⇧Y move the media inside one (slip, slide). Pressing the active
// tool's own key returns to Select, so a tool is never a trap.
const trimTool = TRIM_TOOL_KEYS[`${event.shiftKey ? "shift+" : ""}${key}`];
if (trimTool && !event.altKey && !event.metaKey && !event.ctrlKey) {
event.preventDefault();
const { activeTool, setActiveTool } = usePlayerStore.getState();
setActiveTool(activeTool === trimTool ? "select" : trimTool);
return;
}

if (event.key === "Escape") {
const { activeTool, selectedElementId, setActiveTool, setSelectedElementId } =
usePlayerStore.getState();
Expand Down
8 changes: 6 additions & 2 deletions packages/studio/src/hooks/useTimelineEditing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ import {
} from "./timelineTimingSync";
import type { PersistTimelineEditInput } from "./timelineEditingHelpers";
import type { TimelineStackingReorderIntent } from "../player/components/timelineEditing";
import {
blockedTimelineEditMessage,
type BlockedTimelineEditIntent,
} from "../player/components/timelineBlockedEdits";
import {
useTimelineElementVisibilityEditing,
useTimelineTrackVisibilityEditing,
Expand Down Expand Up @@ -531,11 +535,11 @@ export function useTimelineEditing({
});

const handleBlockedTimelineEdit = useCallback(
(_element: TimelineElement) => {
(_element: TimelineElement, intent: BlockedTimelineEditIntent) => {
const now = Date.now();
if (now - lastBlockedTimelineToastAtRef.current < 1500) return;
lastBlockedTimelineToastAtRef.current = now;
showToast("This clip can't be moved or resized from the timeline yet.", "info");
showToast(blockedTimelineEditMessage(intent), "info");
},
[showToast],
);
Expand Down
71 changes: 71 additions & 0 deletions packages/studio/src/icons/TrimToolIcons.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { ReactElement } from "react";
import type { TimelineTrimMode } from "../player/components/timelineTrimOps";

/**
* Glyphs for the four trim tools. Hand-drawn rather than pulled from Phosphor:
* the set has no marks for these operations, and what matters here is that the
* four read as *different* at 16px — each one shows what moves.
*
* Shared grammar: a solid block is a clip, a vertical rule is an edit point,
* an arrow is what the drag moves.
*/

const STROKE = {
fill: "none",
stroke: "currentColor",
strokeWidth: 1.4,
strokeLinecap: "round",
strokeLinejoin: "round",
} as const;

interface TrimToolIconProps {
size?: number;
}

/** Ripple: an edit point, and the rest of the track pushed along behind it. */
function RippleTrimIcon({ size = 16 }: TrimToolIconProps) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden="true">
<rect x="1" y="4.5" width="3.5" height="7" rx="1" fill="currentColor" />
<path d="M6.5 8h6.5M10.5 5.5 13 8l-2.5 2.5" {...STROKE} />
</svg>
);
}

/** Roll: one edit point, movable either way; the clips around it stay put. */
function RollEditIcon({ size = 16 }: TrimToolIconProps) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden="true">
<path d="M8 2.5v11" {...STROKE} strokeWidth={1.6} />
<path d="M5 5.5 2.5 8 5 10.5M11 5.5 13.5 8 11 10.5" {...STROKE} />
</svg>
);
}

/** Slip: the clip's outline stays; the media inside it slides. */
function SlipEditIcon({ size = 16 }: TrimToolIconProps) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden="true">
<rect x="1.7" y="3.7" width="12.6" height="8.6" rx="1.6" {...STROKE} />
<path d="M5 8h6M6.6 6.4 5 8l1.6 1.6M9.4 6.4 11 8 9.4 9.6" {...STROKE} />
</svg>
);
}

/** Slide: the clip travels; the walls either side of it absorb the travel. */
function SlideEditIcon({ size = 16 }: TrimToolIconProps) {
return (
<svg width={size} height={size} viewBox="0 0 16 16" aria-hidden="true">
<path d="M1.6 2.8v7.4M14.4 2.8v7.4" {...STROKE} />
<rect x="5" y="2.8" width="6" height="7.4" rx="1" fill="currentColor" />
<path d="M3 13.2h10M4.6 11.6 3 13.2l1.6 1.6M11.4 11.6 13 13.2l-1.6 1.6" {...STROKE} />
</svg>
);
}

export const TRIM_TOOL_ICONS: Record<TimelineTrimMode, (p: TrimToolIconProps) => ReactElement> = {
ripple: RippleTrimIcon,
roll: RollEditIcon,
slip: SlipEditIcon,
slide: SlideEditIcon,
};
Loading
Loading