基于 Remotion + React 的竖屏短视频模板规范。
- 中文名:贴文卡
- 英文名:PostCard
「贴文卡」指模拟社交媒体帖文截图,将柔白色卡片居中放置在纯黑动态渐变背景上的竖屏短视频。
一、视频规格
| 项目 | 规范 |
|---|---|
| 分辨率 | 1080 × 1920(9:16 竖屏) |
| 帧率 | 30 fps |
| 时长 | 10 秒(300 帧) |
| 编码 | H.264,CRF 18 |
| 格式 | .mp4 |
| 音频 | AAC,统一背景音乐 bgm.mp3 |
成片 .mp4 必须包含这条音轨。
二、画面结构
2.1 整体布局
- 背景:纯黑底色 + 三层半透明橙色圆形渐变缓慢浮动。
- 主体:一张柔白色卡片水平垂直居中,内部呈现帖文内容。
- 卡片内容从上到下依次为:头像/昵称/日期、正文、底部时间戳。
┌─────────────────────────────┐
│ [头像] 昵称 │
│ YYYY-MM-DD │
│ │
│ 正文第 1 段(整段渲染, │
│ 浏览器按宽度自动换行) │
│ ... │
│ 正文第 N 段 │
│ │
│ HH:MM │
│ 星期X │
└─────────────────────────────┘
2.2 默认账号信息
| 元素 | 默认值 |
|---|---|
| 头像 | https://ai-daily.tech/brand/logo.png |
| 昵称 | 高老师的分享局 |
| 头部日期 | 动态取当前日期,格式 YYYY-MM-DD |
| 底部时间戳 | 动态取当前时间,格式 HH:MM + 换行 + 星期X |
三、画布与背景
3.1 画布规格
| 属性 | 值 |
|---|---|
| 宽度 | 1080px |
| 高度 | 1920px |
| 宽高比 | 9:16(竖屏) |
| 帧率 | 30fps |
| 时长 | 10 秒(300 帧) |
3.2 背景样式
- 底色:纯黑
#000000 - 动态效果:3 层半透明橙色圆形渐变,以不同速度和轨迹缓慢浮动
- 渐变颜色:
rgba(245,160,116, opacity) - 渐变尺寸:800px / 950px / 1100px
- 透明度:0.15 / 0.20 / 0.25
四、卡片规范
4.1 卡片外观
| 属性 | 值 |
|---|---|
| 背景色 | #F5F3F0(柔白) |
| 宽度 | 880px(固定) |
| 高度 | 由内容自动撑开 |
| 圆角 | 36px |
| 内边距 | 50px |
| 投影 | 0 8px 40px rgba(0,0,0,0.35) |
| 定位 | 水平垂直居中(flexbox) |
4.2 头部(头像 + 昵称 + 日期)
| 属性 | 值 |
|---|---|
| 头像尺寸 | 80×80px |
| 头像形状 | 圆形(borderRadius: 50%) |
| 昵称字号 | 40px |
| 昵称字重 | 700 |
| 昵称颜色 | #1A1A1A |
| 日期字号 | 28px |
| 日期颜色 | #999999 |
| 布局 | 头像在左,昵称+日期在右,垂直居中 |
| 与正文间距 | 44px |
4.3 正文
| 属性 | 值 |
|---|---|
| 字号 | 46px |
| 字重 | 700 |
| 颜色 | #1A1A1A |
| 行距 | 1.6 |
| 段间距 | 18px |
| 对齐 | 左对齐 |
| 高亮词颜色 | #F5A074(橙色) |
| 高亮词字重 | 800 |
| 高亮词换行 | 不拆行(whiteSpace: 'nowrap') |
正文内容由调用方传入,支持纯文本和高亮词混合。高亮词用 <Hi> 组件包裹。
正文排版(硬性要求):
- 按段落整体渲染:每个自然段渲染为一个整体块(
<p>),段与段之间用段间距 18px 分隔(paragraphmargin-bottom: 18px)。 - 浏览器自然换行:段内文字由容器宽度自动折行,禁止手动拆分行、禁止在文字内插入
<br/>或\n控制断行,不设置每行字数。 - 每个段落结尾必须有完整句号。
- 高亮词不拆行:
<Hi>包裹的高亮词必须完整留在同一行,不允许被拆到两行(例如「代」在行尾、「码」跑到下一行)。Hi组件加whiteSpace: 'nowrap',放不下时整个高亮词移到下一行。
4.4 底部时间戳
| 属性 | 值 |
|---|---|
| 字号 | 24px |
| 颜色 | #999999 |
| 对齐 | 右对齐 |
| 格式 | HH:MM + 换行 + 星期X |
| 位置 | 卡片底部右下角,自动贴底 |
五、动画规范
- 全部文字在 2 秒内 淡入完毕
- 每段间隔 5 帧(约 0.17 秒)开始淡入
- 每段淡入时长 9 帧(0.3 秒)
- 动画以段落为最小单位(段落整体渲染为块,内部换行由浏览器自然排版,不按行拆分动画)
- 动画类型:纯
opacity变化,无位移、无缩放 transformOrigin:left center
时间线(帧):
| 元素 | 开始帧 | 结束帧 |
|---|---|---|
| 头部(头像+昵称+日期) | 0 | 9 |
| 正文第 1 段 | 6 | 15 |
| 正文第 2 段 | 11 | 20 |
| 正文第 3 段 | 16 | 25 |
| … | … | … |
| 正文第 N 段 | 6 + (N-1)*5 | 15 + (N-1)*5 |
| 底部时间戳 | 55 | 64 |
所有文字在第 60 帧(2 秒)内全部展示完毕。
六、色彩规范
| 用途 | 色值 | 说明 |
|---|---|---|
| 背景底色 | #000000 | 纯黑 |
| 渐变光效 | rgba(245,160,116, 0.15~0.25) | 橙色半透明 |
| 卡片背景 | #F5F3F0 | 柔白,不刺眼 |
| 正文颜色 | #1A1A1A | 近黑 |
| 高亮词 | #F5A074 | 橙色,与背景光效呼应 |
| 辅助文字 | #999999 | 灰色(日期、时间戳) |
七、字体
font-family: "Noto Sans CJK SC", "PingFang SC", "Microsoft YaHei", sans-serif;
八、背景音乐
每条贴文卡视频共用这一条背景音乐,不随成片更换。
| 项目 | 规范 |
|---|---|
| 文件 | bgm.mp3(统一曲目) |
| 项目内路径 | public/bgm.mp3 |
| 加载 | <Audio src={staticFile('bgm.mp3')} />(从 remotion 引入 Audio、staticFile) |
| 对齐 | 视频仍为 10 秒(300 帧),只用音频的前 10 秒 |
| 淡入淡出 | 无 |
| 音量 | volume={1}(不写 volume 时默认就是 1) |
| 成片 | .mp4 必须包含音轨 |
曲目已按响度标准化,真峰值 -1 dB,即不削波的最大响度。保持 volume={1},不要再压低,也不要重新编码、裁剪或加淡化。
文件参数:约 10.03 秒,44.1 kHz,立体声,MP3 320 kbps。<Audio> 从第 0 帧起播;合成在第 300 帧结束,多出的约 0.03 秒不会进入成片。
下载:bgm.mp3
拉进 Remotion 项目的 public/bgm.mp3:
mkdir -p public
curl -L -o public/bgm.mp3 https://ai-daily.tech/audio/postcard/bgm.mp3
九、从零搭建步骤
9.1 创建项目目录
mkdir postcard && cd postcard
9.2 创建 package.json
{
"name": "postcard",
"version": "1.0.0",
"private": true,
"scripts": {
"studio": "remotion studio",
"render": "remotion render PostCard out/video.mp4"
},
"dependencies": {
"@remotion/cli": "4.0.0",
"react": "^18.0.0",
"react-dom": "^18.0.0",
"remotion": "4.0.0"
},
"devDependencies": {
"@types/react": "^18.0.0",
"typescript": "^5.0.0"
}
}
9.3 创建 tsconfig.json
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"outDir": "./dist"
},
"include": ["src"]
}
9.4 创建 src/index.ts
import { registerRoot } from 'remotion';
import { RemotionRoot } from './Root';
registerRoot(RemotionRoot);
9.5 创建 src/Root.tsx
import React from 'react';
import { Composition } from 'remotion';
import { PostCard } from './PostCard';
export const RemotionRoot: React.FC = () => {
return (
<>
<Composition
id="PostCard"
component={PostCard}
durationInFrames={300}
fps={30}
width={1080}
height={1920}
/>
</>
);
};
9.6 创建 src/PostCard.tsx
import React from 'react';
import {
AbsoluteFill,
Audio,
Img,
interpolate,
staticFile,
useCurrentFrame,
} from 'remotion';
const FONT = '"Noto Sans CJK SC", "PingFang SC", "Microsoft YaHei", sans-serif';
const CARD = '#F5F3F0';
const TEXT = '#1A1A1A';
const ORANGE = '#F5A074';
const GRAY = '#999999';
const NICKNAME = '高老师的分享局';
const Hi: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<span style={{ color: ORANGE, fontWeight: 800, whiteSpace: 'nowrap' }}>{children}</span>
);
const now = new Date();
const DATE_STR = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')}`;
const TIME_STR = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
const WEEKDAYS = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'];
const WEEKDAY = WEEKDAYS[now.getDay()];
// 正文按自然段落组织,每个元素是一段;段内换行交给浏览器自然排版,禁止手动分行
const paragraphs: React.ReactNode[] = [
<>在经过了长期的思想斗争以后,现在正式不用 IDE 了,<Hi>Cursor</Hi> 也正式切换成了 <Hi>Agent View</Hi>,<Hi>VSCode</Hi> 也很少打开了。</>,
<>代码只关注 <Hi>架构</Hi>,每次简单浏览 <Hi>Diff</Hi>,<Hi>Code Review</Hi> 交给多个 Agent 互相 review,验证就是完备的 E2E 了。</>,
<>未来代码就是黑盒了,这是心酸但是没办法的事实了。</>,
];
export const PostCard: React.FC = () => {
const frame = useCurrentFrame();
const fade = (start: number, dur = 9) =>
interpolate(frame, [start, start + dur], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<AbsoluteFill style={{ backgroundColor: '#000000' }}>
<Audio src={staticFile('bgm.mp3')} />
{[0, 1, 2].map((i) => {
const s = 0.15 + i * 0.08;
const cx = 50 + Math.sin(frame * s * 0.03 + i * 2.1) * 25;
const cy = 50 + Math.cos(frame * s * 0.025 + i * 1.7) * 30;
return (
<div
key={i}
style={{
position: 'absolute',
inset: 0,
background: `radial-gradient(circle ${800 + i * 150}px at ${cx}% ${cy}%, rgba(245,160,116,${0.15 + i * 0.05}), transparent 70%)`,
}}
/>
);
})}
<div style={{ position: 'absolute', inset: 0, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
<div style={{ width: 880, backgroundColor: CARD, borderRadius: 36, padding: 50, display: 'flex', flexDirection: 'column', boxShadow: '0 8px 40px rgba(0,0,0,0.35)' }}>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: 44, opacity: fade(0) }}>
<Img src="https://ai-daily.tech/brand/logo.png" style={{ width: 80, height: 80, borderRadius: '50%', objectFit: 'cover' }} />
<div style={{ marginLeft: 18 }}>
<div style={{ fontFamily: FONT, fontSize: 40, fontWeight: 700, color: TEXT, lineHeight: 1.3 }}>{NICKNAME}</div>
<div style={{ fontFamily: FONT, fontSize: 28, color: GRAY, lineHeight: 1.4 }}>{DATE_STR}</div>
</div>
</div>
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center' }}>
{paragraphs.map((p, i) => (
<p key={i} style={{ opacity: fade(6 + i * 5), fontFamily: FONT, fontSize: 46, fontWeight: 700, color: TEXT, lineHeight: 1.6, margin: '0 0 18px', transformOrigin: 'left center' }}>
{p}
</p>
))}
</div>
<div style={{ fontFamily: FONT, fontSize: 24, color: GRAY, marginTop: 'auto', paddingTop: 16, opacity: fade(55), textAlign: 'right' }}>
{TIME_STR}<br />{WEEKDAY}
</div>
</div>
</div>
</AbsoluteFill>
);
};
9.7 安装依赖并渲染
mkdir -p public
curl -L -o public/bgm.mp3 https://ai-daily.tech/audio/postcard/bgm.mp3
npm install
npx remotion render PostCard out/video.mp4
Remotion 会自动检测系统中的浏览器。如需指定,添加 --browser-executable=/path/to/chrome。
输出文件:out/video.mp4。成片里带有背景音乐;Remotion 写 mp4 时默认把音轨编码为 AAC。
确认音轨:
ffprobe -hide_banner out/video.mp4
应能看到一条 Audio 流(AAC)。没有这条流,说明背景音乐没有打进成片。
十、封面制作
每条贴文卡视频都要配一张封面图,直接取成片的最后一帧裁剪得到,不单独设计。
| 项目 | 规范 |
|---|---|
| 取帧 | 视频最后一帧:300 帧视频即第 299 帧(从 0 起算) |
| 宽高比 | 3:4 |
| 尺寸 | 1080 × 1440:宽度保持 1080 不变,高度按 3:4 算为 1440 |
| 裁剪方式 | 从中间裁剪:顶部、底部各裁掉 240px,保留 y=240 到 y=1680 的区域 |
| 格式 | .png |
| 首帧 | 成片第 0 帧替换为完整的最后一帧(1080 × 1920),见 10.3 |
视频画面是 1080 × 1920(9:16),1920 − 1440 = 480,上下各去掉 240px,正好留下画面中间的 3:4 区域。
卡片位于约 y≈330–1590,落在保留区域(240–1680)以内,裁剪后卡片完整保留。如果文案变长导致卡片超出这个范围,需要缩短文案,不要改裁剪区域。
10.1 方式一:用 Remotion 导出最后一帧再裁剪
npx remotion still src/index.ts PostCard out/cover-full.png --frame=299
ffmpeg -i out/cover-full.png -vf "crop=1080:1440:0:240" out/cover.png
crop=1080:1440:0:240 的含义是:宽 1080、高 1440,从 x=0、y=240 开始裁。
10.2 方式二:直接从成片 mp4 截取最后一帧
ffmpeg -sseof -0.1 -i out/video.mp4 -frames:v 1 -vf "crop=1080:1440:0:240" out/cover.png
-sseof -0.1 表示从视频结尾前 0.1 秒开始读,取到的第一帧就是最后一帧(第 299 帧)。
确认封面尺寸:
ffprobe -v error -show_entries stream=width,height -of csv=p=0 out/cover.png
应输出 1080,1440。再打开图片检查卡片上下边缘完整、没有被裁掉。
10.3 封面同时作为视频首帧
部分平台默认取视频第一帧当封面,所以成片的第 0 帧必须就是封面画面:把第 0 帧替换成完整的最后一帧(1080 × 1920,不裁剪)。平台按 3:4 从中间裁剪后,看到的就是上面的 cover.png。
- 替换,不是插入:只覆盖第 0 帧,总帧数仍为 300 帧 / 10 秒,背景音乐原样保留、音画不错位。不要在开头额外插一帧(会变成 301 帧,音画差 1 帧)。
- 第 1 帧起照常播放淡入动画,首帧只闪现 1/30 秒,观看时几乎察觉不到。
- 这一步放在渲染之后,对已经渲染好的成片同样适用;
src/PostCard.tsx不用改。
渲染出 out/video.mp4 后执行:
ffmpeg -i out/video.mp4 -sseof -0.1 -i out/video.mp4 \
-filter_complex "[1:v]trim=end_frame=1,setpts=PTS-STARTPTS[last];[0:v][last]overlay=0:0:format=yuv420:eof_action=repeat:enable='eq(n,0)'[v]" \
-map "[v]" -map 0:a -c:v libx264 -crf 18 -x264-params "zones=0,0,q=6" -pix_fmt yuvj420p \
-c:a copy -movflags +faststart out/video-final.mp4
mv out/video-final.mp4 out/video.mp4
参数说明:
- 第二个输入
-sseof -0.1读到最后一帧,trim=end_frame=1只取这一帧,setpts=PTS-STARTPTS把它的时间戳归零,才能对上第 0 帧。 overlay=...:enable='eq(n,0)'只在第 0 帧把最后一帧整幅盖上去,其余帧不变。zones=0,0,q=6让第 0 帧用高质量编码,文字边缘和最后一帧几乎一致;其余帧仍按 CRF 18。-c:a copy音轨直接复制,不重新编码。
检查:
ffprobe -v error -show_entries stream=codec_type,width,height,r_frame_rate,nb_frames -of compact out/video.mp4
ffmpeg -i out/video.mp4 -vf "select=eq(n\,0)" -fps_mode passthrough -frames:v 1 out/frame0.png
应看到视频流 1080×1920、30/1、nb_frames=300,并且有一条 audio 流。打开 out/frame0.png,应与 out/cover-full.png(最后一帧)一致。
十一、定制说明
制作新视频时,只需修改 src/PostCard.tsx 中的 paragraphs 数组,把新的正文按自然段落传入(一个元素对应一段,段尾有完整句号)。高亮词用 <Hi> 包裹。段内换行由浏览器按卡片宽度自然排版,不要手动分行、不要设置每行字数。背景音乐保持 public/bgm.mp3,不要更换。
const paragraphs: React.ReactNode[] = [
<>第 1 段普通文字,浏览器自动折行。</>,
<>第 2 段带 <Hi>高亮词</Hi> 的文字,同样整体渲染。</>,
// ...
];
十二、项目结构
postcard/
├── public/
│ └── bgm.mp3 # 统一背景音乐
├── src/
│ ├── index.ts # Remotion 入口
│ ├── Root.tsx # 根组件
│ └── PostCard.tsx # 贴文卡视频组件(核心)
├── package.json
├── tsconfig.json
└── out/
├── video.mp4 # 渲染输出(含音轨)
├── cover-full.png # 最后一帧原图(1080×1920)
└── cover.png # 封面(1080×1440,3:4)
本文档作为 PostCard 贴文卡短视频模板的设计与开发规范,持续迭代中。