text-message-animation

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Text Message Animation

短信聊天动画

Turn a chat script into a scroll-stopping conversation video: bubbles appear one at a time, a typing indicator pulses before each reply, sounds pop on send, and the thread auto-scrolls to keep the newest message in frame. This is one of the highest-retention faceless short-form formats — a story told in iMessage/WhatsApp/SMS bubbles that the viewer reads like they're peeking at someone's phone.
将聊天脚本转化为吸睛的对话视频:气泡逐个出现,每条回复前会显示跳动的打字指示器,发送时伴随弹出音效,聊天线程自动滚动以保持最新消息在视野内。这是留存率最高的无脸短视频格式之一——以iMessage/WhatsApp/SMS气泡讲述故事,让观众仿佛在偷看他人的手机。

When to use

使用场景

  • "Fake text" / chat-story videos for TikTok, Reels, and Shorts (9:16 vertical).
  • Animating an iMessage, WhatsApp, or generic SMS conversation from a script.
  • Reply/comment-reaction videos where a thread reveals a punchline message-by-message.
  • Batch-producing many chat videos from one template + a messages array or CSV.
  • 用于TikTok、Reels和Shorts的“假短信”/聊天故事视频(9:16竖屏)。
  • 根据脚本制作iMessage、WhatsApp或通用SMS对话动画。
  • 回复/评论反应视频,通过逐条消息揭露笑点。
  • 从一个模板+消息数组或CSV批量制作多个聊天视频。

Two non-negotiable rules

两条不可动摇的规则

  1. One message reveals at a time, on a rhythm. The retention comes from the drip: a beat of typing, then a bubble pops in, then a pause to read. Never dump the whole thread at frame 0 — pace it like a real conversation (a typing indicator before received replies, a short read-gap after each bubble).
  2. The chrome must read as the real app, instantly. Sent = right, blue (iMessage) or green (SMS) / WhatsApp-green; received = left, gray. Tail on the last bubble of a run, rounded ~18–22px, correct status bar and header. If a viewer can't tell which app it is in the first half-second, the illusion breaks.
  1. 按节奏逐个显示消息。高留存率源于循序渐进的呈现:一段打字等待,然后气泡弹出,接着留出发阅读的停顿。绝对不要在第0帧就展示整个聊天线程——要像真实对话一样把控节奏(收到回复前显示打字指示器,每个气泡后留短时间阅读间隙)。
  2. 界面必须瞬间看起来像真实应用。发送的消息在右侧,为iMessage蓝色或SMS绿色/WhatsApp绿色;收到的消息在左侧,为灰色。连续同一发送者的最后一个气泡带尾巴,圆角约18–22px,状态栏和头部样式正确。如果观众在半秒内无法识别是哪个应用,真实感就会破碎。

The data model — script first, render second

数据模型——先写脚本,再渲染

Everything is one array of messages. Author the story as data; the renderer just plays it. This is what makes the format batchable: one template × N scripts → N videos.
ts
type Message = {
  from: "me" | "them";        // me = sent (right/blue), them = received (left/gray)
  text: string;
  typingMs?: number;          // show typing indicator this long before the bubble (received only)
  delayMs?: number;           // read-gap AFTER the previous bubble, before this one
  status?: "delivered" | "read";  // receipt under the last sent bubble
  reaction?: "❤️" | "👍" | "😂" | "‼️" | "❓"; // tapback, lands ~400ms after the bubble
};

const thread: Message[] = [
  { from: "them", text: "wait you're WHERE right now", typingMs: 900 },
  { from: "me",   text: "outside your house 🙂", delayMs: 600, status: "read" },
  { from: "them", text: "...", typingMs: 1400, reaction: "❓" },
];
Derive every frame timestamp from this array — see "Timing" below. Keep
text
/
from
as the only required fields so a CSV (
from,text,typingMs,delayMs,status
) maps straight in.
所有内容都基于一个消息数组。将故事编写为数据格式,渲染器只需按数据播放。这正是该格式支持批量制作的原因:一个模板 × N个脚本 → N个视频。
ts
type Message = {
  from: "me" | "them";        // me = 发送的消息(右侧/蓝色), them = 收到的消息(左侧/灰色)
  text: string;
  typingMs?: number;          // 气泡出现前显示打字指示器的时长(仅收到的消息)
  delayMs?: number;           // 上一个气泡之后、当前气泡之前的阅读间隙时长
  status?: "delivered" | "read";  // 最后一条发送消息下方的回执状态
  reaction?: "❤️" | "👍" | "😂" | "‼️" | "❓"; // 轻触反应,约在气泡出现400ms后显示
};

const thread: Message[] = [
  { from: "them", text: "wait you're WHERE right now", typingMs: 900 },
  { from: "me",   text: "outside your house 🙂", delayMs: 600, status: "read" },
  { from: "them", text: "...", typingMs: 1400, reaction: "❓" },
];
从这个数组推导每一帧的时间戳——详见下方“时序”部分。仅保留
text
/
from
为必填字段,这样CSV(
from,text,typingMs,delayMs,status
)可以直接映射为数组。

Bubble layout & alignment

气泡布局与对齐

A bubble is a max-width pill aligned to its side. Sent hugs the right rail; received hugs the left. Stack vertically in send order; the last bubble of a consecutive same-sender run gets the tail.
tsx
const BUBBLE: React.CSSProperties = {
  maxWidth: "74%",                // never full-width — leaves the "this is a phone" gutter
  padding: "14px 18px",
  borderRadius: 22,
  fontSize: 34,                   // ~iMessage proportions at 1080px wide
  lineHeight: 1.25,
  wordBreak: "break-word",
};
const SENT: React.CSSProperties = {
  ...BUBBLE, alignSelf: "flex-end", background: "#0A84FF", color: "#fff",      // iMessage blue
};
const RECEIVED: React.CSSProperties = {
  ...BUBBLE, alignSelf: "flex-start", background: "#3B3B3D", color: "#fff",    // dark-mode gray
};
Palette cheat-sheet: iMessage sent
#0A84FF
(light bg
#1C1C1E
), received
#E9E9EB
text
#000
(light) /
#3B3B3D
(dark). SMS sent green
#37C24A
. WhatsApp sent
#005C4B
, received
#202C33
on a
#0B141A
chat wallpaper, with check-marks (gray = sent, blue
#53BDEB
= read). Match ONE app fully; don't mix iMessage chrome with WhatsApp ticks.
气泡是一个最大宽度的胶囊状元素,对齐到对应侧边。发送的消息紧贴右侧边缘;收到的消息紧贴左侧边缘。按发送顺序垂直堆叠;连续同一发送者的最后一个气泡带尾巴。
tsx
const BUBBLE: React.CSSProperties = {
  maxWidth: "74%",                // 永远不要占满宽度——保留“这是手机界面”的边距
  padding: "14px 18px",
  borderRadius: 22,
  fontSize: 34,                   // 在1080px宽度下接近iMessage的比例
  lineHeight: 1.25,
  wordBreak: "break-word",
};
const SENT: React.CSSProperties = {
  ...BUBBLE, alignSelf: "flex-end", background: "#0A84FF", color: "#fff",      // iMessage蓝色
};
const RECEIVED: React.CSSProperties = {
  ...BUBBLE, alignSelf: "flex-start", background: "#3B3B3D", color: "#fff",    // 深色模式灰色
};
配色参考:iMessage发送消息
#0A84FF
(浅色背景
#1C1C1E
),收到消息
#E9E9EB
文字
#000
(浅色模式)/
#3B3B3D
(深色模式)。SMS发送消息为绿色
#37C24A
。WhatsApp发送消息
#005C4B
,收到消息
#202C33
,聊天背景为
#0B141A
,带有对勾标记(灰色=已发送,蓝色
#53BDEB
=已读)。完全匹配一个应用的样式;不要混合iMessage界面和WhatsApp对勾。

Staggered appear + typing indicator

交错出现效果 + 打字指示器

Each bubble springs up from slightly below with a small scale pop, anchored to its own entrance frame. Received messages are preceded by a three-dot typing bubble that pulses, then is replaced by the real bubble.
tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate, Sequence } from "remotion";

const Bubble: React.FC<{ enterFrame: number; sent: boolean; children: React.ReactNode }> =
({ enterFrame, sent, children }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const p = spring({ frame: frame - enterFrame, fps, config: { damping: 14, mass: 0.7 } });
  return (
    <div style={{
      alignSelf: sent ? "flex-end" : "flex-start",
      transform: `translateY(${interpolate(p, [0, 1], [24, 0])}px) scale(${interpolate(p, [0, 1], [0.85, 1])})`,
      opacity: interpolate(p, [0, 1], [0, 1]),
      transformOrigin: sent ? "bottom right" : "bottom left",
    }}>{children}</div>
  );
};

// three-dot typing indicator — dots breathe on a sine, frame-driven (no CSS animation timers)
const Typing: React.FC = () => {
  const frame = useCurrentFrame();
  const dot = (i: number) => 0.4 + 0.6 * (0.5 + 0.5 * Math.sin((frame / 8) - i * 0.9));
  return (
    <div style={{ ...RECEIVED, display: "flex", gap: 8, padding: "18px 20px" }}>
      {[0, 1, 2].map(i => (
        <span key={i} style={{ width: 12, height: 12, borderRadius: 6, background: "#aaa", opacity: dot(i) }} />
      ))}
    </div>
  );
};
Place the typing indicator in a
<Sequence from={typingStart} durationInFrames={typingLen}>
immediately before the bubble's
<Sequence>
so it shows, then swaps to the real bubble. Only received messages type; sent messages just pop (you don't watch yourself type).
每个气泡从下方轻微位置弹出并伴随小幅度缩放效果,锚定到自身的入场帧。收到的消息前会显示一个三点打字气泡,气泡会跳动,然后被真实消息气泡替换。
tsx
import { useCurrentFrame, useVideoConfig, spring, interpolate, Sequence } from "remotion";

const Bubble: React.FC<{ enterFrame: number; sent: boolean; children: React.ReactNode }> =
({ enterFrame, sent, children }) => {
  const frame = useCurrentFrame();
  const { fps } = useVideoConfig();
  const p = spring({ frame: frame - enterFrame, fps, config: { damping: 14, mass: 0.7 } });
  return (
    <div style={{
      alignSelf: sent ? "flex-end" : "flex-start",
      transform: `translateY(${interpolate(p, [0, 1], [24, 0])}px) scale(${interpolate(p, [0, 1], [0.85, 1])})`,
      opacity: interpolate(p, [0, 1], [0, 1]),
      transformOrigin: sent ? "bottom right" : "bottom left",
    }}>{children}</div>
  );
};

// 三点打字指示器——圆点基于正弦曲线呼吸效果,由帧驱动(无CSS动画计时器)
const Typing: React.FC = () => {
  const frame = useCurrentFrame();
  const dot = (i: number) => 0.4 + 0.6 * (0.5 + 0.5 * Math.sin((frame / 8) - i * 0.9));
  return (
    <div style={{ ...RECEIVED, display: "flex", gap: 8, padding: "18px 20px" }}>
      {[0, 1, 2].map(i => (
        <span key={i} style={{ width: 12, height: 12, borderRadius: 6, background: "#aaa", opacity: dot(i) }} />
      ))}
    </div>
  );
};
将打字指示器放在
<Sequence from={typingStart} durationInFrames={typingLen}>
中,紧跟在气泡的
<Sequence>
之前,这样它会先显示,然后切换为真实气泡。只有收到的消息会显示打字状态;发送的消息直接弹出(你不会看到自己打字)。

Timing — turn the array into frames

时序——将数组转换为帧

Walk the messages, accumulating a cursor in ms. This is the single source of truth that the spring entrances, typing windows, sounds, and auto-scroll all read from.
ts
const f = (ms: number) => Math.round((ms / 1000) * fps);
let cursor = 0;
const timeline = thread.map((m) => {
  cursor += m.delayMs ?? 500;                          // read-gap before this message
  const typingLen = m.from === "them" ? (m.typingMs ?? 800) : 0;
  const typingStart = cursor;
  cursor += typingLen;                                 // typing plays during the gap
  const enterMs = cursor;                              // bubble pops the instant typing ends
  cursor += 250;                                       // settle before the next read-gap
  return { ...m, typingStartMs: typingStart, typingLenMs: typingLen, enterMs };
});
const durationMs = cursor + 1000;                      // tail pad so the last bubble is readable
Tune the feel: snappy/punchy threads use
delayMs
300–500 and
typingMs
500–900; suspenseful reveals stretch
typingMs
to 1500–2500 before the payoff line. Compute
durationInFrames
from
durationMs
in
calculateMetadata
so a longer script makes a longer video automatically.
遍历消息数组,累积以毫秒为单位的游标。这是弹簧入场效果、打字窗口、音效和自动滚动的唯一数据源。
ts
const f = (ms: number) => Math.round((ms / 1000) * fps);
let cursor = 0;
const timeline = thread.map((m) => {
  cursor += m.delayMs ?? 500;                          // 当前消息之前的阅读间隙
  const typingLen = m.from === "them" ? (m.typingMs ?? 800) : 0;
  const typingStart = cursor;
  cursor += typingLen;                                 // 打字状态在间隙期间播放
  const enterMs = cursor;                              // 打字结束瞬间气泡弹出
  cursor += 250;                                       // 等待气泡稳定后进入下一个阅读间隙
  return { ...m, typingStartMs: typingStart, typingLenMs: typingLen, enterMs };
});
const durationMs = cursor + 1000;                      // 尾部留白,让最后一个气泡可读
调整节奏:活泼紧凑的线程使用
delayMs
300–500和
typingMs
500–900;悬疑风格的揭秘可以将
typingMs
延长至1500–2500再显示关键台词。在
calculateMetadata
中从
durationMs
计算
durationInFrames
,这样更长的脚本会自动生成更长的视频。

Read receipts, timestamps & reactions

已读回执、时间戳与反应

  • Receipt ("Delivered" / "Read") renders small and right-aligned under the last sent bubble; fade it in ~300ms after the bubble lands. On WhatsApp this is the double-check turning blue instead.
  • Timestamp ("Today 9:41 PM") is a centered gray divider above the first message of a new time block — don't stamp every bubble.
  • Reaction / tapback scales-in over the corner of its target bubble ~400ms after the bubble appears, with a tiny overshoot. Anchor it to that bubble's
    enterMs + 400
    , not a global frame.
  • 回执(“已送达”/“已读”)在最后一条发送消息下方以小字号右对齐显示;约在气泡出现300ms后淡入。在WhatsApp中,这表现为双对勾变为蓝色。
  • 时间戳(“今天 9:41 PM”)是居中的灰色分隔线,显示在新时间段的第一条消息上方——不要给每个气泡都加时间戳。
  • 反应/轻触反馈在目标气泡出现约400ms后,从气泡角落缩放进入,带有轻微的过冲效果。锚定到该气泡的
    enterMs + 400
    ,而非全局帧。

Auto-scroll — keep the newest bubble in frame

自动滚动——保持最新消息在视野内

Once the stack grows past the visible area, translate the whole thread up so the latest bubble sits in the lower third. Animate the scroll offset toward the target with a spring each time a message lands — it should glide, not jump.
tsx
// targetY = total height of messages above the viewport floor, recomputed as bubbles enter
const scroll = spring({ frame: frame - lastEnterFrame, fps, config: { damping: 200 } });
const y = prevY + (targetY - prevY) * scroll;   // ease from old offset to new
return <div style={{ transform: `translateY(${-y}px)` }}>{/* bubbles */}</div>;
Keep the newest bubble clear of the bottom safe area (see below) so the receipt/typing dots underneath stay visible.
当消息堆叠超出可见区域时,将整个聊天线程向上平移,使最新消息位于屏幕下方三分之一处。每次消息出现时,用弹簧动画将滚动偏移量移向目标位置——应该是平滑滑动,而非跳跃。
tsx
// targetY = 视口底部上方的消息总高度,气泡入场时重新计算
const scroll = spring({ frame: frame - lastEnterFrame, fps, config: { damping: 200 } });
const y = prevY + (targetY - prevY) * scroll;   // 从旧偏移量缓动到新偏移量
return <div style={{ transform: `translateY(${-y}px)` }}>{/* bubbles */}</div>;
保持最新消息远离底部安全区域(见下文),以便下方的回执/打字圆点保持可见。

Sound-sync (pop per message)

音效同步(每条消息对应一个弹出音)

A crisp "swoosh/pop" on each send and a softer "ding" on each receive is half of why this format feels real. Place one audio cue per message at that message's
enterMs
, in the same timebase as the visuals.
tsx
import { Audio, staticFile, Sequence } from "remotion";
{timeline.map((m, i) => (
  <Sequence key={i} from={f(m.enterMs)}>
    <Audio src={staticFile(m.from === "me" ? "send.mp3" : "receive.mp3")} />
  </Sequence>
))}
Optionally add a quiet keyboard-tick bed under each typing window. Keep cues short (<300ms) so they don't pile up on fast threads. Never trigger sound off a timer — schedule it on the frame timeline so it survives headless render.
每条发送消息对应清脆的“嗖/弹出”声,每条接收消息对应柔和的“叮”声,这是该格式真实感的一半来源。在每条消息的
enterMs
位置放置一个音频提示,与视觉效果使用同一时间基准。
tsx
import { Audio, staticFile, Sequence } from "remotion";
{timeline.map((m, i) => (
  <Sequence key={i} from={f(m.enterMs)}>
    <Audio src={staticFile(m.from === "me" ? "send.mp3" : "receive.mp3")} />
  </Sequence>
))}
可选在每个打字窗口下方添加轻柔的键盘敲击背景音。保持提示音简短(<300ms),以免在快速线程中堆积。绝对不要用计时器触发音效——要在帧时间线上调度,这样在无头渲染时也能正常工作。

Safe-area for 9:16 (1080×1920)

9:16(1080×1920)安全区域

ZoneKeep clearWhy
Top status bar~120pxYour fake status bar / notch lives here; don't put bubbles under it
App header~150px (avatar + name)The contact header sells the app — keep it pinned and unobstructed
Bottom input + UI~360pxFake compose bar AND the platform's caption/CTA/audio UI both crowd the bottom
Side gutterscenter ~88% widthBubbles never touch the screen edge — the gutter is what reads as "a phone"
Run the live conversation in the band between the header and the compose bar. Land each new bubble in the lower third of that band (above the input), then auto-scroll — that's where the eye expects the newest message.
区域需保持空白原因
顶部状态栏~120px模拟的状态栏/刘海在此区域;不要将气泡放在下方
应用头部~150px(头像+名称)联系人头部是应用真实感的关键——保持固定且不被遮挡
底部输入栏+UI~360px模拟的输入栏和平台的标题/CTA/音频UI都会占据底部空间
侧边距中间约88%宽度气泡永远不要触碰屏幕边缘——边距是“这是手机”的视觉标识
在头部和输入栏之间的区域运行实时对话。让每个新气泡落在该区域的下方三分之一(输入栏上方),然后自动滚动——这是眼睛预期最新消息所在的位置。

Output checklist

输出检查清单

  • One messages array drives everything; timestamps derived, not hand-placed.
  • One message at a time: read-gap → typing (received only) → pop, on a tuned rhythm.
  • Correct app chrome end-to-end (colors, tails, header, receipts) — no mixing apps.
  • Sent right/blue-or-green, received left/gray; tail on the last of each run.
  • Receipt under last sent bubble; timestamp divider per time-block; tapbacks overshoot in.
  • Auto-scroll keeps the newest bubble in the lower third, clear of the safe areas.
  • One pop/ding per message, scheduled on the frame timeline (not timers).
  • Header pinned under the status bar; conversation inside the 9:16 safe band.
  • 所有内容由一个消息数组驱动;时间戳通过推导生成,而非手动设置。
  • 逐个显示消息:阅读间隙 → 打字(仅收到的消息)→ 弹出,节奏可控。
  • 全程保持应用界面正确(颜色、尾巴、头部、回执)——不要混合不同应用的样式。
  • 发送消息在右侧/蓝或绿色,收到消息在左侧/灰色;连续消息的最后一个带尾巴。
  • 最后一条发送消息下方有回执;每个时间段有时间戳分隔线;轻触反馈正确入场。
  • 自动滚动保持最新消息在下方三分之一处,且在9:16安全区域内。
  • 每条消息对应一个弹出/提示音,在帧时间线上调度(无计时器)。
  • 头部固定在状态栏下方;对话内容在9:16安全区域内。

Deliver & verify (rendered stills → MP4)

交付与验证(渲染静帧 → MP4)

Packaged helper (
scripts/
): tile your stills with
scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png
, then assert the encode with
scripts/probe-mp4.sh out.mp4 [WxH] [fps]
. See
scripts/README.md
.
Ships as a Remotion composition (
<Composition>
+ zod
schema
+
defaultProps
) whose props are the messages array. Every bubble entrance, typing pulse, scroll offset, and sound cue is frame-driven off
useCurrentFrame()
— never
Date.now()
/
Math.random()
/ CSS-animation timers — so any frame renders identically headless. Deliverable =
out/*.mp4
+ the project (re-render with a new script). 9:16 vertical (1080×1920) is the default. Duration is data-dependent, so compute
durationInFrames
in
calculateMetadata
from the timeline cursor, never by hand.
Verify loop — render stills → inspect → encode. Reveal timing and scroll position are what break; check exact frames before you spend an encode.
bash
undefined
打包工具
scripts/
):使用
scripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png
拼接静帧,然后用
scripts/probe-mp4.sh out.mp4 [WxH] [fps]
验证编码。详见
scripts/README.md
交付形式为Remotion合成组件(
<Composition>
+ zod
schema
+
defaultProps
),其属性为消息数组。每个气泡的入场、打字跳动、滚动偏移和音效提示都由
useCurrentFrame()
驱动——绝对不要使用
Date.now()
/
Math.random()
/ CSS动画计时器——这样任何帧在无头渲染时都能得到完全一致的结果。交付物 =
out/*.mp4
+ 项目文件(可使用新脚本重新渲染)。默认输出9:16竖屏(1080×1920)。时长由数据决定,因此在
calculateMetadata
中从时间线游标计算
durationInFrames
,而非手动设置。
验证流程——渲染静帧 → 检查 → 编码。显示时序和滚动位置最容易出问题;在编码前检查精确帧。
bash
undefined

Stills WITH SHIPPED PROPS (the real thread) at: a typing beat / a mid pop / the last bubble

使用交付属性(真实线程)渲染静帧:打字状态帧 / 中间弹出帧 / 最后一个气泡帧

npx remotion still TextThread out/f-typing.png --frame=24 --props='{"threadSrc":"thread.json"}' npx remotion still TextThread out/f-mid.png --frame=90 --props='{"threadSrc":"thread.json"}' npx remotion still TextThread out/f-end.png --frame=N --props='{"threadSrc":"thread.json"}' # N = durationInFrames-1
npx remotion still TextThread out/f-typing.png --frame=24 --props='{"threadSrc":"thread.json"}' npx remotion still TextThread out/f-mid.png --frame=90 --props='{"threadSrc":"thread.json"}' npx remotion still TextThread out/f-end.png --frame=N --props='{"threadSrc":"thread.json"}' # N = durationInFrames-1

Inspect each PNG:

检查每张PNG:

- frame 24 shows the three-dot typing indicator on the LEFT (received), no real bubble yet

- 第24帧显示左侧(收到消息)的三点打字指示器,无真实气泡

- at the mid frame the just-landed bubble is on the correct side/color with its tail; receipt/reaction placed right

- 中间帧中刚出现的气泡在正确的侧边/颜色,带有尾巴;回执/反应位置正确

- thread is auto-scrolled so the newest bubble sits in the lower third, NOT hidden under the compose bar

- 聊天线程已自动滚动,最新气泡位于下方三分之一,未被输入栏遮挡

- app chrome consistent (status bar, header) and inside the 9:16 safe band; nothing under the top notch

- 应用界面(状态栏、头部)一致且在9:16安全区域内;顶部刘海下方无内容

npx remotion render TextThread out/thread.mp4 --props='{"threadSrc":"thread.json"}' # encode once stills are right npx remotion render TextThread out/demo.gif --codec=gif # README proof clip

Use `npx remotion compositions` to read `durationInFrames`/`fps` and pick the typing/mid/end frames. For a quick no-build preview, the same layout renders as a standalone HTML page with a `?t=N` seek harness — see `references/bubble-recipes.md`.

**Before you finish:**
1. Stills render cleanly at a typing frame, a mid pop, and the last bubble — no missing fonts/audio.
2. At a typing frame the indicator (not the bubble) shows on the received side; the bubble pops only after.
3. Each bubble is on the correct side/color with the right tail; receipt/timestamp/reaction land where expected.
4. Auto-scroll keeps the newest bubble in the lower third and inside the 9:16 safe area at every checked frame.
5. Frame-driven only (no `Date.now()`/`Math.random()`/timers); shipped props render correctly; MP4 + optional GIF emitted.
npx remotion render TextThread out/thread.mp4 --props='{"threadSrc":"thread.json"}' # 静帧检查无误后编码 npx remotion render TextThread out/demo.gif --codec=gif # README演示动图

使用`npx remotion compositions`查看`durationInFrames`/`fps`,选择打字/中间/结束帧。如需快速无构建预览,相同布局可渲染为独立HTML页面,带有`?t=N`跳转功能——详见`references/bubble-recipes.md`。

**完成前检查:**
1. 打字帧、中间弹出帧和最后气泡帧的静帧渲染清晰,无缺失字体/音频。
2. 打字帧中收到消息侧显示指示器(而非气泡);气泡仅在打字结束后弹出。
3. 每个气泡在正确的侧边/颜色,带有正确的尾巴;回执/时间戳/反应位置符合预期。
4. 自动滚动在所有检查帧中保持最新消息在下方三分之一处,且在9:16安全区域内。
5. 仅由帧驱动(无`Date.now()`/`Math.random()`/计时器);交付属性渲染正确;生成MP4和可选GIF。

Reference files

参考文件

  • references/bubble-recipes.md
    — copy-ready build: iMessage/WhatsApp/SMS chrome and palettes, the message data model → frame timeline, spring bubble + tail, three-dot typing indicator, read receipts/timestamps/tapbacks, auto-scroll, per-message sound scheduling, the full Remotion composition with
    calculateMetadata
    , and a standalone-HTML preview with the
    ?t=N
    seek harness.
  • references/data-driven.md
    — the script→messages data shape, authoring threads as JSON/CSV, mapping a CSV into the
    Message[]
    type, and batch-rendering many videos from one template with
    @remotion/renderer
    .
  • references/bubble-recipes.md
    — 可直接复用的构建方案:iMessage/WhatsApp/SMS界面和配色、消息数据模型→帧时间线、弹簧气泡+尾巴、三点打字指示器、已读回执/时间戳/轻触反馈、自动滚动、每条消息的音效调度、完整的Remotion合成组件(含
    calculateMetadata
    ),以及带
    ?t=N
    跳转功能的独立HTML预览。
  • references/data-driven.md
    — 脚本→消息数据格式、以JSON/CSV编写聊天线程、将CSV映射为
    Message[]
    类型,以及使用
    @remotion/renderer
    从一个模板批量渲染多个视频。