{
  "slug": "bubble-swarm-takeover",
  "title": "Bubble Swarm Takeover",
  "tagline": "A curtain transition of pearlescent bubbles — bubbles of varying size drift in from off-screen, growing to cover the whole frame as the page \"whites out\"; the cut hides at peak coverage, and bubbles disperse onto the new scene.",
  "description": "A curtain transition of pearlescent bubbles — bubbles of varying size drift in from off-screen, growing to cover the whole frame as the page \"whites out\"; the",
  "category": "transition",
  "tags": [
    "shotcraft",
    "remotion",
    "transition"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 4.39,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/bubble-swarm-takeover/poster-42d414560add.jpg",
    "video": "https://gimgs.net/motion/media/bubble-swarm-takeover/video-5795a0cb2ded.mp4"
  },
  "variants": [
    {
      "key": "bubble-swarm-takeover",
      "label": "bubble-swarm-takeover",
      "description": "A curtain transition of pearlescent bubbles — bubbles of varying size drift in from off-screen, growing to cover the whole frame as the page \"whites out\"; the cut hides at peak coverage, and bubbles disperse onto the new scene.",
      "use": "",
      "video": "https://gimgs.net/motion/media/bubble-swarm-takeover/video-5795a0cb2ded.mp4",
      "poster": "https://gimgs.net/motion/media/bubble-swarm-takeover/poster-42d414560add.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/bubble-swarm-takeover.html",
  "json_url": "https://gimgs.net/motion/bubble-swarm-takeover.json",
  "template_url": "https://gimgs.net/motion/bubble-swarm-takeover/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/bubble-swarm-takeover.md",
  "updated_at": "2026-09-24T07:39:46.764Z",
  "prompt": "## When to use\n\nChapter-level scene changes where the brand world has a \"physical decorative object\" to use as a curtain (bubbles, petals, icons all reskin-able); segments where the transition itself is brand exposure.\n\n## Intention\n\ntransition-hidden-cut's foreground-occlusion style is \"one object sweeping across for 1–3 frames,\" and wipe-transitions is a geometric boundary sweeping across — both are \"boundary\" thinking. This card is \"curtain\" thinking: let a swarm of brand objects spend ~2 seconds drifting into the foreground and growing to cover the screen, hiding the hard cut at peak coverage, then dispersing to reveal the new scene. What makes it more sophisticated than a geometric wipe is that the curtain itself is a brand asset — the longer the transition, the more brand exposure, so slowness isn't a drawback here. Two things are critical: the peak must truly cover the screen (6 giant bubbles are pinned to grid positions as a safety net; the random swarm is purely for atmosphere); and the whiteout layer sits between the page and the bubbles — the page loses detail first, then the bubbles catch all the attention, and the cut happens behind the white veil where viewers can't compare before/after frames.\n\n## Duration & energy\n\n- Duration: ~4.3s (130f: drift-in ~67f + peak hidden cut + disperse ~43f).\n- Energy: Medium-high (continuous swarm motion, no instantaneous impact).\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\ntransition-hidden-cut 的前景遮挡式是\"一件东西扫过去的 1–3 帧\"，\nwipe-transitions 是一条几何边界扫过——都是\"边界\"思维。本卡是\"幕布\"\n思维：让一**群**品牌实体花 2 秒飘入前景涨大遮屏，遮蔽峰值藏硬切，\n再散开露出新场景。比几何擦除高级的地方在于幕布本身就是品牌资产，\n转场时间越长品牌露出越足，反而不嫌慢。命门有二：**峰值必须真遮满**\n（6 颗巨型气泡按网格钉落点兜底，随机群只管氛围）；**洗白层压在页面\n与气泡之间**——页面先被洗掉细节，气泡才接得住全部视线，切换在白纱\n后面完成，观众无从对比前后帧。\n\n## 动效核心\n- 气泡 = CSS 径向渐变圆（高光偏 34%/28%，四组粉/紫/蓝/青色相微差）\n  + inset 白晕 + 外辉光；三层景深：远 blur7 / 中层实 / 近景 blur9 焦外\n- 34 颗常规气泡 seed 随机（mulberry32）：入场时刻 8–50f、四边画外\n  起点、落点撒满全屏；正弦 wobble（振幅 10–32px）让群体\"活着\"\n- 6 颗巨型气泡（r 430–570）按 2×3 网格落点、22–36f 入场——峰值全\n  覆盖靠它们，不靠运气\n- 峰值帧 75 硬切场景（frame < PEAK ? A : B）；洗白层 opacity\n  0→0.92（42–68f）→0（82–104f），白色 overlay 夹在页面与气泡之间\n- 散开 = 以画面中心为极心沿径向外推 1700px，ease-in 加速离场 +\n  缩小 35% + 尾段淡出；入场 ease-out、离场 ease-in，呼吸方向相反\n- i18n 胶囊变体：文字胶囊挂在指定气泡上随宿主漂（+微转），演\"多语言\"\n  等抽象能力时启用；胶囊 ≤3 枚，多了变词云（那是另一个手法）\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 峰值帧 | 75/130 | 藏切点；峰值前飘入 ≥60f 才有\"涨潮\"感，<40f 读作扑脸 |\n| 气泡数 | 34 随机 + 6 网格巨型 | 巨型是遮屏保底，删了必漏缝；随机 <20 颗群感散 |\n| 洗白 | 0→0.92→0，峰值区间持续 0.92 | <0.8 新旧场景对比穿帮；1.0 变纯白闪，气泡被吞 |\n| 景深 | blur 7 / 0.5 / 9 三层 | 全实心读作贴纸雨；近景不虚就没有\"穿过泡群\"的纵深 |\n| 散开 | 径向 1700px ease-in + scale ×0.65 + 尾段淡出 | 原路退回读作倒放；只淡出不外推读作气泡\"爆了\" |\n| wobble | sin(f×0.09) × 10–32px | 去掉后群体像贴图平移；>50px 读作乱流 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 与 transition-hidden-cut 分工：那卡是 1–3 帧的\"看不见剪刀\"（遮挡物\n  是构图里既有元素，快进快出），本卡是 2 秒级\"幕布仪式\"（遮挡物是外来\n  群体，慢涨慢散）——接缝预算紧用那卡，章节换幕用本卡\n- 与 wipe-transitions/shot-transitions 同层选型：几何擦除/流白是中性\n  转场，本卡自带品牌气质——品牌无\"实体装饰物\"资产时别硬造，中性段落\n  用回几何式\n- 珠光渐变的高光偏移（34%/28%）是\"3D 球\"错觉来源，改成居中径向渐变\n  立即变平面圆点\n- 峰值帧前后各 ±2f 内不得有任何气泡 opacity <1 的空隙露底——检查\n  方式：单帧导出峰值帧看有无旧场景像素\n- 实战换皮（花瓣/logo/表情）时保持三层景深 + 网格保底两条铁律，\n  形状可换、结构不可换\n\n## 参考实现\ndemos/transition/bubble-swarm-takeover/\n（BubbleSwarmTakeover.tsx）\n原片出处：loom-ai 9–12s（i18n 胶囊变体并入 30–34.5s）\n\n## Reference implementation (Remotion)\n\n- demos/transition/bubble-swarm-takeover/BubbleSwarmTakeover.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/bubble-swarm-takeover/BubbleSwarmTakeover.tsx\n\nThe component imports shared fixtures from `demos/_fixtures` and helpers from `assets/lib` in the repo; SKILL.md there documents the render workflow.\n",
  "flow": [
    {
      "step": "Read the recipe",
      "note": "Prompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points."
    },
    {
      "step": "Get the code",
      "tool": "git",
      "cmd": "git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install"
    },
    {
      "step": "Register + render",
      "tool": "remotion",
      "cmd": "npx remotion render <CompositionId> out.mp4   # component: demos/transition/bubble-swarm-takeover/BubbleSwarmTakeover.tsx"
    },
    {
      "step": "Or re-implement",
      "note": "Port the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing."
    }
  ],
  "files": [
    {
      "path": "demos/transition/bubble-swarm-takeover/BubbleSwarmTakeover.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/bubble-swarm-takeover/BubbleSwarmTakeover.tsx"
    }
  ],
  "contract": "React/Remotion composition (TSX) from the open-source video-shotcraft library (Apache-2.0). It is not a browser-playable template: clone the repo, register the component in a Remotion project and render with `npx remotion render`. The recipe (prompt) describes timing, easing and parameters so you can also re-implement the shot in any engine.",
  "render": {
    "engine": "remotion",
    "frames": 132,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/bubble-swarm-takeover/BubbleSwarmTakeover.tsx"
    ],
    "note": "Clone https://github.com/Vincentwei1021/video-shotcraft (npm install). The component lives under demos/{category}/{name}/ and imports shared fixtures from demos/_fixtures and helpers from assets/lib. Copy it (with those imports) into a Remotion project, register a <Composition> with the template's width/height/fps/duration in Root.tsx, then `npx remotion render <CompositionId> out.mp4`. SKILL.md in the repo documents the full Claude Code / Codex workflow. The prompt below is the shot recipe (timing, easing, parameter table, pitfalls) — enough to re-implement the shot in HTML/WAAPI or another engine if you prefer."
  },
  "source": "// ===== demos/transition/bubble-swarm-takeover/BubbleSwarmTakeover.tsx =====\n// bubble-swarm-takeover —— loom-ai 9–12s\n// 无剪切转场：珠光气泡群从画外飘入、越来越大遮满整屏，页面同时被\"洗白\"，\n// 遮蔽峰值处藏场景切换，气泡散开后已是新场景。混入 i18n 文字胶囊变体元素。\nimport React from 'react';\nimport { AbsoluteFill, useCurrentFrame, interpolate, Easing } from 'remotion';\nimport { FakeDashboard } from '../../_fixtures/Fixtures';\n\nconst mulberry32 = (a: number) => () => {\n  let t = (a += 0x6d2b79f5);\n  t = Math.imul(t ^ (t >>> 15), t | 1);\n  t ^= t + Math.imul(t ^ (t >>> 7), t | 61);\n  return ((t ^ (t >>> 14)) >>> 0) / 4294967296;\n};\n\nconst PEAK = 75; // 遮蔽峰值帧（藏切点）\nconst W = 1920;\nconst H = 1080;\nconst CX = W / 2;\nconst CY = H / 2;\n\n// 珠光色相微差（粉/紫/蓝/青）\nconst TINTS = [\n  ['rgba(226,208,255,0.55)', 'rgba(255,200,235,0.45)'],\n  ['rgba(190,225,255,0.55)', 'rgba(215,200,255,0.45)'],\n  ['rgba(255,215,235,0.5)', 'rgba(200,235,255,0.45)'],\n  ['rgba(205,240,250,0.5)', 'rgba(235,210,255,0.45)'],\n];\n\ntype BubbleSpec = {\n  startX: number; startY: number; targetX: number; targetY: number;\n  r: number; t0: number; blur: number; tint: number;\n  wobblePhase: number; wobbleAmp: number; z: number;\n};\n\nconst makeBubbles = (): BubbleSpec[] => {\n  const specs: BubbleSpec[] = [];\n  // 34 颗常规气泡（三层景深）\n  for (let i = 0; i < 34; i++) {\n    const rng = mulberry32(1000 + i * 97);\n    const edge = Math.floor(rng() * 4);\n    const along = rng();\n    let startX = 0, startY = 0;\n    if (edge === 0) { startX = along * W; startY = -320; }\n    if (edge === 1) { startX = W + 320; startY = along * H; }\n    if (edge === 2) { startX = along * W; startY = H + 320; }\n    if (edge === 3) { startX = -320; startY = along * H; }\n    const layer = i % 3; // 0 远 1 中 2 近\n    const r = layer === 0 ? 45 + rng() * 55 : layer === 1 ? 100 + rng() * 90 : 210 + rng() * 150;\n    specs.push({\n      startX, startY,\n      targetX: 140 + rng() * (W - 280),\n      targetY: 100 + rng() * (H - 200),\n      r,\n      t0: 8 + rng() * 42,\n      blur: layer === 0 ? 7 : layer === 1 ? 0.5 : 9,\n      tint: Math.floor(rng() * TINTS.length),\n      wobblePhase: rng() * Math.PI * 2,\n      wobbleAmp: 10 + rng() * 22,\n      z: layer,\n    });\n  }\n  // 6 颗巨型气泡，峰值时铺满屏（网格落点保证覆盖）\n  const grid = [\n    [340, 300], [960, 240], [1580, 330],\n    [320, 800], [980, 860], [1600, 780],\n  ];\n  grid.forEach(([gx, gy], i) => {\n    const rng = mulberry32(7000 + i * 131);\n    const edge = i % 4;\n    let startX = 0, startY = 0;\n    if (edge === 0) { startX = gx; startY = -600; }\n    if (edge === 1) { startX = W + 600; startY = gy; }\n    if (edge === 2) { startX = gx; startY = H + 600; }\n    if (edge === 3) { startX = -600; startY = gy; }\n    specs.push({\n      startX, startY, targetX: gx, targetY: gy,\n      r: 430 + rng() * 140,\n      t0: 22 + rng() * 14,\n      blur: 3, tint: i % TINTS.length,\n      wobblePhase: rng() * Math.PI * 2, wobbleAmp: 8,\n      z: 2,\n    });\n  });\n  return specs;\n};\n\nconst BUBBLES = makeBubbles();\n\nconst CAPSULES = [\n  { text: 'Hallo!', idx: 5 },\n  { text: '¡Hola!', idx: 14 },\n  { text: 'Ciao!', idx: 23 },\n];\n\nconst Bubble: React.FC<{ spec: BubbleSpec; frame: number }> = ({ spec, frame }) => {\n  const pIn = interpolate(frame, [spec.t0, PEAK], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    easing: Easing.out(Easing.cubic),\n  });\n  // 峰值后向外散开（加速离场）\n  const disperse = interpolate(frame, [PEAK + 2, 118], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    easing: Easing.in(Easing.quad),\n  });\n  const wob = Math.sin(frame * 0.09 + spec.wobblePhase) * spec.wobbleAmp * pIn;\n  let x = spec.startX + (spec.targetX - spec.startX) * pIn + wob;\n  let y = spec.startY + (spec.targetY - spec.startY) * pIn + wob * 0.6;\n  const dx = spec.targetX - CX, dy = spec.targetY - CY;\n  const dl = Math.max(Math.hypot(dx, dy), 60);\n  x += (dx / dl) * disperse * 1700;\n  y += (dy / dl) * disperse * 1700;\n  const scale = interpolate(frame, [spec.t0, PEAK], [0.22, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    easing: Easing.out(Easing.quad),\n  }) * (1 - disperse * 0.35);\n  const opacity =\n    interpolate(frame, [spec.t0, spec.t0 + 7], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }) *\n    interpolate(disperse, [0.55, 1], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n  if (opacity <= 0.01) return null;\n  const [c1, c2] = TINTS[spec.tint];\n  const d = spec.r * 2 * scale;\n  return (\n    <div style={{\n      position: 'absolute', left: x - spec.r * scale, top: y - spec.r * scale,\n      width: d, height: d, borderRadius: '50%', opacity,\n      filter: `blur(${spec.blur}px)`,\n      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%)`,\n      boxShadow: 'inset 0 0 40px rgba(255,255,255,0.55), 0 0 30px rgba(255,255,255,0.25)',\n    }} />\n  );\n};\n\nexport const BubbleSwarmTakeover: React.FC = () => {\n  const frame = useCurrentFrame();\n  // 页面\"洗白\"：靠近峰值时整体亮度提升\n  const whiteout = interpolate(frame, [42, 68, 82, 104], [0, 0.92, 0.92, 0], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    easing: Easing.inOut(Easing.quad),\n  });\n  const breathe = 1 + 0.02 * Math.sin(frame * 0.045);\n  return (\n    <AbsoluteFill style={{ background: '#ececea', overflow: 'hidden' }}>\n      <AbsoluteFill style={{ transform: `scale(${breathe})` }}>\n        {frame < PEAK ? <FakeDashboard variant=\"A\" /> : <FakeDashboard variant=\"B\" />}\n      </AbsoluteFill>\n      {/* 洗白层压在页面之上、气泡之下 */}\n      <AbsoluteFill style={{ background: '#ffffff', opacity: whiteout }} />\n      {/* 远/中层气泡 */}\n      {BUBBLES.filter((b) => b.z < 2).map((b, i) => (\n        <Bubble key={i} spec={b} frame={frame} />\n      ))}\n      {/* i18n 文字胶囊混在中层气泡里漂 */}\n      {CAPSULES.map((c, i) => {\n        const host = BUBBLES[c.idx];\n        const pIn = interpolate(frame, [host.t0 + 4, PEAK], [0, 1], {\n          extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic),\n        });\n        const disperse = interpolate(frame, [PEAK + 2, 116], [0, 1], {\n          extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.in(Easing.quad),\n        });\n        const wob = Math.sin(frame * 0.08 + host.wobblePhase + 1.3) * 18;\n        let x = host.startX + (host.targetX - host.startX) * pIn + 60 + wob;\n        let y = host.startY + (host.targetY - host.startY) * pIn - host.r * 0.9;\n        const dx = host.targetX - CX, dy = host.targetY - CY;\n        const dl = Math.max(Math.hypot(dx, dy), 60);\n        x += (dx / dl) * disperse * 1700;\n        y += (dy / dl) * disperse * 1700;\n        const op = pIn * interpolate(disperse, [0.5, 1], [1, 0], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n        const sc = 0.5 + 0.5 * pIn;\n        return (\n          <div key={i} style={{\n            position: 'absolute', left: x, top: y, opacity: op,\n            transform: `scale(${sc}) rotate(${wob * 0.25}deg)`,\n            padding: '18px 40px', borderRadius: 60,\n            background: 'linear-gradient(135deg, rgba(255,255,255,0.95), rgba(235,220,255,0.9))',\n            border: '2px solid rgba(255,255,255,0.9)',\n            boxShadow: '0 8px 32px rgba(150,120,220,0.25)',\n            fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800,\n            fontSize: 46, color: '#5b4a86', whiteSpace: 'nowrap',\n          }}>{c.text}</div>\n        );\n      })}\n      {/* 前景大气泡（焦外） */}\n      {BUBBLES.filter((b) => b.z === 2).map((b, i) => (\n        <Bubble key={i} spec={b} frame={frame} />\n      ))}\n    </AbsoluteFill>\n  );\n};\n\n\n"
}