Loading...
Loading...
Compare original and translation side by side
| Element | Job | Timing |
|---|---|---|
| Quote mark motif | Signals "this is a quote" before words read | Frame 0, scales/fades in first |
| Quote body | The testimonial, revealed line-by-line | Staggered, ~6–10 frames apart |
| Emphasis span | The one phrase that sells | Lands after its line settles |
| Star rating | The score, filled to the real value | Fills left→right after the quote |
| Author block | Name / role / company / avatar | Signs off last, slides up |
| 元素 | 作用 | 时序 |
|---|---|---|
| 引号标识 | 在文字显示前提示“这是一条语录” | 第0帧,最先缩放/淡入 |
| 语录正文 | 证言内容,逐行显示 | 错开显示,间隔约6–10帧 |
| 强调短语 | 最具说服力的核心内容 | 在所在行显示完成后出现 |
| 星级评分 | 真实分数,按实际值填充 | 语录显示完成后从左到右填充 |
| 作者信息块 | 姓名/职位/公司/头像 | 最后出现,向上滑入 |
useCurrentFrame()import { useCurrentFrame, spring, useVideoConfig } from "remotion";
const QuoteBody = ({ lines, startAt = 8, stagger = 6 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<blockquote>
{lines.map((line, i) => {
const enter = spring({ frame: frame - startAt - i * stagger, fps,
config: { damping: 200 } }); // 0→1, no overshoot
return (
<span key={i} style={{ display: "block",
opacity: enter,
transform: `translateY(${(1 - enter) * 14}px)` }}>
{line}
</span>
);
})}
</blockquote>
);
};references/quote-card.mduseCurrentFrame()import { useCurrentFrame, spring, useVideoConfig } from "remotion";
const QuoteBody = ({ lines, startAt = 8, stagger = 6 }) => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
return (
<blockquote>
{lines.map((line, i) => {
const enter = spring({ frame: frame - startAt - i * stagger, fps,
config: { damping: 200 } }); // 0→1, no overshoot
return (
<span key={i} style={{ display: "block",
opacity: enter,
transform: `translateY(${(1 - enter) * 14}px)` }}>
{line}
</span>
);
})}
</blockquote>
);
};references/quote-card.mdconst sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// a highlighter wiping in behind the phrase, left→right
<mark style={{ background:
`linear-gradient(90deg, var(--accent) ${sweep}%, transparent ${sweep}%)`,
color: "inherit", padding: "0 .08em" }}>{phrase}</mark>const sweep = interpolate(frame, [hlStart, hlStart + 10], [0, 100],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
// a highlighter wiping in behind the phrase, left→right
<mark style={{ background:
`linear-gradient(90deg, var(--accent) ${sweep}%, transparent ${sweep}%)`,
color: "inherit", padding: "0 .08em" }}>{phrase}</mark>rating / maxconst StarRating = ({ rating, max = 5, fillStart = 30, fillDur = 14 }) => {
const frame = useCurrentFrame();
const target = (rating / max) * 100;
const w = interpolate(frame, [fillStart, fillStart + fillDur], [0, target],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<div className="stars" role="img" aria-label={`${rating} out of ${max} stars`}>
<div className="track">★★★★★</div>
<div className="fill" style={{ width: `${w}%` }}>★★★★★</div>
</div>
);
};.stars { position: relative; display: inline-block; font-size: 40px; letter-spacing: 4px; }
.track { color: rgba(255,255,255,.22); }
.fill { position: absolute; inset: 0; overflow: hidden; white-space: nowrap;
color: var(--accent); } /* clipped by width → fractional fill */aria-labelrating / maxconst StarRating = ({ rating, max = 5, fillStart = 30, fillDur = 14 }) => {
const frame = useCurrentFrame();
const target = (rating / max) * 100;
const w = interpolate(frame, [fillStart, fillStart + fillDur], [0, target],
{ extrapolateLeft: "clamp", extrapolateRight: "clamp" });
return (
<div className="stars" role="img" aria-label={`${rating} out of ${max} stars`}>
<div className="track">★★★★★</div>
<div className="fill" style={{ width: `${w}%` }}>★★★★★</div>
</div>
);
};.stars { position: relative; display: inline-block; font-size: 40px; letter-spacing: 4px; }
.track { color: rgba(255,255,255,.22); }
.fill { position: absolute; inset: 0; overflow: hidden; white-space: nowrap;
color: var(--accent); } /* clipped by width → fractional fill */aria-label<figcaption className="author">
{avatar && <img src={avatar} alt="" className="avatar" />}
<div>
<span className="name">{name}</span>
<span className="role">{role}{company && `, ${company}`}</span>
</div>
</figcaption><figcaption className="author">
{avatar && <img src={avatar} alt="" className="avatar" />}
<div>
<span className="name">{name}</span>
<span className="role">{role}{company && `, ${company}`}</span>
</div>
</figcaption>export const Testimonial = ({ quote, highlight, rating, name, role, company, avatar }) => { /* … */ };for f in reviews/*.json; do
npx remotion render Testimonial "out/$(basename "$f" .json).mp4" --props="$f"
donereferences/batch-and-typography.mdexport const Testimonial = ({ quote, highlight, rating, name, role, company, avatar }) => { /* … */ };for f in reviews/*.json; do
npx remotion render Testimonial "out/$(basename "$f" .json).mp4" --props="$f"
donereferences/batch-and-typography.mdaria-labeluseCurrentFrame()aria-labeluseCurrentFrame()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
Testimonial<Composition>schemadefaultPropsDate.now()Math.random()out/*.mp4defaultPropsundefined打包工具():使用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
<Composition>TestimonialschemadefaultPropsDate.now()Math.random()out/*.mp4defaultPropsundefined
**Multi-aspect / batch — verify one review in EACH target aspect before batch-rendering the list.** A layout bug (overflowing quote, clipped author) repeats across every review × aspect; catch it once.
```bash
for ar in 9x16 1x1 16x9; do # one representative review, every aspect
npx remotion still Testimonial "out/r1-${ar}.png" --frame=215 \
--props=reviews/r1.json --props-merge="{\"aspect\":\"${ar}\"}"
done
**多宽高比/批量处理 — 批量渲染前,验证一条评测在所有目标宽高比下的效果。** 布局问题(语录溢出、作者信息被截断)会在所有评测×宽高比中重复出现;提前发现一次即可避免批量错误。
```bash
for ar in 9x16 1x1 16x9; do # 一条代表性评测,所有宽高比
npx remotion still Testimonial "out/r1-${ar}.png" --frame=215 \
--props=reviews/r1.json --props-merge="{\"aspect\":\"${ar}\"}"
done
**Before you finish:**
1. `npx remotion still` renders cleanly at quote / stars / author — no errors, no missing fonts/avatar.
2. Quote is verbatim with one emphasized span; stars fill to the real score; name/role/company exact and inside the safe zone (clear of the 9:16 bottom third).
3. Frame-driven only — no CSS/JS timers, `Date.now()`, or `Math.random()`.
4. Shipped review's props render correctly (not just `defaultProps`); `aria-label` carries the real rating.
5. One review verified in 9:16 + 1:1 + 16:9 before the batch; MP4s play; (optional) GIF for the README.
**完成前检查:**
1. `npx remotion still`渲染语录/星级/作者的静帧无错误,无字体/头像缺失。
2. 语录为原文引用且仅一个强调短语;星级填充至真实分数;姓名/职位/公司准确且位于安全区域(远离9:16底部区域)。
3. 仅由帧驱动 — 无CSS/JS定时器、`Date.now()`或`Math.random()`。
4. 实际评测的参数渲染正常(而非仅`defaultProps`);`aria-label`包含真实评分。
5. 一条评测在9:16 + 1:1 + 16:9宽高比下验证通过后再批量渲染;MP4可正常播放;(可选)生成README演示用GIF。references/quote-card.mdTestimonialreferences/batch-and-typography.mdreferences/quote-card.mdTestimonialreferences/batch-and-typography.md