Catalog
No preview yet

Source

563 lines
// Wireflow Canvas — the self-demo hero. A miniature Wireflow graph builds
// itself: a chat prompt types in, nodes spring onto a dotted canvas, edges
// draw port-to-port with a travelling data pulse, a render bar fills on the
// Compose node, and the output tile pops. It literally depicts the product
// making the video you're watching. Native Remotion, deterministic, SVG in a
// 1080x1920 viewBox so it's resolution-independent.

import {
  AbsoluteFill,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import { loadFont as loadGrotesk } from '@remotion/google-fonts/SpaceGrotesk';
import { loadFont as loadMono } from '@remotion/google-fonts/JetBrainsMono';

const { fontFamily: GROTESK } = loadGrotesk();
const { fontFamily: MONO } = loadMono();

export type WireflowCanvasProps = {
  promptText: string;
  outputImage: string;
  accent: string;
  accentSoft: string;
  backgroundColor: string;
  nodeColor: string;
  textColor: string;
};

type NodeDef = {
  id: string;
  x: number;
  y: number;
  w: number;
  h: number;
  title: string;
  sub: string;
  glyph: string;
  appear: number; // 0..1 fraction of duration when it springs in
};

// A clean top-to-bottom DAG that mirrors the real "Wireflow Promo Generator"
// graph: prompt -> Script -> (Image, Voice) -> Compose -> Output.
const NODES: NodeDef[] = [
  {
    id: 'script',
    x: 360,
    y: 560,
    w: 360,
    h: 156,
    title: 'Script',
    sub: 'Opus 4.8',
    glyph: '✦',
    appear: 0.16,
  },
  {
    id: 'image',
    x: 132,
    y: 884,
    w: 352,
    h: 156,
    title: 'Image',
    sub: 'Nano Banana',
    glyph: '◐',
    appear: 0.3,
  },
  {
    id: 'voice',
    x: 596,
    y: 884,
    w: 352,
    h: 156,
    title: 'Voice',
    sub: 'ElevenLabs',
    glyph: '♪',
    appear: 0.34,
  },
  {
    id: 'compose',
    x: 360,
    y: 1208,
    w: 360,
    h: 176,
    title: 'Compose',
    sub: 'Remotion',
    glyph: '▦',
    appear: 0.5,
  },
];

const cx = (n: NodeDef) => n.x + n.w / 2;
const topY = (n: NodeDef) => n.y;
const botY = (n: NodeDef) => n.y + n.h;
const byId = (id: string) => NODES.find((n) => n.id === id)!;

// Prompt bar + output tile geometry
const PROMPT = { x: 120, y: 250, w: 840, h: 150 };
const OUTPUT = { x: 392, y: 1536, w: 296, h: 300 };

type Edge = {
  from: [number, number];
  to: [number, number];
  start: number;
  end: number;
};

export default function WireflowCanvas({
  promptText = 'make a promo reel for my product',
  outputImage = '',
  accent = '#8B7CFF',
  accentSoft = '#5B4FCF',
  backgroundColor = '#0A0A12',
  nodeColor = '#14141F',
  textColor = '#F4F4FA',
}: Partial<WireflowCanvasProps>) {
  const frame = useCurrentFrame();
  const { fps, durationInFrames } = useVideoConfig();
  const t = durationInFrames > 0 ? frame / durationInFrames : 0; // 0..1 progress

  const exit = interpolate(
    frame,
    [durationInFrames - 9, durationInFrames - 1],
    [1, 0],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  // Edges with draw windows (fractions of duration)
  const promptAnchor: [number, number] = [
    PROMPT.x + PROMPT.w / 2,
    PROMPT.y + PROMPT.h,
  ];
  const outAnchor: [number, number] = [OUTPUT.x + OUTPUT.w / 2, OUTPUT.y];
  const edges: Edge[] = [
    {
      from: promptAnchor,
      to: [cx(byId('script')), topY(byId('script'))],
      start: 0.14,
      end: 0.26,
    },
    {
      from: [cx(byId('script')), botY(byId('script'))],
      to: [cx(byId('image')), topY(byId('image'))],
      start: 0.28,
      end: 0.42,
    },
    {
      from: [cx(byId('script')), botY(byId('script'))],
      to: [cx(byId('voice')), topY(byId('voice'))],
      start: 0.32,
      end: 0.46,
    },
    {
      from: [cx(byId('image')), botY(byId('image'))],
      to: [cx(byId('compose')), topY(byId('compose'))],
      start: 0.48,
      end: 0.62,
    },
    {
      from: [cx(byId('voice')), botY(byId('voice'))],
      to: [cx(byId('compose')), topY(byId('compose'))],
      start: 0.5,
      end: 0.64,
    },
    {
      from: [cx(byId('compose')), botY(byId('compose'))],
      to: outAnchor,
      start: 0.8,
      end: 0.92,
    },
  ];

  const renderFill = interpolate(t, [0.62, 0.82], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  // Output tile pop
  const outPop = spring({
    frame: frame - durationInFrames * 0.82,
    fps,
    config: { damping: 12, stiffness: 130 },
    durationInFrames: 22,
  });

  // Prompt typing
  const promptIn = spring({
    frame: frame - 2,
    fps,
    config: { damping: 20, stiffness: 120 },
    durationInFrames: 16,
  });
  const typed = Math.round(
    interpolate(t, [0.03, 0.16], [0, promptText.length], {
      extrapolateLeft: 'clamp',
      extrapolateRight: 'clamp',
    })
  );
  const shownPrompt = promptText.slice(0, typed);
  const caretOn = Math.floor(frame / 8) % 2 === 0;

  return (
    <AbsoluteFill
      style={{ backgroundColor, opacity: exit, fontFamily: GROTESK }}
    >
      <svg
        viewBox='0 0 1080 1920'
        width='100%'
        height='100%'
        preserveAspectRatio='xMidYMid meet'
      >
        <defs>
          <radialGradient id='wfGlow' cx='50%' cy='42%' r='60%'>
            <stop offset='0%' stopColor={accent} stopOpacity='0.16' />
            <stop offset='60%' stopColor={accent} stopOpacity='0.04' />
            <stop offset='100%' stopColor={accent} stopOpacity='0' />
          </radialGradient>
          <linearGradient id='wfEdge' x1='0' y1='0' x2='0' y2='1'>
            <stop offset='0%' stopColor={accentSoft} />
            <stop offset='100%' stopColor={accent} />
          </linearGradient>
          <filter id='wfSoft' x='-30%' y='-30%' width='160%' height='160%'>
            <feGaussianBlur stdDeviation='6' />
          </filter>
        </defs>

        {/* ambient glow */}
        <rect x='0' y='0' width='1080' height='1920' fill='url(#wfGlow)' />

        {/* dotted-grid canvas */}
        <DotGrid color={textColor} />

        {/* edges (under nodes) */}
        {edges.map((e, i) => (
          <EdgePath key={i} edge={e} t={t} accent={accent} />
        ))}

        {/* prompt bar */}
        <g opacity={promptIn}>
          <rect
            x={PROMPT.x}
            y={PROMPT.y}
            width={PROMPT.w}
            height={PROMPT.h}
            rx={22}
            fill={nodeColor}
            stroke={accent}
            strokeOpacity={0.5}
            strokeWidth={2}
          />
          <circle
            cx={PROMPT.x + 42}
            cy={PROMPT.y + PROMPT.h / 2}
            r={10}
            fill={accent}
          />
          <text
            x={PROMPT.x + 78}
            y={PROMPT.y + PROMPT.h / 2 + 12}
            fill={textColor}
            fontSize={38}
            fontFamily={MONO}
          >
            {shownPrompt}
            {caretOn && typed < promptText.length ? (
              <tspan fill={accent}>▍</tspan>
            ) : null}
          </text>
        </g>

        {/* nodes */}
        {NODES.map((n) => (
          <Node
            key={n.id}
            n={n}
            frame={frame}
            fps={fps}
            durationInFrames={durationInFrames}
            accent={accent}
            nodeColor={nodeColor}
            textColor={textColor}
            renderFill={n.id === 'compose' ? renderFill : -1}
          />
        ))}

        {/* output tile */}
        <g
          transform={`translate(${OUTPUT.x + OUTPUT.w / 2} ${
            OUTPUT.y + OUTPUT.h / 2
          }) scale(${outPop}) translate(${-(OUTPUT.x + OUTPUT.w / 2)} ${-(
            OUTPUT.y +
            OUTPUT.h / 2
          )})`}
          opacity={Math.min(1, outPop)}
        >
          <rect
            x={OUTPUT.x - 8}
            y={OUTPUT.y - 8}
            width={OUTPUT.w + 16}
            height={OUTPUT.h + 16}
            rx={26}
            fill={accent}
            opacity={0.18}
            filter='url(#wfSoft)'
          />
          {/* output thumbnail: SVG-native <image> (Remotion <Img> is an HTML
              <img>, invalid inside <svg> — it calls .decode() on an SVG node
              and crashes). href resolves at render; empty = clean play tile. */}
          <clipPath id='outClip'>
            <rect
              x={OUTPUT.x}
              y={OUTPUT.y}
              width={OUTPUT.w}
              height={OUTPUT.h}
              rx={20}
            />
          </clipPath>
          <rect
            x={OUTPUT.x}
            y={OUTPUT.y}
            width={OUTPUT.w}
            height={OUTPUT.h}
            rx={20}
            fill={nodeColor}
            stroke={accent}
            strokeWidth={2.5}
          />
          {outputImage ? (
            <image
              href={outputImage}
              clipPath='url(#outClip)'
              x={OUTPUT.x}
              y={OUTPUT.y}
              width={OUTPUT.w}
              height={OUTPUT.h}
              preserveAspectRatio='xMidYMid slice'
            />
          ) : null}
          {/* play triangle */}
          <g opacity={0.95}>
            <circle
              cx={OUTPUT.x + OUTPUT.w / 2}
              cy={OUTPUT.y + OUTPUT.h / 2}
              r={42}
              fill={accent}
            />
            <path
              d={`M ${OUTPUT.x + OUTPUT.w / 2 - 13} ${
                OUTPUT.y + OUTPUT.h / 2 - 20
              } L ${OUTPUT.x + OUTPUT.w / 2 + 22} ${
                OUTPUT.y + OUTPUT.h / 2
              } L ${OUTPUT.x + OUTPUT.w / 2 - 13} ${
                OUTPUT.y + OUTPUT.h / 2 + 20
              } Z`}
              fill='#0A0A12'
            />
          </g>
        </g>
      </svg>
    </AbsoluteFill>
  );
}

function DotGrid({ color }: { color: string }) {
  const dots = [];
  for (let gx = 60; gx < 1080; gx += 84) {
    for (let gy = 180; gy < 1900; gy += 84) {
      dots.push(
        <circle
          key={`${gx}-${gy}`}
          cx={gx}
          cy={gy}
          r={2.2}
          fill={color}
          opacity={0.06}
        />
      );
    }
  }
  return <g>{dots}</g>;
}

function bezier(from: [number, number], to: [number, number]) {
  const [x1, y1] = from;
  const [x2, y2] = to;
  const dy = (y2 - y1) * 0.5;
  return `M ${x1} ${y1} C ${x1} ${y1 + dy}, ${x2} ${y2 - dy}, ${x2} ${y2}`;
}

function EdgePath({
  edge,
  t,
  accent,
}: {
  edge: Edge;
  t: number;
  accent: string;
}) {
  const draw = interpolate(t, [edge.start, edge.end], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  if (draw <= 0) return null;
  const d = bezier(edge.from, edge.to);
  // travelling pulse once the edge is mostly drawn
  const pulseT = interpolate(t, [edge.end - 0.02, edge.end + 0.18], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  return (
    <g>
      <path
        d={d}
        fill='none'
        stroke='url(#wfEdge)'
        strokeWidth={4}
        strokeLinecap='round'
        pathLength={100}
        strokeDasharray={100}
        strokeDashoffset={100 * (1 - draw)}
        opacity={0.85}
      />
      {draw >= 1 && pulseT < 1 ? (
        <path
          d={d}
          fill='none'
          stroke={accent}
          strokeWidth={7}
          strokeLinecap='round'
          pathLength={100}
          strokeDasharray='5 95'
          strokeDashoffset={100 * (1 - pulseT)}
        />
      ) : null}
    </g>
  );
}

function Node({
  n,
  frame,
  fps,
  durationInFrames,
  accent,
  nodeColor,
  textColor,
  renderFill,
}: {
  n: NodeDef;
  frame: number;
  fps: number;
  durationInFrames: number;
  accent: string;
  nodeColor: string;
  textColor: string;
  renderFill: number;
}) {
  const pop = spring({
    frame: frame - durationInFrames * n.appear,
    fps,
    config: { damping: 13, stiffness: 130 },
    durationInFrames: 20,
  });
  if (pop <= 0.001) return null;
  const ccx = n.x + n.w / 2;
  const ccy = n.y + n.h / 2;
  return (
    <g
      transform={`translate(${ccx} ${ccy}) scale(${pop}) translate(${-ccx} ${-ccy})`}
      opacity={Math.min(1, pop)}
    >
      {/* glow */}
      <rect
        x={n.x - 4}
        y={n.y - 4}
        width={n.w + 8}
        height={n.h + 8}
        rx={22}
        fill={accent}
        opacity={0.1}
        filter='url(#wfSoft)'
      />
      <rect
        x={n.x}
        y={n.y}
        width={n.w}
        height={n.h}
        rx={18}
        fill={nodeColor}
        stroke={accent}
        strokeOpacity={0.55}
        strokeWidth={2}
      />
      {/* port dots */}
      <circle cx={n.x + n.w / 2} cy={n.y} r={7} fill={accent} />
      <circle cx={n.x + n.w / 2} cy={n.y + n.h} r={7} fill={accent} />
      {/* glyph chip */}
      <rect
        x={n.x + 22}
        y={n.y + 26}
        width={56}
        height={56}
        rx={14}
        fill={accent}
        opacity={0.16}
      />
      <text
        x={n.x + 50}
        y={n.y + 62}
        fill={accent}
        fontSize={34}
        textAnchor='middle'
        fontFamily={GROTESK}
      >
        {n.glyph}
      </text>
      {/* title + sub */}
      <text
        x={n.x + 96}
        y={n.y + 56}
        fill={textColor}
        fontSize={38}
        fontWeight={700}
        fontFamily={GROTESK}
      >
        {n.title}
      </text>
      <text
        x={n.x + 96}
        y={n.y + 96}
        fill={textColor}
        opacity={0.5}
        fontSize={26}
        fontFamily={MONO}
      >
        {n.sub}
      </text>
      {/* render bar on compose */}
      {renderFill >= 0 ? (
        <g>
          <rect
            x={n.x + 24}
            y={n.y + n.h - 38}
            width={n.w - 48}
            height={12}
            rx={6}
            fill={textColor}
            opacity={0.12}
          />
          <rect
            x={n.x + 24}
            y={n.y + n.h - 38}
            width={(n.w - 48) * renderFill}
            height={12}
            rx={6}
            fill={accent}
          />
        </g>
      ) : null}
    </g>
  );
}

Schema (Zod)

import { z } from 'zod';

export const Schema = z.object({
  // PORTS — wired from upstream nodes
  promptText: z
    .string()
    .default('make a promo reel for my product')
    .describe('$port:text The chat prompt typed into the canvas'),
  outputImage: z
    .string()
    .default('')
    .describe('$port:image Optional thumbnail shown in the output tile'),

  // STYLE
  accent: z.string().default('#8B7CFF').describe('$style:color Accent color'),
  accentSoft: z
    .string()
    .default('#5B4FCF')
    .describe('$style:color Secondary accent (edge gradient)'),
  backgroundColor: z
    .string()
    .default('#0A0A12')
    .describe('$style:color Background color'),
  nodeColor: z
    .string()
    .default('#14141F')
    .describe('$style:color Node fill color'),
  textColor: z.string().default('#F4F4FA').describe('$style:color Text color'),
});

Wireflow Canvas

wireflow_canvas

The self-demo hero — a miniature Wireflow graph builds itself: a chat prompt types in, nodes spring onto a dotted canvas, edges draw port-to-port with a travelling data pulse, the Compose node renders, and the output tile pops. Use it as the opening "this is what the product does" beat.

Workflow Inputs (2)

  • The chat prompt typed into the canvas
    promptText
    TEXT
  • Optional thumbnail shown in the output tile
    outputImage
    IMAGE

Config Fields (5)

  • accent
  • nodeColor
  • textColor
  • accentSoft
  • backgroundColor

Meta

Updated
7/10/2026