Compare commits

...
Author SHA1 Message Date
Chris Tate ce35074668 update example 2026-02-05 04:58:05 -06:00
Chris Tate 7c20632e7c add motion to remotion 2026-02-05 04:39:05 -06:00
14 changed files with 2005 additions and 56 deletions
+3 -3
View File
@@ -1,10 +1,10 @@
import { streamText } from "ai";
import { videoCatalog } from "@/lib/catalog";
import { getVideoPrompt } from "@/lib/catalog";
export const maxDuration = 30;
// Generate prompt from catalog - uses Remotion schema's prompt template
const SYSTEM_PROMPT = videoCatalog.prompt();
// Generate prompt from catalog - uses Remotion schema's prompt template with custom rules
const SYSTEM_PROMPT = getVideoPrompt();
const MAX_PROMPT_LENGTH = 500;
const DEFAULT_MODEL = "anthropic/claude-haiku-4.5";
+176 -21
View File
@@ -1,9 +1,10 @@
"use client";
import { useState, useCallback, useRef } from "react";
import { useState, useCallback, useRef, useEffect } from "react";
import { createSpecStreamCompiler } from "@json-render/core";
import { Player, PlayerRef } from "@remotion/player";
import { Renderer, type TimelineSpec } from "@json-render/remotion";
import { createHighlighter, type Highlighter } from "shiki";
/**
* Check if spec is complete enough to render
@@ -17,11 +18,146 @@ function isSpecComplete(spec: TimelineSpec): spec is Required<TimelineSpec> {
);
}
/**
* Shiki theme (Vercel-inspired dark theme)
*/
const darkTheme = {
name: "custom-dark",
type: "dark" as const,
colors: {
"editor.background": "transparent",
"editor.foreground": "#EDEDED",
},
settings: [
{
scope: ["string", "string.quoted"],
settings: { foreground: "#50E3C2" },
},
{
scope: [
"constant.numeric",
"constant.language.boolean",
"constant.language.null",
],
settings: { foreground: "#50E3C2" },
},
{
scope: ["punctuation", "meta.brace", "meta.bracket"],
settings: { foreground: "#888888" },
},
{
scope: ["support.type.property-name", "entity.name.tag.json"],
settings: { foreground: "#EDEDED" },
},
],
};
// Preload highlighter
let highlighterPromise: Promise<Highlighter> | null = null;
function getHighlighter() {
if (!highlighterPromise) {
highlighterPromise = createHighlighter({
themes: [darkTheme],
langs: ["json"],
});
}
return highlighterPromise;
}
// Start loading immediately
if (typeof window !== "undefined") {
getHighlighter();
}
/**
* Code block with syntax highlighting
*/
function CodeBlock({ code }: { code: string }) {
const [html, setHtml] = useState<string>("");
useEffect(() => {
getHighlighter().then((highlighter) => {
setHtml(
highlighter.codeToHtml(code, {
lang: "json",
theme: "custom-dark",
}),
);
});
}, [code]);
if (!html) {
return (
<pre className="p-4 text-left">
<code className="text-muted-foreground">{code}</code>
</pre>
);
}
return (
<div
className="p-4 text-[13px] leading-relaxed [&_pre]:bg-transparent! [&_pre]:p-0! [&_pre]:m-0! [&_code]:bg-transparent!"
dangerouslySetInnerHTML={{ __html: html }}
/>
);
}
/**
* Copy button component
*/
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<button
onClick={handleCopy}
className="p-1.5 rounded hover:bg-white/10 transition-colors text-muted-foreground hover:text-foreground"
aria-label="Copy JSON"
>
{copied ? (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
) : (
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
)}
</button>
);
}
const EXAMPLE_PROMPTS = [
"Create a 10-second product intro with title cards",
"Make a social media promo video with stats",
"Build a testimonial video with quotes",
"Design a company intro with split screens",
"Create a 10-second product intro with title cards and images",
"Make a photo slideshow with 5 different images",
"Build a testimonial video with quotes and background images",
"Design a dynamic company intro with animated entrances",
];
export default function Home() {
@@ -98,9 +234,18 @@ export default function Home() {
setTimeout(() => inputRef.current?.focus(), 0);
};
const totalTime = spec?.composition
? spec.composition.durationInFrames / spec.composition.fps
: 0;
const handleExport = useCallback(() => {
if (!spec) return;
const blob = new Blob([JSON.stringify(spec, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = "timeline.json";
a.click();
URL.revokeObjectURL(url);
}, [spec]);
return (
<div className="min-h-screen">
@@ -193,7 +338,7 @@ export default function Home() {
<div className="text-left">
<div className="flex items-center gap-4 mb-2 h-6">
<span className="text-xs font-mono text-muted-foreground">
timeline.json
json
</span>
{isGenerating && (
<span className="text-xs text-muted-foreground animate-pulse">
@@ -201,13 +346,14 @@ export default function Home() {
</span>
)}
</div>
<div className="border border-border rounded bg-background font-mono text-xs h-[28rem] overflow-auto">
<div className="border border-border rounded bg-background font-mono text-xs h-[28rem] overflow-auto relative group">
{spec && (
<div className="absolute top-2 right-2 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
<CopyButton text={JSON.stringify(spec, null, 2)} />
</div>
)}
{spec ? (
<pre className="p-4 text-left">
<code className="text-muted-foreground">
{JSON.stringify(spec, null, 2)}
</code>
</pre>
<CodeBlock code={JSON.stringify(spec, null, 2)} />
) : isGenerating ? (
<div className="p-4 text-muted-foreground/50 h-full flex items-center justify-center">
<div className="flex flex-col items-center gap-2">
@@ -233,13 +379,22 @@ export default function Home() {
<span className="text-xs font-mono text-muted-foreground">
preview
</span>
{spec?.composition && (
<span className="text-xs font-mono text-muted-foreground">
{totalTime.toFixed(1)}s
</span>
)}
<div className="flex items-center gap-2">
{spec && isSpecComplete(spec) && (
<button
onClick={handleExport}
className="text-xs px-2 py-1 rounded border border-border text-muted-foreground hover:text-foreground hover:border-foreground/50 transition-colors"
title="Export timeline JSON"
>
Export
</button>
)}
</div>
</div>
<div className="border border-border rounded bg-black h-[28rem] relative overflow-hidden">
<div
className="border border-border rounded bg-black h-[28rem] relative overflow-hidden"
data-player-container
>
{spec && isSpecComplete(spec) ? (
<Player
ref={playerRef}
+17
View File
@@ -6,6 +6,16 @@ import {
standardEffectDefinitions,
} from "@json-render/remotion/server";
/**
* Custom rules for the AI to follow when generating videos
*/
const customRules = [
// Image URLs using Picsum (free, no API key)
'ImageSlide props: { "src": "https://picsum.photos/1920/1080?random=N", "alt": "description" } - use "src" NOT "imageUrl"',
"Use different random numbers for each image to get different photos (e.g., ?random=1, ?random=2, ?random=3)",
"Picsum provides random professional stock photos - great for product shots, backgrounds, and visual content",
];
/**
* Remotion video catalog
*
@@ -35,3 +45,10 @@ export const videoCatalog = defineCatalog(schema, {
// Use all standard effects from the package
effects: standardEffectDefinitions,
});
/**
* Get the prompt with custom rules for image generation
*/
export function getVideoPrompt(): string {
return videoCatalog.prompt({ customRules });
}
+3
View File
@@ -13,7 +13,9 @@
"@ai-sdk/gateway": "^3.0.13",
"@json-render/core": "workspace:*",
"@json-render/remotion": "workspace:*",
"@remotion/bundler": "4.0.418",
"@remotion/player": "4.0.418",
"@remotion/renderer": "4.0.418",
"ai": "^6.0.70",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -22,6 +24,7 @@
"react": "19.2.3",
"react-dom": "19.2.3",
"remotion": "4.0.418",
"shiki": "^3.21.0",
"tailwind-merge": "^3.4.0",
"zod": "^4.0.0"
},
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -54,10 +54,13 @@ export const standardComponentDefinitions = {
quote: z.string(),
author: z.string().nullable(),
backgroundColor: z.string().nullable(),
textColor: z.string().nullable(),
transparent: z.boolean().nullable(),
}),
type: "scene",
defaultDuration: 150,
description: "Quote display with attribution. Use for testimonials.",
description:
"Quote display with author. Props: quote, author, textColor, backgroundColor. Set transparent:true when using as overlay on images.",
},
StatCard: {
@@ -1,7 +1,7 @@
"use client";
import { AbsoluteFill, useCurrentFrame } from "remotion";
import { useTransition } from "./hooks";
import { useTransition, useMotion } from "./hooks";
import type { Clip } from "./types";
interface ClipWrapperProps {
@@ -10,22 +10,38 @@ interface ClipWrapperProps {
}
/**
* Wrapper component that applies transition animations to clips
* Wrapper component that applies transition and motion animations to clips
*
* Automatically handles transitionIn and transitionOut based on clip config.
* Automatically handles:
* - transitionIn/transitionOut: basic transition presets (fade, slide, zoom, etc.)
* - motion: declarative enter/exit/loop animations with spring physics
*
* Transitions and motion compose together:
* - Opacity: multiplied (both affect final opacity)
* - Transforms: added (both contribute to final position/scale/rotation)
*/
export function ClipWrapper({ clip, children }: ClipWrapperProps) {
const frame = useCurrentFrame();
const { opacity, translateX, translateY, scale } = useTransition(
clip,
frame + clip.from,
);
const absoluteFrame = frame + clip.from;
// Get transition styles (from transitionIn/transitionOut)
const transition = useTransition(clip, absoluteFrame);
// Get motion styles (from motion.enter/exit/loop)
const motion = useMotion(clip, absoluteFrame);
// Compose styles: multiply opacity, add transforms
const composedOpacity = transition.opacity * motion.opacity;
const composedTranslateX = transition.translateX + motion.translateX;
const composedTranslateY = transition.translateY + motion.translateY;
const composedScale = transition.scale * motion.scale;
const composedRotate = motion.rotate; // Only from motion (transitions don't rotate)
return (
<AbsoluteFill
style={{
opacity,
transform: `translateX(${translateX}%) translateY(${translateY}%) scale(${scale})`,
opacity: composedOpacity,
transform: `translateX(${composedTranslateX}%) translateY(${composedTranslateY}%) scale(${composedScale}) rotate(${composedRotate}deg)`,
}}
>
{children}
+169 -2
View File
@@ -1,7 +1,7 @@
"use client";
import { useVideoConfig, spring, interpolate } from "remotion";
import type { Clip, TransitionStyles } from "./types";
import { useVideoConfig, spring, interpolate, Easing } from "remotion";
import type { Clip, TransitionStyles, MotionStyles } from "./types";
/**
* Calculate transition styles based on clip configuration
@@ -102,3 +102,170 @@ export function useTransition(clip: Clip, frame: number): TransitionStyles {
return { opacity, translateX, translateY, scale };
}
/**
* Calculate motion styles based on clip's motion configuration
*
* Handles declarative motion:
* - enter: animate FROM values TO neutral
* - exit: animate FROM neutral TO values
* - loop: continuous animation during clip lifetime
* - spring: physics-based easing
*/
export function useMotion(clip: Clip, frame: number): MotionStyles {
const { fps } = useVideoConfig();
const relativeFrame = frame - clip.from;
const clipEnd = clip.durationInFrames;
// Default neutral values
let opacity = 1;
let translateX = 0;
let translateY = 0;
let scale = 1;
let rotate = 0;
const motion = clip.motion;
if (!motion) {
return { opacity, translateX, translateY, scale, rotate };
}
// Spring config with defaults
const springConfig = {
damping: motion.spring?.damping ?? 20,
stiffness: motion.spring?.stiffness ?? 100,
mass: motion.spring?.mass ?? 1,
};
// Enter animation
if (motion.enter) {
const enterDuration = motion.enter.duration ?? 20;
if (relativeFrame < enterDuration) {
const progress = spring({
frame: relativeFrame,
fps,
config: springConfig,
durationInFrames: enterDuration,
});
// Interpolate FROM enter values TO neutral (1, 0, 0, 1, 0)
if (motion.enter.opacity !== undefined) {
opacity = interpolate(progress, [0, 1], [motion.enter.opacity, 1]);
}
if (motion.enter.scale !== undefined) {
scale = interpolate(progress, [0, 1], [motion.enter.scale, 1]);
}
if (motion.enter.x !== undefined) {
translateX = interpolate(progress, [0, 1], [motion.enter.x, 0]);
}
if (motion.enter.y !== undefined) {
translateY = interpolate(progress, [0, 1], [motion.enter.y, 0]);
}
if (motion.enter.rotate !== undefined) {
rotate = interpolate(progress, [0, 1], [motion.enter.rotate, 0]);
}
}
}
// Exit animation
if (motion.exit) {
const exitDuration = motion.exit.duration ?? 20;
const exitStart = clipEnd - exitDuration;
if (relativeFrame >= exitStart) {
const exitFrame = relativeFrame - exitStart;
const progress = spring({
frame: exitFrame,
fps,
config: springConfig,
durationInFrames: exitDuration,
});
// Interpolate FROM neutral TO exit values
if (motion.exit.opacity !== undefined) {
const exitOpacity = interpolate(
progress,
[0, 1],
[1, motion.exit.opacity],
);
opacity = Math.min(opacity, exitOpacity);
}
if (motion.exit.scale !== undefined) {
const exitScale = interpolate(progress, [0, 1], [1, motion.exit.scale]);
// Multiply scales for composition
scale = scale * exitScale;
}
if (motion.exit.x !== undefined) {
const exitX = interpolate(progress, [0, 1], [0, motion.exit.x]);
translateX = translateX + exitX;
}
if (motion.exit.y !== undefined) {
const exitY = interpolate(progress, [0, 1], [0, motion.exit.y]);
translateY = translateY + exitY;
}
if (motion.exit.rotate !== undefined) {
const exitRotate = interpolate(
progress,
[0, 1],
[0, motion.exit.rotate],
);
rotate = rotate + exitRotate;
}
}
}
// Loop animation (continuous during clip)
if (motion.loop) {
const { property, from, to, duration, easing = "ease" } = motion.loop;
// Calculate loop progress (0-1 repeating)
const loopFrame = relativeFrame % duration;
let loopProgress: number;
switch (easing) {
case "linear":
loopProgress = loopFrame / duration;
break;
case "spring":
loopProgress = spring({
frame: loopFrame,
fps,
config: springConfig,
durationInFrames: duration,
});
break;
case "ease":
default:
// Sine ease in-out for smooth looping
loopProgress = interpolate(
loopFrame,
[0, duration / 2, duration],
[0, 1, 0],
{ extrapolateRight: "clamp" },
);
break;
}
const loopValue = interpolate(loopProgress, [0, 1], [from, to]);
switch (property) {
case "opacity":
opacity = opacity * loopValue;
break;
case "scale":
scale = scale * loopValue;
break;
case "x":
translateX = translateX + loopValue;
break;
case "y":
translateY = translateY + loopValue;
break;
case "rotate":
rotate = rotate + loopValue;
break;
}
}
return { opacity, translateX, translateY, scale, rotate };
}
+6 -1
View File
@@ -4,12 +4,17 @@ export type {
TimelineSpec,
AudioTrack,
TransitionStyles,
MotionStyles,
Motion,
MotionState,
MotionLoop,
SpringConfig,
ClipComponent,
ComponentRegistry,
} from "./types";
// Hooks
export { useTransition } from "./hooks";
export { useTransition, useMotion } from "./hooks";
// Wrapper
export { ClipWrapper } from "./ClipWrapper";
+27 -8
View File
@@ -151,18 +151,26 @@ export function SplitScreen({ clip }: { clip: Clip }) {
// =============================================================================
export function QuoteCard({ clip }: { clip: Clip }) {
const { quote, author, backgroundColor } = clip.props as {
quote: string;
author?: string;
backgroundColor?: string;
};
const { quote, author, backgroundColor, textColor, transparent } =
clip.props as {
quote: string;
author?: string;
backgroundColor?: string;
textColor?: string;
transparent?: boolean;
};
// Use transparent background for overlays, or specified color, or default dark
const bgColor = transparent ? "transparent" : backgroundColor || "#1a1a2e";
const color = textColor || "#ffffff";
return (
<ClipWrapper clip={clip}>
<AbsoluteFill
style={{
backgroundColor: backgroundColor || "#1a1a2e",
color: "#ffffff",
backgroundColor: bgColor,
color,
display: "flex",
flexDirection: "column",
alignItems: "center",
@@ -176,11 +184,22 @@ export function QuoteCard({ clip }: { clip: Clip }) {
fontStyle: "italic",
textAlign: "center",
marginBottom: 24,
textShadow: transparent ? "2px 2px 8px rgba(0,0,0,0.8)" : "none",
}}
>
&ldquo;{quote}&rdquo;
</div>
{author && <div style={{ fontSize: 28, opacity: 0.7 }}>- {author}</div>}
{author && (
<div
style={{
fontSize: 28,
opacity: 0.9,
textShadow: transparent ? "1px 1px 4px rgba(0,0,0,0.8)" : "none",
}}
>
- {author}
</div>
)}
</AbsoluteFill>
</ClipWrapper>
);
+74
View File
@@ -2,6 +2,78 @@
* Types for Remotion timeline components
*/
/**
* Motion state for enter/exit animations
* Values animate FROM these values TO neutral (enter) or FROM neutral TO these values (exit)
*/
export interface MotionState {
/** Opacity (0-1), default: 1 */
opacity?: number;
/** Scale multiplier (0.8 = 80%), default: 1 */
scale?: number;
/** Translate X in pixels, default: 0 */
x?: number;
/** Translate Y in pixels, default: 0 */
y?: number;
/** Rotation in degrees, default: 0 */
rotate?: number;
/** Duration in frames, default: 20 */
duration?: number;
}
/**
* Spring physics configuration
*/
export interface SpringConfig {
/** Damping coefficient, default: 20 */
damping?: number;
/** Stiffness, default: 100 */
stiffness?: number;
/** Mass, default: 1 */
mass?: number;
}
/**
* Continuous loop animation
*/
export interface MotionLoop {
/** Property to animate */
property: "scale" | "rotate" | "x" | "y" | "opacity";
/** Starting value */
from: number;
/** Ending value */
to: number;
/** Duration of one cycle in frames */
duration: number;
/** Easing type, default: "ease" */
easing?: "linear" | "ease" | "spring";
}
/**
* Declarative motion configuration for clips
*/
export interface Motion {
/** Enter animation - animates FROM these values TO neutral */
enter?: MotionState;
/** Exit animation - animates FROM neutral TO these values */
exit?: MotionState;
/** Spring physics config (applies to enter/exit) */
spring?: SpringConfig;
/** Continuous looping animation */
loop?: MotionLoop;
}
/**
* Motion styles calculated by useMotion hook
*/
export interface MotionStyles {
opacity: number;
translateX: number;
translateY: number;
scale: number;
rotate: number;
}
/**
* Clip data passed to components
*/
@@ -14,6 +86,8 @@ export interface Clip {
durationInFrames: number;
transitionIn?: { type: string; durationInFrames: number };
transitionOut?: { type: string; durationInFrames: number };
/** Declarative motion configuration */
motion?: Motion;
}
/**
+6 -1
View File
@@ -26,12 +26,17 @@ export type {
TimelineSpec,
AudioTrack,
TransitionStyles,
MotionStyles,
Motion,
MotionState,
MotionLoop,
SpringConfig,
ClipComponent,
ComponentRegistry,
} from "./components";
// Hooks and utilities
export { useTransition, ClipWrapper } from "./components";
export { useTransition, useMotion, ClipWrapper } from "./components";
// Standard components
export {
+91 -2
View File
@@ -27,8 +27,8 @@ function remotionPromptTemplate(context: PromptContext): string {
lines.push("");
lines.push(`{"op":"set","path":"/composition","value":{"id":"intro","fps":30,"width":1920,"height":1080,"durationInFrames":300}}
{"op":"set","path":"/tracks","value":[{"id":"main","name":"Main","type":"video","enabled":true},{"id":"overlay","name":"Overlay","type":"overlay","enabled":true}]}
{"op":"set","path":"/clips/0","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15}}}
{"op":"set","path":"/clips/1","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90}}
{"op":"set","path":"/clips/0","value":{"id":"clip-1","trackId":"main","component":"TitleCard","props":{"title":"Welcome","subtitle":"Getting Started"},"from":0,"durationInFrames":90,"transitionIn":{"type":"fade","durationInFrames":15},"transitionOut":{"type":"fade","durationInFrames":15},"motion":{"enter":{"opacity":0,"y":50,"scale":0.9,"duration":25},"spring":{"damping":15}}}}
{"op":"set","path":"/clips/1","value":{"id":"clip-2","trackId":"main","component":"TitleCard","props":{"title":"Features"},"from":90,"durationInFrames":90,"motion":{"enter":{"opacity":0,"x":-100,"duration":20},"exit":{"opacity":0,"x":100,"duration":15}}}}
{"op":"set","path":"/audio","value":{"tracks":[]}}`);
lines.push("");
@@ -71,6 +71,38 @@ function remotionPromptTemplate(context: PromptContext): string {
lines.push("");
}
// Motion system documentation
lines.push("MOTION SYSTEM:");
lines.push(
"Clips can have a 'motion' field for declarative animations (optional, use for dynamic/engaging videos):",
);
lines.push("");
lines.push(
"- enter: {opacity?, scale?, x?, y?, rotate?, duration?} - animate FROM these values TO normal when clip starts",
);
lines.push(
"- exit: {opacity?, scale?, x?, y?, rotate?, duration?} - animate FROM normal TO these values when clip ends",
);
lines.push(
"- spring: {damping?, stiffness?, mass?} - physics config (lower damping = more bounce)",
);
lines.push(
'- loop: {property, from, to, duration, easing?} - continuous animation (property: "scale"|"rotate"|"x"|"y"|"opacity")',
);
lines.push("");
lines.push("Example motion configs:");
lines.push(' Fade up: {"enter":{"opacity":0,"y":30,"duration":20}}');
lines.push(
' Scale pop: {"enter":{"scale":0.5,"opacity":0,"duration":15},"spring":{"damping":10}}',
);
lines.push(
' Slide in/out: {"enter":{"x":-100,"duration":20},"exit":{"x":100,"duration":15}}',
);
lines.push(
' Gentle pulse: {"loop":{"property":"scale","from":1,"to":1.05,"duration":60,"easing":"ease"}}',
);
lines.push("");
// Rules
lines.push("RULES:");
const baseRules = [
@@ -83,6 +115,8 @@ function remotionPromptTemplate(context: PromptContext): string {
"fps is always 30 (1 second = 30 frames, 10 seconds = 300 frames)",
'Clips on "main" track flow sequentially (from = previous clip\'s from + durationInFrames)',
'Overlay clips (LowerThird, TextOverlay) go on "overlay" track',
"Use motion.enter for engaging clip entrances, motion.exit for smooth departures",
"Spring damping: 20=smooth, 10=bouncy, 5=very bouncy",
];
const allRules = [...baseRules, ...customRules];
allRules.forEach((rule, i) => {
@@ -161,6 +195,61 @@ export const schema = defineSchema(
type: s.ref("catalog.transitions"),
durationInFrames: s.number(),
}),
/** Declarative motion configuration for custom animations */
motion: s.object({
/** Enter animation - animates FROM these values TO neutral */
enter: s.object({
/** Starting opacity (0-1), animates to 1 */
opacity: s.number(),
/** Starting scale (e.g., 0.8 = 80%), animates to 1 */
scale: s.number(),
/** Starting X offset in pixels, animates to 0 */
x: s.number(),
/** Starting Y offset in pixels, animates to 0 */
y: s.number(),
/** Starting rotation in degrees, animates to 0 */
rotate: s.number(),
/** Duration of enter animation in frames (default: 20) */
duration: s.number(),
}),
/** Exit animation - animates FROM neutral TO these values */
exit: s.object({
/** Ending opacity (0-1), animates from 1 */
opacity: s.number(),
/** Ending scale, animates from 1 */
scale: s.number(),
/** Ending X offset in pixels, animates from 0 */
x: s.number(),
/** Ending Y offset in pixels, animates from 0 */
y: s.number(),
/** Ending rotation in degrees, animates from 0 */
rotate: s.number(),
/** Duration of exit animation in frames (default: 20) */
duration: s.number(),
}),
/** Spring physics configuration */
spring: s.object({
/** Damping coefficient (default: 20) */
damping: s.number(),
/** Stiffness (default: 100) */
stiffness: s.number(),
/** Mass (default: 1) */
mass: s.number(),
}),
/** Continuous looping animation */
loop: s.object({
/** Property to animate: "scale" | "rotate" | "x" | "y" | "opacity" */
property: s.string(),
/** Starting value */
from: s.number(),
/** Ending value */
to: s.number(),
/** Duration of one cycle in frames */
duration: s.number(),
/** Easing type: "linear" | "ease" | "spring" (default: "ease") */
easing: s.string(),
}),
}),
}),
),
+1403 -7
View File
File diff suppressed because it is too large Load Diff