From 6655244e6784e06d3471c859143a38c3498bd5b9 Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 10:38:40 +0100 Subject: [PATCH 01/34] =?UTF-8?q?docs:=20update=20README=20=E2=80=94=20mer?= =?UTF-8?q?ge=20security=20sections,=20add=20format=20table=20&=20size=20b?= =?UTF-8?q?adge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 61 +++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 63112f49e..747e497d6 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@

- Version + Version License FOSSA Status + ~700 kB gzip

@@ -31,21 +32,35 @@ --- -## Security Model +## Supported Formats -MasterSelects is a **local-first editor**. Your timeline, media processing, rendering, and most AI-adjacent operations stay on your machine unless you explicitly call an external API. The project now has explicit trust boundaries instead of relying on "it's just localhost". + + + + + + +
ImportExport
-**Current protections:** -- **Native Helper bridge:** Binds to `127.0.0.1` only and requires a random startup Bearer token for HTTP and WebSocket bridge operations -- **Dev bridge hardening:** Vite `/api/ai-tools` and local file routes require a per-session token and reject non-loopback browser origins -- **Path restrictions:** Local file reads, listings, uploads, and locate/search operations are restricted to explicit allowed roots instead of arbitrary disk access -- **AI tool policy:** External bridge calls run through caller restrictions and approval/confirmation gates instead of getting unrestricted editor control -- **Secret handling:** API keys are stored in encrypted IndexedDB, `.keys.enc` import/export is disabled, and logs redact common secret/token patterns -- **Security verification:** CI includes secret scanning plus JS and Rust security checks, and the repo has dedicated tests for bridge auth, file access, and tool policy behavior +| Type | Formats | +|------|---------| +| **Video** | MP4 (H.264, H.265, AV1), WebM (VP8, VP9, AV1), MOV, MKV | +| **Audio** | MP3, WAV, AAC, OGG, FLAC, M4A | +| **Image** | PNG, JPG, WebP, GIF, BMP, AVIF, SVG | +| **Project** | MasterSelects JSON, FCPXML (import planned) | +| **Download** | YouTube, TikTok, Instagram, Twitter/X, Vimeo + [all yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) via Native Helper | -**Known boundary:** this is still not "perfect sandboxing". Same-user local processes, malicious browser extensions, and compromised same-origin code can still be dangerous. The goal here is **clear, test-covered local trust boundaries**, not pretending a browser app is magically zero-risk. + -See [Security.md](docs/Features/Security.md) for the full trust model, secret handling, bridge details, and current limitations. +| Type | Formats | +|------|---------| +| **WebCodecs** | MP4 (H.264, H.265, VP9, AV1) — GPU-accelerated | +| **FFmpeg WASM** | ProRes, DNxHR, FFV1, UTVideo, MJPEG *(experimental)* | +| **Audio** | AAC (in MP4 container) | +| **Interchange** | FCPXML (Final Cut Pro / DaVinci Resolve) | +| **Frames** | PNG sequence | + +
--- @@ -161,23 +176,25 @@ cargo run --release # WebSocket :9876, HTTP :9877 | **AI Control** | Local HTTP bridge for external agents to steer the running editor | | **Download** | yt-dlp integration for YouTube, TikTok, Instagram, Twitter/X, Vimeo, and other supported sites | -**Export codecs:** H.264, H.265, VP9, AV1 via WebCodecs (production). ProRes, DNxHR, FFV1, UTVideo, MJPEG via experimental FFmpeg WASM path (single-threaded, requires custom build). - **Platforms:** Windows, Linux, macOS. Building the Native Helper requires Rust. Downloads also require `yt-dlp`. See [Native Helper docs](tools/native-helper/README.md) for platform-specific setup. --- -## Security Model +## Security + +MasterSelects is a **local-first editor**. Editing, rendering, caching, and most analysis stay in the browser unless you explicitly invoke an external provider or the Native Helper. -MasterSelects is local-first: editing, rendering, caching, and most analysis stay in the browser unless you explicitly invoke an external provider or the Native Helper. +- **API keys:** stored in IndexedDB with per-browser Web Crypto encryption +- **Native Helper:** binds to `127.0.0.1` only, requires a random startup Bearer token for HTTP and WebSocket +- **Dev bridge:** Vite `/api/ai-tools` and local file routes require a per-session token and reject non-loopback origins +- **Local file access:** restricted to explicit allowed roots (project root, temp, Desktop, Documents, Downloads, Videos) +- **AI tool policy:** external bridge calls run through caller restrictions and approval gates +- **Secret handling:** logs redact common secret/token patterns; `.keys.enc` export disabled +- **CI checks:** secret scanning, JS and Rust security audits, dedicated tests for bridge auth and file access policy -- **API keys:** stored in IndexedDB with per-browser Web Crypto encryption. This protects against casual inspection, not against same-origin script execution. -- **Dev bridge:** sensitive Vite routes require a per-session Bearer token and only accept localhost browser origins. -- **Native Helper:** binds to `127.0.0.1` and requires a random startup token for HTTP and WebSocket bridge operations. -- **Local file access:** limited to explicit roots such as the project root, temp, Desktop, Documents, Downloads, and Videos, plus optional `MASTERSELECTS_ALLOWED_FILE_ROOTS`. -- **Key export:** `.keys.enc` export/import is disabled until passphrase-based encryption is implemented. +**Known boundary:** this is not perfect sandboxing. Same-user local processes, malicious browser extensions, and compromised same-origin code can still be dangerous. The goal is **clear, test-covered local trust boundaries**. -See [Security docs](docs/Features/Security.md) for the trust model and known limitations, and [Native Helper docs](tools/native-helper/README.md) for auth examples. +See [Security.md](docs/Features/Security.md) for the full trust model and limitations. --- From 6838a41004fed850e1018961f75c6e1b8eb8b61c Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 10:44:09 +0100 Subject: [PATCH 02/34] docs: split format table into container vs codec, drop FFmpeg from listing Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 42 +++++++++++++++++------------------------- 1 file changed, 17 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 747e497d6..e342ce301 100644 --- a/README.md +++ b/README.md @@ -34,34 +34,26 @@ ## Supported Formats +Decoding depends on what the **browser** supports — the container is just the wrapper, the codec inside is what matters. + - - - - - + + + + + + + + + + +
ImportExport
- -| Type | Formats | -|------|---------| -| **Video** | MP4 (H.264, H.265, AV1), WebM (VP8, VP9, AV1), MOV, MKV | -| **Audio** | MP3, WAV, AAC, OGG, FLAC, M4A | -| **Image** | PNG, JPG, WebP, GIF, BMP, AVIF, SVG | -| **Project** | MasterSelects JSON, FCPXML (import planned) | -| **Download** | YouTube, TikTok, Instagram, Twitter/X, Vimeo + [all yt-dlp sites](https://github.com/yt-dlp/yt-dlp/blob/master/supportedsites.md) via Native Helper | - - - -| Type | Formats | -|------|---------| -| **WebCodecs** | MP4 (H.264, H.265, VP9, AV1) — GPU-accelerated | -| **FFmpeg WASM** | ProRes, DNxHR, FFV1, UTVideo, MJPEG *(experimental)* | -| **Audio** | AAC (in MP4 container) | -| **Interchange** | FCPXML (Final Cut Pro / DaVinci Resolve) | -| **Frames** | PNG sequence | - -
Import (Decode)
ContainersMP4, MOV, WebM, MKV, AVI, M4V
Video codecsH.264 (AVC), H.265 (HEVC)¹, VP8, VP9, AV1
Audio codecsAAC, MP3, Opus, Vorbis, FLAC, WAV/PCM
ImagePNG, JPG, WebP, GIF, BMP, AVIF, SVG
DownloadYouTube, TikTok, Instagram, Twitter/X, Vimeo + all yt-dlp sites via Native Helper
Export (Encode)
ContainersMP4, WebM
Video codecsH.264, H.265¹, VP9, AV1 — GPU-accelerated via WebCodecs
Audio codecsAAC (MP4), Opus (WebM)
InterchangeFCPXML (Final Cut Pro / DaVinci Resolve), PNG sequence
+¹ H.265 decode/encode depends on OS & hardware — full support on Windows, partial on macOS/Linux. + +> **MOV** files work because they share the same ISO BMFF container as MP4 — any MOV with H.264/H.265 inside plays fine. **MKV** works if it contains browser-decodable codecs (H.264, VP9, etc.). Files with unsupported codecs (e.g. ProRes in MOV) fall back to the Native Helper decode path when available. + --- ## What Makes This Different From 4a2d806f08ee3322333be6e3d07f36b57699ad57 Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 11:26:25 +0100 Subject: [PATCH 03/34] feat: add keyboard shortcut registry with NLE presets Central shortcut system with types, 6 NLE preset maps (MasterSelects, Premiere, DaVinci, FinalCut, After Effects, Beginner), HMR-safe singleton registry with key matching, and settingsStore integration for preset selection and custom overrides. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/services/shortcutPresets.ts | 161 +++++++++++++++++++++++++ src/services/shortcutRegistry.ts | 197 +++++++++++++++++++++++++++++++ src/services/shortcutTypes.ts | 87 ++++++++++++++ src/stores/settingsStore.ts | 69 +++++++++++ 4 files changed, 514 insertions(+) create mode 100644 src/services/shortcutPresets.ts create mode 100644 src/services/shortcutRegistry.ts create mode 100644 src/services/shortcutTypes.ts diff --git a/src/services/shortcutPresets.ts b/src/services/shortcutPresets.ts new file mode 100644 index 000000000..b453c6cc2 --- /dev/null +++ b/src/services/shortcutPresets.ts @@ -0,0 +1,161 @@ +// Keyboard Shortcut Presets — NLE-specific default key bindings + +import type { + ShortcutActionId, + ShortcutActionMeta, + ShortcutMap, + ShortcutPreset, + ShortcutPresetId, + KeyCombo, +} from './shortcutTypes'; + +// ─── Action metadata (for Settings UI) ────────────────────────────── + +export const ACTION_META: ShortcutActionMeta[] = [ + // Playback + { id: 'playback.playPause', label: 'Play / Pause', category: 'Playback' }, + { id: 'playback.pause', label: 'Pause', category: 'Playback' }, + { id: 'playback.playForward', label: 'Play Forward', category: 'Playback' }, + { id: 'playback.playReverse', label: 'Play Reverse', category: 'Playback' }, + { id: 'playback.toggleLoop', label: 'Toggle Loop', category: 'Playback' }, + // Navigation + { id: 'nav.frameForward', label: 'Frame Forward', category: 'Navigation' }, + { id: 'nav.frameBackward', label: 'Frame Backward', category: 'Navigation' }, + // Editing + { id: 'edit.setIn', label: 'Set In Point', category: 'Editing' }, + { id: 'edit.setOut', label: 'Set Out Point', category: 'Editing' }, + { id: 'edit.clearInOut', label: 'Clear In/Out', category: 'Editing' }, + { id: 'edit.addMarker', label: 'Add Marker', category: 'Editing' }, + { id: 'edit.splitAtPlayhead', label: 'Split at Playhead', category: 'Editing' }, + { id: 'edit.delete', label: 'Delete', category: 'Editing' }, + { id: 'edit.copy', label: 'Copy', category: 'Editing' }, + { id: 'edit.paste', label: 'Paste', category: 'Editing' }, + { id: 'edit.blendModeNext', label: 'Next Blend Mode', category: 'Editing' }, + { id: 'edit.blendModePrev', label: 'Previous Blend Mode', category: 'Editing' }, + // Tools + { id: 'tool.cutToggle', label: 'Cut / Razor Tool', category: 'Tools' }, + // Project + { id: 'project.new', label: 'New Project', category: 'Project' }, + { id: 'project.open', label: 'Open Project', category: 'Project' }, + { id: 'project.save', label: 'Save', category: 'Project' }, + { id: 'project.saveAs', label: 'Save As', category: 'Project' }, + // History + { id: 'history.undo', label: 'Undo', category: 'History' }, + { id: 'history.redo', label: 'Redo', category: 'History' }, +]; + +// All valid action IDs (for runtime validation) +export const ALL_ACTION_IDS: ShortcutActionId[] = ACTION_META.map((m) => m.id); + +// ─── Base map (shared across all NLEs) ────────────────────────────── + +const BASE_MAP: ShortcutMap = { + // Playback + 'playback.playPause': [{ code: 'Space' }], + 'playback.pause': [{ key: 'k' }], + 'playback.playForward': [{ key: 'l' }], + 'playback.playReverse': [{ key: 'j' }], + 'playback.toggleLoop': [{ key: 'l', shift: true }], + // Navigation + 'nav.frameForward': [{ key: 'arrowright' }], + 'nav.frameBackward': [{ key: 'arrowleft' }], + // In/Out + 'edit.setIn': [{ key: 'i' }], + 'edit.setOut': [{ key: 'o' }], + 'edit.clearInOut': [{ key: 'x' }], + // Markers + 'edit.addMarker': [{ key: 'm' }], + // Delete (two keys) + 'edit.delete': [{ key: 'delete' }, { key: 'backspace' }], + // Copy/Paste + 'edit.copy': [{ key: 'c', ctrl: true }], + 'edit.paste': [{ key: 'v', ctrl: true }], + // Blend modes + 'edit.blendModeNext': [{ code: 'NumpadAdd' }, { key: '+' }], + 'edit.blendModePrev': [{ code: 'NumpadSubtract' }, { key: '-' }], + // Project + 'project.new': [{ key: 'n', ctrl: true }], + 'project.open': [{ key: 'o', ctrl: true }], + 'project.save': [{ key: 's', ctrl: true }], + 'project.saveAs': [{ key: 's', ctrl: true, shift: true }], + // History + 'history.undo': [{ key: 'z', ctrl: true }], + 'history.redo': [{ key: 'z', ctrl: true, shift: true }, { key: 'y', ctrl: true }], + // Overridden per preset: + 'tool.cutToggle': [{ key: 'c' }], + 'edit.splitAtPlayhead': [{ key: 'c', shift: true }], +}; + +// ─── Helper: create preset by overriding base ─────────────────────── + +function createPreset( + id: ShortcutPresetId, + label: string, + overrides: Partial>, +): ShortcutPreset { + return { + id, + label, + map: { ...BASE_MAP, ...overrides }, + }; +} + +// ─── Presets ───────────────────────────────────────────────────────── + +const masterselects = createPreset('masterselects', 'MasterSelects', { + // Default — no overrides, uses BASE_MAP as-is +}); + +const premiere = createPreset('premiere', 'Premiere Pro', { + // C = Razor tool (same as default) + 'tool.cutToggle': [{ key: 'c' }], + // Ctrl+K = Split (Add to playhead) + 'edit.splitAtPlayhead': [{ key: 'k', ctrl: true }], +}); + +const davinci = createPreset('davinci', 'DaVinci Resolve', { + // B = Blade tool + 'tool.cutToggle': [{ key: 'b' }], + // Ctrl+B = Split at playhead + 'edit.splitAtPlayhead': [{ key: 'b', ctrl: true }], +}); + +const finalcut = createPreset('finalcut', 'Final Cut Pro', { + // B = Blade tool + 'tool.cutToggle': [{ key: 'b' }], + // Ctrl+B = Split at playhead (Cmd+B on Mac, ctrl maps to both) + 'edit.splitAtPlayhead': [{ key: 'b', ctrl: true }], +}); + +const aftereffects = createPreset('aftereffects', 'After Effects', { + // C = Razor (same as default) + 'tool.cutToggle': [{ key: 'c' }], + // Ctrl+Shift+D = Split layer + 'edit.splitAtPlayhead': [{ key: 'd', ctrl: true, shift: true }], +}); + +const beginner = createPreset('beginner', 'Beginner', { + // Same as MasterSelects default — simplest layout +}); + +// ─── Exports ───────────────────────────────────────────────────────── + +export const PRESETS: Record = { + masterselects, + premiere, + davinci, + finalcut, + aftereffects, + beginner, +}; + +export const PRESET_LIST: ShortcutPreset[] = [ + masterselects, + premiere, + davinci, + finalcut, + aftereffects, + beginner, +]; + +export const DEFAULT_PRESET_ID: ShortcutPresetId = 'masterselects'; diff --git a/src/services/shortcutRegistry.ts b/src/services/shortcutRegistry.ts new file mode 100644 index 000000000..31a4e7397 --- /dev/null +++ b/src/services/shortcutRegistry.ts @@ -0,0 +1,197 @@ +// Keyboard Shortcut Registry — central singleton for key matching +// Subscribes to settingsStore for reactive preset/override changes + +import { useSettingsStore } from '../stores/settingsStore'; +import { PRESETS, DEFAULT_PRESET_ID } from './shortcutPresets'; +import type { + ShortcutActionId, + ShortcutMap, + KeyCombo, + ShortcutPresetId, +} from './shortcutTypes'; + +// ─── Key Matching ──────────────────────────────────────────────────── + +const isMac = navigator.platform.toUpperCase().includes('MAC'); + +function matchesCombo(combo: KeyCombo, e: KeyboardEvent): boolean { + // Modifier matching: ctrl flag means Ctrl OR Meta (for Mac Cmd) + const wantCtrl = !!combo.ctrl; + const hasCtrl = e.ctrlKey || e.metaKey; + if (wantCtrl !== hasCtrl) return false; + + const wantShift = !!combo.shift; + if (wantShift !== e.shiftKey) return false; + + const wantAlt = !!combo.alt; + if (wantAlt !== e.altKey) return false; + + // Code match takes priority (physical key) + if (combo.code) { + return e.code === combo.code; + } + + // Key match (logical key, case-insensitive) + if (combo.key) { + return e.key.toLowerCase() === combo.key.toLowerCase(); + } + + return false; +} + +// ─── Display Label ─────────────────────────────────────────────────── + +function formatKeyName(name: string): string { + const map: Record = { + arrowleft: '\u2190', + arrowright: '\u2192', + arrowup: '\u2191', + arrowdown: '\u2193', + space: 'Space', + delete: 'Del', + backspace: 'Backspace', + escape: 'Esc', + numpadadd: '+', + numpadsubtract: '-', + }; + const lower = name.toLowerCase(); + if (map[lower]) return map[lower]; + // Capitalize single letter + if (lower.length === 1) return lower.toUpperCase(); + // Capitalize first letter of others + return lower.charAt(0).toUpperCase() + lower.slice(1); +} + +function comboToLabel(combo: KeyCombo): string { + const parts: string[] = []; + if (combo.ctrl) parts.push(isMac ? '\u2318' : 'Ctrl'); + if (combo.alt) parts.push(isMac ? '\u2325' : 'Alt'); + if (combo.shift) parts.push(isMac ? '\u21E7' : 'Shift'); + parts.push(formatKeyName(combo.key || combo.code || '')); + return parts.join(isMac ? '' : '+'); +} + +// ─── Registry Class ────────────────────────────────────────────────── + +class ShortcutRegistry { + private effectiveMap: ShortcutMap; + private unsubscribe: (() => void) | null = null; + + constructor() { + this.effectiveMap = this.computeMap(); + this.subscribe(); + } + + private computeMap(): ShortcutMap { + const state = useSettingsStore.getState(); + const presetId: ShortcutPresetId = state.activeShortcutPreset || DEFAULT_PRESET_ID; + const preset = PRESETS[presetId] || PRESETS[DEFAULT_PRESET_ID]; + const overrides = state.shortcutOverrides; + + if (overrides) { + return { ...preset.map, ...overrides } as ShortcutMap; + } + return { ...preset.map }; + } + + private subscribe(): void { + // React to settingsStore changes + this.unsubscribe = useSettingsStore.subscribe( + (state) => ({ + preset: state.activeShortcutPreset, + overrides: state.shortcutOverrides, + }), + () => { + this.effectiveMap = this.computeMap(); + }, + { equalityFn: (a, b) => a.preset === b.preset && a.overrides === b.overrides }, + ); + } + + /** Check if a KeyboardEvent matches any combo for the given action */ + matches(action: ShortcutActionId, e: KeyboardEvent): boolean { + const combos = this.effectiveMap[action]; + if (!combos) return false; + return combos.some((combo) => matchesCombo(combo, e)); + } + + /** Get display label for first combo of an action, e.g. "Ctrl+S" */ + getLabel(action: ShortcutActionId): string { + const combos = this.effectiveMap[action]; + if (!combos || combos.length === 0) return ''; + return comboToLabel(combos[0]); + } + + /** Get display labels for all combos of an action */ + getLabels(action: ShortcutActionId): string[] { + const combos = this.effectiveMap[action]; + if (!combos) return []; + return combos.map(comboToLabel); + } + + /** Get the current effective map */ + getMap(): ShortcutMap { + return this.effectiveMap; + } + + /** Get combos for a specific action */ + getCombos(action: ShortcutActionId): KeyCombo[] { + return this.effectiveMap[action] || []; + } + + /** Find which actions conflict with a proposed combo (excluding the given action) */ + findConflicts( + excludeAction: ShortcutActionId, + combo: KeyCombo, + ): ShortcutActionId[] { + const conflicts: ShortcutActionId[] = []; + for (const [actionId, combos] of Object.entries(this.effectiveMap)) { + if (actionId === excludeAction) continue; + // Create a synthetic KeyboardEvent-like object for matching + const syntheticEvent = { + key: combo.key || '', + code: combo.code || '', + ctrlKey: !!combo.ctrl, + metaKey: !!combo.ctrl, // ctrl flag covers both + shiftKey: !!combo.shift, + altKey: !!combo.alt, + } as KeyboardEvent; + + if (combos.some((c) => matchesCombo(c, syntheticEvent))) { + conflicts.push(actionId as ShortcutActionId); + } + } + return conflicts; + } + + dispose(): void { + if (this.unsubscribe) { + this.unsubscribe(); + this.unsubscribe = null; + } + } +} + +// ─── HMR Singleton ─────────────────────────────────────────────────── + +let instance: ShortcutRegistry | null = null; + +if (import.meta.hot) { + import.meta.hot.accept(); + if (import.meta.hot.data?.shortcutRegistry) { + instance = import.meta.hot.data.shortcutRegistry; + } + import.meta.hot.dispose((data) => { + data.shortcutRegistry = instance; + }); +} + +export function getShortcutRegistry(): ShortcutRegistry { + if (!instance) { + instance = new ShortcutRegistry(); + } + return instance; +} + +// Re-export for convenience +export { comboToLabel, matchesCombo }; diff --git a/src/services/shortcutTypes.ts b/src/services/shortcutTypes.ts new file mode 100644 index 000000000..4351dd632 --- /dev/null +++ b/src/services/shortcutTypes.ts @@ -0,0 +1,87 @@ +// Keyboard Shortcut System - Type Definitions + +// Every bindable action in the app +export type ShortcutActionId = + // Playback + | 'playback.playPause' + | 'playback.pause' + | 'playback.playForward' + | 'playback.playReverse' + | 'playback.toggleLoop' + // Navigation + | 'nav.frameForward' + | 'nav.frameBackward' + // In/Out + | 'edit.setIn' + | 'edit.setOut' + | 'edit.clearInOut' + // Markers + | 'edit.addMarker' + // Tools + | 'tool.cutToggle' + | 'edit.splitAtPlayhead' + // Selection operations + | 'edit.delete' + | 'edit.copy' + | 'edit.paste' + // Blend modes + | 'edit.blendModeNext' + | 'edit.blendModePrev' + // Project + | 'project.new' + | 'project.open' + | 'project.save' + | 'project.saveAs' + // History + | 'history.undo' + | 'history.redo'; + +// A single key combination +export interface KeyCombo { + key?: string; // e.key value (lowercase), e.g. 'c', 'arrowleft', 'delete' + code?: string; // e.code for physical key, e.g. 'Space', 'NumpadAdd' + ctrl?: boolean; // Ctrl (or Cmd on Mac) + shift?: boolean; + alt?: boolean; +} + +// Complete shortcut map: action → key combos +export type ShortcutMap = Record; + +// Categories for grouping in UI +export type ShortcutCategory = + | 'Playback' + | 'Navigation' + | 'Editing' + | 'Tools' + | 'Project' + | 'History'; + +// Action metadata for settings UI display +export interface ShortcutActionMeta { + id: ShortcutActionId; + label: string; + category: ShortcutCategory; +} + +// Preset identifiers — matches TutorialOverlay WELCOME_BUTTONS +export type ShortcutPresetId = + | 'masterselects' + | 'premiere' + | 'davinci' + | 'finalcut' + | 'aftereffects' + | 'beginner'; + +export interface ShortcutPreset { + id: ShortcutPresetId; + label: string; + map: ShortcutMap; +} + +// User-saved named preset +export interface CustomShortcutPreset { + name: string; + map: ShortcutMap; + createdAt: number; +} diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 1a5faada5..43b3cc992 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -8,6 +8,8 @@ import { apiKeyManager, type ApiKeyType } from '../services/apiKeyManager'; import { projectFileService } from '../services/project/ProjectFileService'; import { flags } from '../engine/featureFlags'; import { Logger } from '../services/logger'; +import type { ShortcutPresetId, ShortcutMap, KeyCombo, ShortcutActionId, CustomShortcutPreset } from '../services/shortcutTypes'; +import { PRESETS, DEFAULT_PRESET_ID } from '../services/shortcutPresets'; const log = Logger.create('SettingsStore'); function persistChangelogStateToProject( @@ -112,6 +114,11 @@ interface SettingsState { // User background (which program they come from) userBackground: string | null; + // Keyboard shortcuts + activeShortcutPreset: ShortcutPresetId; + shortcutOverrides: Partial | null; + customPresets: CustomShortcutPreset[]; + // Tutorial campaign completion tracking completedTutorials: string[]; @@ -154,6 +161,14 @@ interface SettingsState { setHasSeenTutorial: (seen: boolean) => void; setHasSeenTutorialPart2: (seen: boolean) => void; setUserBackground: (bg: string) => void; + // Shortcut actions + setActiveShortcutPreset: (preset: ShortcutPresetId) => void; + setShortcutOverride: (action: ShortcutActionId, combos: KeyCombo[]) => void; + clearShortcutOverride: (action: ShortcutActionId) => void; + resetShortcutsToPreset: () => void; + saveCustomPreset: (name: string) => void; + loadCustomPreset: (name: string) => void; + deleteCustomPreset: (name: string) => void; completeTutorial: (campaignId: string) => void; setShowChangelogOnStartup: (show: boolean) => void; setLastSeenChangelogVersion: (version: string | null) => void; @@ -211,6 +226,9 @@ export const useSettingsStore = create()( hasSeenTutorial: false, // Show tutorial on first run hasSeenTutorialPart2: false, // Show timeline tutorial after part 1 userBackground: null, // Which program the user comes from + activeShortcutPreset: DEFAULT_PRESET_ID as ShortcutPresetId, + shortcutOverrides: null, + customPresets: [] as CustomShortcutPreset[], completedTutorials: [], // Campaign IDs that have been completed showChangelogOnStartup: true, // Show changelog dialog on every startup lastSeenChangelogVersion: null, // Latest app version whose changelog was acknowledged @@ -322,6 +340,54 @@ export const useSettingsStore = create()( set({ userBackground: bg }); }, + setActiveShortcutPreset: (preset) => { + set({ activeShortcutPreset: preset, shortcutOverrides: null }); + }, + + setShortcutOverride: (action, combos) => { + const current = get().shortcutOverrides || {}; + set({ shortcutOverrides: { ...current, [action]: combos } }); + }, + + clearShortcutOverride: (action) => { + const current = get().shortcutOverrides; + if (!current) return; + const next = { ...current }; + delete next[action as keyof typeof next]; + set({ shortcutOverrides: Object.keys(next).length > 0 ? next : null }); + }, + + resetShortcutsToPreset: () => { + set({ shortcutOverrides: null }); + }, + + saveCustomPreset: (name) => { + const state = get(); + const presetId = state.activeShortcutPreset || DEFAULT_PRESET_ID; + const preset = PRESETS[presetId] || PRESETS[DEFAULT_PRESET_ID]; + const effectiveMap = state.shortcutOverrides + ? { ...preset.map, ...state.shortcutOverrides } as ShortcutMap + : { ...preset.map }; + const existing = state.customPresets.filter((p) => p.name !== name); + set({ + customPresets: [ + ...existing, + { name, map: effectiveMap, createdAt: Date.now() }, + ], + }); + }, + + loadCustomPreset: (name) => { + const custom = get().customPresets.find((p) => p.name === name); + if (!custom) return; + // Store the full map as overrides on top of current preset + set({ shortcutOverrides: custom.map }); + }, + + deleteCustomPreset: (name) => { + set({ customPresets: get().customPresets.filter((p) => p.name !== name) }); + }, + completeTutorial: (campaignId) => { const current = get().completedTutorials; if (!current.includes(campaignId)) { @@ -417,6 +483,9 @@ export const useSettingsStore = create()( hasSeenTutorial: state.hasSeenTutorial, hasSeenTutorialPart2: state.hasSeenTutorialPart2, userBackground: state.userBackground, + activeShortcutPreset: state.activeShortcutPreset, + shortcutOverrides: state.shortcutOverrides, + customPresets: state.customPresets, completedTutorials: state.completedTutorials, showChangelogOnStartup: state.showChangelogOnStartup, lastSeenChangelogVersion: state.lastSeenChangelogVersion, From 2da63bddf406ff476bf51803ac4c4e10416b770b Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 11:29:22 +0100 Subject: [PATCH 04/34] feat: migrate keyboard handlers to shortcut registry Replace hardcoded key checks in useTimelineKeyboard, Toolbar, and useGlobalHistory with registry.matches() calls. Add useShortcut convenience hook. Menu shortcut labels are now dynamic. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/common/Toolbar.tsx | 47 +++++--- .../timeline/hooks/useTimelineKeyboard.ts | 110 ++++++++---------- src/hooks/useGlobalHistory.ts | 16 +-- src/hooks/useShortcut.ts | 49 ++++++++ 4 files changed, 131 insertions(+), 91 deletions(-) create mode 100644 src/hooks/useShortcut.ts diff --git a/src/components/common/Toolbar.tsx b/src/components/common/Toolbar.tsx index ce4c89af0..2a3fd8247 100644 --- a/src/components/common/Toolbar.tsx +++ b/src/components/common/Toolbar.tsx @@ -18,6 +18,7 @@ import { LegalDialog } from './LegalDialog'; import type { LegalPage } from './LegalDialog'; import { NativeHelperStatus } from './NativeHelperStatus'; import { projectFileService } from '../../services/projectFileService'; +import { getShortcutRegistry } from '../../services/shortcutRegistry'; import { useMediaStore } from '../../stores/mediaStore'; import { createNewProject, @@ -294,25 +295,20 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { setOpenMenu(null); }, []); - // Keyboard shortcut handler - // Global keyboard shortcuts - must prevent default FIRST + // Keyboard shortcut handler (capture phase — intercepts before other handlers) useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - const key = typeof e.key === 'string' ? e.key.toLowerCase() : ''; - - if (!key) { - return; - } + const registry = getShortcutRegistry(); - // Ctrl+S / Ctrl+Shift+S: Always prevent browser save dialog - if ((e.ctrlKey || e.metaKey) && key === 's') { + const handleKeyDown = (e: KeyboardEvent) => { + // Save/SaveAs: Always prevent browser save dialog, even in input fields + if (registry.matches('project.save', e) || registry.matches('project.saveAs', e)) { e.preventDefault(); e.stopPropagation(); // Skip if in input field if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; - if (e.shiftKey) { + if (registry.matches('project.saveAs', e)) { // Save As const name = prompt('Save project as:', projectName || 'New Project'); if (name) { @@ -347,12 +343,12 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { // Skip other shortcuts if in input field if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) return; - if ((e.ctrlKey || e.metaKey) && key === 'n') { + if (registry.matches('project.new', e)) { e.preventDefault(); handleNew(); } - if ((e.ctrlKey || e.metaKey) && key === 'o') { + if (registry.matches('project.open', e)) { e.preventDefault(); handleOpen(); } @@ -399,6 +395,19 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { const closeMenu = () => setOpenMenu(null); + // Dynamic shortcut labels from registry + const shortcutLabels = useMemo(() => { + const r = getShortcutRegistry(); + return { + new: r.getLabel('project.new'), + open: r.getLabel('project.open'), + save: r.getLabel('project.save'), + saveAs: r.getLabel('project.saveAs'), + copy: r.getLabel('edit.copy'), + paste: r.getLabel('edit.paste'), + }; + }, []); + return (

{/* Project Name */} @@ -457,20 +466,20 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) {
{isProjectOpen && ( <> @@ -577,11 +586,11 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) {
+
+
+ )} +
+

{editorMode ? 'AI Editor Ready' : 'Start a conversation'}

+ + {editorMode + ? 'Ask me to edit your timeline - cut clips, remove silence, etc.' + : `Using ${OPENAI_MODELS.find(m => m.id === model)?.name}`} + +
+ ) : ( messages.map(msg => { // Tool result messages - show compact diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 43b3cc992..0f87d50c0 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -110,6 +110,7 @@ interface SettingsState { hasCompletedSetup: boolean; hasSeenTutorial: boolean; hasSeenTutorialPart2: boolean; + hasSeenAIChatOnboarding: boolean; // User background (which program they come from) userBackground: string | null; @@ -160,6 +161,7 @@ interface SettingsState { setHasCompletedSetup: (completed: boolean) => void; setHasSeenTutorial: (seen: boolean) => void; setHasSeenTutorialPart2: (seen: boolean) => void; + setHasSeenAIChatOnboarding: (seen: boolean) => void; setUserBackground: (bg: string) => void; // Shortcut actions setActiveShortcutPreset: (preset: ShortcutPresetId) => void; @@ -225,6 +227,7 @@ export const useSettingsStore = create()( hasCompletedSetup: false, // Show welcome overlay on first run hasSeenTutorial: false, // Show tutorial on first run hasSeenTutorialPart2: false, // Show timeline tutorial after part 1 + hasSeenAIChatOnboarding: false, // Show AI chat onboarding hint on first open userBackground: null, // Which program the user comes from activeShortcutPreset: DEFAULT_PRESET_ID as ShortcutPresetId, shortcutOverrides: null, @@ -336,6 +339,10 @@ export const useSettingsStore = create()( set({ hasSeenTutorialPart2: seen }); }, + setHasSeenAIChatOnboarding: (seen) => { + set({ hasSeenAIChatOnboarding: seen }); + }, + setUserBackground: (bg) => { set({ userBackground: bg }); }, @@ -482,6 +489,7 @@ export const useSettingsStore = create()( hasCompletedSetup: state.hasCompletedSetup, hasSeenTutorial: state.hasSeenTutorial, hasSeenTutorialPart2: state.hasSeenTutorialPart2, + hasSeenAIChatOnboarding: state.hasSeenAIChatOnboarding, userBackground: state.userBackground, activeShortcutPreset: state.activeShortcutPreset, shortcutOverrides: state.shortcutOverrides, From b32fa3ac39759a7c12e58f588e4020d66d396f8b Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 11:32:14 +0100 Subject: [PATCH 06/34] feat: add Shortcuts settings tab with preset selector and key recorder New Settings > Shortcuts tab with: - NLE preset dropdown (MasterSelects, Premiere, DaVinci, FinalCut, AE, Beginner) - Searchable shortcut list grouped by category - Click-to-record key combos with conflict detection - Custom preset save/load/delete - Visual indicators for overridden shortcuts Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/common/SettingsDialog.tsx | 4 + .../common/settings/SettingsDialog.css | 190 ++++++++++++++ .../common/settings/ShortcutRecorder.tsx | 131 ++++++++++ .../common/settings/ShortcutsSettings.tsx | 232 ++++++++++++++++++ 4 files changed, 557 insertions(+) create mode 100644 src/components/common/settings/ShortcutRecorder.tsx create mode 100644 src/components/common/settings/ShortcutsSettings.tsx diff --git a/src/components/common/SettingsDialog.tsx b/src/components/common/SettingsDialog.tsx index db1bac793..327026a31 100644 --- a/src/components/common/SettingsDialog.tsx +++ b/src/components/common/SettingsDialog.tsx @@ -14,6 +14,7 @@ import { ApiKeysSettings } from './settings/ApiKeysSettings'; import { AIFeaturesSettings } from './settings/AIFeaturesSettings'; import { MidiSettings } from './settings/MidiSettings'; import { NativeHelperSettings } from './settings/NativeHelperSettings'; +import { ShortcutsSettings } from './settings/ShortcutsSettings'; import './settings/SettingsDialog.css'; interface SettingsDialogProps { @@ -23,6 +24,7 @@ interface SettingsDialogProps { type SettingsCategory = | 'appearance' | 'general' + | 'shortcuts' | 'previews' | 'import' | 'transcription' @@ -42,6 +44,7 @@ interface CategoryConfig { const categories: CategoryConfig[] = [ { id: 'appearance', label: 'Appearance', icon: '\uD83C\uDFA8' }, { id: 'general', label: 'General', icon: '\u2699' }, + { id: 'shortcuts', label: 'Shortcuts', icon: '\u2328' }, { id: 'previews', label: 'Previews', icon: '\u25B6' }, { id: 'import', label: 'Import', icon: '\uD83D\uDCE5' }, { id: 'transcription', label: 'Transcription', icon: '\uD83C\uDFA4' }, @@ -78,6 +81,7 @@ export function SettingsDialog({ onClose }: SettingsDialogProps) { switch (activeCategory) { case 'appearance': return ; case 'general': return ; + case 'shortcuts': return ; case 'previews': return ; case 'import': return ; case 'transcription': return ; diff --git a/src/components/common/settings/SettingsDialog.css b/src/components/common/settings/SettingsDialog.css index ed20bb12a..1ceecb113 100644 --- a/src/components/common/settings/SettingsDialog.css +++ b/src/components/common/settings/SettingsDialog.css @@ -609,3 +609,193 @@ text-align: right; flex-shrink: 0; } + +/* ─── Shortcuts Settings ──────────────────────────────────────────── */ + +.shortcut-search-input { + width: 100%; + box-sizing: border-box; +} + +.shortcut-list { + max-height: 240px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: 4px; + background: var(--bg-secondary, #222); +} + +.shortcut-category-group { + padding: 0; +} + +.shortcut-category-title { + padding: 6px 10px 4px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--text-tertiary, #888); + background: var(--bg-tertiary, #1a1a1a); + border-bottom: 1px solid var(--border-color); + position: sticky; + top: 0; + z-index: 1; +} + +.shortcut-row { + display: flex; + align-items: center; + justify-content: space-between; + padding: 4px 10px; + border-bottom: 1px solid var(--border-subtle, rgba(255,255,255,0.05)); + gap: 8px; +} + +.shortcut-row:last-child { + border-bottom: none; +} + +.shortcut-row:hover { + background: rgba(255, 255, 255, 0.03); +} + +.shortcut-row--overridden .shortcut-action-label { + color: var(--accent-color, #4d9fff); +} + +.shortcut-action-label { + font-size: 11px; + color: var(--text-primary); + white-space: nowrap; + flex-shrink: 0; + min-width: 120px; +} + +/* Recorder */ +.shortcut-recorder { + display: flex; + align-items: center; + gap: 4px; +} + +.shortcut-keycaps { + display: flex; + align-items: center; + gap: 3px; + padding: 2px 4px; + border-radius: 3px; + cursor: pointer; + min-width: 50px; + min-height: 22px; + justify-content: flex-end; +} + +.shortcut-keycaps:hover { + background: rgba(255, 255, 255, 0.06); +} + +.shortcut-keycaps.recording { + outline: 1.5px solid var(--accent-color, #4d9fff); + animation: shortcut-pulse 1s ease-in-out infinite; +} + +@keyframes shortcut-pulse { + 0%, 100% { outline-color: var(--accent-color, #4d9fff); } + 50% { outline-color: transparent; } +} + +.shortcut-keycap { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 1px 5px; + background: var(--bg-tertiary, #333); + border: 1px solid var(--border-color); + border-radius: 3px; + font-size: 10px; + font-family: monospace; + color: var(--text-primary); + min-width: 18px; + text-align: center; + white-space: nowrap; +} + +.shortcut-recording-text { + font-size: 10px; + color: var(--accent-color, #4d9fff); + font-style: italic; +} + +.shortcut-unbound { + font-size: 10px; + color: var(--text-tertiary, #666); + font-style: italic; +} + +.shortcut-reset-btn { + background: none; + border: none; + color: var(--text-tertiary, #888); + cursor: pointer; + font-size: 13px; + padding: 0 2px; + line-height: 1; +} + +.shortcut-reset-btn:hover { + color: var(--accent-color, #4d9fff); +} + +.shortcut-conflict { + font-size: 9px; + color: #ff6b6b; + white-space: nowrap; +} + +.shortcut-reset-all-btn { + font-size: 10px; + padding: 3px 8px; +} + +/* Custom presets */ +.shortcut-custom-save { + display: flex; + gap: 6px; + align-items: center; +} + +.shortcut-preset-name-input { + flex: 1; + min-width: 0; +} + +.shortcut-custom-list { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; +} + +.shortcut-custom-item { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 0; +} + +.shortcut-custom-name { + flex: 1; + font-size: 11px; + color: var(--text-primary); +} + +.shortcut-custom-load-btn, +.shortcut-custom-delete-btn { + font-size: 10px; + padding: 2px 6px; +} + +.shortcut-custom-delete-btn { + color: #ff6b6b; +} diff --git a/src/components/common/settings/ShortcutRecorder.tsx b/src/components/common/settings/ShortcutRecorder.tsx new file mode 100644 index 000000000..e63931ebb --- /dev/null +++ b/src/components/common/settings/ShortcutRecorder.tsx @@ -0,0 +1,131 @@ +// ShortcutRecorder — click-to-record key combo widget +// Shows current binding as keycap badges. Click to enter recording mode. + +import { useState, useEffect, useCallback, useRef } from 'react'; +import type { KeyCombo, ShortcutActionId } from '../../../services/shortcutTypes'; +import { comboToLabel } from '../../../services/shortcutRegistry'; + +interface ShortcutRecorderProps { + combos: KeyCombo[]; + actionId: ShortcutActionId; + isOverridden: boolean; + onRecord: (combo: KeyCombo) => void; + onReset: () => void; + conflictLabel?: string | null; +} + +function eventToCombo(e: KeyboardEvent): KeyCombo | null { + // Ignore bare modifier keys + if (['Control', 'Shift', 'Alt', 'Meta'].includes(e.key)) { + return null; + } + + const combo: KeyCombo = {}; + + // Use code for special keys + if (e.code === 'Space' || e.code.startsWith('Numpad')) { + combo.code = e.code; + } else { + combo.key = e.key.toLowerCase(); + } + + if (e.ctrlKey || e.metaKey) combo.ctrl = true; + if (e.shiftKey) combo.shift = true; + if (e.altKey) combo.alt = true; + + return combo; +} + +export function ShortcutRecorder({ + combos, + isOverridden, + onRecord, + onReset, + conflictLabel, +}: ShortcutRecorderProps) { + const [recording, setRecording] = useState(false); + const recorderRef = useRef(null); + + const startRecording = useCallback(() => { + setRecording(true); + }, []); + + const cancelRecording = useCallback(() => { + setRecording(false); + }, []); + + // Listen for keydown when recording + useEffect(() => { + if (!recording) return; + + const handleKeyDown = (e: KeyboardEvent) => { + e.preventDefault(); + e.stopPropagation(); + + // Escape cancels recording + if (e.key === 'Escape') { + setRecording(false); + return; + } + + const combo = eventToCombo(e); + if (combo) { + onRecord(combo); + setRecording(false); + } + }; + + // Click outside cancels recording + const handleMouseDown = (e: MouseEvent) => { + if (recorderRef.current && !recorderRef.current.contains(e.target as Node)) { + setRecording(false); + e.stopPropagation(); + } + }; + + window.addEventListener('keydown', handleKeyDown, true); + window.addEventListener('mousedown', handleMouseDown, true); + return () => { + window.removeEventListener('keydown', handleKeyDown, true); + window.removeEventListener('mousedown', handleMouseDown, true); + }; + }, [recording, onRecord]); + + return ( +
+
+ {recording ? ( + Press key... + ) : combos.length > 0 ? ( + combos.map((combo, i) => ( + + {comboToLabel(combo)} + + )) + ) : ( + Not set + )} +
+ + {isOverridden && !recording && ( + + )} + + {conflictLabel && ( + + Conflicts with: {conflictLabel} + + )} +
+ ); +} diff --git a/src/components/common/settings/ShortcutsSettings.tsx b/src/components/common/settings/ShortcutsSettings.tsx new file mode 100644 index 000000000..e9ffd4657 --- /dev/null +++ b/src/components/common/settings/ShortcutsSettings.tsx @@ -0,0 +1,232 @@ +// ShortcutsSettings — Settings tab for keyboard shortcut management +// Preset selection, searchable shortcut list, key recording, custom presets + +import { useState, useCallback, useMemo } from 'react'; +import { useSettingsStore } from '../../../stores/settingsStore'; +import { ACTION_META, PRESET_LIST } from '../../../services/shortcutPresets'; +import { getShortcutRegistry } from '../../../services/shortcutRegistry'; +import type { ShortcutPresetId, ShortcutCategory, KeyCombo, ShortcutActionId } from '../../../services/shortcutTypes'; +import { ShortcutRecorder } from './ShortcutRecorder'; + +const CATEGORIES_ORDER: ShortcutCategory[] = [ + 'Playback', + 'Navigation', + 'Editing', + 'Tools', + 'Project', + 'History', +]; + +export function ShortcutsSettings() { + const { + activeShortcutPreset, + shortcutOverrides, + customPresets, + setActiveShortcutPreset, + setShortcutOverride, + clearShortcutOverride, + resetShortcutsToPreset, + saveCustomPreset, + deleteCustomPreset, + loadCustomPreset, + } = useSettingsStore(); + + const [search, setSearch] = useState(''); + const [customPresetName, setCustomPresetName] = useState(''); + + const registry = useMemo(() => getShortcutRegistry(), []); + + // Group actions by category, filtered by search + const groupedActions = useMemo(() => { + const lowerSearch = search.toLowerCase(); + const filtered = ACTION_META.filter((meta) => { + if (!search) return true; + return ( + meta.label.toLowerCase().includes(lowerSearch) || + meta.id.toLowerCase().includes(lowerSearch) || + meta.category.toLowerCase().includes(lowerSearch) + ); + }); + + const groups: Record = {}; + for (const cat of CATEGORIES_ORDER) { + const items = filtered.filter((m) => m.category === cat); + if (items.length > 0) { + groups[cat] = items; + } + } + return groups; + }, [search]); + + const handlePresetChange = useCallback((e: React.ChangeEvent) => { + setActiveShortcutPreset(e.target.value as ShortcutPresetId); + }, [setActiveShortcutPreset]); + + const handleRecord = useCallback((actionId: ShortcutActionId, combo: KeyCombo) => { + setShortcutOverride(actionId, [combo]); + }, [setShortcutOverride]); + + const handleReset = useCallback((actionId: ShortcutActionId) => { + clearShortcutOverride(actionId); + }, [clearShortcutOverride]); + + const handleSaveCustom = useCallback(() => { + const name = customPresetName.trim(); + if (!name) return; + saveCustomPreset(name); + setCustomPresetName(''); + }, [customPresetName, saveCustomPreset]); + + const handleLoadCustom = useCallback((name: string) => { + loadCustomPreset(name); + }, [loadCustomPreset]); + + const handleDeleteCustom = useCallback((name: string) => { + deleteCustomPreset(name); + }, [deleteCustomPreset]); + + const getConflictLabel = useCallback((actionId: ShortcutActionId): string | null => { + const combos = registry.getCombos(actionId); + if (combos.length === 0) return null; + + for (const combo of combos) { + const conflicts = registry.findConflicts(actionId, combo); + if (conflicts.length > 0) { + const meta = ACTION_META.find((m) => m.id === conflicts[0]); + return meta?.label || conflicts[0]; + } + } + return null; + }, [registry]); + + return ( +
+

Keyboard Shortcuts

+ + {/* Preset Selection */} +
+
Preset
+ + + + {shortcutOverrides && Object.keys(shortcutOverrides).length > 0 && ( +
+ + {Object.keys(shortcutOverrides).length} custom override(s) + + +
+ )} +
+ + {/* Custom Presets */} + {(customPresets.length > 0 || true) && ( +
+
Custom Presets
+ +
+ setCustomPresetName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSaveCustom(); }} + /> + +
+ + {customPresets.length > 0 && ( +
+ {customPresets.map((preset) => ( +
+ {preset.name} + + +
+ ))} +
+ )} +
+ )} + + {/* Search */} +
+ setSearch(e.target.value)} + /> +
+ + {/* Shortcut List */} +
+ {CATEGORIES_ORDER.map((cat) => { + const actions = groupedActions[cat]; + if (!actions) return null; + + return ( +
+
{cat}
+ {actions.map((meta) => { + const combos = registry.getCombos(meta.id); + const isOverridden = !!(shortcutOverrides && meta.id in shortcutOverrides); + const conflict = getConflictLabel(meta.id); + + return ( +
+ {meta.label} + handleRecord(meta.id, combo)} + onReset={() => handleReset(meta.id)} + conflictLabel={conflict} + /> +
+ ); + })} +
+ ); + })} +
+
+ ); +} From 6f14ab93837ed9a0e0b94079b6877589d57e20c3 Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 11:35:26 +0100 Subject: [PATCH 07/34] feat: apply shortcut preset on onboarding NLE selection When user picks Premiere/DaVinci/FinalCut/AE/Beginner in the welcome screen, the matching keyboard shortcut preset is now automatically applied. Also fixes lint error in ShortcutsSettings. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/common/TutorialOverlay.tsx | 14 ++- .../common/settings/ShortcutsSettings.tsx | 86 +++++++++---------- 2 files changed, 55 insertions(+), 45 deletions(-) diff --git a/src/components/common/TutorialOverlay.tsx b/src/components/common/TutorialOverlay.tsx index cab85b55e..a5ff3a4a4 100644 --- a/src/components/common/TutorialOverlay.tsx +++ b/src/components/common/TutorialOverlay.tsx @@ -194,6 +194,7 @@ export function TutorialOverlay({ onClose, onSkip, part = 1, campaignSteps, camp const [isClosing, setIsClosing] = useState(false); const activatePanelType = useDockStore((s) => s.activatePanelType); const setUserBackground = useSettingsStore((s) => s.setUserBackground); + const setActiveShortcutPreset = useSettingsStore((s) => s.setActiveShortcutPreset); const closingRef = useRef(false); const isWelcome = stepIndex === -1; @@ -353,8 +354,19 @@ export function TutorialOverlay({ onClose, onSkip, part = 1, campaignSteps, camp const handleWelcomeSelect = useCallback((id: string) => { setUserBackground(id); + // Apply matching keyboard shortcut preset + const presetMap: Record = { + premiere: 'premiere', + davinci: 'davinci', + finalcut: 'finalcut', + aftereffects: 'aftereffects', + beginner: 'beginner', + }; + if (presetMap[id]) { + setActiveShortcutPreset(presetMap[id] as 'premiere' | 'davinci' | 'finalcut' | 'aftereffects' | 'beginner'); + } setStepIndex(0); - }, [setUserBackground]); + }, [setUserBackground, setActiveShortcutPreset]); const tooltipStyle = getTooltipStyle(); diff --git a/src/components/common/settings/ShortcutsSettings.tsx b/src/components/common/settings/ShortcutsSettings.tsx index e9ffd4657..0f74e3d00 100644 --- a/src/components/common/settings/ShortcutsSettings.tsx +++ b/src/components/common/settings/ShortcutsSettings.tsx @@ -135,51 +135,49 @@ export function ShortcutsSettings() {
{/* Custom Presets */} - {(customPresets.length > 0 || true) && ( -
-
Custom Presets
- -
- setCustomPresetName(e.target.value)} - onKeyDown={(e) => { if (e.key === 'Enter') handleSaveCustom(); }} - /> - -
- - {customPresets.length > 0 && ( -
- {customPresets.map((preset) => ( -
- {preset.name} - - -
- ))} -
- )} +
+
Custom Presets
+ +
+ setCustomPresetName(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter') handleSaveCustom(); }} + /> +
- )} + + {customPresets.length > 0 && ( +
+ {customPresets.map((preset) => ( +
+ {preset.name} + + +
+ ))} +
+ )} +
{/* Search */}
From 88838921dab39d02adbf4b128a4746e571f3f398 Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 11:41:53 +0100 Subject: [PATCH 08/34] fix: shortcut list not updating on preset change + consolidate settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix ShortcutsSettings: compute effective shortcut map reactively from store state (activeShortcutPreset + shortcutOverrides) via useMemo instead of reading from singleton registry with stale memo - Remove double-scroll in shortcuts tab (remove max-height/overflow from .shortcut-list, let settings-content handle scrolling) - Merge Output, Performance, Preview, Import, MIDI, AI Features into General tab — fewer sidebar items, less clicking - Reorder sidebar: General first, then Shortcuts - Increase preferences dialog height from 480px to 720px Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/common/SettingsDialog.tsx | 32 +--- .../common/settings/AIFeaturesSettings.tsx | 23 ++- .../common/settings/GeneralSettings.tsx | 141 +++++++++++++++++- .../common/settings/OutputSettings.tsx | 23 ++- .../common/settings/SettingsDialog.css | 7 +- .../common/settings/ShortcutsSettings.tsx | 21 ++- 6 files changed, 194 insertions(+), 53 deletions(-) diff --git a/src/components/common/SettingsDialog.tsx b/src/components/common/SettingsDialog.tsx index 327026a31..3bad0f211 100644 --- a/src/components/common/SettingsDialog.tsx +++ b/src/components/common/SettingsDialog.tsx @@ -5,14 +5,8 @@ import { useSettingsStore } from '../../stores/settingsStore'; import { useDraggableDialog } from './settings/useDraggableDialog'; import { AppearanceSettings } from './settings/AppearanceSettings'; import { GeneralSettings } from './settings/GeneralSettings'; -import { PreviewsSettings } from './settings/PreviewsSettings'; -import { ImportSettings } from './settings/ImportSettings'; import { TranscriptionSettings } from './settings/TranscriptionSettings'; -import { OutputSettings } from './settings/OutputSettings'; -import { PerformanceSettings } from './settings/PerformanceSettings'; import { ApiKeysSettings } from './settings/ApiKeysSettings'; -import { AIFeaturesSettings } from './settings/AIFeaturesSettings'; -import { MidiSettings } from './settings/MidiSettings'; import { NativeHelperSettings } from './settings/NativeHelperSettings'; import { ShortcutsSettings } from './settings/ShortcutsSettings'; import './settings/SettingsDialog.css'; @@ -22,17 +16,11 @@ interface SettingsDialogProps { } type SettingsCategory = - | 'appearance' | 'general' | 'shortcuts' - | 'previews' - | 'import' + | 'appearance' | 'transcription' - | 'output' - | 'performance' | 'nativeHelper' - | 'midi' - | 'aiFeatures' | 'apiKeys'; interface CategoryConfig { @@ -42,22 +30,16 @@ interface CategoryConfig { } const categories: CategoryConfig[] = [ - { id: 'appearance', label: 'Appearance', icon: '\uD83C\uDFA8' }, { id: 'general', label: 'General', icon: '\u2699' }, { id: 'shortcuts', label: 'Shortcuts', icon: '\u2328' }, - { id: 'previews', label: 'Previews', icon: '\u25B6' }, - { id: 'import', label: 'Import', icon: '\uD83D\uDCE5' }, + { id: 'appearance', label: 'Appearance', icon: '\uD83C\uDFA8' }, { id: 'transcription', label: 'Transcription', icon: '\uD83C\uDFA4' }, - { id: 'output', label: 'Output', icon: '\uD83D\uDCE4' }, - { id: 'performance', label: 'Performance', icon: '\u26A1' }, { id: 'nativeHelper', label: 'Native Helper', icon: '\u26A1' }, - { id: 'midi', label: 'MIDI Control', icon: '\uD83C\uDFB9' }, - { id: 'aiFeatures', label: 'AI Features', icon: '\u2726' }, { id: 'apiKeys', label: 'API Keys', icon: '\uD83D\uDD11' }, ]; export function SettingsDialog({ onClose }: SettingsDialogProps) { - const [activeCategory, setActiveCategory] = useState('appearance'); + const [activeCategory, setActiveCategory] = useState('general'); const dialogRef = useRef(null); const { position, isDragging, handleMouseDown } = useDraggableDialog(dialogRef); @@ -79,18 +61,12 @@ export function SettingsDialog({ onClose }: SettingsDialogProps) { const renderCategoryContent = () => { switch (activeCategory) { - case 'appearance': return ; case 'general': return ; case 'shortcuts': return ; - case 'previews': return ; - case 'import': return ; + case 'appearance': return ; case 'transcription': return ; - case 'output': return ; - case 'performance': return ; case 'nativeHelper': return ; - case 'midi': return ; case 'apiKeys': return ; - case 'aiFeatures': return ; default: return null; } }; diff --git a/src/components/common/settings/AIFeaturesSettings.tsx b/src/components/common/settings/AIFeaturesSettings.tsx index c8d6640fe..1bc4c9f56 100644 --- a/src/components/common/settings/AIFeaturesSettings.tsx +++ b/src/components/common/settings/AIFeaturesSettings.tsx @@ -48,7 +48,11 @@ function getStatusColor(status: MatAnyoneSetupStatus): string { } } -export function AIFeaturesSettings() { +interface AIFeaturesSettingsProps { + embedded?: boolean; +} + +export function AIFeaturesSettings({ embedded }: AIFeaturesSettingsProps = {}) { const { matanyoneEnabled, matanyonePythonPath, @@ -92,13 +96,11 @@ export function AIFeaturesSettings() { } }, [setMatAnyonePythonPath]); - return ( -
-

AI Features

- + const matAnyoneContent = ( + <> {/* MatAnyone2 Section */}
-
MatAnyone2 - AI Video Matting
+
{embedded ? 'AI Features — MatAnyone2' : 'MatAnyone2 - AI Video Matting'}
)} + + ); + + if (embedded) return matAnyoneContent; + + return ( +
+

AI Features

+ {matAnyoneContent}
); } diff --git a/src/components/common/settings/GeneralSettings.tsx b/src/components/common/settings/GeneralSettings.tsx index ef31c5b8a..72cba4e43 100644 --- a/src/components/common/settings/GeneralSettings.tsx +++ b/src/components/common/settings/GeneralSettings.tsx @@ -1,18 +1,28 @@ import { useCallback } from 'react'; -import { useSettingsStore, type AutosaveInterval } from '../../../stores/settingsStore'; +import { useSettingsStore, type AutosaveInterval, type PreviewQuality, type GPUPowerPreference } from '../../../stores/settingsStore'; import { useIsMobile } from '../../../hooks/useIsMobile'; +import { useMIDI } from '../../../hooks/useMIDI'; +import { OutputSettings } from './OutputSettings'; +import { AIFeaturesSettings } from './AIFeaturesSettings'; export function GeneralSettings() { const { autosaveEnabled, autosaveInterval, + copyMediaToProject, forceDesktopMode, + previewQuality, + gpuPowerPreference, setAutosaveEnabled, setAutosaveInterval, + setCopyMediaToProject, setForceDesktopMode, + setPreviewQuality, + setGpuPowerPreference, } = useSettingsStore(); const isMobileDevice = useIsMobile(); + const { isSupported, isEnabled, devices, lastMessage, enableMIDI, disableMIDI } = useMIDI(); const handleSwitchToMobile = useCallback(() => { setForceDesktopMode(false); @@ -23,6 +33,25 @@ export function GeneralSettings() {

General

+ {/* Import */} +
+
Import
+ + +

+ When importing clips, copy them to the project's Raw folder for easier relinking. +

+
+ + {/* Autosave */}
Autosave
@@ -63,6 +92,116 @@ export function GeneralSettings() {
)} + + {/* Output */} + + + {/* Preview */} +
+
Preview
+ + +

Lower resolution improves playback performance.

+ +

+ Transparency grid is per-tab. Toggle it using the checkerboard button in each preview panel. +

+
+ + {/* Performance */} +
+
Performance
+ + +

+ Requires page reload to take effect. +

+
+ + {/* MIDI */} +
+
MIDI Control
+ + {isSupported ? ( + <> + + + {isEnabled && ( + <> +
+ 0 ? 'connected' : 'disconnected'}`} /> + + {devices.length > 0 + ? `${devices.length} device${devices.length > 1 ? 's' : ''} connected` + : 'No devices detected'} + +
+ + {devices.length > 0 && ( +
+
Devices
+ {devices.map((device) => ( +
+ {device.name} + + {device.manufacturer !== 'Unknown' ? device.manufacturer : ''} + +
+ ))} +
+ )} + + {lastMessage && ( +
+
Last Message
+
+ + CH {lastMessage.channel} / CC {lastMessage.control} / Val {lastMessage.value} + +
+
+ )} + + )} + + ) : ( +

+ MIDI is not supported in this browser. Use Chrome or Edge for Web MIDI API support. +

+ )} +
+ + {/* AI Features */} +
); } diff --git a/src/components/common/settings/OutputSettings.tsx b/src/components/common/settings/OutputSettings.tsx index 72b4b49ed..0eb1e497b 100644 --- a/src/components/common/settings/OutputSettings.tsx +++ b/src/components/common/settings/OutputSettings.tsx @@ -1,14 +1,16 @@ import { useSettingsStore } from '../../../stores/settingsStore'; -export function OutputSettings() { - const { outputResolution, fps, setResolution } = useSettingsStore(); +interface OutputSettingsProps { + embedded?: boolean; +} - return ( -
-

Output

+export function OutputSettings({ embedded }: OutputSettingsProps) { + const { outputResolution, fps, setResolution } = useSettingsStore(); + const content = ( + <>
-
Default Resolution (New Compositions)
+
{embedded ? 'Output — Default Resolution' : 'Default Resolution (New Compositions)'}

Applies only to newly created compositions. Active composition resolution is set per composition in the Media Panel.

@@ -51,6 +53,15 @@ export function OutputSettings() { Current: {fps} FPS (configured per composition)

+ + ); + + if (embedded) return content; + + return ( +
+

Output

+ {content}
); } diff --git a/src/components/common/settings/SettingsDialog.css b/src/components/common/settings/SettingsDialog.css index 1ceecb113..99b059ae9 100644 --- a/src/components/common/settings/SettingsDialog.css +++ b/src/components/common/settings/SettingsDialog.css @@ -13,7 +13,7 @@ border-radius: 6px; width: 640px; max-width: 95vw; - height: 480px; + height: 720px; max-height: 90vh; display: flex; flex-direction: column; @@ -618,8 +618,6 @@ } .shortcut-list { - max-height: 240px; - overflow-y: auto; border: 1px solid var(--border-color); border-radius: 4px; background: var(--bg-secondary, #222); @@ -638,9 +636,6 @@ color: var(--text-tertiary, #888); background: var(--bg-tertiary, #1a1a1a); border-bottom: 1px solid var(--border-color); - position: sticky; - top: 0; - z-index: 1; } .shortcut-row { diff --git a/src/components/common/settings/ShortcutsSettings.tsx b/src/components/common/settings/ShortcutsSettings.tsx index 0f74e3d00..1840314ff 100644 --- a/src/components/common/settings/ShortcutsSettings.tsx +++ b/src/components/common/settings/ShortcutsSettings.tsx @@ -3,9 +3,9 @@ import { useState, useCallback, useMemo } from 'react'; import { useSettingsStore } from '../../../stores/settingsStore'; -import { ACTION_META, PRESET_LIST } from '../../../services/shortcutPresets'; +import { ACTION_META, PRESET_LIST, PRESETS } from '../../../services/shortcutPresets'; import { getShortcutRegistry } from '../../../services/shortcutRegistry'; -import type { ShortcutPresetId, ShortcutCategory, KeyCombo, ShortcutActionId } from '../../../services/shortcutTypes'; +import type { ShortcutPresetId, ShortcutCategory, ShortcutMap, KeyCombo, ShortcutActionId } from '../../../services/shortcutTypes'; import { ShortcutRecorder } from './ShortcutRecorder'; const CATEGORIES_ORDER: ShortcutCategory[] = [ @@ -34,7 +34,16 @@ export function ShortcutsSettings() { const [search, setSearch] = useState(''); const [customPresetName, setCustomPresetName] = useState(''); - const registry = useMemo(() => getShortcutRegistry(), []); + const registry = getShortcutRegistry(); + + // Compute effective map reactively from store state so combos update when preset changes + const effectiveMap = useMemo((): ShortcutMap => { + const preset = PRESETS[activeShortcutPreset] || PRESETS.masterselects; + if (shortcutOverrides) { + return { ...preset.map, ...shortcutOverrides } as ShortcutMap; + } + return { ...preset.map }; + }, [activeShortcutPreset, shortcutOverrides]); // Group actions by category, filtered by search const groupedActions = useMemo(() => { @@ -86,7 +95,7 @@ export function ShortcutsSettings() { }, [deleteCustomPreset]); const getConflictLabel = useCallback((actionId: ShortcutActionId): string | null => { - const combos = registry.getCombos(actionId); + const combos = effectiveMap[actionId] || []; if (combos.length === 0) return null; for (const combo of combos) { @@ -97,7 +106,7 @@ export function ShortcutsSettings() { } } return null; - }, [registry]); + }, [effectiveMap, registry]); return (
@@ -200,7 +209,7 @@ export function ShortcutsSettings() {
{cat}
{actions.map((meta) => { - const combos = registry.getCombos(meta.id); + const combos = effectiveMap[meta.id] || []; const isOverridden = !!(shortcutOverrides && meta.id in shortcutOverrides); const conflict = getConflictLabel(meta.id); From 34ac0a28a7a183a20e8c05887a7481a5be404bdb Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 11:57:00 +0100 Subject: [PATCH 09/34] feat: add video/audio toggle to effects panel Replace full-width Add Effect dropdown with compact layout: select takes remaining space, video/audio toggle buttons on the right. Audio effects show placeholder for now. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.css | 80 ++++++++++++++++++- .../panels/properties/EffectsTab.tsx | 70 ++++++++++++++-- 2 files changed, 144 insertions(+), 6 deletions(-) diff --git a/src/App.css b/src/App.css index b72a00b2a..e03301c73 100644 --- a/src/App.css +++ b/src/App.css @@ -2102,6 +2102,26 @@ input, textarea { pointer-events: none; } +.effect-item.dragging { + opacity: 0.4; +} + +.effect-item.drop-target { + border-top: 2px solid var(--accent-color, #4fc3f7); +} + +.effect-drag-handle { + cursor: grab; + opacity: 0.4; + font-size: 12px; + user-select: none; + line-height: 1; +} + +.effect-drag-handle:hover { + opacity: 0.8; +} + .effect-header { display: flex; align-items: center; @@ -2614,6 +2634,26 @@ input, textarea { background: transparent; } +/* AE-style rotation: "2x +30.0°" */ +.rotation-value-ae { + gap: 2px; +} + +.rotation-value-ae .draggable-number:first-of-type { + opacity: 0.6; + font-size: var(--font-2xs); + min-width: 20px; + flex-shrink: 0; +} + +.rotation-value-ae .draggable-number:first-of-type:hover { + opacity: 1; +} + +.rotation-row { + flex-wrap: wrap; +} + .properties-section .control-row input[type="range"] { flex: 1; } @@ -2731,11 +2771,15 @@ input, textarea { } .properties-tab-content.effects-tab .effect-add-row { + display: flex; + gap: var(--sp-1-5); + align-items: center; margin-bottom: var(--sp-2); } .properties-tab-content.effects-tab .effect-add-row select { - width: 100%; + flex: 1; + min-width: 0; padding: var(--sp-1-5) var(--sp-2-5); background: var(--bg-tertiary); border: 1px solid var(--border-color); @@ -2744,6 +2788,40 @@ input, textarea { font-size: var(--font-md); } +.effect-mode-toggle { + display: flex; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + overflow: hidden; + flex-shrink: 0; +} + +.effect-mode-btn { + display: flex; + align-items: center; + justify-content: center; + padding: var(--sp-1-5) var(--sp-2); + background: var(--bg-tertiary); + border: none; + color: var(--text-secondary); + cursor: pointer; + transition: background 0.15s, color 0.15s; +} + +.effect-mode-btn:not(:last-child) { + border-right: 1px solid var(--border-color); +} + +.effect-mode-btn:hover { + background: var(--bg-hover); + color: var(--text-primary); +} + +.effect-mode-btn.active { + background: var(--accent-color); + color: #fff; +} + .properties-tab-content.masks-tab .section-header-with-button { margin-bottom: var(--sp-2); } diff --git a/src/components/panels/properties/EffectsTab.tsx b/src/components/panels/properties/EffectsTab.tsx index 55cd04530..0b548c5e7 100644 --- a/src/components/panels/properties/EffectsTab.tsx +++ b/src/components/panels/properties/EffectsTab.tsx @@ -184,7 +184,12 @@ export function EffectsTab({ clipId, effects }: EffectsTabProps) { const playheadPosition = useTimelineStore(state => state.playheadPosition); const clips = useTimelineStore(state => state.clips); // Actions from getState() - stable, no subscription needed - const { addClipEffect, removeClipEffect, updateClipEffect, setClipEffectEnabled, setPropertyValue, getInterpolatedEffects } = useTimelineStore.getState(); + const { addClipEffect, removeClipEffect, updateClipEffect, setClipEffectEnabled, reorderClipEffect, setPropertyValue, getInterpolatedEffects } = useTimelineStore.getState(); + + // Drag-and-drop reorder state + const [dragIdx, setDragIdx] = useState(null); + const [dropIdx, setDropIdx] = useState(null); + const [effectMode, setEffectMode] = useState<'video' | 'audio'>('video'); const handleBatchStart = useCallback(() => startBatch('Adjust effect'), []); const handleBatchEnd = useCallback(() => endBatch(), []); @@ -198,7 +203,7 @@ export function EffectsTab({ clipId, effects }: EffectsTabProps) { return (
- { if (e.target.value) { addClipEffect(clipId, e.target.value); e.target.value = ''; } }} defaultValue="" disabled={effectMode === 'audio'}> {effectCategories.map(({ category, effects: catEffects }) => ( @@ -208,18 +213,73 @@ export function EffectsTab({ clipId, effects }: EffectsTabProps) { ))} +
+ + +
- {effects.length === 0 ? ( + {effectMode === 'audio' ? ( +

Audio effects coming soon

+ ) : effects.length === 0 ? (

No effects applied

) : (
- {effects.map((effect) => { + {effects.map((effect, idx) => { const interpolated = interpolatedEffects.find(e => e.id === effect.id) || effect; const isEnabled = effect.enabled !== false; // default to true if undefined + const isDragging = dragIdx === idx; + const isDropTarget = dropIdx === idx; return ( -
+
{ + setDragIdx(idx); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); + }} + onDragOver={(e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = 'move'; + setDropIdx(idx); + }} + onDragLeave={() => { if (dropIdx === idx) setDropIdx(null); }} + onDrop={(e) => { + e.preventDefault(); + const fromIdx = dragIdx ?? parseInt(e.dataTransfer.getData('text/plain'), 10); + if (!isNaN(fromIdx) && fromIdx !== idx) { + startBatch('Reorder effect'); + reorderClipEffect(clipId, effects[fromIdx].id, idx); + endBatch(); + } + setDragIdx(null); + setDropIdx(null); + }} + onDragEnd={() => { setDragIdx(null); setDropIdx(null); }} + >
+
- {filteredEffects.length === 0 ? ( -

{effectMode === 'audio' ? 'No audio effects applied' : 'No effects applied'}

+ {effectMode === 'audio' ? ( + + ) : videoEffects.length === 0 ? ( +

No effects applied

) : (
- {filteredEffects.map((effect, idx) => { + {videoEffects.map((effect, idx) => { const interpolated = interpolatedEffects.find(e => e.id === effect.id) || effect; const isEnabled = effect.enabled !== false; // default to true if undefined const isDragging = dragIdx === idx; @@ -279,12 +265,6 @@ export function EffectsTab({ clipId, effects }: EffectsTabProps) {
{ - setDragIdx(idx); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', String(idx)); - }} onDragOver={(e) => { e.preventDefault(); e.dataTransfer.dropEffect = 'move'; @@ -296,7 +276,7 @@ export function EffectsTab({ clipId, effects }: EffectsTabProps) { const fromIdx = dragIdx ?? parseInt(e.dataTransfer.getData('text/plain'), 10); if (!isNaN(fromIdx) && fromIdx !== idx) { startBatch('Reorder effect'); - reorderClipEffect(clipId, filteredEffects[fromIdx].id, idx); + reorderClipEffect(clipId, videoEffects[fromIdx].id, idx); endBatch(); } setDragIdx(null); @@ -305,7 +285,16 @@ export function EffectsTab({ clipId, effects }: EffectsTabProps) { onDragEnd={() => { setDragIdx(null); setDropIdx(null); }} >
- + { + setDragIdx(idx); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); + }} + >☰ @@ -151,9 +153,6 @@ export function PropertiesPanel() { ) : ( <> - {!isSolidClip && ( - - )} @@ -179,9 +178,8 @@ export function PropertiesPanel() { {activeTab === 'text' && isTextClip && selectedClip.textProperties && ( )} - {activeTab === 'transform' && !isAudioClip && } - {activeTab === 'volume' && } - {activeTab === 'effects' && } + {activeTab === 'transform' && !isAudioClip && } + {activeTab === 'effects' && } {activeTab === 'masks' && !isAudioClip && } {activeTab === 'transcript' && ( Date: Mon, 23 Mar 2026 12:41:32 +0100 Subject: [PATCH 12/34] feat: enable effects during scrubbing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove skipEffects=isDraggingPlayhead — effects now render during timeline scrubbing for instant visual feedback. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/engine/render/RenderDispatcher.ts | 147 +++++++++++++++++++++++++- 1 file changed, 146 insertions(+), 1 deletion(-) diff --git a/src/engine/render/RenderDispatcher.ts b/src/engine/render/RenderDispatcher.ts index 0e17a83ff..ea15821fc 100644 --- a/src/engine/render/RenderDispatcher.ts +++ b/src/engine/render/RenderDispatcher.ts @@ -24,6 +24,10 @@ import { scrubSettleState } from '../../services/scrubSettleState'; import { vfPipelineMonitor } from '../../services/vfPipelineMonitor'; import { getCopiedHtmlVideoPreviewFrame } from './htmlVideoPreviewFallback'; import { flags } from '../featureFlags'; +import type { ThreeSceneRenderer } from '../three/ThreeSceneRenderer'; +import type { Layer3DData, CameraConfig } from '../three/types'; +import { DEFAULT_CAMERA_CONFIG } from '../three/types'; +import { useMediaStore } from '../../stores/mediaStore'; const log = Logger.create('RenderDispatcher'); @@ -50,6 +54,7 @@ export interface RenderDeps { exportCanvasManager: ExportCanvasManager; performanceStats: PerformanceStats; renderLoop: RenderLoop | null; + threeSceneRenderer?: ThreeSceneRenderer | null; } export class RenderDispatcher { @@ -60,6 +65,10 @@ export class RenderDispatcher { private lastPreviewTargetTimeMs?: number; private static readonly MAX_DRAG_FALLBACK_DRIFT_SECONDS = 1.2; private static readonly MAX_DRAG_LIVE_IMPORT_DRIFT_SECONDS = 0.9; + // 3D scene rendering state + private threeSceneTexture: GPUTexture | null = null; + private threeSceneView: GPUTextureView | null = null; + private threeSceneInitializing = false; constructor(deps: RenderDeps) { this.deps = deps; @@ -206,7 +215,7 @@ export class RenderDispatcher { const t0 = performance.now(); const { width, height } = d.renderTargetManager.getResolution(); - const skipEffects = useTimelineStore.getState().isDraggingPlayhead; + const skipEffects = false; // Collect layer data const t1 = performance.now(); @@ -247,6 +256,14 @@ export class RenderDispatcher { } this.lastRenderHadContent = true; + // === 3D Layer Pass (Three.js) === + // If any layers have is3D=true and the feature flag is on, + // render them via Three.js to an OffscreenCanvas, import as texture, + // and replace individual 3D layers with a single synthetic layer. + if (flags.use3DLayers) { + this.process3DLayers(layerData, device, width, height); + } + // Pre-render nested compositions (batched with main composite) const commandBuffers: GPUCommandBuffer[] = []; let hasNestedComps = false; @@ -365,6 +382,134 @@ export class RenderDispatcher { reportRenderTime(totalTime); } + /** + * Process 3D layers: render them via Three.js to a texture, + * then replace individual 3D LayerRenderData entries with a single synthetic entry. + */ + private process3DLayers(layerData: LayerRenderData[], device: GPUDevice, width: number, height: number): void { + // Find 3D layers + const indices3D: number[] = []; + for (let i = 0; i < layerData.length; i++) { + if (layerData[i].layer.is3D) { + indices3D.push(i); + } + } + if (indices3D.length === 0) return; + + const d = this.deps; + const renderer = d.threeSceneRenderer; + if (!renderer || !renderer.isInitialized) { + // Lazy init happens async — on the first frame with 3D layers, + // we trigger init and skip the 3D pass. Next frames will render. + if (!this.threeSceneInitializing && !renderer?.isInitialized) { + this.threeSceneInitializing = true; + import('../three/ThreeSceneRenderer').then(({ getThreeSceneRenderer }) => { + const r = getThreeSceneRenderer(); + r.initialize(width, height).then((ok) => { + if (ok) { + d.threeSceneRenderer = r; + log.info('Three.js 3D renderer initialized lazily'); + } + this.threeSceneInitializing = false; + }); + }); + } + // Remove 3D layers from layerData so the old 2D shader doesn't render them + // with its fake perspective distortion while Three.js is loading + for (let i = indices3D.length - 1; i >= 0; i--) { + layerData.splice(indices3D[i], 1); + } + return; + } + + // Build Layer3DData from the 3D layers + const layers3D: Layer3DData[] = []; + for (const idx of indices3D) { + const data = layerData[idx]; + const layer = data.layer; + const src = layer.source; + const rot = typeof layer.rotation === 'number' + ? { x: 0, y: 0, z: layer.rotation } + : layer.rotation; + + layers3D.push({ + layerId: layer.id, + clipId: layer.sourceClipId || layer.id, + position: layer.position, + rotation: { x: rot.x, y: rot.y, z: rot.z }, + scale: { x: layer.scale.x, y: layer.scale.y, z: layer.scale.z ?? 1 }, + opacity: layer.opacity, + blendMode: layer.blendMode, + sourceWidth: data.sourceWidth || width, + sourceHeight: data.sourceHeight || height, + videoElement: src?.videoElement ?? undefined, + imageElement: src?.imageElement ?? undefined, + canvas: src?.textCanvas ?? undefined, + modelUrl: src?.modelUrl ?? undefined, + }); + } + + // Get camera config from active composition + const activeComp = useMediaStore.getState().getActiveComposition(); + const cameraConfig: CameraConfig = activeComp?.camera + ? { ...DEFAULT_CAMERA_CONFIG, ...activeComp.camera } + : DEFAULT_CAMERA_CONFIG; + + // Render the 3D scene + const canvas = renderer.renderScene(layers3D, cameraConfig, width, height); + if (!canvas) return; + + // Import the OffscreenCanvas as a GPU texture + // Ensure we have a texture of the right size + if (!this.threeSceneTexture || this.threeSceneTexture.width !== width || this.threeSceneTexture.height !== height) { + this.threeSceneTexture?.destroy(); + this.threeSceneTexture = device.createTexture({ + size: { width, height }, + format: 'rgba8unorm', + usage: GPUTextureUsage.TEXTURE_BINDING | GPUTextureUsage.COPY_DST | GPUTextureUsage.RENDER_ATTACHMENT, + }); + this.threeSceneView = this.threeSceneTexture.createView(); + } + + // Copy OffscreenCanvas content to GPU texture + // No flipY needed: WebGL canvas output is already top-left origin + device.queue.copyExternalImageToTexture( + { source: canvas }, + { texture: this.threeSceneTexture }, + { width, height }, + ); + + // Create a synthetic layer for the 3D scene + const insertIdx = indices3D[0]; // Insert at position of first 3D layer + const syntheticLayer: Layer = { + id: '__three_scene__', + name: '3D Scene', + visible: true, + opacity: 1, + blendMode: 'normal', + source: { type: 'image' }, + effects: [], + position: { x: 0, y: 0, z: 0 }, + scale: { x: 1, y: 1 }, + rotation: { x: 0, y: 0, z: 0 }, + }; + + const syntheticData: LayerRenderData = { + layer: syntheticLayer, + isVideo: false, + externalTexture: null, + textureView: this.threeSceneView, + sourceWidth: width, + sourceHeight: height, + }; + + // Remove 3D layers (in reverse to keep indices valid) and insert synthetic + for (let i = indices3D.length - 1; i >= 0; i--) { + layerData.splice(indices3D[i], 1); + } + layerData.splice(insertIdx, 0, syntheticData); + } + renderEmptyFrame(device: GPUDevice): void { const d = this.deps; const commandEncoder = device.createCommandEncoder(); From 9a2ee13e2f8f3a3e67812707a6e450c963c5993f Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 12:50:55 +0100 Subject: [PATCH 13/34] feat: show shortcut confirmation after NLE selection in onboarding After selecting a program in the welcome screen, shows a brief confirmation that shortcuts were adapted to the selected layout, with a note that the interactive tutorial is coming soon. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/common/TutorialOverlay.tsx | 35 +++++++++++++++++++---- 1 file changed, 29 insertions(+), 6 deletions(-) diff --git a/src/components/common/TutorialOverlay.tsx b/src/components/common/TutorialOverlay.tsx index a5ff3a4a4..eae410f76 100644 --- a/src/components/common/TutorialOverlay.tsx +++ b/src/components/common/TutorialOverlay.tsx @@ -192,12 +192,13 @@ export function TutorialOverlay({ onClose, onSkip, part = 1, campaignSteps, camp const [panelRect, setPanelRect] = useState(null); const [highlightRect, setHighlightRect] = useState(null); const [isClosing, setIsClosing] = useState(false); + const [shortcutConfirmation, setShortcutConfirmation] = useState(null); const activatePanelType = useDockStore((s) => s.activatePanelType); const setUserBackground = useSettingsStore((s) => s.setUserBackground); const setActiveShortcutPreset = useSettingsStore((s) => s.setActiveShortcutPreset); const closingRef = useRef(false); - const isWelcome = stepIndex === -1; + const isWelcome = stepIndex === -1 && !shortcutConfirmation; // Determine steps based on mode const steps: (PanelStep | TimelineStep | CampaignStep)[] = isCampaignMode @@ -365,15 +366,23 @@ export function TutorialOverlay({ onClose, onSkip, part = 1, campaignSteps, camp if (presetMap[id]) { setActiveShortcutPreset(presetMap[id] as 'premiere' | 'davinci' | 'finalcut' | 'aftereffects' | 'beginner'); } - setStepIndex(0); + // Show confirmation with the selected NLE label + const btn = WELCOME_BUTTONS.find((b) => b.id === id); + setShortcutConfirmation(btn?.label || id); }, [setUserBackground, setActiveShortcutPreset]); + const handleConfirmationDismiss = useCallback(() => { + setShortcutConfirmation(null); + // Tutorial not yet implemented — close overlay + close(); + }, [close]); + const tooltipStyle = getTooltipStyle(); return (
@@ -411,12 +420,12 @@ export function TutorialOverlay({ onClose, onSkip, part = 1, campaignSteps, camp )} {/* Clippy in own container so it doesn't fade with tooltip */} -
+
{step && ( @@ -424,7 +433,21 @@ export function TutorialOverlay({ onClose, onSkip, part = 1, campaignSteps, camp )}
- {isWelcome ? ( + {shortcutConfirmation ? ( + <> +
Shortcuts updated!
+
+ Keyboard shortcuts have been set to {shortcutConfirmation} layout. + You can change them anytime in Settings > Shortcuts. +
+
+ Interactive tutorial coming soon. +
+ + + ) : isWelcome ? ( <>
Welcome! Where are you coming from?
This helps us tailor tips to your experience
From 9bcba50bf9e5aa02b211947c7778695509868cf1 Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 13:06:29 +0100 Subject: [PATCH 14/34] feat: add 'Where are you coming from?' to Info menu Adds menu entry at top of Info menu to re-open the welcome/NLE selection dialog for debugging and re-selecting shortcut presets. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.tsx | 6 ++++++ src/components/common/Toolbar.tsx | 4 ++++ 2 files changed, 10 insertions(+) diff --git a/src/App.tsx b/src/App.tsx index 98180221d..6df0150d2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -285,13 +285,19 @@ function App() { const handleOpenCampaignDialog = () => { setShowCampaignDialog(true); }; + const handleOpenWelcomeScreen = () => { + setTutorialPart(1); + setShowTutorial(true); + }; window.addEventListener('start-tutorial', handleStartTutorial); window.addEventListener('start-timeline-tutorial', handleStartTimelineTutorial); window.addEventListener('open-tutorial-campaigns', handleOpenCampaignDialog); + window.addEventListener('open-welcome-screen', handleOpenWelcomeScreen); return () => { window.removeEventListener('start-tutorial', handleStartTutorial); window.removeEventListener('start-timeline-tutorial', handleStartTimelineTutorial); window.removeEventListener('open-tutorial-campaigns', handleOpenCampaignDialog); + window.removeEventListener('open-welcome-screen', handleOpenWelcomeScreen); }; }, []); diff --git a/src/components/common/Toolbar.tsx b/src/components/common/Toolbar.tsx index 2a3fd8247..304170887 100644 --- a/src/components/common/Toolbar.tsx +++ b/src/components/common/Toolbar.tsx @@ -745,6 +745,10 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { {openMenu === 'info' && (
+ +
From 512cc0baa21054bc9a2cae0d8aeec31c3ff7f6c1 Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 13:09:46 +0100 Subject: [PATCH 15/34] =?UTF-8?q?feat:=20add=20continuous=20save=20mode=20?= =?UTF-8?q?as=20default=20=E2=80=94=20project=20saves=20on=20every=20chang?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new 'saveMode' setting: 'continuous' (default) or 'interval'. In continuous mode, every store change triggers a debounced 2s save to disk, so users never have to think about saving. Switching to 'interval' in settings restores the previous timer-based autosave behavior. Undo/redo is unaffected as it operates on in-memory snapshots. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/components/common/Toolbar.tsx | 17 ++++-- .../common/settings/GeneralSettings.tsx | 57 +++++++++++-------- src/services/project/projectLifecycle.ts | 42 +++++++++++++- src/stores/settingsStore.ts | 21 +++++-- 4 files changed, 101 insertions(+), 36 deletions(-) diff --git a/src/components/common/Toolbar.tsx b/src/components/common/Toolbar.tsx index 304170887..64abe4000 100644 --- a/src/components/common/Toolbar.tsx +++ b/src/components/common/Toolbar.tsx @@ -60,12 +60,14 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { const openAuthDialog = useAccountStore((s) => s.openAuthDialog); const { isSettingsOpen, openSettings, closeSettings, + saveMode, autosaveEnabled, setAutosaveEnabled, autosaveInterval, setAutosaveInterval, } = useSettingsStore(useShallow(s => ({ isSettingsOpen: s.isSettingsOpen, openSettings: s.openSettings, closeSettings: s.closeSettings, + saveMode: s.saveMode, autosaveEnabled: s.autosaveEnabled, setAutosaveEnabled: s.setAutosaveEnabled, autosaveInterval: s.autosaveInterval, @@ -169,7 +171,8 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { setOpenMenu(null); }, []); - // Autosave effect + // Autosave effect — only runs in interval mode + // In continuous mode, saves are handled by setupAutoSync in projectLifecycle.ts useEffect(() => { // Clear existing timer if (autosaveTimerRef.current) { @@ -177,14 +180,14 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { autosaveTimerRef.current = null; } - // Set up new timer if autosave is enabled and project is open - if (autosaveEnabled && isProjectOpen) { + // Set up interval timer only when in interval save mode + if (saveMode === 'interval' && autosaveEnabled && isProjectOpen) { const intervalMs = autosaveInterval * 60 * 1000; // Convert minutes to milliseconds - log.info(`Autosave enabled with ${autosaveInterval} minute interval`); + log.info(`Interval save enabled with ${autosaveInterval} minute interval`); autosaveTimerRef.current = setInterval(async () => { if (projectFileService.isProjectOpen() && projectFileService.hasUnsavedChanges()) { - log.info('Autosave: Creating backup and saving project...'); + log.info('Interval save: Creating backup and saving project...'); // Create backup before saving await projectFileService.createBackup(); // Then save the project @@ -192,6 +195,8 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { setShowSavedToast(true); } }, intervalMs); + } else if (saveMode === 'continuous' && isProjectOpen) { + log.info('Continuous save active — project saves automatically on every change'); } return () => { @@ -199,7 +204,7 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) { clearInterval(autosaveTimerRef.current); } }; - }, [autosaveEnabled, autosaveInterval, isProjectOpen]); + }, [saveMode, autosaveEnabled, autosaveInterval, isProjectOpen]); const handleSaveAs = useCallback(async () => { const name = prompt('Save project as:', projectName || 'New Project'); diff --git a/src/components/common/settings/GeneralSettings.tsx b/src/components/common/settings/GeneralSettings.tsx index 72cba4e43..b3cfc456d 100644 --- a/src/components/common/settings/GeneralSettings.tsx +++ b/src/components/common/settings/GeneralSettings.tsx @@ -1,5 +1,6 @@ import { useCallback } from 'react'; -import { useSettingsStore, type AutosaveInterval, type PreviewQuality, type GPUPowerPreference } from '../../../stores/settingsStore'; +import { useSettingsStore, type AutosaveInterval, type SaveMode, type PreviewQuality, type GPUPowerPreference } from '../../../stores/settingsStore'; +// AutosaveInterval used in interval select onChange cast import { useIsMobile } from '../../../hooks/useIsMobile'; import { useMIDI } from '../../../hooks/useMIDI'; import { OutputSettings } from './OutputSettings'; @@ -7,13 +8,13 @@ import { AIFeaturesSettings } from './AIFeaturesSettings'; export function GeneralSettings() { const { - autosaveEnabled, + saveMode, autosaveInterval, copyMediaToProject, forceDesktopMode, previewQuality, gpuPowerPreference, - setAutosaveEnabled, + setSaveMode, setAutosaveInterval, setCopyMediaToProject, setForceDesktopMode, @@ -51,34 +52,44 @@ export function GeneralSettings() {

- {/* Autosave */} + {/* Save Mode */}
-
Autosave
+
Save
- - +

+ {saveMode === 'continuous' + ? 'Project is saved automatically after every change. You never have to think about saving.' + : 'Project is saved on a timer interval. You can also save manually with Ctrl+S.'} +

+ + {saveMode === 'interval' && ( + <> + + + )}
{isMobileDevice && forceDesktopMode && ( diff --git a/src/services/project/projectLifecycle.ts b/src/services/project/projectLifecycle.ts index bbb494778..1de6d6cc0 100644 --- a/src/services/project/projectLifecycle.ts +++ b/src/services/project/projectLifecycle.ts @@ -5,12 +5,45 @@ import { useMediaStore } from '../../stores/mediaStore'; import { useTimelineStore } from '../../stores/timeline'; import { useYouTubeStore } from '../../stores/youtubeStore'; import { useDockStore } from '../../stores/dockStore'; +import { useSettingsStore } from '../../stores/settingsStore'; import { projectFileService } from '../projectFileService'; -import { syncStoresToProject } from './projectSave'; +import { syncStoresToProject, saveCurrentProject } from './projectSave'; import { loadProjectToStores } from './projectLoad'; const log = Logger.create('ProjectSync'); +// Debounced continuous save — saves 2s after the last change +let continuousSaveTimer: ReturnType | null = null; +let isContinuousSaving = false; + +function scheduleContinuousSave(): void { + if (continuousSaveTimer) { + clearTimeout(continuousSaveTimer); + } + continuousSaveTimer = setTimeout(async () => { + continuousSaveTimer = null; + if (isContinuousSaving) return; // Prevent overlapping saves + if (!projectFileService.isProjectOpen() || !projectFileService.hasUnsavedChanges()) return; + + isContinuousSaving = true; + try { + await saveCurrentProject(); + log.debug('Continuous save completed'); + } catch (err) { + log.error('Continuous save failed:', err); + } finally { + isContinuousSaving = false; + } + }, 2000); +} + +function triggerContinuousSaveIfEnabled(): void { + const { saveMode } = useSettingsStore.getState(); + if (saveMode === 'continuous') { + scheduleContinuousSave(); + } +} + /** * Create a new project */ @@ -49,7 +82,8 @@ export function closeCurrentProject(): void { } /** - * Mark project as dirty when stores change + * Mark project as dirty when stores change. + * In continuous save mode, also triggers a debounced save to disk. */ export function setupAutoSync(): void { // Subscribe to store changes and mark project dirty @@ -58,6 +92,7 @@ export function setupAutoSync(): void { () => { if (projectFileService.isProjectOpen()) { projectFileService.markDirty(); + triggerContinuousSaveIfEnabled(); } } ); @@ -67,6 +102,7 @@ export function setupAutoSync(): void { () => { if (projectFileService.isProjectOpen()) { projectFileService.markDirty(); + triggerContinuousSaveIfEnabled(); } } ); @@ -78,6 +114,7 @@ export function setupAutoSync(): void { prevYouTubeVideos = state.videos; if (projectFileService.isProjectOpen()) { projectFileService.markDirty(); + triggerContinuousSaveIfEnabled(); } } }); @@ -89,6 +126,7 @@ export function setupAutoSync(): void { prevDockLayout = state.layout; if (projectFileService.isProjectOpen()) { projectFileService.markDirty(); + triggerContinuousSaveIfEnabled(); } } }); diff --git a/src/stores/settingsStore.ts b/src/stores/settingsStore.ts index 0f87d50c0..c18b9cf79 100644 --- a/src/stores/settingsStore.ts +++ b/src/stores/settingsStore.ts @@ -64,6 +64,9 @@ interface APIKeys { // Autosave interval options (in minutes) export type AutosaveInterval = 1 | 2 | 5 | 10; +// Save mode: continuous saves on every change (debounced), interval saves on a timer +export type SaveMode = 'continuous' | 'interval'; + interface SettingsState { // Theme theme: ThemeMode; @@ -80,9 +83,10 @@ interface SettingsState { previewQuality: PreviewQuality; showTransparencyGrid: boolean; // Show checkerboard pattern for transparent areas - // Autosave settings - autosaveEnabled: boolean; - autosaveInterval: AutosaveInterval; // in minutes + // Save settings + saveMode: SaveMode; // 'continuous' = save on every change, 'interval' = save on timer + autosaveEnabled: boolean; // legacy — derived from saveMode for compat + autosaveInterval: AutosaveInterval; // in minutes (only used in interval mode) // Native Helper (Turbo Mode) turboModeEnabled: boolean; // Connect to native helper (downloads, yt-dlp) @@ -146,6 +150,7 @@ interface SettingsState { setTranscriptionProvider: (provider: TranscriptionProvider) => void; setPreviewQuality: (quality: PreviewQuality) => void; setShowTransparencyGrid: (show: boolean) => void; + setSaveMode: (mode: SaveMode) => void; setAutosaveEnabled: (enabled: boolean) => void; setAutosaveInterval: (interval: AutosaveInterval) => void; setTurboModeEnabled: (enabled: boolean) => void; @@ -212,8 +217,9 @@ export const useSettingsStore = create()( transcriptionProvider: 'local', previewQuality: 1, // Full quality by default showTransparencyGrid: false, // Don't show checkerboard by default - autosaveEnabled: true, // Autosave enabled by default - autosaveInterval: 5, // 5 minutes default interval + saveMode: 'continuous' as SaveMode, // Continuous save by default — every change saved automatically + autosaveEnabled: true, // Legacy compat (interval mode uses this) + autosaveInterval: 5, // 5 minutes default interval (only used in interval mode) turboModeEnabled: true, // Connect to native helper by default (downloads) nativeDecodeEnabled: false, // Native FFmpeg decode off by default nativeHelperPort: 9876, // Default WebSocket port @@ -279,6 +285,10 @@ export const useSettingsStore = create()( set({ showTransparencyGrid: show }); }, + setSaveMode: (mode) => { + set({ saveMode: mode }); + }, + setAutosaveEnabled: (enabled) => { set({ autosaveEnabled: enabled }); }, @@ -475,6 +485,7 @@ export const useSettingsStore = create()( transcriptionProvider: state.transcriptionProvider, previewQuality: state.previewQuality, showTransparencyGrid: state.showTransparencyGrid, + saveMode: state.saveMode, autosaveEnabled: state.autosaveEnabled, autosaveInterval: state.autosaveInterval, turboModeEnabled: state.turboModeEnabled, From 5079b8cb8a10dbd3bde086075f6f2b4dee39bf2f Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 13:13:01 +0100 Subject: [PATCH 16/34] feat: add right-click context menu with Add items in media panel empty area Right-clicking on empty space in the media panel (list view, grid view, or empty state) now shows a context menu with the same options as the Add dropdown: Import Media, Composition, Folder, Text, Solid, and Adjustment Layer (coming soon). Co-Authored-By: Claude Opus 4.6 (1M context) --- src/App.css | 26 +++++++++++++++++++++ src/components/panels/MediaPanel.tsx | 35 ++++++++++++++++++++++++---- 2 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/App.css b/src/App.css index 5cd14e669..2300ec038 100644 --- a/src/App.css +++ b/src/App.css @@ -6771,9 +6771,17 @@ input[type="checkbox"] { .media-panel-actions { display: flex; + align-items: center; gap: 4px; } +.media-panel-actions .btn { + height: 24px; + display: inline-flex; + align-items: center; + box-sizing: border-box; +} + .media-panel-content { flex: 1; overflow: auto; @@ -7035,10 +7043,13 @@ input[type="checkbox"] { .media-view-toggle { padding: 2px 4px !important; min-width: 24px; + height: 24px; display: inline-flex; align-items: center; justify-content: center; opacity: 0.5; + box-sizing: border-box; + border: 1px solid var(--border-color); } .media-view-toggle.active { @@ -8876,6 +8887,21 @@ input[type="checkbox"] { margin: 4px 0; } +.context-menu-icon { + width: 20px; + text-align: center; + font-size: 14px; + margin-right: 8px; + flex-shrink: 0; +} + +.context-menu-hint { + margin-left: auto; + font-size: 10px; + color: var(--text-muted); + font-style: italic; +} + /* Submenu styles */ .context-menu-item.has-submenu { position: relative; diff --git a/src/components/panels/MediaPanel.tsx b/src/components/panels/MediaPanel.tsx index 3283ba57d..aadafbe42 100644 --- a/src/components/panels/MediaPanel.tsx +++ b/src/components/panels/MediaPanel.tsx @@ -1314,7 +1314,7 @@ export function MediaPanel() { {/* Item list with column headers */}
{rootItems.length === 0 ? ( -
+
handleContextMenu(e)}>
@@ -1361,6 +1361,10 @@ export function MediaPanel() { className="media-item-list" ref={itemListRef} onMouseDown={handleMarqueeMouseDown} + onContextMenu={(e) => { + const target = e.target as HTMLElement; + if (!target.closest('.media-item')) handleContextMenu(e); + }} style={{ position: 'relative' }} > {rootItems.map(item => renderItem(item))} @@ -1386,6 +1390,10 @@ export function MediaPanel() { className="media-grid-wrapper" ref={itemListRef} onMouseDown={handleMarqueeMouseDown} + onContextMenu={(e) => { + const target = e.target as HTMLElement; + if (!target.closest('.media-grid-item')) handleContextMenu(e); + }} style={{ position: 'relative' }} > {/* Breadcrumb for folder navigation */} @@ -1474,11 +1482,28 @@ export function MediaPanel() {
Import Media...
-
- New Composition +
+
{ handleNewComposition(); closeContextMenu(); }}> + + Composition +
+
{ handleNewFolder(); closeContextMenu(); }}> + 📁 + Folder +
+
+
{ handleNewText(); closeContextMenu(); }}> + + Text +
+
{ handleNewSolid(); closeContextMenu(); }}> + + Solid
-
- New Folder +
+ + Adjustment Layer + Coming soon
{(contextMenu.itemId || multiSelect) && ( <> From 7b5cfaef9efa6504dc5d81653d082c7e7df134c4 Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 13:16:49 +0100 Subject: [PATCH 17/34] fix: make continuous save more reliable - Reduce debounce from 2s to 1s for faster saves - Add beforeunload handler that flushes pending save on page close/refresh - Remove hasUnsavedChanges guard so continuous save always syncs stores - Add logging for continuous save events (fires, skips, flushes) Co-Authored-By: Claude Opus 4.6 (1M context) --- src/services/project/projectLifecycle.ts | 72 ++++++++++++++++++------ 1 file changed, 56 insertions(+), 16 deletions(-) diff --git a/src/services/project/projectLifecycle.ts b/src/services/project/projectLifecycle.ts index 1de6d6cc0..f59b0bcfa 100644 --- a/src/services/project/projectLifecycle.ts +++ b/src/services/project/projectLifecycle.ts @@ -12,29 +12,61 @@ import { loadProjectToStores } from './projectLoad'; const log = Logger.create('ProjectSync'); -// Debounced continuous save — saves 2s after the last change +// Debounced continuous save — saves 1s after the last change let continuousSaveTimer: ReturnType | null = null; let isContinuousSaving = false; +let hasPendingContinuousSave = false; function scheduleContinuousSave(): void { + hasPendingContinuousSave = true; if (continuousSaveTimer) { clearTimeout(continuousSaveTimer); } - continuousSaveTimer = setTimeout(async () => { + continuousSaveTimer = setTimeout(() => { + void executeContinuousSave(); + }, 1000); +} + +async function executeContinuousSave(): Promise { + continuousSaveTimer = null; + hasPendingContinuousSave = false; + if (isContinuousSaving) return; // Prevent overlapping saves + if (!projectFileService.isProjectOpen()) { + log.debug('Continuous save skipped — no project open'); + return; + } + + isContinuousSaving = true; + try { + await saveCurrentProject(); + log.info('Continuous save completed'); + } catch (err) { + log.error('Continuous save failed:', err); + } finally { + isContinuousSaving = false; + } +} + +/** + * Flush pending continuous save immediately (used on beforeunload). + * Calls syncStoresToProject synchronously so project data is up-to-date, + * then fires off saveProject (may or may not complete before page unload). + */ +function flushContinuousSave(): void { + if (!hasPendingContinuousSave && !projectFileService.hasUnsavedChanges()) return; + if (!projectFileService.isProjectOpen()) return; + + if (continuousSaveTimer) { + clearTimeout(continuousSaveTimer); continuousSaveTimer = null; - if (isContinuousSaving) return; // Prevent overlapping saves - if (!projectFileService.isProjectOpen() || !projectFileService.hasUnsavedChanges()) return; - - isContinuousSaving = true; - try { - await saveCurrentProject(); - log.debug('Continuous save completed'); - } catch (err) { - log.error('Continuous save failed:', err); - } finally { - isContinuousSaving = false; - } - }, 2000); + } + hasPendingContinuousSave = false; + + // Sync stores to project data (mostly synchronous work) + void syncStoresToProject(); + // Fire off disk write — may or may not complete before unload + void projectFileService.saveProject(); + log.info('Continuous save flushed on beforeunload'); } function triggerContinuousSaveIfEnabled(): void { @@ -131,5 +163,13 @@ export function setupAutoSync(): void { } }); - log.info(' Auto-sync setup complete'); + // In continuous mode, flush pending save on page unload + window.addEventListener('beforeunload', () => { + const { saveMode } = useSettingsStore.getState(); + if (saveMode === 'continuous') { + flushContinuousSave(); + } + }); + + log.info(`Auto-sync setup complete (saveMode: ${useSettingsStore.getState().saveMode})`); } From 57c436252416732280f0acb9a565d0ee2a44574b Mon Sep 17 00:00:00 2001 From: MasterSelects Date: Mon, 23 Mar 2026 13:17:51 +0100 Subject: [PATCH 18/34] feat: Three.js 3D layer system, model import, effect reordering & UX fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Three.js integrated as isolated 3D scene renderer (renders to OffscreenCanvas → WebGPU compositor) - Per-clip 3D toggle (AE-style): layers become 3D planes with perspective camera - TransformTab shows 3D controls (Position Z, Rotation X/Y/Z, Scale Z) only when clip is 3D - AE-style rotation display: "2x +30.0°" with independently draggable multiplier and remainder - 3D model import: OBJ/glTF/GLB/FBX files as timeline clips (auto-3D, OBJLoader/GLTFLoader) - Model clips: wireframe debug toggle, default lighting, auto-center/normalize, restore after refresh - Effect drag-drop reordering via handle (drag only from ≡ icon, sliders unblocked) - Effect reorder store action (reorderClipEffect) with undo/redo support - DraggableNumber: clientX fallback when pointer lock fails (fixes unresponsive controls) - WebGPU init: adapter fallback without powerPreference, device fallback without limits, 5s timeouts - WebGPU init failure: clear error message instead of infinite spinner - Safari/Firefox: WebGPU detection instead of hardcoded browser block - 3D keyframe lanes hidden in 2D mode, track height calculated correctly - Timeline property values update dynamically when keyframes change - scale.z support through entire pipeline (interpolation, cache, layer builder, Three.js) Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 54 +- package.json | 2 + src/components/common/WelcomeOverlay.tsx | 14 +- .../panels/properties/TransformTab.tsx | 117 +++- src/components/panels/properties/shared.tsx | 41 +- src/components/preview/Preview.tsx | 14 +- src/components/timeline/TimelineHeader.tsx | 15 +- src/components/timeline/TimelineTrack.tsx | 11 +- src/engine/core/WebGPUContext.ts | 78 ++- src/engine/featureFlags.ts | 1 + src/engine/render/LayerCollector.ts | 12 + src/engine/render/RenderDispatcher.ts | 2 + src/engine/structuralSharing/types.ts | 2 +- src/engine/three/ThreeSceneRenderer.ts | 520 ++++++++++++++++++ src/engine/three/index.ts | 3 + src/engine/three/types.ts | 45 ++ src/hooks/useEngine.ts | 13 + .../layerBuilder/LayerBuilderService.ts | 70 ++- src/services/layerBuilder/TransformCache.ts | 7 +- src/services/project/projectLoad.ts | 4 +- src/stores/engineStore.ts | 9 + .../mediaStore/helpers/importPipeline.ts | 12 +- src/stores/mediaStore/types.ts | 24 +- src/stores/timeline/clip/addModelClip.ts | 66 +++ src/stores/timeline/clipEffectSlice.ts | 16 + src/stores/timeline/clipSlice.ts | 44 +- src/stores/timeline/helpers/blobUrlManager.ts | 2 +- .../timeline/helpers/mediaTypeHelpers.ts | 15 +- src/stores/timeline/keyframeSlice.ts | 10 +- src/stores/timeline/serializationUtils.ts | 24 + src/stores/timeline/types.ts | 4 +- src/types/index.ts | 21 +- src/utils/keyframeInterpolation.ts | 3 + 33 files changed, 1188 insertions(+), 87 deletions(-) create mode 100644 src/engine/three/ThreeSceneRenderer.ts create mode 100644 src/engine/three/index.ts create mode 100644 src/engine/three/types.ts create mode 100644 src/stores/timeline/clip/addModelClip.ts diff --git a/package-lock.json b/package-lock.json index 75a08ba22..0b6d52866 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@ffmpeg/ffmpeg": "^0.12.6", "@ffmpeg/util": "^0.12.2", "@huggingface/transformers": "^3.8.1", + "@types/three": "^0.183.1", "@webgpu/types": "^0.1.66", "mediabunny": "^1.39.2", "mp4box": "^2.3.0", @@ -19,6 +20,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "soundtouch-ts": "^1.1.1", + "three": "^0.183.2", "zustand": "^5.0.9" }, "devDependencies": { @@ -682,6 +684,12 @@ "node": ">=20.19.0" } }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "license": "Apache-2.0" + }, "node_modules/@emnapi/runtime": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.8.1.tgz", @@ -2555,6 +2563,12 @@ "@testing-library/dom": ">=7.21.4" } }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "license": "MIT" + }, "node_modules/@types/aria-query": { "version": "5.0.4", "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", @@ -2684,6 +2698,33 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.183.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.183.1.tgz", + "integrity": "sha512-f2Pu5Hrepfgavttdye3PsH5RWyY/AvdZQwIVhrc4uNtvF7nOWJacQKcoVJn0S4f0yYbmAE6AR+ve7xDcuYtMGw==", + "license": "MIT", + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "@webgpu/types": "*", + "fflate": "~0.8.2", + "meshoptimizer": "~1.0.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.48.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.48.0.tgz", @@ -4023,7 +4064,6 @@ "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", "integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==", - "dev": true, "license": "MIT" }, "node_modules/file-entry-cache": { @@ -4704,6 +4744,12 @@ "integrity": "sha512-O5hkiFIcjjszPIYyUSyvScyvrBoV3NOEEZx/pMlsu44TKzWNkLVBBxnxJz42in5n3QIolYOcBYFCPZZ0h8SkwQ==", "license": "MIT" }, + "node_modules/meshoptimizer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.0.1.tgz", + "integrity": "sha512-Vix+QlA1YYT3FwmBBZ+49cE5y/b+pRrcXKqGpS5ouh33d3lSp2PoTpCw19E0cKDFWalembrHnIaZetf27a+W2g==", + "license": "MIT" + }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -5523,6 +5569,12 @@ "node": ">=18" } }, + "node_modules/three": { + "version": "0.183.2", + "resolved": "https://registry.npmjs.org/three/-/three-0.183.2.tgz", + "integrity": "sha512-di3BsL2FEQ1PA7Hcvn4fyJOlxRRgFYBpMTcyOgkwJIaDOdJMebEFPA+t98EvjuljDx4hNulAGwF6KIjtwI5jgQ==", + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", diff --git a/package.json b/package.json index baa0a1631..a299e3399 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "@ffmpeg/ffmpeg": "^0.12.6", "@ffmpeg/util": "^0.12.2", "@huggingface/transformers": "^3.8.1", + "@types/three": "^0.183.1", "@webgpu/types": "^0.1.66", "mediabunny": "^1.39.2", "mp4box": "^2.3.0", @@ -35,6 +36,7 @@ "react": "^19.2.0", "react-dom": "^19.2.0", "soundtouch-ts": "^1.1.1", + "three": "^0.183.2", "zustand": "^5.0.9" }, "devDependencies": { diff --git a/src/components/common/WelcomeOverlay.tsx b/src/components/common/WelcomeOverlay.tsx index 30ebcee3b..8666d95dd 100644 --- a/src/components/common/WelcomeOverlay.tsx +++ b/src/components/common/WelcomeOverlay.tsx @@ -15,9 +15,11 @@ import { syncStoresToProject } from '../../services/project/projectSave'; type NativeStatus = 'checking' | 'available' | 'outdated' | 'unavailable'; -// Detect browser name and if it's Chromium-based +// Detect browser name and if it supports WebGPU +// isChromium kept for legacy compat but now means "has WebGPU support" function detectBrowser(): { name: string; isChromium: boolean } { const ua = navigator.userAgent; + const hasWebGPU = typeof navigator.gpu !== 'undefined'; // Check specific browsers (order matters - more specific first) if (/Edg\//.test(ua)) { @@ -33,13 +35,15 @@ function detectBrowser(): { name: string; isChromium: boolean } { return { name: 'Chromium', isChromium: true }; } if (/Safari\//.test(ua) && !/Chrome\//.test(ua)) { - return { name: 'Safari', isChromium: false }; + // Safari 17+ supports WebGPU + return { name: 'Safari', isChromium: hasWebGPU }; } if (/Firefox\//.test(ua)) { - return { name: 'Firefox', isChromium: true }; // Firefox supports WebGPU + return { name: 'Firefox', isChromium: hasWebGPU }; } - return { name: 'Unknown Browser', isChromium: false }; + // Unknown browser — trust WebGPU detection + return { name: 'Unknown Browser', isChromium: hasWebGPU }; } interface WelcomeOverlayProps { @@ -453,7 +457,7 @@ export function WelcomeOverlay({ onComplete, noFadeOnClose = false }: WelcomeOve Unsupported Browser {browser.name} - MasterSelects requires WebGPU which is currently only fully supported in Chrome. + MasterSelects requires WebGPU. Supported browsers: Chrome 113+, Edge 113+, Safari 17+, Firefox 141+. diff --git a/src/components/panels/properties/TransformTab.tsx b/src/components/panels/properties/TransformTab.tsx index 245250982..3349ea5d1 100644 --- a/src/components/panels/properties/TransformTab.tsx +++ b/src/components/panels/properties/TransformTab.tsx @@ -20,10 +20,12 @@ interface TransformTabProps { opacity: number; blendMode: BlendMode; position: { x: number; y: number; z: number }; - scale: { x: number; y: number }; + scale: { x: number; y: number; z?: number }; rotation: { x: number; y: number; z: number }; }; speed?: number; + is3D?: boolean; + hasKeyframes?: boolean; } // Labeled value cell: tiny axis label + draggable number @@ -36,8 +38,48 @@ function LabeledValue({ label, wip, ...props }: { label: string; wip?: boolean } ); } -export function TransformTab({ clipId, transform, speed = 1 }: TransformTabProps) { - const { setPropertyValue, updateClipTransform } = useTimelineStore.getState(); +// AE-style rotation: "2x +30.0°" — revolutions multiplier + remainder, both draggable +function RotationValue({ label, degrees, onChange, onDragStart, onDragEnd }: { + label: string; + degrees: number; + onChange: (degrees: number) => void; + onDragStart?: () => void; + onDragEnd?: () => void; +}) { + const revolutions = Math.trunc(degrees / 360); + const remainder = degrees - revolutions * 360; + + return ( +
+ {label} + onChange(Math.round(rev) * 360 + remainder)} + defaultValue={0} + decimals={0} + suffix="x" + sensitivity={4} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + /> + onChange(revolutions * 360 + rem)} + defaultValue={0} + decimals={1} + suffix="°" + sensitivity={0.5} + onDragStart={onDragStart} + onDragEnd={onDragEnd} + /> +
+ ); +} + +export function TransformTab({ clipId, transform, speed = 1, is3D = false }: TransformTabProps) { + const { setPropertyValue, updateClipTransform, toggle3D, updateClip } = useTimelineStore.getState(); + const wireframe = useTimelineStore(s => s.clips.find(c => c.id === clipId)?.wireframe ?? false); + const isModel = useTimelineStore(s => s.clips.find(c => c.id === clipId)?.source?.type === 'model'); const handleBatchStart = useCallback(() => startBatch('Adjust transform'), []); const handleBatchEnd = useCallback(() => endBatch(), []); @@ -50,7 +92,7 @@ export function TransformTab({ clipId, transform, speed = 1 }: TransformTabProps setPropertyValue(clipId, property, value); }; - // Position: normalized → pixels + // Position: normalized -> pixels const posXPx = transform.position.x * (compWidth / 2); const posYPx = transform.position.y * (compHeight / 2); const posZPx = transform.position.z * (compWidth / 2); @@ -58,12 +100,14 @@ export function TransformTab({ clipId, transform, speed = 1 }: TransformTabProps const handlePosYChange = (px: number) => handlePropertyChange('position.y', px / (compHeight / 2)); const handlePosZChange = (px: number) => handlePropertyChange('position.z', px / (compWidth / 2)); - // Scale: multiplier → percentage + // Scale: multiplier -> percentage const scaleXPct = transform.scale.x * 100; const scaleYPct = transform.scale.y * 100; + const scaleZPct = (transform.scale.z ?? 1) * 100; const uniformScalePct = ((transform.scale.x + transform.scale.y) / 2) * 100; const handleScaleXChange = (pct: number) => handlePropertyChange('scale.x', pct / 100); const handleScaleYChange = (pct: number) => handlePropertyChange('scale.y', pct / 100); + const handleScaleZChange = (pct: number) => handlePropertyChange('scale.z', pct / 100); const handleUniformScaleChange = (pct: number) => { const v = pct / 100; handlePropertyChange('scale.x', v); @@ -77,8 +121,32 @@ export function TransformTab({ clipId, transform, speed = 1 }: TransformTabProps return (
- {/* Appearance + Time */} + {/* 3D Toggle + Appearance + Time */}
+
+ + {isModel ? ( + 3D + ) : ( + + )} + {isModel && ( + + )} +