Catalog
No preview yet

Source

331 lines
// Captions Only Block — full-frame video with TikTok-style karaoke
// captions overlay. Strips out the news-image panel from ai_news. Good
// for "talking head with captions" where there's no B-roll needed.
//
// Same caption-normalization logic as ai_news: accepts native shape,
// STT full-object, or word_timestamps flat array.

import {
  AbsoluteFill,
  Audio,
  OffthreadVideo,
  interpolate,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';
import { loadFont as loadInter } from '@remotion/google-fonts/Inter';
import { loadFont as loadRoboto } from '@remotion/google-fonts/Roboto';
import { loadFont } from '@remotion/google-fonts/SpaceGrotesk';

const { fontFamily: SPACE_GROTESK } = loadFont();
const { fontFamily: INTER } = loadInter();
const { fontFamily: ROBOTO } = loadRoboto();

const FONT_MAP: Record<string, string> = {
  SpaceGrotesk: SPACE_GROTESK,
  Inter: INTER,
  Roboto: ROBOTO,
};

type CaptionSegment = {
  startFrame: number;
  endFrame: number;
  text: string;
  words?: Array<{ word: string; startFrame: number; endFrame: number }>;
};

function normalizeCaptions(raw: unknown, fps: number): CaptionSegment[] {
  if (!raw) return [];
  if (typeof raw === 'string') {
    try {
      raw = JSON.parse(raw);
    } catch {
      return [];
    }
  }
  if (!Array.isArray(raw)) {
    const r = raw as { chunks?: unknown };
    if (r && typeof r === 'object' && Array.isArray(r.chunks)) raw = [raw];
    else return [];
  }
  const arr = raw as unknown[];
  if (arr.length === 0) return [];

  const first = arr[0] as Record<string, unknown>;
  if (first && typeof first.startFrame === 'number')
    return arr as CaptionSegment[];

  if (
    first &&
    typeof first.word === 'string' &&
    (typeof first.start === 'number' || typeof first.end === 'number')
  ) {
    const words = arr as Array<{ word: string; start?: number; end?: number }>;
    const out: CaptionSegment[] = [];
    const wpc = 6;
    for (let i = 0; i < words.length; i += wpc) {
      const group = words.slice(i, i + wpc);
      const startSec = group[0]?.start ?? 0;
      const endSec = group[group.length - 1]?.end ?? startSec + 1;
      out.push({
        text: group.map((w) => w.word).join(' '),
        startFrame: Math.round(startSec * fps),
        endFrame: Math.round(endSec * fps),
        words: group.map((w) => ({
          word: String(w.word ?? ''),
          startFrame: Math.round((w.start ?? 0) * fps),
          endFrame: Math.round((w.end ?? 0) * fps),
        })),
      });
    }
    return out;
  }

  const items = arr as Array<{
    text?: string;
    chunks?: Array<{ text: string; timestamp: [number, number] }>;
  }>;
  const out: CaptionSegment[] = [];
  for (const item of items) {
    const full = String(item?.text ?? '').trim();
    const chunks = Array.isArray(item?.chunks) ? item.chunks : [];
    if (chunks.length === 0) continue;
    const sentences = full.length
      ? full.split(/(?<=[.!?])\s+/).filter(Boolean)
      : [full || chunks.map((c) => c.text).join(' ')];
    let chunkIdx = 0;
    for (const sentence of sentences) {
      const wordCount = sentence
        .replace(/[^A-Za-z0-9\s]/g, ' ')
        .split(/\s+/)
        .filter(Boolean).length;
      const seg: typeof chunks = [];
      while (chunkIdx < chunks.length && seg.length < Math.max(1, wordCount)) {
        seg.push(chunks[chunkIdx]);
        chunkIdx += 1;
      }
      if (seg.length === 0) continue;
      const startSec = seg[0].timestamp?.[0] ?? 0;
      const endSec = seg[seg.length - 1].timestamp?.[1] ?? startSec + 1;
      out.push({
        text: sentence,
        startFrame: Math.round(startSec * fps),
        endFrame: Math.round(endSec * fps),
        words: seg.map((c) => ({
          word: String(c.text ?? ''),
          startFrame: Math.round((c.timestamp?.[0] ?? 0) * fps),
          endFrame: Math.round((c.timestamp?.[1] ?? 0) * fps),
        })),
      });
    }
  }
  return out;
}

type CaptionProps = {
  captions: CaptionSegment[];
  frame: number;
  fps: number;
  activeColor: string;
  inactiveColor: string;
  fontFamily: string;
  fontSize: number;
  topPercent: number;
};

function KaraokeCaptions({
  captions,
  frame,
  fps,
  activeColor,
  inactiveColor,
  fontFamily,
  fontSize,
  topPercent,
}: CaptionProps) {
  const active = captions.find(
    (c) => frame >= c.startFrame && frame < c.endFrame
  );
  if (!active) return null;

  let allWords = active.words ?? [];
  if (allWords.length === 0) {
    const raw = (active.text ?? '').split(/\s+/).filter(Boolean);
    const dur = Math.max(
      1,
      (active.endFrame - active.startFrame) / Math.max(1, raw.length)
    );
    allWords = raw.map((word, i) => ({
      word,
      startFrame: active.startFrame + Math.round(i * dur),
      endFrame: active.startFrame + Math.round((i + 1) * dur),
    }));
  }

  const CHUNK = 3;
  const chunks: Array<typeof allWords> = [];
  for (let i = 0; i < allWords.length; i += CHUNK) {
    chunks.push(allWords.slice(i, i + CHUNK));
  }
  let activeIdx = allWords.findIndex(
    (w) => frame >= w.startFrame && frame < w.endFrame
  );
  if (activeIdx < 0) {
    for (let i = allWords.length - 1; i >= 0; i--) {
      if (frame >= allWords[i].startFrame) {
        activeIdx = i;
        break;
      }
    }
  }
  if (activeIdx < 0) return null;
  const chunk = chunks[Math.floor(activeIdx / CHUNK)];
  if (!chunk) return null;

  const chunkStart = chunk[0].startFrame;
  const chunkEnd = chunk[chunk.length - 1].endFrame;
  const localFrame = frame - chunkStart;
  const chunkDuration = Math.max(1, chunkEnd - chunkStart);
  const scale = spring({
    frame: localFrame,
    fps,
    config: { damping: 14, stiffness: 220, mass: 0.4 },
  });
  const opacity = interpolate(
    localFrame,
    [0, 2, chunkDuration - 2, chunkDuration],
    [0, 1, 1, 0],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  return (
    <div
      style={{
        position: 'absolute',
        top: `${topPercent}%`,
        left: 0,
        right: 0,
        display: 'flex',
        justifyContent: 'center',
        zIndex: 10,
        pointerEvents: 'none',
      }}
    >
      <div
        style={{
          transform: `scale(${scale})`,
          opacity,
          display: 'flex',
          gap: '0 12px',
          padding: '10px 28px',
        }}
      >
        {chunk.map((wt, i) => {
          const isCurrent = frame >= wt.startFrame && frame < wt.endFrame;
          return (
            <span
              key={`${chunkStart}-${i}`}
              style={{
                color: isCurrent ? activeColor : inactiveColor,
                fontSize,
                fontWeight: 700,
                fontFamily,
                textTransform: 'uppercase',
                letterSpacing: -0.5,
                WebkitTextStroke: isCurrent ? '0px' : '1.5px rgba(0,0,0,0.6)',
                paintOrder: 'stroke fill',
                textShadow: isCurrent
                  ? `0 0 20px ${activeColor}99, 0 0 40px ${activeColor}55, 0 2px 8px rgba(0,0,0,0.8)`
                  : '0 2px 8px rgba(0,0,0,0.9)',
                whiteSpace: 'nowrap',
              }}
            >
              {wt.word}
            </span>
          );
        })}
      </div>
    </div>
  );
}

export type CaptionsOnlyProps = {
  video: string;
  audioFile: string;
  captions: unknown;
  videoScale: number;
  videoOffsetY: string;
  captionActiveColor: string;
  captionInactiveColor: string;
  captionFont: 'SpaceGrotesk' | 'Inter' | 'Roboto';
  captionFontSize: number;
  captionTopPercent: number;
  backgroundColor: string;
};

export default function CaptionsOnly({
  video,
  audioFile,
  captions,
  videoScale = 1.0,
  videoOffsetY = '0%',
  captionActiveColor = '#00F0FF',
  captionInactiveColor = '#FFFFFF',
  captionFont = 'SpaceGrotesk',
  captionFontSize = 52,
  captionTopPercent = 75,
  backgroundColor = '#05060a',
}: Partial<CaptionsOnlyProps>) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const normalized = normalizeCaptions(captions, fps);
  const fontFamily = FONT_MAP[captionFont] ?? SPACE_GROTESK;
  const hasVideo = typeof video === 'string' && /^https?:\/\//i.test(video);
  const hasAudio =
    typeof audioFile === 'string' && /^https?:\/\//i.test(audioFile);

  return (
    <AbsoluteFill style={{ backgroundColor }}>
      {hasVideo ? (
        <OffthreadVideo
          src={video as string}
          style={{
            width: '100%',
            height: '100%',
            objectFit: 'cover',
            transform: `scale(${videoScale}) translateY(${videoOffsetY})`,
            transformOrigin: 'center center',
          }}
        />
      ) : (
        <div
          style={{
            position: 'absolute',
            inset: 0,
            display: 'flex',
            alignItems: 'center',
            justifyContent: 'center',
            color: 'rgba(255,255,255,0.35)',
            fontFamily: SPACE_GROTESK,
            fontSize: 32,
          }}
        >
          Connect a video to start
        </div>
      )}
      <KaraokeCaptions
        captions={normalized}
        frame={frame}
        fps={fps}
        activeColor={captionActiveColor}
        inactiveColor={captionInactiveColor}
        fontFamily={fontFamily}
        fontSize={captionFontSize}
        topPercent={captionTopPercent}
      />
      {hasAudio ? <Audio src={audioFile as string} /> : null}
    </AbsoluteFill>
  );
}

Schema (Zod)

import { z } from 'zod';

export const Schema = z.object({
  video: z.string().describe('$port:video Main video'),
  audioFile: z.string().describe('$port:audio Voiceover audio'),
  captions: z.any().describe('$port:json Captions'),

  videoScale: z.number().default(1.0).describe('$style:scale Video zoom'),
  videoOffsetY: z
    .string()
    .default('0%')
    .describe('$style:offset Video vertical offset'),

  captionActiveColor: z
    .string()
    .default('#00F0FF')
    .describe('$style:color Active word color'),
  captionInactiveColor: z
    .string()
    .default('#FFFFFF')
    .describe('$style:color Inactive word color'),
  captionFont: z
    .enum(['SpaceGrotesk', 'Inter', 'Roboto'])
    .default('SpaceGrotesk')
    .describe('$style:font Caption font'),
  captionFontSize: z
    .number()
    .default(52)
    .describe('$style:size Caption font size'),
  captionTopPercent: z
    .number()
    .default(75)
    .describe('$style:position Caption vertical position (%)'),

  backgroundColor: z
    .string()
    .default('#05060a')
    .describe('$style:color Background color'),
});

Captions Only

captions_only

Full-frame video with TikTok-style karaoke captions overlay. No news image panel — good for talking-head clips.

Workflow Inputs (3)

  • Main video
    video
    VIDEORequired
  • Voiceover audio
    audioFile
    AUDIORequired
  • Captions
    captions
    JSON

Config Fields (8)

  • videoScale
  • captionFont
  • videoOffsetY
  • backgroundColor
  • captionFontSize
  • captionTopPercent
  • captionActiveColor
  • captionInactiveColor

Meta

Updated
7/10/2026