Motion · Interação

Hashtag to pill materialize

"#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.

1920×108016:930 fps4.46s · 134 quadrosremotionLicença: Apache-2.0#shotcraft#remotion#interaction#typography

Fonte: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composição Remotion (React/TSX) — vídeo de prévia, sem player no navegador

When to use

Demo 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".

Intention

The 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.

Duration & energy

  • 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)
  • Energy: Medium (crisp and clean, driven by the hard cuts, not by bounce)

Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)

意图

文字变实体的常规做法是渐变/morph/展开,原片帧级拆解证明 Bear 反着 来:实体化是 1 帧硬切——上一帧还是文字+光标,下一帧就是完整 胶囊,无展开无 cross-fade 无回弹。硬切给的是"啪、成了"的确定感, 任何渐变都会把"实体"软化成"特效"。全段骨架是两次硬切夹一次 滑动:硬切实体化 → 平滑缩移归位 → 硬切揭示成品页。唯一的连续 运动(缩移)被两记硬切框住,才显得又快又稳。这个节奏骨架是命门, 三段挪动任何一段的性质(把硬切改渐变、把滑动改硬切)整卡就塌。

动效核心

  • 打字段:几何无衬线(Futura 气质)居中打 "#word",**红实心光标

恒亮不闪**(原片实测,别加 blink);人手节奏 4–6f/字带确定性抖动

  • 实体化 = 1 帧硬切:文字+光标整层消失,同帧出现无描边浅灰

胶囊 + 图标 + 词(字号不变、原位等大,# 被图标替换);仅允许 3f 1.03→1 微落定防死板

  • 胶囊 hold ~0.6s → 一段 bezier easeInOut 缩小左移(~0.55x、

~14f)落到页面标签位,位置/缩放同曲线同起止,中途不分段

  • 落位后再 1 帧硬切揭示成品页:底色/标题/正文/胶囊配色同帧

全变,之后真静止收尾

  • 实现要点:胶囊按大字号绘制、整体 transform 缩放,且

transformOrigin 必须 0 0 + translate 先行,否则落位中心漂移

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 打字节奏 | 4–6f/字(原片 ~6 字/秒),mulberry32 抖动 | 均匀间隔读作机打;光标必须恒亮(原片实测不闪烁) | | 实体化 | 1 帧硬切(原片帧级实测无任何过渡)+ 3f 1.03→1 微落定 | 加淡入/展开立刻降级成普通 morph;微落定 >4f 读作弹跳 | | 胶囊几何 | 740×236@1920(原片实测 493×157@720p ×1.5),无描边 | 胶囊要"宽大过头"才有实体感;加描边读作按钮 | | hold | ~18f(0.6s,原片实测) | <12f 观众没看清胶囊长啥样就飞了 | | 缩移 | ~0.55x(原片实测 273/493)、14f、bezier(0.5,0,0.25,1) | 缩放与位移必须同曲线;分开调会读作两个动画 | | 揭示 | 落位 +3f 再 1 帧硬切全页 | 揭示若做转场,第二记硬切没了,骨架塌一半 |

已知坑

  • demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿,

首次实战须以真实素材回验

  • 与 typewriter-moves/ai-stream-response(打字呈现)、

morph-from-primitive(图元渐变成形)撞领域:本卡命门是硬切 实体化非渐变变形——只要你想加过渡,就该去用那几张卡而不是改这张

  • 早期版本曾杜撰"胶囊飞入下方滑入笔记卡"段,原片对照证伪已砍;

别复活飞行段

  • transformOrigin 默认 50% 50% 会让缩放落位中心漂移 (1−s)×半宽,

必须 origin 0 0 + translate 到目标中心再 scale(demo 内注释)

  • 与原片残余差距:音符图标手绘 SVG 曲线、Futura 回退字重、揭示帧

正文间距 ~10px 级别偏差;节奏与硬切时点已密帧对齐

参考实现

demos/interaction/hashtag-to-pill-materialize/ (HashtagToPillMaterialize.tsx) 原片出处:bear-app.mp4 18–21.5s

Reference implementation (Remotion)

  • demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx

The component imports shared fixtures from demos/_fixtures and helpers from assets/lib in the repo; SKILL.md there documents the render workflow.

Tudo nesta página também é legível por máquina. Aponte seu agente para o JSON ou adicione o servidor MCP do gimgs e peça para buscar templates de motion.

  1. Baixe o JSON — contém a receita do plano (timing, easing, tabela de parâmetros, armadilhas) e o código TSX completo.
  2. Clone o repositório video-shotcraft; o componente importa fixtures de demos/_fixtures e helpers de assets/lib.
  3. Registre o componente como <Composition> em um projeto Remotion e renderize com `npx remotion render`, ou reimplemente a receita no seu próprio engine.
curl -s https://gimgs.net/motion/hashtag-to-pill-materialize.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("hashtag-to-pill-materialize")

Prompt

Use the video-shotcraft shot recipe "Hashtag to pill materialize" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/hashtag-to-pill-materialize.json
Raw TSX: https://gimgs.net/motion/hashtag-to-pill-materialize/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 4.46s

1. Read the JSON and follow its "prompt" section (when to use, intention, parameter table, known pitfalls).
2. Adapt it for: <describe what you want>.
3. Either clone the repo, register the component as a <Composition> in a Remotion project and run npx remotion render <CompositionId> out.mp4 — or re-implement the recipe in your own engine (HTML/WAAPI works: keep the same timing and easing).

JSON · Markdown · llms.txt

6,297 chars · Apache-2.0Abrir template bruto ↗
// ===== demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx =====
// hashtag-to-pill-materialize(bear-app 18–21.5s 重做版,按原片密帧逐帧对照)
// 原片实测节奏(25fps 逐帧):
//  1) 白底居中打字 "#music":几何无衬线(Futura 气质)、中灰墨色、红色实心光标恒亮不闪、人手节奏
//  2) 实体化 = 1 帧硬切:文字+光标 → 宽大浅灰无描边胶囊 + 灰色双八分音符图标 + "music"(字号不变,#被图标替换)
//  3) 停约 0.6s → 整体平滑缩小(→~0.55x)并左移落到页面标签位(约 0.55s,easeInOut)
//  4) 再 1 帧硬切揭示成品笔记页:奶油底、墨绿大标题 "My favorite bands"、胶囊换成鼠尾草绿、正文三行——
//     原片没有"胶囊飞入下方滑入卡片"的段落(批次 8 的飞行段为杜撰,已砍)
import React from 'react';
import { AbsoluteFill, interpolate, useCurrentFrame, Easing } from 'remotion';

const FONT = "Futura, 'Century Gothic', 'Avenir Next', 'Trebuchet MS', sans-serif";

const C = {
  bgWhite: '#fcfcfb',
  bgCream: '#f4f1e5',
  ink: '#454543',
  cursor: '#e0453f',
  pillGray: '#e9e9e7',
  pillTextGray: '#4b4b49',
  iconGray: '#7e7e7c',
  pillSage: '#d5e0cf',
  iconSage: '#5c7a63',
  titleGreen: '#2d5c47',
  pillSageText: '#3f5e4c',
  body: '#4c4b43',
};

// ---- mulberry32(仅用于打字节奏的人手抖动,确定性)----
function mulberry32(seed: number) {
  let a = seed >>> 0;
  return () => {
    a = (a + 0x6d2b79f5) >>> 0;
    let t = a;
    t = Math.imul(t ^ (t >>> 15), t | 1);
    t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
  };
}

const TEXT = '#music';
// 打字起点 & 每字间隔(帧),4–8 帧带抖动,模拟原片真人节奏
const TYPE_START = 8;
const rand = mulberry32(20260717);
const TYPE_AT: number[] = (() => {
  const at: number[] = [];
  let f = TYPE_START;
  for (let i = 0; i < TEXT.length; i++) {
    at.push(f);
    f += 4 + Math.floor(rand() * 3); // 4–6 帧(原片 ~6字/秒)
  }
  return at;
})();

// ---- 时间轴(30fps,共 132 帧,节奏对齐原片 18–21.5s)----
const MORPH = 48;       // 1 帧硬切实体化(打完 hold ~0.5s,原片 0.45s)
const MOVE_START = 66;  // 胶囊开始缩小左移(morph 后 0.6s,原片同)
const MOVE_END = 80;    // 落位(0.47s,原片 ~0.45s)
const REVEAL = 83;      // 1 帧硬切揭示成品页,之后静置收尾

// ---- 几何(1920x1080,等比换算自原片 1280x720 实测像素)----
const FS = 132;                       // 打字/胶囊文字字号(原片 glyph 等高换算)
const HERO = { x: 960, y: 540 };      // 大胶囊中心
const PILL_W = 740, PILL_H = 236;     // 原片实测 493x157 @720p ×1.5
const END_SCALE = 0.554;              // 落位缩放(原片 273/493)
const SLOT = { x: 361, y: 473 };      // 标签位中心(原片灰胶囊落点 (244.5,317.5)×1.5 与揭示位折中)

// 灰色双八分音符图标(原片是 beamed 双音符,非 ♪)
const NoteIcon: React.FC<{ size: number; color: string }> = ({ size, color }) => (
  <svg width={size} height={size} viewBox="0 0 48 48" style={{ display: 'block' }}>
    <ellipse cx="11" cy="39" rx="7.2" ry="5.6" fill={color} transform="rotate(-18 11 39)" />
    <ellipse cx="35" cy="35" rx="7.2" ry="5.6" fill={color} transform="rotate(-18 35 35)" />
    <rect x="15.4" y="12.5" width="3" height="27" fill={color} />
    <rect x="39.4" y="8.5" width="3" height="27" fill={color} />
    <polygon points="15.4,12.5 42.4,8.5 42.4,16.5 15.4,20.5" fill={color} />
  </svg>
);

// 胶囊(大字号绘制,整体 transform 缩放,保证实体化前后文字原位等大)
const Pill: React.FC<{ bg: string; iconColor: string; textColor: string }> = ({ bg, iconColor, textColor }) => (
  <div style={{
    width: PILL_W, height: PILL_H, borderRadius: PILL_H / 2, background: bg,
    display: 'flex', alignItems: 'center', paddingLeft: 96, boxSizing: 'border-box', gap: 66,
  }}>
    <NoteIcon size={104} color={iconColor} />
    <span style={{ fontSize: FS, fontWeight: 500, color: textColor, letterSpacing: 2 }}>music</span>
  </div>
);

export const HashtagToPillMaterialize: React.FC = () => {
  const frame = useCurrentFrame();

  // ---- 打字 ----
  const typedCount = TYPE_AT.filter((t) => frame >= t).length;
  const typed = TEXT.slice(0, typedCount);

  // ---- 缩小左移 ----
  const moveT = interpolate(frame, [MOVE_START, MOVE_END], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
    easing: Easing.bezier(0.5, 0, 0.25, 1),
  });
  const px = interpolate(moveT, [0, 1], [HERO.x, SLOT.x]);
  const py = interpolate(moveT, [0, 1], [HERO.y, SLOT.y]);
  const ps = interpolate(moveT, [0, 1], [1, END_SCALE]);
  // 实体化瞬间极轻微落定(原片近乎硬切,仅 3 帧 1.03→1,避免死板)
  const settle = interpolate(frame, [MORPH, MORPH + 3], [1.03, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.quad),
  });

  const revealed = frame >= REVEAL;

  return (
    <AbsoluteFill style={{ background: revealed ? C.bgCream : C.bgWhite, fontFamily: FONT }}>
      {/* 成品页(硬切揭示,之后全静) */}
      {revealed && (
        <>
          <div style={{
            position: 'absolute', left: 160, top: 168,
            fontSize: 122, fontWeight: 700, color: C.titleGreen, letterSpacing: 0.5,
          }}>
            My favorite bands
          </div>
          <div style={{
            position: 'absolute', left: 152, top: 618,
            fontSize: 70, fontWeight: 500, color: C.body, lineHeight: 1.33, letterSpacing: 0.3,
          }}>
            I want to share a few of my favorite bands<br />
            and the song that I always listen when driving<br />
            to home. Welcome. Bring headphones.
          </div>
        </>
      )}

      {/* 打字层:文字 + 恒亮红光标(原片光标不闪烁),实体化帧整体消失 */}
      {frame < MORPH && (
        <AbsoluteFill style={{ justifyContent: 'center', alignItems: 'center' }}>
          <div style={{ display: 'flex', alignItems: 'center' }}>
            <span style={{ fontSize: FS, fontWeight: 500, color: C.ink, letterSpacing: 2, whiteSpace: 'pre' }}>
              {typed}
            </span>
            <span style={{
              display: 'inline-block', width: 7, height: 150,
              background: C.cursor, marginLeft: 8, borderRadius: 2,
            }} />
          </div>
        </AbsoluteFill>
      )}

      {/* 胶囊层:实体化 1 帧硬切出现 → hold → 缩小左移落位 → 揭示帧换鼠尾草绿 */}
      {frame >= MORPH && (
        <div style={{
          position: 'absolute', left: 0, top: 0,
          // origin 必须是 0 0:translate 先把原点送到目标中心,scale 绕该点缩放,
          // 否则默认 50% 50% 会让落位时中心漂移 (1-s)*半宽
          transformOrigin: '0 0',
          transform: `translate(${px}px, ${py}px) scale(${ps * settle})`,
        }}>
          <div style={{ transform: 'translate(-50%, -50%)' }}>
            {revealed
              ? <Pill bg={C.pillSage} iconColor={C.iconSage} textColor={C.pillSageText} />
              : <Pill bg={C.pillGray} iconColor={C.iconGray} textColor={C.pillTextGray} />}
          </div>
        </div>
      )}
    </AbsoluteFill>
  );
};


  1. Read the recipe
    Prompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points.
  2. Get the code
    tool: git
    git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install
  3. Register + render
    tool: remotion
    npx remotion render <CompositionId> out.mp4 # component: demos/interaction/hashtag-to-pill-materialize/HashtagToPillMaterialize.tsx
  4. Or re-implement
    Port the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing.
paramtypedefaultdescription
—

Mais nesta categoria