Skip to content

Commit f6f5152

Browse files
committed
Improve media board organization and track lock UI
1 parent aa6f658 commit f6f5152

26 files changed

Lines changed: 693 additions & 69 deletions

docs/Features/Math-Scene-Clips-Plan.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -91,7 +91,7 @@ Scope:
9191

9292
- Add the dedicated `Math` dock panel.
9393
- Add object list, parameter list, animation list, and inspector.
94-
- Add reusable Math Scene media items in the Media panel.
94+
- Expand the reusable Math Scene media items in the Media panel beyond the current default preset item.
9595
- Add preset buttons: function, point, tangent, area, label, camera move.
9696
- Add timeline badges and better clip display.
9797
- Add copy/paste and duplicate behavior for Math Scene objects.

docs/Features/Motion-Design.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ The motion design system follows `docs/plans/motion-design-system-plan.md`. It i
1414
- `src/services/properties/PropertyRegistry.ts` describes transform, effect, color, mask, vector-animation, and motion properties without owning Zustand state.
1515
- `src/stores/timeline/motionClipSlice.ts` can create rectangle/ellipse shape clips, null clips, adjustment clips, update motion definitions, and convert solid clips to motion rectangle clips.
1616
- `src/components/panels/properties/MotionShapeTab.tsx` exposes primitive, size, corner radius, fill, and stroke controls for motion shape clips.
17-
- Video track-header context menus can create Motion Rectangle and Motion Ellipse clips at the playhead.
17+
- The Media panel add/context menu can create Motion Rectangle and Motion Ellipse preset items that can be dragged to video tracks.
1818
- Solid clip context menus can convert the selected solid to a motion shape while preserving its clip id and timing.
1919
- The Motion tab exposes a first Grid Replicator section with enable, count, spacing, and opacity fade controls.
2020
- `src/engine/motion/MotionRenderer.ts` renders rectangle and ellipse primitives into transparent `rgba8unorm` textures using analytic WGSL SDFs.

docs/Features/Timeline.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,7 @@ Soloing multiple tracks is supported. Non-solo tracks dim visually when any solo
195195

196196
### Track Header Context Menu
197197

198-
- Video tracks can create Motion Rectangle, Motion Ellipse, or Math Scene clips at the playhead.
198+
- Math Scene and Motion Shape presets are created from the Media panel add/context menu and then dragged to video tracks.
199199
- `Duplicate Track` currently creates a new empty track of the same type.
200200
- `Delete` is blocked for the last remaining track of that type.
201201
- Deleting a populated track shows the affected clip count in the menu label/tooltip.

src/components/panels/MediaPanel.tsx

Lines changed: 143 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,14 @@ import { isProxyFrameCountComplete } from '../../stores/mediaStore/helpers/proxy
7676

7777
const log = Logger.create('MediaPanel');
7878
import { useMediaStore } from '../../stores/mediaStore';
79-
import type { MediaFile, Composition, ProjectItem, SolidItem, CameraItem } from '../../stores/mediaStore';
79+
import type {
80+
CameraItem,
81+
Composition,
82+
MediaFile,
83+
MotionShapeItem,
84+
ProjectItem,
85+
SolidItem,
86+
} from '../../stores/mediaStore';
8087
import { useTimelineStore } from '../../stores/timeline';
8188
import { useDockStore } from '../../stores/dockStore';
8289
import { useContextMenuPosition } from '../../hooks/useContextMenuPosition';
@@ -384,10 +391,12 @@ export function MediaPanel() {
384391
const compositions = useMediaStore(state => state.compositions);
385392
const folders = useMediaStore(state => state.folders);
386393
const textItems = useMediaStore(state => state.textItems);
387-
const solidItems = useMediaStore(state => state.solidItems);
388-
const meshItems = useMediaStore(state => state.meshItems);
389-
const cameraItems = useMediaStore(state => state.cameraItems);
390-
const splatEffectorItems = useMediaStore(state => state.splatEffectorItems);
394+
const solidItems = useMediaStore(state => state.solidItems ?? []);
395+
const meshItems = useMediaStore(state => state.meshItems ?? []);
396+
const cameraItems = useMediaStore(state => state.cameraItems ?? []);
397+
const splatEffectorItems = useMediaStore(state => state.splatEffectorItems ?? []);
398+
const mathSceneItems = useMediaStore(state => state.mathSceneItems ?? []);
399+
const motionShapeItems = useMediaStore(state => state.motionShapeItems ?? []);
391400
const selectedIds = useMediaStore(state => state.selectedIds);
392401
const expandedFolderIds = useMediaStore(state => state.expandedFolderIds);
393402
const fileSystemSupported = useMediaStore(state => state.fileSystemSupported);
@@ -423,6 +432,7 @@ export function MediaPanel() {
423432
removeTextItem,
424433
createSolidItem,
425434
getOrCreateSolidFolder,
435+
removeSolidItem,
426436
updateSolidItem,
427437
createMeshItem,
428438
getOrCreateMeshFolder,
@@ -433,6 +443,12 @@ export function MediaPanel() {
433443
createSplatEffectorItem,
434444
getOrCreateSplatEffectorFolder,
435445
removeSplatEffectorItem,
446+
createMathSceneItem,
447+
getOrCreateMathSceneFolder,
448+
removeMathSceneItem,
449+
createMotionShapeItem,
450+
getOrCreateMotionShapeFolder,
451+
removeMotionShapeItem,
436452
setLabelColor,
437453
importGaussianSplat,
438454
} = useMediaStore.getState();
@@ -1319,12 +1335,15 @@ export function MediaPanel() {
13191335
else if (compositions.find(c => c.id === id)) removeComposition(id);
13201336
else if (folders.find(f => f.id === id)) removeFolder(id);
13211337
else if (textItems.find(t => t.id === id)) removeTextItem(id);
1338+
else if (solidItems.find(s => s.id === id)) removeSolidItem(id);
13221339
else if (meshItems.find(m => m.id === id)) removeMeshItem(id);
13231340
else if (cameraItems.find(c => c.id === id)) removeCameraItem(id);
13241341
else if (splatEffectorItems.find(e => e.id === id)) removeSplatEffectorItem(id);
1342+
else if (mathSceneItems.find(m => m.id === id)) removeMathSceneItem(id);
1343+
else if (motionShapeItems.find(m => m.id === id)) removeMotionShapeItem(id);
13251344
});
13261345
closeContextMenu();
1327-
}, [selectedIds, files, compositions, folders, textItems, meshItems, cameraItems, splatEffectorItems, removeFile, removeComposition, removeFolder, removeTextItem, removeMeshItem, removeCameraItem, removeSplatEffectorItem, closeContextMenu]);
1346+
}, [selectedIds, files, compositions, folders, textItems, solidItems, meshItems, cameraItems, splatEffectorItems, mathSceneItems, motionShapeItems, removeFile, removeComposition, removeFolder, removeTextItem, removeSolidItem, removeMeshItem, removeCameraItem, removeSplatEffectorItem, removeMathSceneItem, removeMotionShapeItem, closeContextMenu]);
13281347

13291348
// Get the active parent folder (icons view: current open folder, classic/board view: selected folder or null)
13301349
const getActiveParentId = useCallback((): string | null => {
@@ -1389,6 +1408,18 @@ export function MediaPanel() {
13891408
closeContextMenu();
13901409
}, [createSplatEffectorItem, getOrCreateSplatEffectorFolder, closeContextMenu]);
13911410

1411+
const handleNewMathScene = useCallback(() => {
1412+
const mathFolderId = getOrCreateMathSceneFolder();
1413+
createMathSceneItem(undefined, mathFolderId);
1414+
closeContextMenu();
1415+
}, [createMathSceneItem, getOrCreateMathSceneFolder, closeContextMenu]);
1416+
1417+
const handleNewMotionShape = useCallback((primitive: import('../../types/motionDesign').ShapePrimitive) => {
1418+
const motionFolderId = getOrCreateMotionShapeFolder();
1419+
createMotionShapeItem(primitive, undefined, motionFolderId);
1420+
closeContextMenu();
1421+
}, [createMotionShapeItem, getOrCreateMotionShapeFolder, closeContextMenu]);
1422+
13921423
// Import Gaussian Avatar (.zip) — opens file picker, imports with forced gaussian-avatar type
13931424
const handleImportGaussianSplat = useCallback(() => {
13941425
const input = document.createElement('input');
@@ -1572,6 +1603,42 @@ export function MediaPanel() {
15721603
return;
15731604
}
15741605

1606+
if (item.type === 'math-scene') {
1607+
setExternalDragPayload({
1608+
kind: 'math-scene',
1609+
id: item.id,
1610+
duration: item.duration,
1611+
hasAudio: false,
1612+
isAudio: false,
1613+
isVideo: true,
1614+
});
1615+
e.dataTransfer.setData('application/x-math-scene-item-id', item.id);
1616+
e.dataTransfer.effectAllowed = 'copyMove';
1617+
if (e.currentTarget instanceof HTMLElement) {
1618+
e.dataTransfer.setDragImage(e.currentTarget, 10, 10);
1619+
}
1620+
return;
1621+
}
1622+
1623+
if (item.type === 'motion-shape') {
1624+
const motionShape = item as MotionShapeItem;
1625+
setExternalDragPayload({
1626+
kind: 'motion-shape',
1627+
id: item.id,
1628+
duration: motionShape.duration,
1629+
hasAudio: false,
1630+
isAudio: false,
1631+
isVideo: true,
1632+
primitive: motionShape.primitive,
1633+
});
1634+
e.dataTransfer.setData('application/x-motion-shape-item-id', item.id);
1635+
e.dataTransfer.effectAllowed = 'copyMove';
1636+
if (e.currentTarget instanceof HTMLElement) {
1637+
e.dataTransfer.setDragImage(e.currentTarget, 10, 10);
1638+
}
1639+
return;
1640+
}
1641+
15751642
// Handle media file drag
15761643
const mediaFile = item as MediaFile;
15771644
if (mediaFile.isImporting || mediaNeedsRelink(mediaFile)) {
@@ -2048,6 +2115,7 @@ export function MediaPanel() {
20482115
if (mf.fps) parts.push(`${mf.fps} fps`);
20492116
if (mf.fileSize) parts.push(formatFileSize(mf.fileSize));
20502117
if (mf.bitrate) parts.push(formatBitrate(mf.bitrate));
2118+
if (!mf.duration && 'duration' in item && item.duration) parts.push(formatDuration(item.duration));
20512119
}
20522120

20532121
return parts.join('\n');
@@ -2067,7 +2135,7 @@ export function MediaPanel() {
20672135
const importProgress = getItemImportProgress(item);
20682136

20692137
// Duration badge: videos + compositions
2070-
const duration = mediaFile?.duration || comp?.duration;
2138+
const duration = mediaFile?.duration || comp?.duration || ('duration' in item ? item.duration : undefined);
20712139

20722140
// Folder item count
20732141
const folderCount = isFolder ? getItemsForParent(item.id).length : 0;
@@ -2126,7 +2194,9 @@ export function MediaPanel() {
21262194
...meshItems,
21272195
...cameraItems,
21282196
...splatEffectorItems,
2129-
]), [files, compositions, folders, textItems, solidItems, meshItems, cameraItems, splatEffectorItems]);
2197+
...mathSceneItems,
2198+
...motionShapeItems,
2199+
]), [files, compositions, folders, textItems, solidItems, meshItems, cameraItems, splatEffectorItems, mathSceneItems, motionShapeItems]);
21302200

21312201
const projectListItems = useMemo<ProjectItem[]>(() => ([
21322202
...folders,
@@ -2136,8 +2206,10 @@ export function MediaPanel() {
21362206
...meshItems,
21372207
...cameraItems,
21382208
...splatEffectorItems,
2209+
...mathSceneItems,
2210+
...motionShapeItems,
21392211
...files,
2140-
]), [folders, compositions, textItems, solidItems, meshItems, cameraItems, splatEffectorItems, files]);
2212+
]), [folders, compositions, textItems, solidItems, meshItems, cameraItems, splatEffectorItems, mathSceneItems, motionShapeItems, files]);
21412213

21422214
const allProjectItemsById = useMemo(() => new Map(allProjectItems.map((item) => [item.id, item])), [allProjectItems]);
21432215
const totalItems = allProjectItems.length;
@@ -3051,6 +3123,29 @@ export function MediaPanel() {
30513123
};
30523124
}
30533125

3126+
if (item.type === 'math-scene') {
3127+
return {
3128+
kind: 'math-scene',
3129+
id: item.id,
3130+
duration: item.duration,
3131+
hasAudio: false,
3132+
isAudio: false,
3133+
isVideo: true,
3134+
};
3135+
}
3136+
3137+
if (item.type === 'motion-shape') {
3138+
return {
3139+
kind: 'motion-shape',
3140+
id: item.id,
3141+
duration: item.duration,
3142+
hasAudio: false,
3143+
isAudio: false,
3144+
isVideo: true,
3145+
primitive: item.primitive,
3146+
};
3147+
}
3148+
30543149
if (isImportedMediaFileItem(item) && item.file && !item.isImporting) {
30553150
const isAudioOnly =
30563151
item.file.type.startsWith('audio/') ||
@@ -3896,6 +3991,24 @@ export function MediaPanel() {
38963991
<span className="add-dropdown-icon"><FileTypeIcon type="splat-effector" /></span>
38973992
<span>3D Effector</span>
38983993
</div>
3994+
<div className="add-dropdown-separator" />
3995+
<div className="add-dropdown-item" onClick={() => { handleNewMathScene(); setAddDropdownOpen(false); }}>
3996+
<span className="add-dropdown-icon"><FileTypeIcon type="math-scene" /></span>
3997+
<span>Math Scene</span>
3998+
</div>
3999+
<div className="add-dropdown-item has-submenu" onMouseEnter={handleSubmenuHover} onMouseLeave={handleSubmenuLeave}>
4000+
<span className="add-dropdown-icon"><FileTypeIcon type="motion-shape" /></span>
4001+
<span>Motion Shape</span>
4002+
<span className="submenu-arrow">&#9654;</span>
4003+
<div className="add-dropdown-submenu">
4004+
<div className="add-dropdown-item" onClick={() => { handleNewMotionShape('rectangle'); setAddDropdownOpen(false); }}>
4005+
<span>Rectangle</span>
4006+
</div>
4007+
<div className="add-dropdown-item" onClick={() => { handleNewMotionShape('ellipse'); setAddDropdownOpen(false); }}>
4008+
<span>Ellipse</span>
4009+
</div>
4010+
</div>
4011+
</div>
38994012
<div className="add-dropdown-item has-submenu" onMouseEnter={handleSubmenuHover} onMouseLeave={handleSubmenuLeave}>
39004013
<span className="add-dropdown-icon"><FileTypeIcon type="mesh" /></span>
39014014
<span>Mesh</span>
@@ -4095,7 +4208,9 @@ export function MediaPanel() {
40954208
solidItems.find(s => s.id === contextMenu.itemId) ||
40964209
meshItems.find(m => m.id === contextMenu.itemId) ||
40974210
cameraItems.find(c => c.id === contextMenu.itemId) ||
4098-
splatEffectorItems.find(e => e.id === contextMenu.itemId)
4211+
splatEffectorItems.find(e => e.id === contextMenu.itemId) ||
4212+
mathSceneItems.find(m => m.id === contextMenu.itemId) ||
4213+
motionShapeItems.find(m => m.id === contextMenu.itemId)
40994214
: null;
41004215
const isVideoFile = selectedItem && 'type' in selectedItem && selectedItem.type === 'video';
41014216
const isComposition = selectedItem && 'type' in selectedItem && selectedItem.type === 'composition';
@@ -4153,6 +4268,24 @@ export function MediaPanel() {
41534268
<span className="context-menu-icon"><FileTypeIcon type="splat-effector" /></span>
41544269
3D Effector
41554270
</div>
4271+
<div className="context-menu-separator" />
4272+
<div className="context-menu-item" onClick={() => { handleNewMathScene(); closeContextMenu(); }}>
4273+
<span className="context-menu-icon"><FileTypeIcon type="math-scene" /></span>
4274+
Math Scene
4275+
</div>
4276+
<div className="context-menu-item has-submenu" onMouseEnter={handleSubmenuHover} onMouseLeave={handleSubmenuLeave}>
4277+
<span className="context-menu-icon"><FileTypeIcon type="motion-shape" /></span>
4278+
<span>Motion Shape</span>
4279+
<span className="submenu-arrow">&#9654;</span>
4280+
<div className="context-submenu">
4281+
<div className="context-menu-item" onClick={() => { handleNewMotionShape('rectangle'); closeContextMenu(); }}>
4282+
Rectangle
4283+
</div>
4284+
<div className="context-menu-item" onClick={() => { handleNewMotionShape('ellipse'); closeContextMenu(); }}>
4285+
Ellipse
4286+
</div>
4287+
</div>
4288+
</div>
41564289
<div className="context-menu-item has-submenu" onMouseEnter={handleSubmenuHover} onMouseLeave={handleSubmenuLeave}>
41574290
<span className="context-menu-icon"><FileTypeIcon type="mesh" /></span>
41584291
<span>Mesh</span>

src/components/panels/media/FileTypeIcon.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,22 @@ export const FileTypeIcon = memo(({ type, large }: FileTypeIconProps) => {
118118
<path d="M8 3.5V5.4M8 10.6V12.5M3.5 8H5.4M10.6 8H12.5M4.8 4.8L6.1 6.1M9.9 9.9L11.2 11.2M11.2 4.8L9.9 6.1M6.1 9.9L4.8 11.2" stroke="#a7f0c0" strokeWidth="0.8" strokeLinecap="round"/>
119119
</svg>
120120
);
121+
case 'math-scene':
122+
return (
123+
<svg style={style} viewBox="0 0 16 16" fill="none">
124+
<rect x="1" y="2" width="14" height="12" rx="1.5" fill="#3f5478" stroke="#79a8f2" strokeWidth="0.7"/>
125+
<path d="M3.5 10.5c1.2-4.1 2.1-5.9 3-5.9.8 0 1.1 1.5 1.8 3.3.5 1.4 1 2.5 1.8 2.5.7 0 1.4-1 2.4-3.4" stroke="#d8e8ff" strokeWidth="1" strokeLinecap="round"/>
126+
<path d="M3 8h10M8 4v8" stroke="#79a8f2" strokeWidth="0.5" opacity="0.6"/>
127+
</svg>
128+
);
129+
case 'motion-shape':
130+
return (
131+
<svg style={style} viewBox="0 0 16 16" fill="none">
132+
<rect x="1" y="2" width="14" height="12" rx="1.5" fill="#6a4b5b" stroke="#d28aae" strokeWidth="0.7"/>
133+
<rect x="4" y="5" width="5" height="5" rx="0.8" fill="#f0b1cb" opacity="0.9"/>
134+
<circle cx="10.6" cy="9.6" r="2.2" fill="#8bd6d0" opacity="0.88"/>
135+
</svg>
136+
);
121137
case 'gaussian-avatar':
122138
return (
123139
<svg style={style} viewBox="0 0 16 16" fill="none">
@@ -318,6 +334,25 @@ const LargeIcon = memo(({ type, style }: { type?: string; style: React.CSSProper
318334
<circle cx="24" cy="24" r="2.2" fill="#d6ffe4"/>
319335
</svg>
320336
);
337+
case 'math-scene':
338+
return (
339+
<svg style={style} viewBox="0 0 48 48" fill="none">
340+
<rect x="4" y="8" width="40" height="32" rx="3" fill="#31486c"/>
341+
<rect x="4" y="8" width="40" height="32" rx="3" stroke="#79a8f2" strokeWidth="1"/>
342+
<path d="M10 30c3.7-12 6.7-17.4 9.2-17.4 2.2 0 3.3 4.3 5.1 9.8 1.5 4.3 3.1 7.2 5.2 7.2 2.3 0 4.8-4.4 8.5-14.2" stroke="#d8e8ff" strokeWidth="2.2" strokeLinecap="round"/>
343+
<path d="M9 24h30M24 12v24" stroke="#79a8f2" strokeWidth="1" opacity="0.45"/>
344+
</svg>
345+
);
346+
case 'motion-shape':
347+
return (
348+
<svg style={style} viewBox="0 0 48 48" fill="none">
349+
<rect x="4" y="8" width="40" height="32" rx="3" fill="#59384c"/>
350+
<rect x="4" y="8" width="40" height="32" rx="3" stroke="#d28aae" strokeWidth="1"/>
351+
<rect x="12" y="15" width="14" height="14" rx="2" fill="#f0b1cb" opacity="0.9"/>
352+
<circle cx="31" cy="29" r="7" fill="#8bd6d0" opacity="0.9"/>
353+
<path d="M11 33c8-2 17-2 26 0" stroke="#f5d5e3" strokeWidth="1.4" opacity="0.55" strokeLinecap="round"/>
354+
</svg>
355+
);
321356
case 'gaussian-avatar':
322357
return (
323358
<svg style={style} viewBox="0 0 48 48" fill="none">

src/components/panels/media/board/layout.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,8 @@ export function getMediaBoardTypeLabel(item: MediaBoardItem): string {
9797
}
9898
if (item.type === 'splat-effector') return 'Effector';
9999
if (item.type === 'solid') return 'Solid';
100+
if (item.type === 'math-scene') return 'Math Scene';
101+
if (item.type === 'motion-shape') return 'Motion Shape';
100102
if (item.type === 'model') return 'Model';
101103
return item.type.charAt(0).toUpperCase() + item.type.slice(1);
102104
}
@@ -118,7 +120,7 @@ export function getMediaBoardItemAspectRatio(item: MediaBoardItem): number {
118120
return clampMediaBoardNumber(width / height, MEDIA_BOARD_NODE_ASPECT_MIN, MEDIA_BOARD_NODE_ASPECT_MAX);
119121
}
120122

121-
if (item.type === 'camera' || item.type === 'model' || item.type === 'splat-effector') {
123+
if (item.type === 'camera' || item.type === 'model' || item.type === 'splat-effector' || item.type === 'motion-shape') {
122124
return 1;
123125
}
124126

src/components/panels/media/board/overviewCanvas.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ function getMediaBoardOverviewFill(item: MediaBoardItem): string {
5353
if (item.type === 'composition') return 'rgba(100, 58, 138, 0.82)';
5454
if (item.type === 'text') return 'rgba(46, 59, 78, 0.92)';
5555
if (item.type === 'camera') return 'rgba(139, 108, 45, 0.86)';
56+
if (item.type === 'math-scene') return 'rgba(49, 72, 108, 0.9)';
57+
if (item.type === 'motion-shape') return 'rgba(89, 56, 76, 0.9)';
5658
if (item.type === 'image') return 'rgba(38, 64, 84, 0.88)';
5759
if (item.type === 'video') return 'rgba(45, 43, 64, 0.9)';
5860
if (item.type === 'audio') return 'rgba(52, 71, 55, 0.88)';

src/components/panels/media/itemTypeGuards.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,9 @@ export function isImportedMediaFileItem(item: ProjectItem): item is MediaFile {
1010
item.type === 'text' ||
1111
item.type === 'solid' ||
1212
item.type === 'camera' ||
13-
item.type === 'splat-effector'
13+
item.type === 'splat-effector' ||
14+
item.type === 'math-scene' ||
15+
item.type === 'motion-shape'
1416
) {
1517
return false;
1618
}

0 commit comments

Comments
 (0)