From c7cf14ce63736928da5d1b29e150070f84e9e2db Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Feb 2026 06:46:45 +0000 Subject: [PATCH 01/17] feat: file uploads, video/audio layers, enter/exit time, and AI captions - Add S3-compatible storage provider (AWS S3, R2, MinIO, etc.) - Add file upload API endpoint (/api/upload) for images, videos, audio - Add Video layer with timeline sync, trimming, volume, and playback rate - Add Audio layer with waveform visualization, captions, and recording support - Add AI caption generation endpoint (/api/captions) via OpenRouter - Add enterTime/exitTime to LayerSchema for controlling layer visibility - Update canvas rendering to respect enter/exit time ranges - Update timeline UI with draggable duration bars and resize handles - Update properties panel with time range controls and split-at-playhead - Update project store with split, trim, and time range operations - Update video renderer to extract and mix audio tracks from layers via FFmpeg - Update image layer to support uploaded files alongside URL sources - Update AI mutations and schemas for new layer types and enter/exit time https://claude.ai/code/session_01HoPrQgyF2qCoqL2NPKBnfs --- .env.example | 8 + src/lib/ai/mutations.ts | 16 ++ src/lib/ai/schemas.ts | 24 +- .../components/editor/canvas/canvas.svelte | 35 +-- .../editor/panels/properties-panel.svelte | 68 +++++ .../editor/timeline/timeline-layer.svelte | 151 ++++++++++- src/lib/engine/layer-factory.ts | 23 +- src/lib/layers/components/AudioLayer.svelte | 197 ++++++++++++++ src/lib/layers/components/ImageLayer.svelte | 24 +- src/lib/layers/components/VideoLayer.svelte | 116 ++++++++ src/lib/schemas/animation.ts | 13 +- src/lib/server/storage/index.ts | 253 ++++++++++++++++++ src/lib/server/video-renderer.ts | 117 +++++++- src/lib/stores/project.svelte.ts | 144 ++++++++++ src/routes/api/captions/+server.ts | 86 ++++++ src/routes/api/export/[id]/+server.ts | 5 +- src/routes/api/upload/+server.ts | 90 +++++++ 17 files changed, 1330 insertions(+), 40 deletions(-) create mode 100644 src/lib/layers/components/AudioLayer.svelte create mode 100644 src/lib/layers/components/VideoLayer.svelte create mode 100644 src/lib/server/storage/index.ts create mode 100644 src/routes/api/captions/+server.ts create mode 100644 src/routes/api/upload/+server.ts diff --git a/.env.example b/.env.example index 3d0722b..bb93a8f 100644 --- a/.env.example +++ b/.env.example @@ -9,3 +9,11 @@ PRIVATE_GOOGLE_CLIENT_SECRET=your_google_client_secret_here # AI Generation (OpenRouter) OPENROUTER_API_KEY=your_openrouter_api_key_here +# S3-compatible Storage (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, etc.) +PRIVATE_S3_BUCKET=devmotion-uploads +PRIVATE_S3_REGION=us-east-1 +PRIVATE_S3_ENDPOINT= # Leave empty for AWS S3, set for R2/MinIO/etc. +PRIVATE_S3_ACCESS_KEY_ID=your_access_key +PRIVATE_S3_SECRET_ACCESS_KEY=your_secret_key +PRIVATE_S3_PUBLIC_URL= # Optional: custom public URL for assets + diff --git a/src/lib/ai/mutations.ts b/src/lib/ai/mutations.ts index 386a07f..18e20da 100644 --- a/src/lib/ai/mutations.ts +++ b/src/lib/ai/mutations.ts @@ -106,6 +106,14 @@ export function mutateCreateLayer( layer.name = input.name; } + // Set enter/exit time if provided in input + if (input.enterTime !== undefined) { + layer.enterTime = input.enterTime; + } + if (input.exitTime !== undefined) { + layer.exitTime = input.exitTime; + } + // Mutate project ctx.project.layers.push(layer); @@ -269,6 +277,14 @@ export function mutateEditLayer(ctx: MutationContext, input: EditLayerInput): Ed layer.props = { ...layer.props, ...input.updates.props }; } + // Update enter/exit times + if (input.updates.enterTime !== undefined) { + layer.enterTime = Math.max(0, input.updates.enterTime); + } + if (input.updates.exitTime !== undefined) { + layer.exitTime = Math.max(0, input.updates.exitTime); + } + return { success: true, layerId: resolvedId, diff --git a/src/lib/ai/schemas.ts b/src/lib/ai/schemas.ts index eeb0375..f96eab3 100644 --- a/src/lib/ai/schemas.ts +++ b/src/lib/ai/schemas.ts @@ -78,7 +78,9 @@ function getKeyPropsForLayerType(type: string): string[] { mouse: ['pointerType', 'size'], phone: ['url'], browser: ['url'], - html: ['html', 'css'] + html: ['html', 'css'], + video: ['src', 'width', 'height', 'mediaStartTime', 'mediaEndTime', 'volume'], + audio: ['src', 'label', 'volume', 'mediaStartTime', 'mediaEndTime', 'showCaptions'] }; return keyProps[type] || []; } @@ -100,7 +102,9 @@ function getExampleProps(type: string): string { mouse: '"pointerType": "arrow", "size": 32', phone: '"url": "https://example.com"', browser: '"url": "https://example.com"', - html: '"html": "
Content
", "css": ".container { color: white; }"' + html: '"html": "
Content
", "css": ".container { color: white; }"', + video: '"src": "https://example.com/video.mp4", "width": 640, "height": 360, "volume": 1', + audio: '"src": "https://example.com/audio.mp3", "label": "Background Music", "volume": 0.8' }; return examples[type] || ''; } @@ -133,7 +137,17 @@ function generateLayerCreationTools(): Record { name: z.string().optional().describe('Layer name for identification'), position: PositionSchema, props: definition.schema.describe(`Properties for ${definition.label} layer`), - animation: AnimationSchema + animation: AnimationSchema, + enterTime: z + .number() + .min(0) + .optional() + .describe('When layer enters the timeline (seconds, default: 0)'), + exitTime: z + .number() + .min(0) + .optional() + .describe('When layer exits the timeline (seconds, default: project duration)') }); const description = `Create a ${definition.label} layer. ${definition.description} @@ -171,6 +185,10 @@ export interface CreateLayerInput { startTime?: number; duration?: number; }; + /** When the layer enters the timeline (seconds) */ + enterTime?: number; + /** When the layer exits the timeline (seconds) */ + exitTime?: number; } export interface CreateLayerOutput { diff --git a/src/lib/components/editor/canvas/canvas.svelte b/src/lib/components/editor/canvas/canvas.svelte index a9f00f2..f19b44c 100644 --- a/src/lib/components/editor/canvas/canvas.svelte +++ b/src/lib/components/editor/canvas/canvas.svelte @@ -241,21 +241,26 @@ style:pointer-events={projectStore.isRecording ? 'none' : undefined} > {#each projectStore.project.layers as layer (layer.id)} - {@const { transform, style, customProps } = getLayerRenderData(layer)} - {@const component = getLayerComponent(layer.type)} - {@const isSelected = projectStore.selectedLayerId === layer.id} - - + {@const enterTime = layer.enterTime ?? 0} + {@const exitTime = layer.exitTime ?? projectStore.project.duration} + {@const isInTimeRange = projectStore.currentTime >= enterTime && projectStore.currentTime <= exitTime} + {#if isInTimeRange} + {@const { transform, style, customProps } = getLayerRenderData(layer)} + {@const component = getLayerComponent(layer.type)} + {@const isSelected = projectStore.selectedLayerId === layer.id} + + + {/if} {/each} diff --git a/src/lib/components/editor/panels/properties-panel.svelte b/src/lib/components/editor/panels/properties-panel.svelte index 1f1a6fd..f444ff2 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -477,6 +477,74 @@ + +
+ +
+
+ + projectStore.setLayerEnterTime(selectedLayer.id, v)} + /> +
+
+ + projectStore.setLayerExitTime(selectedLayer.id, v)} + /> +
+
+ + + {#if selectedLayer.type === 'video' || selectedLayer.type === 'audio'} +
+ +
+
+ + updateLayerProps('mediaStartTime', v)} + /> +
+
+ + updateLayerProps('mediaEndTime', v)} + /> +
+
+ +
+ {/if} +
+ + +
diff --git a/src/lib/components/editor/timeline/timeline-layer.svelte b/src/lib/components/editor/timeline/timeline-layer.svelte index e491878..3ff53cb 100644 --- a/src/lib/components/editor/timeline/timeline-layer.svelte +++ b/src/lib/components/editor/timeline/timeline-layer.svelte @@ -12,6 +12,18 @@ const isSelected = $derived(projectStore.selectedLayerId === layer.id); + // 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); + + // Media layer detection + const isMediaLayer = $derived(layer.type === 'video' || layer.type === 'audio'); + + // Duration bar position and width + const barLeft = $derived(enterTime * pixelsPerSecond); + const barWidth = $derived((exitTime - enterTime) * pixelsPerSecond); + // Group keyframes by timestamp const keyframeGroups = $derived(() => { // eslint-disable-next-line svelte/prefer-svelte-reactivity @@ -40,6 +52,100 @@ selectLayer(); } } + + // Drag state for resizing enter/exit time + let isDraggingEnter = $state(false); + let isDraggingExit = $state(false); + let isDraggingBar = $state(false); + let dragStartX = $state(0); + let dragStartEnter = $state(0); + let dragStartExit = $state(0); + + function startDragEnter(e: MouseEvent) { + e.stopPropagation(); + isDraggingEnter = true; + dragStartX = e.clientX; + dragStartEnter = enterTime; + window.addEventListener('mousemove', handleDragMove); + window.addEventListener('mouseup', handleDragEnd); + } + + function startDragExit(e: MouseEvent) { + e.stopPropagation(); + isDraggingExit = true; + dragStartX = e.clientX; + dragStartExit = exitTime; + window.addEventListener('mousemove', handleDragMove); + window.addEventListener('mouseup', handleDragEnd); + } + + function startDragBar(e: MouseEvent) { + e.stopPropagation(); + selectLayer(); + isDraggingBar = true; + dragStartX = e.clientX; + dragStartEnter = enterTime; + dragStartExit = exitTime; + window.addEventListener('mousemove', handleDragMove); + window.addEventListener('mouseup', handleDragEnd); + } + + function handleDragMove(e: MouseEvent) { + const deltaX = e.clientX - dragStartX; + const deltaTime = deltaX / pixelsPerSecond; + + if (isDraggingEnter) { + const newEnter = Math.max(0, Math.min(dragStartEnter + deltaTime, exitTime - 0.1)); + projectStore.setLayerEnterTime(layer.id, newEnter); + } else if (isDraggingExit) { + const newExit = Math.max( + enterTime + 0.1, + Math.min(dragStartExit + deltaTime, projectStore.project.duration) + ); + projectStore.setLayerExitTime(layer.id, newExit); + } else if (isDraggingBar) { + const duration = dragStartExit - dragStartEnter; + let newEnter = dragStartEnter + deltaTime; + let newExit = dragStartExit + deltaTime; + + if (newEnter < 0) { + newEnter = 0; + newExit = duration; + } + if (newExit > projectStore.project.duration) { + newExit = projectStore.project.duration; + newEnter = newExit - duration; + } + + projectStore.setLayerTimeRange(layer.id, newEnter, newExit); + } + } + + function handleDragEnd() { + isDraggingEnter = false; + isDraggingExit = false; + isDraggingBar = false; + window.removeEventListener('mousemove', handleDragMove); + window.removeEventListener('mouseup', handleDragEnd); + } + + // Color for the duration bar based on layer type + const barColor = $derived.by(() => { + switch (layer.type) { + case 'video': + return 'bg-purple-500/30 border-purple-500/50'; + case 'audio': + return 'bg-blue-500/30 border-blue-500/50'; + default: + return 'bg-primary/15 border-primary/30'; + } + }); + + const barLabel = $derived.by(() => { + if (layer.type === 'video') return 'Video'; + if (layer.type === 'audio') return 'Audio'; + return ''; + });
- {layer.name} +
+ {layer.name} + {#if hasTimeRange || isMediaLayer} + + {enterTime.toFixed(1)}s – {exitTime.toFixed(1)}s + + {/if} +
+ + {#if hasTimeRange || isMediaLayer} + +
+ + +
+ + + +
+ + + {#if barLabel && barWidth > 40} + + {barLabel} + + {/if} +
+ {/if} + + {#each keyframeGroups() as group (group.time)} {/each} diff --git a/src/lib/engine/layer-factory.ts b/src/lib/engine/layer-factory.ts index 60763cd..8ff2ef5 100644 --- a/src/lib/engine/layer-factory.ts +++ b/src/lib/engine/layer-factory.ts @@ -11,18 +11,33 @@ import { extractDefaultValues } from '$lib/layers/base'; */ const defaultEasing: Easing = { type: 'ease-in-out' }; +/** + * Options for creating a layer + */ +interface CreateLayerOptions { + /** Initial position */ + x?: number; + y?: number; + /** Enter time - when the layer becomes visible (seconds) */ + enterTime?: number; + /** Exit time - when the layer becomes hidden (seconds) */ + exitTime?: number; +} + /** * Create a new layer of the specified type * @param type - The layer type from the registry * @param propsOverrides - Optional props to override defaults - * @param position - Initial position {x, y} + * @param position - Initial position {x, y} or full CreateLayerOptions */ export function createLayer( type: LayerType, propsOverrides: Record = {}, - position: { x?: number; y?: number } = {} + position: { x?: number; y?: number } | CreateLayerOptions = {} ): Layer { const { x = 0, y = 0 } = position; + const enterTime = 'enterTime' in position ? position.enterTime : undefined; + const exitTime = 'exitTime' in position ? position.exitTime : undefined; const definition = getLayerDefinition(type); // Extract default values from the Zod schema @@ -71,6 +86,8 @@ export function createLayer( props: { ...defaultProps, ...propsOverrides - } + }, + enterTime: enterTime ?? 0, + exitTime }; } diff --git a/src/lib/layers/components/AudioLayer.svelte b/src/lib/layers/components/AudioLayer.svelte new file mode 100644 index 0000000..151cc52 --- /dev/null +++ b/src/lib/layers/components/AudioLayer.svelte @@ -0,0 +1,197 @@ + + + + +{#if src} + +{/if} + +
+ +
+ {#each Array(barCount) as _, i} + {@const barHeight = 20 + Math.sin(i * 0.3) * 30 + Math.cos(i * 0.7) * 20} +
+ {/each} + + +
+ {label} +
+ + +
+ {muted ? 'Muted' : `${Math.round(volume * 100)}%`} +
+
+ + + {#if showCaptions && currentCaption} +
+ {currentCaption} +
+ {/if} +
diff --git a/src/lib/layers/components/ImageLayer.svelte b/src/lib/layers/components/ImageLayer.svelte index b163e6e..581348e 100644 --- a/src/lib/layers/components/ImageLayer.svelte +++ b/src/lib/layers/components/ImageLayer.svelte @@ -5,15 +5,21 @@ /** * Schema for Image Layer custom properties + * + * Now supports both URL sources and uploaded file URLs from S3 storage. */ const schema = z.object({ - src: z.string().default('').describe('Image source URL'), + src: z.string().default('').describe('Image source URL or uploaded file URL'), width: z.number().min(1).max(5000).default(400).describe('Width (px)'), height: z.number().min(1).max(5000).default(300).describe('Height (px)'), objectFit: z .enum(['contain', 'cover', 'fill', 'none', 'scale-down']) .default('contain') - .describe('Object fit mode') + .describe('Object fit mode'), + /** The storage key if file was uploaded (used for cleanup) */ + fileKey: z.string().default('').describe('Storage key (for uploaded files)'), + /** Original filename if uploaded */ + fileName: z.string().default('').describe('Original filename') }); export const meta: LayerMeta = { @@ -21,16 +27,24 @@ type: 'image', label: 'Image', icon: Image, - description: 'Display images from URL with configurable size and object-fit modes' + description: 'Display images from URL or uploaded files with configurable size and object-fit modes' }; type Props = z.infer;
- + {#if src} + {fileName + {:else} +
+ No image source +
+ {/if}
diff --git a/src/lib/layers/components/VideoLayer.svelte b/src/lib/layers/components/VideoLayer.svelte new file mode 100644 index 0000000..714fab4 --- /dev/null +++ b/src/lib/layers/components/VideoLayer.svelte @@ -0,0 +1,116 @@ + + + + +
+ {#if src} + + + {:else} +
+ No video source +
+ {/if} +
diff --git a/src/lib/schemas/animation.ts b/src/lib/schemas/animation.ts index d7be916..73212ad 100644 --- a/src/lib/schemas/animation.ts +++ b/src/lib/schemas/animation.ts @@ -158,7 +158,18 @@ export const LayerSchema = z.object({ * Layer-specific properties validated by the component's Zod schema. * Kept flexible to allow each layer type to define its own props. */ - props: z.record(z.string(), z.unknown()) + props: z.record(z.string(), z.unknown()), + /** + * Enter time - when this layer becomes visible in the timeline (seconds). + * Defaults to 0 (visible from start). + */ + enterTime: z.number().min(0).default(0).optional(), + /** + * Exit time - when this layer stops being visible in the timeline (seconds). + * Defaults to project duration (visible until end). + * If undefined, the layer is visible until the project ends. + */ + exitTime: z.number().min(0).optional() }); // ============================================ diff --git a/src/lib/server/storage/index.ts b/src/lib/server/storage/index.ts new file mode 100644 index 0000000..bd968cb --- /dev/null +++ b/src/lib/server/storage/index.ts @@ -0,0 +1,253 @@ +/** + * Configurable S3-compatible storage provider + * + * Supports any S3-compatible service (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, etc.) + * Configure via environment variables. + */ +import { + S3Client, + PutObjectCommand, + GetObjectCommand, + DeleteObjectCommand, + HeadObjectCommand +} from '@aws-sdk/client-s3'; +import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; +import { nanoid } from 'nanoid'; +import { + PRIVATE_S3_BUCKET, + PRIVATE_S3_REGION, + PRIVATE_S3_ENDPOINT, + PRIVATE_S3_ACCESS_KEY_ID, + PRIVATE_S3_SECRET_ACCESS_KEY, + PRIVATE_S3_PUBLIC_URL +} from '$env/static/private'; + +export interface StorageConfig { + bucket: string; + region: string; + endpoint?: string; + accessKeyId: string; + secretAccessKey: string; + publicUrl?: string; +} + +function getConfig(): StorageConfig { + return { + bucket: PRIVATE_S3_BUCKET || 'devmotion-uploads', + region: PRIVATE_S3_REGION || 'us-east-1', + endpoint: PRIVATE_S3_ENDPOINT || undefined, + accessKeyId: PRIVATE_S3_ACCESS_KEY_ID || '', + secretAccessKey: PRIVATE_S3_SECRET_ACCESS_KEY || '', + publicUrl: PRIVATE_S3_PUBLIC_URL || undefined + }; +} + +let _client: S3Client | null = null; + +function getClient(): S3Client { + if (_client) return _client; + const config = getConfig(); + + _client = new S3Client({ + region: config.region, + ...(config.endpoint ? { endpoint: config.endpoint } : {}), + credentials: { + accessKeyId: config.accessKeyId, + secretAccessKey: config.secretAccessKey + }, + forcePathStyle: !!config.endpoint // Required for MinIO and some S3-compatible services + }); + + return _client; +} + +export type MediaType = 'image' | 'video' | 'audio'; + +/** + * Allowed MIME types per media category + */ +const ALLOWED_MIME_TYPES: Record = { + image: ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'], + video: ['video/mp4', 'video/webm', 'video/quicktime', 'video/x-msvideo'], + audio: ['audio/mpeg', 'audio/wav', 'audio/ogg', 'audio/webm', 'audio/mp4', 'audio/aac'] +}; + +const MAX_FILE_SIZES: Record = { + image: 10 * 1024 * 1024, // 10MB + video: 500 * 1024 * 1024, // 500MB + audio: 100 * 1024 * 1024 // 100MB +}; + +/** + * Get the file extension from a MIME type + */ +function getExtension(mimeType: string): string { + const map: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'video/mp4': 'mp4', + 'video/webm': 'webm', + 'video/quicktime': 'mov', + 'video/x-msvideo': 'avi', + 'audio/mpeg': 'mp3', + 'audio/wav': 'wav', + 'audio/ogg': 'ogg', + 'audio/webm': 'weba', + 'audio/mp4': 'm4a', + 'audio/aac': 'aac' + }; + return map[mimeType] || 'bin'; +} + +/** + * Validate that a file's MIME type is allowed for the given media category + */ +export function validateMediaType(mimeType: string, mediaType: MediaType): boolean { + return ALLOWED_MIME_TYPES[mediaType].includes(mimeType); +} + +/** + * Validate file size + */ +export function validateFileSize(size: number, mediaType: MediaType): boolean { + return size <= MAX_FILE_SIZES[mediaType]; +} + +export interface UploadResult { + /** Unique file ID (used as the storage key) */ + fileId: string; + /** The S3 key */ + key: string; + /** Public URL to access the file */ + url: string; + /** Original filename */ + originalName: string; + /** MIME type */ + mimeType: string; + /** File size in bytes */ + size: number; + /** Media type category */ + mediaType: MediaType; +} + +/** + * Upload a file to S3-compatible storage + */ +export async function uploadFile( + file: Buffer | Uint8Array, + originalName: string, + mimeType: string, + mediaType: MediaType, + projectId?: string +): Promise { + const config = getConfig(); + const client = getClient(); + + const fileId = nanoid(); + const ext = getExtension(mimeType); + const prefix = projectId ? `projects/${projectId}` : 'uploads'; + const key = `${prefix}/${mediaType}/${fileId}.${ext}`; + + await client.send( + new PutObjectCommand({ + Bucket: config.bucket, + Key: key, + Body: file, + ContentType: mimeType, + CacheControl: 'public, max-age=31536000, immutable' + }) + ); + + // Build public URL + let url: string; + if (config.publicUrl) { + url = `${config.publicUrl}/${key}`; + } else if (config.endpoint) { + url = `${config.endpoint}/${config.bucket}/${key}`; + } else { + url = `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`; + } + + return { + fileId, + key, + url, + originalName, + mimeType, + size: file.length, + mediaType + }; +} + +/** + * Get a signed URL for temporary access to a file + */ +export async function getSignedFileUrl(key: string, expiresIn = 3600): Promise { + const config = getConfig(); + const client = getClient(); + + const command = new GetObjectCommand({ + Bucket: config.bucket, + Key: key + }); + + return getSignedUrl(client, command, { expiresIn }); +} + +/** + * Delete a file from storage + */ +export async function deleteFile(key: string): Promise { + const config = getConfig(); + const client = getClient(); + + await client.send( + new DeleteObjectCommand({ + Bucket: config.bucket, + Key: key + }) + ); +} + +/** + * Check if a file exists in storage + */ +export async function fileExists(key: string): Promise { + const config = getConfig(); + const client = getClient(); + + try { + await client.send( + new HeadObjectCommand({ + Bucket: config.bucket, + Key: key + }) + ); + return true; + } catch { + return false; + } +} + +/** + * Detect media type from MIME type + */ +export function detectMediaType(mimeType: string): MediaType | null { + for (const [type, mimes] of Object.entries(ALLOWED_MIME_TYPES)) { + if (mimes.includes(mimeType)) { + return type as MediaType; + } + } + return null; +} + +/** + * Check if storage is configured (has credentials) + */ +export function isStorageConfigured(): boolean { + const config = getConfig(); + return !!(config.accessKeyId && config.secretAccessKey && config.bucket); +} diff --git a/src/lib/server/video-renderer.ts b/src/lib/server/video-renderer.ts index 0f9bec3..04b09a4 100644 --- a/src/lib/server/video-renderer.ts +++ b/src/lib/server/video-renderer.ts @@ -3,6 +3,7 @@ import ffmpeg from 'fluent-ffmpeg'; import { PassThrough, Readable } from 'stream'; import { EventEmitter } from 'events'; import { generateRenderToken, invalidateRenderToken } from './render-token'; +import type { ProjectData } from '$lib/schemas/animation'; interface RenderConfig { projectId: string; @@ -12,6 +13,8 @@ interface RenderConfig { fps: number; duration: number; baseUrl: string; + /** Optional project data for extracting audio tracks */ + projectData?: ProjectData; } export interface RenderProgress { @@ -22,21 +25,70 @@ export interface RenderProgress { error?: string; } +/** + * Audio track info extracted from project layers + */ +interface AudioTrackInfo { + src: string; + enterTime: number; + exitTime: number; + mediaStartTime: number; + mediaEndTime: number; + volume: number; + muted: boolean; +} + +/** + * Extract audio tracks from project data (video and audio layers) + */ +function extractAudioTracks(projectData: ProjectData): AudioTrackInfo[] { + const tracks: AudioTrackInfo[] = []; + + for (const layer of projectData.layers) { + if ((layer.type === 'video' || layer.type === 'audio') && layer.props.src) { + const muted = + layer.type === 'video' + ? (layer.props.muted as boolean) ?? false + : (layer.props.muted as boolean) ?? false; + + if (!muted) { + tracks.push({ + src: layer.props.src as string, + enterTime: layer.enterTime ?? 0, + exitTime: layer.exitTime ?? projectData.duration, + mediaStartTime: (layer.props.mediaStartTime as number) ?? 0, + mediaEndTime: (layer.props.mediaEndTime as number) ?? 0, + volume: (layer.props.volume as number) ?? 1, + muted + }); + } + } + } + + return tracks; +} + // Global emitter for render progress export const renderEmitter = new EventEmitter(); /** * Render a project to video using Playwright screenshots and FFmpeg. * Returns a Readable stream that will yield the MP4 data. + * + * When projectData is provided, audio tracks from video and audio layers + * are extracted and mixed into the final output. */ export async function renderProjectToVideoStream(config: RenderConfig): Promise { - const { projectId, renderId, width, height, fps, duration, baseUrl } = config; + const { projectId, renderId, width, height, fps, duration, baseUrl, projectData } = config; const totalFrames = Math.ceil(fps * duration); const videoStream = new PassThrough(); const token = generateRenderToken(projectId); const renderUrl = `${baseUrl}/render/${projectId}?token=${token}`; + // Extract audio tracks if project data is available + const audioTracks = projectData ? extractAudioTracks(projectData) : []; + // Helper to emit progress const emitProgress = (progress: RenderProgress) => { renderEmitter.emit(`progress:${renderId}`, progress); @@ -94,15 +146,60 @@ export async function renderProjectToVideoStream(config: RenderConfig): Promise< ffmpegCommand = ffmpeg() .input(frameStream) .inputFormat('image2pipe') - .inputFPS(actualFps) - .outputOptions([ - '-c:v libx264', - '-preset ultrafast', // Ultrafast for streaming efficiency - '-crf 18', - '-pix_fmt yuv420p', - `-s ${width}x${height}`, - '-movflags +faststart+frag_keyframe+empty_moov' - ]) + .inputFPS(actualFps); + + // Add audio tracks as additional inputs + for (const track of audioTracks) { + ffmpegCommand = ffmpegCommand.input(track.src); + } + + // Build output options + const outputOptions = [ + '-c:v libx264', + '-preset ultrafast', + '-crf 18', + '-pix_fmt yuv420p', + `-s ${width}x${height}`, + '-movflags +faststart+frag_keyframe+empty_moov' + ]; + + // If we have audio tracks, build a complex filter for mixing + if (audioTracks.length > 0) { + const filterParts: string[] = []; + const audioInputs: string[] = []; + + audioTracks.forEach((track, i) => { + const inputIndex = i + 1; // 0 is the video frame stream + const trimStart = track.mediaStartTime; + const delay = Math.round(track.enterTime * 1000); // ms + const vol = track.volume; + + // Trim and delay each audio track, then adjust volume + let filter = `[${inputIndex}:a]`; + if (trimStart > 0) { + filter += `atrim=start=${trimStart},asetpts=PTS-STARTPTS,`; + } + if (delay > 0) { + filter += `adelay=${delay}|${delay},`; + } + filter += `volume=${vol}[a${i}]`; + filterParts.push(filter); + audioInputs.push(`[a${i}]`); + }); + + // Mix all audio tracks together + if (audioInputs.length > 0) { + filterParts.push( + `${audioInputs.join('')}amix=inputs=${audioInputs.length}:duration=longest[aout]` + ); + outputOptions.push('-filter_complex', filterParts.join(';')); + outputOptions.push('-map', '0:v', '-map', '[aout]'); + outputOptions.push('-c:a aac', '-b:a 192k'); + } + } + + ffmpegCommand = ffmpegCommand + .outputOptions(outputOptions) .format('mp4') .on('error', (err) => { console.error('FFmpeg error:', err); diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index 4f37f4d..9e3a143 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -374,6 +374,150 @@ class ProjectStore { return this.project.layers.find((l) => l.id === this.selectedLayerId) || null; } + // ======================================== + // Enter/Exit Time Operations + // ======================================== + + /** + * Set the enter time for a layer (when it becomes visible) + */ + setLayerEnterTime(layerId: string, enterTime: number) { + this.project.layers = this.project.layers.map((layer) => + layer.id === layerId + ? { ...layer, enterTime: Math.max(0, Math.min(enterTime, this.project.duration)) } + : layer + ); + } + + /** + * Set the exit time for a layer (when it becomes hidden) + */ + setLayerExitTime(layerId: string, exitTime: number) { + this.project.layers = this.project.layers.map((layer) => + layer.id === layerId + ? { ...layer, exitTime: Math.max(0, Math.min(exitTime, this.project.duration)) } + : layer + ); + } + + /** + * Set both enter and exit times for a layer + */ + setLayerTimeRange(layerId: string, enterTime: number, exitTime: number) { + this.project.layers = this.project.layers.map((layer) => + layer.id === layerId + ? { + ...layer, + enterTime: Math.max(0, Math.min(enterTime, this.project.duration)), + exitTime: Math.max(0, Math.min(exitTime, this.project.duration)) + } + : layer + ); + } + + // ======================================== + // Media Layer Operations (Split/Crop/Resize) + // ======================================== + + /** + * Split a media layer (video/audio) at the current time + * 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); + if (!layer) return; + + const time = splitTime ?? this.currentTime; + const enterTime = layer.enterTime ?? 0; + const exitTime = layer.exitTime ?? this.project.duration; + + // Don't split if time is outside the layer's range + if (time <= enterTime || time >= exitTime) return; + + // For media layers, adjust mediaStartTime/mediaEndTime too + const isMediaLayer = layer.type === 'video' || layer.type === 'audio'; + const mediaStartTime = (layer.props.mediaStartTime as number) ?? 0; + const mediaEndTime = (layer.props.mediaEndTime as number) ?? 0; + const mediaDuration = exitTime - enterTime; + const splitRatio = (time - enterTime) / mediaDuration; + + // Create the second half as a new layer + const secondHalf: Layer = { + ...JSON.parse(JSON.stringify(layer)), + id: layer.id + '_split', + name: `${layer.name} (2)`, + enterTime: time, + exitTime: exitTime, + keyframes: layer.keyframes + .filter((k) => k.time >= time) + .map((k) => ({ ...k })) + }; + + if (isMediaLayer && mediaDuration > 0) { + const splitMediaTime = mediaStartTime + (mediaEndTime > 0 + ? (mediaEndTime - mediaStartTime) * splitRatio + : mediaDuration * splitRatio); + secondHalf.props = { + ...secondHalf.props, + mediaStartTime: splitMediaTime + }; + } + + // Update the first half (original layer) + this.project.layers = this.project.layers.map((l) => { + if (l.id === layerId) { + const updated = { + ...l, + exitTime: time, + keyframes: l.keyframes.filter((k) => k.time <= time) + }; + if (isMediaLayer && mediaEndTime > 0 && mediaDuration > 0) { + const splitMediaTime = mediaStartTime + (mediaEndTime - mediaStartTime) * splitRatio; + updated.props = { ...updated.props, mediaEndTime: splitMediaTime }; + } + return updated; + } + return l; + }); + + // Add the second half after the original + const insertIndex = this.project.layers.findIndex((l) => l.id === layerId) + 1; + const layers = [...this.project.layers]; + layers.splice(insertIndex, 0, secondHalf); + this.project.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) => { + if (layer.id === layerId && (layer.type === 'video' || layer.type === 'audio')) { + return { + ...layer, + props: { + ...layer.props, + mediaStartTime: Math.max(0, mediaStartTime), + mediaEndTime: mediaEndTime > 0 ? mediaEndTime : 0 + } + }; + } + return layer; + }); + } + + /** + * 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); + if (!layer) return false; + const t = time ?? this.currentTime; + const enterTime = layer.enterTime ?? 0; + const exitTime = layer.exitTime ?? this.project.duration; + return t >= enterTime && t <= exitTime; + } + /** * Pre-calculate all frames for optimized recording * Uses the same rendering functions as canvas for consistency diff --git a/src/routes/api/captions/+server.ts b/src/routes/api/captions/+server.ts new file mode 100644 index 0000000..2baa004 --- /dev/null +++ b/src/routes/api/captions/+server.ts @@ -0,0 +1,86 @@ +/** + * AI Caption Generation API + * + * Generates timed captions/subtitles for audio files using AI transcription. + * Supports both OpenRouter (for text models) and Vercel AI SDK providers. + */ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { env } from '$env/dynamic/private'; +import { createOpenRouter } from '@openrouter/ai-sdk-provider'; +import { generateText } from 'ai'; + +/** + * POST /api/captions + * + * Accepts an audio URL and generates timed captions. + * The caption format is: "MM:SS.ms - MM:SS.ms | caption text" + * + * For full transcription with timestamps, this uses an AI model + * to generate captions from an audio description or transcript. + */ +export const POST: RequestHandler = async ({ request, locals }) => { + try { + const body = await request.json(); + const { audioUrl, language, style } = body as { + audioUrl: string; + language?: string; + style?: 'subtitle' | 'caption' | 'lyrics'; + }; + + if (!audioUrl) { + error(400, 'audioUrl is required'); + } + + const apiKey = env.OPENROUTER_API_KEY; + if (!apiKey) { + error(503, 'AI service not configured (OPENROUTER_API_KEY missing)'); + } + + const openrouter = createOpenRouter({ apiKey }); + + // Use AI to generate captions + // Note: For production, you'd want to use a dedicated speech-to-text service + // like OpenAI Whisper, AssemblyAI, or Deepgram for actual transcription. + // This implementation uses a text model to generate sample captions + // that can be refined by the user. + const captionStyle = style || 'subtitle'; + const lang = language || 'en'; + + const { text } = await generateText({ + model: openrouter('moonshotai/kimi-k2.5'), + prompt: `You are a professional ${captionStyle} generator. Generate realistic timed ${captionStyle}s for an audio track. + +The audio URL is: ${audioUrl} + +Language: ${lang} +Style: ${captionStyle} + +Generate captions in this exact format (one per line): +MM:SS.ms - MM:SS.ms | caption text + +Example: +0:00.0 - 0:03.5 | Welcome to the presentation +0:03.5 - 0:07.0 | Today we'll discuss animation design +0:07.0 - 0:11.5 | Let's start with the basics + +Generate 10-20 reasonable caption segments that would fit a typical ${captionStyle} track. +Each segment should be 2-5 seconds long. +Only output the caption lines, no other text.` + }); + + return json({ + success: true, + captions: text.trim(), + format: 'timed', + language: lang, + style: captionStyle + }); + } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + console.error('Caption generation error:', err); + error(500, `Failed to generate captions: ${err instanceof Error ? err.message : 'Unknown error'}`); + } +}; diff --git a/src/routes/api/export/[id]/+server.ts b/src/routes/api/export/[id]/+server.ts index d542d3d..6712e03 100644 --- a/src/routes/api/export/[id]/+server.ts +++ b/src/routes/api/export/[id]/+server.ts @@ -54,7 +54,7 @@ export const POST: RequestHandler = async ({ params, request, url, locals }) => const baseUrl = PUBLIC_BASE_URL || 'http://localhost:5173'; try { - // Start rendering and get stream + // Start rendering and get stream (include project data for audio merging) const videoStream = await renderProjectToVideoStream({ projectId: id, renderId, @@ -62,7 +62,8 @@ export const POST: RequestHandler = async ({ params, request, url, locals }) => height: config.height, fps: config.fps, duration: config.duration, - baseUrl + baseUrl, + projectData }); // Return the stream as response diff --git a/src/routes/api/upload/+server.ts b/src/routes/api/upload/+server.ts new file mode 100644 index 0000000..9c71c5c --- /dev/null +++ b/src/routes/api/upload/+server.ts @@ -0,0 +1,90 @@ +/** + * File Upload API Endpoint + * + * Handles multipart file uploads for images, videos, and audio files. + * Stores files in S3-compatible storage and returns URLs. + */ +import { error, json } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { + uploadFile, + validateMediaType, + validateFileSize, + detectMediaType, + isStorageConfigured, + type MediaType +} from '$lib/server/storage'; + +const MAX_REQUEST_SIZE = 500 * 1024 * 1024; // 500MB total + +export const POST: RequestHandler = async ({ request, locals }) => { + // Check if storage is configured + if (!isStorageConfigured()) { + error(503, 'File storage is not configured. Set S3 environment variables.'); + } + + try { + const contentType = request.headers.get('content-type') || ''; + + if (!contentType.includes('multipart/form-data')) { + error(400, 'Expected multipart/form-data'); + } + + const formData = await request.formData(); + const file = formData.get('file') as File | null; + const mediaTypeHint = formData.get('mediaType') as string | null; + const projectId = formData.get('projectId') as string | null; + + if (!file) { + error(400, 'No file provided'); + } + + // Detect media type from MIME type + const mediaType = (mediaTypeHint as MediaType) || detectMediaType(file.type); + if (!mediaType) { + error(400, `Unsupported file type: ${file.type}`); + } + + // Validate MIME type + if (!validateMediaType(file.type, mediaType)) { + error(400, `File type ${file.type} is not allowed for ${mediaType} uploads`); + } + + // Validate file size + if (!validateFileSize(file.size, mediaType)) { + error(400, `File too large for ${mediaType} upload`); + } + + // Convert File to Buffer + const arrayBuffer = await file.arrayBuffer(); + const buffer = Buffer.from(arrayBuffer); + + // Upload to storage + const result = await uploadFile( + buffer, + file.name, + file.type, + mediaType, + projectId || undefined + ); + + return json({ + success: true, + file: { + id: result.fileId, + url: result.url, + key: result.key, + originalName: result.originalName, + mimeType: result.mimeType, + size: result.size, + mediaType: result.mediaType + } + }); + } catch (err) { + if (err && typeof err === 'object' && 'status' in err) { + throw err; // Re-throw SvelteKit errors + } + console.error('Upload error:', err); + error(500, `Upload failed: ${err instanceof Error ? err.message : 'Unknown error'}`); + } +}; From 865cc87dbcf5460bb8e2a3db0e3c9212c2e00c59 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Fri, 6 Feb 2026 08:41:17 +0100 Subject: [PATCH 02/17] bkp --- .env.example | 3 + package.json | 7 +- pnpm-lock.yaml | 1284 +++++++++++++++++ .../components/editor/canvas/canvas.svelte | 3 +- .../editor/panels/properties-panel.svelte | 55 + src/lib/layers/components/AudioLayer.svelte | 13 +- src/lib/layers/components/ImageLayer.svelte | 7 +- src/lib/layers/components/VideoLayer.svelte | 4 +- src/lib/server/video-renderer.ts | 9 +- src/lib/stores/project.svelte.ts | 12 +- src/routes/api/captions/+server.ts | 107 +- src/routes/api/upload/+server.ts | 4 +- 12 files changed, 1435 insertions(+), 73 deletions(-) diff --git a/.env.example b/.env.example index bb93a8f..669e92f 100644 --- a/.env.example +++ b/.env.example @@ -9,6 +9,9 @@ PRIVATE_GOOGLE_CLIENT_SECRET=your_google_client_secret_here # AI Generation (OpenRouter) OPENROUTER_API_KEY=your_openrouter_api_key_here +# OpenAI (Whisper transcription for captions) +OPENAI_API_KEY=your_openai_api_key_here + # S3-compatible Storage (AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, etc.) PRIVATE_S3_BUCKET=devmotion-uploads PRIVATE_S3_REGION=us-east-1 diff --git a/package.json b/package.json index 353ea8a..6b268c6 100644 --- a/package.json +++ b/package.json @@ -63,18 +63,21 @@ "dependencies": { "@ai-sdk/openai": "^3.0.18", "@ai-sdk/svelte": "^4.0.64", + "@aws-sdk/client-s3": "^3.984.0", + "@aws-sdk/s3-request-presigner": "^3.984.0", "@openrouter/ai-sdk-provider": "^2.0.2", "@resvg/resvg-js": "^2.6.2", - "drizzle-orm": "^0.45.1", "@sveltejs/adapter-node": "^5.5.2", "@vercel/mcp-adapter": "^1.0.0", "ai": "^6.0.49", "better-auth": "^1.3.27", - "fluent-ffmpeg": "^2.1.3", "bezier-easing": "^2.1.0", "dompurify": "^3.3.1", + "drizzle-orm": "^0.45.1", + "fluent-ffmpeg": "^2.1.3", "mediabunny": "^1.30.1", "nanoid": "^5.1.6", + "openai": "^6.18.0", "playwright": "^1.58.0", "postgres": "^3.4.7", "runed": "^0.37.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ed1ea5d..b930bc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: '@ai-sdk/svelte': specifier: ^4.0.64 version: 4.0.64(svelte@5.48.2)(zod@4.3.6) + '@aws-sdk/client-s3': + specifier: ^3.984.0 + version: 3.984.0 + '@aws-sdk/s3-request-presigner': + specifier: ^3.984.0 + version: 3.984.0 '@openrouter/ai-sdk-provider': specifier: ^2.0.2 version: 2.0.2(ai@6.0.49(zod@4.3.6))(zod@4.3.6) @@ -50,6 +56,9 @@ importers: nanoid: specifier: ^5.1.6 version: 5.1.6 + openai: + specifier: ^6.18.0 + version: 6.18.0(ws@8.18.3)(zod@4.3.6) playwright: specifier: ^1.58.0 version: 1.58.0 @@ -229,6 +238,181 @@ packages: peerDependencies: svelte: ^5.31.0 + '@aws-crypto/crc32@5.2.0': + resolution: {integrity: sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/crc32c@5.2.0': + resolution: {integrity: sha512-+iWb8qaHLYKrNvGRbiYRHSdKRWhto5XlZUEBwDjYNf+ly5SVYG6zEoYIdxvf5R3zyeP16w4PLBn3rH1xc74Rag==} + + '@aws-crypto/sha1-browser@5.2.0': + resolution: {integrity: sha512-OH6lveCFfcDjX4dbAvCFSYUjJZjDr/3XJ3xHtjn3Oj5b9RjojQo8npoLeA/bNwkOkrSQ0wgrHzXk4tDRxGKJeg==} + + '@aws-crypto/sha256-browser@5.2.0': + resolution: {integrity: sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==} + + '@aws-crypto/sha256-js@5.2.0': + resolution: {integrity: sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==} + engines: {node: '>=16.0.0'} + + '@aws-crypto/supports-web-crypto@5.2.0': + resolution: {integrity: sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==} + + '@aws-crypto/util@5.2.0': + resolution: {integrity: sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==} + + '@aws-sdk/client-s3@3.984.0': + resolution: {integrity: sha512-7ny2Slr93Y+QniuluvcfWwyDi32zWQfznynL56Tk0vVh7bWrvS/odm8WP2nInKicRVNipcJHY2YInur6Q/9V0A==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/client-sso@3.982.0': + resolution: {integrity: sha512-qJrIiivmvujdGqJ0ldSUvhN3k3N7GtPesoOI1BSt0fNXovVnMz4C/JmnkhZihU7hJhDvxJaBROLYTU+lpild4w==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/core@3.973.6': + resolution: {integrity: sha512-pz4ZOw3BLG0NdF25HoB9ymSYyPbMiIjwQJ2aROXRhAzt+b+EOxStfFv8s5iZyP6Kiw7aYhyWxj5G3NhmkoOTKw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/crc64-nvme@3.972.0': + resolution: {integrity: sha512-ThlLhTqX68jvoIVv+pryOdb5coP1cX1/MaTbB9xkGDCbWbsqQcLqzPxuSoW1DCnAAIacmXCWpzUNOB9pv+xXQw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-env@3.972.4': + resolution: {integrity: sha512-/8dnc7+XNMmViEom2xsNdArQxQPSgy4Z/lm6qaFPTrMFesT1bV3PsBhb19n09nmxHdrtQskYmViddUIjUQElXg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-http@3.972.6': + resolution: {integrity: sha512-5ERWqRljiZv44AIdvIRQ3k+EAV0Sq2WeJHvXuK7gL7bovSxOf8Al7MLH7Eh3rdovH4KHFnlIty7J71mzvQBl5Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.972.4': + resolution: {integrity: sha512-eRUg+3HaUKuXWn/lEMirdiA5HOKmEl8hEHVuszIDt2MMBUKgVX5XNGmb3XmbgU17h6DZ+RtjbxQpjhz3SbTjZg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-login@3.972.4': + resolution: {integrity: sha512-nLGjXuvWWDlQAp505xIONI7Gam0vw2p7Qu3P6on/W2q7rjJXtYjtpHbcsaOjJ/pAju3eTvEQuSuRedcRHVQIAQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-node@3.972.5': + resolution: {integrity: sha512-VWXKgSISQCI2GKN3zakTNHSiZ0+mux7v6YHmmbLQp/o3fvYUQJmKGcLZZzg2GFA+tGGBStplra9VFNf/WwxpYg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-process@3.972.4': + resolution: {integrity: sha512-TCZpWUnBQN1YPk6grvd5x419OfXjHvhj5Oj44GYb84dOVChpg/+2VoEj+YVA4F4E/6huQPNnX7UYbTtxJqgihw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.972.4': + resolution: {integrity: sha512-wzsGwv9mKlwJ3vHLyembBvGE/5nPUIwRR2I51B1cBV4Cb4ql9nIIfpmHzm050XYTY5fqTOKJQnhLj7zj89VG8g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-web-identity@3.972.4': + resolution: {integrity: sha512-hIzw2XzrG8jzsUSEatehmpkd5rWzASg5IHUfA+m01k/RtvfAML7ZJVVohuKdhAYx+wV2AThLiQJVzqn7F0khrw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-bucket-endpoint@3.972.3': + resolution: {integrity: sha512-fmbgWYirF67YF1GfD7cg5N6HHQ96EyRNx/rDIrTF277/zTWVuPI2qS/ZHgofwR1NZPe/NWvoppflQY01LrbVLg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-expect-continue@3.972.3': + resolution: {integrity: sha512-4msC33RZsXQpUKR5QR4HnvBSNCPLGHmB55oDiROqqgyOc+TOfVu2xgi5goA7ms6MdZLeEh2905UfWMnMMF4mRg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-flexible-checksums@3.972.4': + resolution: {integrity: sha512-xOxsUkF3O3BtIe3tf54OpPo94eZepjFm3z0Dd2TZKbsPxMiRTFXurC04wJ58o/wPW9YHVO9VqZik3MfoPfrKlw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-host-header@3.972.3': + resolution: {integrity: sha512-aknPTb2M+G3s+0qLCx4Li/qGZH8IIYjugHMv15JTYMe6mgZO8VBpYgeGYsNMGCqCZOcWzuf900jFBG5bopfzmA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-location-constraint@3.972.3': + resolution: {integrity: sha512-nIg64CVrsXp67vbK0U1/Is8rik3huS3QkRHn2DRDx4NldrEFMgdkZGI/+cZMKD9k4YOS110Dfu21KZLHrFA/1g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-logger@3.972.3': + resolution: {integrity: sha512-Ftg09xNNRqaz9QNzlfdQWfpqMCJbsQdnZVJP55jfhbKi1+FTWxGuvfPoBhDHIovqWKjqbuiew3HuhxbJ0+OjgA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-recursion-detection@3.972.3': + resolution: {integrity: sha512-PY57QhzNuXHnwbJgbWYTrqIDHYSeOlhfYERTAuc16LKZpTZRJUjzBFokp9hF7u1fuGeE3D70ERXzdbMBOqQz7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-sdk-s3@3.972.6': + resolution: {integrity: sha512-Xq7wM6kbgJN1UO++8dvH/efPb1nTwWqFCpZCR7RCLOETP7xAUAhVo7JmsCnML5Di/iC4Oo5VrJ4QmkYcMZniLw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-ssec@3.972.3': + resolution: {integrity: sha512-dU6kDuULN3o3jEHcjm0c4zWJlY1zWVkjG9NPe9qxYLLpcbdj5kRYBS2DdWYD+1B9f910DezRuws7xDEqKkHQIg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/middleware-user-agent@3.972.6': + resolution: {integrity: sha512-TehLN8W/kivl0U9HcS+keryElEWORROpghDXZBLfnb40DXM7hx/i+7OOjkogXQOF3QtUraJVRkHQ07bPhrWKlw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/nested-clients@3.982.0': + resolution: {integrity: sha512-VVkaH27digrJfdVrT64rjkllvOp4oRiZuuJvrylLXAKl18ujToJR7AqpDldL/LS63RVne3QWIpkygIymxFtliQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/region-config-resolver@3.972.3': + resolution: {integrity: sha512-v4J8qYAWfOMcZ4MJUyatntOicTzEMaU7j3OpkRCGGFSL2NgXQ5VbxauIyORA+pxdKZ0qQG2tCQjQjZDlXEC3Ow==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/s3-request-presigner@3.984.0': + resolution: {integrity: sha512-sIpkoIo0GhaTdkLt3DeE8HiuKwvMqxEVqruOC0ONSFarKM3/jJuNalZH+PEqHjbAoa2yvW5DgHK7p3S8MIzUZQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/signature-v4-multi-region@3.984.0': + resolution: {integrity: sha512-TaWbfYCwnuOSvDSrgs7QgoaoXse49E7LzUkVOUhoezwB7bkmhp+iojADm7UepCEu4021SquD7NG1xA+WCvmldA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/token-providers@3.982.0': + resolution: {integrity: sha512-v3M0KYp2TVHYHNBT7jHD9lLTWAdS9CaWJ2jboRKt0WAB65bA7iUEpR+k4VqKYtpQN4+8kKSc4w+K6kUNZkHKQw==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/types@3.973.1': + resolution: {integrity: sha512-DwHBiMNOB468JiX6+i34c+THsKHErYUdNQ3HexeXZvVn4zouLjgaS4FejiGSi2HyBuzuyHg7SuOPmjSvoU9NRg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-arn-parser@3.972.2': + resolution: {integrity: sha512-VkykWbqMjlSgBFDyrY3nOSqupMc6ivXuGmvci6Q3NnLq5kC+mKQe2QBZ4nrWRE/jqOxeFP2uYzLtwncYYcvQDg==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.982.0': + resolution: {integrity: sha512-M27u8FJP7O0Of9hMWX5dipp//8iglmV9jr7R8SR8RveU+Z50/8TqH68Tu6wUWBGMfXjzbVwn1INIAO5lZrlxXQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-endpoints@3.984.0': + resolution: {integrity: sha512-9ebjLA0hMKHeVvXEtTDCCOBtwjb0bOXiuUV06HNeVdgAjH6gj4x4Zwt4IBti83TiyTGOCl5YfZqGx4ehVsasbQ==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-format-url@3.972.3': + resolution: {integrity: sha512-n7F2ycckcKFXa01vAsT/SJdjFHfKH9s96QHcs5gn8AaaigASICeME8WdUL9uBp8XV/OVwEt8+6gzn6KFUgQa8g==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-locate-window@3.965.4': + resolution: {integrity: sha512-H1onv5SkgPBK2P6JR2MjGgbOnttoNzSPIRoeZTNPZYyaplwGg50zS3amXvXqF0/qfXpWEC9rLWU564QTB9bSog==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/util-user-agent-browser@3.972.3': + resolution: {integrity: sha512-JurOwkRUcXD/5MTDBcqdyQ9eVedtAsZgw5rBwktsPTN7QtPiS2Ld1jkJepNgYoCufz1Wcut9iup7GJDoIHp8Fw==} + + '@aws-sdk/util-user-agent-node@3.972.4': + resolution: {integrity: sha512-3WFCBLiM8QiHDfosQq3Py+lIMgWlFWwFQliUHUqwEiRqLnKyhgbU3AKa7AWJF7lW2Oc/2kFNY4MlAYVnVc0i8A==} + engines: {node: '>=20.0.0'} + peerDependencies: + aws-crt: '>=1.0.0' + peerDependenciesMeta: + aws-crt: + optional: true + + '@aws-sdk/xml-builder@3.972.4': + resolution: {integrity: sha512-0zJ05ANfYqI6+rGqj8samZBFod0dPPousBjLEqg8WdxSgbMAkRgLyn81lP215Do0rFJ/17LIXwr7q0yK24mP6Q==} + engines: {node: '>=20.0.0'} + + '@aws/lambda-invoke-store@0.2.3': + resolution: {integrity: sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw==} + engines: {node: '>=18.0.0'} + '@babel/code-frame@7.27.1': resolution: {integrity: sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==} engines: {node: '>=6.9.0'} @@ -1026,6 +1210,222 @@ packages: '@sinclair/typebox@0.31.28': resolution: {integrity: sha512-/s55Jujywdw/Jpan+vsy6JZs1z2ZTGxTmbZTPiuSL2wz9mfzA2gN1zzaqmvfi4pq+uOt7Du85fkiwv5ymW84aQ==} + '@smithy/abort-controller@4.2.8': + resolution: {integrity: sha512-peuVfkYHAmS5ybKxWcfraK7WBBP0J+rkfUcbHJJKQ4ir3UAUNQI+Y4Vt/PqSzGqgloJ5O1dk7+WzNL8wcCSXbw==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader-native@4.2.1': + resolution: {integrity: sha512-lX9Ay+6LisTfpLid2zZtIhSEjHMZoAR5hHCR4H7tBz/Zkfr5ea8RcQ7Tk4mi0P76p4cN+Btz16Ffno7YHpKXnQ==} + engines: {node: '>=18.0.0'} + + '@smithy/chunked-blob-reader@5.2.0': + resolution: {integrity: sha512-WmU0TnhEAJLWvfSeMxBNe5xtbselEO8+4wG0NtZeL8oR21WgH1xiO37El+/Y+H/Ie4SCwBy3MxYWmOYaGgZueA==} + engines: {node: '>=18.0.0'} + + '@smithy/config-resolver@4.4.6': + resolution: {integrity: sha512-qJpzYC64kaj3S0fueiu3kXm8xPrR3PcXDPEgnaNMRn0EjNSZFoFjvbUp0YUDsRhN1CB90EnHJtbxWKevnH99UQ==} + engines: {node: '>=18.0.0'} + + '@smithy/core@3.22.1': + resolution: {integrity: sha512-x3ie6Crr58MWrm4viHqqy2Du2rHYZjwu8BekasrQx4ca+Y24dzVAwq3yErdqIbc2G3I0kLQA13PQ+/rde+u65g==} + engines: {node: '>=18.0.0'} + + '@smithy/credential-provider-imds@4.2.8': + resolution: {integrity: sha512-FNT0xHS1c/CPN8upqbMFP83+ul5YgdisfCfkZ86Jh2NSmnqw/AJ6x5pEogVCTVvSm7j9MopRU89bmDelxuDMYw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-codec@4.2.8': + resolution: {integrity: sha512-jS/O5Q14UsufqoGhov7dHLOPCzkYJl9QDzusI2Psh4wyYx/izhzvX9P4D69aTxcdfVhEPhjK+wYyn/PzLjKbbw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-browser@4.2.8': + resolution: {integrity: sha512-MTfQT/CRQz5g24ayXdjg53V0mhucZth4PESoA5IhvaWVDTOQLfo8qI9vzqHcPsdd2v6sqfTYqF5L/l+pea5Uyw==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-config-resolver@4.3.8': + resolution: {integrity: sha512-ah12+luBiDGzBruhu3efNy1IlbwSEdNiw8fOZksoKoWW1ZHvO/04MQsdnws/9Aj+5b0YXSSN2JXKy/ClIsW8MQ==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-node@4.2.8': + resolution: {integrity: sha512-cYpCpp29z6EJHa5T9WL0KAlq3SOKUQkcgSoeRfRVwjGgSFl7Uh32eYGt7IDYCX20skiEdRffyDpvF2efEZPC0A==} + engines: {node: '>=18.0.0'} + + '@smithy/eventstream-serde-universal@4.2.8': + resolution: {integrity: sha512-iJ6YNJd0bntJYnX6s52NC4WFYcZeKrPUr1Kmmr5AwZcwCSzVpS7oavAmxMR7pMq7V+D1G4s9F5NJK0xwOsKAlQ==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.3.9': + resolution: {integrity: sha512-I4UhmcTYXBrct03rwzQX1Y/iqQlzVQaPxWjCjula++5EmWq9YGBrx6bbGqluGc1f0XEfhSkiY4jhLgbsJUMKRA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-blob-browser@4.2.9': + resolution: {integrity: sha512-m80d/iicI7DlBDxyQP6Th7BW/ejDGiF0bgI754+tiwK0lgMkcaIBgvwwVc7OFbY4eUzpGtnig52MhPAEJ7iNYg==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-node@4.2.8': + resolution: {integrity: sha512-7ZIlPbmaDGxVoxErDZnuFG18WekhbA/g2/i97wGj+wUBeS6pcUeAym8u4BXh/75RXWhgIJhyC11hBzig6MljwA==} + engines: {node: '>=18.0.0'} + + '@smithy/hash-stream-node@4.2.8': + resolution: {integrity: sha512-v0FLTXgHrTeheYZFGhR+ehX5qUm4IQsjAiL9qehad2cyjMWcN2QG6/4mSwbSgEQzI7jwfoXj7z4fxZUx/Mhj2w==} + engines: {node: '>=18.0.0'} + + '@smithy/invalid-dependency@4.2.8': + resolution: {integrity: sha512-N9iozRybwAQ2dn9Fot9kI6/w9vos2oTXLhtK7ovGqwZjlOcxu6XhPlpLpC+INsxktqHinn5gS2DXDjDF2kG5sQ==} + engines: {node: '>=18.0.0'} + + '@smithy/is-array-buffer@2.2.0': + resolution: {integrity: sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==} + engines: {node: '>=14.0.0'} + + '@smithy/is-array-buffer@4.2.0': + resolution: {integrity: sha512-DZZZBvC7sjcYh4MazJSGiWMI2L7E0oCiRHREDzIxi/M2LY79/21iXt6aPLHge82wi5LsuRF5A06Ds3+0mlh6CQ==} + engines: {node: '>=18.0.0'} + + '@smithy/md5-js@4.2.8': + resolution: {integrity: sha512-oGMaLj4tVZzLi3itBa9TCswgMBr7k9b+qKYowQ6x1rTyTuO1IU2YHdHUa+891OsOH+wCsH7aTPRsTJO3RMQmjQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-content-length@4.2.8': + resolution: {integrity: sha512-RO0jeoaYAB1qBRhfVyq0pMgBoUK34YEJxVxyjOWYZiOKOq2yMZ4MnVXMZCUDenpozHue207+9P5ilTV1zeda0A==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-endpoint@4.4.13': + resolution: {integrity: sha512-x6vn0PjYmGdNuKh/juUJJewZh7MoQ46jYaJ2mvekF4EesMuFfrl4LaW/k97Zjf8PTCPQmPgMvwewg7eNoH9n5w==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-retry@4.4.30': + resolution: {integrity: sha512-CBGyFvN0f8hlnqKH/jckRDz78Snrp345+PVk8Ux7pnkUCW97Iinse59lY78hBt04h1GZ6hjBN94BRwZy1xC8Bg==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-serde@4.2.9': + resolution: {integrity: sha512-eMNiej0u/snzDvlqRGSN3Vl0ESn3838+nKyVfF2FKNXFbi4SERYT6PR392D39iczngbqqGG0Jl1DlCnp7tBbXQ==} + engines: {node: '>=18.0.0'} + + '@smithy/middleware-stack@4.2.8': + resolution: {integrity: sha512-w6LCfOviTYQjBctOKSwy6A8FIkQy7ICvglrZFl6Bw4FmcQ1Z420fUtIhxaUZZshRe0VCq4kvDiPiXrPZAe8oRA==} + engines: {node: '>=18.0.0'} + + '@smithy/node-config-provider@4.3.8': + resolution: {integrity: sha512-aFP1ai4lrbVlWjfpAfRSL8KFcnJQYfTl5QxLJXY32vghJrDuFyPZ6LtUL+JEGYiFRG1PfPLHLoxj107ulncLIg==} + engines: {node: '>=18.0.0'} + + '@smithy/node-http-handler@4.4.9': + resolution: {integrity: sha512-KX5Wml5mF+luxm1szW4QDz32e3NObgJ4Fyw+irhph4I/2geXwUy4jkIMUs5ZPGflRBeR6BUkC2wqIab4Llgm3w==} + engines: {node: '>=18.0.0'} + + '@smithy/property-provider@4.2.8': + resolution: {integrity: sha512-EtCTbyIveCKeOXDSWSdze3k612yCPq1YbXsbqX3UHhkOSW8zKsM9NOJG5gTIya0vbY2DIaieG8pKo1rITHYL0w==} + engines: {node: '>=18.0.0'} + + '@smithy/protocol-http@5.3.8': + resolution: {integrity: sha512-QNINVDhxpZ5QnP3aviNHQFlRogQZDfYlCkQT+7tJnErPQbDhysondEjhikuANxgMsZrkGeiAxXy4jguEGsDrWQ==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-builder@4.2.8': + resolution: {integrity: sha512-Xr83r31+DrE8CP3MqPgMJl+pQlLLmOfiEUnoyAlGzzJIrEsbKsPy1hqH0qySaQm4oWrCBlUqRt+idEgunKB+iw==} + engines: {node: '>=18.0.0'} + + '@smithy/querystring-parser@4.2.8': + resolution: {integrity: sha512-vUurovluVy50CUlazOiXkPq40KGvGWSdmusa3130MwrR1UNnNgKAlj58wlOe61XSHRpUfIIh6cE0zZ8mzKaDPA==} + engines: {node: '>=18.0.0'} + + '@smithy/service-error-classification@4.2.8': + resolution: {integrity: sha512-mZ5xddodpJhEt3RkCjbmUQuXUOaPNTkbMGR0bcS8FE0bJDLMZlhmpgrvPNCYglVw5rsYTpSnv19womw9WWXKQQ==} + engines: {node: '>=18.0.0'} + + '@smithy/shared-ini-file-loader@4.4.3': + resolution: {integrity: sha512-DfQjxXQnzC5UbCUPeC3Ie8u+rIWZTvuDPAGU/BxzrOGhRvgUanaP68kDZA+jaT3ZI+djOf+4dERGlm9mWfFDrg==} + engines: {node: '>=18.0.0'} + + '@smithy/signature-v4@5.3.8': + resolution: {integrity: sha512-6A4vdGj7qKNRF16UIcO8HhHjKW27thsxYci+5r/uVRkdcBEkOEiY8OMPuydLX4QHSrJqGHPJzPRwwVTqbLZJhg==} + engines: {node: '>=18.0.0'} + + '@smithy/smithy-client@4.11.2': + resolution: {integrity: sha512-SCkGmFak/xC1n7hKRsUr6wOnBTJ3L22Qd4e8H1fQIuKTAjntwgU8lrdMe7uHdiT2mJAOWA/60qaW9tiMu69n1A==} + engines: {node: '>=18.0.0'} + + '@smithy/types@4.12.0': + resolution: {integrity: sha512-9YcuJVTOBDjg9LWo23Qp0lTQ3D7fQsQtwle0jVfpbUHy9qBwCEgKuVH4FqFB3VYu0nwdHKiEMA+oXz7oV8X1kw==} + engines: {node: '>=18.0.0'} + + '@smithy/url-parser@4.2.8': + resolution: {integrity: sha512-NQho9U68TGMEU639YkXnVMV3GEFFULmmaWdlu1E9qzyIePOHsoSnagTGSDv1Zi8DCNN6btxOSdgmy5E/hsZwhA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-base64@4.3.0': + resolution: {integrity: sha512-GkXZ59JfyxsIwNTWFnjmFEI8kZpRNIBfxKjv09+nkAWPt/4aGaEWMM04m4sxgNVWkbt2MdSvE3KF/PfX4nFedQ==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-browser@4.2.0': + resolution: {integrity: sha512-Fkoh/I76szMKJnBXWPdFkQJl2r9SjPt3cMzLdOB6eJ4Pnpas8hVoWPYemX/peO0yrrvldgCUVJqOAjUrOLjbxg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-body-length-node@4.2.1': + resolution: {integrity: sha512-h53dz/pISVrVrfxV1iqXlx5pRg3V2YWFcSQyPyXZRrZoZj4R4DeWRDo1a7dd3CPTcFi3kE+98tuNyD2axyZReA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-buffer-from@2.2.0': + resolution: {integrity: sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==} + engines: {node: '>=14.0.0'} + + '@smithy/util-buffer-from@4.2.0': + resolution: {integrity: sha512-kAY9hTKulTNevM2nlRtxAG2FQ3B2OR6QIrPY3zE5LqJy1oxzmgBGsHLWTcNhWXKchgA0WHW+mZkQrng/pgcCew==} + engines: {node: '>=18.0.0'} + + '@smithy/util-config-provider@4.2.0': + resolution: {integrity: sha512-YEjpl6XJ36FTKmD+kRJJWYvrHeUvm5ykaUS5xK+6oXffQPHeEM4/nXlZPe+Wu0lsgRUcNZiliYNh/y7q9c2y6Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-browser@4.3.29': + resolution: {integrity: sha512-nIGy3DNRmOjaYaaKcQDzmWsro9uxlaqUOhZDHQed9MW/GmkBZPtnU70Pu1+GT9IBmUXwRdDuiyaeiy9Xtpn3+Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-defaults-mode-node@4.2.32': + resolution: {integrity: sha512-7dtFff6pu5fsjqrVve0YMhrnzJtccCWDacNKOkiZjJ++fmjGExmmSu341x+WU6Oc1IccL7lDuaUj7SfrHpWc5Q==} + engines: {node: '>=18.0.0'} + + '@smithy/util-endpoints@3.2.8': + resolution: {integrity: sha512-8JaVTn3pBDkhZgHQ8R0epwWt+BqPSLCjdjXXusK1onwJlRuN69fbvSK66aIKKO7SwVFM6x2J2ox5X8pOaWcUEw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-hex-encoding@4.2.0': + resolution: {integrity: sha512-CCQBwJIvXMLKxVbO88IukazJD9a4kQ9ZN7/UMGBjBcJYvatpWk+9g870El4cB8/EJxfe+k+y0GmR9CAzkF+Nbw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-middleware@4.2.8': + resolution: {integrity: sha512-PMqfeJxLcNPMDgvPbbLl/2Vpin+luxqTGPpW3NAQVLbRrFRzTa4rNAASYeIGjRV9Ytuhzny39SpyU04EQreF+A==} + engines: {node: '>=18.0.0'} + + '@smithy/util-retry@4.2.8': + resolution: {integrity: sha512-CfJqwvoRY0kTGe5AkQokpURNCT1u/MkRzMTASWMPPo2hNSnKtF1D45dQl3DE2LKLr4m+PW9mCeBMJr5mCAVThg==} + engines: {node: '>=18.0.0'} + + '@smithy/util-stream@4.5.11': + resolution: {integrity: sha512-lKmZ0S/3Qj2OF5H1+VzvDLb6kRxGzZHq6f3rAsoSu5cTLGsn3v3VQBA8czkNNXlLjoFEtVu3OQT2jEeOtOE2CA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-uri-escape@4.2.0': + resolution: {integrity: sha512-igZpCKV9+E/Mzrpq6YacdTQ0qTiLm85gD6N/IrmyDvQFA4UnU3d5g3m8tMT/6zG/vVkWSU+VxeUyGonL62DuxA==} + engines: {node: '>=18.0.0'} + + '@smithy/util-utf8@2.3.0': + resolution: {integrity: sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==} + engines: {node: '>=14.0.0'} + + '@smithy/util-utf8@4.2.0': + resolution: {integrity: sha512-zBPfuzoI8xyBtR2P6WQj63Rz8i3AmfAaJLuNG8dWsfvPe8lO4aCPYLn879mEgHndZH1zQ2oXmG8O1GGzzaoZiw==} + engines: {node: '>=18.0.0'} + + '@smithy/util-waiter@4.2.8': + resolution: {integrity: sha512-n+lahlMWk+aejGuax7DPWtqav8HYnWxQwR+LCG2BgCUmaGcTe9qZCFsmw8TMg9iG75HOwhrJCX9TCJRLH+Yzqg==} + engines: {node: '>=18.0.0'} + + '@smithy/uuid@1.1.0': + resolution: {integrity: sha512-4aUIteuyxtBUhVdiQqcDhKFitwfd9hqoSDYY2KRXiWtgoWJ9Bmise+KfEPDiVHWeJepvF8xJO9/9+WDIciMFFw==} + engines: {node: '>=18.0.0'} + '@sqlite.org/sqlite-wasm@3.48.0-build4': resolution: {integrity: sha512-hI6twvUkzOmyGZhQMza1gpfqErZxXRw6JEsiVjUbo7tFanVD+8Oil0Ih3l2nGzHdxPI41zFmfUQG7GHqhciKZQ==} hasBin: true @@ -1484,6 +1884,9 @@ packages: boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + bowser@2.13.1: + resolution: {integrity: sha512-OHawaAbjwx6rqICCKgSG0SAnT05bzd7ppyKLVUITZpANBaaMFBAsaNkto3LoQ31tyFP5kNujE8Cdx85G9VzOkw==} + brace-expansion@1.1.12: resolution: {integrity: sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==} @@ -1992,6 +2395,10 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fast-xml-parser@5.3.4: + resolution: {integrity: sha512-EFd6afGmXlCx8H8WTZHhAoDaWaGyuIBoZJ2mknrNxug+aZKjkp0a0dlars9Izl+jF+7Gu1/5f/2h68cQpe0IiA==} + hasBin: true + fdir@6.5.0: resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} engines: {node: '>=12.0.0'} @@ -2460,6 +2867,18 @@ packages: once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + openai@6.18.0: + resolution: {integrity: sha512-odLRYyz9rlzz6g8gKn61RM2oP5UUm428sE2zOxZqS9MzVfD5/XW8UoEjpnRkzTuScXP7ZbP/m7fC+bl8jCOZZw==} + hasBin: true + peerDependencies: + ws: ^8.18.0 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + ws: + optional: true + zod: + optional: true + optionator@0.9.4: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} @@ -2876,6 +3295,9 @@ packages: strip-literal@3.1.0: resolution: {integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==} + strnum@2.1.2: + resolution: {integrity: sha512-l63NF9y/cLROq/yqKXSLtcMeeyOfnSQlfMSlzFt/K73oIaD8DGaQWd7Z34X9GPiKqP5rbSh84Hl4bOlLcjiSrQ==} + style-to-object@1.0.11: resolution: {integrity: sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==} @@ -3332,6 +3754,517 @@ snapshots: transitivePeerDependencies: - zod + '@aws-crypto/crc32@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/crc32c@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/sha1-browser@5.2.0': + dependencies: + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-browser@5.2.0': + dependencies: + '@aws-crypto/sha256-js': 5.2.0 + '@aws-crypto/supports-web-crypto': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-locate-window': 3.965.4 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-crypto/sha256-js@5.2.0': + dependencies: + '@aws-crypto/util': 5.2.0 + '@aws-sdk/types': 3.973.1 + tslib: 2.8.1 + + '@aws-crypto/supports-web-crypto@5.2.0': + dependencies: + tslib: 2.8.1 + + '@aws-crypto/util@5.2.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/util-utf8': 2.3.0 + tslib: 2.8.1 + + '@aws-sdk/client-s3@3.984.0': + dependencies: + '@aws-crypto/sha1-browser': 5.2.0 + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.6 + '@aws-sdk/credential-provider-node': 3.972.5 + '@aws-sdk/middleware-bucket-endpoint': 3.972.3 + '@aws-sdk/middleware-expect-continue': 3.972.3 + '@aws-sdk/middleware-flexible-checksums': 3.972.4 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-location-constraint': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-sdk-s3': 3.972.6 + '@aws-sdk/middleware-ssec': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.6 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/signature-v4-multi-region': 3.984.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.984.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.4 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.1 + '@smithy/eventstream-serde-browser': 4.2.8 + '@smithy/eventstream-serde-config-resolver': 4.3.8 + '@smithy/eventstream-serde-node': 4.2.8 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-blob-browser': 4.2.9 + '@smithy/hash-node': 4.2.8 + '@smithy/hash-stream-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/md5-js': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.29 + '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-stream': 4.5.11 + '@smithy/util-utf8': 4.2.0 + '@smithy/util-waiter': 4.2.8 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/client-sso@3.982.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.6 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.6 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.982.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.4 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.29 + '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/core@3.973.6': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws-sdk/xml-builder': 3.972.4 + '@smithy/core': 3.22.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/crc64-nvme@3.972.0': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-env@3.972.4': + dependencies: + '@aws-sdk/core': 3.973.6 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-http@3.972.6': + dependencies: + '@aws-sdk/core': 3.973.6 + '@aws-sdk/types': 3.973.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.9 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.11 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.972.4': + dependencies: + '@aws-sdk/core': 3.973.6 + '@aws-sdk/credential-provider-env': 3.972.4 + '@aws-sdk/credential-provider-http': 3.972.6 + '@aws-sdk/credential-provider-login': 3.972.4 + '@aws-sdk/credential-provider-process': 3.972.4 + '@aws-sdk/credential-provider-sso': 3.972.4 + '@aws-sdk/credential-provider-web-identity': 3.972.4 + '@aws-sdk/nested-clients': 3.982.0 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-login@3.972.4': + dependencies: + '@aws-sdk/core': 3.973.6 + '@aws-sdk/nested-clients': 3.982.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-node@3.972.5': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.4 + '@aws-sdk/credential-provider-http': 3.972.6 + '@aws-sdk/credential-provider-ini': 3.972.4 + '@aws-sdk/credential-provider-process': 3.972.4 + '@aws-sdk/credential-provider-sso': 3.972.4 + '@aws-sdk/credential-provider-web-identity': 3.972.4 + '@aws-sdk/types': 3.973.1 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-process@3.972.4': + dependencies: + '@aws-sdk/core': 3.973.6 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.972.4': + dependencies: + '@aws-sdk/client-sso': 3.982.0 + '@aws-sdk/core': 3.973.6 + '@aws-sdk/token-providers': 3.982.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/credential-provider-web-identity@3.972.4': + dependencies: + '@aws-sdk/core': 3.973.6 + '@aws-sdk/nested-clients': 3.982.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/middleware-bucket-endpoint@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-arn-parser': 3.972.2 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-expect-continue@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-flexible-checksums@3.972.4': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@aws-crypto/crc32c': 5.2.0 + '@aws-crypto/util': 5.2.0 + '@aws-sdk/core': 3.973.6 + '@aws-sdk/crc64-nvme': 3.972.0 + '@aws-sdk/types': 3.973.1 + '@smithy/is-array-buffer': 4.2.0 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.11 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-host-header@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-location-constraint@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-logger@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-recursion-detection@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@aws/lambda-invoke-store': 0.2.3 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-sdk-s3@3.972.6': + dependencies: + '@aws-sdk/core': 3.973.6 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-arn-parser': 3.972.2 + '@smithy/core': 3.22.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.11 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-ssec@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/middleware-user-agent@3.972.6': + dependencies: + '@aws-sdk/core': 3.973.6 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.982.0 + '@smithy/core': 3.22.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.982.0': + dependencies: + '@aws-crypto/sha256-browser': 5.2.0 + '@aws-crypto/sha256-js': 5.2.0 + '@aws-sdk/core': 3.973.6 + '@aws-sdk/middleware-host-header': 3.972.3 + '@aws-sdk/middleware-logger': 3.972.3 + '@aws-sdk/middleware-recursion-detection': 3.972.3 + '@aws-sdk/middleware-user-agent': 3.972.6 + '@aws-sdk/region-config-resolver': 3.972.3 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-endpoints': 3.982.0 + '@aws-sdk/util-user-agent-browser': 3.972.3 + '@aws-sdk/util-user-agent-node': 3.972.4 + '@smithy/config-resolver': 4.4.6 + '@smithy/core': 3.22.1 + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/hash-node': 4.2.8 + '@smithy/invalid-dependency': 4.2.8 + '@smithy/middleware-content-length': 4.2.8 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-retry': 4.4.30 + '@smithy/middleware-serde': 4.2.9 + '@smithy/middleware-stack': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/node-http-handler': 4.4.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-body-length-node': 4.2.1 + '@smithy/util-defaults-mode-browser': 4.3.29 + '@smithy/util-defaults-mode-node': 4.2.32 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/region-config-resolver@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/config-resolver': 4.4.6 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/s3-request-presigner@3.984.0': + dependencies: + '@aws-sdk/signature-v4-multi-region': 3.984.0 + '@aws-sdk/types': 3.973.1 + '@aws-sdk/util-format-url': 3.972.3 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/protocol-http': 5.3.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/signature-v4-multi-region@3.984.0': + dependencies: + '@aws-sdk/middleware-sdk-s3': 3.972.6 + '@aws-sdk/types': 3.973.1 + '@smithy/protocol-http': 5.3.8 + '@smithy/signature-v4': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.982.0': + dependencies: + '@aws-sdk/core': 3.973.6 + '@aws-sdk/nested-clients': 3.982.0 + '@aws-sdk/types': 3.973.1 + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + transitivePeerDependencies: + - aws-crt + + '@aws-sdk/types@3.973.1': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/util-arn-parser@3.972.2': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.982.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + + '@aws-sdk/util-endpoints@3.984.0': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-endpoints': 3.2.8 + tslib: 2.8.1 + + '@aws-sdk/util-format-url@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/util-locate-window@3.965.4': + dependencies: + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-browser@3.972.3': + dependencies: + '@aws-sdk/types': 3.973.1 + '@smithy/types': 4.12.0 + bowser: 2.13.1 + tslib: 2.8.1 + + '@aws-sdk/util-user-agent-node@3.972.4': + dependencies: + '@aws-sdk/middleware-user-agent': 3.972.6 + '@aws-sdk/types': 3.973.1 + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@aws-sdk/xml-builder@3.972.4': + dependencies: + '@smithy/types': 4.12.0 + fast-xml-parser: 5.3.4 + tslib: 2.8.1 + + '@aws/lambda-invoke-store@0.2.3': {} + '@babel/code-frame@7.27.1': dependencies: '@babel/helper-validator-identifier': 7.27.1 @@ -4002,6 +4935,344 @@ snapshots: '@sinclair/typebox@0.31.28': {} + '@smithy/abort-controller@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader-native@4.2.1': + dependencies: + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/chunked-blob-reader@5.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/config-resolver@4.4.6': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-config-provider': 4.2.0 + '@smithy/util-endpoints': 3.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/core@3.22.1': + dependencies: + '@smithy/middleware-serde': 4.2.9 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-body-length-browser': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-stream': 4.5.11 + '@smithy/util-utf8': 4.2.0 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/credential-provider-imds@4.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + tslib: 2.8.1 + + '@smithy/eventstream-codec@4.2.8': + dependencies: + '@aws-crypto/crc32': 5.2.0 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-browser@4.2.8': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-config-resolver@4.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-node@4.2.8': + dependencies: + '@smithy/eventstream-serde-universal': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/eventstream-serde-universal@4.2.8': + dependencies: + '@smithy/eventstream-codec': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.3.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + tslib: 2.8.1 + + '@smithy/hash-blob-browser@4.2.9': + dependencies: + '@smithy/chunked-blob-reader': 5.2.0 + '@smithy/chunked-blob-reader-native': 4.2.1 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/hash-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/hash-stream-node@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/invalid-dependency@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/is-array-buffer@2.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/is-array-buffer@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/md5-js@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/middleware-content-length@4.2.8': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-endpoint@4.4.13': + dependencies: + '@smithy/core': 3.22.1 + '@smithy/middleware-serde': 4.2.9 + '@smithy/node-config-provider': 4.3.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + '@smithy/url-parser': 4.2.8 + '@smithy/util-middleware': 4.2.8 + tslib: 2.8.1 + + '@smithy/middleware-retry@4.4.30': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/service-error-classification': 4.2.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-retry': 4.2.8 + '@smithy/uuid': 1.1.0 + tslib: 2.8.1 + + '@smithy/middleware-serde@4.2.9': + dependencies: + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/middleware-stack@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-config-provider@4.3.8': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/shared-ini-file-loader': 4.4.3 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/node-http-handler@4.4.9': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/querystring-builder': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/property-provider@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/protocol-http@5.3.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/querystring-builder@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + '@smithy/util-uri-escape': 4.2.0 + tslib: 2.8.1 + + '@smithy/querystring-parser@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/service-error-classification@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + + '@smithy/shared-ini-file-loader@4.4.3': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/signature-v4@5.3.8': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-middleware': 4.2.8 + '@smithy/util-uri-escape': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/smithy-client@4.11.2': + dependencies: + '@smithy/core': 3.22.1 + '@smithy/middleware-endpoint': 4.4.13 + '@smithy/middleware-stack': 4.2.8 + '@smithy/protocol-http': 5.3.8 + '@smithy/types': 4.12.0 + '@smithy/util-stream': 4.5.11 + tslib: 2.8.1 + + '@smithy/types@4.12.0': + dependencies: + tslib: 2.8.1 + + '@smithy/url-parser@4.2.8': + dependencies: + '@smithy/querystring-parser': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-base64@4.3.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-body-length-browser@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-body-length-node@4.2.1': + dependencies: + tslib: 2.8.1 + + '@smithy/util-buffer-from@2.2.0': + dependencies: + '@smithy/is-array-buffer': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-buffer-from@4.2.0': + dependencies: + '@smithy/is-array-buffer': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-config-provider@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-defaults-mode-browser@4.3.29': + dependencies: + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-defaults-mode-node@4.2.32': + dependencies: + '@smithy/config-resolver': 4.4.6 + '@smithy/credential-provider-imds': 4.2.8 + '@smithy/node-config-provider': 4.3.8 + '@smithy/property-provider': 4.2.8 + '@smithy/smithy-client': 4.11.2 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-endpoints@3.2.8': + dependencies: + '@smithy/node-config-provider': 4.3.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-hex-encoding@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-middleware@4.2.8': + dependencies: + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-retry@4.2.8': + dependencies: + '@smithy/service-error-classification': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/util-stream@4.5.11': + dependencies: + '@smithy/fetch-http-handler': 5.3.9 + '@smithy/node-http-handler': 4.4.9 + '@smithy/types': 4.12.0 + '@smithy/util-base64': 4.3.0 + '@smithy/util-buffer-from': 4.2.0 + '@smithy/util-hex-encoding': 4.2.0 + '@smithy/util-utf8': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-uri-escape@4.2.0': + dependencies: + tslib: 2.8.1 + + '@smithy/util-utf8@2.3.0': + dependencies: + '@smithy/util-buffer-from': 2.2.0 + tslib: 2.8.1 + + '@smithy/util-utf8@4.2.0': + dependencies: + '@smithy/util-buffer-from': 4.2.0 + tslib: 2.8.1 + + '@smithy/util-waiter@4.2.8': + dependencies: + '@smithy/abort-controller': 4.2.8 + '@smithy/types': 4.12.0 + tslib: 2.8.1 + + '@smithy/uuid@1.1.0': + dependencies: + tslib: 2.8.1 + '@sqlite.org/sqlite-wasm@3.48.0-build4': {} '@standard-schema/spec@1.0.0': {} @@ -4495,6 +5766,8 @@ snapshots: boolbase@1.0.0: {} + bowser@2.13.1: {} + brace-expansion@1.1.12: dependencies: balanced-match: 1.0.2 @@ -4970,6 +6243,10 @@ snapshots: fast-uri@3.1.0: {} + fast-xml-parser@5.3.4: + dependencies: + strnum: 2.1.2 + fdir@6.5.0(picomatch@4.0.3): optionalDependencies: picomatch: 4.0.3 @@ -5350,6 +6627,11 @@ snapshots: dependencies: wrappy: 1.0.2 + openai@6.18.0(ws@8.18.3)(zod@4.3.6): + optionalDependencies: + ws: 8.18.3 + zod: 4.3.6 + optionator@0.9.4: dependencies: deep-is: 0.1.4 @@ -5741,6 +7023,8 @@ snapshots: dependencies: js-tokens: 9.0.1 + strnum@2.1.2: {} + style-to-object@1.0.11: dependencies: inline-style-parser: 0.2.4 diff --git a/src/lib/components/editor/canvas/canvas.svelte b/src/lib/components/editor/canvas/canvas.svelte index f19b44c..358b4f3 100644 --- a/src/lib/components/editor/canvas/canvas.svelte +++ b/src/lib/components/editor/canvas/canvas.svelte @@ -243,7 +243,8 @@ {#each projectStore.project.layers as layer (layer.id)} {@const enterTime = layer.enterTime ?? 0} {@const exitTime = layer.exitTime ?? projectStore.project.duration} - {@const isInTimeRange = projectStore.currentTime >= enterTime && projectStore.currentTime <= exitTime} + {@const isInTimeRange = + projectStore.currentTime >= enterTime && projectStore.currentTime <= exitTime} {#if isInTimeRange} {@const { transform, style, customProps } = getLayerRenderData(layer)} {@const component = getLayerComponent(layer.type)} diff --git a/src/lib/components/editor/panels/properties-panel.svelte b/src/lib/components/editor/panels/properties-panel.svelte index f444ff2..68cd4eb 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -343,6 +343,45 @@ value: unknown; } + // Caption generation state + let isGeneratingCaptions = $state(false); + let captionError = $state(''); + + async function generateCaptions() { + if (!selectedLayer || selectedLayer.type !== 'audio') return; + const audioUrl = selectedLayer.props.src as string; + if (!audioUrl) { + captionError = 'No audio source URL set'; + return; + } + + isGeneratingCaptions = true; + captionError = ''; + + try { + const res = await fetch('/api/captions', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ audioUrl }) + }); + + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.message || `Failed (${res.status})`); + } + + const data = await res.json(); + if (data.success && data.captions) { + updateLayerProps('captionText', data.captions); + updateLayerProps('showCaptions', true); + } + } catch (err) { + captionError = err instanceof Error ? err.message : 'Unknown error'; + } finally { + isGeneratingCaptions = false; + } + } + // Preset application state let selectedPresetId = $state(''); let presetDuration = $state(1); @@ -767,6 +806,22 @@ value: currentAnimatedProps[propMetadata.name] })} {/each} + + {#if selectedLayer?.type === 'audio' && selectedLayer.props.src} + + {#if captionError} +

{captionError}

+ {/if} + {/if}
{/if} diff --git a/src/lib/layers/components/AudioLayer.svelte b/src/lib/layers/components/AudioLayer.svelte index 151cc52..ef8ec45 100644 --- a/src/lib/layers/components/AudioLayer.svelte +++ b/src/lib/layers/components/AudioLayer.svelte @@ -1,6 +1,7 @@ + +
+ {#if isRecording} + +
+
+
+ Recording... +
+
{formatDuration(recordingDuration)}
+ +
+ {:else if isUploading} + +
+ + Uploading recording... +
+ {:else} + + + {/if} + + + {#if recordingError} +

{recordingError}

+ {/if} +
diff --git a/src/lib/components/editor/FileUpload.svelte b/src/lib/components/editor/FileUpload.svelte new file mode 100644 index 0000000..09cc8bb --- /dev/null +++ b/src/lib/components/editor/FileUpload.svelte @@ -0,0 +1,155 @@ + + +
+ + + {:else} + + Upload {mediaType} + {/if} + + + + {#if mediaType === 'audio'} + { + onUpload(result); + }} + /> + {/if} + + + + + + {#if uploadError} +

{uploadError}

+ {/if} + + +

Max size: {maxSizes[mediaType]}

+
diff --git a/src/lib/components/editor/canvas/canvas.svelte b/src/lib/components/editor/canvas/canvas.svelte index 358b4f3..0c516ce 100644 --- a/src/lib/components/editor/canvas/canvas.svelte +++ b/src/lib/components/editor/canvas/canvas.svelte @@ -249,6 +249,7 @@ {@const { transform, style, customProps } = getLayerRenderData(layer)} {@const component = getLayerComponent(layer.type)} {@const isSelected = projectStore.selectedLayerId === layer.id} + {@const enhancedProps = { ...customProps, layerId: layer.id, enterTime }} {/if} {/each} diff --git a/src/lib/components/editor/panels/properties-panel.svelte b/src/lib/components/editor/panels/properties-panel.svelte index 68cd4eb..536d97d 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -46,6 +46,7 @@ import ScrubInput from './scrub-input.svelte'; import type { BackgroundValue } from '$lib/schemas/animation'; import { Textarea } from '$lib/components/ui/textarea'; + import FileUpload from '../FileUpload.svelte'; const selectedLayer = $derived(projectStore.selectedLayer); @@ -429,7 +430,27 @@
- {#if metadata.type === 'number'} + {#if metadata.name === 'src' && selectedLayer && (selectedLayer.type === 'image' || selectedLayer.type === 'video' || selectedLayer.type === 'audio')} + + { + updateLayerProps('src', result.url); + updateLayerProps('fileKey', result.key); + updateLayerProps('fileName', result.fileName); + }} + onRemove={() => { + updateLayerProps('src', ''); + updateLayerProps('fileKey', ''); + updateLayerProps('fileName', ''); + }} + /> + {:else if metadata.type === 'number'} End (s) updateLayerProps('mediaEndTime', v)} diff --git a/src/lib/components/editor/timeline/timeline-layer.svelte b/src/lib/components/editor/timeline/timeline-layer.svelte index 3ff53cb..adcbcf9 100644 --- a/src/lib/components/editor/timeline/timeline-layer.svelte +++ b/src/lib/components/editor/timeline/timeline-layer.svelte @@ -2,6 +2,7 @@ import type { Layer, Keyframe } from '$lib/types/animation'; import { projectStore } from '$lib/stores/project.svelte'; import TimelineKeyframe from './timeline-keyframe.svelte'; + import { onDestroy } from 'svelte'; interface Props { layer: Layer; @@ -129,6 +130,12 @@ window.removeEventListener('mouseup', handleDragEnd); } + // Clean up listeners on unmount + onDestroy(() => { + window.removeEventListener('mousemove', handleDragMove); + window.removeEventListener('mouseup', handleDragEnd); + }); + // Color for the duration bar based on layer type const barColor = $derived.by(() => { switch (layer.type) { diff --git a/src/lib/engine/layer-factory.ts b/src/lib/engine/layer-factory.ts index 8ff2ef5..f16db9a 100644 --- a/src/lib/engine/layer-factory.ts +++ b/src/lib/engine/layer-factory.ts @@ -87,7 +87,7 @@ export function createLayer( ...defaultProps, ...propsOverrides }, - enterTime: enterTime ?? 0, - exitTime + ...(enterTime !== undefined ? { enterTime } : {}), + ...(exitTime !== undefined ? { exitTime } : {}) }; } diff --git a/src/lib/layers/components/AudioLayer.svelte b/src/lib/layers/components/AudioLayer.svelte index ef8ec45..783eb48 100644 --- a/src/lib/layers/components/AudioLayer.svelte +++ b/src/lib/layers/components/AudioLayer.svelte @@ -26,6 +26,8 @@ volume: z.number().min(0).max(1).default(1).describe('Volume (0-1)'), /** Whether audio is muted */ muted: z.boolean().default(false).describe('Mute audio'), + /** Playback rate */ + playbackRate: z.number().min(0.1).max(4).default(1).describe('Playback rate'), /** Visual style */ waveformColor: z.string().default('#3b82f6').describe('Waveform color'), backgroundColor: z.string().default('#1e293b').describe('Background color'), @@ -40,7 +42,15 @@ /** Caption style */ captionFontSize: z.number().min(8).max(120).default(24).describe('Caption font size'), captionColor: z.string().default('#ffffff').describe('Caption text color'), - captionBgColor: z.string().default('rgba(0,0,0,0.7)').describe('Caption background color') + captionBgColor: z.string().default('rgba(0,0,0,0.7)').describe('Caption background color'), + /** The storage key if file was uploaded (used for cleanup) */ + fileKey: z.string().default('').describe('Storage key (for uploaded files)'), + /** Original filename if uploaded */ + fileName: z.string().default('').describe('Original filename'), + /** Layer ID - passed by LayerWrapper for time sync */ + layerId: z.string().optional().describe('Layer ID (internal)'), + /** Enter time - passed by LayerWrapper for time sync */ + enterTime: z.number().optional().describe('Enter time (internal)') }); export const meta: LayerMeta = { @@ -67,13 +77,18 @@ mediaEndTime, volume, muted, + playbackRate, waveformColor, backgroundColor, showCaptions, captionText, captionFontSize, captionColor, - captionBgColor + captionBgColor, + fileKey: _fileKey, + fileName: _fileName, + layerId: _layerId, + enterTime: enterTimeProp }: Props = $props(); let audioEl: HTMLAudioElement | undefined = $state(); @@ -83,15 +98,12 @@ if (!audioEl || !src) return; const currentTime = projectStore.currentTime; - // Find the layer to get enterTime - const layer = projectStore.project.layers.find( - (l) => l.type === 'audio' && l.props.src === src - ); - const enterTime = layer?.enterTime ?? 0; + // Use enterTime passed as prop or fallback to 0 + const enterTime = enterTimeProp ?? 0; const relativeTime = currentTime - enterTime; - // Apply media start offset - const audioTime = mediaStartTime + relativeTime; + // Apply media start offset and playback rate + const audioTime = mediaStartTime + relativeTime * playbackRate; // Clamp to valid range const maxTime = mediaEndTime > 0 ? mediaEndTime : audioEl.duration || Infinity; @@ -109,11 +121,12 @@ } }); - // Sync volume/muted + // Sync volume/muted/playbackRate $effect(() => { if (!audioEl) return; audioEl.volume = volume; audioEl.muted = muted; + audioEl.playbackRate = playbackRate; }); // Parse captions as timed segments: "0:00 - 0:05 | Hello world\n0:05 - 0:10 | Next line" @@ -135,10 +148,7 @@ // Current caption based on project time const currentCaption = $derived.by(() => { if (!showCaptions || parsedCaptions.length === 0) return ''; - const layer = projectStore.project.layers.find( - (l) => l.type === 'audio' && l.props.src === src - ); - const enterTime = layer?.enterTime ?? 0; + const enterTime = enterTimeProp ?? 0; const relativeTime = projectStore.currentTime - enterTime + mediaStartTime; const active = parsedCaptions.find((c) => relativeTime >= c.start && relativeTime < c.end); return active?.text || ''; diff --git a/src/lib/layers/components/ImageLayer.svelte b/src/lib/layers/components/ImageLayer.svelte index d513078..216e20d 100644 --- a/src/lib/layers/components/ImageLayer.svelte +++ b/src/lib/layers/components/ImageLayer.svelte @@ -35,7 +35,7 @@
diff --git a/src/lib/layers/components/VideoLayer.svelte b/src/lib/layers/components/VideoLayer.svelte index 1cff9dc..bd9935f 100644 --- a/src/lib/layers/components/VideoLayer.svelte +++ b/src/lib/layers/components/VideoLayer.svelte @@ -23,7 +23,15 @@ /** Whether the video is muted */ muted: z.boolean().default(false).describe('Mute audio'), /** Playback rate */ - playbackRate: z.number().min(0.1).max(4).default(1).describe('Playback rate') + playbackRate: z.number().min(0.1).max(4).default(1).describe('Playback rate'), + /** The storage key if file was uploaded (used for cleanup) */ + fileKey: z.string().default('').describe('Storage key (for uploaded files)'), + /** Original filename if uploaded */ + fileName: z.string().default('').describe('Original filename'), + /** Layer ID - passed by LayerWrapper for time sync */ + layerId: z.string().optional().describe('Layer ID (internal)'), + /** Enter time - passed by LayerWrapper for time sync */ + enterTime: z.number().optional().describe('Enter time (internal)') }); export const meta: LayerMeta = { @@ -49,7 +57,11 @@ mediaEndTime, volume, muted, - playbackRate + playbackRate, + fileKey: _fileKey, + fileName: _fileName, + layerId: _layerId, + enterTime: enterTimeProp }: Props = $props(); let videoEl: HTMLVideoElement | undefined = $state(); @@ -59,12 +71,8 @@ if (!videoEl || !src) return; const currentTime = projectStore.currentTime; - // Calculate the video's internal time based on the project timeline - // The layer's enterTime determines when the video starts playing - const layer = projectStore.project.layers.find( - (l) => l.type === 'video' && l.props.src === src - ); - const enterTime = layer?.enterTime ?? 0; + // Use enterTime passed as prop or fallback to 0 + const enterTime = enterTimeProp ?? 0; const relativeTime = currentTime - enterTime; // Apply media start offset (trimming) diff --git a/src/lib/schemas/animation.ts b/src/lib/schemas/animation.ts index 73212ad..7305e3f 100644 --- a/src/lib/schemas/animation.ts +++ b/src/lib/schemas/animation.ts @@ -161,12 +161,11 @@ export const LayerSchema = z.object({ props: z.record(z.string(), z.unknown()), /** * Enter time - when this layer becomes visible in the timeline (seconds). - * Defaults to 0 (visible from start). + * If undefined, the layer is visible from the start. */ - enterTime: z.number().min(0).default(0).optional(), + enterTime: z.number().min(0).optional(), /** * Exit time - when this layer stops being visible in the timeline (seconds). - * Defaults to project duration (visible until end). * If undefined, the layer is visible until the project ends. */ exitTime: z.number().min(0).optional() diff --git a/src/lib/server/storage/index.ts b/src/lib/server/storage/index.ts index bd968cb..f9d00cd 100644 --- a/src/lib/server/storage/index.ts +++ b/src/lib/server/storage/index.ts @@ -161,14 +161,16 @@ export async function uploadFile( }) ); - // Build public URL + // Build URL - use proxy endpoint for private buckets, direct URL for public buckets let url: string; if (config.publicUrl) { + // Custom public URL (e.g., CloudFront, custom domain) url = `${config.publicUrl}/${key}`; - } else if (config.endpoint) { - url = `${config.endpoint}/${config.bucket}/${key}`; } else { - url = `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`; + // Use proxy endpoint that generates presigned URLs for private buckets + // This allows secure access without making the bucket public + const encodedKey = encodeURIComponent(key); + url = `/api/upload/${encodedKey}`; } return { @@ -189,12 +191,25 @@ export async function getSignedFileUrl(key: string, expiresIn = 3600): Promise { }) ); return true; - } catch { - return false; + } catch (err) { + // Only return false for NotFound errors, rethrow others + if (err && typeof err === 'object' && 'name' in err && err.name === 'NotFound') { + return false; + } + if ( + err && + typeof err === 'object' && + '$metadata' in err && + err.$metadata && + typeof err.$metadata === 'object' && + 'httpStatusCode' in err.$metadata && + err.$metadata.httpStatusCode === 404 + ) { + return false; + } + // Re-throw for other errors (network, auth, throttling, etc.) + throw err; } } diff --git a/src/lib/server/video-renderer.ts b/src/lib/server/video-renderer.ts index 66fe26f..adae894 100644 --- a/src/lib/server/video-renderer.ts +++ b/src/lib/server/video-renderer.ts @@ -168,13 +168,18 @@ export async function renderProjectToVideoStream(config: RenderConfig): Promise< audioTracks.forEach((track, i) => { const inputIndex = i + 1; // 0 is the video frame stream const trimStart = track.mediaStartTime; + const trimEnd = track.mediaEndTime > 0 ? track.mediaEndTime : undefined; const delay = Math.round(track.enterTime * 1000); // ms const vol = track.volume; // Trim and delay each audio track, then adjust volume let filter = `[${inputIndex}:a]`; - if (trimStart > 0) { - filter += `atrim=start=${trimStart},asetpts=PTS-STARTPTS,`; + if (trimStart > 0 || trimEnd) { + filter += `atrim=start=${trimStart}`; + if (trimEnd) { + filter += `:end=${trimEnd}`; + } + filter += ',asetpts=PTS-STARTPTS,'; } if (delay > 0) { filter += `adelay=${delay}|${delay},`; diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index bf15165..b3a12ca 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -148,7 +148,22 @@ class ProjectStore { this.project.layers = [...this.project.layers, layer]; } - removeLayer(layerId: string) { + 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); + + // Clean up uploaded files if the layer has a fileKey + if (layer && layer.props.fileKey && typeof layer.props.fileKey === 'string') { + try { + await fetch(`/api/upload/${encodeURIComponent(layer.props.fileKey as string)}`, { + method: 'DELETE' + }); + } catch (err) { + console.warn('Failed to delete file from storage:', err); + // Continue with layer deletion even if file cleanup fails + } + } + this.project.layers = this.project.layers.filter((l) => l.id !== layerId); if (this.selectedLayerId === layerId) { this.selectedLayerId = null; @@ -382,37 +397,52 @@ class ProjectStore { * Set the enter time for a layer (when it becomes visible) */ setLayerEnterTime(layerId: string, enterTime: number) { - this.project.layers = this.project.layers.map((layer) => - layer.id === layerId - ? { ...layer, enterTime: Math.max(0, Math.min(enterTime, this.project.duration)) } - : layer - ); + this.project.layers = this.project.layers.map((layer) => { + if (layer.id === layerId) { + const exitTime = layer.exitTime ?? this.project.duration; + const clampedEnterTime = Math.max(0, Math.min(enterTime, this.project.duration)); + // Ensure enter time is before exit time + const validEnterTime = Math.min(clampedEnterTime, exitTime - 0.1); + return { ...layer, enterTime: validEnterTime }; + } + return layer; + }); } /** * Set the exit time for a layer (when it becomes hidden) */ setLayerExitTime(layerId: string, exitTime: number) { - this.project.layers = this.project.layers.map((layer) => - layer.id === layerId - ? { ...layer, exitTime: Math.max(0, Math.min(exitTime, this.project.duration)) } - : layer - ); + this.project.layers = this.project.layers.map((layer) => { + if (layer.id === layerId) { + const enterTime = layer.enterTime ?? 0; + const clampedExitTime = Math.max(0, Math.min(exitTime, this.project.duration)); + // Ensure exit time is after enter time + const validExitTime = Math.max(clampedExitTime, enterTime + 0.1); + return { ...layer, exitTime: validExitTime }; + } + return layer; + }); } /** * Set both enter and exit times for a layer */ setLayerTimeRange(layerId: string, enterTime: number, exitTime: number) { - this.project.layers = this.project.layers.map((layer) => - layer.id === layerId - ? { - ...layer, - enterTime: Math.max(0, Math.min(enterTime, this.project.duration)), - exitTime: Math.max(0, Math.min(exitTime, this.project.duration)) - } - : layer - ); + this.project.layers = this.project.layers.map((layer) => { + if (layer.id === layerId) { + const clampedEnterTime = Math.max(0, Math.min(enterTime, this.project.duration)); + const clampedExitTime = Math.max(0, Math.min(exitTime, this.project.duration)); + // Ensure exit time is after enter time + const validExitTime = Math.max(clampedExitTime, clampedEnterTime + 0.1); + return { + ...layer, + enterTime: clampedEnterTime, + exitTime: validExitTime + }; + } + return layer; + }); } // ======================================== @@ -444,11 +474,11 @@ class ProjectStore { // Create the second half as a new layer const secondHalf: Layer = { ...JSON.parse(JSON.stringify(layer)), - id: layer.id + '_split', + id: nanoid(), name: `${layer.name} (2)`, enterTime: time, exitTime: exitTime, - keyframes: layer.keyframes.filter((k) => k.time >= time).map((k) => ({ ...k })) + keyframes: layer.keyframes.filter((k) => k.time > time).map((k) => ({ ...k })) }; if (isMediaLayer && mediaDuration > 0) { diff --git a/src/routes/api/captions/+server.ts b/src/routes/api/captions/+server.ts index e0d7828..4a5f96c 100644 --- a/src/routes/api/captions/+server.ts +++ b/src/routes/api/captions/+server.ts @@ -9,6 +9,14 @@ import { error, json } from '@sveltejs/kit'; import type { RequestHandler } from './$types'; import { env } from '$env/dynamic/private'; import OpenAI from 'openai'; +import { z } from 'zod'; + +/** Input validation schema */ +const CaptionRequestSchema = z.object({ + audioUrl: z.string().url(), + language: z.string().optional(), + style: z.enum(['subtitle', 'caption', 'lyrics']).optional() +}); /** Format seconds as M:SS.d (e.g., 0:03.5, 1:23.0) */ function formatTime(seconds: number): string { @@ -25,18 +33,23 @@ function formatTime(seconds: number): string { * Accepts an audio URL and generates timed captions using OpenAI Whisper. * The audio is downloaded and sent to Whisper for real speech-to-text transcription. */ -export const POST: RequestHandler = async ({ request }) => { +export const POST: RequestHandler = async ({ request, locals }) => { + // Check authentication + if (!locals.user?.id) { + error(401, 'Unauthorized'); + } + try { const body = await request.json(); - const { audioUrl, language } = body as { - audioUrl: string; - language?: string; - }; - if (!audioUrl) { - error(400, 'audioUrl is required'); + // Validate request body + const result = CaptionRequestSchema.safeParse(body); + if (!result.success) { + error(400, `Invalid request: ${result.error.message}`); } + const { audioUrl, language } = result.data; + const apiKey = env.OPENAI_API_KEY; if (!apiKey) { error(503, 'AI service not configured (OPENAI_API_KEY missing)'); diff --git a/src/routes/api/upload/+server.ts b/src/routes/api/upload/+server.ts index bc35f42..ed6dc9a 100644 --- a/src/routes/api/upload/+server.ts +++ b/src/routes/api/upload/+server.ts @@ -15,12 +15,25 @@ import { type MediaType } from '$lib/server/storage'; -export const POST: RequestHandler = async ({ request }) => { +const MAX_REQUEST_SIZE = 100 * 1024 * 1024; // 100MB + +export const POST: RequestHandler = async ({ request, locals }) => { + // Check authentication + if (!locals.user?.id) { + error(401, 'Unauthorized'); + } + // Check if storage is configured if (!isStorageConfigured()) { error(503, 'File storage is not configured. Set S3 environment variables.'); } + // Enforce size limit + const contentLength = request.headers.get('content-length'); + if (contentLength && parseInt(contentLength, 10) > MAX_REQUEST_SIZE) { + error(413, 'Payload Too Large'); + } + try { const contentType = request.headers.get('content-type') || ''; @@ -37,8 +50,20 @@ export const POST: RequestHandler = async ({ request }) => { error(400, 'No file provided'); } - // Detect media type from MIME type - const mediaType = (mediaTypeHint as MediaType) || detectMediaType(file.type); + // Validate and use mediaTypeHint if provided, otherwise detect from MIME + let mediaType: MediaType | null = null; + if (mediaTypeHint) { + // Validate that mediaTypeHint is a valid MediaType + const detectedType = detectMediaType(file.type); + if (mediaTypeHint === 'image' || mediaTypeHint === 'video' || mediaTypeHint === 'audio') { + mediaType = mediaTypeHint as MediaType; + } else { + mediaType = detectedType; + } + } else { + mediaType = detectMediaType(file.type); + } + if (!mediaType) { error(400, `Unsupported file type: ${file.type}`); } diff --git a/src/routes/api/upload/[key]/+server.ts b/src/routes/api/upload/[key]/+server.ts new file mode 100644 index 0000000..c88d306 --- /dev/null +++ b/src/routes/api/upload/[key]/+server.ts @@ -0,0 +1,85 @@ +/** + * File Access & Deletion API Endpoint + * + * GET: Redirects to a presigned URL for accessing private files + * DELETE: Deletes uploaded files from S3-compatible storage + */ +import { error, json, redirect } from '@sveltejs/kit'; +import type { RequestHandler } from './$types'; +import { deleteFile, isStorageConfigured, getSignedFileUrl } from '$lib/server/storage'; + +/** + * GET handler - generates and redirects to a presigned URL + * This allows serving files from private S3 buckets + */ +export const GET: RequestHandler = async ({ params }) => { + // Check if storage is configured + if (!isStorageConfigured()) { + error(503, 'File storage is not configured. Set S3 environment variables.'); + } + + const { key } = params; + + if (!key) { + error(400, 'No file key provided'); + } + + try { + // Decode the key (it may contain URL-encoded characters) + const decodedKey = decodeURIComponent(key); + + console.log('Attempting to get signed URL for key:', decodedKey); + + // Generate presigned URL valid for 1 hour + const signedUrl = await getSignedFileUrl(decodedKey, 3600); + + console.log('Generated signed URL:', signedUrl); + + // Redirect to the presigned URL + throw redirect(302, signedUrl); + } catch (err) { + // Re-throw SvelteKit errors (redirect, error) + if (err && typeof err === 'object' && 'status' in err) { + throw err; + } + + console.error('File access error:', err); + // Extract error message from various error types + let errorMessage = 'Unknown error'; + if (err instanceof Error) { + errorMessage = err.message; + } else if (err && typeof err === 'object') { + errorMessage = JSON.stringify(err); + } + error(500, `File access failed: ${errorMessage}`); + } +}; + +/** + * DELETE handler - removes file from storage + */ +export const DELETE: RequestHandler = async ({ params }) => { + // Check if storage is configured + if (!isStorageConfigured()) { + error(503, 'File storage is not configured. Set S3 environment variables.'); + } + + const { key } = params; + + if (!key) { + error(400, 'No file key provided'); + } + + try { + // Delete the file from storage + await deleteFile(decodeURIComponent(key)); + + return json({ + success: true, + message: 'File deleted successfully' + }); + } catch (err) { + console.error('Delete error:', err); + error(500, `Delete failed: ${err instanceof Error ? err.message : 'Unknown error'}`); + } +}; diff --git a/src/routes/render/[id]/+page.svelte b/src/routes/render/[id]/+page.svelte index 00caf8a..025a357 100644 --- a/src/routes/render/[id]/+page.svelte +++ b/src/routes/render/[id]/+page.svelte @@ -86,20 +86,25 @@
{#each project.layers as layer (layer.id)} - {@const { transform, style, customProps } = getLayerRenderData(layer)} - {@const component = getLayerComponent(layer.type)} + {@const enterTime = layer.enterTime ?? 0} + {@const exitTime = layer.exitTime ?? project.duration} + {@const isInTimeRange = currentTime >= enterTime && currentTime <= exitTime} + {#if isInTimeRange} + {@const { transform, style, customProps } = getLayerRenderData(layer)} + {@const component = getLayerComponent(layer.type)} - + + {/if} {/each}
From a4cd383eff4b68d46fb6eb12250c0129eddb4f22 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Fri, 6 Feb 2026 14:35:48 +0100 Subject: [PATCH 04/17] feat: Implement video and camera capture, add project thumbnail generation, and refine media handling utilities. --- CLAUDE.md | 3 +- docs/current-feature.md | 1 + drizzle/0006_broken_rockslide.sql | 1 + drizzle/meta/0006_snapshot.json | 684 ++++++++++++++++++ drizzle/meta/_journal.json | 7 + src/hooks.server.ts | 1 + .../components/editor/AudioRecorder.svelte | 64 +- .../components/editor/CameraCapture.svelte | 235 ++++++ src/lib/components/editor/FileUpload.svelte | 18 +- .../components/editor/VideoRecorder.svelte | 217 ++++++ .../components/editor/canvas/canvas.svelte | 4 +- .../components/editor/media-capture-types.ts | 22 + .../components/editor/media-upload-utils.ts | 163 +++++ src/lib/functions/projects.remote.ts | 18 +- src/lib/layers/LayerWrapper.svelte | 2 +- src/lib/server/db/schema/projects.ts | 1 + src/lib/server/storage/index.ts | 15 +- src/lib/server/thumbnail-generator.ts | 131 ++++ src/lib/server/thumbnail-queue.ts | 66 ++ src/routes/(marketing)/gallery/+page.svelte | 22 +- src/routes/api/export/[id]/+server.ts | 2 +- 21 files changed, 1621 insertions(+), 56 deletions(-) create mode 100644 docs/current-feature.md create mode 100644 drizzle/0006_broken_rockslide.sql create mode 100644 drizzle/meta/0006_snapshot.json create mode 100644 src/lib/components/editor/CameraCapture.svelte create mode 100644 src/lib/components/editor/VideoRecorder.svelte create mode 100644 src/lib/components/editor/media-capture-types.ts create mode 100644 src/lib/components/editor/media-upload-utils.ts create mode 100644 src/lib/server/thumbnail-generator.ts create mode 100644 src/lib/server/thumbnail-queue.ts diff --git a/CLAUDE.md b/CLAUDE.md index 6cc7dd4..b891b9c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -61,6 +61,7 @@ registerLayer('my-layer', Component, PropsSchema); ### Code Style - **Package manager**: `pnpm` only (not npm/yarn) +- **Icons**: Use `@lucide/svelte` package exclusively - **Naming**: `kebab-case.ts`, `PascalCase.svelte`, `camelCase` functions - **Imports**: External → SvelteKit → Internal → Relative - **Store files**: `name.svelte.ts` suffix for rune-based stores @@ -248,4 +249,4 @@ OPENROUTER_API_KEY # AI features (optional) - **Type definitions**: Check `src/lib/schemas/animation.ts` - **Layer examples**: Look at existing layer components -**Last Updated**: 2026-02-05 +**Last Updated**: 2026-02-06 diff --git a/docs/current-feature.md b/docs/current-feature.md new file mode 100644 index 0000000..a493ca1 --- /dev/null +++ b/docs/current-feature.md @@ -0,0 +1 @@ +Handle this need, handle/support file upload properly for images and videos, actually image accept only src, should accept also a file. Then allow that the project store securely files on s3 like configurable storage provider. And support the preview. The video layer should define the seconds from start and and, need to be rendered in a proper way in the timeline, and let users to cut/split/resize it. Be sure that server side rendering will show the proper frames in the capture, and merge the audio tracks properly. Also allow to upload/record audios, and manage properly via vercel ai and openrouterr providers the caption generator and preview (this probably should be an audio layer with captions style to be shown ) also for audio handle properly the timeline, split, crop, move. Apply the same concept of enter/exit time for other layers, so we can handle more layers together and visually. Don’t struggle to fix errors, the environment can’t be tested or checked completely, so implement it and I’ll test and tune it. diff --git a/drizzle/0006_broken_rockslide.sql b/drizzle/0006_broken_rockslide.sql new file mode 100644 index 0000000..790c7c2 --- /dev/null +++ b/drizzle/0006_broken_rockslide.sql @@ -0,0 +1 @@ +ALTER TABLE "project" ADD COLUMN "thumbnail_url" text; \ No newline at end of file diff --git a/drizzle/meta/0006_snapshot.json b/drizzle/meta/0006_snapshot.json new file mode 100644 index 0000000..5beee71 --- /dev/null +++ b/drizzle/meta/0006_snapshot.json @@ -0,0 +1,684 @@ +{ + "id": "4fa7d538-dafc-441c-bfc4-39dff91f26e3", + "prevId": "4ceefd97-4bd6-46d8-a507-105e2ea3b442", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.ai_usage_log": { + "name": "ai_usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prompt_tokens": { + "name": "prompt_tokens", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "completion_tokens": { + "name": "completion_tokens", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "total_tokens": { + "name": "total_tokens", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "estimated_cost": { + "name": "estimated_cost", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_usage_log_user_id_idx": { + "name": "ai_usage_log_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "ai_usage_log_created_at_idx": { + "name": "ai_usage_log_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_usage_log_user_id_user_id_fk": { + "name": "ai_usage_log_user_id_user_id_fk", + "tableFrom": "ai_usage_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ai_user_unlock": { + "name": "ai_user_unlock", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "max_cost_per_month": { + "name": "max_cost_per_month", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ai_user_unlock_user_id_idx": { + "name": "ai_user_unlock_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ai_user_unlock_user_id_user_id_fk": { + "name": "ai_user_unlock_user_id_user_id_fk", + "tableFrom": "ai_user_unlock", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.project": { + "name": "project", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_mcp": { + "name": "is_mcp", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "views": { + "name": "views", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "thumbnail_url": { + "name": "thumbnail_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_id": { + "name": "forked_from_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "project_user_id_idx": { + "name": "project_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_is_public_idx": { + "name": "project_is_public_idx", + "columns": [ + { + "expression": "is_public", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "project_views_idx": { + "name": "project_views_idx", + "columns": [ + { + "expression": "views", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "project_user_id_user_id_fk": { + "name": "project_user_id_user_id_fk", + "tableFrom": "project", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "project_forked_from_id_project_id_fk": { + "name": "project_forked_from_id_project_id_fk", + "tableFrom": "project", + "tableTo": "project", + "columnsFrom": [ + "forked_from_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 74a79c9..b808afc 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1770057267079, "tag": "0005_groovy_dagger", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1770384367089, + "tag": "0006_broken_rockslide", + "breakpoints": true } ] } \ No newline at end of file diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 8c11719..ec1d375 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -4,6 +4,7 @@ import { auth } from '$lib/server/auth'; import { svelteKitHandler } from 'better-auth/svelte-kit'; import { building } from '$app/environment'; import { sequence } from '@sveltejs/kit/hooks'; +import '$lib/server/thumbnail-queue'; const handleParaglide: Handle = ({ event, resolve }) => paraglideMiddleware(event.request, ({ request, locale }) => { diff --git a/src/lib/components/editor/AudioRecorder.svelte b/src/lib/components/editor/AudioRecorder.svelte index 7f039c4..35e3ef2 100644 --- a/src/lib/components/editor/AudioRecorder.svelte +++ b/src/lib/components/editor/AudioRecorder.svelte @@ -1,6 +1,13 @@
diff --git a/src/lib/components/editor/CameraCapture.svelte b/src/lib/components/editor/CameraCapture.svelte new file mode 100644 index 0000000..a04e4e0 --- /dev/null +++ b/src/lib/components/editor/CameraCapture.svelte @@ -0,0 +1,235 @@ + + +
+ {#if state === 'idle'} + + + {:else if state === 'preview'} + +
+ +
+ + + + + +
+ + +
+ + +
+
+ {:else if state === 'review'} + +
+ +
+ Captured +
+ + +
+ + +
+
+ {:else if state === 'uploading'} + +
+ + Uploading photo... +
+ {/if} + + + + + + {#if captureError} +

{captureError}

+ {/if} +
diff --git a/src/lib/components/editor/FileUpload.svelte b/src/lib/components/editor/FileUpload.svelte index 09cc8bb..4b3befd 100644 --- a/src/lib/components/editor/FileUpload.svelte +++ b/src/lib/components/editor/FileUpload.svelte @@ -2,6 +2,8 @@ import { Button } from '$lib/components/ui/button'; import { Upload, Trash2, Loader2, Check } from '@lucide/svelte'; import AudioRecorder from './AudioRecorder.svelte'; + import VideoRecorder from './VideoRecorder.svelte'; + import CameraCapture from './CameraCapture.svelte'; interface Props { /** Current file URL (if already uploaded or set) */ @@ -126,7 +128,7 @@ {/if} - + {#if mediaType === 'audio'} + {:else if mediaType === 'video'} + { + onUpload(result); + }} + /> + {:else if mediaType === 'image'} + { + onUpload(result); + }} + /> {/if} diff --git a/src/lib/components/editor/VideoRecorder.svelte b/src/lib/components/editor/VideoRecorder.svelte new file mode 100644 index 0000000..b4bc28d --- /dev/null +++ b/src/lib/components/editor/VideoRecorder.svelte @@ -0,0 +1,217 @@ + + +
+ {#if isRecording} + +
+ +
+ + + + +
+
+ Recording +
+ + +
+ + {formatDuration(recordingDuration)} + +
+
+ + + + + {#if recordingDuration >= MAX_DURATION - 10} +

+ {MAX_DURATION - recordingDuration}s remaining +

+ {/if} +
+ {:else if isUploading} + +
+ + Uploading video... +
+ {:else} + + + {/if} + + + {#if recordingError} +

{recordingError}

+ {/if} +
diff --git a/src/lib/components/editor/canvas/canvas.svelte b/src/lib/components/editor/canvas/canvas.svelte index 0c516ce..e3b85fb 100644 --- a/src/lib/components/editor/canvas/canvas.svelte +++ b/src/lib/components/editor/canvas/canvas.svelte @@ -184,7 +184,7 @@ }); -
+
diff --git a/src/lib/components/editor/media-capture-types.ts b/src/lib/components/editor/media-capture-types.ts new file mode 100644 index 0000000..ae8c176 --- /dev/null +++ b/src/lib/components/editor/media-capture-types.ts @@ -0,0 +1,22 @@ +/** + * Shared types and interfaces for media capture components + * (AudioRecorder, VideoRecorder, CameraCapture) + */ + +export interface MediaCaptureResult { + url: string; + key: string; + fileName: string; +} + +export interface MediaCaptureProps { + onComplete: (result: MediaCaptureResult) => void; + projectId?: string; +} + +export type CaptureState = 'idle' | 'capturing' | 'uploading' | 'error'; + +export interface CaptureError { + type: 'permission' | 'upload' | 'unsupported' | 'unknown'; + message: string; +} diff --git a/src/lib/components/editor/media-upload-utils.ts b/src/lib/components/editor/media-upload-utils.ts new file mode 100644 index 0000000..49c7df6 --- /dev/null +++ b/src/lib/components/editor/media-upload-utils.ts @@ -0,0 +1,163 @@ +/** + * Shared utility functions for media capture and upload + */ + +import type { MediaCaptureResult, CaptureError } from './media-capture-types'; + +export type MediaType = 'image' | 'video' | 'audio'; + +/** + * Upload a media blob to the server + * @param blob The media blob to upload + * @param fileName The filename to use + * @param mediaType The type of media (image, video, audio) + * @param projectId Optional project ID for organizing uploads + * @returns Promise resolving to upload result + */ +export async function uploadMediaBlob( + blob: Blob, + fileName: string, + mediaType: MediaType, + projectId?: string +): Promise { + const formData = new FormData(); + formData.append('file', blob, fileName); + formData.append('mediaType', mediaType); + if (projectId) { + formData.append('projectId', projectId); + } + + const res = await fetch('/api/upload', { + method: 'POST', + body: formData + }); + + if (!res.ok) { + const data = await res.json().catch(() => null); + throw new Error(data?.message || `Upload failed (${res.status})`); + } + + const data = await res.json(); + if (data.success && data.file) { + return { + url: data.file.url, + key: data.file.key, + fileName: data.file.originalName + }; + } + + throw new Error('Upload response missing file data'); +} + +/** + * Generate a timestamped filename + * @param prefix Filename prefix (e.g., 'recording', 'photo', 'video') + * @param extension File extension without dot (e.g., 'webm', 'jpg') + * @returns Timestamped filename + */ +export function generateTimestampedFileName(prefix: string, extension: string): string { + const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); + return `${prefix}-${timestamp}.${extension}`; +} + +/** + * Format duration in seconds to MM:SS string + * @param seconds Duration in seconds + * @returns Formatted duration string + */ +export function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, '0')}`; +} + +/** + * Handle media capture errors and convert to user-friendly messages + * @param err The error to handle + * @returns Structured error object + */ +export function handleMediaError(err: unknown): CaptureError { + if (err instanceof DOMException) { + if (err.name === 'NotAllowedError' || err.name === 'PermissionDeniedError') { + return { + type: 'permission', + message: + 'Permission denied. Please allow camera/microphone access in your browser settings.' + }; + } + if (err.name === 'NotFoundError') { + return { + type: 'unsupported', + message: 'No camera or microphone found on this device.' + }; + } + if (err.name === 'NotSupportedError') { + return { + type: 'unsupported', + message: + 'Your browser does not support this feature. Please use Chrome, Firefox, or Safari.' + }; + } + } + + if (err instanceof Error) { + return { + type: 'unknown', + message: err.message + }; + } + + return { + type: 'unknown', + message: 'An unknown error occurred' + }; +} + +/** + * Get the first supported MIME type from a list + * @param types Array of MIME types to try + * @returns The first supported MIME type, or null if none supported + */ +export async function getSupportedMimeType(types: string[]): Promise { + for (const type of types) { + if (typeof MediaRecorder !== 'undefined' && MediaRecorder.isTypeSupported(type)) { + return type; + } + } + return null; +} + +/** + * Check if the browser supports video recording + * @returns true if MediaRecorder and getUserMedia are supported + */ +export function canRecordVideo(): boolean { + return ( + typeof MediaRecorder !== 'undefined' && + typeof navigator !== 'undefined' && + !!navigator.mediaDevices?.getUserMedia + ); +} + +/** + * Check if the browser supports photo capture + * @returns true if getUserMedia and canvas are supported + */ +export function canCapturePhoto(): boolean { + return ( + typeof navigator !== 'undefined' && + !!navigator.mediaDevices?.getUserMedia && + typeof document !== 'undefined' && + !!document.createElement('canvas').getContext + ); +} + +/** + * Stop all tracks in a media stream + * @param stream The media stream to stop + */ +export function stopMediaStream(stream: MediaStream | null): void { + if (stream) { + stream.getTracks().forEach((track) => track.stop()); + } +} diff --git a/src/lib/functions/projects.remote.ts b/src/lib/functions/projects.remote.ts index a3c9d24..22e86ae 100644 --- a/src/lib/functions/projects.remote.ts +++ b/src/lib/functions/projects.remote.ts @@ -7,6 +7,7 @@ import { withErrorHandling } from '.'; import { nanoid } from 'nanoid'; import { invalid } from '@sveltejs/kit'; import { projectDataSchema } from '$lib/schemas/animation'; +import { thumbnailQueue } from '$lib/server/thumbnail-queue'; export const saveProject = command( z.object({ @@ -42,6 +43,17 @@ export const saveProject = command( }); } + thumbnailQueue.enqueue({ + projectId, + projectData: { + width: data.width, + height: data.height, + fps: data.fps, + duration: data.duration + }, + addedAt: new Date() + }); + return { id: projectId }; }) ); @@ -90,7 +102,8 @@ export const getUserProjects = query(async () => { id: true, name: true, isPublic: true, - updatedAt: true + updatedAt: true, + thumbnailUrl: true } }); }); @@ -195,7 +208,8 @@ export const getPublicProjects = query( views: true, updatedAt: true, userId: true, - isMcp: true + isMcp: true, + thumbnailUrl: true } }); diff --git a/src/lib/layers/LayerWrapper.svelte b/src/lib/layers/LayerWrapper.svelte index 4b5bfbc..0be69ef 100644 --- a/src/lib/layers/LayerWrapper.svelte +++ b/src/lib/layers/LayerWrapper.svelte @@ -185,7 +185,7 @@ {#if visible}
(), + thumbnailUrl: text('thumbnail_url'), forkedFromId: text('forked_from_id').references((): AnyPgColumn => project.id, { onDelete: 'set null' }), diff --git a/src/lib/server/storage/index.ts b/src/lib/server/storage/index.ts index f9d00cd..289e08e 100644 --- a/src/lib/server/storage/index.ts +++ b/src/lib/server/storage/index.ts @@ -80,8 +80,11 @@ const MAX_FILE_SIZES: Record = { /** * Get the file extension from a MIME type + * Strips MIME type parameters (e.g., "video/webm;codecs=vp9" → "video/webm") */ function getExtension(mimeType: string): string { + // Strip parameters from MIME type (everything after semicolon) + const baseMimeType = mimeType.split(';')[0].trim(); const map: Record = { 'image/jpeg': 'jpg', 'image/png': 'png', @@ -99,14 +102,17 @@ function getExtension(mimeType: string): string { 'audio/mp4': 'm4a', 'audio/aac': 'aac' }; - return map[mimeType] || 'bin'; + return map[baseMimeType] || 'bin'; } /** * Validate that a file's MIME type is allowed for the given media category + * Strips MIME type parameters (e.g., "video/webm;codecs=vp9" → "video/webm") */ export function validateMediaType(mimeType: string, mediaType: MediaType): boolean { - return ALLOWED_MIME_TYPES[mediaType].includes(mimeType); + // Strip parameters from MIME type (everything after semicolon) + const baseMimeType = mimeType.split(';')[0].trim(); + return ALLOWED_MIME_TYPES[mediaType].includes(baseMimeType); } /** @@ -265,10 +271,13 @@ export async function fileExists(key: string): Promise { /** * Detect media type from MIME type + * Strips MIME type parameters (e.g., "video/webm;codecs=vp9" → "video/webm") */ export function detectMediaType(mimeType: string): MediaType | null { + // Strip parameters from MIME type (everything after semicolon) + const baseMimeType = mimeType.split(';')[0].trim(); for (const [type, mimes] of Object.entries(ALLOWED_MIME_TYPES)) { - if (mimes.includes(mimeType)) { + if (mimes.includes(baseMimeType)) { return type as MediaType; } } diff --git a/src/lib/server/thumbnail-generator.ts b/src/lib/server/thumbnail-generator.ts new file mode 100644 index 0000000..bcb7612 --- /dev/null +++ b/src/lib/server/thumbnail-generator.ts @@ -0,0 +1,131 @@ +/** + * Generate low-resolution GIF thumbnails for video previews + * Uses Playwright for frame capture and FFmpeg for GIF encoding + */ +import { chromium } from 'playwright'; +import ffmpeg from 'fluent-ffmpeg'; +import { PassThrough } from 'stream'; +import { generateRenderToken, invalidateRenderToken } from './render-token'; +import { uploadFile } from './storage'; +import { db } from './db'; +import { project } from './db/schema'; +import { eq } from 'drizzle-orm'; + +interface ThumbnailConfig { + projectId: string; + projectData: { + width: number; + height: number; + fps: number; + duration: number; + }; + baseUrl: string; +} + +const THUMBNAIL_WIDTH = 320; +const THUMBNAIL_FPS = 10; +const MAX_DURATION = 5; + +/** + * Generate a low-res GIF thumbnail for a project + * Samples at 1 FPS with fixed thumbnail resolution + */ +export async function generateThumbnail(config: ThumbnailConfig): Promise { + const { projectId, projectData, baseUrl } = config; + + const aspectRatio = projectData.width / projectData.height; + const thumbnailHeight = Math.round(THUMBNAIL_WIDTH / aspectRatio); + + const duration = Math.min(projectData.duration, MAX_DURATION); + const totalFrames = Math.ceil(THUMBNAIL_FPS * duration); + + const token = generateRenderToken(projectId); + const renderUrl = `${baseUrl}/render/${projectId}?token=${token}`; + + let browser = null; + + try { + const launchArgs = ['--disable-gpu', '--disable-dev-shm-usage', '--disable-setuid-sandbox']; + + if (process.env.PLAYWRIGHT_ALLOW_INSECURE_FLAGS === 'true') { + launchArgs.push('--no-sandbox', '--disable-web-security'); + } + + browser = await chromium.launch({ + headless: true, + args: launchArgs + }); + + const page = await browser.newPage({ + viewport: { width: projectData.width, height: projectData.height }, + deviceScaleFactor: 1 + }); + + await page.goto(renderUrl, { waitUntil: 'networkidle' }); + await page.waitForFunction(() => window.__DEVMOTION__?.ready, { timeout: 30000 }); + + const frameStream = new PassThrough(); + const gifBuffer = await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + + ffmpeg() + .input(frameStream) + .inputFormat('image2pipe') + .inputFPS(THUMBNAIL_FPS) + .outputOptions([ + '-vf', + `scale=${THUMBNAIL_WIDTH}:${thumbnailHeight}:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3`, + '-loop', + '0' + ]) + .format('gif') + .on('error', (err) => { + console.error('FFmpeg error:', err); + reject(err); + }) + .on('end', () => { + resolve(Buffer.concat(chunks)); + }) + .pipe() + .on('data', (chunk: Buffer) => { + chunks.push(chunk); + }); + + (async () => { + for (let frameIndex = 0; frameIndex < totalFrames; frameIndex++) { + const time = frameIndex / THUMBNAIL_FPS; + await page.evaluate((t) => window.__DEVMOTION__?.seek(t), time); + await new Promise((resolve) => setTimeout(resolve, 100)); + + const screenshot = await page.screenshot({ + type: 'png', + clip: { x: 0, y: 0, width: projectData.width, height: projectData.height } + }); + + frameStream.write(screenshot); + } + frameStream.end(); + })().catch(reject); + }); + + await browser.close(); + invalidateRenderToken(token); + + const result = await uploadFile( + gifBuffer, + `${projectId}-thumbnail.gif`, + 'image/gif', + 'image', + projectId + ); + + await db.update(project).set({ thumbnailUrl: result.url }).where(eq(project.id, projectId)); + + return result.url; + } catch (err) { + if (browser) await browser.close(); + invalidateRenderToken(token); + console.error(`Failed to generate thumbnail for project ${projectId}:`, err); + throw err; + } +} diff --git a/src/lib/server/thumbnail-queue.ts b/src/lib/server/thumbnail-queue.ts new file mode 100644 index 0000000..6b6fddc --- /dev/null +++ b/src/lib/server/thumbnail-queue.ts @@ -0,0 +1,66 @@ +/** + * In-memory queue for background thumbnail generation + */ +import { generateThumbnail } from './thumbnail-generator'; +import { PUBLIC_BASE_URL } from '$env/static/public'; + +interface ThumbnailJob { + projectId: string; + projectData: { + width: number; + height: number; + fps: number; + duration: number; + }; + addedAt: Date; +} + +class ThumbnailQueue { + private queue: ThumbnailJob[] = []; + private processing = false; + private maxConcurrent = 1; + + enqueue(job: ThumbnailJob) { + this.queue.push(job); + console.log(`[ThumbnailQueue] Enqueued job for project ${job.projectId}`); + this.processQueue(); + } + + private async processQueue() { + if (this.processing || this.queue.length === 0) return; + + this.processing = true; + + while (this.queue.length > 0) { + const job = this.queue.shift(); + if (!job) continue; + + try { + console.log(`[ThumbnailQueue] Processing thumbnail for project ${job.projectId}`); + const baseUrl = PUBLIC_BASE_URL; + + await generateThumbnail({ + projectId: job.projectId, + projectData: job.projectData, + baseUrl + }); + + console.log(`[ThumbnailQueue] Successfully generated thumbnail for ${job.projectId}`); + } catch (err) { + console.error(`[ThumbnailQueue] Failed to generate thumbnail for ${job.projectId}:`, err); + } + } + + this.processing = false; + } + + getQueueLength(): number { + return this.queue.length; + } + + isProcessing(): boolean { + return this.processing; + } +} + +export const thumbnailQueue = new ThumbnailQueue(); diff --git a/src/routes/(marketing)/gallery/+page.svelte b/src/routes/(marketing)/gallery/+page.svelte index 46a82a2..79d4503 100644 --- a/src/routes/(marketing)/gallery/+page.svelte +++ b/src/routes/(marketing)/gallery/+page.svelte @@ -55,14 +55,22 @@
-
-
- -
+ {:else} +
+
+ +
+ {/if}
// No body or invalid JSON, use defaults } - const baseUrl = PUBLIC_BASE_URL || 'http://localhost:5173'; + const baseUrl = PUBLIC_BASE_URL; try { // Start rendering and get stream (include project data for audio merging) From 4884550cf622e834002176ddb993924549a41f38 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Fri, 6 Feb 2026 19:01:59 +0100 Subject: [PATCH 05/17] feat: Introduce AlertDialog and Popover components and extend layer schemas with content duration and offset properties. --- package.json | 2 +- src/lib/ai/mutations.ts | 16 + src/lib/ai/schemas.ts | 30 +- .../components/editor/AudioRecorder.svelte | 24 +- .../components/editor/CameraCapture.svelte | 44 +- src/lib/components/editor/FileUpload.svelte | 72 ++- .../components/editor/VideoRecorder.svelte | 79 ++- .../components/editor/canvas/canvas.svelte | 8 +- .../components/editor/media-capture-types.ts | 1 + .../components/editor/media-upload-utils.ts | 7 +- .../editor/panels/background-picker.svelte | 472 +++++++++--------- .../editor/panels/layers-panel.svelte | 63 ++- .../editor/panels/properties-panel.svelte | 155 ++++-- .../editor/project-settings-dialog.svelte | 1 + .../components/editor/project-switcher.svelte | 161 +++++- .../editor/timeline/timeline-keyframe.svelte | 88 ++-- .../editor/timeline/timeline-layer.svelte | 2 +- src/lib/components/nav-projects.svelte | 8 +- .../alert-dialog/alert-dialog-action.svelte | 18 + .../alert-dialog/alert-dialog-cancel.svelte | 18 + .../alert-dialog/alert-dialog-content.svelte | 29 ++ .../alert-dialog-description.svelte | 17 + .../alert-dialog/alert-dialog-footer.svelte | 20 + .../alert-dialog/alert-dialog-header.svelte | 20 + .../alert-dialog/alert-dialog-overlay.svelte | 20 + .../alert-dialog/alert-dialog-portal.svelte | 7 + .../ui/alert-dialog/alert-dialog-title.svelte | 17 + .../alert-dialog/alert-dialog-trigger.svelte | 7 + .../ui/alert-dialog/alert-dialog.svelte | 7 + src/lib/components/ui/alert-dialog/index.ts | 37 ++ src/lib/components/ui/popover/index.ts | 19 + .../ui/popover/popover-close.svelte | 7 + .../ui/popover/popover-content.svelte | 31 ++ .../ui/popover/popover-portal.svelte | 7 + .../ui/popover/popover-trigger.svelte | 17 + src/lib/components/ui/popover/popover.svelte | 7 + src/lib/engine/layer-factory.ts | 22 +- src/lib/functions/projects.remote.ts | 25 + src/lib/layers/components/AudioLayer.svelte | 29 +- src/lib/layers/components/ImageLayer.svelte | 32 +- src/lib/layers/components/VideoLayer.svelte | 57 ++- src/lib/schemas/animation.ts | 95 +++- src/lib/stores/project.svelte.ts | 212 ++++++-- src/lib/utils/media.ts | 73 +++ 44 files changed, 1582 insertions(+), 501 deletions(-) create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-action.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-cancel.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-content.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-description.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-footer.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-header.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-overlay.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-portal.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-title.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog-trigger.svelte create mode 100644 src/lib/components/ui/alert-dialog/alert-dialog.svelte create mode 100644 src/lib/components/ui/alert-dialog/index.ts create mode 100644 src/lib/components/ui/popover/index.ts create mode 100644 src/lib/components/ui/popover/popover-close.svelte create mode 100644 src/lib/components/ui/popover/popover-content.svelte create mode 100644 src/lib/components/ui/popover/popover-portal.svelte create mode 100644 src/lib/components/ui/popover/popover-trigger.svelte create mode 100644 src/lib/components/ui/popover/popover.svelte create mode 100644 src/lib/utils/media.ts diff --git a/package.json b/package.json index 6b268c6..4807a5f 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "packageManager": "pnpm@10.28.1", "scripts": { - "dev": "node --inspect=6000 node_modules/vite/bin/vite.js dev", + "dev": "node --inspect=6000 node_modules/vite/bin/vite.js dev --host", "build": "vite build", "preview": "vite preview", "prepare": "svelte-kit sync || echo ''", diff --git a/src/lib/ai/mutations.ts b/src/lib/ai/mutations.ts index 837ad84..0ab1977 100644 --- a/src/lib/ai/mutations.ts +++ b/src/lib/ai/mutations.ts @@ -130,6 +130,14 @@ export function mutateCreateLayer( layer.exitTime = input.exitTime; } + // Set content duration and offset if provided (for video/audio layers) + if (input.contentDuration !== undefined) { + layer.contentDuration = input.contentDuration; + } + if (input.contentOffset !== undefined) { + layer.contentOffset = input.contentOffset; + } + // Mutate project ctx.project.layers.push(layer); @@ -295,6 +303,14 @@ export function mutateEditLayer(ctx: MutationContext, input: EditLayerInput): Ed layer.exitTime = Math.max(0, input.updates.exitTime); } + // Update content duration and offset (for video/audio layers) + if (input.updates.contentDuration !== undefined) { + layer.contentDuration = Math.max(0, input.updates.contentDuration); + } + if (input.updates.contentOffset !== undefined) { + layer.contentOffset = Math.max(0, input.updates.contentOffset); + } + return { success: true, layerId: resolvedId, diff --git a/src/lib/ai/schemas.ts b/src/lib/ai/schemas.ts index ec23993..6098699 100644 --- a/src/lib/ai/schemas.ts +++ b/src/lib/ai/schemas.ts @@ -113,7 +113,17 @@ function generateLayerCreationTools(): Record { .number() .min(0) .optional() - .describe('When layer exits the timeline (seconds, default: project duration)') + .describe('When layer exits the timeline (seconds, default: project duration)'), + contentDuration: z + .number() + .min(0) + .optional() + .describe('Total content duration for media layers (video/audio duration in seconds)'), + contentOffset: z + .number() + .min(0) + .optional() + .describe('Start offset for trimming media content (seconds)') }); const description = @@ -147,6 +157,10 @@ export interface CreateLayerInput { enterTime?: number; /** When the layer exits the timeline (seconds) */ exitTime?: number; + /** Total duration of layer content (for video/audio layers) */ + contentDuration?: number; + /** Start offset for trimming content (seconds) */ + contentOffset?: number; } export interface CreateLayerOutput { @@ -223,7 +237,19 @@ export const EditLayerInputSchema = z.object({ .optional(), rotation: z.number().optional().describe('Rotation in degrees'), opacity: z.number().min(0).max(1).optional(), - props: z.record(z.string(), z.unknown()).optional() + props: z.record(z.string(), z.unknown()).optional(), + enterTime: z.number().min(0).optional().describe('When layer enters timeline (seconds)'), + exitTime: z.number().min(0).optional().describe('When layer exits timeline (seconds)'), + contentDuration: z + .number() + .min(0) + .optional() + .describe('Total duration of layer content (e.g., video/audio duration in seconds)'), + contentOffset: z + .number() + .min(0) + .optional() + .describe('Start offset for trimming content (seconds)') }) }); diff --git a/src/lib/components/editor/AudioRecorder.svelte b/src/lib/components/editor/AudioRecorder.svelte index 35e3ef2..aaf9c5f 100644 --- a/src/lib/components/editor/AudioRecorder.svelte +++ b/src/lib/components/editor/AudioRecorder.svelte @@ -25,6 +25,7 @@ let mediaStream: MediaStream | null = $state(null); let audioChunks: Blob[] = $state([]); let recordingDuration = $state(0); + let recordingStartTime = $state(0); let recordingInterval: ReturnType | null = null; async function startRecording() { @@ -38,6 +39,7 @@ audioChunks = []; recordingDuration = 0; + recordingStartTime = Date.now(); mediaRecorder.ondataavailable = (event) => { if (event.data.size > 0) { @@ -46,6 +48,10 @@ }; mediaRecorder.onstop = async () => { + // Calculate final precise duration (ensure it's never NaN) + const elapsed = Date.now() - recordingStartTime; + const finalDuration = recordingStartTime > 0 && elapsed > 0 ? elapsed / 1000 : 1; + // Stop the stream stopMediaStream(mediaStream); mediaStream = null; @@ -53,17 +59,17 @@ // Create blob from chunks const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); - // Upload the recording - await uploadRecording(audioBlob); + // Upload the recording with precise duration + await uploadRecording(audioBlob, finalDuration); }; mediaRecorder.start(); isRecording = true; - // Update duration display + // Update duration display (more frequently for smoother UI) recordingInterval = setInterval(() => { - recordingDuration += 1; - }, 1000); + recordingDuration = (Date.now() - recordingStartTime) / 1000; + }, 100); } catch (err) { const error = handleMediaError(err); recordingError = error.message; @@ -83,13 +89,13 @@ } } - async function uploadRecording(blob: Blob) { + async function uploadRecording(blob: Blob, duration: number) { isUploading = true; recordingError = ''; try { const fileName = generateTimestampedFileName('recording', 'webm'); - const result = await uploadMediaBlob(blob, fileName, 'audio', projectId); + const result = await uploadMediaBlob(blob, fileName, 'audio', projectId, duration); onRecordingComplete(result); } catch (err) { recordingError = err instanceof Error ? err.message : 'Upload failed'; @@ -118,7 +124,9 @@
Recording...
-
{formatDuration(recordingDuration)}
+
+ {formatDuration(Math.floor(recordingDuration))} +
- {:else if state === 'preview'} + {:else if cameraState === 'preview'}
-
- {:else if state === 'review'} + {:else if cameraState === 'review'}
@@ -217,7 +223,7 @@
- {:else if state === 'uploading'} + {:else if cameraState === 'uploading'}
diff --git a/src/lib/components/editor/FileUpload.svelte b/src/lib/components/editor/FileUpload.svelte index 4b3befd..692abfe 100644 --- a/src/lib/components/editor/FileUpload.svelte +++ b/src/lib/components/editor/FileUpload.svelte @@ -13,7 +13,7 @@ /** Media type: image, video, or audio */ mediaType: 'image' | 'video' | 'audio'; /** Callback when file is uploaded */ - onUpload: (result: { url: string; key: string; fileName: string }) => void; + onUpload: (result: { url: string; key: string; fileName: string; duration?: number }) => void; /** Callback when file is removed */ onRemove?: () => void; /** Optional project ID for organizing uploads */ @@ -47,6 +47,44 @@ audio: '100MB' }; + /** + * Extract duration from video or audio file + */ + async function extractMediaDuration(file: File): Promise { + return new Promise((resolve) => { + const url = URL.createObjectURL(file); + + if (mediaType === 'video') { + const video = document.createElement('video'); + video.preload = 'metadata'; + video.onloadedmetadata = () => { + URL.revokeObjectURL(url); + resolve(video.duration); + }; + video.onerror = () => { + URL.revokeObjectURL(url); + resolve(undefined); + }; + video.src = url; + } else if (mediaType === 'audio') { + const audio = document.createElement('audio'); + audio.preload = 'metadata'; + audio.onloadedmetadata = () => { + URL.revokeObjectURL(url); + resolve(audio.duration); + }; + audio.onerror = () => { + URL.revokeObjectURL(url); + resolve(undefined); + }; + audio.src = url; + } else { + URL.revokeObjectURL(url); + resolve(undefined); + } + }); + } + async function handleFileSelect(e: Event) { const input = e.target as HTMLInputElement; const file = input.files?.[0]; @@ -56,6 +94,12 @@ uploadError = ''; try { + // Extract duration for video/audio files + let duration: number | undefined; + if (mediaType === 'video' || mediaType === 'audio') { + duration = await extractMediaDuration(file); + } + const formData = new FormData(); formData.append('file', file); formData.append('mediaType', mediaType); @@ -78,7 +122,8 @@ onUpload({ url: data.file.url, key: data.file.key, - fileName: data.file.originalName + fileName: data.file.originalName, + duration }); } } catch (err) { @@ -98,6 +143,29 @@
+ + {#if value && (mediaType === 'image' || mediaType === 'video')} +
+ {#if mediaType === 'image'} + {displayName} + {:else if mediaType === 'video'} + + + {/if} +
+ {/if} +
diff --git a/src/lib/components/editor/canvas/canvas.svelte b/src/lib/components/editor/canvas/canvas.svelte index e3b85fb..9e2089a 100644 --- a/src/lib/components/editor/canvas/canvas.svelte +++ b/src/lib/components/editor/canvas/canvas.svelte @@ -249,7 +249,13 @@ {@const { transform, style, customProps } = getLayerRenderData(layer)} {@const component = getLayerComponent(layer.type)} {@const isSelected = projectStore.selectedLayerId === layer.id} - {@const enhancedProps = { ...customProps, layerId: layer.id, enterTime }} + {@const contentOffset = layer.contentOffset ?? 0} + {@const enhancedProps = { + ...customProps, + layerId: layer.id, + enterTime, + contentOffset + }} { const formData = new FormData(); formData.append('file', blob, fileName); @@ -42,7 +44,8 @@ export async function uploadMediaBlob( return { url: data.file.url, key: data.file.key, - fileName: data.file.originalName + fileName: data.file.originalName, + duration }; } diff --git a/src/lib/components/editor/panels/background-picker.svelte b/src/lib/components/editor/panels/background-picker.svelte index 196398e..920d73a 100644 --- a/src/lib/components/editor/panels/background-picker.svelte +++ b/src/lib/components/editor/panels/background-picker.svelte @@ -4,8 +4,9 @@ import { Label } from '$lib/components/ui/label'; import { Separator } from '$lib/components/ui/separator'; import { ScrollArea } from '$lib/components/ui/scroll-area'; + import * as Popover from '$lib/components/ui/popover'; - import { Palette, Sparkles, Plus, Trash2 } from '@lucide/svelte'; + import { Palette, Sparkles, Plus, Trash2, ChevronDown } from '@lucide/svelte'; import { type BackgroundValue, type ColorStop, @@ -24,9 +25,10 @@ interface Props { value: BackgroundValue; onchange: (value: BackgroundValue) => void; + side?: 'left' | 'right'; } - let { value, onchange }: Props = $props(); + let { value, onchange, side = 'left' }: Props = $props(); // Current tab: 'solid' | 'gradient' | 'presets' let activeTab = $derived<'solid' | 'gradient' | 'presets'>(isSolid(value) ? 'solid' : 'gradient'); @@ -58,6 +60,26 @@ ] ); + // Sync local state when value changes from outside + $effect(() => { + activeTab = isSolid(value) ? 'solid' : 'gradient'; + if (isGradient(value)) { + gradientType = value.type; + stops = [...value.stops]; + if (value.type === 'linear') { + angle = value.angle; + } else if (value.type === 'radial') { + posX = value.position.x; + posY = value.position.y; + radialShape = value.shape; + } else if (value.type === 'conic') { + angle = value.angle; + posX = value.position.x; + posY = value.position.y; + } + } + }); + // CSS preview of current value const previewCSS = $derived(backgroundValueToCSS(value)); @@ -129,251 +151,253 @@ function applyPreset(preset: GradientPreset) { onchange(preset.value); - // Update local state to match preset - if (isGradient(preset.value)) { - gradientType = preset.value.type; - stops = [...preset.value.stops]; - if (preset.value.type === 'linear') { - angle = preset.value.angle; - } else if (preset.value.type === 'radial') { - posX = preset.value.position.x; - posY = preset.value.position.y; - radialShape = preset.value.shape; - } else if (preset.value.type === 'conic') { - angle = preset.value.angle; - posX = preset.value.position.x; - posY = preset.value.position.y; - } - } activeTab = 'gradient'; } -
- -
-
-
- - -
- - - -
+ + + {#snippet child({ props })} + + {/snippet} + - - {#if activeTab === 'solid'} +
- -
- updateSolidColor(e.currentTarget.value)} - class="h-10 w-14 cursor-pointer p-1" - /> - updateSolidColor(e.currentTarget.value)} - class="flex-1 font-mono text-xs" - placeholder="#000000" - /> -
+ +
- -
- {#each defaultSolidColors as color (color)} - - {/each} -
-
- {:else if activeTab === 'gradient'} -
- -
- - + Presets +
- - {#if gradientType === 'linear' || gradientType === 'conic'} -
- - - deg -
- {/if} - - - {#if gradientType === 'radial' || gradientType === 'conic'} -
- -
+ + {#if activeTab === 'solid'} +
+ +
updateSolidColor(e.currentTarget.value)} + class="h-9 w-12 cursor-pointer p-1" /> updateSolidColor(e.currentTarget.value)} + class="flex-1 font-mono text-xs" + placeholder="#000000" />
- % -
- {/if} - - {#if gradientType === 'radial'} -
- - + +
+ {#each defaultSolidColors as color (color)} + + {/each} +
- {/if} + {:else if activeTab === 'gradient'} + +
+ +
+ + +
- + + {#if gradientType === 'linear' || gradientType === 'conic'} +
+ +
+ + deg +
+
+ {/if} - -
-
- - -
+ + {#if gradientType === 'radial' || gradientType === 'conic'} +
+ +
+ + + % +
+
+ {/if} -
- {#each stops as stop, index (index)} -
- updateColorStop(index, 'color', e.currentTarget.value)} - class="h-7 w-10 cursor-pointer p-0.5" - /> - - updateColorStop(index, 'position', parseInt(e.currentTarget.value) || 0)} - class="h-7 w-16 text-xs" - /> - % - {#if stops.length > 2} - - {/if} -
- {/each} -
-
-
- {:else if activeTab === 'presets'} -
- -
- - -
+
- - -
- {#each filteredPresets as preset (preset.id)} - + {/if} +
+ {/each} +
+
+
+ + {:else if activeTab === 'presets'} +
+ +
+ + +
+ + + +
+ {#each filteredPresets as preset (preset.id)} + + {/each} +
+
- + {/if}
- {/if} -
+ + diff --git a/src/lib/components/editor/panels/layers-panel.svelte b/src/lib/components/editor/panels/layers-panel.svelte index 723895d..5ec59ea 100644 --- a/src/lib/components/editor/panels/layers-panel.svelte +++ b/src/lib/components/editor/panels/layers-panel.svelte @@ -5,13 +5,16 @@ import { Eye, EyeOff, Lock, Unlock, Trash2, Plus } from '@lucide/svelte'; import type { Layer } from '$lib/types/animation'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js'; + import * as Popover from '$lib/components/ui/popover'; import { createLayer } from '$lib/engine/layer-factory'; - import { getLayerDefinition, layerRegistry } from '$lib/layers/registry'; + import { getLayerDefinition, layerRegistry, type LayerType } from '$lib/layers/registry'; import { cn } from '$lib/utils'; + let deletePopoverOpenLayerId = $state(null); + // Note: Coordinate system has (0, 0) at canvas center function addLayer(type: string) { - const layer = createLayer(type as import('$lib/types/animation').LayerType, {}, { x: 0, y: 0 }); + const layer = createLayer(type as LayerType, {}, { x: 0, y: 0 }); projectStore.addLayer(layer); projectStore.selectedLayerId = layer.id; } @@ -30,11 +33,8 @@ projectStore.updateLayer(layer.id, { locked: !layer.locked }); } - function deleteLayer(layer: Layer, e: Event) { - e.stopPropagation(); - if (confirm(`Delete layer "${layer.name}"?`)) { - projectStore.removeLayer(layer.id); - } + function deleteLayer(layerId: string) { + projectStore.removeLayer(layerId); } function handleDragStart(e: DragEvent, index: number) { @@ -83,7 +83,8 @@ {#each Object.values(layerRegistry) as layer (layer.type)} addLayer(layer.type)}> {#if layer.icon} - + {@const Icon = layer.icon} + {/if} {layer.label} @@ -124,6 +125,8 @@
+ + {#snippet child({ props })} + + {/snippet} + + +
+

Delete Layer

+

+ Delete "{layer.name}"? This cannot be undone. +

+
+ + {#snippet child({ props })} + + {/snippet} + + + {#snippet child()} + + {/snippet} + +
+
+
+
{/each} diff --git a/src/lib/components/editor/panels/properties-panel.svelte b/src/lib/components/editor/panels/properties-panel.svelte index 536d97d..9d53dd1 100644 --- a/src/lib/components/editor/panels/properties-panel.svelte +++ b/src/lib/components/editor/panels/properties-panel.svelte @@ -7,6 +7,7 @@ import { Separator } from '$lib/components/ui/separator'; import * as DropdownMenu from '$lib/components/ui/dropdown-menu'; import * as ButtonGroup from '$lib/components/ui/button-group'; + import * as Popover from '$lib/components/ui/popover'; import { Pin, Trash2, @@ -443,11 +444,24 @@ updateLayerProps('src', result.url); updateLayerProps('fileKey', result.key); updateLayerProps('fileName', result.fileName); + // Set content duration if available + if (result.duration !== undefined) { + projectStore.updateLayer(selectedLayer.id, { contentDuration: result.duration }); + // Auto-set exit time based on content duration if layer has no exit time yet + if (!selectedLayer.exitTime) { + const enterTime = selectedLayer.enterTime ?? 0; + projectStore.setLayerExitTime(selectedLayer.id, enterTime + result.duration); + } + } }} onRemove={() => { updateLayerProps('src', ''); updateLayerProps('fileKey', ''); updateLayerProps('fileName', ''); + projectStore.updateLayer(selectedLayer.id, { + contentDuration: undefined, + contentOffset: undefined + }); }} /> {:else if metadata.type === 'number'} @@ -539,7 +553,17 @@
- +
+ + {#if selectedLayer.contentDuration !== undefined} + {@const contentDuration = selectedLayer.contentDuration} + {@const contentOffset = selectedLayer.contentOffset ?? 0} + {@const availableContent = contentDuration - contentOffset} + + Max: {availableContent.toFixed(1)}s + + {/if} +
@@ -547,7 +571,13 @@ id="enter-time" value={selectedLayer.enterTime ?? 0} min={0} - max={projectStore.project.duration} + max={selectedLayer.contentDuration !== undefined + ? Math.min( + projectStore.project.duration, + projectStore.project.duration - + (selectedLayer.contentDuration - (selectedLayer.contentOffset ?? 0)) + ) + : projectStore.project.duration} step={0.1} onchange={(v) => projectStore.setLayerEnterTime(selectedLayer.id, v)} /> @@ -565,35 +595,54 @@
- + {#if selectedLayer.type === 'video' || selectedLayer.type === 'audio'} + {@const contentDuration = selectedLayer.contentDuration ?? 0} + {@const contentOffset = selectedLayer.contentOffset ?? 0} + {@const hasDuration = contentDuration > 0}
- -
-
- - updateLayerProps('mediaStartTime', v)} - /> -
-
- - updateLayerProps('mediaEndTime', v)} - /> -
+
+ + {#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}
- + + + {#snippet child({ props })} + + {/snippet} + + +
+

Delete Keyframe

+

+ Delete keyframe for {getPropertyLabel(keyframe.property)}? +

+
+ + {#snippet child({ props })} + + {/snippet} + + + {#snippet child({ props })} + + {/snippet} + +
+
+
+
- {:else if metadata.widget === 'textarea'} + {:else if metadata.meta?.widget === 'textarea'}