Motion · UI xuất hiện

Chồng thẻ danh sách ép nảy

Thẻ danh sách bay lên từ đáy khung hình lần lượt xếp chồng, mỗi lần chạm đất ép và bật cả chồng, đồng thời bộ đếm nhảy số đồng bộ.

960×54016:930 fps3.5s · 105 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

Khi nào dùng

Dùng cho cảnh feed/radar/hộp thư kiểu "mỗi ngày đều có thứ mới"; nhấn mạnh danh sách tài sản đang tích lũy liên tục.

Ý đồ

"Chồng lên có trọng lượng": mỗi thẻ mới rơi lên khiến cả chồng đã ổn định bị ép xuống rồi bật lại — khán giả đọc được "đây là thứ tích lũy thật" từ phản hồi vật lý đó. Bộ đếm nhảy số đồng bộ chốt chặt cảm giác về số lượng.

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

  • Thời lượng: Khoảng 3s (18–88f)
  • Năng lượng: Trung bình

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)

意图

"堆叠有重量":每张新卡落上来,已落定的整摞被压下再弹回——观众从物理反馈里读出"这是实打实攒下来的东西"。计数器同步跳格把数量感钉死。

动效核心

  • 预备拍(anticipation,借鉴迪士尼法则 2):首张升入前给一拍段落级预备——计数器先于首卡 4–6f 亮起归零/微缩 scale(0.96)→1,把视线引到堆叠区,再让首卡入画。只做段落级一次,不逐卡加
  • 卡从画面下方升入,交替微倾斜收平,等距节拍逐张摞起
  • 每张到场压下整摞 6px、8f 弹回(stackPress 脉冲——即法则 5 的 follow-through:新卡的动能传给整摞再缓冲耗散,这一笔就是"有重量"的来源)
  • 落地后按拖拽层级依次收尾(法则 5):卡体先停 → 整摞弹回 → 高亮条再长出(滞后 2–4f)——三者同帧发生读作贴图切换
  • 收尾一道 glaze 扫光掠过整摞
  • 屏幕空间 DigitRoll 计数器落一张滚一格;相机跟随堆叠向下

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 节拍 | CUES=[18,30,42,54,66](12f 等距),每张飞 22f,bezier(0.45,0.05,0.25,1.12) 末端过冲 | 少量卡(5 张)可以等距;大批量入场才需要递加速(对照 deck-deal 的 R2) | | 入场 | 从下方 600px 升入,交替 ±2° tilt 收平,scale 1.06→1 | 交替倾斜打破对称;同向倾斜读作复制粘贴 | | 堆叠压感 | 后一张到场时整摞压下 6px、8f 弹回(脉冲 [cue,cue+4,cue+8]→[0,6,0]) | 这是"有重量"的关键一笔,砍掉后堆叠变成简单排版 | | 阴影 | 空中 0 32px 64px、落定 0 2px 8px | 阴影随高度收敛,和 deck-deal/spotlight 同一套语言 | | 联动高亮 | 落地后滞后 2–4f 于 72% 高度处长出 40% 宽强调色(模板片为琥珀)底色(7f 长 + 5f 淡);最后一张离切点仅 ~12f 需压缩节拍 | 高亮是次级动作(法则 5/8):在主动作(落地压弹)之后跟进,不与之同帧抢戏;末尾元素的节拍要按剩余帧预算压缩,不能照抄前面的 | | glaze 扫光 | 420px 宽、rotate 14°、mixBlendMode overlay,帧 82→96 从 −700 扫到 2600 | 扫光全镜头只此一次、扫整摞不逐卡(Q4) | | 计数器(digit-roll 手法) | 连续里程计位置 pos:每张卡落地让 pos 沿数字带 +1 格,单格 8f 滚定,bezier(0.25,0.8,0.25,1);滚动量=pos·lineH 沿双份 0-9 带连续下滚 | 计数与落地不同步立刻穿帮;单格滚动必须短于落卡间距(12f)且尾格在镜头收尾前滚定——以 count 为 key 重挂重滚的写法每次都要从 0 滚 22f,12f 节拍下前一次永远滚不完、尾帧停在 4(PR #40 判例);数字带双份拼接+overflow hidden+tabular-nums(不开 tabular 会横向抖) | | 相机 | zoom 1.35→0.9 五键跟随堆叠向下 | 正视机位(见已知坑),相机只做纵向跟随 |

声音

每张落地不逐张配长音;计数落定钉 click-camera(模板片钉在 f648)、段落切入钉 transition-soft(f623)。若逐张配 pop 参照 S2 三招防机枪。

已知坑

  • 堆叠/列表信息镜头必须正视(Q6)——全片倾斜化时本镜头曾被单独回滚回正视;风格化机位逐镜头验证,不全局套用
  • 逐卡 glint 高亮扫过被否两次(Q4),高亮用静态强调色底色条即可,动感交给压弹
  • 与 history-list-stack(document-typewriter-reveal 卡尾段)是同一动作词汇——全片同一手法只当一次主角(P4),两处并用时一处必须降为配角
  • anticipation/拖拽层级参数借鉴自迪士尼 12 法则(pixel2motion skill 内置提炼版),非用户判例——是默认建议不是令;与判例冲突时判例优先
  • 预备动作幅度必须过肉眼阈值(deck-deal 判例 2026-07-09:小幅度用户完全无感,放大 12 倍才通过)——渲染后自查"不逐帧能否看出蓄力"

参考实现

demos/ui-entrance/list-stack-press/ListStackPress.tsx(原 template/src/aifl/live/ScenePapers.tsx)

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

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

Prompt

Use the video-shotcraft shot recipe "Chồng thẻ danh sách ép nả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/list-stack-press.json
Raw TSX: https://gimgs.net/motion/list-stack-press/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/ui-entrance/list-stack-press/ListStackPress.tsx; imports demos/_fixtures and assets/lib)
Spec: 960x540, 30 fps, 3.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

7,971 chars · Apache-2.0Mở template gốc ↗
// ===== demos/ui-entrance/list-stack-press/ListStackPress.tsx =====
// list-stack-press —— 列表卡从底部逐张飞上摞起,每张落地压弹整摞、计数器同步跳格
// "堆叠有重量":每张新卡落上来,已落定的整摞被压下再弹回——物理反馈里读出
// "这是实打实攒下来的东西"。参考实现从 template ScenePapers 剥离:
// 预备拍(计数器先于首卡 4–6f 亮起微缩,把视线引到堆叠区);5 卡 12f 等距节拍
// 从底部 600px 升入(交替 ±2° 倾斜收平 + scale 1.06→1);后一张到场时整摞压下
// 6px、8f 弹回(stackPress 脉冲——"有重量"的关键一笔);落地后高亮条滞后 2–4f
// 长出;收尾一道 glaze 扫光掠过整摞;屏幕空间 DigitRoll 计数器落一张滚一格。
// 正视机位(堆叠/列表镜头必须正视,Q6),相机跟随堆叠向下。
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';
import { PageCam2D, CamKey2D } from '../../_fixtures/PageCam2D';
import layout from '../../_textures/live-layout.json';

export const LIST_STACK_PRESS_DURATION = 88; // 18–88f 落在 shot 内 offset 0

const cards = layout.papers.cards;
const PAGE_H = layout.papers.pageH;
const MONO = 'ui-monospace, SFMono-Regular, Menlo, monospace';
const AMBER = 'oklch(52% 0.115 65)';
const FILES = ['paper1.png', 'paper2.png', 'paper3.png', 'paper4.png', 'paper5.png'];

const CUES = [6, 18, 30, 42, 54]; // 首卡前留 6f 给计数器预备拍(anticipation)
const DUR = 22;
const FLY_EASE = Easing.bezier(0.45, 0.05, 0.25, 1.12);
const TILTS = [2, -2, 2, -2, 2];

// 预备拍:计数器先于首卡亮起 + 微缩 (0.96→1),把视线引到堆叠区(段落级一次)
const ANTICIPATE_START = 0;
const ANTICIPATE_END = 6;

const CAM_KEYS: CamKey2D[] = [
  { frame: 0, cx: 960, cy: 270, zoom: 1.35 },
  { frame: 12, cx: 960, cy: 330, zoom: 1.15 },
  { frame: 18, cx: 960, cy: 520, zoom: 1.02 },
  { frame: 56, cx: 960, cy: 820, zoom: 0.95 },
  { frame: 82, cx: 960, cy: 860, zoom: 0.9 },
];

// odometer digit column —— 连续里程计,不重挂:`pos` 是数字带上的连续位置
// (0→5),每张卡落地让 pos 多滚进一格。单格滚动必须短于 12f 落卡间距、
// 且最后一格在镜头结束前落定——key 重挂从 0 重滚 22f 的写法两条都踩
// (前一格永远滚不完、尾帧停在 4)。
const DIGITS = '0123456789';
const ROLL_DUR = 8; // 单格滚动帧数,< 12f 落卡间距
const DigitRoll: React.FC<{ pos: number; lineH: number; color: string }> = ({ pos, lineH, color }) => (
  <span style={{ display: 'inline-flex', overflow: 'hidden', height: lineH, verticalAlign: 'bottom' }}>
    <span style={{ display: 'inline-block', height: lineH }}>
      <span style={{ display: 'block', transform: `translateY(${-pos * lineH}px)` }}>
        {(DIGITS + DIGITS).split('').map((d, j) => (
          <span key={j} style={{ display: 'block', fontSize: 96, lineHeight: `${lineH}px`, color, fontVariantNumeric: 'tabular-nums' }}>
            {d}
          </span>
        ))}
      </span>
    </span>
  </span>
);

export const ListStackPress: React.FC = () => {
  const frame = useCurrentFrame();
  // 计数器连续位置:每张卡落地(cue+DUR)时向前滚一格,8f 滚定。
  // 最后一张 f76 落地、f84 滚定,早于 f88 镜头结束。
  let rollPos = 0;
  for (const c of CUES) {
    rollPos += interpolate(frame, [c + DUR, c + DUR + ROLL_DUR], [0, 1], {
      extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.25, 0.8, 0.25, 1),
    });
  }

  // 预备拍:计数器 0→6f 从 scale 0.96 微缩回 1 并亮起,视线先引到堆叠区
  const antT = interpolate(frame, [ANTICIPATE_START, ANTICIPATE_END], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.quad),
  });
  const counterScale = 0.96 + 0.04 * antT;
  const counterOpacity = 0.3 + 0.7 * antT;

  // when a *later* card enters, the settled stack gets pressed down 6px then
  // springs back over ~8 frames
  const stackPress = (settledIndex: number) => {
    let press = 0;
    for (let j = settledIndex + 1; j < CUES.length; j++) {
      const cue = CUES[j];
      const p = interpolate(frame, [cue, cue + 4, cue + 8], [0, 6, 0], {
        extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
      });
      press = Math.max(press, p);
    }
    return press;
  };

  // glaze sweep once everything has landed (~78)
  const glazeX = interpolate(frame, [64, 78], [-700, 2600], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.45, 0, 0.35, 1),
  });
  const glazeVis = interpolate(frame, [63, 68, 74, 77], [0, 1, 1, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  return (
    <AbsoluteFill style={{ backgroundColor: '#faf7f2' }}>
      <PageCam2D src="textures/live/papers-full.png" pageH={PAGE_H} keys={CAM_KEYS}>
        {/* cover the printed cards so blank slots stack in */}
        {cards.map((c, i) => (
          <div
            key={`slot-${i}`}
            style={{
              position: 'absolute', left: c.x - 12, top: c.y - 10,
              width: c.w + 24, height: c.h + 20, background: '#faf7f2',
              opacity: frame >= CUES[i] + DUR - 2 ? 0 : 1,
            }}
          />
        ))}

        {cards.map((c, i) => {
          const cue = CUES[i];
          const t = interpolate(frame, [cue, cue + DUR], [0, 1], {
            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: FLY_EASE,
          });
          if (t <= 0) return null;
          const settled = t >= 0.999;
          const dy = 600 * (1 - t) + (settled ? stackPress(i) : 0);
          const rot = TILTS[i] * (1 - t);
          const scale = 1.06 - 0.06 * t;
          const shadow = settled
            ? '0 2px 8px rgba(60,45,30,.10)'
            : `0 32px 64px rgba(60,45,30,${0.22 * (1 - t) + 0.06})`;

          // amber highlight over the linked-project name, after landing
          const hlStart = cue + DUR;
          const hlGrow = interpolate(frame, [hlStart, hlStart + 7], [0, 1], {
            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),
          });
          const hlFade = interpolate(frame, [hlStart + 7, hlStart + 12], [1, 0], {
            extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
          });

          return (
            <div
              key={FILES[i]}
              style={{
                position: 'absolute', left: c.x, top: c.y, width: c.w, height: c.h,
                transform: `translateY(${dy}px) rotate(${rot}deg) scale(${scale})`,
                transformOrigin: 'center center', boxShadow: shadow, borderRadius: 12,
              }}
            >
              <Img src={staticFile(`textures/live/${FILES[i]}`)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />
              {hlGrow > 0 && hlFade > 0 ? (
                <div
                  style={{
                    position: 'absolute', left: 20, top: c.h * 0.72,
                    width: `${hlGrow * 40}%`, height: 30,
                    background: 'oklch(97% 0.028 85)', opacity: 0.6 * hlFade,
                    borderRight: `2px solid ${AMBER}`, pointerEvents: 'none',
                  }}
                />
              ) : null}
            </div>
          );
        })}

        {/* page-space glaze sweep at the end */}
        <div
          style={{
            position: 'absolute', top: 40, height: PAGE_H - 80, left: glazeX, width: 420,
            transform: 'rotate(14deg)', opacity: glazeVis * 0.5, mixBlendMode: 'overlay',
            background: 'linear-gradient(90deg, transparent, rgba(255,240,214,0.9) 45%, rgba(255,240,214,0.9) 55%, transparent)',
            pointerEvents: 'none',
          }}
        />
      </PageCam2D>

      {/* screen-space counter, top-right — lands one digit per card */}
      <div
        style={{
          position: 'absolute', top: 70, right: 96, textAlign: 'right',
          pointerEvents: 'none', opacity: counterOpacity,
          transform: `scale(${counterScale})`, transformOrigin: 'top right',
        }}
      >
        <div style={{ fontFamily: MONO, fontSize: 24, letterSpacing: '0.16em', color: 'oklch(50% 0.006 82)', textTransform: 'uppercase' }}>
          Paper Radar
        </div>
        <div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: 6 }}>
          <DigitRoll pos={rollPos} lineH={96 * 1.15} color={AMBER} />
        </div>
        <div style={{ fontFamily: MONO, fontSize: 20, letterSpacing: '0.12em', color: 'oklch(50% 0.006 82)', marginTop: 4, textTransform: 'uppercase' }}>
          Of 31 Fetched Today
        </div>
      </div>
    </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/list-stack-press/ListStackPress.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