Skip to content

feat(studio): razor/blade tool for GSAP-aware timeline clip splitting#1266

Closed
miguel-heygen wants to merge 1 commit into
mainfrom
feat/razor-blade-tool
Closed

feat(studio): razor/blade tool for GSAP-aware timeline clip splitting#1266
miguel-heygen wants to merge 1 commit into
mainfrom
feat/razor-blade-tool

Conversation

@miguel-heygen

@miguel-heygen miguel-heygen commented Jun 7, 2026

Copy link
Copy Markdown
Collaborator

Summary

Adds a razor/blade tool to Studio's timeline — the standard non-linear editing workflow for splitting clips at arbitrary positions. Inspired by feedback from an After Effects editor who expected this as a fundamental editing primitive.

  • B enters razor mode (crosshair cursor + red vertical guide line)
  • Click any clip to split it at the click position (not the playhead)
  • Shift+click splits all clips across every track at that time
  • V or Escape exits razor mode
  • Toolbar shows selection arrow / scissors toggle with active state highlighting

Why this matters

Timeline editing without a razor tool forces editors into a playhead-dependent workflow: position the playhead, then use the S shortcut. This is slow and imprecise for the most common editing operation. Every professional NLE (Premiere, DaVinci, After Effects) has a razor/blade tool that splits at the mouse position, and editors expect it.

Technical approach

GSAP-aware splitting

The razor tool doesn't just split HTML elements — it correctly re-times GSAP animations for both halves of a split:

  • Animations entirely before the split: kept on the original element, end-state properties inherited by the new element via tl.set (inserted before other tweens so GSAP records the correct initial state)
  • Animations entirely after the split: retargeted to the new element via direct AST selector update
  • Animations spanning the split point: trimmed on the original, remainder added targeting the new element with correct position offset and duration
  • Keyframes animations: classified by total duration (sum of per-keyframe durations). Retargeted when entirely after split; kept on original when spanning
  • fromTo animations: both from and to values preserved correctly

CSS rule duplication

When splitting an element with ID-based CSS (e.g., #box { background: red }), the new element gets a different ID (#box-split). The splitter uses PostCSS to parse stylesheets and duplicate matching rules for the new ID, correctly handling media queries, compound selectors, and nested rules.

Server-side ID deduplication

When splitting an already-split element, the server checks if the requested ID already exists in the document and auto-increments (box-split-2, box-split-3, etc.) to prevent duplicate IDs.

Preview reload after timeline edits

Timeline move and resize operations now trigger a preview reload so the composition re-renders with the updated timing.

Architecture

  • useRazorSplit.ts — extracted hook with executeSplit() pure orchestration, handleRazorSplit (single clip), handleRazorSplitAll (multi-track)
  • timelineElementSplit.ts — shared utilities (canSplitElement, buildPatchTarget, readFileContent) to break circular dependencies
  • playerStore.tsactiveTool: "select" | "razor" state with setActiveTool setter
  • TimelineCanvas.tsx — click handler intercepts razor mode, converts pixel position to split time
  • Timeline.tsx — red guide line overlay, crosshair cursor, shift+click multi-track routing
  • TimelineToolbar.tsx — selection/scissors toggle buttons
  • gsapParser.tssplitAnimationsInScript(), updateAnimationSelector(), computeKeyframesTotalDuration(), insertInheritedStateSet()
  • sourceMutation.tssplitElementInHtml() enhanced with CSS duplication (PostCSS), ID deduplication, and inherited state via tl.set

Code quality improvements

Cleaned up 20+ pre-existing clone groups across touched files:

  • Extracted PlayheadIndicator shared component
  • Extracted useContextMenuDismiss hook
  • Extracted TimelineCallbacks interfaces
  • Extracted useTimelineZoom hook
  • Extracted gsapParser.test-helpers.ts
  • Collapsed redundant switch cases in files.ts

Test plan

Unit tests (1208 passing)

  • 13 tests for splitAnimationsInScript: first-half, second-half, spanning, no match, multiple animations, fromTo, round-trip, keyframes spanning/retarget/before, set tween retarget, same-position dedup, inherited state ordering
  • 6 tests for splitElementInHtml: basic split, CSS duplication, ID deduplication, clip class preservation, out-of-range rejection, media playback-start adjustment

Manual browser testing

Tested against a complex composition with 10 elements across 9 tracks:

  • B/V/Escape toggle razor mode correctly
  • Single click splits at click position with correct timing
  • GSAP spanning tweens are trimmed + continued on new element
  • GSAP before/after classification is correct
  • fromTo from/to values preserved
  • Multiple animations independently classified per element
  • Double-split chains naming correctly (server-side ID dedup)
  • Shift+click splits all overlapping clips; non-overlapping clips skipped
  • Locked element (data-timeline-locked) rejects split
  • Split elements visible in preview (inherited state + CSS duplication)
  • Timeline thumbnails render on split elements
  • Undo reverts the split
  • Preview reloads after drag/resize timeline edits

@mintlify

mintlify Bot commented Jun 7, 2026

Copy link
Copy Markdown

Preview deployment for your docs. Learn more about Mintlify Previews.

Project Status Preview Updated (UTC)
hyperframes 🟢 Ready View Preview Jun 7, 2026, 10:41 PM

💡 Tip: Enable Workflows to automatically generate PRs for you.

@github-actions

github-actions Bot commented Jun 7, 2026

Copy link
Copy Markdown

Fallow audit report

Found 47 findings.

Dead code (1)
Severity Rule Location Description
major fallow/unused-file packages/studio/src/components/nle/TimelineEditorNotice.tsx:1 File is not reachable from any entry point
Duplication (10)
Severity Rule Location Description
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1492 Code clone group 1 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1494 Code clone group 2 (6 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1508 Code clone group 1 (9 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1548 Code clone group 3 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1565 Code clone group 3 (8 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.test.ts:1568 Code clone group 2 (6 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:561 Code clone group 4 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/parsers/gsapParser.ts:679 Code clone group 4 (7 lines, 2 instances)
minor fallow/code-duplication packages/core/src/studio-api/routes/files.ts:432 Code clone group 5 (5 lines, 2 instances)
minor fallow/code-duplication packages/core/src/studio-api/routes/render.ts:48 Code clone group 5 (5 lines, 2 instances)
Health (36)
Severity Rule Location Description
critical fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:73 'resolveNode' has CRAP score 315.9 (threshold: 30.0, cyclomatic 36)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:147 'selectorFromQueryCall' has CRAP score 49.5 (threshold: 30.0, cyclomatic 13)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:225 'visitCallExpression' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:284 'resolveTargetSelector' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:313 'objectExpressionToRecord' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:404 'visitCallExpression' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:579 'computeKeyframesTotalDuration' has CRAP score 49.5 (threshold: 30.0, cyclomatic 13)
minor fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:993 'buildTweenStatementCode' has CRAP score 31.6 (threshold: 30.0, cyclomatic 10)
minor fallow/high-cognitive-complexity packages/core/src/parsers/gsapParser.ts:1158 'splitAnimationsInScript' has cognitive complexity 16 (threshold: 15)
major fallow/high-cognitive-complexity packages/core/src/parsers/gsapParser.ts:1307 'addKeyframeToScript' has cognitive complexity 33 (threshold: 15)
major fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:1441 'resolveConversionProps' has CRAP score 43.1 (threshold: 30.0, cyclomatic 12)
critical fallow/high-crap-score packages/core/src/parsers/gsapParser.ts:1616 'unrollDynamicAnimations' has CRAP score 482.4 (threshold: 30.0, cyclomatic 45)
critical fallow/high-crap-score packages/core/src/studio-api/routes/files.ts:468 '<arrow>' has CRAP score 160.0 (threshold: 30.0, cyclomatic 25)
critical fallow/high-crap-score packages/core/src/studio-api/routes/files.ts:664 '<arrow>' has CRAP score 283.7 (threshold: 30.0, cyclomatic 34)
minor fallow/high-crap-score packages/studio/src/components/StudioPreviewArea.tsx:121 '<arrow>' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
minor fallow/high-crap-score packages/studio/src/components/StudioPreviewArea.tsx:176 '<arrow>' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
major fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:115 'computePct' has CRAP score 72.0 (threshold: 30.0, cyclomatic 8)
critical fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:133 'onToggle' has CRAP score 132.0 (threshold: 30.0, cyclomatic 11)
minor fallow/high-crap-score packages/studio/src/components/TimelineToolbar.tsx:256 '<arrow>' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
minor fallow/high-crap-score packages/studio/src/hooks/useAppHotkeys.ts:145 'handleUndo' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
minor fallow/high-crap-score packages/studio/src/hooks/useAppHotkeys.ts:170 'handleRedo' has CRAP score 42.0 (threshold: 30.0, cyclomatic 6)
critical fallow/high-crap-score packages/studio/src/hooks/useAppHotkeys.ts:222 '<arrow>' has CRAP score 4970.0 (threshold: 30.0, cyclomatic 70)
minor fallow/high-crap-score packages/studio/src/hooks/useAppHotkeys.ts:445 'syncPreviewTimelineHotkey' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
major fallow/high-crap-score packages/studio/src/hooks/useAppHotkeys.ts:492 'syncPreviewHistoryHotkey' has CRAP score 72.0 (threshold: 30.0, cyclomatic 8)
critical fallow/high-crap-score packages/studio/src/hooks/useClipboard.ts:34 'getElementOuterHtml' has CRAP score 156.0 (threshold: 30.0, cyclomatic 12)
critical fallow/high-crap-score packages/studio/src/hooks/useClipboard.ts:74 'handleCopy' has CRAP score 420.0 (threshold: 30.0, cyclomatic 20)
critical fallow/high-crap-score packages/studio/src/hooks/useClipboard.ts:134 'handlePaste' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/hooks/useClipboard.ts:198 'handleCut' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
critical fallow/high-crap-score packages/studio/src/hooks/useTimelineEditing.ts:53 'patchIframeDomTiming' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
critical fallow/high-crap-score packages/studio/src/hooks/useTimelineEditing.ts:73 'resolveResizePlaybackStart' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
minor fallow/high-crap-score packages/studio/src/hooks/useTimelineEditing.ts:113 'persistTimelineEdit' has CRAP score 30.0 (threshold: 30.0, cyclomatic 5)
major fallow/high-crap-score packages/studio/src/hooks/useTimelineEditing.ts:254 'handleTimelineElementDelete' has CRAP score 90.0 (threshold: 30.0, cyclomatic 9)
critical fallow/high-crap-score packages/studio/src/hooks/useTimelineEditing.ts:323 'handleTimelineAssetDrop' has CRAP score 110.0 (threshold: 30.0, cyclomatic 10)
major fallow/high-crap-score packages/studio/src/hooks/useTimelineEditing.ts:400 'handleTimelineFileDrop' has CRAP score 72.0 (threshold: 30.0, cyclomatic 8)
minor fallow/high-crap-score packages/studio/src/player/components/TimelineCanvas.tsx:248 '<arrow>' has CRAP score 37.1 (threshold: 30.0, cyclomatic 11)
major fallow/high-crap-score packages/studio/src/player/components/TimelineCanvas.tsx:298 '<arrow>' has CRAP score 63.6 (threshold: 30.0, cyclomatic 15)

Generated by fallow.

Comment on lines +42 to +49
const response = await fetch(
`/api/projects/${projectId}/file-mutations/split-element/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ target: patchTarget, splitTime, newId }),
},
);
Comment on lines +63 to +77
const response = await fetch(
`/api/projects/${projectId}/gsap-mutations/${encodeURIComponent(targetPath)}`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
type: "split-animations",
originalId,
newId,
splitTime,
elementStart,
elementDuration,
}),
},
);
Comment on lines +32 to +34
const response = await fetch(
`/api/projects/${projectId}/files/${encodeURIComponent(targetPath)}`,
);
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from d70ab5c to 2516745 Compare June 7, 2026 22:44
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from 2516745 to b86a1c0 Compare June 7, 2026 22:48
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from b86a1c0 to 004d384 Compare June 8, 2026 20:04
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from 004d384 to c71c46f Compare June 8, 2026 20:13
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from c71c46f to f11ac4d Compare June 8, 2026 20:42
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from f11ac4d to ff908ff Compare June 10, 2026 21:07
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from ff908ff to 0e9f84f Compare June 10, 2026 21:12
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from 0e9f84f to 8626a2b Compare June 10, 2026 21:20
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from 8626a2b to 8bdb625 Compare June 10, 2026 21:35
@miguel-heygen miguel-heygen force-pushed the feat/razor-blade-tool branch from 8bdb625 to efc9f32 Compare June 10, 2026 21:38
Add a razor/blade tool to Studio's timeline — the standard NLE workflow
for splitting clips at arbitrary positions.

- B enters razor mode (crosshair cursor + red vertical guide line)
- Click any clip to split it at the click position
- Shift+click splits all clips across every track at that time
- V or Escape exits razor mode

GSAP animations are correctly re-timed for both halves: animations
before the split stay on the original, animations after are retargeted,
and spanning animations are trimmed with a continuation on the new
element. Keyframes animations are classified by total per-keyframe
duration and retargeted when entirely after the split point.

Extracted shared utilities (canSplitElement, PlayheadIndicator,
useContextMenuDismiss, TimelineCallbacks) to reduce duplication
across timeline components.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants