Motion · Chuyển cảnh
Chuyển cảnh máy bay giấy đưa tin
Sau khi bấm "gửi", camera lùi xa cửa sổ A, một máy bay giấy bay theo cung Bézier, camera bám theo nó tới cửa sổ B.
Nguồn: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composition Remotion (React/TSX) — có video preview, không có player trong trình duyệt
Khi nào dùng
Chuyển cảnh kể chuyện nối hai góc nhìn nhân vật/bối cảnh bằng hành động "gửi/mời/chia sẻ"; khi một hành động trừu tượng cần một thực thể ẩn dụ làm phương tiện chuyển cảnh
Ý đồ
transition-travel là camera khoan sâu vào một yếu tố có sẵn trên màn hình để đổi cảnh; cảnh này làm ngược lại — nó thả ra một sứ giả ngữ nghĩa từ hành động, rồi camera bám theo nó bay tới cảnh sau. Hành động trừu tượng "gửi" được hiện thực hóa thành máy bay giấy, quan hệ không gian giữa hai cửa sổ A/B được đo đạc thật sự bằng quỹ đạo bay, người xem có cảm nhận cơ thể về "tin nhắn đã đi đâu". Mấu chốt là hệ thống camera 2.5D: mọi yếu tố treo trên cùng một hệ tọa độ thế giới, camera lấy A làm tâm → bám theo máy bay → tới B, zoom ra rồi zoom vào lại, các đạo cụ thị sai di chuyển theo độ sâu tỉ lệ với dịch chuyển camera — một cảnh 3D thật nặng được hạ cấp thành bộ máy "giả ba chiều", giảm hẳn một bậc chi phí mà vẫn giữ được cảm giác chiều sâu. Góc ngẩng/cúi của máy bay bám theo tiếp tuyến của cung bay là toàn bộ khác biệt giữa "nó đang bay" và "nó chỉ đang dịch chuyển".
Thời lượng & năng lượng
- Thời lượng: ~5s (150f: bấm 12f → lùi xa 16–42f → bay 34–104f → B tiếp quản 112–146f)
- Năng lượng: Trung bình
Công thức cảnh quay (bản gốc tiếng Trung — timing, easing, bảng tham số, lỗi hay gặp)
意图
transition-travel 是镜头钻进画面里的既有元素换景;本卡反过来——从 动作里放出一个语义信使,让镜头跟着它飞去下一个场景。"发送"这个 抽象动作被实体化成折纸飞机,A/B 两窗的空间关系被飞行轨迹真实丈量 过,观众对"消息去了哪"有了身体感。命门是 2.5D 相机管线:所有元素 挂在同一世界坐标系上,镜头中心 A→跟飞机→B、变焦拉远再推近,视差 道具按 depth 乘相机位移——重 3D 原片降级成一台"假三维",成本掉一个 量级而纵深感保留。飞机俯仰角跟随弧线切线是"它在飞"而非"它在平移" 的全部区别。
动效核心
- 世界坐标系相机:
screen = 960 + (world − camCenter) × zoom × depth;
镜头中心 A(520,560) → 跟飞机 → B(3200,600),变焦 1.55(贴脸 A) → 0.62(拉远伴飞)→ 3.1(B 接管全屏)
- 飞机三次贝塞尔:A 发送按钮 → 高抛控制点(y=−80)→ 下压控制点 →
B 门前;朝向 = bez(t) 与 bez(t+0.012) 的 atan2 切线角
- 飞行整体缓动 Easing.bezier(0.45,0.05,0.25,1)(起飞加速、落定
减速)70f;途中 scale 放大 1.7 倍保可读,落定收回 1.1
- 视差道具 16 件(圆环/圆角方块,seed 随机)分 depth 0.45/0.75/1.3
三层,远层 blur3、近层 blur8 焦外;各带正弦漂浮
- 飞机 = SVG 三块折面(白/浅灰/深灰示折痕)+ 描边;入场
Easing.back(1.6) 弹出,B 接管期 opacity 淡出不留残影
- 点击脉冲:按钮 scale ×1.22 + 扩散圈(CLICK 12f 起 3f 峰值)
参数表
| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 变焦三段 | 1.55 → 0.62 → 3.1 | 不先拉远直接跟飞,观众不知道 B 在哪个方位;拉远 <0.7 才装得下旅程感 | | 飞行时长 | 70f,ease-in-out | <45f 读作弹射;匀速读作拖拽平移 | | 切线采样 | dt = 0.012 | 太大角度滞后、太小抖动;俯仰角是"飞"的命门,删掉立即变贴图滑动 | | 途中放大 | ×1.7(0.25–0.75 区间),落定 ×1.1 | 拉远后飞机只剩几十像素,不放大就丢主角 | | 视差层 | depth 0.45/0.75/1.3,远 blur3 近 blur8 | 全同 depth 就没有穿越感;近景不虚会抢飞机视线 | | B 接管 | 112–146f,变焦推到 3.1,道具/飞机同步淡出 | 道具不淡出会在推近时变成巨大糊块扫过全屏 |
已知坑
- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿,
首次实战须以真实素材回验
- 与 transition-travel 分工:那卡钻进画面里既有的元素/容器完成
换景(空间早已在构图里);本卡放出一个新实体连接画外的第二场景 ——A/B 无既有空间关系时才轮到本卡
- 与 glow-flyline-moves B(flyline-arc)分工:飞线是暗场数据流向的
抽象示意(线是主角),本卡是明场叙事旅程(信使是主角、镜头伴飞) ——讲数据用飞线,讲人与人用信使
- 信使实体必须来自动作语义(发送→纸飞机、上传→气球、部署→火箭),
随便找个吉祥物飞一趟读不出因果
- 相机三段(拉远/伴飞/接管)的切换帧上中心与变焦都要连续——demo 里
zBase/zTake 在 TAKEOVER[0] 处相等(0.62)才不跳帧,改时间轴先查这点
- 2.5D 的极限:道具只能是剪影级形状,写实道具会暴露"没有真透视";
要重 3D 质感请回原片路线(成本高一档)
参考实现
demos/transition/paper-plane-messenger/ (PaperPlaneMessenger.tsx) 原片出处:pitch-app 77–82s(重 3D 原版的 2.5D 降级)
Mã nguồn tham chiếu (Remotion)
- demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx
Component import fixture chung từ demos/_fixtures và helper từ assets/lib của repo; xem SKILL.md trong repo để biết quy trình render.
Mọi thứ trên trang này đều có bản máy đọc. Đưa agent link JSON, hoặc thêm MCP server gimgs rồi bảo nó tìm motion template.
- Tải JSON — trong đó có công thức cảnh quay (timing, easing, bảng tham số, lỗi hay gặp) và toàn bộ source TSX.
- Clone repo video-shotcraft; component import fixture chung từ demos/_fixtures và helper từ assets/lib.
- Đăng ký component thành <Composition> trong project Remotion rồi render bằng `npx remotion render`, hoặc dựng lại công thức bằng engine của bạn.
curl -s https://gimgs.net/motion/paper-plane-messenger.jsongit clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx
npx remotion render <CompositionId> out.mp4claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("paper-plane-messenger")Prompt
Use the video-shotcraft shot recipe "Chuyển cảnh máy bay giấy đưa tin" (Remotion composition, Apache-2.0) published on gimgs.net. Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/paper-plane-messenger.json Raw TSX: https://gimgs.net/motion/paper-plane-messenger/template.html Repository: https://github.com/Vincentwei1021/video-shotcraft (component: demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx; imports demos/_fixtures and assets/lib) Spec: 1920x1080, 30 fps, 5.06s 1. Read the JSON and follow its "prompt" section (when to use, intention, parameter table, known pitfalls). 2. Adapt it for: <describe what you want>. 3. Either clone the repo, register the component as a <Composition> in a Remotion project and run npx remotion render <CompositionId> out.mp4 — or re-implement the recipe in your own engine (HTML/WAAPI works: keep the same timing and easing).
// ===== demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx =====
// paper-plane-messenger —— pitch-app 77–82s(2.5D 简化)
// 点击"发送"后镜头拉远脱离窗口 A,折纸飞机从窗口飞出沿弧线飞行
// (俯仰角跟随切线),镜头伴飞穿过多层视差漂浮的灰阶道具,
// 飞抵窗口 B 前落定,窗口 B 放大接管全屏。发送语义实体化成转场信使。
import React from 'react';
import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from 'remotion';
import { Card, G } from '../../_fixtures/Fixtures';
const mulberry32 = (a: number) => () => {
let t = (a += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
// ---- 世界坐标 ----
const AX = 520, AY = 560; // 窗口 A 中心
const BX = 3200, BY = 600; // 窗口 B 中心
const WIN_W = 760, WIN_H = 500;
// 飞机三次贝塞尔弧线:A 的发送按钮 → 高抛 → B 门前
const P0 = { x: AX + 300, y: AY + 150 };
const P1 = { x: 1250, y: -80 };
const P2 = { x: 2500, y: 300 };
const P3 = { x: BX - 470, y: BY + 20 };
const bez = (t: number) => {
const u = 1 - t;
return {
x: u * u * u * P0.x + 3 * u * u * t * P1.x + 3 * u * t * t * P2.x + t * t * t * P3.x,
y: u * u * u * P0.y + 3 * u * u * t * P1.y + 3 * u * t * t * P2.y + t * t * t * P3.y,
};
};
// ---- 时间轴(150f)----
const CLICK = 12; // 点击发送
const ZOOM_OUT = [16, 42] as const; // 镜头拉远
const FLY = [34, 104] as const; // 飞行
const TAKEOVER = [112, 146] as const; // 窗口 B 接管
// 视差道具(圆环/方块),depth: 0.45 远 / 0.75 中 / 1.3 近
type Prop = { x: number; y: number; size: number; ring: boolean; depth: number; drift: number };
const PROPS: Prop[] = (() => {
const rng = mulberry32(42);
const out: Prop[] = [];
const depths = [0.45, 0.75, 1.3];
for (let i = 0; i < 16; i++) {
const depth = depths[i % 3];
out.push({
x: 500 + rng() * 2800,
y: -150 + rng() * 1350,
size: depth < 0.6 ? 60 + rng() * 70 : depth < 1 ? 110 + rng() * 100 : 220 + rng() * 160,
ring: rng() > 0.45,
depth,
drift: rng() * Math.PI * 2,
});
}
return out;
})();
const Window: React.FC<{ cx: number; cy: number; seed: number; sendBtn?: boolean; btnPulse?: number }> = ({ cx, cy, seed, sendBtn, btnPulse = 0 }) => (
<div style={{ position: 'absolute', left: cx - WIN_W / 2, top: cy - WIN_H / 2, width: WIN_W, height: WIN_H, borderRadius: 18, background: '#fff', border: `2px solid ${G.border}`, boxShadow: '0 40px 100px rgba(0,0,0,0.30)', boxSizing: 'border-box', overflow: 'hidden' }}>
<div style={{ height: 54, background: '#f2f2f0', borderBottom: `2px solid ${G.line}`, display: 'flex', alignItems: 'center', gap: 10, padding: '0 20px', boxSizing: 'border-box' }}>
{[0, 1, 2].map((i) => <div key={i} style={{ width: 15, height: 15, borderRadius: 8, background: G.bar }} />)}
<div style={{ marginLeft: 14, height: 13, width: 200, background: G.bar, borderRadius: 7 }} />
</div>
<div style={{ padding: 26, display: 'flex', flexDirection: 'column', gap: 16 }}>
<Card w={WIN_W - 56} h={250} seed={seed} style={{ boxShadow: 'none' }} />
{sendBtn && (
<div style={{ alignSelf: 'flex-end', transform: `scale(${1 + btnPulse * 0.22})`, padding: '16px 46px', borderRadius: 12, background: G.ink, color: '#fff', fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800, fontSize: 30, boxShadow: btnPulse > 0 ? `0 0 0 ${btnPulse * 22}px rgba(47,47,47,0.18)` : 'none' }}>
Send ➤
</div>
)}
</div>
</div>
);
const Plane: React.FC<{ x: number; y: number; angle: number; scale: number; opacity: number }> = ({ x, y, angle, scale, opacity }) => (
<svg width={180} height={110} viewBox="0 0 180 110" style={{ position: 'absolute', left: x - 90, top: y - 55, transform: `rotate(${angle}deg) scale(${scale})`, overflow: 'visible', opacity }}>
{/* 折纸飞机:三块折面,灰阶深浅示折痕 */}
<polygon points="176,30 6,4 62,66" fill="#ffffff" stroke="#b8b8b6" strokeWidth={3} />
<polygon points="176,30 62,66 78,102" fill="#d4d4d2" stroke="#b8b8b6" strokeWidth={3} />
<polygon points="176,30 6,4 50,44" fill="#efefed" stroke="#c6c6c4" strokeWidth={2} />
</svg>
);
export const PaperPlaneMessenger: React.FC = () => {
const frame = useCurrentFrame();
// 飞行进度(整体 ease-in-out:起飞加速、落定减速)
const tFly = interpolate(frame, [FLY[0], FLY[1]], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.bezier(0.45, 0.05, 0.25, 1),
});
const pos = bez(tFly);
const posNext = bez(Math.min(tFly + 0.012, 1));
const angle = Math.atan2(posNext.y - pos.y, posNext.x - pos.x) * (180 / Math.PI);
// ---- 镜头 ----
const zoomOutP = interpolate(frame, [ZOOM_OUT[0], ZOOM_OUT[1]], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.cubic),
});
const takeP = interpolate(frame, [TAKEOVER[0], TAKEOVER[1]], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.cubic),
});
// 镜头中心:A → 跟飞机 → B
const followW = interpolate(frame, [ZOOM_OUT[0], ZOOM_OUT[1]], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
let cx = AX + (pos.x - AX) * followW;
let cy = (AY + (pos.y - AY) * followW) * (1 - takeP) + 0; // 占位,下一行合成
cy = AY + (Math.max(pos.y, 150) - AY) * followW;
cx = cx * (1 - takeP) + BX * takeP;
cy = cy * (1 - takeP) + BY * takeP;
// 变焦:1.55(贴脸 A)→ 0.62(拉远伴飞)→ 2.6(B 接管全屏)
const zBase = 1.55 + (0.62 - 1.55) * zoomOutP;
const zTake = 0.62 + (3.1 - 0.62) * takeP;
const z = frame < TAKEOVER[0] ? zBase : zTake;
const camX = (wx: number, d: number) => 960 + (wx - cx) * z * d;
const camY = (wy: number, d: number) => 540 + (wy - cy) * z * d;
const btnPulse = interpolate(frame, [CLICK, CLICK + 3, CLICK + 12], [0, 1, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.quad),
});
const planeVisible = frame >= FLY[0] - 2;
// 飞行途中放大到 1.7 增强可读性,落定收回 1.1
const flightBoost = interpolate(tFly, [0, 0.25, 0.75, 1], [1, 1.7, 1.7, 1.1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
const planeScale = interpolate(frame, [FLY[0] - 2, FLY[0] + 8], [0.3, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.back(1.6)),
}) * flightBoost;
// 窗口 B 接管时飞机淡出,不留残影
const planeOpacity = interpolate(takeP, [0, 0.35], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
return (
<AbsoluteFill style={{ background: 'linear-gradient(160deg, #e9e9e7 0%, #dcdcda 100%)', overflow: 'hidden' }}>
{/* 视差道具:远层 / 中层(世界层之下) */}
{PROPS.filter((p) => p.depth < 1).map((p, i) => {
const wob = Math.sin(frame * 0.035 + p.drift) * 14;
const x = camX(p.x, p.depth) + wob;
const y = camY(p.y, p.depth) + Math.cos(frame * 0.03 + p.drift) * 10;
const s = p.size * z * p.depth;
const fade = interpolate(takeP, [0.3, 0.9], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
return (
<div key={i} style={{
position: 'absolute', left: x - s / 2, top: y - s / 2, width: s, height: s,
opacity: (p.depth < 0.6 ? 0.5 : 0.75) * fade,
borderRadius: p.ring ? '50%' : 14,
background: p.ring ? 'transparent' : '#c9c9c7',
border: p.ring ? `${Math.max(s * 0.13, 4)}px solid #bfbfbd` : 'none',
filter: p.depth < 0.6 ? 'blur(3px)' : 'none',
boxSizing: 'border-box',
}} />
);
})}
{/* 世界层(depth=1):窗口 A / B + 飞机 */}
<div style={{ position: 'absolute', left: 0, top: 0, transformOrigin: '0 0', transform: `translate(${960 - cx * z}px, ${540 - cy * z}px) scale(${z})` }}>
<Window cx={AX} cy={AY} seed={3} sendBtn btnPulse={btnPulse} />
<Window cx={BX} cy={BY} seed={6} />
{planeVisible && planeOpacity > 0.01 && <Plane x={pos.x} y={pos.y} angle={angle} scale={planeScale} opacity={planeOpacity} />}
</div>
{/* 近景道具(世界层之上,焦外大件) */}
{PROPS.filter((p) => p.depth >= 1).map((p, i) => {
const wob = Math.sin(frame * 0.04 + p.drift) * 20;
const x = camX(p.x, p.depth) + wob;
const y = camY(p.y, p.depth) + Math.cos(frame * 0.033 + p.drift) * 16;
const s = p.size * z * p.depth;
const fade = interpolate(takeP, [0.2, 0.7], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
return (
<div key={i} style={{
position: 'absolute', left: x - s / 2, top: y - s / 2, width: s, height: s,
opacity: 0.55 * fade,
borderRadius: p.ring ? '50%' : 22,
background: p.ring ? 'transparent' : '#b5b5b3',
border: p.ring ? `${Math.max(s * 0.12, 6)}px solid #adadab` : 'none',
filter: 'blur(8px)', boxSizing: 'border-box',
}} />
);
})}
</AbsoluteFill>
);
};
- Read the recipePrompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points.
- Get the codetool: git
git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install - Register + rendertool: remotion
npx remotion render <CompositionId> out.mp4 # component: demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx - Or re-implementPort the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing.
| param | type | default | description |
|---|---|---|---|
| — | |||
Cùng danh mục

Chớp trắng: logo giản lược
Chữ hiệu gradient lỏng nhiều màu đứng yên chảy sáng, màn hình chớp trắng cháy sáng một nhịp, chữ hiệu phiên bản phẳng trên nền trắng mờ vào định hình — một lần chớp trắng hoàn tất bước giản lược chất liệu.

Chuyển cảnh quét hình học
Hai kiểu chuyển cảnh quét hình học — clock-wipe kim đồng hồ radar quét đổi trang và blinds-slice 12 dải dọc lật lệch nhịp thành sóng.

Chuyển cảnh giấu điểm cắt
Ba kiểu chuyển cảnh giấu điểm cắt — che khuất tiền cảnh cắt vô hình, đối đầu va chạm mở màn, và cháy sáng rò rỉ ánh sáng ấm; cắt cứng giấu trong 1-3 khung che khuất/va chạm/đỉnh sáng, khán giả không thấy kéo cắt.

Chuyển cảnh xuyên qua
Hai kiểu chuyển cảnh xuyên qua — trở về vị trí bằng phần tử chung và xuyên khoang chữ, máy quay khoan xuyên phần tử thực trong khung hình để hoàn tất đổi cảnh.