diff --git a/README.md b/README.md index 63112f49e..089b84fa4 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,31 @@ # MasterSelects -

Browser-based Video Compositor

+

Browser-based Video Compositor & 3D Engine

+ +
+ +
+

⚡ 660 KB gzip

+initial load +
+

- GPU-first editing with 30 effects, 37 blend modes, 76 AI tools, and only 13 dependencies.
- Built from scratch in 2,500+ lines of WGSL and 120k lines of TypeScript. + GPU-first editing with 30 effects, 37 blend modes, 76 AI tools, real 3D via Three.js, and only 14 dependencies.
+ Built from scratch in 2,400+ lines of WGSL and 138k lines of TypeScript.
+ Import OBJ, glTF, GLB, FBX models directly into the timeline.

- Version + Version License FOSSA Status

WebGPU + Three.js React 19 TypeScript Vite @@ -31,21 +41,28 @@ --- -## 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". +Decoding depends on what the **browser** supports — the container is just the wrapper, the codec inside is what matters. -**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 + + + + + + + + + + + + + +
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
3D ModelsOBJ, glTF, GLB, FBX — rendered via Three.js with lighting
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
-**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. +¹ H.265 decode/encode depends on OS & hardware — full support on Windows, partial on macOS/Linux. -See [Security.md](docs/Features/Security.md) for the full trust model, secret handling, bridge details, and current limitations. +> **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. --- @@ -53,7 +70,7 @@ See [Security.md](docs/Features/Security.md) for the full trust model, secret ha Most browser-based video editors share a pattern: Canvas 2D compositing, heavyweight dependency trees, and CPU-bound rendering that falls apart at scale. This project takes a fundamentally different approach. -**GPU-first architecture.** Preview, scrubbing, and export all run through the same **WebGPU ping-pong compositor**. Video textures are imported as `texture_external` (**zero-copy**, no CPU roundtrip). **37 blend modes**, 3D rotation, and inline color effects all execute in a **single WGSL composite shader** per layer. No Three.js, no GSAP, no Canvas 2D fallback in the hot path. +**GPU-first architecture.** Preview, scrubbing, and export all run through the same **WebGPU ping-pong compositor**. Video textures are imported as `texture_external` (**zero-copy**, no CPU roundtrip). **37 blend modes**, 3D rotation, and inline color effects all execute in a **single WGSL composite shader** per layer. **Three.js** is lazily loaded only for 3D model rendering — no GSAP, no Canvas 2D fallback in the hot path. **Zero-copy export pipeline.** Frames are captured as `new VideoFrame(offscreenCanvas)` directly from the GPU canvas. **No `readPixels()`**, no `getImageData()`, no staging buffers in the default path. The GPU renders, **WebCodecs encodes**. That's it. @@ -161,23 +178,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. --- diff --git a/docs/Features/3D-Layers.md b/docs/Features/3D-Layers.md new file mode 100644 index 000000000..b6cf1ccb2 --- /dev/null +++ b/docs/Features/3D-Layers.md @@ -0,0 +1,124 @@ +# 3D Layer System + +MasterSelects supports After Effects-style 3D layers via an integrated Three.js renderer. Layers can be toggled between 2D and 3D mode, 3D model files (OBJ, glTF, GLB, FBX) can be imported directly as timeline clips, and primitive 3D meshes can be created from the Media Panel. + +## Architecture + +``` +[2D Layers] --> Existing WebGPU Compositor (unchanged) + | +[3D Layers] --> Three.js Scene --> OffscreenCanvas --> copyExternalImageToTexture --> Compositor + ^ + [Camera + Lighting] +``` + +Three.js renders all 3D-flagged layers into an OffscreenCanvas. The result is imported into the existing WebGPU compositor as a single texture layer. 2D layers continue through the existing pipeline unchanged. Zero overhead when no 3D layers exist (Three.js is lazily loaded via dynamic import). + +## Features + +### Per-Layer 3D Toggle +- Any video/image layer can be toggled to 3D via the **"2D/3D" button** in the Transform panel +- 3D layers become textured planes in a Three.js scene with perspective camera +- Toggling back to 2D resets Position Z, Rotation X/Y to 0 + +### 3D Model Import +- Drag **OBJ, glTF, GLB, FBX** files into the timeline +- Model clips are automatically set to 3D (cannot be switched to 2D) +- Models are auto-centered and normalized to fit the viewport +- Default lighting: Ambient (0.6) + Directional (0.8) +- OBJ without MTL: gets default gray MeshStandardMaterial +- Wireframe debug toggle: **"Wire" button** in Transform panel (blue wireframe) + +### Primitive Mesh Creation +Create 3D mesh primitives from the Media Panel via **+ Add > Mesh** or right-click context menu: + +| Primitive | Three.js Geometry | Default Size | +|-----------|------------------|--------------| +| Cube | `BoxGeometry` | 0.6 x 0.6 x 0.6 | +| Sphere | `SphereGeometry` | radius 0.35, 32x24 segments | +| Plane | `PlaneGeometry` | 0.8 x 0.8 | +| Cylinder | `CylinderGeometry` | radius 0.25, height 0.6 | +| Torus | `TorusGeometry` | radius 0.3, tube 0.1 | +| Cone | `ConeGeometry` | radius 0.3, height 0.6 | + +- Mesh items are stored in a "Meshes" folder in the Media Panel +- Drag to timeline creates a clip with `is3D: true` and `meshType` +- Default material: `MeshStandardMaterial` (color #aaaaaa, metalness 0.3, roughness 0.6) +- Wireframe toggle supported +- Default clip duration: 10 seconds (max 1 hour) +- All transform properties (position, rotation, scale) and keyframe animation supported + +### Transform Controls (3D Mode) +| Property | 2D Mode | 3D Mode | +|----------|---------|---------| +| Position | X, Y | X, Y, Z | +| Scale | All, X, Y | All, X, Y (+ Z for models) | +| Rotation | Z | X, Y, Z (AE-style: `Nx + remainder`) | +| Opacity | Yes | Yes (compositor-level) | +| Blend Mode | Yes | Yes (all 37 modes) | + +### AE-Style Rotation Display +Rotation values are displayed as `2x +30.0°` (2 revolutions + 30 degrees = 750°): +- **Multiplier (`2x`)**: Drag to change in 360° increments +- **Remainder (`+30.0°`)**: Fine rotation within the revolution +- Both are independently draggable and keyframeable + +### Composition Camera +Per-composition camera with configurable properties: +- Position (x, y, z) +- Target / Look-at (x, y, z) +- FOV (default: 50°) +- Near/Far planes + +Camera distance is auto-calculated so default-transform layers fill the viewport exactly. + +## Keyframe Animation +All 3D properties are fully keyframeable: +- `position.z`, `rotation.x`, `rotation.y`, `rotation.z`, `scale.z` +- Keyframe lanes for 3D properties are hidden when clip is in 2D mode +- `scale.z` keyframes work for model clips + +## Export +3D layers are included in video export. The export pipeline uses the same `engine.render()` → `RenderDispatcher` → `process3DLayers()` → Three.js path as the preview. + +## Effect Support +GPU effects (blur, color correction, etc.) are applied as post-processing on the 3D scene output. They work identically to 2D layers since Three.js renders to a texture first. + +## Effect Reordering +Effects can be reordered via drag-and-drop: +- Drag the **≡ handle** on each effect to reorder +- Only the handle initiates drag (sliders/controls are not blocked) +- Order affects render output (effects are chained sequentially) +- Undo/redo supported + +## Key Files + +| File | Purpose | +|------|---------| +| `src/engine/three/ThreeSceneRenderer.ts` | Three.js scene renderer (HMR singleton) | +| `src/engine/three/types.ts` | Layer3DData, CameraConfig types | +| `src/engine/render/RenderDispatcher.ts` | 3D layer routing (`process3DLayers`) | +| `src/engine/render/LayerCollector.ts` | Model layer passthrough | +| `src/stores/timeline/clip/addModelClip.ts` | Model clip creation (file-based) | +| `src/stores/timeline/meshClipSlice.ts` | Primitive mesh clip creation | +| `src/services/layerBuilder/LayerBuilderService.ts` | Model layer builder | +| `src/engine/export/ExportLayerBuilder.ts` | Export 3D layer support | +| `src/components/panels/properties/TransformTab.tsx` | 3D transform UI | +| `src/engine/featureFlags.ts` | `use3DLayers` flag | + +## Supported File Formats + +| Format | Loader | Notes | +|--------|--------|-------| +| `.obj` | OBJLoader | Blender default export, no materials without .mtl | +| `.gltf` | GLTFLoader | Khronos standard, text-based | +| `.glb` | GLTFLoader | Binary glTF, most common for web | +| `.fbx` | GLTFLoader (fallback) | Autodesk format, limited support | + +## Limitations + +- No PBR material editor yet (models use default or embedded materials) +- No shadow casting between layers +- Camera is per-composition, not keyframeable yet +- Single 3D layer: opacity/blend handled by compositor. Multiple 3D layers: opacity via Three.js material +- Model clips need file re-authorization after page refresh (same as video clips) diff --git a/docs/Features/Keyboard-Shortcuts.md b/docs/Features/Keyboard-Shortcuts.md index bdf491150..95dca6c6a 100644 --- a/docs/Features/Keyboard-Shortcuts.md +++ b/docs/Features/Keyboard-Shortcuts.md @@ -2,11 +2,61 @@ [← Back to Index](./README.md) -Complete reference of all keyboard shortcuts (verified from codebase). +Complete reference of all keyboard shortcuts and NLE presets (verified from codebase + official documentation). --- -## Playback +## Presets + +MasterSelects ships with 6 shortcut presets. Switch in **Preferences → Shortcuts → Active Preset**. + +| Preset | Based on | Key Differences from MasterSelects | +|--------|----------|-----------------------------------| +| **MasterSelects** | Custom | Default — balanced NLE layout | +| **Premiere Pro** | Adobe PPro | `Ctrl+K` split, `Ctrl+L` loop, `Ctrl+Shift+X` clear I/O, `Ctrl+Alt+N` new project | +| **DaVinci Resolve** | BMD Resolve | `B` blade tool, `Ctrl+B` split, `Ctrl+/` loop, `Alt+X` clear I/O, no New/Open Project | +| **Final Cut Pro** | Apple FCP | `B` blade tool, `Ctrl+B` split, `Ctrl+L` loop, `Alt+X` clear I/O, no Save/Save As | +| **After Effects** | Adobe AE | `B`/`N` in/out, `Ctrl+Shift+D` split, `PageUp/Down` frame step, `Shift+=/-` blend modes, no JKL shuttle, no razor tool | +| **Beginner** | MasterSelects | Same as MasterSelects — simplest layout | + +### Preset Detail: Differences Per Action + +| Action | MasterSelects | Premiere Pro | DaVinci Resolve | Final Cut Pro | After Effects | +|--------|--------------|--------------|-----------------|---------------|---------------| +| Play/Pause | `Space` | `Space` | `Space` | `Space` | `Space` | +| Pause | `K` | `K` | `K` | `K` | — | +| Play Forward | `L` | `L` | `L` | `L` | — | +| Play Reverse | `J` | `J` | `J` | `J` | — | +| Toggle Loop | `Shift+L` | `Ctrl+L` | `Ctrl+/` | `Ctrl+L` | — | +| Frame Forward | `→` | `→` | `→` | `→` | `PageDown` / `Ctrl+→` | +| Frame Backward | `←` | `←` | `←` | `←` | `PageUp` / `Ctrl+←` | +| Set In Point | `I` | `I` | `I` | `I` | `B` | +| Set Out Point | `O` | `O` | `O` | `O` | `N` | +| Clear In/Out | `X` | `Ctrl+Shift+X` | `Alt+X` | `Alt+X` | — | +| Add Marker | `M` | `M` | `M` | `M` | `Numpad *` / `Shift+8` | +| Split at Playhead | `Shift+C` | `Ctrl+K` | `Ctrl+B` | `Ctrl+B` | `Ctrl+Shift+D` | +| Delete | `Del` / `Backspace` | `Del` / `Backspace` | `Backspace` / `Del` | `Del` / `Backspace` | `Del` | +| Copy | `Ctrl+C` | `Ctrl+C` | `Ctrl+C` | `Ctrl+C` | `Ctrl+C` | +| Paste | `Ctrl+V` | `Ctrl+V` | `Ctrl+V` | `Ctrl+V` | `Ctrl+V` | +| Next Blend Mode | `+` / `Numpad+` | `+` / `Numpad+` | `+` / `Numpad+` | `+` / `Numpad+` | `Shift+=` | +| Prev Blend Mode | `-` / `Numpad-` | `-` / `Numpad-` | `-` / `Numpad-` | `-` / `Numpad-` | `Shift+-` | +| Cut/Razor Tool | `C` | `C` | `B` | `B` | — | +| New Project | `Ctrl+N` | `Ctrl+Alt+N` | — | `Ctrl+N` | `Ctrl+Alt+N` | +| Open Project | `Ctrl+O` | `Ctrl+O` | — | `Ctrl+O` | `Ctrl+O` | +| Save | `Ctrl+S` | `Ctrl+S` | `Ctrl+S` | — | `Ctrl+S` | +| Save As | `Ctrl+Shift+S` | `Ctrl+Shift+S` | `Ctrl+Shift+S` | — | `Ctrl+Shift+S` | +| Undo | `Ctrl+Z` | `Ctrl+Z` | `Ctrl+Z` | `Ctrl+Z` | `Ctrl+Z` | +| Redo | `Ctrl+Shift+Z` / `Ctrl+Y` | `Ctrl+Shift+Z` | `Ctrl+Shift+Z` | `Ctrl+Shift+Z` | `Ctrl+Shift+Z` | + +**—** = No binding (action not available or not mapped in that NLE). + +> **Note:** On Mac, `Ctrl` maps to `Cmd` and `Alt` maps to `Option`. All presets handle this automatically. + +--- + +## MasterSelects Default Shortcuts + +### Playback | Shortcut | Action | |----------|--------| @@ -19,9 +69,7 @@ Complete reference of all keyboard shortcuts (verified from codebase). | `O` | Set Out point at playhead | | `X` | Clear In/Out points | ---- - -## Timeline Navigation +### Timeline Navigation | Shortcut | Action | |----------|--------| @@ -31,9 +79,7 @@ Complete reference of all keyboard shortcuts (verified from codebase). | `Ctrl + Shift + Scroll` | Toggle slot grid view (animated transition) | | `←` / `→` | Frame-by-frame navigation | ---- - -## Editing +### Editing | Shortcut | Action | |----------|--------| @@ -46,9 +92,7 @@ Complete reference of all keyboard shortcuts (verified from codebase). | `Tab` | Toggle edit mode in preview | | `Escape` | Exit cut tool mode (return to select tool) | ---- - -## Selection +### Selection | Action | Method | |--------|--------| @@ -60,9 +104,7 @@ Complete reference of all keyboard shortcuts (verified from codebase). | Move multi-selection | Drag any selected clip | | Deselect | Click empty area or marquee in empty area | ---- - -## Keyframes +### Keyframes | Action | Method | |--------|--------| @@ -74,9 +116,7 @@ Complete reference of all keyboard shortcuts (verified from codebase). | Move multi-select | Drag any selected keyframe | | Easing menu | Right-click keyframe | ---- - -## Blend Modes +### Blend Modes | Shortcut | Action | |----------|--------| @@ -85,9 +125,7 @@ Complete reference of all keyboard shortcuts (verified from codebase). Applies to all selected clips. Cycles through all blend modes. ---- - -## Project +### Project | Shortcut | Action | |----------|--------| @@ -219,6 +257,20 @@ Applies to all selected clips. Cycles through all blend modes. --- +## Customization + +Shortcuts are fully customizable in **Preferences → Shortcuts**: + +- **Preset selection:** Choose from MasterSelects, Premiere Pro, DaVinci Resolve, Final Cut Pro, After Effects, or Beginner +- **Per-key override:** Click any shortcut keycap in the list to record a new binding +- **Custom presets:** Save your current configuration as a named preset for later +- **Conflict detection:** The UI warns when two actions share the same key combination +- **Reset:** "Reset All to Preset" clears all overrides back to the active preset's defaults + +Shortcut preferences are persisted in localStorage and survive page reloads. + +--- + ## Related Features - [Timeline](./Timeline.md) - Main editing @@ -238,4 +290,4 @@ Run tests: `npx vitest run` --- -*Updated March 2026 - verified against codebase* +*Updated March 2026 - verified against codebase + official NLE documentation* diff --git a/docs/Features/Media-Panel.md b/docs/Features/Media-Panel.md index f97e02ed9..43dd68477 100644 --- a/docs/Features/Media-Panel.md +++ b/docs/Features/Media-Panel.md @@ -43,6 +43,9 @@ Click the **+ Add** button for creating new items: - **Folder** - New folder for organization - **Text** - New text item (placed in auto-created "Text" folder) - **Solid** - New solid color item (placed in auto-created "Solids" folder) +- **Mesh** ▶ - Submenu with 3D primitive meshes (placed in auto-created "Meshes" folder): + - Cube, Sphere, Plane, Cylinder, Torus, Cone + - Creates a `MeshItem` which can be dragged to the timeline as a 3D clip - **Adjustment Layer** - Coming soon #### Drag and Drop @@ -271,6 +274,9 @@ Right-click on items or empty space for context options. - Import Media... - New Composition - New Folder +- New Text +- New Solid +- **Mesh** ▶ submenu: Cube, Sphere, Plane, Cylinder, Torus, Cone ### Single/Multi Selection - **Rename** (single selection only) @@ -396,14 +402,15 @@ interface MediaFile { 3. Drop on appropriate track ### Drag Types -| Item Type | Drag Payload Kind | -|-----------|-------------------| -| Media file (video/image) | `media-file` | -| Media file (audio) | `media-file` (marked as audio) | -| Composition | `composition` | -| Text item | `text` | -| Solid item | `solid` | -| Folder | Internal move only (no timeline drop) | +| Item Type | Drag Payload Kind | Data Transfer Key | +|-----------|-------------------|-------------------| +| Media file (video/image) | `media-file` | `application/x-media-file-id` | +| Media file (audio) | `media-file` (marked as audio) | `application/x-media-file-id` | +| Composition | `composition` | `application/x-composition-id` | +| Text item | `text` | `application/x-text-item-id` | +| Solid item | `solid` | `application/x-solid-item-id` | +| Mesh item | `mesh` | `application/x-mesh-item-id` | +| Folder | Internal move only (no timeline drop) | — | ### Drop Behavior - Creates clip from media source @@ -411,11 +418,12 @@ interface MediaFile { - Audio-only files restricted to audio tracks - Files still importing or missing cannot be dragged to timeline - Compositions cannot be dragged into themselves (active comp check) +- Mesh items create 3D clips with `is3D: true` and `meshType` (rendered via Three.js) ### Track Type Enforcement | Media Type | Allowed Tracks | |------------|----------------| -| Video/Image/Composition/Text/Solid | Video tracks only | +| Video/Image/Composition/Text/Solid/Mesh | Video tracks only | | Audio | Audio tracks only | --- @@ -489,7 +497,9 @@ The media store is split into modular slices: | **proxySlice** | `slices/proxySlice.ts` | Proxy generation, cancellation, progress | | **projectSlice** | `slices/projectSlice.ts` | Save, load, init from DB | -**Boot Sequence:** `init.ts` (288 lines) handles IndexedDB initialization, timeline restore from saved state, status synchronization, auto-save interval setup, beforeunload handler, and audio cleanup via `disposeAllAudio()`. +**Inline actions (in `index.ts`):** `createTextItem`, `removeTextItem`, `getOrCreateTextFolder`, `createSolidItem`, `removeSolidItem`, `updateSolidItem`, `getOrCreateSolidFolder`, `createMeshItem`, `removeMeshItem`, `getOrCreateMeshFolder`, `getItemsByFolder`, `getItemById`, `getFileByName`. + +**Boot Sequence:** `init.ts` handles IndexedDB initialization, timeline restore from saved state, status synchronization, auto-save interval setup, beforeunload handler, and audio cleanup via `disposeAllAudio()`. Helper modules in `helpers/`: diff --git a/docs/Features/README.md b/docs/Features/README.md index eedb3bc37..06d17fdde 100644 --- a/docs/Features/README.md +++ b/docs/Features/README.md @@ -4,7 +4,7 @@ **Professional WebGPU Video Compositor & Timeline Editor** -Version 1.4.0 | March 2026 +Version 1.4.2 | March 2026 --- @@ -23,6 +23,7 @@ MASterSelects is a browser-based professional video editing application built on | **AI Video Generation** | PiAPI integration for AI-powered video creation | | **SAM2 Segmentation** | Click-to-segment object tracking with WebGPU ONNX inference | | **Download Panel** | Download videos from YouTube, TikTok, Instagram, Twitter/X and more | +| **3D Layer System** | Three.js integration: per-layer 3D toggle, OBJ/glTF/GLB model import, primitive mesh creation (Cube/Sphere/Plane/Cylinder/Torus/Cone), perspective camera | | **30 GPU Effects** | Modular color, blur, distort, stylize, keying effects with quality controls | | **37 Blend Modes** | After Effects-style blend modes including stencil and silhouette | | **Video Scopes** | GPU-accelerated Histogram, Vectorscope, Waveform monitor (DaVinci-style) | @@ -47,6 +48,7 @@ MASterSelects is a browser-based professional video editing application built on Frontend React 19 + TypeScript + Vite 7.2 State Management Zustand with modular slice architecture GPU Rendering WebGPU + WGSL shaders (2,500+ lines) +3D Engine Three.js (lazy-loaded, isolated scene renderer) GPU Effects 30 modular effects with individual WGSL shaders Video Decoding WebCodecs API with hardware acceleration + parallel decode Video Encoding WebCodecs (Fast/Precise) + FFmpeg WASM (ProRes, DNxHR, HAP) @@ -69,6 +71,7 @@ UI Framework Custom dockable panel system with mobile support | [Keyframes](./Keyframes.md) | Animation system, curve editor, bezier interpolation | | [Preview & Playback](./Preview.md) | RAM Preview, scrubbing, multiple outputs, edit mode | | [Output Manager](./Preview.md#output-manager) | Source routing, slices, corner pin warping, mask layers | +| [3D Layers](./3D-Layers.md) | Three.js 3D layers, OBJ/glTF model import, perspective camera | | [Effects](./Effects.md) | 30 modular GPU effects, 37 blend modes, transforms | | [Masks](./Masks.md) | Shape masks, pen tool, GPU feathering | | [AI Integration](./AI-Integration.md) | 76 AI tools, transcription, AI video generation | @@ -233,7 +236,7 @@ The following features are planned but not currently available: ## Version History See `src/version.ts` and `src/changelog-data.json` for the authoritative changelog. -Current version: 1.4.0. +Current version: 1.4.2. --- diff --git a/docs/Features/Timeline.md b/docs/Features/Timeline.md index 26f133672..09fb76774 100644 --- a/docs/Features/Timeline.md +++ b/docs/Features/Timeline.md @@ -83,6 +83,15 @@ getTrackChildren() - Get child tracks of a parent - Color fill clips with configurable color - Useful for backgrounds and color mattes +### Mesh Clips +- Created via `addMeshClip()` in meshClipSlice +- 3D primitive meshes: Cube, Sphere, Plane, Cylinder, Torus, Cone +- Automatically set to `is3D: true`, rendered via Three.js +- Source type: `model` with `meshType` property +- Default duration: 10 seconds +- Default material: MeshStandardMaterial (gray, metalness 0.3, roughness 0.6) +- Supports wireframe toggle, all 3D transform properties, and keyframe animation + ### Composition Clips - Nested compositions dragged from Media Panel - Orange outline for identification @@ -546,6 +555,7 @@ The timeline store (`src/stores/timeline/index.ts`) combines 17 slices + 2 utili | **clipSlice** | `clipSlice.ts` | Core clip operations: add, move, trim, split, reverse | | **textClipSlice** | `textClipSlice.ts` | Text clip creation and property updates | | **solidClipSlice** | `solidClipSlice.ts` | Solid color clip creation and color updates | +| **meshClipSlice** | `meshClipSlice.ts` | 3D primitive mesh clip creation | | **clipEffectSlice** | `clipEffectSlice.ts` | Add/remove/update effects on clips | | **linkedGroupSlice** | `linkedGroupSlice.ts` | Multicam linked group creation/removal | | **downloadClipSlice** | `downloadClipSlice.ts` | YouTube pending download clips | 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..c8accf0f2 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "masterselects", "private": true, - "version": "1.4.0", + "version": "1.4.2", "type": "module", "scripts": { "dev": "vite", @@ -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/App.css b/src/App.css index b72a00b2a..aa7af2ca0 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,47 @@ 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; +} + +.effect-mode-label { + flex: 1; + font-size: var(--font-md); + color: var(--text-secondary); + font-weight: 500; +} + .properties-tab-content.masks-tab .section-header-with-button { margin-bottom: var(--sp-2); } @@ -6686,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; @@ -6950,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 { @@ -7405,7 +7501,7 @@ input[type="checkbox"] { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); min-width: 180px; z-index: 1000; - overflow: hidden; + overflow: visible; } .add-dropdown-item { @@ -7442,6 +7538,27 @@ input[type="checkbox"] { margin: 4px 0; } +.add-dropdown-item.has-submenu { + position: relative; +} + +.add-dropdown-submenu { + display: none; + position: absolute; + left: 100%; + top: -4px; + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: 4px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + min-width: 140px; + padding: 4px 0; +} + +.add-dropdown-item.has-submenu:hover > .add-dropdown-submenu { + display: block; +} + /* Context Menu */ .media-context-menu { position: fixed; @@ -7451,7 +7568,7 @@ input[type="checkbox"] { box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); min-width: 160px; z-index: 10000; - overflow: hidden; + overflow: visible; } .media-context-menu button { @@ -8791,6 +8908,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/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/SettingsDialog.tsx b/src/components/common/SettingsDialog.tsx index db1bac793..3bad0f211 100644 --- a/src/components/common/SettingsDialog.tsx +++ b/src/components/common/SettingsDialog.tsx @@ -5,15 +5,10 @@ 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'; interface SettingsDialogProps { @@ -21,16 +16,11 @@ interface SettingsDialogProps { } type SettingsCategory = - | 'appearance' | 'general' - | 'previews' - | 'import' + | 'shortcuts' + | 'appearance' | 'transcription' - | 'output' - | 'performance' | 'nativeHelper' - | 'midi' - | 'aiFeatures' | 'apiKeys'; interface CategoryConfig { @@ -40,21 +30,16 @@ interface CategoryConfig { } const categories: CategoryConfig[] = [ - { id: 'appearance', label: 'Appearance', icon: '\uD83C\uDFA8' }, { id: 'general', label: 'General', icon: '\u2699' }, - { id: 'previews', label: 'Previews', icon: '\u25B6' }, - { id: 'import', label: 'Import', icon: '\uD83D\uDCE5' }, + { id: 'shortcuts', label: 'Shortcuts', icon: '\u2328' }, + { 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); @@ -76,17 +61,12 @@ export function SettingsDialog({ onClose }: SettingsDialogProps) { const renderCategoryContent = () => { switch (activeCategory) { - case 'appearance': return ; case 'general': return ; - case 'previews': return ; - case 'import': return ; + case 'shortcuts': 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/Toolbar.tsx b/src/components/common/Toolbar.tsx index ce4c89af0..64abe4000 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, @@ -59,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, @@ -168,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) { @@ -176,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 @@ -191,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 () => { @@ -198,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'); @@ -294,25 +300,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() : ''; + const registry = getShortcutRegistry(); - if (!key) { - return; - } - - // 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 +348,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 +400,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 +471,20 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) {
{isProjectOpen && ( <> @@ -577,11 +591,11 @@ export function Toolbar({ onOpenChangelog, onOpenSplash }: ToolbarProps) {
{openMenu === 'info' && (
+ +
diff --git a/src/components/common/TutorialOverlay.tsx b/src/components/common/TutorialOverlay.tsx index cab85b55e..eae410f76 100644 --- a/src/components/common/TutorialOverlay.tsx +++ b/src/components/common/TutorialOverlay.tsx @@ -192,11 +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 @@ -353,15 +355,34 @@ export function TutorialOverlay({ onClose, onSkip, part = 1, campaignSteps, camp const handleWelcomeSelect = useCallback((id: string) => { setUserBackground(id); - setStepIndex(0); - }, [setUserBackground]); + // 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'); + } + // 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 (
@@ -399,12 +420,12 @@ export function TutorialOverlay({ onClose, onSkip, part = 1, campaignSteps, camp )} {/* Clippy in own container so it doesn't fade with tooltip */} -
+
{step && ( @@ -412,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
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/common/WhatsNewDialog.tsx b/src/components/common/WhatsNewDialog.tsx index 103ab35ba..05a602531 100644 --- a/src/components/common/WhatsNewDialog.tsx +++ b/src/components/common/WhatsNewDialog.tsx @@ -13,7 +13,6 @@ import { type ChangelogNotice as ChangelogNoticeConfig, } from '../../version'; import { - NATIVE_HELPER_RELEASES_URL, type NativeHelperPublishedRelease, } from '../../services/nativeHelper/releases'; @@ -249,27 +248,20 @@ export function getHelperBuildNotice( return null; } - const fallbackNotice: ChangelogNoticeConfig = { + // Use BUILD_NOTICE as-is (title + message from version.ts) + const notice: ChangelogNoticeConfig = { ...BUILD_NOTICE, - title: 'Native Helper release available', - link: BUILD_NOTICE.link ?? { - label: 'GitHub Releases', - href: NATIVE_HELPER_RELEASES_URL, - }, }; - if (!publishedRelease) { - return fallbackNotice; + // Append Native Helper download link if a release is available + if (publishedRelease) { + notice.link = { + label: `Native Helper v${publishedRelease.version}`, + href: publishedRelease.url, + }; } - return { - ...fallbackNotice, - title: `Native Helper v${publishedRelease.version} available`, - link: { - label: 'Download release', - href: publishedRelease.url, - }, - }; + return notice; } export function ReleaseCalendar({ weeks }: { weeks: ChangelogCalendarDay[][] }) { 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..b3cfc456d 100644 --- a/src/components/common/settings/GeneralSettings.tsx +++ b/src/components/common/settings/GeneralSettings.tsx @@ -1,18 +1,29 @@ import { useCallback } from 'react'; -import { useSettingsStore, type AutosaveInterval } 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'; +import { AIFeaturesSettings } from './AIFeaturesSettings'; export function GeneralSettings() { const { - autosaveEnabled, + saveMode, autosaveInterval, + copyMediaToProject, forceDesktopMode, - setAutosaveEnabled, + previewQuality, + gpuPowerPreference, + setSaveMode, setAutosaveInterval, + setCopyMediaToProject, setForceDesktopMode, + setPreviewQuality, + setGpuPowerPreference, } = useSettingsStore(); const isMobileDevice = useIsMobile(); + const { isSupported, isEnabled, devices, lastMessage, enableMIDI, disableMIDI } = useMIDI(); const handleSwitchToMobile = useCallback(() => { setForceDesktopMode(false); @@ -23,33 +34,62 @@ export function GeneralSettings() {

General

+ {/* Import */}
-
Autosave
+
Import
+

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

+
+ + {/* Save Mode */} +
+
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 && ( @@ -63,6 +103,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 ed20bb12a..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; @@ -609,3 +609,188 @@ text-align: right; flex-shrink: 0; } + +/* ─── Shortcuts Settings ──────────────────────────────────────────── */ + +.shortcut-search-input { + width: 100%; + box-sizing: border-box; +} + +.shortcut-list { + 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); +} + +.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..26d0cce30 --- /dev/null +++ b/src/components/common/settings/ShortcutsSettings.tsx @@ -0,0 +1,240 @@ +// 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, PRESETS } from '../../../services/shortcutPresets'; +import { getShortcutRegistry } from '../../../services/shortcutRegistry'; +import type { ShortcutPresetId, ShortcutCategory, ShortcutMap, KeyCombo, ShortcutActionId } from '../../../services/shortcutTypes'; +import { ShortcutRecorder } from './ShortcutRecorder'; + +const CATEGORIES_ORDER: ShortcutCategory[] = [ + 'Playback', + 'Navigation', + 'Editing', + 'Tools', + 'Preview', + '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 = 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(() => { + 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 = effectiveMap[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; + }, [effectiveMap, registry]); + + return ( +
+

Keyboard Shortcuts

+ + {/* Preset Selection */} +
+
Preset
+ + + + {shortcutOverrides && Object.keys(shortcutOverrides).length > 0 && ( +
+ + {Object.keys(shortcutOverrides).length} custom override(s) + + +
+ )} +
+ + {/* Custom Presets */} +
+
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 = effectiveMap[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} + /> +
+ ); + })} +
+ ); + })} +
+
+ ); +} diff --git a/src/components/panels/AIChatPanel.css b/src/components/panels/AIChatPanel.css index ab236fe01..806a2426a 100644 --- a/src/components/panels/AIChatPanel.css +++ b/src/components/panels/AIChatPanel.css @@ -506,3 +506,132 @@ 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } + +/* ── AI Chat Onboarding Hint ── */ + +.ai-chat-onboarding { + display: flex; + justify-content: center; + padding: var(--sp-2) 0; + animation: onboardSlideIn 0.35s ease-out both; +} + +.ai-chat-onboarding.closing { + animation: onboardFadeOut 0.3s ease-in forwards; + pointer-events: none; +} + +@keyframes onboardSlideIn { + from { opacity: 0; transform: translateY(-10px); } + to { opacity: 1; transform: translateY(0); } +} + +@keyframes onboardFadeOut { + from { opacity: 1; transform: translateY(0); } + to { opacity: 0; transform: translateY(-8px); } +} + +.ai-chat-onboarding-card { + background: var(--bg-tertiary); + border: 1px solid var(--border-color); + border-radius: var(--radius-xl); + padding: var(--sp-4) var(--sp-5); + max-width: 420px; + width: 100%; +} + +.ai-chat-onboarding-header { + display: flex; + align-items: center; + gap: var(--sp-2); + margin-bottom: var(--sp-3); +} + +.ai-chat-onboarding-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: var(--accent); + color: var(--bg-primary); + border-radius: var(--radius-md); + font-size: var(--font-xs); + font-weight: var(--font-bold); + flex-shrink: 0; +} + +.ai-chat-onboarding-header h3 { + margin: 0; + font-size: var(--font-lg); + font-weight: var(--font-semibold); + color: var(--text-primary); +} + +.ai-chat-onboarding-body { + margin-bottom: var(--sp-3); +} + +.ai-chat-onboarding-intro { + margin: 0 0 var(--sp-2-5); + font-size: var(--font-md); + color: var(--text-secondary); + line-height: 1.5; +} + +.ai-chat-onboarding-tips { + margin: 0 0 var(--sp-2-5); + padding-left: var(--sp-4); + list-style: none; + display: flex; + flex-direction: column; + gap: var(--sp-1-5); +} + +.ai-chat-onboarding-tips li { + font-size: var(--font-sm); + color: var(--text-secondary); + line-height: 1.45; + position: relative; +} + +.ai-chat-onboarding-tips li::before { + content: '→'; + position: absolute; + left: calc(-1 * var(--sp-3)); + color: var(--accent); + font-weight: var(--font-semibold); +} + +.ai-chat-onboarding-tips li strong { + color: var(--text-primary); +} + +.ai-chat-onboarding-note { + margin: 0; + font-size: var(--font-xs); + color: var(--text-muted); + line-height: 1.5; +} + +.ai-chat-onboarding-note strong { + color: var(--text-secondary); +} + +.ai-chat-onboarding-dismiss { + display: block; + width: 100%; + padding: var(--sp-2) var(--sp-4); + background: var(--accent); + border: none; + border-radius: var(--radius-lg); + color: var(--bg-primary); + font-size: var(--font-md); + font-weight: var(--font-medium); + cursor: pointer; + transition: background var(--transition-normal); +} + +.ai-chat-onboarding-dismiss:hover { + background: var(--accent-hover); +} diff --git a/src/components/panels/AIChatPanel.tsx b/src/components/panels/AIChatPanel.tsx index dffdafbf1..2707271f0 100644 --- a/src/components/panels/AIChatPanel.tsx +++ b/src/components/panels/AIChatPanel.tsx @@ -299,6 +299,8 @@ function parseChatCompletionPayload(data: unknown): { export function AIChatPanel() { const { apiKeys, openSettings, aiApprovalMode } = useSettingsStore(); + const hasSeenAIChatOnboarding = useSettingsStore((s) => s.hasSeenAIChatOnboarding); + const setHasSeenAIChatOnboarding = useSettingsStore((s) => s.setHasSeenAIChatOnboarding); const hostedAIEnabled = useAccountStore((s) => s.hostedAIEnabled); const accountSession = useAccountStore((s) => s.session); const loadAccountState = useAccountStore((s) => s.loadAccountState); @@ -313,6 +315,7 @@ export function AIChatPanel() { const [editorMode, setEditorMode] = useState(true); // Enable tools by default const [currentToolAction, setCurrentToolAction] = useState(null); const [pendingApproval, setPendingApproval] = useState(null); + const [onboardingClosing, setOnboardingClosing] = useState(false); const messagesEndRef = useRef(null); const inputRef = useRef(null); @@ -586,6 +589,14 @@ export function AIChatPanel() { setError(null); }, []); + // Dismiss AI chat onboarding + const dismissOnboarding = useCallback(() => { + setOnboardingClosing(true); + setTimeout(() => { + setHasSeenAIChatOnboarding(true); + }, 300); + }, [setHasSeenAIChatOnboarding]); + return (
{/* API Key Required Overlay */} @@ -662,14 +673,46 @@ export function AIChatPanel() { {/* Messages */}
{messages.length === 0 ? ( -
-

{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}`} - -
+ <> + {/* Onboarding hint — shown once after first login */} + {hasAccess && !hasSeenAIChatOnboarding && ( +
+
+
+ AI +

Welcome to the AI Editor

+
+
+

+ This AI assistant can directly edit your timeline. Just describe what you want in plain language. +

+
    +
  • Cut & trim: "Remove the first 3 seconds" or "Split at 10s"
  • +
  • Remove silence: "Find and remove all silent parts"
  • +
  • Analyze: "What clips are on the timeline?" or "Transcribe this clip"
  • +
  • Batch edits: "Delete all clips shorter than 1 second"
  • +
  • Downloads: "Search YouTube for nature footage and download it"
  • +
+

+ The Tools toggle enables timeline editing. Turn it off for a normal chat. + Use the approval mode in Settings to control which actions need your confirmation. +

+
+ +
+
+ )} +
+

{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/components/panels/MediaPanel.tsx b/src/components/panels/MediaPanel.tsx index 3283ba57d..1d908eb2a 100644 --- a/src/components/panels/MediaPanel.tsx +++ b/src/components/panels/MediaPanel.tsx @@ -73,6 +73,7 @@ export function MediaPanel() { const compositions = useMediaStore(state => state.compositions); const folders = useMediaStore(state => state.folders); const solidItems = useMediaStore(state => state.solidItems); + const meshItems = useMediaStore(state => state.meshItems); const selectedIds = useMediaStore(state => state.selectedIds); const expandedFolderIds = useMediaStore(state => state.expandedFolderIds); const fileSystemSupported = useMediaStore(state => state.fileSystemSupported); @@ -107,6 +108,9 @@ export function MediaPanel() { createSolidItem, getOrCreateSolidFolder, updateSolidItem, + createMeshItem, + getOrCreateMeshFolder, + removeMeshItem, setLabelColor, } = useMediaStore.getState(); @@ -520,9 +524,10 @@ export function MediaPanel() { if (files.find(f => f.id === id)) removeFile(id); else if (compositions.find(c => c.id === id)) removeComposition(id); else if (folders.find(f => f.id === id)) removeFolder(id); + else if (meshItems.find(m => m.id === id)) removeMeshItem(id); }); closeContextMenu(); - }, [selectedIds, files, compositions, folders, removeFile, removeComposition, removeFolder, closeContextMenu]); + }, [selectedIds, files, compositions, folders, meshItems, removeFile, removeComposition, removeFolder, removeMeshItem, closeContextMenu]); // Get the active parent folder (grid view: current open folder, list view: selected folder or null) const getActiveParentId = useCallback((): string | null => { @@ -561,6 +566,13 @@ export function MediaPanel() { closeContextMenu(); }, [createSolidItem, getOrCreateSolidFolder, closeContextMenu]); + // New mesh item (in Media Panel, can be dragged to timeline) + const handleNewMesh = useCallback((meshType: import('../../stores/mediaStore/types').MeshPrimitiveType) => { + const meshFolderId = getOrCreateMeshFolder(); + createMeshItem(meshType, undefined, meshFolderId); + closeContextMenu(); + }, [createMeshItem, getOrCreateMeshFolder, closeContextMenu]); + // Composition settings const openCompositionSettings = useCallback((comp: Composition) => { setSettingsDialog({ @@ -673,6 +685,26 @@ export function MediaPanel() { return; } + // Handle mesh item drag + if (item.type === 'model' && 'meshType' in item) { + const meshItem = item as import('../../stores/mediaStore/types').MeshItem; + setExternalDragPayload({ + kind: 'mesh', + id: item.id, + duration: meshItem.duration, + hasAudio: false, + isAudio: false, + isVideo: true, + meshType: meshItem.meshType, + }); + e.dataTransfer.setData('application/x-mesh-item-id', item.id); + e.dataTransfer.effectAllowed = 'copyMove'; + if (e.currentTarget instanceof HTMLElement) { + e.dataTransfer.setDragImage(e.currentTarget, 10, 10); + } + return; + } + // Handle media file drag const mediaFile = item as MediaFile; if (!mediaFile.file || mediaFile.isImporting) { @@ -1290,6 +1322,18 @@ export function MediaPanel() { Solid
+
+ + Mesh + +
+ {(['cube', 'sphere', 'plane', 'cylinder', 'torus', 'cone'] as const).map(meshType => ( +
{ handleNewMesh(meshType); setAddDropdownOpen(false); }}> + {meshType.charAt(0).toUpperCase() + meshType.slice(1)} +
+ ))} +
+
{ /* TODO: Add adjustment layer */ setAddDropdownOpen(false); }}> Adjustment Layer @@ -1314,7 +1358,7 @@ export function MediaPanel() { {/* Item list with column headers */}
{rootItems.length === 0 ? ( -
+
handleContextMenu(e)}>
@@ -1361,6 +1405,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 +1434,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 */} @@ -1446,7 +1498,8 @@ export function MediaPanel() { ? files.find(f => f.id === contextMenu.itemId) || compositions.find(c => c.id === contextMenu.itemId) || folders.find(f => f.id === contextMenu.itemId) || - solidItems.find(s => s.id === contextMenu.itemId) + solidItems.find(s => s.id === contextMenu.itemId) || + meshItems.find(m => m.id === contextMenu.itemId) : null; const isVideoFile = selectedItem && 'type' in selectedItem && selectedItem.type === 'video'; const isComposition = selectedItem && 'type' in selectedItem && selectedItem.type === 'composition'; @@ -1474,11 +1527,40 @@ export function MediaPanel() {
Import Media...
-
- New Composition +
+
{ handleNewComposition(); closeContextMenu(); }}> + + Composition +
+
{ handleNewFolder(); closeContextMenu(); }}> + 📁 + Folder +
+
+
{ handleNewText(); closeContextMenu(); }}> + + Text +
+
{ handleNewSolid(); closeContextMenu(); }}> + + Solid +
+
+ + Mesh + +
+ {(['cube', 'sphere', 'plane', 'cylinder', 'torus', 'cone'] as const).map(meshType => ( +
{ handleNewMesh(meshType); closeContextMenu(); }}> + {meshType.charAt(0).toUpperCase() + meshType.slice(1)} +
+ ))} +
-
- New Folder +
+ + Adjustment Layer + Coming soon
{(contextMenu.itemId || multiSelect) && ( <> diff --git a/src/components/panels/media/FileTypeIcon.tsx b/src/components/panels/media/FileTypeIcon.tsx index 773f85004..30b4530f2 100644 --- a/src/components/panels/media/FileTypeIcon.tsx +++ b/src/components/panels/media/FileTypeIcon.tsx @@ -66,6 +66,14 @@ export const FileTypeIcon = memo(({ type, large }: FileTypeIconProps) => { ); + case 'mesh': + return ( + + + + + + ); default: return ( @@ -171,6 +179,22 @@ const LargeIcon = memo(({ type, style }: { type?: string; style: React.CSSProper ); + case 'mesh': + return ( + + + + {/* 3D cube wireframe */} + + + + + + + + + + ); default: return ( diff --git a/src/components/panels/properties/EffectsTab.tsx b/src/components/panels/properties/EffectsTab.tsx index 55cd04530..93fd9e2b2 100644 --- a/src/components/panels/properties/EffectsTab.tsx +++ b/src/components/panels/properties/EffectsTab.tsx @@ -1,10 +1,12 @@ -// Effects Tab - Add and configure visual effects +// Effects Tab - Add and configure visual/audio effects import { useState, useMemo, useCallback } from 'react'; import { useTimelineStore } from '../../../stores/timeline'; import { startBatch, endBatch } from '../../../stores/historyStore'; -import type { AnimatableProperty } from '../../../types'; +import type { AnimatableProperty, EffectType } from '../../../types'; +import { isAudioEffect } from '../../../types'; import { EFFECT_REGISTRY, getDefaultParams, getCategoriesWithEffects } from '../../../effects'; import { EffectKeyframeToggle, DraggableNumber } from './shared'; +import { VolumeTab } from './VolumeTab'; // Single parameter control renderer function renderParamControl( @@ -177,14 +179,20 @@ function EffectParams({ effect, onChange, clipId, onDragStart, onDragEnd }: Effe interface EffectsTabProps { clipId: string; effects: Array<{ id: string; name: string; type: string; enabled: boolean; params: Record }>; + isAudioClip?: boolean; } -export function EffectsTab({ clipId, effects }: EffectsTabProps) { +export function EffectsTab({ clipId, effects, isAudioClip }: EffectsTabProps) { // Reactive data - subscribe to specific values only 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'>(isAudioClip ? 'audio' : 'video'); const handleBatchStart = useCallback(() => startBatch('Adjust effect'), []); const handleBatchEnd = useCallback(() => endBatch(), []); @@ -192,34 +200,101 @@ export function EffectsTab({ clipId, effects }: EffectsTabProps) { const clipLocalTime = clip ? playheadPosition - clip.startTime : 0; const interpolatedEffects = getInterpolatedEffects(clipId, clipLocalTime); - // Get effects grouped by category from registry + // Get effects grouped by category from registry (video effects only) const effectCategories = useMemo(() => getCategoriesWithEffects(), []); + // Video effects only (exclude audio effects from the list) + const videoEffects = useMemo(() => + effects.filter(e => !isAudioEffect(e.type as EffectType)), + [effects] + ); + return (
- + {effectMode === 'video' && ( + + )} + {effectMode === 'audio' && Audio} +
+ + +
- {effects.length === 0 ? ( + {effectMode === 'audio' ? ( + + ) : videoEffects.length === 0 ? (

No effects applied

) : (
- {effects.map((effect) => { + {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; + const isDropTarget = dropIdx === idx; return ( -
+
{ + 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, videoEffects[fromIdx].id, idx); + endBatch(); + } + setDragIdx(null); + setDropIdx(null); + }} + onDragEnd={() => { setDragIdx(null); setDropIdx(null); }} + >
+ { + setDragIdx(idx); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', String(idx)); + }} + >☰ + )} + {isModel && ( + + )} +