Motion · Transition
Paper plane messenger transition
After a "send" click the camera pulls back from window A, a paper plane flies out along a bezier arc, and the camera follows it to window B.
Source: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Remotion composition (React/TSX) — preview video, no in-browser player
When to use
A narrative transition connecting two people/scene viewpoints via a "send/invite/share" action; when an abstract action needs a metaphorical object to carry the transition
Intention
transition-travel is the camera drilling into an existing element already on screen to change scenes; this card does the opposite — it releases a semantic messenger out of the action and follows it to the next scene. The abstract action of "sending" is made into a physical paper plane, and the spatial relationship between windows A and B gets genuinely measured out by the flight path, giving the viewer a physical sense of "where the message went." The critical piece is the 2.5D camera pipeline: every element hangs on the same world coordinate system, the camera centers on A→follows the plane→B, zooming out and back in, with parallax props moving proportionally to camera displacement by depth — a heavy true-3D shot downgraded to a "fake 3D" rig, dropping a full cost tier while keeping the sense of depth. The plane's pitch following the arc's tangent is the entire difference between "it's flying" and "it's just translating."
Duration & energy
- Duration: ~5s (150f: click 12f → pull back 16–42f → flight 34–104f → B takes over 112–146f)
- Energy: Medium
Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)
意图
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 降级)
Reference implementation (Remotion)
- demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx
The component imports shared fixtures from demos/_fixtures and helpers from assets/lib in the repo; SKILL.md there documents the render workflow.
Everything on this page is also machine-readable. Point your agent at the JSON, or add the gimgs MCP server and ask it to search motion templates.
- Fetch the JSON — it holds the shot recipe (timing, easing, parameter table, pitfalls) and the full TSX source.
- Clone the video-shotcraft repo; the component imports shared fixtures from demos/_fixtures and helpers from assets/lib.
- Register the component as a <Composition> in a Remotion project and render with `npx remotion render`, or re-implement the recipe in your own engine.
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 "Paper plane messenger transition" (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 |
|---|---|---|---|
| — | |||
More in this category

White flash: logo simplifies
A colorful liquid-gradient wordmark holds with flowing light, then the frame flashes white and overexposes; a flat wordmark fades in on white and settles — one flash completes the downgrade to simplicity.

Geometric wipe transitions
Two geometric wipe transitions — clock-wipe, a radar-hand sweep changing the page, and blinds-slice, 12 vertical strips flipping in staggered waves.

Hidden-cut transitions
Three hidden-cut transitions — foreground occlusion invisible cut, versus-slam opener, and warm light-leak burn; the hard cut hides inside 1-3 frames of occlusion/impact/light peak, so the viewer never sees the scissors.

Travel-through transitions
Two travel-through transitions — shared-element homecoming and letter-cavity pass-through; the camera drills through a real element inside the frame to complete the scene change.