Motion · UI xuất hiện

Biến hình từ khối nguyên thủy

Một vòng tròn viền nét thở một nhịp (anticipation), rồi đường path SVG nội suy trong 24f thành viền thẻ bo góc, nội dung mờ hiện vào.

1920×108016:930 fps4.72s · 142 frameremotionGiấy phép: Apache-2.0#shotcraft#remotion#ui-entrance#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

Nhập cảnh cho chủ thể dạng hình học/icon/viền thẻ; nguyên mẫu kinh điển cho logo biến thành khung chứa UI.

Ý đồ

Thư viện nhập cảnh toàn là "đến từ bên ngoài" — bay vào, đập xuống, lật mặt — chủ thể đã tồn tại sẵn ngoài khung hình từ trước. Thẻ này là tự mọc lên tại chỗ: một vòng tròn viền nét ở giữa khung hình thở một nhịp trước, rồi viền của nó liên tục biến hình thành hình chữ nhật bo góc, nội dung mới mờ hiện vào sau. Việc khối nguyên thủy dừng lại và thở chính là nhịp dọn đường — bản dịch đồ họa của nguyên tắc anticipation của Disney: vòng tròn "hít vào" trước, khán giả đã biết nó sắp biến đổi, nên bản thân sự biến hình không cần giải thích gì thêm. Chỉ áp dụng cho chủ thể dạng hình học/icon/viền thẻ — ảnh chụp màn hình dạng bitmap không thể morph.

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

  • Thời lượng: ~4.7s (thở 20f + biến hình 24f + nội dung mờ hiện 12f)
  • Năng lượng: Trung bình thấp

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)

意图

入场库全是"从外面进来"——飞入、砸落、翻面,主体都预先存在于画外; 本卡是就地长出来:画面中心一个描边正圆先鼓一口气(scale 1→1.12→1),随即轮廓连续变形为圆角矩形卡片,内容再淡入。原型 停留加呼吸这一拍就是预备动作——迪士尼 anticipation 的图形学翻译: 圆先"吸气",观众就知道它要变,变形本身因此不需要任何解释。 仅适用图形/图标/卡轮廓类主体——位图截图不能 morph。

动效核心

  • 圆与圆角矩形写成同构 path:M + 8 段 cubic(4 直边段三等分点

做退化 cubic + 4 圆角段 kappa 公式 k=4/3·tan(Δθ/4)),逐命令 数值 lerp 生成 d 字符串,帧驱动全确定性

  • 圆锚点取矩形锚点同方位角,保证 t=0 是标准正圆、中途无自交
  • 呼吸拍 scale 1→1.12→1(spec 1.06 看不清,加码后可感——可感性

判例同族);呼吸与变形都用 Easing.inOut(cubic),interpolate 全 clamp

  • 内容是 HTML 层叠在 SVG 上,变形完成后 12f 淡入,不做 path 内嵌
  • 时间轴 140f:0–10 静置|10–30 呼吸|30–54 变形|56–68 内容

淡入|68–140 真静止 72f

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 呼吸幅度 | 1→1.12→1,20f inOut cubic | <1.1 读不出"吸气";>1.2 像要爆炸不是要变形 | | 原型停留 | 静置 10f + 呼吸 20f,合计 30f 才起变形 | 停留 <15f 读不出"预备",变形显得突兀 | | 变形时长 | 24f | 太快像切换、太慢像融化;24f 是"生长"的速度 | | 目标形态 | 520×300 圆角矩形卡(demo 值) | 圆角越大与圆同构性越好、中段越顺 | | 内容淡入 | 变形落定后 2f 起,12f 淡入 | 变形没完就进内容 = 内容跟着轮廓扭 |

已知坑

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

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

  • 变形中途别叠其他动画——morph 本身就是全部信息,同时飞入/

变色/晃动会把"长出来"稀释成"乱动"

  • 位图截图不能 morph:本卡只对矢量轮廓成立,页面截图请回

入场库常规手法

  • path 插值两端命令数必须严格同构(同为 M+8C),命令数不齐

中途会闪跳;改目标形状时先对齐锚点数再调参

  • 变形中段呈"桶形"过渡是正常形态(QA 判例),不是穿帮;

出现自交/尖刺才是锚点方位角没对齐

参考实现

demos/ui-entrance/morph-from-primitive/ (MorphFromPrimitive.tsx)

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

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

Prompt

Use the video-shotcraft shot recipe "Biến hình từ khối nguyên thủy" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/morph-from-primitive.json
Raw TSX: https://gimgs.net/motion/morph-from-primitive/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/ui-entrance/morph-from-primitive/MorphFromPrimitive.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,907 chars · Apache-2.0Mở template gốc ↗
// ===== demos/ui-entrance/morph-from-primitive/MorphFromPrimitive.tsx =====
// morph-from-primitive|原型变形
// 正圆呼吸一拍(anticipation) → path d 逐数值插值变形成 520×300 圆角矩形卡片轮廓
// → 卡片内部灰阶内容条淡入。全部帧驱动、确定性。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G } from '../../_fixtures/Fixtures';

const CX = 960;
const CY = 540;
const R = 130;
const RECT_W = 520;
const RECT_H = 300;
const RECT_R = 20;

// ---- 同构 path:M + 8 段 cubic(4 直边段 + 4 圆角段),圆与圆角矩形共用锚点拓扑 ----
// 圆角矩形锚点(中心坐标系,顺时针,从右边上端点起):
// A0(hw,-ihh) 右边上 → A1(hw,ihh) 右边下 → A2(iw,hh) 底边右 → A3(-iw,hh) 底边左
// → A4(-hw,ihh) 左边下 → A5(-hw,-ihh) 左边上 → A6(-iw,-hh) 顶边左 → A7(iw,-hh) 顶边右 → A0
const hw = RECT_W / 2; // 260
const hh = RECT_H / 2; // 150
const iw = hw - RECT_R; // 240
const ihh = hh - RECT_R; // 130
const KAPPA = 0.5522847498;

type Seg = [number, number, number, number, number, number]; // c1x c1y c2x c2y x y
type Shape = { start: [number, number]; segs: Seg[] };

const line = (from: [number, number], to: [number, number]): Seg => [
  from[0] + (to[0] - from[0]) / 3,
  from[1] + (to[1] - from[1]) / 3,
  from[0] + ((to[0] - from[0]) * 2) / 3,
  from[1] + ((to[1] - from[1]) * 2) / 3,
  to[0],
  to[1],
];
// 90° 圆角弧 from → to(弧心 c):c1 = from + k·(to−c),c2 = to + k·(from−c)
const corner = (
  from: [number, number],
  to: [number, number],
  c: [number, number]
): Seg => [
  from[0] + KAPPA * (to[0] - c[0]),
  from[1] + KAPPA * (to[1] - c[1]),
  to[0] + KAPPA * (from[0] - c[0]),
  to[1] + KAPPA * (from[1] - c[1]),
  to[0],
  to[1],
];

const rectAnchors: [number, number][] = [
  [hw, -ihh],
  [hw, ihh],
  [iw, hh],
  [-iw, hh],
  [-hw, ihh],
  [-hw, -ihh],
  [-iw, -hh],
  [iw, -hh],
];
const cornerCenters: [number, number][] = [
  [iw, ihh], // A1→A2 右下
  [-iw, ihh], // A3→A4 左下
  [-iw, -ihh], // A5→A6 左上
  [iw, -ihh], // A7→A0 右上
];

const rectShape: Shape = {
  start: rectAnchors[0],
  segs: [
    line(rectAnchors[0], rectAnchors[1]),
    corner(rectAnchors[1], rectAnchors[2], cornerCenters[0]),
    line(rectAnchors[2], rectAnchors[3]),
    corner(rectAnchors[3], rectAnchors[4], cornerCenters[1]),
    line(rectAnchors[4], rectAnchors[5]),
    corner(rectAnchors[5], rectAnchors[6], cornerCenters[2]),
    line(rectAnchors[6], rectAnchors[7]),
    corner(rectAnchors[7], rectAnchors[0], cornerCenters[3]),
  ],
};

// 圆:锚点取矩形锚点的同方位角,段间用精确弧公式 k = 4/3·tan(Δθ/4)
const angles = rectAnchors.map(([x, y]) => Math.atan2(y, x));
const circAnchors: [number, number][] = angles.map((a) => [
  R * Math.cos(a),
  R * Math.sin(a),
]);
const arcSeg = (i: number): Seg => {
  const a1 = angles[i];
  let a2 = angles[(i + 1) % 8];
  if (a2 <= a1) a2 += Math.PI * 2;
  const k = (4 / 3) * Math.tan((a2 - a1) / 4);
  const p1 = circAnchors[i];
  const p2 = circAnchors[(i + 1) % 8];
  return [
    p1[0] - k * R * Math.sin(a1),
    p1[1] + k * R * Math.cos(a1),
    p2[0] + k * R * Math.sin(a2 % (Math.PI * 2)),
    p2[1] - k * R * Math.cos(a2 % (Math.PI * 2)),
    p2[0],
    p2[1],
  ];
};
const circShape: Shape = {
  start: circAnchors[0],
  segs: [0, 1, 2, 3, 4, 5, 6, 7].map(arcSeg),
};

const lerp = (a: number, b: number, t: number) => a + (b - a) * t;
const f2 = (n: number) => n.toFixed(2);

const morphPath = (t: number): string => {
  const sx = lerp(circShape.start[0], rectShape.start[0], t);
  const sy = lerp(circShape.start[1], rectShape.start[1], t);
  let d = `M ${f2(CX + sx)} ${f2(CY + sy)}`;
  for (let i = 0; i < 8; i++) {
    const a = circShape.segs[i];
    const b = rectShape.segs[i];
    const v = a.map((n, j) => lerp(n, b[j], t));
    d += ` C ${f2(CX + v[0])} ${f2(CY + v[1])} ${f2(CX + v[2])} ${f2(
      CY + v[3]
    )} ${f2(CX + v[4])} ${f2(CY + v[5])}`;
  }
  return d + ' Z';
};

// ---- 时间轴(30fps,总 140f)----
// 0–10 静置圆 | 10–30 呼吸一拍 scale 1→1.06→1 | 30–54 变形 24f
// | 56–68 内容条淡入 12f | 68–140 真静止 72f
export const MorphFromPrimitive: React.FC = () => {
  const frame = useCurrentFrame();

  const breath = interpolate(frame, [10, 20, 30], [1, 1.12, 1], {
    easing: Easing.inOut(Easing.cubic),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  const t = interpolate(frame, [30, 54], [0, 1], {
    easing: Easing.inOut(Easing.cubic),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  const contentOpacity = interpolate(frame, [56, 68], [0, 1], {
    easing: Easing.out(Easing.cubic),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  const d = morphPath(t);

  return (
    <div style={{ width: 1920, height: 1080, background: G.bg, position: 'relative' }}>
      <svg
        width={1920}
        height={1080}
        viewBox="0 0 1920 1080"
        style={{ position: 'absolute', inset: 0 }}
      >
        <g transform={`translate(${CX} ${CY}) scale(${breath}) translate(${-CX} ${-CY})`}>
          <path d={d} fill="none" stroke={G.ink} strokeWidth={3} />
        </g>
      </svg>
      {/* 卡片内容条(参考 line-boil 描边卡内容),变形完成后淡入 */}
      <div
        style={{
          position: 'absolute',
          left: CX - RECT_W / 2,
          top: CY - RECT_H / 2,
          width: RECT_W,
          height: RECT_H,
          boxSizing: 'border-box',
          padding: 36,
          display: 'flex',
          flexDirection: 'column',
          gap: 18,
          opacity: contentOpacity,
        }}
      >
        <div style={{ height: 26, width: '58%', background: G.bar, borderRadius: 13 }} />
        <div style={{ height: 16, width: '86%', background: G.line, borderRadius: 8 }} />
        <div style={{ height: 16, width: '72%', background: G.line, borderRadius: 8 }} />
        <div style={{ marginTop: 'auto', display: 'flex', gap: 14, alignItems: 'center' }}>
          <div style={{ width: 40, height: 40, borderRadius: 20, background: G.mid }} />
          <div style={{ height: 16, width: 120, background: G.line, borderRadius: 8 }} />
        </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/ui-entrance/morph-from-primitive/MorphFromPrimitive.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