diff --git a/docs/Features/3D-Layers.md b/docs/Features/3D-Layers.md index 60d265746..29ceaedb3 100644 --- a/docs/Features/3D-Layers.md +++ b/docs/Features/3D-Layers.md @@ -52,7 +52,10 @@ Prepared splat runtime metadata, native splat rasterization, preview, nested com - Supported import formats are `.obj`, `.gltf`, and `.glb`. - Model clips are automatically marked `is3D: true` and cannot be switched back to 2D. - Models are auto-centered and normalized to fit the viewport. -- Default lighting is Ambient plus Directional lighting. +- glTF / GLB base color textures are loaded from data URIs, external image URIs, or embedded bufferViews when `baseColorTexture` and `TEXCOORD_0` are present. +- Textured glTF / GLB materials render unlit in the native pass, matching scan/photogrammetry assets better than the simple fallback directional light. +- GLB sequences normalize every frame against the first renderable frame's bounds and preload nearby playback frames, avoiding per-frame center/scale jumps and visible current-frame loading flicker. +- Untextured models use ambient plus directional fallback lighting. - The Transform panel exposes a wireframe debug toggle for model clips. - Imported models use a native runtime/cache path. @@ -94,13 +97,13 @@ Camera rotation keyframes interpolate through the shortest angular path so timel ## Gaussian Splats -Gaussian splat clips are imported through the SuperSplat-compatible `@playcanvas/splat-transform` reader path. Supported scene formats include `.ply`, `.compressed.ply`, `.splat`, `.ksplat`, `.spz`, `.sog`, `.lcc`, and zipped SOG-style `.zip` payloads. +Gaussian splat clips are imported through the SuperSplat-compatible `@playcanvas/splat-transform` reader path. Supported scene formats include `.ply`, `.compressed.ply`, `.splat`, `.ksplat`, `.spz`, `.sog`, `.lcc`, and zipped SOG-style `.zip` payloads. Plain point-cloud PLY files without gaussian scale properties fall back to the local point-cloud conversion path. - Clips are created as `is3D: true`. - The Gaussian tab exposes the native renderer status together with `maxSplats`, `sortFrequency`, `splatScale`, `orientationPreset`, `nearPlane`, and `farPlane`. - Gaussian splats participate in scene cameras, object transforms, object-level effectors, preview, nested compositions, export, preload, and readiness checks through the same native shared-scene path. - Realtime splat rendering uses a worker-backed back-to-front order buffer based on the SuperSplat/PlayCanvas sorter approach. Precise export can still fall back to the existing GPU sort path. -- Sequence splats follow the same shared runtime contract and are no longer treated as a permanent legacy-only scene path. +- Sequence splats follow the same shared runtime contract and preload nearby frames without replacing foreground playback with repeated loading overlays. - The Transform tab now exposes normal object transforms for gaussian splats. Scene navigation lives on camera clips. - Large gaussian splats show viewport loading progress during project restore, URL fetch, parser work, normalization, and GPU upload. @@ -149,6 +152,7 @@ If you see avatar-specific code paths in the renderer or AI tooling, treat them The Transform tab is context-sensitive: - For normal 3D layers, it shows position, scale, rotation, opacity, blend mode, and 3D toggles. +- 3D object position fields use scene units. Regular 2D clips still display composition pixel units. - For camera clips, it becomes scene-navigation controls. - For gaussian splats, it now behaves like a normal 3D object transform surface plus 3D effector toggle. - The `Speed` field is explicitly marked WIP in the UI. diff --git a/docs/Features/Media-Panel.md b/docs/Features/Media-Panel.md index 8046d1e40..69243a5c7 100644 --- a/docs/Features/Media-Panel.md +++ b/docs/Features/Media-Panel.md @@ -2,7 +2,7 @@ [← Back to Index](./README.md) -Import, organize, and manage media assets with folder structure, proxy generation, and dual view modes. +Import, organize, and manage media assets with folder structure, proxy generation, and three view modes. --- @@ -122,15 +122,15 @@ When supported (Chrome/Edge): ## View Modes -The panel supports two view modes through a single header toggle button that swaps between list and grid icons. The selected mode is persisted in `localStorage`. +The panel supports three view modes through the header mode control. The selected mode is persisted in `localStorage`. -### List View (default) +### Classic View (default) - Table layout with sortable, reorderable columns - Nested folder tree with expand/collapse arrows - Column headers for sorting and drag-to-reorder - Resizable name column (120px - 500px range, saved to localStorage) -### Grid View +### Icons View - Thumbnail grid with file names below each item - Folder navigation via breadcrumb bar - Double-click folders to navigate into them @@ -139,6 +139,22 @@ The panel supports two view modes through a single header toggle button that swa - Duration badge overlay on video and composition thumbnails - Item count badge on folder thumbnails +### Board View +- Board canvas grouped by Media Panel folders; every folder appears as a group, including empty folders +- Mouse wheel zooms around the cursor; left-dragging the board or a node pans the board +- Items inside each folder snap to a fixed slot grid +- Right-dragging nodes reorders the selected items in the target folder grid; dropping onto another folder group moves them there +- Ctrl/right-drag starts a marquee selection; right-clicking opens the normal Media Panel context menu +- Media, compositions, text, solids, meshes, cameras, and splat effectors appear as board nodes +- Board order and viewport are saved into the project UI state, with `localStorage` as the live-session fallback +- Drag files or folders from the OS onto a group to import directly into that folder +- Drag existing Media Panel items onto groups to move them between folders +- Use the small handle on a node to drag that item to the timeline with the same drag payloads as Classic and Icons view +- The board uses the same Add dropdown and context menu as Classic view; new folders appear immediately in Classic, Icons, and Board view +- The **AI** board action opens the existing AI Video panel; generated results still import through the normal Media Store path + +Board pan and reorder previews use imperative CSS transforms and commit final order only on mouse-up/drop. This avoids re-rendering the Media Panel and writing `localStorage` on every pointer move, which keeps board interaction responsive while heavy preview scenes or splat renders are active. + --- ## Source Thumbnail Cache @@ -285,7 +301,7 @@ Toggle proxy playback mode via `proxyEnabled` / `toggleProxyEnabled()`: - Click and drag on empty space in the item list to draw a selection rectangle - 4px movement threshold before marquee activates - Hold **Ctrl/Cmd** while marquee selecting to add to existing selection -- Works in both list and grid view modes +- Works in Classic and Icons view modes ### Label Colors 16 AE-style label colors assignable to any item (files, folders, compositions, text, solids): @@ -330,7 +346,7 @@ Right-click on items or empty space for context options. ## Media Properties -### Column Display (List View) +### Column Display (Classic View) The media list displays items in a table with the following columns: | Column | Description | Example | @@ -466,6 +482,7 @@ Media references are saved with the project file, while IndexedDB keeps the hand - File metadata (name, type, dimensions, duration, codec, etc.) - File handles (for reload on next session) - Folder structure +- Media Panel view mode, Board viewport, and Board slot order - Composition state with timeline data - Text items and solid items (via localStorage) - When present, `projectPath` points at the copied `Raw/` file and is used for automatic relinking diff --git a/src/App.css b/src/App.css index d0e8bce0b..73a411d40 100644 --- a/src/App.css +++ b/src/App.css @@ -3657,6 +3657,171 @@ input, textarea { font-size: var(--font-md); } +.transform-tab-compact { + container-type: inline-size; +} + +.transform-tab-compact .properties-section { + border-radius: var(--radius-md); + margin-bottom: 4px; + padding: 6px 8px; +} + +.transform-tab-compact .properties-section .control-row { + min-width: 0; +} + +.transform-tab-compact .transform-option-row { + flex-wrap: wrap; + row-gap: 4px; +} + +.transform-tab-compact .transform-option-row .prop-label { + flex: 0 1 auto; + width: auto; + min-width: 50px; + max-width: 84px; + overflow: hidden; + text-overflow: ellipsis; +} + +.transform-tab-compact .scene-nav-row .btn-xs { + flex: 0 0 auto; +} + +.transform-tab-compact .transform-param-row { + display: grid; + grid-template-columns: 18px minmax(42px, max-content) minmax(0, 1fr); + align-items: center; + column-gap: 6px; + row-gap: 2px; +} + +.transform-tab-compact .transform-param-row > .keyframe-toggle { + grid-column: 1; +} + +.transform-tab-compact .transform-param-row > .prop-label { + grid-column: 2; + width: auto; + min-width: 0; + max-width: 72px; + overflow: hidden; + text-overflow: ellipsis; + font-size: 11px; +} + +.transform-tab-compact .transform-param-row > .multi-value-row, +.transform-tab-compact .transform-param-row > .labeled-value, +.transform-tab-compact .transform-param-row > .draggable-number-anchor { + grid-column: 3; + min-width: 0; +} + +.transform-tab-compact .transform-param-row > .labeled-value, +.transform-tab-compact .transform-param-row > .draggable-number-anchor { + justify-self: start; +} + +.transform-tab-compact .multi-value-row { + grid-template-columns: repeat(auto-fit, minmax(74px, 1fr)); + column-gap: 8px; + row-gap: 2px; + align-items: center; +} + +.transform-tab-compact .rotation-row { + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); +} + +.transform-tab-compact .labeled-value { + grid-template-columns: minmax(12px, max-content) max-content; + column-gap: 6px; +} + +.transform-tab-compact .rotation-value-ae { + grid-template-columns: minmax(28px, max-content) max-content max-content; + column-gap: 5px; +} + +.transform-tab-compact .labeled-value-label { + width: auto; + min-width: 12px; + padding: 2px 0; +} + +.transform-tab-compact .labeled-value .draggable-number { + padding: 2px 3px; +} + +@container (max-width: 240px) { + .transform-tab-compact .properties-section { + padding: 5px 6px; + } + + .transform-tab-compact .transform-option-row .prop-label { + min-width: 0; + } + + .transform-tab-compact .scene-nav-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + } + + .transform-tab-compact .scene-nav-speed-control { + grid-column: 1 / -1; + grid-template-columns: minmax(0, 1fr) 34px; + width: 100%; + } + + .transform-tab-compact .transform-param-row { + grid-template-columns: 18px minmax(0, 1fr); + grid-template-areas: + "key label" + "values values"; + } + + .transform-tab-compact .transform-param-row > .keyframe-toggle { + grid-area: key; + } + + .transform-tab-compact .transform-param-row > .prop-label { + grid-area: label; + max-width: none; + } + + .transform-tab-compact .transform-param-row > .multi-value-row, + .transform-tab-compact .transform-param-row > .labeled-value, + .transform-tab-compact .transform-param-row > .draggable-number-anchor { + grid-area: values; + box-sizing: border-box; + padding-left: 22px; + } + + .transform-tab-compact .multi-value-row { + grid-template-columns: 1fr; + row-gap: 3px; + } + + .transform-tab-compact .rotation-row { + grid-template-columns: 1fr; + } + + .transform-tab-compact .labeled-value { + grid-template-columns: 34px max-content; + column-gap: 5px; + } + + .transform-tab-compact .rotation-value-ae { + grid-template-columns: 34px 22px max-content; + column-gap: 4px; + } + + .transform-tab-compact .labeled-value .draggable-number { + text-align: left; + } +} + .properties-actions { padding: var(--sp-2); text-align: center; @@ -8617,6 +8782,25 @@ input[type="checkbox"] { background: var(--bg-hover); } +.media-view-segment { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border: 1px solid var(--border-color); + border-radius: 4px; + background: rgba(0, 0, 0, 0.12); +} + +.media-view-segment .media-view-toggle { + border: 0; + background: transparent; +} + +.media-view-segment .media-view-toggle.active { + background: var(--bg-hover); +} + /* Grid View */ .media-grid-wrapper { flex: 1; @@ -8768,6 +8952,298 @@ input[type="checkbox"] { z-index: 5; } +/* Media Board View */ +.media-board-wrapper { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; + overflow: hidden; + background: + linear-gradient(rgba(255, 255, 255, 0.035) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.035) 1px, transparent 1px), + var(--bg-primary); + background-size: 32px 32px; +} + +.media-board-toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 34px; + padding: 5px 8px; + border-bottom: 1px solid var(--border-color); + background: var(--bg-tertiary); + flex-shrink: 0; +} + +.media-board-toolbar-title { + display: flex; + align-items: baseline; + gap: 8px; + min-width: 0; +} + +.media-board-toolbar-title span:first-child { + font-size: 12px; + font-weight: 600; + color: var(--text-primary); +} + +.media-board-toolbar-title span:last-child { + font-size: 11px; + color: var(--text-secondary); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-board-toolbar-actions { + display: inline-flex; + align-items: center; + gap: 4px; + flex-shrink: 0; +} + +.media-board-canvas { + position: relative; + flex: 1; + min-height: 0; + overflow: hidden; + cursor: grab; + user-select: none; +} + +.media-board-canvas:active { + cursor: grabbing; +} + +.media-board-wrapper.board-interacting .media-board-node, +.media-board-wrapper.board-interacting .media-board-group { + transition: none !important; +} + +.media-board-wrapper.board-interacting .media-board-node { + will-change: transform; +} + +.media-board-wrapper.board-interacting .media-board-node-thumb img { + pointer-events: none; +} + +.media-board-canvas-inner { + position: absolute; + left: 0; + top: 0; + width: 1px; + height: 1px; + transform-origin: 0 0; +} + +.media-board-group { + position: absolute; + border: 1px solid rgba(255, 255, 255, 0.09); + border-radius: 6px; + background: rgba(255, 255, 255, 0.035); + box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.04); + pointer-events: auto; +} + +.media-board-group-header { + height: 42px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.075); + color: var(--text-secondary); + font-size: 12px; +} + +.media-board-group-header span:first-child { + color: var(--text-primary); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-board-group-header span:last-child { + min-width: 22px; + height: 18px; + border-radius: 9px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.08); + color: var(--text-secondary); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.media-board-node { + position: absolute; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--border-color); + border-top: 3px solid var(--border-color); + border-radius: 6px; + background: var(--bg-secondary); + box-shadow: 0 8px 18px rgba(0, 0, 0, 0.22); + cursor: move; + transition: border-color 0.1s, box-shadow 0.1s, background 0.1s; + pointer-events: auto; +} + +.media-board-node:hover { + background: var(--bg-hover); + border-color: rgba(255, 255, 255, 0.18); +} + +.media-board-node.drag-preview { + box-shadow: 0 14px 30px rgba(0, 0, 0, 0.34); + z-index: 20; +} + +.media-board-node.selected { + border-color: var(--accent); + box-shadow: 0 0 0 2px rgba(45, 140, 235, 0.3), 0 10px 22px rgba(0, 0, 0, 0.26); +} + +.media-board-node.no-file { + opacity: 0.55; +} + +.media-board-node-thumb { + position: relative; + height: 82px; + flex-shrink: 0; + background: var(--bg-primary); + overflow: hidden; +} + +.media-board-node-thumb img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.media-board-node-placeholder { + height: 100%; + display: flex; + align-items: center; + justify-content: center; + opacity: 0.74; +} + +.media-board-solid-preview { + width: 100%; + height: 100%; +} + +.media-board-text-preview { + height: 100%; + display: flex; + align-items: center; + padding: 10px; + background: rgba(255, 255, 255, 0.035); + font-size: 17px; + font-weight: 700; + line-height: 1.1; + overflow: hidden; + word-break: break-word; +} + +.media-board-duration, +.media-board-progress { + position: absolute; + right: 6px; + bottom: 5px; + padding: 1px 5px; + border-radius: 3px; + background: rgba(0, 0, 0, 0.72); + color: var(--text-primary); + font-size: 10px; + font-variant-numeric: tabular-nums; +} + +.media-board-progress { + left: 6px; + right: auto; + color: var(--accent); +} + +.media-board-node-timeline-drag { + position: absolute; + top: 5px; + right: 5px; + width: 22px; + height: 22px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 4px; + background: rgba(0, 0, 0, 0.58); + color: var(--text-primary); + cursor: grab; + opacity: 0; + transition: opacity 0.1s, background 0.1s; +} + +.media-board-node:hover .media-board-node-timeline-drag, +.media-board-node.selected .media-board-node-timeline-drag { + opacity: 1; +} + +.media-board-node-timeline-drag:hover { + background: rgba(45, 140, 235, 0.78); +} + +.media-board-node-body { + min-height: 0; + padding: 7px 8px 8px; +} + +.media-board-node-name { + color: var(--text-primary); + font-size: 12px; + font-weight: 500; + line-height: 1.2; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-board-node-meta { + display: flex; + align-items: center; + gap: 5px; + margin-top: 5px; + color: var(--text-muted); + font-size: 10px; + line-height: 1; + overflow: hidden; +} + +.media-board-node-meta span { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.media-board-marquee { + position: absolute; + border: 1px solid rgba(45, 140, 235, 0.72); + background: rgba(45, 140, 235, 0.16); + pointer-events: none; + z-index: 50; +} + .media-item { display: flex; align-items: center; @@ -10038,6 +10514,34 @@ input[type="checkbox"] { color: var(--text-inverse); } +.preview-scene-toggle-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 26px; + height: 24px; + padding: 0; + background: var(--preview-overlay-bg); + border: 1px solid var(--border-color); + border-radius: 4px; + color: var(--text-secondary); + cursor: pointer; + transition: all 0.15s; + backdrop-filter: blur(4px); +} + +.preview-scene-toggle-btn:hover { + background: var(--preview-overlay-bg-hover); + color: var(--text-primary); + border-color: var(--gray-300); +} + +.preview-scene-toggle-btn.active { + background: var(--accent); + border-color: var(--accent); + color: var(--text-inverse); +} + .preview-zoom-label { font-size: 11px; color: var(--text-secondary); @@ -10292,6 +10796,176 @@ input[type="checkbox"] { background: var(--danger); } +.preview-scene-object-overlay { + position: absolute; + left: 50%; + top: 50%; + transform: translate(-50%, -50%); + z-index: 14; + pointer-events: none; +} + +.preview-scene-object-handle { + position: absolute; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + transform: translate(-50%, -50%); + color: #f6f8fb; + background: rgba(12, 15, 20, 0.82); + border: 1px solid rgba(255, 255, 255, 0.38); + border-radius: 50%; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.42); + cursor: pointer; + font-size: 10px; + font-weight: 700; + line-height: 1; + pointer-events: auto; + user-select: none; +} + +.preview-scene-object-handle:hover, +.preview-scene-object-handle.selected { + border-color: #ffffff; + background: #2563eb; +} + +.preview-scene-object-handle.kind-effector { + background: #7c2d12; +} + +.preview-scene-object-handle.kind-splat { + background: #166534; +} + +.preview-scene-object-handle.kind-model, +.preview-scene-object-handle.kind-plane { + background: #581c87; +} + +.preview-scene-object-handle.selected { + background: #2563eb; + box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.92), 0 8px 20px rgba(0, 0, 0, 0.45); +} + +.preview-scene-gizmo-axis-layer { + position: absolute; + inset: 0; + pointer-events: none; +} + +.preview-scene-gizmo-axis { + position: absolute; + height: 22px; + margin: -11px 0 0 0; + padding: 0; + background: transparent; + border: 0; + cursor: grab; + pointer-events: auto; + transform-origin: 0 50%; +} + +.preview-scene-gizmo-axis:active { + cursor: grabbing; +} + +.preview-scene-gizmo-axis-line { + position: absolute; + left: 0; + right: 0; + top: 50%; + height: 3px; + border-radius: 999px; + background: currentColor; + transform: translateY(-50%); +} + +.preview-scene-gizmo-axis.mode-rotate .preview-scene-gizmo-axis-line { + background: repeating-linear-gradient( + 90deg, + currentColor 0, + currentColor 7px, + transparent 7px, + transparent 12px + ); +} + +.preview-scene-gizmo-axis.mode-scale .preview-scene-gizmo-axis-line { + height: 4px; +} + +.preview-scene-gizmo-end { + position: absolute; + right: -6px; + top: 50%; + width: 12px; + height: 12px; + background: #10141c; + border: 2px solid currentColor; + border-radius: 50%; + transform: translateY(-50%); +} + +.preview-scene-gizmo-label { + position: absolute; + transform: translate(-50%, -50%); + font-size: 11px; + font-weight: 800; + pointer-events: none; + text-shadow: + 0 1px 2px rgba(0, 0, 0, 0.9), + 0 -1px 2px rgba(0, 0, 0, 0.9); +} + +.preview-scene-gizmo-axis.axis-x, +.preview-scene-gizmo-label.axis-x { + color: #ef4444; +} + +.preview-scene-gizmo-axis.axis-y, +.preview-scene-gizmo-label.axis-y { + color: #22c55e; +} + +.preview-scene-gizmo-axis.axis-z, +.preview-scene-gizmo-label.axis-z { + color: #3b82f6; +} + +.preview-scene-gizmo-toolbar { + position: absolute; + display: flex; + gap: 3px; + padding: 3px; + background: rgba(8, 11, 16, 0.82); + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 5px; + box-shadow: 0 8px 22px rgba(0, 0, 0, 0.36); + pointer-events: auto; +} + +.preview-scene-gizmo-toolbar button { + min-width: 44px; + height: 22px; + padding: 0 7px; + color: var(--text-secondary); + background: transparent; + border: 0; + border-radius: 3px; + cursor: pointer; + font-size: 10px; +} + +.preview-scene-gizmo-toolbar button:hover, +.preview-scene-gizmo-toolbar button.active { + color: #ffffff; + background: rgba(255, 255, 255, 0.15); +} + .preview-canvas-wrapper { transform-origin: center center; transition: transform 0.1s ease-out; diff --git a/src/changelog-data.json b/src/changelog-data.json index abf2ec84e..ed84929c6 100644 --- a/src/changelog-data.json +++ b/src/changelog-data.json @@ -1,4 +1,44 @@ [ + { + "date": "2026-04-26", + "type": "fix", + "title": "PLY Splat Sequences Play Smoothly", + "description": "PLY frame sequences now preload ahead during playback, keep exact frames instead of nearest-frame fallbacks, avoid per-frame load progress overlays, and report the real visual splat update rate in the preview stats.", + "section": "3D / Gaussian Splats", + "commits": [ + "6f67b2ab" + ] + }, + { + "date": "2026-04-26", + "type": "fix", + "title": "3D Sequence Textures and Transforms Stay Stable", + "description": "GLB sequences keep their materials and textures through frame changes, 3D and splat transform controls can be edited again, and media sequence metadata is preserved when projects are saved and restored.", + "section": "3D / Sequence Playback", + "commits": [ + "6f67b2ab" + ] + }, + { + "date": "2026-04-26", + "type": "new", + "title": "Scene Gizmo Overlay Added", + "description": "The preview now includes a scene object gizmo overlay for manipulating 3D items directly, with additional splat effector stability fixes in the native renderer.", + "section": "3D / Native Scene", + "commits": [ + "f67bbc47" + ] + }, + { + "date": "2026-04-26", + "type": "new", + "title": "Media Panel Board View Added", + "description": "The Media panel now supports a board-style view with persisted layout metadata, making large media sets easier to organize and restore across project sessions.", + "section": "Media Panel", + "commits": [ + "a9336e8f" + ] + }, { "date": "2026-04-26", "type": "new", diff --git a/src/components/panels/MediaPanel.tsx b/src/components/panels/MediaPanel.tsx index b5ca67da7..b70a16f0d 100644 --- a/src/components/panels/MediaPanel.tsx +++ b/src/components/panels/MediaPanel.tsx @@ -1,6 +1,6 @@ // Media Panel - Project browser like After Effects -import React, { useCallback, useRef, useState, useEffect } from 'react'; +import React, { useCallback, useMemo, useRef, useState, useEffect } from 'react'; import { Logger } from '../../services/logger'; import { FileTypeIcon } from './media/FileTypeIcon'; import { LABEL_COLORS, getLabelHex } from './media/labelColors'; @@ -13,7 +13,7 @@ import { collectDroppedMediaFiles, planDroppedMediaImports } from './media/dropI const log = Logger.create('MediaPanel'); import { useMediaStore } from '../../stores/mediaStore'; -import type { MediaFile, Composition, ProjectItem, SolidItem, CameraItem } from '../../stores/mediaStore'; +import type { MediaFile, Composition, ProjectItem, TextItem, SolidItem, CameraItem, MediaFolder } from '../../stores/mediaStore'; import { useTimelineStore } from '../../stores/timeline'; import { useDockStore } from '../../stores/dockStore'; import { useContextMenuPosition } from '../../hooks/useContextMenuPosition'; @@ -25,6 +25,53 @@ import { // Column definitions type ColumnId = 'label' | 'name' | 'duration' | 'resolution' | 'fps' | 'container' | 'codec' | 'audio' | 'bitrate' | 'size'; +type MediaPanelViewMode = 'classic' | 'icons' | 'board'; +type MediaBoardItem = Exclude; + +interface MediaBoardViewport { + zoom: number; + panX: number; + panY: number; +} + +interface MediaBoardNodeLayout { + x: number; + y: number; + width: number; + height: number; +} + +interface MediaBoardGroupLayout { + id: string | null; + name: string; + x: number; + y: number; + width: number; + height: number; + itemCount: number; +} + +interface MediaBoardNodePlacement { + item: MediaBoardItem; + layout: MediaBoardNodeLayout; + defaultLayout: MediaBoardNodeLayout; + groupId: string | null; + slotIndex: number; +} + +interface MediaBoardMarquee { + startX: number; + startY: number; + currentX: number; + currentY: number; +} + +type MediaPanelContextMenu = { + x: number; + y: number; + itemId?: string; + parentId?: string | null; +}; const COLUMN_LABELS_MAP: Record = { label: '●', @@ -41,6 +88,24 @@ const COLUMN_LABELS_MAP: Record = { const DEFAULT_COLUMN_ORDER: ColumnId[] = ['name', 'label', 'duration', 'resolution', 'fps', 'container', 'codec', 'audio', 'bitrate', 'size']; const STORAGE_KEY = 'media-panel-column-order'; +const VIEW_MODE_STORAGE_KEY = 'media-panel-view-mode'; +const BOARD_VIEWPORT_STORAGE_KEY = 'media-panel-board-viewport'; +const BOARD_ORDER_STORAGE_KEY = 'media-panel-board-order'; +const MEDIA_PANEL_PROJECT_UI_LOADED_EVENT = 'media-panel-project-ui-loaded'; +const MEDIA_BOARD_ROOT_ORDER_KEY = '__root__'; + +const DEFAULT_BOARD_VIEWPORT: MediaBoardViewport = { zoom: 0.82, panX: 32, panY: 28 }; +const MEDIA_BOARD_NODE_WIDTH = 156; +const MEDIA_BOARD_NODE_HEIGHT = 132; +const MEDIA_BOARD_NODE_GAP = 14; +const MEDIA_BOARD_GROUP_WIDTH = 732; +const MEDIA_BOARD_GROUP_HEADER_HEIGHT = 42; +const MEDIA_BOARD_GROUP_PADDING = 18; +const MEDIA_BOARD_GROUP_GAP = 72; +const MEDIA_BOARD_COLUMNS_PER_GROUP = 4; +const MEDIA_BOARD_PAN_ZOOM_MIN = 0.18; +const MEDIA_BOARD_PAN_ZOOM_MAX = 2.4; +const MEDIA_BOARD_DRAG_START_DISTANCE = 4; // Load column order from localStorage function loadColumnOrder(): ColumnId[] { @@ -67,6 +132,54 @@ function loadColumnOrder(): ColumnId[] { return DEFAULT_COLUMN_ORDER; } +function loadMediaPanelViewMode(): MediaPanelViewMode { + const stored = localStorage.getItem(VIEW_MODE_STORAGE_KEY); + if (stored === 'board') return 'board'; + if (stored === 'icons' || stored === 'grid') return 'icons'; + return 'classic'; +} + +function loadMediaBoardViewport(): MediaBoardViewport { + try { + const stored = localStorage.getItem(BOARD_VIEWPORT_STORAGE_KEY); + if (!stored) return DEFAULT_BOARD_VIEWPORT; + const parsed = JSON.parse(stored) as Partial; + const zoom = Number(parsed.zoom); + const panX = Number(parsed.panX); + const panY = Number(parsed.panY); + if (!Number.isFinite(zoom) || !Number.isFinite(panX) || !Number.isFinite(panY)) { + return DEFAULT_BOARD_VIEWPORT; + } + return { + zoom: Math.min(MEDIA_BOARD_PAN_ZOOM_MAX, Math.max(MEDIA_BOARD_PAN_ZOOM_MIN, zoom)), + panX, + panY, + }; + } catch { + return DEFAULT_BOARD_VIEWPORT; + } +} + +function loadMediaBoardOrder(): Record { + try { + const stored = localStorage.getItem(BOARD_ORDER_STORAGE_KEY); + if (!stored) return {}; + const parsed = JSON.parse(stored) as Record; + if (!parsed || typeof parsed !== 'object') return {}; + const valid: Record = {}; + Object.entries(parsed).forEach(([folderKey, ids]) => { + if (!folderKey || !Array.isArray(ids)) return; + const uniqueIds = ids.filter((id, index) => typeof id === 'string' && id.length > 0 && ids.indexOf(id) === index); + if (uniqueIds.length > 0) { + valid[folderKey] = uniqueIds; + } + }); + return valid; + } catch { + return {}; + } +} + function getProjectItemIconType(item: ProjectItem | undefined): string | undefined { if (!item || !('type' in item)) return undefined; if (item.type === 'model') { @@ -77,6 +190,30 @@ function getProjectItemIconType(item: ProjectItem | undefined): string | undefin return item.type; } +function getMediaBoardGroupName(folderId: string | null, folders: Array<{ id: string; name: string; parentId: string | null }>): string { + if (!folderId) return 'Root'; + const path: string[] = []; + let current = folders.find((folder) => folder.id === folderId); + while (current) { + path.unshift(current.name); + current = current.parentId ? folders.find((folder) => folder.id === current!.parentId) : undefined; + } + return path.length ? path.join(' / ') : 'Folder'; +} + +function getMediaBoardTypeLabel(item: MediaBoardItem): string { + if (item.type === 'composition') return 'Composition'; + if (item.type === 'gaussian-splat') return 'Splat'; + if (item.type === 'splat-effector') return 'Effector'; + if (item.type === 'solid') return 'Solid'; + if (item.type === 'model') return 'Model'; + return item.type.charAt(0).toUpperCase() + item.type.slice(1); +} + +function getMediaBoardOrderKey(folderId: string | null): string { + return folderId ?? MEDIA_BOARD_ROOT_ORDER_KEY; +} + export function MediaPanel() { // Reactive data - subscribe to specific values only const files = useMediaStore(state => state.files); @@ -138,10 +275,14 @@ export function MediaPanel() { const fileInputRef = useRef(null); const itemListRef = useRef(null); + const boardWrapperRef = useRef(null); + const boardCanvasRef = useRef(null); + const boardCanvasInnerRef = useRef(null); + const boardInteractionFrameRef = useRef(null); const [renamingId, setRenamingId] = useState(null); const [renameValue, setRenameValue] = useState(''); const renameTimerRef = useRef(null); - const [contextMenu, setContextMenu] = useState<{ x: number; y: number; itemId?: string } | null>(null); + const [contextMenu, setContextMenu] = useState(null); // Marquee selection state const [marquee, setMarquee] = useState<{ startX: number; startY: number; currentX: number; currentY: number } | null>(null); @@ -155,11 +296,14 @@ export function MediaPanel() { const [isExternalDragOver, setIsExternalDragOver] = useState(false); const [labelPickerItemId, setLabelPickerItemId] = useState(null); const [labelPickerPos, setLabelPickerPos] = useState<{ x: number; y: number } | null>(null); - const [viewMode, setViewMode] = useState<'list' | 'grid'>(() => { - return (localStorage.getItem('media-panel-view-mode') as 'list' | 'grid') || 'list'; - }); + const [viewMode, setViewMode] = useState(loadMediaPanelViewMode); // Grid view: current open folder (null = root) const [gridFolderId, setGridFolderId] = useState(null); + const [mediaBoardViewport, setMediaBoardViewport] = useState(loadMediaBoardViewport); + const [mediaBoardOrder, setMediaBoardOrder] = useState>(loadMediaBoardOrder); + const [mediaBoardMarquee, setMediaBoardMarquee] = useState(null); + const suppressMediaBoardContextMenuRef = useRef(false); + const suppressMediaBoardContextMenuTimerRef = useRef(null); // Column order state const [columnOrder, setColumnOrder] = useState(loadColumnOrder); @@ -175,6 +319,27 @@ export function MediaPanel() { localStorage.setItem(STORAGE_KEY, JSON.stringify(columnOrder)); }, [columnOrder]); + useEffect(() => { + localStorage.setItem(VIEW_MODE_STORAGE_KEY, viewMode); + }, [viewMode]); + + useEffect(() => { + localStorage.setItem(BOARD_VIEWPORT_STORAGE_KEY, JSON.stringify(mediaBoardViewport)); + }, [mediaBoardViewport]); + + useEffect(() => { + localStorage.setItem(BOARD_ORDER_STORAGE_KEY, JSON.stringify(mediaBoardOrder)); + }, [mediaBoardOrder]); + + useEffect(() => () => { + if (boardInteractionFrameRef.current !== null) { + window.cancelAnimationFrame(boardInteractionFrameRef.current); + } + if (suppressMediaBoardContextMenuTimerRef.current !== null) { + window.clearTimeout(suppressMediaBoardContextMenuTimerRef.current); + } + }, []); + // Column drag handlers const handleColumnDragStart = useCallback((e: React.DragEvent, columnId: ColumnId) => { e.stopPropagation(); @@ -469,8 +634,8 @@ export function MediaPanel() { // Handle double-click (open/expand) const handleItemDoubleClick = useCallback(async (item: ProjectItem) => { if ('isExpanded' in item) { - // It's a folder — in grid view navigate into it, in list view toggle expand - if (viewMode === 'grid') { + // Folders navigate in icon view and expand/collapse in the denser views. + if (viewMode === 'icons') { setGridFolderId(item.id); } else { toggleFolderExpanded(item.id); @@ -491,7 +656,7 @@ export function MediaPanel() { }, [toggleFolderExpanded, openCompositionTab, reloadFile, viewMode]); // Context menu - const handleContextMenu = useCallback((e: React.MouseEvent, itemId?: string) => { + const handleContextMenu = useCallback((e: React.MouseEvent, itemId?: string, parentId?: string | null) => { e.preventDefault(); if (itemId && !selectedIds.includes(itemId)) { // If right-clicking an unselected item, select only it (unless Ctrl held) @@ -501,9 +666,30 @@ export function MediaPanel() { setSelection([itemId]); } } - setContextMenu({ x: e.clientX, y: e.clientY, itemId }); + setContextMenu({ x: e.clientX, y: e.clientY, itemId, parentId }); }, [selectedIds, setSelection, addToSelection]); + const suppressNextMediaBoardContextMenu = useCallback(() => { + suppressMediaBoardContextMenuRef.current = true; + if (suppressMediaBoardContextMenuTimerRef.current !== null) { + window.clearTimeout(suppressMediaBoardContextMenuTimerRef.current); + } + suppressMediaBoardContextMenuTimerRef.current = window.setTimeout(() => { + suppressMediaBoardContextMenuRef.current = false; + suppressMediaBoardContextMenuTimerRef.current = null; + }, 600); + }, []); + + const consumeSuppressedMediaBoardContextMenu = useCallback(() => { + if (!suppressMediaBoardContextMenuRef.current) return false; + suppressMediaBoardContextMenuRef.current = false; + if (suppressMediaBoardContextMenuTimerRef.current !== null) { + window.clearTimeout(suppressMediaBoardContextMenuTimerRef.current); + suppressMediaBoardContextMenuTimerRef.current = null; + } + return true; + }, []); + const closeContextMenu = useCallback(() => { setContextMenu(null); }, []); @@ -580,16 +766,17 @@ export function MediaPanel() { closeContextMenu(); }, [selectedIds, files, compositions, folders, textItems, meshItems, cameraItems, splatEffectorItems, removeFile, removeComposition, removeFolder, removeTextItem, removeMeshItem, removeCameraItem, removeSplatEffectorItem, closeContextMenu]); - // Get the active parent folder (grid view: current open folder, list view: selected folder or null) + // Get the active parent folder (icons view: current open folder, classic/board view: selected folder or null) const getActiveParentId = useCallback((): string | null => { - if (viewMode === 'grid' && gridFolderId) return gridFolderId; - // In list view, if a single folder is selected, create inside it + if (contextMenu && contextMenu.parentId !== undefined) return contextMenu.parentId; + if (viewMode === 'icons' && gridFolderId) return gridFolderId; + // In classic/board view, if a single folder is selected, create inside it if (selectedIds.length === 1) { const sel = folders.find(f => f.id === selectedIds[0]); if (sel) return sel.id; } return null; - }, [viewMode, gridFolderId, selectedIds, folders]); + }, [contextMenu, viewMode, gridFolderId, selectedIds, folders]); // New composition const handleNewComposition = useCallback(() => { @@ -979,6 +1166,21 @@ export function MediaPanel() { }); const resizeRef = useRef<{ startX: number; startWidth: number } | null>(null); + useEffect(() => { + const handleProjectUiLoaded = () => { + setColumnOrder(loadColumnOrder()); + setViewMode(loadMediaPanelViewMode()); + setMediaBoardViewport(loadMediaBoardViewport()); + setMediaBoardOrder(loadMediaBoardOrder()); + const storedNameWidth = localStorage.getItem('media-panel-name-width'); + setNameColumnWidth(storedNameWidth ? parseInt(storedNameWidth, 10) : 250); + setGridFolderId(null); + }; + + window.addEventListener(MEDIA_PANEL_PROJECT_UI_LOADED_EVENT, handleProjectUiLoaded); + return () => window.removeEventListener(MEDIA_PANEL_PROJECT_UI_LOADED_EVENT, handleProjectUiLoaded); + }, []); + // Save name column width useEffect(() => { localStorage.setItem('media-panel-name-width', String(nameColumnWidth)); @@ -1349,6 +1551,805 @@ export function MediaPanel() { splatEffectorItems.length ); + const mediaBoardItems = useMemo(() => ([ + ...files, + ...compositions, + ...textItems, + ...solidItems, + ...meshItems, + ...cameraItems, + ...splatEffectorItems, + ]), [files, compositions, textItems, solidItems, meshItems, cameraItems, splatEffectorItems]); + + const mediaBoardItemIds = useMemo(() => new Set(mediaBoardItems.map((item) => item.id)), [mediaBoardItems]); + + useEffect(() => { + setMediaBoardOrder((current) => { + let changed = false; + const validFolderKeys = new Set([ + MEDIA_BOARD_ROOT_ORDER_KEY, + ...folders.map((folder) => folder.id), + ]); + const next: Record = {}; + + Object.entries(current).forEach(([folderKey, ids]) => { + if (!validFolderKeys.has(folderKey)) { + changed = true; + return; + } + + const filteredIds = ids.filter((id, index) => mediaBoardItemIds.has(id) && ids.indexOf(id) === index); + if (filteredIds.length !== ids.length) { + changed = true; + } + if (filteredIds.length > 0) { + next[folderKey] = filteredIds; + } + }); + + return changed ? next : current; + }); + }, [folders, mediaBoardItemIds]); + + const mediaBoardLayout = useMemo(() => { + const groupsByParent = new Map(); + groupsByParent.set(null, []); + folders.forEach((folder) => groupsByParent.set(folder.id, [])); + + mediaBoardItems.forEach((item) => { + const parentId = item.parentId ?? null; + if (!groupsByParent.has(parentId)) { + groupsByParent.set(parentId, []); + } + groupsByParent.get(parentId)!.push(item); + }); + + const orderedGroupIds: Array = [ + null, + ...folders + .map((folder) => folder.id) + .sort((leftId, rightId) => + getMediaBoardGroupName(leftId, folders).localeCompare(getMediaBoardGroupName(rightId, folders)) + ), + ].filter((groupId, index, ids) => ids.indexOf(groupId) === index); + + const groups: MediaBoardGroupLayout[] = []; + const placements: MediaBoardNodePlacement[] = []; + let rowY = 0; + let rowMaxHeight = 0; + let column = 0; + + const orderItemsForGroup = (groupId: string | null, items: MediaBoardItem[]): MediaBoardItem[] => { + const sortedItems = sortItems([...items]) as MediaBoardItem[]; + const savedOrder = mediaBoardOrder[getMediaBoardOrderKey(groupId)] ?? []; + if (savedOrder.length === 0) return sortedItems; + + const byId = new Map(sortedItems.map((item) => [item.id, item])); + const orderedItems = savedOrder + .map((id) => byId.get(id)) + .filter((item): item is MediaBoardItem => Boolean(item)); + const orderedIds = new Set(orderedItems.map((item) => item.id)); + return [ + ...orderedItems, + ...sortedItems.filter((item) => !orderedIds.has(item.id)), + ]; + }; + + orderedGroupIds.forEach((groupId) => { + const items = orderItemsForGroup(groupId, groupsByParent.get(groupId) ?? []); + const rows = Math.max(1, Math.ceil(items.length / MEDIA_BOARD_COLUMNS_PER_GROUP)); + const height = MEDIA_BOARD_GROUP_HEADER_HEIGHT + + (MEDIA_BOARD_GROUP_PADDING * 2) + + (rows * MEDIA_BOARD_NODE_HEIGHT) + + ((rows - 1) * MEDIA_BOARD_NODE_GAP); + const x = column * (MEDIA_BOARD_GROUP_WIDTH + MEDIA_BOARD_GROUP_GAP); + const y = rowY; + const group: MediaBoardGroupLayout = { + id: groupId, + name: getMediaBoardGroupName(groupId, folders), + x, + y, + width: MEDIA_BOARD_GROUP_WIDTH, + height, + itemCount: items.length, + }; + groups.push(group); + + items.forEach((item, index) => { + const col = index % MEDIA_BOARD_COLUMNS_PER_GROUP; + const row = Math.floor(index / MEDIA_BOARD_COLUMNS_PER_GROUP); + const defaultLayout: MediaBoardNodeLayout = { + x: x + MEDIA_BOARD_GROUP_PADDING + (col * (MEDIA_BOARD_NODE_WIDTH + MEDIA_BOARD_NODE_GAP)), + y: y + MEDIA_BOARD_GROUP_HEADER_HEIGHT + MEDIA_BOARD_GROUP_PADDING + (row * (MEDIA_BOARD_NODE_HEIGHT + MEDIA_BOARD_NODE_GAP)), + width: MEDIA_BOARD_NODE_WIDTH, + height: MEDIA_BOARD_NODE_HEIGHT, + }; + placements.push({ + item, + defaultLayout, + groupId, + layout: defaultLayout, + slotIndex: index, + }); + }); + + rowMaxHeight = Math.max(rowMaxHeight, height); + column += 1; + if (column >= 2) { + rowY += rowMaxHeight + MEDIA_BOARD_GROUP_GAP; + rowMaxHeight = 0; + column = 0; + } + }); + + return { groups, placements }; + }, [folders, mediaBoardItems, mediaBoardOrder, sortItems]); + + const mediaBoardPlacementsById = useMemo(() => { + return new Map(mediaBoardLayout.placements.map((placement) => [placement.item.id, placement])); + }, [mediaBoardLayout.placements]); + + const screenToMediaBoard = useCallback((clientX: number, clientY: number) => { + const rect = boardCanvasRef.current?.getBoundingClientRect(); + if (!rect) return { x: 0, y: 0 }; + return { + x: (clientX - rect.left - mediaBoardViewport.panX) / mediaBoardViewport.zoom, + y: (clientY - rect.top - mediaBoardViewport.panY) / mediaBoardViewport.zoom, + }; + }, [mediaBoardViewport.panX, mediaBoardViewport.panY, mediaBoardViewport.zoom]); + + const openBoardAI = useCallback(() => { + useDockStore.getState().activatePanelType('ai-video'); + closeContextMenu(); + }, [closeContextMenu]); + + const setMediaBoardPerformanceMode = useCallback((enabled: boolean) => { + boardWrapperRef.current?.classList.toggle('board-interacting', enabled); + }, []); + + const startMediaBoardPanGesture = useCallback((e: React.MouseEvent, options?: { clearSelectionOnTap?: boolean }) => { + e.preventDefault(); + closeContextMenu(); + + const startX = e.clientX; + const startY = e.clientY; + const startViewport = { ...mediaBoardViewport }; + let pendingViewport = startViewport; + let didPan = false; + + const schedulePreview = () => { + if (boardInteractionFrameRef.current !== null) return; + boardInteractionFrameRef.current = window.requestAnimationFrame(() => { + boardInteractionFrameRef.current = null; + const inner = boardCanvasInnerRef.current; + if (!inner) return; + inner.style.transform = `translate(${pendingViewport.panX}px, ${pendingViewport.panY}px) scale(${pendingViewport.zoom})`; + }); + }; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const dx = moveEvent.clientX - startX; + const dy = moveEvent.clientY - startY; + const distance = Math.hypot(dx, dy); + if (!didPan && distance < MEDIA_BOARD_DRAG_START_DISTANCE) return; + + if (!didPan) { + didPan = true; + setMediaBoardPerformanceMode(true); + } + + pendingViewport = { + ...startViewport, + panX: startViewport.panX + dx, + panY: startViewport.panY + dy, + }; + schedulePreview(); + }; + + const handleMouseUp = () => { + if (boardInteractionFrameRef.current !== null) { + window.cancelAnimationFrame(boardInteractionFrameRef.current); + boardInteractionFrameRef.current = null; + } + setMediaBoardPerformanceMode(false); + + if (didPan) { + setMediaBoardViewport(pendingViewport); + } else if (options?.clearSelectionOnTap) { + setSelection([]); + } + + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('blur', handleMouseUp); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('blur', handleMouseUp); + }, [closeContextMenu, mediaBoardViewport, setMediaBoardPerformanceMode, setSelection]); + + const startMediaBoardMarqueeGesture = useCallback((e: React.MouseEvent) => { + const startPoint = screenToMediaBoard(e.clientX, e.clientY); + const startClientX = e.clientX; + const startClientY = e.clientY; + const initialSelection = e.ctrlKey || e.metaKey ? selectedIds : []; + let didSelect = false; + + const updateSelectionForRect = (rect: { left: number; right: number; top: number; bottom: number }) => { + const hitIds = mediaBoardLayout.placements + .filter(({ layout }) => { + const right = layout.x + layout.width; + const bottom = layout.y + layout.height; + return right > rect.left && layout.x < rect.right && bottom > rect.top && layout.y < rect.bottom; + }) + .map(({ item }) => item.id); + setSelection([...new Set([...initialSelection, ...hitIds])]); + }; + + const handleMouseMove = (moveEvent: MouseEvent) => { + const distance = Math.hypot(moveEvent.clientX - startClientX, moveEvent.clientY - startClientY); + if (!didSelect && distance < MEDIA_BOARD_DRAG_START_DISTANCE) return; + + didSelect = true; + closeContextMenu(); + const currentPoint = screenToMediaBoard(moveEvent.clientX, moveEvent.clientY); + const rect = { + left: Math.min(startPoint.x, currentPoint.x), + right: Math.max(startPoint.x, currentPoint.x), + top: Math.min(startPoint.y, currentPoint.y), + bottom: Math.max(startPoint.y, currentPoint.y), + }; + setMediaBoardMarquee({ + startX: startPoint.x, + startY: startPoint.y, + currentX: currentPoint.x, + currentY: currentPoint.y, + }); + updateSelectionForRect(rect); + }; + + const handleMouseUp = () => { + if (didSelect) { + suppressNextMediaBoardContextMenu(); + } + setMediaBoardMarquee(null); + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('blur', handleMouseUp); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('blur', handleMouseUp); + }, [closeContextMenu, mediaBoardLayout.placements, screenToMediaBoard, selectedIds, setSelection, suppressNextMediaBoardContextMenu]); + + const getMediaBoardGroupAtPoint = useCallback((point: { x: number; y: number }) => { + return mediaBoardLayout.groups.find((group) => ( + point.x >= group.x + && point.x <= group.x + group.width + && point.y >= group.y + && point.y <= group.y + group.height + )) ?? null; + }, [mediaBoardLayout.groups]); + + const handleMediaBoardWorkspaceContextMenu = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + if (consumeSuppressedMediaBoardContextMenu()) return; + const point = screenToMediaBoard(e.clientX, e.clientY); + const targetGroup = getMediaBoardGroupAtPoint(point); + handleContextMenu(e, undefined, targetGroup?.id ?? null); + }, [consumeSuppressedMediaBoardContextMenu, getMediaBoardGroupAtPoint, handleContextMenu, screenToMediaBoard]); + + const getMediaBoardInsertTarget = useCallback((point: { x: number; y: number }, movingIds: string[]) => { + const targetGroup = getMediaBoardGroupAtPoint(point); + if (!targetGroup) return null; + + const movingIdSet = new Set(movingIds); + const targetPlacements = mediaBoardLayout.placements + .filter((placement) => placement.groupId === targetGroup.id && !movingIdSet.has(placement.item.id)) + .sort((a, b) => a.slotIndex - b.slotIndex); + + let targetIndex = targetPlacements.length; + for (let index = 0; index < targetPlacements.length; index += 1) { + const { layout } = targetPlacements[index]; + const centerX = layout.x + layout.width / 2; + const centerY = layout.y + layout.height / 2; + if (point.y < centerY || (point.y < layout.y + layout.height && point.x < centerX)) { + targetIndex = index; + break; + } + } + + return { groupId: targetGroup.id, index: targetIndex }; + }, [getMediaBoardGroupAtPoint, mediaBoardLayout.placements]); + + const getMediaBoardAppendIndex = useCallback((groupId: string | null, movingIds: string[]) => { + const movingIdSet = new Set(movingIds); + return mediaBoardLayout.placements.filter((placement) => ( + placement.groupId === groupId && !movingIdSet.has(placement.item.id) + )).length; + }, [mediaBoardLayout.placements]); + + const commitMediaBoardOrderChange = useCallback((movingIds: string[], targetGroupId: string | null, targetIndex: number) => { + if (movingIds.length === 0) return; + const movingIdSet = new Set(movingIds); + const targetGroupKey = getMediaBoardOrderKey(targetGroupId); + const groupIds = [ + MEDIA_BOARD_ROOT_ORDER_KEY, + ...folders.map((folder) => folder.id), + ]; + + setMediaBoardOrder((current) => { + const next: Record = { ...current }; + + groupIds.forEach((groupKey) => { + const existingOrder = next[groupKey] + ?? mediaBoardLayout.placements + .filter((placement) => getMediaBoardOrderKey(placement.groupId) === groupKey) + .sort((a, b) => a.slotIndex - b.slotIndex) + .map((placement) => placement.item.id); + const filteredOrder = existingOrder.filter((id) => !movingIdSet.has(id)); + if (filteredOrder.length > 0) { + next[groupKey] = filteredOrder; + } else { + delete next[groupKey]; + } + }); + + const targetOrder = [ + ...(next[targetGroupKey] + ?? mediaBoardLayout.placements + .filter((placement) => placement.groupId === targetGroupId && !movingIdSet.has(placement.item.id)) + .sort((a, b) => a.slotIndex - b.slotIndex) + .map((placement) => placement.item.id)), + ]; + const insertIndex = Math.max(0, Math.min(targetIndex, targetOrder.length)); + targetOrder.splice(insertIndex, 0, ...movingIds); + next[targetGroupKey] = targetOrder; + + return next; + }); + + moveToFolder(movingIds, targetGroupId); + }, [folders, mediaBoardLayout.placements, moveToFolder]); + + const startMediaBoardNodeMoveGesture = useCallback((e: React.MouseEvent, item: MediaBoardItem) => { + const selectedMoveIds = selectedIds.includes(item.id) + ? selectedIds.filter((id) => mediaBoardItemIds.has(id)) + : [item.id]; + const boardOrderedMoveIds = mediaBoardLayout.placements + .filter((placement) => selectedMoveIds.includes(placement.item.id)) + .sort((a, b) => a.slotIndex - b.slotIndex) + .map((placement) => placement.item.id); + const moveIds = boardOrderedMoveIds.length > 0 ? boardOrderedMoveIds : selectedMoveIds; + const startLayouts = moveIds.map((id) => { + const placement = mediaBoardPlacementsById.get(id); + return { + id, + layout: placement?.defaultLayout ?? placement?.layout, + }; + }).filter((entry): entry is { id: string; layout: MediaBoardNodeLayout } => !!entry.layout); + + if (startLayouts.length === 0) return; + + const startX = e.clientX; + const startY = e.clientY; + let didDrag = false; + let previewDx = 0; + let previewDy = 0; + let latestClientX = startX; + let latestClientY = startY; + + const clearPreview = () => { + startLayouts.forEach(({ id }) => { + const node = boardCanvasRef.current?.querySelector(`.media-board-node[data-item-id="${CSS.escape(id)}"]`); + if (!node) return; + node.style.transform = ''; + node.classList.remove('drag-preview'); + }); + }; + + const schedulePreview = () => { + if (boardInteractionFrameRef.current !== null) return; + boardInteractionFrameRef.current = window.requestAnimationFrame(() => { + boardInteractionFrameRef.current = null; + startLayouts.forEach(({ id }) => { + const node = boardCanvasRef.current?.querySelector(`.media-board-node[data-item-id="${CSS.escape(id)}"]`); + if (!node) return; + node.style.transform = `translate3d(${previewDx}px, ${previewDy}px, 0)`; + node.classList.add('drag-preview'); + }); + }); + }; + + const handleMouseMove = (moveEvent: MouseEvent) => { + latestClientX = moveEvent.clientX; + latestClientY = moveEvent.clientY; + const distance = Math.hypot(moveEvent.clientX - startX, moveEvent.clientY - startY); + if (!didDrag && distance < MEDIA_BOARD_DRAG_START_DISTANCE) return; + + if (!didDrag) { + didDrag = true; + suppressNextMediaBoardContextMenu(); + closeContextMenu(); + setMediaBoardPerformanceMode(true); + } + + previewDx = (moveEvent.clientX - startX) / mediaBoardViewport.zoom; + previewDy = (moveEvent.clientY - startY) / mediaBoardViewport.zoom; + schedulePreview(); + }; + + const handleMouseUp = () => { + if (boardInteractionFrameRef.current !== null) { + window.cancelAnimationFrame(boardInteractionFrameRef.current); + boardInteractionFrameRef.current = null; + } + clearPreview(); + setMediaBoardPerformanceMode(false); + + if (didDrag) { + const point = screenToMediaBoard(latestClientX, latestClientY); + const target = getMediaBoardInsertTarget(point, moveIds); + if (target) { + commitMediaBoardOrderChange(moveIds, target.groupId, target.index); + } + } + + window.removeEventListener('mousemove', handleMouseMove); + window.removeEventListener('mouseup', handleMouseUp); + window.removeEventListener('blur', handleMouseUp); + }; + + window.addEventListener('mousemove', handleMouseMove); + window.addEventListener('mouseup', handleMouseUp); + window.addEventListener('blur', handleMouseUp); + }, [ + closeContextMenu, + commitMediaBoardOrderChange, + getMediaBoardInsertTarget, + mediaBoardItemIds, + mediaBoardLayout.placements, + mediaBoardPlacementsById, + mediaBoardViewport.zoom, + selectedIds, + screenToMediaBoard, + setMediaBoardPerformanceMode, + suppressNextMediaBoardContextMenu, + ]); + + const handleMediaBoardWheel = useCallback((e: React.WheelEvent) => { + e.preventDefault(); + const rect = boardCanvasRef.current?.getBoundingClientRect(); + if (!rect) return; + + const zoomDelta = e.deltaY > 0 ? 0.9 : 1.1; + setMediaBoardViewport((current) => { + const nextZoom = Math.min( + MEDIA_BOARD_PAN_ZOOM_MAX, + Math.max(MEDIA_BOARD_PAN_ZOOM_MIN, current.zoom * zoomDelta), + ); + const cursorX = e.clientX - rect.left; + const cursorY = e.clientY - rect.top; + return { + zoom: nextZoom, + panX: cursorX - ((cursorX - current.panX) * (nextZoom / current.zoom)), + panY: cursorY - ((cursorY - current.panY) * (nextZoom / current.zoom)), + }; + }); + }, []); + + const handleMediaBoardMouseDown = useCallback((e: React.MouseEvent) => { + const target = e.target as HTMLElement; + if (target.closest('.media-board-node, button, input, .context-menu')) return; + + if (e.button === 2) { + startMediaBoardMarqueeGesture(e); + return; + } + + if (e.button !== 0 && e.button !== 1) return; + startMediaBoardPanGesture(e, { clearSelectionOnTap: e.button === 0 && !e.ctrlKey && !e.metaKey }); + }, [startMediaBoardMarqueeGesture, startMediaBoardPanGesture]); + + const handleMediaBoardNodeMouseDown = useCallback((e: React.MouseEvent, item: MediaBoardItem) => { + const target = e.target as HTMLElement; + if (target.closest('.media-board-node-timeline-drag, button, input')) return; + + if (e.button === 2) { + e.stopPropagation(); + if (e.ctrlKey || e.metaKey) { + startMediaBoardMarqueeGesture(e); + return; + } + if (!selectedIds.includes(item.id)) { + setSelection([item.id]); + } + startMediaBoardNodeMoveGesture(e, item); + return; + } + + if (e.button !== 0) return; + + e.stopPropagation(); + handleItemClick(item.id, e); + + if (!e.ctrlKey && !e.metaKey && !e.shiftKey) { + startMediaBoardPanGesture(e); + } + }, [ + handleItemClick, + setSelection, + selectedIds, + startMediaBoardMarqueeGesture, + startMediaBoardNodeMoveGesture, + startMediaBoardPanGesture, + ]); + + const handleMediaBoardDrop = useCallback(async (e: React.DragEvent) => { + e.preventDefault(); + e.stopPropagation(); + setIsExternalDragOver(false); + + if (e.dataTransfer.types.includes('application/x-media-panel-item')) { + const itemId = e.dataTransfer.getData('application/x-media-panel-item'); + if (itemId) { + const itemsToMove = selectedIds.includes(itemId) ? selectedIds : [itemId]; + const isFolderMove = itemsToMove.some((id) => folders.some((folder) => folder.id === id)); + if (isFolderMove) { + moveToFolder(itemsToMove, null); + } else { + const point = screenToMediaBoard(e.clientX, e.clientY); + const target = getMediaBoardInsertTarget(point, itemsToMove); + const groupId = target?.groupId ?? null; + const index = target?.index ?? getMediaBoardAppendIndex(groupId, itemsToMove); + commitMediaBoardOrderChange(itemsToMove, groupId, index); + } + } + setDragOverFolderId(null); + setInternalDragId(null); + return; + } + + const point = screenToMediaBoard(e.clientX, e.clientY); + const targetGroup = mediaBoardLayout.groups.find((group) => ( + point.x >= group.x + && point.x <= group.x + group.width + && point.y >= group.y + && point.y <= group.y + group.height + )); + await handleExternalDropImport(e.dataTransfer, targetGroup?.id ?? null); + }, [commitMediaBoardOrderChange, folders, getMediaBoardAppendIndex, getMediaBoardInsertTarget, handleExternalDropImport, mediaBoardLayout.groups, moveToFolder, screenToMediaBoard, selectedIds]); + + const handleMediaBoardGroupDrop = useCallback(async (e: React.DragEvent, groupId: string | null) => { + e.preventDefault(); + e.stopPropagation(); + + if (e.dataTransfer.types.includes('application/x-media-panel-item')) { + const itemId = e.dataTransfer.getData('application/x-media-panel-item'); + if (itemId) { + if (groupId) { + const draggedFolder = folders.find((folder) => folder.id === itemId); + if (draggedFolder) { + let parent = folders.find((folder) => folder.id === groupId); + while (parent) { + if (parent.id === itemId) { + setDragOverFolderId(null); + setInternalDragId(null); + return; + } + parent = parent.parentId ? folders.find((folder) => folder.id === parent!.parentId) : undefined; + } + } + } + const itemsToMove = selectedIds.includes(itemId) ? selectedIds : [itemId]; + const isFolderMove = itemsToMove.some((id) => folders.some((folder) => folder.id === id)); + if (isFolderMove) { + moveToFolder(itemsToMove, groupId); + } else { + const point = screenToMediaBoard(e.clientX, e.clientY); + const target = getMediaBoardInsertTarget(point, itemsToMove); + const targetGroupId = target?.groupId ?? groupId; + const targetIndex = target?.index ?? getMediaBoardAppendIndex(targetGroupId, itemsToMove); + commitMediaBoardOrderChange(itemsToMove, targetGroupId, targetIndex); + } + } + setDragOverFolderId(null); + setInternalDragId(null); + return; + } + + await handleExternalDropImport(e.dataTransfer, groupId); + setIsExternalDragOver(false); + }, [commitMediaBoardOrderChange, folders, getMediaBoardAppendIndex, getMediaBoardInsertTarget, handleExternalDropImport, moveToFolder, screenToMediaBoard, selectedIds]); + + const handleMediaBoardGroupDragOver = useCallback((e: React.DragEvent) => { + if (!e.dataTransfer.types.includes('application/x-media-panel-item') && !e.dataTransfer.types.includes('Files')) return; + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = e.dataTransfer.types.includes('application/x-media-panel-item') ? 'move' : 'copy'; + }, []); + + const resetMediaBoardLayout = useCallback(() => { + setMediaBoardOrder({}); + setMediaBoardViewport(DEFAULT_BOARD_VIEWPORT); + }, []); + + const renderMediaBoardNode = (placement: MediaBoardNodePlacement) => { + const { item, layout } = placement; + const isSelected = selectedIds.includes(item.id); + const isMediaFile = isImportedMediaFileItem(item); + const mediaFile = isMediaFile ? item : null; + const isComp = item.type === 'composition'; + const comp = isComp ? (item as Composition) : null; + const isTextItem = item.type === 'text'; + const textItem = isTextItem ? (item as TextItem) : null; + const isSolidItem = item.type === 'solid'; + const solidItem = isSolidItem ? (item as SolidItem) : null; + const thumbUrl = mediaFile?.thumbnailUrl; + const duration = mediaFile?.duration || comp?.duration; + const importProgress = getItemImportProgress(item); + const labelHex = 'labelColor' in item ? getLabelHex(item.labelColor) : 'transparent'; + const title = buildGridTooltip(item, false, isComp); + const resolutionLabel = 'width' in item && 'height' in item && item.width && item.height + ? `${item.width}x${item.height}` + : comp + ? `${comp.width}x${comp.height}` + : null; + + return ( +
handleMediaBoardNodeMouseDown(e, item)} + onDoubleClick={() => { void handleItemDoubleClick(item); }} + onContextMenu={(e) => { + if (consumeSuppressedMediaBoardContextMenu()) { + e.preventDefault(); + return; + } + handleContextMenu(e, item.id); + }} + > +
+ {isSolidItem && solidItem ? ( +
+ ) : textItem ? ( +
+ {textItem.text} +
+ ) : thumbUrl ? ( + { void refreshFileUrls(mediaFile.id); } : undefined} + /> + ) : ( +
+ +
+ )} + {duration ? {formatDuration(duration)} : null} + {importProgress !== null ? {importProgress}% : null} + e.stopPropagation()} + onDragStart={(e) => handleDragStart(e, item)} + onDragEnd={handleDragEnd} + > + + +
+
+
{item.name}
+
+ {getMediaBoardTypeLabel(item)} + {resolutionLabel ? {resolutionLabel} : null} + {mediaFile?.codec ? {mediaFile.codec} : null} +
+
+
+ ); + }; + + const renderMediaBoardView = () => ( +
+
+
+ Board + {mediaBoardItems.length} assets in {mediaBoardLayout.groups.length} groups +
+
+ + +
+
+
{ + const target = e.target as HTMLElement; + if (!target.closest('.media-board-node')) handleMediaBoardWorkspaceContextMenu(e); + }} + onDragOver={(e) => { + if (e.dataTransfer.types.includes('Files')) { + setIsExternalDragOver(true); + } + e.preventDefault(); + e.dataTransfer.dropEffect = e.dataTransfer.types.includes('application/x-media-panel-item') ? 'move' : 'copy'; + }} + onDrop={handleMediaBoardDrop} + > +
+ {mediaBoardLayout.groups.map((group) => ( +
handleMediaBoardGroupDrop(e, group.id)} + > +
+ {group.name} + {group.itemCount} +
+
+ ))} + {mediaBoardLayout.placements.map(renderMediaBoardNode)} + {mediaBoardMarquee && (() => { + const left = Math.min(mediaBoardMarquee.startX, mediaBoardMarquee.currentX); + const top = Math.min(mediaBoardMarquee.startY, mediaBoardMarquee.currentY); + const width = Math.abs(mediaBoardMarquee.currentX - mediaBoardMarquee.startX); + const height = Math.abs(mediaBoardMarquee.currentY - mediaBoardMarquee.startY); + if (width < 2 && height < 2) return null; + return ( +
+ ); + })()} +
+
+
+ ); + // Grid view: items for current folder + breadcrumb path const gridItems = sortItems(getItemsByFolder(gridFolderId)); const gridBreadcrumb: Array<{ id: string | null; name: string }> = []; @@ -1384,21 +2385,38 @@ export function MediaPanel() { Project {totalItems} items
- +
+ + + +
{filesNeedReload && (
)} - - {sceneNavFpsMode - ? 'Click preview, hold LMB to look, WASD/QE move, MMB/RMB/Shift+LMB pan, wheel speed while moving/looking, wheel zoom otherwise. Dist = orbit distance.' - : 'Click preview, then WASD move, Q/E up-down, LMB orbit, MMB/RMB/Shift+LMB pan, wheel zoom. Dist = orbit distance.'} -
)} {!isCameraClip && ( -
+
{isLocked3D ? ( 3D @@ -243,7 +251,7 @@ export function TransformTab({ clipId, transform, speed = 1, is3D = false }: Tra
)} {supportsThreeDEffectorToggle && ( -
+
{canToggleThreeDEffectors && (