{
  "slug": "hashtag-to-pill-materialize",
  "title": "Hashtag to pill materialize",
  "tagline": "\"#word\" types itself in, hard-cuts on 1 frame into a large pill label, holds, then shrinks and moves left into position, another hard cut reveals the finished page — two hard cuts around one slide.",
  "description": "\"#word\" types itself in, hard-cuts on 1 frame into a large pill label, holds, then shrinks and moves left into position, another hard cut reveals the finished",
  "category": "interaction",
  "tags": [
    "shotcraft",
    "remotion",
    "interaction",
    "typography"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 4.46,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/hashtag-to-pill-materialize/poster-b6aea7328751.jpg",
    "video": "https://gimgs.net/motion/media/hashtag-to-pill-materialize/video-13369ce110c1.mp4"
  },
  "variants": [
    {
      "key": "hashtag-to-pill-materialize",
      "label": "hashtag-to-pill-materialize · Hashtag to pill materialize",
      "description": "\"#word\" types itself in, hard-cuts on 1 frame into a large pill label, holds, then shrinks and moves left into position, another hard cut reveals the finished page — two hard cuts around one slide.",
      "use": "",
      "video": "https://gimgs.net/motion/media/hashtag-to-pill-materialize/video-13369ce110c1.mp4",
      "poster": "https://gimgs.net/motion/media/hashtag-to-pill-materialize/poster-b6aea7328751.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/hashtag-to-pill-materialize.html",
  "json_url": "https://gimgs.net/motion/hashtag-to-pill-materialize.json",
  "template_url": "https://gimgs.net/motion/hashtag-to-pill-materialize/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/hashtag-to-pill-materialize.md",
  "updated_at": "2026-09-24T07:40:01.301Z",
  "prompt": "## When to use\n\nDemo segments for tag/category/keyword features (tagging in a notes app, topic aggregation); a three-act narrative of \"type it → it becomes a UI object → it settles into the finished product\".\n\n## Intention\n\nThe conventional way to materialize text is a fade/morph/expand. Frame-by-frame breakdown of the original footage proves Bear does the opposite: **materializing happens on a 1-frame hard cut** — one frame it's still text+cursor, the next it's a complete pill, no expansion, no cross-fade, no rebound. A hard cut delivers the certainty of \"snap, done\" — any gradual transition would soften \"object\" into \"effect\". The whole segment's skeleton is **two hard cuts bracketing one slide**: hard-cut materialize → smooth shrink-move into place → hard-cut reveal of the finished page. The only continuous motion (the shrink-move) is framed by two hard cuts, which is exactly what makes it read as fast and stable. This rhythm skeleton is the crux — change the nature of any one of the three beats and the whole card collapses.\n\n## Duration & energy\n\n- Duration: Typing ~40f + hard-cut pill hold ~18f + shrink-move 14f + hard-cut reveal then rest; whole segment about 3.5s (18–21.5s in the source footage)\n- Energy: Medium (crisp and clean, driven by the hard cuts, not by bounce)\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n文字变实体的常规做法是渐变/morph/展开，原片帧级拆解证明 Bear 反着\n来：**实体化是 1 帧硬切**——上一帧还是文字+光标，下一帧就是完整\n胶囊，无展开无 cross-fade 无回弹。硬切给的是\"啪、成了\"的确定感，\n任何渐变都会把\"实体\"软化成\"特效\"。全段骨架是**两次硬切夹一次\n滑动**：硬切实体化 → 平滑缩移归位 → 硬切揭示成品页。唯一的连续\n运动（缩移）被两记硬切框住，才显得又快又稳。这个节奏骨架是命门，\n三段挪动任何一段的性质（把硬切改渐变、把滑动改硬切）整卡就塌。\n\n## 动效核心\n- 打字段：几何无衬线（Futura 气质）居中打 \"#word\"，**红实心光标\n  恒亮不闪**（原片实测，别加 blink）；人手节奏 4–6f/字带确定性抖动\n- **实体化 = 1 帧硬切**：文字+光标整层消失，同帧出现无描边浅灰\n  胶囊 + 图标 + 词（字号不变、原位等大，# 被图标替换）；仅允许\n  3f 1.03→1 微落定防死板\n- 胶囊 hold ~0.6s → 一段 bezier easeInOut 缩小左移（~0.55x、\n  ~14f）落到页面标签位，位置/缩放同曲线同起止，中途不分段\n- 落位后**再 1 帧硬切**揭示成品页：底色/标题/正文/胶囊配色同帧\n  全变，之后真静止收尾\n- 实现要点：胶囊按大字号绘制、整体 transform 缩放，且\n  transformOrigin 必须 0 0 + translate 先行，否则落位中心漂移\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 打字节奏 | 4–6f/字（原片 ~6 字/秒），mulberry32 抖动 | 均匀间隔读作机打；光标必须恒亮（**原片实测**不闪烁） |\n| 实体化 | **1 帧硬切**（原片帧级实测无任何过渡）+ 3f 1.03→1 微落定 | 加淡入/展开立刻降级成普通 morph；微落定 >4f 读作弹跳 |\n| 胶囊几何 | 740×236@1920（**原片实测** 493×157@720p ×1.5），无描边 | 胶囊要\"宽大过头\"才有实体感；加描边读作按钮 |\n| hold | ~18f（0.6s，**原片实测**） | <12f 观众没看清胶囊长啥样就飞了 |\n| 缩移 | ~0.55x（**原片实测** 273/493）、14f、bezier(0.5,0,0.25,1) | 缩放与位移必须同曲线；分开调会读作两个动画 |\n| 揭示 | 落位 +3f 再 1 帧硬切全页 | 揭示若做转场，第二记硬切没了，骨架塌一半 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 与 typewriter-moves/ai-stream-response（打字呈现）、\n  morph-from-primitive（图元渐变成形）撞领域：本卡命门是**硬切\n  实体化非渐变变形**——只要你想加过渡，就该去用那几张卡而不是改这张\n- 早期版本曾杜撰\"胶囊飞入下方滑入笔记卡\"段，原片对照证伪已砍；\n  别复活飞行段\n- transformOrigin 默认 50% 50% 会让缩放落位中心漂移 (1−s)×半宽，\n  必须 origin 0 0 + translate 到目标中心再 scale（demo 内注释）\n- 与原片残余差距：音符图标手绘 SVG 曲线、Futura 回退字重、揭示帧\n  正文间距 ~10px 级别偏差；节奏与硬切时点已密帧对齐\n\n## 参考实现\ndemos/interaction/hashtag-to-pill-materialize/\n（HashtagToPillMaterialize.tsx）\n原片出处：bear-app.mp4 18–21.5s\n\n## Reference implementation (Remotion)\n\n- demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.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/hashtag-to-pill-materialize/HashtagToPillMaterialize.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/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.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": 134,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.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/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx =====\n// hashtag-to-pill-materialize（bear-app 18–21.5s 重做版，按原片密帧逐帧对照）\n// 原片实测节奏（25fps 逐帧）：\n//  1) 白底居中打字 \"#music\"：几何无衬线（Futura 气质）、中灰墨色、红色实心光标恒亮不闪、人手节奏\n//  2) 实体化 = 1 帧硬切：文字+光标 → 宽大浅灰无描边胶囊 + 灰色双八分音符图标 + \"music\"（字号不变，#被图标替换）\n//  3) 停约 0.6s → 整体平滑缩小（→~0.55x）并左移落到页面标签位（约 0.55s，easeInOut）\n//  4) 再 1 帧硬切揭示成品笔记页：奶油底、墨绿大标题 \"My favorite bands\"、胶囊换成鼠尾草绿、正文三行——\n//     原片没有\"胶囊飞入下方滑入卡片\"的段落（批次 8 的飞行段为杜撰，已砍）\nimport React from 'react';\nimport { AbsoluteFill, interpolate, useCurrentFrame, Easing } from 'remotion';\n\nconst FONT = \"Futura, 'Century Gothic', 'Avenir Next', 'Trebuchet MS', sans-serif\";\n\nconst C = {\n  bgWhite: '#fcfcfb',\n  bgCream: '#f4f1e5',\n  ink: '#454543',\n  cursor: '#e0453f',\n  pillGray: '#e9e9e7',\n  pillTextGray: '#4b4b49',\n  iconGray: '#7e7e7c',\n  pillSage: '#d5e0cf',\n  iconSage: '#5c7a63',\n  titleGreen: '#2d5c47',\n  pillSageText: '#3f5e4c',\n  body: '#4c4b43',\n};\n\n// ---- mulberry32（仅用于打字节奏的人手抖动，确定性）----\nfunction mulberry32(seed: number) {\n  let a = seed >>> 0;\n  return () => {\n    a = (a + 0x6d2b79f5) >>> 0;\n    let t = a;\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}\n\nconst TEXT = '#music';\n// 打字起点 & 每字间隔（帧），4–8 帧带抖动，模拟原片真人节奏\nconst TYPE_START = 8;\nconst rand = mulberry32(20260717);\nconst TYPE_AT: number[] = (() => {\n  const at: number[] = [];\n  let f = TYPE_START;\n  for (let i = 0; i < TEXT.length; i++) {\n    at.push(f);\n    f += 4 + Math.floor(rand() * 3); // 4–6 帧（原片 ~6字/秒）\n  }\n  return at;\n})();\n\n// ---- 时间轴（30fps，共 132 帧，节奏对齐原片 18–21.5s）----\nconst MORPH = 48;       // 1 帧硬切实体化（打完 hold ~0.5s，原片 0.45s）\nconst MOVE_START = 66;  // 胶囊开始缩小左移（morph 后 0.6s，原片同）\nconst MOVE_END = 80;    // 落位（0.47s，原片 ~0.45s）\nconst REVEAL = 83;      // 1 帧硬切揭示成品页，之后静置收尾\n\n// ---- 几何（1920x1080，等比换算自原片 1280x720 实测像素）----\nconst FS = 132;                       // 打字/胶囊文字字号（原片 glyph 等高换算）\nconst HERO = { x: 960, y: 540 };      // 大胶囊中心\nconst PILL_W = 740, PILL_H = 236;     // 原片实测 493x157 @720p ×1.5\nconst END_SCALE = 0.554;              // 落位缩放（原片 273/493）\nconst SLOT = { x: 361, y: 473 };      // 标签位中心（原片灰胶囊落点 (244.5,317.5)×1.5 与揭示位折中）\n\n// 灰色双八分音符图标（原片是 beamed 双音符，非 ♪）\nconst NoteIcon: React.FC<{ size: number; color: string }> = ({ size, color }) => (\n  <svg width={size} height={size} viewBox=\"0 0 48 48\" style={{ display: 'block' }}>\n    <ellipse cx=\"11\" cy=\"39\" rx=\"7.2\" ry=\"5.6\" fill={color} transform=\"rotate(-18 11 39)\" />\n    <ellipse cx=\"35\" cy=\"35\" rx=\"7.2\" ry=\"5.6\" fill={color} transform=\"rotate(-18 35 35)\" />\n    <rect x=\"15.4\" y=\"12.5\" width=\"3\" height=\"27\" fill={color} />\n    <rect x=\"39.4\" y=\"8.5\" width=\"3\" height=\"27\" fill={color} />\n    <polygon points=\"15.4,12.5 42.4,8.5 42.4,16.5 15.4,20.5\" fill={color} />\n  </svg>\n);\n\n// 胶囊（大字号绘制，整体 transform 缩放，保证实体化前后文字原位等大）\nconst Pill: React.FC<{ bg: string; iconColor: string; textColor: string }> = ({ bg, iconColor, textColor }) => (\n  <div style={{\n    width: PILL_W, height: PILL_H, borderRadius: PILL_H / 2, background: bg,\n    display: 'flex', alignItems: 'center', paddingLeft: 96, boxSizing: 'border-box', gap: 66,\n  }}>\n    <NoteIcon size={104} color={iconColor} />\n    <span style={{ fontSize: FS, fontWeight: 500, color: textColor, letterSpacing: 2 }}>music</span>\n  </div>\n);\n\nexport const HashtagToPillMaterialize: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  // ---- 打字 ----\n  const typedCount = TYPE_AT.filter((t) => frame >= t).length;\n  const typed = TEXT.slice(0, typedCount);\n\n  // ---- 缩小左移 ----\n  const moveT = interpolate(frame, [MOVE_START, MOVE_END], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    easing: Easing.bezier(0.5, 0, 0.25, 1),\n  });\n  const px = interpolate(moveT, [0, 1], [HERO.x, SLOT.x]);\n  const py = interpolate(moveT, [0, 1], [HERO.y, SLOT.y]);\n  const ps = interpolate(moveT, [0, 1], [1, END_SCALE]);\n  // 实体化瞬间极轻微落定（原片近乎硬切，仅 3 帧 1.03→1，避免死板）\n  const settle = interpolate(frame, [MORPH, MORPH + 3], [1.03, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.quad),\n  });\n\n  const revealed = frame >= REVEAL;\n\n  return (\n    <AbsoluteFill style={{ background: revealed ? C.bgCream : C.bgWhite, fontFamily: FONT }}>\n      {/* 成品页（硬切揭示，之后全静） */}\n      {revealed && (\n        <>\n          <div style={{\n            position: 'absolute', left: 160, top: 168,\n            fontSize: 122, fontWeight: 700, color: C.titleGreen, letterSpacing: 0.5,\n          }}>\n            My favorite bands\n          </div>\n          <div style={{\n            position: 'absolute', left: 152, top: 618,\n            fontSize: 70, fontWeight: 500, color: C.body, lineHeight: 1.33, letterSpacing: 0.3,\n          }}>\n            I want to share a few of my favorite bands<br />\n            and the song that I always listen when driving<br />\n            to home. Welcome. Bring headphones.\n          </div>\n        </>\n      )}\n\n      {/* 打字层：文字 + 恒亮红光标（原片光标不闪烁），实体化帧整体消失 */}\n      {frame < MORPH && (\n        <AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>\n          <div style={{ display: 'flex', alignItems: 'center' }}>\n            <span style={{ fontSize: FS, fontWeight: 500, color: C.ink, letterSpacing: 2, whiteSpace: 'pre' }}>\n              {typed}\n            </span>\n            <span style={{\n              display: 'inline-block', width: 7, height: 150,\n              background: C.cursor, marginLeft: 8, borderRadius: 2,\n            }} />\n          </div>\n        </AbsoluteFill>\n      )}\n\n      {/* 胶囊层：实体化 1 帧硬切出现 → hold → 缩小左移落位 → 揭示帧换鼠尾草绿 */}\n      {frame >= MORPH && (\n        <div style={{\n          position: 'absolute', left: 0, top: 0,\n          // origin 必须是 0 0：translate 先把原点送到目标中心，scale 绕该点缩放，\n          // 否则默认 50% 50% 会让落位时中心漂移 (1-s)*半宽\n          transformOrigin: '0 0',\n          transform: `translate(${px}px, ${py}px) scale(${ps * settle})`,\n        }}>\n          <div style={{ transform: 'translate(-50%, -50%)' }}>\n            {revealed\n              ? <Pill bg={C.pillSage} iconColor={C.iconSage} textColor={C.pillSageText} />\n              : <Pill bg={C.pillGray} iconColor={C.iconGray} textColor={C.pillTextGray} />}\n          </div>\n        </div>\n      )}\n    </AbsoluteFill>\n  );\n};\n\n\n"
}