Files
hyperframes/packages/studio/src/components/TimelineToolbar.tsx
T
Miguel Ángelandukimsanov df29fa7a5e feat(studio): revamps Studio + improves code quality (#2291)
* feat(studio): glue API coexistence layer for the NLE swap

What: extends 21 glue files so the OLD timeline/canvas engine and the NEW
NLE components type-check side by side: playerStore (multi-select setters,
zoom pin, snap toggle, non-reactive scale scratch), drag-state types gain
optional NLE fields, timelineLayout/timelineAssetDrop/timelineEditingHelpers/
timelineEditing/timelineElementHelpers/studioHelpers/assetHelpers gain the
NLE exports, DomEditOverlay + gestures + AssetContextMenu + Timeline props
gain optional callbacks/params, contexts gain *Optional hooks, and
TimelineEditCallbacks.onMoveElements becomes a bivariant method accepting
both engines' change shapes. patchDocumentRootDuration's test rides along.

Why: this is the keystone that dissolves the old "welded glue" problem —
every symbol the NLE components need is ADDED next to what the old engine
still uses, so the engine components and the swaps can land as separate
reviewable PRs.

How: 15 authored intermediate files (main content + additive symbols; no
behavior changes — new fields optional, new callbacks unused until wired)
plus 6 files whose final content is already purely additive. New exports
without consumers yet carry TEMP(studio-dnd) ignoreExports entries, removed
by the app-shell swap.

Test plan: tsc --noEmit in studio + studio-server (verifies BOTH engines
compile); bunx vitest run (full suite green incl. the 6 new
patchDocumentRootDuration tests); fallow audit clean.

* feat(studio): timeline interaction hooks and lanes component (unwired)

What: the timeline-side wiring layer, unwired: TimelineLanes (the lane
renderer driving drag/resize/marquee), timelineMarquee (+tests),
useTimelineStackingSync, useTimelineGeometry, useTimelineEditPinning,
useTimelineEditingDrops.

Why: everything between the pure drag math and <Timeline> itself; the
timeline-glue swap PR then only rewires Timeline/TimelineCanvas onto these.

How: new files, tsc-clean against the coexistence layer. Unwired components
carry TEMP(studio-dnd) entry registrations, removed at the app-shell swap.

Test plan: bunx vitest run timelineMarquee.test.ts; tsc --noEmit; fallow
audit clean.

* feat(studio): NLE shell assembly (unwired)

What: EditorShell (the full editor layout replacing NLELayout +
StudioPreviewArea), TimelinePane (timeline host with sub-comp rebasing) and
useTimelineEditCallbacks (the callback bag bridging store edits to the
timeline), all unwired.

Why: the shell that App swaps to in the final step; reviewing it standalone
keeps that swap PR small.

How: new files against the coexistence layer; TEMP(studio-dnd) entries
until App mounts EditorShell in the app-shell swap.

Test plan: tsc --noEmit; bunx vitest run (suite unchanged); fallow audit
clean.

* feat(studio): timeline glue swap — Timeline/TimelineCanvas onto the NLE engine

What: flips the timeline glue to its final form (23 files): Timeline and
TimelineCanvas rebuilt on TimelineLanes/TimelineOverlays, useTimelineClipDrag
drives preview/commit through the new drag engine, range selection goes
multi-select, playback loop moves to useTimelinePlayerLoop. Deletes the 9
old-engine files this orphans (group drag, marquee selection, snap targets,
layer gutter, selection overlays + their suites) — each is compile- or
gate-forced by this swap, verified by probe.

Why: second swap step; timeline-only, canvas and App untouched.

How: modified files to final content + forced deletions.
playerStore/timelineEditing/timelineCallbacks stay at their coexistence
form until the app swap (the old App still runs on them).

Test plan: tsc --noEmit; bunx vitest run (full suite); fallow audit clean.

* feat(studio): clip thumbnail modules

What: ImageThumbnail (+tests) and thumbnailUtils (+tests) — frame decode
with SVG/AVIF format fallbacks and rounded-corner clipping — plus
VideoThumbnail updates.

Why: the decode layer for timeline clip thumbnails, ahead of the visual
refresh that renders them.

How: new modules + one modified file; purely presentational.

Test plan: bunx vitest run on both test files; tsc --noEmit; fallow audit
clean.

* feat(studio): assets/blocks panel behaviors + preview helpers

What: blocks tab install flow, right-panel and global drag-overlay polish,
music beat analysis and clip-content rendering hooks, and the
preview-helper utilities backing asset preview.

Why: completes the studio NLE stack on top of the visual refresh.

How: modified files only (kept as one PR: splitting further would produce
sub-150-LOC fragments of interdependent panel glue).

Test plan: bunx vitest run studioPreviewHelpers/studioUrlState suites; tsc
--noEmit; fallow audit clean.

* fix(studio): restore timeline playback loop

* fix(studio): restore missing GSAP helpers module

* refactor(studio): split timeline GSAP helpers

* style(studio): keep timeline helper under size limit

* fix(studio): restore timeline overlays module

* fix(studio): remove stale GSAP import

* fix(studio): restore canonical timeline dependencies

* style(studio): format restored timeline helpers

* style(studio): satisfy helper line limit

* fix(studio): repair rebuilt timeline integration

* feat(studio): complete rebuilt NLE cutover

* fix(studio): guard project and timeline race boundaries

* fix(studio): preserve graded resize and crop geometry

* fix(studio): log resize/rotate commit failures, move anchor accumulator to resize-local

* fix(studio): treat duration-0 tweens as static holds and settle resize position before persist

Instant holds (to()/fromTo() with duration 0) were classified as animated
tweens by every commit route, so resizing or rotating them converted the
hold into a corrupt duration-0 keyframes tween (new value at 0%, old at
100%) that GSAP drops; panel edits appended a losing set. A shared
isInstantHold() now routes them through the static replace-in-place path,
and percentage math guards zero-duration windows.

Separately, anchored-corner resizes painted 3-5 frames at the new size but
old position while the offset persist round-tripped the server. The commit
path now applies the corrected GSAP position synchronously before awaiting
the offset persist, mirroring the scale route's settle.

* feat(studio): gesture-transaction seam with commit observability

Introduce runGestureTransaction — one owner for a gesture commit's
settle -> persist -> record lifecycle. It settles the live DOM
synchronously before any async persist, folds every mutation into one
undo entry via a per-transaction coalesceKey, restores pre-gesture state
exactly once on failure, and asserts (dev console) + reports (PostHog:
commit_transaction / commit_invariant_violation / commit_transaction_failed)
that a persist never changes pixels. The box-size resize path is migrated
onto it; the ad hoc per-route coalesceKey/reload handling is removed.

Extract the resize draft-rect math into resizeDraft.ts to keep the
gesture-handler file under the size cap.

Also: keep url_hash telemetry to the route slug only (drop the query
string, which carried the user's selected element id/selector), and gate
the [hf-resize] diagnostics behind localStorage hf-resize-debug so they
ship as opt-in tracing rather than console noise.

* fix(studio): transaction owns the undo label

The coalesced history entry took the last sub-mutation's label, so a
resize surfaced as "Move layer" (the offset persist) in undo/redo. The
seam now stamps tx.label on every wrapped mutation, so the folded entry
reads as the gesture.

* fix(studio): atomic static size/position commits (no data loss)

Static resize/position holds updated an existing set via delete+add — two
undo entries, and a delete that succeeded before a failed add lost the
hold on disk. Use one in-place update-properties mutation when a set
exists (one undo entry, no partial-failure window). The keyframed-hold
heal that can't be expressed as a property update now adds before it
deletes, so any single failure leaves a recoverable duplicate, never a
lost hold. Transaction-owned commits are tracked via a WeakSet so the
heal path never double-wraps an already-wrapped gesture.

* fix(core): restore timed-clip visibility after a forced timeline rebind

__hfForceTimelineRebind force-rendered the re-registered timeline but never
re-ran the per-[data-start] visibility pass, so after undo or soft reload
every clip rendered regardless of its time window until a full page reload.
Extract the visibility loop into syncTimedElementVisibility and call it from
both syncMediaForCurrentState (unchanged) and the rebind.

* fix(studio): atomic z-order/keyframe/split commits, one undo entry each

Three edit-commit paths hardened onto the one-transaction invariant:

- Z-order reorder (useElementLifecycleOps): N per-element writes now fold
  into one undo entry (coalesceMs Infinity) and, on a failed persist,
  restore already-written files to disk so no partial reorder survives.
- Enable-keyframes (useEnableKeyframes/useGsapKeyframeOps): the intermediate
  convert phase no longer full-reloads the preview (skipReload), killing the
  black-flash remount; convert + edit share one coalesce key = one undo entry.
- Razor split-all (useRazorSplit): snapshot before the batch and restore on
  any failure, so a mid-batch error never leaves un-revertable partial splits.

Shared file-history helpers (RecordEditInput, DomEditCommitBaseParams,
readProjectFileContent, restoreFilesToOriginal) dedupe the rollback/commit
logic across these paths. Commit options thread as one partial object rather
than field-by-field. Test setup extracted into colocated helpers.

* fix(studio): fold multi-step edits into one undo entry; guard text revert

- Gesture recording (useGestureCommit): the per-property-group commits now
  share one coalesce key and only the last reloads, so a recording is one
  undo entry and one preview reload instead of up to four.
- Delete selected keyframes (deleteSelectedKeyframes, split out of
  timelineEditingHelpers): N removals fold into one coalesced undo entry
  with a single reload.
- Text-field commit (useDomEditTextCommits): commitDomTextFields now uses
  the same version-guarded revert as handleDomTextCommit, so a stale failed
  commit can no longer stomp a newer successful one.

* feat(studio): batch a gesture's mutations into one atomic server write

A transaction that emits N mutations previously did N sequential POSTs,
each rewriting the file and soft-reloading — the root of the multi-phase
persist window. Add a gsap-mutations-batch endpoint that validates every
mutation up front, applies them in one in-memory rewrite chain, and writes
the file once (all-or-nothing: an invalid entry rejects the whole batch,
no partial write). The seam buffers a transaction's commits and, when more
than one targets the same file, dispatches a single batch — one write, one
history entry, one reload. The batch capability rides on the existing
commit-function reference; no option fields are threaded through callers.

* fix(studio): soften off-canvas indicator outline to 30% opacity

The dashed off-canvas selection outline at 60% was noisy with many
protruding elements on screen; drop the resting opacity to 30% (hover
still restores full opacity so it stays discoverable).

* fix(studio): drop off-canvas indicator outline to 10% opacity

Follow-up to the 30% softening — 10% resting opacity reads much calmer
with many protruding elements; hover still restores full opacity.

* fix(studio): gate [hf-commit] console traces to dev only

The start/settled/persisted/restore lifecycle traces logged on every
gesture commit in all environments — console noise for end users. Route
them through a dev-only traceCommit helper (matching the pixel-violation
error's existing DEV gate). The commit_* PostHog events stay always on;
they are the production observability, the console lines are a dev aid.

* fix(studio): count actual reloads, not softReload requests, in commit telemetry

A resize's size and offset persists both request softReload; the seam
counted each request, so a batched gesture reported reload_count 2 even
though the batch is one write and one reload. Compute the count from what
dispatchBufferedCommits actually did — one for a batch, the request count
for the sequential fallback.

* fix(studio): rotate hover + off-canvas overlays with the element; flicker-free crop

- Hover overlay applied the element's rotation only to the selection chrome,
  not the hover box; it now rotates about center like the selection, via a
  shared orientedGroupAwareOverlayRect router (one owner for rotation-aware
  overlay geometry across hover/selection/off-canvas).
- Off-canvas indicator was axis-aligned; it now rotates with the element and
  inverse-rotates the canvas-exclusion clip into the element's local frame,
  so the protruding-sliver clip stays correct for rotated elements.
- Crop commit re-lifted the element only in the commit's .then(), so one
  frame painted the cropped state (the flicker). Re-lift synchronously right
  after onStyleCommit (which applies the clip before its first await), so the
  cropped state never paints; the persisted file value is unchanged.

* fix(studio): address code-review findings across the commit-hardening campaign

Correctness (would ship green, bite under latency):
- Enable-keyframes phase 2 now carries coalesceMs: Infinity, so the convert
  folds into one undo entry instead of splitting past the 300ms default.
- The SDK keyframe persist path forwards coalesceMs (CutoverOptions gains the
  field); multi-keyframe delete and convert coalesce correctly when SDK-routed.
- Razor split-all's rollback is guarded so a failing restore can't swallow the
  error toast that tells the user the split failed.

Simplification (single source of truth / no dead flexibility):
- Decompose resolveResizeDraftRect (drops a fallow-ignore suppression).
- Delegate the third readProjectFileContent copy to the shared helper.
- Inline setPatchFromUpdateProperties (its only caller passes one mutation).
- One toSdkPersistOptions translates gesture overrides to SDK options.
- Bundle the reorder-rollback deps into one object (was 7-9 positional args).
- Dedupe the 'last group reloads' ternary; type gesture options as
  CommitMutationOptions; drop a Map+array wrapper around a single write.

* feat(studio): atomic z-order reorder via batch patch-element endpoint

Z-order reorder issued N per-element inline-style patches (one server
write each), so a mid-chain failure could leave a partial reorder on disk.
Add a patch-elements-batch endpoint that validates every patch, folds them
over the file in one in-memory rewrite, and writes once (all-or-nothing;
unsafe input rejects with no write). The reorder now sends one batch per
source file and records one undo entry. Because a failed atomic write
persists nothing, the interim disk-write-back rollback (restoreReorderedFile
/ restoreFulfilledReorderFiles / ReorderRollbackDeps) is deleted — failure
rolls back only live DOM/store state. Closes the last disk-atomicity gap.

* fix(studio): razor-split undo no longer silently no-ops

The split clone was written to disk without a data-hf-id, so the split
endpoint recorded that unstamped HTML as the undo entry's afterHash. The
next reloadPreview() ran the preview route's ensureHfIds write-back, which
minted a fresh id and persisted DIFFERENT bytes — so at undo time the disk
hash no longer matched afterHash and editHistory's content-mismatch guard
silently refused the undo (no write, no network, no error). Stamp the split
output via ensureHfIds in splitElementInHtml before it is written/returned,
so the preview write-back is a no-op and the recorded afterHash always
equals the final on-disk bytes. Fixes at the source rather than relaxing the
mismatch guard. Corrects the stale comment that credited forceReloadSdkSession.

* feat(studio): closed-hand grab cursor on the rotate handle

The rotate handle used the default arrow cursor; show a grabbing
(closed-hand) cursor on hover to signal it's grabbed and dragged to rotate.

* fix(studio): dropping a dragged element over another no longer selects it

A moved drag's release fired the box click, which re-selected whatever now
sat under the pointer via the hover cache — so dropping an element over a
higher-z one selected the drop target instead of keeping the dragged
element selected. The drag-move branch now suppresses the next box click,
mirroring the resize branch.

* fix(studio): group drag is one undo entry, not one per element

Dragging a multi-selected group committed each member's position write as
its own undo entry, so reverting took N Cmd+Z presses. Force a shared
coalesceKey (infinite window) across every member's commit so they fold
into a single undo entry, like the other multi-step commit paths.

* fix(studio): undo of a split no longer leaves a ghost clip in the timeline

The file and the composition iframe revert correctly on undo, but the
timeline panel kept a ghost node for the split clone. The element-merge
that repopulates the timeline preserves elements the fresh scan dropped —
intended for enriched sub-composition children a bare DOM re-scan misses,
but it also preserved a genuinely-removed TOP-LEVEL element (the split
clone after undo), leaving a phantom clip. Restrict the preserve to
elements with a compositionSrc (the enriched sub-comp children); a
top-level element missing from the fresh scan was truly removed.

---------

Co-authored-by: ukimsanov <ular.kimsanov@heygen.com>
2026-07-13 02:55:36 -04:00

426 lines
18 KiB
TypeScript

import { useEffect, useRef } from "react";
import { Magnet, MagnifyingGlassMinus, MagnifyingGlassPlus } from "@phosphor-icons/react";
import {
useEnableKeyframes,
isPlayheadWithinTween,
type EnableKeyframesSession,
} from "../hooks/useEnableKeyframes";
import { computeElementPercentage } from "../hooks/gsapShared";
import { useKeyframeKeyboard } from "../hooks/useKeyframeKeyboard";
import {
getNextTimelineZoomPercent,
getTimelineZoomPercent,
timelineZoomPercentToSlider,
timelineSliderToZoomPercent,
} from "../player/components/timelineZoom";
import { useTimelineZoom } from "../player/components/useTimelineZoom";
import { usePlayerStore, type TimelineElement } from "../player";
import {
STUDIO_KEYFRAMES_ENABLED,
STUDIO_RAZOR_TOOL_ENABLED,
} from "./editor/manualEditingAvailability";
import { Tooltip } from "./ui";
import { Scissors } from "../icons/SystemIcons";
import type { GsapAnimation } from "@hyperframes/core/gsap-parser";
import type { DomEditSelection } from "./editor/domEditingTypes";
import { canSplitElement } from "../utils/timelineElementSplit";
import { canAddBeatAt, addBeatAtCompositionTime } from "../utils/beatEditActions";
interface DomEditSessionSlice extends EnableKeyframesSession {
domEditSelection: DomEditSelection | null;
selectedGsapAnimations: GsapAnimation[];
}
interface TimelineToolbarProps {
domEditSession?: DomEditSessionSlice;
onSplitElement?: (element: TimelineElement, splitTime: number) => void;
}
function useKeyframeToggle(session?: DomEditSessionSlice) {
const currentTime = usePlayerStore((s) => s.currentTime);
const sessionRef = useRef(session);
sessionRef.current = session;
const onToggle = useEnableKeyframes(
sessionRef as React.RefObject<EnableKeyframesSession | undefined>,
);
if (!session) return { state: "none" as const, onToggle: undefined };
const sel = session.domEditSelection;
const anims = session.selectedGsapAnimations;
const kfAnim = anims.find((a) => a.keyframes);
let state: "active" | "inactive" | "none" = "none";
// Outside the tween, clicking extends the animation to the playhead rather than
// toggling a (clamped) edge keyframe — so the button stays an "add" affordance.
let willExtend = false;
if (kfAnim?.keyframes && sel) {
if (!isPlayheadWithinTween(kfAnim, currentTime)) {
state = "inactive";
willExtend = true;
} else {
// Tween-relative percentage (not the clip range) so the button state matches
// where the keyframe would actually land.
const pct = computeElementPercentage(currentTime, sel, kfAnim);
state = kfAnim.keyframes.keyframes.some((k) => Math.abs(k.percentage - pct) <= 1)
? "active"
: "inactive";
}
}
return { state, willExtend, onToggle: sel ? onToggle : undefined };
}
// fallow-ignore-next-line complexity
export function TimelineToolbar({ domEditSession, onSplitElement }: TimelineToolbarProps) {
const activeTool = usePlayerStore((s) => s.activeTool);
const setActiveTool = usePlayerStore((s) => s.setActiveTool);
const timelineSnapEnabled = usePlayerStore((s) => s.timelineSnapEnabled);
const setTimelineSnapEnabled = usePlayerStore((s) => s.setTimelineSnapEnabled);
const autoKeyframeEnabled = usePlayerStore((s) => s.autoKeyframeEnabled);
const setAutoKeyframeEnabled = usePlayerStore((s) => s.setAutoKeyframeEnabled);
// Subscribe so the add-beat button reacts to playhead movement and analysis load.
const currentTime = usePlayerStore((s) => s.currentTime);
const beatAnalysisReady = usePlayerStore((s) => s.beatAnalysis !== null);
// Subscribe (not getState) so the split button enables/disables the moment
// the selection changes, not only on the next playhead tick.
const selectedElementId = usePlayerStore((s) => s.selectedElementId);
const elements = usePlayerStore((s) => s.elements);
const { zoomMode, manualZoomPercent, setZoomMode, setManualZoomPercent } = useTimelineZoom();
const displayedTimelineZoomPercent = getTimelineZoomPercent(zoomMode, manualZoomPercent);
const {
state: keyframeState,
willExtend: keyframeWillExtend,
onToggle: onToggleKeyframe,
} = useKeyframeToggle(domEditSession);
// Wire the "Add keyframe (K)" shortcut the toolbar advertises. Active only when
// there's a keyframeable selection; otherwise K stays JKL-pause in playback.
useKeyframeKeyboard({
enabled: STUDIO_KEYFRAMES_ENABLED && Boolean(onToggleKeyframe),
onAddKeyframe: onToggleKeyframe,
});
// "N" toggles timeline snapping (industry convention: Resolve/FCP).
// Skip when typing in an input/contenteditable.
useEffect(() => {
// fallow-ignore-next-line complexity
const onKeyDown = (e: KeyboardEvent) => {
if (e.key !== "n" && e.key !== "N") return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const target = e.target instanceof HTMLElement ? e.target : null;
if (target?.isContentEditable) return;
const tag = target?.tagName?.toLowerCase() ?? "";
if (tag === "input" || tag === "textarea" || tag === "select") return;
const store = usePlayerStore.getState();
store.setTimelineSnapEnabled(!store.timelineSnapEnabled);
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
// CapCut-flat icon buttons: no per-button border/box chrome — a transparent
// 28px hit area with a subtle rounded hover wash, consistent 16px glyphs.
const flatBtn = "flex h-7 w-7 items-center justify-center rounded-md transition-colors";
const flatIdle = `${flatBtn} text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200 active:scale-[0.98]`;
const flatActive = `${flatBtn} bg-white/[0.08] text-neutral-100 active:scale-[0.98]`;
const flatDisabled = `${flatBtn} text-neutral-700 cursor-not-allowed`;
return (
// The "TIMELINE" label is dropped for CapCut-like density — the pane's
// position (tracks right below) makes it self-evident.
<div className="border-b border-neutral-800/60">
<div className="flex items-center justify-between px-2 py-0.5">
<div className="flex items-center gap-0.5">
{STUDIO_RAZOR_TOOL_ENABLED && (
<>
<Tooltip label="Selection tool (V)">
<button
type="button"
onClick={() => setActiveTool("select")}
aria-label="Selection tool"
aria-pressed={activeTool === "select"}
className={activeTool === "select" ? flatActive : flatIdle}
>
<svg width="16" height="16" viewBox="0 0 12 12" fill="currentColor">
<path d="M2 0.5L10 6L6.5 6.5L8.5 11L6.5 11.5L4.5 7L2 9Z" />
</svg>
</button>
</Tooltip>
<Tooltip label="Razor tool (B) — Shift+click splits all tracks">
<button
type="button"
onClick={() => setActiveTool("razor")}
aria-label="Razor tool"
aria-pressed={activeTool === "razor"}
className={activeTool === "razor" ? flatActive : flatIdle}
>
<Scissors 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)"}>
<button
type="button"
onClick={() => setTimelineSnapEnabled(!timelineSnapEnabled)}
aria-label="Toggle timeline snapping"
aria-pressed={timelineSnapEnabled}
className={timelineSnapEnabled ? flatActive : flatIdle}
>
<Magnet size={16} weight="bold" aria-hidden="true" />
</button>
</Tooltip>
{STUDIO_KEYFRAMES_ENABLED && (
// Always rendered (CapCut-style): with no keyframeable selection the
// button fades to a disabled state instead of unmounting, so the
// toolbar layout never shifts.
<Tooltip
label={
!onToggleKeyframe
? "Select an animated element to add keyframes"
: keyframeState === "active"
? "Remove keyframe at playhead (K)"
: keyframeState === "inactive"
? keyframeWillExtend
? "Add keyframe at playhead, extends animation (K)"
: "Add keyframe at playhead (K)"
: "Add keyframe (K)"
}
>
<button
type="button"
disabled={!onToggleKeyframe}
onClick={onToggleKeyframe}
aria-label={
keyframeState === "active"
? "Remove keyframe at playhead"
: "Add keyframe at playhead"
}
className={
!onToggleKeyframe
? flatDisabled
: `${flatBtn} active:scale-[0.98] hover:bg-white/[0.06] ${
keyframeState === "active"
? "text-studio-accent"
: keyframeState === "inactive"
? "text-neutral-400 hover:text-studio-accent"
: "text-neutral-600 hover:text-neutral-400"
}`
}
>
<svg width="16" height="16" viewBox="0 0 10 10" fill="currentColor">
{keyframeState === "active" ? (
<path d="M5 0.5L9.5 5L5 9.5L0.5 5Z" />
) : (
<path
d="M5 1.2L8.8 5L5 8.8L1.2 5Z"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
/>
)}
</svg>
</button>
</Tooltip>
)}
{STUDIO_KEYFRAMES_ENABLED && (
<Tooltip
label={
autoKeyframeEnabled
? "Auto-record manual edits as keyframes (click to turn off)"
: "Manual edits will not be recorded as keyframes (click to turn on)"
}
>
<button
type="button"
onClick={() => setAutoKeyframeEnabled(!autoKeyframeEnabled)}
aria-label="Auto-record manual edits as keyframes"
aria-pressed={autoKeyframeEnabled}
className={`${flatBtn} active:scale-[0.98] hover:bg-white/[0.06] ${
autoKeyframeEnabled
? "text-red-400 hover:text-red-300"
: "text-neutral-600 hover:text-neutral-400"
}`}
>
<svg width="16" height="16" viewBox="0 0 10 10" fill="none">
{/* Same diamond outline as the Add-keyframe icon, with a
record-style dot inside: filled = auto-recording,
hollow = manual edits won't be keyframed. */}
<path d="M5 0.7L9.3 5L5 9.3L0.7 5Z" stroke="currentColor" strokeWidth="1" />
<circle
cx="5"
cy="5"
r="1.8"
fill={autoKeyframeEnabled ? "currentColor" : "none"}
stroke="currentColor"
strokeWidth="1"
/>
</svg>
</button>
</Tooltip>
)}
{onSplitElement &&
(() => {
// Render the button unconditionally (disabled when unusable):
// mounting/unmounting mid-task shifts the neighboring controls.
// Mirrors the S-key gate: selected clip + playhead strictly inside it.
const el = selectedElementId
? elements.find((e) => (e.key ?? e.id) === selectedElementId)
: null;
const splittable = el != null && canSplitElement(el);
const canSplit =
splittable && currentTime > el.start && currentTime < el.start + el.duration;
return (
<Tooltip
label={
canSplit
? "Split at playhead (S)"
: splittable
? "Move the playhead inside the clip to split"
: "Select a clip to split"
}
>
<button
type="button"
disabled={!canSplit}
aria-label="Split at playhead"
onClick={() => {
if (canSplit && el) onSplitElement(el, currentTime);
}}
className={canSplit ? flatIdle : flatDisabled}
>
{/* "][" split glyph: two outward-facing brackets with a center gap */}
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{/* Right bracket of left half: ] */}
<path d="M5 3 L7 3 L7 13 L5 13" />
{/* Left bracket of right half: [ */}
<path d="M11 3 L9 3 L9 13 L11 13" />
</svg>
</button>
</Tooltip>
);
})()}
{(() => {
// Always rendered (CapCut-style): before beat analysis loads (or when
// the project has no analyzed music) the button fades to a disabled
// state instead of unmounting, so the toolbar layout never shifts.
const canAdd = beatAnalysisReady && canAddBeatAt(currentTime);
return (
<Tooltip
label={
!beatAnalysisReady
? "Add a music track with beat analysis to place beats"
: canAdd
? "Add beat at playhead"
: "A beat already exists at the playhead"
}
>
<button
type="button"
disabled={!canAdd}
aria-label="Add beat at playhead"
onClick={() => {
if (canAdd) addBeatAtCompositionTime(currentTime);
}}
className={
canAdd
? `${flatBtn} text-neutral-400 hover:bg-white/[0.06] hover:text-[#22c55e] active:scale-[0.98]`
: flatDisabled
}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path
d="M21 10C21 12.2091 16.9706 14 12 14M21 10C21 7.79086 16.9706 6 12 6C7.02944 6 3 7.79086 3 10M21 10V16C21 18.2091 16.9706 20 12 20M12 14C7.02944 14 3 12.2091 3 10M12 14V20M3 10V16C3 18.2091 7.02944 20 12 20M7 19.3264V13.3264M17 19.3264V13.3264M12 10L20 4"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</Tooltip>
);
})()}
</div>
<div className="flex items-center gap-0.5">
<Tooltip label="Fit timeline to width">
<button
type="button"
onClick={() => setZoomMode("fit")}
className={`h-7 px-2 rounded-md text-[11px] font-medium transition-colors ${
zoomMode === "fit"
? "bg-studio-accent/10 text-studio-accent"
: "text-neutral-400 hover:bg-white/[0.06] hover:text-neutral-200"
}`}
>
Fit
</button>
</Tooltip>
<Tooltip label="Zoom out">
<button
type="button"
aria-label="Zoom out"
onClick={() => {
setZoomMode("manual");
setManualZoomPercent(
getNextTimelineZoomPercent("out", zoomMode, manualZoomPercent),
);
}}
className={flatIdle}
>
<MagnifyingGlassMinus size={16} aria-hidden="true" />
</button>
</Tooltip>
<input
type="range"
min="0"
max="100"
value={timelineZoomPercentToSlider(displayedTimelineZoomPercent)}
title={`${displayedTimelineZoomPercent}%`}
aria-label="Timeline zoom"
onChange={(e) => {
setZoomMode("manual");
setManualZoomPercent(timelineSliderToZoomPercent(Number(e.target.value)));
}}
className="mx-1 w-[96px] cursor-pointer appearance-none bg-transparent [&::-webkit-slider-runnable-track]:h-[2px] [&::-webkit-slider-runnable-track]:rounded-full [&::-webkit-slider-runnable-track]:bg-neutral-700 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-[10px] [&::-webkit-slider-thumb]:h-[10px] [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:-mt-1 [&::-webkit-slider-thumb]:shadow-[0_0_0_2px_#0a0a0a,0_1px_3px_rgba(0,0,0,0.5)] [&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb:active]:cursor-grabbing"
/>
<Tooltip label="Zoom in">
<button
type="button"
aria-label="Zoom in"
onClick={() => {
setZoomMode("manual");
setManualZoomPercent(getNextTimelineZoomPercent("in", zoomMode, manualZoomPercent));
}}
className={flatIdle}
>
<MagnifyingGlassPlus size={16} aria-hidden="true" />
</button>
</Tooltip>
{/* Numeric zoom readout (main-parity): "Fit" in fit mode, N% in manual. */}
<span
className="ml-1 w-[38px] text-right font-mono text-[11px] tabular-nums text-neutral-500 select-none"
aria-label="Timeline zoom level"
>
{zoomMode === "fit" ? "Fit" : `${displayedTimelineZoomPercent}%`}
</span>
</div>
</div>
</div>
);
}