From 418efe864861f674de4169da8523b995274d892e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 17:12:23 +0000 Subject: [PATCH 1/6] Promote project settings to a special layer at the top of the layers list Replace the project settings modal with a virtual "project-settings" layer that sits fixed at the top of the layers panel. When selected, the properties panel shows project-level fields (dimensions, duration, background, font) using the same dynamic property system as regular layers, but without transform, style, time range, keyframe, or animation preset sections. Key changes: - New ProjectSettingsLayer.svelte component registered in the layer system - New project-layer.ts with constants, virtual layer synthesis, and helpers - ProjectStore auto-selects the project layer on init and loadProject - Layers panel renders project layer as a fixed, non-draggable, non-deletable item - Properties panel routes project layer property updates to updateProject() - Toolbar and project-settings-dialog modal references removed https://claude.ai/code/session_01REigfXtKvZKtz2JF8sF5HL --- .../components/editor/panels/add-layer.svelte | 6 +- .../editor/panels/layers-panel.svelte | 25 + .../editor/panels/properties-panel.svelte | 753 ++++++++++-------- src/lib/components/editor/toolbar.svelte | 19 - .../components/ProjectSettingsLayer.svelte | 69 ++ src/lib/layers/project-layer.ts | 70 ++ src/lib/layers/typed-registry.ts | 2 + src/lib/stores/project.svelte.ts | 15 +- 8 files changed, 588 insertions(+), 371 deletions(-) create mode 100644 src/lib/layers/components/ProjectSettingsLayer.svelte create mode 100644 src/lib/layers/project-layer.ts diff --git a/src/lib/components/editor/panels/add-layer.svelte b/src/lib/components/editor/panels/add-layer.svelte index 3581ed6..3f402af 100644 --- a/src/lib/components/editor/panels/add-layer.svelte +++ b/src/lib/components/editor/panels/add-layer.svelte @@ -26,7 +26,11 @@ projectStore.selectedLayerId = layer.id; } - const hiddenTypes = new Set>(['captions', 'group']); + const hiddenTypes = new Set>([ + 'captions', + 'group', + 'project-settings' + ]); const categoryOrder: LayerCategory[] = ['media', 'text', 'shape', 'code', 'ui']; diff --git a/src/lib/components/editor/panels/layers-panel.svelte b/src/lib/components/editor/panels/layers-panel.svelte index e3ffce2..1b1fbba 100644 --- a/src/lib/components/editor/panels/layers-panel.svelte +++ b/src/lib/components/editor/panels/layers-panel.svelte @@ -7,6 +7,11 @@ import { cn } from '$lib/utils'; import type { TypedLayer } from '$lib/layers/typed-registry'; import { SvelteSet } from 'svelte/reactivity'; + import { + PROJECT_LAYER_ID, + PROJECT_LAYER_TYPE, + isProjectLayer + } from '$lib/layers/project-layer'; const editorState = $derived(getEditorState()); const projectStore = $derived(editorState.project); @@ -196,6 +201,26 @@ ontouchmove={handleTouchMove} ontouchend={handleTouchEnd} > + + {@const ProjectIcon = getLayerDefinition(PROJECT_LAYER_TYPE).icon} +
selectLayer(PROJECT_LAYER_ID)} + onkeydown={(e) => handleKeyDown(e, PROJECT_LAYER_ID)} + role="button" + tabindex="0" + > + +
+ {projectStore.state.name} +
+
+ {#each layerTree as { layer, children, index } (layer.id)} {@const Icon = getLayerDefinition(layer.type).icon} {@const isGroup = layer.type === 'group'} diff --git a/src/lib/components/editor/panels/properties-panel.svelte b/src/lib/components/editor/panels/properties-panel.svelte index 7fe38c3..7dae571 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -50,11 +50,18 @@ import type { TypedLayer } from '$lib/layers/typed-registry'; import { Select } from '$lib/components/ui/select'; import { scaleMiddleware } from '$lib/schemas/size'; + import { + isProjectLayer, + mapProjectLayerPropsToProject + } from '$lib/layers/project-layer'; const editorState = $derived(getEditorState()); const projectStore = $derived(editorState.project); const selectedLayer = $derived(projectStore.selectedLayer); + /** Whether the currently selected layer is the virtual project settings layer */ + const isProjectSettings = $derived(isProjectLayer(projectStore.selectedLayerId)); + // Extract property metadata from the layer's Zod schema const layerPropertyMetadata = $derived.by(() => { if (!selectedLayer) return []; @@ -108,6 +115,24 @@ const currentValues = $derived.by(() => { if (!selectedLayer) return null; + // For the project settings layer, there are no keyframes, so just use static props + if (isProjectSettings) { + return { + transform: { + x: 0, + y: 0, + z: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + scaleX: 1, + scaleY: 1 + }, + style: { opacity: 1 }, + props: { ...selectedLayer.props } + }; + } + const animatedTransform = getAnimatedTransform( selectedLayer.keyframes, projectStore.currentTime @@ -166,6 +191,11 @@ value: TypedLayer[K] ) { if (!selectedLayer) return; + // For the project settings layer, route name change to updateProject + if (isProjectSettings) { + projectStore.updateProject({ [property]: value }); + return; + } projectStore.updateLayer(selectedLayer.id, { [property]: value }); } @@ -266,7 +296,8 @@ /** * Unified property update function. * Handles transform, style, and props targets. - * For props: runs middleware if defined (e.g., aspect ratio linking). + * For the project settings layer, routes props updates to projectStore.updateProject(). + * For regular layers, handles keyframe logic and middleware. */ function updateProperty( propertyName: string, @@ -275,6 +306,13 @@ ) { if (!selectedLayer) return; + // For the project settings layer, route props directly to updateProject + if (isProjectSettings && target === 'props') { + const projectUpdates = mapProjectLayerPropsToProject({ [propertyName]: value }); + projectStore.updateProject(projectUpdates); + return; + } + if (target === 'props' && layerDefinition?.middleware && currentValues) { // Run middleware for props - may return multiple updates const updates = layerDefinition.middleware(propertyName, value, { @@ -386,7 +424,7 @@
- +
- - - - - {#snippet label()} -
- - {#if selectedLayer.contentDuration !== undefined} - {@const contentDuration = selectedLayer.contentDuration} - {@const contentOffset = selectedLayer.contentOffset ?? 0} - {@const availableContent = contentDuration - contentOffset} - - Max: {availableContent.toFixed(1)}s - - {/if} -
- {/snippet} -
-
- - projectStore.setLayerEnterTime(selectedLayer.id, v)} - /> -
-
- - projectStore.setLayerExitTime(selectedLayer.id, v)} - /> -
-
- - - - {#if selectedLayer.type === 'video' || selectedLayer.type === 'audio'} - {@const contentDuration = selectedLayer.contentDuration ?? 0} - {@const contentOffset = selectedLayer.contentOffset ?? 0} - {@const hasDuration = contentDuration > 0} -
+ {#if !isProjectSettings} + + + + + {#snippet label()}
- - {#if hasDuration} + + {#if selectedLayer.contentDuration !== undefined} + {@const contentDuration = selectedLayer.contentDuration} + {@const contentOffset = selectedLayer.contentOffset ?? 0} + {@const availableContent = contentDuration - contentOffset} - Duration: {contentDuration.toFixed(1)}s + Max: {availableContent.toFixed(1)}s {/if}
+ {/snippet} +
- + { - const clamped = hasDuration ? Math.min(v, contentDuration - 0.1) : Math.max(0, v); - projectStore.updateLayer(selectedLayer.id, { contentOffset: clamped }); - - // Auto-adjust exitTime if it would exceed available content - if (hasDuration && selectedLayer.exitTime !== undefined) { - const enterTime = selectedLayer.enterTime ?? 0; - const maxVisibleDuration = contentDuration - clamped; - const maxExitTime = enterTime + maxVisibleDuration; - if (selectedLayer.exitTime > maxExitTime) { - projectStore.setLayerExitTime(selectedLayer.id, maxExitTime); - } - } - }} + onchange={(v) => projectStore.setLayerEnterTime(selectedLayer.id, v)} + /> +
+
+ + projectStore.setLayerExitTime(selectedLayer.id, v)} /> -

- Where to start playing in the source media -

- {#if !hasDuration && selectedLayer.props.src} -

- Upload a new file to detect duration -

- {/if} -
- {/if} -
- - - - - - - - ({ - id: f.prop, - labels: f.label, - property: f.prop, - addKeyframe: addKeyframe, - hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) - }))} - > - {#snippet prefix()} - - updateProperty('x', v, 'transform')} - onchangeY={(v: number) => updateProperty('y', v, 'transform')} - onchangeZ={(v: number) => updateProperty('z', v, 'transform')} + + + + {#if selectedLayer.type === 'video' || selectedLayer.type === 'audio'} + {@const contentDuration = selectedLayer.contentDuration ?? 0} + {@const contentOffset = selectedLayer.contentOffset ?? 0} + {@const hasDuration = contentDuration > 0} +
+
+ + {#if hasDuration} + + Duration: {contentDuration.toFixed(1)}s + + {/if} +
+
+ + { + const clamped = hasDuration + ? Math.min(v, contentDuration - 0.1) + : Math.max(0, v); + projectStore.updateLayer(selectedLayer.id, { contentOffset: clamped }); + + // Auto-adjust exitTime if it would exceed available content + if (hasDuration && selectedLayer.exitTime !== undefined) { + const enterTime = selectedLayer.enterTime ?? 0; + const maxVisibleDuration = contentDuration - clamped; + const maxExitTime = enterTime + maxVisibleDuration; + if (selectedLayer.exitTime > maxExitTime) { + projectStore.setLayerExitTime(selectedLayer.id, maxExitTime); + } + } + }} + /> +

+ Where to start playing in the source media +

+
+ {#if !hasDuration && selectedLayer.props.src} +

+ Upload a new file to detect duration +

+ {/if} + +
+ {/if} +
+ + + + + + + + ({ + id: f.prop, + labels: f.label, + property: f.prop, + addKeyframe: addKeyframe, + hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) + }))} + > + {#snippet prefix()} + + updateProperty('x', v, 'transform')} + onchangeY={(v: number) => updateProperty('y', v, 'transform')} + onchangeZ={(v: number) => updateProperty('z', v, 'transform')} + /> + {/snippet} + + updateProperty('x', v, 'transform')} /> - {/snippet} - updateProperty('x', v, 'transform')} - /> - - updateProperty('y', v, 'transform')} - /> - updateProperty('z', v, 'transform')} - /> - - - - ({ - id: f.prop, - labels: f.label, - property: f.prop, - addKeyframe: addKeyframe, - hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) - }))} - > - {#snippet prefix()} - - updateProperty('rotationY', v, 'transform')} - onchangeY={(v: number) => updateProperty('rotationX', v, 'transform')} - onchangeZ={(v: number) => updateProperty('rotationZ', v, 'transform')} + updateProperty('y', v, 'transform')} /> - {/snippet} - updateProperty('rotationX', v, 'transform')} - /> - updateProperty('rotationY', v, 'transform')} - /> - updateProperty('rotationZ', v, 'transform')} - /> - - - - {@const scaleLocked = selectedLayer?.props._scaleLocked ?? false} - {@const currentScaleX = currentValues?.transform.scaleX ?? 1} - {@const currentScaleY = currentValues?.transform.scaleY ?? 1} - ({ - id: f.prop, - labels: f.label, - property: f.prop, - addKeyframe: addKeyframe, - hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) - }))} - > - {#snippet prefix()} - - updateProperty('scaleX', v || 1, 'transform')} - onchangeY={(v: number) => updateProperty('scaleY', v || 1, 'transform')} - onchangeZ={() => {}} + updateProperty('z', v, 'transform')} /> - - {/snippet} - updateProperty('scaleX', v || 1, 'transform')} - /> - updateProperty('scaleY', v || 1, 'transform')} - /> - - - - - - - {#snippet child({ props })} - - {/snippet} - - -
- {#each anchorOptions as option (option.value)} - {@const isSelected = selectedLayer?.transform.anchor === option.value} - {@const Icon = option.icon} - updateAnchor(option.value)}> - {#snippet child({ props })} -
-
-
-
-
- - - - - - - updateProperty('opacity', v || 0, 'style')} - /> - - + + + + ({ + id: f.prop, + labels: f.label, + property: f.prop, + addKeyframe: addKeyframe, + hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) + }))} + > + {#snippet prefix()} + + updateProperty('rotationY', v, 'transform')} + onchangeY={(v: number) => updateProperty('rotationX', v, 'transform')} + onchangeZ={(v: number) => updateProperty('rotationZ', v, 'transform')} + /> + {/snippet} + updateProperty('rotationX', v, 'transform')} + /> + updateProperty('rotationY', v, 'transform')} + /> + updateProperty('rotationZ', v, 'transform')} + /> + + + + {@const scaleLocked = selectedLayer?.props._scaleLocked ?? false} + {@const currentScaleX = currentValues?.transform.scaleX ?? 1} + {@const currentScaleY = currentValues?.transform.scaleY ?? 1} + ({ + id: f.prop, + labels: f.label, + property: f.prop, + addKeyframe: addKeyframe, + hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) + }))} + > + {#snippet prefix()} + + updateProperty('scaleX', v || 1, 'transform')} + onchangeY={(v: number) => updateProperty('scaleY', v || 1, 'transform')} + onchangeZ={() => {}} + /> + + {/snippet} + updateProperty('scaleX', v || 1, 'transform')} + /> + updateProperty('scaleY', v || 1, 'transform')} + /> + + + + + + + {#snippet child({ props })} + + {/snippet} + + +
+ {#each anchorOptions as option (option.value)} + {@const isSelected = selectedLayer?.transform.anchor === option.value} + {@const Icon = option.icon} + updateAnchor(option.value)}> + {#snippet child({ props })} +
+
+
+
+ + + + + + + + updateProperty('opacity', v || 0, 'style')} + /> + + + {/if} {#if propertyLayout.items.length > 0} - + {#each propertyLayout.items as item (item.kind === 'group' ? `group:${item.group.id}` : `field:${item.field.name}`)} {#if item.kind === 'group'} ({ id: `props.${field.name}`, labels: field.description || field.name, - property: `props.${field.name}` as AnimatableProperty, - addKeyframe, - hasKeyframes: selectedLayer.keyframes.some( - (k) => k.property === `props.${field.name}` - ) + property: isProjectSettings + ? undefined + : (`props.${field.name}` as AnimatableProperty), + addKeyframe: isProjectSettings ? undefined : addKeyframe, + hasKeyframes: isProjectSettings + ? false + : selectedLayer.keyframes.some( + (k) => k.property === `props.${field.name}` + ) }))} > {#snippet prefix()} @@ -755,8 +801,8 @@ {/if} - - - - - - ({ label: preset.name, value: preset.id }))} + trigger={{ class: 'w-full' }} + /> + (presetDuration = v)} + /> + + + + + + + + + + + + {/if}
{:else}
diff --git a/src/lib/components/editor/toolbar.svelte b/src/lib/components/editor/toolbar.svelte index c0eeb12..09467bc 100644 --- a/src/lib/components/editor/toolbar.svelte +++ b/src/lib/components/editor/toolbar.svelte @@ -4,7 +4,6 @@ import { Download, Save, - Settings, User, LogOut, Keyboard, @@ -21,7 +20,6 @@ const editorState = $derived(getEditorState()); import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; - import ProjectSettingsDialog from './project-settings-dialog.svelte'; import { uiStore } from '$lib/stores/ui.svelte'; import { themeStore } from '$lib/stores/theme.svelte'; import { getUser, signOut } from '$lib/functions/auth.remote'; @@ -67,7 +65,6 @@ let headerOpen = $state(false); let showExportDialog = $state(false); - let showProjectSettings = $state(false); const user = getUser(); @@ -112,10 +109,6 @@ showExportDialog = true; } - function openProjectSettings() { - showProjectSettings = true; - } - async function handleLogin() { await authClient.signIn.social({ provider: 'google', @@ -171,16 +164,6 @@ disabled: isRecording, visible: true }, - { - id: 'settings', - label: 'Settings', - tooltip: 'Project Settings (Dimensions, Duration, etc.)', - icon: Settings, - variant: 'ghost', - onclick: openProjectSettings, - disabled: isRecording || !canEdit, - visible: true - }, { id: 'save', label: 'Save Cloud', @@ -422,5 +405,3 @@ bind:isRecording {projectId} /> - - diff --git a/src/lib/layers/components/ProjectSettingsLayer.svelte b/src/lib/layers/components/ProjectSettingsLayer.svelte new file mode 100644 index 0000000..f2c2c39 --- /dev/null +++ b/src/lib/layers/components/ProjectSettingsLayer.svelte @@ -0,0 +1,69 @@ + + + + + +
diff --git a/src/lib/layers/project-layer.ts b/src/lib/layers/project-layer.ts new file mode 100644 index 0000000..08eebd4 --- /dev/null +++ b/src/lib/layers/project-layer.ts @@ -0,0 +1,70 @@ +/** + * Constants and helpers for the virtual project settings layer. + * + * The project layer is a special "virtual" layer that does not exist in the + * project's `layers[]` array. Instead it is synthesized from top-level project + * fields (name, width, height, duration, background, fontFamily) and displayed + * in the layers panel and properties panel like any other layer. + */ +import type { TypedLayer } from './typed-registry'; +import type { Project } from '$lib/schemas/animation'; + +/** Well-known layer ID for the project settings layer */ +export const PROJECT_LAYER_ID = '__project__'; + +/** Layer type string used in the registry */ +export const PROJECT_LAYER_TYPE = 'project-settings'; + +/** Check whether a layer ID refers to the project settings layer */ +export function isProjectLayer(layerId: string | null | undefined): boolean { + return layerId === PROJECT_LAYER_ID; +} + +/** + * Synthesize a virtual layer object from the current project state. + * This allows the properties panel to treat it like any other layer. + */ +export function createVirtualProjectLayer(project: Project): TypedLayer { + return { + id: PROJECT_LAYER_ID, + name: project.name, + type: PROJECT_LAYER_TYPE, + transform: { + x: 0, + y: 0, + z: 0, + rotationX: 0, + rotationY: 0, + rotationZ: 0, + scaleX: 1, + scaleY: 1, + anchor: 'center' + }, + style: { opacity: 1 }, + visible: true, + locked: false, + keyframes: [], + props: { + width: project.width, + height: project.height, + duration: project.duration, + background: project.background, + fontFamily: project.fontFamily + } + }; +} + +/** + * Map props from the virtual project layer back to Project-level fields. + */ +export function mapProjectLayerPropsToProject( + props: Record +): Partial { + const updates: Partial = {}; + if ('width' in props) updates.width = props.width as number; + if ('height' in props) updates.height = props.height as number; + if ('duration' in props) updates.duration = props.duration as number; + if ('background' in props) updates.background = props.background as Project['background']; + if ('fontFamily' in props) updates.fontFamily = props.fontFamily as Project['fontFamily']; + return updates; +} diff --git a/src/lib/layers/typed-registry.ts b/src/lib/layers/typed-registry.ts index 1983211..fddbd65 100644 --- a/src/lib/layers/typed-registry.ts +++ b/src/lib/layers/typed-registry.ts @@ -18,6 +18,7 @@ import { meta as shapeMeta } from './components/ShapeLayer.svelte'; import { meta as terminalMeta } from './components/TerminalLayer.svelte'; import { meta as textMeta } from './components/TextLayer.svelte'; import { meta as videoMeta } from './components/VideoLayer.svelte'; +import { meta as projectSettingsMeta } from './components/ProjectSettingsLayer.svelte'; import type { z } from 'zod'; @@ -37,6 +38,7 @@ export type LayerPropsMap = { image: z.infer; mouse: z.infer; phone: z.infer; + 'project-settings': z.infer; shape: z.infer; terminal: z.infer; text: z.infer; diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index aa702a0..6a2719e 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -9,6 +9,11 @@ import { SvelteSet } from 'svelte/reactivity'; import { getLayerTransform, getLayerStyle, getLayerProps } from '$lib/engine/layer-rendering'; import { tick } from 'svelte'; import type { TypedLayer } from '$lib/layers/typed-registry'; +import { + PROJECT_LAYER_ID, + isProjectLayer, + createVirtualProjectLayer +} from '$lib/layers/project-layer'; /** * Cached layer data for a single frame @@ -57,6 +62,8 @@ export class ProjectStore { layers: [], fontFamily: 'Inter' }; + // Auto-select the project settings layer on creation + this.selectedLayerId = PROJECT_LAYER_ID; } viewport = $state({ @@ -107,6 +114,9 @@ export class ProjectStore { } async removeLayer(layerId: string) { + // Never allow removing the virtual project settings layer + if (isProjectLayer(layerId)) return; + // Find the layer to check if it has uploaded files to clean up const layer = this.state.layers.find((l) => l.id === layerId); @@ -564,7 +574,7 @@ export class ProjectStore { this.isLoading = true; this.#state = project; await tick(); - this.selectedLayerId = null; + this.selectedLayerId = PROJECT_LAYER_ID; this.isPlaying = false; this.isLoading = false; } @@ -584,6 +594,9 @@ export class ProjectStore { get selectedLayer(): TypedLayer | null { if (!this.selectedLayerId) return null; + if (isProjectLayer(this.selectedLayerId)) { + return createVirtualProjectLayer(this.state); + } return this.state.layers.find((l) => l.id === this.selectedLayerId) || null; } From 312a3a32c82bc2768cd46ada6b8467615e9e1458 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 15 Feb 2026 18:11:27 +0000 Subject: [PATCH 2/6] Improve empty state UX in layers panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Clarify that the empty state refers to animation layers, not the project settings layer which is always visible. Add a visual separator and update the message to be more specific and helpful. Changes: - Message: "No layers yet" → "No animation layers yet" - Action: "Add layers from the toolbar" → "Click the + button above to add your first layer" - Added border-top separator to visually distinguish from project settings layer https://claude.ai/code/session_01REigfXtKvZKtz2JF8sF5HL --- src/lib/components/editor/panels/layers-panel.svelte | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/lib/components/editor/panels/layers-panel.svelte b/src/lib/components/editor/panels/layers-panel.svelte index 1b1fbba..16b8db5 100644 --- a/src/lib/components/editor/panels/layers-panel.svelte +++ b/src/lib/components/editor/panels/layers-panel.svelte @@ -485,9 +485,11 @@ {/each} {#if projectStore.state.layers.length === 0} -
-

No layers yet

-

Add layers from the toolbar

+
+
+

No animation layers yet

+

Click the + button above to add your first layer

+
{/if}
From a854e83d4c6783cdba65b913bebc22c37414f7f7 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Sun, 15 Feb 2026 23:38:07 +0100 Subject: [PATCH 3/6] fix --- .../editor/panels/layers-panel.svelte | 19 ++++++++----------- .../editor/panels/properties-panel.svelte | 9 ++------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/lib/components/editor/panels/layers-panel.svelte b/src/lib/components/editor/panels/layers-panel.svelte index 16b8db5..23a96d4 100644 --- a/src/lib/components/editor/panels/layers-panel.svelte +++ b/src/lib/components/editor/panels/layers-panel.svelte @@ -7,11 +7,7 @@ import { cn } from '$lib/utils'; import type { TypedLayer } from '$lib/layers/typed-registry'; import { SvelteSet } from 'svelte/reactivity'; - import { - PROJECT_LAYER_ID, - PROJECT_LAYER_TYPE, - isProjectLayer - } from '$lib/layers/project-layer'; + import { PROJECT_LAYER_ID, PROJECT_LAYER_TYPE, isProjectLayer } from '$lib/layers/project-layer'; const editorState = $derived(getEditorState()); const projectStore = $derived(editorState.project); @@ -192,6 +188,8 @@ } return topLevel; }); + + const ProjectIcon = $derived(getLayerDefinition(PROJECT_LAYER_TYPE).icon);
- {@const ProjectIcon = getLayerDefinition(PROJECT_LAYER_TYPE).icon}
+
+ {#each layerTree as { layer, children, index } (layer.id)} {@const Icon = getLayerDefinition(layer.type).icon} {@const isGroup = layer.type === 'group'} @@ -485,11 +484,9 @@ {/each} {#if projectStore.state.layers.length === 0} -
-
-

No animation layers yet

-

Click the + button above to add your first layer

-
+
+

No animation layers yet

+

Click the + button above to add your first layer

{/if}
diff --git a/src/lib/components/editor/panels/properties-panel.svelte b/src/lib/components/editor/panels/properties-panel.svelte index 7dae571..879f2ca 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -50,10 +50,7 @@ import type { TypedLayer } from '$lib/layers/typed-registry'; import { Select } from '$lib/components/ui/select'; import { scaleMiddleware } from '$lib/schemas/size'; - import { - isProjectLayer, - mapProjectLayerPropsToProject - } from '$lib/layers/project-layer'; + import { isProjectLayer, mapProjectLayerPropsToProject } from '$lib/layers/project-layer'; const editorState = $derived(getEditorState()); const projectStore = $derived(editorState.project); @@ -767,9 +764,7 @@ addKeyframe: isProjectSettings ? undefined : addKeyframe, hasKeyframes: isProjectSettings ? false - : selectedLayer.keyframes.some( - (k) => k.property === `props.${field.name}` - ) + : selectedLayer.keyframes.some((k) => k.property === `props.${field.name}`) }))} > {#snippet prefix()} From dcf24eb1939f6be8dc7feaa11e4d7f0a980237b3 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Mon, 16 Feb 2026 01:16:22 +0100 Subject: [PATCH 4/6] fix --- src/lib/ai/schemas.ts | 12 ----------- .../components/editor/export-dialog.svelte | 3 +-- .../components/ProjectSettingsLayer.svelte | 19 +++-------------- src/lib/layers/project-layer.ts | 21 ++++++++++++++----- 4 files changed, 20 insertions(+), 35 deletions(-) diff --git a/src/lib/ai/schemas.ts b/src/lib/ai/schemas.ts index 18884c1..c71e83a 100644 --- a/src/lib/ai/schemas.ts +++ b/src/lib/ai/schemas.ts @@ -50,18 +50,6 @@ function extractKeyProps(schema: z.ZodObject): string[] { return Object.keys(schema.shape).slice(0, MAX_KEY_PROPS); } -// ============================================ -// Shared Schemas for Layer Creation -// ============================================ - -const PositionSchema = z - .object({ - x: z.number().default(0).describe('X position (0 = center)'), - y: z.number().default(0).describe('Y position (0 = center)') - }) - .optional() - .describe('Position on canvas. IMPORTANT: always specify to avoid stacking at center'); - const AnimationSchema = z .object({ preset: z.string().describe('Animation preset: ' + getPresetIds().join(', ')), diff --git a/src/lib/components/editor/export-dialog.svelte b/src/lib/components/editor/export-dialog.svelte index df7f466..36bd2c6 100644 --- a/src/lib/components/editor/export-dialog.svelte +++ b/src/lib/components/editor/export-dialog.svelte @@ -45,7 +45,6 @@ let serverExportAbortController = $state(null); let exportSettings = $derived({ - format: 'webm', fps: projectStore.state.fps, width: projectStore.state.width, height: projectStore.state.height @@ -419,7 +418,7 @@
diff --git a/src/lib/layers/components/ProjectSettingsLayer.svelte b/src/lib/layers/components/ProjectSettingsLayer.svelte index f2c2c39..7426a5a 100644 --- a/src/lib/layers/components/ProjectSettingsLayer.svelte +++ b/src/lib/layers/components/ProjectSettingsLayer.svelte @@ -6,26 +6,13 @@ import { BackgroundValueSchema } from '$lib/schemas/background'; import { googleFontValues } from '$lib/utils/fonts'; import FontProperty from '../properties/FontProperty.svelte'; + import { createSizeWithAspectRatioSchema } from '$lib/schemas/size'; /** * Schema for Project Settings layer custom properties. * These map directly to project-level fields (width, height, duration, background, fontFamily). */ - const schema = z.object({ - width: z - .number() - .min(100) - .max(8192) - .default(720) - .describe('Width (px)') - .register(fieldRegistry, { group: 'resolution', interpolationFamily: 'continuous' }), - height: z - .number() - .min(100) - .max(8192) - .default(1280) - .describe('Height (px)') - .register(fieldRegistry, { group: 'resolution', interpolationFamily: 'continuous' }), + const schema = createSizeWithAspectRatioSchema(720, 1280).extend({ duration: z .number() .min(1) @@ -55,7 +42,7 @@ label: 'Project', icon: Settings, description: 'Project-level settings: resolution, duration, background, and default font', - propertyGroups: [{ id: 'resolution', label: 'Resolution' }] + propertyGroups: [{ id: 'size', label: 'Size' }] } as const satisfies LayerMeta; diff --git a/src/lib/layers/project-layer.ts b/src/lib/layers/project-layer.ts index 5c39763..afb6d58 100644 --- a/src/lib/layers/project-layer.ts +++ b/src/lib/layers/project-layer.ts @@ -65,10 +65,21 @@ export function createVirtualProjectLayer(project: Project): TypedLayer { */ export function mapProjectLayerPropsToProject(props: Record): Partial { const updates: Partial = {}; - if ('width' in props) updates.width = props.width as number; - if ('height' in props) updates.height = props.height as number; - if ('duration' in props) updates.duration = props.duration as number; - if ('background' in props) updates.background = props.background as Project['background']; - if ('fontFamily' in props) updates.fontFamily = props.fontFamily as Project['fontFamily']; + if ('width' in props) { + updates.width = props.width as number; + } + if ('height' in props) { + updates.height = props.height as number; + } + if ('duration' in props) { + updates.duration = props.duration as number; + } + if ('background' in props) { + updates.background = props.background as Project['background']; + } + if ('fontFamily' in props) { + updates.fontFamily = props.fontFamily as Project['fontFamily']; + } + return updates; } From 59b6ab506e0f26d1beacfd3ba2795739df6b2af0 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Mon, 16 Feb 2026 01:19:40 +0100 Subject: [PATCH 5/6] fix --- src/lib/ai/schemas.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/ai/schemas.ts b/src/lib/ai/schemas.ts index c71e83a..8c35fc0 100644 --- a/src/lib/ai/schemas.ts +++ b/src/lib/ai/schemas.ts @@ -10,6 +10,7 @@ import { getPresetIds } from '$lib/engine/presets'; import { tool, type InferUITools, type Tool } from 'ai'; import { layerRegistry, getAvailableLayerTypes } from '$lib/layers/registry'; import { AnchorPointSchema, extractDefaultValues } from '$lib/layers/base'; +import type { LayerTypeString } from '$lib/layers/layer-types'; // ============================================ // Helper Functions @@ -127,9 +128,8 @@ const CreateLayerInputSchema = z 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; + for (const layerType of getAvailableLayerTypes() as LayerTypeString[]) { + if (layerType === 'project-settings') continue; const definition = layerRegistry[layerType]; if (!definition) continue; From 55f357098935365e9eb68f86d9ea89b951c08fc9 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Mon, 16 Feb 2026 01:20:15 +0100 Subject: [PATCH 6/6] fix --- src/lib/components/editor/export-dialog.svelte | 1 + 1 file changed, 1 insertion(+) diff --git a/src/lib/components/editor/export-dialog.svelte b/src/lib/components/editor/export-dialog.svelte index 36bd2c6..980bbb9 100644 --- a/src/lib/components/editor/export-dialog.svelte +++ b/src/lib/components/editor/export-dialog.svelte @@ -420,6 +420,7 @@ type="number" value={exportSettings.height} class="col-span-3" + readonly />