{
  "slug": "canvas-materialize-moves",
  "title": "Canvas Materialize Moves",
  "tagline": "Two ways content \"materializes onto the canvas\" — panel-to-canvas row-to-card (a panel table row flies out along an arc, morphing across containers into a canvas card) and diagram-cascade tree generation (nodes pop in layer by layer after a typed prompt, with connectors growing before the nodes).",
  "description": "Two ways content \"materializes onto the canvas\" — panel-to-canvas row-to-card (a panel table row flies out along an arc, morphing across containers into a can",
  "category": "interaction",
  "tags": [
    "shotcraft",
    "remotion",
    "interaction",
    "ui-entrance"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 4.39,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/canvas-materialize-moves/poster-d9d4c0533ca9.jpg",
    "video": "https://gimgs.net/motion/media/canvas-materialize-moves/video-c689debab30a.mp4"
  },
  "variants": [
    {
      "key": "panel-to-canvas",
      "label": "panel-to-canvas · Row-to-card morph",
      "description": "Checkboxes auto-tick one by one → button press → three rows fly out along an upward arc, staggered, morphing mid-flight between row form and card form, landing with a random tilt; the vacated row slots collapse into a dashed-outline gap.",
      "use": "Bulk import/one-click add-to-board: content migrating from a list state to a spatial state.",
      "video": "https://gimgs.net/motion/media/canvas-materialize-moves/video-c689debab30a.mp4",
      "poster": "https://gimgs.net/motion/media/canvas-materialize-moves/poster-d9d4c0533ca9.jpg"
    },
    {
      "key": "diagram-cascade",
      "label": "diagram-cascade · Cascading generated tree",
      "description": "A prompt types itself out letter by letter (the outline darkens on completion to confirm) → a root node (dark) appears → 2 children → 4 grandchildren pop in layer by layer, with angled connector lines drawing in 8f ahead of each node; once the tree is complete, the whole tree breathes once.",
      "use": "AI-generated diagrams/mind maps/ER diagrams: a whole tree growing from a single sentence.",
      "video": "https://gimgs.net/motion/media/canvas-materialize-moves/video-65a791dc6dde.mp4",
      "poster": "https://gimgs.net/motion/media/canvas-materialize-moves/poster-diagram-cascade-138d7015ae58.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/canvas-materialize-moves.html",
  "json_url": "https://gimgs.net/motion/canvas-materialize-moves.json",
  "template_url": "https://gimgs.net/motion/canvas-materialize-moves/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/canvas-materialize-moves.md",
  "updated_at": "2026-09-24T07:39:47.664Z",
  "prompt": "## When to use\n\nNarrative segments where AI/collaboration tools \"land generated results on a canvas\"; A tells \"existing content changed its form of existence,\" B tells \"a whole structure grows from one sentence.\"\n\n## Intention\n\nThe library's existing generation narratives are only \"streaming writes inside a panel\" (ai-stream-response's evidence rows flowing into a panel) and \"a chart coming alive\" (chart-live-moves) — none cover content leaving its container to materialize as a new entity on an open canvas. This card fills that gap: the canvas is the stage, and the generated content is the performer taking it. Style A's key move is cross-container form migration — the same piece of content morphs from a \"row\" into a \"card,\" with position/size/corner-radius/inner layout interpolating together on one spring, so viewers read \"it flew over there and became that\" rather than \"one got deleted, another got added.\" Style B's key move is cascading timing — lines pull nodes out, layers feed layers; the sense of structure comes from sequence, not simultaneity.\n\n## Duration & energy\n\n- Duration: A ~4.3s (130f) / B ~5.3s (160f).\n- Energy: Medium\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n库内生成叙事只有\"面板内流式写入\"（ai-stream-response 证据行汇入面板）\n和\"图表自己活起来\"（chart-live-moves），没有词管**内容离开容器、在\n开放画布上物化成新实体**。本卡补这一块：画布是舞台，生成物是登台的\n演员。A 式的命门是跨容器形态迁移——同一条内容从\"行\"变成\"卡\"，位置\n/宽高/圆角/内容布局在一条 spring 上同步插值，观众读到\"是它飞过去\n变的\"而不是\"删一个加一个\"；B 式的命门是级联时序——线牵出节点、层\n喂出层，结构感来自先后而非同时。\n\n## 两式选型\n| 式 | 做法 | 适用 |\n|----|------|------|\n| A panel-to-canvas 行倒卡 | 复选框逐个自动打勾→按钮按下→三行沿上抛弧线错峰飞出，途中行形态与卡形态交叉淡化，落位带随机倾角；面板行槽塌陷成虚线留白 | 批量导入/一键上板：内容从列表态迁移为空间态 |\n| B diagram-cascade 级联生成树 | prompt 逐字敲出（敲完描边变深确认）→根（深色）→2 子→4 孙逐层弹出，折角连线描线先行 8f，成树后整棵呼吸一拍 | AI 生成结构图/脑图/ER 图：一句话长出一棵树 |\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| A 打勾 | 12/22/32f 逐个，spring(damping 10, stiffness 260) pop | 打勾是\"预告\"，全删观众不知道要飞谁；同帧齐勾读作静态已选 |\n| A 起飞错峰 | 按钮 46f 按下，三行 54/60/66f 起飞（错峰 6f） | 同帧齐飞读作复制粘贴；错峰 >12f 尾行像掉队 |\n| A 飞行插值 | spring(damping 16, stiffness 60, 34f)；位置走二次贝塞尔，中点上抬 170px | 直线飞读作平移不读作\"倒出来\"；中点抬 <100px 弧线感消失 |\n| A 形态迁移 | 行 560×80 → 卡 480×240，圆角 10→18，rowOp 在 u≈0.45 前灭、cardOp 后亮 | 两套内容交叉淡化必须错开——中段两态同显即穿帮 |\n| A 落位倾角 | ±2° 随机（-2/1.5/2） | 全正读作网格排版；>4° 读作散落故障 |\n| B 打字 | 1.1 字符/帧，敲完 prompt 条描边 G.border→G.ink 确认 | 无确认态则\"敲完\"与\"开始生成\"之间没有因果扣 |\n| B 级联时序 | 根 52f 起，层间隔 20f、同层兄弟错峰 6f；节点 spring(damping 11, stiffness 170) | 层间隔 <12f 读作一次性全出；兄弟同帧读作复制 |\n| B 连线先行 | SVG 折角 path（父底边→中线→子顶边）描线 16f ease-out，比子节点早 8f | \"线牵出节点\"是因果感来源；线晚于节点就成了补笔 |\n| B 收尾呼吸 | 末节点 +22f 起，以树心为原点 scale 1→1.035→1（sin inOut，28f） | 呼吸是\"成树\"的句号；>1.06 读作缩放故障 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 与 ai-stream-response 分工：那卡内容在**面板内**流式落位（结论先到、\n  证据随后），本卡内容**离开面板上画布**；同片可串（先流式生成→再倒上画布），\n  串用时 A 式的打勾段可省——流式完成态即\"已选\"\n- 与 chart-live-moves 分工：那卡是数据图表自身的活性（流线/点阵/爆轴），\n  B 式是结构图的**诞生过程**——图活着≠图正在被生成，别拿 B 式去演已有图表\n- A 式行槽必须塌陷成虚线留白——行飞走后原位完好如初，\"迁移\"就退化成\n  \"复制\"，语义全失\n- A 式交叉淡化窗口（rowOp 灭于 u≈0.45 前、cardOp 亮于其后）是防穿帮的\n  命门；spring 有过冲，u 可能短暂 >1，内容 opacity 都要 clamp\n- B 式呼吸原点必须是树的视觉重心（demo 960,620）而非画面中心，\n  不然读作整屏 zoom\n\n## 参考实现\ndemos/interaction/canvas-materialize-moves/\n（DiagramCascadeBuild.tsx / PanelToCanvasMaterialize.tsx）\n原片出处：miro-promo 84–92s（A 式）/ 104–116s（B 式）\n\n## Reference implementation (Remotion)\n\n- demos/interaction/canvas-materialize-moves/DiagramCascadeBuild.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/canvas-materialize-moves/DiagramCascadeBuild.tsx\n- demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.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/canvas-materialize-moves/DiagramCascadeBuild.tsx, demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.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/canvas-materialize-moves/DiagramCascadeBuild.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/canvas-materialize-moves/DiagramCascadeBuild.tsx"
    },
    {
      "path": "demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.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": 132,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/canvas-materialize-moves/DiagramCascadeBuild.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.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/canvas-materialize-moves/DiagramCascadeBuild.tsx =====\n// diagram-cascade-build —— miro-promo 104–116s\n// prompt 行打字 → 图表节点自上而下逐层级联弹出（根→2 子→4 孙），\n// 连线跟随节点生长（SVG path 描线），成树后整体呼吸一拍。\nimport React from 'react';\nimport { AbsoluteFill, useCurrentFrame, interpolate, spring, useVideoConfig, Easing } from 'remotion';\nimport { G } from '../../_fixtures/Fixtures';\n\nconst PROMPT = 'Generate an entity-relationship diagram';\nconst TYPE_START = 6;\nconst TYPE_CPS = 1.1; // 字符/帧\n\n// 树布局（画布坐标，节点中心）\nconst NODE_W = 300;\nconst NODE_H = 110;\nconst NODES = [\n  // level 0\n  { id: 0, x: 960, y: 330, level: 0, parent: -1 },\n  // level 1\n  { id: 1, x: 620, y: 570, level: 1, parent: 0 },\n  { id: 2, x: 1300, y: 570, level: 1, parent: 0 },\n  // level 2\n  { id: 3, x: 400, y: 820, level: 2, parent: 1 },\n  { id: 4, x: 810, y: 820, level: 2, parent: 1 },\n  { id: 5, x: 1110, y: 820, level: 2, parent: 2 },\n  { id: 6, x: 1520, y: 820, level: 2, parent: 2 },\n];\n\nconst CASCADE_START = 52; // 根节点弹出时刻\nconst LEVEL_GAP = 20; // 层间隔\nconst SIBLING_STAGGER = 6;\n\nconst nodeStart = (n: (typeof NODES)[number]) => {\n  if (n.level === 0) return CASCADE_START;\n  const siblingIdx = NODES.filter((m) => m.level === n.level && m.id < n.id).length;\n  return CASCADE_START + n.level * LEVEL_GAP + siblingIdx * SIBLING_STAGGER;\n};\n\n// 折角连线 path：父底边中点 → 垂直下探 → 水平 → 垂直进子顶边\nconst edgePath = (p: (typeof NODES)[number], c: (typeof NODES)[number]) => {\n  const x1 = p.x;\n  const y1 = p.y + NODE_H / 2;\n  const x2 = c.x;\n  const y2 = c.y - NODE_H / 2;\n  const my = (y1 + y2) / 2;\n  return `M ${x1} ${y1} L ${x1} ${my} L ${x2} ${my} L ${x2} ${y2}`;\n};\n\nconst edgeLen = (p: (typeof NODES)[number], c: (typeof NODES)[number]) => {\n  const y1 = p.y + NODE_H / 2;\n  const y2 = c.y - NODE_H / 2;\n  return Math.abs(y2 - y1) + Math.abs(c.x - p.x);\n};\n\nexport const DiagramCascadeBuild: React.FC = () => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n\n  const typed = Math.min(PROMPT.length, Math.max(0, Math.floor((frame - TYPE_START) * TYPE_CPS)));\n  const caretOn = Math.floor(frame / 8) % 2 === 0;\n  const promptDone = typed >= PROMPT.length;\n\n  // 成树后整体呼吸一拍\n  const lastStart = nodeStart(NODES[NODES.length - 1]);\n  const breathe = interpolate(frame, [lastStart + 22, lastStart + 34, lastStart + 50], [1, 1.035, 1], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n    easing: Easing.inOut(Easing.sin),\n  });\n\n  return (\n    <AbsoluteFill style={{ background: G.bg, overflow: 'hidden' }}>\n      <AbsoluteFill\n        style={{\n          backgroundImage: `radial-gradient(${G.line} 3px, transparent 3px)`,\n          backgroundSize: '52px 52px',\n        }}\n      />\n\n      {/* prompt 条 */}\n      <div\n        style={{\n          position: 'absolute',\n          left: 460,\n          top: 88,\n          width: 1000,\n          height: 84,\n          background: G.card,\n          border: `3px solid ${promptDone ? G.ink : G.border}`,\n          borderRadius: 42,\n          display: 'flex',\n          alignItems: 'center',\n          padding: '0 36px',\n          boxSizing: 'border-box',\n          boxShadow: '0 6px 24px rgba(0,0,0,0.08)',\n          fontFamily: 'Helvetica, Arial, sans-serif',\n          fontSize: 30,\n          fontWeight: 600,\n          color: G.ink,\n        }}\n      >\n        <div style={{ width: 30, height: 30, borderRadius: 15, background: G.mid, marginRight: 20, flexShrink: 0 }} />\n        <span>\n          {PROMPT.slice(0, typed)}\n          <span style={{ opacity: caretOn ? 1 : 0, fontWeight: 400 }}>|</span>\n        </span>\n      </div>\n\n      {/* 树（呼吸缩放以树心为原点） */}\n      <div style={{ position: 'absolute', inset: 0, transform: `scale(${breathe})`, transformOrigin: '960px 620px' }}>\n        {/* 连线层 */}\n        <svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>\n          {NODES.filter((n) => n.parent >= 0).map((n) => {\n            const p = NODES[n.parent];\n            const start = nodeStart(n) - 8; // 线比子节点先长\n            const len = edgeLen(p, n);\n            const grow = interpolate(frame, [start, start + 16], [0, 1], {\n              extrapolateLeft: 'clamp',\n              extrapolateRight: 'clamp',\n              easing: Easing.out(Easing.cubic),\n            });\n            if (grow <= 0) return null;\n            return (\n              <path\n                key={n.id}\n                d={edgePath(p, n)}\n                stroke={G.mid}\n                strokeWidth={5}\n                fill=\"none\"\n                strokeLinejoin=\"round\"\n                strokeDasharray={len}\n                strokeDashoffset={len * (1 - grow)}\n              />\n            );\n          })}\n        </svg>\n\n        {/* 节点层 */}\n        {NODES.map((n) => {\n          const start = nodeStart(n);\n          const pop = spring({ frame: frame - start, fps, config: { damping: 11, stiffness: 170 } });\n          if (frame < start) return null;\n          return (\n            <div\n              key={n.id}\n              style={{\n                position: 'absolute',\n                left: n.x - NODE_W / 2,\n                top: n.y - NODE_H / 2,\n                width: NODE_W,\n                height: NODE_H,\n                background: n.level === 0 ? G.side : G.card,\n                border: `3px solid ${n.level === 0 ? G.side : G.border}`,\n                borderRadius: 16,\n                boxSizing: 'border-box',\n                boxShadow: '0 6px 20px rgba(0,0,0,0.10)',\n                transform: `scale(${pop})`,\n                display: 'flex',\n                flexDirection: 'column',\n                justifyContent: 'center',\n                gap: 10,\n                padding: '0 24px',\n              }}\n            >\n              <div style={{ height: 16, width: `${44 + (n.id * 17) % 34}%`, background: n.level === 0 ? '#9a9a98' : G.bar, borderRadius: 8 }} />\n              <div style={{ height: 10, width: `${70 - (n.id * 13) % 26}%`, background: n.level === 0 ? G.sideBar : G.line, borderRadius: 5 }} />\n            </div>\n          );\n        })}\n      </div>\n    </AbsoluteFill>\n  );\n};\n\n\n// ===== demos/interaction/canvas-materialize-moves/PanelToCanvasMaterialize.tsx =====\n// panel-to-canvas-materialize —— miro-promo 84–92s\n// 侧面板表格行复选框自动逐个打勾 → 按钮按下 → 三行内容飞出面板、\n// 物化成画布上三张独立卡片落位（行→卡跨容器形态迁移，尺寸/形状插值）。\nimport React from 'react';\nimport { AbsoluteFill, useCurrentFrame, interpolate, spring, useVideoConfig, Easing } from 'remotion';\nimport { G } from '../../_fixtures/Fixtures';\n\nconst PANEL_X = 1210;\nconst PANEL_Y = 90;\nconst PANEL_W = 620;\nconst ROW_H = 92;\nconst ROWS_TOP = 210; // 面板内第一行的画面 y\n\n// 行 → 卡的目标位（画布左侧区域）\nconst TARGETS = [\n  { x: 150, y: 150, rot: -2 },\n  { x: 480, y: 420, rot: 1.5 },\n  { x: 180, y: 660, rot: 2 },\n];\nconst CARD_W = 480;\nconst CARD_H = 240;\n\nconst CHECK_FRAMES = [12, 22, 32]; // 三个复选框打勾时刻\nconst BUTTON_FRAME = 46; // 按钮按下\nconst FLY_START = [54, 60, 66]; // 三行错峰起飞\n\nexport const PanelToCanvasMaterialize: React.FC = () => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n\n  const btnPress = interpolate(frame, [BUTTON_FRAME, BUTTON_FRAME + 3, BUTTON_FRAME + 9], [0, 1, 0], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n  });\n\n  return (\n    <AbsoluteFill style={{ background: G.bg, overflow: 'hidden' }}>\n      {/* 画布点阵底 */}\n      <AbsoluteFill\n        style={{\n          backgroundImage: `radial-gradient(${G.line} 3px, transparent 3px)`,\n          backgroundSize: '52px 52px',\n        }}\n      />\n\n      {/* 侧面板 */}\n      <div\n        style={{\n          position: 'absolute',\n          left: PANEL_X,\n          top: PANEL_Y,\n          width: PANEL_W,\n          height: 900,\n          background: G.panel,\n          border: `2px solid ${G.border}`,\n          borderRadius: 20,\n          boxShadow: '0 8px 32px rgba(0,0,0,0.10)',\n          boxSizing: 'border-box',\n          padding: 28,\n        }}\n      >\n        {/* 面板标题 */}\n        <div style={{ height: 20, width: 260, background: G.bar, borderRadius: 10, marginBottom: 14 }} />\n        <div style={{ height: 12, width: 380, background: G.line, borderRadius: 6, marginBottom: 26 }} />\n        {/* 表头 */}\n        <div style={{ height: 34, background: G.line, borderRadius: 8, marginBottom: 12, opacity: 0.6 }} />\n        {/* 行槽位（行飞走后留白） */}\n        {[0, 1, 2].map((i) => (\n          <RowSlot key={i} idx={i} frame={frame} fps={fps} />\n        ))}\n        {/* 面板底部按钮 */}\n        <div\n          style={{\n            position: 'absolute',\n            left: 28,\n            bottom: 28,\n            right: 28,\n            height: 64,\n            borderRadius: 14,\n            background: btnPress > 0 ? G.ink : G.side,\n            transform: `scale(${1 - btnPress * 0.06})`,\n            display: 'flex',\n            alignItems: 'center',\n            justifyContent: 'center',\n          }}\n        >\n          <div style={{ fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 700, fontSize: 22, color: '#f2f2f0', letterSpacing: 0.5 }}>\n            Add all to canvas\n          </div>\n        </div>\n      </div>\n\n      {/* 飞行中/落位的三张卡（行→卡形态插值） */}\n      {[0, 1, 2].map((i) => (\n        <FlyingCard key={i} idx={i} frame={frame} fps={fps} />\n      ))}\n\n      {/* 光标 */}\n      <Cursor frame={frame} />\n    </AbsoluteFill>\n  );\n};\n\n// 面板内的一行：复选框自动打勾；起飞后槽位塌陷成虚线留白\nconst RowSlot: React.FC<{ idx: number; frame: number; fps: number }> = ({ idx, frame, fps }) => {\n  const checkF = CHECK_FRAMES[idx];\n  const flyF = FLY_START[idx];\n  const checked = frame >= checkF;\n  const checkPop = spring({ frame: frame - checkF, fps, config: { damping: 10, stiffness: 260 } });\n  const flown = frame >= flyF;\n\n  return (\n    <div\n      style={{\n        height: ROW_H - 12,\n        marginBottom: 12,\n        borderRadius: 10,\n        border: flown ? `2px dashed ${G.line}` : `2px solid ${G.border}`,\n        background: flown ? 'transparent' : G.card,\n        boxSizing: 'border-box',\n        display: 'flex',\n        alignItems: 'center',\n        gap: 18,\n        padding: '0 20px',\n        opacity: flown ? 0.7 : 1,\n      }}\n    >\n      {!flown && (\n        <>\n          {/* 复选框 */}\n          <div\n            style={{\n              width: 30,\n              height: 30,\n              borderRadius: 8,\n              border: `3px solid ${checked ? G.ink : G.bar}`,\n              background: checked ? G.ink : 'transparent',\n              boxSizing: 'border-box',\n              transform: checked ? `scale(${0.8 + 0.2 * checkPop})` : 'scale(1)',\n              display: 'flex',\n              alignItems: 'center',\n              justifyContent: 'center',\n            }}\n          >\n            {checked && (\n              <svg width=\"18\" height=\"18\" viewBox=\"0 0 18 18\">\n                <path d=\"M3 9.5 L7.2 13.5 L15 4.5\" stroke=\"#fff\" strokeWidth=\"3.2\" fill=\"none\" strokeLinecap=\"round\" strokeLinejoin=\"round\" />\n              </svg>\n            )}\n          </div>\n          <div style={{ height: 14, width: 180 + idx * 40, background: G.bar, borderRadius: 7 }} />\n          <div style={{ marginLeft: 'auto', height: 12, width: 90, background: G.line, borderRadius: 6 }} />\n        </>\n      )}\n    </div>\n  );\n};\n\n// 行→卡：位置沿贝塞尔弧线飞、尺寸/圆角/内容布局同步插值\nconst FlyingCard: React.FC<{ idx: number; frame: number; fps: number }> = ({ idx, frame, fps }) => {\n  const flyF = FLY_START[idx];\n  if (frame < flyF) return null;\n\n  const t = spring({ frame: frame - flyF, fps, config: { damping: 16, stiffness: 60 }, durationInFrames: 34 });\n\n  // 起点：面板内该行的屏幕位置/尺寸\n  const sx = PANEL_X + 30;\n  const sy = ROWS_TOP + idx * ROW_H;\n  const sw = PANEL_W - 60;\n  const sh = ROW_H - 12;\n  const tgt = TARGETS[idx];\n\n  // 弧线：中点向上抬，像被\"倒\"出来\n  const mx = (sx + tgt.x) / 2;\n  const my = Math.min(sy, tgt.y) - 170;\n  const u = t;\n  const x = (1 - u) * (1 - u) * sx + 2 * (1 - u) * u * mx + u * u * tgt.x;\n  const y = (1 - u) * (1 - u) * sy + 2 * (1 - u) * u * my + u * u * tgt.y;\n\n  const w = sw + (CARD_W - sw) * u;\n  const h = sh + (CARD_H - sh) * u;\n  const rot = tgt.rot * u;\n  const radius = 10 + 8 * u;\n  const shadow = interpolate(u, [0, 1], [0.08, 0.16]);\n  // 行内容(单行水平) → 卡内容(标题+行+footer) 交叉淡化\n  const rowOp = Math.max(0, 1 - u * 2.2);\n  const cardOp = Math.max(0, (u - 0.45) / 0.55);\n\n  return (\n    <div\n      style={{\n        position: 'absolute',\n        left: x,\n        top: y,\n        width: w,\n        height: h,\n        background: G.card,\n        border: `2px solid ${G.border}`,\n        borderRadius: radius,\n        boxShadow: `0 ${10 + 14 * u}px ${24 + 20 * u}px rgba(0,0,0,${shadow})`,\n        transform: `rotate(${rot}deg)`,\n        boxSizing: 'border-box',\n        overflow: 'hidden',\n        zIndex: 10 + idx,\n      }}\n    >\n      {/* 行形态内容 */}\n      <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center', gap: 18, padding: '0 20px', opacity: rowOp }}>\n        <div style={{ width: 30, height: 30, borderRadius: 8, background: G.ink }} />\n        <div style={{ height: 14, width: 180 + idx * 40, background: G.bar, borderRadius: 7 }} />\n      </div>\n      {/* 卡形态内容 */}\n      <div style={{ position: 'absolute', inset: 0, padding: 24, display: 'flex', flexDirection: 'column', gap: 12, opacity: cardOp, boxSizing: 'border-box' }}>\n        <div style={{ height: 18, width: `${52 + idx * 12}%`, background: G.bar, borderRadius: 9 }} />\n        <div style={{ height: 11, width: '84%', background: G.line, borderRadius: 5 }} />\n        <div style={{ height: 11, width: '66%', background: G.line, borderRadius: 5 }} />\n        <div style={{ marginTop: 'auto', display: 'flex', gap: 10, alignItems: 'center' }}>\n          <div style={{ width: 28, height: 28, borderRadius: 14, background: G.mid }} />\n          <div style={{ height: 11, width: 70, background: G.line, borderRadius: 5 }} />\n        </div>\n      </div>\n    </div>\n  );\n};\n\nconst Cursor: React.FC<{ frame: number }> = ({ frame }) => {\n  // 光标：从画面中部移到按钮上并停留按下\n  const bx = PANEL_X + PANEL_W / 2;\n  const by = PANEL_Y + 900 - 60;\n  const x = interpolate(frame, [8, BUTTON_FRAME - 4], [900, bx], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n    easing: Easing.inOut(Easing.quad),\n  });\n  const y = interpolate(frame, [8, BUTTON_FRAME - 4], [560, by], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n    easing: Easing.inOut(Easing.quad),\n  });\n  const press = interpolate(frame, [BUTTON_FRAME, BUTTON_FRAME + 3, BUTTON_FRAME + 8], [1, 0.78, 1], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n  });\n  return (\n    <svg\n      width={40}\n      height={44}\n      viewBox=\"0 0 20 22\"\n      style={{ position: 'absolute', left: x, top: y, transform: `scale(${press})`, zIndex: 40 }}\n    >\n      <path d=\"M2 1 L2 17 L6.5 13.2 L9.4 20 L12.4 18.7 L9.5 12 L15 11.6 Z\" fill={G.ink} stroke=\"#fff\" strokeWidth=\"1.4\" />\n    </svg>\n  );\n};\n\n\n"
}