From 4db607898fc4b71f4311f6b6429da139a8686cb8 Mon Sep 17 00:00:00 2001 From: Mohamad Mohebifar Date: Sun, 5 Apr 2026 02:24:30 -0700 Subject: [PATCH 1/6] feat: add color grading basics --- .beads/issues.jsonl | 106 ++--- .../color-grading/color-grading-panel.tsx | 347 ++++----------- .../editor/color-grading/color-wheel.tsx | 7 +- .../color-grading/color-wheels-properties.tsx | 104 ++--- .../editor/color-grading/node-graph.tsx | 374 ++++++---------- .../components/editor/properties-panel.tsx | 17 - .../compositor/src/color_grading_uniforms.rs | 178 +------- crates/compositor/src/compositor.rs | 199 +-------- crates/compositor/src/pipeline.rs | 401 +----------------- crates/types/src/color_grading.rs | 55 +-- packages/render-engine/src/types.ts | 41 -- 11 files changed, 326 insertions(+), 1503 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 380811c..3cac2cc 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -1,53 +1,53 @@ -{"id":"tooscut-0ip","title":"Implement spline/curve editor for keyframes","description":"Add a spline/curve editor UI for fine-tuning keyframe interpolation. This includes:\n\n- Bezier curve editor component (similar to After Effects graph editor)\n- Visual representation of property values over time\n- Draggable control points for bezier handles\n- Easing presets (ease-in, ease-out, ease-in-out, linear, custom)\n- Integration with keyframe system to edit interpolation curves\n- Zoom/pan controls for timeline navigation\n\nReference: subformer spline editor at /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T02:14:28.686002-08:00","created_by":"mohebifar","updated_at":"2026-02-07T22:16:06.811012-08:00","closed_at":"2026-02-07T22:16:06.811012-08:00","close_reason":"Closed","dependencies":[{"issue_id":"tooscut-0ip","depends_on_id":"tooscut-vev","type":"blocks","created_at":"2026-02-06T02:15:17.682583-08:00","created_by":"mohebifar"}]} -{"id":"tooscut-1fj","title":"Make menu bar functional","description":"The menu bar is currently fully decorative/non-functional. Wire up all menu items to their corresponding actions (File: new project, open, save, export; Edit: undo, redo, cut, copy, paste, delete; View: zoom controls, panel toggles; etc). Menu items without implementations should be disabled/grayed out.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:15.451904-08:00","created_by":"mohebifar","updated_at":"2026-02-22T16:48:23.082522-08:00","closed_at":"2026-02-22T16:48:23.082522-08:00","close_reason":"Closed","labels":["bug"]} -{"id":"tooscut-2ig","title":"Color Grading: Curves Editor node","description":"Implement RGB curves editor node for the color grading pipeline. Includes: master curve, per-channel R/G/B curves, and advanced hue-vs-sat/hue-vs-hue curves. Needs: interactive bezier curve UI component, 1D LUT texture generation from curves, shader integration for curve application. Types already defined in Rust (Curves, Curve1D) and TypeScript.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-04-06T13:08:29.099134-07:00","created_by":"mohebifar","updated_at":"2026-04-06T13:19:07.156573-07:00","closed_at":"2026-04-06T13:19:07.156573-07:00","close_reason":"Closed"} -{"id":"tooscut-38j","title":"Add audio effects (EQ, reverb, compression, noise gate)","description":"Add audio processing effects beyond volume and speed. Potential effects to implement:\n\n- **EQ (Equalizer)**: Low/mid/high band adjustment for tonal control\n- **Reverb**: Room simulation for spatial depth\n- **Compression**: Dynamic range compression to even out volume levels\n- **Noise gate**: Suppress audio below a threshold to remove background noise\n- **Low-pass / High-pass filters**: Frequency filtering\n\nEach effect needs:\n1. Rust implementation in crates/audio-engine (DSP processing in the mixer pipeline)\n2. UI controls in the audio properties panel (apps/ui/src/components/editor/audio-properties.tsx)\n3. Keyframeable parameters via the existing KeyframeInput system\n4. Timeline state serialization (AudioClipState in packages/render-engine/src/audio-engine.ts)\n5. Support in both playback (AudioWorklet) and export (offline WASM rendering) paths","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-23T01:25:37.868493-08:00","created_by":"mohebifar","updated_at":"2026-03-31T13:01:28.876263-07:00","closed_at":"2026-03-31T13:01:28.876263-07:00","close_reason":"Fully implemented: EQ with visual frequency response editor, compressor, noise gate, and reverb all have WASM DSP + UI controls + keyframe support"} -{"id":"tooscut-38z","title":"Transition system: in, out, and cross transitions","description":"Add ability to apply transitions (fade, dissolve, wipe, slide, etc.) to clips. Transitions should be droppable from the left panel onto clip edges (in/out) or between adjacent clips (cross transitions). The left panel should show a visual preview of each transition type. Leverage the existing transition types already defined in the render engine.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T21:58:28.62805-08:00","created_by":"mohebifar","updated_at":"2026-02-22T01:43:24.526507-08:00","closed_at":"2026-02-22T01:43:24.526507-08:00","close_reason":"Closed"} -{"id":"tooscut-3ck","title":"Add project settings (dimensions, resolution, orientation, frame rate)","description":"Add a project settings UI that allows users to configure: video dimensions (width/height), resolution presets (1080p, 4K, etc), orientation (landscape/portrait), and frame rate (24, 30, 60 fps, custom). Settings should be stored in the project record in DexieJS and applied to the compositor/export pipeline.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:16.677159-08:00","created_by":"mohebifar","updated_at":"2026-02-22T11:48:45.255249-08:00","closed_at":"2026-02-22T11:48:45.255249-08:00","close_reason":"Closed","labels":["feature"]} -{"id":"tooscut-40n","title":"Timeline markers with in/out points","description":"## Summary\nAdd named timeline markers and in/out point system for marking regions of interest.\n\n## UX Design\n\n### Marker creation\n- M: Drop a marker at the current playhead position\n- Marker appears as a small colored triangle on the ruler area (above tracks, below timecode)\n- Default color: cyan. Can be changed per marker.\n- Double-click marker triangle to rename (inline text edit on ruler)\n- Right-click marker: context menu with Rename, Change Color, Delete\n\n### Marker navigation\n- Shift+M: Go to next marker\n- Alt+M (or Ctrl+Shift+M): Go to previous marker\n- Markers listed in a dropdown/popover accessible from the toolbar (optional, lower priority)\n\n### In/Out points\n- I: Set in-point at playhead\n- O: Set out-point at playhead\n- In/out displayed as a highlighted region on the ruler (semi-transparent overlay between in and out)\n- Alt+I / Alt+O: Clear in/out point\n- In/out points constrain export range when set (pass to export dialog)\n- In/out points constrain playback loop when loop mode is enabled\n\n### Visual design\n- Markers: small downward-pointing triangles (6px) on the ruler, same row as timecode\n- Marker label: tiny text above the triangle, visible on hover or always if space permits\n- In/out region: subtle colored overlay on the ruler between the two points\n- In point: [ bracket icon. Out point: ] bracket icon.\n\n### Store changes\n- Add markers: Array\u003c{ id, time, name, color }\u003e to project state\n- Add inPoint: number | null, outPoint: number | null to store\n- Actions: addMarker, removeMarker, updateMarker, setInPoint, setOutPoint, clearInOutPoints\n- Markers saved with project (persisted to IndexedDB)\n- In/out points are session-only (not persisted)\n\n### Integration with export\n- If in/out points are set, export dialog should show option to export only the marked region\n- Default: export full timeline. Checkbox: \"Export in/out range only\"","status":"open","priority":2,"issue_type":"feature","created_at":"2026-03-30T01:10:44.409844-07:00","created_by":"mohebifar","updated_at":"2026-03-30T01:10:44.409844-07:00"} -{"id":"tooscut-42i","title":"Gesture to move clips between tracks","description":"Add ability to use drag gestures to move a clip/text/shape from one track to another. Should work with linked clips (video+audio moving together to their respective track types).","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:56.874235-08:00","created_by":"mohebifar","updated_at":"2026-02-05T00:14:51.901763-08:00","closed_at":"2026-02-05T00:14:51.901763-08:00","close_reason":"Closed"} -{"id":"tooscut-4cn","title":"Fix undo/redo requiring multiple keypresses","description":"Bug: between every undo/redo action, the user needs to press Cmd+Z multiple times for it to take effect. The first undo works with a single Cmd+Z, but subsequent undos require pressing Cmd+Z twice (or more). This suggests the temporal store (zundo) is recording duplicate or no-op history entries, or the undo handler is consuming keystrokes without performing the undo.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-22T02:12:22.105794-08:00","created_by":"mohebifar","updated_at":"2026-02-22T11:02:30.122139-08:00","closed_at":"2026-02-22T11:02:30.122141-08:00","labels":["bug"]} -{"id":"tooscut-4f5","title":"Preview doesn't update when paused and making changes","description":"When paused, moving a clip or making any change doesn't update the preview. The preview must re-render when timeline state changes while paused.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-02-03T15:31:54.584644-08:00","created_by":"mohebifar","updated_at":"2026-02-03T15:36:39.074212-08:00","closed_at":"2026-02-03T15:36:39.074212-08:00","close_reason":"Closed"} -{"id":"tooscut-5du","title":"Linked clip doesn't move visually during drag gesture","description":"When moving a clip, the linked audio/video clip doesn't move with it during the move gesture. It only moves on mouse up. The UX doesn't feel good - linked clips should move together in real-time during the drag.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-02-03T15:31:54.907253-08:00","created_by":"mohebifar","updated_at":"2026-02-03T15:42:35.648624-08:00","closed_at":"2026-02-03T15:42:35.648624-08:00","close_reason":"Closed"} -{"id":"tooscut-61n","title":"Fix file import via menu bar (File \u003e Import Media)","description":"The File \u003e Import Media menu item opens the file picker dialog, but after selecting a file, nothing happens — the asset is not added to the assets panel. The file picker integration (importFilesWithPicker) is not completing the import flow when triggered from the menu bar.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-02-22T15:37:50.751826-08:00","created_by":"mohebifar","updated_at":"2026-02-22T15:40:20.276596-08:00","closed_at":"2026-02-22T15:40:20.276596-08:00","close_reason":"Closed"} -{"id":"tooscut-63m","title":"Color Grading: HSL Qualifier node","description":"Implement HSL Qualifier (secondary color correction) node. Allows isolating specific color ranges by hue/saturation/luminance with soft edges, then applying a correction (PrimaryCorrection) only within that mask. Includes: qualifier mask preview overlay, hue/sat/lum range picker UI with center/width/softness controls, invert toggle, qualifier mask visualization on the preview panel. Shader functions already written (HSL_QUALIFIER_FUNCTIONS). Types defined: HslQualifier.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-04-06T13:08:42.895501-07:00","created_by":"mohebifar","updated_at":"2026-04-06T13:19:07.183977-07:00","closed_at":"2026-04-06T13:19:07.183977-07:00","close_reason":"Closed"} -{"id":"tooscut-63o","title":"Drag and drop files from OS file explorer directly to timeline","description":"Allow dragging files from Finder/file explorer directly onto the timeline to import and place them in one action. If the dropped file matches an already-imported asset (by name/size/type), reuse the existing asset rather than duplicating it in the assets panel. If it's a new file, import it as a new asset first, then add the clip to the timeline at the drop position.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-22T15:37:46.528465-08:00","created_by":"mohebifar","updated_at":"2026-02-22T17:25:09.399064-08:00","closed_at":"2026-02-22T17:25:09.399064-08:00","close_reason":"Closed"} -{"id":"tooscut-731","title":"Timeline drag-to-select (rubber band selection)","description":"Add rubber-band / marquee drag selection to the timeline view. Currently multi-select only works by holding Shift/Cmd and clicking individual clips. Users should be able to click and drag on empty timeline space to draw a selection rectangle that selects all clips it intersects.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:13.781927-08:00","created_by":"mohebifar","updated_at":"2026-02-22T11:34:18.138855-08:00","closed_at":"2026-02-22T11:34:18.138855-08:00","close_reason":"Closed","labels":["feature"]} -{"id":"tooscut-8rt","title":"Color Grading: Power Window node","description":"Implement Power Window (regional mask) node. Allows applying corrections to specific regions of the frame using shapes (circle/ellipse, rectangle, polygon, linear gradient). Includes: interactive shape overlay on preview panel with drag handles for position/size/rotation/softness, inner/outer softness controls, invert toggle. Each window carries its own PrimaryCorrection. Types defined: PowerWindow, PowerWindowShape.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-04-06T13:08:49.169099-07:00","created_by":"mohebifar","updated_at":"2026-04-06T13:19:07.194997-07:00","closed_at":"2026-04-06T13:19:07.194997-07:00","close_reason":"Closed","dependencies":[{"issue_id":"tooscut-8rt","depends_on_id":"tooscut-63m","type":"blocks","created_at":"2026-04-06T13:08:55.133021-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-a6i","title":"Add ability to add and remove tracks","description":"Implement UI and functionality to:\n- Add new video/audio track pairs to the timeline\n- Remove existing tracks from the timeline","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:56.221806-08:00","created_by":"mohebifar","updated_at":"2026-02-03T22:40:45.317523-08:00","closed_at":"2026-02-03T22:40:45.317523-08:00","close_reason":"Closed"} -{"id":"tooscut-and","title":"Color Grading System","description":"## Summary\nImplement a professional-grade color grading system with feature parity to DaVinci Resolve and Final Cut Pro.\n\n## Core Features\n- **Primary Correction** — CDL-based (slope/offset/power) + exposure, temperature, tint, saturation\n- **Color Wheels** — Lift/Gamma/Gain with luminance controls\n- **Curves** — Master, RGB, plus advanced (Hue vs Sat, Lum vs Sat, etc.)\n- **3D LUT Support** — .cube file parsing with tetrahedral interpolation\n- **HSL Qualifier** — Secondary color correction keying\n- **Power Windows** — Shapes/masks for regional corrections\n- **Scopes** — Waveform, vectorscope, histogram, parade (GPU-computed)\n- **Node-Based Pipeline** — Ordered processing chain per clip\n- **Color Matching** — Auto-match between clips\n\n## Architecture\nAll rendering happens in Rust/WASM (per project rules):\n- Color space conversions (sRGB ↔ Linear ↔ ACEScg ↔ Log)\n- CDL/LGG operations in WGSL shaders\n- 3D LUT textures with tetrahedral interpolation\n- Compute shaders for scope generation\n\nTypeScript/React handles UI only:\n- Color wheel canvas components\n- Curves editor\n- Scope display (Canvas 2D rendering of WASM-computed data)\n\n## Key Files to Modify\n- `crates/types/src/` — Add `color_grading.rs`\n- `crates/compositor/src/` — Add shaders and pipeline integration\n- `packages/render-engine/src/types.ts` — TypeScript definitions\n- `apps/ui/src/components/editor/` — New color grading UI components\n- `apps/ui/src/state/video-editor-store.ts` — State management","status":"open","priority":2,"issue_type":"epic","created_at":"2026-04-04T14:02:16.802358-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:02:16.802358-07:00"} -{"id":"tooscut-and.1","title":"Color grading foundation types and color space shaders","description":"## Summary\nDefine core types and implement color space conversion shaders as the foundation for the color grading system.\n\n## Tasks\n\n### Rust Types (crates/types/src/color_grading.rs)\n- [ ] `ColorSpace` enum (sRGB, Linear, ACEScg, LogC, SLog3, CLog3, VLog)\n- [ ] `PrimaryCorrection` struct (CDL: slope, offset, power + saturation, exposure, temperature, tint)\n- [ ] `ColorWheelValue` struct (angle, distance)\n- [ ] `ColorWheels` struct (lift, gamma, gain with luminance controls)\n- [ ] `Curve1D` and `CurvePoint` structs\n- [ ] `ColorGradingState` struct (combines all grading data for a layer)\n- [ ] Add `color_grading: Option\u003cColorGradingState\u003e` to `MediaLayerData`\n\n### TypeScript Types (packages/render-engine/src/types.ts)\n- [ ] Mirror all Rust types in TypeScript\n- [ ] Use snake_case to match serde serialization\n\n### WGSL Shaders (crates/compositor/src/shaders/color_grading.wgsl)\n- [ ] `srgb_to_linear()` and `linear_to_srgb()`\n- [ ] `rgb_to_hsl()` and `hsl_to_rgb()`\n- [ ] LogC/SLog3/VLog transfer functions\n- [ ] ACEScg matrix conversions\n\n### Uniforms (crates/compositor/src/color_grading_uniforms.rs)\n- [ ] `ColorGradingUniforms` struct with proper alignment (repr(C), Pod, Zeroable)\n- [ ] All CDL parameters, wheel values, qualifier params, flags\n\n## Acceptance Criteria\n- Types compile in Rust and generate correct TypeScript bindings via tsify\n- Color space conversion shaders pass visual tests (known color values)\n- Uniforms struct has correct GPU alignment","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:02:29.647666-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:41:09.868939-07:00","closed_at":"2026-04-04T14:41:09.868939-07:00","close_reason":"Closed","dependencies":[{"issue_id":"tooscut-and.1","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:02:29.649247-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.10","title":"Color matching and grade management","description":"## Summary\nImplement advanced color grading workflow features: clip matching, grade presets, copy/paste, and stills.\n\n## Tasks\n\n### Color Matching\n- [ ] `ColorMatchNode` type with reference clip/frame\n- [ ] Histogram matching algorithm:\n - Compute histograms of source and target\n - Generate CDL values to match distributions\n- [ ] UI: Select reference frame, click \"Match\"\n- [ ] Manual refinement after auto-match\n\n### Copy/Paste Grades\n- [ ] Copy full grade (all nodes) from clip\n- [ ] Paste grade to one or multiple clips\n- [ ] Paste specific nodes only (cherry-pick)\n- [ ] Keyboard shortcuts: Cmd+Shift+C / Cmd+Shift+V\n\n### Grade Presets (Stills)\n- [ ] Save current grade as named preset\n- [ ] Preset browser with thumbnails\n- [ ] Apply preset to clip\n- [ ] Export/import presets (JSON file)\n- [ ] Built-in presets: Film looks, B\u0026W, Vintage, etc.\n\n### Gallery (Stills)\n- [ ] Capture still (frame + grade) for reference\n- [ ] Gallery panel showing captured stills\n- [ ] Click still to apply its grade\n- [ ] Compare current frame to still\n\n### Ripple Grade Changes\n- [ ] Option to apply grade changes to all clips with same source\n- [ ] Useful for multi-cam or repeated shots\n\n### State Management\n- [ ] Add `colorGradingPresets: Map\u003cstring, ClipColorGrading\u003e` to store\n- [ ] Add `capturedStills: Array\u003c{ frame, grade, thumbnail }\u003e` to store\n- [ ] Add actions: `savePreset`, `applyPreset`, `captureStill`, `copyGrade`, `pasteGrade`\n\n## Acceptance Criteria\n- Color match produces visually similar results between clips\n- Copy/paste works across clips in same or different projects\n- Presets persist and load correctly\n- Gallery stills show accurate previews\n- Can quickly match multiple clips to a reference","status":"open","priority":3,"issue_type":"feature","created_at":"2026-04-04T14:04:39.71225-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:04:39.71225-07:00","dependencies":[{"issue_id":"tooscut-and.10","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:39.714933-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.10","depends_on_id":"tooscut-and.9","type":"blocks","created_at":"2026-04-04T14:04:39.716245-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.2","title":"Primary color correction (CDL)","description":"## Summary\nImplement CDL (Color Decision List) based primary color correction with basic adjustment controls.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `apply_cdl()` function: `out = pow(in * slope + offset, power)`\n- [ ] `apply_saturation()` function using luminance mix\n- [ ] `apply_exposure()` function (EV stops, -4 to +4)\n- [ ] `apply_temperature_tint()` function (Kelvin offset + green-magenta)\n- [ ] Highlight/shadow recovery functions\n\n### Compositor Integration\n- [ ] Add color grading render pass after media compositing\n- [ ] Create bind group for color grading uniforms\n- [ ] Wire uniforms from `MediaLayerData.color_grading` to shader\n\n### UI Components (apps/ui/src/components/editor/color-grading/)\n- [ ] `PrimaryCorrectionPanel` component with:\n - Exposure slider (-4 to +4 EV)\n - Temperature slider (2000K to 10000K)\n - Tint slider (green to magenta)\n - Contrast slider\n - Highlights/Shadows sliders\n - Saturation slider\n- [ ] CDL sliders (Slope R/G/B, Offset R/G/B, Power R/G/B) in collapsible \"Advanced\" section\n\n### State Management\n- [ ] Add `updateClipPrimaryCorrection(clipId, correction)` action\n- [ ] Integrate with undo/redo (temporal middleware)\n\n### Keyframe Support\n- [ ] Add primary correction properties to `COLOR_GRADING_ANIMATABLE_PROPERTIES`\n- [ ] Test keyframe interpolation for exposure, temperature, etc.\n\n## Acceptance Criteria\n- Adjusting exposure visibly changes clip brightness in preview\n- CDL values match ASC-CDL standard behavior\n- Changes are undoable\n- Keyframes animate smoothly","notes":"Completed: UI components (PrimaryCorrectionProperties, ColorGradingPanel), state management (updateClipColorGrading action), TypeScript types with keyframe support. Remaining: WGSL shader integration, bind group creation, visual testing.","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:02:42.611481-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:50:13.74347-07:00","dependencies":[{"issue_id":"tooscut-and.2","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:02:42.613067-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.2","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:02:42.615138-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.3","title":"Color wheels (Lift/Gamma/Gain)","description":"## Summary\nImplement Lift/Gamma/Gain color wheels for intuitive shadow/midtone/highlight color adjustment.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `color_wheel_to_rgb()` - convert angle/distance to RGB offset\n- [ ] `apply_lift_gamma_gain()` function:\n - Lift affects shadows (adds color when pixel is dark)\n - Gamma affects midtones (power function)\n - Gain affects highlights (multiplies)\n- [ ] Each wheel has RGB color shift + luminance master\n\n### UI Components\n- [ ] `ColorWheel` component (Canvas-based):\n - Circular color picker with saturation gradient\n - Draggable position indicator\n - Double-click to reset to center\n - Luminance slider below wheel\n- [ ] `ColorWheelsPanel` with three wheels (Lift, Gamma, Gain)\n- [ ] \"Link wheels\" toggle for global adjustments\n- [ ] Reset button per wheel and global reset\n\n### Interaction Design\n- [ ] Mouse drag updates angle + distance from center\n- [ ] Shift+drag constrains to current angle (saturation only)\n- [ ] Ctrl+drag for fine adjustment (0.1x speed)\n- [ ] Keyboard: arrow keys for small adjustments when focused\n\n### State Management\n- [ ] Add `updateClipColorWheels(clipId, wheels)` action\n- [ ] Wheel values stored as `{ angle: number, distance: number }`\n\n## Acceptance Criteria\n- Dragging lift wheel adds color to dark areas only\n- Dragging gain wheel adds color to bright areas only\n- Gamma affects midtones without crushing blacks/whites\n- Luminance sliders work independently from color shift\n- All changes are undoable","notes":"Completed: Canvas-based ColorWheel component with drag/shift+drag/ctrl+drag interactions, ColorWheelsProperties panel (Lift/Gamma/Gain), and React Flow node graph visualization. The node graph shows the color grading pipeline with color-coded nodes, enable/disable toggles, and drag-to-reorder support.","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:02:55.013616-07:00","created_by":"mohebifar","updated_at":"2026-04-04T16:58:32.135196-07:00","dependencies":[{"issue_id":"tooscut-and.3","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:02:55.015382-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.3","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:02:55.018517-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.4","title":"Curves editor","description":"## Summary\nImplement RGB curves for precise tonal control, plus advanced curves (Hue vs Sat, Lum vs Sat, etc.).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `evaluate_curve()` function using 1D LUT texture sampling\n- [ ] Support for smooth spline interpolation between control points\n- [ ] Apply curves in correct order: Master → R → G → B → Advanced\n\n### Curve LUT Generation (TypeScript)\n- [ ] `generateCurveLUT(points: CurvePoint[])` - outputs 256-entry Float32Array\n- [ ] Catmull-Rom or cubic Bezier interpolation between points\n- [ ] Handle edge cases (points at 0,0 and 1,1)\n\n### UI Components\n- [ ] `CurvesEditor` component (Canvas-based):\n - 256x256 grid with diagonal reference line\n - Click to add control point\n - Drag to move control point\n - Double-click or Delete key to remove point\n - Smooth curve rendering through points\n- [ ] Channel selector tabs: Master, Red, Green, Blue\n- [ ] Advanced curves dropdown: Hue vs Sat, Hue vs Lum, Sat vs Sat, Lum vs Sat\n- [ ] Preset curves: S-curve, fade, negative, etc.\n- [ ] Reset button\n\n### Advanced Curves\n- [ ] Hue vs Hue - rotate hues selectively\n- [ ] Hue vs Sat - saturate/desaturate specific hues\n- [ ] Hue vs Lum - brighten/darken specific hues\n- [ ] Lum vs Sat - saturate shadows/highlights differently\n- [ ] Sat vs Sat - compress or expand saturation range\n\n### State Management\n- [ ] Add `updateClipCurves(clipId, curves)` action\n- [ ] Curve presets stored separately for quick application\n\n### Performance\n- [ ] Regenerate LUT texture only when curve points change\n- [ ] Debounce during drag operations\n- [ ] Cache curve textures per clip\n\n## Acceptance Criteria\n- Dragging curve up brightens, down darkens\n- RGB curves affect only their respective channels\n- S-curve increases contrast visually\n- Smooth interpolation (no stair-stepping)\n- 60fps interaction during curve editing","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:09.958519-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:03:09.958519-07:00","dependencies":[{"issue_id":"tooscut-and.4","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:09.960905-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.4","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:09.964293-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.5","title":"3D LUT support (.cube files)","description":"## Summary\nImplement 3D LUT loading, GPU upload, and tetrahedral interpolation for professional color transforms.\n\n## Tasks\n\n### .cube File Parser (TypeScript)\n- [ ] `parseCubeFile(content: string): CubeLUT`\n- [ ] Parse header: TITLE, LUT_3D_SIZE, DOMAIN_MIN, DOMAIN_MAX\n- [ ] Parse RGB triplet data lines\n- [ ] Validate cube size (common: 17, 33, 65)\n- [ ] Handle comments and empty lines\n\n### LUT Manager (Rust/WASM)\n- [ ] `LutManager` struct with HashMap of loaded LUTs\n- [ ] `upload_lut(id, size, data)` - creates 3D texture on GPU\n- [ ] `remove_lut(id)` - frees GPU memory\n- [ ] `get_lut_texture(id)` - returns texture view for binding\n\n### WGSL Shader Implementation\n- [ ] `apply_lut_trilinear()` - basic trilinear interpolation\n- [ ] `apply_lut_tetrahedral()` - higher quality tetrahedral interpolation\n- [ ] Handle domain min/max scaling\n- [ ] LUT sampler with clamp-to-edge addressing\n\n### UI Components\n- [ ] `LutBrowserPanel` component:\n - Grid view of available LUTs with thumbnails\n - Search/filter by name\n - Preview on hover\n - Click to apply\n- [ ] LUT import button (file picker for .cube)\n- [ ] LUT intensity slider (mix with original)\n- [ ] Remove LUT button\n\n### LUT Thumbnail Generation\n- [ ] Generate preview by applying LUT to standard gradient image\n- [ ] Cache thumbnails in IndexedDB\n\n### State Management\n- [ ] Add `loadedLuts: Map\u003cstring, LoadedLut\u003e` to store\n- [ ] Add `loadLut(file)`, `removeLut(id)`, `setClipLut(clipId, lutId)` actions\n- [ ] LUT data persisted in project (or referenced by path)\n\n### Memory Management\n- [ ] Limit simultaneous loaded LUTs (e.g., 10 max)\n- [ ] LRU eviction for unused LUTs\n- [ ] 33³ × 16 bytes = ~575KB per LUT\n\n## Acceptance Criteria\n- .cube files from DaVinci/Resolve load correctly\n- LUT applied matches reference implementation\n- Tetrahedral interpolation has no visible banding\n- LUT browser shows accurate previews\n- Memory usage stays bounded with many LUTs","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:25.934398-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:41:14.358352-07:00","dependencies":[{"issue_id":"tooscut-and.5","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:25.936583-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.5","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:03:25.938607-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.6","title":"HSL Qualifier (secondary color correction)","description":"## Summary\nImplement HSL qualifier for isolating and correcting specific colors (secondary color correction / keying).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `hsl_qualifier_mask()` function:\n - Hue center + width + softness (circular distance)\n - Saturation center + width + softness\n - Luminance center + width + softness\n - Combine masks multiplicatively\n - Invert option\n- [ ] Apply correction multiplied by qualifier mask\n- [ ] Edge softness using smoothstep\n\n### UI Components\n- [ ] `HslQualifierPanel` component:\n - Hue range selector (circular/strip visualization)\n - Saturation range selector (horizontal bar)\n - Luminance range selector (horizontal bar)\n - Softness controls for each dimension\n - Invert toggle\n- [ ] Eyedropper tool to pick qualifier center from preview\n- [ ] \"Show Mask\" toggle to visualize selection (B\u0026W mask view)\n- [ ] Correction controls (reuse PrimaryCorrectionPanel) applied to qualified region\n\n### Eyedropper Interaction\n- [ ] Click on preview to sample pixel HSL values\n- [ ] Shift+click to add to selection (expand range)\n- [ ] Alt+click to subtract from selection\n- [ ] Sample multiple pixels for average\n\n### Mask Visualization\n- [ ] Toggle between: Normal view, Mask overlay, Mask only\n- [ ] Mask overlay shows selection as highlight color\n- [ ] Mask only shows B\u0026W (white = selected)\n\n### State Management\n- [ ] Add `HslQualifierNode` to color grading node types\n- [ ] Qualifier stores center, width, softness for H/S/L\n- [ ] Correction stored within qualifier node\n\n## Acceptance Criteria\n- Can isolate skin tones and adjust without affecting background\n- Can select sky blue and increase saturation\n- Soft edges blend naturally (no harsh cutoffs)\n- Eyedropper accurately picks colors from preview\n- Mask visualization helps dial in selection","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:39.983433-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:03:39.983433-07:00","dependencies":[{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:39.985365-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:39.987334-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.7","title":"Power windows (masks for regional correction)","description":"## Summary\nImplement power windows (geometric masks) for regional color corrections - vignettes, sky gradients, face isolation, etc.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] Shape mask generators:\n - Circle/Ellipse\n - Rectangle with corner radius\n - Linear gradient\n - Polygon (for complex shapes)\n- [ ] Transform: position, scale, rotation\n- [ ] Inner/outer softness (feathering)\n- [ ] Invert option\n- [ ] Combine with qualifier mask (AND operation)\n\n### UI Components\n- [ ] `PowerWindowPanel` component:\n - Shape selector (circle, rectangle, gradient, polygon)\n - Position X/Y sliders (% of frame)\n - Scale X/Y sliders\n - Rotation slider\n - Softness inner/outer sliders\n - Invert toggle\n- [ ] On-canvas shape overlay:\n - Draggable shape outline on preview\n - Corner handles for resize\n - Rotation handle\n - Center point for position\n\n### Shape Drawing\n- [ ] Circle: center + radius, draggable edge\n- [ ] Rectangle: corner handles, aspect ratio lock option\n- [ ] Gradient: start point + end point + angle\n- [ ] Polygon: click to add points, close path\n\n### Tracking (stretch goal)\n- [ ] Manual keyframe tracking (position/scale/rotation keyframes)\n- [ ] Data structure for tracked window transforms\n- [ ] Interpolation between tracking keyframes\n\n### Combination with Qualifier\n- [ ] Power window can be combined with HSL qualifier\n- [ ] \"Window AND Qualifier\" mode - both must match\n- [ ] Useful for: face in specific area, sky in upper region only\n\n## Acceptance Criteria\n- Can create vignette effect with soft circular window\n- Can isolate upper sky with gradient window\n- Can draw polygon around subject\n- On-canvas controls are intuitive (drag to move, handles to resize)\n- Soft edges blend naturally","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:54.558574-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:03:54.558574-07:00","dependencies":[{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:54.560807-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:03:54.563678-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.8","title":"Video scopes (waveform, vectorscope, histogram)","description":"## Summary\nImplement professional video scopes computed on GPU for real-time color analysis.\n\n## Tasks\n\n### Scope Types\n- [ ] **Histogram** - RGB + Luma distribution (256 bins each)\n- [ ] **Waveform** - Luma or RGB waveform (brightness by horizontal position)\n- [ ] **RGB Parade** - Separate R/G/B waveforms side by side\n- [ ] **Vectorscope** - Color distribution on color wheel (hue angle + saturation radius)\n\n### WGSL Compute Shaders\n- [ ] `compute_histogram.wgsl`:\n - Atomic histogram binning\n - Output: 4 × 256 buffer (R, G, B, Luma)\n- [ ] `compute_waveform.wgsl`:\n - For each column, accumulate pixel brightness into rows\n - Output: 2D texture (width × 256)\n- [ ] `compute_vectorscope.wgsl`:\n - Map each pixel to 2D position by hue/sat\n - Accumulate density\n - Output: 256×256 texture\n\n### Rust/WASM Implementation\n- [ ] `ScopeComputer` struct managing compute pipelines\n- [ ] `compute_histogram(texture_id) -\u003e Vec\u003cf32\u003e`\n- [ ] `compute_waveform(texture_id, width) -\u003e Vec\u003cu8\u003e`\n- [ ] `compute_vectorscope(texture_id, size) -\u003e Vec\u003cu8\u003e`\n\n### UI Components\n- [ ] `ScopesPanel` component:\n - Scope type selector tabs\n - Canvas for scope display\n - Graticule overlays (scale markers)\n- [ ] Histogram: stacked or separate R/G/B view option\n- [ ] Waveform: Luma / RGB / Parade mode selector\n- [ ] Vectorscope: skin tone line, color target boxes\n\n### Graticules and Reference Lines\n- [ ] Histogram: 0%, 50%, 100% markers\n- [ ] Waveform: IRE/percentage scale, legal range indicators (16-235)\n- [ ] Vectorscope: color boxes (R, Mg, B, Cy, G, Yl), skin tone line\n\n### Performance\n- [ ] Compute scopes on requestAnimationFrame, not every frame\n- [ ] Throttle during playback (every 2-3 frames)\n- [ ] Full rate when paused\n- [ ] Resolution option: 1x, 1/2, 1/4 for faster computation\n\n## Acceptance Criteria\n- Histogram matches reference scope software\n- Waveform shows correct brightness distribution\n- Vectorscope shows correct color positions\n- Scopes update in real-time during playback (\u003c16ms compute time)\n- Graticules help interpret scope readings","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:11.037853-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:41:14.406509-07:00","dependencies":[{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:11.040172-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:04:11.04237-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.9","title":"Node-based color grading pipeline","description":"## Summary\nImplement node-based color grading pipeline allowing multiple stacked corrections executed in order.\n\n## Tasks\n\n### Data Structures\n- [ ] `ColorGradingNode` union type (primary, wheels, curves, LUT, qualifier, window)\n- [ ] `ClipColorGrading` with ordered `nodes: ColorGradingNode[]` array\n- [ ] Each node has: id, type, enabled, mix, label\n\n### Node Execution Engine (Rust/WASM)\n- [ ] `execute_color_pipeline(texture, nodes) -\u003e texture`\n- [ ] Execute nodes in order, each reading previous output\n- [ ] Skip disabled nodes\n- [ ] Apply per-node mix (blend with input)\n- [ ] Intermediate render targets for multi-pass\n\n### UI Components\n- [ ] `NodeListPanel` component:\n - Vertical list of nodes (not visual graph, like Resolve's mini timeline)\n - Drag to reorder\n - Enable/disable toggle per node\n - Mix slider per node\n - Expand/collapse node settings\n - Add node button (dropdown of types)\n - Delete node button\n- [ ] Node type icons for quick identification\n- [ ] \"Solo\" button to preview single node effect\n\n### Node Operations\n- [ ] Add node (at end or after selected)\n- [ ] Remove node\n- [ ] Reorder nodes (drag \u0026 drop)\n- [ ] Duplicate node\n- [ ] Enable/disable node\n- [ ] Reset node to defaults\n- [ ] Copy/paste node between clips\n\n### Bypass and Preview\n- [ ] Global bypass toggle (show original)\n- [ ] \"Solo node\" mode - only selected node active\n- [ ] A/B comparison split view (future enhancement)\n\n### State Management\n- [ ] Add `addColorGradingNode(clipId, node, index?)` action\n- [ ] Add `removeColorGradingNode(clipId, nodeId)` action\n- [ ] Add `reorderColorGradingNodes(clipId, fromIndex, toIndex)` action\n- [ ] Add `updateColorGradingNode(clipId, nodeId, updates)` action\n\n## Acceptance Criteria\n- Can stack multiple corrections (e.g., Primary → Curves → LUT)\n- Node order affects output (e.g., LUT before vs after primary)\n- Disabling node removes its effect\n- Mix slider blends node effect with input\n- Drag reordering updates preview immediately","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:26.186835-07:00","created_by":"mohebifar","updated_at":"2026-04-04T14:04:26.186835-07:00","dependencies":[{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:26.187706-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.3","type":"blocks","created_at":"2026-04-04T14:04:26.188467-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.4","type":"blocks","created_at":"2026-04-04T14:04:26.189223-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.5","type":"blocks","created_at":"2026-04-04T14:04:26.189985-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:04:26.190624-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.7","type":"blocks","created_at":"2026-04-04T14:04:26.191412-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-bxi","title":"Show video clip thumbnails in timeline","description":"Display thumbnail previews for video clips in the timeline:\n- Performant rendering like subformer\n- Handle zoom in/out properly (show more/fewer thumbnails)\n- Reference: /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:57.360668-08:00","created_by":"mohebifar","updated_at":"2026-02-04T23:22:18.364728-08:00","closed_at":"2026-02-04T23:22:18.364728-08:00","close_reason":"Closed"} -{"id":"tooscut-d1w","title":"Create numeric input component with drag-to-adjust","description":"Create a numeric input component that:\n- Can increase/decrease value by click and drag left/right\n- Allows direct editing when clicked\n- Accepts a suffix prop for units (e.g., '%', 'px', '°')\n- Will be used in the properties panel for transform/effect controls","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:55.226838-08:00","created_by":"mohebifar","updated_at":"2026-02-03T15:44:26.731079-08:00","closed_at":"2026-02-03T15:44:26.731079-08:00","close_reason":"Closed"} -{"id":"tooscut-dz0","title":"Generate and display project thumbnails","description":"Projects should have a thumbnail that is shown on the project list page. Generate a thumbnail from the project content (e.g., render a frame from the timeline at a representative time) and store it as thumbnailDataUrl in the DexieJS projects table. Display these thumbnails in the project cards on the home/projects page.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:19.167347-08:00","created_by":"mohebifar","updated_at":"2026-02-22T17:08:56.282213-08:00","closed_at":"2026-02-22T17:08:56.282213-08:00","close_reason":"Closed","labels":["feature"]} -{"id":"tooscut-fbw","title":"Proper handling of clip trimming","description":"Implement proper clip trimming functionality:\n- Trim from left edge (adjusts start time and in-point)\n- Trim from right edge (adjusts duration)\n- Visual feedback during trim operation\n- Respect asset duration limits","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:57.195771-08:00","created_by":"mohebifar","updated_at":"2026-02-05T00:14:26.507498-08:00","closed_at":"2026-02-05T00:14:26.507498-08:00","close_reason":"Closed"} -{"id":"tooscut-i1k","title":"Snap clips in timeline (move and trim)","description":"When moving a clip close to another clip's edge, snap them together automatically. When trimming a clip, snap to edges of clips on other tracks. Show a visual snap line indicator when snapping occurs. Should work for both move and trim operations across all tracks.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T21:58:21.397626-08:00","created_by":"mohebifar","updated_at":"2026-02-07T00:32:00.23669-08:00","closed_at":"2026-02-07T00:32:00.23669-08:00","close_reason":"Closed"} -{"id":"tooscut-ixy","title":"J/K/L playback shortcuts and expanded keyboard navigation","description":"## Summary\nAdd industry-standard NLE keyboard shortcuts for playback control and navigation.\n\n## UX Design\n\n### J/K/L Playback (highest priority)\n- L: Play forward. Press again to increase speed (1x -\u003e 2x -\u003e 4x -\u003e 8x)\n- K: Pause. Hold K + tap L = step forward one frame. Hold K + tap J = step backward one frame.\n- J: Play reverse. Press again to increase reverse speed (1x -\u003e 2x -\u003e 4x -\u003e 8x)\n- Speed resets when switching direction or pressing K\n- Show current playback speed in the playback controls bar when not 1x (e.g. \"2x\" or \"-4x\")\n\n### Frame-accurate navigation\n- , (comma): Previous frame (already in menu, verify wired)\n- . (period): Next frame (already in menu, verify wired)\n- Shift+, : Jump back 1 second (10 frames at 30fps, or fps-based)\n- Shift+. : Jump forward 1 second\n- Home: Jump to start of timeline (verify working)\n- End: Jump to end of last clip (verify working)\n\n### Clip navigation\n- Up arrow: Select previous clip on same track (or move selection up a track)\n- Down arrow: Select next clip on same track (or move selection down a track)\n- Shift+Left/Right: Nudge selected clip by 1 frame\n- Alt+Left/Right: Nudge selected clip by 10 frames\n\n### Implementation notes\n- All shortcuts registered in the existing global keydown handler in canvas-timeline.tsx\n- Skip when focus is on INPUT/TEXTAREA (existing pattern)\n- Store needs: playbackSpeed state, setPlaybackSpeed action\n- Audio engine needs speed parameter adjustment during J/K/L playback\n- Playback controls bar should show speed indicator when not 1x","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-03-30T01:03:57.129655-07:00","created_by":"mohebifar","updated_at":"2026-03-30T18:31:20.948554-07:00","closed_at":"2026-03-30T18:31:20.948554-07:00","close_reason":"Closed"} -{"id":"tooscut-jt2","title":"Implement audio renderer WASM module","description":"Create an audio renderer WASM module for audio processing and playback. Use subformer's implementation at /Users/mohebifar/dev/other/kareem/subformer for inspiration on how audio rendering is handled.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:55.552314-08:00","created_by":"mohebifar","updated_at":"2026-02-03T16:17:41.966779-08:00","closed_at":"2026-02-03T16:17:41.966779-08:00","close_reason":"Closed"} -{"id":"tooscut-k4m","title":"Ripple editing mode","description":"## Summary\nAdd a ripple editing mode that shifts downstream clips when deleting, trimming, or moving clips.\n\n## UX Design\n\n### Toggle\n- Ripple toggle button in TimelineToolbar (next to tool selector)\n- Keyboard shortcut: R to toggle (consistent with V=Select, C=Razor pattern)\n- Visual indicator: button stays highlighted when active\n\n### Behavior when ripple is ON\n\n**Ripple Delete:**\n- Deleting a clip shifts all clips to the right on the same track left to fill the gap\n- Linked clips (audio+video pairs) ripple together\n- Other tracks are NOT affected\n\n**Ripple Trim:**\n- Trimming right edge: downstream clips shift to match\n- Trimming left edge: clip and all downstream clips shift together\n- Visual preview: ghost outlines show where downstream clips will land during drag\n\n### Behavior when ripple is OFF\n- Current behavior (no change)\n\n### Store Changes\n- Add rippleMode boolean to store state with toggleRippleMode() action\n- Add rippleDelete(clipId) that removes clip and shifts downstream\n- Modify trimLeft/trimRight to accept a ripple flag\n- Add utility: getDownstreamClips(trackId, afterTime)\n\n### Visual Feedback\n- Subtle indicator on timeline when ripple mode is active\n- During ripple drag/trim, show ghost positions of affected downstream clips","status":"open","priority":1,"issue_type":"feature","created_at":"2026-03-30T01:03:25.119552-07:00","created_by":"mohebifar","updated_at":"2026-03-30T01:03:25.119552-07:00"} -{"id":"tooscut-li4","title":"Audio clip fade handles on timeline","description":"## Summary\nAdd draggable fade-in/fade-out handles on audio clip edges in the timeline for quick volume fades.\n\n## UX Design\n\n### Visual design\n- Small triangular handles at the top corners of audio clips on the timeline\n- Fade-in handle: top-left corner. Fade-out handle: top-right corner\n- Handles 8x8px, visible on hover over audio clips\n- Fade region draws a curved line from 0 to 100% volume\n- Fade curve rendered as a semi-transparent overlay on the clip, above the waveform\n\n### Interaction\n- Drag fade-in handle rightward to increase fade-in duration\n- Drag fade-out handle leftward to increase fade-out duration\n- Minimum fade: 1 frame. Maximum fade: half the clip duration\n- Snapping: fade handles snap to grid lines (same as clip snap)\n- Double-click a fade handle to reset it to 0 (remove fade)\n\n### Data model\n- Uses dedicated fadeIn/fadeOut duration fields on the clip (NOT keyframes)\n- Fade durations are relative to clip edges so they survive trimming\n- The WASM audio mixer already supports per-clip fade_in and fade_out durations\n- Currently these are always 0 because there is no UI to set them\n\n### Store changes\n- Add fadeIn and fadeOut number fields to AudioClip type (duration in frames)\n- Add setClipFadeIn(clipId, frames) and setClipFadeOut(clipId, frames) actions\n- Pass fade values through useAudioEngine timeline sync with framesToSeconds conversion\n- These are undoable (temporal-tracked state)\n\n### Timeline rendering (Konva)\n- Detect hover near top corners of audio clips (8px hit zone)\n- Show ew-resize cursor on hover\n- During drag, render the fade curve preview in real-time\n- Fade region: filled area between the straight top edge and the curve","status":"open","priority":2,"issue_type":"feature","created_at":"2026-03-31T13:01:17.976325-07:00","created_by":"mohebifar","updated_at":"2026-03-31T13:01:17.976325-07:00"} -{"id":"tooscut-lso","title":"Implement video export with muxer and web worker parallelization","description":"Add ability to export videos using the same muxer method and web worker parallelization used in subformer. Reference: /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:55.879274-08:00","created_by":"mohebifar","updated_at":"2026-02-06T02:12:38.306274-08:00","closed_at":"2026-02-06T02:12:38.306274-08:00","close_reason":"Closed"} -{"id":"tooscut-mgr","title":"Project persistence with IndexedDB and project chooser page","description":"Store projects in IndexedDB using DexieJS. Add a project list page (home/index route) where users can see saved projects, create new ones, and open existing ones. Projects should auto-save on changes (debounced). Store project name, settings, timeline content, and thumbnail.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T21:58:26.053163-08:00","created_by":"mohebifar","updated_at":"2026-02-07T22:16:17.087971-08:00","closed_at":"2026-02-07T22:16:17.087971-08:00","close_reason":"Closed"} -{"id":"tooscut-n75","title":"Color Grading: LUT Support node","description":"Implement 3D LUT loading and application node. Includes: .cube file parser (standard 3D LUT format), LUT preview thumbnail, 3D texture upload to GPU, trilinear and tetrahedral interpolation in shader (shader functions already written in color_grading_shader.rs), LUT browser UI. Types already defined: LutReference, LutInterpolation.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-04-06T13:08:35.744189-07:00","created_by":"mohebifar","updated_at":"2026-04-06T13:19:07.170923-07:00","closed_at":"2026-04-06T13:19:07.170923-07:00","close_reason":"Closed"} -{"id":"tooscut-nse","title":"Add shapes, texts, images, clips and audios to timeline","description":"Implement ability to add various layer types to the timeline:\n- Shape layers (rectangles, circles, polygons, lines)\n- Text layers\n- Image layers\n- Video clips\n- Audio clips","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:56.702715-08:00","created_by":"mohebifar","updated_at":"2026-02-06T01:14:51.651987-08:00","closed_at":"2026-02-06T01:14:51.651987-08:00","close_reason":"Already implemented: Asset panel, text panel, and shape panel all support drag-and-drop to timeline. Canvas timeline has drop handlers for all clip types. Store has addClipToTrack action supporting video, audio, image, text, and shape clips."} -{"id":"tooscut-p7h","title":"Auto-place first clip at timeline start","description":"When adding the very first clip to an empty timeline, automatically place it at time 0 (the beginning). This only applies when the timeline has no existing clips. Subsequent clips should still be placed at the drop position or current time.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-02-06T21:58:30.572339-08:00","created_by":"mohebifar","updated_at":"2026-02-06T22:20:58.705414-08:00","closed_at":"2026-02-06T22:20:58.705414-08:00","close_reason":"Closed"} -{"id":"tooscut-pvp","title":"Add transform mode and view mode toggle for preview panel","description":"Introduce two modes for the preview panel: View mode and Transform mode, with toggle buttons below the preview. Currently, clicking on clip boundaries in the preview or selecting any clip from the timeline shows transform handles (resize/move). This should only happen in Transform mode. In View mode, transform handles should be hidden and clips should not be movable/resizable in the preview. Add toggle buttons (e.g. icons) below the preview panel to switch between modes.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-22T15:37:43.765143-08:00","created_by":"mohebifar","updated_at":"2026-02-22T19:41:28.491218-08:00","closed_at":"2026-02-22T19:41:28.491218-08:00","close_reason":"Closed"} -{"id":"tooscut-qpn","title":"Add mute/unmute and lock/unlock for tracks","description":"Implement track controls:\n- Mute/unmute: For video tracks, this hides the track's clips from rendering. For audio tracks, this mutes the audio.\n- Lock/unlock: Prevents editing of clips on locked tracks","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:56.544018-08:00","created_by":"mohebifar","updated_at":"2026-02-04T02:09:25.954736-08:00","closed_at":"2026-02-04T02:09:25.954736-08:00","close_reason":"Closed"} -{"id":"tooscut-sg3","title":"Show audio waveform on audio clips","description":"Display audio waveform visualization on audio clips in the timeline:\n- Performant rendering like subformer\n- Handle zoom in/out properly\n- Reference: /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:57.521544-08:00","created_by":"mohebifar","updated_at":"2026-02-05T00:12:30.453041-08:00","closed_at":"2026-02-05T00:12:30.453041-08:00","close_reason":"Closed"} -{"id":"tooscut-shs","title":"Multi-select clips: batch move and trim","description":"Allow selecting multiple clips in the timeline (click + shift/ctrl, or drag-select). Moving one selected clip moves all selected clips. Trimming/extending one selected clip applies the same delta to all selected clips.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T21:58:23.231693-08:00","created_by":"mohebifar","updated_at":"2026-02-22T19:42:40.147061-08:00","closed_at":"2026-02-22T19:42:40.147061-08:00","close_reason":"Closed"} -{"id":"tooscut-sp0","title":"Fix overlap handling to trim instead of delete","description":"Fix the behavior of overlap handling. When a clip overlaps another clip:\n- Instead of deleting the overlapped clip, it should trim it to avoid the overlap\n- Only delete if the clip is fully overlapped (100% covered)\n- Partial overlaps should result in trimming the overlapped portion","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-02-03T15:31:57.038584-08:00","created_by":"mohebifar","updated_at":"2026-02-04T01:52:40.207676-08:00","closed_at":"2026-02-04T01:52:40.207676-08:00","close_reason":"Closed"} -{"id":"tooscut-t6f","title":"Drag and drop assets from assets panel to preview","description":"Allow dragging assets from the assets panel directly into the preview panel. The asset should be added at the current playhead timestamp on the highest (frontmost) video track. If the highest track is already occupied at the playhead position and/or adding the asset would overlap existing clips, automatically create a new track pair and place the asset there instead.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-22T15:37:39.905379-08:00","created_by":"mohebifar","updated_at":"2026-02-22T17:20:28.702887-08:00","closed_at":"2026-02-22T17:20:28.702887-08:00","close_reason":"Closed"} -{"id":"tooscut-vev","title":"Implement keyframe animation system","description":"Add keyframe animation support for clip properties (position, scale, rotation, opacity, etc.). This includes:\n\n- Keyframe data model in clip state (KeyframeTracks with property -\u003e keyframe[] mapping)\n- Keyframe interpolation using the existing EvaluatorManager from render-engine\n- UI for adding/removing keyframes at current playhead position\n- Visual keyframe indicators on timeline clips\n- Property panel integration to show animated values\n\nReference: subformer implementation at /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T02:14:25.299746-08:00","created_by":"mohebifar","updated_at":"2026-02-06T21:58:14.325586-08:00","closed_at":"2026-02-06T21:58:14.325586-08:00","close_reason":"Closed"} -{"id":"tooscut-wio","title":"Playhead is not movable during playback","description":"While playing, the user cannot move the playhead to scrub/seek. It just keeps playing and ignores playhead drag interaction. Expected: dragging the playhead during playback should pause and seek (or seek while playing).","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-02-06T21:58:19.344465-08:00","created_by":"mohebifar","updated_at":"2026-02-06T22:18:49.143947-08:00","closed_at":"2026-02-06T22:18:49.143947-08:00","close_reason":"Closed"} -{"id":"tooscut-wlg","title":"Drag ghost should reflect actual clip duration","description":"When dragging a clip from the assets panel onto the timeline, the preview ghost/shadow should be representative of the actual duration of the video/audio asset, not a fixed size. The ghost width should match what the clip will look like at the current zoom level.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-02-06T21:58:32.635988-08:00","created_by":"mohebifar","updated_at":"2026-02-06T22:23:14.006719-08:00","closed_at":"2026-02-06T22:23:14.006719-08:00","close_reason":"Closed"} -{"id":"tooscut-yq4","title":"Cut (Cmd+X) and Duplicate clip operations","description":"## Summary\nImplement cut (copy + delete) and duplicate operations. Cut menu item exists but is disabled.\n\n## UX Design\n\n### Cut (Cmd+X)\n- Copy selected clips to clipboard, then delete them\n- If ripple mode is on (see tooscut-k4m), ripple-delete after copy\n- If ripple mode is off, just delete (leave gap)\n- Linked clips: cut both video and audio together\n- Menu item in Edit menu already exists — enable it and wire handler\n\n### Duplicate (Cmd+D)\n- Duplicate selected clips and place them immediately after the originals\n- New clips start at the end of the rightmost selected clip\n- Linked clips: duplicate both video and audio\n- Select the new duplicates (deselect originals)\n- Useful for repeating patterns, creating variations\n\n### Implementation\n- Store actions needed: cutSelectedClips() — calls copySelectedClips() then removeClip() for each\n- Store actions needed: duplicateSelectedClips() — deep-clone clips, assign new IDs, offset startTime\n- Keyboard handler: add Cmd+X and Cmd+D to global keydown in canvas-timeline.tsx\n- Menu: enable existing Cut item, add Duplicate item to Edit menu\n- Duplicate should generate new clip IDs and new linkedClipIds for pairs","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-03-30T01:11:31.108201-07:00","created_by":"mohebifar","updated_at":"2026-04-01T22:15:34.121127-07:00","closed_at":"2026-04-01T22:15:34.121127-07:00","close_reason":"Closed"} -{"id":"tooscut-yqc","title":"Make entire assets panel a dropzone for file imports","description":"Make the entire assets panel act as a dropzone so users can drag and drop files from Finder/file explorer anywhere on the assets panel to import them. Currently only specific areas may accept drops.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-22T15:37:48.50237-08:00","created_by":"mohebifar","updated_at":"2026-02-22T16:50:04.215085-08:00","closed_at":"2026-02-22T16:50:04.215085-08:00","close_reason":"Closed"} +{"id":"tooscut-0ip","title":"Implement spline/curve editor for keyframes","description":"Add a spline/curve editor UI for fine-tuning keyframe interpolation. This includes:\n\n- Bezier curve editor component (similar to After Effects graph editor)\n- Visual representation of property values over time\n- Draggable control points for bezier handles\n- Easing presets (ease-in, ease-out, ease-in-out, linear, custom)\n- Integration with keyframe system to edit interpolation curves\n- Zoom/pan controls for timeline navigation\n\nReference: subformer spline editor at /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T02:14:28.686002-08:00","updated_at":"2026-02-07T22:16:06.811012-08:00","closed_at":"2026-02-07T22:16:06.811012-08:00","close_reason":"Closed","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-0ip","depends_on_id":"tooscut-vev","type":"blocks","created_at":"2026-02-06T02:15:17.682583-08:00","created_by":"mohebifar"}]} +{"id":"tooscut-1fj","title":"Make menu bar functional","description":"The menu bar is currently fully decorative/non-functional. Wire up all menu items to their corresponding actions (File: new project, open, save, export; Edit: undo, redo, cut, copy, paste, delete; View: zoom controls, panel toggles; etc). Menu items without implementations should be disabled/grayed out.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:15.451904-08:00","updated_at":"2026-02-22T16:48:23.082522-08:00","closed_at":"2026-02-22T16:48:23.082522-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-2ig","title":"Color Grading: Curves Editor node","description":"Implement RGB curves editor node for the color grading pipeline. Includes: master curve, per-channel R/G/B curves, and advanced hue-vs-sat/hue-vs-hue curves. Needs: interactive bezier curve UI component, 1D LUT texture generation from curves, shader integration for curve application. Types already defined in Rust (Curves, Curve1D) and TypeScript.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-04-06T13:08:29.099134-07:00","updated_at":"2026-04-06T13:19:07.156573-07:00","closed_at":"2026-04-06T13:19:07.156573-07:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-38j","title":"Add audio effects (EQ, reverb, compression, noise gate)","description":"Add audio processing effects beyond volume and speed. Potential effects to implement:\n\n- **EQ (Equalizer)**: Low/mid/high band adjustment for tonal control\n- **Reverb**: Room simulation for spatial depth\n- **Compression**: Dynamic range compression to even out volume levels\n- **Noise gate**: Suppress audio below a threshold to remove background noise\n- **Low-pass / High-pass filters**: Frequency filtering\n\nEach effect needs:\n1. Rust implementation in crates/audio-engine (DSP processing in the mixer pipeline)\n2. UI controls in the audio properties panel (apps/ui/src/components/editor/audio-properties.tsx)\n3. Keyframeable parameters via the existing KeyframeInput system\n4. Timeline state serialization (AudioClipState in packages/render-engine/src/audio-engine.ts)\n5. Support in both playback (AudioWorklet) and export (offline WASM rendering) paths","status":"closed","priority":3,"issue_type":"task","created_at":"2026-02-23T01:25:37.868493-08:00","updated_at":"2026-03-31T13:01:28.876263-07:00","closed_at":"2026-03-31T13:01:28.876263-07:00","close_reason":"Fully implemented: EQ with visual frequency response editor, compressor, noise gate, and reverb all have WASM DSP + UI controls + keyframe support","created_by":"mohebifar"} +{"id":"tooscut-38z","title":"Transition system: in, out, and cross transitions","description":"Add ability to apply transitions (fade, dissolve, wipe, slide, etc.) to clips. Transitions should be droppable from the left panel onto clip edges (in/out) or between adjacent clips (cross transitions). The left panel should show a visual preview of each transition type. Leverage the existing transition types already defined in the render engine.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T21:58:28.62805-08:00","updated_at":"2026-02-22T01:43:24.526507-08:00","closed_at":"2026-02-22T01:43:24.526507-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-3ck","title":"Add project settings (dimensions, resolution, orientation, frame rate)","description":"Add a project settings UI that allows users to configure: video dimensions (width/height), resolution presets (1080p, 4K, etc), orientation (landscape/portrait), and frame rate (24, 30, 60 fps, custom). Settings should be stored in the project record in DexieJS and applied to the compositor/export pipeline.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:16.677159-08:00","updated_at":"2026-02-22T11:48:45.255249-08:00","closed_at":"2026-02-22T11:48:45.255249-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-40n","title":"Timeline markers with in/out points","description":"## Summary\nAdd named timeline markers and in/out point system for marking regions of interest.\n\n## UX Design\n\n### Marker creation\n- M: Drop a marker at the current playhead position\n- Marker appears as a small colored triangle on the ruler area (above tracks, below timecode)\n- Default color: cyan. Can be changed per marker.\n- Double-click marker triangle to rename (inline text edit on ruler)\n- Right-click marker: context menu with Rename, Change Color, Delete\n\n### Marker navigation\n- Shift+M: Go to next marker\n- Alt+M (or Ctrl+Shift+M): Go to previous marker\n- Markers listed in a dropdown/popover accessible from the toolbar (optional, lower priority)\n\n### In/Out points\n- I: Set in-point at playhead\n- O: Set out-point at playhead\n- In/out displayed as a highlighted region on the ruler (semi-transparent overlay between in and out)\n- Alt+I / Alt+O: Clear in/out point\n- In/out points constrain export range when set (pass to export dialog)\n- In/out points constrain playback loop when loop mode is enabled\n\n### Visual design\n- Markers: small downward-pointing triangles (6px) on the ruler, same row as timecode\n- Marker label: tiny text above the triangle, visible on hover or always if space permits\n- In/out region: subtle colored overlay on the ruler between the two points\n- In point: [ bracket icon. Out point: ] bracket icon.\n\n### Store changes\n- Add markers: Array\u003c{ id, time, name, color }\u003e to project state\n- Add inPoint: number | null, outPoint: number | null to store\n- Actions: addMarker, removeMarker, updateMarker, setInPoint, setOutPoint, clearInOutPoints\n- Markers saved with project (persisted to IndexedDB)\n- In/out points are session-only (not persisted)\n\n### Integration with export\n- If in/out points are set, export dialog should show option to export only the marked region\n- Default: export full timeline. Checkbox: \"Export in/out range only\"","status":"open","priority":2,"issue_type":"feature","created_at":"2026-03-30T01:10:44.409844-07:00","updated_at":"2026-03-30T01:10:44.409844-07:00","created_by":"mohebifar"} +{"id":"tooscut-42i","title":"Gesture to move clips between tracks","description":"Add ability to use drag gestures to move a clip/text/shape from one track to another. Should work with linked clips (video+audio moving together to their respective track types).","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:56.874235-08:00","updated_at":"2026-02-05T00:14:51.901763-08:00","closed_at":"2026-02-05T00:14:51.901763-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-4cn","title":"Fix undo/redo requiring multiple keypresses","description":"Bug: between every undo/redo action, the user needs to press Cmd+Z multiple times for it to take effect. The first undo works with a single Cmd+Z, but subsequent undos require pressing Cmd+Z twice (or more). This suggests the temporal store (zundo) is recording duplicate or no-op history entries, or the undo handler is consuming keystrokes without performing the undo.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-02-22T02:12:22.105794-08:00","updated_at":"2026-02-22T11:02:30.122139-08:00","closed_at":"2026-02-22T11:02:30.122141-08:00","created_by":"mohebifar"} +{"id":"tooscut-4f5","title":"Preview doesn't update when paused and making changes","description":"When paused, moving a clip or making any change doesn't update the preview. The preview must re-render when timeline state changes while paused.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-02-03T15:31:54.584644-08:00","updated_at":"2026-02-03T15:36:39.074212-08:00","closed_at":"2026-02-03T15:36:39.074212-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-5du","title":"Linked clip doesn't move visually during drag gesture","description":"When moving a clip, the linked audio/video clip doesn't move with it during the move gesture. It only moves on mouse up. The UX doesn't feel good - linked clips should move together in real-time during the drag.","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-02-03T15:31:54.907253-08:00","updated_at":"2026-02-03T15:42:35.648624-08:00","closed_at":"2026-02-03T15:42:35.648624-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-61n","title":"Fix file import via menu bar (File \u003e Import Media)","description":"The File \u003e Import Media menu item opens the file picker dialog, but after selecting a file, nothing happens — the asset is not added to the assets panel. The file picker integration (importFilesWithPicker) is not completing the import flow when triggered from the menu bar.","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-02-22T15:37:50.751826-08:00","updated_at":"2026-02-22T15:40:20.276596-08:00","closed_at":"2026-02-22T15:40:20.276596-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-63m","title":"Color Grading: HSL Qualifier node","description":"Implement HSL Qualifier (secondary color correction) node. Allows isolating specific color ranges by hue/saturation/luminance with soft edges, then applying a correction (PrimaryCorrection) only within that mask. Includes: qualifier mask preview overlay, hue/sat/lum range picker UI with center/width/softness controls, invert toggle, qualifier mask visualization on the preview panel. Shader functions already written (HSL_QUALIFIER_FUNCTIONS). Types defined: HslQualifier.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-04-06T13:08:42.895501-07:00","updated_at":"2026-04-06T13:19:07.183977-07:00","closed_at":"2026-04-06T13:19:07.183977-07:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-63o","title":"Drag and drop files from OS file explorer directly to timeline","description":"Allow dragging files from Finder/file explorer directly onto the timeline to import and place them in one action. If the dropped file matches an already-imported asset (by name/size/type), reuse the existing asset rather than duplicating it in the assets panel. If it's a new file, import it as a new asset first, then add the clip to the timeline at the drop position.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-22T15:37:46.528465-08:00","updated_at":"2026-02-22T17:25:09.399064-08:00","closed_at":"2026-02-22T17:25:09.399064-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-731","title":"Timeline drag-to-select (rubber band selection)","description":"Add rubber-band / marquee drag selection to the timeline view. Currently multi-select only works by holding Shift/Cmd and clicking individual clips. Users should be able to click and drag on empty timeline space to draw a selection rectangle that selects all clips it intersects.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:13.781927-08:00","updated_at":"2026-02-22T11:34:18.138855-08:00","closed_at":"2026-02-22T11:34:18.138855-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-8rt","title":"Color Grading: Power Window node","description":"Implement Power Window (regional mask) node. Allows applying corrections to specific regions of the frame using shapes (circle/ellipse, rectangle, polygon, linear gradient). Includes: interactive shape overlay on preview panel with drag handles for position/size/rotation/softness, inner/outer softness controls, invert toggle. Each window carries its own PrimaryCorrection. Types defined: PowerWindow, PowerWindowShape.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-04-06T13:08:49.169099-07:00","updated_at":"2026-04-06T13:19:07.194997-07:00","closed_at":"2026-04-06T13:19:07.194997-07:00","close_reason":"Closed","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-8rt","depends_on_id":"tooscut-63m","type":"blocks","created_at":"2026-04-06T13:08:55.133021-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-a6i","title":"Add ability to add and remove tracks","description":"Implement UI and functionality to:\n- Add new video/audio track pairs to the timeline\n- Remove existing tracks from the timeline","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:56.221806-08:00","updated_at":"2026-02-03T22:40:45.317523-08:00","closed_at":"2026-02-03T22:40:45.317523-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-and","title":"Color Grading System","description":"## Summary\nImplement a professional-grade color grading system with feature parity to DaVinci Resolve and Final Cut Pro.\n\n## Core Features\n- **Primary Correction** — CDL-based (slope/offset/power) + exposure, temperature, tint, saturation\n- **Color Wheels** — Lift/Gamma/Gain with luminance controls\n- **Curves** — Master, RGB, plus advanced (Hue vs Sat, Lum vs Sat, etc.)\n- **3D LUT Support** — .cube file parsing with tetrahedral interpolation\n- **HSL Qualifier** — Secondary color correction keying\n- **Power Windows** — Shapes/masks for regional corrections\n- **Scopes** — Waveform, vectorscope, histogram, parade (GPU-computed)\n- **Node-Based Pipeline** — Ordered processing chain per clip\n- **Color Matching** — Auto-match between clips\n\n## Architecture\nAll rendering happens in Rust/WASM (per project rules):\n- Color space conversions (sRGB ↔ Linear ↔ ACEScg ↔ Log)\n- CDL/LGG operations in WGSL shaders\n- 3D LUT textures with tetrahedral interpolation\n- Compute shaders for scope generation\n\nTypeScript/React handles UI only:\n- Color wheel canvas components\n- Curves editor\n- Scope display (Canvas 2D rendering of WASM-computed data)\n\n## Key Files to Modify\n- `crates/types/src/` — Add `color_grading.rs`\n- `crates/compositor/src/` — Add shaders and pipeline integration\n- `packages/render-engine/src/types.ts` — TypeScript definitions\n- `apps/ui/src/components/editor/` — New color grading UI components\n- `apps/ui/src/state/video-editor-store.ts` — State management","status":"open","priority":2,"issue_type":"epic","created_at":"2026-04-04T14:02:16.802358-07:00","updated_at":"2026-04-04T14:02:16.802358-07:00","created_by":"mohebifar"} +{"id":"tooscut-and.1","title":"Color grading foundation types and color space shaders","description":"## Summary\nDefine core types and implement color space conversion shaders as the foundation for the color grading system.\n\n## Tasks\n\n### Rust Types (crates/types/src/color_grading.rs)\n- [ ] `ColorSpace` enum (sRGB, Linear, ACEScg, LogC, SLog3, CLog3, VLog)\n- [ ] `PrimaryCorrection` struct (CDL: slope, offset, power + saturation, exposure, temperature, tint)\n- [ ] `ColorWheelValue` struct (angle, distance)\n- [ ] `ColorWheels` struct (lift, gamma, gain with luminance controls)\n- [ ] `Curve1D` and `CurvePoint` structs\n- [ ] `ColorGradingState` struct (combines all grading data for a layer)\n- [ ] Add `color_grading: Option\u003cColorGradingState\u003e` to `MediaLayerData`\n\n### TypeScript Types (packages/render-engine/src/types.ts)\n- [ ] Mirror all Rust types in TypeScript\n- [ ] Use snake_case to match serde serialization\n\n### WGSL Shaders (crates/compositor/src/shaders/color_grading.wgsl)\n- [ ] `srgb_to_linear()` and `linear_to_srgb()`\n- [ ] `rgb_to_hsl()` and `hsl_to_rgb()`\n- [ ] LogC/SLog3/VLog transfer functions\n- [ ] ACEScg matrix conversions\n\n### Uniforms (crates/compositor/src/color_grading_uniforms.rs)\n- [ ] `ColorGradingUniforms` struct with proper alignment (repr(C), Pod, Zeroable)\n- [ ] All CDL parameters, wheel values, qualifier params, flags\n\n## Acceptance Criteria\n- Types compile in Rust and generate correct TypeScript bindings via tsify\n- Color space conversion shaders pass visual tests (known color values)\n- Uniforms struct has correct GPU alignment","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:02:29.647666-07:00","updated_at":"2026-04-04T14:41:09.868939-07:00","closed_at":"2026-04-04T14:41:09.868939-07:00","close_reason":"Closed","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.1","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:02:29.649247-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.10","title":"Color matching and grade management","description":"## Summary\nImplement advanced color grading workflow features: clip matching, grade presets, copy/paste, and stills.\n\n## Tasks\n\n### Color Matching\n- [ ] `ColorMatchNode` type with reference clip/frame\n- [ ] Histogram matching algorithm:\n - Compute histograms of source and target\n - Generate CDL values to match distributions\n- [ ] UI: Select reference frame, click \"Match\"\n- [ ] Manual refinement after auto-match\n\n### Copy/Paste Grades\n- [ ] Copy full grade (all nodes) from clip\n- [ ] Paste grade to one or multiple clips\n- [ ] Paste specific nodes only (cherry-pick)\n- [ ] Keyboard shortcuts: Cmd+Shift+C / Cmd+Shift+V\n\n### Grade Presets (Stills)\n- [ ] Save current grade as named preset\n- [ ] Preset browser with thumbnails\n- [ ] Apply preset to clip\n- [ ] Export/import presets (JSON file)\n- [ ] Built-in presets: Film looks, B\u0026W, Vintage, etc.\n\n### Gallery (Stills)\n- [ ] Capture still (frame + grade) for reference\n- [ ] Gallery panel showing captured stills\n- [ ] Click still to apply its grade\n- [ ] Compare current frame to still\n\n### Ripple Grade Changes\n- [ ] Option to apply grade changes to all clips with same source\n- [ ] Useful for multi-cam or repeated shots\n\n### State Management\n- [ ] Add `colorGradingPresets: Map\u003cstring, ClipColorGrading\u003e` to store\n- [ ] Add `capturedStills: Array\u003c{ frame, grade, thumbnail }\u003e` to store\n- [ ] Add actions: `savePreset`, `applyPreset`, `captureStill`, `copyGrade`, `pasteGrade`\n\n## Acceptance Criteria\n- Color match produces visually similar results between clips\n- Copy/paste works across clips in same or different projects\n- Presets persist and load correctly\n- Gallery stills show accurate previews\n- Can quickly match multiple clips to a reference","status":"open","priority":3,"issue_type":"feature","created_at":"2026-04-04T14:04:39.71225-07:00","updated_at":"2026-04-04T14:04:39.71225-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.10","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:39.714933-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.10","depends_on_id":"tooscut-and.9","type":"blocks","created_at":"2026-04-04T14:04:39.716245-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.2","title":"Primary color correction (CDL)","description":"## Summary\nImplement CDL (Color Decision List) based primary color correction with basic adjustment controls.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `apply_cdl()` function: `out = pow(in * slope + offset, power)`\n- [ ] `apply_saturation()` function using luminance mix\n- [ ] `apply_exposure()` function (EV stops, -4 to +4)\n- [ ] `apply_temperature_tint()` function (Kelvin offset + green-magenta)\n- [ ] Highlight/shadow recovery functions\n\n### Compositor Integration\n- [ ] Add color grading render pass after media compositing\n- [ ] Create bind group for color grading uniforms\n- [ ] Wire uniforms from `MediaLayerData.color_grading` to shader\n\n### UI Components (apps/ui/src/components/editor/color-grading/)\n- [ ] `PrimaryCorrectionPanel` component with:\n - Exposure slider (-4 to +4 EV)\n - Temperature slider (2000K to 10000K)\n - Tint slider (green to magenta)\n - Contrast slider\n - Highlights/Shadows sliders\n - Saturation slider\n- [ ] CDL sliders (Slope R/G/B, Offset R/G/B, Power R/G/B) in collapsible \"Advanced\" section\n\n### State Management\n- [ ] Add `updateClipPrimaryCorrection(clipId, correction)` action\n- [ ] Integrate with undo/redo (temporal middleware)\n\n### Keyframe Support\n- [ ] Add primary correction properties to `COLOR_GRADING_ANIMATABLE_PROPERTIES`\n- [ ] Test keyframe interpolation for exposure, temperature, etc.\n\n## Acceptance Criteria\n- Adjusting exposure visibly changes clip brightness in preview\n- CDL values match ASC-CDL standard behavior\n- Changes are undoable\n- Keyframes animate smoothly","notes":"Completed: UI components (PrimaryCorrectionProperties, ColorGradingPanel), state management (updateClipColorGrading action), TypeScript types with keyframe support. Remaining: WGSL shader integration, bind group creation, visual testing.","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:02:42.611481-07:00","updated_at":"2026-04-04T14:50:13.74347-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.2","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:02:42.613067-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.2","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:02:42.615138-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.3","title":"Color wheels (Lift/Gamma/Gain)","description":"## Summary\nImplement Lift/Gamma/Gain color wheels for intuitive shadow/midtone/highlight color adjustment.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `color_wheel_to_rgb()` - convert angle/distance to RGB offset\n- [ ] `apply_lift_gamma_gain()` function:\n - Lift affects shadows (adds color when pixel is dark)\n - Gamma affects midtones (power function)\n - Gain affects highlights (multiplies)\n- [ ] Each wheel has RGB color shift + luminance master\n\n### UI Components\n- [ ] `ColorWheel` component (Canvas-based):\n - Circular color picker with saturation gradient\n - Draggable position indicator\n - Double-click to reset to center\n - Luminance slider below wheel\n- [ ] `ColorWheelsPanel` with three wheels (Lift, Gamma, Gain)\n- [ ] \"Link wheels\" toggle for global adjustments\n- [ ] Reset button per wheel and global reset\n\n### Interaction Design\n- [ ] Mouse drag updates angle + distance from center\n- [ ] Shift+drag constrains to current angle (saturation only)\n- [ ] Ctrl+drag for fine adjustment (0.1x speed)\n- [ ] Keyboard: arrow keys for small adjustments when focused\n\n### State Management\n- [ ] Add `updateClipColorWheels(clipId, wheels)` action\n- [ ] Wheel values stored as `{ angle: number, distance: number }`\n\n## Acceptance Criteria\n- Dragging lift wheel adds color to dark areas only\n- Dragging gain wheel adds color to bright areas only\n- Gamma affects midtones without crushing blacks/whites\n- Luminance sliders work independently from color shift\n- All changes are undoable","notes":"Completed: Canvas-based ColorWheel component with drag/shift+drag/ctrl+drag interactions, ColorWheelsProperties panel (Lift/Gamma/Gain), and React Flow node graph visualization. The node graph shows the color grading pipeline with color-coded nodes, enable/disable toggles, and drag-to-reorder support.","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:02:55.013616-07:00","updated_at":"2026-04-04T16:58:32.135196-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.3","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:02:55.015382-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.3","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:02:55.018517-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.4","title":"Curves editor","description":"## Summary\nImplement RGB curves for precise tonal control, plus advanced curves (Hue vs Sat, Lum vs Sat, etc.).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `evaluate_curve()` function using 1D LUT texture sampling\n- [ ] Support for smooth spline interpolation between control points\n- [ ] Apply curves in correct order: Master → R → G → B → Advanced\n\n### Curve LUT Generation (TypeScript)\n- [ ] `generateCurveLUT(points: CurvePoint[])` - outputs 256-entry Float32Array\n- [ ] Catmull-Rom or cubic Bezier interpolation between points\n- [ ] Handle edge cases (points at 0,0 and 1,1)\n\n### UI Components\n- [ ] `CurvesEditor` component (Canvas-based):\n - 256x256 grid with diagonal reference line\n - Click to add control point\n - Drag to move control point\n - Double-click or Delete key to remove point\n - Smooth curve rendering through points\n- [ ] Channel selector tabs: Master, Red, Green, Blue\n- [ ] Advanced curves dropdown: Hue vs Sat, Hue vs Lum, Sat vs Sat, Lum vs Sat\n- [ ] Preset curves: S-curve, fade, negative, etc.\n- [ ] Reset button\n\n### Advanced Curves\n- [ ] Hue vs Hue - rotate hues selectively\n- [ ] Hue vs Sat - saturate/desaturate specific hues\n- [ ] Hue vs Lum - brighten/darken specific hues\n- [ ] Lum vs Sat - saturate shadows/highlights differently\n- [ ] Sat vs Sat - compress or expand saturation range\n\n### State Management\n- [ ] Add `updateClipCurves(clipId, curves)` action\n- [ ] Curve presets stored separately for quick application\n\n### Performance\n- [ ] Regenerate LUT texture only when curve points change\n- [ ] Debounce during drag operations\n- [ ] Cache curve textures per clip\n\n## Acceptance Criteria\n- Dragging curve up brightens, down darkens\n- RGB curves affect only their respective channels\n- S-curve increases contrast visually\n- Smooth interpolation (no stair-stepping)\n- 60fps interaction during curve editing","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:09.958519-07:00","updated_at":"2026-04-04T14:03:09.958519-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.4","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:09.960905-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.4","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:09.964293-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.5","title":"3D LUT support (.cube files)","description":"## Summary\nImplement 3D LUT loading, GPU upload, and tetrahedral interpolation for professional color transforms.\n\n## Tasks\n\n### .cube File Parser (TypeScript)\n- [ ] `parseCubeFile(content: string): CubeLUT`\n- [ ] Parse header: TITLE, LUT_3D_SIZE, DOMAIN_MIN, DOMAIN_MAX\n- [ ] Parse RGB triplet data lines\n- [ ] Validate cube size (common: 17, 33, 65)\n- [ ] Handle comments and empty lines\n\n### LUT Manager (Rust/WASM)\n- [ ] `LutManager` struct with HashMap of loaded LUTs\n- [ ] `upload_lut(id, size, data)` - creates 3D texture on GPU\n- [ ] `remove_lut(id)` - frees GPU memory\n- [ ] `get_lut_texture(id)` - returns texture view for binding\n\n### WGSL Shader Implementation\n- [ ] `apply_lut_trilinear()` - basic trilinear interpolation\n- [ ] `apply_lut_tetrahedral()` - higher quality tetrahedral interpolation\n- [ ] Handle domain min/max scaling\n- [ ] LUT sampler with clamp-to-edge addressing\n\n### UI Components\n- [ ] `LutBrowserPanel` component:\n - Grid view of available LUTs with thumbnails\n - Search/filter by name\n - Preview on hover\n - Click to apply\n- [ ] LUT import button (file picker for .cube)\n- [ ] LUT intensity slider (mix with original)\n- [ ] Remove LUT button\n\n### LUT Thumbnail Generation\n- [ ] Generate preview by applying LUT to standard gradient image\n- [ ] Cache thumbnails in IndexedDB\n\n### State Management\n- [ ] Add `loadedLuts: Map\u003cstring, LoadedLut\u003e` to store\n- [ ] Add `loadLut(file)`, `removeLut(id)`, `setClipLut(clipId, lutId)` actions\n- [ ] LUT data persisted in project (or referenced by path)\n\n### Memory Management\n- [ ] Limit simultaneous loaded LUTs (e.g., 10 max)\n- [ ] LRU eviction for unused LUTs\n- [ ] 33³ × 16 bytes = ~575KB per LUT\n\n## Acceptance Criteria\n- .cube files from DaVinci/Resolve load correctly\n- LUT applied matches reference implementation\n- Tetrahedral interpolation has no visible banding\n- LUT browser shows accurate previews\n- Memory usage stays bounded with many LUTs","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:25.934398-07:00","updated_at":"2026-04-04T14:41:14.358352-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.5","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:25.936583-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.5","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:03:25.938607-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.6","title":"HSL Qualifier (secondary color correction)","description":"## Summary\nImplement HSL qualifier for isolating and correcting specific colors (secondary color correction / keying).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `hsl_qualifier_mask()` function:\n - Hue center + width + softness (circular distance)\n - Saturation center + width + softness\n - Luminance center + width + softness\n - Combine masks multiplicatively\n - Invert option\n- [ ] Apply correction multiplied by qualifier mask\n- [ ] Edge softness using smoothstep\n\n### UI Components\n- [ ] `HslQualifierPanel` component:\n - Hue range selector (circular/strip visualization)\n - Saturation range selector (horizontal bar)\n - Luminance range selector (horizontal bar)\n - Softness controls for each dimension\n - Invert toggle\n- [ ] Eyedropper tool to pick qualifier center from preview\n- [ ] \"Show Mask\" toggle to visualize selection (B\u0026W mask view)\n- [ ] Correction controls (reuse PrimaryCorrectionPanel) applied to qualified region\n\n### Eyedropper Interaction\n- [ ] Click on preview to sample pixel HSL values\n- [ ] Shift+click to add to selection (expand range)\n- [ ] Alt+click to subtract from selection\n- [ ] Sample multiple pixels for average\n\n### Mask Visualization\n- [ ] Toggle between: Normal view, Mask overlay, Mask only\n- [ ] Mask overlay shows selection as highlight color\n- [ ] Mask only shows B\u0026W (white = selected)\n\n### State Management\n- [ ] Add `HslQualifierNode` to color grading node types\n- [ ] Qualifier stores center, width, softness for H/S/L\n- [ ] Correction stored within qualifier node\n\n## Acceptance Criteria\n- Can isolate skin tones and adjust without affecting background\n- Can select sky blue and increase saturation\n- Soft edges blend naturally (no harsh cutoffs)\n- Eyedropper accurately picks colors from preview\n- Mask visualization helps dial in selection","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:39.983433-07:00","updated_at":"2026-04-04T14:03:39.983433-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:39.985365-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:39.987334-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.7","title":"Power windows (masks for regional correction)","description":"## Summary\nImplement power windows (geometric masks) for regional color corrections - vignettes, sky gradients, face isolation, etc.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] Shape mask generators:\n - Circle/Ellipse\n - Rectangle with corner radius\n - Linear gradient\n - Polygon (for complex shapes)\n- [ ] Transform: position, scale, rotation\n- [ ] Inner/outer softness (feathering)\n- [ ] Invert option\n- [ ] Combine with qualifier mask (AND operation)\n\n### UI Components\n- [ ] `PowerWindowPanel` component:\n - Shape selector (circle, rectangle, gradient, polygon)\n - Position X/Y sliders (% of frame)\n - Scale X/Y sliders\n - Rotation slider\n - Softness inner/outer sliders\n - Invert toggle\n- [ ] On-canvas shape overlay:\n - Draggable shape outline on preview\n - Corner handles for resize\n - Rotation handle\n - Center point for position\n\n### Shape Drawing\n- [ ] Circle: center + radius, draggable edge\n- [ ] Rectangle: corner handles, aspect ratio lock option\n- [ ] Gradient: start point + end point + angle\n- [ ] Polygon: click to add points, close path\n\n### Tracking (stretch goal)\n- [ ] Manual keyframe tracking (position/scale/rotation keyframes)\n- [ ] Data structure for tracked window transforms\n- [ ] Interpolation between tracking keyframes\n\n### Combination with Qualifier\n- [ ] Power window can be combined with HSL qualifier\n- [ ] \"Window AND Qualifier\" mode - both must match\n- [ ] Useful for: face in specific area, sky in upper region only\n\n## Acceptance Criteria\n- Can create vignette effect with soft circular window\n- Can isolate upper sky with gradient window\n- Can draw polygon around subject\n- On-canvas controls are intuitive (drag to move, handles to resize)\n- Soft edges blend naturally","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:54.558574-07:00","updated_at":"2026-04-04T14:03:54.558574-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:03:54.563678-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:54.560807-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.8","title":"Video scopes (waveform, vectorscope, histogram)","description":"## Summary\nImplement professional video scopes computed on GPU for real-time color analysis.\n\n## Tasks\n\n### Scope Types\n- [ ] **Histogram** - RGB + Luma distribution (256 bins each)\n- [ ] **Waveform** - Luma or RGB waveform (brightness by horizontal position)\n- [ ] **RGB Parade** - Separate R/G/B waveforms side by side\n- [ ] **Vectorscope** - Color distribution on color wheel (hue angle + saturation radius)\n\n### WGSL Compute Shaders\n- [ ] `compute_histogram.wgsl`:\n - Atomic histogram binning\n - Output: 4 × 256 buffer (R, G, B, Luma)\n- [ ] `compute_waveform.wgsl`:\n - For each column, accumulate pixel brightness into rows\n - Output: 2D texture (width × 256)\n- [ ] `compute_vectorscope.wgsl`:\n - Map each pixel to 2D position by hue/sat\n - Accumulate density\n - Output: 256×256 texture\n\n### Rust/WASM Implementation\n- [ ] `ScopeComputer` struct managing compute pipelines\n- [ ] `compute_histogram(texture_id) -\u003e Vec\u003cf32\u003e`\n- [ ] `compute_waveform(texture_id, width) -\u003e Vec\u003cu8\u003e`\n- [ ] `compute_vectorscope(texture_id, size) -\u003e Vec\u003cu8\u003e`\n\n### UI Components\n- [ ] `ScopesPanel` component:\n - Scope type selector tabs\n - Canvas for scope display\n - Graticule overlays (scale markers)\n- [ ] Histogram: stacked or separate R/G/B view option\n- [ ] Waveform: Luma / RGB / Parade mode selector\n- [ ] Vectorscope: skin tone line, color target boxes\n\n### Graticules and Reference Lines\n- [ ] Histogram: 0%, 50%, 100% markers\n- [ ] Waveform: IRE/percentage scale, legal range indicators (16-235)\n- [ ] Vectorscope: color boxes (R, Mg, B, Cy, G, Yl), skin tone line\n\n### Performance\n- [ ] Compute scopes on requestAnimationFrame, not every frame\n- [ ] Throttle during playback (every 2-3 frames)\n- [ ] Full rate when paused\n- [ ] Resolution option: 1x, 1/2, 1/4 for faster computation\n\n## Acceptance Criteria\n- Histogram matches reference scope software\n- Waveform shows correct brightness distribution\n- Vectorscope shows correct color positions\n- Scopes update in real-time during playback (\u003c16ms compute time)\n- Graticules help interpret scope readings","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:11.037853-07:00","updated_at":"2026-04-04T14:41:14.406509-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:04:11.04237-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:11.040172-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.9","title":"Node-based color grading pipeline","description":"## Summary\nImplement node-based color grading pipeline allowing multiple stacked corrections executed in order.\n\n## Tasks\n\n### Data Structures\n- [ ] `ColorGradingNode` union type (primary, wheels, curves, LUT, qualifier, window)\n- [ ] `ClipColorGrading` with ordered `nodes: ColorGradingNode[]` array\n- [ ] Each node has: id, type, enabled, mix, label\n\n### Node Execution Engine (Rust/WASM)\n- [ ] `execute_color_pipeline(texture, nodes) -\u003e texture`\n- [ ] Execute nodes in order, each reading previous output\n- [ ] Skip disabled nodes\n- [ ] Apply per-node mix (blend with input)\n- [ ] Intermediate render targets for multi-pass\n\n### UI Components\n- [ ] `NodeListPanel` component:\n - Vertical list of nodes (not visual graph, like Resolve's mini timeline)\n - Drag to reorder\n - Enable/disable toggle per node\n - Mix slider per node\n - Expand/collapse node settings\n - Add node button (dropdown of types)\n - Delete node button\n- [ ] Node type icons for quick identification\n- [ ] \"Solo\" button to preview single node effect\n\n### Node Operations\n- [ ] Add node (at end or after selected)\n- [ ] Remove node\n- [ ] Reorder nodes (drag \u0026 drop)\n- [ ] Duplicate node\n- [ ] Enable/disable node\n- [ ] Reset node to defaults\n- [ ] Copy/paste node between clips\n\n### Bypass and Preview\n- [ ] Global bypass toggle (show original)\n- [ ] \"Solo node\" mode - only selected node active\n- [ ] A/B comparison split view (future enhancement)\n\n### State Management\n- [ ] Add `addColorGradingNode(clipId, node, index?)` action\n- [ ] Add `removeColorGradingNode(clipId, nodeId)` action\n- [ ] Add `reorderColorGradingNodes(clipId, fromIndex, toIndex)` action\n- [ ] Add `updateColorGradingNode(clipId, nodeId, updates)` action\n\n## Acceptance Criteria\n- Can stack multiple corrections (e.g., Primary → Curves → LUT)\n- Node order affects output (e.g., LUT before vs after primary)\n- Disabling node removes its effect\n- Mix slider blends node effect with input\n- Drag reordering updates preview immediately","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:26.186835-07:00","updated_at":"2026-04-04T14:04:26.186835-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.3","type":"blocks","created_at":"2026-04-04T14:04:26.188467-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.4","type":"blocks","created_at":"2026-04-04T14:04:26.189223-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.5","type":"blocks","created_at":"2026-04-04T14:04:26.189985-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:04:26.190624-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.7","type":"blocks","created_at":"2026-04-04T14:04:26.191412-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:26.187706-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-bxi","title":"Show video clip thumbnails in timeline","description":"Display thumbnail previews for video clips in the timeline:\n- Performant rendering like subformer\n- Handle zoom in/out properly (show more/fewer thumbnails)\n- Reference: /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:57.360668-08:00","updated_at":"2026-02-04T23:22:18.364728-08:00","closed_at":"2026-02-04T23:22:18.364728-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-d1w","title":"Create numeric input component with drag-to-adjust","description":"Create a numeric input component that:\n- Can increase/decrease value by click and drag left/right\n- Allows direct editing when clicked\n- Accepts a suffix prop for units (e.g., '%', 'px', '°')\n- Will be used in the properties panel for transform/effect controls","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:55.226838-08:00","updated_at":"2026-02-03T15:44:26.731079-08:00","closed_at":"2026-02-03T15:44:26.731079-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-dz0","title":"Generate and display project thumbnails","description":"Projects should have a thumbnail that is shown on the project list page. Generate a thumbnail from the project content (e.g., render a frame from the timeline at a representative time) and store it as thumbnailDataUrl in the DexieJS projects table. Display these thumbnails in the project cards on the home/projects page.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:19.167347-08:00","updated_at":"2026-02-22T17:08:56.282213-08:00","closed_at":"2026-02-22T17:08:56.282213-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-fbw","title":"Proper handling of clip trimming","description":"Implement proper clip trimming functionality:\n- Trim from left edge (adjusts start time and in-point)\n- Trim from right edge (adjusts duration)\n- Visual feedback during trim operation\n- Respect asset duration limits","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:57.195771-08:00","updated_at":"2026-02-05T00:14:26.507498-08:00","closed_at":"2026-02-05T00:14:26.507498-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-i1k","title":"Snap clips in timeline (move and trim)","description":"When moving a clip close to another clip's edge, snap them together automatically. When trimming a clip, snap to edges of clips on other tracks. Show a visual snap line indicator when snapping occurs. Should work for both move and trim operations across all tracks.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T21:58:21.397626-08:00","updated_at":"2026-02-07T00:32:00.23669-08:00","closed_at":"2026-02-07T00:32:00.23669-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-ixy","title":"J/K/L playback shortcuts and expanded keyboard navigation","description":"## Summary\nAdd industry-standard NLE keyboard shortcuts for playback control and navigation.\n\n## UX Design\n\n### J/K/L Playback (highest priority)\n- L: Play forward. Press again to increase speed (1x -\u003e 2x -\u003e 4x -\u003e 8x)\n- K: Pause. Hold K + tap L = step forward one frame. Hold K + tap J = step backward one frame.\n- J: Play reverse. Press again to increase reverse speed (1x -\u003e 2x -\u003e 4x -\u003e 8x)\n- Speed resets when switching direction or pressing K\n- Show current playback speed in the playback controls bar when not 1x (e.g. \"2x\" or \"-4x\")\n\n### Frame-accurate navigation\n- , (comma): Previous frame (already in menu, verify wired)\n- . (period): Next frame (already in menu, verify wired)\n- Shift+, : Jump back 1 second (10 frames at 30fps, or fps-based)\n- Shift+. : Jump forward 1 second\n- Home: Jump to start of timeline (verify working)\n- End: Jump to end of last clip (verify working)\n\n### Clip navigation\n- Up arrow: Select previous clip on same track (or move selection up a track)\n- Down arrow: Select next clip on same track (or move selection down a track)\n- Shift+Left/Right: Nudge selected clip by 1 frame\n- Alt+Left/Right: Nudge selected clip by 10 frames\n\n### Implementation notes\n- All shortcuts registered in the existing global keydown handler in canvas-timeline.tsx\n- Skip when focus is on INPUT/TEXTAREA (existing pattern)\n- Store needs: playbackSpeed state, setPlaybackSpeed action\n- Audio engine needs speed parameter adjustment during J/K/L playback\n- Playback controls bar should show speed indicator when not 1x","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-03-30T01:03:57.129655-07:00","updated_at":"2026-03-30T18:31:20.948554-07:00","closed_at":"2026-03-30T18:31:20.948554-07:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-jt2","title":"Implement audio renderer WASM module","description":"Create an audio renderer WASM module for audio processing and playback. Use subformer's implementation at /Users/mohebifar/dev/other/kareem/subformer for inspiration on how audio rendering is handled.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:55.552314-08:00","updated_at":"2026-02-03T16:17:41.966779-08:00","closed_at":"2026-02-03T16:17:41.966779-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-k4m","title":"Ripple editing mode","description":"## Summary\nAdd a ripple editing mode that shifts downstream clips when deleting, trimming, or moving clips.\n\n## UX Design\n\n### Toggle\n- Ripple toggle button in TimelineToolbar (next to tool selector)\n- Keyboard shortcut: R to toggle (consistent with V=Select, C=Razor pattern)\n- Visual indicator: button stays highlighted when active\n\n### Behavior when ripple is ON\n\n**Ripple Delete:**\n- Deleting a clip shifts all clips to the right on the same track left to fill the gap\n- Linked clips (audio+video pairs) ripple together\n- Other tracks are NOT affected\n\n**Ripple Trim:**\n- Trimming right edge: downstream clips shift to match\n- Trimming left edge: clip and all downstream clips shift together\n- Visual preview: ghost outlines show where downstream clips will land during drag\n\n### Behavior when ripple is OFF\n- Current behavior (no change)\n\n### Store Changes\n- Add rippleMode boolean to store state with toggleRippleMode() action\n- Add rippleDelete(clipId) that removes clip and shifts downstream\n- Modify trimLeft/trimRight to accept a ripple flag\n- Add utility: getDownstreamClips(trackId, afterTime)\n\n### Visual Feedback\n- Subtle indicator on timeline when ripple mode is active\n- During ripple drag/trim, show ghost positions of affected downstream clips","status":"open","priority":1,"issue_type":"feature","created_at":"2026-03-30T01:03:25.119552-07:00","updated_at":"2026-03-30T01:03:25.119552-07:00","created_by":"mohebifar"} +{"id":"tooscut-li4","title":"Audio clip fade handles on timeline","description":"## Summary\nAdd draggable fade-in/fade-out handles on audio clip edges in the timeline for quick volume fades.\n\n## UX Design\n\n### Visual design\n- Small triangular handles at the top corners of audio clips on the timeline\n- Fade-in handle: top-left corner. Fade-out handle: top-right corner\n- Handles 8x8px, visible on hover over audio clips\n- Fade region draws a curved line from 0 to 100% volume\n- Fade curve rendered as a semi-transparent overlay on the clip, above the waveform\n\n### Interaction\n- Drag fade-in handle rightward to increase fade-in duration\n- Drag fade-out handle leftward to increase fade-out duration\n- Minimum fade: 1 frame. Maximum fade: half the clip duration\n- Snapping: fade handles snap to grid lines (same as clip snap)\n- Double-click a fade handle to reset it to 0 (remove fade)\n\n### Data model\n- Uses dedicated fadeIn/fadeOut duration fields on the clip (NOT keyframes)\n- Fade durations are relative to clip edges so they survive trimming\n- The WASM audio mixer already supports per-clip fade_in and fade_out durations\n- Currently these are always 0 because there is no UI to set them\n\n### Store changes\n- Add fadeIn and fadeOut number fields to AudioClip type (duration in frames)\n- Add setClipFadeIn(clipId, frames) and setClipFadeOut(clipId, frames) actions\n- Pass fade values through useAudioEngine timeline sync with framesToSeconds conversion\n- These are undoable (temporal-tracked state)\n\n### Timeline rendering (Konva)\n- Detect hover near top corners of audio clips (8px hit zone)\n- Show ew-resize cursor on hover\n- During drag, render the fade curve preview in real-time\n- Fade region: filled area between the straight top edge and the curve","status":"open","priority":2,"issue_type":"feature","created_at":"2026-03-31T13:01:17.976325-07:00","updated_at":"2026-03-31T13:01:17.976325-07:00","created_by":"mohebifar"} +{"id":"tooscut-lso","title":"Implement video export with muxer and web worker parallelization","description":"Add ability to export videos using the same muxer method and web worker parallelization used in subformer. Reference: /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:55.879274-08:00","updated_at":"2026-02-06T02:12:38.306274-08:00","closed_at":"2026-02-06T02:12:38.306274-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-mgr","title":"Project persistence with IndexedDB and project chooser page","description":"Store projects in IndexedDB using DexieJS. Add a project list page (home/index route) where users can see saved projects, create new ones, and open existing ones. Projects should auto-save on changes (debounced). Store project name, settings, timeline content, and thumbnail.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T21:58:26.053163-08:00","updated_at":"2026-02-07T22:16:17.087971-08:00","closed_at":"2026-02-07T22:16:17.087971-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-n75","title":"Color Grading: LUT Support node","description":"Implement 3D LUT loading and application node. Includes: .cube file parser (standard 3D LUT format), LUT preview thumbnail, 3D texture upload to GPU, trilinear and tetrahedral interpolation in shader (shader functions already written in color_grading_shader.rs), LUT browser UI. Types already defined: LutReference, LutInterpolation.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-04-06T13:08:35.744189-07:00","updated_at":"2026-04-06T13:19:07.170923-07:00","closed_at":"2026-04-06T13:19:07.170923-07:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-nse","title":"Add shapes, texts, images, clips and audios to timeline","description":"Implement ability to add various layer types to the timeline:\n- Shape layers (rectangles, circles, polygons, lines)\n- Text layers\n- Image layers\n- Video clips\n- Audio clips","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:56.702715-08:00","updated_at":"2026-02-06T01:14:51.651987-08:00","closed_at":"2026-02-06T01:14:51.651987-08:00","close_reason":"Already implemented: Asset panel, text panel, and shape panel all support drag-and-drop to timeline. Canvas timeline has drop handlers for all clip types. Store has addClipToTrack action supporting video, audio, image, text, and shape clips.","created_by":"mohebifar"} +{"id":"tooscut-p7h","title":"Auto-place first clip at timeline start","description":"When adding the very first clip to an empty timeline, automatically place it at time 0 (the beginning). This only applies when the timeline has no existing clips. Subsequent clips should still be placed at the drop position or current time.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-02-06T21:58:30.572339-08:00","updated_at":"2026-02-06T22:20:58.705414-08:00","closed_at":"2026-02-06T22:20:58.705414-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-pvp","title":"Add transform mode and view mode toggle for preview panel","description":"Introduce two modes for the preview panel: View mode and Transform mode, with toggle buttons below the preview. Currently, clicking on clip boundaries in the preview or selecting any clip from the timeline shows transform handles (resize/move). This should only happen in Transform mode. In View mode, transform handles should be hidden and clips should not be movable/resizable in the preview. Add toggle buttons (e.g. icons) below the preview panel to switch between modes.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-22T15:37:43.765143-08:00","updated_at":"2026-02-22T19:41:28.491218-08:00","closed_at":"2026-02-22T19:41:28.491218-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-qpn","title":"Add mute/unmute and lock/unlock for tracks","description":"Implement track controls:\n- Mute/unmute: For video tracks, this hides the track's clips from rendering. For audio tracks, this mutes the audio.\n- Lock/unlock: Prevents editing of clips on locked tracks","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:56.544018-08:00","updated_at":"2026-02-04T02:09:25.954736-08:00","closed_at":"2026-02-04T02:09:25.954736-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-sg3","title":"Show audio waveform on audio clips","description":"Display audio waveform visualization on audio clips in the timeline:\n- Performant rendering like subformer\n- Handle zoom in/out properly\n- Reference: /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:57.521544-08:00","updated_at":"2026-02-05T00:12:30.453041-08:00","closed_at":"2026-02-05T00:12:30.453041-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-shs","title":"Multi-select clips: batch move and trim","description":"Allow selecting multiple clips in the timeline (click + shift/ctrl, or drag-select). Moving one selected clip moves all selected clips. Trimming/extending one selected clip applies the same delta to all selected clips.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T21:58:23.231693-08:00","updated_at":"2026-02-22T19:42:40.147061-08:00","closed_at":"2026-02-22T19:42:40.147061-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-sp0","title":"Fix overlap handling to trim instead of delete","description":"Fix the behavior of overlap handling. When a clip overlaps another clip:\n- Instead of deleting the overlapped clip, it should trim it to avoid the overlap\n- Only delete if the clip is fully overlapped (100% covered)\n- Partial overlaps should result in trimming the overlapped portion","status":"closed","priority":2,"issue_type":"bug","created_at":"2026-02-03T15:31:57.038584-08:00","updated_at":"2026-02-04T01:52:40.207676-08:00","closed_at":"2026-02-04T01:52:40.207676-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-t6f","title":"Drag and drop assets from assets panel to preview","description":"Allow dragging assets from the assets panel directly into the preview panel. The asset should be added at the current playhead timestamp on the highest (frontmost) video track. If the highest track is already occupied at the playhead position and/or adding the asset would overlap existing clips, automatically create a new track pair and place the asset there instead.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-22T15:37:39.905379-08:00","updated_at":"2026-02-22T17:20:28.702887-08:00","closed_at":"2026-02-22T17:20:28.702887-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-vev","title":"Implement keyframe animation system","description":"Add keyframe animation support for clip properties (position, scale, rotation, opacity, etc.). This includes:\n\n- Keyframe data model in clip state (KeyframeTracks with property -\u003e keyframe[] mapping)\n- Keyframe interpolation using the existing EvaluatorManager from render-engine\n- UI for adding/removing keyframes at current playhead position\n- Visual keyframe indicators on timeline clips\n- Property panel integration to show animated values\n\nReference: subformer implementation at /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-06T02:14:25.299746-08:00","updated_at":"2026-02-06T21:58:14.325586-08:00","closed_at":"2026-02-06T21:58:14.325586-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-wio","title":"Playhead is not movable during playback","description":"While playing, the user cannot move the playhead to scrub/seek. It just keeps playing and ignores playhead drag interaction. Expected: dragging the playhead during playback should pause and seek (or seek while playing).","status":"closed","priority":1,"issue_type":"bug","created_at":"2026-02-06T21:58:19.344465-08:00","updated_at":"2026-02-06T22:18:49.143947-08:00","closed_at":"2026-02-06T22:18:49.143947-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-wlg","title":"Drag ghost should reflect actual clip duration","description":"When dragging a clip from the assets panel onto the timeline, the preview ghost/shadow should be representative of the actual duration of the video/audio asset, not a fixed size. The ghost width should match what the clip will look like at the current zoom level.","status":"closed","priority":3,"issue_type":"feature","created_at":"2026-02-06T21:58:32.635988-08:00","updated_at":"2026-02-06T22:23:14.006719-08:00","closed_at":"2026-02-06T22:23:14.006719-08:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-yq4","title":"Cut (Cmd+X) and Duplicate clip operations","description":"## Summary\nImplement cut (copy + delete) and duplicate operations. Cut menu item exists but is disabled.\n\n## UX Design\n\n### Cut (Cmd+X)\n- Copy selected clips to clipboard, then delete them\n- If ripple mode is on (see tooscut-k4m), ripple-delete after copy\n- If ripple mode is off, just delete (leave gap)\n- Linked clips: cut both video and audio together\n- Menu item in Edit menu already exists — enable it and wire handler\n\n### Duplicate (Cmd+D)\n- Duplicate selected clips and place them immediately after the originals\n- New clips start at the end of the rightmost selected clip\n- Linked clips: duplicate both video and audio\n- Select the new duplicates (deselect originals)\n- Useful for repeating patterns, creating variations\n\n### Implementation\n- Store actions needed: cutSelectedClips() — calls copySelectedClips() then removeClip() for each\n- Store actions needed: duplicateSelectedClips() — deep-clone clips, assign new IDs, offset startTime\n- Keyboard handler: add Cmd+X and Cmd+D to global keydown in canvas-timeline.tsx\n- Menu: enable existing Cut item, add Duplicate item to Edit menu\n- Duplicate should generate new clip IDs and new linkedClipIds for pairs","status":"closed","priority":1,"issue_type":"feature","created_at":"2026-03-30T01:11:31.108201-07:00","updated_at":"2026-04-01T22:15:34.121127-07:00","closed_at":"2026-04-01T22:15:34.121127-07:00","close_reason":"Closed","created_by":"mohebifar"} +{"id":"tooscut-yqc","title":"Make entire assets panel a dropzone for file imports","description":"Make the entire assets panel act as a dropzone so users can drag and drop files from Finder/file explorer anywhere on the assets panel to import them. Currently only specific areas may accept drops.","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-22T15:37:48.50237-08:00","updated_at":"2026-02-22T16:50:04.215085-08:00","closed_at":"2026-02-22T16:50:04.215085-08:00","close_reason":"Closed","created_by":"mohebifar"} diff --git a/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx b/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx index 7604bbe..425a6b6 100644 --- a/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx +++ b/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx @@ -10,12 +10,8 @@ import type { ColorGrading, - ColorSpace, PrimaryCorrection, ColorWheels, - Curves, - HslQualifier, - LutReference, ColorGradingNode as CGNode, } from "@tooscut/render-engine"; @@ -23,9 +19,6 @@ import { DEFAULT_PRIMARY_CORRECTION, DEFAULT_COLOR_GRADING, DEFAULT_COLOR_WHEELS, - DEFAULT_HSL_QUALIFIER, - DEFAULT_CURVES, - DEFAULT_LUT_REFERENCE, } from "@tooscut/render-engine"; import { Eye, @@ -38,20 +31,17 @@ import { Spline, Grid3X3, Crosshair, + Square, } from "lucide-react"; import { useState, useCallback, useMemo } from "react"; import { Button } from "../../ui/button"; -import { SearchableDropdown, type SearchableDropdownItem } from "../../ui/searchable-dropdown"; +import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; import { Separator } from "../../ui/separator"; import { Toggle } from "../../ui/toggle"; import { ColorWheelsProperties } from "./color-wheels-properties"; -import { CstProperties } from "./cst-properties"; -import { CurvesProperties } from "./curves-properties"; -import { LutProperties } from "./lut-properties"; import { ColorGradingNodeGraph } from "./node-graph"; import { PrimaryCorrectionProperties } from "./primary-correction"; -import { QualifierProperties } from "./qualifier-properties"; // ============================================================================ // Types @@ -96,28 +86,28 @@ const NODE_TYPE_CONFIGS: NodeTypeConfig[] = [ label: "Curves", icon: Spline, description: "RGB curves adjustment", - available: true, + available: false, }, { type: "Lut", label: "LUT", icon: Grid3X3, description: "3D lookup table", - available: true, + available: false, }, { type: "Qualifier", label: "HSL Qualifier", icon: Crosshair, description: "Secondary color keying", - available: true, + available: false, }, { - type: "ColorSpaceTransform", - label: "Color Space", - icon: Palette, - description: "Convert color space", - available: true, + type: "Window", + label: "Power Window", + icon: Square, + description: "Regional mask", + available: false, }, ]; @@ -143,22 +133,6 @@ export function ColorGradingPanel({ [grading.nodes, selectedNodeId], ); - // Update a CST node - const handleUpdateCstNode = useCallback( - (updates: { from_space?: ColorSpace; to_space?: ColorSpace }) => { - if (!selectedNode || selectedNode.type !== "ColorSpaceTransform") return; - - const newNodes = grading.nodes.map((node) => { - if (node.id === selectedNode.id && node.type === "ColorSpaceTransform") { - return { ...node, ...updates }; - } - return node; - }); - onColorGradingChange({ ...grading, nodes: newNodes }); - }, - [grading, onColorGradingChange, selectedNode], - ); - // Toggle bypass const handleBypassToggle = useCallback( (bypass: boolean) => { @@ -191,60 +165,15 @@ export function ColorGradingPanel({ wheels: { ...DEFAULT_COLOR_WHEELS }, }; break; - case "Curves": - newNode = { - type: "Curves", - id: `curves-${Date.now()}`, - enabled: true, - mix: 1, - curves: { ...DEFAULT_CURVES }, - }; - break; - case "Lut": - newNode = { - type: "Lut", - id: `lut-${Date.now()}`, - enabled: true, - mix: 1, - lut: { ...DEFAULT_LUT_REFERENCE }, - }; - break; - case "Qualifier": - newNode = { - type: "Qualifier", - id: `qualifier-${Date.now()}`, - enabled: true, - mix: 1, - qualifier: { ...DEFAULT_HSL_QUALIFIER }, - correction: { ...DEFAULT_PRIMARY_CORRECTION }, - }; - break; - case "ColorSpaceTransform": - newNode = { - type: "ColorSpaceTransform", - id: `cst-${Date.now()}`, - enabled: true, - mix: 1, - from_space: "SLog3", - to_space: "Srgb", - }; - break; default: return; } - // Insert after the selected node, or at the end if nothing selected - const newNodes = [...grading.nodes]; - const selectedIndex = selectedNodeId - ? newNodes.findIndex((n) => n.id === selectedNodeId) - : -1; - const insertAt = selectedIndex !== -1 ? selectedIndex + 1 : newNodes.length; - newNodes.splice(insertAt, 0, newNode); - + const newNodes = [...grading.nodes, newNode]; onColorGradingChange({ ...grading, nodes: newNodes }); setSelectedNodeId(newNode.id); }, - [grading, onColorGradingChange, selectedNodeId], + [grading, onColorGradingChange], ); // Toggle node enabled state @@ -271,31 +200,11 @@ export function ColorGradingPanel({ ); // Reorder nodes - // Reorder nodes based on connection order (array of node IDs) const handleReorderNodes = useCallback( - (nodeIds: string[]) => { - const nodeMap = new Map(grading.nodes.map((n) => [n.id, n])); - const reordered = nodeIds - .map((id) => nodeMap.get(id)) - .filter(Boolean) as typeof grading.nodes; - // Append any disconnected nodes at the end - const reorderedIds = new Set(nodeIds); - for (const node of grading.nodes) { - if (!reorderedIds.has(node.id)) { - reordered.push(node); - } - } - onColorGradingChange({ ...grading, nodes: reordered }); - }, - [grading, onColorGradingChange], - ); - - // Update node position (persisted to store) - const handleUpdateNodePosition = useCallback( - (nodeId: string, x: number, y: number) => { - const newNodes = grading.nodes.map((node) => - node.id === nodeId ? { ...node, position: { x, y } } : node, - ); + (fromIndex: number, toIndex: number) => { + const newNodes = [...grading.nodes]; + const [removed] = newNodes.splice(fromIndex, 1); + newNodes.splice(toIndex, 0, removed); onColorGradingChange({ ...grading, nodes: newNodes }); }, [grading, onColorGradingChange], @@ -345,88 +254,6 @@ export function ColorGradingPanel({ [grading, onColorGradingChange, selectedNode], ); - // Update a curves node - const handleUpdateCurvesNode = useCallback( - (updatedCurves: Curves) => { - if (!selectedNode || selectedNode.type !== "Curves") return; - - const newNodes = grading.nodes.map((node) => { - if (node.id === selectedNode.id && node.type === "Curves") { - return { ...node, curves: updatedCurves }; - } - return node; - }); - onColorGradingChange({ ...grading, nodes: newNodes }); - }, - [grading, onColorGradingChange, selectedNode], - ); - - // Update a LUT node - const handleUpdateLutNode = useCallback( - (updates: Partial) => { - if (!selectedNode || selectedNode.type !== "Lut") return; - - const newNodes = grading.nodes.map((node) => { - if (node.id === selectedNode.id && node.type === "Lut") { - return { - ...node, - lut: { - ...node.lut, - ...updates, - }, - }; - } - return node; - }); - onColorGradingChange({ ...grading, nodes: newNodes }); - }, - [grading, onColorGradingChange, selectedNode], - ); - - // Update a qualifier node's qualifier params - const handleUpdateQualifierNode = useCallback( - (key: keyof HslQualifier, value: number | boolean) => { - if (!selectedNode || selectedNode.type !== "Qualifier") return; - - const newNodes = grading.nodes.map((node) => { - if (node.id === selectedNode.id && node.type === "Qualifier") { - return { - ...node, - qualifier: { - ...node.qualifier, - [key]: value, - }, - }; - } - return node; - }); - onColorGradingChange({ ...grading, nodes: newNodes }); - }, - [grading, onColorGradingChange, selectedNode], - ); - - // Update a qualifier node's correction params - const handleUpdateQualifierCorrection = useCallback( - (key: keyof PrimaryCorrection, value: number | [number, number, number]) => { - if (!selectedNode || selectedNode.type !== "Qualifier") return; - - const newNodes = grading.nodes.map((node) => { - if (node.id === selectedNode.id && node.type === "Qualifier") { - return { - ...node, - correction: { - ...node.correction, - [key]: value, - }, - }; - } - return node; - }); - onColorGradingChange({ ...grading, nodes: newNodes }); - }, - [grading, onColorGradingChange, selectedNode], - ); - // Check if we have any active corrections const hasActiveCorrections = useMemo(() => grading.nodes.some((n) => n.enabled), [grading.nodes]); @@ -452,23 +279,18 @@ export function ColorGradingPanel({ -
- {/* Node Graph */} - - -
- {/* Add Node */} - -
-
+ {/* Node Graph */} + + + {/* Add Node */} + {/* Selected Node Parameters */} {selectedNode && ( @@ -480,11 +302,6 @@ export function ColorGradingPanel({ node={selectedNode} onUpdatePrimary={handleUpdatePrimaryNode} onUpdateColorWheels={handleUpdateColorWheelsNode} - onUpdateCurves={handleUpdateCurvesNode} - onUpdateCst={handleUpdateCstNode} - onUpdateLut={handleUpdateLutNode} - onUpdateQualifier={handleUpdateQualifierNode} - onUpdateQualifierCorrection={handleUpdateQualifierCorrection} /> )} @@ -507,30 +324,48 @@ interface AddNodeMenuProps { onAddNode: (type: CGNode["type"]) => void; } -const addNodeItems: SearchableDropdownItem[] = NODE_TYPE_CONFIGS.map((config) => ({ - key: config.type, - label: config.label, - description: config.description, - disabled: !config.available, - icon: config.icon, - trailing: !config.available ? ( - Soon - ) : undefined, -})); - function AddNodeMenu({ onAddNode }: AddNodeMenuProps) { + const [open, setOpen] = useState(false); + + const handleAdd = (type: CGNode["type"]) => { + onAddNode(type); + setOpen(false); + }; + return ( - onAddNode(key as CGNode["type"])} - placeholder="Search nodes..." - align="start" - > - - + + + + + +
+ {NODE_TYPE_CONFIGS.map((config) => { + const Icon = config.icon; + return ( + + ); + })} +
+
+
); } @@ -544,31 +379,14 @@ interface NodeParameterEditorProps { node: CGNode; onUpdatePrimary: (key: keyof PrimaryCorrection, value: number | [number, number, number]) => void; onUpdateColorWheels: (updates: Partial) => void; - onUpdateCurves: (curves: Curves) => void; - onUpdateCst: (updates: { from_space?: ColorSpace; to_space?: ColorSpace }) => void; - onUpdateLut: (updates: Partial) => void; - onUpdateQualifier: (key: keyof HslQualifier, value: number | boolean) => void; - onUpdateQualifierCorrection: ( - key: keyof PrimaryCorrection, - value: number | [number, number, number], - ) => void; } -// ============================================================================ -// Node Parameter Editor -// ============================================================================ - function NodeParameterEditor({ clipId, clipStartTime, node, onUpdatePrimary, onUpdateColorWheels, - onUpdateCurves, - onUpdateCst, - onUpdateLut, - onUpdateQualifier, - onUpdateQualifierCorrection, }: NodeParameterEditorProps) { const [expanded, setExpanded] = useState(true); @@ -584,8 +402,8 @@ function NodeParameterEditor({ return "LUT"; case "Qualifier": return "HSL Qualifier"; - case "ColorSpaceTransform": - return "Color Space Transform"; + case "Window": + return "Power Window"; default: return "Unknown"; } @@ -610,7 +428,7 @@ function NodeParameterEditor({ {/* Parameters */} {expanded && ( -
+
{node.type === "Primary" && ( )} {node.type === "Curves" && ( - +

Curves editor coming soon

+ )} + {node.type === "Lut" && ( +

LUT browser coming soon

)} - {node.type === "Lut" && } {node.type === "Qualifier" && ( - +

HSL Qualifier coming soon

)} - {node.type === "ColorSpaceTransform" && ( - + {node.type === "Window" && ( +

Power Window coming soon

)}
)} diff --git a/apps/ui/src/components/editor/color-grading/color-wheel.tsx b/apps/ui/src/components/editor/color-grading/color-wheel.tsx index 5caf6d4..952da15 100644 --- a/apps/ui/src/components/editor/color-grading/color-wheel.tsx +++ b/apps/ui/src/components/editor/color-grading/color-wheel.tsx @@ -1,7 +1,6 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "../../../lib/utils"; -import { useVideoEditorStore } from "../../../state/video-editor-store"; import { Slider } from "../../ui/slider"; interface ColorWheelProps { @@ -162,7 +161,6 @@ export function ColorWheel({ canvas.setPointerCapture(e.pointerId); setIsDragging(true); dragStartRef.current = { angle, distance }; - useVideoEditorStore.temporal.getState().pause(); // Calculate position const rect = canvas.getBoundingClientRect(); @@ -222,7 +220,6 @@ export function ColorWheel({ const handlePointerUp = useCallback(() => { setIsDragging(false); dragStartRef.current = null; - useVideoEditorStore.temporal.getState().resume(); }, []); const handleDoubleClick = useCallback(() => { @@ -236,7 +233,7 @@ export function ColorWheel({ }, [onLuminanceChange]); return ( -
+
{label}
@@ -263,8 +260,6 @@ export function ColorWheel({ max={1} step={0.01} onValueChange={([v]) => onLuminanceChange(v)} - onValueCommit={() => useVideoEditorStore.temporal.getState().resume()} - onPointerDown={() => useVideoEditorStore.temporal.getState().pause()} disabled={disabled} className="flex-1" /> diff --git a/apps/ui/src/components/editor/color-grading/color-wheels-properties.tsx b/apps/ui/src/components/editor/color-grading/color-wheels-properties.tsx index 0d2467f..71eeb39 100644 --- a/apps/ui/src/components/editor/color-grading/color-wheels-properties.tsx +++ b/apps/ui/src/components/editor/color-grading/color-wheels-properties.tsx @@ -1,10 +1,9 @@ import type { ColorWheels, ColorWheelValue } from "@tooscut/render-engine"; import { RotateCcw } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useCallback } from "react"; import { Button } from "../../ui/button"; -import { ResetButton } from "../../ui/reset-button"; import { ColorWheel } from "./color-wheel"; interface ColorWheelsPropertiesProps { @@ -28,10 +27,6 @@ const DEFAULT_WHEEL: ColorWheelValue = { angle: 0, distance: 0 }; * - Distance from center (color intensity) * - Luminance slider (brightness adjustment) */ -function isWheelDirty(wheel: ColorWheelValue, luminance: number): boolean { - return wheel.distance > 0.001 || Math.abs(luminance) > 0.001; -} - export function ColorWheelsProperties({ wheels, onWheelsChange }: ColorWheelsPropertiesProps) { // Handlers for lift wheel const handleLiftColorChange = useCallback( @@ -103,41 +98,25 @@ export function ColorWheelsProperties({ wheels, onWheelsChange }: ColorWheelsPro onWheelsChange({ gain: DEFAULT_WHEEL, gain_luminance: 0 }); }, [onWheelsChange]); - const liftDirty = useMemo( - () => isWheelDirty(wheels.lift, wheels.lift_luminance), - [wheels.lift, wheels.lift_luminance], - ); - const gammaDirty = useMemo( - () => isWheelDirty(wheels.gamma, wheels.gamma_luminance), - [wheels.gamma, wheels.gamma_luminance], - ); - const gainDirty = useMemo( - () => isWheelDirty(wheels.gain, wheels.gain_luminance), - [wheels.gain, wheels.gain_luminance], - ); - const anyDirty = liftDirty || gammaDirty || gainDirty; - return ( -
+
{/* Header with reset button */} -
+
Color Wheels - {anyDirty && ( - - )} +
- {/* Wheels — column below 320px, 3-col row above */} -
+ {/* Three wheels in a row */} +
{/* Lift (Shadows) */}
-
- {liftDirty ? ( - - ) : ( - - )} - Shadows - -
+
{/* Gamma (Midtones) */} @@ -171,15 +149,14 @@ export function ColorWheelsProperties({ wheels, onWheelsChange }: ColorWheelsPro onLuminanceChange={handleGammaLuminanceChange} size={100} /> -
- {gammaDirty ? ( - - ) : ( - - )} - Midtones - -
+
{/* Gain (Highlights) */} @@ -193,20 +170,19 @@ export function ColorWheelsProperties({ wheels, onWheelsChange }: ColorWheelsPro onLuminanceChange={handleGainLuminanceChange} size={100} /> -
- {gainDirty ? ( - - ) : ( - - )} - Highlights - -
+
{/* Instructions */} -

+

Drag to adjust color. Double-click to reset. Shift+drag for saturation only.

diff --git a/apps/ui/src/components/editor/color-grading/node-graph.tsx b/apps/ui/src/components/editor/color-grading/node-graph.tsx index eb522d1..f337c66 100644 --- a/apps/ui/src/components/editor/color-grading/node-graph.tsx +++ b/apps/ui/src/components/editor/color-grading/node-graph.tsx @@ -1,68 +1,53 @@ /** * Node-based color grading graph using React Flow. * - * Features: - * - Fixed Input/Output terminal nodes (non-deletable) - * - All grading nodes have both input and output handles - * - User can connect/reconnect nodes by dragging edges - * - Edge connections determine processing order + * Displays color grading nodes in a horizontal pipeline layout. + * Each node can be expanded to show parameters, enabled/disabled, + * or removed. Nodes can be reordered by dragging. */ import type { ColorGradingNode as CGNode } from "@tooscut/render-engine"; -import type { Node, NodeProps, Edge, Connection, OnConnect } from "@xyflow/react"; +import type { Node, NodeProps, Edge } from "@xyflow/react"; import { ReactFlow, - ReactFlowProvider, Background, BackgroundVariant, useNodesState, useEdgesState, - useReactFlow, Handle, Position, - addEdge, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; -import { Eye, EyeOff, Trash2 } from "lucide-react"; -import { useCallback, useMemo, useEffect, useRef, memo } from "react"; +import { Eye, EyeOff, Trash2, GripVertical } from "lucide-react"; +import { useCallback, useMemo, useEffect, memo } from "react"; import { cn } from "../../../lib/utils"; -// ============================================================================ -// Constants -// ============================================================================ - -const NODE_WIDTH = 160; -const NODE_GAP = 60; -const INPUT_NODE_ID = "__input__"; -const OUTPUT_NODE_ID = "__output__"; - // ============================================================================ // Types // ============================================================================ -interface GradingNodeData extends Record { +interface NodeData extends Record { node: CGNode; + index: number; + isFirst: boolean; + isLast: boolean; onToggleEnabled: (enabled: boolean) => void; onRemove: () => void; onSelect: () => void; isSelected: boolean; } -interface TerminalNodeData extends Record { - label: string; - type: "input" | "output"; -} - -type GradingFlowNode = Node; -type TerminalFlowNode = Node; -type AnyFlowNode = GradingFlowNode | TerminalFlowNode; +type ColorGradingFlowNode = Node; // ============================================================================ -// Node Preview / Theme / Label helpers +// Node Components // ============================================================================ +/** + * Get a preview string for a node's current settings. + */ function getNodePreview(node: CGNode): string { switch (node.type) { case "Primary": { @@ -81,30 +66,28 @@ function getNodePreview(node: CGNode): string { const active = [hasLift && "L", hasGamma && "G", hasGain && "Gn"].filter(Boolean); return active.length > 0 ? active.join(" · ") : "Default"; } - case "Curves": { - const cu = node.curves; - const isIdentity = (c: { points: { x: number; y: number }[] }) => - c.points.every((p) => Math.abs(p.x - p.y) < 0.01); - const totalPts = - cu.master.points.length + - cu.red.points.length + - cu.green.points.length + - cu.blue.points.length; - const allIdentity = - isIdentity(cu.master) && isIdentity(cu.red) && isIdentity(cu.green) && isIdentity(cu.blue); - return allIdentity ? "Identity" : `${totalPts} pts`; - } + case "Curves": + return "RGB Curves"; case "Lut": return node.lut.lut_id || "No LUT"; case "Qualifier": return "HSL Key"; - case "ColorSpaceTransform": - return `${node.from_space} → ${node.to_space}`; + case "Window": { + const shape = node.window.shape; + if ("Circle" in shape) return "Circle"; + if ("Rectangle" in shape) return "Rectangle"; + if ("Polygon" in shape) return "Polygon"; + if ("Gradient" in shape) return "Gradient"; + return "Unknown"; + } default: return "Unknown"; } } +/** + * Get the color theme for a node type. + */ function getNodeTheme(type: CGNode["type"]): { bg: string; border: string; accent: string } { switch (type) { case "Primary": @@ -117,13 +100,16 @@ function getNodeTheme(type: CGNode["type"]): { bg: string; border: string; accen return { bg: "bg-green-950/50", border: "border-green-700/50", accent: "text-green-400" }; case "Qualifier": return { bg: "bg-pink-950/50", border: "border-pink-700/50", accent: "text-pink-400" }; - case "ColorSpaceTransform": - return { bg: "bg-sky-950/50", border: "border-sky-700/50", accent: "text-sky-400" }; + case "Window": + return { bg: "bg-cyan-950/50", border: "border-cyan-700/50", accent: "text-cyan-400" }; default: return { bg: "bg-neutral-900", border: "border-neutral-700", accent: "text-neutral-400" }; } } +/** + * Get the label for a node type. + */ function getNodeLabel(node: CGNode): string { if (node.label) return node.label; switch (node.type) { @@ -137,70 +123,40 @@ function getNodeLabel(node: CGNode): string { return "LUT"; case "Qualifier": return "Qualifier"; - case "ColorSpaceTransform": - return "CST"; + case "Window": + return "Window"; default: return "Unknown"; } } -// ============================================================================ -// Terminal Node Component (Input / Output) -// ============================================================================ - -const TerminalNodeComponent = memo(function TerminalNodeComponent({ - data, -}: NodeProps) { - const isInput = data.type === "input"; - return ( - <> - {!isInput && ( - - )} -
- - {data.label} - -
- {isInput && ( - - )} - - ); -}); - -// ============================================================================ -// Grading Node Component -// ============================================================================ - +/** + * Base node component for all color grading node types. + */ const ColorGradingNodeComponent = memo(function ColorGradingNodeComponent({ data, selected, -}: NodeProps) { - const { node, onToggleEnabled, onRemove, onSelect, isSelected } = data; +}: NodeProps) { + const { node, isFirst, isLast, onToggleEnabled, onRemove, onSelect, isSelected } = data; const theme = getNodeTheme(node.type); const preview = getNodePreview(node); const label = getNodeLabel(node); return ( <> - + {/* Input handle */} + {!isFirst && ( + + )} + {/* Node content */}
+ {/* Drag handle */} +
+ +
+ {/* Header */}
@@ -265,11 +226,14 @@ const ColorGradingNodeComponent = memo(function ColorGradingNodeComponent({ )}
- + {/* Output handle */} + {!isLast && ( + + )} ); }); @@ -280,7 +244,6 @@ const ColorGradingNodeComponent = memo(function ColorGradingNodeComponent({ const nodeTypes = { colorGrading: ColorGradingNodeComponent, - terminal: TerminalNodeComponent, }; // ============================================================================ @@ -293,133 +256,59 @@ interface ColorGradingNodeGraphProps { onSelectNode: (nodeId: string | null) => void; onToggleNodeEnabled: (nodeId: string, enabled: boolean) => void; onRemoveNode: (nodeId: string) => void; - onReorderNodes: (nodeIds: string[]) => void; - onUpdateNodePosition: (nodeId: string, x: number, y: number) => void; -} - -export function ColorGradingNodeGraph(props: ColorGradingNodeGraphProps) { - return ( - - - - ); + onReorderNodes: (fromIndex: number, toIndex: number) => void; } -/** - * Derive processing order from edges by walking the graph from Input → Output. - * Returns an array of grading node IDs in the connected order. - */ -function deriveOrderFromEdges(edges: Edge[], gradingNodeIds: Set): string[] { - // Build adjacency: source → target - const adj = new Map(); - for (const edge of edges) { - adj.set(edge.source, edge.target); - } - - // Walk from Input - const order: string[] = []; - let current = adj.get(INPUT_NODE_ID); - const visited = new Set(); - while (current && current !== OUTPUT_NODE_ID && !visited.has(current)) { - visited.add(current); - if (gradingNodeIds.has(current)) { - order.push(current); - } - current = adj.get(current); - } - - return order; -} +const NODE_WIDTH = 160; +const NODE_GAP = 60; -function ColorGradingNodeGraphInner({ +export function ColorGradingNodeGraph({ nodes, selectedNodeId, onSelectNode, onToggleNodeEnabled, onRemoveNode, onReorderNodes, - onUpdateNodePosition, }: ColorGradingNodeGraphProps) { - const { fitView } = useReactFlow(); - const prevNodeCountRef = useRef(nodes.length); - - // Build flow nodes: Input terminal + grading nodes + Output terminal - const flowNodes = useMemo((): AnyFlowNode[] => { - const result: AnyFlowNode[] = []; - - // Input terminal - result.push({ - id: INPUT_NODE_ID, - type: "terminal", - position: { x: -NODE_WIDTH - NODE_GAP, y: 10 }, - data: { label: "Input", type: "input" }, - draggable: false, - selectable: false, - deletable: false, - }); - - // Grading nodes - for (let i = 0; i < nodes.length; i++) { - const node = nodes[i]; - const defaultPos = { x: i * (NODE_WIDTH + NODE_GAP), y: 0 }; - const pos = node.position ?? defaultPos; - result.push({ - id: node.id, - type: "colorGrading", - position: { x: pos.x, y: pos.y }, - data: { - node, - onToggleEnabled: (enabled: boolean) => onToggleNodeEnabled(node.id, enabled), - onRemove: () => onRemoveNode(node.id), - onSelect: () => onSelectNode(node.id), - isSelected: node.id === selectedNodeId, - }, - draggable: true, - deletable: false, - }); - } - - // Output terminal - result.push({ - id: OUTPUT_NODE_ID, - type: "terminal", - position: { x: nodes.length * (NODE_WIDTH + NODE_GAP), y: 10 }, - data: { label: "Output", type: "output" }, - draggable: false, - selectable: false, - deletable: false, - }); - - return result; + // Convert color grading nodes to React Flow nodes + const flowNodes = useMemo((): ColorGradingFlowNode[] => { + return nodes.map((node, index) => ({ + id: node.id, + type: "colorGrading", + position: { x: index * (NODE_WIDTH + NODE_GAP), y: 0 }, + data: { + node, + index, + isFirst: index === 0, + isLast: index === nodes.length - 1, + onToggleEnabled: (enabled: boolean) => onToggleNodeEnabled(node.id, enabled), + onRemove: () => onRemoveNode(node.id), + onSelect: () => onSelectNode(node.id), + isSelected: node.id === selectedNodeId, + }, + draggable: true, + })); }, [nodes, selectedNodeId, onToggleNodeEnabled, onRemoveNode, onSelectNode]); - // Create edges: Input → node[0] → node[1] → ... → Output + // Create edges connecting nodes in sequence const flowEdges = useMemo((): Edge[] => { - const chain = [INPUT_NODE_ID, ...nodes.map((n) => n.id), OUTPUT_NODE_ID]; - return chain.slice(0, -1).map((source, i) => { - const target = chain[i + 1]; - const sourceEnabled = - source === INPUT_NODE_ID || (nodes.find((n) => n.id === source)?.enabled ?? true); - const targetEnabled = - target === OUTPUT_NODE_ID || (nodes.find((n) => n.id === target)?.enabled ?? true); - return { - id: `edge-${source}-${target}`, - source, - target, - type: "smoothstep", - animated: sourceEnabled && targetEnabled, - style: { - stroke: sourceEnabled ? "#525252" : "#262626", - strokeWidth: 2, - }, - }; - }); + return nodes.slice(0, -1).map((node, index) => ({ + id: `edge-${node.id}-${nodes[index + 1].id}`, + source: node.id, + target: nodes[index + 1].id, + type: "smoothstep", + animated: nodes[index].enabled && nodes[index + 1].enabled, + style: { + stroke: nodes[index].enabled ? "#525252" : "#262626", + strokeWidth: 2, + }, + })); }, [nodes]); const [rfNodes, setRfNodes, onNodesChange] = useNodesState(flowNodes); const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState(flowEdges); - // Sync React Flow state when source data changes + // Sync React Flow nodes when props change useEffect(() => { setRfNodes(flowNodes); }, [flowNodes, setRfNodes]); @@ -428,47 +317,21 @@ function ColorGradingNodeGraphInner({ setRfEdges(flowEdges); }, [flowEdges, setRfEdges]); - // Fit view when nodes are added or removed - useEffect(() => { - if (nodes.length !== prevNodeCountRef.current) { - prevNodeCountRef.current = nodes.length; - requestAnimationFrame(() => void fitView({ padding: 0.3 })); - } - }, [nodes.length, fitView]); - - // Handle new connections - const gradingNodeIds = useMemo(() => new Set(nodes.map((n) => n.id)), [nodes]); - - const onConnect: OnConnect = useCallback( - (connection: Connection) => { - // Add the new edge, removing any existing edge from the same source - setRfEdges((eds) => { - const filtered = eds.filter( - (e) => e.source !== connection.source && e.target !== connection.target, - ); - const newEdges = addEdge( - { ...connection, type: "smoothstep", style: { stroke: "#525252", strokeWidth: 2 } }, - filtered, - ); - // Derive new order from the updated edges - const newOrder = deriveOrderFromEdges(newEdges, gradingNodeIds); - if (newOrder.length > 0) { - onReorderNodes(newOrder); - } - return newEdges; - }); - }, - [setRfEdges, gradingNodeIds, onReorderNodes], - ); - - // Persist position to store on drag end + // Handle node drag end for reordering const onNodeDragStop = useCallback( (_event: React.MouseEvent, node: Node) => { - if (node.id !== INPUT_NODE_ID && node.id !== OUTPUT_NODE_ID) { - onUpdateNodePosition(node.id, node.position.x, node.position.y); + const currentIndex = nodes.findIndex((n) => n.id === node.id); + if (currentIndex === -1) return; + + // Calculate new index based on x position + const newIndex = Math.round(node.position.x / (NODE_WIDTH + NODE_GAP)); + const clampedIndex = Math.max(0, Math.min(nodes.length - 1, newIndex)); + + if (clampedIndex !== currentIndex) { + onReorderNodes(currentIndex, clampedIndex); } }, - [onUpdateNodePosition], + [nodes, onReorderNodes], ); // Handle click on background to deselect @@ -476,14 +339,21 @@ function ColorGradingNodeGraphInner({ onSelectNode(null); }, [onSelectNode]); + if (nodes.length === 0) { + return ( +
+

No nodes in pipeline

+
+ ); + } + return ( -
+
diff --git a/apps/ui/src/components/editor/properties-panel.tsx b/apps/ui/src/components/editor/properties-panel.tsx index acfa113..c5fd498 100644 --- a/apps/ui/src/components/editor/properties-panel.tsx +++ b/apps/ui/src/components/editor/properties-panel.tsx @@ -1,14 +1,5 @@ import type { Effects, AudioEffectsParams, ColorGrading } from "@tooscut/render-engine"; -import { - ClapperboardIcon, - ImageIcon, - PaletteIcon, - ShapesIcon, - SparklesIcon, - TextIcon, - Volume2, -} from "lucide-react"; import { useMemo, useState, useCallback } from "react"; import { useVideoEditorStore } from "../../state/video-editor-store"; @@ -256,49 +247,41 @@ export function PropertiesPanel() { > {showPicture && ( - Picture )} {showAudio && ( - Audio )} {showText && ( - Text )} {showShape && ( - Shape )} {showLine && ( - Line )} {showEffect && ( - Effect )} {showColor && ( - Color )} {showTransition && ( - Transition )} diff --git a/crates/compositor/src/color_grading_uniforms.rs b/crates/compositor/src/color_grading_uniforms.rs index 621e91b..2ffbf2a 100644 --- a/crates/compositor/src/color_grading_uniforms.rs +++ b/crates/compositor/src/color_grading_uniforms.rs @@ -302,43 +302,8 @@ pub struct ColorGradingUniforms { /// LUT size (cube dimension, e.g., 33). pub lut_size: f32, - /// Input color space transform (ColorSpace enum as u32, 0 = sRGB). - pub input_cst: u32, - /// Output color space transform (ColorSpace enum as u32, 0 = sRGB). - pub output_cst: u32, - /// Padding to maintain vec4 alignment. - pub _pad_align: [f32; 2], - - // === Qualifier correction CDL (applied within qualified region) === - /// Qualifier correction slope. - pub q_slope: [f32; 4], // 16 bytes - /// Qualifier correction offset. - pub q_offset: [f32; 4], // 16 bytes - /// Qualifier correction power. - pub q_power: [f32; 4], // 16 bytes - /// Qualifier correction adjustments: sat, exposure, temperature, tint. - pub q_adjustments: [f32; 4], // 16 bytes - - // === Power window params === - /// Window center (x, y) and scale (x, y). - pub window_center_scale: [f32; 4], // 16 bytes - /// Window shape params: (radius_x/width, radius_y/height, corner_radius/angle, shape_type). - pub window_shape: [f32; 4], // 16 bytes - /// Window: rotation, softness_inner, softness_outer, invert. - pub window_params: [f32; 4], // 16 bytes - /// Window correction slope. - pub w_slope: [f32; 4], // 16 bytes - /// Window correction offset. - pub w_offset: [f32; 4], // 16 bytes - /// Window correction power. - pub w_power: [f32; 4], // 16 bytes - /// Window correction adjustments: sat, exposure, temperature, tint. - pub w_adjustments: [f32; 4], // 16 bytes - /// Window mix + pad. - pub window_mix: [f32; 4], // 16 bytes - - // Padding to 512 bytes (400 used, 112 remaining = 28 floats) - pub _pad: [f32; 28], + // Padding to 256 bytes (64 bytes) + pub _pad: [f32; 16], } impl Default for ColorGradingUniforms { @@ -362,24 +327,7 @@ impl Default for ColorGradingUniforms { highlights: 0.0, shadows: 0.0, lut_size: 33.0, - input_cst: 0, - output_cst: 0, - _pad_align: [0.0; 2], - // Qualifier correction - q_slope: [1.0, 1.0, 1.0, 1.0], - q_offset: [0.0, 0.0, 0.0, 0.0], - q_power: [1.0, 1.0, 1.0, 1.0], - q_adjustments: [1.0, 0.0, 0.0, 0.0], - // Power window - window_center_scale: [0.5, 0.5, 1.0, 1.0], - window_shape: [0.25, 0.25, 0.0, 0.0], // circle default - window_params: [0.0, 0.0, 0.1, 0.0], // rotation, softness_inner, softness_outer, invert - w_slope: [1.0, 1.0, 1.0, 1.0], - w_offset: [0.0, 0.0, 0.0, 0.0], - w_power: [1.0, 1.0, 1.0, 1.0], - w_adjustments: [1.0, 0.0, 0.0, 0.0], - window_mix: [1.0, 0.0, 0.0, 0.0], - _pad: [0.0; 28], + _pad: [0.0; 16], } } } @@ -391,26 +339,6 @@ pub const FLAG_WHEELS_ENABLED: u32 = 1 << 2; pub const FLAG_CURVES_ENABLED: u32 = 1 << 3; pub const FLAG_LUT_ENABLED: u32 = 1 << 4; pub const FLAG_QUALIFIER_ENABLED: u32 = 1 << 5; -pub const FLAG_WINDOW_ENABLED: u32 = 1 << 6; -pub const FLAG_INPUT_CST: u32 = 1 << 7; -pub const FLAG_OUTPUT_CST: u32 = 1 << 8; - -/// Convert ColorSpace enum to u32 for shader. -fn color_space_to_u32(cs: &tooscut_types::ColorSpace) -> u32 { - use tooscut_types::ColorSpace; - match cs { - ColorSpace::Srgb => 0, - ColorSpace::Linear => 1, - ColorSpace::AcesCg => 2, - ColorSpace::LogC => 3, - ColorSpace::SLog2 => 4, - ColorSpace::SLog3 => 5, - ColorSpace::CLog3 => 6, - ColorSpace::VLog => 7, - ColorSpace::BmFilm => 8, - ColorSpace::RedLog3G10 => 9, - } -} impl ColorGradingUniforms { /// Create uniforms from a ColorGrading configuration. @@ -425,39 +353,6 @@ impl ColorGradingUniforms { return uniforms; } - // Scan for CST nodes: first enabled CST → input, last enabled CST → output - let mut first_cst: Option<(tooscut_types::ColorSpace, tooscut_types::ColorSpace)> = None; - let mut last_cst: Option<(tooscut_types::ColorSpace, tooscut_types::ColorSpace)> = None; - for node in &grading.nodes { - if let ColorGradingNode::ColorSpaceTransform { - enabled: true, - from_space, - to_space, - .. - } = node - { - if first_cst.is_none() { - first_cst = Some((from_space.clone(), to_space.clone())); - } - last_cst = Some((from_space.clone(), to_space.clone())); - } - } - - // First CST node: use from_space as input CST (convert from source to linear) - if let Some((from_space, _)) = &first_cst { - if *from_space != tooscut_types::ColorSpace::Srgb { - uniforms.flags |= FLAG_INPUT_CST; - uniforms.input_cst = color_space_to_u32(from_space); - } - } - // Last CST node: use to_space as output CST (convert from linear to target) - if let Some((_, to_space)) = &last_cst { - if *to_space != tooscut_types::ColorSpace::Srgb { - uniforms.flags |= FLAG_OUTPUT_CST; - uniforms.output_cst = color_space_to_u32(to_space); - } - } - for node in &grading.nodes { if !node.is_enabled() { continue; @@ -491,13 +386,13 @@ impl ColorGradingUniforms { uniforms.gain = [gain_rgb[0], gain_rgb[1], gain_rgb[2], wheels.gain_luminance]; uniforms.wheels_mix = *mix; } - ColorGradingNode::Lut { lut, .. } => { + ColorGradingNode::Lut { lut, mix, .. } => { uniforms.flags |= FLAG_LUT_ENABLED; - uniforms.lut_mix = lut.mix; + uniforms.lut_mix = *mix; // LUT texture binding handled separately } ColorGradingNode::Qualifier { - qualifier, correction, mix, .. + qualifier, mix, .. } => { uniforms.flags |= FLAG_QUALIFIER_ENABLED; uniforms.qualifier_center = [ @@ -519,63 +414,8 @@ impl ColorGradingUniforms { if qualifier.invert { 1.0 } else { 0.0 }, ]; uniforms.qualifier_mix = *mix; - // Qualifier correction CDL - uniforms.q_slope = [correction.slope[0], correction.slope[1], correction.slope[2], 1.0]; - uniforms.q_offset = [correction.offset[0], correction.offset[1], correction.offset[2], 0.0]; - uniforms.q_power = [correction.power[0], correction.power[1], correction.power[2], 1.0]; - uniforms.q_adjustments = [ - correction.saturation, - correction.exposure, - correction.temperature, - correction.tint, - ]; - } - ColorGradingNode::Window { - window, correction, mix, .. - } => { - uniforms.flags |= FLAG_WINDOW_ENABLED; - uniforms.window_center_scale = [ - window.center_x, - window.center_y, - window.scale_x, - window.scale_y, - ]; - // Encode shape type: 0=circle, 1=rectangle, 2=gradient - let (p1, p2, p3, shape_type) = match &window.shape { - tooscut_types::PowerWindowShape::Circle { radius_x, radius_y } => { - (*radius_x, *radius_y, 0.0, 0.0) - } - tooscut_types::PowerWindowShape::Rectangle { width, height, corner_radius } => { - (*width, *height, *corner_radius, 1.0) - } - tooscut_types::PowerWindowShape::Gradient { angle } => { - (*angle / 360.0, 0.0, 0.0, 2.0) - } - tooscut_types::PowerWindowShape::Polygon { .. } => { - (0.25, 0.25, 0.0, 0.0) // fallback to circle - } - }; - uniforms.window_shape = [p1, p2, p3, shape_type]; - uniforms.window_params = [ - window.rotation / 360.0, - window.softness_inner, - window.softness_outer, - if window.invert { 1.0 } else { 0.0 }, - ]; - uniforms.w_slope = [correction.slope[0], correction.slope[1], correction.slope[2], 1.0]; - uniforms.w_offset = [correction.offset[0], correction.offset[1], correction.offset[2], 0.0]; - uniforms.w_power = [correction.power[0], correction.power[1], correction.power[2], 1.0]; - uniforms.w_adjustments = [ - correction.saturation, - correction.exposure, - correction.temperature, - correction.tint, - ]; - uniforms.window_mix = [*mix, 0.0, 0.0, 0.0]; } - // CST handled above in the pre-scan - ColorGradingNode::ColorSpaceTransform { .. } => {} - // Curves require additional texture, handled separately + // Curves and Window require additional textures/data, handled separately _ => {} } } @@ -588,7 +428,7 @@ impl ColorGradingUniforms { const _: () = assert!(std::mem::size_of::() == 128); const _: () = assert!(std::mem::size_of::() == 128); const _: () = assert!(std::mem::size_of::() == 128); -const _: () = assert!(std::mem::size_of::() == 512); +const _: () = assert!(std::mem::size_of::() == 256); #[cfg(test)] mod tests { @@ -599,7 +439,7 @@ mod tests { assert_eq!(std::mem::size_of::(), 128); assert_eq!(std::mem::size_of::(), 128); assert_eq!(std::mem::size_of::(), 128); - assert_eq!(std::mem::size_of::(), 512); + assert_eq!(std::mem::size_of::(), 256); } #[test] diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index d0cb4c1..9dd2775 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -28,31 +28,6 @@ use tooscut_types::{ #[cfg(target_arch = "wasm32")] use web_sys::{HtmlCanvasElement, HtmlVideoElement, ImageBitmap, OffscreenCanvas}; -/// Convert f32 to IEEE 754 half-precision (f16) stored as u16. -fn f32_to_f16(value: f32) -> u16 { - let bits = value.to_bits(); - let sign = (bits >> 16) & 0x8000; - let exponent = ((bits >> 23) & 0xFF) as i32; - let mantissa = bits & 0x7FFFFF; - - if exponent == 0xFF { - // Inf/NaN - return (sign | 0x7C00 | if mantissa != 0 { 0x200 } else { 0 }) as u16; - } - - let exp = exponent - 127 + 15; - if exp >= 31 { - // Overflow → Inf - return (sign | 0x7C00) as u16; - } - if exp <= 0 { - // Underflow → 0 - return sign as u16; - } - - (sign | ((exp as u32) << 10) | (mantissa >> 13)) as u16 -} - /// A renderable item with its z-index for sorting. #[derive(Debug)] enum RenderItem<'a> { @@ -86,11 +61,6 @@ pub struct Compositor { // Color grading color_grading_bind_group_layout: BindGroupLayout, default_cg_bind_group: BindGroup, - lut_sampler: wgpu::Sampler, - /// Identity 2x2x2 LUT texture (passthrough) used when no LUT is loaded. - default_lut_texture_view: wgpu::TextureView, - /// Currently loaded 3D LUT texture, keyed by lut_id. - active_lut: Option<(String, wgpu::TextureView)>, // Shape/line rendering shape_pipeline: RenderPipeline, shape_bind_group_layout: BindGroupLayout, @@ -163,27 +133,6 @@ impl Compositor { .upload_bitmap(&self.device, &self.queue, texture_id, bitmap) .map_err(Into::into) } - - /// Upload a 3D LUT from RGBA float data. - /// - /// `data` must be a Float32Array with `size^3 * 4` elements (RGBA). - /// `size` is the cube dimension (e.g., 17, 33, 65). - #[wasm_bindgen] - pub fn upload_lut( - &mut self, - lut_id: &str, - size: u32, - data: &[f32], - ) -> std::result::Result<(), JsValue> { - self.upload_lut_internal(lut_id, size, data) - .map_err(|e| JsValue::from_str(&e.to_string())) - } - - /// Remove the active LUT, reverting to the identity (passthrough) LUT. - #[wasm_bindgen] - pub fn remove_lut(&mut self) { - self.active_lut = None; - } } #[wasm_bindgen] @@ -438,52 +387,12 @@ impl Compositor { // Media layer pipeline with color grading support let bind_group_layout = create_bind_group_layout(&device); let color_grading_bind_group_layout = create_color_grading_bind_group_layout(&device); - log::info!("[compositor] Creating pipeline..."); let pipeline = create_pipeline( &device, &bind_group_layout, &color_grading_bind_group_layout, surface_format, )?; - log::info!("[compositor] Pipeline created OK"); - - // LUT sampler (linear filtering for smooth LUT interpolation) - let lut_sampler = device.create_sampler(&wgpu::SamplerDescriptor { - label: Some("lut_sampler"), - mag_filter: wgpu::FilterMode::Linear, - min_filter: wgpu::FilterMode::Linear, - ..Default::default() - }); - - // Default identity LUT (2x2x2, each texel = its own coordinate) in f16 - let default_lut_f32: [f32; 32] = [ - 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, - 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, - 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, - 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, - ]; - let default_lut_data: Vec = default_lut_f32.iter().map(|&f| f32_to_f16(f)).collect(); - let default_lut_texture = device.create_texture_with_data( - &queue, - &wgpu::TextureDescriptor { - label: Some("default_lut_3d"), - size: wgpu::Extent3d { - width: 2, - height: 2, - depth_or_array_layers: 2, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D3, - format: wgpu::TextureFormat::Rgba16Float, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }, - wgpu::util::TextureDataOrder::default(), - cast_slice(&default_lut_data), - ); - let default_lut_texture_view = - default_lut_texture.create_view(&wgpu::TextureViewDescriptor::default()); // Create cached default color grading bind group (no-op) let default_cg_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -494,20 +403,10 @@ impl Compositor { let default_cg_bind_group = device.create_bind_group(&BindGroupDescriptor { label: Some("default_cg_bind_group"), layout: &color_grading_bind_group_layout, - entries: &[ - BindGroupEntry { - binding: 0, - resource: default_cg_buffer.as_entire_binding(), - }, - BindGroupEntry { - binding: 1, - resource: BindingResource::TextureView(&default_lut_texture_view), - }, - BindGroupEntry { - binding: 2, - resource: BindingResource::Sampler(&lut_sampler), - }, - ], + entries: &[BindGroupEntry { + binding: 0, + resource: default_cg_buffer.as_entire_binding(), + }], }); let textures = TextureManager::new(&device); @@ -526,9 +425,6 @@ impl Compositor { bind_group_layout, color_grading_bind_group_layout, default_cg_bind_group, - lut_sampler, - default_lut_texture_view, - active_lut: None, shape_pipeline, shape_bind_group_layout, textures, @@ -540,63 +436,6 @@ impl Compositor { }) } - /// Upload a 3D LUT texture from RGBA float data. - fn upload_lut_internal(&mut self, lut_id: &str, size: u32, data: &[f32]) -> Result<()> { - let expected = (size * size * size * 4) as usize; - if data.len() != expected { - return Err(CompositorError::Serialization(format!( - "LUT data size mismatch: expected {} floats, got {}", - expected, - data.len() - ))); - } - - // Convert f32 RGBA data to f16 RGBA for GPU (Rgba16Float supports filtering) - let f16_data: Vec = data.iter().map(|&f| f32_to_f16(f)).collect(); - - let texture = self.device.create_texture(&wgpu::TextureDescriptor { - label: Some("lut_3d"), - size: wgpu::Extent3d { - width: size, - height: size, - depth_or_array_layers: size, - }, - mip_level_count: 1, - sample_count: 1, - dimension: wgpu::TextureDimension::D3, - format: wgpu::TextureFormat::Rgba16Float, - usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, - view_formats: &[], - }); - - let bytes_per_row = size * 4 * 2; // width * 4 channels * 2 bytes per f16 - self.queue.write_texture( - wgpu::TexelCopyTextureInfo { - texture: &texture, - mip_level: 0, - origin: wgpu::Origin3d::ZERO, - aspect: wgpu::TextureAspect::All, - }, - cast_slice(&f16_data), - wgpu::TexelCopyBufferLayout { - offset: 0, - bytes_per_row: Some(bytes_per_row), - rows_per_image: Some(size), - }, - wgpu::Extent3d { - width: size, - height: size, - depth_or_array_layers: size, - }, - ); - - let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); - self.active_lut = Some((lut_id.to_string(), view)); - - log::info!("[compositor] Uploaded 3D LUT '{}' ({}x{}x{})", lut_id, size, size, size); - Ok(()) - } - /// Ensure the text renderer is initialized. fn ensure_text_renderer(&mut self) { if self.text_renderer.is_none() { @@ -1176,35 +1015,13 @@ impl Compositor { contents: cast_slice(&[cg_uniforms]), usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, }); - - // Use active LUT texture if loaded, otherwise default - let has_active_lut = self.active_lut.is_some(); - let lut_view = self - .active_lut - .as_ref() - .map(|(_, view)| view) - .unwrap_or(&self.default_lut_texture_view); - if cg_uniforms.flags & crate::color_grading_uniforms::FLAG_LUT_ENABLED != 0 { - log::info!("[compositor] LUT enabled, active_lut={}, lut_mix={}", has_active_lut, cg_uniforms.lut_mix); - } - let cg_bind_group = self.device.create_bind_group(&BindGroupDescriptor { label: Some("cg_bind_group"), layout: &self.color_grading_bind_group_layout, - entries: &[ - BindGroupEntry { - binding: 0, - resource: cg_buffer.as_entire_binding(), - }, - BindGroupEntry { - binding: 1, - resource: BindingResource::TextureView(lut_view), - }, - BindGroupEntry { - binding: 2, - resource: BindingResource::Sampler(&self.lut_sampler), - }, - ], + entries: &[BindGroupEntry { + binding: 0, + resource: cg_buffer.as_entire_binding(), + }], }); render_pass.set_bind_group(1, &cg_bind_group, &[]); } else { diff --git a/crates/compositor/src/pipeline.rs b/crates/compositor/src/pipeline.rs index 5a659cd..d0a8028 100644 --- a/crates/compositor/src/pipeline.rs +++ b/crates/compositor/src/pipeline.rs @@ -58,24 +58,7 @@ struct ColorGradingUniforms { highlights: f32, shadows: f32, lut_size: f32, - input_cst: u32, - output_cst: u32, - _pad_align: vec2, - // Qualifier correction CDL - q_slope: vec4, - q_offset: vec4, - q_power: vec4, - q_adjustments: vec4, - // Power window - window_center_scale: vec4, - window_shape: vec4, - window_params: vec4, - w_slope: vec4, - w_offset: vec4, - w_power: vec4, - w_adjustments: vec4, - window_mix: vec4, - _pad: array, 7>, + _pad: array, 4>, }; @group(0) @binding(0) var uniforms: LayerUniforms; @@ -83,8 +66,6 @@ struct ColorGradingUniforms { @group(0) @binding(2) var s_diffuse: sampler; @group(1) @binding(0) var cg: ColorGradingUniforms; -@group(1) @binding(1) var t_lut_3d: texture_3d; -@group(1) @binding(2) var s_lut: sampler; // Quad vertices (two triangles) - corners of a unit quad [0,0] to [1,1] // Will be scaled by texture dimensions in vertex shader @@ -184,156 +165,6 @@ fn hsl_to_rgb(hsl: vec3) -> vec3 { ); } -// ============================================================================ -// Color Space Transforms -// ============================================================================ - -// Color space IDs (must match Rust ColorSpace enum) -const CS_SRGB: u32 = 0u; -const CS_LINEAR: u32 = 1u; -const CS_ACES_CG: u32 = 2u; -const CS_LOGC: u32 = 3u; -const CS_SLOG2: u32 = 4u; -const CS_SLOG3: u32 = 5u; -const CS_CLOG3: u32 = 6u; -const CS_VLOG: u32 = 7u; -const CS_BM_FILM: u32 = 8u; -const CS_RED_LOG3G10: u32 = 9u; - -// sRGB <-> Linear -fn srgb_to_linear(srgb: vec3) -> vec3 { - let cutoff = vec3(0.04045); - let linear_low = srgb / 12.92; - let linear_high = pow((srgb + 0.055) / 1.055, vec3(2.4)); - return select(linear_low, linear_high, srgb > cutoff); -} - -fn linear_to_srgb(linear: vec3) -> vec3 { - let cutoff = vec3(0.0031308); - let srgb_low = linear * 12.92; - let srgb_high = 1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055; - return select(srgb_low, srgb_high, linear > cutoff); -} - -// Sony S-Log2 <-> Linear -fn slog2_to_linear(slog: vec3) -> vec3 { - let linear_low = (slog - 0.030001222851889303) / 3.53881278538813; - let linear_high = pow(vec3(10.0), (slog - 0.616596 - 0.03) / 0.432699) - 0.037584; - return select(linear_low, linear_high, slog >= vec3(0.0929)); -} - -fn linear_to_slog2(linear: vec3) -> vec3 { - let cut = 0.0; - let slog_low = linear * 3.53881278538813 + 0.030001222851889303; - let slog_high = 0.432699 * log(linear + 0.037584) / log(10.0) + 0.616596 + 0.03; - return select(slog_low, slog_high, linear >= vec3(cut)); -} - -// ARRI LogC3 <-> Linear (EI 800) -fn logc_to_linear(logc: vec3) -> vec3 { - let a = 5.555556; - let b = 0.052272; - let c = 0.247190; - let d = 0.385537; - let e_val = 5.367655; - let cut = 0.1496582; - let linear_low = (logc - d) / e_val; - let linear_high = (pow(vec3(10.0), (logc - c) / a) - b) / a; - return select(linear_low, linear_high, logc > vec3(cut)); -} - -fn linear_to_logc(linear: vec3) -> vec3 { - let a = 5.555556; - let b = 0.052272; - let c = 0.247190; - let d = 0.385537; - let e_val = 5.367655; - let cut = 0.010591; - let logc_low = e_val * linear + d; - let logc_high = a * log(a * linear + b) / log(10.0) + c; - return select(logc_low, logc_high, linear > vec3(cut)); -} - -// Sony S-Log3 <-> Linear -fn slog3_to_linear(slog: vec3) -> vec3 { - let linear_low = (slog - 0.030001222851889303) / 5.26; - let linear_high = pow(vec3(10.0), (slog - 0.410557184750733) / 0.255620723362659) * 0.19 - 0.01; - return select(linear_low, linear_high, slog >= vec3(0.1673609920)); -} - -fn linear_to_slog3(linear: vec3) -> vec3 { - let cut = 0.01125000; - let slog_low = linear * 5.26 + 0.030001222851889303; - let slog_high = (420.0 + log((linear + 0.01) / 0.19) / log(10.0) * 261.5) / 1023.0; - return select(slog_low, slog_high, linear >= vec3(cut)); -} - -// Canon CLog3 <-> Linear (simplified) -fn clog3_to_linear(clog: vec3) -> vec3 { - let cut = 0.097465473; - let linear_low = (clog - 0.073059361) / 5.0; - let linear_high = (pow(vec3(10.0), (clog - 0.449369) / 0.42889912) - 1.0) * 0.08; - return select(linear_low, linear_high, clog > vec3(cut)); -} - -fn linear_to_clog3(linear: vec3) -> vec3 { - let cut = 0.014; - let clog_low = linear * 5.0 + 0.073059361; - let clog_high = 0.42889912 * log(linear / 0.08 + 1.0) / log(10.0) + 0.449369; - return select(clog_low, clog_high, linear > vec3(cut)); -} - -// Panasonic V-Log <-> Linear -fn vlog_to_linear(vlog: vec3) -> vec3 { - let cut_in = 0.181; - let linear_low = (vlog - 0.125) / 5.6; - let linear_high = pow(vec3(10.0), (vlog - 0.598206) / 0.241514) - 0.00873; - return select(linear_low, linear_high, vlog >= vec3(cut_in)); -} - -fn linear_to_vlog(linear: vec3) -> vec3 { - let cut = 0.01; - let vlog_low = linear * 5.6 + 0.125; - let vlog_high = 0.241514 * log(linear + 0.00873) / log(10.0) + 0.598206; - return select(vlog_low, vlog_high, linear >= vec3(cut)); -} - -// Convert any color space to linear -fn to_linear(color: vec3, cs: u32) -> vec3 { - switch cs { - case CS_LINEAR: { return color; } - case CS_SRGB: { return srgb_to_linear(color); } - case CS_LOGC: { return logc_to_linear(color); } - case CS_SLOG2: { return slog2_to_linear(color); } - case CS_SLOG3: { return slog3_to_linear(color); } - case CS_CLOG3: { return clog3_to_linear(color); } - case CS_VLOG: { return vlog_to_linear(color); } - // ACES CG is already linear (just different primaries, simplified here) - case CS_ACES_CG: { return color; } - // BmFilm and RedLog3G10 simplified as log curves - case CS_BM_FILM: { return logc_to_linear(color); } - case CS_RED_LOG3G10: { return slog3_to_linear(color); } - default: { return srgb_to_linear(color); } - } -} - -// Convert from linear to any color space -fn from_linear(color: vec3, cs: u32) -> vec3 { - switch cs { - case CS_LINEAR: { return color; } - case CS_SRGB: { return linear_to_srgb(color); } - case CS_LOGC: { return linear_to_logc(color); } - case CS_SLOG2: { return linear_to_slog2(color); } - case CS_SLOG3: { return linear_to_slog3(color); } - case CS_CLOG3: { return linear_to_clog3(color); } - case CS_VLOG: { return linear_to_vlog(color); } - case CS_ACES_CG: { return color; } - case CS_BM_FILM: { return linear_to_logc(color); } - case CS_RED_LOG3G10: { return linear_to_slog3(color); } - default: { return linear_to_srgb(color); } - } -} - // ============================================================================ // Color Grading // ============================================================================ @@ -341,11 +172,6 @@ fn from_linear(color: vec3, cs: u32) -> vec3 { const CG_FLAG_BYPASS: u32 = 1u; const CG_FLAG_PRIMARY_ENABLED: u32 = 2u; const CG_FLAG_WHEELS_ENABLED: u32 = 4u; -const CG_FLAG_LUT_ENABLED: u32 = 16u; -const CG_FLAG_QUALIFIER_ENABLED: u32 = 32u; -const CG_FLAG_WINDOW_ENABLED: u32 = 64u; -const CG_FLAG_INPUT_CST: u32 = 128u; -const CG_FLAG_OUTPUT_CST: u32 = 256u; fn cg_luminance(rgb: vec3) -> f32 { return dot(rgb, vec3(0.2126, 0.7152, 0.0722)); @@ -424,170 +250,11 @@ fn apply_lift_gamma_gain( return mix(color, result, mix_amount); } -// ============================================================================ -// HSL Qualifier -// ============================================================================ - -fn rgb_to_hsl_cg(rgb: vec3) -> vec3 { - let max_c = max(max(rgb.r, rgb.g), rgb.b); - let min_c = min(min(rgb.r, rgb.g), rgb.b); - let delta = max_c - min_c; - let l = (max_c + min_c) * 0.5; - if (delta < 0.00001) { - return vec3(0.0, 0.0, l); - } - let s = select(delta / (2.0 - max_c - min_c), delta / (max_c + min_c), l < 0.5); - var h: f32; - if (max_c == rgb.r) { - h = (rgb.g - rgb.b) / delta + select(0.0, 6.0, rgb.g < rgb.b); - } else if (max_c == rgb.g) { - h = (rgb.b - rgb.r) / delta + 2.0; - } else { - h = (rgb.r - rgb.g) / delta + 4.0; - } - h /= 6.0; - return vec3(h, s, l); -} - -fn hsl_qualifier_mask( - hsl: vec3, - center: vec3, - width: vec3, - softness: vec3, - invert_flag: f32, -) -> f32 { - // Hue distance (circular) - var hue_diff = abs(hsl.x - center.x); - hue_diff = min(hue_diff, 1.0 - hue_diff); - let sat_diff = abs(hsl.y - center.y); - let lum_diff = abs(hsl.z - center.z); - - let hue_inner = width.x * (1.0 - softness.x); - let hue_mask = 1.0 - smoothstep(hue_inner, width.x, hue_diff); - let sat_inner = width.y * (1.0 - softness.y); - let sat_mask = 1.0 - smoothstep(sat_inner, width.y, sat_diff); - let lum_inner = width.z * (1.0 - softness.z); - let lum_mask = 1.0 - smoothstep(lum_inner, width.z, lum_diff); - - var mask = hue_mask * sat_mask * lum_mask; - if (invert_flag > 0.5) { - mask = 1.0 - mask; - } - return mask; -} - -fn apply_qualifier(color: vec3) -> vec3 { - let hsl = rgb_to_hsl_cg(color); - let mask = hsl_qualifier_mask( - hsl, - cg.qualifier_center.xyz, - cg.qualifier_width.xyz, - cg.qualifier_softness.xyz, - cg.qualifier_softness.w, - ); - // Apply correction within qualified region - var corrected = apply_cdl(color, cg.q_slope.rgb, cg.q_offset.rgb, cg.q_power.rgb); - corrected = corrected * pow(2.0, cg.q_adjustments.y); // exposure - let lum_q = cg_luminance(corrected); - corrected = mix(vec3(lum_q), corrected, cg.q_adjustments.x); // saturation - // Desaturate non-qualified region so user can see the selection - let outside = mix(vec3(cg_luminance(color)), color, 0.3); - return mix(outside, corrected, mask * cg.qualifier_mix); -} - -// ============================================================================ -// Power Window -// ============================================================================ - -fn power_window_mask(uv: vec2) -> f32 { - let center = cg.window_center_scale.xy; - let scale = cg.window_center_scale.zw; - let rotation = cg.window_params.x * 6.28318530718; // normalized to radians - let softness_inner = cg.window_params.y; - let softness_outer = cg.window_params.z; - let invert_flag = cg.window_params.w; - let shape_type = cg.window_shape.w; - - // Transform UV relative to window center, accounting for rotation and scale - var p = uv - center; - let cos_r = cos(rotation); - let sin_r = sin(rotation); - p = vec2(p.x * cos_r + p.y * sin_r, -p.x * sin_r + p.y * cos_r); - p = p / max(scale, vec2(0.001)); - - var dist: f32; - if (shape_type < 0.5) { - // Circle/Ellipse - let r = cg.window_shape.xy; - let d = p / max(r, vec2(0.001)); - dist = length(d); - } else if (shape_type < 1.5) { - // Rectangle - let half_size = cg.window_shape.xy * 0.5; - let corner = cg.window_shape.z; - let d = abs(p) - half_size + corner; - dist = (length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - corner) - / max(min(half_size.x, half_size.y), 0.001) + 1.0; - } else { - // Gradient - let angle = cg.window_shape.x * 6.28318530718; - let dir = vec2(cos(angle), sin(angle)); - dist = dot(p, dir) + 0.5; - } - - // Apply softness - let edge_start = 1.0 - softness_inner; - let edge_end = 1.0 + softness_outer; - var mask = 1.0 - smoothstep(edge_start, edge_end, dist); - - if (invert_flag > 0.5) { - mask = 1.0 - mask; - } - return mask; -} - -fn apply_window(color: vec3, uv: vec2) -> vec3 { - let mask = power_window_mask(uv); - if (mask < 0.001) { - return color; - } - var corrected = apply_cdl(color, cg.w_slope.rgb, cg.w_offset.rgb, cg.w_power.rgb); - corrected = corrected * pow(2.0, cg.w_adjustments.y); // exposure - let lum_w = cg_luminance(corrected); - corrected = mix(vec3(lum_w), corrected, cg.w_adjustments.x); // saturation - return mix(color, corrected, mask * cg.window_mix.x); -} - -// ============================================================================ -// 3D LUT -// ============================================================================ - -fn apply_lut(color: vec3, lut_mix: f32) -> vec3 { - let lut_size = cg.lut_size; - // Scale color to LUT coordinates with half-texel offset for correct sampling - let half_texel = 0.5 / lut_size; - let scale = (lut_size - 1.0) / lut_size; - let lut_coord = clamp(color, vec3(0.0), vec3(1.0)) * scale + half_texel; - let lut_color = textureSampleLevel(t_lut_3d, s_lut, lut_coord, 0.0).rgb; - return mix(color, lut_color, lut_mix); -} - -// ============================================================================ -// Combined Color Grading -// ============================================================================ - -fn apply_color_grading(color: vec3, uv: vec2) -> vec3 { +fn apply_color_grading(color: vec3) -> vec3 { if ((cg.flags & CG_FLAG_BYPASS) != 0u) { return color; } var result = color; - - // Input CST: convert from source color space to linear for grading - if ((cg.flags & CG_FLAG_INPUT_CST) != 0u) { - result = to_linear(result, cg.input_cst); - } - - // Primary correction (operates in linear) if ((cg.flags & CG_FLAG_PRIMARY_ENABLED) != 0u) { result = apply_primary_correction( result, @@ -597,8 +264,6 @@ fn apply_color_grading(color: vec3, uv: vec2) -> vec3 { cg.highlights, cg.shadows, cg.primary_mix ); } - - // Color wheels (operates in linear) if ((cg.flags & CG_FLAG_WHEELS_ENABLED) != 0u) { result = apply_lift_gamma_gain( result, @@ -608,27 +273,6 @@ fn apply_color_grading(color: vec3, uv: vec2) -> vec3 { cg.wheels_mix ); } - - // 3D LUT - if ((cg.flags & CG_FLAG_LUT_ENABLED) != 0u) { - result = apply_lut(result, cg.lut_mix); - } - - // HSL Qualifier (secondary correction within color range) - if ((cg.flags & CG_FLAG_QUALIFIER_ENABLED) != 0u) { - result = apply_qualifier(result); - } - - // Power Window (regional correction) - if ((cg.flags & CG_FLAG_WINDOW_ENABLED) != 0u) { - result = apply_window(result, uv); - } - - // Output CST: convert from linear to output color space - if ((cg.flags & CG_FLAG_OUTPUT_CST) != 0u) { - result = from_linear(result, cg.output_cst); - } - return clamp(result, vec3(0.0), vec3(1.0)); } @@ -685,7 +329,7 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } // Apply color grading - color = vec4(apply_color_grading(color.rgb, in.tex_coord), color.a); + color = vec4(apply_color_grading(color.rgb), color.a); // Apply wipe transition masking (for cross-transition wipes) // Wipe types: 3=WipeLeft, 4=WipeRight, 5=WipeUp, 6=WipeDown @@ -768,37 +412,16 @@ pub fn create_bind_group_layout(device: &Device) -> BindGroupLayout { pub fn create_color_grading_bind_group_layout(device: &Device) -> BindGroupLayout { device.create_bind_group_layout(&BindGroupLayoutDescriptor { label: Some("color_grading_bind_group_layout"), - entries: &[ - // binding 0: color grading uniforms - BindGroupLayoutEntry { - binding: 0, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, - }, - count: None, - }, - // binding 1: 3D LUT texture (Rgba16Float — supports linear filtering) - BindGroupLayoutEntry { - binding: 1, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Texture { - sample_type: TextureSampleType::Float { filterable: true }, - view_dimension: TextureViewDimension::D3, - multisampled: false, - }, - count: None, + entries: &[BindGroupLayoutEntry { + binding: 0, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, }, - // binding 2: LUT sampler (linear filtering for smooth interpolation) - BindGroupLayoutEntry { - binding: 2, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Sampler(SamplerBindingType::Filtering), - count: None, - }, - ], + count: None, + }], }) } diff --git a/crates/types/src/color_grading.rs b/crates/types/src/color_grading.rs index 5087b07..c124208 100644 --- a/crates/types/src/color_grading.rs +++ b/crates/types/src/color_grading.rs @@ -32,8 +32,6 @@ pub enum ColorSpace { AcesCg, /// ARRI Log C (wide dynamic range). LogC, - /// Sony S-Log2. - SLog2, /// Sony S-Log3. SLog3, /// Canon Log 3. @@ -619,14 +617,6 @@ impl Default for PowerWindow { // Color Grading Nodes // ============================================================================ -/// Node position in the graph editor. -#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Tsify)] -#[tsify(into_wasm_abi, from_wasm_abi)] -pub struct NodePosition { - pub x: f32, - pub y: f32, -} - /// A node in the color grading pipeline. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Tsify)] #[tsify(into_wasm_abi, from_wasm_abi)] @@ -644,10 +634,6 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, - /// Graph editor position. - #[serde(skip_serializing_if = "Option::is_none")] - #[tsify(optional)] - position: Option, /// Correction parameters. correction: PrimaryCorrection, }, @@ -660,9 +646,6 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[tsify(optional)] - position: Option, wheels: ColorWheels, }, @@ -674,9 +657,6 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[tsify(optional)] - position: Option, curves: Curves, }, @@ -688,9 +668,6 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[tsify(optional)] - position: Option, lut: LutReference, }, @@ -702,9 +679,6 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[tsify(optional)] - position: Option, qualifier: HslQualifier, /// Correction to apply within qualified region. correction: PrimaryCorrection, @@ -718,30 +692,10 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[tsify(optional)] - position: Option, window: PowerWindow, /// Correction to apply within window. correction: PrimaryCorrection, }, - - /// Color space transform. - ColorSpaceTransform { - id: String, - enabled: bool, - mix: f32, - #[serde(skip_serializing_if = "Option::is_none")] - #[tsify(optional)] - label: Option, - #[serde(skip_serializing_if = "Option::is_none")] - #[tsify(optional)] - position: Option, - /// Source color space. - from_space: ColorSpace, - /// Target color space. - to_space: ColorSpace, - }, } impl ColorGradingNode { @@ -753,8 +707,7 @@ impl ColorGradingNode { | Self::Curves { id, .. } | Self::Lut { id, .. } | Self::Qualifier { id, .. } - | Self::Window { id, .. } - | Self::ColorSpaceTransform { id, .. } => id, + | Self::Window { id, .. } => id, } } @@ -766,8 +719,7 @@ impl ColorGradingNode { | Self::Curves { enabled, .. } | Self::Lut { enabled, .. } | Self::Qualifier { enabled, .. } - | Self::Window { enabled, .. } - | Self::ColorSpaceTransform { enabled, .. } => *enabled, + | Self::Window { enabled, .. } => *enabled, } } @@ -779,8 +731,7 @@ impl ColorGradingNode { | Self::Curves { mix, .. } | Self::Lut { mix, .. } | Self::Qualifier { mix, .. } - | Self::Window { mix, .. } - | Self::ColorSpaceTransform { mix, .. } => *mix, + | Self::Window { mix, .. } => *mix, } } } diff --git a/packages/render-engine/src/types.ts b/packages/render-engine/src/types.ts index 8cb9ced..223b9e2 100644 --- a/packages/render-engine/src/types.ts +++ b/packages/render-engine/src/types.ts @@ -265,7 +265,6 @@ export type ColorSpace = | "Linear" | "AcesCg" | "LogC" - | "SLog2" | "SLog3" | "CLog3" | "VLog" @@ -379,12 +378,6 @@ export interface PowerWindow { invert: boolean; } -/** Node position in the graph editor. */ -export interface NodePosition { - x: number; - y: number; -} - /** Color grading node types. */ export type ColorGradingNode = | { @@ -393,7 +386,6 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; - position?: NodePosition; correction: PrimaryCorrection; } | { @@ -402,7 +394,6 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; - position?: NodePosition; wheels: ColorWheels; } | { @@ -411,7 +402,6 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; - position?: NodePosition; curves: Curves; } | { @@ -420,7 +410,6 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; - position?: NodePosition; lut: LutReference; } | { @@ -429,7 +418,6 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; - position?: NodePosition; qualifier: HslQualifier; correction: PrimaryCorrection; } @@ -439,19 +427,8 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; - position?: NodePosition; window: PowerWindow; correction: PrimaryCorrection; - } - | { - type: "ColorSpaceTransform"; - id: string; - enabled: boolean; - mix: number; - label?: string; - position?: NodePosition; - from_space: ColorSpace; - to_space: ColorSpace; }; /** Complete color grading configuration. */ @@ -620,24 +597,6 @@ export const DEFAULT_HSL_QUALIFIER: HslQualifier = { invert: false, }; -export const DEFAULT_LUT_REFERENCE: LutReference = { - lut_id: "", - interpolation: "Tetrahedral", - mix: 1.0, -}; - -export const DEFAULT_POWER_WINDOW: PowerWindow = { - shape: { Circle: { radius_x: 0.25, radius_y: 0.25 } }, - center_x: 0.5, - center_y: 0.5, - scale_x: 1, - scale_y: 1, - rotation: 0, - softness_inner: 0, - softness_outer: 0.1, - invert: false, -}; - export const DEFAULT_COLOR_GRADING: ColorGrading = { input_color_space: "Srgb", output_color_space: "Srgb", From ef2bcd7fc163587fcf7256f6e6a0b3539e97afa1 Mon Sep 17 00:00:00 2001 From: Mohamad Mohebifar Date: Sun, 5 Apr 2026 12:00:00 -0700 Subject: [PATCH 2/6] feat: add color grading --- .../color-grading/color-grading-panel.tsx | 168 ++++++++---- .../editor/color-grading/color-wheel.tsx | 7 +- .../color-grading/color-wheels-properties.tsx | 104 ++++--- .../editor/color-grading/node-graph.tsx | 103 ++++--- .../src/components/editor/export-dialog.tsx | 16 +- .../components/editor/properties-panel.tsx | 17 ++ apps/ui/src/components/ui/dropdown-menu.tsx | 256 ++++++++++++++++++ .../compositor/src/color_grading_uniforms.rs | 70 ++++- crates/compositor/src/pipeline.rs | 172 +++++++++++- crates/types/src/color_grading.rs | 55 +++- packages/render-engine/src/types.ts | 23 ++ 11 files changed, 839 insertions(+), 152 deletions(-) create mode 100644 apps/ui/src/components/ui/dropdown-menu.tsx diff --git a/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx b/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx index 425a6b6..cd404b6 100644 --- a/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx +++ b/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx @@ -10,6 +10,7 @@ import type { ColorGrading, + ColorSpace, PrimaryCorrection, ColorWheels, ColorGradingNode as CGNode, @@ -36,10 +37,11 @@ import { import { useState, useCallback, useMemo } from "react"; import { Button } from "../../ui/button"; -import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover"; +import { SearchableDropdown, type SearchableDropdownItem } from "../../ui/searchable-dropdown"; import { Separator } from "../../ui/separator"; import { Toggle } from "../../ui/toggle"; import { ColorWheelsProperties } from "./color-wheels-properties"; +import { CstProperties } from "./cst-properties"; import { ColorGradingNodeGraph } from "./node-graph"; import { PrimaryCorrectionProperties } from "./primary-correction"; @@ -109,6 +111,13 @@ const NODE_TYPE_CONFIGS: NodeTypeConfig[] = [ description: "Regional mask", available: false, }, + { + type: "ColorSpaceTransform", + label: "Color Space", + icon: Palette, + description: "Convert color space", + available: true, + }, ]; // ============================================================================ @@ -133,6 +142,22 @@ export function ColorGradingPanel({ [grading.nodes, selectedNodeId], ); + // Update a CST node + const handleUpdateCstNode = useCallback( + (updates: { from_space?: ColorSpace; to_space?: ColorSpace }) => { + if (!selectedNode || selectedNode.type !== "ColorSpaceTransform") return; + + const newNodes = grading.nodes.map((node) => { + if (node.id === selectedNode.id && node.type === "ColorSpaceTransform") { + return { ...node, ...updates }; + } + return node; + }); + onColorGradingChange({ ...grading, nodes: newNodes }); + }, + [grading, onColorGradingChange, selectedNode], + ); + // Toggle bypass const handleBypassToggle = useCallback( (bypass: boolean) => { @@ -165,15 +190,32 @@ export function ColorGradingPanel({ wheels: { ...DEFAULT_COLOR_WHEELS }, }; break; + case "ColorSpaceTransform": + newNode = { + type: "ColorSpaceTransform", + id: `cst-${Date.now()}`, + enabled: true, + mix: 1, + from_space: "SLog3", + to_space: "Srgb", + }; + break; default: return; } - const newNodes = [...grading.nodes, newNode]; + // Insert after the selected node, or at the end if nothing selected + const newNodes = [...grading.nodes]; + const selectedIndex = selectedNodeId + ? newNodes.findIndex((n) => n.id === selectedNodeId) + : -1; + const insertAt = selectedIndex !== -1 ? selectedIndex + 1 : newNodes.length; + newNodes.splice(insertAt, 0, newNode); + onColorGradingChange({ ...grading, nodes: newNodes }); setSelectedNodeId(newNode.id); }, - [grading, onColorGradingChange], + [grading, onColorGradingChange, selectedNodeId], ); // Toggle node enabled state @@ -210,6 +252,17 @@ export function ColorGradingPanel({ [grading, onColorGradingChange], ); + // Update node position (persisted to store) + const handleUpdateNodePosition = useCallback( + (nodeId: string, x: number, y: number) => { + const newNodes = grading.nodes.map((node) => + node.id === nodeId ? { ...node, position: { x, y } } : node, + ); + onColorGradingChange({ ...grading, nodes: newNodes }); + }, + [grading, onColorGradingChange], + ); + // Update a primary correction node const handleUpdatePrimaryNode = useCallback( (key: keyof PrimaryCorrection, value: number | [number, number, number]) => { @@ -279,18 +332,23 @@ export function ColorGradingPanel({
- {/* Node Graph */} - - - {/* Add Node */} - +
+ {/* Node Graph */} + + +
+ {/* Add Node */} + +
+
{/* Selected Node Parameters */} {selectedNode && ( @@ -302,6 +360,7 @@ export function ColorGradingPanel({ node={selectedNode} onUpdatePrimary={handleUpdatePrimaryNode} onUpdateColorWheels={handleUpdateColorWheelsNode} + onUpdateCst={handleUpdateCstNode} /> )} @@ -324,48 +383,30 @@ interface AddNodeMenuProps { onAddNode: (type: CGNode["type"]) => void; } -function AddNodeMenu({ onAddNode }: AddNodeMenuProps) { - const [open, setOpen] = useState(false); - - const handleAdd = (type: CGNode["type"]) => { - onAddNode(type); - setOpen(false); - }; +const addNodeItems: SearchableDropdownItem[] = NODE_TYPE_CONFIGS.map((config) => ({ + key: config.type, + label: config.label, + description: config.description, + disabled: !config.available, + icon: config.icon, + trailing: !config.available ? ( + Soon + ) : undefined, +})); +function AddNodeMenu({ onAddNode }: AddNodeMenuProps) { return ( - - - - - -
- {NODE_TYPE_CONFIGS.map((config) => { - const Icon = config.icon; - return ( - - ); - })} -
-
-
+ onAddNode(key as CGNode["type"])} + placeholder="Search nodes..." + align="start" + > + + ); } @@ -379,14 +420,20 @@ interface NodeParameterEditorProps { node: CGNode; onUpdatePrimary: (key: keyof PrimaryCorrection, value: number | [number, number, number]) => void; onUpdateColorWheels: (updates: Partial) => void; + onUpdateCst: (updates: { from_space?: ColorSpace; to_space?: ColorSpace }) => void; } +// ============================================================================ +// Node Parameter Editor +// ============================================================================ + function NodeParameterEditor({ clipId, clipStartTime, node, onUpdatePrimary, onUpdateColorWheels, + onUpdateCst, }: NodeParameterEditorProps) { const [expanded, setExpanded] = useState(true); @@ -404,6 +451,8 @@ function NodeParameterEditor({ return "HSL Qualifier"; case "Window": return "Power Window"; + case "ColorSpaceTransform": + return "Color Space Transform"; default: return "Unknown"; } @@ -428,7 +477,7 @@ function NodeParameterEditor({ {/* Parameters */} {expanded && ( -
+
{node.type === "Primary" && ( Power Window coming soon

)} + {node.type === "ColorSpaceTransform" && ( + + )}
)}
diff --git a/apps/ui/src/components/editor/color-grading/color-wheel.tsx b/apps/ui/src/components/editor/color-grading/color-wheel.tsx index 952da15..5caf6d4 100644 --- a/apps/ui/src/components/editor/color-grading/color-wheel.tsx +++ b/apps/ui/src/components/editor/color-grading/color-wheel.tsx @@ -1,6 +1,7 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { cn } from "../../../lib/utils"; +import { useVideoEditorStore } from "../../../state/video-editor-store"; import { Slider } from "../../ui/slider"; interface ColorWheelProps { @@ -161,6 +162,7 @@ export function ColorWheel({ canvas.setPointerCapture(e.pointerId); setIsDragging(true); dragStartRef.current = { angle, distance }; + useVideoEditorStore.temporal.getState().pause(); // Calculate position const rect = canvas.getBoundingClientRect(); @@ -220,6 +222,7 @@ export function ColorWheel({ const handlePointerUp = useCallback(() => { setIsDragging(false); dragStartRef.current = null; + useVideoEditorStore.temporal.getState().resume(); }, []); const handleDoubleClick = useCallback(() => { @@ -233,7 +236,7 @@ export function ColorWheel({ }, [onLuminanceChange]); return ( -
+
{label}
@@ -260,6 +263,8 @@ export function ColorWheel({ max={1} step={0.01} onValueChange={([v]) => onLuminanceChange(v)} + onValueCommit={() => useVideoEditorStore.temporal.getState().resume()} + onPointerDown={() => useVideoEditorStore.temporal.getState().pause()} disabled={disabled} className="flex-1" /> diff --git a/apps/ui/src/components/editor/color-grading/color-wheels-properties.tsx b/apps/ui/src/components/editor/color-grading/color-wheels-properties.tsx index 71eeb39..0d2467f 100644 --- a/apps/ui/src/components/editor/color-grading/color-wheels-properties.tsx +++ b/apps/ui/src/components/editor/color-grading/color-wheels-properties.tsx @@ -1,9 +1,10 @@ import type { ColorWheels, ColorWheelValue } from "@tooscut/render-engine"; import { RotateCcw } from "lucide-react"; -import { useCallback } from "react"; +import { useCallback, useMemo } from "react"; import { Button } from "../../ui/button"; +import { ResetButton } from "../../ui/reset-button"; import { ColorWheel } from "./color-wheel"; interface ColorWheelsPropertiesProps { @@ -27,6 +28,10 @@ const DEFAULT_WHEEL: ColorWheelValue = { angle: 0, distance: 0 }; * - Distance from center (color intensity) * - Luminance slider (brightness adjustment) */ +function isWheelDirty(wheel: ColorWheelValue, luminance: number): boolean { + return wheel.distance > 0.001 || Math.abs(luminance) > 0.001; +} + export function ColorWheelsProperties({ wheels, onWheelsChange }: ColorWheelsPropertiesProps) { // Handlers for lift wheel const handleLiftColorChange = useCallback( @@ -98,25 +103,41 @@ export function ColorWheelsProperties({ wheels, onWheelsChange }: ColorWheelsPro onWheelsChange({ gain: DEFAULT_WHEEL, gain_luminance: 0 }); }, [onWheelsChange]); + const liftDirty = useMemo( + () => isWheelDirty(wheels.lift, wheels.lift_luminance), + [wheels.lift, wheels.lift_luminance], + ); + const gammaDirty = useMemo( + () => isWheelDirty(wheels.gamma, wheels.gamma_luminance), + [wheels.gamma, wheels.gamma_luminance], + ); + const gainDirty = useMemo( + () => isWheelDirty(wheels.gain, wheels.gain_luminance), + [wheels.gain, wheels.gain_luminance], + ); + const anyDirty = liftDirty || gammaDirty || gainDirty; + return ( -
+
{/* Header with reset button */} -
+
Color Wheels - + {anyDirty && ( + + )}
- {/* Three wheels in a row */} -
+ {/* Wheels — column below 320px, 3-col row above */} +
{/* Lift (Shadows) */}
- +
+ {liftDirty ? ( + + ) : ( + + )} + Shadows + +
{/* Gamma (Midtones) */} @@ -149,14 +171,15 @@ export function ColorWheelsProperties({ wheels, onWheelsChange }: ColorWheelsPro onLuminanceChange={handleGammaLuminanceChange} size={100} /> - +
+ {gammaDirty ? ( + + ) : ( + + )} + Midtones + +
{/* Gain (Highlights) */} @@ -170,19 +193,20 @@ export function ColorWheelsProperties({ wheels, onWheelsChange }: ColorWheelsPro onLuminanceChange={handleGainLuminanceChange} size={100} /> - +
+ {gainDirty ? ( + + ) : ( + + )} + Highlights + +
{/* Instructions */} -

+

Drag to adjust color. Double-click to reset. Shift+drag for saturation only.

diff --git a/apps/ui/src/components/editor/color-grading/node-graph.tsx b/apps/ui/src/components/editor/color-grading/node-graph.tsx index f337c66..3593a35 100644 --- a/apps/ui/src/components/editor/color-grading/node-graph.tsx +++ b/apps/ui/src/components/editor/color-grading/node-graph.tsx @@ -11,16 +11,18 @@ import type { Node, NodeProps, Edge } from "@xyflow/react"; import { ReactFlow, + ReactFlowProvider, Background, BackgroundVariant, useNodesState, useEdgesState, + useReactFlow, Handle, Position, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { Eye, EyeOff, Trash2, GripVertical } from "lucide-react"; -import { useCallback, useMemo, useEffect, memo } from "react"; +import { useCallback, useMemo, useEffect, useRef, memo } from "react"; import { cn } from "../../../lib/utils"; @@ -80,6 +82,8 @@ function getNodePreview(node: CGNode): string { if ("Gradient" in shape) return "Gradient"; return "Unknown"; } + case "ColorSpaceTransform": + return `${node.from_space} → ${node.to_space}`; default: return "Unknown"; } @@ -102,6 +106,8 @@ function getNodeTheme(type: CGNode["type"]): { bg: string; border: string; accen return { bg: "bg-pink-950/50", border: "border-pink-700/50", accent: "text-pink-400" }; case "Window": return { bg: "bg-cyan-950/50", border: "border-cyan-700/50", accent: "text-cyan-400" }; + case "ColorSpaceTransform": + return { bg: "bg-sky-950/50", border: "border-sky-700/50", accent: "text-sky-400" }; default: return { bg: "bg-neutral-900", border: "border-neutral-700", accent: "text-neutral-400" }; } @@ -125,6 +131,8 @@ function getNodeLabel(node: CGNode): string { return "Qualifier"; case "Window": return "Window"; + case "ColorSpaceTransform": + return "CST"; default: return "Unknown"; } @@ -149,14 +157,14 @@ const ColorGradingNodeComponent = memo(function ColorGradingNodeComponent({ )} {/* Node content */}
)} @@ -257,37 +265,53 @@ interface ColorGradingNodeGraphProps { onToggleNodeEnabled: (nodeId: string, enabled: boolean) => void; onRemoveNode: (nodeId: string) => void; onReorderNodes: (fromIndex: number, toIndex: number) => void; + onUpdateNodePosition: (nodeId: string, x: number, y: number) => void; } const NODE_WIDTH = 160; const NODE_GAP = 60; -export function ColorGradingNodeGraph({ +export function ColorGradingNodeGraph(props: ColorGradingNodeGraphProps) { + return ( + + + + ); +} + +function ColorGradingNodeGraphInner({ nodes, selectedNodeId, onSelectNode, onToggleNodeEnabled, onRemoveNode, - onReorderNodes, + onUpdateNodePosition, }: ColorGradingNodeGraphProps) { - // Convert color grading nodes to React Flow nodes + const { fitView } = useReactFlow(); + const prevNodeCountRef = useRef(nodes.length); + + // Build React Flow nodes — positions come from the node data (persisted in store) const flowNodes = useMemo((): ColorGradingFlowNode[] => { - return nodes.map((node, index) => ({ - id: node.id, - type: "colorGrading", - position: { x: index * (NODE_WIDTH + NODE_GAP), y: 0 }, - data: { - node, - index, - isFirst: index === 0, - isLast: index === nodes.length - 1, - onToggleEnabled: (enabled: boolean) => onToggleNodeEnabled(node.id, enabled), - onRemove: () => onRemoveNode(node.id), - onSelect: () => onSelectNode(node.id), - isSelected: node.id === selectedNodeId, - }, - draggable: true, - })); + return nodes.map((node, index) => { + const defaultPos = { x: index * (NODE_WIDTH + NODE_GAP), y: 0 }; + const pos = node.position ?? defaultPos; + return { + id: node.id, + type: "colorGrading", + position: { x: pos.x, y: pos.y }, + data: { + node, + index, + isFirst: index === 0, + isLast: index === nodes.length - 1, + onToggleEnabled: (enabled: boolean) => onToggleNodeEnabled(node.id, enabled), + onRemove: () => onRemoveNode(node.id), + onSelect: () => onSelectNode(node.id), + isSelected: node.id === selectedNodeId, + }, + draggable: true, + }; + }); }, [nodes, selectedNodeId, onToggleNodeEnabled, onRemoveNode, onSelectNode]); // Create edges connecting nodes in sequence @@ -308,7 +332,7 @@ export function ColorGradingNodeGraph({ const [rfNodes, setRfNodes, onNodesChange] = useNodesState(flowNodes); const [rfEdges, setRfEdges, onEdgesChange] = useEdgesState(flowEdges); - // Sync React Flow nodes when props change + // Sync React Flow state when source data changes useEffect(() => { setRfNodes(flowNodes); }, [flowNodes, setRfNodes]); @@ -317,21 +341,20 @@ export function ColorGradingNodeGraph({ setRfEdges(flowEdges); }, [flowEdges, setRfEdges]); - // Handle node drag end for reordering + // Fit view when nodes are added or removed + useEffect(() => { + if (nodes.length !== prevNodeCountRef.current) { + prevNodeCountRef.current = nodes.length; + requestAnimationFrame(() => void fitView({ padding: 0.3 })); + } + }, [nodes.length, fitView]); + + // Persist position to store on drag end const onNodeDragStop = useCallback( (_event: React.MouseEvent, node: Node) => { - const currentIndex = nodes.findIndex((n) => n.id === node.id); - if (currentIndex === -1) return; - - // Calculate new index based on x position - const newIndex = Math.round(node.position.x / (NODE_WIDTH + NODE_GAP)); - const clampedIndex = Math.max(0, Math.min(nodes.length - 1, newIndex)); - - if (clampedIndex !== currentIndex) { - onReorderNodes(currentIndex, clampedIndex); - } + onUpdateNodePosition(node.id, node.position.x, node.position.y); }, - [nodes, onReorderNodes], + [onUpdateNodePosition], ); // Handle click on background to deselect @@ -348,7 +371,7 @@ export function ColorGradingNodeGraph({ } return ( -
+
- {QUALITY_PRESETS.map((preset) => ( - - {preset.label} + + {preset.label} ({formatBitrate(preset.bitrate)}) ))} diff --git a/apps/ui/src/components/editor/properties-panel.tsx b/apps/ui/src/components/editor/properties-panel.tsx index c5fd498..acfa113 100644 --- a/apps/ui/src/components/editor/properties-panel.tsx +++ b/apps/ui/src/components/editor/properties-panel.tsx @@ -1,5 +1,14 @@ import type { Effects, AudioEffectsParams, ColorGrading } from "@tooscut/render-engine"; +import { + ClapperboardIcon, + ImageIcon, + PaletteIcon, + ShapesIcon, + SparklesIcon, + TextIcon, + Volume2, +} from "lucide-react"; import { useMemo, useState, useCallback } from "react"; import { useVideoEditorStore } from "../../state/video-editor-store"; @@ -247,41 +256,49 @@ export function PropertiesPanel() { > {showPicture && ( + Picture )} {showAudio && ( + Audio )} {showText && ( + Text )} {showShape && ( + Shape )} {showLine && ( + Line )} {showEffect && ( + Effect )} {showColor && ( + Color )} {showTransition && ( + Transition )} diff --git a/apps/ui/src/components/ui/dropdown-menu.tsx b/apps/ui/src/components/ui/dropdown-menu.tsx new file mode 100644 index 0000000..a0a01e6 --- /dev/null +++ b/apps/ui/src/components/ui/dropdown-menu.tsx @@ -0,0 +1,256 @@ +import { Menu as MenuPrimitive } from "@base-ui/react/menu"; +import { ChevronRightIcon, CheckIcon } from "lucide-react"; +import * as React from "react"; + +import { cn } from "@/lib/utils"; + +function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { + return ; +} + +function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { + return ; +} + +function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { + return ; +} + +function DropdownMenuContent({ + align = "start", + alignOffset = 0, + side = "bottom", + sideOffset = 4, + className, + ...props +}: MenuPrimitive.Popup.Props & + Pick) { + return ( + + + + + + ); +} + +function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { + return ; +} + +function DropdownMenuLabel({ + className, + inset, + ...props +}: MenuPrimitive.GroupLabel.Props & { + inset?: boolean; +}) { + return ( + + ); +} + +function DropdownMenuItem({ + className, + inset, + variant = "default", + ...props +}: MenuPrimitive.Item.Props & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { + return ; +} + +function DropdownMenuSubTrigger({ + className, + inset, + children, + ...props +}: MenuPrimitive.SubmenuTrigger.Props & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function DropdownMenuSubContent({ + align = "start", + alignOffset = -3, + side = "right", + sideOffset = 0, + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DropdownMenuCheckboxItem({ + className, + children, + checked, + inset, + ...props +}: MenuPrimitive.CheckboxItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { + return ; +} + +function DropdownMenuRadioItem({ + className, + children, + inset, + ...props +}: MenuPrimitive.RadioItem.Props & { + inset?: boolean; +}) { + return ( + + + + + + + {children} + + ); +} + +function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { + return ( + + ); +} + +function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + DropdownMenu, + DropdownMenuPortal, + DropdownMenuTrigger, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuItem, + DropdownMenuCheckboxItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubTrigger, + DropdownMenuSubContent, +}; diff --git a/crates/compositor/src/color_grading_uniforms.rs b/crates/compositor/src/color_grading_uniforms.rs index 2ffbf2a..791ee2c 100644 --- a/crates/compositor/src/color_grading_uniforms.rs +++ b/crates/compositor/src/color_grading_uniforms.rs @@ -302,8 +302,15 @@ pub struct ColorGradingUniforms { /// LUT size (cube dimension, e.g., 33). pub lut_size: f32, - // Padding to 256 bytes (64 bytes) - pub _pad: [f32; 16], + /// Input color space transform (ColorSpace enum as u32, 0 = sRGB). + pub input_cst: u32, + /// Output color space transform (ColorSpace enum as u32, 0 = sRGB). + pub output_cst: u32, + /// Padding to maintain vec4 alignment. + pub _pad_align: [f32; 2], + + // Padding to 256 bytes (48 bytes) + pub _pad: [f32; 12], } impl Default for ColorGradingUniforms { @@ -327,7 +334,10 @@ impl Default for ColorGradingUniforms { highlights: 0.0, shadows: 0.0, lut_size: 33.0, - _pad: [0.0; 16], + input_cst: 0, + output_cst: 0, + _pad_align: [0.0; 2], + _pad: [0.0; 12], } } } @@ -339,6 +349,25 @@ pub const FLAG_WHEELS_ENABLED: u32 = 1 << 2; pub const FLAG_CURVES_ENABLED: u32 = 1 << 3; pub const FLAG_LUT_ENABLED: u32 = 1 << 4; pub const FLAG_QUALIFIER_ENABLED: u32 = 1 << 5; +pub const FLAG_INPUT_CST: u32 = 1 << 6; +pub const FLAG_OUTPUT_CST: u32 = 1 << 7; + +/// Convert ColorSpace enum to u32 for shader. +fn color_space_to_u32(cs: &tooscut_types::ColorSpace) -> u32 { + use tooscut_types::ColorSpace; + match cs { + ColorSpace::Srgb => 0, + ColorSpace::Linear => 1, + ColorSpace::AcesCg => 2, + ColorSpace::LogC => 3, + ColorSpace::SLog2 => 4, + ColorSpace::SLog3 => 5, + ColorSpace::CLog3 => 6, + ColorSpace::VLog => 7, + ColorSpace::BmFilm => 8, + ColorSpace::RedLog3G10 => 9, + } +} impl ColorGradingUniforms { /// Create uniforms from a ColorGrading configuration. @@ -353,6 +382,39 @@ impl ColorGradingUniforms { return uniforms; } + // Scan for CST nodes: first enabled CST → input, last enabled CST → output + let mut first_cst: Option<(tooscut_types::ColorSpace, tooscut_types::ColorSpace)> = None; + let mut last_cst: Option<(tooscut_types::ColorSpace, tooscut_types::ColorSpace)> = None; + for node in &grading.nodes { + if let ColorGradingNode::ColorSpaceTransform { + enabled: true, + from_space, + to_space, + .. + } = node + { + if first_cst.is_none() { + first_cst = Some((from_space.clone(), to_space.clone())); + } + last_cst = Some((from_space.clone(), to_space.clone())); + } + } + + // First CST node: use from_space as input CST (convert from source to linear) + if let Some((from_space, _)) = &first_cst { + if *from_space != tooscut_types::ColorSpace::Srgb { + uniforms.flags |= FLAG_INPUT_CST; + uniforms.input_cst = color_space_to_u32(from_space); + } + } + // Last CST node: use to_space as output CST (convert from linear to target) + if let Some((_, to_space)) = &last_cst { + if *to_space != tooscut_types::ColorSpace::Srgb { + uniforms.flags |= FLAG_OUTPUT_CST; + uniforms.output_cst = color_space_to_u32(to_space); + } + } + for node in &grading.nodes { if !node.is_enabled() { continue; @@ -415,6 +477,8 @@ impl ColorGradingUniforms { ]; uniforms.qualifier_mix = *mix; } + // CST handled above in the pre-scan + ColorGradingNode::ColorSpaceTransform { .. } => {} // Curves and Window require additional textures/data, handled separately _ => {} } diff --git a/crates/compositor/src/pipeline.rs b/crates/compositor/src/pipeline.rs index d0a8028..4736721 100644 --- a/crates/compositor/src/pipeline.rs +++ b/crates/compositor/src/pipeline.rs @@ -58,7 +58,10 @@ struct ColorGradingUniforms { highlights: f32, shadows: f32, lut_size: f32, - _pad: array, 4>, + input_cst: u32, + output_cst: u32, + _pad_align: vec2, + _pad: array, 3>, }; @group(0) @binding(0) var uniforms: LayerUniforms; @@ -165,6 +168,156 @@ fn hsl_to_rgb(hsl: vec3) -> vec3 { ); } +// ============================================================================ +// Color Space Transforms +// ============================================================================ + +// Color space IDs (must match Rust ColorSpace enum) +const CS_SRGB: u32 = 0u; +const CS_LINEAR: u32 = 1u; +const CS_ACES_CG: u32 = 2u; +const CS_LOGC: u32 = 3u; +const CS_SLOG2: u32 = 4u; +const CS_SLOG3: u32 = 5u; +const CS_CLOG3: u32 = 6u; +const CS_VLOG: u32 = 7u; +const CS_BM_FILM: u32 = 8u; +const CS_RED_LOG3G10: u32 = 9u; + +// sRGB <-> Linear +fn srgb_to_linear(srgb: vec3) -> vec3 { + let cutoff = vec3(0.04045); + let linear_low = srgb / 12.92; + let linear_high = pow((srgb + 0.055) / 1.055, vec3(2.4)); + return select(linear_low, linear_high, srgb > cutoff); +} + +fn linear_to_srgb(linear: vec3) -> vec3 { + let cutoff = vec3(0.0031308); + let srgb_low = linear * 12.92; + let srgb_high = 1.055 * pow(linear, vec3(1.0 / 2.4)) - 0.055; + return select(srgb_low, srgb_high, linear > cutoff); +} + +// Sony S-Log2 <-> Linear +fn slog2_to_linear(slog: vec3) -> vec3 { + let linear_low = (slog - 0.030001222851889303) / 3.53881278538813; + let linear_high = pow(vec3(10.0), (slog - 0.616596 - 0.03) / 0.432699) - 0.037584; + return select(linear_low, linear_high, slog >= vec3(0.0929)); +} + +fn linear_to_slog2(linear: vec3) -> vec3 { + let cut = 0.0; + let slog_low = linear * 3.53881278538813 + 0.030001222851889303; + let slog_high = 0.432699 * log(linear + 0.037584) / log(10.0) + 0.616596 + 0.03; + return select(slog_low, slog_high, linear >= vec3(cut)); +} + +// ARRI LogC3 <-> Linear (EI 800) +fn logc_to_linear(logc: vec3) -> vec3 { + let a = 5.555556; + let b = 0.052272; + let c = 0.247190; + let d = 0.385537; + let e_val = 5.367655; + let cut = 0.1496582; + let linear_low = (logc - d) / e_val; + let linear_high = (pow(vec3(10.0), (logc - c) / a) - b) / a; + return select(linear_low, linear_high, logc > vec3(cut)); +} + +fn linear_to_logc(linear: vec3) -> vec3 { + let a = 5.555556; + let b = 0.052272; + let c = 0.247190; + let d = 0.385537; + let e_val = 5.367655; + let cut = 0.010591; + let logc_low = e_val * linear + d; + let logc_high = a * log(a * linear + b) / log(10.0) + c; + return select(logc_low, logc_high, linear > vec3(cut)); +} + +// Sony S-Log3 <-> Linear +fn slog3_to_linear(slog: vec3) -> vec3 { + let linear_low = (slog - 0.030001222851889303) / 5.26; + let linear_high = pow(vec3(10.0), (slog - 0.410557184750733) / 0.255620723362659) * 0.19 - 0.01; + return select(linear_low, linear_high, slog >= vec3(0.1673609920)); +} + +fn linear_to_slog3(linear: vec3) -> vec3 { + let cut = 0.01125000; + let slog_low = linear * 5.26 + 0.030001222851889303; + let slog_high = (420.0 + log((linear + 0.01) / 0.19) / log(10.0) * 261.5) / 1023.0; + return select(slog_low, slog_high, linear >= vec3(cut)); +} + +// Canon CLog3 <-> Linear (simplified) +fn clog3_to_linear(clog: vec3) -> vec3 { + let cut = 0.097465473; + let linear_low = (clog - 0.073059361) / 5.0; + let linear_high = (pow(vec3(10.0), (clog - 0.449369) / 0.42889912) - 1.0) * 0.08; + return select(linear_low, linear_high, clog > vec3(cut)); +} + +fn linear_to_clog3(linear: vec3) -> vec3 { + let cut = 0.014; + let clog_low = linear * 5.0 + 0.073059361; + let clog_high = 0.42889912 * log(linear / 0.08 + 1.0) / log(10.0) + 0.449369; + return select(clog_low, clog_high, linear > vec3(cut)); +} + +// Panasonic V-Log <-> Linear +fn vlog_to_linear(vlog: vec3) -> vec3 { + let cut_in = 0.181; + let linear_low = (vlog - 0.125) / 5.6; + let linear_high = pow(vec3(10.0), (vlog - 0.598206) / 0.241514) - 0.00873; + return select(linear_low, linear_high, vlog >= vec3(cut_in)); +} + +fn linear_to_vlog(linear: vec3) -> vec3 { + let cut = 0.01; + let vlog_low = linear * 5.6 + 0.125; + let vlog_high = 0.241514 * log(linear + 0.00873) / log(10.0) + 0.598206; + return select(vlog_low, vlog_high, linear >= vec3(cut)); +} + +// Convert any color space to linear +fn to_linear(color: vec3, cs: u32) -> vec3 { + switch cs { + case CS_LINEAR: { return color; } + case CS_SRGB: { return srgb_to_linear(color); } + case CS_LOGC: { return logc_to_linear(color); } + case CS_SLOG2: { return slog2_to_linear(color); } + case CS_SLOG3: { return slog3_to_linear(color); } + case CS_CLOG3: { return clog3_to_linear(color); } + case CS_VLOG: { return vlog_to_linear(color); } + // ACES CG is already linear (just different primaries, simplified here) + case CS_ACES_CG: { return color; } + // BmFilm and RedLog3G10 simplified as log curves + case CS_BM_FILM: { return logc_to_linear(color); } + case CS_RED_LOG3G10: { return slog3_to_linear(color); } + default: { return srgb_to_linear(color); } + } +} + +// Convert from linear to any color space +fn from_linear(color: vec3, cs: u32) -> vec3 { + switch cs { + case CS_LINEAR: { return color; } + case CS_SRGB: { return linear_to_srgb(color); } + case CS_LOGC: { return linear_to_logc(color); } + case CS_SLOG2: { return linear_to_slog2(color); } + case CS_SLOG3: { return linear_to_slog3(color); } + case CS_CLOG3: { return linear_to_clog3(color); } + case CS_VLOG: { return linear_to_vlog(color); } + case CS_ACES_CG: { return color; } + case CS_BM_FILM: { return linear_to_logc(color); } + case CS_RED_LOG3G10: { return linear_to_slog3(color); } + default: { return linear_to_srgb(color); } + } +} + // ============================================================================ // Color Grading // ============================================================================ @@ -172,6 +325,8 @@ fn hsl_to_rgb(hsl: vec3) -> vec3 { const CG_FLAG_BYPASS: u32 = 1u; const CG_FLAG_PRIMARY_ENABLED: u32 = 2u; const CG_FLAG_WHEELS_ENABLED: u32 = 4u; +const CG_FLAG_INPUT_CST: u32 = 64u; +const CG_FLAG_OUTPUT_CST: u32 = 128u; fn cg_luminance(rgb: vec3) -> f32 { return dot(rgb, vec3(0.2126, 0.7152, 0.0722)); @@ -255,6 +410,13 @@ fn apply_color_grading(color: vec3) -> vec3 { return color; } var result = color; + + // Input CST: convert from source color space to linear for grading + if ((cg.flags & CG_FLAG_INPUT_CST) != 0u) { + result = to_linear(result, cg.input_cst); + } + + // Primary correction (operates in linear) if ((cg.flags & CG_FLAG_PRIMARY_ENABLED) != 0u) { result = apply_primary_correction( result, @@ -264,6 +426,8 @@ fn apply_color_grading(color: vec3) -> vec3 { cg.highlights, cg.shadows, cg.primary_mix ); } + + // Color wheels (operates in linear) if ((cg.flags & CG_FLAG_WHEELS_ENABLED) != 0u) { result = apply_lift_gamma_gain( result, @@ -273,6 +437,12 @@ fn apply_color_grading(color: vec3) -> vec3 { cg.wheels_mix ); } + + // Output CST: convert from linear to output color space + if ((cg.flags & CG_FLAG_OUTPUT_CST) != 0u) { + result = from_linear(result, cg.output_cst); + } + return clamp(result, vec3(0.0), vec3(1.0)); } diff --git a/crates/types/src/color_grading.rs b/crates/types/src/color_grading.rs index c124208..5087b07 100644 --- a/crates/types/src/color_grading.rs +++ b/crates/types/src/color_grading.rs @@ -32,6 +32,8 @@ pub enum ColorSpace { AcesCg, /// ARRI Log C (wide dynamic range). LogC, + /// Sony S-Log2. + SLog2, /// Sony S-Log3. SLog3, /// Canon Log 3. @@ -617,6 +619,14 @@ impl Default for PowerWindow { // Color Grading Nodes // ============================================================================ +/// Node position in the graph editor. +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, Tsify)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub struct NodePosition { + pub x: f32, + pub y: f32, +} + /// A node in the color grading pipeline. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Tsify)] #[tsify(into_wasm_abi, from_wasm_abi)] @@ -634,6 +644,10 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, + /// Graph editor position. + #[serde(skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + position: Option, /// Correction parameters. correction: PrimaryCorrection, }, @@ -646,6 +660,9 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + position: Option, wheels: ColorWheels, }, @@ -657,6 +674,9 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + position: Option, curves: Curves, }, @@ -668,6 +688,9 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + position: Option, lut: LutReference, }, @@ -679,6 +702,9 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + position: Option, qualifier: HslQualifier, /// Correction to apply within qualified region. correction: PrimaryCorrection, @@ -692,10 +718,30 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + position: Option, window: PowerWindow, /// Correction to apply within window. correction: PrimaryCorrection, }, + + /// Color space transform. + ColorSpaceTransform { + id: String, + enabled: bool, + mix: f32, + #[serde(skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + label: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + position: Option, + /// Source color space. + from_space: ColorSpace, + /// Target color space. + to_space: ColorSpace, + }, } impl ColorGradingNode { @@ -707,7 +753,8 @@ impl ColorGradingNode { | Self::Curves { id, .. } | Self::Lut { id, .. } | Self::Qualifier { id, .. } - | Self::Window { id, .. } => id, + | Self::Window { id, .. } + | Self::ColorSpaceTransform { id, .. } => id, } } @@ -719,7 +766,8 @@ impl ColorGradingNode { | Self::Curves { enabled, .. } | Self::Lut { enabled, .. } | Self::Qualifier { enabled, .. } - | Self::Window { enabled, .. } => *enabled, + | Self::Window { enabled, .. } + | Self::ColorSpaceTransform { enabled, .. } => *enabled, } } @@ -731,7 +779,8 @@ impl ColorGradingNode { | Self::Curves { mix, .. } | Self::Lut { mix, .. } | Self::Qualifier { mix, .. } - | Self::Window { mix, .. } => *mix, + | Self::Window { mix, .. } + | Self::ColorSpaceTransform { mix, .. } => *mix, } } } diff --git a/packages/render-engine/src/types.ts b/packages/render-engine/src/types.ts index 223b9e2..6a68617 100644 --- a/packages/render-engine/src/types.ts +++ b/packages/render-engine/src/types.ts @@ -265,6 +265,7 @@ export type ColorSpace = | "Linear" | "AcesCg" | "LogC" + | "SLog2" | "SLog3" | "CLog3" | "VLog" @@ -378,6 +379,12 @@ export interface PowerWindow { invert: boolean; } +/** Node position in the graph editor. */ +export interface NodePosition { + x: number; + y: number; +} + /** Color grading node types. */ export type ColorGradingNode = | { @@ -386,6 +393,7 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; + position?: NodePosition; correction: PrimaryCorrection; } | { @@ -394,6 +402,7 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; + position?: NodePosition; wheels: ColorWheels; } | { @@ -402,6 +411,7 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; + position?: NodePosition; curves: Curves; } | { @@ -410,6 +420,7 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; + position?: NodePosition; lut: LutReference; } | { @@ -418,6 +429,7 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; + position?: NodePosition; qualifier: HslQualifier; correction: PrimaryCorrection; } @@ -427,8 +439,19 @@ export type ColorGradingNode = enabled: boolean; mix: number; label?: string; + position?: NodePosition; window: PowerWindow; correction: PrimaryCorrection; + } + | { + type: "ColorSpaceTransform"; + id: string; + enabled: boolean; + mix: number; + label?: string; + position?: NodePosition; + from_space: ColorSpace; + to_space: ColorSpace; }; /** Complete color grading configuration. */ From 9796b19112e3e529c7633d7647dba67911c52fc6 Mon Sep 17 00:00:00 2001 From: Mohamad Mohebifar Date: Sun, 5 Apr 2026 12:00:00 -0700 Subject: [PATCH 3/6] feat: add LUT management functionality - Implemented LUT asset management in `lut-manager.ts` for importing, persisting, hydrating, and uploading LUT files to the GPU. - Enhanced the video editor store to support LUT assets with new properties and types. - Integrated LUT hydration during project loading in the editor route. - Updated compositor API to handle uploading and removing LUTs. - Added support for 3D LUT textures in the compositor, including texture creation and sampling in shaders. - Introduced new uniforms and bindings for LUT processing in the rendering pipeline. - Added default LUT and power window configurations in types. --- .beads/issues.jsonl | 8 +- .../color-grading/color-grading-panel.tsx | 187 +++++++++-- .../editor/color-grading/node-graph.tsx | 299 ++++++++++++------ apps/ui/src/lib/lut-manager.ts | 27 +- .../compositor/src/color_grading_uniforms.rs | 118 ++++++- crates/compositor/src/compositor.rs | 199 +++++++++++- crates/compositor/src/pipeline.rs | 235 +++++++++++++- packages/render-engine/src/types.ts | 18 ++ 8 files changed, 921 insertions(+), 170 deletions(-) diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 3cac2cc..36224f6 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,10 +22,10 @@ {"id":"tooscut-and.3","title":"Color wheels (Lift/Gamma/Gain)","description":"## Summary\nImplement Lift/Gamma/Gain color wheels for intuitive shadow/midtone/highlight color adjustment.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `color_wheel_to_rgb()` - convert angle/distance to RGB offset\n- [ ] `apply_lift_gamma_gain()` function:\n - Lift affects shadows (adds color when pixel is dark)\n - Gamma affects midtones (power function)\n - Gain affects highlights (multiplies)\n- [ ] Each wheel has RGB color shift + luminance master\n\n### UI Components\n- [ ] `ColorWheel` component (Canvas-based):\n - Circular color picker with saturation gradient\n - Draggable position indicator\n - Double-click to reset to center\n - Luminance slider below wheel\n- [ ] `ColorWheelsPanel` with three wheels (Lift, Gamma, Gain)\n- [ ] \"Link wheels\" toggle for global adjustments\n- [ ] Reset button per wheel and global reset\n\n### Interaction Design\n- [ ] Mouse drag updates angle + distance from center\n- [ ] Shift+drag constrains to current angle (saturation only)\n- [ ] Ctrl+drag for fine adjustment (0.1x speed)\n- [ ] Keyboard: arrow keys for small adjustments when focused\n\n### State Management\n- [ ] Add `updateClipColorWheels(clipId, wheels)` action\n- [ ] Wheel values stored as `{ angle: number, distance: number }`\n\n## Acceptance Criteria\n- Dragging lift wheel adds color to dark areas only\n- Dragging gain wheel adds color to bright areas only\n- Gamma affects midtones without crushing blacks/whites\n- Luminance sliders work independently from color shift\n- All changes are undoable","notes":"Completed: Canvas-based ColorWheel component with drag/shift+drag/ctrl+drag interactions, ColorWheelsProperties panel (Lift/Gamma/Gain), and React Flow node graph visualization. The node graph shows the color grading pipeline with color-coded nodes, enable/disable toggles, and drag-to-reorder support.","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:02:55.013616-07:00","updated_at":"2026-04-04T16:58:32.135196-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.3","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:02:55.015382-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.3","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:02:55.018517-07:00","created_by":"mohebifar"}]} {"id":"tooscut-and.4","title":"Curves editor","description":"## Summary\nImplement RGB curves for precise tonal control, plus advanced curves (Hue vs Sat, Lum vs Sat, etc.).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `evaluate_curve()` function using 1D LUT texture sampling\n- [ ] Support for smooth spline interpolation between control points\n- [ ] Apply curves in correct order: Master → R → G → B → Advanced\n\n### Curve LUT Generation (TypeScript)\n- [ ] `generateCurveLUT(points: CurvePoint[])` - outputs 256-entry Float32Array\n- [ ] Catmull-Rom or cubic Bezier interpolation between points\n- [ ] Handle edge cases (points at 0,0 and 1,1)\n\n### UI Components\n- [ ] `CurvesEditor` component (Canvas-based):\n - 256x256 grid with diagonal reference line\n - Click to add control point\n - Drag to move control point\n - Double-click or Delete key to remove point\n - Smooth curve rendering through points\n- [ ] Channel selector tabs: Master, Red, Green, Blue\n- [ ] Advanced curves dropdown: Hue vs Sat, Hue vs Lum, Sat vs Sat, Lum vs Sat\n- [ ] Preset curves: S-curve, fade, negative, etc.\n- [ ] Reset button\n\n### Advanced Curves\n- [ ] Hue vs Hue - rotate hues selectively\n- [ ] Hue vs Sat - saturate/desaturate specific hues\n- [ ] Hue vs Lum - brighten/darken specific hues\n- [ ] Lum vs Sat - saturate shadows/highlights differently\n- [ ] Sat vs Sat - compress or expand saturation range\n\n### State Management\n- [ ] Add `updateClipCurves(clipId, curves)` action\n- [ ] Curve presets stored separately for quick application\n\n### Performance\n- [ ] Regenerate LUT texture only when curve points change\n- [ ] Debounce during drag operations\n- [ ] Cache curve textures per clip\n\n## Acceptance Criteria\n- Dragging curve up brightens, down darkens\n- RGB curves affect only their respective channels\n- S-curve increases contrast visually\n- Smooth interpolation (no stair-stepping)\n- 60fps interaction during curve editing","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:09.958519-07:00","updated_at":"2026-04-04T14:03:09.958519-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.4","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:09.960905-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.4","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:09.964293-07:00","created_by":"mohebifar"}]} {"id":"tooscut-and.5","title":"3D LUT support (.cube files)","description":"## Summary\nImplement 3D LUT loading, GPU upload, and tetrahedral interpolation for professional color transforms.\n\n## Tasks\n\n### .cube File Parser (TypeScript)\n- [ ] `parseCubeFile(content: string): CubeLUT`\n- [ ] Parse header: TITLE, LUT_3D_SIZE, DOMAIN_MIN, DOMAIN_MAX\n- [ ] Parse RGB triplet data lines\n- [ ] Validate cube size (common: 17, 33, 65)\n- [ ] Handle comments and empty lines\n\n### LUT Manager (Rust/WASM)\n- [ ] `LutManager` struct with HashMap of loaded LUTs\n- [ ] `upload_lut(id, size, data)` - creates 3D texture on GPU\n- [ ] `remove_lut(id)` - frees GPU memory\n- [ ] `get_lut_texture(id)` - returns texture view for binding\n\n### WGSL Shader Implementation\n- [ ] `apply_lut_trilinear()` - basic trilinear interpolation\n- [ ] `apply_lut_tetrahedral()` - higher quality tetrahedral interpolation\n- [ ] Handle domain min/max scaling\n- [ ] LUT sampler with clamp-to-edge addressing\n\n### UI Components\n- [ ] `LutBrowserPanel` component:\n - Grid view of available LUTs with thumbnails\n - Search/filter by name\n - Preview on hover\n - Click to apply\n- [ ] LUT import button (file picker for .cube)\n- [ ] LUT intensity slider (mix with original)\n- [ ] Remove LUT button\n\n### LUT Thumbnail Generation\n- [ ] Generate preview by applying LUT to standard gradient image\n- [ ] Cache thumbnails in IndexedDB\n\n### State Management\n- [ ] Add `loadedLuts: Map\u003cstring, LoadedLut\u003e` to store\n- [ ] Add `loadLut(file)`, `removeLut(id)`, `setClipLut(clipId, lutId)` actions\n- [ ] LUT data persisted in project (or referenced by path)\n\n### Memory Management\n- [ ] Limit simultaneous loaded LUTs (e.g., 10 max)\n- [ ] LRU eviction for unused LUTs\n- [ ] 33³ × 16 bytes = ~575KB per LUT\n\n## Acceptance Criteria\n- .cube files from DaVinci/Resolve load correctly\n- LUT applied matches reference implementation\n- Tetrahedral interpolation has no visible banding\n- LUT browser shows accurate previews\n- Memory usage stays bounded with many LUTs","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:25.934398-07:00","updated_at":"2026-04-04T14:41:14.358352-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.5","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:25.936583-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.5","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:03:25.938607-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.6","title":"HSL Qualifier (secondary color correction)","description":"## Summary\nImplement HSL qualifier for isolating and correcting specific colors (secondary color correction / keying).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `hsl_qualifier_mask()` function:\n - Hue center + width + softness (circular distance)\n - Saturation center + width + softness\n - Luminance center + width + softness\n - Combine masks multiplicatively\n - Invert option\n- [ ] Apply correction multiplied by qualifier mask\n- [ ] Edge softness using smoothstep\n\n### UI Components\n- [ ] `HslQualifierPanel` component:\n - Hue range selector (circular/strip visualization)\n - Saturation range selector (horizontal bar)\n - Luminance range selector (horizontal bar)\n - Softness controls for each dimension\n - Invert toggle\n- [ ] Eyedropper tool to pick qualifier center from preview\n- [ ] \"Show Mask\" toggle to visualize selection (B\u0026W mask view)\n- [ ] Correction controls (reuse PrimaryCorrectionPanel) applied to qualified region\n\n### Eyedropper Interaction\n- [ ] Click on preview to sample pixel HSL values\n- [ ] Shift+click to add to selection (expand range)\n- [ ] Alt+click to subtract from selection\n- [ ] Sample multiple pixels for average\n\n### Mask Visualization\n- [ ] Toggle between: Normal view, Mask overlay, Mask only\n- [ ] Mask overlay shows selection as highlight color\n- [ ] Mask only shows B\u0026W (white = selected)\n\n### State Management\n- [ ] Add `HslQualifierNode` to color grading node types\n- [ ] Qualifier stores center, width, softness for H/S/L\n- [ ] Correction stored within qualifier node\n\n## Acceptance Criteria\n- Can isolate skin tones and adjust without affecting background\n- Can select sky blue and increase saturation\n- Soft edges blend naturally (no harsh cutoffs)\n- Eyedropper accurately picks colors from preview\n- Mask visualization helps dial in selection","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:39.983433-07:00","updated_at":"2026-04-04T14:03:39.983433-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:39.985365-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:39.987334-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.7","title":"Power windows (masks for regional correction)","description":"## Summary\nImplement power windows (geometric masks) for regional color corrections - vignettes, sky gradients, face isolation, etc.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] Shape mask generators:\n - Circle/Ellipse\n - Rectangle with corner radius\n - Linear gradient\n - Polygon (for complex shapes)\n- [ ] Transform: position, scale, rotation\n- [ ] Inner/outer softness (feathering)\n- [ ] Invert option\n- [ ] Combine with qualifier mask (AND operation)\n\n### UI Components\n- [ ] `PowerWindowPanel` component:\n - Shape selector (circle, rectangle, gradient, polygon)\n - Position X/Y sliders (% of frame)\n - Scale X/Y sliders\n - Rotation slider\n - Softness inner/outer sliders\n - Invert toggle\n- [ ] On-canvas shape overlay:\n - Draggable shape outline on preview\n - Corner handles for resize\n - Rotation handle\n - Center point for position\n\n### Shape Drawing\n- [ ] Circle: center + radius, draggable edge\n- [ ] Rectangle: corner handles, aspect ratio lock option\n- [ ] Gradient: start point + end point + angle\n- [ ] Polygon: click to add points, close path\n\n### Tracking (stretch goal)\n- [ ] Manual keyframe tracking (position/scale/rotation keyframes)\n- [ ] Data structure for tracked window transforms\n- [ ] Interpolation between tracking keyframes\n\n### Combination with Qualifier\n- [ ] Power window can be combined with HSL qualifier\n- [ ] \"Window AND Qualifier\" mode - both must match\n- [ ] Useful for: face in specific area, sky in upper region only\n\n## Acceptance Criteria\n- Can create vignette effect with soft circular window\n- Can isolate upper sky with gradient window\n- Can draw polygon around subject\n- On-canvas controls are intuitive (drag to move, handles to resize)\n- Soft edges blend naturally","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:54.558574-07:00","updated_at":"2026-04-04T14:03:54.558574-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:03:54.563678-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:54.560807-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.8","title":"Video scopes (waveform, vectorscope, histogram)","description":"## Summary\nImplement professional video scopes computed on GPU for real-time color analysis.\n\n## Tasks\n\n### Scope Types\n- [ ] **Histogram** - RGB + Luma distribution (256 bins each)\n- [ ] **Waveform** - Luma or RGB waveform (brightness by horizontal position)\n- [ ] **RGB Parade** - Separate R/G/B waveforms side by side\n- [ ] **Vectorscope** - Color distribution on color wheel (hue angle + saturation radius)\n\n### WGSL Compute Shaders\n- [ ] `compute_histogram.wgsl`:\n - Atomic histogram binning\n - Output: 4 × 256 buffer (R, G, B, Luma)\n- [ ] `compute_waveform.wgsl`:\n - For each column, accumulate pixel brightness into rows\n - Output: 2D texture (width × 256)\n- [ ] `compute_vectorscope.wgsl`:\n - Map each pixel to 2D position by hue/sat\n - Accumulate density\n - Output: 256×256 texture\n\n### Rust/WASM Implementation\n- [ ] `ScopeComputer` struct managing compute pipelines\n- [ ] `compute_histogram(texture_id) -\u003e Vec\u003cf32\u003e`\n- [ ] `compute_waveform(texture_id, width) -\u003e Vec\u003cu8\u003e`\n- [ ] `compute_vectorscope(texture_id, size) -\u003e Vec\u003cu8\u003e`\n\n### UI Components\n- [ ] `ScopesPanel` component:\n - Scope type selector tabs\n - Canvas for scope display\n - Graticule overlays (scale markers)\n- [ ] Histogram: stacked or separate R/G/B view option\n- [ ] Waveform: Luma / RGB / Parade mode selector\n- [ ] Vectorscope: skin tone line, color target boxes\n\n### Graticules and Reference Lines\n- [ ] Histogram: 0%, 50%, 100% markers\n- [ ] Waveform: IRE/percentage scale, legal range indicators (16-235)\n- [ ] Vectorscope: color boxes (R, Mg, B, Cy, G, Yl), skin tone line\n\n### Performance\n- [ ] Compute scopes on requestAnimationFrame, not every frame\n- [ ] Throttle during playback (every 2-3 frames)\n- [ ] Full rate when paused\n- [ ] Resolution option: 1x, 1/2, 1/4 for faster computation\n\n## Acceptance Criteria\n- Histogram matches reference scope software\n- Waveform shows correct brightness distribution\n- Vectorscope shows correct color positions\n- Scopes update in real-time during playback (\u003c16ms compute time)\n- Graticules help interpret scope readings","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:11.037853-07:00","updated_at":"2026-04-04T14:41:14.406509-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:04:11.04237-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:11.040172-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.9","title":"Node-based color grading pipeline","description":"## Summary\nImplement node-based color grading pipeline allowing multiple stacked corrections executed in order.\n\n## Tasks\n\n### Data Structures\n- [ ] `ColorGradingNode` union type (primary, wheels, curves, LUT, qualifier, window)\n- [ ] `ClipColorGrading` with ordered `nodes: ColorGradingNode[]` array\n- [ ] Each node has: id, type, enabled, mix, label\n\n### Node Execution Engine (Rust/WASM)\n- [ ] `execute_color_pipeline(texture, nodes) -\u003e texture`\n- [ ] Execute nodes in order, each reading previous output\n- [ ] Skip disabled nodes\n- [ ] Apply per-node mix (blend with input)\n- [ ] Intermediate render targets for multi-pass\n\n### UI Components\n- [ ] `NodeListPanel` component:\n - Vertical list of nodes (not visual graph, like Resolve's mini timeline)\n - Drag to reorder\n - Enable/disable toggle per node\n - Mix slider per node\n - Expand/collapse node settings\n - Add node button (dropdown of types)\n - Delete node button\n- [ ] Node type icons for quick identification\n- [ ] \"Solo\" button to preview single node effect\n\n### Node Operations\n- [ ] Add node (at end or after selected)\n- [ ] Remove node\n- [ ] Reorder nodes (drag \u0026 drop)\n- [ ] Duplicate node\n- [ ] Enable/disable node\n- [ ] Reset node to defaults\n- [ ] Copy/paste node between clips\n\n### Bypass and Preview\n- [ ] Global bypass toggle (show original)\n- [ ] \"Solo node\" mode - only selected node active\n- [ ] A/B comparison split view (future enhancement)\n\n### State Management\n- [ ] Add `addColorGradingNode(clipId, node, index?)` action\n- [ ] Add `removeColorGradingNode(clipId, nodeId)` action\n- [ ] Add `reorderColorGradingNodes(clipId, fromIndex, toIndex)` action\n- [ ] Add `updateColorGradingNode(clipId, nodeId, updates)` action\n\n## Acceptance Criteria\n- Can stack multiple corrections (e.g., Primary → Curves → LUT)\n- Node order affects output (e.g., LUT before vs after primary)\n- Disabling node removes its effect\n- Mix slider blends node effect with input\n- Drag reordering updates preview immediately","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:26.186835-07:00","updated_at":"2026-04-04T14:04:26.186835-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.3","type":"blocks","created_at":"2026-04-04T14:04:26.188467-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.4","type":"blocks","created_at":"2026-04-04T14:04:26.189223-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.5","type":"blocks","created_at":"2026-04-04T14:04:26.189985-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:04:26.190624-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.7","type":"blocks","created_at":"2026-04-04T14:04:26.191412-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:26.187706-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.6","title":"HSL Qualifier (secondary color correction)","description":"## Summary\nImplement HSL qualifier for isolating and correcting specific colors (secondary color correction / keying).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `hsl_qualifier_mask()` function:\n - Hue center + width + softness (circular distance)\n - Saturation center + width + softness\n - Luminance center + width + softness\n - Combine masks multiplicatively\n - Invert option\n- [ ] Apply correction multiplied by qualifier mask\n- [ ] Edge softness using smoothstep\n\n### UI Components\n- [ ] `HslQualifierPanel` component:\n - Hue range selector (circular/strip visualization)\n - Saturation range selector (horizontal bar)\n - Luminance range selector (horizontal bar)\n - Softness controls for each dimension\n - Invert toggle\n- [ ] Eyedropper tool to pick qualifier center from preview\n- [ ] \"Show Mask\" toggle to visualize selection (B\u0026W mask view)\n- [ ] Correction controls (reuse PrimaryCorrectionPanel) applied to qualified region\n\n### Eyedropper Interaction\n- [ ] Click on preview to sample pixel HSL values\n- [ ] Shift+click to add to selection (expand range)\n- [ ] Alt+click to subtract from selection\n- [ ] Sample multiple pixels for average\n\n### Mask Visualization\n- [ ] Toggle between: Normal view, Mask overlay, Mask only\n- [ ] Mask overlay shows selection as highlight color\n- [ ] Mask only shows B\u0026W (white = selected)\n\n### State Management\n- [ ] Add `HslQualifierNode` to color grading node types\n- [ ] Qualifier stores center, width, softness for H/S/L\n- [ ] Correction stored within qualifier node\n\n## Acceptance Criteria\n- Can isolate skin tones and adjust without affecting background\n- Can select sky blue and increase saturation\n- Soft edges blend naturally (no harsh cutoffs)\n- Eyedropper accurately picks colors from preview\n- Mask visualization helps dial in selection","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:39.983433-07:00","updated_at":"2026-04-04T14:03:39.983433-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:39.987334-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:39.985365-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.7","title":"Power windows (masks for regional correction)","description":"## Summary\nImplement power windows (geometric masks) for regional color corrections - vignettes, sky gradients, face isolation, etc.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] Shape mask generators:\n - Circle/Ellipse\n - Rectangle with corner radius\n - Linear gradient\n - Polygon (for complex shapes)\n- [ ] Transform: position, scale, rotation\n- [ ] Inner/outer softness (feathering)\n- [ ] Invert option\n- [ ] Combine with qualifier mask (AND operation)\n\n### UI Components\n- [ ] `PowerWindowPanel` component:\n - Shape selector (circle, rectangle, gradient, polygon)\n - Position X/Y sliders (% of frame)\n - Scale X/Y sliders\n - Rotation slider\n - Softness inner/outer sliders\n - Invert toggle\n- [ ] On-canvas shape overlay:\n - Draggable shape outline on preview\n - Corner handles for resize\n - Rotation handle\n - Center point for position\n\n### Shape Drawing\n- [ ] Circle: center + radius, draggable edge\n- [ ] Rectangle: corner handles, aspect ratio lock option\n- [ ] Gradient: start point + end point + angle\n- [ ] Polygon: click to add points, close path\n\n### Tracking (stretch goal)\n- [ ] Manual keyframe tracking (position/scale/rotation keyframes)\n- [ ] Data structure for tracked window transforms\n- [ ] Interpolation between tracking keyframes\n\n### Combination with Qualifier\n- [ ] Power window can be combined with HSL qualifier\n- [ ] \"Window AND Qualifier\" mode - both must match\n- [ ] Useful for: face in specific area, sky in upper region only\n\n## Acceptance Criteria\n- Can create vignette effect with soft circular window\n- Can isolate upper sky with gradient window\n- Can draw polygon around subject\n- On-canvas controls are intuitive (drag to move, handles to resize)\n- Soft edges blend naturally","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:54.558574-07:00","updated_at":"2026-04-04T14:03:54.558574-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:54.560807-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:03:54.563678-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.8","title":"Video scopes (waveform, vectorscope, histogram)","description":"## Summary\nImplement professional video scopes computed on GPU for real-time color analysis.\n\n## Tasks\n\n### Scope Types\n- [ ] **Histogram** - RGB + Luma distribution (256 bins each)\n- [ ] **Waveform** - Luma or RGB waveform (brightness by horizontal position)\n- [ ] **RGB Parade** - Separate R/G/B waveforms side by side\n- [ ] **Vectorscope** - Color distribution on color wheel (hue angle + saturation radius)\n\n### WGSL Compute Shaders\n- [ ] `compute_histogram.wgsl`:\n - Atomic histogram binning\n - Output: 4 × 256 buffer (R, G, B, Luma)\n- [ ] `compute_waveform.wgsl`:\n - For each column, accumulate pixel brightness into rows\n - Output: 2D texture (width × 256)\n- [ ] `compute_vectorscope.wgsl`:\n - Map each pixel to 2D position by hue/sat\n - Accumulate density\n - Output: 256×256 texture\n\n### Rust/WASM Implementation\n- [ ] `ScopeComputer` struct managing compute pipelines\n- [ ] `compute_histogram(texture_id) -\u003e Vec\u003cf32\u003e`\n- [ ] `compute_waveform(texture_id, width) -\u003e Vec\u003cu8\u003e`\n- [ ] `compute_vectorscope(texture_id, size) -\u003e Vec\u003cu8\u003e`\n\n### UI Components\n- [ ] `ScopesPanel` component:\n - Scope type selector tabs\n - Canvas for scope display\n - Graticule overlays (scale markers)\n- [ ] Histogram: stacked or separate R/G/B view option\n- [ ] Waveform: Luma / RGB / Parade mode selector\n- [ ] Vectorscope: skin tone line, color target boxes\n\n### Graticules and Reference Lines\n- [ ] Histogram: 0%, 50%, 100% markers\n- [ ] Waveform: IRE/percentage scale, legal range indicators (16-235)\n- [ ] Vectorscope: color boxes (R, Mg, B, Cy, G, Yl), skin tone line\n\n### Performance\n- [ ] Compute scopes on requestAnimationFrame, not every frame\n- [ ] Throttle during playback (every 2-3 frames)\n- [ ] Full rate when paused\n- [ ] Resolution option: 1x, 1/2, 1/4 for faster computation\n\n## Acceptance Criteria\n- Histogram matches reference scope software\n- Waveform shows correct brightness distribution\n- Vectorscope shows correct color positions\n- Scopes update in real-time during playback (\u003c16ms compute time)\n- Graticules help interpret scope readings","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:11.037853-07:00","updated_at":"2026-04-04T14:41:14.406509-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:11.040172-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:04:11.04237-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.9","title":"Node-based color grading pipeline","description":"## Summary\nImplement node-based color grading pipeline allowing multiple stacked corrections executed in order.\n\n## Tasks\n\n### Data Structures\n- [ ] `ColorGradingNode` union type (primary, wheels, curves, LUT, qualifier, window)\n- [ ] `ClipColorGrading` with ordered `nodes: ColorGradingNode[]` array\n- [ ] Each node has: id, type, enabled, mix, label\n\n### Node Execution Engine (Rust/WASM)\n- [ ] `execute_color_pipeline(texture, nodes) -\u003e texture`\n- [ ] Execute nodes in order, each reading previous output\n- [ ] Skip disabled nodes\n- [ ] Apply per-node mix (blend with input)\n- [ ] Intermediate render targets for multi-pass\n\n### UI Components\n- [ ] `NodeListPanel` component:\n - Vertical list of nodes (not visual graph, like Resolve's mini timeline)\n - Drag to reorder\n - Enable/disable toggle per node\n - Mix slider per node\n - Expand/collapse node settings\n - Add node button (dropdown of types)\n - Delete node button\n- [ ] Node type icons for quick identification\n- [ ] \"Solo\" button to preview single node effect\n\n### Node Operations\n- [ ] Add node (at end or after selected)\n- [ ] Remove node\n- [ ] Reorder nodes (drag \u0026 drop)\n- [ ] Duplicate node\n- [ ] Enable/disable node\n- [ ] Reset node to defaults\n- [ ] Copy/paste node between clips\n\n### Bypass and Preview\n- [ ] Global bypass toggle (show original)\n- [ ] \"Solo node\" mode - only selected node active\n- [ ] A/B comparison split view (future enhancement)\n\n### State Management\n- [ ] Add `addColorGradingNode(clipId, node, index?)` action\n- [ ] Add `removeColorGradingNode(clipId, nodeId)` action\n- [ ] Add `reorderColorGradingNodes(clipId, fromIndex, toIndex)` action\n- [ ] Add `updateColorGradingNode(clipId, nodeId, updates)` action\n\n## Acceptance Criteria\n- Can stack multiple corrections (e.g., Primary → Curves → LUT)\n- Node order affects output (e.g., LUT before vs after primary)\n- Disabling node removes its effect\n- Mix slider blends node effect with input\n- Drag reordering updates preview immediately","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:26.186835-07:00","updated_at":"2026-04-04T14:04:26.186835-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:26.187706-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.3","type":"blocks","created_at":"2026-04-04T14:04:26.188467-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.4","type":"blocks","created_at":"2026-04-04T14:04:26.189223-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.5","type":"blocks","created_at":"2026-04-04T14:04:26.189985-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:04:26.190624-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.7","type":"blocks","created_at":"2026-04-04T14:04:26.191412-07:00","created_by":"mohebifar"}]} {"id":"tooscut-bxi","title":"Show video clip thumbnails in timeline","description":"Display thumbnail previews for video clips in the timeline:\n- Performant rendering like subformer\n- Handle zoom in/out properly (show more/fewer thumbnails)\n- Reference: /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:57.360668-08:00","updated_at":"2026-02-04T23:22:18.364728-08:00","closed_at":"2026-02-04T23:22:18.364728-08:00","close_reason":"Closed","created_by":"mohebifar"} {"id":"tooscut-d1w","title":"Create numeric input component with drag-to-adjust","description":"Create a numeric input component that:\n- Can increase/decrease value by click and drag left/right\n- Allows direct editing when clicked\n- Accepts a suffix prop for units (e.g., '%', 'px', '°')\n- Will be used in the properties panel for transform/effect controls","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:55.226838-08:00","updated_at":"2026-02-03T15:44:26.731079-08:00","closed_at":"2026-02-03T15:44:26.731079-08:00","close_reason":"Closed","created_by":"mohebifar"} {"id":"tooscut-dz0","title":"Generate and display project thumbnails","description":"Projects should have a thumbnail that is shown on the project list page. Generate a thumbnail from the project content (e.g., render a frame from the timeline at a representative time) and store it as thumbnailDataUrl in the DexieJS projects table. Display these thumbnails in the project cards on the home/projects page.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:19.167347-08:00","updated_at":"2026-02-22T17:08:56.282213-08:00","closed_at":"2026-02-22T17:08:56.282213-08:00","close_reason":"Closed","created_by":"mohebifar"} diff --git a/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx b/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx index cd404b6..7604bbe 100644 --- a/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx +++ b/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx @@ -13,6 +13,9 @@ import type { ColorSpace, PrimaryCorrection, ColorWheels, + Curves, + HslQualifier, + LutReference, ColorGradingNode as CGNode, } from "@tooscut/render-engine"; @@ -20,6 +23,9 @@ import { DEFAULT_PRIMARY_CORRECTION, DEFAULT_COLOR_GRADING, DEFAULT_COLOR_WHEELS, + DEFAULT_HSL_QUALIFIER, + DEFAULT_CURVES, + DEFAULT_LUT_REFERENCE, } from "@tooscut/render-engine"; import { Eye, @@ -32,7 +38,6 @@ import { Spline, Grid3X3, Crosshair, - Square, } from "lucide-react"; import { useState, useCallback, useMemo } from "react"; @@ -42,8 +47,11 @@ import { Separator } from "../../ui/separator"; import { Toggle } from "../../ui/toggle"; import { ColorWheelsProperties } from "./color-wheels-properties"; import { CstProperties } from "./cst-properties"; +import { CurvesProperties } from "./curves-properties"; +import { LutProperties } from "./lut-properties"; import { ColorGradingNodeGraph } from "./node-graph"; import { PrimaryCorrectionProperties } from "./primary-correction"; +import { QualifierProperties } from "./qualifier-properties"; // ============================================================================ // Types @@ -88,28 +96,21 @@ const NODE_TYPE_CONFIGS: NodeTypeConfig[] = [ label: "Curves", icon: Spline, description: "RGB curves adjustment", - available: false, + available: true, }, { type: "Lut", label: "LUT", icon: Grid3X3, description: "3D lookup table", - available: false, + available: true, }, { type: "Qualifier", label: "HSL Qualifier", icon: Crosshair, description: "Secondary color keying", - available: false, - }, - { - type: "Window", - label: "Power Window", - icon: Square, - description: "Regional mask", - available: false, + available: true, }, { type: "ColorSpaceTransform", @@ -190,6 +191,34 @@ export function ColorGradingPanel({ wheels: { ...DEFAULT_COLOR_WHEELS }, }; break; + case "Curves": + newNode = { + type: "Curves", + id: `curves-${Date.now()}`, + enabled: true, + mix: 1, + curves: { ...DEFAULT_CURVES }, + }; + break; + case "Lut": + newNode = { + type: "Lut", + id: `lut-${Date.now()}`, + enabled: true, + mix: 1, + lut: { ...DEFAULT_LUT_REFERENCE }, + }; + break; + case "Qualifier": + newNode = { + type: "Qualifier", + id: `qualifier-${Date.now()}`, + enabled: true, + mix: 1, + qualifier: { ...DEFAULT_HSL_QUALIFIER }, + correction: { ...DEFAULT_PRIMARY_CORRECTION }, + }; + break; case "ColorSpaceTransform": newNode = { type: "ColorSpaceTransform", @@ -242,12 +271,21 @@ export function ColorGradingPanel({ ); // Reorder nodes + // Reorder nodes based on connection order (array of node IDs) const handleReorderNodes = useCallback( - (fromIndex: number, toIndex: number) => { - const newNodes = [...grading.nodes]; - const [removed] = newNodes.splice(fromIndex, 1); - newNodes.splice(toIndex, 0, removed); - onColorGradingChange({ ...grading, nodes: newNodes }); + (nodeIds: string[]) => { + const nodeMap = new Map(grading.nodes.map((n) => [n.id, n])); + const reordered = nodeIds + .map((id) => nodeMap.get(id)) + .filter(Boolean) as typeof grading.nodes; + // Append any disconnected nodes at the end + const reorderedIds = new Set(nodeIds); + for (const node of grading.nodes) { + if (!reorderedIds.has(node.id)) { + reordered.push(node); + } + } + onColorGradingChange({ ...grading, nodes: reordered }); }, [grading, onColorGradingChange], ); @@ -307,6 +345,88 @@ export function ColorGradingPanel({ [grading, onColorGradingChange, selectedNode], ); + // Update a curves node + const handleUpdateCurvesNode = useCallback( + (updatedCurves: Curves) => { + if (!selectedNode || selectedNode.type !== "Curves") return; + + const newNodes = grading.nodes.map((node) => { + if (node.id === selectedNode.id && node.type === "Curves") { + return { ...node, curves: updatedCurves }; + } + return node; + }); + onColorGradingChange({ ...grading, nodes: newNodes }); + }, + [grading, onColorGradingChange, selectedNode], + ); + + // Update a LUT node + const handleUpdateLutNode = useCallback( + (updates: Partial) => { + if (!selectedNode || selectedNode.type !== "Lut") return; + + const newNodes = grading.nodes.map((node) => { + if (node.id === selectedNode.id && node.type === "Lut") { + return { + ...node, + lut: { + ...node.lut, + ...updates, + }, + }; + } + return node; + }); + onColorGradingChange({ ...grading, nodes: newNodes }); + }, + [grading, onColorGradingChange, selectedNode], + ); + + // Update a qualifier node's qualifier params + const handleUpdateQualifierNode = useCallback( + (key: keyof HslQualifier, value: number | boolean) => { + if (!selectedNode || selectedNode.type !== "Qualifier") return; + + const newNodes = grading.nodes.map((node) => { + if (node.id === selectedNode.id && node.type === "Qualifier") { + return { + ...node, + qualifier: { + ...node.qualifier, + [key]: value, + }, + }; + } + return node; + }); + onColorGradingChange({ ...grading, nodes: newNodes }); + }, + [grading, onColorGradingChange, selectedNode], + ); + + // Update a qualifier node's correction params + const handleUpdateQualifierCorrection = useCallback( + (key: keyof PrimaryCorrection, value: number | [number, number, number]) => { + if (!selectedNode || selectedNode.type !== "Qualifier") return; + + const newNodes = grading.nodes.map((node) => { + if (node.id === selectedNode.id && node.type === "Qualifier") { + return { + ...node, + correction: { + ...node.correction, + [key]: value, + }, + }; + } + return node; + }); + onColorGradingChange({ ...grading, nodes: newNodes }); + }, + [grading, onColorGradingChange, selectedNode], + ); + // Check if we have any active corrections const hasActiveCorrections = useMemo(() => grading.nodes.some((n) => n.enabled), [grading.nodes]); @@ -360,7 +480,11 @@ export function ColorGradingPanel({ node={selectedNode} onUpdatePrimary={handleUpdatePrimaryNode} onUpdateColorWheels={handleUpdateColorWheelsNode} + onUpdateCurves={handleUpdateCurvesNode} onUpdateCst={handleUpdateCstNode} + onUpdateLut={handleUpdateLutNode} + onUpdateQualifier={handleUpdateQualifierNode} + onUpdateQualifierCorrection={handleUpdateQualifierCorrection} /> )} @@ -420,7 +544,14 @@ interface NodeParameterEditorProps { node: CGNode; onUpdatePrimary: (key: keyof PrimaryCorrection, value: number | [number, number, number]) => void; onUpdateColorWheels: (updates: Partial) => void; + onUpdateCurves: (curves: Curves) => void; onUpdateCst: (updates: { from_space?: ColorSpace; to_space?: ColorSpace }) => void; + onUpdateLut: (updates: Partial) => void; + onUpdateQualifier: (key: keyof HslQualifier, value: number | boolean) => void; + onUpdateQualifierCorrection: ( + key: keyof PrimaryCorrection, + value: number | [number, number, number], + ) => void; } // ============================================================================ @@ -433,7 +564,11 @@ function NodeParameterEditor({ node, onUpdatePrimary, onUpdateColorWheels, + onUpdateCurves, onUpdateCst, + onUpdateLut, + onUpdateQualifier, + onUpdateQualifierCorrection, }: NodeParameterEditorProps) { const [expanded, setExpanded] = useState(true); @@ -449,8 +584,6 @@ function NodeParameterEditor({ return "LUT"; case "Qualifier": return "HSL Qualifier"; - case "Window": - return "Power Window"; case "ColorSpaceTransform": return "Color Space Transform"; default: @@ -495,16 +628,18 @@ function NodeParameterEditor({ /> )} {node.type === "Curves" && ( -

Curves editor coming soon

- )} - {node.type === "Lut" && ( -

LUT browser coming soon

+ )} + {node.type === "Lut" && } {node.type === "Qualifier" && ( -

HSL Qualifier coming soon

- )} - {node.type === "Window" && ( -

Power Window coming soon

+ )} {node.type === "ColorSpaceTransform" && ( { +interface GradingNodeData extends Record { node: CGNode; - index: number; - isFirst: boolean; - isLast: boolean; onToggleEnabled: (enabled: boolean) => void; onRemove: () => void; onSelect: () => void; isSelected: boolean; } -type ColorGradingFlowNode = Node; +interface TerminalNodeData extends Record { + label: string; + type: "input" | "output"; +} + +type GradingFlowNode = Node; +type TerminalFlowNode = Node; +type AnyFlowNode = GradingFlowNode | TerminalFlowNode; // ============================================================================ -// Node Components +// Node Preview / Theme / Label helpers // ============================================================================ -/** - * Get a preview string for a node's current settings. - */ function getNodePreview(node: CGNode): string { switch (node.type) { case "Primary": { @@ -68,20 +81,23 @@ function getNodePreview(node: CGNode): string { const active = [hasLift && "L", hasGamma && "G", hasGain && "Gn"].filter(Boolean); return active.length > 0 ? active.join(" · ") : "Default"; } - case "Curves": - return "RGB Curves"; + case "Curves": { + const cu = node.curves; + const isIdentity = (c: { points: { x: number; y: number }[] }) => + c.points.every((p) => Math.abs(p.x - p.y) < 0.01); + const totalPts = + cu.master.points.length + + cu.red.points.length + + cu.green.points.length + + cu.blue.points.length; + const allIdentity = + isIdentity(cu.master) && isIdentity(cu.red) && isIdentity(cu.green) && isIdentity(cu.blue); + return allIdentity ? "Identity" : `${totalPts} pts`; + } case "Lut": return node.lut.lut_id || "No LUT"; case "Qualifier": return "HSL Key"; - case "Window": { - const shape = node.window.shape; - if ("Circle" in shape) return "Circle"; - if ("Rectangle" in shape) return "Rectangle"; - if ("Polygon" in shape) return "Polygon"; - if ("Gradient" in shape) return "Gradient"; - return "Unknown"; - } case "ColorSpaceTransform": return `${node.from_space} → ${node.to_space}`; default: @@ -89,9 +105,6 @@ function getNodePreview(node: CGNode): string { } } -/** - * Get the color theme for a node type. - */ function getNodeTheme(type: CGNode["type"]): { bg: string; border: string; accent: string } { switch (type) { case "Primary": @@ -104,8 +117,6 @@ function getNodeTheme(type: CGNode["type"]): { bg: string; border: string; accen return { bg: "bg-green-950/50", border: "border-green-700/50", accent: "text-green-400" }; case "Qualifier": return { bg: "bg-pink-950/50", border: "border-pink-700/50", accent: "text-pink-400" }; - case "Window": - return { bg: "bg-cyan-950/50", border: "border-cyan-700/50", accent: "text-cyan-400" }; case "ColorSpaceTransform": return { bg: "bg-sky-950/50", border: "border-sky-700/50", accent: "text-sky-400" }; default: @@ -113,9 +124,6 @@ function getNodeTheme(type: CGNode["type"]): { bg: string; border: string; accen } } -/** - * Get the label for a node type. - */ function getNodeLabel(node: CGNode): string { if (node.label) return node.label; switch (node.type) { @@ -129,8 +137,6 @@ function getNodeLabel(node: CGNode): string { return "LUT"; case "Qualifier": return "Qualifier"; - case "Window": - return "Window"; case "ColorSpaceTransform": return "CST"; default: @@ -138,30 +144,60 @@ function getNodeLabel(node: CGNode): string { } } -/** - * Base node component for all color grading node types. - */ +// ============================================================================ +// Terminal Node Component (Input / Output) +// ============================================================================ + +const TerminalNodeComponent = memo(function TerminalNodeComponent({ + data, +}: NodeProps) { + const isInput = data.type === "input"; + return ( + <> + {!isInput && ( + + )} +
+ + {data.label} + +
+ {isInput && ( + + )} + + ); +}); + +// ============================================================================ +// Grading Node Component +// ============================================================================ + const ColorGradingNodeComponent = memo(function ColorGradingNodeComponent({ data, selected, -}: NodeProps) { - const { node, isFirst, isLast, onToggleEnabled, onRemove, onSelect, isSelected } = data; +}: NodeProps) { + const { node, onToggleEnabled, onRemove, onSelect, isSelected } = data; const theme = getNodeTheme(node.type); const preview = getNodePreview(node); const label = getNodeLabel(node); return ( <> - {/* Input handle */} - {!isFirst && ( - - )} + - {/* Node content */}
- {/* Drag handle */} -
- -
- {/* Header */}
@@ -234,14 +265,11 @@ const ColorGradingNodeComponent = memo(function ColorGradingNodeComponent({ )}
- {/* Output handle */} - {!isLast && ( - - )} + ); }); @@ -252,6 +280,7 @@ const ColorGradingNodeComponent = memo(function ColorGradingNodeComponent({ const nodeTypes = { colorGrading: ColorGradingNodeComponent, + terminal: TerminalNodeComponent, }; // ============================================================================ @@ -264,13 +293,10 @@ interface ColorGradingNodeGraphProps { onSelectNode: (nodeId: string | null) => void; onToggleNodeEnabled: (nodeId: string, enabled: boolean) => void; onRemoveNode: (nodeId: string) => void; - onReorderNodes: (fromIndex: number, toIndex: number) => void; + onReorderNodes: (nodeIds: string[]) => void; onUpdateNodePosition: (nodeId: string, x: number, y: number) => void; } -const NODE_WIDTH = 160; -const NODE_GAP = 60; - export function ColorGradingNodeGraph(props: ColorGradingNodeGraphProps) { return ( @@ -279,54 +305,115 @@ export function ColorGradingNodeGraph(props: ColorGradingNodeGraphProps) { ); } +/** + * Derive processing order from edges by walking the graph from Input → Output. + * Returns an array of grading node IDs in the connected order. + */ +function deriveOrderFromEdges(edges: Edge[], gradingNodeIds: Set): string[] { + // Build adjacency: source → target + const adj = new Map(); + for (const edge of edges) { + adj.set(edge.source, edge.target); + } + + // Walk from Input + const order: string[] = []; + let current = adj.get(INPUT_NODE_ID); + const visited = new Set(); + while (current && current !== OUTPUT_NODE_ID && !visited.has(current)) { + visited.add(current); + if (gradingNodeIds.has(current)) { + order.push(current); + } + current = adj.get(current); + } + + return order; +} + function ColorGradingNodeGraphInner({ nodes, selectedNodeId, onSelectNode, onToggleNodeEnabled, onRemoveNode, + onReorderNodes, onUpdateNodePosition, }: ColorGradingNodeGraphProps) { const { fitView } = useReactFlow(); const prevNodeCountRef = useRef(nodes.length); - // Build React Flow nodes — positions come from the node data (persisted in store) - const flowNodes = useMemo((): ColorGradingFlowNode[] => { - return nodes.map((node, index) => { - const defaultPos = { x: index * (NODE_WIDTH + NODE_GAP), y: 0 }; + // Build flow nodes: Input terminal + grading nodes + Output terminal + const flowNodes = useMemo((): AnyFlowNode[] => { + const result: AnyFlowNode[] = []; + + // Input terminal + result.push({ + id: INPUT_NODE_ID, + type: "terminal", + position: { x: -NODE_WIDTH - NODE_GAP, y: 10 }, + data: { label: "Input", type: "input" }, + draggable: false, + selectable: false, + deletable: false, + }); + + // Grading nodes + for (let i = 0; i < nodes.length; i++) { + const node = nodes[i]; + const defaultPos = { x: i * (NODE_WIDTH + NODE_GAP), y: 0 }; const pos = node.position ?? defaultPos; - return { + result.push({ id: node.id, type: "colorGrading", position: { x: pos.x, y: pos.y }, data: { node, - index, - isFirst: index === 0, - isLast: index === nodes.length - 1, onToggleEnabled: (enabled: boolean) => onToggleNodeEnabled(node.id, enabled), onRemove: () => onRemoveNode(node.id), onSelect: () => onSelectNode(node.id), isSelected: node.id === selectedNodeId, }, draggable: true, - }; + deletable: false, + }); + } + + // Output terminal + result.push({ + id: OUTPUT_NODE_ID, + type: "terminal", + position: { x: nodes.length * (NODE_WIDTH + NODE_GAP), y: 10 }, + data: { label: "Output", type: "output" }, + draggable: false, + selectable: false, + deletable: false, }); + + return result; }, [nodes, selectedNodeId, onToggleNodeEnabled, onRemoveNode, onSelectNode]); - // Create edges connecting nodes in sequence + // Create edges: Input → node[0] → node[1] → ... → Output const flowEdges = useMemo((): Edge[] => { - return nodes.slice(0, -1).map((node, index) => ({ - id: `edge-${node.id}-${nodes[index + 1].id}`, - source: node.id, - target: nodes[index + 1].id, - type: "smoothstep", - animated: nodes[index].enabled && nodes[index + 1].enabled, - style: { - stroke: nodes[index].enabled ? "#525252" : "#262626", - strokeWidth: 2, - }, - })); + const chain = [INPUT_NODE_ID, ...nodes.map((n) => n.id), OUTPUT_NODE_ID]; + return chain.slice(0, -1).map((source, i) => { + const target = chain[i + 1]; + const sourceEnabled = + source === INPUT_NODE_ID || (nodes.find((n) => n.id === source)?.enabled ?? true); + const targetEnabled = + target === OUTPUT_NODE_ID || (nodes.find((n) => n.id === target)?.enabled ?? true); + return { + id: `edge-${source}-${target}`, + source, + target, + type: "smoothstep", + animated: sourceEnabled && targetEnabled, + style: { + stroke: sourceEnabled ? "#525252" : "#262626", + strokeWidth: 2, + }, + }; + }); }, [nodes]); const [rfNodes, setRfNodes, onNodesChange] = useNodesState(flowNodes); @@ -349,10 +436,37 @@ function ColorGradingNodeGraphInner({ } }, [nodes.length, fitView]); + // Handle new connections + const gradingNodeIds = useMemo(() => new Set(nodes.map((n) => n.id)), [nodes]); + + const onConnect: OnConnect = useCallback( + (connection: Connection) => { + // Add the new edge, removing any existing edge from the same source + setRfEdges((eds) => { + const filtered = eds.filter( + (e) => e.source !== connection.source && e.target !== connection.target, + ); + const newEdges = addEdge( + { ...connection, type: "smoothstep", style: { stroke: "#525252", strokeWidth: 2 } }, + filtered, + ); + // Derive new order from the updated edges + const newOrder = deriveOrderFromEdges(newEdges, gradingNodeIds); + if (newOrder.length > 0) { + onReorderNodes(newOrder); + } + return newEdges; + }); + }, + [setRfEdges, gradingNodeIds, onReorderNodes], + ); + // Persist position to store on drag end const onNodeDragStop = useCallback( (_event: React.MouseEvent, node: Node) => { - onUpdateNodePosition(node.id, node.position.x, node.position.y); + if (node.id !== INPUT_NODE_ID && node.id !== OUTPUT_NODE_ID) { + onUpdateNodePosition(node.id, node.position.x, node.position.y); + } }, [onUpdateNodePosition], ); @@ -362,14 +476,6 @@ function ColorGradingNodeGraphInner({ onSelectNode(null); }, [onSelectNode]); - if (nodes.length === 0) { - return ( -
-

No nodes in pipeline

-
- ); - } - return (
diff --git a/apps/ui/src/lib/lut-manager.ts b/apps/ui/src/lib/lut-manager.ts index 2cf649e..dee1108 100644 --- a/apps/ui/src/lib/lut-manager.ts +++ b/apps/ui/src/lib/lut-manager.ts @@ -23,7 +23,7 @@ import { parseCubeFile, type CubeLut } from "./cube-parser"; * * Returns the asset ID. */ -async function importLutFromHandle( +export async function importLutFromHandle( handle: FileSystemFileHandle, ): Promise<{ id: string; name: string } | null> { try { @@ -70,10 +70,7 @@ async function importLutFromHandle( * Import a .cube LUT file via the File System Access API picker. * Falls back to if the API is unavailable. */ -export async function importLutWithPicker(): Promise<{ - id: string; - name: string; -} | null> { +export async function importLutWithPicker(): Promise<{ id: string; name: string } | null> { if ("showOpenFilePicker" in window) { try { const [handle] = await (window as any).showOpenFilePicker({ @@ -147,13 +144,9 @@ export async function hydrateLutAsset(asset: MediaAsset): Promise { try { // Check/request permission - const permission = await (stored.handle as any).queryPermission({ - mode: "read", - }); + const permission = await (stored.handle as any).queryPermission({ mode: "read" }); if (permission !== "granted") { - const requested = await (stored.handle as any).requestPermission({ - mode: "read", - }); + const requested = await (stored.handle as any).requestPermission({ mode: "read" }); if (requested !== "granted") { console.warn(`[lut-manager] Permission denied for LUT ${asset.id}`); return false; @@ -171,6 +164,18 @@ export async function hydrateLutAsset(asset: MediaAsset): Promise { } } +/** + * Remove a LUT asset — remove from store, DB, and GPU. + */ +export async function removeLutAsset(assetId: string): Promise { + const compositor = getSharedCompositor(); + if (compositor) { + await compositor.removeLut(); + } + await db.fileHandles.delete(assetId); + useVideoEditorStore.getState().removeAsset(assetId); +} + /** * Upload parsed LUT data to the GPU compositor. */ diff --git a/crates/compositor/src/color_grading_uniforms.rs b/crates/compositor/src/color_grading_uniforms.rs index 791ee2c..621e91b 100644 --- a/crates/compositor/src/color_grading_uniforms.rs +++ b/crates/compositor/src/color_grading_uniforms.rs @@ -309,8 +309,36 @@ pub struct ColorGradingUniforms { /// Padding to maintain vec4 alignment. pub _pad_align: [f32; 2], - // Padding to 256 bytes (48 bytes) - pub _pad: [f32; 12], + // === Qualifier correction CDL (applied within qualified region) === + /// Qualifier correction slope. + pub q_slope: [f32; 4], // 16 bytes + /// Qualifier correction offset. + pub q_offset: [f32; 4], // 16 bytes + /// Qualifier correction power. + pub q_power: [f32; 4], // 16 bytes + /// Qualifier correction adjustments: sat, exposure, temperature, tint. + pub q_adjustments: [f32; 4], // 16 bytes + + // === Power window params === + /// Window center (x, y) and scale (x, y). + pub window_center_scale: [f32; 4], // 16 bytes + /// Window shape params: (radius_x/width, radius_y/height, corner_radius/angle, shape_type). + pub window_shape: [f32; 4], // 16 bytes + /// Window: rotation, softness_inner, softness_outer, invert. + pub window_params: [f32; 4], // 16 bytes + /// Window correction slope. + pub w_slope: [f32; 4], // 16 bytes + /// Window correction offset. + pub w_offset: [f32; 4], // 16 bytes + /// Window correction power. + pub w_power: [f32; 4], // 16 bytes + /// Window correction adjustments: sat, exposure, temperature, tint. + pub w_adjustments: [f32; 4], // 16 bytes + /// Window mix + pad. + pub window_mix: [f32; 4], // 16 bytes + + // Padding to 512 bytes (400 used, 112 remaining = 28 floats) + pub _pad: [f32; 28], } impl Default for ColorGradingUniforms { @@ -337,7 +365,21 @@ impl Default for ColorGradingUniforms { input_cst: 0, output_cst: 0, _pad_align: [0.0; 2], - _pad: [0.0; 12], + // Qualifier correction + q_slope: [1.0, 1.0, 1.0, 1.0], + q_offset: [0.0, 0.0, 0.0, 0.0], + q_power: [1.0, 1.0, 1.0, 1.0], + q_adjustments: [1.0, 0.0, 0.0, 0.0], + // Power window + window_center_scale: [0.5, 0.5, 1.0, 1.0], + window_shape: [0.25, 0.25, 0.0, 0.0], // circle default + window_params: [0.0, 0.0, 0.1, 0.0], // rotation, softness_inner, softness_outer, invert + w_slope: [1.0, 1.0, 1.0, 1.0], + w_offset: [0.0, 0.0, 0.0, 0.0], + w_power: [1.0, 1.0, 1.0, 1.0], + w_adjustments: [1.0, 0.0, 0.0, 0.0], + window_mix: [1.0, 0.0, 0.0, 0.0], + _pad: [0.0; 28], } } } @@ -349,8 +391,9 @@ pub const FLAG_WHEELS_ENABLED: u32 = 1 << 2; pub const FLAG_CURVES_ENABLED: u32 = 1 << 3; pub const FLAG_LUT_ENABLED: u32 = 1 << 4; pub const FLAG_QUALIFIER_ENABLED: u32 = 1 << 5; -pub const FLAG_INPUT_CST: u32 = 1 << 6; -pub const FLAG_OUTPUT_CST: u32 = 1 << 7; +pub const FLAG_WINDOW_ENABLED: u32 = 1 << 6; +pub const FLAG_INPUT_CST: u32 = 1 << 7; +pub const FLAG_OUTPUT_CST: u32 = 1 << 8; /// Convert ColorSpace enum to u32 for shader. fn color_space_to_u32(cs: &tooscut_types::ColorSpace) -> u32 { @@ -448,13 +491,13 @@ impl ColorGradingUniforms { uniforms.gain = [gain_rgb[0], gain_rgb[1], gain_rgb[2], wheels.gain_luminance]; uniforms.wheels_mix = *mix; } - ColorGradingNode::Lut { lut, mix, .. } => { + ColorGradingNode::Lut { lut, .. } => { uniforms.flags |= FLAG_LUT_ENABLED; - uniforms.lut_mix = *mix; + uniforms.lut_mix = lut.mix; // LUT texture binding handled separately } ColorGradingNode::Qualifier { - qualifier, mix, .. + qualifier, correction, mix, .. } => { uniforms.flags |= FLAG_QUALIFIER_ENABLED; uniforms.qualifier_center = [ @@ -476,10 +519,63 @@ impl ColorGradingUniforms { if qualifier.invert { 1.0 } else { 0.0 }, ]; uniforms.qualifier_mix = *mix; + // Qualifier correction CDL + uniforms.q_slope = [correction.slope[0], correction.slope[1], correction.slope[2], 1.0]; + uniforms.q_offset = [correction.offset[0], correction.offset[1], correction.offset[2], 0.0]; + uniforms.q_power = [correction.power[0], correction.power[1], correction.power[2], 1.0]; + uniforms.q_adjustments = [ + correction.saturation, + correction.exposure, + correction.temperature, + correction.tint, + ]; + } + ColorGradingNode::Window { + window, correction, mix, .. + } => { + uniforms.flags |= FLAG_WINDOW_ENABLED; + uniforms.window_center_scale = [ + window.center_x, + window.center_y, + window.scale_x, + window.scale_y, + ]; + // Encode shape type: 0=circle, 1=rectangle, 2=gradient + let (p1, p2, p3, shape_type) = match &window.shape { + tooscut_types::PowerWindowShape::Circle { radius_x, radius_y } => { + (*radius_x, *radius_y, 0.0, 0.0) + } + tooscut_types::PowerWindowShape::Rectangle { width, height, corner_radius } => { + (*width, *height, *corner_radius, 1.0) + } + tooscut_types::PowerWindowShape::Gradient { angle } => { + (*angle / 360.0, 0.0, 0.0, 2.0) + } + tooscut_types::PowerWindowShape::Polygon { .. } => { + (0.25, 0.25, 0.0, 0.0) // fallback to circle + } + }; + uniforms.window_shape = [p1, p2, p3, shape_type]; + uniforms.window_params = [ + window.rotation / 360.0, + window.softness_inner, + window.softness_outer, + if window.invert { 1.0 } else { 0.0 }, + ]; + uniforms.w_slope = [correction.slope[0], correction.slope[1], correction.slope[2], 1.0]; + uniforms.w_offset = [correction.offset[0], correction.offset[1], correction.offset[2], 0.0]; + uniforms.w_power = [correction.power[0], correction.power[1], correction.power[2], 1.0]; + uniforms.w_adjustments = [ + correction.saturation, + correction.exposure, + correction.temperature, + correction.tint, + ]; + uniforms.window_mix = [*mix, 0.0, 0.0, 0.0]; } // CST handled above in the pre-scan ColorGradingNode::ColorSpaceTransform { .. } => {} - // Curves and Window require additional textures/data, handled separately + // Curves require additional texture, handled separately _ => {} } } @@ -492,7 +588,7 @@ impl ColorGradingUniforms { const _: () = assert!(std::mem::size_of::() == 128); const _: () = assert!(std::mem::size_of::() == 128); const _: () = assert!(std::mem::size_of::() == 128); -const _: () = assert!(std::mem::size_of::() == 256); +const _: () = assert!(std::mem::size_of::() == 512); #[cfg(test)] mod tests { @@ -503,7 +599,7 @@ mod tests { assert_eq!(std::mem::size_of::(), 128); assert_eq!(std::mem::size_of::(), 128); assert_eq!(std::mem::size_of::(), 128); - assert_eq!(std::mem::size_of::(), 256); + assert_eq!(std::mem::size_of::(), 512); } #[test] diff --git a/crates/compositor/src/compositor.rs b/crates/compositor/src/compositor.rs index 9dd2775..d0cb4c1 100644 --- a/crates/compositor/src/compositor.rs +++ b/crates/compositor/src/compositor.rs @@ -28,6 +28,31 @@ use tooscut_types::{ #[cfg(target_arch = "wasm32")] use web_sys::{HtmlCanvasElement, HtmlVideoElement, ImageBitmap, OffscreenCanvas}; +/// Convert f32 to IEEE 754 half-precision (f16) stored as u16. +fn f32_to_f16(value: f32) -> u16 { + let bits = value.to_bits(); + let sign = (bits >> 16) & 0x8000; + let exponent = ((bits >> 23) & 0xFF) as i32; + let mantissa = bits & 0x7FFFFF; + + if exponent == 0xFF { + // Inf/NaN + return (sign | 0x7C00 | if mantissa != 0 { 0x200 } else { 0 }) as u16; + } + + let exp = exponent - 127 + 15; + if exp >= 31 { + // Overflow → Inf + return (sign | 0x7C00) as u16; + } + if exp <= 0 { + // Underflow → 0 + return sign as u16; + } + + (sign | ((exp as u32) << 10) | (mantissa >> 13)) as u16 +} + /// A renderable item with its z-index for sorting. #[derive(Debug)] enum RenderItem<'a> { @@ -61,6 +86,11 @@ pub struct Compositor { // Color grading color_grading_bind_group_layout: BindGroupLayout, default_cg_bind_group: BindGroup, + lut_sampler: wgpu::Sampler, + /// Identity 2x2x2 LUT texture (passthrough) used when no LUT is loaded. + default_lut_texture_view: wgpu::TextureView, + /// Currently loaded 3D LUT texture, keyed by lut_id. + active_lut: Option<(String, wgpu::TextureView)>, // Shape/line rendering shape_pipeline: RenderPipeline, shape_bind_group_layout: BindGroupLayout, @@ -133,6 +163,27 @@ impl Compositor { .upload_bitmap(&self.device, &self.queue, texture_id, bitmap) .map_err(Into::into) } + + /// Upload a 3D LUT from RGBA float data. + /// + /// `data` must be a Float32Array with `size^3 * 4` elements (RGBA). + /// `size` is the cube dimension (e.g., 17, 33, 65). + #[wasm_bindgen] + pub fn upload_lut( + &mut self, + lut_id: &str, + size: u32, + data: &[f32], + ) -> std::result::Result<(), JsValue> { + self.upload_lut_internal(lut_id, size, data) + .map_err(|e| JsValue::from_str(&e.to_string())) + } + + /// Remove the active LUT, reverting to the identity (passthrough) LUT. + #[wasm_bindgen] + pub fn remove_lut(&mut self) { + self.active_lut = None; + } } #[wasm_bindgen] @@ -387,12 +438,52 @@ impl Compositor { // Media layer pipeline with color grading support let bind_group_layout = create_bind_group_layout(&device); let color_grading_bind_group_layout = create_color_grading_bind_group_layout(&device); + log::info!("[compositor] Creating pipeline..."); let pipeline = create_pipeline( &device, &bind_group_layout, &color_grading_bind_group_layout, surface_format, )?; + log::info!("[compositor] Pipeline created OK"); + + // LUT sampler (linear filtering for smooth LUT interpolation) + let lut_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("lut_sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + + // Default identity LUT (2x2x2, each texel = its own coordinate) in f16 + let default_lut_f32: [f32; 32] = [ + 0.0, 0.0, 0.0, 1.0, 1.0, 0.0, 0.0, 1.0, + 0.0, 1.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, + 0.0, 0.0, 1.0, 1.0, 1.0, 0.0, 1.0, 1.0, + 0.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, + ]; + let default_lut_data: Vec = default_lut_f32.iter().map(|&f| f32_to_f16(f)).collect(); + let default_lut_texture = device.create_texture_with_data( + &queue, + &wgpu::TextureDescriptor { + label: Some("default_lut_3d"), + size: wgpu::Extent3d { + width: 2, + height: 2, + depth_or_array_layers: 2, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D3, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }, + wgpu::util::TextureDataOrder::default(), + cast_slice(&default_lut_data), + ); + let default_lut_texture_view = + default_lut_texture.create_view(&wgpu::TextureViewDescriptor::default()); // Create cached default color grading bind group (no-op) let default_cg_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -403,10 +494,20 @@ impl Compositor { let default_cg_bind_group = device.create_bind_group(&BindGroupDescriptor { label: Some("default_cg_bind_group"), layout: &color_grading_bind_group_layout, - entries: &[BindGroupEntry { - binding: 0, - resource: default_cg_buffer.as_entire_binding(), - }], + entries: &[ + BindGroupEntry { + binding: 0, + resource: default_cg_buffer.as_entire_binding(), + }, + BindGroupEntry { + binding: 1, + resource: BindingResource::TextureView(&default_lut_texture_view), + }, + BindGroupEntry { + binding: 2, + resource: BindingResource::Sampler(&lut_sampler), + }, + ], }); let textures = TextureManager::new(&device); @@ -425,6 +526,9 @@ impl Compositor { bind_group_layout, color_grading_bind_group_layout, default_cg_bind_group, + lut_sampler, + default_lut_texture_view, + active_lut: None, shape_pipeline, shape_bind_group_layout, textures, @@ -436,6 +540,63 @@ impl Compositor { }) } + /// Upload a 3D LUT texture from RGBA float data. + fn upload_lut_internal(&mut self, lut_id: &str, size: u32, data: &[f32]) -> Result<()> { + let expected = (size * size * size * 4) as usize; + if data.len() != expected { + return Err(CompositorError::Serialization(format!( + "LUT data size mismatch: expected {} floats, got {}", + expected, + data.len() + ))); + } + + // Convert f32 RGBA data to f16 RGBA for GPU (Rgba16Float supports filtering) + let f16_data: Vec = data.iter().map(|&f| f32_to_f16(f)).collect(); + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("lut_3d"), + size: wgpu::Extent3d { + width: size, + height: size, + depth_or_array_layers: size, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D3, + format: wgpu::TextureFormat::Rgba16Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + let bytes_per_row = size * 4 * 2; // width * 4 channels * 2 bytes per f16 + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + cast_slice(&f16_data), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row), + rows_per_image: Some(size), + }, + wgpu::Extent3d { + width: size, + height: size, + depth_or_array_layers: size, + }, + ); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.active_lut = Some((lut_id.to_string(), view)); + + log::info!("[compositor] Uploaded 3D LUT '{}' ({}x{}x{})", lut_id, size, size, size); + Ok(()) + } + /// Ensure the text renderer is initialized. fn ensure_text_renderer(&mut self) { if self.text_renderer.is_none() { @@ -1015,13 +1176,35 @@ impl Compositor { contents: cast_slice(&[cg_uniforms]), usage: BufferUsages::UNIFORM | BufferUsages::COPY_DST, }); + + // Use active LUT texture if loaded, otherwise default + let has_active_lut = self.active_lut.is_some(); + let lut_view = self + .active_lut + .as_ref() + .map(|(_, view)| view) + .unwrap_or(&self.default_lut_texture_view); + if cg_uniforms.flags & crate::color_grading_uniforms::FLAG_LUT_ENABLED != 0 { + log::info!("[compositor] LUT enabled, active_lut={}, lut_mix={}", has_active_lut, cg_uniforms.lut_mix); + } + let cg_bind_group = self.device.create_bind_group(&BindGroupDescriptor { label: Some("cg_bind_group"), layout: &self.color_grading_bind_group_layout, - entries: &[BindGroupEntry { - binding: 0, - resource: cg_buffer.as_entire_binding(), - }], + entries: &[ + BindGroupEntry { + binding: 0, + resource: cg_buffer.as_entire_binding(), + }, + BindGroupEntry { + binding: 1, + resource: BindingResource::TextureView(lut_view), + }, + BindGroupEntry { + binding: 2, + resource: BindingResource::Sampler(&self.lut_sampler), + }, + ], }); render_pass.set_bind_group(1, &cg_bind_group, &[]); } else { diff --git a/crates/compositor/src/pipeline.rs b/crates/compositor/src/pipeline.rs index 4736721..5a659cd 100644 --- a/crates/compositor/src/pipeline.rs +++ b/crates/compositor/src/pipeline.rs @@ -61,7 +61,21 @@ struct ColorGradingUniforms { input_cst: u32, output_cst: u32, _pad_align: vec2, - _pad: array, 3>, + // Qualifier correction CDL + q_slope: vec4, + q_offset: vec4, + q_power: vec4, + q_adjustments: vec4, + // Power window + window_center_scale: vec4, + window_shape: vec4, + window_params: vec4, + w_slope: vec4, + w_offset: vec4, + w_power: vec4, + w_adjustments: vec4, + window_mix: vec4, + _pad: array, 7>, }; @group(0) @binding(0) var uniforms: LayerUniforms; @@ -69,6 +83,8 @@ struct ColorGradingUniforms { @group(0) @binding(2) var s_diffuse: sampler; @group(1) @binding(0) var cg: ColorGradingUniforms; +@group(1) @binding(1) var t_lut_3d: texture_3d; +@group(1) @binding(2) var s_lut: sampler; // Quad vertices (two triangles) - corners of a unit quad [0,0] to [1,1] // Will be scaled by texture dimensions in vertex shader @@ -325,8 +341,11 @@ fn from_linear(color: vec3, cs: u32) -> vec3 { const CG_FLAG_BYPASS: u32 = 1u; const CG_FLAG_PRIMARY_ENABLED: u32 = 2u; const CG_FLAG_WHEELS_ENABLED: u32 = 4u; -const CG_FLAG_INPUT_CST: u32 = 64u; -const CG_FLAG_OUTPUT_CST: u32 = 128u; +const CG_FLAG_LUT_ENABLED: u32 = 16u; +const CG_FLAG_QUALIFIER_ENABLED: u32 = 32u; +const CG_FLAG_WINDOW_ENABLED: u32 = 64u; +const CG_FLAG_INPUT_CST: u32 = 128u; +const CG_FLAG_OUTPUT_CST: u32 = 256u; fn cg_luminance(rgb: vec3) -> f32 { return dot(rgb, vec3(0.2126, 0.7152, 0.0722)); @@ -405,7 +424,159 @@ fn apply_lift_gamma_gain( return mix(color, result, mix_amount); } -fn apply_color_grading(color: vec3) -> vec3 { +// ============================================================================ +// HSL Qualifier +// ============================================================================ + +fn rgb_to_hsl_cg(rgb: vec3) -> vec3 { + let max_c = max(max(rgb.r, rgb.g), rgb.b); + let min_c = min(min(rgb.r, rgb.g), rgb.b); + let delta = max_c - min_c; + let l = (max_c + min_c) * 0.5; + if (delta < 0.00001) { + return vec3(0.0, 0.0, l); + } + let s = select(delta / (2.0 - max_c - min_c), delta / (max_c + min_c), l < 0.5); + var h: f32; + if (max_c == rgb.r) { + h = (rgb.g - rgb.b) / delta + select(0.0, 6.0, rgb.g < rgb.b); + } else if (max_c == rgb.g) { + h = (rgb.b - rgb.r) / delta + 2.0; + } else { + h = (rgb.r - rgb.g) / delta + 4.0; + } + h /= 6.0; + return vec3(h, s, l); +} + +fn hsl_qualifier_mask( + hsl: vec3, + center: vec3, + width: vec3, + softness: vec3, + invert_flag: f32, +) -> f32 { + // Hue distance (circular) + var hue_diff = abs(hsl.x - center.x); + hue_diff = min(hue_diff, 1.0 - hue_diff); + let sat_diff = abs(hsl.y - center.y); + let lum_diff = abs(hsl.z - center.z); + + let hue_inner = width.x * (1.0 - softness.x); + let hue_mask = 1.0 - smoothstep(hue_inner, width.x, hue_diff); + let sat_inner = width.y * (1.0 - softness.y); + let sat_mask = 1.0 - smoothstep(sat_inner, width.y, sat_diff); + let lum_inner = width.z * (1.0 - softness.z); + let lum_mask = 1.0 - smoothstep(lum_inner, width.z, lum_diff); + + var mask = hue_mask * sat_mask * lum_mask; + if (invert_flag > 0.5) { + mask = 1.0 - mask; + } + return mask; +} + +fn apply_qualifier(color: vec3) -> vec3 { + let hsl = rgb_to_hsl_cg(color); + let mask = hsl_qualifier_mask( + hsl, + cg.qualifier_center.xyz, + cg.qualifier_width.xyz, + cg.qualifier_softness.xyz, + cg.qualifier_softness.w, + ); + // Apply correction within qualified region + var corrected = apply_cdl(color, cg.q_slope.rgb, cg.q_offset.rgb, cg.q_power.rgb); + corrected = corrected * pow(2.0, cg.q_adjustments.y); // exposure + let lum_q = cg_luminance(corrected); + corrected = mix(vec3(lum_q), corrected, cg.q_adjustments.x); // saturation + // Desaturate non-qualified region so user can see the selection + let outside = mix(vec3(cg_luminance(color)), color, 0.3); + return mix(outside, corrected, mask * cg.qualifier_mix); +} + +// ============================================================================ +// Power Window +// ============================================================================ + +fn power_window_mask(uv: vec2) -> f32 { + let center = cg.window_center_scale.xy; + let scale = cg.window_center_scale.zw; + let rotation = cg.window_params.x * 6.28318530718; // normalized to radians + let softness_inner = cg.window_params.y; + let softness_outer = cg.window_params.z; + let invert_flag = cg.window_params.w; + let shape_type = cg.window_shape.w; + + // Transform UV relative to window center, accounting for rotation and scale + var p = uv - center; + let cos_r = cos(rotation); + let sin_r = sin(rotation); + p = vec2(p.x * cos_r + p.y * sin_r, -p.x * sin_r + p.y * cos_r); + p = p / max(scale, vec2(0.001)); + + var dist: f32; + if (shape_type < 0.5) { + // Circle/Ellipse + let r = cg.window_shape.xy; + let d = p / max(r, vec2(0.001)); + dist = length(d); + } else if (shape_type < 1.5) { + // Rectangle + let half_size = cg.window_shape.xy * 0.5; + let corner = cg.window_shape.z; + let d = abs(p) - half_size + corner; + dist = (length(max(d, vec2(0.0))) + min(max(d.x, d.y), 0.0) - corner) + / max(min(half_size.x, half_size.y), 0.001) + 1.0; + } else { + // Gradient + let angle = cg.window_shape.x * 6.28318530718; + let dir = vec2(cos(angle), sin(angle)); + dist = dot(p, dir) + 0.5; + } + + // Apply softness + let edge_start = 1.0 - softness_inner; + let edge_end = 1.0 + softness_outer; + var mask = 1.0 - smoothstep(edge_start, edge_end, dist); + + if (invert_flag > 0.5) { + mask = 1.0 - mask; + } + return mask; +} + +fn apply_window(color: vec3, uv: vec2) -> vec3 { + let mask = power_window_mask(uv); + if (mask < 0.001) { + return color; + } + var corrected = apply_cdl(color, cg.w_slope.rgb, cg.w_offset.rgb, cg.w_power.rgb); + corrected = corrected * pow(2.0, cg.w_adjustments.y); // exposure + let lum_w = cg_luminance(corrected); + corrected = mix(vec3(lum_w), corrected, cg.w_adjustments.x); // saturation + return mix(color, corrected, mask * cg.window_mix.x); +} + +// ============================================================================ +// 3D LUT +// ============================================================================ + +fn apply_lut(color: vec3, lut_mix: f32) -> vec3 { + let lut_size = cg.lut_size; + // Scale color to LUT coordinates with half-texel offset for correct sampling + let half_texel = 0.5 / lut_size; + let scale = (lut_size - 1.0) / lut_size; + let lut_coord = clamp(color, vec3(0.0), vec3(1.0)) * scale + half_texel; + let lut_color = textureSampleLevel(t_lut_3d, s_lut, lut_coord, 0.0).rgb; + return mix(color, lut_color, lut_mix); +} + +// ============================================================================ +// Combined Color Grading +// ============================================================================ + +fn apply_color_grading(color: vec3, uv: vec2) -> vec3 { if ((cg.flags & CG_FLAG_BYPASS) != 0u) { return color; } @@ -438,6 +609,21 @@ fn apply_color_grading(color: vec3) -> vec3 { ); } + // 3D LUT + if ((cg.flags & CG_FLAG_LUT_ENABLED) != 0u) { + result = apply_lut(result, cg.lut_mix); + } + + // HSL Qualifier (secondary correction within color range) + if ((cg.flags & CG_FLAG_QUALIFIER_ENABLED) != 0u) { + result = apply_qualifier(result); + } + + // Power Window (regional correction) + if ((cg.flags & CG_FLAG_WINDOW_ENABLED) != 0u) { + result = apply_window(result, uv); + } + // Output CST: convert from linear to output color space if ((cg.flags & CG_FLAG_OUTPUT_CST) != 0u) { result = from_linear(result, cg.output_cst); @@ -499,7 +685,7 @@ fn fs_main(in: VertexOutput) -> @location(0) vec4 { } // Apply color grading - color = vec4(apply_color_grading(color.rgb), color.a); + color = vec4(apply_color_grading(color.rgb, in.tex_coord), color.a); // Apply wipe transition masking (for cross-transition wipes) // Wipe types: 3=WipeLeft, 4=WipeRight, 5=WipeUp, 6=WipeDown @@ -582,16 +768,37 @@ pub fn create_bind_group_layout(device: &Device) -> BindGroupLayout { pub fn create_color_grading_bind_group_layout(device: &Device) -> BindGroupLayout { device.create_bind_group_layout(&BindGroupLayoutDescriptor { label: Some("color_grading_bind_group_layout"), - entries: &[BindGroupLayoutEntry { - binding: 0, - visibility: ShaderStages::FRAGMENT, - ty: BindingType::Buffer { - ty: wgpu::BufferBindingType::Uniform, - has_dynamic_offset: false, - min_binding_size: None, + entries: &[ + // binding 0: color grading uniforms + BindGroupLayoutEntry { + binding: 0, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + // binding 1: 3D LUT texture (Rgba16Float — supports linear filtering) + BindGroupLayoutEntry { + binding: 1, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Texture { + sample_type: TextureSampleType::Float { filterable: true }, + view_dimension: TextureViewDimension::D3, + multisampled: false, + }, + count: None, + }, + // binding 2: LUT sampler (linear filtering for smooth interpolation) + BindGroupLayoutEntry { + binding: 2, + visibility: ShaderStages::FRAGMENT, + ty: BindingType::Sampler(SamplerBindingType::Filtering), + count: None, }, - count: None, - }], + ], }) } diff --git a/packages/render-engine/src/types.ts b/packages/render-engine/src/types.ts index 6a68617..8cb9ced 100644 --- a/packages/render-engine/src/types.ts +++ b/packages/render-engine/src/types.ts @@ -620,6 +620,24 @@ export const DEFAULT_HSL_QUALIFIER: HslQualifier = { invert: false, }; +export const DEFAULT_LUT_REFERENCE: LutReference = { + lut_id: "", + interpolation: "Tetrahedral", + mix: 1.0, +}; + +export const DEFAULT_POWER_WINDOW: PowerWindow = { + shape: { Circle: { radius_x: 0.25, radius_y: 0.25 } }, + center_x: 0.5, + center_y: 0.5, + scale_x: 1, + scale_y: 1, + rotation: 0, + softness_inner: 0, + softness_outer: 0.1, + invert: false, +}; + export const DEFAULT_COLOR_GRADING: ColorGrading = { input_color_space: "Srgb", output_color_space: "Srgb", From 06e58edb0fe5d88fd1cbe065548a93a8a5e50704 Mon Sep 17 00:00:00 2001 From: Mohamad Mohebifar Date: Tue, 7 Apr 2026 00:26:08 -0700 Subject: [PATCH 4/6] feat: enhance video frame handling with rotation correction and current frame capture --- .beads/issues.jsonl | 8 +- apps/ui/src/components/ui/dropdown-menu.tsx | 256 -------------------- apps/ui/src/lib/lut-manager.ts | 65 ----- 3 files changed, 4 insertions(+), 325 deletions(-) delete mode 100644 apps/ui/src/components/ui/dropdown-menu.tsx diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl index 36224f6..5f7938e 100644 --- a/.beads/issues.jsonl +++ b/.beads/issues.jsonl @@ -22,10 +22,10 @@ {"id":"tooscut-and.3","title":"Color wheels (Lift/Gamma/Gain)","description":"## Summary\nImplement Lift/Gamma/Gain color wheels for intuitive shadow/midtone/highlight color adjustment.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `color_wheel_to_rgb()` - convert angle/distance to RGB offset\n- [ ] `apply_lift_gamma_gain()` function:\n - Lift affects shadows (adds color when pixel is dark)\n - Gamma affects midtones (power function)\n - Gain affects highlights (multiplies)\n- [ ] Each wheel has RGB color shift + luminance master\n\n### UI Components\n- [ ] `ColorWheel` component (Canvas-based):\n - Circular color picker with saturation gradient\n - Draggable position indicator\n - Double-click to reset to center\n - Luminance slider below wheel\n- [ ] `ColorWheelsPanel` with three wheels (Lift, Gamma, Gain)\n- [ ] \"Link wheels\" toggle for global adjustments\n- [ ] Reset button per wheel and global reset\n\n### Interaction Design\n- [ ] Mouse drag updates angle + distance from center\n- [ ] Shift+drag constrains to current angle (saturation only)\n- [ ] Ctrl+drag for fine adjustment (0.1x speed)\n- [ ] Keyboard: arrow keys for small adjustments when focused\n\n### State Management\n- [ ] Add `updateClipColorWheels(clipId, wheels)` action\n- [ ] Wheel values stored as `{ angle: number, distance: number }`\n\n## Acceptance Criteria\n- Dragging lift wheel adds color to dark areas only\n- Dragging gain wheel adds color to bright areas only\n- Gamma affects midtones without crushing blacks/whites\n- Luminance sliders work independently from color shift\n- All changes are undoable","notes":"Completed: Canvas-based ColorWheel component with drag/shift+drag/ctrl+drag interactions, ColorWheelsProperties panel (Lift/Gamma/Gain), and React Flow node graph visualization. The node graph shows the color grading pipeline with color-coded nodes, enable/disable toggles, and drag-to-reorder support.","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:02:55.013616-07:00","updated_at":"2026-04-04T16:58:32.135196-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.3","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:02:55.015382-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.3","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:02:55.018517-07:00","created_by":"mohebifar"}]} {"id":"tooscut-and.4","title":"Curves editor","description":"## Summary\nImplement RGB curves for precise tonal control, plus advanced curves (Hue vs Sat, Lum vs Sat, etc.).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `evaluate_curve()` function using 1D LUT texture sampling\n- [ ] Support for smooth spline interpolation between control points\n- [ ] Apply curves in correct order: Master → R → G → B → Advanced\n\n### Curve LUT Generation (TypeScript)\n- [ ] `generateCurveLUT(points: CurvePoint[])` - outputs 256-entry Float32Array\n- [ ] Catmull-Rom or cubic Bezier interpolation between points\n- [ ] Handle edge cases (points at 0,0 and 1,1)\n\n### UI Components\n- [ ] `CurvesEditor` component (Canvas-based):\n - 256x256 grid with diagonal reference line\n - Click to add control point\n - Drag to move control point\n - Double-click or Delete key to remove point\n - Smooth curve rendering through points\n- [ ] Channel selector tabs: Master, Red, Green, Blue\n- [ ] Advanced curves dropdown: Hue vs Sat, Hue vs Lum, Sat vs Sat, Lum vs Sat\n- [ ] Preset curves: S-curve, fade, negative, etc.\n- [ ] Reset button\n\n### Advanced Curves\n- [ ] Hue vs Hue - rotate hues selectively\n- [ ] Hue vs Sat - saturate/desaturate specific hues\n- [ ] Hue vs Lum - brighten/darken specific hues\n- [ ] Lum vs Sat - saturate shadows/highlights differently\n- [ ] Sat vs Sat - compress or expand saturation range\n\n### State Management\n- [ ] Add `updateClipCurves(clipId, curves)` action\n- [ ] Curve presets stored separately for quick application\n\n### Performance\n- [ ] Regenerate LUT texture only when curve points change\n- [ ] Debounce during drag operations\n- [ ] Cache curve textures per clip\n\n## Acceptance Criteria\n- Dragging curve up brightens, down darkens\n- RGB curves affect only their respective channels\n- S-curve increases contrast visually\n- Smooth interpolation (no stair-stepping)\n- 60fps interaction during curve editing","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:09.958519-07:00","updated_at":"2026-04-04T14:03:09.958519-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.4","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:09.960905-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.4","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:09.964293-07:00","created_by":"mohebifar"}]} {"id":"tooscut-and.5","title":"3D LUT support (.cube files)","description":"## Summary\nImplement 3D LUT loading, GPU upload, and tetrahedral interpolation for professional color transforms.\n\n## Tasks\n\n### .cube File Parser (TypeScript)\n- [ ] `parseCubeFile(content: string): CubeLUT`\n- [ ] Parse header: TITLE, LUT_3D_SIZE, DOMAIN_MIN, DOMAIN_MAX\n- [ ] Parse RGB triplet data lines\n- [ ] Validate cube size (common: 17, 33, 65)\n- [ ] Handle comments and empty lines\n\n### LUT Manager (Rust/WASM)\n- [ ] `LutManager` struct with HashMap of loaded LUTs\n- [ ] `upload_lut(id, size, data)` - creates 3D texture on GPU\n- [ ] `remove_lut(id)` - frees GPU memory\n- [ ] `get_lut_texture(id)` - returns texture view for binding\n\n### WGSL Shader Implementation\n- [ ] `apply_lut_trilinear()` - basic trilinear interpolation\n- [ ] `apply_lut_tetrahedral()` - higher quality tetrahedral interpolation\n- [ ] Handle domain min/max scaling\n- [ ] LUT sampler with clamp-to-edge addressing\n\n### UI Components\n- [ ] `LutBrowserPanel` component:\n - Grid view of available LUTs with thumbnails\n - Search/filter by name\n - Preview on hover\n - Click to apply\n- [ ] LUT import button (file picker for .cube)\n- [ ] LUT intensity slider (mix with original)\n- [ ] Remove LUT button\n\n### LUT Thumbnail Generation\n- [ ] Generate preview by applying LUT to standard gradient image\n- [ ] Cache thumbnails in IndexedDB\n\n### State Management\n- [ ] Add `loadedLuts: Map\u003cstring, LoadedLut\u003e` to store\n- [ ] Add `loadLut(file)`, `removeLut(id)`, `setClipLut(clipId, lutId)` actions\n- [ ] LUT data persisted in project (or referenced by path)\n\n### Memory Management\n- [ ] Limit simultaneous loaded LUTs (e.g., 10 max)\n- [ ] LRU eviction for unused LUTs\n- [ ] 33³ × 16 bytes = ~575KB per LUT\n\n## Acceptance Criteria\n- .cube files from DaVinci/Resolve load correctly\n- LUT applied matches reference implementation\n- Tetrahedral interpolation has no visible banding\n- LUT browser shows accurate previews\n- Memory usage stays bounded with many LUTs","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:25.934398-07:00","updated_at":"2026-04-04T14:41:14.358352-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.5","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:25.936583-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.5","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:03:25.938607-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.6","title":"HSL Qualifier (secondary color correction)","description":"## Summary\nImplement HSL qualifier for isolating and correcting specific colors (secondary color correction / keying).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `hsl_qualifier_mask()` function:\n - Hue center + width + softness (circular distance)\n - Saturation center + width + softness\n - Luminance center + width + softness\n - Combine masks multiplicatively\n - Invert option\n- [ ] Apply correction multiplied by qualifier mask\n- [ ] Edge softness using smoothstep\n\n### UI Components\n- [ ] `HslQualifierPanel` component:\n - Hue range selector (circular/strip visualization)\n - Saturation range selector (horizontal bar)\n - Luminance range selector (horizontal bar)\n - Softness controls for each dimension\n - Invert toggle\n- [ ] Eyedropper tool to pick qualifier center from preview\n- [ ] \"Show Mask\" toggle to visualize selection (B\u0026W mask view)\n- [ ] Correction controls (reuse PrimaryCorrectionPanel) applied to qualified region\n\n### Eyedropper Interaction\n- [ ] Click on preview to sample pixel HSL values\n- [ ] Shift+click to add to selection (expand range)\n- [ ] Alt+click to subtract from selection\n- [ ] Sample multiple pixels for average\n\n### Mask Visualization\n- [ ] Toggle between: Normal view, Mask overlay, Mask only\n- [ ] Mask overlay shows selection as highlight color\n- [ ] Mask only shows B\u0026W (white = selected)\n\n### State Management\n- [ ] Add `HslQualifierNode` to color grading node types\n- [ ] Qualifier stores center, width, softness for H/S/L\n- [ ] Correction stored within qualifier node\n\n## Acceptance Criteria\n- Can isolate skin tones and adjust without affecting background\n- Can select sky blue and increase saturation\n- Soft edges blend naturally (no harsh cutoffs)\n- Eyedropper accurately picks colors from preview\n- Mask visualization helps dial in selection","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:39.983433-07:00","updated_at":"2026-04-04T14:03:39.983433-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:39.987334-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:39.985365-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.7","title":"Power windows (masks for regional correction)","description":"## Summary\nImplement power windows (geometric masks) for regional color corrections - vignettes, sky gradients, face isolation, etc.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] Shape mask generators:\n - Circle/Ellipse\n - Rectangle with corner radius\n - Linear gradient\n - Polygon (for complex shapes)\n- [ ] Transform: position, scale, rotation\n- [ ] Inner/outer softness (feathering)\n- [ ] Invert option\n- [ ] Combine with qualifier mask (AND operation)\n\n### UI Components\n- [ ] `PowerWindowPanel` component:\n - Shape selector (circle, rectangle, gradient, polygon)\n - Position X/Y sliders (% of frame)\n - Scale X/Y sliders\n - Rotation slider\n - Softness inner/outer sliders\n - Invert toggle\n- [ ] On-canvas shape overlay:\n - Draggable shape outline on preview\n - Corner handles for resize\n - Rotation handle\n - Center point for position\n\n### Shape Drawing\n- [ ] Circle: center + radius, draggable edge\n- [ ] Rectangle: corner handles, aspect ratio lock option\n- [ ] Gradient: start point + end point + angle\n- [ ] Polygon: click to add points, close path\n\n### Tracking (stretch goal)\n- [ ] Manual keyframe tracking (position/scale/rotation keyframes)\n- [ ] Data structure for tracked window transforms\n- [ ] Interpolation between tracking keyframes\n\n### Combination with Qualifier\n- [ ] Power window can be combined with HSL qualifier\n- [ ] \"Window AND Qualifier\" mode - both must match\n- [ ] Useful for: face in specific area, sky in upper region only\n\n## Acceptance Criteria\n- Can create vignette effect with soft circular window\n- Can isolate upper sky with gradient window\n- Can draw polygon around subject\n- On-canvas controls are intuitive (drag to move, handles to resize)\n- Soft edges blend naturally","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:54.558574-07:00","updated_at":"2026-04-04T14:03:54.558574-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:54.560807-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:03:54.563678-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.8","title":"Video scopes (waveform, vectorscope, histogram)","description":"## Summary\nImplement professional video scopes computed on GPU for real-time color analysis.\n\n## Tasks\n\n### Scope Types\n- [ ] **Histogram** - RGB + Luma distribution (256 bins each)\n- [ ] **Waveform** - Luma or RGB waveform (brightness by horizontal position)\n- [ ] **RGB Parade** - Separate R/G/B waveforms side by side\n- [ ] **Vectorscope** - Color distribution on color wheel (hue angle + saturation radius)\n\n### WGSL Compute Shaders\n- [ ] `compute_histogram.wgsl`:\n - Atomic histogram binning\n - Output: 4 × 256 buffer (R, G, B, Luma)\n- [ ] `compute_waveform.wgsl`:\n - For each column, accumulate pixel brightness into rows\n - Output: 2D texture (width × 256)\n- [ ] `compute_vectorscope.wgsl`:\n - Map each pixel to 2D position by hue/sat\n - Accumulate density\n - Output: 256×256 texture\n\n### Rust/WASM Implementation\n- [ ] `ScopeComputer` struct managing compute pipelines\n- [ ] `compute_histogram(texture_id) -\u003e Vec\u003cf32\u003e`\n- [ ] `compute_waveform(texture_id, width) -\u003e Vec\u003cu8\u003e`\n- [ ] `compute_vectorscope(texture_id, size) -\u003e Vec\u003cu8\u003e`\n\n### UI Components\n- [ ] `ScopesPanel` component:\n - Scope type selector tabs\n - Canvas for scope display\n - Graticule overlays (scale markers)\n- [ ] Histogram: stacked or separate R/G/B view option\n- [ ] Waveform: Luma / RGB / Parade mode selector\n- [ ] Vectorscope: skin tone line, color target boxes\n\n### Graticules and Reference Lines\n- [ ] Histogram: 0%, 50%, 100% markers\n- [ ] Waveform: IRE/percentage scale, legal range indicators (16-235)\n- [ ] Vectorscope: color boxes (R, Mg, B, Cy, G, Yl), skin tone line\n\n### Performance\n- [ ] Compute scopes on requestAnimationFrame, not every frame\n- [ ] Throttle during playback (every 2-3 frames)\n- [ ] Full rate when paused\n- [ ] Resolution option: 1x, 1/2, 1/4 for faster computation\n\n## Acceptance Criteria\n- Histogram matches reference scope software\n- Waveform shows correct brightness distribution\n- Vectorscope shows correct color positions\n- Scopes update in real-time during playback (\u003c16ms compute time)\n- Graticules help interpret scope readings","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:11.037853-07:00","updated_at":"2026-04-04T14:41:14.406509-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:11.040172-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:04:11.04237-07:00","created_by":"mohebifar"}]} -{"id":"tooscut-and.9","title":"Node-based color grading pipeline","description":"## Summary\nImplement node-based color grading pipeline allowing multiple stacked corrections executed in order.\n\n## Tasks\n\n### Data Structures\n- [ ] `ColorGradingNode` union type (primary, wheels, curves, LUT, qualifier, window)\n- [ ] `ClipColorGrading` with ordered `nodes: ColorGradingNode[]` array\n- [ ] Each node has: id, type, enabled, mix, label\n\n### Node Execution Engine (Rust/WASM)\n- [ ] `execute_color_pipeline(texture, nodes) -\u003e texture`\n- [ ] Execute nodes in order, each reading previous output\n- [ ] Skip disabled nodes\n- [ ] Apply per-node mix (blend with input)\n- [ ] Intermediate render targets for multi-pass\n\n### UI Components\n- [ ] `NodeListPanel` component:\n - Vertical list of nodes (not visual graph, like Resolve's mini timeline)\n - Drag to reorder\n - Enable/disable toggle per node\n - Mix slider per node\n - Expand/collapse node settings\n - Add node button (dropdown of types)\n - Delete node button\n- [ ] Node type icons for quick identification\n- [ ] \"Solo\" button to preview single node effect\n\n### Node Operations\n- [ ] Add node (at end or after selected)\n- [ ] Remove node\n- [ ] Reorder nodes (drag \u0026 drop)\n- [ ] Duplicate node\n- [ ] Enable/disable node\n- [ ] Reset node to defaults\n- [ ] Copy/paste node between clips\n\n### Bypass and Preview\n- [ ] Global bypass toggle (show original)\n- [ ] \"Solo node\" mode - only selected node active\n- [ ] A/B comparison split view (future enhancement)\n\n### State Management\n- [ ] Add `addColorGradingNode(clipId, node, index?)` action\n- [ ] Add `removeColorGradingNode(clipId, nodeId)` action\n- [ ] Add `reorderColorGradingNodes(clipId, fromIndex, toIndex)` action\n- [ ] Add `updateColorGradingNode(clipId, nodeId, updates)` action\n\n## Acceptance Criteria\n- Can stack multiple corrections (e.g., Primary → Curves → LUT)\n- Node order affects output (e.g., LUT before vs after primary)\n- Disabling node removes its effect\n- Mix slider blends node effect with input\n- Drag reordering updates preview immediately","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:26.186835-07:00","updated_at":"2026-04-04T14:04:26.186835-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:26.187706-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.3","type":"blocks","created_at":"2026-04-04T14:04:26.188467-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.4","type":"blocks","created_at":"2026-04-04T14:04:26.189223-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.5","type":"blocks","created_at":"2026-04-04T14:04:26.189985-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:04:26.190624-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.7","type":"blocks","created_at":"2026-04-04T14:04:26.191412-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.6","title":"HSL Qualifier (secondary color correction)","description":"## Summary\nImplement HSL qualifier for isolating and correcting specific colors (secondary color correction / keying).\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] `hsl_qualifier_mask()` function:\n - Hue center + width + softness (circular distance)\n - Saturation center + width + softness\n - Luminance center + width + softness\n - Combine masks multiplicatively\n - Invert option\n- [ ] Apply correction multiplied by qualifier mask\n- [ ] Edge softness using smoothstep\n\n### UI Components\n- [ ] `HslQualifierPanel` component:\n - Hue range selector (circular/strip visualization)\n - Saturation range selector (horizontal bar)\n - Luminance range selector (horizontal bar)\n - Softness controls for each dimension\n - Invert toggle\n- [ ] Eyedropper tool to pick qualifier center from preview\n- [ ] \"Show Mask\" toggle to visualize selection (B\u0026W mask view)\n- [ ] Correction controls (reuse PrimaryCorrectionPanel) applied to qualified region\n\n### Eyedropper Interaction\n- [ ] Click on preview to sample pixel HSL values\n- [ ] Shift+click to add to selection (expand range)\n- [ ] Alt+click to subtract from selection\n- [ ] Sample multiple pixels for average\n\n### Mask Visualization\n- [ ] Toggle between: Normal view, Mask overlay, Mask only\n- [ ] Mask overlay shows selection as highlight color\n- [ ] Mask only shows B\u0026W (white = selected)\n\n### State Management\n- [ ] Add `HslQualifierNode` to color grading node types\n- [ ] Qualifier stores center, width, softness for H/S/L\n- [ ] Correction stored within qualifier node\n\n## Acceptance Criteria\n- Can isolate skin tones and adjust without affecting background\n- Can select sky blue and increase saturation\n- Soft edges blend naturally (no harsh cutoffs)\n- Eyedropper accurately picks colors from preview\n- Mask visualization helps dial in selection","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:39.983433-07:00","updated_at":"2026-04-04T14:03:39.983433-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:39.985365-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.6","depends_on_id":"tooscut-and.2","type":"blocks","created_at":"2026-04-04T14:03:39.987334-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.7","title":"Power windows (masks for regional correction)","description":"## Summary\nImplement power windows (geometric masks) for regional color corrections - vignettes, sky gradients, face isolation, etc.\n\n## Tasks\n\n### WGSL Shader Implementation\n- [ ] Shape mask generators:\n - Circle/Ellipse\n - Rectangle with corner radius\n - Linear gradient\n - Polygon (for complex shapes)\n- [ ] Transform: position, scale, rotation\n- [ ] Inner/outer softness (feathering)\n- [ ] Invert option\n- [ ] Combine with qualifier mask (AND operation)\n\n### UI Components\n- [ ] `PowerWindowPanel` component:\n - Shape selector (circle, rectangle, gradient, polygon)\n - Position X/Y sliders (% of frame)\n - Scale X/Y sliders\n - Rotation slider\n - Softness inner/outer sliders\n - Invert toggle\n- [ ] On-canvas shape overlay:\n - Draggable shape outline on preview\n - Corner handles for resize\n - Rotation handle\n - Center point for position\n\n### Shape Drawing\n- [ ] Circle: center + radius, draggable edge\n- [ ] Rectangle: corner handles, aspect ratio lock option\n- [ ] Gradient: start point + end point + angle\n- [ ] Polygon: click to add points, close path\n\n### Tracking (stretch goal)\n- [ ] Manual keyframe tracking (position/scale/rotation keyframes)\n- [ ] Data structure for tracked window transforms\n- [ ] Interpolation between tracking keyframes\n\n### Combination with Qualifier\n- [ ] Power window can be combined with HSL qualifier\n- [ ] \"Window AND Qualifier\" mode - both must match\n- [ ] Useful for: face in specific area, sky in upper region only\n\n## Acceptance Criteria\n- Can create vignette effect with soft circular window\n- Can isolate upper sky with gradient window\n- Can draw polygon around subject\n- On-canvas controls are intuitive (drag to move, handles to resize)\n- Soft edges blend naturally","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:03:54.558574-07:00","updated_at":"2026-04-04T14:03:54.558574-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:03:54.563678-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.7","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:03:54.560807-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.8","title":"Video scopes (waveform, vectorscope, histogram)","description":"## Summary\nImplement professional video scopes computed on GPU for real-time color analysis.\n\n## Tasks\n\n### Scope Types\n- [ ] **Histogram** - RGB + Luma distribution (256 bins each)\n- [ ] **Waveform** - Luma or RGB waveform (brightness by horizontal position)\n- [ ] **RGB Parade** - Separate R/G/B waveforms side by side\n- [ ] **Vectorscope** - Color distribution on color wheel (hue angle + saturation radius)\n\n### WGSL Compute Shaders\n- [ ] `compute_histogram.wgsl`:\n - Atomic histogram binning\n - Output: 4 × 256 buffer (R, G, B, Luma)\n- [ ] `compute_waveform.wgsl`:\n - For each column, accumulate pixel brightness into rows\n - Output: 2D texture (width × 256)\n- [ ] `compute_vectorscope.wgsl`:\n - Map each pixel to 2D position by hue/sat\n - Accumulate density\n - Output: 256×256 texture\n\n### Rust/WASM Implementation\n- [ ] `ScopeComputer` struct managing compute pipelines\n- [ ] `compute_histogram(texture_id) -\u003e Vec\u003cf32\u003e`\n- [ ] `compute_waveform(texture_id, width) -\u003e Vec\u003cu8\u003e`\n- [ ] `compute_vectorscope(texture_id, size) -\u003e Vec\u003cu8\u003e`\n\n### UI Components\n- [ ] `ScopesPanel` component:\n - Scope type selector tabs\n - Canvas for scope display\n - Graticule overlays (scale markers)\n- [ ] Histogram: stacked or separate R/G/B view option\n- [ ] Waveform: Luma / RGB / Parade mode selector\n- [ ] Vectorscope: skin tone line, color target boxes\n\n### Graticules and Reference Lines\n- [ ] Histogram: 0%, 50%, 100% markers\n- [ ] Waveform: IRE/percentage scale, legal range indicators (16-235)\n- [ ] Vectorscope: color boxes (R, Mg, B, Cy, G, Yl), skin tone line\n\n### Performance\n- [ ] Compute scopes on requestAnimationFrame, not every frame\n- [ ] Throttle during playback (every 2-3 frames)\n- [ ] Full rate when paused\n- [ ] Resolution option: 1x, 1/2, 1/4 for faster computation\n\n## Acceptance Criteria\n- Histogram matches reference scope software\n- Waveform shows correct brightness distribution\n- Vectorscope shows correct color positions\n- Scopes update in real-time during playback (\u003c16ms compute time)\n- Graticules help interpret scope readings","status":"in_progress","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:11.037853-07:00","updated_at":"2026-04-04T14:41:14.406509-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and.1","type":"blocks","created_at":"2026-04-04T14:04:11.04237-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.8","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:11.040172-07:00","created_by":"mohebifar"}]} +{"id":"tooscut-and.9","title":"Node-based color grading pipeline","description":"## Summary\nImplement node-based color grading pipeline allowing multiple stacked corrections executed in order.\n\n## Tasks\n\n### Data Structures\n- [ ] `ColorGradingNode` union type (primary, wheels, curves, LUT, qualifier, window)\n- [ ] `ClipColorGrading` with ordered `nodes: ColorGradingNode[]` array\n- [ ] Each node has: id, type, enabled, mix, label\n\n### Node Execution Engine (Rust/WASM)\n- [ ] `execute_color_pipeline(texture, nodes) -\u003e texture`\n- [ ] Execute nodes in order, each reading previous output\n- [ ] Skip disabled nodes\n- [ ] Apply per-node mix (blend with input)\n- [ ] Intermediate render targets for multi-pass\n\n### UI Components\n- [ ] `NodeListPanel` component:\n - Vertical list of nodes (not visual graph, like Resolve's mini timeline)\n - Drag to reorder\n - Enable/disable toggle per node\n - Mix slider per node\n - Expand/collapse node settings\n - Add node button (dropdown of types)\n - Delete node button\n- [ ] Node type icons for quick identification\n- [ ] \"Solo\" button to preview single node effect\n\n### Node Operations\n- [ ] Add node (at end or after selected)\n- [ ] Remove node\n- [ ] Reorder nodes (drag \u0026 drop)\n- [ ] Duplicate node\n- [ ] Enable/disable node\n- [ ] Reset node to defaults\n- [ ] Copy/paste node between clips\n\n### Bypass and Preview\n- [ ] Global bypass toggle (show original)\n- [ ] \"Solo node\" mode - only selected node active\n- [ ] A/B comparison split view (future enhancement)\n\n### State Management\n- [ ] Add `addColorGradingNode(clipId, node, index?)` action\n- [ ] Add `removeColorGradingNode(clipId, nodeId)` action\n- [ ] Add `reorderColorGradingNodes(clipId, fromIndex, toIndex)` action\n- [ ] Add `updateColorGradingNode(clipId, nodeId, updates)` action\n\n## Acceptance Criteria\n- Can stack multiple corrections (e.g., Primary → Curves → LUT)\n- Node order affects output (e.g., LUT before vs after primary)\n- Disabling node removes its effect\n- Mix slider blends node effect with input\n- Drag reordering updates preview immediately","status":"open","priority":2,"issue_type":"feature","created_at":"2026-04-04T14:04:26.186835-07:00","updated_at":"2026-04-04T14:04:26.186835-07:00","created_by":"mohebifar","dependencies":[{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.6","type":"blocks","created_at":"2026-04-04T14:04:26.190624-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.7","type":"blocks","created_at":"2026-04-04T14:04:26.191412-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and","type":"parent-child","created_at":"2026-04-04T14:04:26.187706-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.3","type":"blocks","created_at":"2026-04-04T14:04:26.188467-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.4","type":"blocks","created_at":"2026-04-04T14:04:26.189223-07:00","created_by":"mohebifar"},{"issue_id":"tooscut-and.9","depends_on_id":"tooscut-and.5","type":"blocks","created_at":"2026-04-04T14:04:26.189985-07:00","created_by":"mohebifar"}]} {"id":"tooscut-bxi","title":"Show video clip thumbnails in timeline","description":"Display thumbnail previews for video clips in the timeline:\n- Performant rendering like subformer\n- Handle zoom in/out properly (show more/fewer thumbnails)\n- Reference: /Users/mohebifar/dev/other/kareem/subformer","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:57.360668-08:00","updated_at":"2026-02-04T23:22:18.364728-08:00","closed_at":"2026-02-04T23:22:18.364728-08:00","close_reason":"Closed","created_by":"mohebifar"} {"id":"tooscut-d1w","title":"Create numeric input component with drag-to-adjust","description":"Create a numeric input component that:\n- Can increase/decrease value by click and drag left/right\n- Allows direct editing when clicked\n- Accepts a suffix prop for units (e.g., '%', 'px', '°')\n- Will be used in the properties panel for transform/effect controls","status":"closed","priority":2,"issue_type":"feature","created_at":"2026-02-03T15:31:55.226838-08:00","updated_at":"2026-02-03T15:44:26.731079-08:00","closed_at":"2026-02-03T15:44:26.731079-08:00","close_reason":"Closed","created_by":"mohebifar"} {"id":"tooscut-dz0","title":"Generate and display project thumbnails","description":"Projects should have a thumbnail that is shown on the project list page. Generate a thumbnail from the project content (e.g., render a frame from the timeline at a representative time) and store it as thumbnailDataUrl in the DexieJS projects table. Display these thumbnails in the project cards on the home/projects page.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-02-22T02:12:19.167347-08:00","updated_at":"2026-02-22T17:08:56.282213-08:00","closed_at":"2026-02-22T17:08:56.282213-08:00","close_reason":"Closed","created_by":"mohebifar"} diff --git a/apps/ui/src/components/ui/dropdown-menu.tsx b/apps/ui/src/components/ui/dropdown-menu.tsx deleted file mode 100644 index a0a01e6..0000000 --- a/apps/ui/src/components/ui/dropdown-menu.tsx +++ /dev/null @@ -1,256 +0,0 @@ -import { Menu as MenuPrimitive } from "@base-ui/react/menu"; -import { ChevronRightIcon, CheckIcon } from "lucide-react"; -import * as React from "react"; - -import { cn } from "@/lib/utils"; - -function DropdownMenu({ ...props }: MenuPrimitive.Root.Props) { - return ; -} - -function DropdownMenuPortal({ ...props }: MenuPrimitive.Portal.Props) { - return ; -} - -function DropdownMenuTrigger({ ...props }: MenuPrimitive.Trigger.Props) { - return ; -} - -function DropdownMenuContent({ - align = "start", - alignOffset = 0, - side = "bottom", - sideOffset = 4, - className, - ...props -}: MenuPrimitive.Popup.Props & - Pick) { - return ( - - - - - - ); -} - -function DropdownMenuGroup({ ...props }: MenuPrimitive.Group.Props) { - return ; -} - -function DropdownMenuLabel({ - className, - inset, - ...props -}: MenuPrimitive.GroupLabel.Props & { - inset?: boolean; -}) { - return ( - - ); -} - -function DropdownMenuItem({ - className, - inset, - variant = "default", - ...props -}: MenuPrimitive.Item.Props & { - inset?: boolean; - variant?: "default" | "destructive"; -}) { - return ( - - ); -} - -function DropdownMenuSub({ ...props }: MenuPrimitive.SubmenuRoot.Props) { - return ; -} - -function DropdownMenuSubTrigger({ - className, - inset, - children, - ...props -}: MenuPrimitive.SubmenuTrigger.Props & { - inset?: boolean; -}) { - return ( - - {children} - - - ); -} - -function DropdownMenuSubContent({ - align = "start", - alignOffset = -3, - side = "right", - sideOffset = 0, - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function DropdownMenuCheckboxItem({ - className, - children, - checked, - inset, - ...props -}: MenuPrimitive.CheckboxItem.Props & { - inset?: boolean; -}) { - return ( - - - - - - - {children} - - ); -} - -function DropdownMenuRadioGroup({ ...props }: MenuPrimitive.RadioGroup.Props) { - return ; -} - -function DropdownMenuRadioItem({ - className, - children, - inset, - ...props -}: MenuPrimitive.RadioItem.Props & { - inset?: boolean; -}) { - return ( - - - - - - - {children} - - ); -} - -function DropdownMenuSeparator({ className, ...props }: MenuPrimitive.Separator.Props) { - return ( - - ); -} - -function DropdownMenuShortcut({ className, ...props }: React.ComponentProps<"span">) { - return ( - - ); -} - -export { - DropdownMenu, - DropdownMenuPortal, - DropdownMenuTrigger, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuLabel, - DropdownMenuItem, - DropdownMenuCheckboxItem, - DropdownMenuRadioGroup, - DropdownMenuRadioItem, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuSub, - DropdownMenuSubTrigger, - DropdownMenuSubContent, -}; diff --git a/apps/ui/src/lib/lut-manager.ts b/apps/ui/src/lib/lut-manager.ts index dee1108..070e978 100644 --- a/apps/ui/src/lib/lut-manager.ts +++ b/apps/ui/src/lib/lut-manager.ts @@ -13,59 +13,6 @@ import { useVideoEditorStore } from "../state/video-editor-store"; import { getSharedCompositor } from "../workers/compositor-api"; import { parseCubeFile, type CubeLut } from "./cube-parser"; -/** - * Import a .cube LUT file from a FileSystemFileHandle. - * - * - Parses the .cube file - * - Stores the FileSystemFileHandle in IndexedDB - * - Adds the LUT as an asset in the editor store - * - Uploads the LUT data to the GPU compositor - * - * Returns the asset ID. - */ -export async function importLutFromHandle( - handle: FileSystemFileHandle, -): Promise<{ id: string; name: string } | null> { - try { - const file = await handle.getFile(); - const text = await file.text(); - const parsed = parseCubeFile(text); - const lutName = parsed.title || file.name.replace(/\.cube$/i, ""); - const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; - - // Persist file handle - await db.fileHandles.put({ - id, - handle, - fileName: file.name, - mimeType: "application/x-cube", - size: file.size, - storedAt: Date.now(), - }); - - // Add to editor store as asset - const store = useVideoEditorStore.getState(); - store.addAssets([ - { - id, - type: "lut", - name: lutName, - url: "", - duration: 0, - lutSize: parsed.size, - }, - ]); - - // Upload to GPU - await uploadLutToGpu(id, parsed); - - return { id, name: lutName }; - } catch (err) { - console.error("Failed to import LUT:", err); - return null; - } -} - /** * Import a .cube LUT file via the File System Access API picker. * Falls back to if the API is unavailable. @@ -164,18 +111,6 @@ export async function hydrateLutAsset(asset: MediaAsset): Promise { } } -/** - * Remove a LUT asset — remove from store, DB, and GPU. - */ -export async function removeLutAsset(assetId: string): Promise { - const compositor = getSharedCompositor(); - if (compositor) { - await compositor.removeLut(); - } - await db.fileHandles.delete(assetId); - useVideoEditorStore.getState().removeAsset(assetId); -} - /** * Upload parsed LUT data to the GPU compositor. */ From 3509bf8e2d1520d6beb614e57a5949984b190095 Mon Sep 17 00:00:00 2001 From: Mohamad Mohebifar Date: Tue, 7 Apr 2026 00:50:54 -0700 Subject: [PATCH 5/6] feat: enhance export dialog and project settings with improved quality presets and resolution options --- .../src/components/editor/export-dialog.tsx | 16 ++--- apps/ui/src/lib/lut-manager.ts | 66 ++++++++++++++++++- 2 files changed, 71 insertions(+), 11 deletions(-) diff --git a/apps/ui/src/components/editor/export-dialog.tsx b/apps/ui/src/components/editor/export-dialog.tsx index b2d24ba..c9d5b2b 100644 --- a/apps/ui/src/components/editor/export-dialog.tsx +++ b/apps/ui/src/components/editor/export-dialog.tsx @@ -35,13 +35,13 @@ interface ExportDialogProps { interface QualityPreset { label: string; - bitrate: number; + value: number; } const QUALITY_PRESETS: QualityPreset[] = [ - { label: "High", bitrate: 20_000_000 }, - { label: "Medium", bitrate: 10_000_000 }, - { label: "Low", bitrate: 5_000_000 }, + { label: `High (${formatBitrate(20_000_000)})`, value: 20_000_000 }, + { label: `Medium (${formatBitrate(10_000_000)})`, value: 10_000_000 }, + { label: `Low (${formatBitrate(5_000_000)})`, value: 5_000_000 }, ]; // ===================== UTILITIES ===================== @@ -126,7 +126,7 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) { width: settings.width, height: settings.height, frameRate: settings.fps.numerator / settings.fps.denominator, - videoBitrate: qualityPreset?.bitrate, + videoBitrate: qualityPreset?.value, fileHandle, }; @@ -189,14 +189,14 @@ export function ExportDialog({ open, onOpenChange }: ExportDialogProps) {
- {QUALITY_PRESETS.map((preset) => ( - - {preset.label} ({formatBitrate(preset.bitrate)}) + + {preset.label} ))} diff --git a/apps/ui/src/lib/lut-manager.ts b/apps/ui/src/lib/lut-manager.ts index 070e978..2cf649e 100644 --- a/apps/ui/src/lib/lut-manager.ts +++ b/apps/ui/src/lib/lut-manager.ts @@ -13,11 +13,67 @@ import { useVideoEditorStore } from "../state/video-editor-store"; import { getSharedCompositor } from "../workers/compositor-api"; import { parseCubeFile, type CubeLut } from "./cube-parser"; +/** + * Import a .cube LUT file from a FileSystemFileHandle. + * + * - Parses the .cube file + * - Stores the FileSystemFileHandle in IndexedDB + * - Adds the LUT as an asset in the editor store + * - Uploads the LUT data to the GPU compositor + * + * Returns the asset ID. + */ +async function importLutFromHandle( + handle: FileSystemFileHandle, +): Promise<{ id: string; name: string } | null> { + try { + const file = await handle.getFile(); + const text = await file.text(); + const parsed = parseCubeFile(text); + const lutName = parsed.title || file.name.replace(/\.cube$/i, ""); + const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; + + // Persist file handle + await db.fileHandles.put({ + id, + handle, + fileName: file.name, + mimeType: "application/x-cube", + size: file.size, + storedAt: Date.now(), + }); + + // Add to editor store as asset + const store = useVideoEditorStore.getState(); + store.addAssets([ + { + id, + type: "lut", + name: lutName, + url: "", + duration: 0, + lutSize: parsed.size, + }, + ]); + + // Upload to GPU + await uploadLutToGpu(id, parsed); + + return { id, name: lutName }; + } catch (err) { + console.error("Failed to import LUT:", err); + return null; + } +} + /** * Import a .cube LUT file via the File System Access API picker. * Falls back to if the API is unavailable. */ -export async function importLutWithPicker(): Promise<{ id: string; name: string } | null> { +export async function importLutWithPicker(): Promise<{ + id: string; + name: string; +} | null> { if ("showOpenFilePicker" in window) { try { const [handle] = await (window as any).showOpenFilePicker({ @@ -91,9 +147,13 @@ export async function hydrateLutAsset(asset: MediaAsset): Promise { try { // Check/request permission - const permission = await (stored.handle as any).queryPermission({ mode: "read" }); + const permission = await (stored.handle as any).queryPermission({ + mode: "read", + }); if (permission !== "granted") { - const requested = await (stored.handle as any).requestPermission({ mode: "read" }); + const requested = await (stored.handle as any).requestPermission({ + mode: "read", + }); if (requested !== "granted") { console.warn(`[lut-manager] Permission denied for LUT ${asset.id}`); return false; From c557d4da93a946a1890efe8c1ede94b0438f3d84 Mon Sep 17 00:00:00 2001 From: Mohamad Mohebifar Date: Tue, 7 Apr 2026 23:03:00 -0700 Subject: [PATCH 6/6] feat: add tone mapping and gamut support to color grading - Introduced `ToneMapping` enum for tone mapping methods in `color_grading.rs`. - Added `Gamut` enum for color gamut definitions, including various industry standards. - Updated `ColorGradingNode` struct to include optional `from_gamut`, `to_gamut`, and `tone_mapping` fields. - Extended TypeScript definitions in `types.ts` to reflect new `Gamut` and `ToneMapping` types. - Implemented comprehensive tests for color space transformations in `cst.test.ts`, validating against reference values from the `colour-science` library. - Added fixture data for transfer functions and gamut conversions in `cst-reference.json`. --- .../color-grading/color-grading-panel.tsx | 24 +- .../editor/color-grading/cst-properties.tsx | 145 +++++- .../compositor/src/color_grading_uniforms.rs | 185 ++++++-- crates/compositor/src/pipeline.rs | 293 ++++++++++-- crates/types/src/color_grading.rs | 72 ++- packages/render-engine/src/types.ts | 18 + packages/render-engine/tests/cst.test.ts | 133 ++++++ .../tests/fixtures/cst-reference.json | 434 ++++++++++++++++++ 8 files changed, 1236 insertions(+), 68 deletions(-) create mode 100644 packages/render-engine/tests/cst.test.ts create mode 100644 packages/render-engine/tests/fixtures/cst-reference.json diff --git a/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx b/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx index 7604bbe..c656816 100644 --- a/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx +++ b/apps/ui/src/components/editor/color-grading/color-grading-panel.tsx @@ -11,6 +11,8 @@ import type { ColorGrading, ColorSpace, + Gamut, + ToneMapping, PrimaryCorrection, ColorWheels, Curves, @@ -145,7 +147,13 @@ export function ColorGradingPanel({ // Update a CST node const handleUpdateCstNode = useCallback( - (updates: { from_space?: ColorSpace; to_space?: ColorSpace }) => { + (updates: { + from_space?: ColorSpace; + to_space?: ColorSpace; + from_gamut?: Gamut; + to_gamut?: Gamut; + tone_mapping?: ToneMapping; + }) => { if (!selectedNode || selectedNode.type !== "ColorSpaceTransform") return; const newNodes = grading.nodes.map((node) => { @@ -227,6 +235,9 @@ export function ColorGradingPanel({ mix: 1, from_space: "SLog3", to_space: "Srgb", + from_gamut: "Rec709", + to_gamut: "Rec709", + tone_mapping: "Simple", }; break; default: @@ -545,7 +556,13 @@ interface NodeParameterEditorProps { onUpdatePrimary: (key: keyof PrimaryCorrection, value: number | [number, number, number]) => void; onUpdateColorWheels: (updates: Partial) => void; onUpdateCurves: (curves: Curves) => void; - onUpdateCst: (updates: { from_space?: ColorSpace; to_space?: ColorSpace }) => void; + onUpdateCst: (updates: { + from_space?: ColorSpace; + to_space?: ColorSpace; + from_gamut?: Gamut; + to_gamut?: Gamut; + tone_mapping?: ToneMapping; + }) => void; onUpdateLut: (updates: Partial) => void; onUpdateQualifier: (key: keyof HslQualifier, value: number | boolean) => void; onUpdateQualifierCorrection: ( @@ -645,6 +662,9 @@ function NodeParameterEditor({ )} diff --git a/apps/ui/src/components/editor/color-grading/cst-properties.tsx b/apps/ui/src/components/editor/color-grading/cst-properties.tsx index 7e9556b..07b3e56 100644 --- a/apps/ui/src/components/editor/color-grading/cst-properties.tsx +++ b/apps/ui/src/components/editor/color-grading/cst-properties.tsx @@ -1,8 +1,11 @@ /** * Color Space Transform node properties editor. + * + * Allows separate selection of transfer function (gamma curve) + * and gamut (color primaries) for source and target. */ -import type { ColorSpace } from "@tooscut/render-engine"; +import type { ColorSpace, Gamut, ToneMapping } from "@tooscut/render-engine"; import { Select, @@ -14,6 +17,7 @@ import { SelectValue, } from "../../ui/select"; +// Transfer function options (gamma curves) const STANDARD_OPTIONS: { value: ColorSpace; label: string }[] = [ { value: "Srgb", label: "sRGB" }, { value: "Linear", label: "Linear" }, @@ -33,23 +37,98 @@ const LOG_OPTIONS: { value: ColorSpace; label: string }[] = [ { value: "RedLog3G10", label: "RED Log3G10" }, ]; +// Gamut (color primaries) options +const STANDARD_GAMUT_OPTIONS: { value: Gamut; label: string }[] = [ + { value: "Rec709", label: "Rec.709 / sRGB" }, + { value: "DciP3", label: "DCI-P3 (D65)" }, + { value: "Rec2020", label: "Rec.2020" }, +]; + +const CAMERA_GAMUT_OPTIONS: { value: Gamut; label: string }[] = [ + { value: "SGamut", label: "Sony S-Gamut" }, + { value: "SGamut3", label: "Sony S-Gamut3" }, + { value: "SGamut3Cine", label: "Sony S-Gamut3.Cine" }, + { value: "ArriWideGamut", label: "ARRI Wide Gamut" }, + { value: "VGamut", label: "Panasonic V-Gamut" }, + { value: "BmdWideGamut", label: "BMD Wide Gamut" }, + { value: "RedWideGamut", label: "RED Wide Gamut" }, +]; + +const ACES_GAMUT_OPTIONS: { value: Gamut; label: string }[] = [ + { value: "AcesCgAp1", label: "ACES AP1 (ACEScg)" }, +]; + +const TONE_MAPPING_OPTIONS: { value: ToneMapping; label: string }[] = [ + { value: "None", label: "None" }, + { value: "Simple", label: "Simple" }, +]; + interface CstPropertiesProps { fromSpace: ColorSpace; toSpace: ColorSpace; - onChange: (updates: { from_space?: ColorSpace; to_space?: ColorSpace }) => void; + fromGamut: Gamut; + toGamut: Gamut; + toneMapping: ToneMapping; + onChange: (updates: { + from_space?: ColorSpace; + to_space?: ColorSpace; + from_gamut?: Gamut; + to_gamut?: Gamut; + tone_mapping?: ToneMapping; + }) => void; } -export function CstProperties({ fromSpace, toSpace, onChange }: CstPropertiesProps) { +export function CstProperties({ + fromSpace, + toSpace, + fromGamut, + toGamut, + toneMapping, + onChange, +}: CstPropertiesProps) { return (
-
- - onChange({ from_space: v })} /> +
Transfer Function
+
+
+ + onChange({ from_space: v })} /> +
+
+ + onChange({ to_space: v })} /> +
-
- - onChange({ to_space: v })} /> +
Color Gamut
+
+
+ + onChange({ from_gamut: v })} /> +
+
+ + onChange({ to_gamut: v })} /> +
+
Tone Mapping
+
); } @@ -101,3 +180,51 @@ function ColorSpaceSelect({ ); } + +function GamutSelect({ + value, + onValueChange, +}: { + value: Gamut; + onValueChange: (value: Gamut) => void; +}) { + return ( + + ); +} diff --git a/crates/compositor/src/color_grading_uniforms.rs b/crates/compositor/src/color_grading_uniforms.rs index 621e91b..c965695 100644 --- a/crates/compositor/src/color_grading_uniforms.rs +++ b/crates/compositor/src/color_grading_uniforms.rs @@ -306,8 +306,10 @@ pub struct ColorGradingUniforms { pub input_cst: u32, /// Output color space transform (ColorSpace enum as u32, 0 = sRGB). pub output_cst: u32, - /// Padding to maintain vec4 alignment. - pub _pad_align: [f32; 2], + /// Input gamut (Gamut enum as u32, 0 = Rec709). + pub input_gamut: u32, + /// Output gamut (Gamut enum as u32, 0 = Rec709). + pub output_gamut: u32, // === Qualifier correction CDL (applied within qualified region) === /// Qualifier correction slope. @@ -337,8 +339,16 @@ pub struct ColorGradingUniforms { /// Window mix + pad. pub window_mix: [f32; 4], // 16 bytes - // Padding to 512 bytes (400 used, 112 remaining = 28 floats) - pub _pad: [f32; 28], + /// Tone mapping method (0 = none, 1 = simple). + pub tone_mapping_method: u32, + /// Tone mapping 'a' parameter (rolloff scale). + pub tone_mapping_a: f32, + /// Tone mapping 'b' parameter (rolloff offset). + pub tone_mapping_b: f32, + pub _pad_tone: f32, + + // Padding to 512 bytes (416 used, 96 remaining = 24 floats) + pub _pad: [f32; 24], } impl Default for ColorGradingUniforms { @@ -364,7 +374,8 @@ impl Default for ColorGradingUniforms { lut_size: 33.0, input_cst: 0, output_cst: 0, - _pad_align: [0.0; 2], + input_gamut: 0, + output_gamut: 0, // Qualifier correction q_slope: [1.0, 1.0, 1.0, 1.0], q_offset: [0.0, 0.0, 0.0, 0.0], @@ -379,7 +390,11 @@ impl Default for ColorGradingUniforms { w_power: [1.0, 1.0, 1.0, 1.0], w_adjustments: [1.0, 0.0, 0.0, 0.0], window_mix: [1.0, 0.0, 0.0, 0.0], - _pad: [0.0; 28], + tone_mapping_method: 0, + tone_mapping_a: 1.0, + tone_mapping_b: 1.0, + _pad_tone: 0.0, + _pad: [0.0; 24], } } } @@ -394,6 +409,59 @@ pub const FLAG_QUALIFIER_ENABLED: u32 = 1 << 5; pub const FLAG_WINDOW_ENABLED: u32 = 1 << 6; pub const FLAG_INPUT_CST: u32 = 1 << 7; pub const FLAG_OUTPUT_CST: u32 = 1 << 8; +pub const FLAG_INPUT_GAMUT: u32 = 1 << 9; +pub const FLAG_OUTPUT_GAMUT: u32 = 1 << 10; + +/// Get peak scene-linear luminance (in nits) for a source transfer function. +/// Used to compute tone mapping parameters. +fn source_peak_nits(cs: &tooscut_types::ColorSpace) -> f32 { + use tooscut_types::ColorSpace; + // Peak nits = peak_scene_linear * 100 (where 1.0 scene-linear = 100 nits SDR) + match cs { + ColorSpace::SLog2 => 1376.0, // 13.76 scene-linear + ColorSpace::SLog3 => 3842.0, // 38.42 scene-linear + ColorSpace::LogC => 5508.0, // 55.08 scene-linear (EI800) + ColorSpace::VLog => 4609.0, // 46.09 scene-linear + ColorSpace::CLog3 => 2500.0, // ~25 scene-linear + ColorSpace::BmFilm => 5508.0, // approximate as LogC + ColorSpace::RedLog3G10 => 3842.0, // approximate as S-Log3 + ColorSpace::AcesCg => 6500.0, // ACES scene-referred, ~65 peak + ColorSpace::Linear => 1000.0, // arbitrary + ColorSpace::Srgb => 100.0, // already display-referred + } +} + +/// Compute tone mapping a/b parameters from input/output peak luminance. +/// Formula: f(x) = a * x / (x + b) +/// Derived from constraints: f(input_white) = output_white, plus adaptation. +fn compute_tone_mapping_ab(input_nits: f32, output_nits: f32, adaptation: f32) -> (f32, f32) { + let iw = input_nits / output_nits; + let ow = 1.0; + if (iw - ow).abs() < 0.001 { + return (1.0, 1.0); // No tone mapping needed + } + let b = (iw - (adaptation / 100.0) * (iw / ow)) / ((iw / ow) - 1.0); + let a = ow / (iw / (iw + b)); + (a, b) +} + +/// Convert Gamut enum to u32 for shader. +fn gamut_to_u32(g: &tooscut_types::Gamut) -> u32 { + use tooscut_types::Gamut; + match g { + Gamut::Rec709 => 0, + Gamut::SGamut => 1, + Gamut::SGamut3 => 2, + Gamut::SGamut3Cine => 3, + Gamut::ArriWideGamut => 4, + Gamut::AcesCgAp1 => 5, + Gamut::RedWideGamut => 6, + Gamut::DciP3 => 7, + Gamut::Rec2020 => 8, + Gamut::VGamut => 9, + Gamut::BmdWideGamut => 10, + } +} /// Convert ColorSpace enum to u32 for shader. fn color_space_to_u32(cs: &tooscut_types::ColorSpace) -> u32 { @@ -426,35 +494,64 @@ impl ColorGradingUniforms { } // Scan for CST nodes: first enabled CST → input, last enabled CST → output - let mut first_cst: Option<(tooscut_types::ColorSpace, tooscut_types::ColorSpace)> = None; - let mut last_cst: Option<(tooscut_types::ColorSpace, tooscut_types::ColorSpace)> = None; + let mut first_cst: Option<&ColorGradingNode> = None; + let mut last_cst: Option<&ColorGradingNode> = None; for node in &grading.nodes { - if let ColorGradingNode::ColorSpaceTransform { - enabled: true, - from_space, - to_space, - .. - } = node - { + if let ColorGradingNode::ColorSpaceTransform { enabled: true, .. } = node { if first_cst.is_none() { - first_cst = Some((from_space.clone(), to_space.clone())); + first_cst = Some(node); } - last_cst = Some((from_space.clone(), to_space.clone())); + last_cst = Some(node); } } - // First CST node: use from_space as input CST (convert from source to linear) - if let Some((from_space, _)) = &first_cst { - if *from_space != tooscut_types::ColorSpace::Srgb { - uniforms.flags |= FLAG_INPUT_CST; - uniforms.input_cst = color_space_to_u32(from_space); + // First CST node: use from_space/from_gamut/tone_mapping as input transforms + if let Some(ColorGradingNode::ColorSpaceTransform { + from_space, + from_gamut, + tone_mapping, + .. + }) = first_cst + { + // Tone mapping method + parameters + if let Some(tm) = tone_mapping { + uniforms.tone_mapping_method = match tm { + tooscut_types::ToneMapping::None => 0, + tooscut_types::ToneMapping::Simple => 1, + }; + if *tm != tooscut_types::ToneMapping::None { + // Compute a/b from source format's peak luminance + let peak_nits = source_peak_nits(from_space); + let (a, b) = compute_tone_mapping_ab(peak_nits, 100.0, 9.0); + uniforms.tone_mapping_a = a; + uniforms.tone_mapping_b = b; + } + } + // Always set input CST — even sRGB needs srgb_to_linear() when + // we want corrections to operate in linear space. + uniforms.flags |= FLAG_INPUT_CST; + uniforms.input_cst = color_space_to_u32(from_space); + if let Some(g) = from_gamut { + if *g != tooscut_types::Gamut::Rec709 { + uniforms.flags |= FLAG_INPUT_GAMUT; + uniforms.input_gamut = gamut_to_u32(g); + } } } - // Last CST node: use to_space as output CST (convert from linear to target) - if let Some((_, to_space)) = &last_cst { - if *to_space != tooscut_types::ColorSpace::Srgb { - uniforms.flags |= FLAG_OUTPUT_CST; - uniforms.output_cst = color_space_to_u32(to_space); + // Last CST node: use to_space/to_gamut as output transforms + if let Some(ColorGradingNode::ColorSpaceTransform { + to_space, to_gamut, .. + }) = last_cst + { + // Always set output CST — we must encode back from linear to + // the target transfer function (e.g. linear → sRGB gamma). + uniforms.flags |= FLAG_OUTPUT_CST; + uniforms.output_cst = color_space_to_u32(to_space); + if let Some(g) = to_gamut { + if *g != tooscut_types::Gamut::Rec709 { + uniforms.flags |= FLAG_OUTPUT_GAMUT; + uniforms.output_gamut = gamut_to_u32(g); + } } } @@ -624,4 +721,38 @@ mod tests { let uniforms = ColorGradingUniforms::from_color_grading(&grading); assert_eq!(uniforms.flags & FLAG_BYPASS, FLAG_BYPASS); } + + #[test] + fn from_color_grading_gamut() { + use tooscut_types::{ColorSpace, Gamut}; + let grading = ColorGrading { + bypass: false, + input_color_space: ColorSpace::Srgb, + output_color_space: ColorSpace::Srgb, + nodes: vec![ColorGradingNode::ColorSpaceTransform { + id: "cst-1".into(), + enabled: true, + mix: 1.0, + label: None, + position: None, + from_space: ColorSpace::SLog2, + to_space: ColorSpace::Srgb, + from_gamut: Some(Gamut::SGamut), + to_gamut: Some(Gamut::Rec709), + tone_mapping: None, + }], + }; + let uniforms = ColorGradingUniforms::from_color_grading(&grading); + // Input CST should be set (SLog2 != Srgb) + assert_ne!(uniforms.flags & FLAG_INPUT_CST, 0, "INPUT_CST flag should be set"); + assert_eq!(uniforms.input_cst, 4); // SLog2 = 4 + // Input gamut should be set (SGamut != Rec709) + assert_ne!(uniforms.flags & FLAG_INPUT_GAMUT, 0, "INPUT_GAMUT flag should be set"); + assert_eq!(uniforms.input_gamut, 1); // SGamut = 1 + // Output CST should be set (always encode back, even for sRGB) + assert_ne!(uniforms.flags & FLAG_OUTPUT_CST, 0, "OUTPUT_CST flag should be set"); + assert_eq!(uniforms.output_cst, 0); // Srgb = 0 + // Output gamut should NOT be set (to_gamut = Rec709 = default) + assert_eq!(uniforms.flags & FLAG_OUTPUT_GAMUT, 0, "OUTPUT_GAMUT flag should NOT be set for Rec709"); + } } diff --git a/crates/compositor/src/pipeline.rs b/crates/compositor/src/pipeline.rs index 5a659cd..cf0e2c6 100644 --- a/crates/compositor/src/pipeline.rs +++ b/crates/compositor/src/pipeline.rs @@ -60,7 +60,8 @@ struct ColorGradingUniforms { lut_size: f32, input_cst: u32, output_cst: u32, - _pad_align: vec2, + input_gamut: u32, + output_gamut: u32, // Qualifier correction CDL q_slope: vec4, q_offset: vec4, @@ -75,7 +76,11 @@ struct ColorGradingUniforms { w_power: vec4, w_adjustments: vec4, window_mix: vec4, - _pad: array, 7>, + tone_mapping_method: u32, + tone_mapping_a: f32, + tone_mapping_b: f32, + _pad_tone2: f32, + _pad: array, 6>, }; @group(0) @binding(0) var uniforms: LayerUniforms; @@ -215,57 +220,74 @@ fn linear_to_srgb(linear: vec3) -> vec3 { return select(srgb_low, srgb_high, linear > cutoff); } -// Sony S-Log2 <-> Linear +// Sony S-Log2 <-> Linear (scene-referred) +// S-Log2 is defined as 219/155 * S-Log, with legal-to-full range conversion. +// Input: normalised code value (0.0-1.0). Output: scene-linear reflection. +// Reference: Sony S-Log2 Technical Summary, colour-science library. fn slog2_to_linear(slog: vec3) -> vec3 { - let linear_low = (slog - 0.030001222851889303) / 3.53881278538813; - let linear_high = pow(vec3(10.0), (slog - 0.616596 - 0.03) / 0.432699) - 0.037584; - return select(linear_low, linear_high, slog >= vec3(0.0929)); + // Step 1: legal-to-full range (10-bit: (v*1023 - 64) / 876) + let x = (slog * 1023.0 - 64.0) / 876.0; + // Step 2: S-Log inverse + let threshold = vec3(0.088251); // S-Log encoding of 0.0 as NCV + let linear_low = (x - 0.030001222851889303) / 5.0; + let linear_high = pow(vec3(10.0), (x - 0.616596 - 0.03) / 0.432699) - 0.037584; + let slog_linear = select(linear_low, linear_high, slog >= threshold); + // Step 3: reflection * S-Log2 scale factor (0.9 * 219/155) + return slog_linear * 1.2716129032; } fn linear_to_slog2(linear: vec3) -> vec3 { - let cut = 0.0; - let slog_low = linear * 3.53881278538813 + 0.030001222851889303; - let slog_high = 0.432699 * log(linear + 0.037584) / log(10.0) + 0.616596 + 0.03; - return select(slog_low, slog_high, linear >= vec3(cut)); + // Inverse of slog2_to_linear + let x = linear / 1.2716129032; // undo S-Log2 scale + let slog_low = x * 5.0 + 0.030001222851889303; + let slog_high = 0.432699 * log(x + 0.037584) / log(10.0) + 0.616596 + 0.03; + let full = select(slog_low, slog_high, x >= vec3(0.0)); + // full-to-legal range + return (full * 876.0 + 64.0) / 1023.0; } -// ARRI LogC3 <-> Linear (EI 800) +// ARRI LogC3 <-> Linear (EI 800, SUP 3.x) +// Reference: ARRI LogC3 specification, colour-science library. fn logc_to_linear(logc: vec3) -> vec3 { let a = 5.555556; let b = 0.052272; - let c = 0.247190; + let c = 0.24719; let d = 0.385537; let e_val = 5.367655; - let cut = 0.1496582; - let linear_low = (logc - d) / e_val; - let linear_high = (pow(vec3(10.0), (logc - c) / a) - b) / a; - return select(linear_low, linear_high, logc > vec3(cut)); + let f = 0.092809; + let breakpoint = vec3(0.1496578); // e*cut + f + let linear_low = (logc - f) / e_val; + let linear_high = (pow(vec3(10.0), (logc - d) / c) - b) / a; + return select(linear_low, linear_high, logc > breakpoint); } fn linear_to_logc(linear: vec3) -> vec3 { let a = 5.555556; let b = 0.052272; - let c = 0.247190; + let c = 0.24719; let d = 0.385537; let e_val = 5.367655; - let cut = 0.010591; - let logc_low = e_val * linear + d; - let logc_high = a * log(a * linear + b) / log(10.0) + c; - return select(logc_low, logc_high, linear > vec3(cut)); + let f = 0.092809; + let cut = vec3(0.010591); + let logc_low = e_val * linear + f; + let logc_high = c * log(a * linear + b) / log(10.0) + d; + return select(logc_low, logc_high, linear > cut); } -// Sony S-Log3 <-> Linear +// Sony S-Log3 <-> Linear (scene-referred) +// Reference: Sony S-Log3 Technical Summary, colour-science library. fn slog3_to_linear(slog: vec3) -> vec3 { - let linear_low = (slog - 0.030001222851889303) / 5.26; - let linear_high = pow(vec3(10.0), (slog - 0.410557184750733) / 0.255620723362659) * 0.19 - 0.01; - return select(linear_low, linear_high, slog >= vec3(0.1673609920)); + let threshold = vec3(171.2102946929 / 1023.0); // ~0.16736 + let linear_low = (slog * 1023.0 - 95.0) * 0.01125000 / (171.2102946929 - 95.0); + let linear_high = pow(vec3(10.0), (slog * 1023.0 - 420.0) / 261.5) * 0.19 - 0.01; + return select(linear_low, linear_high, slog >= threshold); } fn linear_to_slog3(linear: vec3) -> vec3 { - let cut = 0.01125000; - let slog_low = linear * 5.26 + 0.030001222851889303; + let cut = vec3(0.01125000); + let slog_low = (linear / 0.01125000 * (171.2102946929 - 95.0) + 95.0) / 1023.0; let slog_high = (420.0 + log((linear + 0.01) / 0.19) / log(10.0) * 261.5) / 1023.0; - return select(slog_low, slog_high, linear >= vec3(cut)); + return select(slog_low, slog_high, linear >= cut); } // Canon CLog3 <-> Linear (simplified) @@ -334,6 +356,204 @@ fn from_linear(color: vec3, cs: u32) -> vec3 { } } +// ============================================================================ +// Gamut Conversion (Color Primaries) +// ============================================================================ + +// Gamut IDs (must match Rust Gamut enum) +const GAMUT_REC709: u32 = 0u; +const GAMUT_SGAMUT: u32 = 1u; +const GAMUT_SGAMUT3: u32 = 2u; +const GAMUT_SGAMUT3_CINE: u32 = 3u; +const GAMUT_ARRI_WIDE: u32 = 4u; +const GAMUT_ACES_AP1: u32 = 5u; +const GAMUT_RED_WIDE: u32 = 6u; +const GAMUT_DCI_P3: u32 = 7u; +const GAMUT_REC2020: u32 = 8u; +const GAMUT_VGAMUT: u32 = 9u; +const GAMUT_BMD_WIDE: u32 = 10u; + +// 3x3 matrices to convert from source gamut to Rec.709 (linear space). +// Pre-computed as: XYZ_to_Rec709 * source_RGB_to_XYZ (D65 white point, no CAT needed). +// Matrices are in WGSL column-major order (each vec3 is a column). + +fn gamut_to_rec709(color: vec3, gamut: u32) -> vec3 { + switch gamut { + case GAMUT_REC709: { return color; } + case GAMUT_SGAMUT, GAMUT_SGAMUT3: { + return mat3x3( + vec3( 1.8775895, -0.1768085, -0.0262071), + vec3(-0.7940379, 1.3510232, -0.1484570), + vec3(-0.0837210, -0.1741716, 1.1747362) + ) * color; + } + case GAMUT_SGAMUT3_CINE: { + return mat3x3( + vec3( 1.6266602, -0.1785165, -0.0444460), + vec3(-0.5400464, 1.4179576, -0.1959648), + vec3(-0.0867831, -0.2393980, 1.2404829) + ) * color; + } + case GAMUT_ARRI_WIDE: { + return mat3x3( + vec3( 1.6172660, -0.0705744, -0.0211068), + vec3(-0.5372011, 1.3346439, -0.2270083), + vec3(-0.0802240, -0.2640464, 1.2483551) + ) * color; + } + case GAMUT_ACES_AP1: { + return mat3x3( + vec3( 1.7309784, -0.1316220, -0.0245741), + vec3(-0.6039470, 1.1348678, -0.1257806), + vec3(-0.0800949, -0.0086797, 1.0658927) + ) * color; + } + case GAMUT_RED_WIDE: { + return mat3x3( + vec3( 1.9816606, -0.1781473, -0.1018204), + vec3(-0.9002885, 1.5005030, -0.5353919), + vec3(-0.0815312, -0.3223326, 1.6374523) + ) * color; + } + case GAMUT_DCI_P3: { + return mat3x3( + vec3( 1.2247450, -0.0420578, -0.0196423), + vec3(-0.2249043, 1.0420810, -0.0786549), + vec3( 0.0000001, -0.0000001, 1.0985372) + ) * color; + } + case GAMUT_REC2020: { + return mat3x3( + vec3( 1.6602266, -0.1245533, -0.0181551), + vec3(-0.5875477, 1.1329261, -0.1006030), + vec3(-0.0728382, -0.0083497, 1.1189982) + ) * color; + } + case GAMUT_VGAMUT: { + return mat3x3( + vec3( 1.8062884, -0.1700943, -0.0252119), + vec3(-0.6955865, 1.3059854, -0.1545054), + vec3(-0.1108609, -0.1358680, 1.1799572) + ) * color; + } + case GAMUT_BMD_WIDE: { + return mat3x3( + vec3( 1.5683008, -0.0863812, -0.0520228), + vec3(-0.5227529, 1.3449488, -0.2491763), + vec3(-0.0457070, -0.2585445, 1.3014390) + ) * color; + } + default: { return color; } + } +} + +fn rec709_to_gamut(color: vec3, gamut: u32) -> vec3 { + switch gamut { + case GAMUT_REC709: { return color; } + case GAMUT_SGAMUT, GAMUT_SGAMUT3: { + return mat3x3( + vec3( 0.5661472, 0.0769741, 0.0223577), + vec3( 0.3427600, 0.7990405, 0.1086252), + vec3( 0.0911672, 0.1239551, 0.8689536) + ) * color; + } + case GAMUT_SGAMUT3_CINE: { + return mat3x3( + vec3( 0.6457935, 0.0875449, 0.0369684), + vec3( 0.2591131, 0.7596906, 0.1292957), + vec3( 0.0951848, 0.1527355, 0.8336765) + ) * color; + } + case GAMUT_ARRI_WIDE: { + return mat3x3( + vec3( 0.6314215, 0.0368258, 0.0173725), + vec3( 0.2707945, 0.7930187, 0.1487858), + vec3( 0.0978548, 0.1701023, 0.8336410) + ) * color; + } + case GAMUT_ACES_AP1: { + return mat3x3( + vec3( 0.6032028, 0.0701291, 0.0221824), + vec3( 0.3263270, 0.9198951, 0.1160756), + vec3( 0.0479841, 0.0127606, 0.9407928) + ) * color; + } + case GAMUT_RED_WIDE: { + return mat3x3( + vec3( 0.5420324, 0.0770016, 0.0588817), + vec3( 0.3601398, 0.7679506, 0.2734883), + vec3( 0.0978822, 0.1550052, 0.6674728) + ) * color; + } + case GAMUT_DCI_P3: { + return mat3x3( + vec3( 0.8225930, 0.0331994, 0.0170854), + vec3( 0.1775339, 0.9667835, 0.0723957), + vec3( 0.0000000, 0.0000000, 0.9103014) + ) * color; + } + case GAMUT_REC2020: { + return mat3x3( + vec3( 0.6275038, 0.0691083, 0.0163941), + vec3( 0.3292755, 0.9195191, 0.0880113), + vec3( 0.0433026, 0.0113596, 0.8953803) + ) * color; + } + case GAMUT_VGAMUT: { + return mat3x3( + vec3( 0.5852893, 0.0786011, 0.0227979), + vec3( 0.3226342, 0.8196082, 0.1142144), + vec3( 0.0921401, 0.1017599, 0.8627817) + ) * color; + } + case GAMUT_BMD_WIDE: { + return mat3x3( + vec3( 0.6549678, 0.0488989, 0.0355435), + vec3( 0.2687242, 0.7919967, 0.1623791), + vec3( 0.0763876, 0.1590558, 0.8018868) + ) * color; + } + default: { return color; } + } +} + +// ============================================================================ +// Tone Mapping +// ============================================================================ + +const TM_NONE: u32 = 0u; +const TM_SIMPLE: u32 = 1u; + +// Per-channel hyperbolic rolloff tone mapping. +// Matches DaVinci Resolve's "Simple" / "DaVinci" tone mapping method. +// Formula: f(x) = a * x / (x + b) +// where a and b are derived from input/output white points. +// Reference: reverse-engineered by Thatcher Freeman +// (github.com/thatcherfreeman/utility-dctls) +// +// For SDR output (100 nits) from camera log (~5500 nits peak): +// input_white = 55.0 (5500/100), output_white = 1.0 +// adaptation = 9.0 +// b = (Wᵢ - (adapt/100)*(Wᵢ/Wₒ)) / ((Wᵢ/Wₒ) - 1) = 0.926852 +// a = Wₒ / (Wᵢ/(Wᵢ+b)) = 1.016852 +// Per-channel hyperbolic rolloff: f(x) = a * x / (x + b) +// a and b are pre-computed from input/output peak luminance and passed via uniforms. +fn apply_tone_mapping(color: vec3, method: u32) -> vec3 { + if (method == TM_NONE) { return color; } + + let a = cg.tone_mapping_a; + let b = cg.tone_mapping_b; + + // Clamp negative values (from gamut conversion of out-of-gamut colors) + let c = max(color, vec3(0.0)); + + return vec3( + a * c.x / (c.x + b), + a * c.y / (c.y + b), + a * c.z / (c.z + b) + ); +} + // ============================================================================ // Color Grading // ============================================================================ @@ -346,6 +566,8 @@ const CG_FLAG_QUALIFIER_ENABLED: u32 = 32u; const CG_FLAG_WINDOW_ENABLED: u32 = 64u; const CG_FLAG_INPUT_CST: u32 = 128u; const CG_FLAG_OUTPUT_CST: u32 = 256u; +const CG_FLAG_INPUT_GAMUT: u32 = 512u; +const CG_FLAG_OUTPUT_GAMUT: u32 = 1024u; fn cg_luminance(rgb: vec3) -> f32 { return dot(rgb, vec3(0.2126, 0.7152, 0.0722)); @@ -587,6 +809,16 @@ fn apply_color_grading(color: vec3, uv: vec2) -> vec3 { result = to_linear(result, cg.input_cst); } + // Input gamut: convert from source primaries to Rec.709 + if ((cg.flags & CG_FLAG_INPUT_GAMUT) != 0u) { + result = gamut_to_rec709(result, cg.input_gamut); + } + + // Tone mapping: compress dynamic range for display + if (cg.tone_mapping_method != TM_NONE) { + result = apply_tone_mapping(result, cg.tone_mapping_method); + } + // Primary correction (operates in linear) if ((cg.flags & CG_FLAG_PRIMARY_ENABLED) != 0u) { result = apply_primary_correction( @@ -624,6 +856,11 @@ fn apply_color_grading(color: vec3, uv: vec2) -> vec3 { result = apply_window(result, uv); } + // Output gamut: convert from Rec.709 to target primaries + if ((cg.flags & CG_FLAG_OUTPUT_GAMUT) != 0u) { + result = rec709_to_gamut(result, cg.output_gamut); + } + // Output CST: convert from linear to output color space if ((cg.flags & CG_FLAG_OUTPUT_CST) != 0u) { result = from_linear(result, cg.output_cst); diff --git a/crates/types/src/color_grading.rs b/crates/types/src/color_grading.rs index 5087b07..af83a6c 100644 --- a/crates/types/src/color_grading.rs +++ b/crates/types/src/color_grading.rs @@ -46,6 +46,62 @@ pub enum ColorSpace { RedLog3G10, } +// ============================================================================ +// Tone Mapping +// ============================================================================ + +/// Tone mapping method for dynamic range compression. +/// +/// When converting from wide-DR log footage to a display-referred space, +/// tone mapping smoothly compresses highlights to avoid hard clipping. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Tsify, Default)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub enum ToneMapping { + /// No tone mapping — 1:1 linear mapping, highlights clip at 1.0. + #[default] + None, + /// Simple luminance-preserving highlight compression. + /// Smoothly rolls off values above the shoulder threshold. + Simple, +} + +// ============================================================================ +// Color Gamuts (Primaries) +// ============================================================================ + +/// Color gamut (primary) for gamut mapping. +/// +/// Separate from the transfer function (ColorSpace). When source footage +/// uses wide-gamut primaries (e.g. S-Gamut), converting the gamut to +/// Rec.709 is necessary in addition to the transfer function conversion. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Tsify, Default)] +#[tsify(into_wasm_abi, from_wasm_abi)] +pub enum Gamut { + /// Rec.709 / sRGB primaries (default display gamut). + #[default] + Rec709, + /// Sony S-Gamut (used with S-Log2/S-Log3). + SGamut, + /// Sony S-Gamut3 (improved S-Gamut). + SGamut3, + /// Sony S-Gamut3.Cine (cinema-optimized, closer to DCI-P3). + SGamut3Cine, + /// ARRI Wide Gamut (ALEXA Wide Gamut 3, used with LogC). + ArriWideGamut, + /// ACES AP1 primaries (ACEScg). + AcesCgAp1, + /// RED Wide Gamut RGB. + RedWideGamut, + /// DCI-P3 (D65 white point variant). + DciP3, + /// Rec.2020 / BT.2020 (UHDTV). + Rec2020, + /// Panasonic V-Gamut. + VGamut, + /// Blackmagic Design Wide Gamut (Gen 5). + BmdWideGamut, +} + // ============================================================================ // Primary Correction (CDL) // ============================================================================ @@ -737,10 +793,22 @@ pub enum ColorGradingNode { #[serde(skip_serializing_if = "Option::is_none")] #[tsify(optional)] position: Option, - /// Source color space. + /// Source transfer function (gamma curve). from_space: ColorSpace, - /// Target color space. + /// Target transfer function (gamma curve). to_space: ColorSpace, + /// Source gamut (color primaries). None = Rec709. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + from_gamut: Option, + /// Target gamut (color primaries). None = Rec709. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + to_gamut: Option, + /// Tone mapping method. None = no tone mapping. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[tsify(optional)] + tone_mapping: Option, }, } diff --git a/packages/render-engine/src/types.ts b/packages/render-engine/src/types.ts index 8cb9ced..f5c2752 100644 --- a/packages/render-engine/src/types.ts +++ b/packages/render-engine/src/types.ts @@ -272,6 +272,21 @@ export type ColorSpace = | "BmFilm" | "RedLog3G10"; +export type Gamut = + | "Rec709" + | "SGamut" + | "SGamut3" + | "SGamut3Cine" + | "ArriWideGamut" + | "AcesCgAp1" + | "RedWideGamut" + | "DciP3" + | "Rec2020" + | "VGamut" + | "BmdWideGamut"; + +export type ToneMapping = "None" | "Simple"; + export type LutInterpolation = "Trilinear" | "Tetrahedral"; /** Primary correction using ASC-CDL model. */ @@ -452,6 +467,9 @@ export type ColorGradingNode = position?: NodePosition; from_space: ColorSpace; to_space: ColorSpace; + from_gamut?: Gamut; + to_gamut?: Gamut; + tone_mapping?: ToneMapping; }; /** Complete color grading configuration. */ diff --git a/packages/render-engine/tests/cst.test.ts b/packages/render-engine/tests/cst.test.ts new file mode 100644 index 0000000..70ab793 --- /dev/null +++ b/packages/render-engine/tests/cst.test.ts @@ -0,0 +1,133 @@ +/** + * Color Space Transform tests. + * + * Validates our transfer function and gamut conversion math against + * reference values from the `colour-science` Python library. + * + * Regenerate fixtures: run the Python script in the commit message. + */ + +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { describe, it, expect } from "vitest"; + +const fixtures = JSON.parse(readFileSync(join(__dirname, "fixtures/cst-reference.json"), "utf-8")); + +function slog2ToLinear(slog: number): number { + // legal-to-full (10-bit) + const x = (slog * 1023.0 - 64.0) / 876.0; + const threshold = 0.088251; + let slogLinear: number; + if (slog >= threshold) { + slogLinear = Math.pow(10, (x - 0.616596 - 0.03) / 0.432699) - 0.037584; + } else { + slogLinear = (x - 0.030001222851889303) / 5.0; + } + return slogLinear * 1.2716129032; // 0.9 * 219/155 +} + +function slog3ToLinear(slog: number): number { + const threshold = 171.2102946929 / 1023; + if (slog >= threshold) { + return Math.pow(10, (slog * 1023 - 420) / 261.5) * 0.19 - 0.01; + } + return ((slog * 1023 - 95) * 0.01125) / (171.2102946929 - 95); +} + +function logc3ToLinear(logc: number): number { + const a = 5.555556; + const b = 0.052272; + const c = 0.24719; + const d = 0.385537; + const e = 5.367655; + const f = 0.092809; + const breakpoint = 0.1496578; // e*cut + f + if (logc > breakpoint) { + return (Math.pow(10, (logc - d) / c) - b) / a; + } + return (logc - f) / e; +} + +function vlogToLinear(vlog: number): number { + if (vlog >= 0.181) { + return Math.pow(10, (vlog - 0.598206) / 0.241514) - 0.00873; + } + return (vlog - 0.125) / 5.6; +} + +function linearToSrgb(linear: number): number { + if (linear <= 0.0031308) { + return linear * 12.92; + } + return 1.055 * Math.pow(Math.max(linear, 0), 1 / 2.4) - 0.055; +} + +// Gamut matrix: S-Gamut → Rec.709 (row-major, computed from colour-science) +const SGAMUT_TO_REC709 = [ + [1.8775895, -0.7940379, -0.083721], + [-0.1768085, 1.3510232, -0.1741716], + [-0.0262071, -0.148457, 1.1747362], +]; + +function matMul3(m: number[][], v: [number, number, number]): [number, number, number] { + return [ + m[0][0] * v[0] + m[0][1] * v[1] + m[0][2] * v[2], + m[1][0] * v[0] + m[1][1] * v[1] + m[1][2] * v[2], + m[2][0] * v[0] + m[2][1] * v[1] + m[2][2] * v[2], + ]; +} + +describe("Transfer Functions", () => { + it("S-Log2 → Linear matches colour-science", () => { + for (const { input, expected } of fixtures.slog2_to_linear) { + const actual = slog2ToLinear(input); + expect(actual).toBeCloseTo(expected, 3); + } + }); + + it("S-Log3 → Linear matches colour-science", () => { + for (const { input, expected } of fixtures.slog3_to_linear) { + const actual = slog3ToLinear(input); + expect(actual).toBeCloseTo(expected, 4); + } + }); + + it("LogC3 → Linear matches colour-science", () => { + for (const { input, expected } of fixtures.logc3_to_linear) { + const actual = logc3ToLinear(input); + expect(actual).toBeCloseTo(expected, 4); + } + }); + + it("V-Log → Linear matches colour-science", () => { + for (const { input, expected } of fixtures.vlog_to_linear) { + const actual = vlogToLinear(input); + expect(actual).toBeCloseTo(expected, 5); + } + }); +}); + +describe("Full Pipeline: S-Log2/S-Gamut → sRGB/Rec.709", () => { + it("matches colour-science reference within tolerance", () => { + for (const { input, expected } of fixtures.slog2_sgamut_to_srgb_rec709) { + const [r, g, b] = input; + + // Step 1: S-Log2 → linear + const linear: [number, number, number] = [ + slog2ToLinear(r), + slog2ToLinear(g), + slog2ToLinear(b), + ]; + + // Step 2: S-Gamut → Rec.709 + const rec709Linear = matMul3(SGAMUT_TO_REC709, linear); + + // Step 3: linear → sRGB (clamp negatives before gamma) + const srgb = rec709Linear.map((v) => Math.min(1, Math.max(0, linearToSrgb(Math.max(0, v))))); + + for (let ch = 0; ch < 3; ch++) { + expect(srgb[ch]).toBeCloseTo(expected[ch], 2); + } + } + }); +}); diff --git a/packages/render-engine/tests/fixtures/cst-reference.json b/packages/render-engine/tests/fixtures/cst-reference.json new file mode 100644 index 0000000..7a617c5 --- /dev/null +++ b/packages/render-engine/tests/fixtures/cst-reference.json @@ -0,0 +1,434 @@ +{ + "slog2_to_linear": [ + { + "input": 0.0, + "expected": -0.026210633579493398 + }, + { + "input": 0.05, + "expected": -0.011360633579493396 + }, + { + "input": 0.1, + "expected": 0.003619929299073656 + }, + { + "input": 0.15, + "expected": 0.022355018619810103 + }, + { + "input": 0.2, + "expected": 0.047917346927185286 + }, + { + "input": 0.25, + "expected": 0.08279482294598373 + }, + { + "input": 0.3, + "expected": 0.1303819722390667 + }, + { + "input": 0.35, + "expected": 0.19531031672768767 + }, + { + "input": 0.4, + "expected": 0.28389914754983203 + }, + { + "input": 0.45, + "expected": 0.4047705636970571 + }, + { + "input": 0.5, + "expected": 0.5696886363914269 + }, + { + "input": 0.55, + "expected": 0.7947043726850902 + }, + { + "input": 0.6, + "expected": 1.1017179143338063 + }, + { + "input": 0.65, + "expected": 1.5206100163185121 + }, + { + "input": 0.7, + "expected": 2.0921502557582965 + }, + { + "input": 0.75, + "expected": 2.8719650189185155 + }, + { + "input": 0.8, + "expected": 3.935951459206058 + }, + { + "input": 0.85, + "expected": 5.387664351288298 + }, + { + "input": 0.9, + "expected": 7.368394782841789 + }, + { + "input": 0.95, + "expected": 10.070921614338557 + }, + { + "input": 1.0, + "expected": 13.758274097347455 + } + ], + "slog3_to_linear": [ + { + "input": 0.0, + "expected": -0.014023695936443719 + }, + { + "input": 0.05, + "expected": -0.006473042808558495 + }, + { + "input": 0.1, + "expected": 0.0010776103193267297 + }, + { + "input": 0.15, + "expected": 0.008628263447211949 + }, + { + "input": 0.2, + "expected": 0.01851308652166711 + }, + { + "input": 0.25, + "expected": 0.03473490571895879 + }, + { + "input": 0.3, + "expected": 0.060185729916100474 + }, + { + "input": 0.35, + "expected": 0.10011617448806046 + }, + { + "input": 0.4, + "expected": 0.16276406327012358 + }, + { + "input": 0.45, + "expected": 0.26105392733053484 + }, + { + "input": 0.5, + "expected": 0.4152633917647167 + }, + { + "input": 0.55, + "expected": 0.6572065376669349 + }, + { + "input": 0.6, + "expected": 1.0367972849913025 + }, + { + "input": 0.65, + "expected": 1.632346850642181 + }, + { + "input": 0.7, + "expected": 2.5667196920430464 + }, + { + "input": 0.75, + "expected": 4.032680977386896 + }, + { + "input": 0.8, + "expected": 6.332664875575779 + }, + { + "input": 0.85, + "expected": 9.941168036481097 + }, + { + "input": 0.9, + "expected": 15.602640307011931 + }, + { + "input": 0.95, + "expected": 24.48506796212528 + }, + { + "input": 1.0, + "expected": 38.42093433720254 + } + ], + "logc3_to_linear": [ + { + "input": 0.0, + "expected": -0.01729041825527162 + }, + { + "input": 0.05, + "expected": -0.007975363543297772 + }, + { + "input": 0.1, + "expected": 0.0013396911686760797 + }, + { + "input": 0.15, + "expected": 0.01065489411188905 + }, + { + "input": 0.2, + "expected": 0.022557011654414694 + }, + { + "input": 0.25, + "expected": 0.04151960753378661 + }, + { + "input": 0.3, + "expected": 0.07173104186046103 + }, + { + "input": 0.35, + "expected": 0.11986426000639311 + }, + { + "input": 0.4, + "expected": 0.1965506782688429 + }, + { + "input": 0.45, + "expected": 0.3187283961204467 + }, + { + "input": 0.5, + "expected": 0.513383396023284 + }, + { + "input": 0.55, + "expected": 0.8235100676737995 + }, + { + "input": 0.6, + "expected": 1.3176075864482162 + }, + { + "input": 0.65, + "expected": 2.104809657007795 + }, + { + "input": 0.7, + "expected": 3.3589894015861264 + }, + { + "input": 0.75, + "expected": 5.357163556022022 + }, + { + "input": 0.8, + "expected": 8.540678493745718 + }, + { + "input": 0.85, + "expected": 13.612692530265484 + }, + { + "input": 0.9, + "expected": 21.69348589542808 + }, + { + "input": 0.95, + "expected": 34.5679024369212 + }, + { + "input": 1.0, + "expected": 55.079576698813185 + } + ], + "vlog_to_linear": [ + { + "input": 0.0, + "expected": -0.022321428571428572 + }, + { + "input": 0.05, + "expected": -0.013392857142857144 + }, + { + "input": 0.1, + "expected": -0.004464285714285713 + }, + { + "input": 0.15, + "expected": 0.004464285714285713 + }, + { + "input": 0.2, + "expected": 0.013719643752877516 + }, + { + "input": 0.25, + "expected": 0.027430696720631136 + }, + { + "input": 0.3, + "expected": 0.04951573439629128 + }, + { + "input": 0.35, + "expected": 0.0850891429654538 + }, + { + "input": 0.4, + "expected": 0.14238890472330837 + }, + { + "input": 0.45, + "expected": 0.2346843250826899 + }, + { + "input": 0.5, + "expected": 0.3833488981626514 + }, + { + "input": 0.55, + "expected": 0.6228099158706739 + }, + { + "input": 0.6, + "expected": 1.0085210359700107 + }, + { + "input": 0.65, + "expected": 1.6298040723166047 + }, + { + "input": 0.7, + "expected": 2.630533870183547 + }, + { + "input": 0.75, + "expected": 4.242456407498944 + }, + { + "input": 0.8, + "expected": 6.838855827046132 + }, + { + "input": 0.85, + "expected": 11.020998448522443 + }, + { + "input": 0.9, + "expected": 17.757372203150346 + }, + { + "input": 0.95, + "expected": 28.607966137706466 + }, + { + "input": 1.0, + "expected": 46.08552795674034 + } + ], + "slog2_sgamut_to_srgb_rec709": [ + { + "input": [0.1, 0.1, 0.1], + "expected": [0.0463948, 0.0464056, 0.0464046] + }, + { + "input": [0.2, 0.2, 0.2], + "expected": [0.2424606, 0.2424923, 0.2424892] + }, + { + "input": [0.3, 0.3, 0.3], + "expected": [0.3964033, 0.3964514, 0.3964468] + }, + { + "input": [0.39, 0.39, 0.39], + "expected": [0.5505732, 0.5506378, 0.5506316] + }, + { + "input": [0.5, 0.5, 0.5], + "expected": [0.7794617, 0.7795507, 0.7795421] + }, + { + "input": [0.6, 0.6, 0.6], + "expected": [1.0, 1.0, 1.0] + }, + { + "input": [0.5, 0.3, 0.3], + "expected": [0.9800652, 0.2547766, 0.379422] + }, + { + "input": [0.3, 0.5, 0.3], + "expected": [0.0, 0.8670937, 0.2831792] + }, + { + "input": [0.3, 0.3, 0.5], + "expected": [0.3381213, 0.2573445, 0.8246358] + }, + { + "input": [0.6, 0.4, 0.3], + "expected": [1.0, 0.4445224, 0.3174843] + } + ], + "slog2_sgamut_to_srgb_rec709_tonemapped": [ + { + "input": [0.1, 0.1, 0.1], + "expected": [0.046242, 0.046253, 0.046252] + }, + { + "input": [0.2, 0.2, 0.2], + "expected": [0.236715, 0.236747, 0.236744] + }, + { + "input": [0.3, 0.3, 0.3], + "expected": [0.37393, 0.373976, 0.373972] + }, + { + "input": [0.39, 0.39, 0.39], + "expected": [0.494266, 0.494325, 0.494319] + }, + { + "input": [0.5, 0.5, 0.5], + "expected": [0.636536, 0.636609, 0.636602] + }, + { + "input": [0.6, 0.6, 0.6], + "expected": [0.751015, 0.751101, 0.751093] + }, + { + "input": [0.7, 0.7, 0.7], + "expected": [0.841446, 0.841541, 0.841532] + }, + { + "input": [0.5, 0.3, 0.3], + "expected": [0.888343, 0.227326, 0.340926] + }, + { + "input": [0.3, 0.5, 0.3], + "expected": [0.0, 0.718963, 0.228852] + }, + { + "input": [0.3, 0.3, 0.5], + "expected": [0.32209, 0.244607, 0.788764] + }, + { + "input": [0.6, 0.4, 0.3], + "expected": [1.0, 0.365201, 0.258336] + } + ] +}