From 5f780221d2b970020fdba73b2f648001aa7fd091 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 20:25:43 +0000 Subject: [PATCH 1/2] feat: add layer groups system with linked transforms and temporal shifting Introduces a complete layer groups system that allows grouping 2+ layers into a parent container with shared transforms. Groups support: - Nested CSS transform context (children move/rotate/scale with parent) - Drag & drop layers into groups in the layers panel - Group/ungroup operations preserving world-space positions - Timeline hierarchy with indented child rows and group time bar - Temporal shifting of all children when moving the group bar - Group visibility, locking, and opacity inheritance - AI tool support (group_layers, ungroup_layers) for web + MCP - Automatic child cleanup on group deletion https://claude.ai/code/session_016GG1bhyZuuk1V8zDE3fjLF --- src/lib/ai/ai-operations.svelte.ts | 30 +- src/lib/ai/mutations.ts | 178 +++++++++++- src/lib/ai/schemas.ts | 54 ++++ src/lib/components/ai/ai-chat.svelte | 10 + .../editor/canvas/layers-renderer.svelte | 117 ++++++-- .../editor/panels/layers-panel.svelte | 266 ++++++++++++++++- .../editor/timeline/timeline-layer.svelte | 22 +- .../editor/timeline/timeline.svelte | 29 +- src/lib/layers/components/GroupLayer.svelte | 26 ++ src/lib/layers/typed-registry.ts | 2 + src/lib/schemas/base.ts | 3 +- src/lib/stores/project.svelte.ts | 274 ++++++++++++++++++ src/routes/mcp/+server.ts | 8 + 13 files changed, 963 insertions(+), 56 deletions(-) create mode 100644 src/lib/layers/components/GroupLayer.svelte diff --git a/src/lib/ai/ai-operations.svelte.ts b/src/lib/ai/ai-operations.svelte.ts index 7a33663..f01f455 100644 --- a/src/lib/ai/ai-operations.svelte.ts +++ b/src/lib/ai/ai-operations.svelte.ts @@ -15,7 +15,11 @@ import type { RemoveLayerInput, RemoveLayerOutput, ConfigureProjectInput, - ConfigureProjectOutput + ConfigureProjectOutput, + GroupLayersInput, + GroupLayersOutput, + UngroupLayersInput, + UngroupLayersOutput } from './schemas'; import { SvelteMap } from 'svelte/reactivity'; import { @@ -24,6 +28,8 @@ import { mutateEditLayer, mutateRemoveLayer, mutateConfigureProject, + mutateGroupLayers, + mutateUngroupLayers, type MutationContext } from './mutations'; @@ -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; +} diff --git a/src/lib/ai/mutations.ts b/src/lib/ai/mutations.ts index f934a43..5c86f1e 100644 --- a/src/lib/ai/mutations.ts +++ b/src/lib/ai/mutations.ts @@ -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,12 +342,20 @@ 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, @@ -351,6 +363,164 @@ export function mutateRemoveLayer( }; } +// ============================================ +// 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' + }; + } +} + +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; + } + } + + // 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 diff --git a/src/lib/ai/schemas.ts b/src/lib/ai/schemas.ts index 5eb79ab..86a3a93 100644 --- a/src/lib/ai/schemas.ts +++ b/src/lib/ai/schemas.ts @@ -138,6 +138,8 @@ function generateLayerCreationTools(): Record { const tools: Record = {}; 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; @@ -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; + +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; + +export interface UngroupLayersOutput { + success: boolean; + message: string; + error?: string; +} + // ============================================ // Tool Definitions for AI SDK // ============================================ @@ -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. ' + diff --git a/src/lib/components/ai/ai-chat.svelte b/src/lib/components/ai/ai-chat.svelte index ef62d65..c4044f7 100644 --- a/src/lib/components/ai/ai-chat.svelte +++ b/src/lib/components/ai/ai-chat.svelte @@ -13,6 +13,8 @@ executeEditLayer, executeRemoveLayer, executeConfigureProject, + executeGroupLayers, + executeUngroupLayers, resetLayerTracking } from '$lib/ai/ai-operations.svelte'; import { @@ -20,6 +22,8 @@ type EditLayerInput, type RemoveLayerInput, type ConfigureProjectInput, + type GroupLayersInput, + type UngroupLayersInput, type AnimationUITools, isLayerCreationTool, getLayerTypeFromToolName, @@ -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; diff --git a/src/lib/components/editor/canvas/layers-renderer.svelte b/src/lib/components/editor/canvas/layers-renderer.svelte index ca6bf5e..67377a4 100644 --- a/src/lib/components/editor/canvas/layers-renderer.svelte +++ b/src/lib/components/editor/canvas/layers-renderer.svelte @@ -2,6 +2,7 @@ import LayerWrapper from '$lib/layers/LayerWrapper.svelte'; import { getLayerComponent } from '$lib/layers/registry'; import { getLayerTransform, getLayerStyle, getLayerProps } from '$lib/engine/layer-rendering'; + import { generateTransformCSS } from '$lib/layers/base'; import type { TypedLayer } from '$lib/layers/typed-registry'; import type { FrameCache } from '$lib/stores/project.svelte'; @@ -44,37 +45,101 @@ customProps: getLayerProps(layer, currentTime) }; } + + /** Top-level layers only (children are rendered inside their group) */ + const topLevelLayers = $derived(layers.filter((l) => !l.parentId)); + + /** Get child layers for a given group */ + function getChildLayers(groupId: string): TypedLayer[] { + return layers.filter((l) => l.parentId === groupId); + } - -{#each layers as layer (layer.id)} + +{#each topLevelLayers as layer (layer.id)} {@const enterTime = layer.enterTime ?? 0} {@const exitTime = layer.exitTime ?? duration} {@const isInTimeRange = currentTime >= enterTime && currentTime <= exitTime} - - {@const mustKeepWarm = layer.type === 'video' || layer.type === 'image' || layer.type === 'audio'} - {#if isInTimeRange || mustKeepWarm} - {@const { transform, style, customProps } = getLayerRenderData(layer)} - {@const component = getLayerComponent(layer.type)} - {@const isSelected = selectedLayerId === layer.id} - {@const enhancedProps = { - ...customProps, - layer, - currentTime, - isPlaying, - isServerSideRendering - }} - + {#if layer.type === 'group'} + + {@const { transform: groupTransform, style: groupStyle } = getLayerRenderData(layer)} + {@const groupVisible = layer.visible && isInTimeRange} + {@const groupTransformCSS = generateTransformCSS(groupTransform)} + +
+ {#each getChildLayers(layer.id) as child (child.id)} + {@const childEnter = child.enterTime ?? 0} + {@const childExit = child.exitTime ?? duration} + {@const childInRange = currentTime >= childEnter && currentTime <= childExit} + {@const mustKeepWarm = + child.type === 'video' || child.type === 'image' || child.type === 'audio'} + {#if childInRange || mustKeepWarm} + {@const { transform, style, customProps } = getLayerRenderData(child)} + {@const component = getLayerComponent(child.type)} + {@const isSelected = selectedLayerId === child.id} + {@const enhancedProps = { + ...customProps, + layer: child, + currentTime, + isPlaying, + isServerSideRendering + }} + + + {/if} + {/each} +
+ {:else} + + {@const mustKeepWarm = + layer.type === 'video' || layer.type === 'image' || layer.type === 'audio'} + {#if isInTimeRange || mustKeepWarm} + {@const { transform, style, customProps } = getLayerRenderData(layer)} + {@const component = getLayerComponent(layer.type)} + {@const isSelected = selectedLayerId === layer.id} + {@const enhancedProps = { + ...customProps, + layer, + currentTime, + isPlaying, + isServerSideRendering + }} + + + {/if} {/if} {/each} + + diff --git a/src/lib/components/editor/panels/layers-panel.svelte b/src/lib/components/editor/panels/layers-panel.svelte index 36185ba..bae0b63 100644 --- a/src/lib/components/editor/panels/layers-panel.svelte +++ b/src/lib/components/editor/panels/layers-panel.svelte @@ -1,13 +1,25 @@
- {#each projectStore.project.layers as layer, index (layer.id)} + +
+ {#if selectedIsGroup} + + {/if} +
+ + {#each layerTree as { layer, children, index } (layer.id)} {@const Icon = getLayerDefinition(layer.type).icon} + {@const isGroup = layer.type === 'group'} + {@const isCollapsed = collapsedGroups.has(layer.id)} + {@const isDropTarget = dropTargetId === layer.id} + +
selectLayer(layer.id)} onkeydown={(e) => handleKeyDown(e, layer.id)} draggable="true" - ondragstart={(e) => handleDragStart(e, index)} - ondragover={handleDragOver} - ondrop={(e) => handleDrop(e, index)} + ondragstart={(e) => handleDragStart(e, layer.id, index)} + ondragover={(e) => handleDragOver(e, layer.id)} + ondragleave={handleDragLeave} + ondrop={(e) => handleDrop(e, index, layer.id)} role="button" tabindex="0" > - - + + {#if isGroup} + + {/if} + + +
{layer.name} + {#if isGroup} + ({children.length}) + {/if}
@@ -132,9 +243,10 @@
-

Delete Layer

+

Delete {isGroup ? 'Group' : 'Layer'}

- Delete "{layer.name}"? This cannot be undone. + Delete "{layer.name}"{isGroup ? ' and all its children' : ''}? This cannot be + undone.

@@ -160,6 +272,130 @@
+ + + {#if isGroup && !isCollapsed} + {#each children as child (child.id)} + {@const ChildIcon = getLayerDefinition(child.type).icon} + {@const childIndex = projectStore.project.layers.indexOf(child)} + +
selectLayer(child.id)} + onkeydown={(e) => handleKeyDown(e, child.id)} + draggable="true" + ondragstart={(e) => handleDragStart(e, child.id, childIndex)} + ondragover={(e) => handleDragOver(e, child.id)} + ondragleave={handleDragLeave} + ondrop={(e) => handleDrop(e, childIndex, child.id)} + role="button" + tabindex="0" + > + + +
+ {child.name} +
+ + +
+ + + + + (deletePopoverOpenLayerId = open ? child.id : null)} + > + + {#snippet child({ props })} + + {/snippet} + + +
+

Remove from Group

+

+ Remove "{child.name}" from group, or delete it entirely? +

+
+ + {#snippet child({ props })} + + {/snippet} + + + {#snippet child({ props })} + + {/snippet} + + + {#snippet child({ props })} + + {/snippet} + +
+
+
+
+
+
+ {/each} + {/if} {/each} {#if projectStore.project.layers.length === 0} diff --git a/src/lib/components/editor/timeline/timeline-layer.svelte b/src/lib/components/editor/timeline/timeline-layer.svelte index a5b39f9..15904b5 100644 --- a/src/lib/components/editor/timeline/timeline-layer.svelte +++ b/src/lib/components/editor/timeline/timeline-layer.svelte @@ -8,9 +8,10 @@ interface Props { layer: TypedLayer; pixelsPerSecond: number; + indent?: number; } - let { layer, pixelsPerSecond }: Props = $props(); + let { layer, pixelsPerSecond, indent = 0 }: Props = $props(); const isSelected = $derived(projectStore.selectedLayerId === layer.id); @@ -138,6 +139,8 @@ window.removeEventListener('mouseup', handleDragEnd); }); + const isGroupLayer = $derived(layer.type === 'group'); + // Color for the duration bar based on layer type const barColor = $derived.by(() => { switch (layer.type) { @@ -145,6 +148,8 @@ return 'bg-purple-500/30 border-purple-500/50'; case 'audio': return 'bg-blue-500/30 border-blue-500/50'; + case 'group': + return 'bg-emerald-500/25 border-emerald-500/40'; default: return 'bg-primary/15 border-primary/30'; } @@ -153,8 +158,12 @@ const barLabel = $derived.by(() => { if (layer.type === 'video') return 'Video'; if (layer.type === 'audio') return 'Audio'; + if (layer.type === 'group') return 'Group'; return ''; }); + + // Always show the bar for groups so users can drag the group range + const showBar = $derived(hasTimeRange || isMediaLayer || isGroupLayer);
-
+
{layer.name} - {#if hasTimeRange || isMediaLayer} + {#if hasTimeRange || isMediaLayer || isGroupLayer} {enterTime.toFixed(1)}s – {exitTime.toFixed(1)}s @@ -179,8 +191,8 @@
- - {#if hasTimeRange || isMediaLayer} + + {#if showBar}
(); - projectStore.project.layers.forEach((layer, index) => { + // Build visible row order (top-level + children of groups) + const visibleRows: string[] = []; + for (const layer of projectStore.project.layers) { + if (!layer.parentId) { + visibleRows.push(layer.id); + if (layer.type === 'group') { + for (const child of projectStore.project.layers) { + if (child.parentId === layer.id) { + visibleRows.push(child.id); + } + } + } + } + } + + visibleRows.forEach((layerId, index) => { const layerTop = layerOffset + index * layerHeight; const layerBottom = layerTop + layerHeight; - // Check if layer overlaps with selection box vertically if (y < layerBottom && y + height > layerTop) { - activeLayerIds.add(layer.id); + activeLayerIds.add(layerId); } }); @@ -167,8 +181,15 @@
- {#each projectStore.project.layers as layer (layer.id)} + {#each projectStore.project.layers.filter((l) => !l.parentId) as layer (layer.id)} + + + {#if layer.type === 'group'} + {#each projectStore.project.layers.filter((l) => l.parentId === layer.id) as child (child.id)} + + {/each} + {/if} {/each} {#if projectStore.project.layers.length === 0} diff --git a/src/lib/layers/components/GroupLayer.svelte b/src/lib/layers/components/GroupLayer.svelte new file mode 100644 index 0000000..bfb378e --- /dev/null +++ b/src/lib/layers/components/GroupLayer.svelte @@ -0,0 +1,26 @@ + + + + + +
diff --git a/src/lib/layers/typed-registry.ts b/src/lib/layers/typed-registry.ts index 30a9270..1983211 100644 --- a/src/lib/layers/typed-registry.ts +++ b/src/lib/layers/typed-registry.ts @@ -8,6 +8,7 @@ import { meta as browserMeta } from './components/BrowserLayer.svelte'; import { meta as buttonMeta } from './components/ButtonLayer.svelte'; import { meta as captionsMeta } from './components/CaptionsLayer.svelte'; import { meta as codeMeta } from './components/CodeLayer.svelte'; +import { meta as groupMeta } from './components/GroupLayer.svelte'; import { meta as htmlMeta } from './components/HtmlLayer.svelte'; import { meta as iconMeta } from './components/IconLayer.svelte'; import { meta as imageMeta } from './components/ImageLayer.svelte'; @@ -30,6 +31,7 @@ export type LayerPropsMap = { button: z.infer; captions: z.infer; code: z.infer; + group: z.infer; html: z.infer; icon: z.infer; image: z.infer; diff --git a/src/lib/schemas/base.ts b/src/lib/schemas/base.ts index bd06855..2390402 100644 --- a/src/lib/schemas/base.ts +++ b/src/lib/schemas/base.ts @@ -76,7 +76,8 @@ export const BaseLayerFieldsSchema = z.object({ id: z.string().describe('Unique layer identifier'), name: z.string().describe('Layer name for identification'), visible: z.boolean().describe('Layer visibility state'), - locked: z.boolean().describe('Layer locked state (prevents editing)') + locked: z.boolean().describe('Layer locked state (prevents editing)'), + parentId: z.string().optional().describe('Parent group layer ID') }); export type BaseLayerFields = z.infer; diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index d4d8337..ef5b56e 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -171,6 +171,41 @@ class ProjectStore { this.newProject(); } + // ======================================== + // Group Helpers + // ======================================== + + /** + * Get child layers of a group (layers whose parentId matches) + */ + getChildLayers(groupId: string): TypedLayer[] { + return this.project.layers.filter((l) => l.parentId === groupId); + } + + /** + * Get top-level layers (layers without a parentId) + */ + getTopLevelLayers(): TypedLayer[] { + return this.project.layers.filter((l) => !l.parentId); + } + + /** + * Get the parent group of a layer, if any + */ + getParentGroup(layerId: string): TypedLayer | null { + const layer = this.project.layers.find((l) => l.id === layerId); + if (!layer?.parentId) return null; + return this.project.layers.find((l) => l.id === layer.parentId) ?? null; + } + + /** + * Check if a layer is a group + */ + isGroupLayer(layerId: string): boolean { + const layer = this.project.layers.find((l) => l.id === layerId); + return layer?.type === 'group'; + } + // Layer operations addLayer(layer: TypedLayer) { this.project.layers = [...this.project.layers, layer]; @@ -180,6 +215,14 @@ class ProjectStore { // Find the layer to check if it has uploaded files to clean up const layer = this.project.layers.find((l) => l.id === layerId); + // If removing a group, also remove all children + if (layer?.type === 'group') { + const childIds = this.getChildLayers(layerId).map((c) => c.id); + for (const childId of childIds) { + await this.removeLayer(childId); + } + } + // Clean up uploaded files if the layer has a fileKey if (layer && layer.props.fileKey && typeof layer.props.fileKey === 'string') { try { @@ -211,6 +254,226 @@ class ProjectStore { this.project.layers = layers; } + // ======================================== + // Group Operations + // ======================================== + + /** + * Create a group from the given layer IDs. + * The group is inserted at the position of the first selected layer. + * Children are re-parented under the group and their order is preserved. + */ + createGroup(childIds: string[]): string | null { + if (childIds.length < 2) return null; + + // Validate all layers exist and are not already in a group + const children = childIds + .map((id) => this.project.layers.find((l) => l.id === id)) + .filter((l): l is TypedLayer => !!l && !l.parentId && l.type !== 'group'); + if (children.length < 2) return null; + + const groupId = nanoid(); + + // Find earliest position among children for insertion + const indices = children.map((c) => this.project.layers.indexOf(c)).sort((a, b) => a - b); + const insertIndex = indices[0]; + + // Create the group layer + const groupLayer: TypedLayer = { + id: groupId, + name: 'Group', + type: 'group', + transform: { + x: 0, + y: 0, + z: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + scaleX: 1, + scaleY: 1, + scaleZ: 1, + anchor: 'center' + }, + style: { opacity: 1 }, + visible: true, + locked: false, + keyframes: [], + props: { collapsed: false } + }; + + // Set parentId on children + const childIdSet = new Set(childIds); + let layers = this.project.layers.map((l) => + childIdSet.has(l.id) ? { ...l, parentId: groupId } : l + ); + + // Remove children from their current positions and collect them + const childLayers = layers.filter((l) => childIdSet.has(l.id)); + layers = layers.filter((l) => !childIdSet.has(l.id)); + + // Insert group at the earliest child position, then children right after + layers.splice(insertIndex, 0, groupLayer, ...childLayers); + + this.project.layers = layers; + this.selectedLayerId = groupId; + return groupId; + } + + /** + * Dissolve a group, keeping children as top-level layers. + * Applies group transform offsets to children so they maintain their world position. + */ + ungroupLayers(groupId: string) { + const group = this.project.layers.find((l) => l.id === groupId); + if (!group || group.type !== 'group') return; + + const gt = group.transform; + const gs = group.style; + + // Remove parentId from children and bake group transform into them + this.project.layers = this.project.layers + .map((layer) => { + if (layer.parentId === groupId) { + return { + ...layer, + parentId: undefined, + transform: { + ...layer.transform, + x: layer.transform.x + gt.x, + y: layer.transform.y + gt.y, + z: layer.transform.z + gt.z, + rotationX: layer.transform.rotationX + gt.rotationX, + rotationY: layer.transform.rotationY + gt.rotationY, + rotationZ: layer.transform.rotationZ + gt.rotationZ, + scaleX: layer.transform.scaleX * gt.scaleX, + scaleY: layer.transform.scaleY * gt.scaleY, + scaleZ: layer.transform.scaleZ * gt.scaleZ + }, + style: { + ...layer.style, + opacity: layer.style.opacity * gs.opacity + } + }; + } + return layer; + }) + .filter((l) => l.id !== groupId); + + if (this.selectedLayerId === groupId) { + this.selectedLayerId = null; + } + } + + /** + * Add an existing layer to a group + */ + addLayerToGroup(layerId: string, groupId: string) { + const layer = this.project.layers.find((l) => l.id === layerId); + const group = this.project.layers.find((l) => l.id === groupId); + if (!layer || !group || group.type !== 'group') return; + if (layer.parentId === groupId) return; // already in this group + if (layer.type === 'group') return; // don't nest groups + + // Set parentId and move layer in array to be right after group's children + const layers = this.project.layers.map((l) => + l.id === layerId ? { ...l, parentId: groupId } : l + ); + + const layerIndex = layers.findIndex((l) => l.id === layerId); + const movedLayer = layers.splice(layerIndex, 1)[0]; + + // Find the last child of the group (or the group itself) + const groupIndex = layers.findIndex((l) => l.id === groupId); + let insertAfter = groupIndex; + for (let i = groupIndex + 1; i < layers.length; i++) { + if (layers[i].parentId === groupId) { + insertAfter = i; + } else { + break; + } + } + + layers.splice(insertAfter + 1, 0, movedLayer); + this.project.layers = layers; + } + + /** + * Remove a layer from its parent group (keeps the layer in the project). + * Applies the group transform so the layer maintains its world position. + * If the group would have fewer than 2 children, the group is dissolved. + */ + removeLayerFromGroup(layerId: string) { + const layer = this.project.layers.find((l) => l.id === layerId); + if (!layer?.parentId) return; + + const groupId = layer.parentId; + const group = this.project.layers.find((l) => l.id === groupId); + + const gt = group?.transform; + const gs = group?.style; + + this.project.layers = this.project.layers.map((l) => { + if (l.id === layerId) { + const updated: TypedLayer = { ...l, parentId: undefined }; + if (gt) { + updated.transform = { + ...l.transform, + x: l.transform.x + gt.x, + y: l.transform.y + gt.y, + z: l.transform.z + gt.z + }; + } + if (gs) { + updated.style = { + ...l.style, + opacity: l.style.opacity * gs.opacity + }; + } + return updated; + } + return l; + }); + + // If group has fewer than 2 children, dissolve it + const remaining = this.project.layers.filter((l) => l.parentId === groupId); + if (remaining.length < 2 && group) { + this.ungroupLayers(groupId); + } + } + + /** + * Shift the time range of all children in a group by a delta. + * Used when moving a group bar in the timeline. + */ + shiftGroupChildrenTime(groupId: string, deltaTime: number) { + this.project.layers = this.project.layers.map((layer) => { + if (layer.parentId !== groupId) return layer; + + const oldEnter = layer.enterTime ?? 0; + const oldExit = layer.exitTime ?? this.project.duration; + const newEnter = Math.max(0, Math.min(oldEnter + deltaTime, this.project.duration)); + const newExit = Math.max( + newEnter + 0.1, + Math.min(oldExit + deltaTime, this.project.duration) + ); + + const shiftedKeyframes = layer.keyframes + .map((kf) => ({ + ...kf, + time: Math.max(0, Math.min(this.project.duration, kf.time + deltaTime)) + })) + .sort((a, b) => a.time - b.time); + + return { + ...layer, + enterTime: newEnter, + exitTime: newExit, + keyframes: shiftedKeyframes + }; + }); + } + // Keyframe operations addKeyframe(layerId: string, keyframe: Keyframe) { this.project.layers = this.project.layers.map((layer) => { @@ -547,6 +810,17 @@ class ProjectStore { * @param shiftKeyframes If true, shift all keyframes by the time delta (default: false) */ setLayerTimeRange(layerId: string, enterTime: number, exitTime: number, shiftKeyframes = false) { + // For group layers, also shift all children by the same time delta + const targetLayer = this.project.layers.find((l) => l.id === layerId); + if (targetLayer?.type === 'group') { + const oldGroupEnter = targetLayer.enterTime ?? 0; + const clampedEnter = Math.max(0, Math.min(enterTime, this.project.duration)); + const delta = clampedEnter - oldGroupEnter; + if (delta !== 0) { + this.shiftGroupChildrenTime(layerId, delta); + } + } + this.project.layers = this.project.layers.map((layer) => { if (layer.id !== layerId) return layer; diff --git a/src/routes/mcp/+server.ts b/src/routes/mcp/+server.ts index 94960f2..7bfcb5a 100644 --- a/src/routes/mcp/+server.ts +++ b/src/routes/mcp/+server.ts @@ -31,6 +31,8 @@ import { mutateEditLayer, mutateRemoveLayer, mutateConfigureProject, + mutateGroupLayers, + mutateUngroupLayers, type MutationContext } from '$lib/ai/mutations'; import { PUBLIC_BASE_URL } from '$env/static/public'; @@ -219,6 +221,12 @@ const handler = createMcpHandler( case 'remove_layer': result = mutateRemoveLayer(ctx, toolInput as any); break; + case 'group_layers': + result = mutateGroupLayers(ctx, toolInput as any); + break; + case 'ungroup_layers': + result = mutateUngroupLayers(ctx, toolInput as any); + break; case 'configure_project': result = mutateConfigureProject(ctx, toolInput as any); break; From 19d1acceec35d65eb8ef2e235d907a161569da2d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Feb 2026 20:26:40 +0000 Subject: [PATCH 2/2] style: apply prettier formatting to VideoRecorder https://claude.ai/code/session_016GG1bhyZuuk1V8zDE3fjLF --- src/lib/components/editor/VideoRecorder.svelte | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/src/lib/components/editor/VideoRecorder.svelte b/src/lib/components/editor/VideoRecorder.svelte index a1d2c8f..ced4739 100644 --- a/src/lib/components/editor/VideoRecorder.svelte +++ b/src/lib/components/editor/VideoRecorder.svelte @@ -245,7 +245,9 @@
- Recording {recordingMode === 'screen' ? 'Screen' : 'Camera'} + Recording {recordingMode === 'screen' ? 'Screen' : 'Camera'}
@@ -291,16 +293,22 @@