Motion · Rhythm

Panel grid rhythm moves

Three grid-rhythm techniques: flash-fill a grid, reshuffle it, or line up angled panels — all riding the beat.

1920×108016:930 fps4.72s · 142 framesremotionLicense: Apache-2.0#shotcraft#remotion#rhythm#ui-entrance

Source: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Remotion composition (React/TSX) — preview video, no in-browser player

Variants

grid-flash-mosaic
The 3×3 grid mounts one cell every 2f in an h(i)-scrambled order with a hard cut-in (each cell scales 1.18→1 over 3f plus a 2f deepening pulse); once full the grid holds a breathing beat, then the center cell scales up 3.28x over 14f with Easing.in(cubic) to swallow the screen.
Full feature-matrix reveal in one second; build-up before a climax
flip-grid-reflow
Two coordinate tables are pre-written (row layout / 3×2 grid); each card flies in a straight line with delay=i×1.5f over 16f inOut cubic plus a scale 1→1.28 with a 1.02 overshoot; once settled a 6f brightness-0.78 pulse hits the whole frame.
Layout narrative: a semantic shift from side-by-side to grouped
comic-panel-split
Each of the three panels holds a full-page clip-path cut at a 12° diagonal edge plus a translate/scale camera position (1x/1.9x/2.6x), popping in one panel every 2f; they freeze for 18f with a slow push on each panel to keep it alive, then the last panel's diagonal edge expands over 12f with out-cubic to swallow the screen.
Same subject, multiple angles side by side; comic-book-feel sections

When to use

Using the "grid" itself as a rhythm device: cell-by-cell reveal on the beat (A), collective repositioning on the beat (B), same-subject multi-angle freeze layout (C); all three ride the beat

Intention

The difference from wall-reveal-moves is the anchor point: that card is about "how the whole wall enters" (a one-time reveal), this one is about "how the grid becomes a rhythm device" — splitting, reshuffling, and lining up panels all happen on the beat, each grid change is a drum hit. A fills: a 3×3 grid hard-mounts cell by cell in sixteenth-note scrambled order, holds a beat once full, then the center cell scales up 3.28x over 14f with Easing.in(cubic) to swallow the screen — a full feature matrix reveal in one second. B moves: six cards collectively swap straight-line positions on the beat (row → 3×2 grid), settling in half a second plus a deepening pulse to close it out — the layout reshuffle itself is a drum hit. C aligns: the frame cuts hard into 3 diagonal panels, the same product freezes in three different camera angles at once, holds half a beat, then one panel's diagonal edge expands to swallow the screen — Scott Pilgrim's split-screen language. Choose by need: multi-feature reveal uses A, layout narrative uses B, single-product multi-angle uses C.

Duration & energy

  • Duration: A ~4.7s / B ~4.8s / C ~5s
  • Energy: A High / B Medium / C Medium-high

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

意图

与 wall-reveal-moves 的区别是本卡的定位锚:那是"整墙怎么入场" (一次性揭示),这是"格子怎么当节奏器"——分格、重排、并列都发生 在节拍上,格变即鼓点。A 是填:3×3 格按十六分音符乱序逐格啪啪硬入, 填满停一拍,中心格放大吞掉全屏——功能矩阵一秒全亮相;B 是移:6 张卡 在节拍点集体直线换位(横排→3×2 网格),半秒落定 + 加深脉冲收束—— 布局重排本身当一个鼓点;C 是并:画面咔咔切成 3 个斜线分格,同一产品 三种机位同时定格,停半拍后一格斜边扩张吃掉全屏——Scott Pilgrim 的 分屏语言。按需选:多功能亮相用 A,布局叙事用 B,单品多视角用 C。

三式选型

| 式 | 做法 | 适用 | |----|------|------| | A grid-flash-mosaic | 3×3 格每 2f 一格按 h(i) 乱序条件挂载硬入(入格 3f scale 1.18→1 + 2f 加深脉冲),满墙呼吸一拍,中心格 14f Easing.in(cubic) 放大 3.28x 吞屏 | 功能矩阵一秒全亮相;高潮前铺排 | | B flip-grid-reflow | 预写两套坐标表(横排/3×2 网格),每卡 delay=i×1.5f、16f inOut cubic 直线飞行 + scale 1→1.28 带 1.02 过冲;落定后 6f brightness 0.78 全画面脉冲 | 布局叙事:并列→分组的语义换挡 | | C comic-panel-split | 三格各一份整页 clip-path 12° 斜边裁切 + translate/scale 摆机位(1x/1.9x/2.6x),逐格 2f 间隔弹入;定格 18f 各格缓推保活,末格斜边 12f out-cubic 扩张吃屏 | 同一主体多视角并列;漫画感段落 |

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | A 硬入 | 未到拍点直接不渲染(条件挂载),零淡化 | 逐格硬入无淡化——命门;淡入就不是"啪" | | A 描边 | 每格 3px 深色描边(border-box) | 白底提亮不可见判例的反向应用:白卡靠描边分格 | | A 吞屏 | 中心格塞 0.3125x 整页,放大 3.28(>3.2)让描边滑出画外 | 描边残在收尾满屏上就穿帮 | | B 坐标 | 两套表预写(行 y=330 / 网格列 571.6/960/1348.4、行 416.2/663.8),纯 transform | 免真 FLIP 量测;帧确定无随机 | | B 交错 | 偶数索引上排、奇数下排,轨迹互不对穿 | 乱排轨迹会恶性穿帮 | | B 脉冲 | f58–64 brightness 0.78,filter 仅窗口内条件挂载 | 摘罩判例;窗口外挂 filter 毁真静止 | | C 斜缝 | SVG 双线:16px 墨 #2f2f2f 底 + 10px 白核,12° 斜角 | 斜缝白线是漫画感命门;直缝读作分屏工具 | | C 保活 | 定格 18f 各格 scale 缓推 +3%/+5.5%/+8% | 三格全静读作截图拼贴 | | 收尾 | A 68f / B 81f / C 93f 真静止(≥40f) | R1;C 摘罩帧与扩张终值对齐防跳变 |

已知坑

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

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

  • C 三格必须是同一主体的不同机位——三格塞不同内容读作 bento

排版不是漫画分格,语义就散了

  • 三式都吃拍点、声音强依赖:A 逐格一声啪、B 起飞与脉冲各一声、

C 逐格咔咔三声(sound-design §4.5);不踩拍的分格只是布局动画

  • 同片分格系 ≤1 段——格子语言辨识度太强,两段就撞
  • A 与 wall-reveal-moves 别同片连用(都是"墙",观众分不出

入场还是节奏器)

参考实现

demos/rhythm/panel-grid-moves/ (ComicPanelSplit.tsx / FlipGridReflow.tsx / GridFlashMosaic.tsx)

Reference implementation (Remotion)

  • demos/rhythm/panel-grid-moves/ComicPanelSplit.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/rhythm/panel-grid-moves/ComicPanelSplit.tsx
  • demos/rhythm/panel-grid-moves/FlipGridReflow.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/rhythm/panel-grid-moves/FlipGridReflow.tsx
  • demos/rhythm/panel-grid-moves/GridFlashMosaic.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/rhythm/panel-grid-moves/GridFlashMosaic.tsx

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

Everything on this page is also machine-readable. Point your agent at the JSON, or add the gimgs MCP server and ask it to search motion templates.

  1. Fetch the JSON — it holds the shot recipe (timing, easing, parameter table, pitfalls) and the full TSX source.
  2. Clone the video-shotcraft repo; the component imports shared fixtures from demos/_fixtures and helpers from assets/lib.
  3. Register the component as a <Composition> in a Remotion project and render with `npx remotion render`, or re-implement the recipe in your own engine.
curl -s https://gimgs.net/motion/panel-grid-moves.json
git clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/rhythm/panel-grid-moves/ComicPanelSplit.tsx, demos/rhythm/panel-grid-moves/FlipGridReflow.tsx, demos/rhythm/panel-grid-moves/GridFlashMosaic.tsx
npx remotion render <CompositionId> out.mp4
claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("panel-grid-moves")

Prompt

Use the video-shotcraft shot recipe "Panel grid rhythm moves" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/panel-grid-moves.json
Raw TSX: https://gimgs.net/motion/panel-grid-moves/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/rhythm/panel-grid-moves/ComicPanelSplit.tsx, demos/rhythm/panel-grid-moves/FlipGridReflow.tsx, demos/rhythm/panel-grid-moves/GridFlashMosaic.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

15,643 chars · Apache-2.0Open raw template ↗
// ===== demos/rhythm/panel-grid-moves/ComicPanelSplit.tsx =====
// comic-panel-split|漫画分格并列
// FakeDashboard A(加 KPI 数字块)全屏 20f → 咔咔咔切成 3 个斜线分格
// (12° 斜边,10px 白缝 + 墨线描边),逐格 2f 间隔弹入(3f scale 1.06→1
// + 加深脉冲)。三格 = 同页面三机位:全景 1x / 卡片特写 1.9x / 数字区
// 特写 2.6x。定格 18f 各格缓慢微推近保持活 → 第三格斜边框 12f out-cubic
// 扩张吃掉全屏成为下一镜特写。57f 起摘罩(特写直出、分格结构与缝线
// 全部卸载),57–150f 真静止 93f ≥ 40f。帧确定,无随机源。
import React from 'react';
import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from 'remotion';
import { FakeDashboard, G } from '../../_fixtures/Fixtures';

const SPLIT = 20;              // 全屏 A 结束、开始分格
const POP = 3;                 // 每格弹入 3f
const STAGGER = 2;             // 逐格 2f 间隔
const HOLD_END = 45;           // 27f 弹完 + 定格 18f
const EXPAND_END = 57;         // 第三格 12f 扩张结束
// 57–150 真静止 93f

// 斜边 12°:tan(12°)×1080 ≈ 230,顶边比底边右移 230
// 缝 10px(水平半宽 5px)
// 边界1:顶 750 / 底 520;边界2:顶 1405 / 底 1175
const outCubic = Easing.out(Easing.cubic);

// 页面 = FakeDashboard A + 左上卡片内 KPI 数字块(给"数字区特写"一个锚点)
const PageA: React.FC = () => (
  <div style={{ width: 1920, height: 1080, position: 'relative' }}>
    <FakeDashboard variant="A" />
    <div style={{
      position: 'absolute', left: 328, top: 320, width: 380, height: 160,
      background: G.card, borderRadius: 12, display: 'flex', flexDirection: 'column',
      alignItems: 'center', justifyContent: 'center', gap: 8,
    }}>
      <div style={{ fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800, fontSize: 96, color: G.ink, letterSpacing: -2, lineHeight: 1 }}>
        1,284
      </div>
      <div style={{ height: 10, width: 150, background: G.mid, borderRadius: 5 }} />
    </div>
  </div>
);

type PanelSpec = {
  clip: (f: number) => string; // clip-path polygon
  centroidX: number;           // 弹入缩放原点
  originX: number; originY: number; // 内容变换原点(焦点)
  baseScale: number;           // 机位倍率
  tx: number; ty: number;      // 焦点搬到格中心的位移
  z: number;
};

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

  // ===== 摘罩:扩张完成后特写直出,分格结构 / 缝线全部卸载 =====
  if (frame >= EXPAND_END) {
    return (
      <AbsoluteFill style={{ background: G.bg, overflow: 'hidden' }}>
        <div style={{
          width: 1920, height: 1080,
          transform: 'translate(442px, 140px) scale(2.6)',
          transformOrigin: '518px 400px',
        }}>
          <PageA />
        </div>
      </AbsoluteFill>
    );
  }

  // ===== 阶段 1:全屏 A =====
  if (frame < SPLIT) {
    return (
      <AbsoluteFill style={{ background: G.bg }}>
        <PageA />
      </AbsoluteFill>
    );
  }

  // ===== 阶段 2/3:分格 + 第三格扩张 =====
  // 定格期微推近(27–45f 线性,很缓)
  const push = interpolate(frame, [SPLIT + 2 * STAGGER + POP, HOLD_END], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  // 第三格扩张进度(扩散用 out-cubic)
  const ex = interpolate(frame, [HOLD_END, EXPAND_END], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: outCubic,
  });

  // 第三格左斜边(= 边界2 吃屏轨迹)
  const e3Top = 1410 + ex * (-60 - 1410);    // 1410 → -60
  const e3Bot = 1180 + ex * (-290 - 1180);   // 1180 → -290

  const panels: PanelSpec[] = [
    { // 全景 1x
      clip: () => 'polygon(0px 0px, 745px 0px, 515px 1080px, 0px 1080px)',
      centroidX: 315, originX: 960, originY: 540,
      baseScale: 1 + push * 0.03, tx: 0, ty: 0, z: 1,
    },
    { // 卡片特写 1.9x(中上卡片)
      clip: () => 'polygon(755px 0px, 1400px 0px, 1170px 1080px, 525px 1080px)',
      centroidX: 962, originX: 1070, originY: 371,
      baseScale: 1.9 + push * 0.055, tx: -108, ty: 169, z: 1,
    },
    { // 数字区特写 2.6x(KPI 块),扩张时焦点从格中心搬到屏中心
      clip: () => `polygon(${e3Top}px 0px, 1920px 0px, 1920px 1080px, ${e3Bot}px 1080px)`,
      centroidX: 1607, originX: 518, originY: 400,
      // 扩张时 push 增量退掉,scale 收敛回 2.6(与摘罩帧完全一致)
      baseScale: 2.6 + push * 0.08 * (1 - ex), tx: 1089 + ex * (442 - 1089), ty: 140, z: 3,
    },
  ];

  // 缝线透明度:边界1 随第 2 格弹入出现、扩张期被吃前线性淡出;
  // 边界2 随第 3 格弹入出现、扩张末 4f 线性淡出(消散用线性)
  const seam1O = Math.min(
    interpolate(frame, [SPLIT + STAGGER, SPLIT + STAGGER + 2], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),
    interpolate(frame, [HOLD_END, HOLD_END + 3], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),
  );
  const seam2O = Math.min(
    interpolate(frame, [SPLIT + 2 * STAGGER, SPLIT + 2 * STAGGER + 2], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),
    interpolate(frame, [EXPAND_END - 4, EXPAND_END], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),
  );

  return (
    <AbsoluteFill style={{ background: '#ffffff' }}>
      {panels.map((p, i) => {
        const start = SPLIT + i * STAGGER;
        if (frame < start) return null; // 未弹入不渲染
        // 弹入:3f scale 1.06→1 + 加深脉冲
        const pop = interpolate(frame, [start, start + POP], [0, 1], {
          extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: outCubic,
        });
        const popScale = 1.06 - 0.06 * pop;
        const pulse = 0.3 * (1 - pop);
        return (
          <div key={i} style={{
            position: 'absolute', inset: 0, zIndex: p.z,
            clipPath: p.clip(frame),
            transform: `scale(${popScale})`,
            transformOrigin: `${p.centroidX}px 540px`,
          }}>
            <div style={{
              width: 1920, height: 1080,
              transform: `translate(${p.tx}px, ${p.ty}px) scale(${p.baseScale})`,
              transformOrigin: `${p.originX}px ${p.originY}px`,
            }}>
              <PageA />
            </div>
            {pulse > 0.005 && (
              <div style={{ position: 'absolute', inset: 0, background: `rgba(0,0,0,${pulse})` }} />
            )}
          </div>
        );
      })}
      {/* 斜缝:白缝命门 + 墨线描边(暗 16 下、白 10 上 → 两侧各 3px 墨边) */}
      {(seam1O > 0.005 || seam2O > 0.005) && (
        <svg width={1920} height={1080} style={{ position: 'absolute', inset: 0, zIndex: 5, pointerEvents: 'none' }}>
          {seam1O > 0.005 && (
            <g opacity={seam1O}>
              <line x1={750} y1={-10} x2={520} y2={1090} stroke="#2f2f2f" strokeWidth={16} />
              <line x1={750} y1={-10} x2={520} y2={1090} stroke="#ffffff" strokeWidth={10} />
            </g>
          )}
          {seam2O > 0.005 && (
            <g opacity={seam2O}>
              <line x1={e3Top - 5} y1={-10} x2={e3Bot - 5} y2={1090} stroke="#2f2f2f" strokeWidth={16} />
              <line x1={e3Top - 5} y1={-10} x2={e3Bot - 5} y2={1090} stroke="#ffffff" strokeWidth={10} />
            </g>
          )}
        </svg>
      )}
    </AbsoluteFill>
  );
};


// ===== demos/rhythm/panel-grid-moves/FlipGridReflow.tsx =====
// flip-grid-reflow —— 网格 FLIP 重排
// 6 张 Card 初始横排一行(marquee 式,屏心偏上)静止 30f,节拍点集体换位:
// 每卡沿直线飞向 3×2 网格目标位(预写两套坐标表),scale 1→1.28,
// delay = 卡索引×1.5f 微错峰,16f inOut(cubic) + 落定 3f 过冲 1.02。
// 全部落定后整体加深脉冲收束。收尾真静止 ≥40f。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, Card, TitleBlock } from '../../_fixtures/Fixtures';

const CARD_W = 280;
const CARD_H = 170;
const N = 6;

// ---- 坐标表 A:横排一行(卡间 24px,屏心偏上)----
// 总宽 6*280 + 5*24 = 1800,x0 = 60;中心 y = 330
const ROW_Y = 330;
const ROW_CENTERS: Array<[number, number]> = Array.from({ length: N }, (_, i) => [
  60 + CARD_W / 2 + i * (CARD_W + 24),
  ROW_Y,
]); // x: 200, 504, 808, 1112, 1416, 1720

// ---- 坐标表 B:3×2 网格(卡放大 1.28 → 358.4×217.6,网格间距 30px)----
// 网格总宽 3*358.4 + 2*30 = 1135.2 → x0 = 392.4;总高 2*217.6 + 30 = 465.2 → y0 = 307.4
const GC = [571.6, 960, 1348.4]; // 列中心
const GR = [416.2, 663.8]; // 行中心
// 交错映射:偶数索引去上排、奇数去下排(每卡直线飞行、互不对穿)
const GRID_CENTERS: Array<[number, number]> = [
  [GC[0], GR[0]], // card0 → 上左
  [GC[0], GR[1]], // card1 → 下左
  [GC[1], GR[0]], // card2 → 上中
  [GC[1], GR[1]], // card3 → 下中
  [GC[2], GR[0]], // card4 → 上右
  [GC[2], GR[1]], // card5 → 下右
];

const BEAT = 30; // 节拍点
const STAGGER = 1.5; // 每卡延迟
const MOVE = 16; // 飞行帧数
const SETTLE = 3; // 落定过冲帧数
const SCALE_END = 1.28;
const OVERSHOOT = 1.02;
// 末卡完全落定:30 + 5*1.5 + 16 + 3 = 56.5
const ALL_SETTLED = BEAT + (N - 1) * STAGGER + MOVE + SETTLE; // 56.5

// 加深脉冲:f58 → f61 谷值 → f64 回正(谷值 brightness 0.78,spec 4f 放宽到 6f 保可感)
const PULSE_IN = 58;
const PULSE_MID = 61;
const PULSE_OUT = 64;

const moveEase = Easing.inOut(Easing.cubic);

const FlipCard: React.FC<{ i: number; frame: number }> = ({ i, frame }) => {
  const t0 = BEAT + i * STAGGER;
  const [x0, y0] = ROW_CENTERS[i];
  const [x1, y1] = GRID_CENTERS[i];

  const x = interpolate(frame, [t0, t0 + MOVE], [x0, x1], {
    easing: moveEase, extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const y = interpolate(frame, [t0, t0 + MOVE], [y0, y1], {
    easing: moveEase, extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });

  // scale:16f 冲到 1.28*1.02,随后 3f 回落到 1.28
  const sUp = interpolate(frame, [t0, t0 + MOVE], [1, SCALE_END * OVERSHOOT], {
    easing: moveEase, extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const sBack = interpolate(frame, [t0 + MOVE, t0 + MOVE + SETTLE], [SCALE_END * OVERSHOOT, SCALE_END], {
    easing: Easing.out(Easing.cubic), extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
  });
  const s = frame < t0 + MOVE ? sUp : sBack;

  return (
    <div
      style={{
        position: 'absolute',
        left: x - CARD_W / 2,
        top: y - CARD_H / 2,
        width: CARD_W,
        height: CARD_H,
        transform: `scale(${s})`,
        transformOrigin: '50% 50%',
        zIndex: i,
      }}
    >
      <Card w={CARD_W} h={CARD_H} seed={i + 1} />
    </div>
  );
};

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

  // 加深脉冲:仅脉冲窗口内挂载 filter,窗口外完全不挂(摘罩)
  const pulsing = frame >= PULSE_IN && frame <= PULSE_OUT;
  const bright = pulsing
    ? interpolate(frame, [PULSE_IN, PULSE_MID, PULSE_OUT], [1, 0.78, 1], {
        extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
      })
    : 1;

  return (
    <div
      style={{
        width: 1920,
        height: 1080,
        background: G.bg,
        position: 'relative',
        overflow: 'hidden',
        ...(pulsing ? { filter: `brightness(${bright})` } : {}),
      }}
    >
      <div style={{ position: 'absolute', left: 70, top: 56 }}>
        <TitleBlock text="FLIP GRID REFLOW" size={44} />
      </div>
      {Array.from({ length: N }).map((_, i) => (
        <FlipCard key={i} i={i} frame={frame} />
      ))}
    </div>
  );
};


// ===== demos/rhythm/panel-grid-moves/GridFlashMosaic.tsx =====
// grid-flash-mosaic —— 九宫格闪切
// 浅底 → f25 起 3×3 网格按十六分音符(每 2f)逐格啪啪硬入(顺序 h(i) 打乱),
// 每格 = FakeDashboard 不同区域裁切片或灰卡;入格 3f scale 1.18→1 + 2f 加深脉冲。
// 填满后停 14f(整墙微呼吸 1.008) → 中心格 14f Easing.in(cubic) 放大吞掉全屏
// 成为满屏页面。收尾真静止 ≥40f。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, Card, FakeDashboard } from '../../_fixtures/Fixtures';

const h = (n: number) => {
  const s = Math.sin(n * 127.3) * 43758.5453;
  return s - Math.floor(s);
};

const CELL_W = 600;
const CELL_H = 340;
const GAP = 12;
const GRID_X = (1920 - (CELL_W * 3 + GAP * 2)) / 2; // 48
const GRID_Y = (1080 - (CELL_H * 3 + GAP * 2)) / 2; // 18

const FILL_START = 25; // 第一格落下
const STEP = 2; // 十六分音符:每 2f 一格
// 打乱顺序:索引按 h(i+1) 排序
const ORDER = Array.from({ length: 9 }, (_, i) => i).sort(
  (a, b) => h(a + 1) - h(b + 1)
);
const RANK: number[] = [];
ORDER.forEach((cell, k) => (RANK[cell] = k));

const LAST_IN = FILL_START + 8 * STEP + 3; // 最后一格入格动画结束 f44
const HOLD_END = LAST_IN + 14; // 整墙呼吸段结束 f58
const ZOOM_DUR = 14;
const ZOOM_END = HOLD_END + ZOOM_DUR; // f72,之后真静止

// 中心格内是整页 FakeDashboard 缩小版(0.3125),放大后正好成为满屏页面
const MINI_SCALE = CELL_W / 1920; // 0.3125
// 3.2 恰好铺满;加到 3.28 让格子的深色描边完全滑出画外,收尾满屏页面干净
const ZOOM_SCALE = 3.28;

// 非中心格:FakeDashboard 裁切偏移 (x, y, variant) 或灰卡(null)
const CROPS: Array<{ x: number; y: number; v: 'A' | 'B' } | null> = [
  { x: -120, y: -60, v: 'A' },
  null, // 灰卡
  { x: -1260, y: -120, v: 'B' },
  { x: -60, y: -520, v: 'B' },
  null, // 中心格(单独处理,占位)
  { x: -1300, y: -640, v: 'A' },
  { x: -420, y: -300, v: 'B' },
  null, // 灰卡
  { x: -900, y: -680, v: 'A' },
];

const CellContent: React.FC<{ i: number }> = ({ i }) => {
  if (i === 4) {
    // 中心格:整页 dashboard 缩到格内
    return (
      <div
        style={{
          position: 'absolute',
          left: 0,
          top: (CELL_H - 1080 * MINI_SCALE) / 2,
          transform: `scale(${MINI_SCALE})`,
          transformOrigin: 'top left',
        }}
      >
        <FakeDashboard variant="A" />
      </div>
    );
  }
  const crop = CROPS[i];
  if (crop === null) {
    // 灰卡格
    return (
      <div
        style={{
          width: '100%',
          height: '100%',
          background: G.panel,
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
        }}
      >
        <Card w={430} h={240} seed={i * 3 + 2} />
      </div>
    );
  }
  // 裁切片:整页 dashboard 以不同偏移塞进格子(相当于 backgroundPosition 各异)
  return (
    <div style={{ position: 'absolute', left: crop.x, top: crop.y }}>
      <FakeDashboard variant={crop.v} />
    </div>
  );
};

export const GridFlashMosaic: React.FC = () => {
  const f = useCurrentFrame();

  // 整墙微呼吸:仅在填满后的 14f 停顿段,一个正弦来回 1→1.008→1
  const breath =
    f >= LAST_IN && f < HOLD_END
      ? 1 + 0.008 * Math.sin((Math.PI * (f - LAST_IN)) / 14)
      : 1;

  // 中心格放大:14f Easing.in(cubic),吞掉全屏
  const zoom = interpolate(f, [HOLD_END, ZOOM_END], [1, ZOOM_SCALE], {
    easing: Easing.in(Easing.cubic),
    extrapolateLeft: 'clamp',
    extrapolateRight: 'clamp',
  });

  return (
    <div
      style={{
        width: 1920,
        height: 1080,
        background: G.bg,
        position: 'relative',
        overflow: 'hidden',
      }}
    >
      <div
        style={{
          position: 'absolute',
          inset: 0,
          transform: `scale(${breath})`,
          transformOrigin: '960px 540px',
        }}
      >
        {Array.from({ length: 9 }).map((_, i) => {
          const start = FILL_START + RANK[i] * STEP;
          if (f < start) return null; // 硬入:未到拍点不渲染,无淡化
          const row = Math.floor(i / 3);
          const col = i % 3;
          // 入格 3f scale 1.18→1
          const popScale = interpolate(f, [start, start + 3], [1.18, 1], {
            extrapolateLeft: 'clamp',
            extrapolateRight: 'clamp',
          });
          // 2f 加深脉冲
          const darken = interpolate(f, [start, start + 2], [0.45, 0], {
            extrapolateLeft: 'clamp',
            extrapolateRight: 'clamp',
          });
          const isCenter = i === 4;
          const cellScale = isCenter ? popScale * zoom : popScale;
          return (
            <div
              key={i}
              style={{
                position: 'absolute',
                left: GRID_X + col * (CELL_W + GAP),
                top: GRID_Y + row * (CELL_H + GAP),
                width: CELL_W,
                height: CELL_H,
                overflow: 'hidden',
                background: G.card,
                border: `3px solid ${G.ink}`,
                boxSizing: 'border-box',
                transform: `scale(${cellScale})`,
                transformOrigin: 'center',
                zIndex: isCenter ? 10 : 1,
              }}
            >
              <CellContent i={i} />
              {darken > 0.001 && (
                <div
                  style={{
                    position: 'absolute',
                    inset: 0,
                    background: '#000',
                    opacity: darken,
                  }}
                />
              )}
            </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/rhythm/panel-grid-moves/ComicPanelSplit.tsx, demos/rhythm/panel-grid-moves/FlipGridReflow.tsx, demos/rhythm/panel-grid-moves/GridFlashMosaic.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
—

More in this category