{
  "slug": "word-relay-filmstrip",
  "title": "Word relay filmstrip",
  "tagline": "A left column of black-and-white page cards steps forward while a serif big word on the right relays in place (a fixed noun + a cycling verb) — the column steps only when the word changes, aligned to the current page card's midpoint.",
  "description": "A left column of black-and-white page cards steps forward while a serif big word on the right relays in place (a fixed noun + a cycling verb) — the column ste",
  "category": "typography",
  "tags": [
    "shotcraft",
    "remotion",
    "typography",
    "ui-entrance"
  ],
  "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/word-relay-filmstrip/poster-1b22d487377c.jpg",
    "video": "https://gimgs.net/motion/media/word-relay-filmstrip/video-c61d45abbbf8.mp4"
  },
  "variants": [
    {
      "key": "word-relay-filmstrip",
      "label": "word-relay-filmstrip",
      "description": "A left column of black-and-white page cards steps forward while a serif big word on the right relays in place (a fixed noun + a cycling verb) — the column steps only when the word changes, aligned to the current page card's midpoint",
      "use": "",
      "video": "https://gimgs.net/motion/media/word-relay-filmstrip/video-c61d45abbbf8.mp4",
      "poster": "https://gimgs.net/motion/media/word-relay-filmstrip/poster-1b22d487377c.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/word-relay-filmstrip.html",
  "json_url": "https://gimgs.net/motion/word-relay-filmstrip.json",
  "template_url": "https://gimgs.net/motion/word-relay-filmstrip/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/word-relay-filmstrip.md",
  "updated_at": "2026-09-24T07:40:35.339Z",
  "prompt": "## When to use\n\nA \"one subject × many capabilities\" enumeration (\"Computer researches/builds/codes…\"); portfolio/case-study passages; a product multi-scenario tour\n\n## Intention\n\nThe left side is evidence (filmstrip page screenshots), the right side is the argument (a big word): each verb swap steps the filmstrip forward one frame with new evidence — text and image footnote each other. The linchpin is **step motion**: the left column stays completely still otherwise, scrolling one step only on the word swap (scrolling = the mechanical act of changing evidence); continuous scrolling would degrade the left column into background decoration and lose the \"click\" of the swap. The second linchpin is **alignment**: the big word block's vertical center must align pixel-precisely with the current page card's midpoint — off by a hair and the whole layout's \"editorial rigor\" feel collapses.\n\n## Duration & energy\n\n- Duration: Each word holds ~1.5–2s × 3–4 words; whole passage 5–7s\n- Energy: Medium-low (an editorial temperament, its rhythm carried by the \"click\" of the word swap)\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n左边是证据（页面截图胶片），右边是论点（大词）：动词换一次，\n胶片就步进一格给出新证据——文字与画面互为注脚。命门是**步进制**：\n左列平时完全静止，只在切词瞬间滚动一格（滚动=换证据的机械动作），\n持续滚动会让左列沦为背景装饰、切词失去\"咔哒\"感。第二命门是\n**对齐**：大词块垂直中心必须与当前页面卡中点精确对齐（像素级），\n歪了整个版式的\"编辑部严谨感\"就塌了。\n\n## 动效核心\n- 左列：等高页面卡纵向排列，黑白（深浅）强制相间以显滚动步进；\n  切词窗口内 ease-in-out 滚动恰好一卡高，其余时间零位移\n- 右侧：衬线体（Didot 类）两行——主体名词恒定，动词原位接力：\n  旧词灰化淡出（先出），新词落位（后进），先出后进不许叠影\n  （判例：v2 叠影\"resebuilds\"被抓）\n- 对齐：词块总高的垂直中心 = 当前卡顶 + 卡高/2（实测 y=540@1080p，\n  精确到个位像素）\n- 滚动与切词同步：滚动起点=旧词开始灰化，滚动终点=新词落稳\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 页面卡 | 等高（530px@1080p）、黑白相间、约半屏宽 | 不等高/同色系读不出步进；卡太窄失去\"证据\"分量 |\n| 词期 | ~45–60f/词 | <40f 读不完词+胶片看不清；词期不必均匀 |\n| 滚动窗口 | ~12f ease-in-out，恰一卡高 | 持续滚动=命门违例；滚过头/不足读作机械故障 |\n| 词接力 | 旧词 4–6f 灰化淡出 → 新词 6–8f 落位 | 同帧交叉必叠影；间隙 >10f 读作断片 |\n| 垂直对齐 | 词块中心=当前卡中点（像素级） | 用户单点意见\"文字高度和中间的页面中点要对齐\"；差 >8px 可感 |\n| 字体 | 衬线 Didot 类、动词字重比名词轻或同 | 无衬线会丢\"编辑部\"气质 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 与 text-column-converge 分工：那张是左右对峙合拢的揭晓戏；\n  本卡是纵向证据流+原位接力，无合拢无揭晓\n- 与 pill-slot-cycle 分工：那张是胶囊槽位轮换 UI 元素；\n  本卡是版式级的图文对位系统\n- 实战素材：页面卡应使用真实截图（demo 为灰条示意，残余差距\n  已知）；截图亮度要人工分档保证相间可读\n\n## 参考实现\ndemos/typography/word-relay-filmstrip/\n（WordRelayFilmstrip.tsx）\n原片出处：perplexity-promo.mp4 ≈76–90s\n\n## Reference implementation (Remotion)\n\n- demos/typography/word-relay-filmstrip/WordRelayFilmstrip.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/typography/word-relay-filmstrip/WordRelayFilmstrip.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/typography/word-relay-filmstrip/WordRelayFilmstrip.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/typography/word-relay-filmstrip/WordRelayFilmstrip.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/typography/word-relay-filmstrip/WordRelayFilmstrip.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/typography/word-relay-filmstrip/WordRelayFilmstrip.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/typography/word-relay-filmstrip/WordRelayFilmstrip.tsx =====\n// word-relay-filmstrip v3 —— 批次 12 单点微调：右侧大词块（Computer+动词）\n// 的垂直中心与左列当前页面卡的垂直中点（y=540）对齐（top 462→402）。\n// 其余沿用 v2：对照用户截图重做（perplexity-promo01，7 张）：\n// ① 左列页面卡黑白相间、每张等高（940x530，间距 105）；\n// ② 左列平时静止，只在右侧切词的窗口内滚动一格（滚动与切词同步）；\n// ③ 尺寸/字号/位置按截图量取：卡 x=106 宽 940，词右对齐至 x≈1710，\n//    Didot 系衬线 116px，\"Computer\" 固定第一行，动词第二行原位灰化淡出换词。\nimport React from 'react';\nimport { AbsoluteFill, interpolate, useCurrentFrame } from 'remotion';\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 CARD_W = 940;\nconst CARD_H = 530;\nconst GAP = 105;\nconst STEP = CARD_H + GAP;\n\n// —— 页面卡集合：黑白相间（奇偶强制交替），内容各异 ——\nconst DarkArticle: React.FC<{ seed: number }> = ({ seed }) => {\n  const rand = mulberry32(seed);\n  return (\n    <div style={{ position: 'absolute', inset: 0, background: '#101318', padding: '38px 46px' }}>\n      <div style={{ display: 'flex', gap: 26, alignItems: 'center', marginBottom: 34 }}>\n        <div style={{ width: 90, height: 13, background: '#d8b25a', borderRadius: 3, opacity: 0.9 }} />\n        <div style={{ marginLeft: 'auto', display: 'flex', gap: 18 }}>\n          {[0, 1, 2, 3].map((i) => <div key={i} style={{ width: 54, height: 9, background: '#3a3f48', borderRadius: 3 }} />)}\n        </div>\n      </div>\n      <div style={{ width: 150, height: 9, background: '#a8452e', borderRadius: 3, marginBottom: 20 }} />\n      <div style={{ width: '62%', height: 30, background: '#e8e6df', borderRadius: 5, marginBottom: 14 }} />\n      <div style={{ width: '44%', height: 30, background: '#e8e6df', borderRadius: 5, marginBottom: 30 }} />\n      <div style={{ display: 'flex', gap: 30 }}>\n        <div style={{ flex: 1.3 }}>\n          {Array.from({ length: 6 }).map((_, i) => (\n            <div key={i} style={{ height: 9, width: `${62 + rand() * 34}%`, background: '#41454e', borderRadius: 3, marginBottom: 13 }} />\n          ))}\n        </div>\n        <div style={{ flex: 1, border: '1px solid #2c313a', borderRadius: 8, background: '#151a22', padding: 20 }}>\n          {Array.from({ length: 4 }).map((_, i) => (\n            <div key={i} style={{ display: 'flex', gap: 12, alignItems: 'center', marginBottom: 15 }}>\n              <div style={{ width: 58, height: 12, background: '#6d5423', borderRadius: 3 }} />\n              <div style={{ width: `${34 + rand() * 30}%`, height: 9, background: '#4a4f58', borderRadius: 3 }} />\n            </div>\n          ))}\n        </div>\n      </div>\n    </div>\n  );\n};\n\nconst LightMedal: React.FC<{ seed: number }> = ({ seed }) => {\n  const rand = mulberry32(seed);\n  return (\n    <div style={{ position: 'absolute', inset: 0, background: '#ffffff', padding: '36px 44px' }}>\n      <div style={{ width: 120, height: 9, background: '#b9bcc2', borderRadius: 3, marginBottom: 14 }} />\n      <div style={{ display: 'flex', gap: 14, marginBottom: 24 }}>\n        <div style={{ width: 150, height: 32, background: '#17181a', borderRadius: 5 }} />\n        <div style={{ width: 170, height: 32, background: '#2f6fd6', borderRadius: 5, opacity: 0.85 }} />\n      </div>\n      <div style={{ display: 'flex', gap: 10, marginBottom: 22 }}>\n        {[64, 96, 78, 110, 70, 88, 92].map((w, i) => (\n          <div key={i} style={{ width: w, height: 24, borderRadius: 12, background: i === 0 ? '#2f6fd6' : '#f2f3f5', border: i === 0 ? 'none' : '1px solid #dfe1e5' }} />\n        ))}\n      </div>\n      <div style={{ height: 34, borderRadius: 6, border: '1px solid #dfe1e5', marginBottom: 26 }} />\n      <div style={{ display: 'flex', gap: 16 }}>\n        {[0, 1, 2].map((c) => (\n          <div key={c} style={{ flex: 1, border: '1px solid #e4e6ea', borderRadius: 8, padding: 18 }}>\n            <div style={{ width: 74, height: 8, background: '#9aa0aa', borderRadius: 3, marginBottom: 12 }} />\n            <div style={{ width: `${52 + rand() * 30}%`, height: 13, background: '#1c1d20', borderRadius: 4, marginBottom: 10 }} />\n            <div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>\n              <div style={{ width: 20, height: 13, background: c === 0 ? '#c8342f' : c === 1 ? '#2c8a4b' : '#c8342f', borderRadius: 2 }} />\n              <div style={{ width: 60, height: 8, background: '#c3c7cd', borderRadius: 3 }} />\n            </div>\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n\nconst DarkStats: React.FC<{ seed: number }> = ({ seed }) => {\n  const rand = mulberry32(seed);\n  return (\n    <div style={{ position: 'absolute', inset: 0, background: '#0e0d12', padding: '44px 52px' }}>\n      <div style={{ borderLeft: '3px solid #6a2430', paddingLeft: 34 }}>\n        {[\n          { n: 92, accent: false }, { n: 74, accent: false }, { n: 58, accent: false }, { n: 118, accent: true },\n        ].map((row, i) => (\n          <div key={i} style={{ display: 'flex', gap: 20, alignItems: 'center', marginBottom: 26 }}>\n            <div style={{ width: row.n, height: row.accent ? 30 : 20, background: row.accent ? '#c33b2e' : '#d6d3cc', borderRadius: 4, opacity: row.accent ? 0.95 : 0.85 }} />\n            <div style={{ width: 130, height: 11, background: row.accent ? '#7a3328' : '#4c4a52', borderRadius: 3 }} />\n            <div style={{ width: `${20 + rand() * 26}%`, height: 8, background: '#33323a', borderRadius: 3 }} />\n          </div>\n        ))}\n      </div>\n      <div style={{ marginTop: 30, background: '#1c1216', borderRadius: 8, padding: '22px 30px', display: 'flex', gap: 60 }}>\n        {[0, 1, 2].map((i) => (\n          <div key={i}>\n            <div style={{ width: 96, height: 16, background: '#c3564a', borderRadius: 3, marginBottom: 10, opacity: 0.9 }} />\n            <div style={{ width: 76, height: 8, background: '#5a4448', borderRadius: 3 }} />\n          </div>\n        ))}\n      </div>\n    </div>\n  );\n};\n\nconst LightTable: React.FC<{ seed: number }> = ({ seed }) => {\n  const rand = mulberry32(seed);\n  return (\n    <div style={{ position: 'absolute', inset: 0, background: '#ffffff', padding: '34px 44px' }}>\n      <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 26 }}>\n        <div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>\n          <div style={{ width: 34, height: 22, background: '#1d4f9e', borderRadius: 4 }} />\n          <div style={{ width: 130, height: 11, background: '#2a2b2e', borderRadius: 3 }} />\n        </div>\n        <div style={{ display: 'flex', gap: 16 }}>\n          <div style={{ width: 84, height: 10, background: '#c6c9cf', borderRadius: 3 }} />\n          <div style={{ width: 70, height: 10, background: '#c6c9cf', borderRadius: 3 }} />\n        </div>\n      </div>\n      {Array.from({ length: 7 }).map((_, i) => (\n        <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 20, padding: '13px 0', borderBottom: '1px solid #eceef1' }}>\n          <div style={{ width: 22, height: 10, background: '#9aa0aa', borderRadius: 3 }} />\n          <div style={{ width: 26, height: 16, background: ['#b23a3a', '#2c62b8', '#caa53c', '#3a8a52'][i % 4], borderRadius: 2, opacity: 0.85 }} />\n          <div style={{ width: 90 + rand() * 60, height: 10, background: '#3a3c40', borderRadius: 3 }} />\n          <div style={{ marginLeft: 'auto', display: 'flex', gap: 46 }}>\n            <div style={{ width: 16, height: 16, background: i % 3 === 1 ? '#8d8f94' : '#eceef1', borderRadius: 3 }} />\n            <div style={{ width: 16, height: 16, background: i % 3 === 2 ? '#a5772e' : '#eceef1', borderRadius: 3 }} />\n            <div style={{ width: 14, height: 10, background: '#5a5c60', borderRadius: 3 }} />\n          </div>\n        </div>\n      ))}\n    </div>\n  );\n};\n\nconst DarkMri: React.FC = () => (\n  <div style={{ position: 'absolute', inset: 0, background: '#08090b', padding: 0 }}>\n    <div style={{ height: 44, borderBottom: '1px solid #1c1e22', display: 'flex', alignItems: 'center', gap: 16, padding: '0 26px' }}>\n      <div style={{ width: 100, height: 10, background: '#cfd2d6', borderRadius: 3, opacity: 0.8 }} />\n      <div style={{ marginLeft: 'auto', width: 60, height: 8, background: '#2c2f34', borderRadius: 3 }} />\n    </div>\n    <div style={{ display: 'flex', height: CARD_H - 44 }}>\n      <div style={{ width: 190, borderRight: '1px solid #17191d', padding: 20 }}>\n        <div style={{ width: 110, height: 10, background: '#b8a24e', borderRadius: 3, marginBottom: 14 }} />\n        {Array.from({ length: 5 }).map((_, i) => (\n          <div key={i} style={{ height: 7, width: `${58 + (i * 13) % 36}%`, background: '#2e3138', borderRadius: 3, marginBottom: 10 }} />\n        ))}\n        <div style={{ marginTop: 24, width: 44, height: 130, margin: '24px auto 0', border: '1px solid #4a4330', borderRadius: 6 }} />\n      </div>\n      <div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>\n        <div style={{\n          width: 300, height: 360, borderRadius: 16,\n          background: 'radial-gradient(ellipse 46% 40% at 50% 42%, #b9bcc0 0%, #6c7076 34%, #33363c 62%, #101216 100%)',\n          position: 'relative',\n        }}>\n          {[[120, 100], [200, 150], [96, 210]].map(([x, y], i) => (\n            <div key={i} style={{ position: 'absolute', left: x, top: y, width: 12, height: 12, borderRadius: 6, border: '2px solid #d8b25a' }} />\n          ))}\n        </div>\n      </div>\n      <div style={{ width: 170, borderLeft: '1px solid #17191d', padding: 18 }}>\n        {Array.from({ length: 8 }).map((_, i) => (\n          <div key={i} style={{ height: 7, width: `${50 + (i * 17) % 44}%`, background: '#26292f', borderRadius: 3, marginBottom: 11 }} />\n        ))}\n      </div>\n    </div>\n  </div>\n);\n\nconst LightPortfolio: React.FC = () => (\n  <div style={{ position: 'absolute', inset: 0, background: '#f7f7f8', padding: '40px 60px', textAlign: 'center' }}>\n    <div style={{ width: 340, height: 26, background: '#242528', borderRadius: 5, margin: '10px auto 18px' }} />\n    {[420, 470, 300].map((w, i) => (\n      <div key={i} style={{ width: w, height: 9, background: '#b6b9bf', borderRadius: 3, margin: '0 auto 11px' }} />\n    ))}\n    <div style={{ display: 'flex', gap: 18, marginTop: 36 }}>\n      {[0, 1, 2, 3].map((i) => (\n        <div key={i} style={{ flex: 1, height: 150, background: '#ffffff', border: '1px solid #e2e4e8', borderRadius: 10, padding: 16, textAlign: 'left' }}>\n          <div style={{ width: '54%', height: 12, background: '#2c2d30', borderRadius: 3, marginBottom: 12 }} />\n          <div style={{ width: '80%', height: 8, background: '#d3d6db', borderRadius: 3, marginBottom: 8 }} />\n          <div style={{ width: '66%', height: 8, background: '#d3d6db', borderRadius: 3 }} />\n        </div>\n      ))}\n    </div>\n  </div>\n);\n\n// 黑白相间的固定顺序（奇偶交替）\nconst CARDS: React.FC<{ seed: number }>[] = [\n  ({ seed }) => <DarkArticle seed={seed} />,\n  ({ seed }) => <LightMedal seed={seed} />,\n  () => <DarkMri />,\n  ({ seed }) => <LightTable seed={seed} />,\n  ({ seed }) => <DarkStats seed={seed} />,\n  () => <LightPortfolio />,\n];\n\nconst WORDS = ['researches', 'builds', 'codes'];\n// 切词窗口：第一个词入场 f14–30；换词 f62–78、f108–124\nconst SWITCHES = [14, 62, 108];\nconst SW_DUR = 16;\nconst SERIF = '\"Didot\", \"Bodoni 72\", \"Playfair Display\", Georgia, serif';\n\nexport const WordRelayFilmstrip: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  // —— 滚动步进：平时静止，仅在切词窗口内滚一格（ease-in-out）——\n  let stepF = 0;\n  SWITCHES.forEach((s) => {\n    const p = interpolate(frame, [s, s + SW_DUR], [0, 1], {\n      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    });\n    // easeInOutCubic\n    stepF += p < 0.5 ? 4 * p * p * p : 1 - Math.pow(-2 * p + 2, 3) / 2;\n  });\n  const scroll = stepF * STEP;\n\n  // 卡片布局：中心卡顶 y=275，向两侧铺开；黑白相间\n  const total = CARDS.length * STEP;\n  const cards: React.ReactNode[] = [];\n  for (let rep = -1; rep < 2; rep++) {\n    CARDS.forEach((C, i) => {\n      const y = 275 + i * STEP + rep * total - scroll;\n      if (y > 1200 || y < -CARD_H - 120) return;\n      cards.push(\n        <div key={`${rep}-${i}`} style={{\n          position: 'absolute', top: y, left: 106, width: CARD_W, height: CARD_H,\n          borderRadius: 12, overflow: 'hidden',\n          boxShadow: '0 12px 40px rgba(30,26,20,0.14)',\n          border: '1px solid rgba(0,0,0,0.06)', background: '#fff',\n        }}>\n          <C seed={(i + 1) * 733} />\n        </div>,\n      );\n    });\n  }\n\n  // —— 右侧词接力：原位交叉淡（旧词灰化淡出，新词淡入，不位移）——\n  const wordStyle: React.CSSProperties = {\n    fontFamily: SERIF, fontWeight: 400, fontSize: 116, lineHeight: 1.18,\n    letterSpacing: '0.002em', textAlign: 'right', whiteSpace: 'nowrap',\n  };\n  // 每个词的生命周期：入场淡入（黑）→ 常驻黑 → 下个切点前 14 帧灰化\n  //（截图 4/6 捕捉到的就是\"旧词已灰、新词未现\"的状态）→ 切点起淡出。\n  const smooth = (x: number) => x * x * (3 - 2 * x);\n  const wordNodes = WORDS.map((w, i) => {\n    const sIn = SWITCHES[i];\n    const sOut = i + 1 < SWITCHES.length ? SWITCHES[i + 1] : null;\n    // 新词后半窗口淡入（避免与旧词叠影）\n    const pIn = interpolate(frame, [sIn + 7, sIn + SW_DUR], [0, 1], {\n      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    });\n    if (i === 0 ? frame < sIn : pIn <= 0) return null;\n    const pInEff = i === 0 ? interpolate(frame, [sIn, sIn + 12], [0, 1], {\n      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    }) : pIn;\n    // 灰化：切点前 14 帧开始，切点时已全灰\n    const grey = sOut ? interpolate(frame, [sOut - 14, sOut - 2], [0, 1], {\n      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    }) : 0;\n    // 淡出：切点起前半窗口内出完\n    const pOut = sOut ? interpolate(frame, [sOut, sOut + 8], [1, 0], {\n      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    }) : 1;\n    const op = smooth(pInEff) * smooth(pOut);\n    if (op <= 0) return null;\n    const mix = smooth(grey);\n    const ch = Math.round(0x19 + (0x9d - 0x19) * mix);\n    return (\n      <div key={w} style={{\n        ...wordStyle, position: 'absolute', right: 0, top: 0,\n        color: `rgb(${ch},${Math.round(0x19 + (0x98 - 0x19) * mix)},${Math.round(0x19 + (0x8e - 0x19) * mix)})`,\n        opacity: op,\n      }}>\n        {w}\n      </div>\n    );\n  });\n\n  return (\n    <AbsoluteFill style={{ background: '#faf8f3' }}>\n      <div style={{ position: 'absolute', inset: 0 }}>{cards}</div>\n      {/* 右侧词组：Computer 固定第一行，动词第二行原位换词。\n          v3：块总高≈137(Computer 行)+140(词行容器)=277，垂直中心须对齐\n          当前页面卡中点 y=540（卡顶 275 + 卡高 530/2）→ top=540-277/2≈402 */}\n      <div style={{ position: 'absolute', right: 210, top: 402 }}>\n        <div style={{ ...wordStyle, color: '#191919' }}>Computer</div>\n        <div style={{ position: 'relative', height: 140 }}>{wordNodes}</div>\n      </div>\n    </AbsoluteFill>\n  );\n};\n\n\n"
}