{
  "slug": "shot-transitions",
  "title": "Shot transitions six",
  "tagline": "Six shot hand-off techniques — flash-cut, fly-through, focus relay, black card, whip-pan, mask-wipe — chosen by energy delta.",
  "description": "Six shot hand-off techniques — flash-cut, fly-through, focus relay, black card, whip-pan, mask-wipe — chosen by energy delta.",
  "category": "transition",
  "tags": [
    "shotcraft",
    "remotion",
    "transition"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 960,
    "height": 540,
    "fps": 30,
    "duration": 1.43,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/shot-transitions/poster-dc3edd3f12b8.jpg",
    "video": "https://gimgs.net/motion/media/shot-transitions/video-cbb2cab300eb.mp4"
  },
  "variants": [
    {
      "key": "flash-cut",
      "label": "flash-cut · Push-in white flash",
      "description": "The outgoing shot's camera pushes in, and the cut point straddles a white flash that covers the page change.",
      "use": "Interaction pass-through, page→page with similar energy levels (already in the library, folded into this family)",
      "video": "https://gimgs.net/motion/media/shot-transitions/video-cbb2cab300eb.mp4",
      "poster": "https://gimgs.net/motion/media/shot-transitions/poster-dc3edd3f12b8.jpg"
    },
    {
      "key": "shot-transitions-4",
      "label": "shot-transitions-4 · Deep-space fly-through",
      "description": "The camera pushes out past the foreground along its direction of motion → glides through a few frames of pure darkness → the background zooms in to meet it from the depth of field — a single unbroken take, no cut.",
      "use": "High-energy → high-energy scene jumps; the workhorse transition for a dark-toned film",
      "video": "https://gimgs.net/motion/media/shot-transitions/video-7a5b3da76de7.mp4",
      "poster": "https://gimgs.net/motion/media/shot-transitions/poster-shot-transitions-4-48a435ad4eb1.jpg"
    },
    {
      "key": "shot-transitions-5",
      "label": "shot-transitions-5 · Rack-focus relay",
      "description": "The foreground slides out of the focal plane (blur deepening) while the background racks into focus from the opposite direction at the same time — focus itself is the cut point.",
      "use": "Section→section within the same page; segmenting a document/long-page tour",
      "video": "https://gimgs.net/motion/media/shot-transitions/video-c334c7bbe134.mp4",
      "poster": "https://gimgs.net/motion/media/shot-transitions/poster-shot-transitions-5-11374d6c40e8.jpg"
    },
    {
      "key": "shot-transitions-6",
      "label": "shot-transitions-6 · Black title card",
      "description": "The outgoing shot fades to black, a title card appears via typewriter/letterpress effect, then hands off to the next shot.",
      "use": "A two-in-one of chapter-level segmentation plus a breathing beat (a dark-mode variant of paper-title-card)",
      "video": "https://gimgs.net/motion/media/shot-transitions/video-b04c57a0ecb6.mp4",
      "poster": "https://gimgs.net/motion/media/shot-transitions/poster-shot-transitions-6-273482ab6a84.jpg"
    },
    {
      "key": "whip-pan",
      "label": "whip-pan · Whip pan",
      "description": "The camera whips sideways in one beat to the next scene, blurring into total illegibility mid-motion, using the blurred frames to hide the scene change.",
      "use": "Fast-paced feature sections in rapid succession (a light, quick high-energy→high-energy option, cheaper in frames and faster than style B)",
      "video": "https://gimgs.net/motion/media/shot-transitions/video-1a9780360673.mp4",
      "poster": "https://gimgs.net/motion/media/shot-transitions/poster-whip-pan-961f0d6b7548.jpg"
    },
    {
      "key": "mask-wipe",
      "label": "mask-wipe · Window wipe",
      "description": "A real element on the page (a card) scales up into a full-screen window, and the new scene grows out of it to take over.",
      "use": "A semantic seam: feature overview → that feature's detail (\"click this card to enter its world\")",
      "video": "https://gimgs.net/motion/media/shot-transitions/video-e380b490ef75.mp4",
      "poster": "https://gimgs.net/motion/media/shot-transitions/poster-mask-wipe-958b04d53ac7.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/shot-transitions.html",
  "json_url": "https://gimgs.net/motion/shot-transitions.json",
  "template_url": "https://gimgs.net/motion/shot-transitions/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/shot-transitions.md",
  "updated_at": "2026-09-24T07:40:23.422Z",
  "prompt": "## When to use\n\nAny seam between two shots (a technique card: once shots are laid out at the storyboard stage, pick one style per seam)\n\n## Intention\n\nEvery other shot card in the library only handles its own entrance and exit — how shots actually \"connect\" has never had a vocabulary. A recognized, excellent launch film (Linear Releases/Agent, frame-by-frame reverse-engineered 2026-07-11) never uses a single hard cut throughout: every scene change relies on either a depth journey, a focus relay, or a black title card to build momentum.\n\n## Duration & energy\n\n- Duration: n/a (a technique card; each style's frame cost comes from its parameter sheet, carved out of the neighboring shots' budget)\n- Energy: n/a (a technique card, doesn't occupy an energy slot)\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n库内各镜头卡只管自己的入场和收尾，镜与镜\"怎么接\"一直没有词汇——\n接缝裸切会把逐镜头攒出的电影感一次漏光。公认优秀的发布片\n（Linear Releases/Agent，2026-07-11 抽帧逆向）全程没有一次裸切：\n换景要么靠深度旅行、要么靠焦点接力、要么靠黑场字卡蓄力。\n\n## 六式选型\n\n| 式 | 做法 | 适用接缝 |\n|----|------|----------|\n| A 推进流白 flash-cut | 前镜相机推进，切点跨骑一层白闪盖住换页 | 交互穿透、能量相近的页面→页面（库内已有，收编）|\n| B 穿暗场直航 | 相机顺运动方向推出前景→纯暗场滑行几帧→后景从景深迎面放大而来，一条 take 不切 | 高能量→高能量的场景跳转；暗色调全片的主力转场 |\n| C 虚焦接力 | 前景滑出焦平面（blur 渐深）同时后景反向收焦入场，焦点当剪辑点 | 同页面内区块→区块；文档/长页游览的分段 |\n| D 黑场字卡 | 前镜收尾淡入黑场，字卡打字机/压印出现，再交棒后镜 | 章节级分段 + 呼吸位二合一（paper-title-card 的暗场变体）|\n| E whip-pan 甩镜 | 机位一拍横甩到下一场景，中段运动模糊糊到不可辨，借糊帧换景 | 快节奏功能段连打（高能量→高能量的轻快款，比 B 式省帧提速）|\n| F mask-wipe 穿窗 | 页面内真实元素（卡片）放大成全屏窗口，新场景从窗内长出接管 | 语义化接缝：功能总览→该功能详情（\"点开这张卡进入它的世界\"）|\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| A flash-cut | `assets/lib/FlashCut.tsx`，from = 切点 − 5，两侧各 5f | 用法详见 type-and-filter 卡；白闪只盖硬切，不当装饰光效（Q4）|\n| B 暗场滑行 | 前镜推出 8–12f → 暗场 4–8f → 后景放大入场 10–14f；后景从 scale ~0.6 + blur 起步收焦 | 暗场段不能全黑死场——保留背景微渐变/尘点让\"still moving\"可感；滑行帧过长读作断片，>10f 须有元素在动；推出/迎入的快速运镜可包 `<CameraMotionBlur>`（参数见 deck-deal-flyin 运动模糊行） |\n| B 运动方向 | 出与入同向（前镜向右推出→后景从右迎来），速度曲线连续不回摆 | 方向不接读作两条片子硬拼；参考片 Releases 全程一条 take 的秘密就是方向连续 |\n| C 焦点交换 | 前景 blur 0→8px 同时后景 8px→0，交叉窗口 10–16f；两景错开 2–4f 起跑 | 同帧起跑读作整屏糊掉；浅景深要贯穿该段落而非突然出现（DoF 语言先立后用）|\n| D 黑场字卡 | 前镜淡出 6–10f → 字卡段（参数走 paper-title-card，底色换暗场）→ 淡入后镜 6–10f | 暗底字卡的字色用页面底色（浅色）/等宽字才不像报错弹窗；一支 30s 片 D 式 ≤2 次，多了节奏碎 |\n| E 甩镜基本款 | 两端各 hold ≥20f → 甩动 8f 跨约 1.5 屏（ease-in-out 陡曲线）→ 直接落位；`<CameraMotionBlur shutterAngle={200} samples={8}>` 只包甩动段 | 跨度按\"峰值速度 ≥300px/f\"配，不足则糊不透露出换景穿帮；两景同一水平线上拼接（页面空间同 y），甩完即停 |\n| E 甩镜急刹款 | 前 70% 路程用 30% 时间（糊段）→ 后 30% 路程 ease-out 长尾 ~48f 滑入落位 | 适合落点是\"要读内容\"的镜头——长尾给观众视线一个追上的坡；长尾 >60f 偷走后镜入场，宁短勿长 |\n| F 穿窗 | 元素 45f 放大成全屏（单段 bezier），窗内新景从 ~0.42 缩放反向补偿长到 1，元素脸前半段渐隐（opacity 1−t·2.2） | 窗几何与窗内景必须同步驱动（同一个 t），异步读作穿帮；被放大元素用高清纹理（Q2，全程被特写）|\n| F 穿窗纵深款 portal-wipe | 窗放大 40f 用 bezier(0.7,0,0.3,1) 先慢后快；窗内**只 2 层**视差（远景整页缩略系数 0.08 + 近景 2 卡 0.3，不加 blur），穿窗完成后 8f 缓停 → 静止 ≥30f 读清新景 | 层数/散开是可读性命门——3 层+0.85 散开+blur 版被裁，收敛后过；穿完必须真停，视差飘着不停偷走后镜阅读 |\n\n## 已知坑\n- 分镜表阶段就逐接缝标注转场式（写进\"关键动效\"列），逐镜头阶段才补\n  等于全线挪帧——转场占用的帧从相邻镜头预算划走，规矩同 hold 预算（R3）\n- B/C 两式借鉴自 Linear 发布片抽帧逆向（2026-07-11，进化轮 #3），\n  非用户判例——是默认建议不是令，与判例冲突时判例优先\n- B 式对源素材要求高：后景迎面放大 = 纹理被放大看，低倍截图先过\n  审美准则 Q2 的高分辨率栅格化技法再上\n- 转场不叠加：一个接缝只用一式；B 式暗场里再加 flash 白闪读作穿帮\n- E/F 式参数经占位素材调校+真实素材转正，非用户\n  实战判例——单例判例，与判例冲突时判例优先；E 基本款/急刹款按\n  后镜性质二选一（后镜自带入场动效→基本款；后镜靠内容说话→急刹款），\n  同片混用两款算两式各记一次\n- F 式窗内新景语义应是被点开元素的\"详情世界\"（实战接真实详情页\n  截图）——窗里塞无关场景语义断裂，沦为花哨擦除\n- F 纵深款参数系灰阶占位调校起点、未经真实素材验证；\n  参考实现 demos/transition/shot-transitions/PortalWipeV2.tsx\n\n## 参考实现\nA 式：template/src/aifl/Main.tsx flash-cut 层（组件 `assets/lib/FlashCut.tsx`）；\nB 式（穿暗场直航）：demos/transition/shot-transitions/DarkTunnelTransition.tsx；\nC 式（虚焦接力）：demos/transition/shot-transitions/FocusHandoffTransition.tsx；\nD 式（黑场字卡）：demos/transition/shot-transitions/BlackCardTransition.tsx；\nE 式（whip 甩镜）：demos/transition/shot-transitions/WhipPanReal.tsx / WhipBrakeReal.tsx；\nF 式（遮罩擦除）：demos/transition/shot-transitions/MaskWipeReal.tsx（纵深款 PortalWipeV2.tsx 同目录）。\n\n## Reference implementation (Remotion)\n\n- demos/transition/shot-transitions/BlackCardTransition.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/BlackCardTransition.tsx\n- demos/transition/shot-transitions/DarkTunnelTransition.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/DarkTunnelTransition.tsx\n- demos/transition/shot-transitions/FocusHandoffTransition.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/FocusHandoffTransition.tsx\n- demos/transition/shot-transitions/MaskWipeReal.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/MaskWipeReal.tsx\n- demos/transition/shot-transitions/PortalWipeV2.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/PortalWipeV2.tsx\n- demos/transition/shot-transitions/WhipBrakeReal.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/WhipBrakeReal.tsx\n- demos/transition/shot-transitions/WhipPanReal.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/WhipPanReal.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/shot-transitions/BlackCardTransition.tsx, demos/transition/shot-transitions/DarkTunnelTransition.tsx, demos/transition/shot-transitions/FocusHandoffTransition.tsx, demos/transition/shot-transitions/MaskWipeReal.tsx, demos/transition/shot-transitions/PortalWipeV2.tsx, demos/transition/shot-transitions/WhipBrakeReal.tsx, demos/transition/shot-transitions/WhipPanReal.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/shot-transitions/BlackCardTransition.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/BlackCardTransition.tsx"
    },
    {
      "path": "demos/transition/shot-transitions/DarkTunnelTransition.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/DarkTunnelTransition.tsx"
    },
    {
      "path": "demos/transition/shot-transitions/FocusHandoffTransition.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/FocusHandoffTransition.tsx"
    },
    {
      "path": "demos/transition/shot-transitions/MaskWipeReal.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/MaskWipeReal.tsx"
    },
    {
      "path": "demos/transition/shot-transitions/PortalWipeV2.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/PortalWipeV2.tsx"
    },
    {
      "path": "demos/transition/shot-transitions/WhipBrakeReal.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/WhipBrakeReal.tsx"
    },
    {
      "path": "demos/transition/shot-transitions/WhipPanReal.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/WhipPanReal.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": 43,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/BlackCardTransition.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/DarkTunnelTransition.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/FocusHandoffTransition.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/MaskWipeReal.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/PortalWipeV2.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/WhipBrakeReal.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/shot-transitions/WhipPanReal.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/shot-transitions/BlackCardTransition.tsx =====\n// D 式 黑场字卡（black-card）——前镜收尾淡入黑场，字卡逐词压印出现（paper-title-card\n// 的暗场变体），再交棒后镜。章节级分段 + 呼吸位二合一；一支 30s 片 D 式 ≤2 次。\n// 参考实现（真实纹理）：A 景 = projects-full 项目板（前镜收尾 **8f** 淡入黑场，\n// 符合定义 6–10 帧）；暗底字卡 = \"weekly brief, every project linked\" 逐词 letterpress\n// （暗底 + 页面底色浅色字，不用强调色当正文字——浅底字卡才不像报错弹窗），等宽小字\n// 副行；完整标题落定后 **hold ~15 帧** 再退场，避免\"最后一个字刚出现就淡出\"；\n// 后镜 = wbr-full 周报页从黑场淡入 8f 交棒。\n// 节拍：0–8 A 淡出 → 8–20 黑场静 → 20–47 字卡逐词压印 → 47–62 完整标题 hold →\n// 62–70 字卡退场 → 70–78 淡入 B 景 → 78–120 B hold。\nimport { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';\n\nexport const BLACKCARD_DUR = 120;\n\nconst A_VIEW_Y = -180;\nconst SERIF = 'ui-serif, Georgia, \"Times New Roman\", serif';\nconst MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';\n\n// 暗底字卡的正文用页面底色（浅色），强调色只给重点词\nconst PAPER_LIGHT = 'oklch(92% 0.01 82)';\nconst AMBER = 'oklch(68% 0.12 65)';\nconst DIM = 'oklch(72% 0.01 82)';\n\nconst WORDS: { text: string; accent?: boolean }[] = [\n  { text: 'Every' },\n  { text: 'project,' },\n  { text: 'linked' },\n  { text: 'to' },\n  { text: 'your', accent: true },\n  { text: 'weekly' },\n  { text: 'report.' },\n];\n\nconst Scene: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  // A 景收尾：0→8 淡出到黑场（定义要求 6–10 帧，此处 8）\n  const aOut = interpolate(frame, [0, 8], [1, 0], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.5, 0, 0.4, 1),\n  });\n\n  // 字卡：20 起逐词压印（delay=20+i·3，最后词 i=6 delay=38 完成 47）\n  const cardOpacity = interpolate(frame, [18, 26], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.2, 0.7, 0.3, 1),\n  });\n  // 完整标题 47f 落定，hold 到 62 再退场（保留 ~15 帧完整展示）\n  const cardOut = interpolate(frame, [62, 70], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.4, 0, 0.5, 1),\n  });\n\n  // 后镜 B 淡入：70→78（字卡 62→70 完全淡出后再交棒）\n  const bIn = interpolate(frame, [70, 78], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),\n  });\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: '#0c0c10', overflow: 'hidden' }}>\n      {/* A 景 */}\n      {frame < 14 ? (\n        <div style={{ position: 'absolute', opacity: aOut }}>\n          <Img\n            src={staticFile('textures/live/projects-full.png')}\n            style={{ position: 'absolute', left: 0, top: A_VIEW_Y, width: 1920 }}\n          />\n        </div>\n      ) : null}\n\n      {/* 黑场字卡（18–74） */}\n      {frame >= 18 && frame < 74 ? (\n        <AbsoluteFill\n          style={{\n            justifyContent: 'center', alignItems: 'center',\n            opacity: cardOpacity * (1 - cardOut), pointerEvents: 'none',\n          }}\n        >\n          <div style={{ textAlign: 'center', maxWidth: 1500 }}>\n            <div\n              style={{\n                fontFamily: SERIF, fontSize: 96, fontWeight: 600, lineHeight: 1.16,\n                color: PAPER_LIGHT, letterSpacing: '-0.012em',\n                display: 'flex', flexWrap: 'wrap', justifyContent: 'center', columnGap: '0.26em',\n              }}\n            >\n              {WORDS.map((w, i) => {\n                const delay = 20 + i * 3;\n                const t = interpolate(frame, [delay, delay + 9], [0, 1], {\n                  extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.2, 0.75, 0.3, 1),\n                });\n                return (\n                  <span\n                    key={i}\n                    style={{\n                      opacity: t, transform: `scale(${1.3 - 0.3 * t})`,\n                      filter: `blur(${(1 - t) * 7}px)`, display: 'inline-block',\n                      fontStyle: w.accent ? 'italic' : 'normal',\n                      color: w.accent ? AMBER : undefined,\n                    }}\n                  >\n                    {w.text}\n                  </span>\n                );\n              })}\n            </div>\n            <div\n              style={{\n                height: 4, width: 180, margin: '30px auto 0', borderRadius: 2,\n                background: AMBER, transform: `scaleX(${interpolate(frame, [32, 44], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1) })})`,\n              }}\n            />\n            <div\n              style={{\n                fontFamily: MONO, fontSize: 20, letterSpacing: '0.14em', color: DIM,\n                marginTop: 26, textTransform: 'uppercase',\n                opacity: interpolate(frame, [26, 36], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),\n              }}\n            >\n              Weekly Brief · 2026-W28\n            </div>\n          </div>\n        </AbsoluteFill>\n      ) : null}\n\n      {/* B 景（70 起淡入） */}\n      {frame >= 70 ? (\n        <div style={{ position: 'absolute', opacity: bIn }}>\n          <Img\n            src={staticFile('textures/live/wbr-full.png')}\n            style={{ position: 'absolute', left: 0, top: 0, width: 1920, height: 1080 }}\n          />\n        </div>\n      ) : null}\n    </AbsoluteFill>\n  );\n};\n\nexport const BlackCardTransition: React.FC = () => (\n  <Scene />\n);\n\n\n// ===== demos/transition/shot-transitions/DarkTunnelTransition.tsx =====\n// B 式 穿暗场直航（dark-tunnel）——相机顺运动方向推出前景 → 纯暗场滑行几帧 →\n// 后景从景深迎面放大而来，一条 take 不切。高能量→高能量的场景跳转主力转场。\n// 参考实现（真实纹理）：A 景 = projects-full 全满项目板（前镜收尾段），\n// 推出 10f 相机向右（前景在屏幕空间向左滑出画）；暗场段保留微渐变尘点让\n// \"still moving\" 可感，不纯黑死场；B 景 = wbr-full 周报页从 scale ~0.6 + blur\n// 起步、10f 放大收焦入场。出与入同向（相机向右推出 → B 景从右缘迎来、\n// 屏幕空间同样向左），速度曲线连续。\n// 节拍：0–26 A hold → 26–36 推出（前景左移出画，暗场 32f 起淡入压住尾巴，\n// 不留纯黑死帧）→ 40–42 暗场满 → 42–52 B 放大入场 → 52–120 B hold（真静止）。\nimport { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';\n\nexport const DARKTUNNEL_DUR = 120;\n\n// 两景都以 cards 网格区为观察窗；A 景顶对 y=180，B 景（wbr）顶对 y=0\nconst A_VIEW_Y = -180;\nconst A_W = 1920;\n\nconst Scene: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  // A 景推出：26→36，相机向右推 = 前景在屏幕空间向左平移出画。\n  // 屏幕空间方向必须与 B 景一致（都向左）：之前写成 0→+1920（内容向右）\n  // 与 B 景 +960→0（向左）对撞，读作两条片子硬拼。\n  const pushX = interpolate(frame, [26, 36], [0, -A_W], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.45, 0, 0.2, 1),\n  });\n\n  // 暗场：32→40 从 0→1（进入暗场），42→50 1→0（让 B 景迎入可见）。\n  // 淡入必须压着 A 段推出的尾巴（f34–35 A 景已基本出画）——从 f36 才起\n  // 会先漏 2f 纯黑死帧；不纯黑——保留微渐变 + 尘点，让\"still moving\"可感\n  const tunnelVis = interpolate(frame, [32, 40, 42, 50], [0, 1, 1, 0], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.4, 0, 0.6, 1),\n  });\n\n  // B 景迎面放大入场：42→52，从 scale 0.6 + blur 起步收焦，同时沿\n  // 与 A 相同的运动轴（X 向右）继续移动——出与入同向、速度连续，\n  // 读作\"穿过暗场直航\"而不是两景各自动自己的。\n  const bIn = interpolate(frame, [42, 52], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),\n  });\n  const bScale = 0.6 + 0.4 * bIn;\n  const bBlur = (1 - bIn) * 12;\n  // 从右缘滑入（+960→0），屏幕空间向左——与 A 段前景向左出画同向，\n  // 相机就是一路向右的一条 take。\n  const bShiftX = (1 - bIn) * 960;\n\n  // 尘点：确定性 index 派生（渲染必须可复现）。相机一路向右，\n  // 世界固定的尘点在屏幕空间向左掠过——漂移方向必须与两景同向\n  const DUST = Array.from({ length: 4 }, (_, i) => ({\n    x: 780 + i * 320,\n    y: 300 + ((i * 137) % 480),\n    drift: 6 + (i % 3) * 4, // px/frame，缓慢漂移表示\"still moving\"\n  }));\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: '#0d0d10', overflow: 'hidden' }}>\n      {/* A 景（推出段） */}\n      {frame < 42 ? (\n        <div style={{ position: 'absolute', transform: `translateX(${pushX}px)` }}>\n          <Img\n            src={staticFile('textures/live/projects-full.png')}\n            style={{ position: 'absolute', left: 0, top: A_VIEW_Y, width: A_W }}\n          />\n        </div>\n      ) : null}\n\n      {/* 暗场：微渐变 + 尘点，非纯黑死场 */}\n      {frame >= 32 && frame < 50 ? (\n        <AbsoluteFill style={{ opacity: tunnelVis, pointerEvents: 'none' }}>\n          <div\n            style={{\n              position: 'absolute', inset: 0,\n              background: 'radial-gradient(900px 500px at 50% 45%, rgba(40,42,50,0.9), rgba(12,12,16,1) 70%)',\n            }}\n          />\n          {DUST.map((d, i) => (\n            <div\n              key={i}\n              style={{\n                position: 'absolute', left: d.x - frame * d.drift, top: d.y,\n                width: 2, height: 2, borderRadius: '50%',\n                background: 'rgba(120,125,140,0.35)',\n              }}\n            />\n          ))}\n        </AbsoluteFill>\n      ) : null}\n\n      {/* B 景：沿 A 的运动轴继续向右滑入（自左缘）+ 迎面放大 + 收焦 */}\n      {frame >= 42 ? (\n        <div\n          style={{\n            position: 'absolute', left: 0, top: 0, width: 1920, height: 1080,\n            transform: `translateX(${bShiftX}px) scale(${bScale})`, filter: `blur(${bBlur}px)`,\n            transformOrigin: '50% 45%',\n          }}\n        >\n          <Img\n            src={staticFile('textures/live/wbr-full.png')}\n            style={{ position: 'absolute', left: 0, top: 0, width: 1920, height: 1080 }}\n          />\n        </div>\n      ) : null}\n    </AbsoluteFill>\n  );\n};\n\nexport const DarkTunnelTransition: React.FC = () => (\n  <Scene />\n);\n\n\n// ===== demos/transition/shot-transitions/FocusHandoffTransition.tsx =====\n// C 式 虚焦接力（focus-handoff）——前景滑出焦平面（blur 渐深）同时后景反向\n// 收焦入场，焦点当剪辑点。同页面内区块→区块 / 文档长页游览的分段转场。\n// 参考实现（真实纹理）：A/B 都取自**同一张** projects-full 长页——A 景对准\n// 上部网格区（cards，y≈180 起），B 景对准**同一页的下半部**（Perception &\n// Sensing / Research Infra 区块，y≈900 起），模拟\"游览同一长页、区块接力\"。\n// 节拍：0–24 A hold → 24–40 A 淡出 + blur 0→8px 推出，同时 26–42 B 从 8px→0\n// 收焦 + 淡入 + 反向滑入（错开 2f 起跑——同帧起跑读作整屏糊掉）；两景在交叉\n// 窗口内互为焦点交换 → 42–120 B hold（真静止）。浅景深语言先立后用。\nimport { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';\n\nexport const FOCUSHANDOFF_DUR = 120;\n\nconst PAGE_H = 1746; // projects-full 的 CSS 高（layout.projects.pageH）\n// A 景观察窗：对准上部网格区（viewport 顶对页面 y=180）\nconst A_VIEW_Y = -180;\n// B 景观察窗：同一页面的下半部（viewport 顶对页面 y=900，显示\n// Perception & Sensing / Research Infra 区块），viewport 底不超页底\nconst B_VIEW_Y = -900;\n\nconst Scene: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  // A 景：24→40 淡出 + blur 加深 + 略推出（前景滑出焦平面）\n  const aOut = interpolate(frame, [24, 40], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.45, 0, 0.3, 1),\n  });\n  const aBlur = aOut * 8;\n  const aDrift = aOut * 60; // 轻微左移，读作\"滑出\"而非原地淡\n\n  // B 景：26→42 收焦入场（错开 2f 起跑），8px→0 + 淡入 + 反向滑入\n  const bIn = interpolate(frame, [26, 42], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),\n  });\n  const bBlur = (1 - bIn) * 8;\n  const bDrift = (1 - bIn) * -50; // 反向（从右滑入）\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: '#faf7f2', overflow: 'hidden' }}>\n      {/* A 景：同一页的上半部（网格区） */}\n      <div style={{ position: 'absolute', opacity: 1 - aOut, filter: `blur(${aBlur}px)`, transform: `translateX(${-aDrift}px)` }}>\n        <Img\n          src={staticFile('textures/live/projects-full.png')}\n          style={{ position: 'absolute', left: 0, top: A_VIEW_Y, width: 1920, height: PAGE_H }}\n        />\n      </div>\n\n      {/* B 景（上层）：同一页的下半部（sections 区），交叉窗口内收焦 */}\n      <div style={{ position: 'absolute', opacity: bIn, filter: `blur(${bBlur}px)`, transform: `translateX(${bDrift}px)` }}>\n        <Img\n          src={staticFile('textures/live/projects-full.png')}\n          style={{ position: 'absolute', left: 0, top: B_VIEW_Y, width: 1920, height: PAGE_H }}\n        />\n      </div>\n    </AbsoluteFill>\n  );\n};\n\nexport const FocusHandoffTransition: React.FC = () => (\n  <Scene />\n);\n\n\n// ===== demos/transition/shot-transitions/MaskWipeReal.tsx =====\n// mask-wipe 元素遮罩擦除（轮 D）——真实项目卡（card4-hires）放大成\n// 全屏窗口，projects 全景从窗内长出接管：\"点开一张卡进入它的世界\"。\n// 节拍：0–40 全景 hold → 40–85 卡放大成窗（窗内新景反向补偿）→ 85–120 hold。\nimport { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';\nimport layout from '../../_textures/live-layout.json';\n\nexport const MASKWIPE_DUR = 120;\n\nconst C4 = layout.projects.cards[3]; // 页面空间 x=781,y=616,w=357,h=312\nconst VIEW_Y = 180; // 全景观察窗：页面 y=180 起\n\nexport const MaskWipeReal: React.FC = () => {\n  const frame = useCurrentFrame();\n  const t = interpolate(frame, [40, 85], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    easing: Easing.bezier(0.5, 0, 0.2, 1),\n  });\n  // 卡在屏幕空间的初始 geometry（全景视口下）\n  const g0 = { x: C4.x, y: C4.y - VIEW_Y, w: C4.w, h: C4.h, r: 12 };\n  const x = interpolate(t, [0, 1], [g0.x, 0]);\n  const y = interpolate(t, [0, 1], [g0.y, 0]);\n  const w = interpolate(t, [0, 1], [g0.w, 1920]);\n  const h = interpolate(t, [0, 1], [g0.h, 1080]);\n  const r = interpolate(t, [0, 1], [g0.r, 0]);\n  // 窗内新景（detail 视角用 papers 页顶部模拟）：从 0.42 缩放反向长到 1\n  const innerScale = interpolate(t, [0, 1], [0.42, 1]);\n  return (\n    <AbsoluteFill style={{ backgroundColor: '#f9f6f1', overflow: 'hidden' }}>\n      {/* 背景：projects 全景 */}\n      <Img\n        src={staticFile('textures/live/projects-full.png')}\n        style={{ position: 'absolute', left: 0, top: -VIEW_Y, width: 1920 }}\n      />\n      {/* 卡片即窗口 */}\n      <div\n        style={{\n          position: 'absolute', left: x, top: y, width: w, height: h,\n          borderRadius: r, overflow: 'hidden',\n          boxShadow: t > 0 && t < 1 ? '0 16px 56px rgba(31,28,23,0.25)' : 'none',\n        }}\n      >\n        {/* 窗内新景：projects 页深部（detail 语义），反向补偿铺满 */}\n        <div\n          style={{\n            position: 'absolute', width: 1920, height: 1080,\n            left: '50%', top: '50%',\n            transform: `translate(-50%, -50%) scale(${innerScale})`,\n            overflow: 'hidden', background: '#f9f6f1',\n          }}\n        >\n          <Img\n            src={staticFile('textures/live/projects-full.png')}\n            style={{ position: 'absolute', left: 0, top: -846, width: 1920 }}\n          />\n        </div>\n        {/* 卡片脸：随放大渐隐露出窗内景 */}\n        <Img\n          src={staticFile('textures/live/card4-hires.png')}\n          style={{\n            position: 'absolute', inset: 0, width: '100%', height: '100%',\n            opacity: Math.max(0, 1 - t * 2.2),\n          }}\n        />\n      </div>\n    </AbsoluteFill>\n  );\n};\n\n\n// ===== demos/transition/shot-transitions/PortalWipeV2.tsx =====\nimport React from 'react';\nimport { AbsoluteFill, Easing, interpolate, useCurrentFrame } from 'remotion';\nimport { FakeDashboard, Card, TitleBlock, G } from '../../_fixtures/Fixtures';\n\n// portal-wipe 穿窗入景·改〔批次 1 重做〕\n// 批次 1 弱点：3 层 7 卡散开幅度大(0.85)+blur，穿窗后画面碎读不清。\n// 本版：窗放大 bezier(0.7,0,0.3,1) 40f 先慢后快；窗内只 2 层\n// （远景整页 dashboard 缩略 + 近景 2 张卡），散开系数近 0.3 / 远 0.08，\n// 不加 blur；穿窗完成(f65)后所有层 8f 内缓停(f73)，之后静止 hold 77f 读清新场景。\n//\n// 节拍（150f @30fps）：\n//   0–25   hold：旧 dashboard 建立，目标卡原位可见\n//   25–65  窗放大 40f：卡放大成全屏窗，窗内新场景随之显形\n//   65–73  缓停 8f：近/远两层视差余势收干（Easing.out 自然归零）\n//   73–150 静止 hold：新场景完整可读\nexport const PortalWipeV2: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  // ── 窗放大：40f，先慢后快再缓收 ──\n  const t = interpolate(frame, [25, 65], [0, 1], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n    easing: Easing.bezier(0.7, 0, 0.3, 1),\n  });\n  const c0 = { x: 1180, y: 620, w: 480, h: 320, r: 14 };\n  const x = interpolate(t, [0, 1], [c0.x, 0]);\n  const y = interpolate(t, [0, 1], [c0.y, 0]);\n  const w = interpolate(t, [0, 1], [c0.w, 1920]);\n  const h = interpolate(t, [0, 1], [c0.h, 1080]);\n  const r = interpolate(t, [0, 1], [c0.r, 0]);\n\n  // ── 窗内视差散开：40→73f，Easing.out 保证 f65 穿窗完成后 8f 内速度归零 ──\n  const spread = interpolate(frame, [40, 73], [0, 1], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n    easing: Easing.out(Easing.cubic),\n  });\n\n  // 窗内整体从缩略推到满幅\n  const innerScale = interpolate(t, [0, 1], [0.42, 1]);\n\n  return (\n    <AbsoluteFill style={{ background: G.bg, overflow: 'hidden' }}>\n      {/* 旧场景：dashboard A */}\n      <FakeDashboard variant=\"A\" />\n\n      {/* 窗（放大的卡）——内藏新场景 */}\n      <div\n        style={{\n          position: 'absolute',\n          left: x,\n          top: y,\n          width: w,\n          height: h,\n          borderRadius: r,\n          overflow: 'hidden',\n          boxShadow: t < 1 ? '0 12px 48px rgba(0,0,0,0.22)' : 'none',\n        }}\n      >\n        {/* 窗内 1920×1080 舞台，随穿窗从 0.42 推到 1 */}\n        <div\n          style={{\n            position: 'absolute',\n            width: 1920,\n            height: 1080,\n            left: '50%',\n            top: '50%',\n            transform: `translate(-50%, -50%) scale(${innerScale})`,\n            background: '#e4e4e2',\n          }}\n        >\n          {/* 远景层（系数 0.08，不加 blur）：新场景整页 dashboard 缩略 */}\n          <div\n            style={{\n              position: 'absolute',\n              inset: 0,\n              transform: `scale(${1 + spread * 0.08})`,\n              transformOrigin: '960px 540px',\n            }}\n          >\n            <div\n              style={{\n                position: 'absolute',\n                left: '50%',\n                top: '50%',\n                width: 1920,\n                height: 1080,\n                transform: 'translate(-50%, -50%) scale(0.82)',\n                transformOrigin: 'center',\n                borderRadius: 20,\n                overflow: 'hidden',\n                border: `2px solid ${G.border}`,\n                boxShadow: '0 8px 32px rgba(0,0,0,0.10)',\n              }}\n            >\n              <FakeDashboard variant=\"B\" />\n            </div>\n            <div style={{ position: 'absolute', left: 250, top: 100 }}>\n              <TitleBlock text=\"Scene B\" size={64} />\n            </div>\n          </div>\n\n          {/* 近景层（系数 0.3，不加 blur）：只 2 张卡，向边缘让位 */}\n          <div\n            style={{\n              position: 'absolute',\n              inset: 0,\n              transform: `scale(${1 + spread * 0.3})`,\n              transformOrigin: '960px 540px',\n            }}\n          >\n            <div style={{ position: 'absolute', left: 150, top: 660 }}>\n              <Card w={380} h={250} seed={71} />\n            </div>\n            <div style={{ position: 'absolute', left: 1420, top: 160 }}>\n              <Card w={340} h={220} seed={72} />\n            </div>\n          </div>\n        </div>\n\n        {/* 卡正面：放大初期渐隐，露出窗内新场景 */}\n        <div style={{ position: 'absolute', inset: 0, opacity: Math.max(0, 1 - t * 2.4) }}>\n          <Card w={c0.w} h={c0.h} seed={5} style={{ width: '100%', height: '100%' }} />\n        </div>\n      </div>\n    </AbsoluteFill>\n  );\n};\n\n\n// ===== demos/transition/shot-transitions/WhipBrakeReal.tsx =====\n// E 式急刹款 whip-brake——真实卡片切片横向长廊，甩过 9 张卡后\n// 在目标卡（card4-hires，高清纹理）前急刹长尾滑入。\n// 速率：前 70% 路程用 12f（糊），后 30% 路程 ease-out 48f 长尾。\n// 节拍：0–30 起点 hold → 30–42 全速甩 → 42–90 急刹滑入 → 90–130 真静止。\nimport { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';\nimport { CameraMotionBlur } from '@remotion/motion-blur';\nimport layout from '../../_textures/live-layout.json';\n\nexport const WHIPBRAKE_DUR = 130;\n\nconst RAIL = layout.projects.cards.slice(0, 9); // 9 张真实卡依次排开\nconst GAP = 60;\nconst CARD_W = 460;\nconst TARGET_I = 9; // 第 10 位：card4-hires 目标卡\nconst TOTAL = (TARGET_I + 1) * (CARD_W + GAP);\n// 终点：目标卡居中（120 = 导轨起始偏移）\nconst END = 120 + TARGET_I * (CARD_W + GAP) + CARD_W / 2 - 960;\n\nconst Scene: React.FC = () => {\n  const frame = useCurrentFrame();\n  // 前 70% 路程 12f，后 30% 路程 48f ease-out 长尾\n  const dx = interpolate(frame, [30, 42, 90], [0, END * 0.7, END], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n    easing: Easing.out(Easing.cubic),\n  });\n  return (\n    <AbsoluteFill style={{ backgroundColor: '#f9f6f1', overflow: 'hidden' }}>\n      <div style={{ position: 'absolute', top: 340, transform: `translateX(${-dx}px)` }}>\n        {RAIL.map((c, k) => (\n          <Img\n            key={c.file + k}\n            src={staticFile(`textures/live/${c.file}`)}\n            style={{\n              position: 'absolute', left: 120 + k * (CARD_W + GAP), top: 0,\n              width: CARD_W, borderRadius: 12,\n              boxShadow: '0 4px 16px rgba(31,28,23,0.10)',\n            }}\n          />\n        ))}\n        {/* 目标卡：高清纹理，急刹落点 */}\n        <Img\n          src={staticFile('textures/live/card4-hires.png')}\n          style={{\n            position: 'absolute', left: 120 + TARGET_I * (CARD_W + GAP), top: -20,\n            width: CARD_W, borderRadius: 12,\n            boxShadow: '0 10px 32px rgba(31,28,23,0.18)',\n          }}\n        />\n      </div>\n    </AbsoluteFill>\n  );\n};\n\nexport const WhipBrakeReal: React.FC = () => (\n  // 峰值 ~710px/f：采样密度按\"回波间距 ≤ 字高\"配，20 samples 拖影连续\n  <CameraMotionBlur shutterAngle={200} samples={20}>\n    <Scene />\n  </CameraMotionBlur>\n);\n\n\n// ===== demos/transition/shot-transitions/WhipPanReal.tsx =====\n// E 式基本款 whip-pan——真实整页冻结纹理（demos/_textures/）。\n// 世界：projects-full 顶部 section（A 景）与同页底部 section（B 景）横向并排\n// ——模拟\"功能段之间的区块交棒\"，相机 8f 甩 2880px（峰值 ~540px/f），\n// blur 只包甩动段。节拍：0–35 A hold → 35–43 甩（糊）→ 43–120 B hold（真静止）。\nimport { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';\nimport { CameraMotionBlur } from '@remotion/motion-blur';\n\nexport const WHIPPAN_DUR = 120;\n\nconst SWING = 2880; // 1.5 屏\n// 观察窗都对准 cards 网格区（页面空间 y≈247 起），viewport 顶对 y=180\nconst VIEW_Y = -180;\n\nconst Scene: React.FC = () => {\n  const frame = useCurrentFrame();\n  const dx = interpolate(frame, [35, 43], [0, SWING], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n    easing: Easing.bezier(0.6, 0, 0.4, 1),\n  });\n  return (\n    <AbsoluteFill style={{ backgroundColor: '#f9f6f1', overflow: 'hidden' }}>\n      <div style={{ position: 'absolute', transform: `translateX(${-dx}px)` }}>\n        {/* A 景：项目板全满 */}\n        <Img\n          src={staticFile('textures/live/projects-full.png')}\n          style={{ position: 'absolute', left: 0, top: VIEW_Y, width: 1920 }}\n        />\n        {/* 缝隙纸色空场 960px——甩动中段一晃而过，全糊 */}\n        {/* B 景：同页底部 section（PERCEPTION & SENSING 区），换景可辨且内容饱满 */}\n        <Img\n          src={staticFile('textures/live/projects-full.png')}\n          style={{ position: 'absolute', left: SWING, top: -666, width: 1920 }}\n        />\n      </div>\n    </AbsoluteFill>\n  );\n};\n\nexport const WhipPanReal: React.FC = () => (\n  // 峰值 ~540px/f：采样按\"回波间距 ≤ 字高\"配，20 samples 拖影连续\n  <CameraMotionBlur shutterAngle={200} samples={20}>\n    <Scene />\n  </CameraMotionBlur>\n);\n\n\n"
}