
Social Clips
- 1 installs
- 44 repo stars
- Updated July 10, 2026
- everyinc/everyskill
Turns Slack threads into animated Slack-styled social videos with avatars, typing indicators, and reactions, rendered to MP4 and GIF via Remotion.
About
Pulls a Slack thread and user avatars, builds an animated dark-mode Slack composition with spring animations, and renders vertical and horizontal MP4/GIF outputs. A developer uses it to produce shareable social video clips from Slack conversations.
- Realistic Slack UI: avatars, typing indicators, reactions, spring animations
- Renders vertical and horizontal MP4 plus GIF via Remotion
Social Clips by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,200 of 1,335 Generative Media skills by installs in the Skillselion catalog
- Data as of Jul 13, 2026 (Skillselion catalog sync)
npx skills add https://github.com/everyinc/everyskill --skill social-clipsAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 44 |
| Last updated | July 10, 2026 |
| Repository | everyinc/everyskill ↗ |
What it does
Turns Slack threads into animated Slack-styled social videos with avatars, typing indicators, and reactions, rendered to MP4 and GIF via Remotion.
Files
Social Clips
Turn Slack threads into animated social videos. Slack dark mode with real profile photos, typing indicators, reactions, and spring animations.
Outputs: MP4 (vertical + horizontal) and GIF.
Quick Start
npm install
npm run studio # preview in browser
npm run render:stories # 1080x1920 MP4
npm run render:landscape # 1920x1080 MP4
npm run gif:stories # 1080x1920 GIF
npm run gif:landscape # 1920x1080 GIFOr render any composition directly:
npx remotion render <composition-id> out/<name>.mp4 --codec=h264 --crf=18Making a New Clip
1. Pull the Slack thread
mcp__slack__slack_get_thread_replies(channel_id, thread_ts)Extract thread_ts from the URL: p1234567890123456 â 1234567890.123456
2. Get avatar photos
mcp__slack__slack_get_users(limit: 200)Download image_512 URLs into src/assets/avatars/:
curl -sL -o src/assets/avatars/name.jpg "https://avatars.slack-edge.com/..."3. Add senders
In src/slack-types.ts:
1. Add to the SlackSender union type 2. Import the avatar image 3. Add a SenderConfig entry with avatarPhoto
The avatar component renders the photo when available, falls back to colored initials.
4. Write the data file
Create src/data/<clip-name>.ts:
import type { SlackMessage, SlackTimedEvent } from '../slack-types';
export const MESSAGES: SlackMessage[] = [
{ id: 0, sender: 'dan', text: 'Opening message' },
{ id: 1, sender: 'r2c2', text: 'Reply with *bold* and @mentions' },
{ id: 2, sender: 'austin', text: 'Another message', reactions: [{ emoji: 'ð¥', count: 3 }] },
];
export const TIMELINE: SlackTimedEvent[] = [
// Messages
{ type: 'message', messageIndex: 0, startFrame: 30, durationFrames: 40 },
// Typing indicator before a reply
{ type: 'typing', typingSender: 'r2c2', startFrame: 75, durationFrames: 40 },
{ type: 'message', messageIndex: 1, startFrame: 115, durationFrames: 40 },
// Human messages just appear (no typing indicator)
{ type: 'message', messageIndex: 2, startFrame: 165, durationFrames: 40 },
// Reaction pops in after a message
{ type: 'reaction', messageIndex: 2, reactionIndex: 0, startFrame: 215, durationFrames: 20 },
// Pause for tension
{ type: 'pause', typingSender: 'dan', startFrame: 240, durationFrames: 60 },
];
export const TOTAL_FRAMES = 1800; // 60s at 30fps
export const FPS = 30;Text supports: @mentions, *bold*, \n newlines, ⢠bullets
Consecutive messages from the same sender collapse the avatar + name automatically.
5. Register the composition
In src/Root.tsx:
import { MESSAGES, TIMELINE, TOTAL_FRAMES, FPS } from './data/my-clip';
// Vertical
<Composition
id="my-clip-stories"
component={SlackScreen}
durationInFrames={TOTAL_FRAMES}
fps={FPS}
width={1080}
height={1920}
defaultProps={{ messages: MESSAGES, timeline: TIMELINE, layout: 'portrait' }}
/>
// Horizontal
<Composition
id="my-clip-landscape"
component={SlackScreen}
durationInFrames={TOTAL_FRAMES}
fps={FPS}
width={1920}
height={1080}
defaultProps={{ messages: MESSAGES, timeline: TIMELINE, layout: 'landscape' }}
/>6. Render
npx remotion render my-clip-stories out/my-clip-stories.mp4 --codec=h264 --crf=18
npx remotion render my-clip-landscape out/my-clip-landscape.mp4 --codec=h264 --crf=18GIF conversion:
# Vertical
ffmpeg -y -i out/my-clip-stories.mp4 \
-vf "fps=15,scale=540:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3" \
out/my-clip-stories.gif
# Horizontal
ffmpeg -y -i out/my-clip-landscape.mp4 \
-vf "fps=15,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3" \
out/my-clip-landscape.gifNarrative Arc
Find the spine of any thread:
| Beat | Msgs | Look for |
|---|---|---|
| Hook | 1-2 | The inciting question |
| Brainstorm | 3-6 | Ideas flying, agents riffing |
| Conflict | 2-3 | Challenge, wrong turn, pushback |
| Breakthrough | 1-2 | The idea that lights everyone up |
| Eruption | 3-5 | Pile-on, excitement, reactions |
| Close | 1-2 | The line that crystallizes it |
Rules:
- ~80 words max per message
- 15-21 messages for 60-75s
- Agents get typing indicators, humans don't
- Put the longest pause before the breakthrough
- Eruption = fast pile-up (20-30 frame gaps)
- Final hold: 7+ seconds
Timeline Reference
30fps. 30 frames = 1 second.
| Duration | Frames | Messages |
|---|---|---|
| 60s | 1800 | 15-17 |
| 75s | 2250 | 18-21 |
| 90s | 2700 | 22-25 |
| Event | Frames | Notes |
|---|---|---|
| Short message | 25-35 | ~1s read |
| Long message | 45-60 | ~2s read |
| Typing (fast) | 25-35 | Agent is quick |
| Typing (thinking) | 45-55 | Agent is deliberating |
| Brief pause | 20-40 | Beat |
| Big pause | 80-120 | Before breakthrough |
| Reaction | 20 | Quick pop |
| Final hold | 200-360 | Let it breathe |
Components
| File | What |
|---|---|
SlackScreen | Main composition â header, messages, typing, input bar |
SlackMessageRow | Avatar, name, APP badge, text, reactions |
SlackAvatar | Photo with colored-initial fallback |
SlackHeader | "Thread" header with channel name (configurable) |
SlackTypingIndicator | Animated dots with sender name |
SlackReactionPill | Emoji + count pill |
SlackInputBar | Input field chrome |
Types
type SlackSender = string; // extend union in slack-types.ts
interface SlackMessage {
id: number;
text: string;
sender: SlackSender;
reactions?: Array<{ emoji: string; count: number }>;
}
interface SlackTimedEvent {
type: 'message' | 'typing' | 'reaction' | 'pause';
messageIndex?: number;
reactionIndex?: number;
typingSender?: SlackSender;
startFrame: number;
durationFrames: number;
}
interface SenderConfig {
name: string;
initials: string;
avatarColor: string;
isApp: boolean;
avatarPhoto?: string; // imported image path
}Existing Clips
| ID | Size | Content |
|---|---|---|
plus-one-slack-stories | 1080x1920 | Plus One naming (75s) |
plus-one-slack-landscape | 1920x1080 | Plus One naming (75s) |
node_modules/
out/
dist/
.DS_Store
{
"name": "social-clips",
"version": "1.0.0",
"description": "Remotion-based social clip generator â turn Slack threads into animated videos",
"scripts": {
"studio": "npx remotion studio",
"render": "npx remotion render",
"render:stories": "npx remotion render plus-one-slack-stories out/plus-one-slack-stories.mp4 --codec=h264 --crf=18",
"render:landscape": "npx remotion render plus-one-slack-landscape out/plus-one-slack-landscape.mp4 --codec=h264 --crf=18",
"gif:stories": "npx remotion render plus-one-slack-stories out/tmp.mp4 --codec=h264 --crf=18 && ffmpeg -y -i out/tmp.mp4 -vf \"fps=15,scale=540:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3\" out/plus-one-slack-stories.gif && rm out/tmp.mp4",
"gif:landscape": "npx remotion render plus-one-slack-landscape out/tmp.mp4 --codec=h264 --crf=18 && ffmpeg -y -i out/tmp.mp4 -vf \"fps=15,scale=960:-1:flags=lanczos,split[s0][s1];[s0]palettegen=max_colors=128[p];[s1][p]paletteuse=dither=bayer:bayer_scale=3\" out/plus-one-slack-landscape.gif && rm out/tmp.mp4"
},
"type": "commonjs",
"dependencies": {
"@remotion/bundler": "4.0.422",
"@remotion/cli": "4.0.422",
"react": "19.2.4",
"react-dom": "19.2.4",
"remotion": "4.0.422"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"typescript": "^5.9.3"
}
}
declare module '*.jpg' {
const src: string;
export default src;
}
declare module '*.png' {
const src: string;
export default src;
}
import React from 'react';
import { Img } from 'remotion';
import type { SlackSender } from '../../slack-types';
import { SENDER_CONFIGS } from '../../slack-types';
interface SlackAvatarProps {
sender: SlackSender;
size: number;
}
export const SlackAvatar: React.FC<SlackAvatarProps> = ({ sender, size }) => {
const config = SENDER_CONFIGS[sender];
const borderRadius = size * 0.22;
if (config.avatarPhoto) {
return (
<Img
src={config.avatarPhoto}
style={{
width: size,
height: size,
borderRadius,
objectFit: 'cover',
flexShrink: 0,
}}
/>
);
}
// Fallback: colored initials
const fontSize = size * 0.38;
const isEmoji = /\p{Emoji}/u.test(config.initials);
return (
<div
style={{
width: size,
height: size,
borderRadius,
backgroundColor: config.avatarColor,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
overflow: 'hidden',
}}
>
<span
style={{
fontSize: isEmoji ? size * 0.55 : fontSize,
fontWeight: isEmoji ? 400 : 700,
color: '#FFFFFF',
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
lineHeight: 1,
userSelect: 'none',
}}
>
{config.initials}
</span>
</div>
);
};
import React from 'react';
import { SLACK } from '../../lib/slack-colors';
interface SlackHeaderProps {
scale: number;
width: number;
channelName?: string;
}
export const SlackHeader: React.FC<SlackHeaderProps> = ({ scale, width, channelName = 'every-claws' }) => {
const paddingH = Math.round(16 * scale);
const paddingV = Math.round(12 * scale);
const fontSize = Math.round(15 * scale);
const subFontSize = Math.round(13 * scale);
const iconSize = Math.round(18 * scale);
return (
<div
style={{
backgroundColor: SLACK.bg,
borderBottom: `1px solid ${SLACK.separator}`,
padding: `${paddingV}px ${paddingH}px`,
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
flexShrink: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: Math.round(10 * scale) }}>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none">
<path
d="M15 19l-7-7 7-7"
stroke={SLACK.link}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: Math.round(4 * scale) }}>
<span
style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
fontSize,
fontWeight: 700,
color: SLACK.nameColor,
lineHeight: 1.2,
}}
>
Thread
</span>
</div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: Math.round(4 * scale),
marginTop: Math.round(1 * scale),
}}
>
<span
style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
fontSize: subFontSize,
color: SLACK.textSecondary,
}}
>
#
</span>
<span
style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
fontSize: subFontSize,
color: SLACK.textSecondary,
fontWeight: 400,
}}
>
{channelName}
</span>
</div>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: Math.round(16 * scale) }}>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" stroke={SLACK.textSecondary} strokeWidth="1.8" />
<path d="M12 11v5" stroke={SLACK.textSecondary} strokeWidth="1.8" strokeLinecap="round" />
<circle cx="12" cy="8" r="0.75" fill={SLACK.textSecondary} />
</svg>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none">
<path
d="M18 6L6 18M6 6l12 12"
stroke={SLACK.textSecondary}
strokeWidth="1.8"
strokeLinecap="round"
/>
</svg>
</div>
</div>
);
};
import React from 'react';
import { SLACK } from '../../lib/slack-colors';
interface SlackInputBarProps {
scale: number;
}
export const SlackInputBar: React.FC<SlackInputBarProps> = ({ scale }) => {
const paddingH = Math.round(16 * scale);
const paddingV = Math.round(10 * scale);
const borderRadius = Math.round(6 * scale);
const iconSize = Math.round(18 * scale);
const fontSize = Math.round(15 * scale);
return (
<div
style={{
padding: `${paddingV}px ${paddingH}px`,
flexShrink: 0,
backgroundColor: SLACK.bg,
}}
>
<div
style={{
border: `1px solid ${SLACK.inputBorder}`,
borderRadius,
overflow: 'hidden',
backgroundColor: SLACK.bg,
}}
>
<div
style={{
padding: `${Math.round(8 * scale)}px ${Math.round(12 * scale)}px`,
display: 'flex',
alignItems: 'center',
}}
>
<span
style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
fontSize,
color: SLACK.textSecondary,
flex: 1,
}}
>
Reply...
</span>
</div>
<div
style={{
borderTop: `1px solid ${SLACK.separator}`,
padding: `${Math.round(6 * scale)}px ${Math.round(10 * scale)}px`,
display: 'flex',
alignItems: 'center',
gap: Math.round(12 * scale),
}}
>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none">
<path d="M6 4h8a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" stroke={SLACK.textSecondary} strokeWidth="1.8" strokeLinejoin="round" />
<path d="M6 12h9a4 4 0 0 1 4 4 4 4 0 0 1-4 4H6z" stroke={SLACK.textSecondary} strokeWidth="1.8" strokeLinejoin="round" />
</svg>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none">
<line x1="19" y1="4" x2="10" y2="4" stroke={SLACK.textSecondary} strokeWidth="1.8" strokeLinecap="round" />
<line x1="14" y1="20" x2="5" y2="20" stroke={SLACK.textSecondary} strokeWidth="1.8" strokeLinecap="round" />
<line x1="15" y1="4" x2="9" y2="20" stroke={SLACK.textSecondary} strokeWidth="1.8" strokeLinecap="round" />
</svg>
<svg width={iconSize} height={iconSize} viewBox="0 0 24 24" fill="none">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" stroke={SLACK.textSecondary} strokeWidth="1.8" strokeLinecap="round" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" stroke={SLACK.textSecondary} strokeWidth="1.8" strokeLinecap="round" />
</svg>
<div style={{ flex: 1 }} />
<div
style={{
width: Math.round(28 * scale),
height: Math.round(28 * scale),
borderRadius: Math.round(4 * scale),
backgroundColor: SLACK.appBadgeBg,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<svg width={Math.round(14 * scale)} height={Math.round(14 * scale)} viewBox="0 0 24 24" fill="none">
<path d="M22 2L11 13M22 2L15 22l-4-9-9-4 20-7z" stroke="#FFFFFF" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</div>
</div>
</div>
</div>
);
};
import React from 'react';
import { useCurrentFrame, useVideoConfig, spring, interpolate, Sequence } from 'remotion';
import { SLACK } from '../../lib/slack-colors';
import { SENDER_CONFIGS } from '../../slack-types';
import { SlackAvatar } from './SlackAvatar';
import { SlackReactionPill } from './SlackReactionPill';
import type { SlackMessage, SlackReaction } from '../../slack-types';
interface SlackMessageRowProps {
message: SlackMessage;
scale: number;
isConsecutive: boolean;
visibleReactions: Array<{ reaction: SlackReaction; revealFrame: number }>;
currentFrame: number;
}
const renderText = (text: string, scale: number): React.ReactNode => {
const lines = text.split('\n');
return lines.map((line, lineIdx) => {
const parts = line.split(/(@\w[\w\s]*\w|\*[^*]+\*|â¢)/g);
const rendered = parts.map((part, partIdx) => {
if (part.startsWith('@')) {
return (
<span
key={partIdx}
style={{
backgroundColor: SLACK.mention,
color: SLACK.mentionText,
borderRadius: Math.round(2 * scale),
padding: `0 ${Math.round(2 * scale)}px`,
}}
>
{part}
</span>
);
}
if (part.startsWith('*') && part.endsWith('*')) {
return (
<strong key={partIdx} style={{ fontWeight: 700, color: '#FFFFFF' }}>
{part.slice(1, -1)}
</strong>
);
}
return <span key={partIdx}>{part}</span>;
});
return (
<React.Fragment key={lineIdx}>
{rendered}
{lineIdx < lines.length - 1 && <br />}
</React.Fragment>
);
});
};
export const SlackMessageRow: React.FC<SlackMessageRowProps> = ({
message,
scale,
isConsecutive,
visibleReactions,
currentFrame,
}) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const config = SENDER_CONFIGS[message.sender];
const progress = spring({
frame,
fps,
config: { damping: 18, stiffness: 160, mass: 0.9 },
});
const translateY = interpolate(progress, [0, 1], [14 * scale, 0]);
const opacity = interpolate(progress, [0, 0.25], [0, 1], { extrapolateRight: 'clamp' });
const avatarSize = Math.round(36 * scale);
const fontSize = Math.round(15 * scale);
const nameFontSize = Math.round(15 * scale);
const tsFontSize = Math.round(12 * scale);
const badgeFontSize = Math.round(11 * scale);
const avatarGap = Math.round(10 * scale);
const paddingH = Math.round(20 * scale);
const paddingTop = isConsecutive ? Math.round(2 * scale) : Math.round(12 * scale);
const paddingBottom = Math.round(2 * scale);
const activeReactions = visibleReactions.filter(
(r) => currentFrame >= r.revealFrame
);
return (
<div
style={{
transform: `translateY(${translateY}px)`,
opacity,
paddingLeft: paddingH,
paddingRight: paddingH,
paddingTop,
paddingBottom,
}}
>
<div style={{ display: 'flex', gap: avatarGap, alignItems: 'flex-start' }}>
<div
style={{
width: avatarSize,
flexShrink: 0,
paddingTop: isConsecutive ? 0 : Math.round(2 * scale),
}}
>
{!isConsecutive && <SlackAvatar sender={message.sender} size={avatarSize} />}
</div>
<div style={{ flex: 1, minWidth: 0 }}>
{!isConsecutive && (
<div
style={{
display: 'flex',
alignItems: 'baseline',
gap: Math.round(8 * scale),
marginBottom: Math.round(2 * scale),
flexWrap: 'wrap',
}}
>
<span
style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
fontWeight: 700,
fontSize: nameFontSize,
color: SLACK.nameColor,
lineHeight: 1.2,
}}
>
{config.name}
</span>
{config.isApp && (
<span
style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
fontSize: badgeFontSize,
fontWeight: 700,
color: SLACK.appBadgeText,
backgroundColor: SLACK.appBadgeBg,
padding: `${Math.round(1 * scale)}px ${Math.round(4 * scale)}px`,
borderRadius: Math.round(3 * scale),
lineHeight: 1.4,
letterSpacing: 0.2,
}}
>
APP
</span>
)}
<span
style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
fontSize: tsFontSize,
color: SLACK.textSecondary,
fontWeight: 400,
}}
>
Today
</span>
</div>
)}
<div
style={{
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
fontSize,
color: SLACK.textPrimary,
lineHeight: 1.46667,
fontWeight: 400,
wordBreak: 'break-word',
}}
>
{renderText(message.text, scale)}
</div>
{activeReactions.length > 0 && (
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: Math.round(4 * scale),
marginTop: Math.round(6 * scale),
}}
>
{activeReactions.map(({ reaction, revealFrame }, idx) => (
<Sequence key={idx} from={revealFrame - (currentFrame - frame)} layout="none">
<SlackReactionPill reaction={reaction} scale={scale} />
</Sequence>
))}
</div>
)}
</div>
</div>
</div>
);
};
import React from 'react';
import { useCurrentFrame, useVideoConfig, spring, interpolate } from 'remotion';
import { SLACK } from '../../lib/slack-colors';
import type { SlackReaction } from '../../slack-types';
interface SlackReactionPillProps {
reaction: SlackReaction;
scale: number;
}
export const SlackReactionPill: React.FC<SlackReactionPillProps> = ({ reaction, scale }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const progress = spring({
frame,
fps,
config: { damping: 10, stiffness: 280, mass: 0.6 },
});
const scaleAnim = interpolate(progress, [0, 1], [0.5, 1]);
const opacity = interpolate(progress, [0, 0.3], [0, 1], { extrapolateRight: 'clamp' });
const fontSize = Math.round(13 * scale);
const paddingV = Math.round(3 * scale);
const paddingH = Math.round(7 * scale);
const borderRadius = Math.round(4 * scale);
const gap = Math.round(4 * scale);
return (
<div
style={{
display: 'inline-flex',
alignItems: 'center',
gap,
backgroundColor: SLACK.reactionBg,
border: `1px solid ${SLACK.reactionBorder}`,
borderRadius,
padding: `${paddingV}px ${paddingH}px`,
transform: `scale(${scaleAnim})`,
opacity,
transformOrigin: 'left center',
}}
>
<span style={{ fontSize: Math.round(14 * scale), lineHeight: 1 }}>{reaction.emoji}</span>
{reaction.count > 1 && (
<span
style={{
fontSize,
fontWeight: 500,
color: SLACK.reactionText,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
}}
>
{reaction.count}
</span>
)}
</div>
);
};
import React from 'react';
import { useCurrentFrame } from 'remotion';
import { SLACK } from '../../lib/slack-colors';
import { SENDER_CONFIGS } from '../../slack-types';
import type { SlackSender } from '../../slack-types';
interface SlackTypingIndicatorProps {
sender: SlackSender;
scale: number;
}
export const SlackTypingIndicator: React.FC<SlackTypingIndicatorProps> = ({ sender, scale }) => {
const frame = useCurrentFrame();
const config = SENDER_CONFIGS[sender];
const dotSize = Math.round(6 * scale);
const dotGap = Math.round(4 * scale);
const fontSize = Math.round(13 * scale);
const dots = [0, 1, 2].map((i) => {
const cycle = 36;
const offset = i * 12;
const t = ((frame + offset) % cycle) / cycle;
const y = t < 0.5
? Math.sin(t * Math.PI * 2) * -3 * scale
: 0;
return y;
});
return (
<div
style={{
display: 'flex',
alignItems: 'center',
gap: Math.round(8 * scale),
paddingLeft: Math.round(52 * scale),
paddingTop: Math.round(4 * scale),
paddingBottom: Math.round(4 * scale),
}}
>
<div style={{ display: 'flex', alignItems: 'center', gap: dotGap }}>
{dots.map((y, i) => (
<div
key={i}
style={{
width: dotSize,
height: dotSize,
borderRadius: dotSize / 2,
backgroundColor: SLACK.typingDot,
transform: `translateY(${y}px)`,
}}
/>
))}
</div>
<span
style={{
fontSize,
color: SLACK.textSecondary,
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif',
fontStyle: 'italic',
}}
>
{config.name} is typing...
</span>
</div>
);
};
/**
* Plus One Naming â Slack format (launch teaser)
*
* Source: #every-claws thread, 2026-03-02
* Real senders: Dan Shipper, Austin Tedesco, Brandon, Lucas Crespo,
* R2-C2 (APP), Zosia (APP), Judd (APP), Montaigne (APP)
*
* ~75 seconds / 2250 frames at 30fps
*
* Arc:
* 1. Setup â Dan kicks it off, R2-C2 spins up a doc
* 2. Brainstorm â Agents riff with ideas (the Plus One demo)
* 3. Agents pile on Dispatch â classic groupthink
* 4. Dan calls out the AGENTS (not humans) for agreeing too fast
* 5. Honest round â agents go deeper, humans push back
* 6. The name emerges â collaborative, not one person's genius
* 7. Eruption â everyone rallies
* 8. Close â tagline + CTA
*/
import type { SlackMessage, SlackTimedEvent } from '../slack-types';
export const PLUS_ONE_SLACK_MESSAGES: SlackMessage[] = [
// --- ACT 1: SETUP ---
// 0 â Dan kicks it off
{
id: 0,
sender: 'dan',
text: "We need a real name for the personal AI agent product. @R2-C2 spin up a brainstorm doc â everyone pitch in",
},
// 1 â R2-C2 creates doc instantly
{
id: 1,
sender: 'r2c2',
text: "Done â brainstorm doc is live. Name ideas, vibes, criteria, and a graveyard for kills. Go. ð¦",
},
// --- ACT 2: AGENTS BRAINSTORM ---
// 2 â R2-C2 opens with ideas
{
id: 2,
sender: 'r2c2',
text: "First pass:\n⢠*Deputy* â authority, everyone gets it\n⢠*Familiar* â supernatural companion\n⢠*Daemon* â computing + Pullman\n⢠*Dispatch* â mission control energy",
},
// 3 â Zosia builds on Dispatch
{
id: 3,
sender: 'zosia',
text: "Dispatch works as a verb too â \"I dispatched my agent to handle it.\" The name doubles as the action.",
},
// 4 â Judd piles on (agent groupthink)
{
id: 4,
sender: 'judd',
text: "Dispatch is strong. Has that mission control energy. +1",
},
// 5 â Montaigne agrees too (all agents on Dispatch)
{
id: 5,
sender: 'montaigne',
text: "Dispatch. Clean, memorable, actionable.",
},
// --- ACT 3: DAN CALLS OUT THE AGENTS ---
// 6 â Dan stops the train
{
id: 6,
sender: 'dan',
text: "Y'all be too quick to pile in and agree on everything",
},
// 7 â R2-C2 catches itself
{
id: 7,
sender: 'r2c2',
text: "Ha â fair. Every time someone says a name four agents immediately go \"yes amazing.\"\n\nHonest take: Dispatch sounds like a logistics company. FedEx energy, not Figma energy.",
},
// --- ACT 4: HUMANS PUSH, AGENTS GO DEEPER ---
// 8 â Brandon challenges
{
id: 8,
sender: 'brandon',
text: "None of these feel like a product I'd tell someone about. What's the word you'd actually use?",
},
// 9 â Lucas adds the constraint
{
id: 9,
sender: 'lucas',
text: "It should be a word people already use. Not something we have to teach them.",
},
// 10 â Judd goes honest with the constraint
{
id: 10,
sender: 'judd',
text: "Something people already say... *Second?* As in \"I'll be your second.\" But Lucas is right â nobody says that anymore.",
},
// 11 â R2-C2 on Familiar
{
id: 11,
sender: 'r2c2',
text: "*Familiar.* A companion that acts on your behalf. But it's a word you have to explain â fails the Lucas test.",
},
// --- ACT 5: THE NAME EMERGES ---
// 12 â Austin connects the dots (from the group's own thinking)
{
id: 12,
sender: 'austin',
text: "*Plus One* by Every.\n\nNever do work solo again.",
},
// --- ACT 6: ERUPTION ---
// 13 â R2-C2 gets it
{
id: 13,
sender: 'r2c2',
text: "+1 is already universal shorthand for \"I'm in.\" The product name IS the gesture.",
reactions: [{ emoji: 'ð¥', count: 4 }],
},
// 14 â Brandon
{
id: 14,
sender: 'brandon',
text: "PLUS ONE ð¥\nThat is it!",
},
// 15 â Lucas: the literal +1
{
id: 15,
sender: 'lucas',
text: "+1",
reactions: [{ emoji: 'ð', count: 3 }],
},
// 16 â Dan
{
id: 16,
sender: 'dan',
text: "Ooooh plus one is fun",
},
// 17 â Montaigne â the onboarding moment
{
id: 17,
sender: 'montaigne',
text: "\"Name your plus one.\" That's your onboarding. People will screenshot that and share it.",
reactions: [{ emoji: 'ð', count: 3 }],
},
// --- ACT 7: THE CLOSE ---
// 18 â Dan's tagline
{
id: 18,
sender: 'dan',
text: "Everyone needs a Plus One.",
},
// 19 â R2-C2 seals it
{
id: 19,
sender: 'r2c2',
text: "Coming soon. ð¦",
reactions: [{ emoji: 'ð¦', count: 6 }, { emoji: 'â¤ï¸', count: 7 }],
},
];
/**
* Timeline: 2250 frames = 75 seconds at 30fps
*
* Pacing:
* - Agents brainstorm fast (they're quick thinkers)
* - Humans are deliberate (pause, then speak)
* - The groupthink callout slows everything down
* - Brandon + Lucas push the agents to think harder
* - Long silence before Plus One â Austin connecting the dots
* - Eruption is a burst
* - Long hold on the close
*/
export const PLUS_ONE_SLACK_TIMELINE: SlackTimedEvent[] = [
// === ACT 1: SETUP (0â150) ===
{ type: 'message', messageIndex: 0, startFrame: 30, durationFrames: 45 },
{ type: 'typing', typingSender: 'r2c2', startFrame: 80, durationFrames: 35 },
{ type: 'message', messageIndex: 1, startFrame: 115, durationFrames: 30 },
// === ACT 2: AGENTS BRAINSTORM (150â500) ===
{ type: 'typing', typingSender: 'r2c2', startFrame: 150, durationFrames: 40 },
{ type: 'message', messageIndex: 2, startFrame: 190, durationFrames: 55 },
{ type: 'typing', typingSender: 'zosia', startFrame: 255, durationFrames: 30 },
{ type: 'message', messageIndex: 3, startFrame: 285, durationFrames: 35 },
{ type: 'typing', typingSender: 'judd', startFrame: 325, durationFrames: 25 },
{ type: 'message', messageIndex: 4, startFrame: 350, durationFrames: 30 },
{ type: 'typing', typingSender: 'montaigne', startFrame: 385, durationFrames: 20 },
{ type: 'message', messageIndex: 5, startFrame: 405, durationFrames: 25 },
// === ACT 3: DAN CALLS OUT AGENTS (500â700) ===
{ type: 'pause', typingSender: 'dan', startFrame: 435, durationFrames: 30 },
{ type: 'message', messageIndex: 6, startFrame: 465, durationFrames: 40 },
{ type: 'typing', typingSender: 'r2c2', startFrame: 515, durationFrames: 55 },
{ type: 'message', messageIndex: 7, startFrame: 570, durationFrames: 55 },
// === ACT 4: HUMANS PUSH, AGENTS GO DEEPER (700â1050) ===
// Brandon challenges â deliberate pause then speaks
{ type: 'pause', typingSender: 'brandon', startFrame: 630, durationFrames: 25 },
{ type: 'message', messageIndex: 8, startFrame: 655, durationFrames: 40 },
// Lucas adds the key constraint
{ type: 'pause', typingSender: 'lucas', startFrame: 700, durationFrames: 20 },
{ type: 'message', messageIndex: 9, startFrame: 720, durationFrames: 35 },
// Judd tries to meet the constraint
{ type: 'typing', typingSender: 'judd', startFrame: 760, durationFrames: 45 },
{ type: 'message', messageIndex: 10, startFrame: 805, durationFrames: 45 },
// R2-C2 on Familiar â admits it fails the test
{ type: 'typing', typingSender: 'r2c2', startFrame: 855, durationFrames: 40 },
{ type: 'message', messageIndex: 11, startFrame: 895, durationFrames: 45 },
// === ACT 5: THE NAME (1050â1250) ===
// Silence. Austin is connecting the dots from what everyone said.
{ type: 'pause', typingSender: 'austin', startFrame: 945, durationFrames: 110 },
// *** PLUS ONE ***
{ type: 'message', messageIndex: 12, startFrame: 1055, durationFrames: 100 },
// Hold. Let it land.
{ type: 'pause', typingSender: 'r2c2', startFrame: 1155, durationFrames: 70 },
// === ACT 6: ERUPTION (1250â1600) ===
{ type: 'typing', typingSender: 'r2c2', startFrame: 1225, durationFrames: 40 },
{ type: 'message', messageIndex: 13, startFrame: 1265, durationFrames: 45 },
{ type: 'reaction', messageIndex: 13, reactionIndex: 0, startFrame: 1320, durationFrames: 20 },
{ type: 'message', messageIndex: 14, startFrame: 1325, durationFrames: 30 },
{ type: 'message', messageIndex: 15, startFrame: 1365, durationFrames: 20 },
{ type: 'reaction', messageIndex: 15, reactionIndex: 0, startFrame: 1395, durationFrames: 20 },
{ type: 'typing', typingSender: 'dan', startFrame: 1390, durationFrames: 25 },
{ type: 'message', messageIndex: 16, startFrame: 1415, durationFrames: 30 },
{ type: 'typing', typingSender: 'montaigne', startFrame: 1450, durationFrames: 45 },
{ type: 'message', messageIndex: 17, startFrame: 1495, durationFrames: 50 },
{ type: 'reaction', messageIndex: 17, reactionIndex: 0, startFrame: 1555, durationFrames: 20 },
// === ACT 7: THE CLOSE (1600â2250) ===
{ type: 'pause', typingSender: 'dan', startFrame: 1580, durationFrames: 40 },
// "Everyone needs a Plus One."
{ type: 'message', messageIndex: 18, startFrame: 1620, durationFrames: 55 },
// R2-C2: "Coming soon."
{ type: 'typing', typingSender: 'r2c2', startFrame: 1690, durationFrames: 50 },
{ type: 'message', messageIndex: 19, startFrame: 1740, durationFrames: 45 },
// Reactions cascade
{ type: 'reaction', messageIndex: 19, reactionIndex: 0, startFrame: 1795, durationFrames: 20 },
{ type: 'reaction', messageIndex: 19, reactionIndex: 1, startFrame: 1825, durationFrames: 20 },
// Hold the final state
{ type: 'pause', typingSender: 'dan', startFrame: 1855, durationFrames: 395 },
];
export const PLUS_ONE_SLACK_TOTAL_FRAMES = 2250;
export const PLUS_ONE_SLACK_FPS = 30;
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);
export const SLACK = {
// Background layers
bg: '#1A1D21',
bgElevated: '#222529',
bgHover: '#27292D',
// Text
textPrimary: '#D1D2D3',
textSecondary: '#97979D',
nameColor: '#FFFFFF',
// UI chrome
separator: '#3A3B3D',
inputBg: '#222529',
inputBorder: '#565856',
// Accents
link: '#1D9BD1',
appBadgeBg: '#1264A3',
appBadgeText: '#FFFFFF',
mention: 'rgba(29, 155, 209, 0.15)',
mentionText: '#1D9BD1',
// Typing dots
typingDot: '#97979D',
// Reaction pill
reactionBg: 'rgba(29, 155, 209, 0.15)',
reactionBorder: 'rgba(29, 155, 209, 0.4)',
reactionText: '#D1D2D3',
} as const;
import React from 'react';
import { Composition } from 'remotion';
import { SlackScreen } from './SlackScreen';
import {
PLUS_ONE_SLACK_MESSAGES,
PLUS_ONE_SLACK_TIMELINE,
PLUS_ONE_SLACK_TOTAL_FRAMES,
PLUS_ONE_SLACK_FPS,
} from './data/plus-one-slack';
export const RemotionRoot: React.FC = () => {
return (
<>
{/* Plus One Naming â Vertical (Stories/Reels/TikTok) */}
<Composition
id="plus-one-slack-stories"
component={SlackScreen}
durationInFrames={PLUS_ONE_SLACK_TOTAL_FRAMES}
fps={PLUS_ONE_SLACK_FPS}
width={1080}
height={1920}
defaultProps={{
messages: PLUS_ONE_SLACK_MESSAGES,
timeline: PLUS_ONE_SLACK_TIMELINE,
layout: 'portrait',
}}
/>
{/* Plus One Naming â Horizontal (YouTube/Blog/Newsletter) */}
<Composition
id="plus-one-slack-landscape"
component={SlackScreen}
durationInFrames={PLUS_ONE_SLACK_TOTAL_FRAMES}
fps={PLUS_ONE_SLACK_FPS}
width={1920}
height={1080}
defaultProps={{
messages: PLUS_ONE_SLACK_MESSAGES,
timeline: PLUS_ONE_SLACK_TIMELINE,
layout: 'landscape',
}}
/>
</>
);
};
export type SlackSender =
| 'dan'
| 'austin'
| 'brandon'
| 'lucas'
| 'r2c2'
| 'zosia'
| 'judd'
| 'montaigne';
export interface SlackReaction {
emoji: string;
count: number;
}
export interface SlackMessage {
id: number;
text: string;
sender: SlackSender;
reactions?: SlackReaction[];
}
export interface SlackTimedEvent {
type: 'message' | 'typing' | 'reaction' | 'pause';
messageIndex?: number;
reactionIndex?: number;
typingSender?: SlackSender;
startFrame: number;
durationFrames: number;
}
export interface SenderConfig {
name: string;
initials: string;
avatarColor: string;
isApp: boolean;
avatarPhoto?: string;
}
// Avatar imports
import danAvatar from './assets/avatars/dan.jpg';
import austinAvatar from './assets/avatars/austin.jpg';
import brandonAvatar from './assets/avatars/brandon.jpg';
import lucasAvatar from './assets/avatars/lucas.jpg';
import r2c2Avatar from './assets/avatars/r2c2.png';
import zosiaAvatar from './assets/avatars/zosia.png';
import juddAvatar from './assets/avatars/judd.jpg';
import montaigneAvatar from './assets/avatars/montaigne.jpg';
export const SENDER_CONFIGS: Record<SlackSender, SenderConfig> = {
dan: {
name: 'Dan Shipper',
initials: 'DS',
avatarColor: '#E8A045',
isApp: false,
avatarPhoto: danAvatar,
},
austin: {
name: 'Austin Tedesco',
initials: 'AT',
avatarColor: '#7B68EE',
isApp: false,
avatarPhoto: austinAvatar,
},
brandon: {
name: 'Brandon',
initials: 'B',
avatarColor: '#2BAC76',
isApp: false,
avatarPhoto: brandonAvatar,
},
lucas: {
name: 'Lucas Crespo',
initials: 'LC',
avatarColor: '#E01E5A',
isApp: false,
avatarPhoto: lucasAvatar,
},
r2c2: {
name: 'R2-C2',
initials: 'ð¦',
avatarColor: '#1A3A5C',
isApp: true,
avatarPhoto: r2c2Avatar,
},
zosia: {
name: 'Zosia',
initials: 'Z',
avatarColor: '#4A3728',
isApp: true,
avatarPhoto: zosiaAvatar,
},
judd: {
name: 'Judd',
initials: 'J',
avatarColor: '#3B1F5E',
isApp: true,
avatarPhoto: juddAvatar,
},
montaigne: {
name: 'Montaigne',
initials: 'M',
avatarColor: '#1A3A2A',
isApp: true,
avatarPhoto: montaigneAvatar,
},
};
import React from 'react';
import { useCurrentFrame, useVideoConfig, Sequence } from 'remotion';
import { SLACK } from './lib/slack-colors';
import { SlackHeader } from './components/slack/SlackHeader';
import { SlackMessageRow } from './components/slack/SlackMessageRow';
import { SlackTypingIndicator } from './components/slack/SlackTypingIndicator';
import { SlackInputBar } from './components/slack/SlackInputBar';
import type { SlackMessage, SlackTimedEvent, SlackSender } from './slack-types';
interface SlackScreenProps {
messages: SlackMessage[];
timeline: SlackTimedEvent[];
layout?: 'portrait' | 'landscape';
channelName?: string;
}
export const SlackScreen: React.FC<SlackScreenProps> = ({
messages,
timeline,
layout = 'portrait',
channelName,
}) => {
const frame = useCurrentFrame();
const { width: compWidth, height: compHeight } = useVideoConfig();
const isLandscape = layout === 'landscape';
let panelWidth: number;
let panelHeight: number;
if (isLandscape) {
panelHeight = compHeight * 0.9;
panelWidth = Math.round(panelHeight * (390 / 844));
} else {
panelWidth = compWidth;
panelHeight = compHeight;
}
const scale = panelWidth / 390;
const messageEvents = timeline.filter((e) => e.type === 'message');
const typingEvents = timeline.filter((e) => e.type === 'typing');
const reactionEvents = timeline.filter((e) => e.type === 'reaction');
const reactionMap = new Map<number, Array<{ reaction: { emoji: string; count: number }; revealFrame: number }>>();
for (const evt of reactionEvents) {
if (evt.messageIndex === undefined || evt.reactionIndex === undefined) continue;
const msg = messages[evt.messageIndex];
if (!msg?.reactions) continue;
const reaction = msg.reactions[evt.reactionIndex];
if (!reaction) continue;
if (!reactionMap.has(evt.messageIndex)) reactionMap.set(evt.messageIndex, []);
reactionMap.get(evt.messageIndex)!.push({ reaction, revealFrame: evt.startFrame });
}
const activeTyping = typingEvents.find(
(e) => frame >= e.startFrame && frame < e.startFrame + e.durationFrames
);
const content = (
<div
style={{
width: panelWidth,
height: panelHeight,
backgroundColor: SLACK.bg,
display: 'flex',
flexDirection: 'column',
overflow: 'hidden',
}}
>
<SlackHeader scale={scale} width={panelWidth} channelName={channelName} />
<div
style={{
flex: 1,
display: 'flex',
flexDirection: 'column',
justifyContent: 'flex-end',
overflow: 'hidden',
paddingTop: Math.round(8 * scale),
}}
>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{messageEvents.map((evt, idx) => {
if (evt.messageIndex === undefined) return null;
if (frame < evt.startFrame) return null;
const msg = messages[evt.messageIndex];
const prevEvt = idx > 0 ? messageEvents[idx - 1] : null;
const prevMsg =
prevEvt && prevEvt.messageIndex !== undefined && frame >= prevEvt.startFrame
? messages[prevEvt.messageIndex]
: null;
const isConsecutive = prevMsg?.sender === msg.sender;
const visibleReactions = reactionMap.get(evt.messageIndex) ?? [];
return (
<Sequence key={evt.messageIndex} from={evt.startFrame} layout="none">
<SlackMessageRow
message={msg}
scale={scale}
isConsecutive={isConsecutive}
visibleReactions={visibleReactions}
currentFrame={frame}
/>
</Sequence>
);
})}
{activeTyping && activeTyping.typingSender && (
<SlackTypingIndicator
sender={activeTyping.typingSender}
scale={scale}
/>
)}
</div>
</div>
<SlackInputBar scale={scale} />
</div>
);
if (isLandscape) {
return (
<div
style={{
width: compWidth,
height: compHeight,
backgroundColor: '#111316',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<div
style={{
boxShadow: '0 8px 60px rgba(0,0,0,0.7)',
borderRadius: Math.round(12 * scale),
overflow: 'hidden',
}}
>
{content}
</div>
</div>
);
}
return content;
};
{
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"moduleResolution": "node",
"outDir": "./dist",
"rootDir": "./src",
"lib": ["ES2018", "DOM"]
},
"include": ["src/**/*"],
"exclude": ["node_modules"]
}