Skip to content

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

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

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
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 (no-op)
  • Animations entirely after the split: retargeted to the new element via 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 (keyframe percentage recomputation is deferred to a follow-up)
  • fromTo animations: both from and to values preserved correctly

The split pipeline: useRazorSplit → HTML split via split-element mutation → GSAP split via splitAnimationsInScript in gsapParser.ts → save with undo history via saveProjectFilesWithHistory.

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()

Code quality improvements (while-we're-here)

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

  • Extracted PlayheadIndicator shared component (33 lines deduped)
  • Extracted useContextMenuDismiss hook (21 lines deduped)
  • Extracted TimelineCallbacks interfaces (14 lines deduped)
  • Extracted useTimelineZoom hook (4 selectors consolidated)
  • Extracted gsapParser.test-helpers.ts (shared test utilities)
  • Collapsed redundant switch cases in files.ts
  • Extracted collectNumericKeyframeProps and computePct helpers in toolbar

Testing performed

Unit tests (1199 passing)

  • 10 tests for splitAnimationsInScript: first-half only, second-half retarget, spanning split, no matching animations, multiple animations, fromTo preservation, round-trip, keyframes spanning, keyframes retarget, keyframes before split

Browser testing (agent-browser via Chrome DevTools MCP)

Tested against a complex composition with 12 elements across 11 tracks:

  • gsap.to with opacity/y/scale (Hero Title)
  • gsap.fromTo with elastic easing (Floating Circle)
  • Percentage keyframes with 5 stages (Counter Box)
  • Stagger entrance/exit across 4 cards
  • Multiple animations on single element — 4 tweens (Badge)
  • Long single tween spanning full timeline (Progress Bar)
  • Short mid-timeline clip (Overlay Panel)
  • Late-entrance fromTo with bounce (CTA Button)

Verified:

  • B/V/Escape toggle razor mode correctly
  • Single click splits at click position with correct data-start/data-duration
  • GSAP spanning tweens are trimmed + continued on new element
  • GSAP before/after classification is correct
  • fromTo from and to values preserved
  • Multiple animations independently classified per element
  • Double-split (split an already-split clip) chains naming correctly
  • Shift+click splits all 10 overlapping clips at once; non-overlapping clip (Overlay Panel ending at 5s) correctly skipped
  • Toast notifications show element name and split time
  • Undo button activates after splits

Known limitations

  • Keyframes animations spanning a split point are kept on the original element rather than split (would require percentage recomputation)
  • Stagger groups split as individual elements since stagger is a class-level selector pattern

@mintlify
Copy link
Copy Markdown

mintlify Bot commented Jun 7, 2026

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
Copy link
Copy Markdown

github-actions Bot commented Jun 7, 2026

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