Motion · Chữ & title card

Từ dẫn phóng to rồi ráp câu

Từ dẫn chiếm giữa khung hình ở cỡ chữ 2.3 lần, tiếp tục đẩy gần trong lúc giữ hình, rồi thu về cỡ chữ cuối trong khi cả dòng trượt trái vào đúng vị trí, các từ còn lại được đẩy vào từ đúng chỗ của chúng.

1920×108016:930 fps2.8s · 84 frameremotionGiấy phép: Apache-2.0#shotcraft#remotion#typography#opening

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

Thẻ chữ kiểu Introducing cho tên thương hiệu/sản phẩm; cảnh thứ hai của phần mở đầu kiểu sự kiện ra mắt; bất kỳ câu khẩu hiệu một dòng nào cần "cho một từ chiếm hết khung hình trước, rồi mới ráp đủ cả câu quanh nó".

Ý đồ

Lỗi dễ mắc nhất của thẻ chữ là "mờ hiện cả câu cùng lúc" — khán giả nhận 5 từ cùng một lúc và không nhớ được từ nào. Thẻ này biến thứ tự đọc thành thứ tự vật lý: trước tiên chỉ cho một từ, và cho nó to đến mức chiếm hết khung hình, còn đang tiếp tục đẩy về phía khán giả, buộc họ phải đọc nó trước; rồi từ đó thu về đúng cỡ chữ và vị trí của nó trong câu, cả câu mọc ra quanh nó. Khi hình ổn định, khán giả đã đọc từ chủ đạo hai lần. Điểm chốt về cảm giác thật sự không nằm ở đường cong scale, mà ở điểm neo. Từ dẫn di chuyển từ "chính giữa khung hình" đến vị trí của nó trong dòng — nếu điểm neo lấy tâm hộp dòng, việc scale và trượt trái sẽ mỗi cái kéo chữ theo một hướng khác nhau, khiến đường cơ sở rung mắt thường thấy được ở vài khung hình cuối. Ghim transform-origin theo chiều ngang tại tâm từ dẫn, theo chiều dọc tại đường cơ sở, hai điểm này đứng yên suốt toàn đoạn, cả dòng chỉ đơn giản là "thu nhỏ lại" mà không hề trôi dạt.

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

  • Thời lượng: Khoảng 2.8s (84f@30fps; câu ráp xong ở khung f32, dịch lên và dòng phụ chạy f34–50, giữ hình 22f, crash cuối 12f)
  • Năng lượng: Trung bình cao (mở đầu đã là chữ to chiếm hết khung hình, nhưng toàn đoạn chỉ có một chuyển động, không có cú va chạm thứ hai)

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)

意图

字卡最容易犯的错是"整句一起淡入"——观众同时收到 5 个词,一个都没记住。这张卡把 阅读顺序做成物理顺序:先只给一个词,而且大到占满画面、还在继续朝观众推近,观众 被迫先读它;然后这个词退回自己在句子里的尺寸和位置,整句围绕它长出来。落定时观众 已经读过两遍主词。

真正的手感命门不是缩放曲线,而是支点。首词从"画面正中"走到"行内第三个字符的 位置",如果支点取行盒中心,缩放和左滑会各自把字往两边拽,末尾几帧基线抖得肉眼可见。 把 transform-origin 横向钉在首词中心、纵向钉在基线,这两点全程不动,整行就只是 "收小",没有任何漂移。

动效核心

  • 两段缩放叠加成一条 scale:

scale = interpolate(f, [0,12], [2.3, 2.3·1.06], PUSH_EASE) + interpolate(f, [12,24], [0, 1-2.3·1.06], ZOOM_EASE)。 第一段是 hold 期间的推近(cubic-bezier(.25,1,.5,1),减速后挂在峰值),第二段是缩回 (从峰值一路减到终字号 1)。写成相加而不是分段判断,两段之间没有速度断点

  • 缩回与左滑共用 ZOOM_EASE(cubic-bezier(.5,0,.05,1),重 ease-in-out、零回弹):

translate = interpolate(f, [12,36], [slideDistance, 0], ZOOM_EASE)。两个动作必须读作 一次运动;各走各的曲线时会读成"先缩小、再滑走"两拍

  • 偏心距怎么来:slideDistance = lineWidth · (0.5 - leadRatio),leadRatio =

首词中心在整行里的占比。首词要停在画面正中,整行就得先向右偏这么多

  • 支点实测:挂载时 delayRender 挂起渲染,量三个值——整行 offsetWidth、

首词 offsetLeft + offsetWidth/2、基线。基线用一个零尺寸 inline-block 尺子读出 (<span style={{display:'inline-block',width:0,height:0}}> 的 offsetTop 就是基线), 比 lineHeight × 0.8 之类的估算准得多。量到之后 continueRender,transform-origin 写成 ${leadRatio*100}% ${baseline}px

  • 后续词是被推进来的,不是淡进来的:第 i 词(i≥1)在

pushStart = 12 + 6 + (i-1)·4 起,translate 从 0.5em → 0 (cubic-bezier(.22,.8,.36,1),起步快、落地长),透明度只用 2f 淡完。淡入必须极短, 否则词会"浮现"而不是"被塞进槽位"

  • 词间空格必须放在 inline-block 之外。跟在词 span 内部尾随时会被行盒裁掉,

落定后所有词黏成一团(上游原组件的 bug,这里已修)

  • 场景层的三拍收尾:f34–50 整行 translateY 0→−56px(outCubic),副行在同一时窗

淡入 + 上移 16px——上移让出的空间当帧被副行填掉,没有空当;f50–72 完全静止 22f; f72–83 整幕 scale 1→1.2 + blur 0→9px + opacity 1→0.45(ease-in-quad),推近失焦 交棒下一镜

  • 强调词:精确匹配某个词换强调色(demo 里是 Lumen)。品牌词推入落位那一下自带

高亮,不用再加第二个动作

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 首词起手倍数 | initialScale 2.3 | 2.3 让首词横向溢出画面一点点(读作"太大了装不下");<1.8 只是"稍大的字",冲击消失 | | 推近 | pushScale 1.06,占满 hold 12f | 6% 是"还在朝观众来"的最小可感量;>1.15 会在缩回时读作反向弹跳 | | hold | 12f | 首词独占画面的时长;<8f 观众读不完一个长单词,>20f 整句节奏塌 | | 缩回 | 12f,ZOOM_EASE | 与左滑同曲线;单独给缩回一条更快的曲线会让整行"先缩完再滑",断成两拍 | | 左滑 | assembleDuration 24f | 比缩回长一倍:字号已经落定、位置还在走,这段"滑行尾巴"是电影感的来源 | | 后续词延迟 | wordDelay 6f(缩回起点之后) | 首词开始缩回才放后续词;同帧放会挤在一起看不清谁在动 | | 词间错峰 | wordStagger 4f | 4f 读作"依次被推进";0 则整段后缀一起进,回到"整句淡入" | | 单词推入 | wordDuration 12f,wordPush 0.5em | 0.5em 是"刚从槽位外面";>1em 会读作从画外飞入,与首词的收束方向打架 | | 词淡入 | wordFade 2f | 刻意极短。>6f 词会读作"浮现",推入的力学感消失 | | 字号/字重/字距 | 96px / 600 / −0.03em | 负字距在放大 2.3 倍时才不会散架;正字距的大字会读作"标语横幅" | | 上移 + 副行 | f34–50,−56px,副行同窗 | 两件事必须同窗:先上移再出副行会多出一个空当拍 | | 静止 | 22f | 落定后的可读时间,低于 15f 观众来不及读副行 | | crash | 末 12f,scale→1.2,blur→9px | 这是转场的前半,动机要和下一镜的第一帧对上;单独播时看起来像"没结束"是正常的 |

已知坑

  • 支点错了就抖基线。transform-origin 必须是「首词中心 % + 基线 px」这一对。

取行盒中心(默认 50% 50%)时缩放与左滑互相拉扯,末尾几帧基线肉眼可见地跳

  • 基线别估算。lineHeight × 0.8 这类经验值在不同字体上差几像素,放大 2.3 倍后就是

十几像素的抖动。零尺寸 inline-block 尺子的 offsetTop 是唯一可靠的读法

  • delayRender 期间整行 opacity: 0——必须如此。否则未量到支点的第一帧会闪一下

错位的大字,成片里就是一帧脏帧

  • 布局是挂载时实测的,所以结果随渲染环境的字体而变:跨平台字体回退不同时整行宽度

会整体漂移(首词占比也跟着变)。介意的话把量到的三个值写死成常量

  • scale / translate 用的是独立 CSS 属性(不是 transform 简写)。混用会让其中一个

被覆盖;要合并成 transform 就必须自己保证 translate → scale 的顺序

  • 词间空格放进词 span 内部会被行盒裁掉(见上)。这个 bug 在小字号下不明显,96px 时

所有词会明显黏连

  • 首词必须是短词。"Introducing" 这类 10 字符左右刚好;首词超过 14 字符时 2.3 倍

放大会溢出画面两侧,观众读不到词尾

  • 强调色只给一个词。两个词都上色时观众不知道该记哪个,而且推入落位的高亮变成了

"彩色标题",动作的意义被抹掉

  • 末段 crash 是转场的前半式,不是收尾。单独当片尾用会显得"断了"——要收尾就把

crash 换成静止 hold 或淡出

参考实现

demos/typography/lead-word-zoom-assemble/ (LeadWordZoomAssemble.tsx)

Mã nguồn tham chiếu (Remotion)

  • demos/typography/lead-word-zoom-assemble/LeadWordZoomAssemble.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/typography/lead-word-zoom-assemble/LeadWordZoomAssemble.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/lead-word-zoom-assemble.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/typography/lead-word-zoom-assemble/LeadWordZoomAssemble.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("lead-word-zoom-assemble")

Prompt

Use the video-shotcraft shot recipe "Từ dẫn phóng to rồi ráp câu" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/lead-word-zoom-assemble.json
Raw TSX: https://gimgs.net/motion/lead-word-zoom-assemble/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/typography/lead-word-zoom-assemble/LeadWordZoomAssemble.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 2.8s

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

8,322 chars · Apache-2.0Mở template gốc ↗
// ===== demos/typography/lead-word-zoom-assemble/LeadWordZoomAssemble.tsx =====
// lead-word-zoom-assemble — 首词推近落位组句
// 首词以 2.3 倍字号占据画面中央,hold 期间继续向观众推近 6%,随后一条曲线同时
// 完成「缩回终字号」与「整行左滑到最终排布」,后续词从右侧各自槽位外 0.5em 推入;
// 整行上移的同一时窗里副行浮出,停一拍后整幕 crash-zoom 推近失焦交棒下一镜。
//
// 手感命门是支点:transform-origin 横向钉在**首词中心**、纵向钉在**基线**——
// 这两点是全程唯一不该移动的锚,挂载时实测一次(基线用零尺寸 inline-block 的
// offsetTop 读出),否则缩放过程中字会逐帧抖基线。
// 参数以 1920×1080 标定。
import React, { Fragment, useEffect, useRef, useState } from 'react';
import {
  AbsoluteFill,
  continueRender,
  delayRender,
  Easing,
  getRemotionEnvironment,
  interpolate,
  useCurrentFrame,
  useVideoConfig,
} from 'remotion';

export const LEAD_WORD_ZOOM_ASSEMBLE_DURATION = 84; // 2.8s @30fps

// ---- 编舞常量 ----
const TEXT = 'Introducing Lumen Deck';
const HIGHLIGHT_WORD = 'Lumen'; // 精确匹配的词换强调色:品牌词落位那一下自带高亮
const FONT_SIZE = 96;
const INITIAL_SCALE = 2.3; // 首词起手放大倍数
const INTRO_DURATION = 6; // f:首词淡入
const HOLD_DURATION = 12; // f:首词在峰值停留(推近发生在这一段)
const PUSH_SCALE = 1.06; // hold 期间继续推近的倍数
const RECEDE_DURATION = 12; // f:缩回终字号
const ASSEMBLE_DURATION = 24; // f:整行左滑到位
const WORD_DELAY = 6; // f:缩回起点后第二个词开始推入
const WORD_STAGGER = 4; // f:后续词之间的错峰
const WORD_DURATION = 12; // f:单词推入行程
const WORD_PUSH = 0.5; // em:后续词起始位置在自己槽位右侧多远
const WORD_FADE = 2; // f:后续词淡入(刻意极短——是被推进来的,不是淡进来的)
const LETTER_SPACING = '-0.03em';

// ---- 场景层 ----
const LIFT: [number, number] = [34, 50]; // f:整行上移 + 副行同帧出现
const LIFT_DISTANCE = -56; // px
const SUBLINE = 'One shot card, one motion recipe — copy, paste, render.';
const CRASH_FRAMES = 12; // f:段尾 crash-zoom 占用的收尾帧数
const CRASH_SCALE = 0.2;
const CRASH_BLUR = 9;

const INK = '#1d1d1f';
const INK_DIM = '#7a7a7a';
const ACCENT = '#7A5AF8';
const SANS = '-apple-system, "PingFang SC", BlinkMacSystemFont, sans-serif';
const MESH_BG =
  'radial-gradient(52% 44% at 18% 22%, rgba(122,90,248,0.20) 0%, rgba(122,90,248,0) 70%),' +
  'radial-gradient(46% 42% at 84% 18%, rgba(255,138,178,0.20) 0%, rgba(255,138,178,0) 70%),' +
  'radial-gradient(58% 50% at 78% 84%, rgba(96,190,255,0.20) 0%, rgba(96,190,255,0) 70%),' +
  'radial-gradient(50% 46% at 24% 88%, rgba(255,196,112,0.20) 0%, rgba(255,196,112,0) 70%),' +
  'linear-gradient(180deg, #f7f6f9 0%, #f2f1f5 100%)';

/** 首词向观众漂移:先快后停,峰值处挂住 */
const PUSH_EASE = Easing.bezier(0.25, 1, 0.5, 1);
/** 缩回与左滑共用同一条曲线——两个动作必须读作一次运动,不能各走各的 */
const ZOOM_EASE = Easing.bezier(0.5, 0, 0.05, 1);
/** 后续词推入槽位:起步快,落地长而软 */
const WORD_EASE = Easing.bezier(0.22, 0.8, 0.36, 1);

const TextReveal: React.FC = () => {
  const frame = useCurrentFrame();
  const { width } = useVideoConfig();

  // 整行宽度、首词中心占比、基线位置:只在挂载时量一次,量到之前挂起渲染
  const lineRef = useRef<HTMLSpanElement>(null);
  const leadRef = useRef<HTMLSpanElement>(null);
  const baselineRef = useRef<HTMLSpanElement>(null);
  const [handle] = useState(() => delayRender('lead-word-zoom-assemble: measure line'));
  const [metrics, setMetrics] = useState<{
    lineWidth: number;
    leadRatio: number;
    baseline: number;
  } | null>(null);

  useEffect(() => {
    const line = lineRef.current;
    const lead = leadRef.current;
    if (!line || !lead) {
      continueRender(handle);
      return;
    }
    setMetrics({
      lineWidth: line.offsetWidth,
      leadRatio: (lead.offsetLeft + lead.offsetWidth / 2) / line.offsetWidth,
      baseline: baselineRef.current?.offsetTop ?? line.offsetHeight * 0.8,
    });
  }, [handle]);

  useEffect(() => {
    if (metrics) continueRender(handle);
  }, [metrics, handle]);

  const words = TEXT.split(' ').filter(Boolean);
  const ready = metrics !== null;
  const leadRatio = metrics?.leadRatio ?? 0.14;
  const lineWidth = metrics?.lineWidth ?? width * 0.5;
  const baseline = metrics?.baseline ?? FONT_SIZE * 0.88;

  const zoomStart = HOLD_DURATION;
  // 首词从"画面正中"走到"行内自己的位置",位移就是这段偏心距
  const slideDistance = lineWidth * (0.5 - leadRatio);

  return (
    <AbsoluteFill style={{ alignItems: 'center', justifyContent: 'center' }}>
      <span
        ref={lineRef}
        style={{
          position: 'relative',
          display: 'inline-block',
          fontSize: FONT_SIZE,
          fontWeight: 600,
          color: INK,
          letterSpacing: LETTER_SPACING,
          lineHeight: 1.1,
          whiteSpace: 'nowrap',
          fontFamily: SANS,
          transformOrigin: `${leadRatio * 100}% ${baseline}px`,
          scale:
            interpolate(frame, [0, HOLD_DURATION], [INITIAL_SCALE, INITIAL_SCALE * PUSH_SCALE], {
              extrapolateLeft: 'clamp',
              extrapolateRight: 'clamp',
              easing: PUSH_EASE,
            }) +
            interpolate(
              frame,
              [zoomStart, zoomStart + RECEDE_DURATION],
              [0, 1 - INITIAL_SCALE * PUSH_SCALE],
              { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: ZOOM_EASE },
            ),
          translate: `${interpolate(
            frame,
            [zoomStart, zoomStart + ASSEMBLE_DURATION],
            [slideDistance, 0],
            { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: ZOOM_EASE },
          )}px`,
          opacity: ready ? 1 : 0,
          textRendering: 'geometricPrecision',
          ...(getRemotionEnvironment().isRendering ? null : { willChange: 'transform' as const }),
        }}
      >
        {words.map((word, i) => {
          const isLead = i === 0;
          const pushStart = zoomStart + WORD_DELAY + (i - 1) * WORD_STAGGER;
          const opacity = isLead
            ? interpolate(frame, [0, INTRO_DURATION], [0, 1], {
                extrapolateLeft: 'clamp',
                extrapolateRight: 'clamp',
              })
            : interpolate(frame, [pushStart, pushStart + WORD_FADE], [0, 1], {
                extrapolateLeft: 'clamp',
                extrapolateRight: 'clamp',
              });
          return (
            <Fragment key={i}>
              <span
                ref={isLead ? leadRef : undefined}
                style={{
                  display: 'inline-block',
                  opacity,
                  color: word === HIGHLIGHT_WORD ? ACCENT : undefined,
                  translate: isLead
                    ? undefined
                    : `${interpolate(
                        frame,
                        [pushStart, pushStart + WORD_DURATION],
                        [WORD_PUSH * FONT_SIZE, 0],
                        { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: WORD_EASE },
                      )}px`,
                }}
              >
                {word}
              </span>
              {/* 词间空格必须放在 inline-block 之外——跟在词里会被行盒裁掉,词会黏在一起 */}
              {i < words.length - 1 ? ' ' : null}
            </Fragment>
          );
        })}
        {/* 基线尺:零尺寸 inline-block,它的 offsetTop 就是基线 */}
        <span ref={baselineRef} style={{ display: 'inline-block', width: 0, height: 0 }} />
      </span>
    </AbsoluteFill>
  );
};

export const LeadWordZoomAssemble: React.FC = () => {
  const frame = useCurrentFrame();
  const { durationInFrames } = useVideoConfig();

  const lift = interpolate(frame, LIFT, [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
    easing: Easing.out(Easing.cubic),
  });
  const crash = interpolate(
    frame,
    [durationInFrames - CRASH_FRAMES, durationInFrames - 1],
    [0, 1],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.in(Easing.quad) },
  );

  return (
    <AbsoluteFill style={{ background: MESH_BG, fontFamily: SANS }}>
      <AbsoluteFill
        style={{
          transform: `scale(${1 + crash * CRASH_SCALE})`,
          filter: crash > 0.01 ? `blur(${crash * CRASH_BLUR}px)` : undefined,
          opacity: 1 - crash * 0.55,
        }}
      >
        <AbsoluteFill style={{ transform: `translateY(${lift * LIFT_DISTANCE}px)` }}>
          <TextReveal />
        </AbsoluteFill>
        <div
          style={{
            position: 'absolute',
            left: 0,
            right: 0,
            top: '50%',
            marginTop: 62,
            textAlign: 'center',
            fontSize: 32,
            color: INK_DIM,
            opacity: lift,
            transform: `translateY(${(1 - lift) * 16}px)`,
          }}
        >
          {SUBLINE}
        </div>
      </AbsoluteFill>
    </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/typography/lead-word-zoom-assemble/LeadWordZoomAssemble.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