reelcn
GitHubBack to landing

Ai generation

A prompt types into a neutral input, then morphs into a skeleton loader and flips into result cards. One continuous rect drives the box, a 3D flip drives the cards.

AiGeneration, defaults
AiGeneration, defaults thumbnail, 16:9

Installation

npx shadcn add https://www.reelcn.dev/r/ai-generation.json

Files land in src/reelcn/ with the items it builds on. Dependencies: remotion, zod.

Register the composition

<Composition
  id="AiGeneration"
  component={AiGeneration}
  schema={aiGenerationSchema}
  defaultProps={aiGenerationDefaults}
  calculateMetadata={aiGenerationMetadata}
  width={1920} height={1080} fps={30} durationInFrames={1}
/>

Render

npx remotion render AiGeneration # 16:9
npx remotion render AiGeneration --width=1080 --height=1920 # 9:16

Props

PropTypeDescription
prompt*string
resultCards*{ title: string; metric: string; }[]
theme"daylight" | "midnight" | "mono" | "ledger" | "afterglow" | "chromewave" | "neon" | "paper" | "sunset"Theme preset. Inherits the surrounding `ThemeProvider` when omitted.
brand{ accent?: string; logo?: string; font?: string; }
sfxbooleanPlays a soft whoosh as each scene starts.
musicstringBackground music file, looped under everything at 25% volume.

Built from

Use

  • An AI product's core generate-and-see-results loop
  • Any "type a prompt, get structured results" launch demo

Avoid

  • A branded chat-bubble look. This stays neutral (no ChatGPT-style chrome)

Make it with an agent

With the reelcn agent skill installed, ask for the video in plain words, such as "make a launch video for Relay with reelcn". The skill picks a template, sets its props, reviews a contact sheet and renders.

Guides

Components take their colors from a theme, their timing from motion props and their layout from the canvas.

Source

registry/items/ai-generation.tsx
/**
 * @title Ai Generation
 * @category templates
 * @description A prompt types into a neutral input, then morphs into a skeleton loader and flips into result cards. One continuous rect drives the box, a 3D flip drives the cards.
 * @duration data-driven
 * @use An AI product's core generate-and-see-results loop
 * @use Any "type a prompt, get structured results" launch demo
 * @avoid A branded chat-bubble look. This stays neutral (no ChatGPT-style chrome)
 * @tags ai, generation, prompt, skeleton, flip, template
 * @example
 * <Composition
 *   id="AiGeneration"
 *   component={AiGeneration}
 *   schema={aiGenerationSchema}
 *   defaultProps={aiGenerationDefaults}
 *   calculateMetadata={aiGenerationMetadata}
 *   width={1920} height={1080} fps={30} durationInFrames={1}
 * />
 */

import { AbsoluteFill, type CalculateMetadataFunction, useCurrentFrame, useVideoConfig } from "remotion";
import { z } from "zod";
import { clamp01, flipInterpolate, layoutRectsFor, type Rect, tween, useTheme, useViewport } from "./core";
import { Input } from "./input";
import { type CustomScene, type Scene, type Story, storyFrames } from "./story";
import { defineScene, Storyboard, templateSchema, templateStory } from "./storyboard";

export const aiGenerationSchema = templateSchema.extend({
  prompt: z.string(),
  resultCards: z.array(z.object({ title: z.string(), metric: z.string() })).min(1),
});

export type AiGenerationProps = z.infer<typeof aiGenerationSchema>;

export const aiGenerationDefaults: AiGenerationProps = {
  prompt: "Summarize this quarter's churn drivers",
  resultCards: [
    { title: "Top driver", metric: "Failed payments, 41%" },
    { title: "At-risk accounts", metric: "128 this month" },
    { title: "Suggested action", metric: "Retry + dunning email" },
  ],
};

function AiGenerationScene({
  prompt,
  resultCards,
}: {
  prompt: string;
  resultCards: { title: string; metric: string }[];
}) {
  const theme = useTheme();
  const { u, width, height } = useViewport();
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const TYPE_CPS = 12;
  const typeEnd = Math.round((prompt.length / TYPE_CPS) * fps);
  const submit = typeEnd + Math.round(fps * 0.4);
  const skeletonEnd = submit + Math.round(fps * 1.2);
  const flipEnd = skeletonEnd + Math.round(fps * 0.8);

  const inputRect: Rect = { x: width / 2 - u(170), y: height / 2 - u(26), width: u(340), height: u(52) };
  const skeletonRect: Rect = { x: width / 2 - u(220), y: height / 2 - u(70), width: u(440), height: u(140) };
  const cardsCanvas = { width: u(240) * resultCards.length + u(24) * (resultCards.length - 1), height: u(160) };
  const cardsOriginX = width / 2 - cardsCanvas.width / 2;
  const cardsOriginY = height / 2 - cardsCanvas.height / 2;
  const cardRects = layoutRectsFor("strip", resultCards.length, "landscape", cardsCanvas);

  const boxT = clamp01(
    tween(frame, fps, { from: submit, duration: Math.max(1, skeletonEnd - submit), motion: "smooth" }),
  );
  const box = flipInterpolate(inputRect, skeletonRect, boxT);
  const flipT = clamp01(
    tween(frame, fps, { from: skeletonEnd, duration: Math.max(1, flipEnd - skeletonEnd), motion: "smooth" }),
  );

  if (frame < submit) {
    return (
      <AbsoluteFill style={{ alignItems: "center", justifyContent: "center" }}>
        <Input id="prompt" placeholder="Describe what you need" steps={[{ at: 0, state: "typing", type: prompt }]} />
      </AbsoluteFill>
    );
  }

  return (
    <AbsoluteFill>
      <div
        style={{
          position: "absolute",
          left: box.x,
          top: box.y,
          width: box.width,
          height: box.height,
          borderRadius: u(theme.radius),
          background: theme.colors.surface,
          opacity: 1 - flipT,
        }}
      />
      {resultCards.map((card, i) => {
        const rect = cardRects[i];
        return (
          <div
            key={i}
            style={{
              position: "absolute",
              left: cardsOriginX + rect.x,
              top: cardsOriginY + rect.y,
              width: rect.width - u(12),
              height: rect.height,
              opacity: flipT,
              perspective: 900,
            }}
          >
            <div
              style={{
                position: "relative",
                width: "100%",
                height: "100%",
                transformStyle: "preserve-3d",
                transform: `rotateY(${flipT * 180}deg)`,
              }}
            >
              <div
                style={{
                  position: "absolute",
                  inset: 0,
                  backfaceVisibility: "hidden",
                  borderRadius: u(theme.radius),
                  background: theme.colors.border,
                }}
              />
              <div
                style={{
                  position: "absolute",
                  inset: 0,
                  backfaceVisibility: "hidden",
                  transform: "rotateY(180deg)",
                  borderRadius: u(theme.radius),
                  background: theme.colors.accent,
                  color: theme.colors.accentForeground,
                  display: "flex",
                  flexDirection: "column",
                  alignItems: "center",
                  justifyContent: "center",
                  gap: u(6),
                  fontFamily: theme.fonts.body,
                  padding: u(16),
                  textAlign: "center",
                }}
              >
                <div style={{ fontSize: u(15), opacity: 0.85 }}>{card.title}</div>
                <div style={{ fontSize: u(20), fontWeight: 700 }}>{card.metric}</div>
              </div>
            </div>
          </div>
        );
      })}
    </AbsoluteFill>
  );
}

const aiGenerationScene = defineScene({
  type: "ai-generation-beat",
  schema: z.object({ prompt: z.string(), resultCards: z.array(z.object({ title: z.string(), metric: z.string() })) }),
  component: AiGenerationScene,
  duration: (scene: { prompt: string }) => scene.prompt.length / 12 + 3,
});

export function aiGenerationStory(props: AiGenerationProps): Story {
  const scenes: (Scene | CustomScene)[] = [
    { type: "ai-generation-beat", prompt: props.prompt, resultCards: props.resultCards } as CustomScene,
  ];
  // One array-level cast, not per-scene `any`. See brand-reel.tsx's own note (Task 15).
  return templateStory(props, scenes as Scene[]);
}

const aiGenerationScenes = [aiGenerationScene];

export function AiGeneration(props: AiGenerationProps) {
  return <Storyboard story={aiGenerationStory(props)} scenes={aiGenerationScenes} />;
}

// Not `templateMetadata(aiGenerationStory)`: that helper calls `storyFrames(story)` with no custom scene
// rules, and this story's one scene is the template-local `defineScene` type above ("ai-generation-beat").
// Without its rule, `sceneSeconds` falls through to `Scene`'s own switch, matches no case, and returns
// `undefined`, making `durationInFrames` NaN (same failure mode documented in product-launch.tsx).
export const aiGenerationMetadata: CalculateMetadataFunction<AiGenerationProps> = ({ props }) => ({
  durationInFrames: storyFrames(aiGenerationStory(props), aiGenerationScenes),
});

On this page