Catalog
No preview yet

Source

801 lines
// AI News Block — canonical, in-tree composition.
//
// This is a real TSX module (not an inline string in a script), so authors
// get typecheck + IDE autocomplete. The publish pipeline reads this file's
// source text, ships it to the /bundle-block Lambda, and upserts a
// RemotionBlock row keyed by sourceHash.
//
// Accompanying schema at ./ai_news.schema.ts declares props with
// `$port:*` (workflow input ports) and `$style:*` (direct-manipulation UI
// controls) markers. The editor's Properties panel renders style controls
// automatically per marker type (color picker, slider, dropdown).
//
// Prop contract:
//   - avatarVideo / newsImage / audioFile / captions — wired via workflow ports
//   - headline — free text
//   - captionActiveColor / captionInactiveColor / captionFontSize /
//     captionTopPercent — style knobs rendered as UI controls
//   - avatarScale / avatarOffsetY — style knobs for framing
//
// Captions prop accepts three shapes, auto-normalized:
//   1. Native: [{ startFrame, endFrame, text, words? }]
//   2. STT full object: { text, chunks: [{ text, timestamp: [start, end] }] }
//   3. STT word_timestamps: [{ word, start, end }]
//
// Dynamic editing: when individual caption segments carry extra fields
// (layout / transition / kenBurns / visual) — e.g. from a text node
// holding hand-crafted JSON, an LLM node emitting the same shape, or
// an external API call pushing segments in — the block adapts per
// segment:
//   - layout: 'split' (default, news on top / avatar on bottom),
//             'speaker' (avatar full-frame),
//             'broll'   (B-roll full-frame, avatar hidden)
//   - transition: 'cut' | 'flash' | 'zoom' — applied at segment start
//   - kenBurns:   'zoomIn' | 'zoomOut' | 'panLeft' | 'panRight' |
//                 'panUp' | 'none' — applied to the image
//   - visual:     URL overriding newsImage just for this segment
// Segments without these fields fall back to the fixed split layout —
// whisper output works unchanged.

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';
import {
  AbsoluteFill,
  Audio,
  Easing,
  Img,
  interpolate,
  OffthreadVideo,
  spring,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';

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 Layout = 'split' | 'speaker' | 'broll';
type Transition = 'cut' | 'flash' | 'zoom';
type KenBurns =
  | 'zoomIn'
  | 'zoomOut'
  | 'panLeft'
  | 'panRight'
  | 'panUp'
  | 'none';

type CaptionSegment = {
  startFrame: number;
  endFrame: number;
  text: string;
  words?: Array<{ word: string; startFrame: number; endFrame: number }>;
  // Optional per-segment edit directives. Populated by whatever
  // upstream node emits segments in this shape (text node with JSON,
  // LLM node, API-pushed value). Absent on raw whisper output — which
  // is why these are all optional.
  layout?: Layout;
  transition?: Transition;
  kenBurns?: KenBurns;
  visual?: string;
};

const LAYOUTS: Layout[] = ['split', 'speaker', 'broll'];
const TRANSITIONS: Transition[] = ['cut', 'flash', 'zoom'];
const KEN_BURNS: KenBurns[] = [
  'zoomIn',
  'zoomOut',
  'panLeft',
  'panRight',
  'panUp',
  'none',
];

function coerceLayout(v: unknown): Layout | undefined {
  return typeof v === 'string' && (LAYOUTS as string[]).includes(v)
    ? (v as Layout)
    : undefined;
}
function coerceTransition(v: unknown): Transition | undefined {
  return typeof v === 'string' && (TRANSITIONS as string[]).includes(v)
    ? (v as Transition)
    : undefined;
}
function coerceKenBurns(v: unknown): KenBurns | undefined {
  return typeof v === 'string' && (KEN_BURNS as string[]).includes(v)
    ? (v as KenBurns)
    : undefined;
}

function extractEditFields(
  obj: Record<string, unknown>
): Pick<CaptionSegment, 'layout' | 'transition' | 'kenBurns' | 'visual'> {
  return {
    layout: coerceLayout(obj.layout),
    transition: coerceTransition(obj.transition),
    kenBurns: coerceKenBurns(obj.kenBurns),
    visual: typeof obj.visual === 'string' ? obj.visual : undefined,
  };
}

// Edit directives = time-ranged layout/transition/kenBurns/visual
// decisions, decoupled from the caption source. Accepts frame-based
// ranges ({ startFrame, endFrame }) or seconds ({ start, end }). The
// block merges these over whatever captions provide at render time.
type EditDirective = {
  startFrame: number;
  endFrame: number;
  layout?: Layout;
  transition?: Transition;
  kenBurns?: KenBurns;
  visual?: string;
};

function normalizeEdits(raw: unknown, fps: number): EditDirective[] {
  if (!raw) return [];
  if (typeof raw === 'string') {
    try {
      raw = JSON.parse(raw);
    } catch {
      return [];
    }
  }
  if (!Array.isArray(raw)) return [];
  const out: EditDirective[] = [];
  for (const item of raw as unknown[]) {
    if (!item || typeof item !== 'object') continue;
    const o = item as Record<string, unknown>;
    const startFrame =
      typeof o.startFrame === 'number'
        ? o.startFrame
        : typeof o.start === 'number'
        ? Math.round(o.start * fps)
        : null;
    const endFrame =
      typeof o.endFrame === 'number'
        ? o.endFrame
        : typeof o.end === 'number'
        ? Math.round(o.end * fps)
        : null;
    if (startFrame === null || endFrame === null) continue;
    out.push({
      startFrame,
      endFrame,
      ...extractEditFields(o),
    });
  }
  return out;
}

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') {
    // Native shape — segments already in frame units. Preserve edit
    // fields if present so the renderer can pick them up.
    return arr.map((item) => {
      const seg = item as CaptionSegment & Record<string, unknown>;
      const edits = extractEditFields(seg as Record<string, unknown>);
      return { ...seg, ...edits };
    });
  }

  // Flat word_timestamps: [{word, start, end}]
  if (
    first &&
    typeof first.word === 'string' &&
    (typeof first.start === 'number' || typeof first.end === 'number')
  ) {
    const wordsPerCaption = 6;
    const words = arr as Array<{ word: string; start?: number; end?: number }>;
    const out: CaptionSegment[] = [];
    for (let i = 0; i < words.length; i += wordsPerCaption) {
      const group = words.slice(i, i + wordsPerCaption);
      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;
  }

  // STT full-object shape
  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 ActiveCaption({
  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_SIZE = 3;
  const chunks: Array<typeof allWords> = [];
  for (let i = 0; i < allWords.length; i += CHUNK_SIZE) {
    chunks.push(allWords.slice(i, i + CHUNK_SIZE));
  }
  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 chunkIdx = Math.floor(activeIdx / CHUNK_SIZE);
  const activeChunk = chunks[chunkIdx];
  if (!activeChunk) return null;

  const chunkStart = activeChunk[0].startFrame;
  const chunkEnd = activeChunk[activeChunk.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',
        alignItems: 'center',
        zIndex: 10,
        pointerEvents: 'none',
      }}
    >
      <div
        style={{
          transform: `scale(${scale})`,
          opacity,
          padding: '10px 28px',
          display: 'flex',
          flexWrap: 'nowrap',
          justifyContent: 'center',
          gap: '0 12px',
        }}
      >
        {activeChunk.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,
                lineHeight: 1.2,
                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)',
                display: 'inline-block',
                whiteSpace: 'nowrap',
              }}
            >
              {wt.word}
            </span>
          );
        })}
      </div>
    </div>
  );
}

type NewsImageProps = {
  newsImage: string;
  heightPercent: number;
  showHeadline: boolean;
  headline: string;
  headlineColor: string;
  headlineFontSize: number;
  headlineFontFamily: string;
  kenBurns: KenBurns;
  kenBurnsProgress: number;
};

function kenBurnsTransform(type: KenBurns, p: number): string {
  // Soft cinematic pans — bounded so the image never reveals panel
  // edges (scale stays ≥ 1.0; translate capped at 3%).
  switch (type) {
    case 'zoomOut':
      return `scale(${interpolate(p, [0, 1], [1.08, 1.0])})`;
    case 'panLeft':
      return `scale(1.06) translateX(${interpolate(p, [0, 1], [0, -3])}%)`;
    case 'panRight':
      return `scale(1.06) translateX(${interpolate(p, [0, 1], [0, 3])}%)`;
    case 'panUp':
      return `scale(1.06) translateY(${interpolate(p, [0, 1], [0, -3])}%)`;
    case 'none':
      return 'scale(1)';
    case 'zoomIn':
    default:
      return `scale(${interpolate(p, [0, 1], [1.0, 1.08])})`;
  }
}

function NewsImagePanel({
  newsImage,
  heightPercent,
  showHeadline,
  headline,
  headlineColor,
  headlineFontSize,
  headlineFontFamily,
  kenBurns,
  kenBurnsProgress,
}: NewsImageProps) {
  const hasImage =
    typeof newsImage === 'string' && /^https?:\/\//i.test(newsImage);
  const isVideo = hasImage && /\.(mp4|webm|mov)(\?|$)/i.test(newsImage);
  const transform = isVideo
    ? 'scale(1)'
    : kenBurnsTransform(kenBurns, kenBurnsProgress);
  return (
    <div
      style={{
        position: 'absolute',
        top: 0,
        left: 0,
        right: 0,
        height: `${heightPercent}%`,
        overflow: 'hidden',
        backgroundColor: '#0a0b0f',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      {hasImage ? (
        isVideo ? (
          <OffthreadVideo
            src={newsImage}
            style={{
              width: '100%',
              height: '100%',
              objectFit: 'cover',
            }}
            muted
          />
        ) : (
          <Img
            src={newsImage}
            style={{
              width: '100%',
              height: '100%',
              objectFit: 'cover',
              transform,
              transformOrigin: 'center center',
            }}
          />
        )
      ) : (
        <div
          style={{
            color: 'rgba(255,255,255,0.35)',
            fontFamily: headlineFontFamily,
            fontSize: 28,
            letterSpacing: '0.1em',
            textTransform: 'uppercase',
          }}
        >
          News image
        </div>
      )}
      {showHeadline && headline ? (
        <div
          style={{
            position: 'absolute',
            top: 32,
            left: 0,
            right: 0,
            textAlign: 'center',
            color: headlineColor,
            fontFamily: headlineFontFamily,
            fontWeight: 800,
            fontSize: headlineFontSize,
            textShadow: '0 2px 12px rgba(0,0,0,0.8)',
            padding: '0 48px',
          }}
        >
          {headline}
        </div>
      ) : null}
    </div>
  );
}

type AvatarProps = {
  avatarVideo: string;
  avatarScale: number;
  avatarOffsetY: string;
  topPercent: number;
  heightPercent: number;
};

function AvatarPanel({
  avatarVideo,
  avatarScale,
  avatarOffsetY,
  topPercent,
  heightPercent,
}: AvatarProps) {
  const hasVideo =
    typeof avatarVideo === 'string' && /^https?:\/\//i.test(avatarVideo);
  return (
    <div
      style={{
        position: 'absolute',
        top: `${topPercent}%`,
        left: 0,
        right: 0,
        height: `${heightPercent}%`,
        overflow: 'hidden',
        backgroundColor: '#0a0b0f',
        display: 'flex',
        alignItems: 'center',
        justifyContent: 'center',
      }}
    >
      {hasVideo ? (
        <OffthreadVideo
          src={avatarVideo}
          style={{
            width: '100%',
            height: '100%',
            objectFit: 'cover',
            transform: `scale(${avatarScale}) translateY(${avatarOffsetY})`,
            transformOrigin: 'center center',
          }}
        />
      ) : (
        <div
          style={{
            color: 'rgba(255,255,255,0.35)',
            fontFamily: SPACE_GROTESK,
            fontSize: 28,
          }}
        >
          Avatar video
        </div>
      )}
    </div>
  );
}

export type AINewsProps = {
  avatarVideo: string;
  newsImage: string;
  audioFile: string;
  captions: unknown;
  edits: unknown;
  headline: string;
  headlineColor: string;
  headlineFontSize: number;
  captionActiveColor: string;
  captionInactiveColor: string;
  captionFont: 'SpaceGrotesk' | 'Inter' | 'Roboto';
  captionFontSize: number;
  captionTopPercent: number;
  avatarScale: number;
  avatarOffsetY: string;
  backgroundColor: string;
};

export default function AINews({
  avatarVideo,
  newsImage,
  audioFile,
  captions,
  edits,
  headline = 'Breaking News',
  headlineColor = '#FFFFFF',
  headlineFontSize = 56,
  captionActiveColor = '#00F0FF',
  captionInactiveColor = '#FFFFFF',
  captionFont = 'SpaceGrotesk',
  captionFontSize = 46,
  captionTopPercent = 43,
  avatarScale = 1.0,
  avatarOffsetY = '0%',
  backgroundColor = '#05060a',
}: Partial<AINewsProps>) {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const normalized = normalizeCaptions(captions, fps);
  const normalizedEdits = normalizeEdits(edits, fps);
  const captionFontFamily = FONT_MAP[captionFont] ?? SPACE_GROTESK;
  const hasAudio =
    typeof audioFile === 'string' && /^https?:\/\//i.test(audioFile);

  // Two independent streams drive the render:
  //   1. captions — whisper word_timestamps for karaoke (timing-accurate)
  //   2. edits    — optional per-time-range directives (layout,
  //                 transition, kenBurns, visual)
  // Edit directives from the `edits` port take precedence. If no edits
  // are wired, fall back to per-segment directives carried on the
  // caption itself (legacy inline shape). Finally, defaults.
  const activeCaption = normalized.find(
    (c) => frame >= c.startFrame && frame < c.endFrame
  );
  const activeEdit = normalizedEdits.find(
    (e) => frame >= e.startFrame && frame < e.endFrame
  );
  const directive = activeEdit ?? activeCaption;
  const layout: Layout = activeEdit?.layout ?? activeCaption?.layout ?? 'split';
  const transition: Transition =
    activeEdit?.transition ?? activeCaption?.transition ?? 'cut';
  const kenBurns: KenBurns =
    activeEdit?.kenBurns ?? activeCaption?.kenBurns ?? 'zoomIn';
  const segmentVisual =
    typeof directive?.visual === 'string' && directive.visual.length > 0
      ? directive.visual
      : null;
  const resolvedNewsImage = segmentVisual ?? newsImage ?? '';

  const directiveStart = directive?.startFrame ?? 0;
  const directiveEnd = directive?.endFrame ?? 1;
  const localFrame = directive ? frame - directiveStart : 0;
  const segmentDuration = directive
    ? Math.max(1, directiveEnd - directiveStart)
    : 1;
  const kenBurnsProgress = Math.min(1, localFrame / segmentDuration);

  // Layout-driven panel sizing, with a ~10-frame ease between layouts
  // so cuts don't SLAM from split → full-screen broll. Finds the most
  // recent directive whose endFrame matches the active one's start,
  // interpolates panel heights from prev → active over the transition
  // window. Without this the layout change feels like a glitch, not a
  // directorial beat.
  const panelSizes = (l: Layout) => ({
    newsH: l === 'speaker' ? 0 : l === 'broll' ? 100 : 50,
    avH: l === 'broll' ? 0 : l === 'speaker' ? 100 : 50,
    avTop: l === 'speaker' ? 0 : l === 'broll' ? 100 : 50,
  });
  const targetSizes = panelSizes(layout);
  const sortedEdits = [...normalizedEdits].sort(
    (a, b) => a.startFrame - b.startFrame
  );
  const prevEdit = activeEdit
    ? [...sortedEdits]
        .reverse()
        .find((e) => e.endFrame <= activeEdit.startFrame)
    : undefined;
  const prevLayout: Layout = prevEdit?.layout ?? layout;
  const prevSizes = panelSizes(prevLayout);
  const TRANSITION_FRAMES = 10;
  const blend = activeEdit
    ? interpolate(localFrame, [0, TRANSITION_FRAMES], [0, 1], {
        extrapolateRight: 'clamp',
        easing: Easing.out(Easing.cubic),
      })
    : 1;
  const newsHeightPercent =
    prevSizes.newsH + (targetSizes.newsH - prevSizes.newsH) * blend;
  const avatarHeightPercent =
    prevSizes.avH + (targetSizes.avH - prevSizes.avH) * blend;
  const avatarTopPercent =
    prevSizes.avTop + (targetSizes.avTop - prevSizes.avTop) * blend;
  const showDivider = layout === 'split';
  const showHeadline = layout === 'split';

  // Caption vertical position adapts to layout: in "speaker" the
  // avatar fills the frame, so captions sit near the bottom instead
  // of over the divider line.
  const effectiveCaptionTopPercent =
    layout === 'speaker' ? 75 : captionTopPercent;

  // Zoom-punch transition: small scale-down at segment start that
  // settles in ~5 frames. Soft emphasis beat without feeling gimmicky.
  const zoomPunch =
    transition === 'zoom' && directive
      ? interpolate(localFrame, [0, 2, 5], [1.06, 1.03, 1.0], {
          extrapolateRight: 'clamp',
        })
      : 1.0;

  // Flash transition: soft white overlay decays over 4 frames at
  // segment start. Kept subtle on purpose — peak 0.45 reads as a
  // "punch-in" beat without the jarring full-white blink that makes
  // news reels feel like 2007 vlogs.
  const flashOpacity =
    transition === 'flash' && directive
      ? interpolate(localFrame, [0, 4], [0.45, 0], {
          extrapolateRight: 'clamp',
        })
      : 0;

  return (
    <AbsoluteFill style={{ backgroundColor }}>
      <div
        style={{
          width: '100%',
          height: '100%',
          transform: `scale(${zoomPunch})`,
          transformOrigin: 'center center',
        }}
      >
        <NewsImagePanel
          newsImage={resolvedNewsImage}
          heightPercent={newsHeightPercent}
          showHeadline={showHeadline}
          headline={headline}
          headlineColor={headlineColor}
          headlineFontSize={headlineFontSize}
          headlineFontFamily={captionFontFamily}
          kenBurns={kenBurns}
          kenBurnsProgress={kenBurnsProgress}
        />
        {showDivider ? (
          <div
            style={{
              position: 'absolute',
              top: '50%',
              left: 0,
              right: 0,
              height: 3,
              backgroundColor: '#1e2128',
              zIndex: 5,
            }}
          />
        ) : null}
        <AvatarPanel
          avatarVideo={avatarVideo ?? ''}
          avatarScale={avatarScale}
          avatarOffsetY={avatarOffsetY}
          topPercent={avatarTopPercent}
          heightPercent={avatarHeightPercent}
        />
      </div>
      {flashOpacity > 0 ? (
        <div
          style={{
            position: 'absolute',
            inset: 0,
            backgroundColor: `rgba(255,255,255,${flashOpacity})`,
            pointerEvents: 'none',
            zIndex: 10,
          }}
        />
      ) : null}
      <ActiveCaption
        captions={normalized}
        frame={frame}
        fps={fps}
        activeColor={captionActiveColor}
        inactiveColor={captionInactiveColor}
        fontFamily={captionFontFamily}
        fontSize={captionFontSize}
        topPercent={effectiveCaptionTopPercent}
      />
      {hasAudio ? <Audio src={audioFile as string} /> : null}
    </AbsoluteFill>
  );
}

Schema (Zod)

// Zod schema paired with ./ai_news.tsx. Markers in `.describe()`:
//   - `$port:<type>` — workflow input port (video/image/audio/text/json)
//   - `$style:<kind>` — direct-manipulation UI control in Properties panel
//        color, size, position, scale, offset, font, select
//
// The publish pipeline reads this file's source text, passes it to the
// Lambda bundler, which generates the JSON Schema. The editor then
// re-extracts both `$port:*` and `$style:*` markers client-side to
// render workflow ports + style controls.

import { z } from 'zod';

export const Schema = z.object({
  // ─── Workflow ports (wired via drag-to-connect) ─────────────────────────
  avatarVideo: z.string().describe('$port:video Avatar video'),
  newsImage: z.string().describe('$port:image News image'),
  audioFile: z.string().describe('$port:audio Voiceover audio'),
  captions: z
    .any()
    .describe(
      '$port:json Captions — whisper word_timestamps (recommended, accurate karaoke timing) or native segments. Drives the highlighted-word readout across the bottom.'
    ),
  edits: z
    .any()
    .optional()
    .describe(
      '$port:json Edit directives (optional) — array of { startFrame, endFrame, layout, transition, kenBurns, visual } per time range. Drives layout switches (split/speaker/broll), transitions, ken-burns, and per-segment B-roll URLs INDEPENDENT of the captions source. Leave unwired for static split layout.'
    ),

  // ─── Text content ───────────────────────────────────────────────────────
  headline: z.string().default('Breaking News').describe('Headline text'),

  // ─── Style knobs (rendered as UI controls) ──────────────────────────────
  headlineColor: z
    .string()
    .default('#FFFFFF')
    .describe('$style:color Headline color'),
  headlineFontSize: z
    .number()
    .default(56)
    .describe('$style:size Headline font size'),

  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(46)
    .describe('$style:size Caption font size'),
  captionTopPercent: z
    .number()
    .default(43)
    .describe('$style:position Caption vertical position (%)'),

  avatarScale: z
    .number()
    .default(1.0)
    .describe('$style:scale Avatar zoom (1.0 = natural fit)'),
  avatarOffsetY: z
    .string()
    .default('0%')
    .describe('$style:offset Avatar vertical offset'),

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

AI News

ai_news

TikTok-style AI news reel — news image on top, avatar video on bottom, karaoke-highlighted captions, optional voiceover.

Workflow Inputs (5)

  • Avatar video
    avatarVideo
    VIDEORequired
  • News image
    newsImage
    IMAGERequired
  • Voiceover audio
    audioFile
    AUDIORequired
  • Captions — whisper word_timestamps (recommended, accurate karaoke timing) or native segments. Drives the highlighted-word readout across the bottom.
    captions
    JSON
  • Edit directives (optional) — array of { startFrame, endFrame, layout, transition, kenBurns, visual } per time range. Drives layout switches (split/speaker/broll), transitions, ken-burns, and per-segment B-roll URLs INDEPENDENT of the captions source. Leave unwired for static split layout.
    edits
    JSON

Config Fields (11)

  • headline
  • avatarScale
  • captionFont
  • avatarOffsetY
  • headlineColor
  • backgroundColor
  • captionFontSize
  • headlineFontSize
  • captionTopPercent
  • captionActiveColor
  • captionInactiveColor

Meta

Updated
7/10/2026