Loading...
Loading...
This skill should be used when the user asks to "make a fake text message video", "animate an iMessage conversation", "create a text story video", "do a chat reply animation", "WhatsApp/SMS conversation video", "typing indicator animation", "render a chat thread to a video", or "batch text-message videos from a script/CSV". Covers sent/received bubble layout, staggered appear timing with typing indicators, read receipts/timestamps, per-message pop sound-sync, 9:16 safe-area, and data-driven render from a messages array.
npx skill4agent add iart-ai/text-message-video-skills text-message-animationtype 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: "❓" },
];textfromfrom,text,typingMs,delayMs,statusconst 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
};#0A84FF#1C1C1E#E9E9EB#000#3B3B3D#37C24A#005C4B#202C33#0B141A#53BDEBimport { 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>
);
};<Sequence from={typingStart} durationInFrames={typingLen}><Sequence>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 readabledelayMstypingMstypingMsdurationInFramesdurationMscalculateMetadataenterMs + 400// 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>;enterMsimport { 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>
))}| Zone | Keep clear | Why |
|---|---|---|
| Top status bar | ~120px | Your 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 | ~360px | Fake compose bar AND the platform's caption/CTA/audio UI both crowd the bottom |
| Side gutters | center ~88% width | Bubbles never touch the screen edge — the gutter is what reads as "a phone" |
Packaged helper (): tile your stills withscripts/, then assert the encode withscripts/contact-sheet.sh sheet.png f-hook.png f-mid.png f-end.png. Seescripts/probe-mp4.sh out.mp4 [WxH] [fps].scripts/README.md
<Composition>schemadefaultPropsuseCurrentFrame()Date.now()Math.random()out/*.mp4durationInFramescalculateMetadata# 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
# Inspect each PNG:
# - frame 24 shows the three-dot typing indicator on the LEFT (received), no real bubble yet
# - 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
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 clipnpx remotion compositionsdurationInFramesfps?t=Nreferences/bubble-recipes.mdDate.now()Math.random()references/bubble-recipes.mdcalculateMetadata?t=Nreferences/data-driven.mdMessage[]@remotion/renderer