Pārlūkot izejas kodu

add motion to remotion (#58)

* add motion to remotion

* update example
Chris Tate 5 mēneši atpakaļ
vecāks
revīzija
85f82590a8

+ 3 - 3
examples/remotion/app/api/generate/route.ts

@@ -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
examples/remotion/app/page.tsx

@@ -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 - 0
examples/remotion/lib/catalog.ts

@@ -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 - 0
examples/remotion/package.json

@@ -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"
   },

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
examples/remotion/tsconfig.tsbuildinfo


+ 4 - 1
packages/remotion/src/catalog/definitions.ts

@@ -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: {

+ 25 - 9
packages/remotion/src/components/ClipWrapper.tsx

@@ -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
packages/remotion/src/components/hooks.ts

@@ -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
packages/remotion/src/components/index.ts

@@ -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
packages/remotion/src/components/standard.tsx

@@ -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 - 0
packages/remotion/src/components/types.ts

@@ -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
packages/remotion/src/index.ts

@@ -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
packages/remotion/src/schema.ts

@@ -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(),
+            }),
+          }),
         }),
       ),
 

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 515 - 3
pnpm-lock.yaml


Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels