Motion · Data

Particle celebration hits

Confetti cannons fire from both sides, or sparks fizz off the counter each time it ticks past a thousand.

1920×108016:930 fps5.38s · 161 framesremotionLicense: Apache-2.0#shotcraft#remotion#data#effects

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

Variants

confetti-crossfire
Each of the two cannons fires 50 rectangular confetti pieces: initial velocity 90–150px/f (at 0.9 decay, needing ~900–1500px total travel to cross the centerline), 55° spread, tumbling 8–15° per frame; after ~90f they've all fallen off-screen and are conditionally unmounted.
The one-time climax of a final-value reveal or announcement
counter-tick-sparks
Tick frames are derived from the same interpolate as the counter; each tick fires 6–10 2px sparks (initial upward velocity 4–6px/f, falling and extinguishing over 12–18f of gravity), with the final-value tick doubling to 20 sparks plus a 1.1x number bounce.
Rhythm accents during a counting-up climb

When to use

Milestone number/KPI reveal/achievement sections; A for a one-time big celebration, B for continuous small ticks

Intention

Give the "number reveal" a visual beat you can almost hear: A is the full stop of the reveal moment — two cannons at bottom-left and bottom-right fire on the same frame, hundreds of tumbling confetti pieces arc and cross over the full screen before falling off, pure celebration semantics; B is the comma along the way — every time the counter ticks past a thousand, a small burst of sparks fizzes off the top of the number and falls out dark, with the final value tick doubling into a bigger burst plus a 1.1x number bounce. Both use closed-form ballistics (initial velocity + gravity + decay, computed frame by frame) — no physics engine, fully frame-deterministic.

Duration & energy

  • Duration: A 3–4s / B 4–5s
  • Energy: Climax-accent type (must settle back to clean stillness after the burst)

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

意图

给"数字揭晓"配可听感的视觉打点:A 是揭晓瞬间的句号——画面左下右下 两门炮同帧开火,上百颗翻转彩屑抛物线交叉飞过全屏再落出画外,纯庆祝 语义;B 是过程中的逗号——计数器每跳过一个整千,数字顶部"叮"地溅一撮 小火星坠落熄灭,终值那跳翻倍爆一大撮+数字弹 1.1x。两式都是闭式弹道 (初速+重力+衰减逐帧公式),免物理引擎、帧确定。

两式选型

| 式 | 做法 | 适用 | |----|------|------| | A confetti-crossfire | 双炮各 50 颗矩形彩屑:初速 90–150px/f(decay 0.9 下总程 ~900–1500px 才能交叉过中线)、spread 55°、每帧翻转 8–15°;~90f 全部落出画外后条件卸载 | 终值揭晓/发布宣言的一次性高潮 | | B counter-tick-sparks | tick 帧由计数器同一 interpolate 派生;每 tick 6–10 颗 2px 火星(初速向上 4–6px/f、重力 12–18f 坠灭),终值跳翻倍 20 颗+数字弹 1.1x | 计数爬升过程的节奏打点 |

参数表

| 参数 | 典型值 | 调节手感 | |------|--------|----------| | A 初速 | 90–150px/f | 词汇原案 18px/f 在 decay 下只飞 180px 不可感——弹道类幅度按总位移倒推 | | A 配色 | 灰阶为主 + 1/3 强调色 | 全彩读作 B 端不合品牌;全灰读不出庆祝 | | B 火星量 | 常规 6–10、终值 20 | 默认档最含蓄,嫌弱可 3 倍量做极端版 | | 弹道 | 闭式公式(seed 伪随机初速角度) | 每帧独立可算,禁 Math.random | | 收尾 | 粒子寿命耗尽条件卸载 | 结尾必须真静止,残留粒子=脏帧 |

已知坑

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

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

  • 同批组合款 ring-confetti-blowout(合环×礼炮)、lights-out-tick-burst

(灭灯×溅火)双双淘汰——爆发×爆发同质叠加读作过载; 本卡两式与其他爆发型手法(slam/impact 系)同帧叠用需谨慎

  • A 只配"值得开炮"的时刻,一片一次;连开读作廉价
  • 声音:A 双炮"砰砰"错开 1–2f 更有层次;B 每 tick 一声细"叮"

参考实现

demos/data/particle-celebrate-hits/ (ConfettiCrossfire.tsx / CounterTickSparks.tsx)

Reference implementation (Remotion)

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

Prompt

Use the video-shotcraft shot recipe "Particle celebration hits" (Remotion composition, Apache-2.0) published on gimgs.net.
Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/particle-celebrate-hits.json
Raw TSX: https://gimgs.net/motion/particle-celebrate-hits/template.html
Repository: https://github.com/Vincentwei1021/video-shotcraft  (component: demos/data/particle-celebrate-hits/ConfettiCrossfire.tsx, demos/data/particle-celebrate-hits/CounterTickSparks.tsx; imports demos/_fixtures and assets/lib)
Spec: 1920x1080, 30 fps, 5.38s

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

8,798 chars · Apache-2.0Open raw template ↗
// ===== demos/data/particle-celebrate-hits/ConfettiCrossfire.tsx =====
// confetti-crossfire —— 双侧礼炮交叉喷洒
// 中央大 KPI 卡 scale 落定(f0–14),揭晓帧 f16 左下+右下两门炮各射 50 颗矩形彩屑:
// 闭式弹道(初速 ~18px/f + spread 55° + 重力 + decay 0.9 的位移闭式解),每帧翻转 8–15°,
// 灰阶为主 + 1/3 琥珀。全部彩屑 ~f100 前落出画外(越界即条件卸载),结尾真静止 ≥55f。
// 帧确定性:sin 散列伪随机派生每颗初速/角度/翻转率,弹道 = 纯 age 的函数。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, TitleBlock } from '../../_fixtures/Fixtures';

const AMBER = '#b45309';
const FIRE = 16; // 揭晓帧 = 发射帧
const DECAY = 0.9;
const GRAV = 1.5; // px/f² (等效重力;decay 下终端落速 = GRAV/(1-d) = 15px/f)

const frac = (x: number) => x - Math.floor(x);
const rnd = (i: number, salt: number) => frac(Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453);

// decay 弹道闭式解:v 每帧 ×0.9 → 位移 = v0·(1-d^age)/(1-d);重力项同样按衰减序列累积
const decaySum = (age: number) => (1 - Math.pow(DECAY, age)) / (1 - DECAY);

type Confetto = {
  vx: number; vy: number; w: number; h: number;
  spin: number; phase: number; amber: boolean; shade: string;
};

const makeGun = (originDeg: number, saltBase: number): Confetto[] =>
  Array.from({ length: 50 }).map((_, i) => {
    const ang = ((originDeg + (rnd(i, saltBase) - 0.5) * 55) * Math.PI) / 180;
    // decay 0.9 下初速总位移只有 10×v0(velocity 6.6f 减半),18px/f 只够 180px 完全不可感
    // → 加码到 70–95px/f:最陡颗峰值升到 y≈390(画面上 1/3),弹幕跨越中央卡,~f96 全部落出
    const speed = 70 + rnd(i, saltBase + 1) * 25;
    const grays = ['#6d6d6b', '#8f8f8d', '#4a4a48', '#b0b0ae'];
    return {
      vx: Math.cos(ang) * speed,
      vy: -Math.sin(ang) * speed, // 屏幕坐标向下为正,射向斜上
      w: 14 + rnd(i, saltBase + 2) * 12,
      h: 8 + rnd(i, saltBase + 3) * 8,
      spin: 8 + rnd(i, saltBase + 4) * 7, // 8–15°/f
      phase: rnd(i, saltBase + 5) * 360,
      amber: rnd(i, saltBase + 6) < 1 / 3,
      shade: grays[Math.floor(rnd(i, saltBase + 7) * 4)],
    };
  });

const LEFT_GUN = makeGun(60, 3);   // 左下炮朝 60°(偏右上)
const RIGHT_GUN = makeGun(120, 9); // 右下炮朝 120°(偏左上)
const LEFT_POS = { x: 140, y: 1040 };
const RIGHT_POS = { x: 1780, y: 1040 };

export const ConfettiCrossfire: React.FC = () => {
  const frame = useCurrentFrame();
  const age = frame - FIRE;

  // KPI 卡 scale 入场落定
  const cardScale = interpolate(frame, [0, 14], [0.6, 1], {
    easing: Easing.out(Easing.back(1.8)),
    extrapolateRight: 'clamp',
  });
  const cardOp = interpolate(frame, [0, 8], [0, 1], { extrapolateRight: 'clamp' });

  const renderGun = (gun: Confetto[], origin: { x: number; y: number }, keyBase: string) =>
    gun.map((c, i) => {
      if (age <= 0) return null;
      const s = decaySum(age);
      const x = origin.x + c.vx * s;
      // 重力:每帧速度 +GRAV 再统一衰减 → 位移闭式 Σ 可整理为 GRAV·(age - s·d)/(1-d) 的近似;
      // 直接用精确序列和:g 项位移 = GRAV · Σ_{k=1..age} (1-d^k)/(1-d) = GRAV·(age-(d-d^{age+1})/(1-d))/(1-d)
      const gDisp = (GRAV * (age - (DECAY - Math.pow(DECAY, age + 1)) / (1 - DECAY))) / (1 - DECAY);
      const y = origin.y + c.vy * s + gDisp;
      // 落出画外即卸载(左右也裁)
      if (y > 1140 || x < -80 || x > 2000) return null;
      const rot = c.phase + c.spin * age;
      return (
        <div
          key={`${keyBase}${i}`}
          style={{
            position: 'absolute', left: x, top: y, width: c.w, height: c.h,
            background: c.amber ? AMBER : c.shade,
            borderRadius: 2,
            // rotateX 造翻牌式 3D 翻转(宽度不变、高度压扁),再加平面 rotate
            transform: `rotate(${rot}deg) rotateX(${rot * 2.3}deg)`,
          }}
        />
      );
    });

  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="CONFETTI CROSSFIRE" size={72} />
      </div>

      {/* 中央 KPI 卡 */}
      <div style={{
        position: 'absolute', left: 660, top: 400, width: 600, height: 320,
        background: G.card, border: `2px solid ${G.border}`, borderRadius: 16,
        boxSizing: 'border-box', padding: 36, boxShadow: '0 4px 14px rgba(0,0,0,0.08)',
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',
        transform: `scale(${cardScale})`, opacity: cardOp,
      }}>
        <div style={{ height: 14, width: 220, background: G.bar, borderRadius: 7 }} />
        <div style={{
          fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800, fontSize: 150,
          color: AMBER, letterSpacing: -3, lineHeight: 1.1, fontVariantNumeric: 'tabular-nums',
        }}>
          98.5%
        </div>
        <div style={{ height: 10, width: 150, background: G.line, borderRadius: 5 }} />
      </div>

      {renderGun(LEFT_GUN, LEFT_POS, 'L')}
      {renderGun(RIGHT_GUN, RIGHT_POS, 'R')}
    </div>
  );
};


// ===== demos/data/particle-celebrate-hits/CounterTickSparks.tsx =====
// counter-tick-sparks —— 数字跳动溅火
// 中央大计数器 0 → 12,847(easeOut 逐位跳动),每逢跳过整千的 tick 帧,
// 数字顶部溅 6–10 颗大火星(初速向上 9–13px/f、重力下坠 14–18f 内坠灭);
// 终值揭晓那跳翻倍 20 颗 + 数字弹 1.1x 回落。
// 结尾所有火星寿命耗尽条件卸载,真静止 ≥40f。
// 帧确定性:tick 帧从同一 easeOut 曲线预解析(模块级求出),火星 = 纯 age 闭式弹道。
import React from 'react';
import { useCurrentFrame, interpolate, Easing } from 'remotion';
import { G, TitleBlock } from '../../_fixtures/Fixtures';

const AMBER = '#b45309';
const TARGET = 12847;
const COUNT_END = 78; // 计数结束帧
const easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);
const valueAt = (f: number) => Math.round(TARGET * easeOutCubic(Math.min(Math.max(f / COUNT_END, 0), 1)));

const frac = (x: number) => x - Math.floor(x);
const rnd = (i: number, salt: number) => frac(Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453);

// 预解析 tick 帧:跳过整千的那一帧 + 终值揭晓帧
const TICKS: { f: number; big: boolean }[] = (() => {
  const out: { f: number; big: boolean }[] = [];
  let prev = 0;
  for (let f = 1; f <= COUNT_END; f++) {
    const v = valueAt(f);
    if (Math.floor(v / 1000) > Math.floor(prev / 1000)) out.push({ f, big: false });
    prev = v;
  }
  out.push({ f: COUNT_END, big: true }); // 终值揭晓
  return out;
})();

const SPARK_LIFE = 18;
const GRAV = 0.9;

export const CounterTickSparks: React.FC = () => {
  const frame = useCurrentFrame();
  const value = valueAt(frame);

  // 终值揭晓弹 1.1x 回落
  const popScale =
    1 +
    0.1 *
      interpolate(frame, [COUNT_END, COUNT_END + 5, COUNT_END + 16], [0, 1, 0], {
        easing: Easing.out(Easing.quad),
        extrapolateLeft: 'clamp',
        extrapolateRight: 'clamp',
      });

  const TOP_Y = 452; // 数字顶缘(火星发射线)
  const CX = 960;

  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="COUNTER TICK SPARKS" size={72} />
      </div>

      {/* 计数卡 */}
      <div style={{
        position: 'absolute', left: 560, top: 400, width: 800, height: 330,
        background: G.card, border: `2px solid ${G.border}`, borderRadius: 16,
        boxSizing: 'border-box', boxShadow: '0 4px 14px rgba(0,0,0,0.08)',
        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20,
      }}>
        <div style={{ height: 14, width: 220, background: G.bar, borderRadius: 7 }} />
        <div style={{
          fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800, fontSize: 168,
          color: G.ink, letterSpacing: -3, lineHeight: 1, fontVariantNumeric: 'tabular-nums',
          transform: `scale(${popScale})`,
        }}>
          {value.toLocaleString('en-US')}
        </div>
        <div style={{ height: 10, width: 150, background: G.line, borderRadius: 5 }} />
      </div>

      {/* 火星:每个 tick 一簇,寿命耗尽条件卸载 */}
      {TICKS.map((tick, t) => {
        const age = frame - tick.f;
        if (age <= 0 || age >= SPARK_LIFE) return null;
        const n = tick.big ? 20 : 6 + Math.floor(rnd(t, 21) * 5); // 6–10,终跳 20
        return Array.from({ length: n }).map((_, i) => {
          const salt = t * 31 + i;
          // 初速:向上 9–13px/f,水平 ±4.5px/f 扇形铺开(终跳更宽)
          const vy = -(9 + rnd(salt, 2) * 4);
          const vx = (rnd(salt, 3) - 0.5) * (tick.big ? 13 : 9);
          const x0 = CX + (rnd(salt, 4) - 0.5) * (tick.big ? 560 : 380); // 沿数字顶缘散布
          const x = x0 + vx * age;
          const y = TOP_Y + vy * age + 0.5 * GRAV * age * age;
          const life = 1 - age / SPARK_LIFE;
          const size = (tick.big ? 7 : 5.5) * (0.4 + 0.6 * life);
          return (
            <div key={`${t}-${i}`} style={{
              position: 'absolute', left: x, top: y, width: size, height: size,
              borderRadius: size / 2, background: AMBER, opacity: life,
              boxShadow: `0 0 ${6 * life}px ${AMBER}`,
            }} />
          );
        });
      })}
    </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/data/particle-celebrate-hits/ConfettiCrossfire.tsx, demos/data/particle-celebrate-hits/CounterTickSparks.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