{
  "slug": "circle-match-iris",
  "title": "Circle Match Iris Cut",
  "tagline": "An iris wipe bursts open from the center of a circular element on the page, and the new page's circular chart lands on that same circle inside the ring — a match cut that gives the iris a semantic anchor.",
  "description": "An iris wipe bursts open from the center of a circular element on the page, and the new page's circular chart lands on that same circle inside the ring — a ma",
  "category": "transition",
  "tags": [
    "shotcraft",
    "remotion",
    "transition"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 4.72,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/circle-match-iris/poster-eefd66910f81.jpg",
    "video": "https://gimgs.net/motion/media/circle-match-iris/video-b8e14bb985d8.mp4"
  },
  "variants": [
    {
      "key": "circle-match-iris",
      "label": "circle-match-iris",
      "description": "An iris wipe bursts open from the center of a circular element on the page, and the new page's circular chart lands on that same circle inside the ring — a match cut that gives the iris a semantic anchor.",
      "use": "",
      "video": "https://gimgs.net/motion/media/circle-match-iris/video-b8e14bb985d8.mp4",
      "poster": "https://gimgs.net/motion/media/circle-match-iris/poster-eefd66910f81.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/circle-match-iris.html",
  "json_url": "https://gimgs.net/motion/circle-match-iris.json",
  "template_url": "https://gimgs.net/motion/circle-match-iris/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/circle-match-iris.md",
  "updated_at": "2026-09-24T07:39:50.017Z",
  "prompt": "## When to use\n\nCuts where the foreground has a circular element (avatar/icon/round button) and the background has a circular subject (donut chart/circular progress); a transition-technique card.\n\n## Intention\n\nA plain iris wipe (the vocabulary's original entry) opens from any arbitrary point, and viewers only read \"page changed.\" This card welds it to a match cut: the iris must burst from the center of a real circular element in the foreground, and the new page that grows inside it has a circular chart that lands right on that same circle — what viewers see is \"the avatar's circle became the chart's circle,\" and the circle itself carries the narrative (this person → this person's data). This is a match cut's UI-native form: it doesn't rely on compositional coincidence, it relies on circle-to-circle semantics. Same family as the crossing-three-style cards (\"the background is already present inside the foreground\"), but the anchor here is an abstract shape rather than a container.\n\n## Duration & energy\n\n- Duration: 4.7s (anchor pulse 30f + iris expansion 45f + chart growth 55f + hold 40f).\n- Energy: Medium-high\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n普通 iris 光圈（词汇表原条目）从任意点开圈，观众只读到\"换页了\"。\n本卡把它和 match-cut 焊死：光圈必须从前景一个**真实圆形元素**的圆心\n炸开，圈内长出的新页里有个圆形图表恰好接在同一个圆上——观众看到的\n是\"头像的圆变成了图表的圆\"，圆形本身完成叙事（这个人→这个人的数据）。\n这是 match-cut 在 UI 语境里的落地形态：不靠构图巧合，靠语义圆对圆。\n与穿越三式同属\"后景在前景里预先在场\"，但锚点是抽象形状而非容器。\n\n## 动效核心\n- **两景的圆严格同心是命门**：锚点圆心写死为一组常量（demo：CX=308,\n  CY=384.8，手算自布局），clip-path circle、新景圆环、脉冲光环全部\n  引用同一组常量——坐标各算各的必然错心\n- 定睛引导：光圈开圈前锚点元素两次放大脉冲 + 两道扩散光环（30f），\n  把视线钉在圆上再动\n- 光圈 `clip-path: circle(r at x y)`，r 22→2100px（45f inOut cubic）；\n  新景圆环同窗从 22→170px 生长——**接圆发生在扩张中途**（观众在\n  光圈还没吃满屏时就看到圆被接住了），不是开完圈再画图\n- 圆环 sweep：strokeDasharray = sweep×周长，rotate(-90) 从顶部起笔，\n  中央数字随 sweep 同步计数\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 锚点脉冲 | 2 次 scale 1→1.15 + 光环扩两道 / 30f | 没有定睛引导，开圈瞬间观众找不到圆心 |\n| 光圈扩张 | 22→2100px / 45f Easing.inOut(cubic) | 快于 30f 接圆读不到；圆心偏移 >10px 一眼穿帮 |\n| 接圆时机 | 圆环在光圈扩张中途（~40% 进度）即可见并同心生长 | 开完圈再长图表 = 普通转场+普通图表动画，匹配感归零 |\n| sweep | 45–100f 到目标值，数字同步计数 | 数字与 sweep 异步读作两个动画 |\n| 收尾 | sweep 完成后真静止 ≥40f | R1 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- 真实素材上锚点坐标从截图量测（devtools/标尺），量到的是元素盒不是\n  视觉圆心——带内边距的头像取内容圆心；量完渲一帧对不齐就调常量，\n  别信第一次量的数（同 transition-travel C 腔心判例）\n- 前景锚点必须是**真圆**：圆角方块（border-radius < 50%）当锚点，\n  开圈瞬间方圆错型一眼假；demo 里就是用白补丁把方块盖成真圆的\n- 语义要成对：头像→个人数据、产品图标→产品指标——圆对圆只是形，\n  \"这个圆的内容变成它的展开\"才是匹配剪辑成立的因\n- 一缝一式规矩同 shot-transitions；本卡自带图表生长段，圈内新景\n  别再叠入场动效\n\n## 参考实现\ndemos/transition/circle-match-iris/\n（CircleMatchIris.tsx）\n\n## Reference implementation (Remotion)\n\n- demos/transition/circle-match-iris/CircleMatchIris.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/circle-match-iris/CircleMatchIris.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/circle-match-iris/CircleMatchIris.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/circle-match-iris/CircleMatchIris.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/circle-match-iris/CircleMatchIris.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": 142,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/transition/circle-match-iris/CircleMatchIris.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/circle-match-iris/CircleMatchIris.tsx =====\n// 圆心匹配光圈切(match-cut × iris-reveal 组合):\n// 帧 0–30:景 A(列表面板)hold,第 2 行 44px 圆形头像做两次脉冲 + 扩散光环提示\"看这里\";\n// 帧 30–75:景 B 以 clip-path: circle(r at CX CY) 从 22px 炸开到 2100px(Easing.inOut(cubic)),\n//   景 B 是深色圆环图表页,圆环半径同步从 22px 长到 170px——在光圈吃满全屏前就\"接住\"头像的圆;\n// 帧 45–100:圆环描边 sweep 到 78%,中央大数字随之浮现计数;帧 100–140 全属性静止收尾(≥35f)。\n// 命门:两景的圆严格同心——CX/CY 写死为 FakeDashboard B 第 2 行头像的屏幕坐标常量。\nimport React from 'react';\nimport { useCurrentFrame, interpolate, Easing } from 'remotion';\nimport { FakeDashboard, G } from '../../_fixtures/Fixtures';\n\n// FakeDashboard variant B 第 2 行左侧 44px 头像的圆心(手算自 fixture 布局)\nconst CX = 308;\nconst CY = 384.8;\n\nexport const CircleMatchIris: React.FC = () => {\n  const f = useCurrentFrame();\n\n  // ---- 景 A:头像脉冲(帧 0–30,两次呼吸) ----\n  const pulseT = Math.min(f, 30) / 30;\n  const scale = f < 30 ? 1 + 0.45 * Math.abs(Math.sin(pulseT * Math.PI * 2)) : 1;\n  // 两道扩散光环\n  const waves = [0, 14].map((start) => {\n    const p = interpolate(f, [start, start + 16], [0, 1], {\n      extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n    });\n    return { r: 22 + p * 40, o: f < start + 16 ? 0.85 * (1 - p) : 0 };\n  });\n\n  // ---- 光圈:景 B 从同一圆心炸开 ----\n  const irisR = interpolate(f, [30, 75], [22, 2100], {\n    easing: Easing.inOut(Easing.cubic),\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n\n  // ---- 景 B 圆环:半径从 22 长到 170,\"接住\"头像的圆 ----\n  const ringR = interpolate(f, [30, 70], [22, 170], {\n    easing: Easing.inOut(Easing.cubic),\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n  const ringW = interpolate(f, [30, 70], [12, 40], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n  // 描边 sweep 到 78%\n  const sweep = interpolate(f, [45, 100], [0, 0.78], {\n    easing: Easing.out(Easing.cubic),\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n  const circ = 2 * Math.PI * ringR;\n  const num = Math.round(sweep * 100);\n  const numOpacity = interpolate(f, [68, 88], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n  const furnitureOpacity = interpolate(f, [60, 85], [0, 1], {\n    extrapolateLeft: 'clamp', extrapolateRight: 'clamp',\n  });\n\n  return (\n    <div style={{ width: 1920, height: 1080, position: 'relative', overflow: 'hidden', background: G.bg }}>\n      {/* ===== 景 A:列表面板 ===== */}\n      <FakeDashboard variant=\"B\" />\n      {/* 白色补丁盖住 fixture 自带的圆角方块,再叠真正的圆形头像 */}\n      <div style={{ position: 'absolute', left: CX - 23, top: CY - 23, width: 46, height: 46, background: G.card }} />\n      <div style={{\n        position: 'absolute', left: CX - 22, top: CY - 22, width: 44, height: 44,\n        borderRadius: 22, background: G.mid, border: `3px solid ${G.ink}`,\n        boxSizing: 'border-box', transform: `scale(${scale})`,\n      }} />\n      {/* 脉冲扩散光环 */}\n      <svg width={1920} height={1080} style={{ position: 'absolute', left: 0, top: 0 }}>\n        {waves.map((w, i) => (\n          <circle key={i} cx={CX} cy={CY} r={w.r} fill=\"none\" stroke={G.ink} strokeWidth={4} opacity={w.o} />\n        ))}\n      </svg>\n\n      {/* ===== 景 B:深色圆环图表页,从同一圆心以光圈长出 ===== */}\n      {f >= 30 && (\n        <div style={{\n          position: 'absolute', left: 0, top: 0, width: 1920, height: 1080,\n          background: G.ink,\n          clipPath: `circle(${irisR}px at ${CX}px ${CY}px)`,\n        }}>\n          {/* 圆环 donut:圆心与头像严格同点 */}\n          <svg width={1920} height={1080} style={{ position: 'absolute', left: 0, top: 0 }}>\n            {/* 底轨 */}\n            <circle cx={CX} cy={CY} r={ringR} fill=\"none\" stroke=\"#5a5a58\" strokeWidth={ringW} />\n            {/* sweep 弧,从正上方起 */}\n            <circle\n              cx={CX} cy={CY} r={ringR} fill=\"none\" stroke=\"#ececea\"\n              strokeWidth={ringW} strokeLinecap=\"round\"\n              strokeDasharray={`${sweep * circ} ${circ}`}\n              transform={`rotate(-90 ${CX} ${CY})`}\n            />\n          </svg>\n          {/* 中央大数字 */}\n          <div style={{\n            position: 'absolute', left: CX - 150, top: CY - 80, width: 300, height: 160,\n            display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center',\n            opacity: numOpacity,\n          }}>\n            <div style={{ fontFamily: 'Helvetica, Arial, sans-serif', fontWeight: 800, fontSize: 96, color: '#f2f2f0', letterSpacing: -2 }}>\n              {num}%\n            </div>\n            <div style={{ marginTop: 6, height: 12, width: 130, background: '#6a6a68', borderRadius: 6 }} />\n          </div>\n          {/* 右侧页面家具:标题 + 统计条,证明这是一整页 */}\n          <div style={{ position: 'absolute', left: 680, top: 260, opacity: furnitureOpacity, display: 'flex', flexDirection: 'column', gap: 30 }}>\n            <div style={{ height: 34, width: 520, background: '#c2c2c0', borderRadius: 10 }} />\n            <div style={{ height: 16, width: 780, background: '#5a5a58', borderRadius: 8 }} />\n            <div style={{ height: 16, width: 640, background: '#5a5a58', borderRadius: 8 }} />\n            <div style={{ height: 16, width: 700, background: '#5a5a58', borderRadius: 8 }} />\n            <div style={{ display: 'flex', gap: 28, marginTop: 24 }}>\n              {[0, 1, 2].map((i) => (\n                <div key={i} style={{ width: 240, height: 150, background: '#454543', border: '2px solid #5a5a58', borderRadius: 14, padding: 20, boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 12 }}>\n                  <div style={{ height: 12, width: `${55 + i * 12}%`, background: '#8f8f8d', borderRadius: 6 }} />\n                  <div style={{ height: 30, width: '45%', background: '#c2c2c0', borderRadius: 8, marginTop: 'auto' }} />\n                </div>\n              ))}\n            </div>\n          </div>\n        </div>\n      )}\n    </div>\n  );\n};\n\n\n"
}