Motion · Camera

Con trỏ bay lượn tham quan

Sau khi toàn trang nhìn từ trên xuống mờ dần hiện ra, camera lần lượt bay đến bốn góc zoom cận cảnh, con trỏ SVG đồng bộ bám theo chỉ điểm và để lại gợn sóng khi click.

1920×108016:930 fps6.5s · 195 frameremotionGiấy phép: Apache-2.0#shotcraft#remotion#camera

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

Tham quan tính năng sản phẩm một trang: một cảnh liên tục dẫn người xem qua bốn khu tính năng, con trỏ đóng vai "ngón tay hướng dẫn viên".

Ý đồ

Phương án tiết kiệm cảnh quay thay cho "bốn tính năng bốn đoạn cắt": một camera liên tục xâu chuỗi bốn góc thành một tuyến tham quan, con trỏ là đại diện sự chú ý của người xem — nó đến đâu, click đâu, người xem nhìn đó. Tính liên tục không gian còn giúp ghi nhớ luôn mối quan hệ vị trí giữa các tính năng.

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

  • Thời lượng: Khoảng 6.0s (180f@30fps; nhìn tổng quan 0–1.2s · bốn bước mỗi bước chuyển 0.7s + dừng 0.5s).
  • Năng lượng: Trung bình (hành trình đều đặn, nhịp điệu đến từ các gợn sóng click đánh dấu).

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)

意图

替代"四个功能四段剪辑"的省镜方案:一个连续相机把四个角串成一条参观动线,光标是观众的注意力代理——它到哪、点哪,观众就看哪。空间连续让功能之间的位置关系也被顺带记住。

动效核心

  • 相机是查表步进:CAM[0] 俯瞰(scale 0.8 居中)→ 四角特写(scale 1.72,tx/ty 各角),五通道 tx/ty/s/cx/cy 用 acc + inOutCubic 插值
  • 变换公式 translate(50-tx*s%, 50-ty*s%) scale(s)——tx/ty 是"相机对准点",改表即换巡览路线
  • 四段窗口 [0.20,0.32][0.40,0.52][0.60,0.72][0.79,0.91]:过渡 0.12 + 停留 0.08,等长节奏像"参观脚步"
  • 光标(SVG 箭头 + 投影)坐标 cx/cy 与相机同表同插值——相机和光标是一个系统,永远同时到位;光标 scale(1/s) 抵消画面缩放保持视觉等大
  • 每段落位瞬间(WIN[i][1] 起 0.055)触发点击涟漪:26px 圆环从 0.3 扩到 1.9 倍并淡出,光标头同拍闪白
  • 开场 0–0.14 整页从 blur 5px 淡入聚焦

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 特写倍率 | s=1.72(俯瞰 0.8) | >2.2 时占位截图纹理会糊,换真实高分截图后可推到 2.5 | | 步进节奏 | 过渡 0.12 / 停留 0.08 | 停留是读功能位,短于 0.05 变"扫过"没有"参观" | | 点击涟漪 | 0.055 时长,scale 0.3→1.9 | 涟漪是节拍器,四次点击均匀落在 0.32/0.52/0.72/0.91——配 SFX click 四连 | | 光标补偿 | scale(1/s) | 砍掉则特写时光标巨大化;保持"光标是 UI 层"的错觉必需 | | 巡览顺序 | 左上→右上→右下→左下(Z 形反向) | 改 CAM 表即换路线;保持相邻角相邻访问,对角跳跃会晕 | | 开场聚焦 | 0–0.14 blur 5px→0 | 与俯瞰同拍完成"这是全景"的交代;直接从特写开跳过它 |

已知坑

  • 占位截图(窗口 chrome+侧栏+四象限控件)必须换成真实产品截图,四个 cx/cy 光标落点要重新标到真实控件中心
  • 相机表的 tx/ty 是内容百分比坐标,截图宽高比变了(如换竖屏)需整表重标
  • 光标与相机绑定同一插值意味着"光标先飞相机后到"这类编排做不了——需要解耦时给光标单独一张同结构的表
  • 停留段完全静止,1080p 大屏下会显得"卡住"——可给 world 加 ±0.5px 的呼吸位移(确定性 sin),但幅度别过 1px

参考实现

demos/camera/cursor-flyover/ (CursorFlyover.tsx)

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

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

Prompt

Use the video-shotcraft shot recipe "Con trỏ bay lượn tham quan" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/cursor-flyover.json
Raw TSX: https://gimgs.net/motion/cursor-flyover/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/camera/cursor-flyover/CursorFlyover.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 6.5s

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

9,023 chars · Apache-2.0Mở template gốc ↗
// ===== demos/camera/cursor-flyover/CursorFlyover.tsx =====
// cursor-flyover — Cursor Flyover 四角巡览指点(motion-lab 定稿转原生 Remotion)
// 产品截图平铺,相机先整体俯瞰淡入,再依次飞到四个角落 zoom-in 特写;一枚带阴影
// 的 SVG 光标跟到对应区域指点并留下点击涟漪。每步过渡+停留等长,easeInOutCubic。
// 设计坐标 480×270(DesignStage 等比放大),参数表数值以此坐标系标定。
import React from 'react';
import { DesignStage, E, lerp, rand, seg, useT } from '../../_fixtures/Motion';

export const CURSOR_FLYOVER_DURATION = 180; // 6000ms @30fps

// 关键帧累加器:base 起步,每段 [at0,at1] 内朝 to 推进(效果源文件级共享 helper)
type Vec = Record<string, number>;
const acc = (t: number, base: Vec, kfs: { at: number[]; to: Vec }[], keys: string[], ease: (x: number) => number) => {
  const out: Vec = {};
  for (const k of keys) out[k] = base[k];
  let prev = base;
  for (const kf of kfs) {
    const u = seg(t, kf.at[0], kf.at[1], ease);
    for (const k of keys) out[k] += u * (kf.to[k] - prev[k]);
    prev = kf.to;
  }
  return out;
};

// 相机 Step:overview + 四角;光标目标(world %)
const CAM = [
  { tx: 50, ty: 50, s: 0.8,  cx: 50, cy: 52 },
  { tx: 37, ty: 32, s: 1.72, cx: 41, cy: 38 },
  { tx: 77, ty: 32, s: 1.72, cx: 82, cy: 36 },
  { tx: 77, ty: 72, s: 1.72, cx: 71, cy: 76 },
  { tx: 37, ty: 72, s: 1.72, cx: 33, cy: 78 },
];
const WIN = [[0.20, 0.32], [0.40, 0.52], [0.60, 0.72], [0.79, 0.91]];
const KEY = ['tx', 'ty', 's', 'cx', 'cy'];

// 右上折线图路径(种子与 effect.js 相同)
const LINE_D = (() => {
  let dd = '';
  for (let i = 0; i <= 14; i++) dd += `${i === 0 ? 'M' : 'L'}${i * 7.14},${40 - (6 + rand(i * 5) * 22 + i * 0.7)} `;
  return dd;
})();

// 四象限容器(截图内相对定位)
const Quad: React.FC<{ x: number; y: number; w: number; h: number; children?: React.ReactNode }> = ({
  x, y, w, h, children,
}) => (
  <div
    style={{
      position: 'absolute',
      left: `${x}%`,
      top: `${y}%`,
      width: `${w}%`,
      height: `${h}%`,
      borderRadius: 8,
      background: '#191d29',
      boxShadow: 'inset 0 0 0 1px #262c3b',
    }}
  >
    {children}
  </div>
);

export const CursorFlyover: React.FC = () => {
  const t = useT();

  const v = acc(t, CAM[0], WIN.map((w, i) => ({ at: w, to: CAM[i + 1] })), KEY, E.inOutCubic);
  const fade = seg(t, 0, 0.14, E.outCubic);

  // 每次落位时的点击涟漪
  let click = 0;
  let hold = 0;
  for (let i = 0; i < WIN.length; i++) {
    const c = seg(t, WIN[i][1], WIN[i][1] + 0.055, E.outCubic);
    if (c > 0 && c < 1) { click = c; hold = 1; }
    else if (c >= 1 && t < (WIN[i + 1] ? WIN[i + 1][0] : 1.01)) { click = 1; hold = 1; }
  }

  return (
    <DesignStage bg="#080910" raster="zoom">
      <div style={{ position: 'absolute', inset: 0, background: '#080910', overflow: 'hidden' }}>
        {/* world:相机 translate+scale 的载体,淡入由 opacity 承担 */}
        <div
          style={{
            position: 'absolute',
            inset: 0,
            transformOrigin: '0 0',
            transform: `translate(${50 - v.tx * v.s}%,${50 - v.ty * v.s}%) scale(${v.s})`,
            opacity: fade,
          }}
        >
          {/* ---- 截图占位:窗口 chrome + 侧栏 + 四象限控件 ---- */}
          <div
            style={{
              position: 'absolute',
              left: '4%',
              top: '5%',
              width: '92%',
              height: '90%',
              borderRadius: 10,
              background: '#12141d',
              boxShadow: '0 24px 60px rgba(0,0,0,.6),inset 0 0 0 1px #262c3b',
              overflow: 'hidden',
              filter: `blur(${(1 - fade) * 5}px)`,
            }}
          >
            {/* 顶栏:红黄绿灯 + URL 胶囊(原采集页 content-box:border-bottom 在 9% 高度之外) */}
            <div style={{ position: 'absolute', left: 0, top: 0, width: '100%', height: '9%', background: '#1a1d28', borderBottom: '1px solid #262c3b', boxSizing: 'content-box' }}>
              {['#ff6058', '#ffbd2e', '#28ca42'].map((c, i) => (
                <div
                  key={c}
                  style={{
                    position: 'absolute', left: 8 + i * 12, top: '50%', width: 6, height: 6, marginTop: -3,
                    borderRadius: '50%', background: c,
                  }}
                />
              ))}
              <div
                style={{
                  position: 'absolute', left: 56, top: '50%', transform: 'translateY(-50%)', height: 13, lineHeight: '13px',
                  padding: '0 10px', borderRadius: 7, background: '#0f1119', color: '#5d6580',
                  font: '500 8px/13px ui-monospace,monospace', letterSpacing: 0.4,
                }}
              >
                app.example.com/overview
              </div>
            </div>
            {/* 侧栏导航(content-box:border-right 在 15% 宽度之外) */}
            <div style={{ position: 'absolute', left: 0, top: '9%', width: '15%', height: '91%', background: '#161923', borderRight: '1px solid #232937', boxSizing: 'content-box' }}>
              {['Overview', 'Traffic', 'Revenue', 'Cohorts', 'Alerts'].map((s, i) => (
                <div
                  key={s}
                  style={{
                    position: 'absolute', left: '8%', top: `${8 + i * 15}%`, width: '84%', height: '11%', borderRadius: 5,
                    font: '600 7px/1 -apple-system,sans-serif', color: i === 0 ? '#cfd6ea' : '#5a6280',
                    display: 'flex', alignItems: 'center', paddingLeft: 6,
                    boxSizing: 'content-box', // 原采集页 content-box:paddingLeft 加在 84% 之外,激活项底色更宽
                    background: i === 0 ? '#222839' : 'transparent',
                  }}
                >
                  {s}
                </div>
              ))}
            </div>
            {/* 左上:stat tiles */}
            <Quad x={18} y={14} w={38} h={36}>
              {['12.4k', '+38%', '4.1s'].map((s, i) => (
                <div
                  key={s}
                  style={{
                    position: 'absolute', left: `${5 + i * 31.5}%`, top: '16%', width: '28%', height: '44%', borderRadius: 6,
                    background: '#202634', padding: '8px 0 0 8px', boxSizing: 'border-box',
                  }}
                >
                  <div style={{ font: '700 13px/1 -apple-system,sans-serif', color: '#e7ecfb' }}>{s}</div>
                  <div style={{ marginTop: 4, font: '600 6px/1 -apple-system,sans-serif', letterSpacing: 1, color: '#5d6580' }}>
                    METRIC {i + 1}
                  </div>
                </div>
              ))}
            </Quad>
            {/* 右上:折线图 */}
            <Quad x={59} y={14} w={37} h={36}>
              <svg viewBox="0 0 100 46" style={{ position: 'absolute', left: '6%', top: '16%', width: '88%', height: '70%' }}>
                <path d={LINE_D} fill="none" stroke="#6c8cff" strokeWidth={1.6} strokeLinejoin="round" />
              </svg>
            </Quad>
            {/* 右下:柱状图 */}
            <Quad x={59} y={55} w={37} h={34}>
              {Array.from({ length: 9 }, (_, i) => (
                <div
                  key={i}
                  style={{
                    position: 'absolute', left: `${7 + i * 9.8}%`, bottom: '14%', width: '6.4%',
                    height: `${22 + rand(i * 3 + 1) * 55}%`,
                    borderRadius: 2, background: 'linear-gradient(180deg,#c86cff,#5c4bd6)',
                  }}
                />
              ))}
            </Quad>
            {/* 左下:表格行 */}
            <Quad x={18} y={55} w={38} h={34}>
              {Array.from({ length: 5 }, (_, i) => (
                <div
                  key={i}
                  style={{
                    position: 'absolute', left: '6%', top: `${12 + i * 17}%`, width: '88%', height: '11%', borderRadius: 3,
                    background: '#202634',
                    boxShadow: `inset ${30 + rand(i + 2) * 45}% 0 0 rgba(108,140,255,.35)`,
                  }}
                />
              ))}
            </Quad>
          </div>

          {/* ---- 点击涟漪(world 内锚定,反缩放保持屏幕尺寸;content-box:外径 26+2×2=30px 与原采集页一致) ---- */}
          <div
            style={{
              position: 'absolute', left: `${v.cx}%`, top: `${v.cy}%`, width: 26, height: 26,
              boxSizing: 'content-box',
              borderRadius: '50%', border: '2px solid #8fa7ff', zIndex: 39, transformOrigin: '50% 50%',
              opacity: hold ? (1 - click) * 0.85 : 0,
              transform: `translate(-50%,-50%) scale(${(0.3 + click * 1.6) / v.s})`,
            }}
          />
          {/* ---- 光标(world 内锚定,逐帧反缩放保持屏幕尺寸) ---- */}
          <svg
            viewBox="0 0 24 24"
            style={{
              position: 'absolute', left: `${v.cx}%`, top: `${v.cy}%`, width: 22, height: 22, transformOrigin: '0 0',
              filter: 'drop-shadow(0 3px 5px rgba(0,0,0,.75))', pointerEvents: 'none', zIndex: 40,
              transform: `scale(${1 / v.s})`,
            }}
          >
            <path
              d="M4 2 L4 19 L9 14.4 L12.2 21.5 L15.4 20 L12.2 13 L19 12.6 Z"
              fill={click > 0 && click < 0.4 ? '#dfe6ff' : '#fff'}
              stroke="#101320"
              strokeWidth={1.1}
            />
          </svg>
        </div>
      </div>
    </DesignStage>
  );
};


  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/camera/cursor-flyover/CursorFlyover.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