{
  "slug": "type-rhythm-sync",
  "title": "Text synced to audio",
  "tagline": "Two text-to-audio sync moves — font-weight-pump, strokes pumping thicker on the beat and springing back, and karaoke-fill-sync, words filling color one by one with the narration.",
  "description": "Two text-to-audio sync moves — font-weight-pump, strokes pumping thicker on the beat and springing back, and karaoke-fill-sync, words filling color one by one",
  "category": "typography",
  "tags": [
    "shotcraft",
    "remotion",
    "typography",
    "rhythm"
  ],
  "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/type-rhythm-sync/poster-0f3c2f417157.jpg",
    "video": "https://gimgs.net/motion/media/type-rhythm-sync/video-7c32770fb909.mp4"
  },
  "variants": [
    {
      "key": "font-weight-pump",
      "label": "font-weight-pump · Weight pulse",
      "description": "On the hit frame, strokes instantly go bolder (stroke + weight jump), decaying back over ~10f; accented beats widen an extra 8%",
      "use": "Music-driven high-energy passages; the title as a bass drop",
      "video": "https://gimgs.net/motion/media/type-rhythm-sync/video-7c32770fb909.mp4",
      "poster": "https://gimgs.net/motion/media/type-rhythm-sync/poster-0f3c2f417157.jpg"
    },
    {
      "key": "karaoke-fill-sync",
      "label": "karaoke-fill-sync · Read-along fill",
      "description": "Each word fills dark color left to right, its progress paced to the speech, holding once done; the active word carries a read-along underline",
      "use": "Voiceover/spoken-emphasis passages; eye-guiding for multi-word taglines",
      "video": "https://gimgs.net/motion/media/type-rhythm-sync/video-2974dd64293f.mp4",
      "poster": "https://gimgs.net/motion/media/type-rhythm-sync/poster-karaoke-fill-sync-443eae8f21d1.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/type-rhythm-sync.html",
  "json_url": "https://gimgs.net/motion/type-rhythm-sync.json",
  "template_url": "https://gimgs.net/motion/type-rhythm-sync/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/type-rhythm-sync.md",
  "updated_at": "2026-09-24T07:40:31.635Z",
  "prompt": "## When to use\n\nTitle/tagline passages tightly bound to the audio track; A binds to the beat (drums), B binds to speech (narration, word by word)\n\n## Intention\n\nThe type-motion library so far is all about \"how does it enter\" — nothing covers \"how does text that's already on screen live along with the sound.\" These two moves fill that gap: A turns the title into a rhythm organ — strokes pump thicker on the bass hit and spring back, the text dancing, good as a visual bass drop for high-energy passages; B turns a tagline into read-along captions — whichever word the narration reaches fills with color left to right, pulling the eye along, good for voiceover/spoken-emphasis passages. Their common trait: **the letters don't move, their properties do** — no displacement, no entrance/exit, purely weight or color breathing on the sound's beat.\n\n## Duration & energy\n\n- Duration: Single style 4–5s; A: each beat gets a 10f decay window; B: each word gets 15–35f depending on speech pace\n- Energy: A high (a dance-floor feel) / B medium (a follow-along guide)\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n文字动效库内此前全是\"入场怎么进\"，没有词管\"在场的字怎么跟着声音活\"。\n这两式补上：A 让标题变成节奏器官——笔画随低音鼓抽动变粗又弹回，\n文字在蹦迪，适合高能段落的视觉低音炮；B 让标语变成跟读字幕——\n旁白读到哪个词哪个词从左到右填色点亮，视线被牵着走，适合口播/\n标语强调段。共同点是**字不动、属性动**：无位移、无入退场，\n纯粹是字重/颜色在声音的拍点上呼吸。\n\n## 两式选型\n| 式 | 做法 | 适用 |\n|----|------|------|\n| A font-weight-pump 字重脉冲 | 命中帧笔画瞬间变粗（stroke+字重跳变），~10f 衰减弹回；重音拍额外撑宽 8% | 音乐驱动的高能段；标题当低音炮 |\n| B karaoke-fill-sync 填色随读 | 每词深色从左到右填亮，进度跟语速，读完保持；活跃词下带读指下划线 | 旁白/口播强调段；多词标语的视线引导 |\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| A 脉冲包络 | `env=(1−t/10)^0.8`（t=命中后帧数，≥10f 精确归 0）；`-webkit-text-stroke: 10px·env` + fontWeight 在 env>0.15 窗口 400↔900 跳变 | stroke 连续衰减 + 字重离散跳变叠加才读作\"连续变粗\"；单用 fontWeight 只有两挡、单用 stroke 变化太细 |\n| A 节拍 | 每 20f 一拍；重音拍额外 `scaleX = 1+0.08·env`（transform 缩放不改排版） | 拍距必须 > 衰减窗（20>10），两拍重叠读作抽搐 |\n| A 节拍参照物 | 画面下方一排小节拍点，命中哪拍哪个点闪深并放大 | 静音环境（预览/静帧）没有这排点就读不出\"在跟拍\" |\n| B 词级时间表 | 逐词 [start,end] 帧区间，词内 linear 填充；词间留 4–10f 停顿（换气） | 时间戳实战来自旁白打点表；均匀分配读作机器朗读 |\n| B 填色实现 | 每词双层同文本叠放：底层浅灰 + 上层深色 `clipPath: inset(0 X% 0 0)`，X=(1−p)·100 | 逐词独立叠层免量测词宽占比；整行单层 clip 需精确像素表、易漂 |\n| B 读指 | 活跃词下 8px 深色下划线，宽度 = p·100% | 4px 浅灰在 1080p 缩略下不可见（本批实渲踩坑）；读完的词不留线 |\n| 收尾 | A 末拍衰减归零后 / B 末词填完后真静止 ≥20f | R1 呼吸判例 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 两式都是**声音强依赖**手法：A 命中帧必须与鼓点同帧、B 填色区间必须\n  跟旁白词边界（钉帧走 sound-design §4.5 相对起点）——无声上片则 A 读作\n  抽风、B 读作莫名变色，别在无音轨段落用\n- A 式实战想要真·可变字体连续字重（wght 轴逐帧插值）需先确认字体加载\n  与渲染器支持；demo 的 stroke+跳变方案是无字体风险的兜底，效果已可感\n- A 式全片 ≤1 段（P4）——标题蹦迪是抢戏大户，两段同片互相拆台\n- B 式词数 ≤6/屏：更多词时填色进度读不过来，拆行或拆镜\n\n## 参考实现\ndemos/typography/type-rhythm-sync/\n（FontWeightPump.tsx / KaraokeFillSync.tsx）\n\n## Reference implementation (Remotion)\n\n- demos/typography/type-rhythm-sync/FontWeightPump.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/typography/type-rhythm-sync/FontWeightPump.tsx\n- demos/typography/type-rhythm-sync/KaraokeFillSync.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/typography/type-rhythm-sync/KaraokeFillSync.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/type-rhythm-sync/FontWeightPump.tsx, demos/typography/type-rhythm-sync/KaraokeFillSync.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/type-rhythm-sync/FontWeightPump.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/typography/type-rhythm-sync/FontWeightPump.tsx"
    },
    {
      "path": "demos/typography/type-rhythm-sync/KaraokeFillSync.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/typography/type-rhythm-sync/KaraokeFillSync.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/typography/type-rhythm-sync/FontWeightPump.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/typography/type-rhythm-sync/KaraokeFillSync.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/type-rhythm-sync/FontWeightPump.tsx =====\n// 字重脉冲（font-weight-pump）——标题笔画随节拍变粗又弹回，像文字跟着低音鼓蹦迪。\n// 节拍每 20f 一拍：帧 30/50/70/90/110 命中。命中瞬间 -webkit-text-stroke 0→10px\n// 跳满，随后 10f 幂衰减 (1-t/10)^0.8 弹回 0；fontWeight 在命中窗口 400→900 离散跳变\n// （env>0.15），配合 stroke 连续衰减读作\"连续变粗\"。第 3、5 拍（帧 70/110）为重音，\n// 额外 scaleX 1→1.08 同衰减（transform 缩放不改排版）。底部 5 个节拍点作节拍参照。\n// 结构：0–29f 静止 hold；30–119f 五拍脉冲；120–139f 真静止收尾（20f，110+10=120 无残留）。\nimport React from 'react';\nimport { useCurrentFrame } from 'remotion';\nimport { G } from '../../_fixtures/Fixtures';\n\nconst BEATS = [30, 50, 70, 90, 110];\nconst ACCENTS = new Set([2, 4]); // 第 3、5 拍重音\nconst DECAY = 10; // 衰减帧数\n\n// 命中后 10f 幂衰减包络：t=0 → 1，t>=10 → 精确 0（保证结尾真静止）\nconst envAt = (frame: number, beat: number) => {\n  const t = frame - beat;\n  if (t < 0 || t >= DECAY) return 0;\n  return Math.pow(1 - t / DECAY, 0.8);\n};\n\nexport const FontWeightPump: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  // 只可能有一个活跃拍（拍距 20f > 衰减 10f），取最大包络及其拍序号\n  let env = 0;\n  let activeBeat = -1;\n  BEATS.forEach((b, i) => {\n    const e = envAt(frame, b);\n    if (e > env) {\n      env = e;\n      activeBeat = i;\n    }\n  });\n\n  const strokeW = 10 * env; // 笔画粗细连续衰减\n  const weight = env > 0.15 ? 900 : 400; // 命中窗口离散跳字重\n  const accent = activeBeat >= 0 && ACCENTS.has(activeBeat);\n  const scaleX = accent ? 1 + 0.08 * env : 1; // 重音拍变宽一挡\n\n  return (\n    <div\n      style={{\n        width: 1920,\n        height: 1080,\n        background: G.bg,\n        position: 'relative',\n        overflow: 'hidden',\n        fontFamily: 'Helvetica, Arial, sans-serif',\n      }}\n    >\n      {/* 定宽居中容器，transform 缩放不改排版 */}\n      <div\n        style={{\n          position: 'absolute',\n          left: 0,\n          top: 0,\n          width: 1920,\n          height: 1080,\n          display: 'flex',\n          alignItems: 'center',\n          justifyContent: 'center',\n        }}\n      >\n        <div\n          style={{\n            fontSize: 150,\n            fontWeight: weight,\n            color: G.ink,\n            letterSpacing: 6,\n            whiteSpace: 'nowrap',\n            WebkitTextStroke: `${strokeW}px ${G.ink}`,\n            transform: `scaleX(${scaleX})`,\n            transformOrigin: 'center center',\n          }}\n        >\n          PUMP IT UP\n        </div>\n      </div>\n\n      {/* 底部节拍点：5 个，命中哪拍哪个点闪 ink 并放大 */}\n      <div\n        style={{\n          position: 'absolute',\n          left: 0,\n          bottom: 130,\n          width: 1920,\n          display: 'flex',\n          justifyContent: 'center',\n          gap: 56,\n        }}\n      >\n        {BEATS.map((b, i) => {\n          const e = envAt(frame, b);\n          const dotOpacity = e > 0.02 ? Math.min(1, 0.3 + e * 1.2) : 0;\n          const dotScale = 1 + 0.8 * e;\n          return (\n            <div\n              key={i}\n              style={{\n                width: 26,\n                height: 26,\n                borderRadius: 13,\n                background: G.line,\n                position: 'relative',\n                transform: `scale(${dotScale})`,\n              }}\n            >\n              <div\n                style={{\n                  position: 'absolute',\n                  inset: 0,\n                  borderRadius: 13,\n                  background: G.ink,\n                  opacity: dotOpacity,\n                }}\n              />\n            </div>\n          );\n        })}\n      </div>\n    </div>\n  );\n};\n\n\n// ===== demos/typography/type-rhythm-sync/KaraokeFillSync.tsx =====\n// 卡拉OK填色随读（karaoke-fill-sync）——旁白读到哪个词，哪个词就被深色从左到右\n// 点亮。两行标语 \"SHIP FASTER / BREAK NOTHING\"，每个词双层同文本叠放：底层 G.line\n// 浅灰字，上层 G.ink 深字用 clip-path: inset(0 X% 0 0) 按词内进度线性填充（逐词独立\n// 叠层，clip 百分比即词内进度，无需量测词在整行的像素占比）。词级时间表模拟语速：\n// SHIP 20–38、FASTER 42–75（长词慢读）、BREAK 85–103、NOTHING 107–130，词间停顿。\n// 正在填的词底下有 8px 深色下划线跟随填充右缘作读指。0–19f hold；130–149f 真静止。\nimport React from 'react';\nimport { useCurrentFrame, interpolate } from 'remotion';\nimport { G } from '../../_fixtures/Fixtures';\n\ntype Word = { text: string; start: number; end: number };\n\nconst LINES: Word[][] = [\n  [\n    { text: 'SHIP', start: 20, end: 38 },\n    { text: 'FASTER', start: 42, end: 75 },\n  ],\n  [\n    { text: 'BREAK', start: 85, end: 103 },\n    { text: 'NOTHING', start: 107, end: 130 },\n  ],\n];\n\nconst KaraokeWord: React.FC<{ word: Word; frame: number }> = ({ word, frame }) => {\n  // 词内 linear 填充进度，clamp 保证读完保持\n  const p = interpolate(frame, [word.start, word.end], [0, 1], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n  });\n  const active = frame >= word.start && frame < word.end; // 正在读这个词\n  return (\n    <span style={{ position: 'relative', display: 'inline-block' }}>\n      {/* 底层：浅灰未读字 */}\n      <span style={{ color: G.line }}>{word.text}</span>\n      {/* 上层：深字按进度从左到右揭开 */}\n      <span\n        style={{\n          position: 'absolute',\n          inset: 0,\n          color: G.ink,\n          clipPath: `inset(0 ${(1 - p) * 100}% 0 0)`,\n        }}\n      >\n        {word.text}\n      </span>\n      {/* 读指下划线：只在正在填的词下出现，右缘跟随填充进度 */}\n      {active && (\n        <span\n          style={{\n            position: 'absolute',\n            left: 0,\n            bottom: -14,\n            width: `${p * 100}%`,\n            height: 8,\n            background: G.ink,\n          }}\n        />\n      )}\n    </span>\n  );\n};\n\nexport const KaraokeFillSync: React.FC = () => {\n  const frame = useCurrentFrame();\n  return (\n    <div\n      style={{\n        width: 1920,\n        height: 1080,\n        background: G.bg,\n        overflow: 'hidden',\n        display: 'flex',\n        flexDirection: 'column',\n        justifyContent: 'center',\n        paddingLeft: 240,\n        boxSizing: 'border-box',\n        fontFamily: 'Helvetica, Arial, sans-serif',\n        fontSize: 130,\n        fontWeight: 800,\n        letterSpacing: 2,\n        lineHeight: 1.45,\n      }}\n    >\n      {LINES.map((words, li) => (\n        <div key={li} style={{ display: 'flex', gap: 48 }}>\n          {words.map((w) => (\n            <KaraokeWord key={w.text} word={w} frame={frame} />\n          ))}\n        </div>\n      ))}\n    </div>\n  );\n};\n\n\n"
}