{
  "slug": "particle-sand-fill",
  "title": "Particle sand-fill bars",
  "tagline": "Bar charts don't grow tall — they \"rain into being\": square particles fall one by one and pile up into bars, then solidify.",
  "description": "Bar charts don't grow tall — they \"rain into being\": square particles fall one by one and pile up into bars, then solidify.",
  "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-sand-fill/poster-41c6902f83ce.jpg",
    "video": "https://gimgs.net/motion/media/particle-sand-fill/video-a5aff1a7449a.mp4"
  },
  "variants": [
    {
      "key": "particle-sand-fill",
      "label": "particle-sand-fill",
      "description": "Particles fall into a funnel to form a bar — bar charts don't grow tall, they \"rain into being\": square-dot particles fall one by one, pile up into a bar, and once full solidify into a solid shape with the value popping up.",
      "use": "",
      "video": "https://gimgs.net/motion/media/particle-sand-fill/video-a5aff1a7449a.mp4",
      "poster": "https://gimgs.net/motion/media/particle-sand-fill/poster-41c6902f83ce.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/particle-sand-fill.html",
  "json_url": "https://gimgs.net/motion/particle-sand-fill.json",
  "template_url": "https://gimgs.net/motion/particle-sand-fill/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/particle-sand-fill.md",
  "updated_at": "2026-09-24T07:40:14.085Z",
  "prompt": "## When to use\n\nBar chart / magnitude-comparison entrance; data sections about \"accumulation/convergence\" semantics\n\n## Intention\n\nSwaps the bar-chart entrance from a \"growing taller\" animation to \"falling accumulation\": a rain of square particles falls above each bar, falling one by one under gravity acceleration, stopping and bouncing back 15% the instant they hit the pile, layering up until the target height is reached, then the particle surface fades out and is replaced by a solid bar with the value label popping up top. The data's \"quantity\" becomes something you can see \"being piled up one grain at a time\" — division of labor with unit-dot-swarm-regroup (the same batch of dots changing formation): that one is dots reshaping in a plane, this is falling material building up a shape.\n\n## Duration & energy\n\n- Duration: 4–5s\n- Energy: Medium-high (a constructive-feeling entrance)\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n把柱状图入场从\"长高动画\"换成\"落体堆积\"：每根柱上方下一场方块雨，\n粒子逐颗坠落（重力加速）、触堆积面即停+15% 回弹，一层压一层堆到目标\n高度后粒子面淡出换实体柱、顶部数值标签弹出。数据的\"量\"变成看得见的\n\"一颗一颗攒出来\"——与 unit-dot-swarm-regroup（点群平面迁徙聚形）\n分工：那是同一批点改队形，这是落体材料筑成图形。\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 粒径 | 14px 方点 | 词汇原案 4px 不可感，QA 中加码 3.5 倍 |\n| 每柱粒子数 | ~50 颗 | 太少堆积感稀疏，太多单颗不可辨 |\n| 坠落 | 重力加速，落地帧闭式预解析 | 堆积高度=已落数×粒径，禁真碰撞模拟 |\n| 回弹 | 触面 15% 单次回弹 | 无回弹读作贴图；多次回弹拖节奏 |\n| 柱间错峰 | 6f 逐柱启动 | 齐落读作机械；错峰有\"雨势推移\"感 |\n| 收束 | 堆满→粒子面淡出→实体柱+标签 back-out 弹出 | 粒子态直接定格显脏，必须凝成实体 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 落地帧全部闭式预解析（每颗粒子的落点/落帧编译期可算），\n  帧确定性是渲染正确的前提——禁逐帧状态累积\n- 只适合 3–5 根柱的量级对比；十几根柱的密集图表用常规入场\n  （雨帘太多读作噪声）\n- 与\"碎块解体\"否决判例的界限：这是构筑（从无到有堆出图形），\n  不是破坏（把完整图形打碎）——方向语义相反，别做反向播放变体\n- 声音：细密颗粒落地声铺底，每柱堆满一声轻\"叩\"，标签弹出一声 tick\n\n## 参考实现\ndemos/data/particle-sand-fill/\n（ParticleSandFill.tsx）\n\n## Reference implementation (Remotion)\n\n- demos/data/particle-sand-fill/ParticleSandFill.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/particle-sand-fill/ParticleSandFill.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-sand-fill/ParticleSandFill.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-sand-fill/ParticleSandFill.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/data/particle-sand-fill/ParticleSandFill.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-sand-fill/ParticleSandFill.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-sand-fill/ParticleSandFill.tsx =====\n// particle-sand-fill —— 粒子落斗成柱\n// 图表卡内 4 根柱，每根柱上方\"下雨\"：14px 方点错峰坠落（重力加速），触堆积面即停\n// + 15% 回弹一下，逐层堆高——堆积高度闭式预解析（第 k 层顶面 = 基线 - (k+1)×粒径，\n// 无真碰撞）。各柱错峰 6f 启动；堆满后粒子面淡出换实体柱 + 顶部数值标签弹出。\n// 结尾全部粒子条件卸载、只剩实体柱 + 标签，真静止 ≥35f。\n// 帧确定性：sin 散列派生每颗出发帧抖动/起点错高，落地帧由高度差闭式反解。\nimport React from 'react';\nimport { useCurrentFrame, interpolate, Easing } from 'remotion';\nimport { G, TitleBlock } from '../../_fixtures/Fixtures';\n\nconst AMBER = '#b45309';\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\nconst CARD = { x: 460, y: 300, w: 1000, h: 560 };\nconst PLOT_BOTTOM = CARD.y + CARD.h - 70; // 堆积地面（卡内基线）\nconst GRAIN = 14; // 方点边长（宁大勿小：4px 在 1080p 卡内不可感，加码到 14）\nconst PER_LAYER = 9; // 每层 9 颗 → 柱宽 126px\nconst BAR_W = GRAIN * PER_LAYER;\nconst DROP_FROM = 230; // 距各自落点上方 ~230px 起落\nconst GRAV = 1.6; // px/f²\nconst STAGGER = 6; // 各柱错峰启动\nconst RATE = 0.28; // 颗间出发间隔（帧）——最高柱 216 颗需 ~60f 发完，全局 f120 内收束\n\nconst BARS = [\n  { cx: CARD.x + 175, h: 238, label: '238' },\n  { cx: CARD.x + 395, h: 336, label: '336' },\n  { cx: CARD.x + 615, h: 182, label: '182' },\n  { cx: CARD.x + 835, h: 294, label: '294' },\n].map((b) => ({ ...b, layers: Math.round(b.h / GRAIN), n: Math.round(b.h / GRAIN) * PER_LAYER }));\n\nconst fallTime = (dist: number) => Math.sqrt((2 * dist) / GRAV);\nconst departOf = (bar: number, i: number) => 8 + bar * STAGGER + i * RATE + rnd(i, bar * 7 + 1) * 1.5;\n\nexport const ParticleSandFill: React.FC = () => {\n  const frame = useCurrentFrame();\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=\"PARTICLE SAND FILL\" size={72} />\n      </div>\n\n      {/* 图表卡 */}\n      <div style={{\n        position: 'absolute', left: CARD.x, top: CARD.y, width: CARD.w, height: CARD.h,\n        background: G.card, border: `2px solid ${G.border}`, borderRadius: 14,\n        boxSizing: 'border-box', padding: 28, boxShadow: '0 2px 8px rgba(0,0,0,0.06)',\n      }}>\n        <div style={{ height: 14, width: 260, background: G.bar, borderRadius: 7 }} />\n        <div style={{ height: 10, width: 160, background: G.line, borderRadius: 5, marginTop: 10 }} />\n      </div>\n      {/* 基线 */}\n      <div style={{ position: 'absolute', left: CARD.x + 40, top: PLOT_BOTTOM, width: CARD.w - 80, height: 3, background: G.line }} />\n\n      {BARS.map((bar, b) => {\n        const left = bar.cx - BAR_W / 2;\n        // 末颗落地帧（闭式）：末颗落点在堆顶，坠距仍 ≈DROP_FROM\n        const lastLand = departOf(b, bar.n - 1) + fallTime(DROP_FROM);\n        const doneAt = lastLand + 7; // 回弹收完 → 开始交接\n        const solidOp = interpolate(frame, [doneAt, doneAt + 10], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });\n        const labelScale = interpolate(frame, [doneAt + 6, doneAt + 18], [0, 1], {\n          easing: Easing.out(Easing.back(2.2)),\n          extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n        });\n\n        return (\n          <React.Fragment key={b}>\n            {solidOp > 0 && (\n              <div style={{\n                position: 'absolute', left, top: PLOT_BOTTOM - bar.h,\n                width: BAR_W, height: bar.h, background: b === 1 ? AMBER : G.bar,\n                borderRadius: '6px 6px 0 0', opacity: solidOp,\n              }} />\n            )}\n            {labelScale > 0 && (\n              <div style={{\n                position: 'absolute', left: bar.cx - 70, top: PLOT_BOTTOM - bar.h - 62, width: 140,\n                textAlign: 'center', fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800,\n                fontSize: 46, color: b === 1 ? AMBER : G.ink,\n                transform: `scale(${labelScale})`,\n              }}>\n                {bar.label}\n              </div>\n            )}\n            {/* 粒子面：交接完成（solidOp=1）即整体条件卸载 → 真静止 */}\n            {solidOp < 1 && Array.from({ length: bar.n }).map((_, i) => {\n              const depart = departOf(b, i);\n              const age = frame - depart;\n              if (age <= 0) return null;\n              const layer = Math.floor(i / PER_LAYER);\n              const col = i % PER_LAYER;\n              const targetTop = PLOT_BOTTOM - (layer + 1) * GRAIN; // 闭式堆积面\n              const startTop = targetTop - DROP_FROM - rnd(i, b * 13 + 3) * 70;\n              const dist = targetTop - startTop;\n              const tLand = fallTime(dist);\n              let top: number;\n              if (age < tLand) {\n                top = startTop + 0.5 * GRAV * age * age;\n              } else {\n                const ba = age - tLand;\n                const bounce = ba < 6 ? Math.sin((ba / 6) * Math.PI) * GRAIN * 2 * 0.15 * (1 + rnd(i, b * 13 + 9)) : 0;\n                top = targetTop - bounce;\n              }\n              const amber = b === 1 || rnd(i, b * 13 + 7) < 0.18;\n              return (\n                <div key={i} style={{\n                  position: 'absolute', left: left + col * GRAIN + 1, top, width: GRAIN - 2, height: GRAIN - 2,\n                  background: amber ? AMBER : G.mid, opacity: 1 - solidOp,\n                  borderRadius: 2,\n                }} />\n              );\n            })}\n          </React.Fragment>\n        );\n      })}\n    </div>\n  );\n};\n\n\n"
}