Motion · UI xuất hiện

Hiệu ứng thủ công giấy

Hai kiểu thủ công giấy: masking-tape-slap dán băng keo ghim thẻ xuống, và popup-book-rise dựng thẻ lên như sách bật hình.

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

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

Biến thể

masking-tape-slap
Độ lắc = biên độ bao hình × sin (rot ±1.5°/bob ±5px); băng keo lao vào trong 6f: scale 1.45→1 kèm rotate thiếu 16°→vọt quá 7°→về đúng + khung hình hạ cánh nén scaleY 0.72 trong một khung hình; mép xé dùng clipPath răng cưa 14 điểm.
Màn vào cảnh chỉnh trang cho một thẻ/huy hiệu; căn theo trọng âm của nội dung
popup-book-rise
3D hai lớp: bối cảnh ở rotateX 75° nhìn từ trên (perspective 2600); mỗi thẻ spring từ rotateX 0→-90° (damping 11, vọt quá -95°) với transform-origin ở cạnh đáy, preserve-3d xuyên suốt; hàng xa dựng trước, hàng gần dựng sau, lệch pha 7f.
Mở đầu cả dashboard; "hệ thống đang được dựng lên"

Khi nào dùng

Ngôn ngữ chất liệu thực cho hình ảnh chủ đạo giấy/mực: dùng A cho màn vào cảnh chỉnh trang một thẻ, dùng B cho thiết lập mở đầu cả dashboard; tự nhiên đồng chất với hình ảnh chủ đạo giấy/mực + màu nhấn (phim mẫu giấy/mực/hổ phách)

Ý đồ

Bản dịch UI của mỹ thuật sổ tay Wes Anderson và thủ công sách bật hình. A: thẻ bay nhẹ vào vị trí rồi treo lơ lửng lắc nhẹ (giấy chưa được cố định), hai dải băng keo xé mép bán trong suốt lần lượt dán "bốp, bốp" vào hai góc đối diện, ở lần dán thứ hai thẻ đồng thời ngừng lắc, bóng mỏng đi, và toàn thẻ chìm xuống — khoảnh khắc "ghim chết" là nhân vật chính, hai tiếng "bốp" là điểm âm thanh tự nhiên. B: cả trang nằm phẳng như một trang sách mở nhìn từ trên xuống, các thẻ như miếng giấy dán trên trang lần lượt dựng lên dọc theo cạnh đáy của mình theo làn sóng lệch pha để tạo thành tường, vọt quá 95° rồi bật lại 90° (độ đàn hồi của giấy), bóng ở gốc thu hẹp dần khi dựng lên.

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

  • Thời lượng: A 3–4s / B 4–5.5s
  • Năng lượng: A Trung bình (hai nhịp va chạm) / B Trung bình cao (dựng tường có va chạm chiều sâ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)

意图

Wes Anderson 手账美术与立体书纸艺的 UI 翻译。A:卡片轻飘入位后 悬着微晃(未固定的纸),两条撕边半透明胶带"啪、啪"先后拍在对角, 第二条拍下同帧卡片停晃、投影变薄、整卡下沉——"按死"的定妆 一瞬是主角,两声"啪"是天然音效点。B:整页平躺如摊开书页(俯视), 卡片像贴在页上的纸片沿各自底边错峰立起成墙,立到 95° 回弹 90° (纸的韧性),根部投影随立起收窄。

两式选型

| 式 | 做法 | 适用 | |----|------|------| | A masking-tape-slap | 晃动=幅度包络×正弦(rot ±1.5°/bob ±5px);胶带扑入 6f:scale 1.45→1 + rotate 欠 16°→过冲 7°→回正 + 落帧 scaleY 0.72 一帧压扁;撕边 14 点 clipPath 锯齿 | 单卡/徽章的定妆入场;文案重音对齐 | | B popup-book-rise | 双层 3D:场景 rotateX 75° 俯视(persp 2600),每卡 rotateX 0→-90° spring(damping 11 过冲 -95°),origin 底边,preserve-3d 贯通;远排先近排后错峰 7f | 整版 dashboard 开场;"系统被搭建起来" |

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | A 按死三件套 | 同帧:晃动 2f 归零 + 投影 34px→8px + 全卡下沉 2px | 三件拆帧就没有"按死"感;第一条胶带只把包络衰到 0.45(半死) | | A 胶带质感 | 半透明 + 锯齿撕边 + 落点故意歪 2–3° | 歪斜是设计的手工感;直挺挺读作 UI 元素 | | B 立起方向 | rotateX 0→-90°(向观众立起) | demo 判例:方向写反卡片往画面里倒被底板裁切 | | B 根部投影 | blur 矩形不随卡立起,高 104→14px、透明度 0.26→0.1 随角度插值 | 无投影纸片浮空;投影跟着立起来穿帮 | | B 收尾 | 全部站定后场景 75°→68° 轻回正(in-out cubic) | 直接定住少一口呼吸 |

已知坑

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

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

  • A 的晃动是"未固定"的叙事铺垫不是手持抖动(Q3 边界):

振幅包络必须最终归零,全片只在被按死前存在

  • B 与 tilt-reveal 分工:tilt 是机位抬头看静止页面,

本卡机位基本不动、构件自己站起来

  • B 立墙后卡片呈 90° 侧立态,文字不可读——立墙是构图动作,

信息交给立起后的正视段落(Q6 同源)

  • 声音:A 两声"啪"(纸拍击拟音);B 每排立起一声轻纸响,

错峰对齐(S2/S4 同源)

参考实现

demos/ui-entrance/paper-craft-moves/ (MaskingTapeSlap.tsx / PopupBookRise.tsx)

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

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

Prompt

Use the video-shotcraft shot recipe "Hiệu ứng thủ công giấ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/paper-craft-moves.json
Raw TSX: https://gimgs.net/motion/paper-craft-moves/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/ui-entrance/paper-craft-moves/MaskingTapeSlap.tsx, demos/ui-entrance/paper-craft-moves/PopupBookRise.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

10,375 chars · Apache-2.0Mở template gốc ↗
// ===== demos/ui-entrance/paper-craft-moves/MaskingTapeSlap.tsx =====
// masking-tape-slap —— 纸胶带拍定
// 一张 Card 轻飘入位后悬着微晃(±1.5° 正弦 + 5px 上下浮),两条半透明纸胶带
// 先后从画外拍在对角(scale 1.45→1 + rotate 过冲 + 一帧压扁)。第一条拍下晃动减半,
// 第二条拍下同帧卡片停晃、投影瞬间变薄、整卡 2px 下沉——"按死"的一瞬是主角。
// 帧确定性:全部由 frame 派生,无随机。收尾 f86 后真静止 54f。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, Card, TitleBlock } from '../../_fixtures/Fixtures';

const CARD_W = 560;
const CARD_H = 350;
const CX = (1920 - CARD_W) / 2;
const CY = (1080 - CARD_H) / 2 + 40;

const FLOAT_START = 12; // 开头 12f 空场静置
const FLOAT_END = 38;
const SLAP1 = 58;
const SLAP2 = 82;
const APPROACH = 6; // 胶带从画外扑向卡面的帧数
const FREEZE = 2; // 拍死后晃动归零帧数

// 悬浮晃动幅度包络:入位后升起 → 第一条胶带拍下后减半 → 第二条拍下冻结(由外层处理)
const amp = (f: number): number => {
  const rise = interpolate(f, [FLOAT_END, FLOAT_END + 8], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const damp = interpolate(f, [SLAP1, SLAP1 + 4], [1, 0.45], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  return rise * damp;
};

const rawRot = (f: number): number => amp(f) * 1.5 * Math.sin((f - FLOAT_END) * 0.16);
const rawBob = (f: number): number => amp(f) * 5 * Math.sin((f - FLOAT_END) * 0.11);

// 第二条拍下(SLAP2)同帧起 2f 内把晃动按死到 0
const frozen = (f: number, raw: (x: number) => number): number =>
  f <= SLAP2
    ? raw(f)
    : interpolate(f, [SLAP2, SLAP2 + FREEZE], [raw(SLAP2), 0], {
        extrapolateRight: 'clamp',
      });

const Tape: React.FC<{
  frame: number;
  land: number;
  cx: number; // 胶带中心点(世界坐标)
  cy: number;
  rot: number; // 落定角度
  fromX: number; // 画外来向偏移
  fromY: number;
}> = ({ frame, land, cx, cy, rot, fromX, fromY }) => {
  if (frame < land - APPROACH) return null; // 条件卸载:拍上前真不存在

  const t = interpolate(frame, [land - APPROACH, land], [0, 1], {
    easing: Easing.out(Easing.cubic),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const scale = interpolate(t, [0, 1], [1.45, 1]);
  const dx = fromX * (1 - t);
  const dy = fromY * (1 - t);
  const opacity = interpolate(frame, [land - APPROACH, land - APPROACH + 2], [0, 0.85], {
    extrapolateRight: 'clamp',
  });
  // rotate 过冲:来时欠 16° → 落帧过 7° → 4f 内回正
  const r = interpolate(frame, [land - APPROACH, land, land + 4], [rot - 16, rot + 7, rot], {
    easing: Easing.out(Easing.quad),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  // 一帧压扁:落帧 scaleY 0.72,次帧 0.9,随后复原
  const sy = frame === land ? 0.72 : frame === land + 1 ? 0.9 : 1;

  return (
    <div
      style={{
        position: 'absolute',
        left: cx - 160,
        top: cy - 34,
        width: 320,
        height: 68,
        transform: `translate(${dx}px, ${dy}px) rotate(${r}deg) scale(${scale}) scaleY(${sy})`,
        transformOrigin: '50% 50%',
        opacity,
        background:
          'linear-gradient(90deg, rgba(214,212,206,0.95) 0%, rgba(226,224,218,0.95) 30%, rgba(212,210,204,0.95) 60%, rgba(222,220,214,0.95) 100%)',
        // 撕边:两端锯齿
        clipPath:
          'polygon(0% 8%, 2.5% 0%, 97% 3%, 100% 12%, 98.2% 30%, 100% 52%, 98% 74%, 100% 90%, 96.5% 100%, 3% 97%, 0% 88%, 1.8% 64%, 0% 42%, 2% 22%)',
        boxShadow: '0 1px 3px rgba(0,0,0,0.15)',
      }}
    />
  );
};

export const MaskingTapeSlap: React.FC = () => {
  const frame = useCurrentFrame();

  // 卡片飘入:从上方 -120px 缓落
  const floatY = interpolate(frame, [FLOAT_START, FLOAT_END], [-120, 0], {
    easing: Easing.out(Easing.cubic),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const floatOp = interpolate(frame, [FLOAT_START, FLOAT_START + 10], [0, 1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  const rot = frozen(frame, rawRot);
  const bob = frozen(frame, rawBob);

  // 按死:2px 下沉 + 投影瞬间变薄
  const sink = interpolate(frame, [SLAP2, SLAP2 + FREEZE], [0, 2], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const shOff = interpolate(frame, [SLAP2, SLAP2 + FREEZE], [16, 3], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const shBlur = interpolate(frame, [SLAP2, SLAP2 + FREEZE], [34, 8], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });
  const shAlpha = interpolate(frame, [SLAP2, SLAP2 + FREEZE], [0.22, 0.1], {
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <div style={{ width: 1920, height: 1080, background: G.bg, position: 'relative', overflow: 'hidden' }}>
      <div style={{ position: 'absolute', top: 110, width: '100%', textAlign: 'center' }}>
        <TitleBlock text="MASKING TAPE SLAP" size={72} />
      </div>

      <div
        style={{
          position: 'absolute',
          left: CX,
          top: CY,
          transform: `translateY(${floatY + bob + sink}px) rotate(${rot}deg)`,
          transformOrigin: '50% 50%',
          opacity: floatOp,
        }}
      >
        <Card
          w={CARD_W}
          h={CARD_H}
          seed={3}
          style={{ boxShadow: `0 ${shOff}px ${shBlur}px rgba(0,0,0,${shAlpha})` }}
        />
      </div>

      {/* 两条胶带钉在卡片对角(世界坐标,卡片在其下滑动微晃) */}
      <Tape frame={frame} land={SLAP1} cx={CX + 55} cy={CY + 40} rot={-45} fromX={-170} fromY={-130} />
      <Tape frame={frame} land={SLAP2} cx={CX + CARD_W - 55} cy={CY + CARD_H - 40} rot={-45} fromX={170} fromY={130} />
    </div>
  );
};


// ===== demos/ui-entrance/paper-craft-moves/PopupBookRise.tsx =====
// popup-book-rise —— 立体书立起
// FakeDashboard(A) 打平躺下(场景 rotateX 75° 透视俯视),6 张卡片是贴在页上的
// 纸片,沿各自底边从平躺错峰立起(rotateX 90°→-5° 过冲→0° 回弹,即立到 95° 再回 90°),
// 根部投影随立起角度收窄变淡。全部立起后整个场景轻微回正(75°→68°)收尾。
// 卡片用独立网格复刻 dashboard A 区布局(Fixtures 内嵌卡无法单独驱动)。
// 收尾 f108 后真静止 ≥52f。帧确定性:全由 frame 派生。
import React from 'react';
import { useCurrentFrame, interpolate, spring, Easing } from 'remotion';
import { G, Card } from '../../_fixtures/Fixtures';

const FPS = 30;
const HOLD = 14; // 开头静置
const STAGGER = 7;
const RISE_DUR = 34; // spring 视觉收敛帧数
const LAST_START = HOLD + 5 * STAGGER; // 49
const SETTLE = LAST_START + RISE_DUR; // 83:全部立起
const REST = SETTLE + 25; // 108:场景回正完成

// dashboard A 区几何(照抄 FakeDashboard:侧栏 220 + 顶栏 72 + padding 36 + gap 28)
const AREA_X = 220 + 36;
const AREA_Y = 72 + 36;
const AREA_W = 1920 - 220 - 72;
const AREA_H = 1080 - 72 - 72;
const GAP = 28;
const CELL_W = (AREA_W - 2 * GAP) / 3;
const CELL_H = (AREA_H - GAP) / 2;

const PageCard: React.FC<{ i: number; frame: number }> = ({ i, frame }) => {
  const col = i % 3;
  const row = Math.floor(i / 3);
  // 远排(row 0)先立,近排后立;同排从左到右
  const order = row === 0 ? col : 3 + col;
  const start = HOLD + order * STAGGER;

  const s = spring({
    frame: frame - start,
    fps: FPS,
    config: { damping: 11, stiffness: 130, mass: 0.9 },
    durationInFrames: RISE_DUR,
    durationRestThreshold: 0.0001,
  });
  // 平躺(贴页面 = local 0°)→ 立起(垂直页面 = local -90°,顶边朝观众翻起),
  // spring 过冲自然冲过 -90° 到约 -95°(纸的韧性)再回弹。
  const rx = interpolate(s, [0, 1], [0, -90]);

  // 根部投影:躺平时长影(卡片盖在页面上),立起后收成窄条
  const lie = 1 - Math.min(Math.abs(rx) / 90, 1); // 1 = 躺平, 0 = 立直
  const shH = 14 + 90 * Math.max(lie, 0);
  const shAlpha = 0.1 + 0.16 * Math.max(lie, 0);

  return (
    <div
      style={{
        position: 'absolute',
        left: AREA_X + col * (CELL_W + GAP),
        top: AREA_Y + row * (CELL_H + GAP),
        width: CELL_W,
        height: CELL_H,
        transformStyle: 'preserve-3d',
      }}
    >
      {/* 根部投影贴在页面上,不随卡片立起 */}
      <div
        style={{
          position: 'absolute',
          left: 6,
          right: 6,
          bottom: -4,
          height: shH,
          background: `rgba(0,0,0,${shAlpha})`,
          borderRadius: 12,
          filter: 'blur(10px)',
        }}
      />
      <div
        style={{
          position: 'absolute',
          inset: 0,
          transform: `rotateX(${rx}deg)`,
          transformOrigin: '50% 100%',
          backfaceVisibility: 'hidden',
        }}
      >
        <Card w={0} h={0} seed={i + 1} style={{ width: '100%', height: '100%' }} />
      </div>
    </div>
  );
};

export const PopupBookRise: React.FC = () => {
  const frame = useCurrentFrame();

  // 场景(书页)俯视角:全程 75°,全部立起后轻微回正到 68°
  const sceneRx = interpolate(frame, [SETTLE, REST], [75, 68], {
    easing: Easing.inOut(Easing.cubic),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <div style={{ width: 1920, height: 1080, background: '#dddddb', position: 'relative', overflow: 'hidden' }}>
      <div style={{ position: 'absolute', inset: 0, perspective: 2600, perspectiveOrigin: '50% 30%' }}>
        <div
          style={{
            position: 'absolute',
            inset: 0,
            transform: `translateY(-40px) rotateX(${sceneRx}deg)`,
            transformOrigin: '50% 62%',
            transformStyle: 'preserve-3d',
          }}
        >
          {/* 书页底板:dashboard 的壳(侧栏+顶栏+空白页面) */}
          <div style={{ position: 'absolute', inset: 0, background: G.bg, boxShadow: '0 40px 80px rgba(0,0,0,0.25)' }}>
            <div style={{ position: 'absolute', left: 0, top: 0, bottom: 0, width: 220, background: G.side, padding: '28px 22px', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 18 }}>
              <div style={{ width: 40, height: 40, borderRadius: 10, background: '#777775' }} />
              {Array.from({ length: 7 }).map((_, i) => (
                <div key={i} style={{ height: 12, width: `${60 + ((i * 29) % 35)}%`, background: G.sideBar, borderRadius: 6 }} />
              ))}
            </div>
            <div style={{ position: 'absolute', left: 220, right: 0, top: 0, height: 72, background: G.panel, borderBottom: `2px solid ${G.line}`, display: 'flex', alignItems: 'center', padding: '0 32px', gap: 20, boxSizing: 'border-box' }}>
              <div style={{ height: 18, width: 180, background: G.bar, borderRadius: 9 }} />
              <div style={{ marginLeft: 'auto', height: 36, width: 320, background: '#fff', border: `2px solid ${G.line}`, borderRadius: 18, boxSizing: 'border-box' }} />
              <div style={{ width: 36, height: 36, borderRadius: 18, background: G.mid }} />
            </div>
          </div>
          {/* 6 张纸片卡沿底边立起 */}
          {Array.from({ length: 6 }).map((_, i) => (
            <PageCard key={i} i={i} frame={frame} />
          ))}
        </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/paper-craft-moves/MaskingTapeSlap.tsx, demos/ui-entrance/paper-craft-moves/PopupBookRise.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