Motion · Chuyển cảnh

Cắt vòng tròn khớp iris

Vòng iris nổ ra từ tâm một phần tử tròn có sẵn trên trang, bên trong vòng trang mới có biểu đồ tròn khớp đúng vào cùng một vòng đó — match cut cho iris một điểm neo ngữ nghĩa.

1920×108016:930 fps4.72s · 142 frameremotionGiấy phép: Apache-2.0#shotcraft#remotion#transition

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

Điểm nối khi tiền cảnh có phần tử tròn (avatar/icon/nút tròn) và hậu cảnh có chủ thể tròn (biểu đồ donut/vòng tiến độ tròn); thẻ kỹ thuật chuyển cảnh.

Ý đồ

Vòng iris thông thường (mục gốc trong bảng từ vựng) mở ra từ bất kỳ điểm nào, người xem chỉ đọc được "đã đổi trang". Thẻ này hàn nó với match-cut: vòng iris phải nổ ra từ tâm một phần tử tròn có thật trong tiền cảnh, bên trong vòng đó trang mới mọc lên có một biểu đồ tròn khớp đúng vào cùng một vòng — người xem thấy "vòng tròn của avatar biến thành vòng tròn của biểu đồ", bản thân hình tròn hoàn tất tường thuật (người này → dữ liệu của người này). Đây là dạng match-cut đặt trong ngữ cảnh UI: không dựa vào trùng hợp bố cục, mà dựa vào ngữ nghĩa tròn khớp tròn. Cùng nhóm với bộ ba "xuyên cảnh" (hậu cảnh đã hiện diện sẵn trong tiền cảnh), nhưng điểm neo ở đây là một hình dạng trừu tượng chứ không phải một container.

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

  • Thời lượng: 4.7s (xung nhịp điểm neo 30f + iris giãn nở 45f + biểu đồ mọc lên 55f + giữ hình 40f).
  • Năng lượng: Trung bình cao

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)

意图

普通 iris 光圈(词汇表原条目)从任意点开圈,观众只读到"换页了"。 本卡把它和 match-cut 焊死:光圈必须从前景一个真实圆形元素的圆心 炸开,圈内长出的新页里有个圆形图表恰好接在同一个圆上——观众看到的 是"头像的圆变成了图表的圆",圆形本身完成叙事(这个人→这个人的数据)。 这是 match-cut 在 UI 语境里的落地形态:不靠构图巧合,靠语义圆对圆。 与穿越三式同属"后景在前景里预先在场",但锚点是抽象形状而非容器。

动效核心

  • 两景的圆严格同心是命门:锚点圆心写死为一组常量(demo:CX=308,

CY=384.8,手算自布局),clip-path circle、新景圆环、脉冲光环全部 引用同一组常量——坐标各算各的必然错心

  • 定睛引导:光圈开圈前锚点元素两次放大脉冲 + 两道扩散光环(30f),

把视线钉在圆上再动

  • 光圈 clip-path: circle(r at x y),r 22→2100px(45f inOut cubic);

新景圆环同窗从 22→170px 生长——接圆发生在扩张中途(观众在 光圈还没吃满屏时就看到圆被接住了),不是开完圈再画图

  • 圆环 sweep:strokeDasharray = sweep×周长,rotate(-90) 从顶部起笔,

中央数字随 sweep 同步计数

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 锚点脉冲 | 2 次 scale 1→1.15 + 光环扩两道 / 30f | 没有定睛引导,开圈瞬间观众找不到圆心 | | 光圈扩张 | 22→2100px / 45f Easing.inOut(cubic) | 快于 30f 接圆读不到;圆心偏移 >10px 一眼穿帮 | | 接圆时机 | 圆环在光圈扩张中途(~40% 进度)即可见并同心生长 | 开完圈再长图表 = 普通转场+普通图表动画,匹配感归零 | | sweep | 45–100f 到目标值,数字同步计数 | 数字与 sweep 异步读作两个动画 | | 收尾 | sweep 完成后真静止 ≥40f | R1 |

已知坑

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

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

  • 真实素材上锚点坐标从截图量测(devtools/标尺),量到的是元素盒不是

视觉圆心——带内边距的头像取内容圆心;量完渲一帧对不齐就调常量, 别信第一次量的数(同 transition-travel C 腔心判例)

  • 前景锚点必须是真圆:圆角方块(border-radius < 50%)当锚点,

开圈瞬间方圆错型一眼假;demo 里就是用白补丁把方块盖成真圆的

  • 语义要成对:头像→个人数据、产品图标→产品指标——圆对圆只是形,

"这个圆的内容变成它的展开"才是匹配剪辑成立的因

  • 一缝一式规矩同 shot-transitions;本卡自带图表生长段,圈内新景

别再叠入场动效

参考实现

demos/transition/circle-match-iris/ (CircleMatchIris.tsx)

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

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

Prompt

Use the video-shotcraft shot recipe "Cắt vòng tròn khớp iris" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/circle-match-iris.json
Raw TSX: https://gimgs.net/motion/circle-match-iris/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/transition/circle-match-iris/CircleMatchIris.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 4.72s

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

5,774 chars · Apache-2.0Mở template gốc ↗
// ===== demos/transition/circle-match-iris/CircleMatchIris.tsx =====
// 圆心匹配光圈切(match-cut × iris-reveal 组合):
// 帧 0–30:景 A(列表面板)hold,第 2 行 44px 圆形头像做两次脉冲 + 扩散光环提示"看这里";
// 帧 30–75:景 B 以 clip-path: circle(r at CX CY) 从 22px 炸开到 2100px(Easing.inOut(cubic)),
//   景 B 是深色圆环图表页,圆环半径同步从 22px 长到 170px——在光圈吃满全屏前就"接住"头像的圆;
// 帧 45–100:圆环描边 sweep 到 78%,中央大数字随之浮现计数;帧 100–140 全属性静止收尾(≥35f)。
// 命门:两景的圆严格同心——CX/CY 写死为 FakeDashboard B 第 2 行头像的屏幕坐标常量。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { FakeDashboard, G } from '../../_fixtures/Fixtures';

// FakeDashboard variant B 第 2 行左侧 44px 头像的圆心(手算自 fixture 布局)
const CX = 308;
const CY = 384.8;

export const CircleMatchIris: React.FC = () => {
  const f = useCurrentFrame();

  // ---- 景 A:头像脉冲(帧 0–30,两次呼吸) ----
  const pulseT = Math.min(f, 30) / 30;
  const scale = f < 30 ? 1 + 0.45 * Math.abs(Math.sin(pulseT * Math.PI * 2)) : 1;
  // 两道扩散光环
  const waves = [0, 14].map((start) => {
    const p = interpolate(f, [start, start + 16], [0, 1], {
      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
    });
    return { r: 22 + p * 40, o: f < start + 16 ? 0.85 * (1 - p) : 0 };
  });

  // ---- 光圈:景 B 从同一圆心炸开 ----
  const irisR = interpolate(f, [30, 75], [22, 2100], {
    easing: Easing.inOut(Easing.cubic),
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  // ---- 景 B 圆环:半径从 22 长到 170,"接住"头像的圆 ----
  const ringR = interpolate(f, [30, 70], [22, 170], {
    easing: Easing.inOut(Easing.cubic),
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const ringW = interpolate(f, [30, 70], [12, 40], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  // 描边 sweep 到 78%
  const sweep = interpolate(f, [45, 100], [0, 0.78], {
    easing: Easing.out(Easing.cubic),
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const circ = 2 * Math.PI * ringR;
  const num = Math.round(sweep * 100);
  const numOpacity = interpolate(f, [68, 88], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const furnitureOpacity = interpolate(f, [60, 85], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  return (
    <div style={{ width: 1920, height: 1080, position: 'relative', overflow: 'hidden', background: G.bg }}>
      {/* ===== 景 A:列表面板 ===== */}
      <FakeDashboard variant="B" />
      {/* 白色补丁盖住 fixture 自带的圆角方块,再叠真正的圆形头像 */}
      <div style={{ position: 'absolute', left: CX - 23, top: CY - 23, width: 46, height: 46, background: G.card }} />
      <div style={{
        position: 'absolute', left: CX - 22, top: CY - 22, width: 44, height: 44,
        borderRadius: 22, background: G.mid, border: `3px solid ${G.ink}`,
        boxSizing: 'border-box', transform: `scale(${scale})`,
      }} />
      {/* 脉冲扩散光环 */}
      <svg width={1920} height={1080} style={{ position: 'absolute', left: 0, top: 0 }}>
        {waves.map((w, i) => (
          <circle key={i} cx={CX} cy={CY} r={w.r} fill="none" stroke={G.ink} strokeWidth={4} opacity={w.o} />
        ))}
      </svg>

      {/* ===== 景 B:深色圆环图表页,从同一圆心以光圈长出 ===== */}
      {f >= 30 && (
        <div style={{
          position: 'absolute', left: 0, top: 0, width: 1920, height: 1080,
          background: G.ink,
          clipPath: `circle(${irisR}px at ${CX}px ${CY}px)`,
        }}>
          {/* 圆环 donut:圆心与头像严格同点 */}
          <svg width={1920} height={1080} style={{ position: 'absolute', left: 0, top: 0 }}>
            {/* 底轨 */}
            <circle cx={CX} cy={CY} r={ringR} fill="none" stroke="#5a5a58" strokeWidth={ringW} />
            {/* sweep 弧,从正上方起 */}
            <circle
              cx={CX} cy={CY} r={ringR} fill="none" stroke="#ececea"
              strokeWidth={ringW} strokeLinecap="round"
              strokeDasharray={`${sweep * circ} ${circ}`}
              transform={`rotate(-90 ${CX} ${CY})`}
            />
          </svg>
          {/* 中央大数字 */}
          <div style={{
            position: 'absolute', left: CX - 150, top: CY - 80, width: 300, height: 160,
            display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
            opacity: numOpacity,
          }}>
            <div style={{ fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800, fontSize: 96, color: '#f2f2f0', letterSpacing: -2 }}>
              {num}%
            </div>
            <div style={{ marginTop: 6, height: 12, width: 130, background: '#6a6a68', borderRadius: 6 }} />
          </div>
          {/* 右侧页面家具:标题 + 统计条,证明这是一整页 */}
          <div style={{ position: 'absolute', left: 680, top: 260, opacity: furnitureOpacity, display: 'flex', flexDirection: 'column', gap: 30 }}>
            <div style={{ height: 34, width: 520, background: '#c2c2c0', borderRadius: 10 }} />
            <div style={{ height: 16, width: 780, background: '#5a5a58', borderRadius: 8 }} />
            <div style={{ height: 16, width: 640, background: '#5a5a58', borderRadius: 8 }} />
            <div style={{ height: 16, width: 700, background: '#5a5a58', borderRadius: 8 }} />
            <div style={{ display: 'flex', gap: 28, marginTop: 24 }}>
              {[0, 1, 2].map((i) => (
                <div key={i} style={{ width: 240, height: 150, background: '#454543', border: '2px solid #5a5a58', borderRadius: 14, padding: 20, boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 12 }}>
                  <div style={{ height: 12, width: `${55 + i * 12}%`, background: '#8f8f8d', borderRadius: 6 }} />
                  <div style={{ height: 30, width: '45%', background: '#c2c2c0', borderRadius: 8, marginTop: 'auto' }} />
                </div>
              ))}
            </div>
          </div>
        </div>
      )}
    </div>
  );
};


  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/transition/circle-match-iris/CircleMatchIris.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