{
  "slug": "particle-celebrate-hits",
  "title": "Particle celebration hits",
  "tagline": "Confetti cannons fire from both sides, or sparks fizz off the counter each time it ticks past a thousand.",
  "description": "Confetti cannons fire from both sides, or sparks fizz off the counter each time it ticks past a thousand.",
  "category": "data",
  "tags": [
    "shotcraft",
    "remotion",
    "data",
    "effects"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 5.38,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/particle-celebrate-hits/poster-f8504f29720e.jpg",
    "video": "https://gimgs.net/motion/media/particle-celebrate-hits/video-563d9875747a.mp4"
  },
  "variants": [
    {
      "key": "confetti-crossfire",
      "label": "confetti-crossfire",
      "description": "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.",
      "use": "The one-time climax of a final-value reveal or announcement",
      "video": "https://gimgs.net/motion/media/particle-celebrate-hits/video-563d9875747a.mp4",
      "poster": "https://gimgs.net/motion/media/particle-celebrate-hits/poster-f8504f29720e.jpg"
    },
    {
      "key": "counter-tick-sparks",
      "label": "counter-tick-sparks",
      "description": "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.",
      "use": "Rhythm accents during a counting-up climb",
      "video": "https://gimgs.net/motion/media/particle-celebrate-hits/video-44c3eea8e5de.mp4",
      "poster": "https://gimgs.net/motion/media/particle-celebrate-hits/poster-counter-tick-sparks-e132a838699b.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/particle-celebrate-hits.html",
  "json_url": "https://gimgs.net/motion/particle-celebrate-hits.json",
  "template_url": "https://gimgs.net/motion/particle-celebrate-hits/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/particle-celebrate-hits.md",
  "updated_at": "2026-09-24T07:40:14.335Z",
  "prompt": "## When to use\n\nMilestone number/KPI reveal/achievement sections; A for a one-time big celebration, B for continuous small ticks\n\n## Intention\n\nGive 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.\n\n## Duration & energy\n\n- Duration: A 3–4s / B 4–5s\n- Energy: Climax-accent type (must settle back to clean stillness after the burst)\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n给\"数字揭晓\"配可听感的视觉打点：A 是揭晓瞬间的句号——画面左下右下\n两门炮同帧开火，上百颗翻转彩屑抛物线交叉飞过全屏再落出画外，纯庆祝\n语义；B 是过程中的逗号——计数器每跳过一个整千，数字顶部\"叮\"地溅一撮\n小火星坠落熄灭，终值那跳翻倍爆一大撮+数字弹 1.1x。两式都是闭式弹道\n（初速+重力+衰减逐帧公式），免物理引擎、帧确定。\n\n## 两式选型\n| 式 | 做法 | 适用 |\n|----|------|------|\n| A confetti-crossfire | 双炮各 50 颗矩形彩屑：初速 90–150px/f（decay 0.9 下总程 ~900–1500px 才能交叉过中线）、spread 55°、每帧翻转 8–15°；~90f 全部落出画外后条件卸载 | 终值揭晓/发布宣言的一次性高潮 |\n| B counter-tick-sparks | tick 帧由计数器同一 interpolate 派生；每 tick 6–10 颗 2px 火星（初速向上 4–6px/f、重力 12–18f 坠灭），终值跳翻倍 20 颗+数字弹 1.1x | 计数爬升过程的节奏打点 |\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| A 初速 | 90–150px/f | 词汇原案 18px/f 在 decay 下只飞 180px 不可感——弹道类幅度按总位移倒推 |\n| A 配色 | 灰阶为主 + 1/3 强调色 | 全彩读作 B 端不合品牌；全灰读不出庆祝 |\n| B 火星量 | 常规 6–10、终值 20 | 默认档最含蓄，嫌弱可 3 倍量做极端版 |\n| 弹道 | 闭式公式（seed 伪随机初速角度） | 每帧独立可算，禁 Math.random |\n| 收尾 | 粒子寿命耗尽条件卸载 | 结尾必须真静止，残留粒子=脏帧 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 同批组合款 ring-confetti-blowout（合环×礼炮）、lights-out-tick-burst\n  （灭灯×溅火）双双淘汰——**爆发×爆发同质叠加读作过载**；\n  本卡两式与其他爆发型手法（slam/impact 系）同帧叠用需谨慎\n- A 只配\"值得开炮\"的时刻，一片一次；连开读作廉价\n- 声音：A 双炮\"砰砰\"错开 1–2f 更有层次；B 每 tick 一声细\"叮\"\n\n## 参考实现\ndemos/data/particle-celebrate-hits/\n（ConfettiCrossfire.tsx / CounterTickSparks.tsx）\n\n## Reference implementation (Remotion)\n\n- demos/data/particle-celebrate-hits/ConfettiCrossfire.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/particle-celebrate-hits/ConfettiCrossfire.tsx\n- demos/data/particle-celebrate-hits/CounterTickSparks.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/particle-celebrate-hits/CounterTickSparks.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/data/particle-celebrate-hits/ConfettiCrossfire.tsx, demos/data/particle-celebrate-hits/CounterTickSparks.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/data/particle-celebrate-hits/ConfettiCrossfire.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/particle-celebrate-hits/ConfettiCrossfire.tsx"
    },
    {
      "path": "demos/data/particle-celebrate-hits/CounterTickSparks.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/particle-celebrate-hits/CounterTickSparks.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": 161,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/particle-celebrate-hits/ConfettiCrossfire.tsx",
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/particle-celebrate-hits/CounterTickSparks.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/data/particle-celebrate-hits/ConfettiCrossfire.tsx =====\n// confetti-crossfire —— 双侧礼炮交叉喷洒\n// 中央大 KPI 卡 scale 落定（f0–14），揭晓帧 f16 左下+右下两门炮各射 50 颗矩形彩屑：\n// 闭式弹道（初速 ~18px/f + spread 55° + 重力 + decay 0.9 的位移闭式解），每帧翻转 8–15°，\n// 灰阶为主 + 1/3 琥珀。全部彩屑 ~f100 前落出画外（越界即条件卸载），结尾真静止 ≥55f。\n// 帧确定性：sin 散列伪随机派生每颗初速/角度/翻转率，弹道 = 纯 age 的函数。\nimport React from 'react';\nimport { useCurrentFrame, interpolate, Easing } from 'remotion';\nimport { G, TitleBlock } from '../../_fixtures/Fixtures';\n\nconst AMBER = '#b45309';\nconst FIRE = 16; // 揭晓帧 = 发射帧\nconst DECAY = 0.9;\nconst GRAV = 1.5; // px/f² （等效重力；decay 下终端落速 = GRAV/(1-d) = 15px/f）\n\nconst frac = (x: number) => x - Math.floor(x);\nconst rnd = (i: number, salt: number) => frac(Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453);\n\n// decay 弹道闭式解：v 每帧 ×0.9 → 位移 = v0·(1-d^age)/(1-d)；重力项同样按衰减序列累积\nconst decaySum = (age: number) => (1 - Math.pow(DECAY, age)) / (1 - DECAY);\n\ntype Confetto = {\n  vx: number; vy: number; w: number; h: number;\n  spin: number; phase: number; amber: boolean; shade: string;\n};\n\nconst makeGun = (originDeg: number, saltBase: number): Confetto[] =>\n  Array.from({ length: 50 }).map((_, i) => {\n    const ang = ((originDeg + (rnd(i, saltBase) - 0.5) * 55) * Math.PI) / 180;\n    // decay 0.9 下初速总位移只有 10×v0（velocity 6.6f 减半），18px/f 只够 180px 完全不可感\n    // → 加码到 70–95px/f：最陡颗峰值升到 y≈390（画面上 1/3），弹幕跨越中央卡，~f96 全部落出\n    const speed = 70 + rnd(i, saltBase + 1) * 25;\n    const grays = ['#6d6d6b', '#8f8f8d', '#4a4a48', '#b0b0ae'];\n    return {\n      vx: Math.cos(ang) * speed,\n      vy: -Math.sin(ang) * speed, // 屏幕坐标向下为正，射向斜上\n      w: 14 + rnd(i, saltBase + 2) * 12,\n      h: 8 + rnd(i, saltBase + 3) * 8,\n      spin: 8 + rnd(i, saltBase + 4) * 7, // 8–15°/f\n      phase: rnd(i, saltBase + 5) * 360,\n      amber: rnd(i, saltBase + 6) < 1 / 3,\n      shade: grays[Math.floor(rnd(i, saltBase + 7) * 4)],\n    };\n  });\n\nconst LEFT_GUN = makeGun(60, 3);   // 左下炮朝 60°（偏右上）\nconst RIGHT_GUN = makeGun(120, 9); // 右下炮朝 120°（偏左上）\nconst LEFT_POS = { x: 140, y: 1040 };\nconst RIGHT_POS = { x: 1780, y: 1040 };\n\nexport const ConfettiCrossfire: React.FC = () => {\n  const frame = useCurrentFrame();\n  const age = frame - FIRE;\n\n  // KPI 卡 scale 入场落定\n  const cardScale = interpolate(frame, [0, 14], [0.6, 1], {\n    easing: Easing.out(Easing.back(1.8)),\n    extrapolateRight: 'clamp',\n  });\n  const cardOp = interpolate(frame, [0, 8], [0, 1], { extrapolateRight: 'clamp' });\n\n  const renderGun = (gun: Confetto[], origin: { x: number; y: number }, keyBase: string) =>\n    gun.map((c, i) => {\n      if (age <= 0) return null;\n      const s = decaySum(age);\n      const x = origin.x + c.vx * s;\n      // 重力：每帧速度 +GRAV 再统一衰减 → 位移闭式 Σ 可整理为 GRAV·(age - s·d)/(1-d) 的近似；\n      // 直接用精确序列和：g 项位移 = GRAV · Σ_{k=1..age} (1-d^k)/(1-d) = GRAV·(age-(d-d^{age+1})/(1-d))/(1-d)\n      const gDisp = (GRAV * (age - (DECAY - Math.pow(DECAY, age + 1)) / (1 - DECAY))) / (1 - DECAY);\n      const y = origin.y + c.vy * s + gDisp;\n      // 落出画外即卸载（左右也裁）\n      if (y > 1140 || x < -80 || x > 2000) return null;\n      const rot = c.phase + c.spin * age;\n      return (\n        <div\n          key={`${keyBase}${i}`}\n          style={{\n            position: 'absolute', left: x, top: y, width: c.w, height: c.h,\n            background: c.amber ? AMBER : c.shade,\n            borderRadius: 2,\n            // rotateX 造翻牌式 3D 翻转（宽度不变、高度压扁），再加平面 rotate\n            transform: `rotate(${rot}deg) rotateX(${rot * 2.3}deg)`,\n          }}\n        />\n      );\n    });\n\n  return (\n    <div style={{ width: 1920, height: 1080, background: G.bg, position: 'relative', overflow: 'hidden' }}>\n      <div style={{ position: 'absolute', top: 110, width: '100%', textAlign: 'center' }}>\n        <TitleBlock text=\"CONFETTI CROSSFIRE\" size={72} />\n      </div>\n\n      {/* 中央 KPI 卡 */}\n      <div style={{\n        position: 'absolute', left: 660, top: 400, width: 600, height: 320,\n        background: G.card, border: `2px solid ${G.border}`, borderRadius: 16,\n        boxSizing: 'border-box', padding: 36, boxShadow: '0 4px 14px rgba(0,0,0,0.08)',\n        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',\n        transform: `scale(${cardScale})`, opacity: cardOp,\n      }}>\n        <div style={{ height: 14, width: 220, background: G.bar, borderRadius: 7 }} />\n        <div style={{\n          fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800, fontSize: 150,\n          color: AMBER, letterSpacing: -3, lineHeight: 1.1, fontVariantNumeric: 'tabular-nums',\n        }}>\n          98.5%\n        </div>\n        <div style={{ height: 10, width: 150, background: G.line, borderRadius: 5 }} />\n      </div>\n\n      {renderGun(LEFT_GUN, LEFT_POS, 'L')}\n      {renderGun(RIGHT_GUN, RIGHT_POS, 'R')}\n    </div>\n  );\n};\n\n\n// ===== demos/data/particle-celebrate-hits/CounterTickSparks.tsx =====\n// counter-tick-sparks —— 数字跳动溅火\n// 中央大计数器 0 → 12,847（easeOut 逐位跳动），每逢跳过整千的 tick 帧，\n// 数字顶部溅 6–10 颗大火星（初速向上 9–13px/f、重力下坠 14–18f 内坠灭）；\n// 终值揭晓那跳翻倍 20 颗 + 数字弹 1.1x 回落。\n// 结尾所有火星寿命耗尽条件卸载，真静止 ≥40f。\n// 帧确定性：tick 帧从同一 easeOut 曲线预解析（模块级求出），火星 = 纯 age 闭式弹道。\nimport React from 'react';\nimport { useCurrentFrame, interpolate, Easing } from 'remotion';\nimport { G, TitleBlock } from '../../_fixtures/Fixtures';\n\nconst AMBER = '#b45309';\nconst TARGET = 12847;\nconst COUNT_END = 78; // 计数结束帧\nconst easeOutCubic = (t: number) => 1 - Math.pow(1 - t, 3);\nconst valueAt = (f: number) => Math.round(TARGET * easeOutCubic(Math.min(Math.max(f / COUNT_END, 0), 1)));\n\nconst frac = (x: number) => x - Math.floor(x);\nconst rnd = (i: number, salt: number) => frac(Math.sin(i * 12.9898 + salt * 78.233) * 43758.5453);\n\n// 预解析 tick 帧：跳过整千的那一帧 + 终值揭晓帧\nconst TICKS: { f: number; big: boolean }[] = (() => {\n  const out: { f: number; big: boolean }[] = [];\n  let prev = 0;\n  for (let f = 1; f <= COUNT_END; f++) {\n    const v = valueAt(f);\n    if (Math.floor(v / 1000) > Math.floor(prev / 1000)) out.push({ f, big: false });\n    prev = v;\n  }\n  out.push({ f: COUNT_END, big: true }); // 终值揭晓\n  return out;\n})();\n\nconst SPARK_LIFE = 18;\nconst GRAV = 0.9;\n\nexport const CounterTickSparks: React.FC = () => {\n  const frame = useCurrentFrame();\n  const value = valueAt(frame);\n\n  // 终值揭晓弹 1.1x 回落\n  const popScale =\n    1 +\n    0.1 *\n      interpolate(frame, [COUNT_END, COUNT_END + 5, COUNT_END + 16], [0, 1, 0], {\n        easing: Easing.out(Easing.quad),\n        extrapolateLeft: 'clamp',\n        extrapolateRight: 'clamp',\n      });\n\n  const TOP_Y = 452; // 数字顶缘（火星发射线）\n  const CX = 960;\n\n  return (\n    <div style={{ width: 1920, height: 1080, background: G.bg, position: 'relative', overflow: 'hidden' }}>\n      <div style={{ position: 'absolute', top: 110, width: '100%', textAlign: 'center' }}>\n        <TitleBlock text=\"COUNTER TICK SPARKS\" size={72} />\n      </div>\n\n      {/* 计数卡 */}\n      <div style={{\n        position: 'absolute', left: 560, top: 400, width: 800, height: 330,\n        background: G.card, border: `2px solid ${G.border}`, borderRadius: 16,\n        boxSizing: 'border-box', boxShadow: '0 4px 14px rgba(0,0,0,0.08)',\n        display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 20,\n      }}>\n        <div style={{ height: 14, width: 220, background: G.bar, borderRadius: 7 }} />\n        <div style={{\n          fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800, fontSize: 168,\n          color: G.ink, letterSpacing: -3, lineHeight: 1, fontVariantNumeric: 'tabular-nums',\n          transform: `scale(${popScale})`,\n        }}>\n          {value.toLocaleString('en-US')}\n        </div>\n        <div style={{ height: 10, width: 150, background: G.line, borderRadius: 5 }} />\n      </div>\n\n      {/* 火星：每个 tick 一簇，寿命耗尽条件卸载 */}\n      {TICKS.map((tick, t) => {\n        const age = frame - tick.f;\n        if (age <= 0 || age >= SPARK_LIFE) return null;\n        const n = tick.big ? 20 : 6 + Math.floor(rnd(t, 21) * 5); // 6–10，终跳 20\n        return Array.from({ length: n }).map((_, i) => {\n          const salt = t * 31 + i;\n          // 初速：向上 9–13px/f，水平 ±4.5px/f 扇形铺开（终跳更宽）\n          const vy = -(9 + rnd(salt, 2) * 4);\n          const vx = (rnd(salt, 3) - 0.5) * (tick.big ? 13 : 9);\n          const x0 = CX + (rnd(salt, 4) - 0.5) * (tick.big ? 560 : 380); // 沿数字顶缘散布\n          const x = x0 + vx * age;\n          const y = TOP_Y + vy * age + 0.5 * GRAV * age * age;\n          const life = 1 - age / SPARK_LIFE;\n          const size = (tick.big ? 7 : 5.5) * (0.4 + 0.6 * life);\n          return (\n            <div key={`${t}-${i}`} style={{\n              position: 'absolute', left: x, top: y, width: size, height: size,\n              borderRadius: size / 2, background: AMBER, opacity: life,\n              boxShadow: `0 0 ${6 * life}px ${AMBER}`,\n            }} />\n          );\n        });\n      })}\n    </div>\n  );\n};\n\n\n"
}