From 45e70ed9fd235634be8057a5c4bb487c7f6c79ca Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 11 Feb 2026 22:29:24 +0100 Subject: [PATCH 1/3] ui --- .../components/editor/login-prompt-dialog.svelte | 15 +++------------ 1 file changed, 3 insertions(+), 12 deletions(-) diff --git a/src/lib/components/editor/login-prompt-dialog.svelte b/src/lib/components/editor/login-prompt-dialog.svelte index 53404c6..5f42248 100644 --- a/src/lib/components/editor/login-prompt-dialog.svelte +++ b/src/lib/components/editor/login-prompt-dialog.svelte @@ -3,8 +3,6 @@ import { Button } from '$lib/components/ui/button'; import GoogleIcon from '$lib/assets/svg/google-icon.svelte'; import { authClient } from '$lib/auth-client'; - import { resolve } from '$app/paths'; - import { goto } from '$app/navigation'; interface Props { open: boolean; @@ -24,15 +22,10 @@ console.error('Google login failed:', error); } } - - function handleEmailLogin() { - open = false; - goto(resolve('/login')); - } - + Login required @@ -42,11 +35,9 @@
- +
free AI credits included
From 200ac10d09342646724c62a341a6f5b6e144d379 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 11 Feb 2026 22:45:58 +0100 Subject: [PATCH 2/3] project state --- src/lib/ai/ai-operations.svelte.ts | 2 +- src/lib/components/ai/ai-chat.svelte | 2 +- .../components/editor/canvas/canvas.svelte | 32 ++--- .../components/editor/editor-layout.svelte | 8 +- .../components/editor/export-dialog.svelte | 12 +- .../components/editor/keyboard-handler.svelte | 8 +- .../components/editor/keyframe-card.svelte | 4 +- .../editor/panels/layers-panel.svelte | 4 +- .../editor/panels/properties-panel.svelte | 10 +- .../editor/project-settings-dialog.svelte | 34 +++--- .../editor/timeline/timeline-layer.svelte | 10 +- .../editor/timeline/timeline.svelte | 12 +- src/lib/components/editor/toolbar.svelte | 2 +- src/lib/layers/LayerWrapper.svelte | 6 +- src/lib/layers/components/ImageLayer.svelte | 8 +- src/lib/layers/components/VideoLayer.svelte | 8 +- .../layers/properties/GenerateCaption.svelte | 2 +- .../layers/properties/SourceLayerRef.svelte | 2 +- src/lib/stores/project.svelte.ts | 109 +++++++++--------- 19 files changed, 136 insertions(+), 139 deletions(-) diff --git a/src/lib/ai/ai-operations.svelte.ts b/src/lib/ai/ai-operations.svelte.ts index 7a33663..a4ad5f2 100644 --- a/src/lib/ai/ai-operations.svelte.ts +++ b/src/lib/ai/ai-operations.svelte.ts @@ -50,7 +50,7 @@ export function resetLayerTracking() { function getContext(): MutationContext { return { - project: projectStore.project, + project: projectStore.state, layerIdMap: layerIdMap, layerCreationIndex: layerCreationIndex }; diff --git a/src/lib/components/ai/ai-chat.svelte b/src/lib/components/ai/ai-chat.svelte index ef62d65..371c025 100644 --- a/src/lib/components/ai/ai-chat.svelte +++ b/src/lib/components/ai/ai-chat.svelte @@ -48,7 +48,7 @@ api: resolve('/(app)/chat'), get body() { return { - project: projectStore.project, + project: projectStore.state, modelId: selectedModelId } satisfies Omit; } diff --git a/src/lib/components/editor/canvas/canvas.svelte b/src/lib/components/editor/canvas/canvas.svelte index 438a234..aad73ee 100644 --- a/src/lib/components/editor/canvas/canvas.svelte +++ b/src/lib/components/editor/canvas/canvas.svelte @@ -64,10 +64,10 @@ const newTime = projectStore.currentTime + deltaTime; - if (newTime >= projectStore.project.duration) { + if (newTime >= projectStore.state.duration) { // When recording, stop at the end instead of looping if (projectStore.isRecording) { - projectStore.setCurrentTime(projectStore.project.duration); + projectStore.setCurrentTime(projectStore.state.duration); projectStore.pause(); } else { projectStore.setCurrentTime(0); @@ -137,13 +137,13 @@ // Calculate scale factor to fit project dimensions in viewport // This maintains aspect ratio and leaves some padding const VIEWPORT_PADDING = 0.9; // Use 90% of available space for padding - const projectAspectRatio = $derived(projectStore.project.width / projectStore.project.height); + const projectAspectRatio = $derived(projectStore.state.width / projectStore.state.height); const containerAspectRatio = $derived(containerWidth / containerHeight); const fitScale = $derived( containerAspectRatio > projectAspectRatio - ? (containerHeight * VIEWPORT_PADDING) / projectStore.project.height - : (containerWidth * VIEWPORT_PADDING) / projectStore.project.width + ? (containerHeight * VIEWPORT_PADDING) / projectStore.state.height + : (containerWidth * VIEWPORT_PADDING) / projectStore.state.width ); // Calculate viewport transform for pan, zoom, and fit scale @@ -154,8 +154,8 @@ // Calculate scale for recording mode to fit viewport while maintaining aspect ratio const recordingScale = $derived.by(() => { if (typeof window === 'undefined') return 1; - const scaleX = window.innerWidth / projectStore.project.width; - const scaleY = window.innerHeight / projectStore.project.height; + const scaleX = window.innerWidth / projectStore.state.width; + const scaleY = window.innerHeight / projectStore.state.height; return Math.min(1, scaleX, scaleY); // Never scale up, only down }); @@ -187,7 +187,7 @@ style:left="-10000px" style:top="-10000px" style:background-color="rgba(127, 127, 127, 0.6)" - style:clip-path={`polygon(evenodd, 0 0, 0 20000px, 20000px 20000px, 20000px 0, 0 0, ${10000 - projectStore.project.width / 2}px ${10000 - projectStore.project.height / 2}px, ${10000 - projectStore.project.width / 2}px ${10000 + projectStore.project.height / 2}px, ${10000 + projectStore.project.width / 2}px ${10000 + projectStore.project.height / 2}px, ${10000 + projectStore.project.width / 2}px ${10000 - projectStore.project.height / 2}px, ${10000 - projectStore.project.width / 2}px ${10000 - projectStore.project.height / 2}px)`} + style:clip-path={`polygon(evenodd, 0 0, 0 20000px, 20000px 20000px, 20000px 0, 0 0, ${10000 - projectStore.state.width / 2}px ${10000 - projectStore.state.height / 2}px, ${10000 - projectStore.state.width / 2}px ${10000 + projectStore.state.height / 2}px, ${10000 + projectStore.state.width / 2}px ${10000 + projectStore.state.height / 2}px, ${10000 + projectStore.state.width / 2}px ${10000 - projectStore.state.height / 2}px, ${10000 - projectStore.state.width / 2}px ${10000 - projectStore.state.height / 2}px)`} style:pointer-events="none" > {/if} @@ -197,17 +197,17 @@ bind:this={projectViewport} class="project-viewport" class:project-viewport-recording={isRecording} - style:width="{projectStore.project.width}px" - style:height="{projectStore.project.height}px" - style:left={isRecording ? 'auto' : `-${projectStore.project.width / 2}px`} - style:top={isRecording ? 'auto' : `-${projectStore.project.height / 2}px`} + style:width="{projectStore.state.width}px" + style:height="{projectStore.state.height}px" + style:left={isRecording ? 'auto' : `-${projectStore.state.width / 2}px`} + style:top={isRecording ? 'auto' : `-${projectStore.state.height / 2}px`} style:transform={isRecording ? `scale(${recordingScale})` : undefined} style:perspective="1000px" style:perspective-origin="center center" style:transform-style="preserve-3d" style:isolation="isolate" - style:background-color={getBackgroundColor(projectStore.project.background)} - style:background-image={getBackgroundImage(projectStore.project.background)} + style:background-color={getBackgroundColor(projectStore.state.background)} + style:background-image={getBackgroundImage(projectStore.state.background)} style:cursor={projectStore.isRecording ? 'none' : undefined} > @@ -217,9 +217,9 @@ style:pointer-events={projectStore.isRecording ? 'none' : undefined} > - {projectStore.currentTime.toFixed(2)}s / {projectStore.project.duration}s + {projectStore.currentTime.toFixed(2)}s / {projectStore.state.duration}s {/snippet} @@ -164,7 +164,7 @@ {#snippet content()} @@ -208,7 +208,7 @@ {/snippet} {#snippet actionsSnippet()} - {projectStore.currentTime.toFixed(2)}s / {projectStore.project.duration}s + {projectStore.currentTime.toFixed(2)}s / {projectStore.state.duration}s {/snippet} diff --git a/src/lib/components/editor/export-dialog.svelte b/src/lib/components/editor/export-dialog.svelte index bfb7a05..7771402 100644 --- a/src/lib/components/editor/export-dialog.svelte +++ b/src/lib/components/editor/export-dialog.svelte @@ -43,9 +43,9 @@ let exportSettings = $derived({ format: 'webm', - fps: projectStore.project.fps, - width: projectStore.project.width, - height: projectStore.project.height + fps: projectStore.state.fps, + width: projectStore.state.width, + height: projectStore.state.height }); let exportMode = $derived(projectId ? 'server' : 'browser'); @@ -123,7 +123,7 @@ // In a streaming response, we have to read it as a blob if we want to trigger a download window const blob = await response.blob(); - const filename = `${projectStore.project.name || 'video'}.mp4`; + const filename = `${projectStore.state.name || 'video'}.mp4`; const url = URL.createObjectURL(blob); const a = document.createElement('a'); @@ -209,7 +209,7 @@ width: exportSettings.width, height: exportSettings.height, fps: exportSettings.fps, - duration: projectStore.project.duration, + duration: projectStore.state.duration, onReadyToRecord: () => { // Start playback synchronized with recording projectStore.play(); @@ -233,7 +233,7 @@ try { // Download the video - const filename = `${projectStore.project.name || 'video'}.mp4`; + const filename = `${projectStore.state.name || 'video'}.mp4`; VideoCapture.downloadBlob(blob, filename); // Close dialog after successful export diff --git a/src/lib/components/editor/keyboard-handler.svelte b/src/lib/components/editor/keyboard-handler.svelte index 76c3375..85ab056 100644 --- a/src/lib/components/editor/keyboard-handler.svelte +++ b/src/lib/components/editor/keyboard-handler.svelte @@ -71,21 +71,21 @@ // End - Go to end if (e.code === 'End') { e.preventDefault(); - projectStore.setCurrentTime(projectStore.project.duration); + projectStore.setCurrentTime(projectStore.state.duration); } // Arrow Left - Step backward if (e.code === 'ArrowLeft' && !projectStore.isPlaying) { e.preventDefault(); - const newTime = projectStore.currentTime - 1 / projectStore.project.fps; + const newTime = projectStore.currentTime - 1 / projectStore.state.fps; projectStore.setCurrentTime(Math.max(0, newTime)); } // Arrow Right - Step forward if (e.code === 'ArrowRight' && !projectStore.isPlaying) { e.preventDefault(); - const newTime = projectStore.currentTime + 1 / projectStore.project.fps; - projectStore.setCurrentTime(Math.min(newTime, projectStore.project.duration)); + const newTime = projectStore.currentTime + 1 / projectStore.state.fps; + projectStore.setCurrentTime(Math.min(newTime, projectStore.state.duration)); } // + / = - Zoom in diff --git a/src/lib/components/editor/keyframe-card.svelte b/src/lib/components/editor/keyframe-card.svelte index ef6bf8f..754fcab 100644 --- a/src/lib/components/editor/keyframe-card.svelte +++ b/src/lib/components/editor/keyframe-card.svelte @@ -118,7 +118,7 @@ } function handleTimeChange(newTime: number) { - const clampedTime = Math.max(0, Math.min(newTime, projectStore.project.duration)); + const clampedTime = Math.max(0, Math.min(newTime, projectStore.state.duration)); projectStore.updateKeyframe(layerId, keyframe.id, { time: clampedTime }); } @@ -231,7 +231,7 @@ value={keyframe.time} step={0.01} min={0} - max={projectStore.project.duration} + max={projectStore.state.duration} onchange={handleTimeChange} /> - {#each projectStore.project.layers as layer, index (layer.id)} + {#each projectStore.state.layers as layer, index (layer.id)} {@const Icon = getLayerDefinition(layer.type).icon}
{/each} - {#if projectStore.project.layers.length === 0} + {#if projectStore.state.layers.length === 0}

No layers yet

Add layers from the toolbar

diff --git a/src/lib/components/editor/panels/properties-panel.svelte b/src/lib/components/editor/panels/properties-panel.svelte index c6d7c2d..6d6f1f8 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -402,11 +402,11 @@ min={0} max={selectedLayer.contentDuration !== undefined ? Math.min( - projectStore.project.duration, - projectStore.project.duration - + projectStore.state.duration, + projectStore.state.duration - (selectedLayer.contentDuration - (selectedLayer.contentOffset ?? 0)) ) - : projectStore.project.duration} + : projectStore.state.duration} step={0.1} onchange={(v) => projectStore.setLayerEnterTime(selectedLayer.id, v)} /> @@ -415,9 +415,9 @@ projectStore.setLayerExitTime(selectedLayer.id, v)} /> diff --git a/src/lib/components/editor/project-settings-dialog.svelte b/src/lib/components/editor/project-settings-dialog.svelte index f91e4e4..d59d497 100644 --- a/src/lib/components/editor/project-settings-dialog.svelte +++ b/src/lib/components/editor/project-settings-dialog.svelte @@ -35,16 +35,16 @@ let { open = $bindable() }: Props = $props(); let formData: Pick = $derived({ - name: projectStore.project.name, - width: projectStore.project.width, - height: projectStore.project.height, - duration: projectStore.project.duration, - background: projectStore.project.background + name: projectStore.state.name, + width: projectStore.state.width, + height: projectStore.state.height, + duration: projectStore.state.duration, + background: projectStore.state.background }); let selectedResolution = $derived.by(() => { const res = commonResolutions.find( - (r) => r.width === projectStore.project.width && r.height === projectStore.project.height + (r) => r.width === projectStore.state.width && r.height === projectStore.state.height ); return res ? `${res.width}x${res.height}` : 'custom'; }); @@ -63,11 +63,11 @@ } function handleSave() { - projectStore.project.name = formData.name; - projectStore.project.width = formData.width; - projectStore.project.height = formData.height; - projectStore.project.duration = formData.duration; - projectStore.project.background = formData.background; + projectStore.state.name = formData.name; + projectStore.state.width = formData.width; + projectStore.state.height = formData.height; + projectStore.state.duration = formData.duration; + projectStore.state.background = formData.background; open = false; } @@ -75,14 +75,14 @@ function handleOpenChange(newOpen: boolean) { if (newOpen) { // Reset form to current project state when opening - formData.name = projectStore.project.name; - formData.width = projectStore.project.width; - formData.height = projectStore.project.height; - formData.duration = projectStore.project.duration; - formData.background = projectStore.project.background; + formData.name = projectStore.state.name; + formData.width = projectStore.state.width; + formData.height = projectStore.state.height; + formData.duration = projectStore.state.duration; + formData.background = projectStore.state.background; const res = commonResolutions.find( - (r) => r.width === projectStore.project.width && r.height === projectStore.project.height + (r) => r.width === projectStore.state.width && r.height === projectStore.state.height ); selectedResolution = res ? `${res.width}x${res.height}` : 'custom'; } diff --git a/src/lib/components/editor/timeline/timeline-layer.svelte b/src/lib/components/editor/timeline/timeline-layer.svelte index a5b39f9..5322859 100644 --- a/src/lib/components/editor/timeline/timeline-layer.svelte +++ b/src/lib/components/editor/timeline/timeline-layer.svelte @@ -16,8 +16,8 @@ // Enter/exit time for the layer const enterTime = $derived(layer.enterTime ?? 0); - const exitTime = $derived(layer.exitTime ?? projectStore.project.duration); - const hasTimeRange = $derived(enterTime > 0 || exitTime < projectStore.project.duration); + const exitTime = $derived(layer.exitTime ?? projectStore.state.duration); + const hasTimeRange = $derived(enterTime > 0 || exitTime < projectStore.state.duration); const isMediaLayer = $derived( layer.type === 'video' || layer.type === 'audio' || layer.type === 'captions' @@ -103,7 +103,7 @@ } else if (isDraggingExit) { const newExit = Math.max( enterTime + 0.1, - Math.min(dragStartExit + deltaTime, projectStore.project.duration) + Math.min(dragStartExit + deltaTime, projectStore.state.duration) ); projectStore.setLayerExitTime(layer.id, newExit); } else if (isDraggingBar) { @@ -115,8 +115,8 @@ newEnter = 0; newExit = duration; } - if (newExit > projectStore.project.duration) { - newExit = projectStore.project.duration; + if (newExit > projectStore.state.duration) { + newExit = projectStore.state.duration; newEnter = newExit - duration; } diff --git a/src/lib/components/editor/timeline/timeline.svelte b/src/lib/components/editor/timeline/timeline.svelte index 3c257fd..501700b 100644 --- a/src/lib/components/editor/timeline/timeline.svelte +++ b/src/lib/components/editor/timeline/timeline.svelte @@ -20,7 +20,7 @@ const rect = timelineContainer.getBoundingClientRect(); const x = e.clientX - rect.left - 200; // 200px for layer names column const time = Math.max(0, x / pixelsPerSecond); - projectStore.setCurrentTime(Math.min(time, projectStore.project.duration)); + projectStore.setCurrentTime(Math.min(time, projectStore.state.duration)); } function handleTimelineMouseDown(e: MouseEvent) { @@ -80,7 +80,7 @@ const layerHeight = 49; // 48px + 1px border const activeLayerIds = new SvelteSet(); - projectStore.project.layers.forEach((layer, index) => { + projectStore.state.layers.forEach((layer, index) => { const layerTop = layerOffset + index * layerHeight; const layerBottom = layerTop + layerHeight; @@ -154,7 +154,7 @@
- +
- {#each projectStore.project.layers as layer (layer.id)} + {#each projectStore.state.layers as layer (layer.id)} {/each} - {#if projectStore.project.layers.length === 0} + {#if projectStore.state.layers.length === 0}
No layers yet. Add a layer to start animating.
diff --git a/src/lib/components/editor/toolbar.svelte b/src/lib/components/editor/toolbar.svelte index ce289ea..7366fd3 100644 --- a/src/lib/components/editor/toolbar.svelte +++ b/src/lib/components/editor/toolbar.svelte @@ -125,7 +125,7 @@ async function doSaveToCloud() { const result = await saveProjectToDb({ id: projectId || undefined, - data: projectStore.project + data: projectStore.state }); if (result.success && result.data.id) { diff --git a/src/lib/layers/LayerWrapper.svelte b/src/lib/layers/LayerWrapper.svelte index e7b9401..8ffe1d6 100644 --- a/src/lib/layers/LayerWrapper.svelte +++ b/src/lib/layers/LayerWrapper.svelte @@ -60,8 +60,8 @@ const canvasY = relY / viewport.zoom; // Canvas coordinates with (0,0) at center of canvas - const centerOffsetX = projectStore.project.width / 2; - const centerOffsetY = projectStore.project.height / 2; + const centerOffsetX = projectStore.state.width / 2; + const centerOffsetY = projectStore.state.height / 2; return { x: canvasX - centerOffsetX, @@ -96,7 +96,7 @@ if (!isDragging) return; const canvasPos = screenToCanvas(event.clientX, event.clientY); - const layer = projectStore.project.layers.find((l) => l.id === id); + const layer = projectStore.state.layers.find((l) => l.id === id); if (layer) { const movementX = canvasPos.x - dragStart.x; diff --git a/src/lib/layers/components/ImageLayer.svelte b/src/lib/layers/components/ImageLayer.svelte index a5fc1c6..0c96c1d 100644 --- a/src/lib/layers/components/ImageLayer.svelte +++ b/src/lib/layers/components/ImageLayer.svelte @@ -26,8 +26,8 @@ .default( () => calculateCoverDimensions( - projectStore.project.width, - projectStore.project.height, + projectStore.state.width, + projectStore.state.height, ASPECT_RATIOS.IMAGE_DEFAULT ).width ) @@ -40,8 +40,8 @@ .default( () => calculateCoverDimensions( - projectStore.project.width, - projectStore.project.height, + projectStore.state.width, + projectStore.state.height, ASPECT_RATIOS.IMAGE_DEFAULT ).height ) diff --git a/src/lib/layers/components/VideoLayer.svelte b/src/lib/layers/components/VideoLayer.svelte index e34e8d3..7a74443 100644 --- a/src/lib/layers/components/VideoLayer.svelte +++ b/src/lib/layers/components/VideoLayer.svelte @@ -24,8 +24,8 @@ .default( () => calculateCoverDimensions( - projectStore.project.width, - projectStore.project.height, + projectStore.state.width, + projectStore.state.height, ASPECT_RATIOS.VIDEO_DEFAULT ).width ) @@ -38,8 +38,8 @@ .default( () => calculateCoverDimensions( - projectStore.project.width, - projectStore.project.height, + projectStore.state.width, + projectStore.state.height, ASPECT_RATIOS.VIDEO_DEFAULT ).height ) diff --git a/src/lib/layers/properties/GenerateCaption.svelte b/src/lib/layers/properties/GenerateCaption.svelte index ba2554d..8935a27 100644 --- a/src/lib/layers/properties/GenerateCaption.svelte +++ b/src/lib/layers/properties/GenerateCaption.svelte @@ -32,7 +32,7 @@ try { // Calculate timing parameters based on layer properties const enterTime = layer.enterTime ?? 0; - const exitTime = layer.exitTime ?? projectStore.project.duration; + const exitTime = layer.exitTime ?? projectStore.state.duration; const contentOffset = layer.contentOffset ?? 0; const layerDuration = exitTime - enterTime; diff --git a/src/lib/layers/properties/SourceLayerRef.svelte b/src/lib/layers/properties/SourceLayerRef.svelte index fb237ff..8fb01d3 100644 --- a/src/lib/layers/properties/SourceLayerRef.svelte +++ b/src/lib/layers/properties/SourceLayerRef.svelte @@ -10,7 +10,7 @@ const sourceLayer = $derived.by(() => { if (!sourceLayerId) return null; - return projectStore.project.layers.find((l) => l.id === sourceLayerId); + return projectStore.state.layers.find((l) => l.id === sourceLayerId); }); function handleActivateSource() { diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index d4d8337..4ba1236 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -31,8 +31,8 @@ export interface FrameCache { [layerId: string]: LayerFrameCache; } -class ProjectStore { - project = $state(undefined!); +export class ProjectStore { + state = $state(undefined!); #saveTimeout: ReturnType | null = null; #initialized = false; #isLoading = false; @@ -58,13 +58,13 @@ class ProjectStore { constructor() { // Load first, before setting up the effect this.#isLoading = true; - this.project = this.loadFromLocalStorage(); + this.state = this.loadFromLocalStorage(); this.#initialized = true; this.#isLoading = false; $effect.root(() => { watch( - () => $state.snapshot(this.project), + () => $state.snapshot(this.state), () => { // Skip saving during initial load or when explicitly loading if (!this.#initialized || this.#isLoading) { @@ -124,7 +124,7 @@ class ProjectStore { if (typeof localStorage === 'undefined') return; try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(this.project)); + localStorage.setItem(STORAGE_KEY, JSON.stringify(this.state)); console.log('Project saved to localStorage'); } catch (error) { console.error('Failed to save project to localStorage:', error); @@ -173,12 +173,12 @@ class ProjectStore { // Layer operations addLayer(layer: TypedLayer) { - this.project.layers = [...this.project.layers, layer]; + this.state.layers = [...this.state.layers, layer]; } async removeLayer(layerId: string) { // Find the layer to check if it has uploaded files to clean up - const layer = this.project.layers.find((l) => l.id === layerId); + const layer = this.state.layers.find((l) => l.id === layerId); // Clean up uploaded files if the layer has a fileKey if (layer && layer.props.fileKey && typeof layer.props.fileKey === 'string') { @@ -192,28 +192,28 @@ class ProjectStore { } } - this.project.layers = this.project.layers.filter((l) => l.id !== layerId); + this.state.layers = this.state.layers.filter((l) => l.id !== layerId); if (this.selectedLayerId === layerId) { this.selectedLayerId = null; } } updateLayer(layerId: string, updates: Partial) { - this.project.layers = this.project.layers.map((layer) => + this.state.layers = this.state.layers.map((layer) => layer.id === layerId ? { ...layer, ...updates } : layer ); } reorderLayers(fromIndex: number, toIndex: number) { - const layers = [...this.project.layers]; + const layers = [...this.state.layers]; const [movedLayer] = layers.splice(fromIndex, 1); layers.splice(toIndex, 0, movedLayer); - this.project.layers = layers; + this.state.layers = layers; } // Keyframe operations addKeyframe(layerId: string, keyframe: Keyframe) { - this.project.layers = this.project.layers.map((layer) => { + this.state.layers = this.state.layers.map((layer) => { if (layer.id === layerId) { return { ...layer, @@ -225,7 +225,7 @@ class ProjectStore { } removeKeyframe(layerId: string, keyframeId: string) { - this.project.layers = this.project.layers.map((layer) => { + this.state.layers = this.state.layers.map((layer) => { if (layer.id === layerId) { return { ...layer, @@ -237,7 +237,7 @@ class ProjectStore { } updateKeyframe(layerId: string, keyframeId: string, updates: Partial) { - this.project.layers = this.project.layers.map((layer) => { + this.state.layers = this.state.layers.map((layer) => { if (layer.id === layerId) { return { ...layer, @@ -276,7 +276,7 @@ class ProjectStore { const start = Math.min(startTime, endTime); const end = Math.max(startTime, endTime); - for (const layer of this.project.layers) { + for (const layer of this.state.layers) { if (layerIds && !layerIds.has(layer.id)) continue; for (const kf of layer.keyframes) { @@ -290,10 +290,10 @@ class ProjectStore { shiftSelectedKeyframes(deltaTime: number) { if (this.selectedKeyframeIds.size === 0) return; - this.project.layers = this.project.layers.map((layer) => { + this.state.layers = this.state.layers.map((layer) => { const layerKeyframes = layer.keyframes.map((kf) => { if (this.selectedKeyframeIds.has(kf.id)) { - const newTime = Math.max(0, Math.min(this.project.duration, kf.time + deltaTime)); + const newTime = Math.max(0, Math.min(this.state.duration, kf.time + deltaTime)); return { ...kf, time: newTime }; } return kf; @@ -314,7 +314,7 @@ class ProjectStore { * @param duration - Duration of the animation in seconds */ applyPreset(layerId: string, presetId: string, startTime?: number, duration: number = 1) { - const layer = this.project.layers.find((l) => l.id === layerId); + const layer = this.state.layers.find((l) => l.id === layerId); if (!layer) { console.warn(`Layer not found: ${layerId}`); return; @@ -335,7 +335,7 @@ class ProjectStore { const scaledTime = actualStartTime + kf.time * duration; // Clamp to project duration - const clampedTime = Math.max(0, Math.min(scaledTime, this.project.duration)); + const clampedTime = Math.max(0, Math.min(scaledTime, this.state.duration)); // Get the base position from the layer's transform if it's a position property let value = kf.value; @@ -357,7 +357,7 @@ class ProjectStore { // Timeline operations setCurrentTime(time: number) { - this.currentTime = Math.max(0, Math.min(time, this.project.duration)); + this.currentTime = Math.max(0, Math.min(time, this.state.duration)); } play() { @@ -388,7 +388,7 @@ class ProjectStore { // Project operations async loadProject(project: Project) { this.#isLoading = true; - this.project = project; + this.state = project; await tick(); this.selectedLayerId = null; this.isPlaying = false; @@ -398,7 +398,7 @@ class ProjectStore { async newProject() { this.#isLoading = true; - this.project = this.getDefaultProject(); + this.state = this.getDefaultProject(); await tick(); this.currentTime = 0; this.selectedLayerId = null; @@ -408,7 +408,7 @@ class ProjectStore { } exportToJSON(): string { - return JSON.stringify(this.project, null, 2); + return JSON.stringify(this.state, null, 2); } importFromJSON(json: string) { @@ -422,7 +422,7 @@ class ProjectStore { get selectedLayer(): TypedLayer | null { if (!this.selectedLayerId) return null; - return this.project.layers.find((l) => l.id === this.selectedLayerId) || null; + return this.state.layers.find((l) => l.id === this.selectedLayerId) || null; } // ======================================== @@ -435,11 +435,11 @@ class ProjectStore { * When contentOffset reaches 0, shifts exitTime to maintain visible duration (sliding mode). */ setLayerEnterTime(layerId: string, enterTime: number) { - this.project.layers = this.project.layers.map((layer) => { + this.state.layers = this.state.layers.map((layer) => { if (layer.id !== layerId) return layer; const oldEnterTime = layer.enterTime ?? 0; - const oldExitTime = layer.exitTime ?? this.project.duration; + const oldExitTime = layer.exitTime ?? this.state.duration; const isMediaLayer = layer.type === 'video' || layer.type === 'audio'; // For media layers with content duration, adjust content offset @@ -461,10 +461,7 @@ class ProjectStore { let newExitTime = oldExitTime + remainingDelta; // shift exit left by remaining delta // Ensure exit time stays valid - newExitTime = Math.max( - validEnterTime + 0.1, - Math.min(newExitTime, this.project.duration) - ); + newExitTime = Math.max(validEnterTime + 0.1, Math.min(newExitTime, this.state.duration)); // Ensure visible duration doesn't exceed content duration if (newExitTime - validEnterTime > contentDuration) { @@ -489,7 +486,7 @@ class ProjectStore { // Ensure enter time is within bounds and before exit time validEnterTime = Math.max( 0, - Math.min(validEnterTime, oldExitTime - 0.1, this.project.duration) + Math.min(validEnterTime, oldExitTime - 0.1, this.state.duration) ); // Ensure visible duration doesn't exceed available content @@ -508,7 +505,7 @@ class ProjectStore { } // For non-media layers, use simple logic - const clampedEnterTime = Math.max(0, Math.min(enterTime, this.project.duration)); + const clampedEnterTime = Math.max(0, Math.min(enterTime, this.state.duration)); const validEnterTime = Math.min(clampedEnterTime, oldExitTime - 0.1); return { ...layer, enterTime: validEnterTime }; }); @@ -519,18 +516,18 @@ class ProjectStore { * For media layers (video/audio), respects contentDuration constraints */ setLayerExitTime(layerId: string, exitTime: number) { - this.project.layers = this.project.layers.map((layer) => { + this.state.layers = this.state.layers.map((layer) => { if (layer.id !== layerId) return layer; const enterTime = layer.enterTime ?? 0; const isMediaLayer = layer.type === 'video' || layer.type === 'audio'; // Calculate max exit time for media layers - let maxExitTime = this.project.duration; + let maxExitTime = this.state.duration; if (isMediaLayer && layer.contentDuration !== undefined) { const contentOffset = layer.contentOffset ?? 0; const availableContent = layer.contentDuration - contentOffset; - maxExitTime = Math.min(this.project.duration, enterTime + availableContent); + maxExitTime = Math.min(this.state.duration, enterTime + availableContent); } // Clamp and validate exit time @@ -547,7 +544,7 @@ class ProjectStore { * @param shiftKeyframes If true, shift all keyframes by the time delta (default: false) */ setLayerTimeRange(layerId: string, enterTime: number, exitTime: number, shiftKeyframes = false) { - this.project.layers = this.project.layers.map((layer) => { + this.state.layers = this.state.layers.map((layer) => { if (layer.id !== layerId) return layer; const oldEnterTime = layer.enterTime ?? 0; @@ -560,12 +557,12 @@ class ProjectStore { const availableContent = layer.contentDuration - contentOffset; const maxDuration = Math.min(duration, availableContent); - let clampedEnterTime = Math.max(0, Math.min(enterTime, this.project.duration)); + let clampedEnterTime = Math.max(0, Math.min(enterTime, this.state.duration)); let clampedExitTime = clampedEnterTime + maxDuration; // Ensure exit time doesn't exceed project duration - if (clampedExitTime > this.project.duration) { - clampedExitTime = this.project.duration; + if (clampedExitTime > this.state.duration) { + clampedExitTime = this.state.duration; clampedEnterTime = Math.max(0, clampedExitTime - maxDuration); } @@ -576,7 +573,7 @@ class ProjectStore { ? layer.keyframes .map((kf) => ({ ...kf, - time: Math.max(0, Math.min(this.project.duration, kf.time + timeDelta)) + time: Math.max(0, Math.min(this.state.duration, kf.time + timeDelta)) })) .sort((a, b) => a.time - b.time) : layer.keyframes; @@ -590,8 +587,8 @@ class ProjectStore { } // For non-media layers, use simple logic - const clampedEnterTime = Math.max(0, Math.min(enterTime, this.project.duration)); - const clampedExitTime = Math.max(0, Math.min(exitTime, this.project.duration)); + const clampedEnterTime = Math.max(0, Math.min(enterTime, this.state.duration)); + const clampedExitTime = Math.max(0, Math.min(exitTime, this.state.duration)); const validExitTime = Math.max(clampedExitTime, clampedEnterTime + 0.1); // Shift keyframes if requested @@ -601,7 +598,7 @@ class ProjectStore { ? layer.keyframes .map((kf) => ({ ...kf, - time: Math.max(0, Math.min(this.project.duration, kf.time + timeDelta)) + time: Math.max(0, Math.min(this.state.duration, kf.time + timeDelta)) })) .sort((a, b) => a.time - b.time) : layer.keyframes; @@ -624,12 +621,12 @@ class ProjectStore { * Creates two layers from one, each with appropriate time ranges */ splitLayer(layerId: string, splitTime?: number) { - const layer = this.project.layers.find((l) => l.id === layerId); + const layer = this.state.layers.find((l) => l.id === layerId); if (!layer) return; const time = splitTime ?? this.currentTime; const enterTime = layer.enterTime ?? 0; - const exitTime = layer.exitTime ?? this.project.duration; + const exitTime = layer.exitTime ?? this.state.duration; // Don't split if time is outside the layer's range if (time <= enterTime || time >= exitTime) return; @@ -651,7 +648,7 @@ class ProjectStore { }; // Update the first half (original layer) - keep same contentOffset, just trim exit time - this.project.layers = this.project.layers.map((l) => { + this.state.layers = this.state.layers.map((l) => { if (l.id === layerId) { return { ...l, @@ -663,17 +660,17 @@ class ProjectStore { }); // Add the second half after the original - const insertIndex = this.project.layers.findIndex((l) => l.id === layerId) + 1; - const layers = [...this.project.layers]; + const insertIndex = this.state.layers.findIndex((l) => l.id === layerId) + 1; + const layers = [...this.state.layers]; layers.splice(insertIndex, 0, secondHalf); - this.project.layers = layers; + this.state.layers = layers; } /** * Trim/crop a media layer's source time range */ trimMediaLayer(layerId: string, mediaStartTime: number, mediaEndTime: number) { - this.project.layers = this.project.layers.map((layer) => { + this.state.layers = this.state.layers.map((layer) => { if (layer.id === layerId && (layer.type === 'video' || layer.type === 'audio')) { return { ...layer, @@ -692,11 +689,11 @@ class ProjectStore { * Check if a layer is visible at the current time based on enter/exit times */ isLayerVisibleAtTime(layerId: string, time?: number): boolean { - const layer = this.project.layers.find((l) => l.id === layerId); + const layer = this.state.layers.find((l) => l.id === layerId); if (!layer) return false; const t = time ?? this.currentTime; const enterTime = layer.enterTime ?? 0; - const exitTime = layer.exitTime ?? this.project.duration; + const exitTime = layer.exitTime ?? this.state.duration; return t >= enterTime && t <= exitTime; } @@ -706,16 +703,16 @@ class ProjectStore { * @returns Promise that resolves when preparation is complete */ async prepareRecording(): Promise { - const totalFrames = Math.ceil(this.project.fps * this.project.duration); + const totalFrames = Math.ceil(this.state.fps * this.state.duration); this.frameCache = new Map(); this.preparingProgress = 0; for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { - const time = frameIndex / this.project.fps; + const time = frameIndex / this.state.fps; const frameData: FrameCache = {}; // Pre-calculate values for all layers using shared rendering functions - for (const layer of this.project.layers) { + for (const layer of this.state.layers) { frameData[layer.id] = { transform: getLayerTransform(layer, time), style: getLayerStyle(layer, time), @@ -752,7 +749,7 @@ class ProjectStore { */ getCachedFrame(time: number): FrameCache | null { if (!this.frameCache) return null; - const frameIndex = Math.floor(time * this.project.fps); + const frameIndex = Math.floor(time * this.state.fps); return this.frameCache.get(frameIndex) ?? null; } } From 3c370f465cc3ed011491a21af7d303a5d8dab138 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Wed, 11 Feb 2026 23:13:47 +0100 Subject: [PATCH 3/3] editor context --- src/lib/ai/ai-operations.svelte.ts | 42 +++-- src/lib/components/ai/ai-chat.svelte | 15 +- .../components/editor/VideoRecorder.svelte | 18 +- .../editor/canvas/canvas-controls.svelte | 5 +- .../components/editor/canvas/canvas.svelte | 5 +- .../editor/canvas/playback-controls.svelte | 5 +- .../components/editor/editor-layout.svelte | 5 +- .../components/editor/export-dialog.svelte | 5 +- .../components/editor/keyboard-handler.svelte | 5 +- .../components/editor/keyframe-card.svelte | 5 +- .../components/editor/panels/add-layer.svelte | 5 +- .../editor/panels/input-propery.svelte | 7 +- .../editor/panels/input-wrapper.svelte | 5 +- .../editor/panels/layers-panel.svelte | 5 +- .../editor/panels/properties-panel.svelte | 4 +- .../editor/project-settings-dialog.svelte | 5 +- .../components/editor/project-switcher.svelte | 6 +- .../editor/timeline/timeline-keyframe.svelte | 5 +- .../editor/timeline/timeline-layer.svelte | 5 +- .../editor/timeline/timeline.svelte | 5 +- src/lib/components/editor/toolbar.svelte | 16 +- src/lib/components/layout/app-header.svelte | 2 +- src/lib/contexts/editor.svelte.ts | 161 ++++++++++++++++++ src/lib/layers/LayerWrapper.svelte | 5 +- src/lib/layers/components/AudioLayer.svelte | 2 +- src/lib/layers/components/ImageLayer.svelte | 34 ++-- src/lib/layers/components/VideoLayer.svelte | 34 ++-- src/lib/layers/layer-schemas.ts | 2 +- .../layers/properties/GenerateCaption.svelte | 5 +- .../layers/properties/SourceLayerRef.svelte | 5 +- src/lib/stores/project.svelte.ts | 135 +-------------- src/routes/(app)/+layout.svelte | 5 + src/routes/(app)/p/[id]/+page.svelte | 14 +- 33 files changed, 358 insertions(+), 224 deletions(-) create mode 100644 src/lib/contexts/editor.svelte.ts diff --git a/src/lib/ai/ai-operations.svelte.ts b/src/lib/ai/ai-operations.svelte.ts index a4ad5f2..d4c66d4 100644 --- a/src/lib/ai/ai-operations.svelte.ts +++ b/src/lib/ai/ai-operations.svelte.ts @@ -4,7 +4,7 @@ * Handles progressive tool execution with layer ID tracking across tool calls. * Refactored to use shared 'mutations.ts' logic. */ -import { projectStore } from '$lib/stores/project.svelte'; +import type { ProjectStore } from '$lib/stores/project.svelte'; import type { CreateLayerInput, CreateLayerOutput, @@ -48,7 +48,7 @@ export function resetLayerTracking() { // Context Helper // ============================================ -function getContext(): MutationContext { +function getContext(projectStore: ProjectStore): MutationContext { return { project: projectStore.state, layerIdMap: layerIdMap, @@ -63,8 +63,11 @@ function getContext(): MutationContext { /** * Execute create_layer tool */ -export function executeCreateLayer(input: CreateLayerInput): CreateLayerOutput { - const ctx = getContext(); +export function executeCreateLayer( + projectStore: ProjectStore, + input: CreateLayerInput +): CreateLayerOutput { + const ctx = getContext(projectStore); const result = mutateCreateLayer(ctx, input); // Update local index tracker @@ -86,26 +89,36 @@ export function executeCreateLayer(input: CreateLayerInput): CreateLayerOutput { /** * Execute animate_layer tool */ -export function executeAnimateLayer(input: AnimateLayerInput): AnimateLayerOutput { - return mutateAnimateLayer(getContext(), input); +export function executeAnimateLayer( + projectStore: ProjectStore, + input: AnimateLayerInput +): AnimateLayerOutput { + return mutateAnimateLayer(getContext(projectStore), input); } /** * Execute edit_layer tool */ -export function executeEditLayer(input: EditLayerInput): EditLayerOutput { - return mutateEditLayer(getContext(), input); +export function executeEditLayer( + projectStore: ProjectStore, + input: EditLayerInput +): EditLayerOutput { + return mutateEditLayer(getContext(projectStore), input); } /** * Execute remove_layer tool */ -export function executeRemoveLayer(input: RemoveLayerInput): RemoveLayerOutput { - const result = mutateRemoveLayer(getContext(), input); +export function executeRemoveLayer( + projectStore: ProjectStore, + input: RemoveLayerInput +): RemoveLayerOutput { + const result = mutateRemoveLayer(getContext(projectStore), input); if (result.success) { if ( projectStore.selectedLayerId && - getContext().project.layers.find((l) => l.id === projectStore.selectedLayerId) === undefined + getContext(projectStore).project.layers.find((l) => l.id === projectStore.selectedLayerId) === + undefined ) { projectStore.selectedLayerId = null; } @@ -116,6 +129,9 @@ export function executeRemoveLayer(input: RemoveLayerInput): RemoveLayerOutput { /** * Execute configure_project tool */ -export function executeConfigureProject(input: ConfigureProjectInput): ConfigureProjectOutput { - return mutateConfigureProject(getContext(), input); +export function executeConfigureProject( + projectStore: ProjectStore, + input: ConfigureProjectInput +): ConfigureProjectOutput { + return mutateConfigureProject(getContext(projectStore), input); } diff --git a/src/lib/components/ai/ai-chat.svelte b/src/lib/components/ai/ai-chat.svelte index 371c025..77be9bd 100644 --- a/src/lib/components/ai/ai-chat.svelte +++ b/src/lib/components/ai/ai-chat.svelte @@ -1,5 +1,5 @@ diff --git a/src/routes/(app)/p/[id]/+page.svelte b/src/routes/(app)/p/[id]/+page.svelte index fbd4749..5c38407 100644 --- a/src/routes/(app)/p/[id]/+page.svelte +++ b/src/routes/(app)/p/[id]/+page.svelte @@ -2,25 +2,27 @@ import SeoHead from '$lib/components/seo-head.svelte'; import JsonLd from '$lib/components/json-ld.svelte'; import EditorLayout from '$lib/components/editor/editor-layout.svelte'; - import { projectStore } from '$lib/stores/project.svelte'; import { PUBLIC_BASE_URL } from '$env/static/public'; import type { PageData } from './$types'; + import { getEditorState } from '$lib/contexts/editor.svelte'; let { data }: { data: PageData } = $props(); + const editorState = $derived(getEditorState()); + const baseUrl = PUBLIC_BASE_URL; - const projectName = $derived(data.project.data.name || 'Untitled Project'); + const projectName = $derived(editorState.project.state.name || 'Untitled Project'); const projectDescription = $derived( `${projectName} - Animated video created with DevMotion. Design with manual controls or use AI-powered suggestions.` ); const projectUrl = $derived(`${baseUrl}/p/${data.project.id}`); const ogImage = $derived(`${baseUrl}/p/${data.project.id}/og.png`); - // Use $effect to react to project changes when switching between projects + // Load project data when route changes $effect(() => { const projectData = data.project.data; - projectStore.loadProject({ + editorState.project.loadProject({ id: data.project.id, name: projectData.name, width: projectData.width, @@ -31,7 +33,9 @@ layers: projectData.layers }); - projectStore.setDbContext(data.project.id, data.isOwner, data.canEdit, data.project.isPublic); + editorState.setDbContext(data.project.id, data.isOwner, data.canEdit, data.project.isPublic); + + editorState.isMcp = data.project.isMcp; });