← 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_newsTikTok-style AI news reel — news image on top, avatar video on bottom, karaoke-highlighted captions, optional voiceover.
Workflow Inputs (5)
- Avatar video
avatarVideoVIDEORequired - News image
newsImageIMAGERequired - Voiceover audio
audioFileAUDIORequired - Captions — whisper word_timestamps (recommended, accurate karaoke timing) or native segments. Drives the highlighted-word readout across the bottom.
captionsJSON - 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.
editsJSON
Config Fields (11)
headlineavatarScalecaptionFontavatarOffsetYheadlineColorbackgroundColorcaptionFontSizeheadlineFontSizecaptionTopPercentcaptionActiveColorcaptionInactiveColor
Meta
- Updated
- 7/10/2026