Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions examples/remotion/app/api/generate/route.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
197 changes: 176 additions & 21 deletions examples/remotion/app/page.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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() {
Expand Down Expand Up @@ -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">
Expand Down Expand Up @@ -193,21 +338,22 @@ 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">
generating...
</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">
Expand All @@ -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}
Expand Down
17 changes: 17 additions & 0 deletions examples/remotion/lib/catalog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*
Expand Down Expand Up @@ -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 changes: 3 additions & 0 deletions examples/remotion/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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"
},
Expand Down
2 changes: 1 addition & 1 deletion examples/remotion/tsconfig.tsbuildinfo

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion packages/remotion/src/catalog/definitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
34 changes: 25 additions & 9 deletions packages/remotion/src/components/ClipWrapper.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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}
Expand Down
Loading