-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add layer groups system with linked transforms and temporal shifting #18
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -24,7 +24,11 @@ import type { | |
| RemoveLayerInput, | ||
| RemoveLayerOutput, | ||
| ConfigureProjectInput, | ||
| ConfigureProjectOutput | ||
| ConfigureProjectOutput, | ||
| GroupLayersInput, | ||
| GroupLayersOutput, | ||
| UngroupLayersInput, | ||
| UngroupLayersOutput | ||
| } from './schemas'; | ||
| import type { ProjectData } from '$lib/schemas/animation'; | ||
|
|
||
|
|
@@ -338,19 +342,185 @@ export function mutateRemoveLayer( | |
|
|
||
| const index = ctx.project.layers.findIndex((l) => l.id === resolvedId); | ||
| if (index === -1) { | ||
| // Already removed? | ||
| return { success: true, message: 'Layer already removed or not found' }; | ||
| } | ||
|
|
||
| const name = ctx.project.layers[index].name; | ||
| ctx.project.layers.splice(index, 1); | ||
| const layer = ctx.project.layers[index]; | ||
| const name = layer.name; | ||
|
|
||
| // If removing a group, also remove all children | ||
| if (layer.type === 'group') { | ||
| ctx.project.layers = ctx.project.layers.filter( | ||
| (l) => l.id !== resolvedId && l.parentId !== resolvedId | ||
| ); | ||
| } else { | ||
| ctx.project.layers.splice(index, 1); | ||
| } | ||
|
|
||
| return { | ||
| success: true, | ||
| message: `Removed layer "${name}"` | ||
| }; | ||
| } | ||
|
|
||
| // ============================================ | ||
| // Group Mutations | ||
| // ============================================ | ||
|
|
||
| export function mutateGroupLayers( | ||
| ctx: MutationContext, | ||
| input: GroupLayersInput | ||
| ): GroupLayersOutput { | ||
| try { | ||
| const resolvedIds: string[] = []; | ||
| for (const ref of input.layerIds) { | ||
| const id = resolveLayerId(ctx.project, ref, ctx.layerIdMap); | ||
| if (!id) { | ||
| return { | ||
| success: false, | ||
| message: layerNotFoundError(ctx.project, ref), | ||
| error: `Layer "${ref}" not found` | ||
| }; | ||
| } | ||
| resolvedIds.push(id); | ||
| } | ||
|
|
||
| if (resolvedIds.length < 2) { | ||
| return { | ||
| success: false, | ||
| message: 'Need at least 2 layers to create a group', | ||
| error: 'Insufficient layers' | ||
| }; | ||
| } | ||
|
|
||
| // Validate none are already in a group or are groups themselves | ||
| for (const id of resolvedIds) { | ||
| const layer = ctx.project.layers.find((l) => l.id === id); | ||
| if (layer?.parentId) { | ||
| return { | ||
| success: false, | ||
| message: `Layer "${layer.name}" is already in a group`, | ||
| error: 'Layer already grouped' | ||
| }; | ||
| } | ||
| if (layer?.type === 'group') { | ||
| return { | ||
| success: false, | ||
| message: `Cannot nest group "${layer.name}" inside another group`, | ||
| error: 'Cannot nest groups' | ||
| }; | ||
| } | ||
| } | ||
|
|
||
| const groupId = nanoid(); | ||
|
|
||
| // Create the group layer | ||
| const groupLayer = { | ||
| id: groupId, | ||
| name: input.name ?? 'Group', | ||
| type: 'group' as const, | ||
| transform: { | ||
| x: 0, | ||
| y: 0, | ||
| z: 0, | ||
| rotationX: 0, | ||
| rotationY: 0, | ||
| rotationZ: 0, | ||
| scaleX: 1, | ||
| scaleY: 1, | ||
| scaleZ: 1, | ||
| anchor: 'center' as const | ||
| }, | ||
| style: { opacity: 1 }, | ||
| visible: true, | ||
| locked: false, | ||
| keyframes: [], | ||
| props: { collapsed: false } | ||
| }; | ||
|
|
||
| // Find insertion point (earliest child position) | ||
| const childIdSet = new Set(resolvedIds); | ||
| const indices = ctx.project.layers | ||
| .map((l, i) => (childIdSet.has(l.id) ? i : -1)) | ||
| .filter((i) => i >= 0) | ||
| .sort((a, b) => a - b); | ||
| const insertIndex = indices[0]; | ||
|
|
||
| // Set parentId on children | ||
| for (const layer of ctx.project.layers) { | ||
| if (childIdSet.has(layer.id)) { | ||
| layer.parentId = groupId; | ||
| } | ||
| } | ||
|
|
||
| // Move children out, insert group + children at the right position | ||
| const childLayers = ctx.project.layers.filter((l) => childIdSet.has(l.id)); | ||
| const otherLayers = ctx.project.layers.filter((l) => !childIdSet.has(l.id)); | ||
| otherLayers.splice(insertIndex, 0, groupLayer, ...childLayers); | ||
| ctx.project.layers = otherLayers; | ||
|
|
||
| return { | ||
| success: true, | ||
| groupId, | ||
| message: `Created group "${groupLayer.name}" with ${resolvedIds.length} layers` | ||
| }; | ||
| } catch (err) { | ||
| return { | ||
| success: false, | ||
| message: 'Failed to create group', | ||
| error: err instanceof Error ? err.message : 'Unknown error' | ||
| }; | ||
| } | ||
| } | ||
|
Comment on lines
+370
to
+474
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: rg -n 'enterTime|exitTime' --type=ts --type=tsx --type=svelte -B 2 -A 2 | head -100Repository: epavanello/devmotion Length of output: 91 🏁 Script executed: rg -n 'type.*Layer|interface.*Layer' --type=ts --type=tsx | head -30Repository: epavanello/devmotion Length of output: 91 🏁 Script executed: rg -n 'group.*render|render.*group' --type=svelte -B 3 -A 3 | head -80Repository: epavanello/devmotion Length of output: 94 🏁 Script executed: rg -n 'parentId.*group\|group.*children' --type=ts --type=tsx -B 2 -A 2 | head -60Repository: epavanello/devmotion Length of output: 91 🏁 Script executed: rg 'enterTime|exitTime' -B 2 -A 2 | head -120Repository: epavanello/devmotion Length of output: 6955 🏁 Script executed: rg 'type Layer|interface Layer' -B 1 -A 15 | head -100Repository: epavanello/devmotion Length of output: 6024 🏁 Script executed: find . -name '*.svelte' -type f | xargs grep -l 'group' | head -5Repository: epavanello/devmotion Length of output: 375 🏁 Script executed: rg 'mutateGroupLayers' -B 3 -A 3Repository: epavanello/devmotion Length of output: 2178 🏁 Script executed: rg 'group.*bar\|bar.*group\|renderGroup\|TimelineTrack' -B 2 -A 5 | head -100Repository: epavanello/devmotion Length of output: 46 🏁 Script executed: rg 'createLayer' -B 2 -A 10 | grep -A 10 'export\|function createLayer' | head -60Repository: epavanello/devmotion Length of output: 784 🏁 Script executed: rg 'type.*"group"\|type === .*group' src/lib/ai/mutations.ts -B 3 -A 10 | head -80Repository: epavanello/devmotion Length of output: 46 🏁 Script executed: find . -name '*timeline*' -o -name '*time-track*' | head -10Repository: epavanello/devmotion Length of output: 395 🏁 Script executed: cat src/lib/components/editor/timeline/timeline-layer.svelte | head -150Repository: epavanello/devmotion Length of output: 4809 🏁 Script executed: rg 'enterTime|exitTime' src/lib/components/editor/timeline/ -B 2 -A 2Repository: epavanello/devmotion Length of output: 4204 🏁 Script executed: cat src/lib/engine/layer-factory.ts | head -100Repository: epavanello/devmotion Length of output: 2376
The group layer created at lines 418–438 doesn't include The insertion-at-earliest-child-position logic is correct: removing children before reinsertion prevents index drift. 🤖 Prompt for AI Agents |
||
|
|
||
| export function mutateUngroupLayers( | ||
| ctx: MutationContext, | ||
| input: UngroupLayersInput | ||
| ): UngroupLayersOutput { | ||
| const resolvedId = resolveLayerId(ctx.project, input.groupId, ctx.layerIdMap); | ||
| if (!resolvedId) { | ||
| const errMsg = layerNotFoundError(ctx.project, input.groupId); | ||
| return { success: false, message: errMsg, error: errMsg }; | ||
| } | ||
|
|
||
| const group = ctx.project.layers.find((l) => l.id === resolvedId); | ||
| if (!group || group.type !== 'group') { | ||
| return { | ||
| success: false, | ||
| message: `Layer "${input.groupId}" is not a group`, | ||
| error: 'Not a group layer' | ||
| }; | ||
| } | ||
|
|
||
| const gt = group.transform; | ||
| const gs = group.style; | ||
|
|
||
| // Bake group transform into children and remove parentId | ||
| for (const layer of ctx.project.layers) { | ||
| if (layer.parentId === resolvedId) { | ||
| layer.parentId = undefined; | ||
| layer.transform.x += gt.x; | ||
| layer.transform.y += gt.y; | ||
| layer.transform.z += gt.z; | ||
| layer.transform.rotationX += gt.rotationX; | ||
| layer.transform.rotationY += gt.rotationY; | ||
| layer.transform.rotationZ += gt.rotationZ; | ||
| layer.transform.scaleX *= gt.scaleX; | ||
| layer.transform.scaleY *= gt.scaleY; | ||
| layer.transform.scaleZ *= gt.scaleZ; | ||
| layer.style.opacity *= gs.opacity; | ||
| } | ||
|
Comment on lines
+498
to
+512
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Transform baking on ungroup is a simplification that won't preserve world positions with non-trivial group transforms. The current approach adds translations and rotations, and multiplies scales. This is only correct when the group transform is purely translational (or the identity). If the group has rotation, child positions are in the group's rotated coordinate space, and simply adding The same simplification exists in This could be addressed later by composing transformation matrices, but flagging it as a known limitation. 🤖 Prompt for AI Agents |
||
| } | ||
|
|
||
| // Remove the group layer | ||
| ctx.project.layers = ctx.project.layers.filter((l) => l.id !== resolvedId); | ||
|
|
||
| return { | ||
| success: true, | ||
| message: `Ungrouped "${group.name}"` | ||
| }; | ||
| } | ||
|
|
||
| export function mutateConfigureProject( | ||
| ctx: MutationContext, | ||
| input: ConfigureProjectInput | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Group removal in mutations doesn't clean up child layers' uploaded files.
Unlike
ProjectStore.removeLayer(inproject.svelte.ts, lines 214–242) which recursively removes children and issuesDELETErequests for anyfileKey-bearing layers, this mutation path silently drops child layers without file cleanup. IfmutateRemoveLayeris invoked via the AI/MCP pathway for a group that contains video/image/audio layers with uploaded files, those files will become orphaned.This may be acceptable if file cleanup is only expected through the UI path, but worth tracking.
🤖 Prompt for AI Agents