{
  "slug": "paper-plane-messenger",
  "title": "Paper plane messenger transition",
  "tagline": "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.",
  "description": "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.",
  "category": "transition",
  "tags": [
    "shotcraft",
    "remotion",
    "transition",
    "camera"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 5.06,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/paper-plane-messenger/poster-ee7da9ee1ea1.jpg",
    "video": "https://gimgs.net/motion/media/paper-plane-messenger/video-f15f89cf8837.mp4"
  },
  "variants": [
    {
      "key": "paper-plane-messenger",
      "label": "paper-plane-messenger",
      "description": "Paper plane messenger transition — after a \"send\" click the camera pulls back away from window A, a folded paper plane flies out along a bezier arc (pitching to follow the tangent), the camera flies alongside it through multiple parallax layers of props, arrives and settles at window B's door, and B scales up to take over the full screen.",
      "use": "",
      "video": "https://gimgs.net/motion/media/paper-plane-messenger/video-f15f89cf8837.mp4",
      "poster": "https://gimgs.net/motion/media/paper-plane-messenger/poster-ee7da9ee1ea1.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/paper-plane-messenger.html",
  "json_url": "https://gimgs.net/motion/paper-plane-messenger.json",
  "template_url": "https://gimgs.net/motion/paper-plane-messenger/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/paper-plane-messenger.md",
  "updated_at": "2026-09-24T07:40:12.253Z",
  "prompt": "## When to use\n\nA 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\n\n## Intention\n\ntransition-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.\"\n\n## Duration & energy\n\n- Duration: ~5s (150f: click 12f → pull back 16–42f → flight 34–104f → B takes over 112–146f)\n- Energy: Medium\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\ntransition-travel 是镜头钻进画面里的既有元素换景；本卡反过来——从\n动作里**放出一个语义信使**，让镜头跟着它飞去下一个场景。\"发送\"这个\n抽象动作被实体化成折纸飞机，A/B 两窗的空间关系被飞行轨迹真实丈量\n过，观众对\"消息去了哪\"有了身体感。命门是 2.5D 相机管线：所有元素\n挂在同一世界坐标系上，镜头中心 A→跟飞机→B、变焦拉远再推近，视差\n道具按 depth 乘相机位移——重 3D 原片降级成一台\"假三维\"，成本掉一个\n量级而纵深感保留。飞机俯仰角跟随弧线切线是\"它在飞\"而非\"它在平移\"\n的全部区别。\n\n## 动效核心\n- 世界坐标系相机：`screen = 960 + (world − camCenter) × zoom × depth`；\n  镜头中心 A(520,560) → 跟飞机 → B(3200,600)，变焦 1.55（贴脸 A）\n  → 0.62（拉远伴飞）→ 3.1（B 接管全屏）\n- 飞机三次贝塞尔：A 发送按钮 → 高抛控制点（y=−80）→ 下压控制点 →\n  B 门前；朝向 = bez(t) 与 bez(t+0.012) 的 atan2 切线角\n- 飞行整体缓动 Easing.bezier(0.45,0.05,0.25,1)（起飞加速、落定\n  减速）70f；途中 scale 放大 1.7 倍保可读，落定收回 1.1\n- 视差道具 16 件（圆环/圆角方块，seed 随机）分 depth 0.45/0.75/1.3\n  三层，远层 blur3、近层 blur8 焦外；各带正弦漂浮\n- 飞机 = SVG 三块折面（白/浅灰/深灰示折痕）+ 描边；入场\n  Easing.back(1.6) 弹出，B 接管期 opacity 淡出不留残影\n- 点击脉冲：按钮 scale ×1.22 + 扩散圈（CLICK 12f 起 3f 峰值）\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 变焦三段 | 1.55 → 0.62 → 3.1 | 不先拉远直接跟飞，观众不知道 B 在哪个方位；拉远 <0.7 才装得下旅程感 |\n| 飞行时长 | 70f，ease-in-out | <45f 读作弹射；匀速读作拖拽平移 |\n| 切线采样 | dt = 0.012 | 太大角度滞后、太小抖动；俯仰角是\"飞\"的命门，删掉立即变贴图滑动 |\n| 途中放大 | ×1.7（0.25–0.75 区间），落定 ×1.1 | 拉远后飞机只剩几十像素，不放大就丢主角 |\n| 视差层 | depth 0.45/0.75/1.3，远 blur3 近 blur8 | 全同 depth 就没有穿越感；近景不虚会抢飞机视线 |\n| B 接管 | 112–146f，变焦推到 3.1，道具/飞机同步淡出 | 道具不淡出会在推近时变成巨大糊块扫过全屏 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 与 transition-travel 分工：那卡钻进**画面里既有的**元素/容器完成\n  换景（空间早已在构图里）；本卡**放出一个新实体**连接画外的第二场景\n  ——A/B 无既有空间关系时才轮到本卡\n- 与 glow-flyline-moves B（flyline-arc）分工：飞线是暗场数据流向的\n  抽象示意（线是主角），本卡是明场叙事旅程（信使是主角、镜头伴飞）\n  ——讲数据用飞线，讲人与人用信使\n- 信使实体必须来自动作语义（发送→纸飞机、上传→气球、部署→火箭），\n  随便找个吉祥物飞一趟读不出因果\n- 相机三段（拉远/伴飞/接管）的切换帧上中心与变焦都要连续——demo 里\n  zBase/zTake 在 TAKEOVER[0] 处相等（0.62）才不跳帧，改时间轴先查这点\n- 2.5D 的极限：道具只能是剪影级形状，写实道具会暴露\"没有真透视\"；\n  要重 3D 质感请回原片路线（成本高一档）\n\n## 参考实现\ndemos/transition/paper-plane-messenger/\n（PaperPlaneMessenger.tsx）\n原片出处：pitch-app 77–82s（重 3D 原版的 2.5D 降级）\n\n## Reference implementation (Remotion)\n\n- demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx\n\nThe component imports shared fixtures from `demos/_fixtures` and helpers from `assets/lib` in the repo; SKILL.md there documents the render workflow.\n",
  "flow": [
    {
      "step": "Read the recipe",
      "note": "Prompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points."
    },
    {
      "step": "Get the code",
      "tool": "git",
      "cmd": "git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install"
    },
    {
      "step": "Register + render",
      "tool": "remotion",
      "cmd": "npx remotion render <CompositionId> out.mp4   # component: demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx"
    },
    {
      "step": "Or re-implement",
      "note": "Port the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing."
    }
  ],
  "files": [
    {
      "path": "demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx"
    }
  ],
  "contract": "React/Remotion composition (TSX) from the open-source video-shotcraft library (Apache-2.0). It is not a browser-playable template: clone the repo, register the component in a Remotion project and render with `npx remotion render`. The recipe (prompt) describes timing, easing and parameters so you can also re-implement the shot in any engine.",
  "render": {
    "engine": "remotion",
    "frames": 152,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx"
    ],
    "note": "Clone https://github.com/Vincentwei1021/video-shotcraft (npm install). The component lives under demos/{category}/{name}/ and imports shared fixtures from demos/_fixtures and helpers from assets/lib. Copy it (with those imports) into a Remotion project, register a <Composition> with the template's width/height/fps/duration in Root.tsx, then `npx remotion render <CompositionId> out.mp4`. SKILL.md in the repo documents the full Claude Code / Codex workflow. The prompt below is the shot recipe (timing, easing, parameter table, pitfalls) — enough to re-implement the shot in HTML/WAAPI or another engine if you prefer."
  },
  "source": "// ===== demos/transition/paper-plane-messenger/PaperPlaneMessenger.tsx =====\n// paper-plane-messenger —— pitch-app 77–82s（2.5D 简化）\n// 点击\"发送\"后镜头拉远脱离窗口 A，折纸飞机从窗口飞出沿弧线飞行\n// （俯仰角跟随切线），镜头伴飞穿过多层视差漂浮的灰阶道具，\n// 飞抵窗口 B 前落定，窗口 B 放大接管全屏。发送语义实体化成转场信使。\nimport React from 'react';\nimport { AbsoluteFill, useCurrentFrame, interpolate, Easing } from 'remotion';\nimport { Card, G } from '../../_fixtures/Fixtures';\n\nconst mulberry32 = (a: number) => () => {\n  let t = (a += 0x6d2b79f5);\n  t = Math.imul(t ^ (t >>> 15), t | 1);\n  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n};\n\n// ---- 世界坐标 ----\nconst AX = 520, AY = 560;   // 窗口 A 中心\nconst BX = 3200, BY = 600;  // 窗口 B 中心\nconst WIN_W = 760, WIN_H = 500;\n\n// 飞机三次贝塞尔弧线：A 的发送按钮 → 高抛 → B 门前\nconst P0 = { x: AX + 300, y: AY + 150 };\nconst P1 = { x: 1250, y: -80 };\nconst P2 = { x: 2500, y: 300 };\nconst P3 = { x: BX - 470, y: BY + 20 };\nconst bez = (t: number) => {\n  const u = 1 - t;\n  return {\n    x: u * u * u * P0.x + 3 * u * u * t * P1.x + 3 * u * t * t * P2.x + t * t * t * P3.x,\n    y: u * u * u * P0.y + 3 * u * u * t * P1.y + 3 * u * t * t * P2.y + t * t * t * P3.y,\n  };\n};\n\n// ---- 时间轴（150f）----\nconst CLICK = 12;        // 点击发送\nconst ZOOM_OUT = [16, 42] as const; // 镜头拉远\nconst FLY = [34, 104] as const;     // 飞行\nconst TAKEOVER = [112, 146] as const; // 窗口 B 接管\n\n// 视差道具（圆环/方块），depth: 0.45 远 / 0.75 中 / 1.3 近\ntype Prop = { x: number; y: number; size: number; ring: boolean; depth: number; drift: number };\nconst PROPS: Prop[] = (() => {\n  const rng = mulberry32(42);\n  const out: Prop[] = [];\n  const depths = [0.45, 0.75, 1.3];\n  for (let i = 0; i < 16; i++) {\n    const depth = depths[i % 3];\n    out.push({\n      x: 500 + rng() * 2800,\n      y: -150 + rng() * 1350,\n      size: depth < 0.6 ? 60 + rng() * 70 : depth < 1 ? 110 + rng() * 100 : 220 + rng() * 160,\n      ring: rng() > 0.45,\n      depth,\n      drift: rng() * Math.PI * 2,\n    });\n  }\n  return out;\n})();\n\nconst Window: React.FC<{ cx: number; cy: number; seed: number; sendBtn?: boolean; btnPulse?: number }> = ({ cx, cy, seed, sendBtn, btnPulse = 0 }) => (\n  <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' }}>\n    <div style={{ height: 54, background: '#f2f2f0', borderBottom: `2px solid ${G.line}`, display: 'flex', alignItems: 'center', gap: 10, padding: '0 20px', boxSizing: 'border-box' }}>\n      {[0, 1, 2].map((i) => <div key={i} style={{ width: 15, height: 15, borderRadius: 8, background: G.bar }} />)}\n      <div style={{ marginLeft: 14, height: 13, width: 200, background: G.bar, borderRadius: 7 }} />\n    </div>\n    <div style={{ padding: 26, display: 'flex', flexDirection: 'column', gap: 16 }}>\n      <Card w={WIN_W - 56} h={250} seed={seed} style={{ boxShadow: 'none' }} />\n      {sendBtn && (\n        <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' }}>\n          Send ➤\n        </div>\n      )}\n    </div>\n  </div>\n);\n\nconst Plane: React.FC<{ x: number; y: number; angle: number; scale: number; opacity: number }> = ({ x, y, angle, scale, opacity }) => (\n  <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 }}>\n    {/* 折纸飞机：三块折面，灰阶深浅示折痕 */}\n    <polygon points=\"176,30 6,4 62,66\" fill=\"#ffffff\" stroke=\"#b8b8b6\" strokeWidth={3} />\n    <polygon points=\"176,30 62,66 78,102\" fill=\"#d4d4d2\" stroke=\"#b8b8b6\" strokeWidth={3} />\n    <polygon points=\"176,30 6,4 50,44\" fill=\"#efefed\" stroke=\"#c6c6c4\" strokeWidth={2} />\n  </svg>\n);\n\nexport const PaperPlaneMessenger: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  // 飞行进度（整体 ease-in-out：起飞加速、落定减速）\n  const tFly = interpolate(frame, [FLY[0], FLY[1]], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    easing: Easing.bezier(0.45, 0.05, 0.25, 1),\n  });\n  const pos = bez(tFly);\n  const posNext = bez(Math.min(tFly + 0.012, 1));\n  const angle = Math.atan2(posNext.y - pos.y, posNext.x - pos.x) * (180 / Math.PI);\n\n  // ---- 镜头 ----\n  const zoomOutP = interpolate(frame, [ZOOM_OUT[0], ZOOM_OUT[1]], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.cubic),\n  });\n  const takeP = interpolate(frame, [TAKEOVER[0], TAKEOVER[1]], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.cubic),\n  });\n  // 镜头中心：A → 跟飞机 → B\n  const followW = interpolate(frame, [ZOOM_OUT[0], ZOOM_OUT[1]], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n  let cx = AX + (pos.x - AX) * followW;\n  let cy = (AY + (pos.y - AY) * followW) * (1 - takeP) + 0; // 占位，下一行合成\n  cy = AY + (Math.max(pos.y, 150) - AY) * followW;\n  cx = cx * (1 - takeP) + BX * takeP;\n  cy = cy * (1 - takeP) + BY * takeP;\n  // 变焦：1.55（贴脸 A）→ 0.62（拉远伴飞）→ 2.6（B 接管全屏）\n  const zBase = 1.55 + (0.62 - 1.55) * zoomOutP;\n  const zTake = 0.62 + (3.1 - 0.62) * takeP;\n  const z = frame < TAKEOVER[0] ? zBase : zTake;\n\n  const camX = (wx: number, d: number) => 960 + (wx - cx) * z * d;\n  const camY = (wy: number, d: number) => 540 + (wy - cy) * z * d;\n\n  const btnPulse = interpolate(frame, [CLICK, CLICK + 3, CLICK + 12], [0, 1, 0], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.quad),\n  });\n  const planeVisible = frame >= FLY[0] - 2;\n  // 飞行途中放大到 1.7 增强可读性，落定收回 1.1\n  const flightBoost = interpolate(tFly, [0, 0.25, 0.75, 1], [1, 1.7, 1.7, 1.1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n  const planeScale = interpolate(frame, [FLY[0] - 2, FLY[0] + 8], [0.3, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.back(1.6)),\n  }) * flightBoost;\n  // 窗口 B 接管时飞机淡出，不留残影\n  const planeOpacity = interpolate(takeP, [0, 0.35], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n\n  return (\n    <AbsoluteFill style={{ background: 'linear-gradient(160deg, #e9e9e7 0%, #dcdcda 100%)', overflow: 'hidden' }}>\n      {/* 视差道具：远层 / 中层（世界层之下） */}\n      {PROPS.filter((p) => p.depth < 1).map((p, i) => {\n        const wob = Math.sin(frame * 0.035 + p.drift) * 14;\n        const x = camX(p.x, p.depth) + wob;\n        const y = camY(p.y, p.depth) + Math.cos(frame * 0.03 + p.drift) * 10;\n        const s = p.size * z * p.depth;\n        const fade = interpolate(takeP, [0.3, 0.9], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n        return (\n          <div key={i} style={{\n            position: 'absolute', left: x - s / 2, top: y - s / 2, width: s, height: s,\n            opacity: (p.depth < 0.6 ? 0.5 : 0.75) * fade,\n            borderRadius: p.ring ? '50%' : 14,\n            background: p.ring ? 'transparent' : '#c9c9c7',\n            border: p.ring ? `${Math.max(s * 0.13, 4)}px solid #bfbfbd` : 'none',\n            filter: p.depth < 0.6 ? 'blur(3px)' : 'none',\n            boxSizing: 'border-box',\n          }} />\n        );\n      })}\n\n      {/* 世界层（depth=1）：窗口 A / B + 飞机 */}\n      <div style={{ position: 'absolute', left: 0, top: 0, transformOrigin: '0 0', transform: `translate(${960 - cx * z}px, ${540 - cy * z}px) scale(${z})` }}>\n        <Window cx={AX} cy={AY} seed={3} sendBtn btnPulse={btnPulse} />\n        <Window cx={BX} cy={BY} seed={6} />\n        {planeVisible && planeOpacity > 0.01 && <Plane x={pos.x} y={pos.y} angle={angle} scale={planeScale} opacity={planeOpacity} />}\n      </div>\n\n      {/* 近景道具（世界层之上，焦外大件） */}\n      {PROPS.filter((p) => p.depth >= 1).map((p, i) => {\n        const wob = Math.sin(frame * 0.04 + p.drift) * 20;\n        const x = camX(p.x, p.depth) + wob;\n        const y = camY(p.y, p.depth) + Math.cos(frame * 0.033 + p.drift) * 16;\n        const s = p.size * z * p.depth;\n        const fade = interpolate(takeP, [0.2, 0.7], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n        return (\n          <div key={i} style={{\n            position: 'absolute', left: x - s / 2, top: y - s / 2, width: s, height: s,\n            opacity: 0.55 * fade,\n            borderRadius: p.ring ? '50%' : 22,\n            background: p.ring ? 'transparent' : '#b5b5b3',\n            border: p.ring ? `${Math.max(s * 0.12, 6)}px solid #adadab` : 'none',\n            filter: 'blur(8px)', boxSizing: 'border-box',\n          }} />\n        );\n      })}\n    </AbsoluteFill>\n  );\n};\n\n\n"
}