Motion · Câmera

Cursor Flyover Tour

After a full-page overhead view fades in, the camera flies in turn to zoom-in close-ups on four corners, an SVG cursor tracking along to point and leave click ripples.

1920×108016:930 fps6.5s · 195 quadrosremotionLicença: Apache-2.0#shotcraft#remotion#camera

Fonte: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composição Remotion (React/TSX) — vídeo de prévia, sem player no navegador

When to use

A single-page product feature tour: one continuous shot walking viewers through four functional areas, with the cursor acting as a "tour guide's finger."

Intention

A shot-saving alternative to "four features, four cuts": one continuous camera strings the four corners into a single tour route, with the cursor acting as the viewer's attention proxy — wherever it goes and clicks, that's where the viewer looks. Spatial continuity also lets the positional relationships between features get remembered along the way.

Duration & energy

  • Duration: About 6.0s (180f@30fps; overhead 0–1.2s · four steps, each 0.7s transition + 0.5s dwell).
  • Energy: Medium (a steady cruise, with rhythm punctuated by click ripples).

Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)

意图

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

动效核心

  • 相机是查表步进: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)

Reference implementation (Remotion)

  • demos/camera/cursor-flyover/CursorFlyover.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/camera/cursor-flyover/CursorFlyover.tsx

The component imports shared fixtures from demos/_fixtures and helpers from assets/lib in the repo; SKILL.md there documents the render workflow.

Tudo nesta página também é legível por máquina. Aponte seu agente para o JSON ou adicione o servidor MCP do gimgs e peça para buscar templates de motion.

  1. Baixe o JSON — contém a receita do plano (timing, easing, tabela de parâmetros, armadilhas) e o código TSX completo.
  2. Clone o repositório video-shotcraft; o componente importa fixtures de demos/_fixtures e helpers de assets/lib.
  3. Registre o componente como <Composition> em um projeto Remotion e renderize com `npx remotion render`, ou reimplemente a receita no seu próprio engine.
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 "Cursor Flyover Tour" (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.0Abrir template bruto ↗
// ===== 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
—

Mais nesta categoria