Motion · Tương tác

Hashtag hóa thành viên nhãn

Chữ "#word" gõ ra, cắt cứng 1 khung hình thành viên nhãn lớn, giữ hình rồi thu nhỏ trượt trái về vị trí, một lần cắt cứng nữa lộ ra trang hoàn thiện — hai lần cắt cứng kẹp một lần trượt.

1920×108016:930 fps4.46s · 134 frameremotionGiấy phép: Apache-2.0#shotcraft#remotion#interaction#typography

Nguồn: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composition Remotion (React/TSX) — có video preview, không có player trong trình duyệt

Khi nào dùng

Đoạn demo tính năng thẻ/phân loại/từ khóa (gắn tag trong app ghi chú, tổng hợp chủ đề); kể chuyện ba hồi "gõ vào → thành thực thể UI → về đúng vị trí trong sản phẩm hoàn thiện".

Ý đồ

Cách thông thường để biến chữ thành thực thể là gradient/morph/mở rộng, nhưng phân tích từng khung hình của tư liệu gốc chứng minh Bear làm ngược lại: thực thể hóa là cắt cứng 1 khung hình — khung trước vẫn là chữ+con trỏ, khung sau đã là viên nhãn hoàn chỉnh, không mở rộng, không crossfade, không bật lại. Cắt cứng mang lại cảm giác chắc chắn "bụp, xong", bất kỳ chuyển tiếp mượt nào cũng sẽ làm mềm "thực thể" thành "hiệu ứng". Khung xương của cả đoạn là hai lần cắt cứng kẹp một lần trượt: cắt cứng thực thể hóa → trượt-thu nhỏ mượt về vị trí → cắt cứng lộ ra trang hoàn thiện. Chuyển động liên tục duy nhất (trượt-thu nhỏ) bị đóng khung bởi hai lần cắt cứng, nhờ vậy mới vừa nhanh vừa vững. Khung nhịp này là điểm chốt — đổi bản chất của bất kỳ đoạn nào trong ba đoạn là cả thẻ sụp đổ.

Thời lượng & năng lượng

  • Thời lượng: Gõ chữ ~40f + cắt cứng thành viên nhãn giữ hình ~18f + thu nhỏ-di chuyển 14f + cắt cứng lộ trang rồi đứng yên; cả đoạn khoảng 3.5s (18–21.5s trong tư liệu gốc)
  • Năng lượng: Trung bình (dứt khoát gọn gàng, nhờ cắt cứng tạo lực chứ không phải nảy)

Công thức cảnh quay (bản gốc tiếng Trung — timing, easing, bảng tham số, lỗi hay gặp)

意图

文字变实体的常规做法是渐变/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

Mã nguồn tham chiếu (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

Component import fixture chung từ demos/_fixtures và helper từ assets/lib của repo; xem SKILL.md trong repo để biết quy trình render.

Mọi thứ trên trang này đều có bản máy đọc. Đưa agent link JSON, hoặc thêm MCP server gimgs rồi bảo nó tìm motion template.

  1. Tải JSON — trong đó có công thức cảnh quay (timing, easing, bảng tham số, lỗi hay gặp) và toàn bộ source TSX.
  2. Clone repo video-shotcraft; component import fixture chung từ demos/_fixtures và helper từ assets/lib.
  3. Đăng ký component thành <Composition> trong project Remotion rồi render bằng `npx remotion render`, hoặc dựng lại công thức bằng engine của bạn.
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 hóa thành viên nhãn" (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.0Mở template gốc ↗
// ===== 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
—

Cùng danh mục