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..fab6ed1 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -50,11 +50,15 @@ 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 +112,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 @@ -172,6 +194,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 }); } @@ -248,7 +275,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, @@ -257,6 +285,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 +402,7 @@
- +
- + {#if !isProjectSettings} + - - - {#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} -
+ + + {#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} -
@@ -669,12 +646,51 @@ {/each}
- - - -
- - +
+ + { + 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} + @@ -693,18 +709,20 @@ {#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}` - ) + 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()} @@ -736,8 +754,8 @@ {/if} - + {#if !isProjectSettings} + @@ -788,18 +807,24 @@ /> - - + + - + - - - - + + + + + {/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 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; }