From 8d0337013bb291108fbef52adca305648bce9d66 Mon Sep 17 00:00:00 2001 From: EmaDev Date: Sun, 22 Feb 2026 13:01:10 +0100 Subject: [PATCH 1/2] wip --- src/lib/components/ai/model-selector.svelte | 2 +- .../editor/canvas/layers-renderer.svelte | 4 +- .../editor/panels/properties-panel.svelte | 16 +- .../properties/animation-preset-select.svelte | 157 +++ .../properties/groups/time-range-group.svelte | 108 +- .../editor/timeline/timeline-layer.svelte | 76 +- src/lib/engine/layer-rendering.ts | 221 +++- src/lib/engine/presets.ts | 1166 ++++------------- src/lib/schemas/animation.ts | 26 +- src/lib/stores/project.svelte.ts | 4 +- 10 files changed, 806 insertions(+), 974 deletions(-) create mode 100644 src/lib/components/editor/panels/properties/animation-preset-select.svelte diff --git a/src/lib/components/ai/model-selector.svelte b/src/lib/components/ai/model-selector.svelte index 8db0d44..e366f1d 100644 --- a/src/lib/components/ai/model-selector.svelte +++ b/src/lib/components/ai/model-selector.svelte @@ -1,5 +1,5 @@ + + + + + + +
+ {#each options as preset (preset.id)} + {@const isSelected = value === preset.id} + {@const isHovered = hoveredPresetId === preset.id} + + {/each} +
+
+
diff --git a/src/lib/components/editor/panels/properties/groups/time-range-group.svelte b/src/lib/components/editor/panels/properties/groups/time-range-group.svelte index 8d86386..1021996 100644 --- a/src/lib/components/editor/panels/properties/groups/time-range-group.svelte +++ b/src/lib/components/editor/panels/properties/groups/time-range-group.svelte @@ -1,19 +1,48 @@ -
-
- +
+ projectStore.setLayerEnterTime(layer.id, v)} /> -
-
- projectStore.setLayerExitTime(layer.id, v)} /> + + +
+ + + { + enterPresetId = v; + updateTransition('enter', v, enterDuration); + }} + /> + {#if enterPresetId} + { + enterDuration = v; + updateTransition('enter', enterPresetId, v); + }} + /> + {/if} + +
+ +
+ + + { + exitPresetId = v; + updateTransition('exit', v, exitDuration); + }} + /> + {#if exitPresetId} + { + exitDuration = v; + updateTransition('exit', exitPresetId, v); + }} + /> + {/if} +
diff --git a/src/lib/components/editor/timeline/timeline-layer.svelte b/src/lib/components/editor/timeline/timeline-layer.svelte index 12e66c2..07709d3 100644 --- a/src/lib/components/editor/timeline/timeline-layer.svelte +++ b/src/lib/components/editor/timeline/timeline-layer.svelte @@ -21,11 +21,6 @@ // Enter/exit time for the layer const enterTime = $derived(layer.enterTime ?? 0); const exitTime = $derived(layer.exitTime ?? projectStore.state.duration); - const hasTimeRange = $derived(enterTime > 0 || exitTime < projectStore.state.duration); - - const isMediaLayer = $derived( - layer.type === 'video' || layer.type === 'audio' || layer.type === 'captions' - ); // Duration bar position and width const barLeft = $derived(enterTime * pixelsPerSecond); @@ -164,9 +159,6 @@ if (layer.type === 'group') return 'Group'; return ''; }); - - // Always show the bar for groups so users can drag the group range - const showBar = $derived(hasTimeRange || isMediaLayer || isGroupLayer);
{layer.name} - {#if hasTimeRange || isMediaLayer || isGroupLayer} - - {enterTime.toFixed(1)}s – {exitTime.toFixed(1)}s - - {/if} + + {enterTime.toFixed(1)}s – {exitTime.toFixed(1)}s +
- {#if showBar} + +
+ + +
+ +
- - -
- - - -
- - - {#if barLabel && barWidth > 40} - - {barLabel} - - {/if} -
- {/if} + class="absolute top-0 right-0 bottom-0 w-1.5 cursor-col-resize rounded-r-sm bg-white/30 hover:bg-white/50" + onmousedown={startDragExit} + >
+ + + {#if barLabel && barWidth > 40} + + {barLabel} + + {/if} +
{#each keyframeGroups as group (group.time)} diff --git a/src/lib/engine/layer-rendering.ts b/src/lib/engine/layer-rendering.ts index c222b26..130dd59 100644 --- a/src/lib/engine/layer-rendering.ts +++ b/src/lib/engine/layer-rendering.ts @@ -2,49 +2,84 @@ * Shared layer rendering utilities * Used by both canvas rendering and frame cache preparation */ -import type { Transform } from '$lib/types/animation'; +import type { Transform, Keyframe, LayerStyle } from '$lib/types/animation'; import type { TypedLayer } from '$lib/layers/typed-registry'; -import { getAnimatedTransform, getAnimatedStyle, getAnimatedProps } from './interpolation'; +import { + getAnimatedTransform, + getAnimatedStyle, + getAnimatedProps, + interpolateValue +} from './interpolation'; import { getLayerSchema } from '$lib/layers/registry'; import { extractPropertyMetadata } from '$lib/layers/base'; +import { getPresetById } from './presets'; /** - * Get animated transform for a layer, merging base transform with animated values + * Get animated transform for a layer, merging base transform with animated values and transitions */ -export function getLayerTransform(layer: TypedLayer, currentTime: number): Transform { +export function getLayerTransform( + layer: TypedLayer, + currentTime: number, + projectDuration: number +): Transform { const animatedTransform = getAnimatedTransform(layer.keyframes, currentTime); + const { transform: transitionTransform } = applyTransitionPresets( + layer, + currentTime, + projectDuration + ); return { position: { - x: animatedTransform.position?.x ?? layer.transform.position.x, - y: animatedTransform.position?.y ?? layer.transform.position.y, - z: animatedTransform.position?.z ?? layer.transform.position.z + x: + transitionTransform.position?.x ?? + animatedTransform.position?.x ?? + layer.transform.position.x, + y: + transitionTransform.position?.y ?? + animatedTransform.position?.y ?? + layer.transform.position.y, + z: + transitionTransform.position?.z ?? + animatedTransform.position?.z ?? + layer.transform.position.z }, rotation: { - x: animatedTransform.rotation?.x ?? layer.transform.rotation.x, - y: animatedTransform.rotation?.y ?? layer.transform.rotation.y, - z: animatedTransform.rotation?.z ?? layer.transform.rotation.z + x: + transitionTransform.rotation?.x ?? + animatedTransform.rotation?.x ?? + layer.transform.rotation.x, + y: + transitionTransform.rotation?.y ?? + animatedTransform.rotation?.y ?? + layer.transform.rotation.y, + z: + transitionTransform.rotation?.z ?? + animatedTransform.rotation?.z ?? + layer.transform.rotation.z }, scale: { - x: animatedTransform.scale?.x ?? layer.transform.scale.x, - y: animatedTransform.scale?.y ?? layer.transform.scale.y + x: transitionTransform.scale?.x ?? animatedTransform.scale?.x ?? layer.transform.scale.x, + y: transitionTransform.scale?.y ?? animatedTransform.scale?.y ?? layer.transform.scale.y }, anchor: layer.transform.anchor ?? 'center' }; } /** - * Get animated style for a layer + * Get animated style for a layer, merging base style with animated values and transitions */ -export function getLayerStyle(layer: TypedLayer, currentTime: number) { +export function getLayerStyle(layer: TypedLayer, currentTime: number, projectDuration: number) { const animatedStyle = getAnimatedStyle(layer.keyframes, currentTime); + const { style: transitionStyle } = applyTransitionPresets(layer, currentTime, projectDuration); return { - opacity: animatedStyle.opacity ?? layer.style.opacity, - blur: animatedStyle.blur ?? layer.style.blur ?? 0, - brightness: animatedStyle.brightness ?? layer.style.brightness ?? 1, - contrast: animatedStyle.contrast ?? layer.style.contrast ?? 1, - saturate: animatedStyle.saturate ?? layer.style.saturate ?? 1, + opacity: transitionStyle.opacity ?? animatedStyle.opacity ?? layer.style.opacity, + blur: transitionStyle.blur ?? animatedStyle.blur ?? layer.style.blur ?? 0, + brightness: + transitionStyle.brightness ?? animatedStyle.brightness ?? layer.style.brightness ?? 1, + contrast: transitionStyle.contrast ?? animatedStyle.contrast ?? layer.style.contrast ?? 1, + saturate: transitionStyle.saturate ?? animatedStyle.saturate ?? layer.style.saturate ?? 1, dropShadowX: animatedStyle.dropShadowX ?? layer.style.dropShadowX ?? 0, dropShadowY: animatedStyle.dropShadowY ?? layer.style.dropShadowY ?? 0, dropShadowBlur: animatedStyle.dropShadowBlur ?? layer.style.dropShadowBlur ?? 0, @@ -60,3 +95,151 @@ export function getLayerProps(layer: TypedLayer, currentTime: number): Record; + style: Partial; +} { + const enterTime = layer.enterTime ?? 0; + const exitTime = layer.exitTime ?? projectDuration; + + let transitionTransform: Partial = {}; + let transitionStyle: Partial = {}; + + // Apply enter transition if within duration + if (layer.enterTransition && currentTime >= enterTime) { + const timeSinceEnter = currentTime - enterTime; + if (timeSinceEnter <= layer.enterTransition.duration) { + const preset = getPresetById(layer.enterTransition.presetId); + if (preset) { + const { transform, style } = applyPresetAtTime( + preset.keyframes, + timeSinceEnter, + layer.enterTransition.duration + ); + transitionTransform = { ...transitionTransform, ...transform }; + transitionStyle = { ...transitionStyle, ...style }; + } + } + } + + // Apply exit transition if within duration + if (layer.exitTransition && currentTime <= exitTime) { + const timeUntilExit = exitTime - currentTime; + if (timeUntilExit <= layer.exitTransition.duration) { + const preset = getPresetById(layer.exitTransition.presetId); + if (preset) { + // For exit transitions, we interpolate from end backwards + const transitionProgress = layer.exitTransition.duration - timeUntilExit; + const { transform, style } = applyPresetAtTime( + preset.keyframes, + transitionProgress, + layer.exitTransition.duration + ); + transitionTransform = { ...transitionTransform, ...transform }; + transitionStyle = { ...transitionStyle, ...style }; + } + } + } + + return { transform: transitionTransform, style: transitionStyle }; +} + +/** + * Apply a preset's keyframes at a specific normalized time + */ +function applyPresetAtTime( + presetKeyframes: Omit[], + currentTime: number, + duration: number +): { + transform: Partial; + style: Partial; +} { + const transform: Partial = {}; + const style: Partial = {}; + + // Scale preset keyframe times to actual duration + const scaledKeyframes = presetKeyframes.map((kf) => ({ + ...kf, + time: kf.time * duration + })); + + // Group keyframes by property + const propertiesByName = new Map(); + for (const kf of scaledKeyframes) { + const existing = propertiesByName.get(kf.property) ?? []; + propertiesByName.set(kf.property, [...existing, kf]); + } + + // Interpolate each property + for (const [property, keyframes] of propertiesByName) { + // Sort by time + keyframes.sort((a, b) => a.time - b.time); + + // Find surrounding keyframes + let prevKf = keyframes[0]; + let nextKf = keyframes[keyframes.length - 1]; + + for (let i = 0; i < keyframes.length - 1; i++) { + if (currentTime >= keyframes[i].time && currentTime <= keyframes[i + 1].time) { + prevKf = keyframes[i]; + nextKf = keyframes[i + 1]; + break; + } + } + + // Calculate progress between keyframes (0-1) + const progress = + prevKf.time === nextKf.time ? 1 : (currentTime - prevKf.time) / (nextKf.time - prevKf.time); + + // Interpolate value using the interpolation function + const value = interpolateValue( + prevKf.value, + nextKf.value, + Math.max(0, Math.min(1, progress)), + nextKf.interpolation ?? { family: 'continuous', strategy: 'ease-in-out' } + ); + + // Map to transform or style + if (property === 'position.x') { + if (!transform.position) transform.position = { x: 0, y: 0, z: 0 }; + transform.position.x = value as number; + } else if (property === 'position.y') { + if (!transform.position) transform.position = { x: 0, y: 0, z: 0 }; + transform.position.y = value as number; + } else if (property === 'position.z') { + if (!transform.position) transform.position = { x: 0, y: 0, z: 0 }; + transform.position.z = value as number; + } else if (property === 'scale.x') { + if (!transform.scale) transform.scale = { x: 1, y: 1 }; + transform.scale.x = value as number; + } else if (property === 'scale.y') { + if (!transform.scale) transform.scale = { x: 1, y: 1 }; + transform.scale.y = value as number; + } else if (property === 'rotation.x') { + if (!transform.rotation) transform.rotation = { x: 0, y: 0, z: 0 }; + transform.rotation.x = value as number; + } else if (property === 'rotation.y') { + if (!transform.rotation) transform.rotation = { x: 0, y: 0, z: 0 }; + transform.rotation.y = value as number; + } else if (property === 'rotation.z') { + if (!transform.rotation) transform.rotation = { x: 0, y: 0, z: 0 }; + transform.rotation.z = value as number; + } else if (property === 'opacity') style.opacity = value as number; + else if (property === 'blur') style.blur = value as number; + else if (property === 'brightness') style.brightness = value as number; + else if (property === 'contrast') style.contrast = value as number; + else if (property === 'saturate') style.saturate = value as number; + } + + return { transform, style }; +} diff --git a/src/lib/engine/presets.ts b/src/lib/engine/presets.ts index 253c738..bef246a 100644 --- a/src/lib/engine/presets.ts +++ b/src/lib/engine/presets.ts @@ -3,926 +3,301 @@ * These can be applied to any layer by the AI using create_layer tool */ import { z } from 'zod'; -import type { AnimationPreset } from '$lib/types/animation'; +import type { Paths } from 'type-fest'; +import type { Interpolation } from '$lib/types/animation'; +import type { Transform, LayerStyle } from '$lib/schemas/base'; + +// ============================================ +// Type-safe preset system +// ============================================ + +/** + * Animatable transform properties - auto-inferred from Transform type + * Excludes 'anchor' and parent objects, only includes leaf paths (e.g., 'position.x') + */ +export type TransformProperty = Extract, `${string}.${string}`>; + +/** + * Animatable style properties - extracted from LayerStyle type + */ +export type StyleProperty = keyof LayerStyle; + +/** + * All base animatable properties (transform + style) + */ +export type BaseAnimatableProperty = TransformProperty | StyleProperty; + +/** + * Preset category based on usage context + */ +export type PresetCategory = 'enter' | 'exit' | 'generic'; + +/** + * Type-safe keyframe for presets + * Only allows valid transform and style properties with correct value types + */ +export type PresetKeyframe

= { + time: number; + property: P; + value: P extends TransformProperty + ? number + : P extends + | 'opacity' + | 'blur' + | 'brightness' + | 'contrast' + | 'saturate' + | 'dropShadowX' + | 'dropShadowY' + | 'dropShadowBlur' + ? number + : P extends 'dropShadowColor' + ? string + : never; + interpolation?: Interpolation; +}; + +/** + * Type-safe animation preset with category + */ +export interface TypedAnimationPreset { + id: string; + name: string; + category: PresetCategory; + keyframes: PresetKeyframe[]; +} + +/** + * Helper to create type-safe preset keyframes + */ +function kf

( + time: number, + property: P, + value: PresetKeyframe

['value'], + interpolation?: Interpolation +): PresetKeyframe

{ + return { time, property, value, interpolation } as PresetKeyframe

; +} + +/** + * Helper to create a type-safe preset + */ +function preset( + id: string, + name: string, + category: PresetCategory, + keyframes: PresetKeyframe[] +): TypedAnimationPreset { + return { id, name, category, keyframes }; +} /** * All available animation presets * Keyframe times are normalized (0-1) and will be scaled by duration when applied */ -export const animationPresets: AnimationPreset[] = [ +export const animationPresets: TypedAnimationPreset[] = [ // ============================================ // Fade animations // ============================================ - { - id: 'fade-in', - name: 'Fade In', - keyframes: [ - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'fade-out', - name: 'Fade Out', - keyframes: [ - { - time: 0, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 1, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, + preset('fade-in', 'Fade In', 'enter', [ + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('fade-out', 'Fade Out', 'exit', [ + kf(0, 'opacity', 1, { family: 'continuous', strategy: 'ease-in' }), + kf(1, 'opacity', 0, { family: 'continuous', strategy: 'linear' }) + ]), // ============================================ // Slide animations // ============================================ - { - id: 'slide-in-left', - name: 'Slide In from Left', - keyframes: [ - { - time: 0, - property: 'position.x', - value: -500, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'position.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'slide-in-right', - name: 'Slide In from Right', - keyframes: [ - { - time: 0, - property: 'position.x', - value: 500, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'position.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'slide-in-top', - name: 'Slide In from Top', - keyframes: [ - { - time: 0, - property: 'position.y', - value: -400, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'position.y', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'slide-in-bottom', - name: 'Slide In from Bottom', - keyframes: [ - { - time: 0, - property: 'position.y', - value: 400, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'position.y', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'slide-out-left', - name: 'Slide Out to Left', - keyframes: [ - { - time: 0, - property: 'position.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 0, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 1, - property: 'position.x', - value: -500, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'slide-out-right', - name: 'Slide Out to Right', - keyframes: [ - { - time: 0, - property: 'position.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 0, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 1, - property: 'position.x', - value: 500, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, + preset('slide-in-left', 'Slide In from Left', 'enter', [ + kf(0, 'position.x', -500, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'position.x', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('slide-in-right', 'Slide In from Right', 'enter', [ + kf(0, 'position.x', 500, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'position.x', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('slide-in-top', 'Slide In from Top', 'enter', [ + kf(0, 'position.y', -400, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'position.y', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('slide-in-bottom', 'Slide In from Bottom', 'enter', [ + kf(0, 'position.y', 400, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'position.y', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('slide-out-left', 'Slide Out to Left', 'exit', [ + kf(0, 'position.x', 0, { family: 'continuous', strategy: 'ease-in' }), + kf(0, 'opacity', 1, { family: 'continuous', strategy: 'ease-in' }), + kf(1, 'position.x', -500, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 0, { family: 'continuous', strategy: 'linear' }) + ]), + preset('slide-out-right', 'Slide Out to Right', 'exit', [ + kf(0, 'position.x', 0, { family: 'continuous', strategy: 'ease-in' }), + kf(0, 'opacity', 1, { family: 'continuous', strategy: 'ease-in' }), + kf(1, 'position.x', 500, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 0, { family: 'continuous', strategy: 'linear' }) + ]), // ============================================ // Scale animations // ============================================ - { - id: 'scale-in', - name: 'Scale In', - keyframes: [ - { - time: 0, - property: 'scale.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'scale.y', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'scale.x', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'scale-out', - name: 'Scale Out', - keyframes: [ - { - time: 0, - property: 'scale.x', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 0, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 0, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 1, - property: 'scale.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'scale.y', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'pop', - name: 'Pop (Scale with Overshoot)', - keyframes: [ - { - time: 0, - property: 'scale.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'scale.y', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0.6, - property: 'scale.x', - value: 1.15, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0.6, - property: 'scale.y', - value: 1.15, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0.6, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'scale.x', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 1, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - } - ] - }, + preset('scale-in', 'Scale In', 'enter', [ + kf(0, 'scale.x', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'scale.y', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'scale.x', 1, { family: 'continuous', strategy: 'linear' }), + kf(1, 'scale.y', 1, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('scale-out', 'Scale Out', 'exit', [ + kf(0, 'scale.x', 1, { family: 'continuous', strategy: 'ease-in' }), + kf(0, 'scale.y', 1, { family: 'continuous', strategy: 'ease-in' }), + kf(0, 'opacity', 1, { family: 'continuous', strategy: 'ease-in' }), + kf(1, 'scale.x', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'scale.y', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 0, { family: 'continuous', strategy: 'linear' }) + ]), + preset('pop', 'Pop (Scale with Overshoot)', 'enter', [ + kf(0, 'scale.x', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'scale.y', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(0.6, 'scale.x', 1.15, { family: 'continuous', strategy: 'ease-out' }), + kf(0.6, 'scale.y', 1.15, { family: 'continuous', strategy: 'ease-out' }), + kf(0.6, 'opacity', 1, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'scale.x', 1, { family: 'continuous', strategy: 'ease-in-out' }), + kf(1, 'scale.y', 1, { family: 'continuous', strategy: 'ease-in-out' }) + ]), // ============================================ // Bounce animations // ============================================ - { - id: 'bounce', - name: 'Bounce', - keyframes: [ - { - time: 0, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.25, - property: 'scale.y', - value: 1.2, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.5, - property: 'scale.y', - value: 0.9, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.75, - property: 'scale.y', - value: 1.05, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 1, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'bounce-in', - name: 'Bounce In', - keyframes: [ - { - time: 0, - property: 'scale.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'scale.y', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0.4, - property: 'scale.x', - value: 1.2, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0.4, - property: 'scale.y', - value: 1.2, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0.4, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 0.6, - property: 'scale.x', - value: 0.9, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.6, - property: 'scale.y', - value: 0.9, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.8, - property: 'scale.x', - value: 1.05, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.8, - property: 'scale.y', - value: 1.05, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 1, - property: 'scale.x', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 1, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - } - ] - }, + preset('bounce', 'Bounce', 'generic', [ + kf(0, 'scale.y', 1, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.25, 'scale.y', 1.2, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.5, 'scale.y', 0.9, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.75, 'scale.y', 1.05, { family: 'continuous', strategy: 'ease-in-out' }), + kf(1, 'scale.y', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('bounce-in', 'Bounce In', 'enter', [ + kf(0, 'scale.x', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'scale.y', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(0.4, 'scale.x', 1.2, { family: 'continuous', strategy: 'ease-out' }), + kf(0.4, 'scale.y', 1.2, { family: 'continuous', strategy: 'ease-out' }), + kf(0.4, 'opacity', 1, { family: 'continuous', strategy: 'linear' }), + kf(0.6, 'scale.x', 0.9, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.6, 'scale.y', 0.9, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.8, 'scale.x', 1.05, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.8, 'scale.y', 1.05, { family: 'continuous', strategy: 'ease-in-out' }), + kf(1, 'scale.x', 1, { family: 'continuous', strategy: 'ease-in-out' }), + kf(1, 'scale.y', 1, { family: 'continuous', strategy: 'ease-in-out' }) + ]), // ============================================ // Rotation animations // ============================================ - { - id: 'rotate-in', - name: 'Rotate In', - keyframes: [ - { - time: 0, - property: 'rotation.z', - value: -Math.PI, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'scale.x', - value: 0.5, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'scale.y', - value: 0.5, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'rotation.z', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'scale.x', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'spin', - name: 'Spin (Full Rotation)', - keyframes: [ - { - time: 0, - property: 'rotation.z', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'rotation.z', - value: Math.PI * 2, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, + preset('rotate-in', 'Rotate In', 'enter', [ + kf(0, 'rotation.z', -Math.PI, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'scale.x', 0.5, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'scale.y', 0.5, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'rotation.z', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }), + kf(1, 'scale.x', 1, { family: 'continuous', strategy: 'linear' }), + kf(1, 'scale.y', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('spin', 'Spin (Full Rotation)', 'generic', [ + kf(0, 'rotation.z', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'rotation.z', Math.PI * 2, { family: 'continuous', strategy: 'linear' }) + ]), // ============================================ // Attention/emphasis animations // ============================================ - { - id: 'pulse', - name: 'Pulse', - keyframes: [ - { - time: 0, - property: 'scale.x', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.5, - property: 'scale.x', - value: 1.1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.5, - property: 'scale.y', - value: 1.1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 1, - property: 'scale.x', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 1, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - } - ] - }, - { - id: 'shake', - name: 'Shake', - keyframes: [ - { - time: 0, - property: 'position.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 0.1, - property: 'position.x', - value: -10, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 0.2, - property: 'position.x', - value: 10, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 0.3, - property: 'position.x', - value: -10, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 0.4, - property: 'position.x', - value: 10, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 0.5, - property: 'position.x', - value: -5, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 0.6, - property: 'position.x', - value: 5, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 0.7, - property: 'position.x', - value: -2, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 0.8, - property: 'position.x', - value: 2, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'position.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'float', - name: 'Float (Subtle Up/Down)', - keyframes: [ - { - time: 0, - property: 'position.y', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.5, - property: 'position.y', - value: -15, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 1, - property: 'position.y', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - } - ] - }, - { - id: 'glow', - name: 'Glow (Opacity Pulse)', - keyframes: [ - { - time: 0, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.5, - property: 'opacity', - value: 0.6, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - } - ] - }, + preset('pulse', 'Pulse', 'generic', [ + kf(0, 'scale.x', 1, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0, 'scale.y', 1, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.5, 'scale.x', 1.1, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.5, 'scale.y', 1.1, { family: 'continuous', strategy: 'ease-in-out' }), + kf(1, 'scale.x', 1, { family: 'continuous', strategy: 'ease-in-out' }), + kf(1, 'scale.y', 1, { family: 'continuous', strategy: 'ease-in-out' }) + ]), + preset('shake', 'Shake', 'generic', [ + kf(0, 'position.x', 0, { family: 'continuous', strategy: 'linear' }), + kf(0.1, 'position.x', -10, { family: 'continuous', strategy: 'linear' }), + kf(0.2, 'position.x', 10, { family: 'continuous', strategy: 'linear' }), + kf(0.3, 'position.x', -10, { family: 'continuous', strategy: 'linear' }), + kf(0.4, 'position.x', 10, { family: 'continuous', strategy: 'linear' }), + kf(0.5, 'position.x', -5, { family: 'continuous', strategy: 'linear' }), + kf(0.6, 'position.x', 5, { family: 'continuous', strategy: 'linear' }), + kf(0.7, 'position.x', -2, { family: 'continuous', strategy: 'linear' }), + kf(0.8, 'position.x', 2, { family: 'continuous', strategy: 'linear' }), + kf(1, 'position.x', 0, { family: 'continuous', strategy: 'linear' }) + ]), + preset('float', 'Float (Subtle Up/Down)', 'generic', [ + kf(0, 'position.y', 0, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.5, 'position.y', -15, { family: 'continuous', strategy: 'ease-in-out' }), + kf(1, 'position.y', 0, { family: 'continuous', strategy: 'ease-in-out' }) + ]), + preset('glow', 'Glow (Opacity Pulse)', 'generic', [ + kf(0, 'opacity', 1, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.5, 'opacity', 0.6, { family: 'continuous', strategy: 'ease-in-out' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'ease-in-out' }) + ]), // ============================================ // Zoom animations // ============================================ - { - id: 'zoom-in', - name: 'Zoom In (from far)', - keyframes: [ - { - time: 0, - property: 'scale.x', - value: 0.3, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'scale.y', - value: 0.3, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'scale.x', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'zoom-out', - name: 'Zoom Out (to far)', - keyframes: [ - { - time: 0, - property: 'scale.x', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 0, - property: 'scale.y', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 0, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'ease-in' } - }, - { - time: 1, - property: 'scale.x', - value: 0.3, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'scale.y', - value: 0.3, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, + preset('zoom-in', 'Zoom In (from far)', 'enter', [ + kf(0, 'scale.x', 0.3, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'scale.y', 0.3, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'scale.x', 1, { family: 'continuous', strategy: 'linear' }), + kf(1, 'scale.y', 1, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('zoom-out', 'Zoom Out (to far)', 'exit', [ + kf(0, 'scale.x', 1, { family: 'continuous', strategy: 'ease-in' }), + kf(0, 'scale.y', 1, { family: 'continuous', strategy: 'ease-in' }), + kf(0, 'opacity', 1, { family: 'continuous', strategy: 'ease-in' }), + kf(1, 'scale.x', 0.3, { family: 'continuous', strategy: 'linear' }), + kf(1, 'scale.y', 0.3, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 0, { family: 'continuous', strategy: 'linear' }) + ]), // ============================================ // Special effects // ============================================ - { - id: 'flip-in-x', - name: 'Flip In (Horizontal)', - keyframes: [ - { - time: 0, - property: 'rotation.y', - value: Math.PI / 2, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'rotation.y', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'flip-in-y', - name: 'Flip In (Vertical)', - keyframes: [ - { - time: 0, - property: 'rotation.x', - value: Math.PI / 2, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 0, - property: 'opacity', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-out' } - }, - { - time: 1, - property: 'rotation.x', - value: 0, - interpolation: { family: 'continuous', strategy: 'linear' } - }, - { - time: 1, - property: 'opacity', - value: 1, - interpolation: { family: 'continuous', strategy: 'linear' } - } - ] - }, - { - id: 'swing', - name: 'Swing', - keyframes: [ - { - time: 0, - property: 'rotation.z', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, - { - time: 0.2, - property: 'rotation.z', - value: 0.26, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, // ~15deg - { - time: 0.4, - property: 'rotation.z', - value: -0.17, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, // ~-10deg - { - time: 0.6, - property: 'rotation.z', - value: 0.09, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, // ~5deg - { - time: 0.8, - property: 'rotation.z', - value: -0.05, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - }, // ~-3deg - { - time: 1, - property: 'rotation.z', - value: 0, - interpolation: { family: 'continuous', strategy: 'ease-in-out' } - } - ] - } + preset('flip-in-x', 'Flip In (Horizontal)', 'enter', [ + kf(0, 'rotation.y', Math.PI / 2, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'rotation.y', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('flip-in-y', 'Flip In (Vertical)', 'enter', [ + kf(0, 'rotation.x', Math.PI / 2, { family: 'continuous', strategy: 'ease-out' }), + kf(0, 'opacity', 0, { family: 'continuous', strategy: 'ease-out' }), + kf(1, 'rotation.x', 0, { family: 'continuous', strategy: 'linear' }), + kf(1, 'opacity', 1, { family: 'continuous', strategy: 'linear' }) + ]), + preset('swing', 'Swing', 'generic', [ + kf(0, 'rotation.z', 0, { family: 'continuous', strategy: 'ease-in-out' }), + kf(0.2, 'rotation.z', 0.26, { family: 'continuous', strategy: 'ease-in-out' }), // ~15deg + kf(0.4, 'rotation.z', -0.17, { family: 'continuous', strategy: 'ease-in-out' }), // ~-10deg + kf(0.6, 'rotation.z', 0.09, { family: 'continuous', strategy: 'ease-in-out' }), // ~5deg + kf(0.8, 'rotation.z', -0.05, { family: 'continuous', strategy: 'ease-in-out' }), // ~-3deg + kf(1, 'rotation.z', 0, { family: 'continuous', strategy: 'ease-in-out' }) + ]) ]; /** @@ -936,7 +311,7 @@ export type AnimationPresetId = z.infer; /** * Get a preset by ID */ -export function getPresetById(id: string): AnimationPreset | undefined { +export function getPresetById(id: string): TypedAnimationPreset | undefined { return animationPresets.find((p) => p.id === id); } @@ -946,3 +321,24 @@ export function getPresetById(id: string): AnimationPreset | undefined { export function getPresetIds(): string[] { return animationPresets.map((p) => p.id); } + +/** + * Get presets suitable for enter transitions (fade-in, slide-in, etc.) + */ +export function getEnterPresets(): TypedAnimationPreset[] { + return animationPresets.filter((p) => p.category === 'enter'); +} + +/** + * Get presets suitable for exit transitions (fade-out, slide-out, etc.) + */ +export function getExitPresets(): TypedAnimationPreset[] { + return animationPresets.filter((p) => p.category === 'exit'); +} + +/** + * Get generic animation presets (shake, pulse, etc.) + */ +export function getGenericPresets(): TypedAnimationPreset[] { + return animationPresets.filter((p) => p.category === 'generic'); +} diff --git a/src/lib/schemas/animation.ts b/src/lib/schemas/animation.ts index f20318b..16917f9 100644 --- a/src/lib/schemas/animation.ts +++ b/src/lib/schemas/animation.ts @@ -222,6 +222,19 @@ export const KeyframeSchema = z.object({ export const LayerTypeSchema = z.enum(getAvailableLayerTypes()); +// ============================================ +// Layer Transitions +// ============================================ + +/** + * Layer transition configuration + * Defines an animation preset to apply automatically at layer enter/exit + */ +export const LayerTransitionSchema = z.object({ + presetId: z.string().describe('ID of the animation preset to apply'), + duration: z.number().positive().describe('Duration of the transition in seconds') +}); + // ============================================ // Layer // ============================================ @@ -259,7 +272,17 @@ export const LayerSchema = BaseLayerFieldsSchema.extend({ * Combined with enter/exit times, this determines what portion of content is shown. * Only applicable when contentDuration is defined. */ - contentOffset: z.number().min(0).optional() + contentOffset: z.number().min(0).optional(), + /** + * Enter transition - animation preset to apply automatically when layer enters + * Applied at runtime without creating keyframes + */ + enterTransition: LayerTransitionSchema.optional(), + /** + * Exit transition - animation preset to apply automatically when layer exits + * Applied at runtime without creating keyframes + */ + exitTransition: LayerTransitionSchema.optional() }).refine( (data) => { // Validate contentOffset doesn't exceed contentDuration @@ -368,4 +391,5 @@ export type ProjectData = z.infer; export type ViewportSettings = z.infer; export type AnimationPreset = z.infer; +export type LayerTransition = z.infer; export type ExportSettings = z.infer; diff --git a/src/lib/stores/project.svelte.ts b/src/lib/stores/project.svelte.ts index 9d6109c..171b1e2 100644 --- a/src/lib/stores/project.svelte.ts +++ b/src/lib/stores/project.svelte.ts @@ -896,8 +896,8 @@ export class ProjectStore { // Pre-calculate values for all layers using shared rendering functions for (const layer of this.state.layers) { frameData[layer.id] = { - transform: getLayerTransform(layer, time), - style: getLayerStyle(layer, time), + transform: getLayerTransform(layer, time, this.state.duration), + style: getLayerStyle(layer, time, this.state.duration), customProps: getLayerProps(layer, time) }; } From 6f7866c994fe7577ebd6478d74b20fb5301ec26e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 22 Feb 2026 13:16:45 +0000 Subject: [PATCH 2/2] feat: Complete animation presets system with centralized config, preview, and AI integration - Centralized presets config (presets.ts): Added descriptions, categories (enter/exit/emphasis), and AI summary generation. Single source of truth for UI, rendering, and AI. - Preset preview (animation-preset-select.svelte): Rewrote component to use CSS-based interpolation preview on hover - no createLayer dependency, uses interpolateValue directly. Shows animated preview box with description tooltip. - Fixed time-range-group.svelte: Removed invalid $derived mutation pattern. Transition state is now read-only derived from layer, updates go through projectStore.updateLayer. - Fixed layer-rendering.ts: Rewrote transition system to use additive offsets for position/rotation and multiplicative factors for scale/opacity. Transitions now correctly compose with the layer's base transform instead of replacing it. - Timeline transition markers: Added visual enter/exit transition zones on timeline bars with "In"/"Out" labels. - AI schema updates: Added enterTransition/exitTransition fields to both create_layer and edit_layer tool schemas, with descriptions explaining offset/factor semantics. - AI system prompt: Added complete presets documentation with available presets list, usage guidance (transitions vs keyframe presets vs custom keyframes), and updated default creative baseline to prefer transitions. - AI mutations: Both mutateCreateLayer and mutateEditLayer now handle enterTransition and exitTransition, validating preset IDs before applying. https://claude.ai/code/session_01V2YcPwVv25drxzAw7R17Wy --- src/lib/ai/mutations.ts | 48 ++ src/lib/ai/schemas.ts | 43 +- src/lib/ai/system-prompt.ts | 45 +- .../properties/animation-preset-select.svelte | 191 ++++--- .../groups/animation-presets-group.svelte | 71 +-- .../properties/groups/time-range-group.svelte | 41 +- .../editor/timeline/timeline-layer.svelte | 38 +- src/lib/engine/layer-rendering.ts | 257 +++++---- src/lib/engine/presets.ts | 514 ++++++++++++------ 9 files changed, 830 insertions(+), 418 deletions(-) diff --git a/src/lib/ai/mutations.ts b/src/lib/ai/mutations.ts index a249b65..7c71a80 100644 --- a/src/lib/ai/mutations.ts +++ b/src/lib/ai/mutations.ts @@ -131,6 +131,26 @@ export function mutateCreateLayer( layer.contentOffset = input.contentOffset; } + // Set enter/exit transitions if provided + if (input.enterTransition) { + const preset = getPresetById(input.enterTransition.presetId); + if (preset) { + layer.enterTransition = { + presetId: input.enterTransition.presetId, + duration: input.enterTransition.duration + }; + } + } + if (input.exitTransition) { + const preset = getPresetById(input.exitTransition.presetId); + if (preset) { + layer.exitTransition = { + presetId: input.exitTransition.presetId, + duration: input.exitTransition.duration + }; + } + } + // Mutate project ctx.project.layers.push(layer); @@ -252,6 +272,34 @@ export function mutateEditLayer(ctx: MutationContext, input: EditLayerInput): Ed layer.contentOffset = Math.max(0, input.contentOffset); } + // Update enter/exit transitions + if (input.enterTransition !== undefined) { + if (input.enterTransition) { + const preset = getPresetById(input.enterTransition.presetId); + if (preset) { + layer.enterTransition = { + presetId: input.enterTransition.presetId, + duration: input.enterTransition.duration + }; + } + } else { + layer.enterTransition = undefined; + } + } + if (input.exitTransition !== undefined) { + if (input.exitTransition) { + const preset = getPresetById(input.exitTransition.presetId); + if (preset) { + layer.exitTransition = { + presetId: input.exitTransition.presetId, + duration: input.exitTransition.duration + }; + } + } else { + layer.exitTransition = undefined; + } + } + return { success: true, layerId: resolvedId, diff --git a/src/lib/ai/schemas.ts b/src/lib/ai/schemas.ts index 21bd425..8ea98f7 100644 --- a/src/lib/ai/schemas.ts +++ b/src/lib/ai/schemas.ts @@ -45,6 +45,19 @@ const TimingFieldsSchema = z.object({ .describe('Start offset for trimming media content (seconds)') }); +// ============================================ +// Layer Transition (enter/exit preset) +// ============================================ + +const LayerTransitionFieldSchema = z + .object({ + presetId: AnimationPresetIdSchema.describe('Animation preset ID to apply as transition'), + duration: z.number().positive().describe('Transition duration in seconds (typically 0.3-0.8)') + }) + .describe( + 'Automatic animation applied at layer enter/exit. Position presets are relative offsets from the layer base position. Scale/opacity are factors (0→1 means invisible→visible).' + ); + // ============================================ // Layer Type + Props Union // ============================================ @@ -123,9 +136,17 @@ export const CreateLayerInputSchema = z // Layer type and properties layer: LayerTypePropsUnion, - // Animation (preset OR custom keyframes) + // Transitions (automatic enter/exit animations, no keyframes created) + enterTransition: LayerTransitionFieldSchema.optional().describe( + 'Auto-play animation when layer enters (e.g., fade-in, slide-in-left). Applied as offset/factor on base transform.' + ), + exitTransition: LayerTransitionFieldSchema.optional().describe( + 'Auto-play animation when layer exits (e.g., fade-out, slide-out-right). Applied as offset/factor on base transform.' + ), + + // Animation (preset OR custom keyframes - baked as keyframes) animation: CreateLayerAnimationSchema.optional().describe( - 'Animation: preset OR custom keyframes' + 'Animation as keyframes: preset (baked at startTime) OR custom keyframes' ) }) .refine( @@ -199,7 +220,15 @@ export const EditLayerInputSchema = z .describe('Layer-specific properties to update (merged with existing)'), // Timing fields - ...TimingFieldsSchema.shape + ...TimingFieldsSchema.shape, + + // Transitions (automatic enter/exit animations) + enterTransition: LayerTransitionFieldSchema.optional().describe( + 'Set enter transition preset. Omit to keep existing, set to null to remove.' + ), + exitTransition: LayerTransitionFieldSchema.optional().describe( + 'Set exit transition preset. Omit to keep existing, set to null to remove.' + ) }) .refine( (data) => { @@ -295,11 +324,12 @@ export const animationTools = { - Position, rotation, scale, anchor point - Layer-specific props (text content, colors, sizes, etc.) - Style (opacity, blur, filters, drop shadow) -- Animation via preset OR custom keyframes +- Enter/exit transitions (automatic preset animations at layer boundaries) +- Animation via preset (baked as keyframes) OR custom keyframes - Timing (enter/exit times, content duration/offset) -Example: Create a text layer with animation: -{ "layer": { "type": "text", "props": { "content": "Hello World", "fontSize": 48, "fill": "#ffffff" } }, "transform": { "position": { "x": 0, "y": -200 } }, "animation": { "preset": { "id": "fade-in", "startTime": 0, "duration": 0.5 } } }`, +Example with enter transition: +{ "layer": { "type": "text", "props": { "content": "Hello", "fontSize": 48, "fill": "#fff" } }, "transform": { "position": { "x": 0, "y": -200 } }, "enterTransition": { "presetId": "fade-in", "duration": 0.5 }, "exitTransition": { "presetId": "fade-out", "duration": 0.3 } }`, inputSchema: CreateLayerInputSchema }), @@ -308,6 +338,7 @@ Example: Create a text layer with animation: - Provide only the fields you want to change - transform/style sections replace entire object if provided - props are merged with existing props +- enterTransition/exitTransition: set automatic enter/exit animations - Use layer ID from create_layer response or layer name`, inputSchema: EditLayerInputSchema }), diff --git a/src/lib/ai/system-prompt.ts b/src/lib/ai/system-prompt.ts index 8e0eb37..dc7587a 100644 --- a/src/lib/ai/system-prompt.ts +++ b/src/lib/ai/system-prompt.ts @@ -3,9 +3,11 @@ * * Tools: create_layer, edit_layer, remove_layer, configure_project, group_layers, ungroup_layers * Animation: can use preset AND/OR custom keyframes in create_layer. + * Transitions: can set enterTransition/exitTransition on layers via edit_layer. */ import type { Project } from '$lib/types/animation'; import { projectDataSchema } from '$lib/schemas/animation'; +import { getPresetsSummaryForAI } from '$lib/engine/presets'; import goodExampleRaw from '$lib/good-example.json'; const exampleProject = projectDataSchema.parse(goodExampleRaw); @@ -29,12 +31,47 @@ Steps: present plan (if full video) → execute tool calls → conclude with a f ## Tools -- **create_layer**: Layer type + props + transform + style + animation + timing -- **edit_layer**: Modify existing layer (provide fields to change) +- **create_layer**: Layer type + props + transform + style + animation + timing + enterTransition/exitTransition +- **edit_layer**: Modify existing layer (provide fields to change, including enterTransition/exitTransition) - **remove_layer**: Delete layer by ID or name - **configure_project**: Project settings (name, dimensions, duration, background) - **group_layers** / **ungroup_layers**: Group/ungroup layers +## Animation System + +Two complementary ways to animate layers: + +### 1. Transitions (enterTransition / exitTransition) +Automatic enter/exit animations applied at runtime (no keyframes created). +Set via create_layer or edit_layer. Position presets are relative offsets from the layer's base position. +Scale and opacity presets are applied as factors (e.g., scale 0→1 means from invisible to full size). +When the transition finishes, the layer returns to its base values. + +\`\`\`json +{ "enterTransition": { "presetId": "fade-in", "duration": 0.5 } } +{ "exitTransition": { "presetId": "slide-out-left", "duration": 0.3 } } +\`\`\` + +### 2. Keyframe Presets (animation.preset in create_layer) +Bakes preset keyframes onto the layer at a specific time. Good for emphasis effects or custom timing. +Position values are added as offsets to the layer's current base position. + +\`\`\`json +{ "animation": { "preset": { "id": "pulse", "startTime": 1.0, "duration": 0.5 } } } +\`\`\` + +### 3. Custom Keyframes (animation.keyframes in create_layer) +Full control over individual property animations. Can be combined with presets. + +### When to use which: +- **Transitions**: Best for entrance/exit effects. No keyframes cluttering the timeline. Automatically tied to layer enter/exit time. +- **Keyframe presets**: Best for emphasis effects (pulse, shake, bounce) at specific moments. +- **Custom keyframes**: Best for unique, hand-crafted animations. + +## Available Presets + +${getPresetsSummaryForAI()} + ## Graphic Style - MANDATORY DIRECTIVES ### Backgrounds @@ -106,8 +143,8 @@ ${JSON.stringify(exampleProject)} When the user gives no specific animation directions, apply at minimum: -- **Entrances/Exits**: Fade in from opacity:0 + slight scale (0.95→1) + blur (filter.blur 8→0). Add a gentle slide (20-40px) from a direction. Reverse for exits. -- **Text layers**: At minimum, appear from opacity:0 with a subtle slide-up on Y (~20px). Titles can add scale+blur for more impact. +- **Entrances/Exits**: Use enterTransition/exitTransition with appropriate presets (fade-in, slide-in-*, scale-in for entrances; fade-out, slide-out-*, scale-out for exits). Default duration 0.3-0.6s. +- **Text layers**: At minimum, use enterTransition with fade-in or slide-in-bottom (duration 0.4s). Titles can use pop or bounce-in for more impact. - **Flat backgrounds**: If a background feels too plain, add 1-2 decorative circle shapes (≈200x200, high blur ≈150, low opacity ≈0.15-0.25) as soft ambient blobs with gentle position drift. These are sensible defaults — override freely when the user provides specific creative direction. diff --git a/src/lib/components/editor/panels/properties/animation-preset-select.svelte b/src/lib/components/editor/panels/properties/animation-preset-select.svelte index e31411b..4221d03 100644 --- a/src/lib/components/editor/panels/properties/animation-preset-select.svelte +++ b/src/lib/components/editor/panels/properties/animation-preset-select.svelte @@ -2,9 +2,8 @@ import * as Popover from '$lib/components/ui/popover'; import { Button } from '$lib/components/ui/button'; import { ChevronDown } from '@lucide/svelte'; - import type { TypedAnimationPreset } from '$lib/engine/presets'; - import { getLayerTransform, getLayerStyle } from '$lib/engine/layer-rendering'; - import { generateTransformCSS, generateFilterCSS } from '$lib/layers/base'; + import type { TypedAnimationPreset, PresetKeyframe } from '$lib/engine/presets'; + import { interpolateValue } from '$lib/engine/interpolation'; const { value, @@ -26,56 +25,105 @@ const selectedPreset = $derived(options.find((opt) => opt.id === value)); const hoveredPreset = $derived(options.find((opt) => opt.id === hoveredPresetId)); - // Create a dummy layer for preview - const previewLayer = $derived( - createLayer('rectangle', { - name: 'Preview', - transform: { - position: { x: 0, y: 0, z: 0 }, - rotation: { x: 0, y: 0, z: 0 }, - scale: { x: 1, y: 1, z: 1 }, - anchor: 'center' - }, - style: { opacity: 1 }, - props: { - width: 60, - height: 60, - fill: '#3b82f6', - stroke: '#2563eb', - strokeWidth: 2, - borderRadius: 8 + /** + * Interpolate preset keyframes at a given normalized time (0-1). + * Returns computed CSS transform and opacity for the preview box. + */ + function interpolatePresetAt( + keyframes: PresetKeyframe[], + time: number + ): { transform: string; opacity: number } { + // eslint-disable-next-line svelte/prefer-svelte-reactivity + const byProperty = new Map(); + for (const kf of keyframes) { + const existing = byProperty.get(kf.property) ?? []; + byProperty.set(kf.property, [...existing, kf]); + } + + let tx = 0, + ty = 0; + let sx = 1, + sy = 1; + let rz = 0, + rx = 0, + ry = 0; + let opacity = 1; + + for (const [property, kfs] of byProperty) { + const sorted = [...kfs].sort((a, b) => a.time - b.time); + + let prev = sorted[0]; + let next = sorted[sorted.length - 1]; + for (let i = 0; i < sorted.length - 1; i++) { + if (time >= sorted[i].time && time <= sorted[i + 1].time) { + prev = sorted[i]; + next = sorted[i + 1]; + break; + } } - }) - ); - - // Apply preset keyframes to preview layer - const previewLayerWithKeyframes = $derived.by(() => { - if (!hoveredPreset) return previewLayer; - - const keyframes = hoveredPreset.keyframes.map((kf) => ({ - id: crypto.randomUUID(), - time: kf.time, - property: kf.property as any, - value: kf.value, - interpolation: kf.interpolation - })); - - return { ...previewLayer, keyframes }; - }); - // Get current transform and style for preview at current time - const previewTransform = $derived(getLayerTransform(previewLayerWithKeyframes, previewTime, 1)); - const previewStyle = $derived(getLayerStyle(previewLayerWithKeyframes, previewTime, 1)); - const previewTransformCSS = $derived(generateTransformCSS(previewTransform)); - const previewFilterCSS = $derived(generateFilterCSS(previewStyle)); + const progress = prev.time === next.time ? 1 : (time - prev.time) / (next.time - prev.time); + const clamped = Math.max(0, Math.min(1, progress)); + + const val = interpolateValue( + prev.value, + next.value, + clamped, + next.interpolation ?? { family: 'continuous', strategy: 'ease-in-out' } + ) as number; + + // Scale position values for preview (500px → ~20px in the small box) + const posScale = 0.04; + + switch (property) { + case 'position.x': + tx = val * posScale; + break; + case 'position.y': + ty = val * posScale; + break; + case 'scale.x': + sx = val; + break; + case 'scale.y': + sy = val; + break; + case 'rotation.z': + rz = val; + break; + case 'rotation.x': + rx = val; + break; + case 'rotation.y': + ry = val; + break; + case 'opacity': + opacity = val; + break; + } + } + + const parts: string[] = []; + parts.push(`translate(${tx}px, ${ty}px)`); + if (rx !== 0) parts.push(`rotateX(${rx}rad)`); + if (ry !== 0) parts.push(`rotateY(${ry}rad)`); + if (rz !== 0) parts.push(`rotateZ(${rz}rad)`); + if (sx !== 1 || sy !== 1) parts.push(`scale(${sx}, ${sy})`); + + return { transform: parts.join(' '), opacity }; + } + + const previewValues = $derived.by(() => { + if (!hoveredPreset) return { transform: 'none', opacity: 1 }; + return interpolatePresetAt(hoveredPreset.keyframes, previewTime); + }); function startPreview(presetId: string) { hoveredPresetId = presetId; previewTime = 0; - // Animate preview const startTime = performance.now(); - const duration = 1000; // 1 second loop + const duration = 1200; function animate(currentTime: number) { const elapsed = currentTime - startTime; @@ -109,47 +157,50 @@ - - - -

+ +
{#each options as preset (preset.id)} {@const isSelected = value === preset.id} {@const isHovered = hoveredPresetId === preset.id} {/each}
diff --git a/src/lib/components/editor/panels/properties/groups/animation-presets-group.svelte b/src/lib/components/editor/panels/properties/groups/animation-presets-group.svelte index 393839b..a344159 100644 --- a/src/lib/components/editor/panels/properties/groups/animation-presets-group.svelte +++ b/src/lib/components/editor/panels/properties/groups/animation-presets-group.svelte @@ -1,11 +1,13 @@ - -