shadimage

Ai Composer Utils

Shared typewriter, caret, morph, and intro helpers for AI composer scenes

registry/bases/default/lib/ai-composer-utils.tsx

import type { CSSProperties } from "react";
import {
  Easing,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from "remotion";

/** Frame the prompt starts typing in the chat-style composers. */
export const AI_TYPING_START = 42;
/** Frame the prompt starts typing in the terminal-style composers. */
export const AI_TYPING_START_TUI = 48;
/** Characters per second for the chat-style composers. */
export const AI_TYPING_CPS = 22;

export function stageScale(
  width: number,
  height: number,
  refW = 1280,
  refH = 720,
): number {
  return Math.min(width / refW, height / refH);
}

export interface TypewriterOptions {
  cps?: number;
  speed?: number;
  startFrame?: number;
}

export interface TypewriterState {
  text: string;
  count: number;
  done: boolean;
  typing: boolean;
}

/**
 * Character-by-character reveal. `typing` is false both before the first
 * character and after the last one, so a caret bound to `!typing` blinks while
 * idle and holds solid while text is being revealed.
 */
export function useTypewriter(
  full: string,
  options: TypewriterOptions = {},
): TypewriterState {
  const {
    cps = AI_TYPING_CPS,
    speed = 1,
    startFrame = AI_TYPING_START,
  } = options;
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const local = frame * speed - startFrame;
  const over = (full.length / cps) * fps;
  const count =
    local <= 0
      ? 0
      : over <= 0
        ? full.length
        : Math.max(0, Math.min(full.length, Math.floor((local / over) * full.length)));

  return {
    text: full.slice(0, count),
    count,
    done: count >= full.length,
    typing: count > 0 && count < full.length,
  };
}

/** Spring that drives the mic/waveform → send button morph. */
export function morphProgressAt(
  frame: number,
  opts: { startFrame?: number; fps: number; speed?: number },
): number {
  const { startFrame = AI_TYPING_START, fps, speed = 1 } = opts;
  const value = spring({
    fps,
    frame: frame * speed - startFrame,
    config: { damping: 14, stiffness: 200, mass: 0.6 },
  });
  return Math.max(0, Math.min(value, 1));
}

export interface SendBeat {
  /** Speed-adjusted frame the send happens on. */
  frame: number;
  /** True once the prompt has left the composer. */
  sent: boolean;
  /** 0 → 1 → 0 pulse across the button press. Map it onto a scale dip. */
  press: number;
  /** 0 → 1 as the sent message rises into the thread. */
  bubble: number;
  /** 0 → 1 as the assistant starts working on the reply. */
  reply: number;
}

/**
 * The beat after the typewriter stops: press, clear, and the prompt landing in
 * the thread. Timed off the prompt length so it always follows the last
 * character rather than sitting on a hand-picked frame that a longer prompt
 * would overrun.
 *
 * `frame` must already be speed-adjusted (`frame * speed`), the same value the
 * typewriter is driven with.
 */
export function sendBeatAt(
  frame: number,
  opts: {
    promptLength: number;
    fps: number;
    cps?: number;
    startFrame?: number;
    /** Beat between the last character and the press. */
    pauseSeconds?: number;
  },
): SendBeat {
  const {
    promptLength,
    fps,
    cps = AI_TYPING_CPS,
    startFrame = AI_TYPING_START,
    pauseSeconds = 0.34,
  } = opts;
  const clampBoth = {
    extrapolateLeft: "clamp" as const,
    extrapolateRight: "clamp" as const,
  };
  const at =
    startFrame + (cps > 0 ? (promptLength / cps) * fps : 0) + pauseSeconds * fps;

  return {
    frame: at,
    sent: frame >= at,
    press: interpolate(frame, [at - 4, at, at + 8], [0, 1, 0], clampBoth),
    bubble: interpolate(frame, [at + 1, at + 15], [0, 1], {
      easing: Easing.out(Easing.cubic),
      ...clampBoth,
    }),
    reply: interpolate(frame, [at + 10, at + 22], [0, 1], {
      easing: Easing.out(Easing.cubic),
      ...clampBoth,
    }),
  };
}

/**
 * Three-dot "working on it" pulse. `index` is the dot, `frame` the
 * speed-adjusted frame; the period is deliberately short so a preview sampled
 * late in its window still has something moving in it.
 */
export function replyDotOpacity(
  frame: number,
  index: number,
  fps: number,
): number {
  const period = fps * 0.9;
  const phase = ((frame - index * (period / 3)) % period + period) % period;
  return 0.28 + 0.72 * (0.5 - 0.5 * Math.cos((phase / period) * Math.PI * 2));
}

export function introBounceIn(
  frame: number,
  fps: number,
): { translateY: number; scale: number } {
  const s = spring({
    fps,
    frame,
    config: { damping: 14, stiffness: 110, mass: 0.7 },
  });
  return {
    translateY: interpolate(s, [0, 1], [28, 0]),
    scale: interpolate(s, [0, 1], [0.97, 1], { output: "perceptual-scale" }),
  };
}

export function fadeUpAt(
  frame: number,
  range: [number, number],
): { opacity: number; translateY: number } {
  const opts = {
    extrapolateLeft: "clamp" as const,
    extrapolateRight: "clamp" as const,
  };
  return {
    opacity: interpolate(frame, range, [0, 1], opts),
    translateY: interpolate(frame, range, [12, 0], opts),
  };
}

export function caretBlinkOpacity(
  frame: number,
  opts: { fps: number; blinkPerSecond: number; speed: number },
): number {
  const cycles = opts.blinkPerSecond <= 0 ? 1 : opts.blinkPerSecond;
  const halfPeriod = opts.fps / cycles / 2;
  if (halfPeriod <= 0) return 1;
  return Math.floor((frame * opts.speed) / halfPeriod) % 2 === 0 ? 1 : 0;
}

export interface CaretProps {
  color?: string;
  width?: number;
  height?: number;
  radius?: number;
  opacity?: number;
  blink?: boolean;
  blinkPerSecond?: number;
  speed?: number;
  marginLeft?: number;
  style?: CSSProperties;
}

export function Caret({
  color = "currentColor",
  width = 2,
  height = 18,
  radius = 1,
  opacity,
  blink = false,
  blinkPerSecond = 1,
  speed = 1,
  marginLeft = 0,
  style,
}: CaretProps) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();

  const resolvedOpacity =
    opacity !== undefined
      ? opacity
      : blink
        ? caretBlinkOpacity(frame, { fps, blinkPerSecond, speed })
        : 1;

  return (
    <span
      style={{
        display: "inline-block",
        flexShrink: 0,
        width,
        height,
        borderRadius: radius,
        background: color,
        opacity: resolvedOpacity,
        marginLeft,
        ...style,
      }}
    />
  );
}
Interaction
text-animation