diff --git a/.gitignore b/.gitignore index aa4f2026..4a8f7deb 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,7 @@ Thumbs.db .env .env.local *.env.* + +/clypra_next_releases.md +/custom_gpu_rendering_plan.md ``` \ No newline at end of file diff --git a/src/components/editor/TopBar.tsx b/src/components/editor/TopBar.tsx index 5f0ce0a2..805b0d1c 100644 --- a/src/components/editor/TopBar.tsx +++ b/src/components/editor/TopBar.tsx @@ -1,5 +1,5 @@ import React, { useState, lazy, Suspense } from "react"; -import { Film, Upload, Home, Settings } from "lucide-react"; +import { Film, Upload, Home, Settings, Undo2, Redo2 } from "lucide-react"; import { Button } from "../ui/Button"; import { useProjectStore } from "@/store/projectStore"; import { useUIStore } from "@/store/uiStore"; @@ -17,7 +17,7 @@ interface TopBarProps { export const TopBar: React.FC = ({ onRequestClose }) => { const { project, closeProject } = useProjectStore(); const { toggleSettingsModal } = useUIStore(); - const { state: historyState } = useHistoryStore(); + const { state: historyState, undo, redo } = useHistoryStore(); const [showExportDialog, setShowExportDialog] = useState(false); const { isFullscreen } = useTauriFullscreen(); @@ -48,17 +48,16 @@ export const TopBar: React.FC = ({ onRequestClose }) => { {/* Right side - actions */}
- {/* Undo/Redo indicator */} - {(historyState.canUndo || historyState.canRedo) && ( -
- {historyState.position + 1} undo - {historyState.canRedo && ( - <> - - {historyState.size - historyState.position - 1} redo - - )} -
+ {/* Undo/Redo buttons with action-specific tooltips */} + {historyState.canUndo && ( + + )} + {historyState.canRedo && ( + )} +
+ + ); +}; + +// ── Main component ────────────────────────────────────────────────────── + export const TimelineRuler: React.FC = ({ pixelsPerSecond, scrollLeft }) => { const clockState = usePlaybackClock(); const frameRate = clockState.frameRate; const containerRef = useRef(null); const [viewportWidth, setViewportWidth] = useState(1200); + const [selectedMarkerId, setSelectedMarkerId] = useState(null); + + const { markers, addMarker, removeMarker, updateMarker } = useTimelineStore(); useEffect(() => { const el = containerRef.current; @@ -52,25 +316,22 @@ export const TimelineRuler: React.FC = ({ pixelsPerSecond, s return () => ro.disconnect(); }, []); - // ── Format label (CapCut style: always 00:SS) ────────────────────────────── + // ── Format label (00:SS) ──────────────────────────────────────────────── const formatLabel = useCallback((seconds: number): string => { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; }, []); - // ── Memoized tick generation ───────────────────────────────────────────────── + // ── Memoized tick generation ───────────────────────────────────────────── const ticks = useMemo(() => { const validPPS = typeof pixelsPerSecond === "number" && !isNaN(pixelsPerSecond) && pixelsPerSecond > 0 ? pixelsPerSecond : 50; const validScrollLeft = typeof scrollLeft === "number" && !isNaN(scrollLeft) ? scrollLeft : 0; const validViewportWidth = typeof viewportWidth === "number" && !isNaN(viewportWidth) && viewportWidth > 0 ? viewportWidth : 1200; - // Pick best interval let majorInterval = INTERVAL_TABLE[INTERVAL_TABLE.length - 1][0]; let minorDivisions = INTERVAL_TABLE[INTERVAL_TABLE.length - 1][1]; - // Iterate smallest → largest: pick the smallest interval that still - // guarantees ≥ MIN_LABEL_GAP_PX between labels (no overlap). for (let i = INTERVAL_TABLE.length - 1; i >= 0; i--) { const [interval, divisions] = INTERVAL_TABLE[i]; if (interval * validPPS >= MIN_LABEL_GAP_PX) { @@ -81,13 +342,10 @@ export const TimelineRuler: React.FC = ({ pixelsPerSecond, s } const minorInterval = majorInterval / minorDivisions; - - // Visible time range const padPx = 60; const startTime = Math.max(0, (validScrollLeft - padPx) / validPPS); const endTime = (validScrollLeft + validViewportWidth + padPx) / validPPS; - // Generate ticks const result: { time: number; isMajor: boolean }[] = []; const firstTick = Math.floor(startTime / minorInterval) * minorInterval; @@ -96,9 +354,9 @@ export const TimelineRuler: React.FC = ({ pixelsPerSecond, s for (let t = firstTick; t <= endTime && count < 2000; t += minorInterval) { const time = Math.round(t * 10000) / 10000; if (time < 0) continue; - - const isMajor = Math.abs(time % majorInterval) < minorInterval * 0.01 || Math.abs((time % majorInterval) - majorInterval) < minorInterval * 0.01; - + const isMajor = + Math.abs(time % majorInterval) < minorInterval * 0.01 || + Math.abs((time % majorInterval) - majorInterval) < minorInterval * 0.01; result.push({ time, isMajor }); count++; } @@ -107,11 +365,40 @@ export const TimelineRuler: React.FC = ({ pixelsPerSecond, s return result; }, [pixelsPerSecond, scrollLeft, viewportWidth]); - // ── Render ───────────────────────────────────────────────────────────── + // ── Double-click ruler to add marker ──────────────────────────────────── + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + const rect = containerRef.current?.getBoundingClientRect(); + if (!rect) return; + const clickX = e.clientX - rect.left + scrollLeft; + const time = Math.max(0, clickX / pixelsPerSecond); + addMarker(time); + }, + [pixelsPerSecond, scrollLeft, addMarker], + ); + + // ── Close popover when clicking background ──────────────────────────────── + const handleBackgroundClick = useCallback(() => { + setSelectedMarkerId(null); + }, []); + + const selectedMarker = markers.find((m) => m.id === selectedMarkerId); + return ( -
+
+ {/* ── Tick marks ── */} {ticks.map(({ time, isMajor }) => { - const x = Math.round(time * pixelsPerSecond); + const x = Math.round(time * pixelsPerSecond) - scrollLeft; return (
= ({ pixelsPerSecond, s position: "absolute", left: x, top: 0, + pointerEvents: "none", }} > - {/* Tick line — hangs from top */}
= ({ pixelsPerSecond, s backgroundColor: isMajor ? "var(--color-timeline-ruler-tick-major)" : "var(--color-timeline-ruler-tick-minor)", }} /> - {/* Label — CapCut places it right after the tick */} {isMajor && ( = ({ pixelsPerSecond, s
); })} + + {/* ── Marker pins ── */} + {markers.map((marker) => ( + setSelectedMarkerId((prev) => (prev === id ? null : id))} + onDragEnd={(id, newTime) => updateMarker(id, { time: newTime })} + /> + ))} + + {/* ── Marker popover ── */} + {selectedMarker && ( + setSelectedMarkerId(null)} + onUpdate={(updates) => updateMarker(selectedMarker.id, updates)} + onDelete={() => { + removeMarker(selectedMarker.id); + setSelectedMarkerId(null); + }} + /> + )}
); }; diff --git a/src/components/settings/KeyboardShortcutsSettings.tsx b/src/components/settings/KeyboardShortcutsSettings.tsx new file mode 100644 index 00000000..56b9f8cc --- /dev/null +++ b/src/components/settings/KeyboardShortcutsSettings.tsx @@ -0,0 +1,312 @@ +import React, { useState, useCallback, useRef } from "react"; +import { RotateCcw, Keyboard, Search, AlertTriangle, Check } from "lucide-react"; +import { useShortcutStore, formatBinding, getShortcutCategories, type KeyBinding } from "@/store/shortcutStore"; + +// ─── Key chip ────────────────────────────────────────────────────────────── + +function KeyChip({ binding }: { binding: KeyBinding }) { + const parts = formatBinding(binding).split(" "); + return ( + + {parts.map((part, i) => ( + + {part} + + ))} + + ); +} + +// ─── Capture Input ───────────────────────────────────────────────────────── + +interface CaptureInputProps { + onCapture: (binding: KeyBinding) => void; + onCancel: () => void; +} + +function CaptureInput({ onCapture, onCancel }: CaptureInputProps) { + const [captured, setCaptured] = useState(null); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Ignore bare modifier presses + if (["Control", "Meta", "Shift", "Alt"].includes(e.key)) return; + if (e.key === "Escape") { + onCancel(); + return; + } + + const binding: KeyBinding = { + key: e.key, + ctrl: e.ctrlKey || e.metaKey || undefined, + shift: e.shiftKey || undefined, + alt: e.altKey || undefined, + }; + + // Clean up undefined + if (!binding.ctrl) delete binding.ctrl; + if (!binding.shift) delete binding.shift; + if (!binding.alt) delete binding.alt; + + setCaptured(binding); + onCapture(binding); + }, + [onCapture, onCancel] + ); + + return ( + + ); +} + +// ─── Single shortcut row ─────────────────────────────────────────────────── + +interface ShortcutRowProps { + id: string; + label: string; + binding: KeyBinding; + defaultBinding: KeyBinding; + conflictWith: string | null; + onEdit: (id: string) => void; + onReset: (id: string) => void; + isEditing: boolean; + onCapture: (id: string, binding: KeyBinding) => void; + onCancelEdit: () => void; +} + +function ShortcutRow({ + id, + label, + binding, + defaultBinding, + conflictWith, + onEdit, + onReset, + isEditing, + onCapture, + onCancelEdit, +}: ShortcutRowProps) { + const isModified = formatBinding(binding) !== formatBinding(defaultBinding); + + return ( +
+
+ {label} + {conflictWith && ( + + + conflict + + )} +
+ +
+ {isEditing ? ( +
+ onCapture(id, b)} + onCancel={onCancelEdit} + /> +
+ ) : ( + + )} + + +
+
+ ); +} + +// ─── Main Component ──────────────────────────────────────────────────────── + +export function KeyboardShortcutsSettings() { + const { shortcuts, setShortcut, resetShortcut, resetAll } = useShortcutStore(); + const [editingId, setEditingId] = useState(null); + const [searchQuery, setSearchQuery] = useState(""); + const [recentlySaved, setRecentlySaved] = useState(null); + const [showResetConfirm, setShowResetConfirm] = useState(false); + + const categories = getShortcutCategories(); + + // Build conflict map: for each action id, which other action has the same binding? + const conflictMap = React.useMemo(() => { + const bindingIndex: Record = {}; + const result: Record = {}; + + for (const action of Object.values(shortcuts)) { + const key = formatBinding(action.binding); + if (bindingIndex[key] && bindingIndex[key] !== action.id) { + // Both sides conflict + result[action.id] = bindingIndex[key]; + result[bindingIndex[key]] = action.id; + } else { + bindingIndex[key] = action.id; + if (result[action.id] === undefined) result[action.id] = null; + } + } + + return result; + }, [shortcuts]); + + const handleEdit = (id: string) => { + setEditingId(id); + }; + + const handleCapture = (id: string, binding: KeyBinding) => { + setShortcut(id, binding); + setEditingId(null); + setRecentlySaved(id); + setTimeout(() => setRecentlySaved(null), 1500); + }; + + const handleCancelEdit = () => { + setEditingId(null); + }; + + const handleReset = (id: string) => { + resetShortcut(id); + }; + + const handleResetAll = () => { + resetAll(); + setShowResetConfirm(false); + setEditingId(null); + }; + + const filteredCategories = categories.filter((cat) => { + const actionsInCat = Object.values(shortcuts).filter( + (a) => + a.category === cat && + (!searchQuery || a.label.toLowerCase().includes(searchQuery.toLowerCase())) + ); + return actionsInCat.length > 0; + }); + + const hasAnyModified = Object.values(shortcuts).some( + (a) => formatBinding(a.binding) !== formatBinding(a.defaultBinding) + ); + + return ( +
+ {/* Header */} +
+

+ Click any binding to rebind it. Press Esc to cancel. +

+ + {showResetConfirm ? ( +
+ Reset all? + + +
+ ) : ( + + )} +
+ + {/* Search */} +
+ + setSearchQuery(e.target.value)} + className="w-full pl-8 pr-3 py-2 text-[12px] rounded-lg bg-surface-raised border border-white/6 text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent/40" + /> +
+ + {/* Shortcut list by category */} +
+ {filteredCategories.length === 0 && ( +

No shortcuts match "{searchQuery}"

+ )} + {filteredCategories.map((category) => { + const actionsInCat = Object.values(shortcuts).filter( + (a) => + a.category === category && + (!searchQuery || a.label.toLowerCase().includes(searchQuery.toLowerCase())) + ); + + return ( +
+

+ {category} +

+
+ {actionsInCat.map((action) => ( + + ))} +
+
+ ); + })} +
+
+ ); +} diff --git a/src/components/ui/SettingsModal.tsx b/src/components/ui/SettingsModal.tsx index feef3bca..b42ec625 100644 --- a/src/components/ui/SettingsModal.tsx +++ b/src/components/ui/SettingsModal.tsx @@ -1,5 +1,5 @@ import React, { useState } from "react"; -import { Check, Palette, SlidersHorizontal, Info, Paintbrush, RotateCcw, Copy, Download, Upload, HardDrive, Captions, RefreshCw } from "lucide-react"; +import { Check, Palette, SlidersHorizontal, Info, Paintbrush, RotateCcw, Copy, Download, Upload, HardDrive, Captions, RefreshCw, Keyboard } from "lucide-react"; import { openUrl } from "@tauri-apps/plugin-opener"; import { Modal } from "./Modal"; import { useSettingsStore, Theme, FontFamily, THEME_META, FONT_META, getThemeColors, getBaseThemeForCustomization, getThemeColorKeys } from "@/store/settingsStore"; @@ -7,6 +7,7 @@ import { useProjectStore } from "@/store/projectStore"; import { useTimelineStore } from "@/store/timelineStore"; import { CacheSettings } from "@/components/settings/CacheSettings"; import { WhisperSettings } from "@/components/settings/WhisperSettings"; +import { KeyboardShortcutsSettings } from "@/components/settings/KeyboardShortcutsSettings"; import { refitClipsForCanvasChange } from "@/lib/timeline/refitClips"; import { checkAppUpdate, installAndRelaunchUpdate, isTauriDesktop } from "@/services/updaterService"; @@ -15,11 +16,12 @@ interface SettingsModalProps { onClose: () => void; } -type Tab = "appearance" | "editor" | "captions" | "cache" | "about"; +type Tab = "appearance" | "editor" | "shortcuts" | "captions" | "cache" | "about"; const TABS: { id: Tab; label: string; icon: React.FC<{ className?: string }> }[] = [ { id: "appearance", label: "Appearance", icon: Palette }, { id: "editor", label: "Editor", icon: SlidersHorizontal }, + { id: "shortcuts", label: "Shortcuts", icon: Keyboard }, { id: "captions", label: "Auto-Captions", icon: Captions }, { id: "cache", label: "Storage & Cache", icon: HardDrive }, { id: "about", label: "About", icon: Info }, @@ -555,7 +557,7 @@ function AboutTab() {

Version 1.0.1

A modern, native video editor built with Tauri, React, and FFmpeg. Designed for speed and creative freedom.

- + {/* Auto-updater card */}
@@ -581,10 +583,7 @@ function AboutTab() { {updateStatus === "idle" && ( <>

Keep Clypra running at peak performance.

- @@ -607,10 +606,7 @@ function AboutTab() {

Clypra is up to date

You are currently running the latest version.

-
@@ -622,20 +618,15 @@ function AboutTab() {

New Version Available

v{updateInfo.version}

- + {updateInfo.body && (

Release Notes

-
- {updateInfo.body} -
+
{updateInfo.body}
)} - - @@ -648,10 +639,7 @@ function AboutTab() { {downloadProgress}%
-
+

The application will automatically restart once complete.

@@ -663,13 +651,8 @@ function AboutTab() { !

Update Check Failed

-

- {updateError || "An unknown error occurred."} -

- @@ -727,6 +710,7 @@ export const SettingsModal: React.FC = ({ isOpen, onClose })
{activeTab === "appearance" && } {activeTab === "editor" && } + {activeTab === "shortcuts" && } {activeTab === "captions" && } {activeTab === "cache" && } {activeTab === "about" && } diff --git a/src/core/history/CommandJournal.ts b/src/core/history/CommandJournal.ts index f1006a84..b6a9a370 100644 --- a/src/core/history/CommandJournal.ts +++ b/src/core/history/CommandJournal.ts @@ -58,6 +58,12 @@ export interface CommandJournalState { /** Active transaction (if any) */ activeTransaction: Transaction | null; + + /** Label for undo action (e.g., "Undo Move Clip") */ + undoLabel: string | null; + + /** Label for redo action (e.g., "Redo Move Clip") */ + redoLabel: string | null; } /** @@ -287,12 +293,17 @@ export class CommandJournal { * Get current history state. */ getState(): CommandJournalState { + const undoCommand = this._position >= 0 ? this._history[this._position] : null; + const redoCommand = this._position < this._history.length - 1 ? this._history[this._position + 1] : null; + return { canUndo: this.canUndo(), canRedo: this.canRedo(), position: this._position, size: this._history.length, activeTransaction: this._activeTransaction, + undoLabel: undoCommand?.label ?? null, + redoLabel: redoCommand?.label ?? null, }; } diff --git a/src/hooks/useKeyboardShortcuts.ts b/src/hooks/useKeyboardShortcuts.ts index 92354fda..e81455f1 100644 --- a/src/hooks/useKeyboardShortcuts.ts +++ b/src/hooks/useKeyboardShortcuts.ts @@ -6,6 +6,7 @@ import { useTimelineStore } from "@/store/timelineStore"; import { useUIStore } from "@/store/uiStore"; import { useProjectStore } from "@/store/projectStore"; import { useHistoryStore } from "@/store/historyStore"; +import { useShortcutStore } from "@/store/shortcutStore"; import { EditingActions } from "@/core/interactions"; import { generateId } from "@/lib/utils/id"; import { useAnchoredTimelineZoom } from "./useAnchoredTimelineZoom"; @@ -31,7 +32,7 @@ let copiedClipsClipboard: Array<{ export const useKeyboardShortcuts = () => { const { play, pause, seek, setActiveContext } = useTransportControls(); const { state: transportState, time: transportTime, speed } = useTransportSnapshot(); - const { swapClips, rippleEditEnabled, toggleRippleEdit } = useTimelineStore(); + const { swapClips, rippleEditEnabled, toggleRippleEdit, addMarker } = useTimelineStore(); const { selectedClipIds, selectClip, selectTrack, previewMode, exitSourceMode, markSourceIn, markSourceOut } = useUIStore(); const { project } = useProjectStore(); const { undo, redo } = useHistoryStore(); @@ -457,6 +458,16 @@ export const useKeyboardShortcuts = () => { // Select the new track useUIStore.getState().selectTrack(newTrackId); + } else if (e.key === "m" && !isMeta && !e.altKey) { + e.preventDefault(); + // M: Add marker at playhead position + const liveTime = getPlaybackClock().time; + const mins = Math.floor(liveTime / 60); + const secs = Math.floor(liveTime % 60); + const timeLabel = `${String(mins).padStart(2, "0")}:${String(secs).padStart(2, "0")}`; + addMarker(liveTime); + setToastMessage(`Marker added at ${timeLabel}`); + setTimeout(() => setToastMessage(null), 2000); } else if (e.key === "s" && !isMeta) { e.preventDefault(); const results = EditingActions.splitAtPlayhead(); @@ -499,7 +510,7 @@ export const useKeyboardShortcuts = () => { window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [isPlaying, transportTime, frameRate, selectedClipIds, previewMode, rippleEditEnabled, play, pause, seek, setActiveContext, zoomByStep, selectClip, selectTrack, exitSourceMode, markSourceIn, markSourceOut, swapClips, toggleRippleEdit, undo, redo]); + }, [isPlaying, transportTime, frameRate, selectedClipIds, previewMode, rippleEditEnabled, play, pause, seek, setActiveContext, zoomByStep, selectClip, selectTrack, exitSourceMode, markSourceIn, markSourceOut, swapClips, toggleRippleEdit, addMarker, undo, redo]); return { toastMessage }; }; diff --git a/src/store/projectStore.ts b/src/store/projectStore.ts index 08e744c7..bd1f86c6 100644 --- a/src/store/projectStore.ts +++ b/src/store/projectStore.ts @@ -395,6 +395,7 @@ export const useProjectStore = create((set, get) => ({ clips: normalizedClips, transitions: payload?.transitions ?? [], gaps: (payload as any)?.gaps ?? [], + markers: (payload as any)?.markers ?? [], }); console.log(" ✅ Timeline hydrated"); } catch (err) { @@ -554,10 +555,10 @@ export const useProjectStore = create((set, get) => ({ if (project) { try { const { useTimelineStore } = await import("./timelineStore"); - const { tracks, clips, transitions, gaps } = useTimelineStore.getState(); + const { tracks, clips, transitions, gaps, markers } = useTimelineStore.getState(); // Convert camelCase to snake_case using centralized serialization - const rustProject = toRustProject(project, { tracks, clips, transitions, gaps, mediaAssets }); + const rustProject = toRustProject(project, { tracks, clips, transitions, gaps, markers, mediaAssets }); const { invoke } = await import("@tauri-apps/api/core"); await invoke("save_project", { @@ -656,10 +657,10 @@ export const useProjectStore = create((set, get) => ({ try { // Import timeline store to get tracks and clips const { useTimelineStore } = await import("./timelineStore"); - const { tracks, clips, transitions, gaps } = useTimelineStore.getState(); + const { tracks, clips, transitions, gaps, markers } = useTimelineStore.getState(); // Convert camelCase to snake_case using centralized serialization - const rustProject = toRustProject(project, { tracks, clips, transitions, gaps, mediaAssets }); + const rustProject = toRustProject(project, { tracks, clips, transitions, gaps, markers, mediaAssets }); const { invoke } = await import("@tauri-apps/api/core"); await invoke("save_project", { diff --git a/src/store/shortcutStore.ts b/src/store/shortcutStore.ts new file mode 100644 index 00000000..c3f9e976 --- /dev/null +++ b/src/store/shortcutStore.ts @@ -0,0 +1,427 @@ +/** + * Shortcut Store + * + * Defines every named keyboard shortcut action with its default binding. + * Users can rebind shortcuts from the Settings → Shortcuts tab. + * Bindings are persisted to localStorage under "clypra-shortcuts". + * + * Usage: + * const { matchesShortcut } = useShortcutStore(); + * if (matchesShortcut(e, "undo")) { ... } + */ + +import { create } from "zustand"; +import { persist } from "zustand/middleware"; + +// ─── Types ─────────────────────────────────────────────────────────────────── + +export interface KeyBinding { + /** Primary key (e.g. "z", "Space", "ArrowLeft") */ + key: string; + /** Requires Ctrl (Windows/Linux) or Cmd (macOS) */ + ctrl?: boolean; + /** Requires Shift */ + shift?: boolean; + /** Requires Alt / Option */ + alt?: boolean; +} + +export interface ShortcutAction { + /** Machine-readable action id */ + id: string; + /** Human-readable label shown in the UI */ + label: string; + /** Category for grouping in the settings panel */ + category: string; + /** Default binding (cannot be deleted, only overridden) */ + defaultBinding: KeyBinding; + /** Current binding (may differ from default after user customisation) */ + binding: KeyBinding; +} + +// ─── Default Shortcut Registry ──────────────────────────────────────────────── + +const DEFAULT_SHORTCUTS: Omit[] = [ + // Transport + { + id: "play-pause", + label: "Play / Pause", + category: "Transport", + defaultBinding: { key: "Space" }, + }, + { + id: "pause", + label: "Pause", + category: "Transport", + defaultBinding: { key: "k" }, + }, + { + id: "seek-back-frame", + label: "Step Back One Frame", + category: "Transport", + defaultBinding: { key: "ArrowLeft" }, + }, + { + id: "seek-forward-frame", + label: "Step Forward One Frame", + category: "Transport", + defaultBinding: { key: "ArrowRight" }, + }, + // Source Mode + { + id: "mark-source-in", + label: "Mark In Point (Source)", + category: "Source Mode", + defaultBinding: { key: "i" }, + }, + { + id: "mark-source-out", + label: "Mark Out Point (Source)", + category: "Source Mode", + defaultBinding: { key: "o" }, + }, + { + id: "exit-source-mode", + label: "Exit Source Mode", + category: "Source Mode", + defaultBinding: { key: "Escape" }, + }, + // Edit + { + id: "undo", + label: "Undo", + category: "Edit", + defaultBinding: { key: "z", ctrl: true }, + }, + { + id: "redo", + label: "Redo", + category: "Edit", + defaultBinding: { key: "z", ctrl: true, shift: true }, + }, + { + id: "redo-alt", + label: "Redo (Alt)", + category: "Edit", + defaultBinding: { key: "y", ctrl: true }, + }, + { + id: "split-at-playhead", + label: "Split at Playhead", + category: "Edit", + defaultBinding: { key: "s" }, + }, + { + id: "split-selected-at-playhead", + label: "Split Selected at Playhead", + category: "Edit", + defaultBinding: { key: "k", ctrl: true }, + }, + { + id: "split-all-at-playhead", + label: "Split All at Playhead", + category: "Edit", + defaultBinding: { key: "k", ctrl: true, shift: true }, + }, + { + id: "delete-left-at-playhead", + label: "Delete Left of Playhead", + category: "Edit", + defaultBinding: { key: "q" }, + }, + { + id: "delete-right-at-playhead", + label: "Delete Right of Playhead", + category: "Edit", + defaultBinding: { key: "w" }, + }, + { + id: "duplicate-clips", + label: "Duplicate Selected Clips", + category: "Edit", + defaultBinding: { key: "d", ctrl: true }, + }, + { + id: "copy-clips", + label: "Copy Selected Clips", + category: "Edit", + defaultBinding: { key: "c", ctrl: true }, + }, + { + id: "paste-clips", + label: "Paste Clips", + category: "Edit", + defaultBinding: { key: "v", ctrl: true }, + }, + { + id: "swap-clips", + label: "Swap Clips", + category: "Edit", + defaultBinding: { key: "S", ctrl: true, shift: true }, + }, + { + id: "select-all", + label: "Select All Clips", + category: "Edit", + defaultBinding: { key: "a", ctrl: true }, + }, + { + id: "deselect-all", + label: "Deselect All Clips", + category: "Edit", + defaultBinding: { key: "d", ctrl: true, shift: true }, + }, + { + id: "clear-selection", + label: "Clear Selection", + category: "Edit", + defaultBinding: { key: "Escape" }, + }, + // Nudge + { + id: "nudge-right", + label: "Nudge Right 1 Frame", + category: "Nudge", + defaultBinding: { key: "]", ctrl: true }, + }, + { + id: "nudge-left", + label: "Nudge Left 1 Frame", + category: "Nudge", + defaultBinding: { key: "[", ctrl: true }, + }, + { + id: "nudge-right-10", + label: "Nudge Right 10 Frames", + category: "Nudge", + defaultBinding: { key: "]", ctrl: true, shift: true }, + }, + { + id: "nudge-left-10", + label: "Nudge Left 10 Frames", + category: "Nudge", + defaultBinding: { key: "[", ctrl: true, shift: true }, + }, + // Track Navigation + { + id: "select-clip-above", + label: "Select Clip on Track Above", + category: "Navigation", + defaultBinding: { key: "ArrowUp", alt: true }, + }, + { + id: "select-clip-below", + label: "Select Clip on Track Below", + category: "Navigation", + defaultBinding: { key: "ArrowDown", alt: true }, + }, + // Timeline + { + id: "zoom-in", + label: "Zoom In Timeline", + category: "Timeline", + defaultBinding: { key: "=", ctrl: true }, + }, + { + id: "zoom-out", + label: "Zoom Out Timeline", + category: "Timeline", + defaultBinding: { key: "-", ctrl: true }, + }, + { + id: "toggle-ripple-edit", + label: "Toggle Ripple Edit", + category: "Timeline", + defaultBinding: { key: "r" }, + }, + { + id: "add-marker", + label: "Add Timeline Marker", + category: "Timeline", + defaultBinding: { key: "m" }, + }, + // Track Operations + { + id: "toggle-track-lock", + label: "Toggle Track Lock", + category: "Track", + defaultBinding: { key: "l", ctrl: true, alt: true }, + }, + { + id: "toggle-track-visibility", + label: "Toggle Track Visibility", + category: "Track", + defaultBinding: { key: "v", ctrl: true, alt: true }, + }, + { + id: "toggle-track-mute", + label: "Toggle Track Mute", + category: "Track", + defaultBinding: { key: "m", ctrl: true, alt: true }, + }, + { + id: "pack-track", + label: "Pack Track (Remove Gaps)", + category: "Track", + defaultBinding: { key: "p", ctrl: true, alt: true }, + }, + { + id: "add-track", + label: "Add New Track", + category: "Track", + defaultBinding: { key: "t", ctrl: true, alt: true }, + }, +]; + +// Hydrate bindings from defaults +function buildInitialShortcuts(): Record { + const result: Record = {}; + for (const s of DEFAULT_SHORTCUTS) { + result[s.id] = { ...s, binding: { ...s.defaultBinding } }; + } + return result; +} + +// ─── Store Interface ────────────────────────────────────────────────────────── + +interface ShortcutStore { + shortcuts: Record; + + /** Override a single shortcut's binding */ + setShortcut: (id: string, binding: KeyBinding) => void; + + /** Reset a single shortcut to its default binding */ + resetShortcut: (id: string) => void; + + /** Reset all shortcuts to defaults */ + resetAll: () => void; + + /** Returns the action id whose binding matches the event, or null */ + getMatchingAction: (e: KeyboardEvent) => string | null; +} + +// ─── Store ──────────────────────────────────────────────────────────────────── + +export const useShortcutStore = create()( + persist( + (set, get) => ({ + shortcuts: buildInitialShortcuts(), + + setShortcut: (id, binding) => { + set((state) => ({ + shortcuts: { + ...state.shortcuts, + [id]: { ...state.shortcuts[id], binding }, + }, + })); + }, + + resetShortcut: (id) => { + set((state) => { + const action = state.shortcuts[id]; + if (!action) return state; + return { + shortcuts: { + ...state.shortcuts, + [id]: { ...action, binding: { ...action.defaultBinding } }, + }, + }; + }); + }, + + resetAll: () => { + set({ shortcuts: buildInitialShortcuts() }); + }, + + getMatchingAction: (e: KeyboardEvent) => { + const { shortcuts } = get(); + const isMeta = e.ctrlKey || e.metaKey; + for (const action of Object.values(shortcuts)) { + const b = action.binding; + if (b.key !== e.key) continue; + if (!!b.ctrl !== isMeta) continue; + if (!!b.shift !== e.shiftKey) continue; + if (!!b.alt !== e.altKey) continue; + return action.id; + } + return null; + }, + }), + { + name: "clypra-shortcuts", + // Only persist the binding overrides, not the full action metadata + // This way new shortcuts added in future versions are always picked up + partialize: (state) => ({ + shortcuts: Object.fromEntries( + Object.entries(state.shortcuts).map(([id, action]) => [ + id, + { binding: action.binding }, + ]) + ), + }), + // Merge persisted binding overrides back onto the full action list + merge: (persisted: any, current) => { + const base = buildInitialShortcuts(); + if (persisted?.shortcuts) { + for (const [id, data] of Object.entries(persisted.shortcuts as Record)) { + if (base[id] && data?.binding) { + base[id] = { ...base[id], binding: data.binding }; + } + } + } + return { ...current, shortcuts: base }; + }, + } + ) +); + +// ─── Standalone helper (usable outside React) ───────────────────────────────── + +/** + * Returns true if the keyboard event matches the named action's current binding. + * Can be called imperatively from event handlers. + */ +export function matchesShortcut(e: KeyboardEvent, actionId: string): boolean { + return useShortcutStore.getState().getMatchingAction(e) === actionId; +} + +/** Returns a human-readable key label for a binding, e.g. "⌘ Shift Z" */ +export function formatBinding(binding: KeyBinding): string { + const isMac = + typeof navigator !== "undefined" && + /Mac|iPhone|iPad/.test(navigator.userAgent); + const parts: string[] = []; + if (binding.ctrl) parts.push(isMac ? "⌘" : "Ctrl"); + if (binding.alt) parts.push(isMac ? "⌥" : "Alt"); + if (binding.shift) parts.push("Shift"); + + const keyLabel = + binding.key === "Space" + ? "Space" + : binding.key === "Escape" + ? "Esc" + : binding.key === "ArrowLeft" + ? "←" + : binding.key === "ArrowRight" + ? "→" + : binding.key === "ArrowUp" + ? "↑" + : binding.key === "ArrowDown" + ? "↓" + : binding.key.toUpperCase(); + parts.push(keyLabel); + return parts.join(" "); +} + +/** Returns all unique categories in definition order */ +export function getShortcutCategories(): string[] { + const seen = new Set(); + const result: string[] = []; + for (const s of DEFAULT_SHORTCUTS) { + if (!seen.has(s.category)) { + seen.add(s.category); + result.push(s.category); + } + } + return result; +} diff --git a/src/store/timelineStore.ts b/src/store/timelineStore.ts index 7a43069b..af0e670e 100644 --- a/src/store/timelineStore.ts +++ b/src/store/timelineStore.ts @@ -22,7 +22,7 @@ */ import { create } from "zustand"; -import type { Track, TrackType, Clip, TextClip, TransitionTimelineItem, TransitionType } from "@/types"; +import type { Track, TrackType, Clip, TextClip, TransitionTimelineItem, TransitionType, TimelineMarker } from "@/types"; import type { Gap } from "@/types/gap"; import { generateId, getCounter } from "@/lib/utils/id"; import { detectGaps, createGap, insertGapWithRipple, removeGapWithRipple, resizeGap, packTrack, mergeAdjacentGaps, validateGap } from "@/lib/timeline/gapEngine"; @@ -69,7 +69,7 @@ interface TimelineStore { /** Increment epoch (for cache invalidation) */ incrementEpoch: () => void; /** Hydrate timeline state from project load (atomic operation) */ - hydrateFromProject: (payload: { tracks?: any[]; clips?: any[]; transitions?: TransitionTimelineItem[]; gaps?: Gap[] }) => void; + hydrateFromProject: (payload: { tracks?: any[]; clips?: any[]; transitions?: TransitionTimelineItem[]; gaps?: Gap[]; markers?: TimelineMarker[] }) => void; addTrack: (type: TrackType) => void; /** Inserts a track at index (clamped); returns the new track id. */ insertTrackAt: (type: TrackType, index: number) => string; @@ -107,6 +107,11 @@ interface TimelineStore { toggleGapProtection: (gapId: string) => void; detectAndSyncGaps: (trackId?: string) => void; packTrackGaps: (trackId: string) => void; + // Marker operations + markers: TimelineMarker[]; + addMarker: (time: number, name?: string, color?: string) => string; + removeMarker: (markerId: string) => void; + updateMarker: (markerId: string, updates: Partial) => void; } const trackHeights: Record = { @@ -213,6 +218,7 @@ export const useTimelineStore = create( clips: [], gaps: [], // NEW: Initialize empty gaps array transitions: [], + markers: [], mainVideoTrackId: null, epoch: 0, zoomLevel: TIMELINE_ZOOM_DEFAULT, @@ -259,6 +265,7 @@ export const useTimelineStore = create( const finalClipsRaw = payload?.clips ?? []; const finalTransitions = payload?.transitions ?? []; const finalGaps = (payload as any)?.gaps ?? []; // Load gaps from project + const finalMarkers: TimelineMarker[] = (payload as any)?.markers ?? []; // Normalize clip timing with media asset data const mediaAssets = useProjectStore.getState().mediaAssets; @@ -277,6 +284,7 @@ export const useTimelineStore = create( clips: normalizedClips, gaps: finalGaps, // Load gaps from project transitions: finalTransitions, + markers: finalMarkers, scrollLeft: 0, zoomLevel: TIMELINE_ZOOM_DEFAULT, pixelsPerSecond: TIMELINE_ZOOM_DEFAULT * TIMELINE_PPS_PER_ZOOM, @@ -1316,5 +1324,33 @@ export const useTimelineStore = create( return next; }); }, + + // ─── Marker actions ────────────────────────────────────────────────────── + + addMarker: (time, name = "Marker", color = "purple") => { + const id = generateId("marker"); + const marker: TimelineMarker = { id, time, name, color }; + set((state) => ({ + markers: [...state.markers, marker].sort((a, b) => a.time - b.time), + epoch: state.epoch + 1, + })); + return id; + }, + + removeMarker: (markerId) => { + set((state) => ({ + markers: state.markers.filter((m) => m.id !== markerId), + epoch: state.epoch + 1, + })); + }, + + updateMarker: (markerId, updates) => { + set((state) => ({ + markers: state.markers + .map((m) => (m.id === markerId ? { ...m, ...updates } : m)) + .sort((a, b) => a.time - b.time), + epoch: state.epoch + 1, + })); + }, })), ); diff --git a/src/types/index.ts b/src/types/index.ts index 5db7bbb0..01f5cdb8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -86,6 +86,7 @@ export interface Project { frameRate: 24 | 30 | 60; duration: number; mediaAssets?: MediaAsset[]; + markers?: TimelineMarker[]; /** Timeline schema version for forward-compatible project migrations. */ timelineSchemaVersion?: number; } @@ -181,6 +182,10 @@ export interface Clip { conform?: import("@clypra-studio/engine").ClipConform; /** Audio volume (0.0 to 1.0, default 1.0) */ volume?: number; + /** Audio fade in duration in seconds */ + fadeIn?: number; + /** Audio fade out duration in seconds */ + fadeOut?: number; kind?: ClipKind; // Optional for backward compatibility /** Video overlays (actual video files like smoke, fire, light leaks) */ overlays?: ClipOverlay[]; @@ -484,3 +489,10 @@ export interface TransformConstraints { snapToGrid?: boolean; snapThreshold?: number; } + +export interface TimelineMarker { + id: string; + time: number; + name: string; + color: string; +} diff --git a/src/types/serialization.ts b/src/types/serialization.ts index d1a36823..c263a013 100644 --- a/src/types/serialization.ts +++ b/src/types/serialization.ts @@ -15,7 +15,7 @@ * - Easy to maintain when schema changes */ -import type { Project, MediaAsset, Track, Clip, AspectRatio, TransitionTimelineItem } from "./index"; +import type { Project, MediaAsset, Track, Clip, AspectRatio, TransitionTimelineItem, TimelineMarker } from "./index"; import type { Gap } from "./gap"; // ============================================================================ @@ -44,6 +44,7 @@ export interface RustProject { clips?: RustClip[]; transitions?: TransitionTimelineItem[]; gaps?: RustGap[]; + markers?: TimelineMarker[]; timeline_schema_version?: number | null; } @@ -110,6 +111,8 @@ export interface RustClip { fitMode?: "contain" | "cover" | "fill" | "stretch" | "original"; conform?: import("@clypra-studio/engine").ClipConform; volume?: number; + fade_in?: number; + fade_out?: number; kind?: string; } @@ -332,6 +335,7 @@ export function toRustProject( mediaAssets?: MediaAsset[]; transitions?: TransitionTimelineItem[]; gaps?: Gap[]; + markers?: TimelineMarker[]; /** Update modification timestamp to current time (default: true, set false for round-trip serialization) */ updateModifiedTime?: boolean; }, @@ -352,6 +356,7 @@ export function toRustProject( clips: options?.clips?.map(toRustClip) ?? [], transitions: options?.transitions ?? [], gaps: options?.gaps?.map(toRustGap) ?? [], + markers: options?.markers ?? [], timeline_schema_version: frontend.timelineSchemaVersion ?? 1, }; }