{
  "slug": "type-and-filter",
  "title": "Type, filter, drill in",
  "tagline": "Typing a search on a real UI, the grid converges to a single card on its own, then a click drills through into the detail page.",
  "description": "Typing a search on a real UI, the grid converges to a single card on its own, then a click drills through into the detail page.",
  "category": "interaction",
  "tags": [
    "shotcraft",
    "remotion",
    "interaction"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 960,
    "height": 540,
    "fps": 30,
    "duration": 2.4,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/type-and-filter/poster-7bd9a85e03f7.jpg",
    "video": "https://gimgs.net/motion/media/type-and-filter/video-f27f2965be52.mp4"
  },
  "variants": [
    {
      "key": "type-and-filter",
      "label": "type-and-filter",
      "description": "Typing a search on a real UI, the grid converges to a single card on its own, then a click drills through into the detail page",
      "use": "",
      "video": "https://gimgs.net/motion/media/type-and-filter/video-f27f2965be52.mp4",
      "poster": "https://gimgs.net/motion/media/type-and-filter/poster-7bd9a85e03f7.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/type-and-filter.html",
  "json_url": "https://gimgs.net/motion/type-and-filter.json",
  "template_url": "https://gimgs.net/motion/type-and-filter/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/type-and-filter.md",
  "updated_at": "2026-09-24T07:40:30.365Z",
  "prompt": "## When to use\n\nThe \"operation narrative\" passage of a feature demo; any search/filter/drill-into-detail interaction chain\n\n## Intention\n\nLet the viewer \"follow along\": see exactly what was typed, how the page responds, where the click landed. This is the only shot in the whole film that simulates a real person operating the UI, so the pacing must feel human-handed, never scripted.\n\n## Duration & energy\n\n- Duration: ~2.5s (118–190f)\n- Energy: Medium (a composed beat following a high-energy deal-out passage)\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n让观众\"跟着做一遍\"：看清输入了什么、页面怎么响应、点了哪里。这是全片唯一模拟真人操作的镜头，节奏必须像人手，不能像脚本。\n\n## 动效核心\n- 相机上移到搜索框，逐字符打字，光标常亮→闪烁\n- 打完留一口气，然后非目标卡按阅读序错峰淡出下沉，网格收敛\n- 目标卡滑到首行槽位，途中浮起 + 阴影变宽\n- 双圈强调色（模板片为琥珀）ripple 点击确认，相机推进穿透进详情页交棒转场\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 打字速度 | 3f/字符（TYPE_START=128，\"nano-lab\" 8 字符 ≈149 收尾） | 初版嫌快返工后的定稿值（\"unhurried\"）；交互演示按真人操作速度走（R3） |\n| 呼吸位 | 打完到过滤 FILTER_START=160，留 ~11f（0.37s） | 打完立刻过滤读作机器自动，观众跟不上因果 |\n| 光标 | 打字时常亮，之后 8f 周期闪烁；点击 176f，光标 185f 消失 | 打字中闪烁会读作卡顿；常亮→闪烁的切换本身就是\"打完了\"的信号 |\n| 过滤退场 | 25 张非目标卡按阅读序 0.4f 间隔错峰，各 5f 淡出+下沉 8px | 同时消失读作页面崩溃；错峰哪怕只差 0.4f 也够 |\n| 目标卡滑位 | 10f 滑到首行槽位，bezier(0.35,0,0.2,1)，途中 sin 浮起 z 18px、scale +0.02、阴影随浮起变宽 | 终点必须是布局真实槽位（Q9），滑到画面中央悬停就假了 |\n| 点击确认 | 两圈同心强调色 ripple（起点差 3f、各 10f、半径 14→54/78）+ 3px 描边 + 40px 辉光，随后相机 16f 推进 zoom 2.2 | 单圈 ripple 太轻看不见；推进交棒给 flash-cut 盖住换页硬切（组件见 assets/lib/FlashCut.tsx，用法：from = 切点 − 5，跨骑硬切两侧各 5f） |\n| 搜索框 | 页面底色补丁盖掉纹理里的 placeholder（保留放大镜图标），文字层叠上去打 | 直接在截图纹理上叠字会和烤入的 placeholder 重影 |\n\n## 声音\n打字段钉 keyboard.mp3 并按 Sequence 截 24f 与动作严格等长（S4）；点击钉 click-camera（全片最响 vol 0.6，S2 响度分层）；网格飞走钉 whoosh-fast。\n\n## 已知坑\n- 初版打字+过滤\"太快了\"被返工（R3）——交互镜头的第一版几乎总是偏快，起稿就按 3f/字符\n- 过滤后目标卡若不归位到真实槽位、悬浮在网格上方，读作假（Q9，曾因此近乎整文件重写）\n\n## 参考实现\ndemos/interaction/type-and-filter/TypeAndFilter.tsx（原 template/src/aifl/live/SceneFlyIn.tsx 118–190 段）\n\n## Reference implementation (Remotion)\n\n- demos/interaction/type-and-filter/TypeAndFilter.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/type-and-filter/TypeAndFilter.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/interaction/type-and-filter/TypeAndFilter.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/interaction/type-and-filter/TypeAndFilter.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/type-and-filter/TypeAndFilter.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": 72,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/interaction/type-and-filter/TypeAndFilter.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/interaction/type-and-filter/TypeAndFilter.tsx =====\n// type-and-filter —— 真实 UI 上打字搜索、网格自己收敛成一张卡、点击穿透进详情页\n// 功能演示的\"操作叙事\"段：让观众\"跟着做一遍\"。节奏必须像人手，不能像脚本。\n// 参考实现从 template SceneFlyIn 118–190 段剥离（self-contained）：\n// 相机移到搜索框，逐字符打字（3f/字符，光标常亮→闪烁），打完留一口气（11f），\n// 25 张非目标卡按阅读序错峰淡出+下沉，目标卡滑到首行槽位（途中浮起+阴影变宽），\n// 双圈强调色 ripple 点击确认 + 3px 描边 + 辉光，相机 16f 推进 zoom 2.2 交棒。\n// 网格卡用真实纹理裁片（复用 projects-empty.png 里已有的卡），nano-lab 目标卡\n// 过滤后滑到首行槽位。缺的 float-* 小块用纯色占位，不影响动效读感。\nimport { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';\nimport { PageCam2D, CamKey2D } from '../../_fixtures/PageCam2D';\nimport layout from '../../_textures/live-layout.json';\n\nexport const TYPE_AND_FILTER_DURATION = 73; // 118–190f 落在 shot 内 offset 0\n\nconst PAGE_H = layout.projects.pageH;\nconst cards = layout.projects.cards;\nconst PAPER = '#f9f6f1';\nconst FIELD = '#fefcf9';\nconst AMBER = 'oklch(58% 0.13 65)';\nconst QUERY = 'nano-lab';\nconst TYPE_START = 10; // 118 - 118 = 0 偏移基准：scene-local 从 118 起\nconst FILTER_START = 42; // 160 - 118\n\n// search box (page-space CSS px)\nconst SEARCH = { x: 408, y: 130, w: 1016, h: 44 };\n\n// nano-lab target card (the one that filters to row one)\nconst NANO_TITLE = 'nano-lab';\nconst nanoIdx = cards.findIndex((c) => c.title.includes(NANO_TITLE));\nconst nano = cards[nanoIdx];\nconst NANO_TO = { x: 408, y: 247 }; // first-row slot\nconst CLICK_C = { x: 586, y: 391 }; // click point on the settled result card\n\n// filter departure rank for the non-target cards (reading order)\nconst leaveRank = new Map<number, number>();\ncards.forEach((_, i) => {\n  if (i !== nanoIdx) leaveRank.set(i, leaveRank.size);\n});\n\n// camera: hold through typing/breath/filter/slide, then push into the clicked card\nconst CAM_KEYS: CamKey2D[] = [\n  { frame: 0, cx: 960, cy: 380, zoom: 0.9 },\n  { frame: 56, cx: 960, cy: 380, zoom: 0.9 },\n  { frame: 72, cx: CLICK_C.x, cy: CLICK_C.y, zoom: 2.2 },\n];\n\nexport const TypeAndFilter: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  // typed chars: one every 3 frames (unhurried)\n  const typedCount =\n    frame < TYPE_START ? 0 : Math.min(QUERY.length, Math.floor((frame - TYPE_START) / 3) + 1);\n  // caret: solid while typing, blinks on an 8f cycle after, until the click\n  const caretOn =\n    frame >= TYPE_START - 2 &&\n    frame <= 67 &&\n    (frame <= TYPE_START + 24 || Math.floor((frame - (TYPE_START + 24)) / 8) % 2 === 0);\n\n  return (\n    <AbsoluteFill style={{ backgroundColor: PAPER }}>\n      <PageCam2D src=\"textures/live/projects-empty.png\" pageH={PAGE_H} keys={CAM_KEYS}>\n        {/* ---- 25 non-target cards: staggered fade+sink on filter ---- */}\n        {cards.map((c, i) => {\n          if (i === nanoIdx) return null;\n          const outCue = FILTER_START + leaveRank.get(i)! * 0.4;\n          if (frame >= outCue + 5) return null;\n          const outT = interpolate(frame, [outCue, outCue + 5], [0, 1], {\n            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.inOut(Easing.quad),\n          });\n          return (\n            <div\n              key={`${c.file}-${i}`}\n              style={{\n                position: 'absolute', left: c.x, top: c.y, width: c.w, height: c.h,\n                transform: `translate3d(0px, ${8 * outT}px, 0px)`,\n                opacity: 1 - outT, borderRadius: 16, overflow: 'hidden',\n                boxShadow: '0 2px 6px rgba(60,45,30,.08)',\n              }}\n            >\n              <Img src={staticFile(`textures/live/${c.file}`)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />\n            </div>\n          );\n        })}\n\n        {/* ---- nano-lab: slides up to the first-row slot on filter, floating ---- */}\n        {(() => {\n          const slideT = interpolate(frame, [FILTER_START, FILTER_START + 10], [0, 1], {\n            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.35, 0, 0.2, 1),\n          });\n          const slideDy = (NANO_TO.y - nano.y) * slideT;\n          const slideDx = (NANO_TO.x - nano.x) * slideT;\n          const float = Math.sin(slideT * Math.PI);\n          const slideScale = 1 + 0.02 * float;\n          const slideZ = 18 * float;\n          return (\n            <div\n              style={{\n                position: 'absolute', left: nano.x, top: nano.y, width: nano.w, height: nano.h,\n                transform: `translate3d(${slideDx}px, ${slideDy}px, ${slideZ}px) scale(${slideScale})`,\n                transformOrigin: 'center center',\n                boxShadow: `0 ${2 + 14 * float}px ${6 + 26 * float}px rgba(60,45,30,${0.08 + 0.1 * float})`,\n                borderRadius: 16, overflow: 'hidden', zIndex: 2,\n              }}\n            >\n              <Img src={staticFile(`textures/live/${nano.file}`)} style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }} />\n            </div>\n          );\n        })()}\n\n        {/* ---- search box: paper patch over the placeholder (keeps magnifier), typed text + caret ---- */}\n        {frame >= 0 ? (\n          <div\n            style={{\n              position: 'absolute', left: 440, top: SEARCH.y + 4,\n              width: SEARCH.x + SEARCH.w - 448, height: SEARCH.h - 8,\n              background: FIELD, opacity: 1, pointerEvents: 'none',\n            }}\n          />\n        ) : null}\n        <div\n          style={{\n            position: 'absolute', left: 448, top: SEARCH.y, height: SEARCH.h,\n            display: 'flex', alignItems: 'center',\n            fontFamily: 'ui-sans-serif, system-ui, -apple-system, sans-serif',\n            fontSize: 15, letterSpacing: 0.2, color: 'oklch(25% 0.006 82)', pointerEvents: 'none',\n          }}\n        >\n          <span>{QUERY.slice(0, typedCount)}</span>\n          {caretOn ? (\n            <span style={{ display: 'inline-block', width: 2, height: 20, marginLeft: 2, background: AMBER }} />\n          ) : null}\n        </div>\n\n        {/* ---- click ripple: two concentric amber rings ---- */}\n        {[0, 1].map((r) => {\n          const start = 58 + r * 3; // 176 - 118\n          if (frame < start || frame > start + 10) return null;\n          const t = interpolate(frame, [start, start + 10], [0, 1], {\n            extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.out(Easing.cubic),\n          });\n          const rad = interpolate(t, [0, 1], [14, r === 0 ? 54 : 78]);\n          return (\n            <div\n              key={`ripple-${r}`}\n              style={{\n                position: 'absolute', left: CLICK_C.x - rad, top: CLICK_C.y - rad,\n                width: rad * 2, height: rad * 2, borderRadius: '50%',\n                border: `2px solid ${AMBER}`, opacity: 1 - t, pointerEvents: 'none', zIndex: 5,\n              }}\n            />\n          );\n        })}\n\n        {/* ---- selected-card amber outline, lit from the click ---- */}\n        {frame >= 60 ? (\n          <div\n            style={{\n              position: 'absolute', left: NANO_TO.x - 6, top: NANO_TO.y - 6,\n              width: nano.w + 12, height: nano.h + 12, borderRadius: 16,\n              border: `3px solid ${AMBER}`, boxShadow: '0 0 40px rgba(180,120,50,0.5)',\n              opacity: interpolate(frame, [60, 63], [0.5, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }),\n              pointerEvents: 'none', zIndex: 4,\n            }}\n          />\n        ) : null}\n      </PageCam2D>\n    </AbsoluteFill>\n  );\n};\n\n\n"
}