From 229a897ac6703d4788ab06c6cdfb2c8edebd4940 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Tue, 17 Feb 2026 23:19:56 +0100 Subject: [PATCH 1/2] ai impr --- .gitignore | 1 + src/lib/ai/models.ts | 75 ++++---- src/lib/ai/mutations.ts | 6 +- src/lib/ai/schemas.ts | 164 +++++++--------- src/lib/ai/system-prompt.ts | 28 ++- src/lib/components/ai/ai-chat.svelte | 127 ++++++------- src/lib/components/ai/tool-part.svelte | 8 +- .../ui/color-picker/color-picker.svelte | 11 +- src/lib/engine/interpolation.ts | 31 +++- src/lib/layers/base.ts | 3 + src/lib/layers/components/AudioLayer.svelte | 48 +++-- src/lib/layers/components/BrowserLayer.svelte | 58 ++++-- src/lib/layers/components/ButtonLayer.svelte | 110 ++++++++--- .../layers/components/CaptionsLayer.svelte | 90 ++++++--- src/lib/layers/components/CodeLayer.svelte | 142 ++++++++++++-- src/lib/layers/components/HtmlLayer.svelte | 120 ++++++++---- src/lib/layers/components/IconLayer.svelte | 91 ++++++++- src/lib/layers/components/ImageLayer.svelte | 42 +++-- src/lib/layers/components/MouseLayer.svelte | 36 ++-- src/lib/layers/components/PhoneLayer.svelte | 70 +++++-- src/lib/layers/components/ShapeLayer.svelte | 64 +++++-- .../layers/components/TerminalLayer.svelte | 89 ++++++--- src/lib/layers/components/TextLayer.svelte | 92 ++++++--- src/lib/layers/components/VideoLayer.svelte | 80 +++++--- src/lib/layers/properties/field-registry.ts | 2 + src/lib/schemas/animation.ts | 175 +++++++++++------- src/lib/types/animation.ts | 3 - src/routes/(app)/p/[id]/+page.svelte | 49 +++-- src/routes/mcp/+server.ts | 71 ++++--- 29 files changed, 1264 insertions(+), 622 deletions(-) diff --git a/.gitignore b/.gitignore index 03aee31..a2fb76d 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ src/lib/paraglide logs/ .vscode/chrome/ demo.json +tools.json diff --git a/src/lib/ai/models.ts b/src/lib/ai/models.ts index bf7174f..5912ada 100644 --- a/src/lib/ai/models.ts +++ b/src/lib/ai/models.ts @@ -31,7 +31,7 @@ export const AI_MODELS = { name: 'Minimax M2.5', provider: 'Minimax', description: 'Excellent for creative and complex tasks with 128K context', - recommended: true, + recommended: false, costTier: 'low', pricing: { input: 0.3, @@ -56,37 +56,49 @@ export const AI_MODELS = { name: 'Grok 4.1 Fast', provider: 'X AI', description: 'Excellent for creative and complex tasks with 128K context', - recommended: true, + recommended: false, costTier: 'low', pricing: { input: 0.2, output: 0.5 } - }, - 'x-ai/grok-4': { - id: 'x-ai/grok-4', - name: 'Grok 4', - provider: 'X AI', - description: 'Excellent for creative and complex tasks with 256K context', - recommended: true, - costTier: 'medium', - pricing: { - input: 3, - output: 15 - } - }, - 'google/gemini-3-pro-preview': { - id: 'google/gemini-3-pro-preview', - name: 'Gemini 3 Pro', - provider: 'Google', - description: 'Excellent for creative and complex tasks with 128K context', - recommended: true, - costTier: 'high', - pricing: { - input: 2, - output: 12 - } } + // 'x-ai/grok-4': { + // id: 'x-ai/grok-4', + // name: 'Grok 4', + // provider: 'X AI', + // description: 'Excellent for creative and complex tasks with 256K context', + // recommended: false, + // costTier: 'medium', + // pricing: { + // input: 3, + // output: 15 + // } + // }, + // 'google/gemini-3-pro-preview': { + // id: 'google/gemini-3-pro-preview', + // name: 'Gemini 3 Pro', + // provider: 'Google', + // description: 'Excellent for creative and complex tasks with 128K context', + // recommended: false, + // costTier: 'high', + // pricing: { + // input: 2, + // output: 12 + // } + // }, + // 'anthropic/claude-sonnet-4.6': { + // id: 'anthropic/claude-sonnet-4.6', + // name: 'Claude Sonnet 4.6', + // provider: 'Anthropic', + // description: 'Excellent for creative and complex tasks with 1M context', + // recommended: false, + // costTier: 'high', + // pricing: { + // input: 3, + // output: 15 + // } + // } // // Claude 4.5 Sonnet - Great reasoning and creativity // 'anthropic/claude-sonnet-4.5': { @@ -131,11 +143,6 @@ export const AI_MODELS = { export type ModelId = keyof typeof AI_MODELS; -/** - * Default model to use - */ -export const DEFAULT_MODEL_ID: ModelId = 'x-ai/grok-4.1-fast'; - /** * Get model by ID with fallback to default */ @@ -160,6 +167,12 @@ export function getAvailableModels(): AIModel[] { }); } +/** + * Default model to use + */ +export const DEFAULT_MODEL_ID: ModelId = + (getRecommendedModels().find((m) => m.recommended)?.id as ModelId) || 'x-ai/grok-4.1-fast'; + /** * Get recommended models */ diff --git a/src/lib/ai/mutations.ts b/src/lib/ai/mutations.ts index 223e03e..d51cd7b 100644 --- a/src/lib/ai/mutations.ts +++ b/src/lib/ai/mutations.ts @@ -121,8 +121,8 @@ export function mutateCreateLayer( input: CreateLayerInput ): MutationResult { try { - const layer = createLayer(input.type, { - props: input.props, + const layer = createLayer(input.layer.type, { + props: input.layer.props, transform: input.transform, projectDimensions: { width: ctx.project.width, @@ -185,7 +185,7 @@ export function mutateCreateLayer( layerId: layer.id, layerIndex: ctx.layerCreationIndex, // Return the index used for this layer layerName: layer.name, - message: `Created ${input.type} layer "${layer.name}"` + message: `Created ${input.layer.type} layer "${layer.name}"` }, nextLayerCreationIndex: nextIndex }; diff --git a/src/lib/ai/schemas.ts b/src/lib/ai/schemas.ts index 8c35fc0..73c33e7 100644 --- a/src/lib/ai/schemas.ts +++ b/src/lib/ai/schemas.ts @@ -5,52 +5,18 @@ * Each layer type has its own creation tool (create_text_layer, create_icon_layer, etc.) */ import { z } from 'zod'; -import { InterpolationSchema, TransformSchema } from '$lib/schemas/animation'; +import { InterpolationSchema } from '$lib/schemas/animation'; import { getPresetIds } from '$lib/engine/presets'; -import { tool, type InferUITools, type Tool } from 'ai'; +import { tool, type InferUITools } from 'ai'; import { layerRegistry, getAvailableLayerTypes } from '$lib/layers/registry'; -import { AnchorPointSchema, extractDefaultValues } from '$lib/layers/base'; +import { AnchorPointSchema } from '$lib/layers/base'; import type { LayerTypeString } from '$lib/layers/layer-types'; - -// ============================================ -// Helper Functions -// ============================================ - -/** - * Check if a tool name is a layer creation tool - */ -export function isLayerCreationTool(toolName: string): boolean { - return toolName.startsWith('create_') && toolName.endsWith('_layer'); -} - -/** - * Extract layer type from tool name - * e.g., "create_text_layer" → "text" - */ -export function getLayerTypeFromToolName(toolName: string): string | null { - const match = toolName.match(/^create_(.+)_layer$/); - return match ? match[1] : null; -} +import { CleanTransformSchema } from '$lib/schemas/base'; // ============================================ // Schema Introspection // ============================================ -/** - * Maximum number of key props to show in tool descriptions. - * Layer authors should place the most important fields first in their schema. - */ -const MAX_KEY_PROPS = 4; - -/** - * Extract key property names from a Zod object schema. - * Takes the first N fields from the schema shape, relying on layer authors - * ordering the most important properties first. - */ -function extractKeyProps(schema: z.ZodObject): string[] { - return Object.keys(schema.shape).slice(0, MAX_KEY_PROPS); -} - const AnimationSchema = z .object({ preset: z.string().describe('Animation preset: ' + getPresetIds().join(', ')), @@ -104,69 +70,74 @@ const validateTimingFields = (data: { enterTime?: number; exitTime?: number }) = }; // ============================================ -// Layer Creation Tool Generator +// Layer Creation Tool Schema // ============================================ -const CreateLayerInputSchema = z - .object({ - name: z.string().optional().describe('Layer name for identification'), - visible: z.boolean().optional().default(true).describe('Layer visibility (default: true)'), - locked: z.boolean().optional().default(false).describe('Layer locked state (default: false)'), - transform: TransformSchema, - animation: AnimationSchema - }) - .extend(TimingFieldsSchema.shape) - .refine(validateTimingFields, { - message: 'enterTime must be less than exitTime', - path: ['enterTime'] - }); +const BaseCreateLayerSchema = z.object({ + name: z.string().optional().describe('Layer name for identification'), + visible: z.boolean().optional().default(true).describe('Layer visibility (default: true)'), + locked: z.boolean().optional().default(false).describe('Layer locked state (default: false)'), + transform: CleanTransformSchema, + animation: AnimationSchema +}); + /** - * Generate tool definitions for all layer types from the registry. - * Key props and defaults are derived directly from each layer's Zod schema, - * so no manual mapping needs to be maintained. + * Generate discriminated union schema for layer type and props. + * Each layer type gets its full schema with all metadata preserved. */ -function generateLayerCreationTools(): Record { - const tools: Record = {}; +function generateLayerTypePropsUnion() { + const layerSchemas = [] as unknown as [z.ZodObject]; for (const layerType of getAvailableLayerTypes() as LayerTypeString[]) { - if (layerType === 'project-settings') continue; + switch (layerType) { + case 'browser': + case 'captions': + case 'terminal': + case 'video': + case 'audio': + case 'phone': + case 'code': + case 'project-settings': + continue; + } const definition = layerRegistry[layerType]; if (!definition) continue; - const toolName = `create_${layerType}_layer`; - const keyProps = extractKeyProps(definition.schema); - const defaults = extractDefaultValues(definition.schema); - - // Build key props description with defaults from schema - const keyPropsDescription = keyProps - .map((prop) => { - const defaultVal = defaults[prop]; - return defaultVal !== undefined ? `${prop} (default: ${JSON.stringify(defaultVal)})` : prop; - }) - .join(', '); - - const description = - `Create a ${definition.label} layer. ${definition.description}\n` + - `Key props: ${keyPropsDescription}`; - - tools[toolName] = tool({ - description, - inputSchema: CreateLayerInputSchema.extend({ - props: definition.schema.describe(`Properties for ${definition.label} layer`) - }) + // Create schema for type + props discriminated union + const layerSchema = z.object({ + type: z.literal(layerType), + props: definition.schema.describe(`Properties for ${definition.label} layer`) }); + + layerSchemas.push(layerSchema); } - return tools; + return z.discriminatedUnion('type', layerSchemas); } +const LayerTypePropsUnion = generateLayerTypePropsUnion(); + +/** + * Full create_layer input schema: base fields + timing + discriminated type/props union + */ +export const CreateLayerInputSchema = BaseCreateLayerSchema.extend(TimingFieldsSchema.shape) + .refine(validateTimingFields, { + message: 'enterTime must be less than exitTime', + path: ['enterTime'] + }) + .extend({ + layer: LayerTypePropsUnion + }); + // ============================================ // Input/Output Types for Layer Creation // ============================================ export type CreateLayerInput = z.infer & { - type: string; - props: Record; + layer: { + type: string; + props: Record; + }; }; export interface CreateLayerOutput { success: boolean; @@ -194,18 +165,26 @@ export const AnimateLayerInputSchema = z.object({ keyframes: z .array( z.object({ - time: z.number().min(0).describe('Time in seconds'), + time: z.number().min(0).describe('Time in seconds when this value is reached'), property: z .string() .describe( - 'Property: position.x, position.y, scale.x, scale.y, rotation.z, opacity, props.*' + 'Property path: position.x/y/z, scale.x/y, rotation.x/y/z, opacity, or props.* for layer-specific (e.g., props.fontSize, props.content for text layers)' ), - value: z.union([z.number(), z.string(), z.boolean()]), - interpolation: InterpolationSchema.optional() + value: z + .union([z.number(), z.string(), z.boolean()]) + .describe( + 'Target value: number for transforms/opacity, hex string (#ff0000) for colors, string for text content, boolean for flags' + ), + interpolation: InterpolationSchema.optional().describe( + 'How to animate TO this keyframe. Omit for smooth ease-in-out. Use {family:"text",strategy:"char-reveal"} ONLY on props.content to create typewriter effects' + ) }) ) .optional() - .describe('Custom keyframes (alternative to preset)') + .describe( + 'Custom keyframes (alternative to preset). IMPORTANT: For text typewriter/reveal effects, animate props.content from "" to full text with text interpolation' + ) }); export type AnimateLayerInput = z.infer; @@ -229,7 +208,7 @@ export const EditLayerInputSchema = z.object({ name: z.string().optional(), visible: z.boolean().optional(), locked: z.boolean().optional(), - transform: TransformSchema.optional(), + transform: CleanTransformSchema.optional(), anchor: AnchorPointSchema.optional().describe('Anchor point for transformations'), opacity: z.number().min(0).max(1).optional(), props: z.record(z.string(), z.unknown()).optional() @@ -385,12 +364,11 @@ export interface RemoveKeyframeOutput { // Tool Definitions for AI SDK // ============================================ -// Generate layer creation tools from registry -const layerCreationTools = generateLayerCreationTools(); - export const animationTools = { - // Layer creation tools (dynamically generated from registry) - ...layerCreationTools, + create_layer: tool({ + description: 'Create a new layer.', + inputSchema: CreateLayerInputSchema + }), animate_layer: tool({ description: diff --git a/src/lib/ai/system-prompt.ts b/src/lib/ai/system-prompt.ts index 474f87d..b74ea55 100644 --- a/src/lib/ai/system-prompt.ts +++ b/src/lib/ai/system-prompt.ts @@ -64,10 +64,21 @@ IMPORTANT: All messages must be in PLAIN TEXT without markdown formatting. ## Interpolation Options When creating custom keyframes, you can specify interpolation: -- **continuous**: linear, ease-in, ease-out, ease-in-out (smooth transitions) -- **discrete**: step-end, step-start, step-mid (instant changes) -- **quantized**: integer, snap-grid (rounded values) -- **text**: char-reveal, word-reveal (for text animations) +- **continuous**: For numbers (position, scale, opacity) AND colors (fill, stroke, dropShadowColor) + - Colors: use hex values (#ff0000 → #0000ff), interpolates smoothly in RGB space + - Easing: ease-out for entrances (fast→slow), ease-in for exits (slow→fast) + - More dramatic: ease-*-back (overshoot), bounce, elastic + - Examples: + - Fade in text: {property:"opacity", value:0→1, interpolation:{family:"continuous", strategy:"ease-out"}} + - Color shift: {property:"props.fill", value:"#ff0000"→"#00ff00", interpolation:{family:"continuous", strategy:"ease-in-out"}} +- **discrete**: step-end, step-start, step-mid (instant jumps, no smooth transition) +- **quantized**: integer, snap-grid (animates smoothly but snaps to whole numbers) +- **text**: char-reveal, word-reveal (ONLY for props.content on text layers) + - IMPORTANT: To create typewriter effects, add keyframes for props.content: + - Start keyframe: time=0, value="", interpolation={family:"text", strategy:"char-reveal"} + - End keyframe: time=2, value="Full text here" + - char-reveal: types character by character + - word-reveal: reveals word by word Default is continuous/ease-in-out if not specified. @@ -93,6 +104,15 @@ Distribute layers across the canvas — never stack everything at (0,0). - Stagger start times by 0.1–0.3 s between layers for a professional sequence. - Entrances: 0.3–0.6 s with ease-out. Exits: 0.2–0.4 s with ease-in. - Animate hero elements first, then supporting ones. +- **Text typewriter effects**: Always animate props.content with text interpolation: + \`\`\`json + { + "keyframes": [ + { "time": 0, "property": "props.content", "value": "", "interpolation": { "family": "text", "strategy": "char-reveal" } }, + { "time": 2, "property": "props.content", "value": "Your full text here" } + ] + } + \`\`\` ## Good example (reference) diff --git a/src/lib/components/ai/ai-chat.svelte b/src/lib/components/ai/ai-chat.svelte index d95b882..45b9fb3 100644 --- a/src/lib/components/ai/ai-chat.svelte +++ b/src/lib/components/ai/ai-chat.svelte @@ -33,8 +33,6 @@ type GroupLayersInput, type UngroupLayersInput, type AnimationUITools, - isLayerCreationTool, - getLayerTypeFromToolName, type CreateLayerInput, type UpdateKeyframeInput, type RemoveKeyframeInput @@ -46,6 +44,7 @@ import { PersistedState, watch } from 'runed'; import ToolPart from './tool-part.svelte'; import ReasoningPart from './reasoning-part.svelte'; + import { tick } from 'svelte'; const editorState = $derived(getEditorState()); const projectStore = $derived(editorState.project); @@ -64,6 +63,24 @@ [] ); + function scrollToBottom() { + let isAtBottom = false; + if (scrollRef) { + const top = scrollRef.scrollTop; + const scrollHeight = scrollRef.scrollHeight; + const clientHeight = scrollRef.clientHeight; + const diff = scrollHeight - clientHeight; + isAtBottom = top >= diff - 100; + } + // allow the user to scroll up to read previous messages + if (isAtBottom) { + scrollRef?.scrollTo({ + top: scrollRef.scrollHeight, + behavior: 'instant' + }); + } + } + const chat = new Chat>({ transport: new DefaultChatTransport({ api: resolve('/(app)/chat'), @@ -80,63 +97,46 @@ toast.error(parseErrorMessage(error)); }, onToolCall({ toolCall }) { + console.log('Tool call:', toolCall); if (toolCall.dynamic) { return; } const toolName = toolCall.toolName; let result: unknown; - // Handle layer creation tools (create_text_layer, create_icon_layer, etc.) - if (isLayerCreationTool(toolName)) { - const layerType = getLayerTypeFromToolName(toolName); - if (layerType) { + // Handle tools + switch (toolName) { + case 'create_layer': { const input = toolCall.input as CreateLayerInput; - result = executeCreateLayer(projectStore, { - type: layerType, - name: input.name, - visible: input.visible, - locked: input.locked, - transform: input.transform, - props: input.props || {}, - animation: input.animation, - enterTime: input.enterTime, - exitTime: input.exitTime, - contentDuration: input.contentDuration, - contentOffset: input.contentOffset - }); - } else { - result = { success: false, error: `Invalid layer creation tool: ${toolName}` }; - } - } else { - // Handle other tools - switch (toolName) { - case 'animate_layer': - result = executeAnimateLayer(projectStore, toolCall.input as AnimateLayerInput); - break; - case 'edit_layer': - result = executeEditLayer(projectStore, toolCall.input as EditLayerInput); - break; - case 'update_keyframe': - result = executeUpdateKeyframe(projectStore, toolCall.input as UpdateKeyframeInput); - break; - case 'remove_keyframe': - result = executeRemoveKeyframe(projectStore, toolCall.input as RemoveKeyframeInput); - break; - case 'remove_layer': - result = executeRemoveLayer(projectStore, toolCall.input as RemoveLayerInput); - break; - case 'group_layers': - result = executeGroupLayers(projectStore, toolCall.input as GroupLayersInput); - break; - case 'ungroup_layers': - result = executeUngroupLayers(projectStore, toolCall.input as UngroupLayersInput); - break; - case 'configure_project': - result = executeConfigureProject(projectStore, toolCall.input as ConfigureProjectInput); - break; - default: - result = { success: false, error: `Unknown tool: ${toolName}` }; + result = executeCreateLayer(projectStore, input); + break; } + case 'animate_layer': + result = executeAnimateLayer(projectStore, toolCall.input as AnimateLayerInput); + break; + case 'edit_layer': + result = executeEditLayer(projectStore, toolCall.input as EditLayerInput); + break; + case 'update_keyframe': + result = executeUpdateKeyframe(projectStore, toolCall.input as UpdateKeyframeInput); + break; + case 'remove_keyframe': + result = executeRemoveKeyframe(projectStore, toolCall.input as RemoveKeyframeInput); + break; + case 'remove_layer': + result = executeRemoveLayer(projectStore, toolCall.input as RemoveLayerInput); + break; + case 'group_layers': + result = executeGroupLayers(projectStore, toolCall.input as GroupLayersInput); + break; + case 'ungroup_layers': + result = executeUngroupLayers(projectStore, toolCall.input as UngroupLayersInput); + break; + case 'configure_project': + result = executeConfigureProject(projectStore, toolCall.input as ConfigureProjectInput); + break; + default: + result = { success: false, error: `Unknown tool: ${toolName}` }; } chat.addToolOutput({ @@ -150,12 +150,14 @@ } }); - function sendMessage() { + async function sendMessage() { // Reset layer tracking for new message resetLayerTracking(); chat.sendMessage({ text: prompt.current }); prompt.current = ''; + await tick(); + scrollToBottom(); } function onSubmit(event?: Event) { @@ -178,26 +180,7 @@ toast.success('Chat history cleared'); } - watch.pre( - () => $state.snapshot(chat.messages), - () => { - let isAtBottom = false; - if (scrollRef) { - const top = scrollRef.scrollTop; - const scrollHeight = scrollRef.scrollHeight; - const clientHeight = scrollRef.clientHeight; - const diff = scrollHeight - clientHeight; - isAtBottom = top >= diff - 100; - } - // allow the user to scroll up to read previous messages - if (isAtBottom) { - scrollRef?.scrollTo({ - top: scrollRef.scrollHeight, - behavior: 'instant' - }); - } - } - ); + watch(() => $state.snapshot(chat.messages), scrollToBottom);
diff --git a/src/lib/components/ai/tool-part.svelte b/src/lib/components/ai/tool-part.svelte index a4ec9f5..65abe17 100644 --- a/src/lib/components/ai/tool-part.svelte +++ b/src/lib/components/ai/tool-part.svelte @@ -32,7 +32,13 @@

{message}

{:else}

- {tool.state === 'output-available' ? 'Completed' : 'Processing...'} + {#if tool.state === 'output-available'} + Completed + {:else if tool.state === 'input-streaming'} + Processing... + {:else if tool.state === 'output-error'} + Error + {/if}

{/if}
diff --git a/src/lib/components/ui/color-picker/color-picker.svelte b/src/lib/components/ui/color-picker/color-picker.svelte index e416542..a9132ff 100644 --- a/src/lib/components/ui/color-picker/color-picker.svelte +++ b/src/lib/components/ui/color-picker/color-picker.svelte @@ -4,6 +4,7 @@ import { colord } from 'colord'; import Input from '../input/input.svelte'; import * as Popover from '$lib/components/ui/popover'; + import { cn } from '$lib/utils'; type Props = { value: string; @@ -32,7 +33,15 @@ - + {#snippet child({ props })} + + {/snippet} Hello World') - .describe('HTML content - use {{varName}} for variable interpolation') - .register(fieldRegistry, { widget: 'textarea', interpolationFamily: 'discrete' }), + .describe( + 'The HTML markup to render. Use standard HTML tags. Reference variables with {{varName}} syntax: {{var1}}–{{var5}} for text, {{num1}}–{{num3}} for numbers, {{color1}}/{{color2}} for colors, {{show1}}/{{show2}} for visibility (block/none). HTML is sanitized for security.' + ) + .register(fieldRegistry, { + widget: 'textarea', + interpolationFamily: 'discrete', + label: 'HTML' + }), css: z .string() .default( @@ -28,95 +34,133 @@ font-family: 'Inter', sans-serif; }` ) - .describe('CSS styles (scoped to this layer)') - .register(fieldRegistry, { widget: 'textarea', interpolationFamily: 'discrete' }), + .describe( + 'The CSS styles scoped to this layer. Selectors are automatically namespaced so they only affect this layer. Use {{varName}} for variable interpolation in CSS values too (e.g., color: {{color1}}).' + ) + .register(fieldRegistry, { + widget: 'textarea', + interpolationFamily: 'discrete', + label: 'CSS' + }), width: z .number() .min(10) .max(5000) .default(400) - .describe('Container width (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), + .describe( + 'The width of the HTML container in pixels. Also accessible in HTML/CSS via {{width}}. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Width' + }), height: z .number() .min(10) .max(5000) .default(200) - .describe('Container height (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), - // Dynamic variables that can be interpolated and animated + .describe( + 'The height of the HTML container in pixels. Also accessible in HTML/CSS via {{height}}. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Height' + }), var1: z .string() .default('') - .describe('Variable {{var1}} for interpolation') - .register(fieldRegistry, { interpolationFamily: 'text' }), + .describe( + 'Text variable injected into HTML/CSS via {{var1}}. Animatable with text interpolation (typewriter reveal). Use for dynamic labels, counters, or any text content.' + ) + .register(fieldRegistry, { interpolationFamily: 'text', label: 'var1' }), var2: z .string() .default('') - .describe('Variable {{var2}} for interpolation') - .register(fieldRegistry, { interpolationFamily: 'text' }), + .describe( + 'Text variable injected into HTML/CSS via {{var2}}. Animatable with text interpolation. Use for secondary dynamic text content.' + ) + .register(fieldRegistry, { interpolationFamily: 'text', label: 'var2' }), var3: z .string() .default('') - .describe('Variable {{var3}} for interpolation') - .register(fieldRegistry, { interpolationFamily: 'text' }), + .describe( + 'Text variable injected into HTML/CSS via {{var3}}. Animatable with text interpolation. Use for tertiary dynamic text content.' + ) + .register(fieldRegistry, { interpolationFamily: 'text', label: 'var3' }), var4: z .string() .default('') - .describe('Variable {{var4}} for interpolation') - .register(fieldRegistry, { interpolationFamily: 'text' }), + .describe( + 'Text variable injected into HTML/CSS via {{var4}}. Animatable with text interpolation.' + ) + .register(fieldRegistry, { interpolationFamily: 'text', label: 'var4' }), var5: z .string() .default('') - .describe('Variable {{var5}} for interpolation') - .register(fieldRegistry, { interpolationFamily: 'text' }), - // Numeric variables for animations + .describe( + 'Text variable injected into HTML/CSS via {{var5}}. Animatable with text interpolation.' + ) + .register(fieldRegistry, { interpolationFamily: 'text', label: 'var5' }), num1: z .number() .default(0) - .describe('Numeric variable {{num1}} for animations') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), + .describe( + 'Numeric variable injected into HTML/CSS via {{num1}}. Smoothly animatable with continuous interpolation. Use for counters, percentages, progress bars, or any animated numeric value.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'num1' }), num2: z .number() .default(0) - .describe('Numeric variable {{num2}} for animations') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), + .describe( + 'Numeric variable injected into HTML/CSS via {{num2}}. Smoothly animatable. Use for a secondary animated number.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'num2' }), num3: z .number() .default(0) - .describe('Numeric variable {{num3}} for animations') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), - // Color variables + .describe( + 'Numeric variable injected into HTML/CSS via {{num3}}. Smoothly animatable. Use for a third animated number.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'num3' }), color1: z .string() .default('#ffffff') - .describe('Color variable {{color1}}') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), + .describe( + 'Color variable injected into HTML/CSS via {{color1}} as a hex string. Smoothly animatable between colors. Use for animated theme colors, highlights, or backgrounds in CSS.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'color1' }), color2: z .string() .default('#000000') - .describe('Color variable {{color2}}') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), - // Boolean for conditional rendering + .describe( + 'Color variable injected into HTML/CSS via {{color2}} as a hex string. Smoothly animatable between colors. Use for a secondary animated color.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'color2' }), show1: z .boolean() .default(true) - .describe('Boolean {{show1}} for conditional display') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), + .describe( + 'Boolean variable injected into HTML/CSS via {{show1}} as "block" (true) or "none" (false). Use for conditional CSS display to show/hide elements. Changes discretely.' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'show1' }), show2: z .boolean() .default(true) - .describe('Boolean {{show2}} for conditional display') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), + .describe( + 'Boolean variable injected into HTML/CSS via {{show2}} as "block" (true) or "none" (false). Use for a secondary conditional element visibility. Changes discretely.' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'show2' }), _aspectRatioLocked: z .boolean() .default(false) - .describe('Aspect ratio locked') + .describe('Internal property: whether aspect ratio is locked during resize') .register(fieldRegistry, { hidden: true }), _aspectRatio: z .number() .default(1) - .describe('Aspect ratio value') + .describe('Internal property: stored aspect ratio value when locked') .register(fieldRegistry, { hidden: true }) }); diff --git a/src/lib/layers/components/IconLayer.svelte b/src/lib/layers/components/IconLayer.svelte index b4335c8..387ae0c 100644 --- a/src/lib/layers/components/IconLayer.svelte +++ b/src/lib/layers/components/IconLayer.svelte @@ -2,6 +2,7 @@ import { z } from 'zod'; import type { LayerMeta } from '../registry'; import { Star } from '@lucide/svelte'; + import { fieldRegistry } from '$lib/layers/properties/field-registry'; /** * Common Lucide icon names for motion graphics @@ -115,14 +116,88 @@ * Schema for Icon Layer custom properties */ const schema = z.object({ - icon: z.enum(commonIconValues).default('star').describe('Icon name (Lucide icon)'), - size: z.number().min(16).max(512).default(64).describe('Icon size (px)'), - color: z.string().default('#ffffff').describe('Icon color'), - strokeWidth: z.number().min(0.5).max(4).default(2).describe('Stroke width'), - fill: z.string().default('none').describe('Fill color (none for outline)'), - backgroundColor: z.string().default('transparent').describe('Background color'), - backgroundRadius: z.number().min(0).max(256).default(0).describe('Background border radius'), - backgroundPadding: z.number().min(0).max(64).default(0).describe('Background padding') + icon: z + .enum(commonIconValues) + .default('star') + .describe( + 'The icon name from Lucide icon library. Choose from common icons like arrows, actions, objects, shapes, and social media symbols. Changes discretely (no smooth animation between different icons).' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Icon' }), + size: z + .number() + .min(16) + .max(512) + .default(64) + .describe( + 'The icon size in pixels. Determines both width and height of the icon. Smoothly animatable for scaling effects.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'Size' }), + color: z + .string() + .default('#ffffff') + .describe( + 'The stroke/outline color of the icon in hexadecimal format. Defines the line color for the icon paths. Smoothly animatable for color transitions.' + ) + .register(fieldRegistry, { + interpolationFamily: 'continuous', + widget: 'color', + label: 'Stroke' + }), + strokeWidth: z + .number() + .min(0.5) + .max(4) + .default(2) + .describe( + 'The thickness of the icon stroke/outline in pixels. Lower values create thinner lines, higher values create bolder icons. Smoothly animatable.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'Stroke Width' }), + fill: z + .string() + .default('none') + .describe( + 'The fill color inside the icon shapes in hexadecimal format. Use "none" for outline-only icons, or a color hex (e.g., #ff0000) to fill the icon interior. Smoothly animatable between colors.' + ) + .register(fieldRegistry, { + interpolationFamily: 'continuous', + widget: 'color', + label: 'Fill' + }), + backgroundColor: z + .string() + .default('transparent') + .describe( + 'The background color behind the icon in hexadecimal format. Use "transparent" for no background, or a color (e.g., #000000) to add a background shape. Smoothly animatable.' + ) + .register(fieldRegistry, { + interpolationFamily: 'continuous', + widget: 'color', + label: 'Background' + }), + backgroundRadius: z + .number() + .min(0) + .max(256) + .default(0) + .describe( + 'The border radius of the background shape in pixels. 0 = square background, higher values = more rounded. Use size/2 for circular backgrounds. Smoothly animatable.' + ) + .register(fieldRegistry, { + interpolationFamily: 'continuous', + label: 'Background Radius' + }), + backgroundPadding: z + .number() + .min(0) + .max(64) + .default(0) + .describe( + 'The padding/spacing between the icon and its background edge in pixels. Increases the background size without affecting icon size. Smoothly animatable.' + ) + .register(fieldRegistry, { + interpolationFamily: 'continuous', + label: 'Background Padding' + }) }); export const meta = { diff --git a/src/lib/layers/components/ImageLayer.svelte b/src/lib/layers/components/ImageLayer.svelte index 8450125..915b51d 100644 --- a/src/lib/layers/components/ImageLayer.svelte +++ b/src/lib/layers/components/ImageLayer.svelte @@ -15,48 +15,62 @@ src: z .string() .default('') - .describe('Image source URL or uploaded file URL') - .register(fieldRegistry, { widget: 'upload', mediaType: 'image' }), + .describe( + 'The image source URL or uploaded file URL. Can be an external URL (e.g., https://...) or a storage URL from an uploaded file. Leave empty to show placeholder.' + ) + .register(fieldRegistry, { widget: 'upload', mediaType: 'image', label: 'Image' }), width: z .number() .min(1) .max(5000) .default(720) - .describe('Width (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), + .describe( + 'The display width of the image container in pixels. Does not affect the source image resolution. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Width' + }), height: z .number() .min(1) .max(5000) .default(1280) - .describe('Height (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), + .describe( + 'The display height of the image container in pixels. Does not affect the source image resolution. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Height' + }), objectFit: z .enum(['contain', 'cover', 'fill', 'none', 'scale-down']) .default('cover') - .describe('Object fit mode') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), + .describe( + 'How the image fills its container. Cover = crop to fill (no whitespace), Contain = fit inside (may have letterbox), Fill = stretch to fill, None = use original size, Scale-down = smallest of none/contain. Changes discretely.' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Fit' }), _aspectRatioLocked: z .boolean() .default(false) - .describe('Aspect ratio locked') + .describe('Internal property: whether aspect ratio is locked during resize') .register(fieldRegistry, { hidden: true }), _aspectRatio: z .number() .default(1) - .describe('Aspect ratio value') + .describe('Internal property: stored aspect ratio value when locked') .register(fieldRegistry, { hidden: true }), - /** The storage key if file was uploaded (used for cleanup) */ fileKey: z .string() .default('') - .describe('Storage key (for uploaded files)') + .describe('Internal property: S3 storage key for uploaded files, used for cleanup on delete') .register(fieldRegistry, { hidden: true }), - /** Original filename if uploaded */ fileName: z .string() .default('') - .describe('Original filename') + .describe('Internal property: original filename of the uploaded image, used for alt text') .register(fieldRegistry, { hidden: true }) }); diff --git a/src/lib/layers/components/MouseLayer.svelte b/src/lib/layers/components/MouseLayer.svelte index 67c917c..b8a9b37 100644 --- a/src/lib/layers/components/MouseLayer.svelte +++ b/src/lib/layers/components/MouseLayer.svelte @@ -11,28 +11,42 @@ pointerType: z .enum(['arrow', 'pointer', 'hand', 'crosshair', 'text']) .default('arrow') - .describe('Mouse pointer type') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), + .describe( + 'The mouse cursor icon type. Arrow = default cursor, Pointer = click cursor, Hand = grab/clickable, Crosshair = precision selection, Text = text editing. Changes discretely.' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Cursor' }), size: z .number() .min(16) .max(64) .default(24) - .describe('Pointer size (px)') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), - color: z.string().default('#ffffff').describe('Pointer color').register(fieldRegistry, { - group: 'appearance', - interpolationFamily: 'continuous', - widget: 'color' - }), + .describe( + 'The size of the cursor icon in pixels. Larger sizes make the cursor more visible in videos. Smoothly animatable.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'Size' }), + color: z + .string() + .default('#ffffff') + .describe( + 'The color of the cursor icon in hexadecimal format. Should contrast with the background for visibility. Smoothly animatable for color transitions.' + ) + .register(fieldRegistry, { + group: 'appearance', + interpolationFamily: 'continuous', + widget: 'color', + label: 'Cursor Color' + }), backgroundColor: z .string() .default('#000000') - .describe('Background circle color') + .describe( + 'The color of the background circle behind the cursor in hexadecimal format. Provides contrast and emphasis for the cursor. Smoothly animatable.' + ) .register(fieldRegistry, { group: 'appearance', interpolationFamily: 'continuous', - widget: 'color' + widget: 'color', + label: 'Background' }) }); diff --git a/src/lib/layers/components/PhoneLayer.svelte b/src/lib/layers/components/PhoneLayer.svelte index 53a20b1..4cc8749 100644 --- a/src/lib/layers/components/PhoneLayer.svelte +++ b/src/lib/layers/components/PhoneLayer.svelte @@ -10,65 +10,101 @@ * Schema for Phone Layer custom properties */ const schema = z.object({ - url: z.string().default('https://example.com').describe('URL to display in iframe'), + url: z + .string() + .default('https://example.com') + .describe( + 'The URL to display inside the phone screen iframe. The page is loaded in a sandboxed iframe at mobile viewport size. Note: some sites block embedding via X-Frame-Options.' + ), width: z .number() .min(200) .max(600) .default(375) - .describe('Phone width (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), + .describe( + 'The width of the phone device mockup in pixels, including the bezel frame. Common sizes: 375 (iPhone SE), 390 (iPhone 14). Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Width' + }), height: z .number() .min(400) .max(1200) .default(667) - .describe('Phone height (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), + .describe( + 'The height of the phone device mockup in pixels, including the bezel frame. Common sizes: 667 (iPhone SE), 844 (iPhone 14). Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Height' + }), phoneColor: z .string() .default('#1f2937') - .describe('Phone frame color') + .describe( + 'The color of the phone body/bezel frame in hexadecimal. Dark colors (black, space gray) match modern phones. Smoothly animatable.' + ) .register(fieldRegistry, { group: 'appearance', interpolationFamily: 'continuous', - widget: 'color' + widget: 'color', + label: 'Frame Color' }), notchHeight: z .number() .min(20) .max(40) .default(28) - .describe('Notch height (px)') - .register(fieldRegistry, { group: 'appearance', interpolationFamily: 'continuous' }), + .describe( + 'The height of the notch/dynamic island cutout at the top of the screen in pixels. Visible when showNotch is enabled. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'appearance', + interpolationFamily: 'continuous', + label: 'Notch Height' + }), showNotch: z .boolean() .default(true) - .describe('Show device notch') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), + .describe( + 'Whether to display the camera notch at the top of the screen. Enable for modern phone look, disable for older/notchless design. Changes discretely (on/off).' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Show Notch' }), borderRadius: z .number() .min(20) .max(60) .default(40) - .describe('Screen border radius (px)') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), + .describe( + 'The corner radius of the phone body and screen in pixels. Higher values = more rounded corners. Typical modern phones use 40–50px. Smoothly animatable.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'Corner Radius' }), bezelWidth: z .number() .min(8) .max(20) .default(12) - .describe('Bezel width (px)') - .register(fieldRegistry, { group: 'appearance', interpolationFamily: 'continuous' }), + .describe( + 'The width of the phone bezel border around the screen in pixels. Smaller values = thinner, more modern look. Defines the padding between the frame edge and the screen. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'appearance', + interpolationFamily: 'continuous', + label: 'Bezel' + }), _aspectRatioLocked: z .boolean() .default(false) - .describe('Aspect ratio locked') + .describe('Internal property: whether aspect ratio is locked during resize') .register(fieldRegistry, { hidden: true }), _aspectRatio: z .number() .default(1) - .describe('Aspect ratio value') + .describe('Internal property: stored aspect ratio value when locked') .register(fieldRegistry, { hidden: true }) }); diff --git a/src/lib/layers/components/ShapeLayer.svelte b/src/lib/layers/components/ShapeLayer.svelte index aa3f08e..0d7f9c1 100644 --- a/src/lib/layers/components/ShapeLayer.svelte +++ b/src/lib/layers/components/ShapeLayer.svelte @@ -20,49 +20,79 @@ shapeType: z .enum(['rectangle', 'ellipse', 'circle', 'triangle', 'polygon']) .default('rectangle') - .describe('Shape type') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), + .describe( + 'The geometric shape type to render. Rectangle = four-sided, Ellipse = oval using width/height, Circle = perfect circle using radius, Triangle = three-pointed, Polygon = multi-sided using sides count. Changes discretely.' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Shape' }), background: BackgroundValueSchema.optional() .default('#4a90e2') - .describe('Fill background (solid color or gradient)') - .register(fieldRegistry, { widget: 'background', interpolationFamily: 'discrete' }), - stroke: z.string().default('#000000').describe('Stroke color').register(fieldRegistry, { - group: 'stroke', - interpolationFamily: 'continuous', - widget: 'color' - }), + .describe( + 'The fill background of the shape. Supports solid colors (e.g., #4a90e2) or complex gradients (linear, radial, conic with multiple color stops). Changes discretely between different gradient configurations.' + ) + .register(fieldRegistry, { + widget: 'background', + interpolationFamily: 'discrete', + label: 'Fill' + }), + stroke: z + .string() + .default('#000000') + .describe( + 'The outline/border color in hexadecimal format. Draws a line around the shape perimeter. Smoothly animatable for color transitions.' + ) + .register(fieldRegistry, { + group: 'stroke', + interpolationFamily: 'continuous', + widget: 'color', + label: 'Stroke' + }), strokeWidth: z .number() .min(0) .max(50) .default(2) - .describe('Stroke width (px)') - .register(fieldRegistry, { group: 'stroke', interpolationFamily: 'continuous' }), + .describe( + 'The thickness of the stroke/outline in pixels. 0 = no stroke, higher values = thicker border. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'stroke', + interpolationFamily: 'continuous', + label: 'Width' + }), borderRadius: z .number() .min(0) .max(500) .default(0) .optional() - .describe('Corner radius (px)') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), + .describe( + 'The corner radius for rectangles in pixels. 0 = sharp corners, higher values = more rounded. Only applies to rectangle shapes. Smoothly animatable.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'Corner Radius' }), radius: z .number() .min(0) .max(1000) .default(100) .optional() - .describe('Radius for circle/polygon (px)') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), + .describe( + 'The radius for circle and polygon shapes in pixels. Determines the size from center to edge. Smoothly animatable for scaling effects.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'Radius' }), sides: z .number() .min(3) .max(12) .default(6) .optional() - .describe('Number of sides for polygon') - .register(fieldRegistry, { interpolationFamily: ['quantized', 'continuous'] }) + .describe( + 'The number of sides for polygon shapes. 3 = triangle, 4 = diamond, 6 = hexagon, etc. Can use quantized interpolation (snaps to integers) or continuous (morphs smoothly).' + ) + .register(fieldRegistry, { + interpolationFamily: ['quantized', 'continuous'], + label: 'Sides' + }) }); export const meta = { diff --git a/src/lib/layers/components/TerminalLayer.svelte b/src/lib/layers/components/TerminalLayer.svelte index fc497b4..3947409 100644 --- a/src/lib/layers/components/TerminalLayer.svelte +++ b/src/lib/layers/components/TerminalLayer.svelte @@ -13,67 +13,104 @@ title: z .string() .default('Terminal') - .describe('Terminal window title') - .register(fieldRegistry, { interpolationFamily: 'text' }), + .describe( + 'The title text displayed in the terminal window header bar. Typically the shell name or current directory (e.g., "bash", "zsh", "~/projects"). Animatable with text interpolation.' + ) + .register(fieldRegistry, { interpolationFamily: 'text', label: 'Title' }), content: z .string() .default('$ Welcome to terminal') - .describe('Terminal content/text') - .register(fieldRegistry, { widget: 'textarea', interpolationFamily: 'text' }), + .describe( + 'The text content displayed in the terminal body. Write multi-line content with newlines. Supports the $ prompt prefix and any terminal output. Animatable with text interpolation for typewriter effects.' + ) + .register(fieldRegistry, { + widget: 'textarea', + interpolationFamily: 'text', + label: 'Content' + }), width: z .number() .min(200) .max(2000) .default(600) - .describe('Width (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), + .describe('The width of the terminal window in pixels. Smoothly animatable.') + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Width' + }), height: z .number() .min(150) .max(2000) .default(400) - .describe('Height (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), + .describe('The height of the terminal window in pixels. Smoothly animatable.') + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Height' + }), fontSize: z .number() .min(10) .max(32) .default(14) - .describe('Font size (px)') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), + .describe( + 'The font size of terminal text in pixels. Uses a monospace font. Larger values improve readability in presentations. Smoothly animatable.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'Font Size' }), backgroundColor: z .string() .default('#1e1e1e') - .describe('Background color') + .describe( + 'The background color of the terminal body in hexadecimal. Classic terminals use black (#000000) or dark gray (#1e1e1e). Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'appearance', + interpolationFamily: 'continuous', + widget: 'color', + label: 'Background' + }), + textColor: z + .string() + .default('#00ff00') + .describe( + 'The text color inside the terminal in hexadecimal. Classic terminals use green (#00ff00) or white (#ffffff). Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'appearance', + interpolationFamily: 'continuous', + widget: 'color', + label: 'Text Color' + }), + borderColor: z + .string() + .default('#404040') + .describe( + 'The border/outline color of the terminal window in hexadecimal. Visible when showBorder is enabled. Smoothly animatable.' + ) .register(fieldRegistry, { group: 'appearance', interpolationFamily: 'continuous', - widget: 'color' + widget: 'color', + label: 'Border Color' }), - textColor: z.string().default('#00ff00').describe('Text color').register(fieldRegistry, { - group: 'appearance', - interpolationFamily: 'continuous', - widget: 'color' - }), - borderColor: z.string().default('#404040').describe('Border color').register(fieldRegistry, { - group: 'appearance', - interpolationFamily: 'continuous', - widget: 'color' - }), showBorder: z .boolean() .default(true) - .describe('Show terminal border') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), + .describe( + 'Whether to display a border/outline around the terminal window. When true, draws a 1px border using the borderColor. Changes discretely (on/off).' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Show Border' }), _aspectRatioLocked: z .boolean() .default(false) - .describe('Aspect ratio locked') + .describe('Internal property: whether aspect ratio is locked during resize') .register(fieldRegistry, { hidden: true }), _aspectRatio: z .number() .default(1) - .describe('Aspect ratio value') + .describe('Internal property: stored aspect ratio value when locked') .register(fieldRegistry, { hidden: true }) }); diff --git a/src/lib/layers/components/TextLayer.svelte b/src/lib/layers/components/TextLayer.svelte index 1f647b9..f9f10e3 100644 --- a/src/lib/layers/components/TextLayer.svelte +++ b/src/lib/layers/components/TextLayer.svelte @@ -14,65 +14,111 @@ content: z .string() .default('New Text') - .describe('Text content') - .register(fieldRegistry, { interpolationFamily: 'text' }), + .describe( + 'The text content to display. Supports text interpolation for typewriter effects (character-by-character or word-by-word reveal animations). Animatable using the text interpolation family.' + ) + .register(fieldRegistry, { interpolationFamily: 'text', label: 'Content' }), fontSize: z .number() .min(8) .max(500) .default(48) - .describe('Size (px)') - .register(fieldRegistry, { group: 'typography', interpolationFamily: 'continuous' }), + .describe( + 'The font size in pixels. Controls how large the text appears. Smoothly animatable using continuous interpolation.' + ) + .register(fieldRegistry, { + group: 'typography', + interpolationFamily: 'continuous', + label: 'Size' + }), fontFamily: z .enum(googleFontValues) .optional() - .describe('Font family') + .describe( + 'The font family from Google Fonts library. Determines the typeface style. Can be changed discretely (no smooth animation between fonts).' + ) .register(fieldRegistry, { interpolationFamily: 'discrete', widget: 'custom', - component: FontProperty + component: FontProperty, + label: 'Font' }), fontWeight: z .enum(['normal', 'bold', '100', '200', '300', '400', '500', '600', '700', '800', '900']) .default('normal') - .describe('Weight') - .register(fieldRegistry, { group: 'typography', interpolationFamily: 'discrete' }), - color: z.string().default('#ffffff').describe('Color').register(fieldRegistry, { - group: 'typography', - interpolationFamily: 'continuous', - widget: 'color' - }), + .describe( + 'The font weight/thickness. Options range from 100 (thin) to 900 (black), or named values normal/bold. Changes discretely.' + ) + .register(fieldRegistry, { + group: 'typography', + interpolationFamily: 'discrete', + label: 'Weight' + }), + color: z + .string() + .default('#ffffff') + .describe( + 'The text color in hexadecimal format (e.g., #ffffff for white). Smoothly animatable between colors using continuous interpolation for color transitions.' + ) + .register(fieldRegistry, { + group: 'typography', + interpolationFamily: 'continuous', + widget: 'color', + label: 'Color' + }), letterSpacing: z .number() .min(-10) .max(100) .default(0) - .describe('Letter spacing (px)') - .register(fieldRegistry, { group: 'spacing', interpolationFamily: 'continuous' }), + .describe( + 'The spacing between individual letters in pixels. Positive values spread letters apart, negative values bring them closer. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'spacing', + interpolationFamily: 'continuous', + label: 'Letter Spacing' + }), lineHeight: z .number() .min(0.5) .max(5) .default(1.4) - .describe('Line height') - .register(fieldRegistry, { group: 'spacing', interpolationFamily: 'continuous' }), + .describe( + 'The vertical spacing between lines as a multiplier of font size. 1.0 = single spacing, 1.5 = one-and-a-half spacing, etc. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'spacing', + interpolationFamily: 'continuous', + label: 'Line Height' + }), autoWidth: z .boolean() .default(true) - .describe('Auto width') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), + .describe( + 'Whether the text container automatically adjusts its width to fit content. When true, text stays on one line. When false, uses the specified width property for wrapping.' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Auto Width' }), width: z .number() .min(10) .max(5000) .default(400) - .describe('Size (px)') - .register(fieldRegistry, { group: 'layout', interpolationFamily: 'continuous' }), + .describe( + 'The fixed width of the text container in pixels when autoWidth is disabled. Text wraps to multiple lines if it exceeds this width. Smoothly animatable.' + ) + .register(fieldRegistry, { + group: 'layout', + interpolationFamily: 'continuous', + label: 'Width' + }), textAlign: z .enum(['left', 'center', 'right']) .default('center') - .describe('Alignment') - .register(fieldRegistry, { interpolationFamily: 'discrete' }) + .describe( + 'The horizontal alignment of text within its container. Left aligns to the start, center to the middle, right to the end. Changes discretely.' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Align' }) }); export const meta = { diff --git a/src/lib/layers/components/VideoLayer.svelte b/src/lib/layers/components/VideoLayer.svelte index aa7c6a4..2ff704a 100644 --- a/src/lib/layers/components/VideoLayer.svelte +++ b/src/lib/layers/components/VideoLayer.svelte @@ -14,80 +14,110 @@ src: z .string() .default('') - .describe('Video source URL or uploaded file URL') - .register(fieldRegistry, { widget: 'upload', mediaType: 'video' }), + .describe( + 'The video source URL or uploaded file URL. Can be an external URL or a storage URL from an uploaded file. The video is synchronized to the project timeline automatically.' + ) + .register(fieldRegistry, { widget: 'upload', mediaType: 'video', label: 'Video' }), width: z .number() .min(1) .max(5000) .default(720) - .describe('Width (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), + .describe( + 'The display width of the video container in pixels. Smoothly animatable for zoom/resize effects.' + ) + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Width' + }), height: z .number() .min(1) .max(5000) .default(1280) - .describe('Height (px)') - .register(fieldRegistry, { group: 'size', interpolationFamily: 'continuous' }), + .describe( + 'The display height of the video container in pixels. Smoothly animatable for zoom/resize effects.' + ) + .register(fieldRegistry, { + group: 'size', + interpolationFamily: 'continuous', + label: 'Height' + }), borderRadius: z .number() .min(0) .max(2_000) .default(40) - .describe('Border radius (px)') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), + .describe( + 'The corner radius of the video frame in pixels. 0 = sharp corners, higher = more rounded. Use a large value for circular video. Smoothly animatable.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'Radius' }), crop: z .number() .min(0) .max(500) .default(0) - .describe('Crop inset (px)') - .register(fieldRegistry, { interpolationFamily: 'continuous' }), + .describe( + 'Uniform inset crop in pixels applied to all sides of the video. Hides edge content (e.g., to remove borders or notifications). Combined with borderRadius for rounded cropped frames. Smoothly animatable.' + ) + .register(fieldRegistry, { interpolationFamily: 'continuous', label: 'Crop' }), objectFit: z .enum(['contain', 'cover', 'none']) .default('cover') - .describe('Object fit mode') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), - /** Playback volume (0-1) */ + .describe( + 'How the video fills its container. Cover = crop to fill (no letterbox), Contain = fit inside (may show letterbox), None = use original video size. Changes discretely.' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Fit' }), volume: z .number() .min(0) .max(1) .multipleOf(0.1) .default(1) - .describe('Volume (0-1)') - .register(fieldRegistry, { group: 'playback', interpolationFamily: 'continuous' }), - /** Whether the video is muted */ + .describe( + 'The audio playback volume from 0.0 (silent) to 1.0 (full volume). Smoothly animatable for fade-in/fade-out audio effects.' + ) + .register(fieldRegistry, { + group: 'playback', + interpolationFamily: 'continuous', + label: 'Volume' + }), muted: z .boolean() .default(false) - .describe('Mute audio') - .register(fieldRegistry, { interpolationFamily: 'discrete' }), - /** Playback rate */ + .describe( + 'Whether the video audio is muted. When true, audio is completely silent regardless of volume. Changes discretely (on/off).' + ) + .register(fieldRegistry, { interpolationFamily: 'discrete', label: 'Muted' }), playbackRate: z .number() .min(0.1) .max(4) .multipleOf(0.1) .default(1) - .describe('Playback rate') - .register(fieldRegistry, { group: 'playback', interpolationFamily: 'continuous' }), + .describe( + 'The playback speed multiplier. 1.0 = normal speed, 0.5 = half speed (slow motion), 2.0 = double speed. Smoothly animatable for speed ramp effects.' + ) + .register(fieldRegistry, { + group: 'playback', + interpolationFamily: 'continuous', + label: 'Speed' + }), _aspectRatioLocked: z .boolean() .default(false) - .describe('Aspect ratio locked') + .describe('Internal property: whether aspect ratio is locked during resize') .register(fieldRegistry, { hidden: true }), _aspectRatio: z .number() .default(1) - .describe('Aspect ratio value') + .describe('Internal property: stored aspect ratio value when locked') .register(fieldRegistry, { hidden: true }), - /** The storage key if file was uploaded (used for cleanup) */ fileKey: z .string() .default('') - .describe('Storage key (for uploaded files)') + .describe('Internal property: S3 storage key for uploaded files, used for cleanup on delete') .register(fieldRegistry, { hidden: true }) }); diff --git a/src/lib/layers/properties/field-registry.ts b/src/lib/layers/properties/field-registry.ts index 916f9c9..5d8853d 100644 --- a/src/lib/layers/properties/field-registry.ts +++ b/src/lib/layers/properties/field-registry.ts @@ -20,6 +20,8 @@ export type FieldMeta = { group?: string; /** Supported interpolation families for this field (e.g., ['continuous', 'quantized']) */ interpolationFamily?: InterpolationFamily | InterpolationFamily[]; + + label?: string; } & ( | { /** Override the default input widget rendered for this field */ diff --git a/src/lib/schemas/animation.ts b/src/lib/schemas/animation.ts index 367d7bc..ab48e4f 100644 --- a/src/lib/schemas/animation.ts +++ b/src/lib/schemas/animation.ts @@ -14,87 +14,123 @@ import type { LiteralUnion } from 'type-fest'; // ============================================ // Continuous interpolation (smooth numeric transitions with easing) +// Use for: numbers (position, scale, opacity, fontSize, etc.) AND hex colors (including alpha) +// Colors are interpolated in RGB space (e.g., #ff0000 → #0000ff smoothly transitions through purple) +// The value smoothly transitions between keyframes using mathematical easing curves const ContinuousInterpolationSchema = z.object({ - family: z.literal('continuous'), - strategy: z.enum([ - 'linear', - 'ease-in', - 'ease-out', - 'ease-in-out', - // Quad - 'ease-in-quad', - 'ease-out-quad', - 'ease-in-out-quad', - // Cubic - 'ease-in-cubic', - 'ease-out-cubic', - 'ease-in-out-cubic', - // Quart - 'ease-in-quart', - 'ease-out-quart', - 'ease-in-out-quart', - // Quint - 'ease-in-quint', - 'ease-out-quint', - 'ease-in-out-quint', - // Sine - 'ease-in-sine', - 'ease-out-sine', - 'ease-in-out-sine', - // Expo - 'ease-in-expo', - 'ease-out-expo', - 'ease-in-out-expo', - // Circ - 'ease-in-circ', - 'ease-out-circ', - 'ease-in-out-circ', - // Back (overshoots) - 'ease-in-back', - 'ease-out-back', - 'ease-in-out-back', - // Bounce - 'ease-in-bounce', - 'ease-out-bounce', - 'ease-in-out-bounce', - // Elastic - 'ease-in-elastic', - 'ease-out-elastic', - 'ease-in-out-elastic' - ]) + family: z + .literal('continuous') + .describe( + 'Smooth interpolation for numbers AND colors. Use for: position, scale, opacity, fontSize, AND color properties (fill, stroke, dropShadowColor). Colors are interpolated in RGB space.' + ), + strategy: z + .enum([ + 'linear', + 'ease-in', + 'ease-out', + 'ease-in-out', + // Quad + 'ease-in-quad', + 'ease-out-quad', + 'ease-in-out-quad', + // Cubic + 'ease-in-cubic', + 'ease-out-cubic', + 'ease-in-out-cubic', + // Quart + 'ease-in-quart', + 'ease-out-quart', + 'ease-in-out-quart', + // Quint + 'ease-in-quint', + 'ease-out-quint', + 'ease-in-out-quint', + // Sine + 'ease-in-sine', + 'ease-out-sine', + 'ease-in-out-sine', + // Expo + 'ease-in-expo', + 'ease-out-expo', + 'ease-in-out-expo', + // Circ + 'ease-in-circ', + 'ease-out-circ', + 'ease-in-out-circ', + // Back (overshoots) + 'ease-in-back', + 'ease-out-back', + 'ease-in-out-back', + // Bounce + 'ease-in-bounce', + 'ease-out-bounce', + 'ease-in-out-bounce', + // Elastic + 'ease-in-elastic', + 'ease-out-elastic', + 'ease-in-out-elastic' + ]) + .describe( + 'Easing curve: ease-out for entrances (fast→slow), ease-in for exits (slow→fast), ease-in-out for middle animations. More dramatic effects: back (overshoot), bounce, elastic. Default: ease-in-out' + ) }); export type ContinuousInterpolation = z.infer; export type ContinuousInterpolationStrategy = ContinuousInterpolation['strategy']; // Discrete interpolation (instant value changes) +// Use for: properties that should jump instantly between values (visibility, booleans, categorical values) const DiscreteInterpolationSchema = z.object({ - family: z.literal('discrete'), - strategy: z.enum(['step-end', 'step-start', 'step-mid']) + family: z.literal('discrete').describe('Instant jumps between values with no smooth transition'), + strategy: z + .enum(['step-end', 'step-start', 'step-mid']) + .describe( + 'When the jump happens: step-end = hold old value until keyframe time, step-start = jump immediately to new value, step-mid = jump halfway between keyframes' + ) }); // Quantized interpolation (continuous but rounded) +// Use for: numbers that should animate smoothly but snap to whole values (counters, step indicators) const QuantizedIntegerSchema = z.object({ - family: z.literal('quantized'), - strategy: z.literal('integer') + family: z.literal('quantized').describe('Smooth animation that snaps to discrete values'), + strategy: z.literal('integer').describe('Round to nearest whole number (for counters, indices)') }); const QuantizedSnapGridSchema = z.object({ - family: z.literal('quantized'), - strategy: z.literal('snap-grid'), - increment: z.number().positive() + family: z.literal('quantized').describe('Smooth animation that snaps to discrete values'), + strategy: z + .literal('snap-grid') + .describe('Snap to grid increments (e.g., increment: 10 for values like 0, 10, 20, 30)'), + increment: z.number().positive().describe('Grid size for snapping (e.g., 5, 10, 100)') }); // Text interpolation (string transitions) +// IMPORTANT: Use ONLY for 'props.content' property on text layers when you want typewriter/reveal effects +// Requires exactly 2 keyframes: start (empty or partial text) and end (full text) const TextCharRevealSchema = z.object({ - family: z.literal('text'), - strategy: z.literal('char-reveal') + family: z + .literal('text') + .describe('Text reveal animations - use ONLY on props.content for text layers'), + strategy: z + .literal('char-reveal') + .describe( + 'Typewriter effect: reveals text character by character. Example: animate from "" to "Hello World" to type out the text progressively' + ) }); const TextWordRevealSchema = z.object({ - family: z.literal('text'), - strategy: z.literal('word-reveal'), - separator: z.string().optional() + family: z + .literal('text') + .describe('Text reveal animations - use ONLY on props.content for text layers'), + strategy: z + .literal('word-reveal') + .describe( + 'Word-by-word reveal: shows complete words one at a time. Example: animate from "" to "Hello beautiful world" to reveal words sequentially' + ), + separator: z + .string() + .optional() + .describe('Word separator (default: space " "). Use to split by custom delimiter') }); // Union of all interpolation types @@ -157,19 +193,24 @@ export type { BackgroundValue } from './background'; -// Re-export base schemas for backward compatibility -export { AnchorPointSchema, TransformSchema, LayerStyleSchema } from './base'; - // ============================================ // Keyframe // ============================================ export const KeyframeSchema = z.object({ - id: z.string(), - time: z.number().min(0), - property: AnimatablePropertySchema, - value: z.union([z.number(), z.string(), z.boolean()]), - interpolation: InterpolationSchema.optional() + id: z.string().describe('Unique keyframe identifier'), + time: z.number().min(0).describe('Time in seconds when this value occurs'), + property: AnimatablePropertySchema.describe( + 'Property to animate: position.x/y/z, scale.x/y, rotation.x/y/z, opacity, or props.* for layer-specific properties (e.g., props.fontSize, props.content)' + ), + value: z + .union([z.number(), z.string(), z.boolean()]) + .describe( + 'Target value at this time: number for position/scale/rotation/opacity, hex string (#ffffff) for colors, string for text content, boolean for flags' + ), + interpolation: InterpolationSchema.optional().describe( + 'How to transition TO this keyframe from the previous one. If omitted, uses continuous/ease-in-out. Choose based on property type and desired effect' + ) }); // ============================================ diff --git a/src/lib/types/animation.ts b/src/lib/types/animation.ts index ebc80a4..8e91f47 100644 --- a/src/lib/types/animation.ts +++ b/src/lib/types/animation.ts @@ -26,9 +26,6 @@ export { BuiltInAnimatablePropertySchema, PropsAnimatablePropertySchema, AnimatablePropertySchema, - AnchorPointSchema, - TransformSchema, - LayerStyleSchema, KeyframeSchema, LayerTypeSchema, LayerSchema, diff --git a/src/routes/(app)/p/[id]/+page.svelte b/src/routes/(app)/p/[id]/+page.svelte index 50d347f..67ab21e 100644 --- a/src/routes/(app)/p/[id]/+page.svelte +++ b/src/routes/(app)/p/[id]/+page.svelte @@ -7,6 +7,7 @@ import { getEditorState } from '$lib/contexts/editor.svelte'; import { ProjectSchema } from '$lib/types/animation'; import { toast } from 'svelte-sonner'; + import { watch } from 'runed'; let { data }: { data: PageData } = $props(); @@ -21,30 +22,38 @@ const ogImage = $derived(`${baseUrl}/p/${data.project.id}/og.png`); // Load project data when route changes - $effect(() => { - try { - const projectData = ProjectSchema.omit({ id: true }).parse(data.project.data); + watch( + () => data, + () => { + try { + const projectData = ProjectSchema.omit({ id: true }).parse(data.project.data); - editorState.project.loadProject({ - id: data.project.id, - name: projectData.name, - width: projectData.width, - height: projectData.height, - duration: projectData.duration, - fps: projectData.fps, - background: projectData.background, - layers: projectData.layers, - fontFamily: projectData.fontFamily - }); + editorState.project.loadProject({ + id: data.project.id, + name: projectData.name, + width: projectData.width, + height: projectData.height, + duration: projectData.duration, + fps: projectData.fps, + background: projectData.background, + layers: projectData.layers, + fontFamily: projectData.fontFamily + }); - editorState.setDbContext(data.project.id, data.isOwner, data.canEdit, data.project.isPublic); + editorState.setDbContext( + data.project.id, + data.isOwner, + data.canEdit, + data.project.isPublic + ); - editorState.isMcp = data.project.isMcp; - } catch (error) { - console.error('Failed to load project data:', error); - toast.error('Failed to load project data'); + editorState.isMcp = data.project.isMcp; + } catch (error) { + console.error('Failed to load project data:', error); + toast.error('Failed to load project data'); + } } - }); + ); Date: Tue, 17 Feb 2026 23:25:06 +0100 Subject: [PATCH 2/2] circle --- src/lib/layers/components/ShapeLayer.svelte | 3 ++- src/lib/schemas/background.ts | 22 +++++++++++++++------ 2 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/lib/layers/components/ShapeLayer.svelte b/src/lib/layers/components/ShapeLayer.svelte index 0d7f9c1..3e5b361 100644 --- a/src/lib/layers/components/ShapeLayer.svelte +++ b/src/lib/layers/components/ShapeLayer.svelte @@ -153,8 +153,9 @@ } const shapeStyles = $derived.by(() => { + const isRound = shapeType === 'circle' || shapeType === 'ellipse'; const base = { - ...getStyleProperties(background), + ...getStyleProperties(background, isRound ? { radialSize: 'closest-side' } : undefined), border: `${strokeWidth}px solid ${stroke}` }; diff --git a/src/lib/schemas/background.ts b/src/lib/schemas/background.ts index 36f8624..d12d789 100644 --- a/src/lib/schemas/background.ts +++ b/src/lib/schemas/background.ts @@ -139,7 +139,10 @@ export function isSolid(value: BackgroundValue): value is SolidBackground { /** * Convert a BackgroundValue to a CSS background / background-image string */ -export function backgroundValueToCSS(value: BackgroundValue): string { +export function backgroundValueToCSS( + value: BackgroundValue, + options?: { radialSize?: 'closest-side' | 'closest-corner' | 'farthest-side' | 'farthest-corner' } +): string { if (isSolid(value)) { return value; } @@ -152,7 +155,8 @@ export function backgroundValueToCSS(value: BackgroundValue): string { if (value.type === 'radial') { const stops = value.stops.map((s) => `${s.color} ${s.position}%`).join(', '); const pos = `${value.position.x}% ${value.position.y}%`; - return `radial-gradient(${value.shape} ${value.size} at ${pos}, ${stops})`; + const size = options?.radialSize ?? value.size; + return `radial-gradient(${value.shape} ${size} at ${pos}, ${stops})`; } if (value.type === 'conic') { @@ -167,9 +171,12 @@ export function backgroundValueToCSS(value: BackgroundValue): string { /** * Get style properties for a background value */ -export function getStyleProperties(value: BackgroundValue) { +export function getStyleProperties( + value: BackgroundValue, + options?: { radialSize?: 'closest-side' | 'closest-corner' | 'farthest-side' | 'farthest-corner' } +) { return { - backgroundImage: getBackgroundImage(value), + backgroundImage: getBackgroundImage(value, options), backgroundColor: getBackgroundColor(value) }; } @@ -186,7 +193,10 @@ export function getBackgroundColor(value?: BackgroundValue) { return 'transparent'; } -export function getBackgroundImage(value?: BackgroundValue) { +export function getBackgroundImage( + value?: BackgroundValue, + options?: { radialSize?: 'closest-side' | 'closest-corner' | 'farthest-side' | 'farthest-corner' } +) { if (!value) { return undefined; } @@ -195,7 +205,7 @@ export function getBackgroundImage(value?: BackgroundValue) { return 'none'; } - return backgroundValueToCSS(value); + return backgroundValueToCSS(value, options); } /** Create a solid background value */