diff --git a/src/lib/ai/schemas.ts b/src/lib/ai/schemas.ts index 18884c1..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 @@ -50,18 +51,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(', ')), @@ -139,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; diff --git a/src/lib/components/editor/export-dialog.svelte b/src/lib/components/editor/export-dialog.svelte index df7f466..980bbb9 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,8 +418,9 @@ diff --git a/src/lib/components/editor/panels/add-layer.svelte b/src/lib/components/editor/panels/add-layer.svelte index 61b3a32..0cbc288 100644 --- a/src/lib/components/editor/panels/add-layer.svelte +++ b/src/lib/components/editor/panels/add-layer.svelte @@ -31,7 +31,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..23a96d4 100644 --- a/src/lib/components/editor/panels/layers-panel.svelte +++ b/src/lib/components/editor/panels/layers-panel.svelte @@ -7,6 +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'; const editorState = $derived(getEditorState()); const projectStore = $derived(editorState.project); @@ -187,6 +188,8 @@ } return topLevel; }); + + const ProjectIcon = $derived(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'} @@ -460,9 +484,9 @@ {/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}
diff --git a/src/lib/components/editor/panels/properties-panel.svelte b/src/lib/components/editor/panels/properties-panel.svelte index 2f5e9e6..d7c66a4 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -32,11 +32,9 @@ getAnimatedStyle, getAnimatedProps } from '$lib/engine/interpolation'; - import { getLayerDefinition, getLayerSchema } from '$lib/layers/registry'; import { extractPropertyMetadata } from '$lib/layers/base'; import { animationPresets } from '$lib/engine/presets'; - import { getDefaultInterpolationForProperty } from '$lib/utils/interpolation-utils'; import InputWrapper from './input-wrapper.svelte'; import ScrubXyz from './scrub-xyz.svelte'; @@ -50,11 +48,17 @@ 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 type { Layer } from '$lib/schemas/animation'; + import { getDefaultInterpolationForProperty } from '$lib/utils/interpolation-utils'; 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 []; @@ -105,9 +109,34 @@ }); // Unified current values: transform + style + props (animated when keyframes exist) - const currentValues = $derived.by(() => { + const currentValues = $derived.by((): Pick | null => { if (!selectedLayer) return null; + // For the project settings layer, there are no keyframes, so just use static props + if (isProjectSettings) { + return { + transform: { + position: { + x: 0, + y: 0, + z: 0 + }, + rotation: { + x: 0, + y: 0, + z: 0 + }, + scale: { + x: 1, + y: 1 + }, + anchor: 'center' + }, + style: { opacity: 1 }, + props: { ...selectedLayer.props } + }; + } + const animatedTransform = getAnimatedTransform( selectedLayer.keyframes, projectStore.currentTime @@ -116,6 +145,7 @@ return { transform: { + ...selectedLayer.transform, position: { x: animatedTransform.position.x ?? selectedLayer.transform.position.x, y: animatedTransform.position.y ?? selectedLayer.transform.position.y, @@ -172,6 +202,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 }); } @@ -257,6 +292,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, { @@ -367,7 +409,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} -
- - - - - - - - ({ - for: f.id, - labels: f.label, - property: f.prop, - addKeyframe: addKeyframe, - hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) - }))} - > - {#snippet prefix()} - - updateProperty('position.x', v, 'transform')} - onchangeY={(v: number) => updateProperty('position.y', v, 'transform')} - onchangeZ={(v: number) => updateProperty('position.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} +
+ + + + + + + + ({ + for: f.id, + labels: f.label, + property: f.prop, + addKeyframe: addKeyframe, + hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) + }))} + > + {#snippet prefix()} + + updateProperty('position.x', v, 'transform')} + onchangeY={(v: number) => updateProperty('position.y', v, 'transform')} + onchangeZ={(v: number) => updateProperty('position.z', v, 'transform')} + /> + {/snippet} + + updateProperty('position.x', v, 'transform')} /> - {/snippet} - updateProperty('position.x', v, 'transform')} - /> - - updateProperty('position.y', v, 'transform')} - /> - updateProperty('position.z', v, 'transform')} - /> - - - - ({ - for: f.id, - labels: f.label, - property: f.prop, - addKeyframe: addKeyframe, - hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) - }))} - > - {#snippet prefix()} - - updateProperty('rotation.y', v, 'transform')} - onchangeY={(v: number) => updateProperty('rotation.x', v, 'transform')} - onchangeZ={(v: number) => updateProperty('rotation.z', v, 'transform')} + updateProperty('position.y', v, 'transform')} /> - {/snippet} - updateProperty('rotation.x', v, 'transform')} - /> - updateProperty('rotation.y', v, 'transform')} - /> - updateProperty('rotation.z', v, 'transform')} - /> - - - - {@const scaleLocked = selectedLayer?.props._scaleLocked ?? false} - {@const currentScaleX = currentValues?.transform.scale.x ?? 1} - {@const currentScaleY = currentValues?.transform.scale.y ?? 1} - ({ - for: f.id, - labels: f.label, - property: f.prop, - addKeyframe: addKeyframe, - hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) - }))} - > - {#snippet prefix()} - - updateProperty('scale.x', v || 1, 'transform')} - onchangeY={(v: number) => updateProperty('scale.y', v || 1, 'transform')} - onchangeZ={() => {}} + updateProperty('position.z', v, 'transform')} /> - - {/snippet} - updateProperty('scale.x', v || 1, 'transform')} - /> - updateProperty('scale.y', 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')} - /> - - + + + + ({ + for: f.id, + labels: f.label, + property: f.prop, + addKeyframe: addKeyframe, + hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) + }))} + > + {#snippet prefix()} + + updateProperty('rotation.y', v, 'transform')} + onchangeY={(v: number) => updateProperty('rotation.x', v, 'transform')} + onchangeZ={(v: number) => updateProperty('rotation.z', v, 'transform')} + /> + {/snippet} + updateProperty('rotation.x', v, 'transform')} + /> + updateProperty('rotation.y', v, 'transform')} + /> + updateProperty('rotation.z', v, 'transform')} + /> + + + + {@const scaleLocked = selectedLayer?.props._scaleLocked ?? false} + {@const currentScaleX = currentValues?.transform.scale.x ?? 1} + {@const currentScaleY = currentValues?.transform.scale.y ?? 1} + ({ + for: f.id, + labels: f.label, + property: f.prop, + addKeyframe: addKeyframe, + hasKeyframes: selectedLayer?.keyframes.some((k) => k.property === f.prop) + }))} + > + {#snippet prefix()} + + updateProperty('scale.x', v || 1, 'transform')} + onchangeY={(v: number) => updateProperty('scale.y', v || 1, 'transform')} + onchangeZ={() => {}} + /> + + {/snippet} + updateProperty('scale.x', v || 1, 'transform')} + /> + updateProperty('scale.y', 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'} ({ for: `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}` - ) + + ...(isProjectSettings + ? { + property: undefined, + addKeyframe: undefined, + hasKeyframes: false + } + : { + property: `props.${field.name}` as AnimatableProperty, + addKeyframe, + hasKeyframes: selectedLayer.keyframes.some( + (k) => k.property === `props.${field.name}` + ) + }) }))} > {#snippet prefix()} @@ -736,8 +791,9 @@ {/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..7426a5a --- /dev/null +++ b/src/lib/layers/components/ProjectSettingsLayer.svelte @@ -0,0 +1,56 @@ + + + + + +
diff --git a/src/lib/layers/project-layer.ts b/src/lib/layers/project-layer.ts new file mode 100644 index 0000000..afb6d58 --- /dev/null +++ b/src/lib/layers/project-layer.ts @@ -0,0 +1,85 @@ +/** + * 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: { + position: { + x: 0, + y: 0, + z: 0 + }, + rotation: { + x: 0, + y: 0, + z: 0 + }, + scale: { + x: 1, + y: 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 8fd7488..785f5bf 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); @@ -576,7 +586,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; } @@ -596,6 +606,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; }