Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 29 additions & 1 deletion src/lib/ai/ai-operations.svelte.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import type {
RemoveLayerInput,
RemoveLayerOutput,
ConfigureProjectInput,
ConfigureProjectOutput
ConfigureProjectOutput,
GroupLayersInput,
GroupLayersOutput,
UngroupLayersInput,
UngroupLayersOutput
} from './schemas';
import { SvelteMap } from 'svelte/reactivity';
import {
Expand All @@ -24,6 +28,8 @@ import {
mutateEditLayer,
mutateRemoveLayer,
mutateConfigureProject,
mutateGroupLayers,
mutateUngroupLayers,
type MutationContext
} from './mutations';

Expand Down Expand Up @@ -119,3 +125,25 @@ export function executeRemoveLayer(input: RemoveLayerInput): RemoveLayerOutput {
export function executeConfigureProject(input: ConfigureProjectInput): ConfigureProjectOutput {
return mutateConfigureProject(getContext(), input);
}

/**
* Execute group_layers tool
*/
export function executeGroupLayers(input: GroupLayersInput): GroupLayersOutput {
const result = mutateGroupLayers(getContext(), input);
if (result.success && result.groupId) {
projectStore.selectedLayerId = result.groupId;
}
return result;
}

/**
* Execute ungroup_layers tool
*/
export function executeUngroupLayers(input: UngroupLayersInput): UngroupLayersOutput {
const result = mutateUngroupLayers(getContext(), input);
if (result.success) {
projectStore.selectedLayerId = null;
}
return result;
}
178 changes: 174 additions & 4 deletions src/lib/ai/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,11 @@ import type {
RemoveLayerInput,
RemoveLayerOutput,
ConfigureProjectInput,
ConfigureProjectOutput
ConfigureProjectOutput,
GroupLayersInput,
GroupLayersOutput,
UngroupLayersInput,
UngroupLayersOutput
} from './schemas';
import type { ProjectData } from '$lib/schemas/animation';

Expand Down Expand Up @@ -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);
}
Comment on lines +348 to +358
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Group removal in mutations doesn't clean up child layers' uploaded files.

Unlike ProjectStore.removeLayer (in project.svelte.ts, lines 214–242) which recursively removes children and issues DELETE requests for any fileKey-bearing layers, this mutation path silently drops child layers without file cleanup. If mutateRemoveLayer is 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
In `@src/lib/ai/mutations.ts` around lines 348 - 358, mutateRemoveLayer's
group-removal branch currently drops child layers without deleting their
uploaded files; update the group branch in mutateRemoveLayer (where it inspects
ctx.project.layers and layer.type === 'group') to mirror
ProjectStore.removeLayer's behavior: recursively collect child layers, for each
child layer that has a fileKey issue the same DELETE file request (or call the
same file-cleanup helper used by ProjectStore.removeLayer), then remove those
children from ctx.project.layers before splicing/removing the parent; reference
ctx.project.layers, mutateRemoveLayer and fileKey to locate where to add the
cleanup calls.


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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n 'enterTime|exitTime' --type=ts --type=tsx --type=svelte -B 2 -A 2 | head -100

Repository: epavanello/devmotion

Length of output: 91


🏁 Script executed:

rg -n 'type.*Layer|interface.*Layer' --type=ts --type=tsx | head -30

Repository: epavanello/devmotion

Length of output: 91


🏁 Script executed:

rg -n 'group.*render|render.*group' --type=svelte -B 3 -A 3 | head -80

Repository: epavanello/devmotion

Length of output: 94


🏁 Script executed:

rg -n 'parentId.*group\|group.*children' --type=ts --type=tsx -B 2 -A 2 | head -60

Repository: epavanello/devmotion

Length of output: 91


🏁 Script executed:

rg 'enterTime|exitTime' -B 2 -A 2 | head -120

Repository: epavanello/devmotion

Length of output: 6955


🏁 Script executed:

rg 'type Layer|interface Layer' -B 1 -A 15 | head -100

Repository: epavanello/devmotion

Length of output: 6024


🏁 Script executed:

find . -name '*.svelte' -type f | xargs grep -l 'group' | head -5

Repository: epavanello/devmotion

Length of output: 375


🏁 Script executed:

rg 'mutateGroupLayers' -B 3 -A 3

Repository: epavanello/devmotion

Length of output: 2178


🏁 Script executed:

rg 'group.*bar\|bar.*group\|renderGroup\|TimelineTrack' -B 2 -A 5 | head -100

Repository: epavanello/devmotion

Length of output: 46


🏁 Script executed:

rg 'createLayer' -B 2 -A 10 | grep -A 10 'export\|function createLayer' | head -60

Repository: epavanello/devmotion

Length of output: 784


🏁 Script executed:

rg 'type.*"group"\|type === .*group' src/lib/ai/mutations.ts -B 3 -A 10 | head -80

Repository: epavanello/devmotion

Length of output: 46


🏁 Script executed:

find . -name '*timeline*' -o -name '*time-track*' | head -10

Repository: epavanello/devmotion

Length of output: 395


🏁 Script executed:

cat src/lib/components/editor/timeline/timeline-layer.svelte | head -150

Repository: epavanello/devmotion

Length of output: 4809


🏁 Script executed:

rg 'enterTime|exitTime' src/lib/components/editor/timeline/ -B 2 -A 2

Repository: epavanello/devmotion

Length of output: 4204


🏁 Script executed:

cat src/lib/engine/layer-factory.ts | head -100

Repository: epavanello/devmotion

Length of output: 2376


mutateGroupLayers — group layer missing enterTime/exitTime will span full timeline by default.

The group layer created at lines 418–438 doesn't include enterTime or exitTime, so the timeline will render it with the default fallback values (0 and project.duration), making it span the entire timeline. This appears intentional based on the optional schema design, but if the group's time range should instead reflect the union of its children's ranges, that computation is missing.

The insertion-at-earliest-child-position logic is correct: removing children before reinsertion prevents index drift.

🤖 Prompt for AI Agents
In `@src/lib/ai/mutations.ts` around lines 370 - 474, mutateGroupLayers creates a
group layer without enterTime/exitTime so it spans the full timeline; instead
compute the group's time range from its children's ranges: after computing
childLayers (the variable childLayers in mutateGroupLayers) calculate enterTime
= min(child.enterTime ?? 0) and exitTime = max(child.exitTime ??
ctx.project.duration) (or use 0/project.duration as sensible defaults), then
include these enterTime/exitTime properties on the groupLayer object before
inserting it into ctx.project.layers so the group's timebounds reflect the union
of its children.


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
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

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 gt.x/gt.y to the child's translation won't yield the correct world position — you'd need to apply the group's rotation matrix to the child's local offset first.

The same simplification exists in ProjectStore.ungroupLayers (project.svelte.ts, lines 335–361), so at least the behavior is consistent across code paths. However, users who rotate a group and then ungroup will see children jump to incorrect positions.

This could be addressed later by composing transformation matrices, but flagging it as a known limitation.

🤖 Prompt for AI Agents
In `@src/lib/ai/mutations.ts` around lines 498 - 512, The current loop that bakes
group transform into children (iterating ctx.project.layers and checking
layer.parentId === resolvedId) naively adds gt.x/gt.y/gt.z and multiplies
scales/rotations, which breaks when group rotations/skews are present; replace
this by composing full transforms: build the group's transform matrix from gt
and gs, build each child's local transform matrix from layer.transform, multiply
groupMatrix * childLocalMatrix to get the child's world matrix, then decompose
that world matrix back into
layer.transform.{x,y,z,rotationX,rotationY,rotationZ,scaleX,scaleY,scaleZ} and
set layer.parentId = undefined (keep opacity multiplication for style), and
apply the same matrix-based fix in ProjectStore.ungroupLayers
(project.svelte.ts) to preserve world positions for rotated/scaled groups.

}

// 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
Expand Down
54 changes: 54 additions & 0 deletions src/lib/ai/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,8 @@ function generateLayerCreationTools(): Record<string, Tool> {
const tools: Record<string, Tool> = {};

for (const layerType of getAvailableLayerTypes()) {
// Skip group type - groups are created via group_layers tool
if (layerType === 'group') continue;
const definition = layerRegistry[layerType];
if (!definition) continue;

Expand Down Expand Up @@ -305,6 +307,43 @@ export interface ConfigureProjectOutput {
error?: string;
}

// ============================================
// Tool: group_layers
// ============================================

export const GroupLayersInputSchema = z.object({
layerIds: z
.array(z.string())
.min(2)
.describe('Array of layer IDs or references to group together (minimum 2)'),
name: z.string().optional().describe('Name for the group (default: "Group")')
});

export type GroupLayersInput = z.infer<typeof GroupLayersInputSchema>;

export interface GroupLayersOutput {
success: boolean;
groupId?: string;
message: string;
error?: string;
}

// ============================================
// Tool: ungroup_layers
// ============================================

export const UngroupLayersInputSchema = z.object({
groupId: z.string().describe('Group layer ID or reference to dissolve')
});

export type UngroupLayersInput = z.infer<typeof UngroupLayersInputSchema>;

export interface UngroupLayersOutput {
success: boolean;
message: string;
error?: string;
}

// ============================================
// Tool Definitions for AI SDK
// ============================================
Expand Down Expand Up @@ -335,6 +374,21 @@ export const animationTools = {
inputSchema: RemoveLayerInputSchema
}),

group_layers: tool({
description:
'Group multiple layers together so they share a common transform. ' +
'Moving/rotating/scaling the group affects all children. ' +
'Use layer_N for layers you just created, or actual ID/name for existing layers.',
inputSchema: GroupLayersInputSchema
}),

ungroup_layers: tool({
description:
'Dissolve a group, making its children top-level layers again. ' +
'Group transforms are baked into children to preserve their world position.',
inputSchema: UngroupLayersInputSchema
}),

configure_project: tool({
description:
'Set project dimensions, duration, and background color. ' +
Expand Down
10 changes: 10 additions & 0 deletions src/lib/components/ai/ai-chat.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,17 @@
executeEditLayer,
executeRemoveLayer,
executeConfigureProject,
executeGroupLayers,
executeUngroupLayers,
resetLayerTracking
} from '$lib/ai/ai-operations.svelte';
import {
type AnimateLayerInput,
type EditLayerInput,
type RemoveLayerInput,
type ConfigureProjectInput,
type GroupLayersInput,
type UngroupLayersInput,
type AnimationUITools,
isLayerCreationTool,
getLayerTypeFromToolName,
Expand Down Expand Up @@ -90,6 +94,12 @@
case 'remove_layer':
result = executeRemoveLayer(toolCall.input as RemoveLayerInput);
break;
case 'group_layers':
result = executeGroupLayers(toolCall.input as GroupLayersInput);
break;
case 'ungroup_layers':
result = executeUngroupLayers(toolCall.input as UngroupLayersInput);
break;
case 'configure_project':
result = executeConfigureProject(toolCall.input as ConfigureProjectInput);
break;
Expand Down
18 changes: 13 additions & 5 deletions src/lib/components/editor/VideoRecorder.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,9 @@
<!-- Recording indicator overlay -->
<div class="absolute top-2 left-2 flex items-center gap-2 rounded bg-black/50 px-2 py-1">
<div class="h-2 w-2 animate-pulse rounded-full bg-destructive"></div>
<span class="text-xs font-medium text-white">Recording {recordingMode === 'screen' ? 'Screen' : 'Camera'}</span>
<span class="text-xs font-medium text-white"
>Recording {recordingMode === 'screen' ? 'Screen' : 'Camera'}</span
>
</div>

<!-- Duration overlay -->
Expand Down Expand Up @@ -291,16 +293,22 @@
<div class="flex gap-1 rounded border bg-muted/30 p-1">
<button
type="button"
class="flex flex-1 items-center justify-center gap-1 rounded px-2 py-1.5 text-xs transition-colors {recordingMode === 'camera' ? 'bg-background shadow-sm' : 'hover:bg-background/50'}"
onclick={() => recordingMode = 'camera'}
class="flex flex-1 items-center justify-center gap-1 rounded px-2 py-1.5 text-xs transition-colors {recordingMode ===
'camera'
? 'bg-background shadow-sm'
: 'hover:bg-background/50'}"
onclick={() => (recordingMode = 'camera')}
>
<Camera class="size-3" />
Camera
</button>
<button
type="button"
class="flex flex-1 items-center justify-center gap-1 rounded px-2 py-1.5 text-xs transition-colors {recordingMode === 'screen' ? 'bg-background shadow-sm' : 'hover:bg-background/50'}"
onclick={() => recordingMode = 'screen'}
class="flex flex-1 items-center justify-center gap-1 rounded px-2 py-1.5 text-xs transition-colors {recordingMode ===
'screen'
? 'bg-background shadow-sm'
: 'hover:bg-background/50'}"
onclick={() => (recordingMode = 'screen')}
>
<Monitor class="size-3" />
Screen
Expand Down
Loading