Motion · Chuyển cảnh

Bong bóng bầy đàn phủ màn

Chuyển cảnh kiểu màn bong bóng ngọc trai — bong bóng lớn nhỏ trôi vào từ ngoài khung, phồng to dần phủ kín màn hình, trang "tẩy trắng" đồng thời, cú cắt ẩn ở đỉnh phủ, bong bóng tan ra là đã sang cảnh mới; có thể pha biến thể viên chữ i18n.

1920×108016:930 fps4.39s · 132 frameremotionGiấy phép: Apache-2.0#shotcraft#remotion#transition

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

Đổi cảnh cấp chương khi thế giới thương hiệu có "vật trang trí thực thể" để dùng làm màn (bong bóng/cánh hoa/icon đều đổi da được); đoạn mà bản thân chuyển cảnh là lộ diện thương hiệu.

Ý đồ

Kiểu che tiền cảnh của transition-hidden-cut là "một vật quét qua trong 1–3 khung hình", wipe-transitions là một ranh giới hình học quét qua — cả hai đều là tư duy "ranh giới". Thẻ này là tư duy "tấm màn": để một bầy thực thể thương hiệu mất 2 giây trôi vào tiền cảnh phồng to che kín màn hình, ẩn cú cắt cứng ở đỉnh che phủ, rồi tan ra lộ cảnh mới. Điểm cao cấp hơn xóa hình học là bản thân tấm màn chính là tài sản thương hiệu, chuyển cảnh càng dài thì thương hiệu càng lộ diện nhiều, nên không hề ngại chậm. Có hai điểm then chốt: đỉnh phải thực sự che kín (6 bong bóng khổng lồ ghim vào lưới làm phương án bảo hiểm, bầy ngẫu nhiên chỉ để tạo không khí); lớp tẩy trắng nằm giữa trang và bong bóng — trang bị tẩy chi tiết trước, bong bóng mới hứng trọn ánh nhìn, cú cắt diễn ra sau tấm voan trắng, người xem không thể so sánh khung hình trước/sau.

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

  • Thời lượng: ~4.3s (130f: trôi vào ~67f + ẩn cắt ở đỉnh + tan ra ~43f).
  • Năng lượng: Trung bình cao (chuyển động bầy đàn liên tục, không có va chạm tức thời).

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)

意图

transition-hidden-cut 的前景遮挡式是"一件东西扫过去的 1–3 帧", wipe-transitions 是一条几何边界扫过——都是"边界"思维。本卡是"幕布" 思维:让一群品牌实体花 2 秒飘入前景涨大遮屏,遮蔽峰值藏硬切, 再散开露出新场景。比几何擦除高级的地方在于幕布本身就是品牌资产, 转场时间越长品牌露出越足,反而不嫌慢。命门有二:峰值必须真遮满 (6 颗巨型气泡按网格钉落点兜底,随机群只管氛围);洗白层压在页面 与气泡之间——页面先被洗掉细节,气泡才接得住全部视线,切换在白纱 后面完成,观众无从对比前后帧。

动效核心

  • 气泡 = CSS 径向渐变圆(高光偏 34%/28%,四组粉/紫/蓝/青色相微差)

+ inset 白晕 + 外辉光;三层景深:远 blur7 / 中层实 / 近景 blur9 焦外

  • 34 颗常规气泡 seed 随机(mulberry32):入场时刻 8–50f、四边画外

起点、落点撒满全屏;正弦 wobble(振幅 10–32px)让群体"活着"

  • 6 颗巨型气泡(r 430–570)按 2×3 网格落点、22–36f 入场——峰值全

覆盖靠它们,不靠运气

  • 峰值帧 75 硬切场景(frame < PEAK ? A : B);洗白层 opacity

0→0.92(42–68f)→0(82–104f),白色 overlay 夹在页面与气泡之间

  • 散开 = 以画面中心为极心沿径向外推 1700px,ease-in 加速离场 +

缩小 35% + 尾段淡出;入场 ease-out、离场 ease-in,呼吸方向相反

  • i18n 胶囊变体:文字胶囊挂在指定气泡上随宿主漂(+微转),演"多语言"

等抽象能力时启用;胶囊 ≤3 枚,多了变词云(那是另一个手法)

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 峰值帧 | 75/130 | 藏切点;峰值前飘入 ≥60f 才有"涨潮"感,<40f 读作扑脸 | | 气泡数 | 34 随机 + 6 网格巨型 | 巨型是遮屏保底,删了必漏缝;随机 <20 颗群感散 | | 洗白 | 0→0.92→0,峰值区间持续 0.92 | <0.8 新旧场景对比穿帮;1.0 变纯白闪,气泡被吞 | | 景深 | blur 7 / 0.5 / 9 三层 | 全实心读作贴纸雨;近景不虚就没有"穿过泡群"的纵深 | | 散开 | 径向 1700px ease-in + scale ×0.65 + 尾段淡出 | 原路退回读作倒放;只淡出不外推读作气泡"爆了" | | wobble | sin(f×0.09) × 10–32px | 去掉后群体像贴图平移;>50px 读作乱流 |

已知坑

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

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

  • 与 transition-hidden-cut 分工:那卡是 1–3 帧的"看不见剪刀"(遮挡物

是构图里既有元素,快进快出),本卡是 2 秒级"幕布仪式"(遮挡物是外来 群体,慢涨慢散)——接缝预算紧用那卡,章节换幕用本卡

  • 与 wipe-transitions/shot-transitions 同层选型:几何擦除/流白是中性

转场,本卡自带品牌气质——品牌无"实体装饰物"资产时别硬造,中性段落 用回几何式

  • 珠光渐变的高光偏移(34%/28%)是"3D 球"错觉来源,改成居中径向渐变

立即变平面圆点

  • 峰值帧前后各 ±2f 内不得有任何气泡 opacity <1 的空隙露底——检查

方式:单帧导出峰值帧看有无旧场景像素

  • 实战换皮(花瓣/logo/表情)时保持三层景深 + 网格保底两条铁律,

形状可换、结构不可换

参考实现

demos/transition/bubble-swarm-takeover/ (BubbleSwarmTakeover.tsx) 原片出处:loom-ai 9–12s(i18n 胶囊变体并入 30–34.5s)

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

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

Prompt

Use the video-shotcraft shot recipe "Bong bóng bầy đàn phủ màn" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/bubble-swarm-takeover.json
Raw TSX: https://gimgs.net/motion/bubble-swarm-takeover/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/transition/bubble-swarm-takeover/BubbleSwarmTakeover.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 4.39s

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,543 chars · Apache-2.0Mở template gốc ↗
// ===== demos/transition/bubble-swarm-takeover/BubbleSwarmTakeover.tsx =====
// bubble-swarm-takeover —— loom-ai 9–12s
// 无剪切转场:珠光气泡群从画外飘入、越来越大遮满整屏,页面同时被"洗白",
// 遮蔽峰值处藏场景切换,气泡散开后已是新场景。混入 i18n 文字胶囊变体元素。
import React from 'react';
import { AbsoluteFill, useCurrentFrame, interpolate, Easing } from 'remotion';
import { FakeDashboard } from '../../_fixtures/Fixtures';

const mulberry32 = (a: number) => () => {
  let t = (a += 0x6d2b79f5);
  t = Math.imul(t ^ (t >>> 15), t | 1);
  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};

const PEAK = 75; // 遮蔽峰值帧(藏切点)
const W = 1920;
const H = 1080;
const CX = W / 2;
const CY = H / 2;

// 珠光色相微差(粉/紫/蓝/青)
const TINTS = [
  ['rgba(226,208,255,0.55)', 'rgba(255,200,235,0.45)'],
  ['rgba(190,225,255,0.55)', 'rgba(215,200,255,0.45)'],
  ['rgba(255,215,235,0.5)', 'rgba(200,235,255,0.45)'],
  ['rgba(205,240,250,0.5)', 'rgba(235,210,255,0.45)'],
];

type BubbleSpec = {
  startX: number; startY: number; targetX: number; targetY: number;
  r: number; t0: number; blur: number; tint: number;
  wobblePhase: number; wobbleAmp: number; z: number;
};

const makeBubbles = (): BubbleSpec[] => {
  const specs: BubbleSpec[] = [];
  // 34 颗常规气泡(三层景深)
  for (let i = 0; i < 34; i++) {
    const rng = mulberry32(1000 + i * 97);
    const edge = Math.floor(rng() * 4);
    const along = rng();
    let startX = 0, startY = 0;
    if (edge === 0) { startX = along * W; startY = -320; }
    if (edge === 1) { startX = W + 320; startY = along * H; }
    if (edge === 2) { startX = along * W; startY = H + 320; }
    if (edge === 3) { startX = -320; startY = along * H; }
    const layer = i % 3; // 0 远 1 中 2 近
    const r = layer === 0 ? 45 + rng() * 55 : layer === 1 ? 100 + rng() * 90 : 210 + rng() * 150;
    specs.push({
      startX, startY,
      targetX: 140 + rng() * (W - 280),
      targetY: 100 + rng() * (H - 200),
      r,
      t0: 8 + rng() * 42,
      blur: layer === 0 ? 7 : layer === 1 ? 0.5 : 9,
      tint: Math.floor(rng() * TINTS.length),
      wobblePhase: rng() * Math.PI * 2,
      wobbleAmp: 10 + rng() * 22,
      z: layer,
    });
  }
  // 6 颗巨型气泡,峰值时铺满屏(网格落点保证覆盖)
  const grid = [
    [340, 300], [960, 240], [1580, 330],
    [320, 800], [980, 860], [1600, 780],
  ];
  grid.forEach(([gx, gy], i) => {
    const rng = mulberry32(7000 + i * 131);
    const edge = i % 4;
    let startX = 0, startY = 0;
    if (edge === 0) { startX = gx; startY = -600; }
    if (edge === 1) { startX = W + 600; startY = gy; }
    if (edge === 2) { startX = gx; startY = H + 600; }
    if (edge === 3) { startX = -600; startY = gy; }
    specs.push({
      startX, startY, targetX: gx, targetY: gy,
      r: 430 + rng() * 140,
      t0: 22 + rng() * 14,
      blur: 3, tint: i % TINTS.length,
      wobblePhase: rng() * Math.PI * 2, wobbleAmp: 8,
      z: 2,
    });
  });
  return specs;
};

const BUBBLES = makeBubbles();

const CAPSULES = [
  { text: 'Hallo!', idx: 5 },
  { text: '¡Hola!', idx: 14 },
  { text: 'Ciao!', idx: 23 },
];

const Bubble: React.FC<{ spec: BubbleSpec; frame: number }> = ({ spec, frame }) => {
  const pIn = interpolate(frame, [spec.t0, PEAK], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
    easing: Easing.out(Easing.cubic),
  });
  // 峰值后向外散开(加速离场)
  const disperse = interpolate(frame, [PEAK + 2, 118], [0, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
    easing: Easing.in(Easing.quad),
  });
  const wob = Math.sin(frame * 0.09 + spec.wobblePhase) * spec.wobbleAmp * pIn;
  let x = spec.startX + (spec.targetX - spec.startX) * pIn + wob;
  let y = spec.startY + (spec.targetY - spec.startY) * pIn + wob * 0.6;
  const dx = spec.targetX - CX, dy = spec.targetY - CY;
  const dl = Math.max(Math.hypot(dx, dy), 60);
  x += (dx / dl) * disperse * 1700;
  y += (dy / dl) * disperse * 1700;
  const scale = interpolate(frame, [spec.t0, PEAK], [0.22, 1], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
    easing: Easing.out(Easing.quad),
  }) * (1 - disperse * 0.35);
  const opacity =
    interpolate(frame, [spec.t0, spec.t0 + 7], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) *
    interpolate(disperse, [0.55, 1], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
  if (opacity <= 0.01) return null;
  const [c1, c2] = TINTS[spec.tint];
  const d = spec.r * 2 * scale;
  return (
    <div style={{
      position: 'absolute', left: x - spec.r * scale, top: y - spec.r * scale,
      width: d, height: d, borderRadius: '50%', opacity,
      filter: `blur(${spec.blur}px)`,
      background: `radial-gradient(circle at 34% 28%, rgba(255,255,255,0.98) 0%, rgba(255,255,255,0.45) 22%, ${c1} 52%, ${c2} 76%, rgba(255,255,255,0.65) 96%)`,
      boxShadow: 'inset 0 0 40px rgba(255,255,255,0.55), 0 0 30px rgba(255,255,255,0.25)',
    }} />
  );
};

export const BubbleSwarmTakeover: React.FC = () => {
  const frame = useCurrentFrame();
  // 页面"洗白":靠近峰值时整体亮度提升
  const whiteout = interpolate(frame, [42, 68, 82, 104], [0, 0.92, 0.92, 0], {
    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
    easing: Easing.inOut(Easing.quad),
  });
  const breathe = 1 + 0.02 * Math.sin(frame * 0.045);
  return (
    <AbsoluteFill style={{ background: '#ececea', overflow: 'hidden' }}>
      <AbsoluteFill style={{ transform: `scale(${breathe})` }}>
        {frame < PEAK ? <FakeDashboard variant="A" /> : <FakeDashboard variant="B" />}
      </AbsoluteFill>
      {/* 洗白层压在页面之上、气泡之下 */}
      <AbsoluteFill style={{ background: '#ffffff', opacity: whiteout }} />
      {/* 远/中层气泡 */}
      {BUBBLES.filter((b) => b.z < 2).map((b, i) => (
        <Bubble key={i} spec={b} frame={frame} />
      ))}
      {/* i18n 文字胶囊混在中层气泡里漂 */}
      {CAPSULES.map((c, i) => {
        const host = BUBBLES[c.idx];
        const pIn = interpolate(frame, [host.t0 + 4, PEAK], [0, 1], {
          extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic),
        });
        const disperse = interpolate(frame, [PEAK + 2, 116], [0, 1], {
          extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.in(Easing.quad),
        });
        const wob = Math.sin(frame * 0.08 + host.wobblePhase + 1.3) * 18;
        let x = host.startX + (host.targetX - host.startX) * pIn + 60 + wob;
        let y = host.startY + (host.targetY - host.startY) * pIn - host.r * 0.9;
        const dx = host.targetX - CX, dy = host.targetY - CY;
        const dl = Math.max(Math.hypot(dx, dy), 60);
        x += (dx / dl) * disperse * 1700;
        y += (dy / dl) * disperse * 1700;
        const op = pIn * interpolate(disperse, [0.5, 1], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
        const sc = 0.5 + 0.5 * pIn;
        return (
          <div key={i} style={{
            position: 'absolute', left: x, top: y, opacity: op,
            transform: `scale(${sc}) rotate(${wob * 0.25}deg)`,
            padding: '18px 40px', borderRadius: 60,
            background: 'linear-gradient(135deg, rgba(255,255,255,0.95), rgba(235,220,255,0.9))',
            border: '2px solid rgba(255,255,255,0.9)',
            boxShadow: '0 8px 32px rgba(150,120,220,0.25)',
            fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800,
            fontSize: 46, color: '#5b4a86', whiteSpace: 'nowrap',
          }}>{c.text}</div>
        );
      })}
      {/* 前景大气泡(焦外) */}
      {BUBBLES.filter((b) => b.z === 2).map((b, i) => (
        <Bubble key={i} spec={b} frame={frame} />
      ))}
    </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/transition/bubble-swarm-takeover/BubbleSwarmTakeover.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