Motion · UI xuất hiện

Thẻ UI đổ mưa lướt sát mặt đất

Từ góc máy thấp lướt sát mặt đất, thẻ UI đổ như mưa rào rồi dừng cứng không nảy, sau đó cả trang dựng lên và quay thẳng vào camera.

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

Màn ra mắt cho giao diện dashboard/dòng thẻ (nội dung "từ trên trời rơi xuống tự hoàn thiện"); màn quay thẳng lại sau đoạn phô diễn góc thấp; phiên bản nặng "rơi khớp cùng lúc" của ngôn ngữ lơ lửng-dán-xuống kiểu ClickUp

Ý đồ

Quay giao diện như một đường băng: ở góc nhìn thấp, các thẻ UI lơ lửng ở nhiều độ cao khác nhau, rồi đổ xuống nhanh như mưa rào theo thứ tự đọc (theo hàng trước, trái→phải trước) — cảm giác rơi là sự dứt khoát gọn gàng. Ba mấu chốt: chồng lấn song song ồ ạt (điểm bắt đầu chỉ lệch nhau 1.5f, mỗi lần rơi 9f ⇒ trên không luôn có 5–6 thẻ đang rơi cùng lúc, "gần như cùng lúc nhưng có cảm giác gợn sóng"; phiên bản chờ tuần tự hai vòng bị loại), không nảy (chạm đất là dừng ngay, nảy lên bị loại), và nhanh (rơi trong 9f; phiên bản 15f bị loại vì "cần nhanh hơn một chút"). Rơi khớp xong, trang từ nằm phẳng dựng đứng lên (rotateX 66°→0°), camera lùi ra và căn giữa, đưa người xem từ "xem trình diễn" trở về "nhìn giao diện".

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

  • Thời lượng: Trưng bày lơ lửng ~0.4s + đổ xuống ~1.2s + dựng lên quay thẳng ~1.8s; toàn đoạn 4s
  • Năng lượng: Cao (cảm giác rơi là điểm nhấn chính, dựng lên quay thẳng là phần kết)

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)

意图

把界面拍成跑道:低角度透视里 UI 卡片悬空在不同高度,随后按界面 位置顺序(行优先左→右)一阵急雨式贴落——掉落感=干脆利落。 三个命门:重叠并行(起点只错 1.5f、下落 9f ⇒ 空中恒有 5–6 张 同时在落,"几乎一起但有涟漪感";串行等待两轮被裁)、零回弹 (着地即停,弹起被裁)、快(下落 9f,15f 版被裁"快一点")。 落齐后页面从躺倒立起(rotateX 66°→0°)、镜头拉远居中,观众从 "看戏"回到"看界面"。

动效核心

  • 机位:低角度强透视(rotateX ~66° 起),页面平躺如跑道
  • 悬空:卡片初始悬浮 560–880px(高度错落),空中卡追光提亮

~1.35x(判例:被环境压黑带吞掉=悬空不可感)

  • 贴落:起点 = 6 + i×1.5f + jitter(≤1.2f)(保序),下落 9f 重力

加速(距离∝t²),着地即停零回弹

  • 立起:全员落定后 rotateX 66°→0° + 镜头拉远归中(easeRise),

终态正视整页

  • 压黑带控制在 ~32%,给空中卡留可见度

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 起点差 | 1.5f/张(jitter ≤1.2f 不乱序) | 3f 版被裁"不需要一个落下了再启动第二个";0 差读作开关(那是 orbit 的同帧语法) | | 下落时长 | 9f 重力加速 | 15f 被裁慢;<6f 读不出下落方向 | | 回弹 | 零(着地即停) | 压弹 4.5% 版被裁"去掉弹起"——掉落感=干脆利落 | | 悬浮高度 | 560–880px 错落 | 同高读作阅兵;>1000px 落地时间拉长 | | 空中提亮 | ~1.35x 追光 | 不提亮悬空不可感(返工判例) | | 立起段 | rotateX 66→0 + 拉远,~56f | 只落不立起=没有收束;立起太快读作弹板 |

已知坑

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

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

  • 贴落节奏语义(三卡判例族):本卡=急雨式重叠错峰;

graze-face-tour=巡礼错峰;neon-frame-orbit-drop=同帧齐落。 按镜头语义选,错用即被裁

  • 与 deck-deal-flyin 分工:那张是卡片飞入列队(水平方向运动);

本卡是垂直贴落+页面立起的两段式,掉落感是本体

  • 立起段原片无对应参考(用户口述新增),终态版式对齐 clickup11

正视图

参考实现

demos/ui-entrance/runway-ground-skim/ (RunwayGroundSkim.tsx) 原片出处:clickup-30.mp4 ≈46–50s

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

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

Prompt

Use the video-shotcraft shot recipe "Thẻ UI đổ mưa lướt sát mặt đất" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/runway-ground-skim.json
Raw TSX: https://gimgs.net/motion/runway-ground-skim/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/ui-entrance/runway-ground-skim/RunwayGroundSkim.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 3.99s

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

12,072 chars · Apache-2.0Mở template gốc ↗
// ===== demos/ui-entrance/runway-ground-skim/RunwayGroundSkim.tsx =====
// runway-ground-skim v5 —— 源片 clickup-30.mp4 约 46–50s(clickup10 截图 5 张):
// 用户 v5 意见(逐字):"去掉落地后弹起的效果,然后整个下落的过程快一点"
// 落实:①删除落地压弹——着地即停,零回弹零压缩(判例:掉落感=干脆利落);
// ②下落整体提速——单卡下落 15→9 帧,全员落定 f45→f33,立起段随之前移;
// ③保留项不动:错峰 3 帧起点、界面位置顺序(行优先左→右)、重力加速
// (距离∝t²)、贴落完成后页面立起转正收尾。
import React from 'react';
import { AbsoluteFill, interpolate, useCurrentFrame, Easing } from 'remotion';

const FONT = 'Helvetica, Arial, sans-serif';
const INK = '#3c3c42';
const MID = '#8d8d94';
const FAINT = '#d2d2d5';

const easeRise = Easing.bezier(0.42, 0, 0.16, 1);

/* mulberry32 带种子(起跳节奏 ≤1.5 帧微差 < 3 帧错峰,顺序不乱) */
const mulberry32 = (seed: number) => () => {
  seed |= 0; seed = (seed + 0x6d2b79f5) | 0;
  let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
  t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};

const CARD_W = 760;
const MiniCardFace: React.FC<{ title: string; sub: string }> = ({ title, sub }) => (
  <div style={{
    width: CARD_W, border: `4px solid ${FAINT}`, borderRadius: 22, padding: '30px 40px',
    background: '#fcfcfb', display: 'flex', flexDirection: 'column', gap: 14, boxSizing: 'border-box',
  }}>
    <div style={{ display: 'flex', alignItems: 'center', gap: 24 }}>
      <div style={{ width: 40, height: 40, border: '6px solid #85858b', borderRadius: 8, flexShrink: 0 }} />
      <div style={{ fontFamily: FONT, fontSize: 46, color: INK, fontWeight: 650, whiteSpace: 'nowrap' }}>{title}</div>
    </div>
    <div style={{ fontFamily: FONT, fontSize: 36, color: MID, paddingLeft: 64, whiteSpace: 'nowrap' }}>{sub}</div>
  </div>
);

/* 界面位置顺序 = 数组顺序:第一行左→右,再第二行左→右 */
const CARDS: { title: string; sub: string; col: number; row: number }[] = [
  { title: 'Creative Refresh', sub: 'New logo exploration', col: 0, row: 0 },
  { title: 'New Bugs Per Week', sub: 'Bug tracker Dashboard', col: 1, row: 0 },
  { title: 'Tiger Team Roadmap', sub: 'Roadmap Outline', col: 2, row: 0 },
  { title: 'Design System', sub: 'Design Handbook Inspo', col: 3, row: 0 },
  { title: 'Development Sprint Dashboard', sub: 'Dev Team Sprints', col: 0, row: 1 },
  { title: 'CSS Bug Tracker', sub: 'Query Reports', col: 1, row: 1 },
  { title: 'Platform', sub: 'System Health Monitor', col: 2, row: 1 },
];

/* Recent 网格槽位(面板内容坐标) */
const GRID_X = 1180, GRID_Y = 760, COL_GAP = 850, ROW_GAP = 250;
const slotPos = (col: number, row: number) => ({ x: GRID_X + col * COL_GAP, y: GRID_Y + row * ROW_GAP });

/* 平躺地面:Home 仪表盘(卡片槽位留空,由悬浮卡片落入) */
const Ground: React.FC = () => (
  <div style={{ width: 4600, height: 2600, background: '#f6f6f5', borderRadius: 60, position: 'relative', overflow: 'hidden' }}>
    <div style={{ display: 'flex', height: '100%' }}>
      {/* 左侧栏 */}
      <div style={{ width: 860, borderRight: `4px solid ${FAINT}`, padding: '70px 60px 0', background: '#f1f1f0' }}>
        <div style={{ display: 'flex', alignItems: 'center', gap: 26 }}>
          <div style={{ width: 64, height: 64, borderRadius: 18, background: 'linear-gradient(135deg,#adadb3,#6b6b72)' }} />
          <div style={{ fontFamily: FONT, fontSize: 56, fontWeight: 800, color: INK }}>ClickUp</div>
        </div>
        <div style={{ height: 46 }} />
        {['Home', 'Inbox', 'Company', 'People & Teams', 'Goals', 'Docs', 'More'].map((t, i) => (
          <div key={t} style={{
            display: 'flex', alignItems: 'center', gap: 30, height: 108, paddingLeft: 32,
            background: i === 0 ? '#e6e6f0' : 'transparent', borderRadius: 20,
          }}>
            <div style={{ width: 36, height: 36, border: '6px solid #90909a', borderRadius: 9 }} />
            <div style={{ fontFamily: FONT, fontSize: 46, color: INK, fontWeight: i === 0 ? 650 : 400 }}>{t}</div>
          </div>
        ))}
        <div style={{ height: 60 }} />
        <div style={{ fontFamily: FONT, fontSize: 38, letterSpacing: 5, color: MID, fontWeight: 600, paddingLeft: 32 }}>SPACES</div>
        <div style={{ height: 16 }} />
        {['EPD', 'Product roadmap', 'Design', 'Designer handbook', '3.0', 'Design system'].map((t) => (
          <div key={t} style={{ display: 'flex', alignItems: 'center', gap: 30, height: 96, paddingLeft: 32 }}>
            <div style={{ width: 44, height: 44, borderRadius: 12, background: '#d6d6da' }} />
            <div style={{ fontFamily: FONT, fontSize: 42, color: INK }}>{t}</div>
          </div>
        ))}
      </div>
      {/* 主区 */}
      <div style={{ flex: 1, padding: '70px 100px 0', position: 'relative' }}>
        {/* 顶部 tab 条 */}
        <div style={{ display: 'flex', gap: 110, fontFamily: FONT, fontSize: 42, color: MID, marginBottom: 60 }}>
          <div>Product analytics</div><div style={{ fontWeight: 700, color: INK }}>ClickUp 3.0</div>
          <div>Widget brainstorm</div><div>Design system</div><div>Design</div>
        </div>
        <div style={{ fontFamily: FONT, fontSize: 110, fontWeight: 750, color: INK }}>Home</div>
        <div style={{ height: 40 }} />
        <div style={{
          display: 'flex', alignItems: 'center', gap: 30, border: `4px solid ${FAINT}`,
          borderRadius: 24, padding: '28px 42px', background: '#fff', width: 1400,
        }}>
          <div style={{ width: 38, height: 38, borderRadius: 19, border: '6px solid #9a9aa0' }} />
          <div style={{ fontFamily: FONT, fontSize: 42, color: MID }}>Search by app, filetype, or keyword</div>
        </div>
        <div style={{ height: 66 }} />
        <div style={{ display: 'flex', gap: 70, fontFamily: FONT, fontSize: 46 }}>
          <div style={{ color: INK, fontWeight: 700 }}>Recent</div>
          <div style={{ color: MID }}>Favorites</div>
        </div>
        {/* ↑ Recent 网格槽位区域留空(卡片从空中落入 GRID_X/GRID_Y 起的两行四列) */}
        <div style={{ height: 560 }} />
        <div style={{ display: 'flex', gap: 70, fontFamily: FONT, fontSize: 44 }}>
          <div style={{ color: INK, fontWeight: 700 }}>Todo</div>
          <div style={{ color: MID }}>Comments</div>
          <div style={{ color: MID }}>Done</div>
          <div style={{ color: MID }}>Delegated</div>
        </div>
        <div style={{ height: 36 }} />
        <div style={{
          display: 'inline-block', padding: '16px 40px', background: '#e4e4e3', borderRadius: 14,
          fontFamily: FONT, fontSize: 36, letterSpacing: 4, color: '#6f6f75', fontWeight: 600,
        }}>TODAY</div>
        <div style={{ height: 40 }} />
        {['New Bugs Per Week', 'Designer handbook', 'Mobile screens', 'Product roadmap'].map((t) => (
          <div key={t} style={{
            display: 'flex', alignItems: 'center', gap: 34, height: 118,
            borderBottom: '3px solid #e5e5e3', width: 2600,
          }}>
            <div style={{ width: 32, height: 32, borderRadius: 9, background: '#c04a6e' }} />
            <div style={{ fontFamily: FONT, fontSize: 46, color: INK, fontWeight: 550 }}>{t}</div>
            <div style={{ marginLeft: 'auto', width: 180, height: 16, background: '#e3e3e8', borderRadius: 8 }} />
          </div>
        ))}
      </div>
    </div>
  </div>
);

export const RunwayGroundSkim: React.FC = () => {
  const frame = useCurrentFrame();
  const rand = mulberry32(20260718);
  const jit = CARDS.map(() => rand() * 1.2); // ≤1.2 帧微差 < 3 帧错峰,顺序绝不乱

  /* ---- 节奏(掉落提速+着地即停版,118 帧)----
   * f0–6    开场:全员悬空在黑色空域
   * f6–33   掉落:第 i 张起跳 f = 6 + i*3(错峰只差 3 帧),下落 9 帧
   *         ⇒ 9 >> 3,空中同时 3–4 张在落(重叠并行,非串行等待);
   *         重力加速(距离∝t²),着地即停——零回弹零压缩
   * f38–94  页面立起 + 视角转正(rotateX 66→0,镜头拉远居中)
   * f94–118 终态正视整页 hold */
  // v6(批次 15):用户意见"下落的时间差多调小一些,不需要一个落下了
  // 再启动第二个"——起点差 3→1.5 帧(9 帧下落窗口重叠度 6 倍,
  // 任意时刻空中 5–6 张同落,几乎齐落带涟漪感)
  const START0 = 6, GAP = 1.5, FALLF = 9;

  const lifts = CARDS.map((c, i) => {
    const t = frame - (START0 + i * GAP + jit[i]);
    const H = 560 + (i % 3) * 160; // 初始悬浮高度错落
    if (t <= 0) return H;
    const p = t / FALLF;
    if (p < 1) return H * (1 - p * p); // 重力加速:下落距离 ∝ t²
    return 0; // 着地即停,无弹起
  });

  /* 立起段进度 */
  const riseP = interpolate(frame, [38, 94], [0, 1], { easing: easeRise, extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });

  /* 镜头:落卡段轻微推进(72→66°),立起段转正(→0°)并拉远居中 */
  const landP = interpolate(frame, [0, 34], [0, 1], { easing: Easing.bezier(0.3, 0.1, 0.6, 0.9), extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
  const rx = interpolate(landP, [0, 1], [72, 66]) - 66 * riseP;
  const z = interpolate(landP, [0, 1], [-620, -320]) + riseP * (-1620 - -320);
  const bright = interpolate(frame, [0, 32, 86], [0.32, 0.8, 1.0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });

  /* 锚点/透视原点随立起归中:终态整页入画、中心≈画面中心 */
  const anchorTop = 58 - riseP * 6;           // % :58 → 52(终态整页不裁底)
  const perspY = 30 + riseP * 20;             // % :30 → 50

  const cam = (children: React.ReactNode, extra?: React.CSSProperties) => (
    <AbsoluteFill style={{ perspective: 1050, perspectiveOrigin: `50% ${perspY}%`, ...extra }}>
      <div style={{
        position: 'absolute', left: '50%', top: `${anchorTop}%`, width: 0, height: 0,
        transformStyle: 'preserve-3d',
        transform: `translateZ(${z}px) rotateX(${rx}deg)`,
      }}>
        {children}
      </div>
    </AbsoluteFill>
  );

  const scene = (
    <div style={{ position: 'absolute', transformStyle: 'preserve-3d', transform: 'translate(-2300px, -1500px)' }}>
      {/* 地面 */}
      <div style={{ filter: `brightness(${bright})` }}>
        <Ground />
      </div>
      {/* 地面上的软影(z≈0,卡片同形,随悬浮高度变化大小/偏移/浓度) */}
      {CARDS.map((c, i) => {
        const h = lifts[i];
        if (h < 2) return null;
        const s = slotPos(c.col, c.row);
        return (
          <div key={'sh' + i} style={{
            position: 'absolute', left: s.x + 20, top: s.y + 14, width: CARD_W - 40, height: 150,
            transform: `translateZ(1px) translate(${h * 0.08}px, ${h * 0.12}px) scale(${1 + h * 0.0004})`,
            background: 'rgba(10,8,16,0.9)', borderRadius: 24,
            filter: `blur(${10 + h * 0.03}px)`,
            opacity: Math.max(0.12, 0.38 - h * 0.0003),
          }} />
        );
      })}
      {/* 悬空卡片:与地面同向平躺,translateZ 抬高,落回槽位。
          空中时被"追光"打亮(比暗地面亮),落地融入地面亮度 */}
      {CARDS.map((c, i) => {
        const h = lifts[i];
        const s = slotPos(c.col, c.row);
        const airLit = h > 2 ? Math.max(1.35, bright) : bright;
        return (
          <div key={'card' + i} style={{
            position: 'absolute', left: s.x, top: s.y,
            transform: `translateZ(${h}px)`,
            filter: `brightness(${airLit})`,
            boxShadow: h > 2 ? `0 0 ${30 + h * 0.05}px rgba(240,235,255,${Math.min(0.3, h * 0.0004)})` : 'none',
          }}>
            <MiniCardFace title={c.title} sub={c.sub} />
          </div>
        );
      })}
    </div>
  );

  /* 立起段各氛围层收敛:黑空域压顶/地平线光/近景糊都随转正淡出 */
  const airOp = 1 - riseP;

  return (
    <AbsoluteFill style={{ background: '#07060a' }}>
      {/* 地平线微光(转正后消失) */}
      <AbsoluteFill style={{
        background: `radial-gradient(ellipse 60% 14% at 50% 40%, rgba(190,170,255,${(0.16 + landP * 0.1) * airOp}), transparent 75%)`,
      }} />
      {cam(scene)}
      {/* 近景轻糊(转正后消失) */}
      {airOp > 0.02 && (
        <AbsoluteFill style={{
          filter: 'blur(10px) brightness(0.88)', opacity: airOp,
          WebkitMaskImage: 'linear-gradient(180deg, transparent 60%, rgba(0,0,0,0.7) 84%, black 98%)',
          maskImage: 'linear-gradient(180deg, transparent 60%, rgba(0,0,0,0.7) 84%, black 98%)',
        }}>
          {cam(scene)}
        </AbsoluteFill>
      )}
      {/* 上半空域压黑(随立起淡出) */}
      <AbsoluteFill style={{
        background: 'linear-gradient(180deg, rgba(4,3,8,0.9) 0%, rgba(4,3,8,0.35) 16%, transparent 32%)',
        opacity: airOp, pointerEvents: 'none',
      }} />
      {/* 暗角(转正后减弱不消失) */}
      <AbsoluteFill style={{
        background: 'radial-gradient(ellipse 95% 90% at 50% 55%, transparent 50%, rgba(3,2,7,0.55) 85%, rgba(2,1,5,0.88) 100%)',
        opacity: 1 - riseP * 0.55, pointerEvents: 'none',
      }} />
    </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/ui-entrance/runway-ground-skim/RunwayGroundSkim.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