{
  "slug": "collab-cursor-moves",
  "title": "Collab Cursor Moves",
  "tagline": "Two ways collaborative cursors perform — dialogue-duet, a two-cursor dark-stage duet (approach/circle/hand off the light/zoom into a transition), and cast-ensemble, a five-cursor ambient crowd layer (staggered entrance + drift + a typing cameo + gathering around).",
  "description": "Two ways collaborative cursors perform — dialogue-duet, a two-cursor dark-stage duet (approach/circle/hand off the light/zoom into a transition), and cast-ens",
  "category": "interaction",
  "tags": [
    "shotcraft",
    "remotion",
    "interaction"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 4.72,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/collab-cursor-moves/poster-02fdf3e2c95a.jpg",
    "video": "https://gimgs.net/motion/media/collab-cursor-moves/video-99fac995c469.mp4"
  },
  "variants": [
    {
      "key": "dialogue-duet",
      "label": "dialogue-duet",
      "description": "Two named blue/green cursors: bezier entrance → approach each other in dialogue (breathing pulses) → swap positions along an up-down arc → name tags light up and dim in a handoff → the green cursor easeIn-scales up dozens of times into a giant arrow used as a transition occluder.",
      "use": "Two-role handoff narrative (design → dev, you → AI); needs a pure UI-free narrative breathing beat.",
      "video": "https://gimgs.net/motion/media/collab-cursor-moves/video-99fac995c469.mp4",
      "poster": "https://gimgs.net/motion/media/collab-cursor-moves/poster-02fdf3e2c95a.jpg"
    },
    {
      "key": "cast-ensemble",
      "label": "cast-ensemble",
      "description": "5 named, colored cursors spring in staggered onto a grayscale canvas → dual-frequency sine drift while resident → one types out a line on a sticky note (typewriter) → all ease-in-out gather around at the end.",
      "use": "Ambient full-film layer for canvas/whiteboard products; background liveliness when the main content needs \"others are here too.\"",
      "video": "https://gimgs.net/motion/media/collab-cursor-moves/video-b8d7bd848df7.mp4",
      "poster": "https://gimgs.net/motion/media/collab-cursor-moves/poster-cast-ensemble-cef649df370e.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/collab-cursor-moves.html",
  "json_url": "https://gimgs.net/motion/collab-cursor-moves.json",
  "template_url": "https://gimgs.net/motion/collab-cursor-moves/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/collab-cursor-moves.md",
  "updated_at": "2026-09-24T07:39:51.225Z",
  "prompt": "## When to use\n\nNarrative segments about collaboration/multi-user/handoff themes; A carries a pure narrative beat with no UI at all, B gives a canvas scene the \"the team is here\" warmth.\n\n## Intention\n\nThe library's cursors have so far only been \"operating tools\" (input-trigger-moves' cursor-performance is a single cursor's click performance). This card upgrades named collaborative cursors to actors: style A stages two named cursors in a pure dark scene, telling a \"design-to-dev handoff\" story purely through their positional choreography — no UI element at all, and viewers still follow the plot, because cursor position IS the dialogue; style B has five cursors enter staggered, then keep drifting as ambient particles for the \"the team is here\" feel, with one cursor stopping to type (a cameo bit). SVG cursors plus name-tag chips cost almost nothing and carry a huge narrative density — the cheapest visual proof of \"collaboration\" as a theme.\n\n## Duration & energy\n\n- Duration: A ~4.7s (140f) / B ~4.7s (140f).\n- Energy: A medium (dense narrative) / B low-medium (an ambient layer, can pad any duration).\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n库内光标一直是\"操作工具\"（input-trigger-moves 的 cursor-performance\n是单光标点击表演）。本卡把带身份名牌的协作光标升格成**演员**：\nA 式两枚具名光标在纯暗场用位移编排讲\"设计-开发交接\"——没有一个 UI\n元素，观众照样看懂了剧情，光标位置关系就是台词；B 式五枚光标错峰\n入场后持续漂移当氛围粒子，\"团队在场\"感全靠它，其中一枚还能停下来\n打字（cameo 戏份）。SVG 光标+名牌 chip 成本极低、叙事密度极高，\n是\"协作\"主题最便宜的视觉论证。\n\n## 两式选型\n| 式 | 做法 | 适用 |\n|----|------|------|\n| A dialogue-duet | 蓝/绿两枚具名光标：贝塞尔入场→相互靠近对话（脉冲呼吸）→上下弧线绕位交换→名牌一亮一暗灯光交接→绿光标 easeIn 放大数十倍成巨箭头当转场遮挡 | 双角色交接叙事（设计→开发、你→AI）；需要一段无 UI 的纯叙事呼吸拍 |\n| B cast-ensemble | 5 枚具名彩色光标错峰 spring 飞入灰阶画布→双频正弦漂移驻场→一枚在便签上打字机补全一行→结尾全员 easeInOut 聚拢围观 | 画布/白板类产品全片氛围层；主内容之外需要\"别人也在\"的背景活性 |\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| A 位移 | 全部三次贝塞尔：入场 easeOut 24f / 靠近 easeInOut 30f / 绕位 easeInOut 38f / 甩出 easeIn 24f，无 linear | 直线+linear 立即读作\"程序在挪图标\"；曲线+缓动才是\"人在动\" |\n| A 绕位 | 两枚各走半圆弧（上弧 180°→360° vs 下弧 0°→180°）同步交换左右位，R≈270px | 同弧同向会相撞感；上下分弧是\"礼让\"的身体语言 |\n| A 灯光交接 | 名牌 opacity 0.28+0.72·lit、saturate 0.35+0.65·lit、lit>0.6 加辉光；交接在绕位中段 70–84f easeInOut 完成 | 一亮一暗是\"话语权移交\"；两个都亮读不出主从 |\n| A 转场放大 | 100–134f easeIn，scale 2.8→68 冲向画外角落，名牌同步淡出 | easeIn 加速是甩出的关键；巨箭头即前景遮挡物，接 transition-hidden-cut 语法 |\n| B 入场 | 5 枚 delay 0/5/9/13/17f 错峰，spring(damping 15)；名牌晚 12f 淡入+上浮 10px | 名牌跟光标同帧出现读作贴纸；随减速淡入才是\"人到了自报家门\" |\n| B 漂移 | 双频正弦叠加（0.055/0.021 rad/f，每枚相位不同），幅度 ±46/30px | 单频正弦读作钟摆；双频不同相才是\"人手的随机\" |\n| B 打字 cameo | 落位后 60f 线性吐字 + 光标条 8f 周期闪 | 装饰性打字可 1f/字符级；正文交互打字回 typewriter-moves 节奏 |\n| B 聚拢 | 92f 起 26f easeInOut 到围观位，漂移幅度衰减到 25% 保留 | 聚拢后漂移不能归零——完全静止的光标群读作死机 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 名牌颜色=身份编码，全片必须一致（蓝=Designer 就一直蓝）；中途换色观众会以为换了人\n- A 式是全片一段的\"戏\"，≤1 次（P4）；B 式是可全片驻场的氛围层，两式同片可共存但 A 的两枚主角要从 B 的群演里出（同名同色），否则读作两拨人\n- B 式漂移光标永远不许盖住正在阅读的主内容——氛围层要让戏，漂移范围避开主体安全区\n- 与 input-trigger-moves 分工：那是光标\"操作 UI\"（点击有 payload），本卡光标\"表演给你看\"（位移即剧情）；光标要点东西时回那张卡\n- 声音：A 绕位配轻 whoosh、交接一声柔和 chime、放大甩出一声重 whoosh；B 入场每枚一声极轻 pop，漂移段静，打字段 keyboard 轻声\n\n## 参考实现\ndemos/interaction/collab-cursor-moves/\n（CursorCastEnsemble.tsx / CursorDialogueDuet.tsx）\n原片出处：A figma-devmode 0:16–0:20 / B figma-devmode 0:05 + miro-promo 全片 + pitch-app 82–90s 打字 cameo\n\n## Reference implementation (Remotion)\n\n- demos/interaction/collab-cursor-moves/CursorCastEnsemble.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/collab-cursor-moves/CursorCastEnsemble.tsx\n- demos/interaction/collab-cursor-moves/CursorDialogueDuet.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/collab-cursor-moves/CursorDialogueDuet.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/interaction/collab-cursor-moves/CursorCastEnsemble.tsx, demos/interaction/collab-cursor-moves/CursorDialogueDuet.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/interaction/collab-cursor-moves/CursorCastEnsemble.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/collab-cursor-moves/CursorCastEnsemble.tsx"
    },
    {
      "path": "demos/interaction/collab-cursor-moves/CursorDialogueDuet.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/collab-cursor-moves/CursorDialogueDuet.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": 142,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/collab-cursor-moves/CursorCastEnsemble.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/collab-cursor-moves/CursorDialogueDuet.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/interaction/collab-cursor-moves/CursorCastEnsemble.tsx =====\n// cursor-cast-ensemble —— figma 0:05 (cursor-badge-cast) + miro 全片 cursor-ensemble-ambience\n// 5 枚具名彩色光标从画外飞入（弹簧减速+名牌淡入），落位后在灰阶画布上持续漂移、\n// 指向、聚拢；灰阶便签当环境道具；其中一枚(Rita)在便签上实时打字补全一行文本\n// （pitch collaborator-cameo 的打字戏并入）。\nimport React from 'react';\nimport { useCurrentFrame, spring, interpolate } from 'remotion';\nimport { 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\nconst FPS = 30;\nconst clamp01 = (t: number) => Math.min(1, Math.max(0, t));\nconst easeInOut = (t: number) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2);\n\ntype Actor = {\n  name: string; color: string;\n  from: [number, number]; home: [number, number];\n  delay: number; phase: number;\n};\n\nconst ACTORS: Actor[] = [\n  { name: 'Lisa', color: '#4C8DF6', from: [-160, 200], home: [430, 330], delay: 0, phase: 0.0 },\n  { name: 'Lucas', color: '#2FBF71', from: [2080, 160], home: [1370, 290], delay: 5, phase: 1.7 },\n  { name: 'Marta', color: '#F2994A', from: [-160, 900], home: [560, 760], delay: 9, phase: 3.1 },\n  { name: 'Niko', color: '#4C8DF6', from: [2080, 950], home: [1420, 780], delay: 13, phase: 4.4 },\n  { name: 'Rita', color: '#2FBF71', from: [900, -180], home: [960, 545], delay: 17, phase: 5.6 },\n];\n\nconst TYPED = 'Our customers love it';\n\nconst CursorActor: React.FC<{ x: number; y: number; a: Actor; badge: number; scale?: number }> = ({\n  x, y, a, badge, scale = 2.1,\n}) => (\n  <div style={{ position: 'absolute', left: x, top: y, transform: `scale(${scale})`, transformOrigin: '0 0', zIndex: 10 }}>\n    <svg width={30} height={44} viewBox=\"0 0 13.5 20\" style={{ display: 'block', overflow: 'visible' }}>\n      <path d=\"M0.5 0.5 L0.5 17.2 L4.7 13.4 L7.3 19.5 L10 18.3 L7.4 12.3 L13 12.3 Z\"\n        fill={a.color} stroke=\"#ffffff\" strokeWidth={1.1} strokeLinejoin=\"round\" />\n    </svg>\n    <div style={{\n      position: 'absolute', left: 24, top: 40, whiteSpace: 'nowrap',\n      background: a.color, color: '#fff', borderRadius: 7, padding: '4px 11px',\n      fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 700, fontSize: 14,\n      opacity: badge, transform: `translateY(${(1 - badge) * 10}px)`,\n    }}>\n      {a.name}\n    </div>\n  </div>\n);\n\nexport const CursorCastEnsemble: React.FC = () => {\n  const f = useCurrentFrame();\n  const rnd = mulberry32(88);\n  // 便签的固定随机旋转\n  const noteRots = Array.from({ length: 4 }).map(() => (rnd() - 0.5) * 8);\n\n  // 聚拢时刻：90 帧后所有光标向中央便签聚拢\n  const gatherT = easeInOut(clamp01((f - 92) / 26));\n  const GATHER: [number, number][] = [\n    [770, 430], [1160, 420], [800, 640], [1130, 650], [960, 545],\n  ];\n\n  // 打字进度（Rita 落位后开始，60–130 帧）\n  const typedCount = Math.floor(interpolate(f, [58, 118], [0, TYPED.length], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  }));\n  const caretOn = Math.floor(f / 8) % 2 === 0;\n\n  return (\n    <div style={{ width: 1920, height: 1080, background: G.bg, position: 'relative', overflow: 'hidden' }}>\n      {/* 点阵无限画布底 */}\n      <div style={{\n        position: 'absolute', inset: 0,\n        backgroundImage: `radial-gradient(${G.line} 2.4px, transparent 2.4px)`,\n        backgroundSize: '46px 46px',\n      }} />\n      {/* 灰阶便签环境道具 */}\n      {[\n        { x: 250, y: 170, s: 1 }, { x: 1490, y: 140, s: 2 },\n        { x: 210, y: 700, s: 3 }, { x: 1530, y: 690, s: 4 },\n      ].map((n, i) => (\n        <div key={i} style={{\n          position: 'absolute', left: n.x, top: n.y, width: 230, height: 210,\n          background: '#e6e6e2', border: `2px solid ${G.border}`, borderRadius: 6,\n          boxShadow: '0 4px 14px rgba(0,0,0,0.10)', transform: `rotate(${noteRots[i]}deg)`,\n          padding: 18, boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 12,\n        }}>\n          <div style={{ height: 12, width: '70%', background: G.bar, borderRadius: 6 }} />\n          <div style={{ height: 10, width: '86%', background: G.line, borderRadius: 5 }} />\n          <div style={{ height: 10, width: '58%', background: G.line, borderRadius: 5 }} />\n        </div>\n      ))}\n      {/* 中央大便签：Rita 打字的舞台 */}\n      <div style={{\n        position: 'absolute', left: 660, top: 420, width: 600, height: 240,\n        background: '#ffffff', border: `2px solid ${G.border}`, borderRadius: 12,\n        boxShadow: '0 6px 22px rgba(0,0,0,0.12)', padding: 30, boxSizing: 'border-box',\n      }}>\n        <div style={{ height: 14, width: 180, background: G.bar, borderRadius: 7, marginBottom: 24 }} />\n        <div style={{\n          fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 700, fontSize: 44,\n          color: G.ink, letterSpacing: -0.5, minHeight: 56,\n        }}>\n          {TYPED.slice(0, typedCount)}\n          <span style={{\n            display: 'inline-block', width: 4, height: 44, background: '#2FBF71',\n            marginLeft: 3, verticalAlign: 'middle',\n            opacity: f > 50 && typedCount < TYPED.length ? (caretOn ? 1 : 0.15) : 0,\n          }} />\n        </div>\n      </div>\n\n      {/* 五枚光标 */}\n      {ACTORS.map((a, i) => {\n        const s = spring({ frame: f - a.delay, fps: FPS, config: { damping: 15, stiffness: 90, mass: 0.9 } });\n        let x = interpolate(s, [0, 1], [a.from[0], a.home[0]]);\n        let y = interpolate(s, [0, 1], [a.from[1], a.home[1]]);\n        // 落位后持续漂移 + 偶尔\"指向\"性的小冲刺\n        const settled = clamp01((f - a.delay - 26) / 10);\n        const driftX = Math.sin(f * 0.055 + a.phase) * 46 + Math.sin(f * 0.021 + a.phase * 2) * 30;\n        const driftY = Math.cos(f * 0.047 + a.phase * 1.3) * 38 + Math.cos(f * 0.017 + a.phase) * 24;\n        x += driftX * settled * (1 - gatherT * 0.55);\n        y += driftY * settled * (1 - gatherT * 0.55);\n        // 聚拢\n        x = interpolate(gatherT, [0, 1], [x, GATHER[i][0] + driftX * 0.25]);\n        y = interpolate(gatherT, [0, 1], [y, GATHER[i][1] + driftY * 0.25]);\n        const badge = clamp01((f - a.delay - 12) / 12);\n        return <CursorActor key={a.name} x={x} y={y} a={a} badge={badge} />;\n      })}\n    </div>\n  );\n};\n\n\n// ===== demos/interaction/collab-cursor-moves/CursorDialogueDuet.tsx =====\n// cursor-dialogue-duet —— figma-devmode 0:16–0:20\n// 纯暗场上 Designer(蓝)/Developer(绿) 两枚具名光标做\"对话式\"双人舞：\n// 相互靠近 → 绕位交换 → 名牌一亮一暗交接 → 一枚放大成巨箭头当转场。\nimport React from 'react';\nimport { useCurrentFrame, interpolate } from 'remotion';\n\nconst BLUE = '#4C8DF6';\nconst GREEN = '#2FBF71';\nconst DARK = '#101012';\n\nconst easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);\nconst easeInOutCubic = (t: number) =>\n  t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;\nconst easeInCubic = (t: number) => t * t * t;\n\nconst clamp01 = (t: number) => Math.min(1, Math.max(0, t));\n\n// 三次贝塞尔单段位移\nconst bez = (\n  t: number,\n  p0: [number, number], c1: [number, number], c2: [number, number], p3: [number, number],\n): [number, number] => {\n  const u = 1 - t;\n  const x = u * u * u * p0[0] + 3 * u * u * t * c1[0] + 3 * u * t * t * c2[0] + t * t * t * p3[0];\n  const y = u * u * u * p0[1] + 3 * u * u * t * c1[1] + 3 * u * t * t * c2[1] + t * t * t * p3[1];\n  return [x, y];\n};\n\nconst Cursor: React.FC<{\n  x: number; y: number; scale: number; color: string; name: string;\n  badgeLit: number; // 0 压暗 1 点亮\n  badgeOpacity?: number;\n}> = ({ x, y, scale, color, name, badgeLit, badgeOpacity = 1 }) => (\n  <div style={{ position: 'absolute', left: x, top: y, transform: `scale(${scale})`, transformOrigin: '0 0' }}>\n    <svg width={42} height={62} viewBox=\"0 0 13.5 20\" style={{ display: 'block', overflow: 'visible' }}>\n      <path\n        d=\"M0.5 0.5 L0.5 17.2 L4.7 13.4 L7.3 19.5 L10 18.3 L7.4 12.3 L13 12.3 Z\"\n        fill={color} stroke=\"#ffffff\" strokeWidth={1.1} strokeLinejoin=\"round\"\n      />\n    </svg>\n    <div style={{\n      position: 'absolute', left: 34, top: 56, whiteSpace: 'nowrap',\n      background: color, color: '#fff', borderRadius: 8, padding: '5px 13px',\n      fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 700, fontSize: 16,\n      opacity: badgeOpacity * (0.28 + 0.72 * badgeLit),\n      filter: `saturate(${0.35 + 0.65 * badgeLit})`,\n      boxShadow: badgeLit > 0.6 ? `0 0 ${22 * badgeLit}px ${color}` : 'none',\n    }}>\n      {name}\n    </div>\n  </div>\n);\n\nexport const CursorDialogueDuet: React.FC = () => {\n  const f = useCurrentFrame();\n  const CX = 960; const CY = 520;\n  const R = 270;\n\n  // ---------- Designer(蓝) 轨迹 ----------\n  let dx = 0; let dy = 0;\n  // 入场：画外左 → 左位（弹性减速）\n  {\n    const t = easeOutCubic(clamp01(f / 24));\n    [dx, dy] = bez(t, [-140, 260], [300, 300], [520, 620], [CX - R, CY]);\n  }\n  // 靠近：向 Developer 探身（26–56）\n  if (f > 26) {\n    const t = easeInOutCubic(clamp01((f - 26) / 30));\n    const [nx, ny] = bez(t, [CX - R, CY], [CX - R + 120, CY - 150], [CX - 90, CY - 120], [CX - 120, CY - 24]);\n    dx = nx; dy = ny;\n  }\n  // 绕位：从左侧经上方绕到右侧（60–98）\n  if (f > 60) {\n    const t = easeInOutCubic(clamp01((f - 60) / 38));\n    const a = Math.PI + t * Math.PI; // 180° → 360°(上半弧)\n    dx = CX + Math.cos(a) * (R - 40) * (1 - t * 0.15) - 0;\n    dy = CY + Math.sin(a) * (R - 110);\n    if (t < 0.08) { // 与靠近段衔接的小混合\n      const m = t / 0.08;\n      dx = (CX - 120) * (1 - m) + dx * m;\n      dy = (CY - 24) * (1 - m) + dy * m;\n    }\n  }\n  // 退场：加速甩出画面左下（104–128）\n  if (f > 104) {\n    const t = easeInCubic(clamp01((f - 104) / 24));\n    dx = interpolate(t, [0, 1], [dx, -320]);\n    dy = interpolate(t, [0, 1], [dy, 1240]);\n  }\n\n  // ---------- Developer(绿) 轨迹 ----------\n  let gx = 0; let gy = 0;\n  {\n    const t = easeOutCubic(clamp01((f - 4) / 24));\n    [gx, gy] = bez(clamp01(t), [2080, 820], [1700, 760], [1420, 420], [CX + R, CY]);\n  }\n  // 回应：微微后仰再前倾（30–58）\n  if (f > 30) {\n    const t = easeInOutCubic(clamp01((f - 30) / 28));\n    const sway = Math.sin(t * Math.PI) * 70;\n    gx = CX + R + sway * 0.6;\n    gy = CY - Math.sin(t * Math.PI * 2) * 40;\n  }\n  // 绕位：从右侧经下方绕到左侧（60–98）\n  if (f > 60) {\n    const t = easeInOutCubic(clamp01((f - 60) / 38));\n    const a = 0 + t * Math.PI; // 0° → 180°(下半弧)\n    gx = CX + Math.cos(a) * (R - 40);\n    gy = CY + Math.sin(a) * (R - 110);\n  }\n  // 终场：冲向中心并放大成巨箭头（100–140）\n  const blow = easeInCubic(clamp01((f - 100) / 34));\n  if (f > 100) {\n    gx = interpolate(blow, [0, 1], [gx, -160]);\n    gy = interpolate(blow, [0, 1], [gy, -140]);\n  }\n  const gScale = 2.8 + blow * 66;\n\n  // ---------- 名牌灯光交接 ----------\n  // 前半 Designer 点亮，绕位中段(f≈78)交接给 Developer\n  const handoff = easeInOutCubic(clamp01((f - 70) / 14));\n  const dLit = f < 24 ? easeOutCubic(clamp01(f / 24)) : 1 - handoff;\n  const gLit = handoff;\n  const dBadgeIn = easeOutCubic(clamp01((f - 12) / 14));\n  const gBadgeIn = easeOutCubic(clamp01((f - 18) / 14));\n\n  // 对话\"脉冲\"——靠近对话时轻微缩放呼吸\n  const dPulse = f > 26 && f < 60 ? 1 + Math.sin((f - 26) * 0.5) * 0.05 : 1;\n\n  return (\n    <div style={{ width: 1920, height: 1080, background: DARK, position: 'relative', overflow: 'hidden' }}>\n      {/* 暗场微光晕，给纯黑一点空间感 */}\n      <div style={{\n        position: 'absolute', inset: 0,\n        background: `radial-gradient(ellipse 1100px 700px at 50% 48%, rgba(255,255,255,0.045), transparent 70%)`,\n      }} />\n      {f <= 132 && (\n        <Cursor x={dx} y={dy} scale={2.8 * dPulse} color={BLUE} name=\"Designer\"\n          badgeLit={dLit} badgeOpacity={dBadgeIn * (f > 104 ? 1 - easeInCubic(clamp01((f - 104) / 18)) : 1)} />\n      )}\n      <Cursor x={gx} y={gy} scale={gScale} color={GREEN} name=\"Developer\"\n        badgeLit={gLit} badgeOpacity={gBadgeIn * (1 - clamp01((f - 102) / 12))} />\n    </div>\n  );\n};\n\n\n"
}