mirror of
https://github.com/vercel-labs/json-render.git
synced 2026-10-03 04:18:15 +08:00
Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce35074668 | ||
|
|
7c20632e7c |
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
“{quote}”
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(),
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
),
|
||||
|
||||
|
||||
Generated
+1403
-7
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user