{
  "slug": "morph-from-primitive",
  "title": "Morph from primitive shape",
  "tagline": "A stroked circle breathes once (anticipation), then its SVG path interpolates over 24f into a rounded card outline, and the content fades in.",
  "description": "A stroked circle breathes once (anticipation), then its SVG path interpolates over 24f into a rounded card outline, and the content fades in.",
  "category": "ui-entrance",
  "tags": [
    "shotcraft",
    "remotion",
    "ui-entrance",
    "opening"
  ],
  "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/morph-from-primitive/poster-f9f4eea44a20.jpg",
    "video": "https://gimgs.net/motion/media/morph-from-primitive/video-c71072b2fa2d.mp4"
  },
  "variants": [
    {
      "key": "morph-from-primitive",
      "label": "morph-from-primitive · Morph from primitive shape",
      "description": "A stroked circle breathes once (anticipation), then its SVG path interpolates over 24f into a rounded card outline, and the content fades in.",
      "use": "",
      "video": "https://gimgs.net/motion/media/morph-from-primitive/video-c71072b2fa2d.mp4",
      "poster": "https://gimgs.net/motion/media/morph-from-primitive/poster-f9f4eea44a20.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/morph-from-primitive.html",
  "json_url": "https://gimgs.net/motion/morph-from-primitive.json",
  "template_url": "https://gimgs.net/motion/morph-from-primitive/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/morph-from-primitive.md",
  "updated_at": "2026-09-24T07:40:07.707Z",
  "prompt": "## When to use\n\nEntrances for graphic/icon/card-outline subjects; the classic primitive for a logo turning into a UI container.\n\n## Intention\n\nThe entrance library is all \"coming in from outside\" — flying in, slamming down, flipping — the subject already exists off-screen beforehand. This card is **growing in place**: a stroked circle at the frame's center takes one breath first, then its outline continuously morphs into a rounded rectangle card, and the content fades in after. The primitive's pause-and-breathe is the anticipation beat — the graphic-design translation of Disney's anticipation principle: the circle \"inhales\" first, so the audience already knows it's about to change, and the transformation itself needs no explanation. Only applies to graphic/icon/card-outline subjects — a bitmap screenshot can't morph.\n\n## Duration & energy\n\n- Duration: ~4.7s (breathing 20f + morph 24f + content fade-in 12f)\n- Energy: Medium-low\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n入场库全是\"从外面进来\"——飞入、砸落、翻面，主体都预先存在于画外；\n本卡是**就地长出来**：画面中心一个描边正圆先鼓一口气（scale\n1→1.12→1），随即轮廓连续变形为圆角矩形卡片，内容再淡入。原型\n停留加呼吸这一拍就是预备动作——迪士尼 anticipation 的图形学翻译：\n圆先\"吸气\"，观众就知道它要变，变形本身因此不需要任何解释。\n仅适用图形/图标/卡轮廓类主体——位图截图不能 morph。\n\n## 动效核心\n- **圆与圆角矩形写成同构 path**：M + 8 段 cubic（4 直边段三等分点\n  做退化 cubic + 4 圆角段 kappa 公式 k=4/3·tan(Δθ/4)），逐命令\n  数值 lerp 生成 d 字符串，帧驱动全确定性\n- 圆锚点取矩形锚点同方位角，保证 t=0 是标准正圆、中途无自交\n- 呼吸拍 scale 1→1.12→1（spec 1.06 看不清，加码后可感——可感性\n  判例同族）；呼吸与变形都用 Easing.inOut(cubic)，interpolate 全 clamp\n- 内容是 HTML 层叠在 SVG 上，变形完成后 12f 淡入，不做 path 内嵌\n- 时间轴 140f：0–10 静置｜10–30 呼吸｜30–54 变形｜56–68 内容\n  淡入｜68–140 真静止 72f\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 呼吸幅度 | 1→1.12→1，20f inOut cubic | <1.1 读不出\"吸气\"；>1.2 像要爆炸不是要变形 |\n| 原型停留 | 静置 10f + 呼吸 20f，合计 30f 才起变形 | 停留 <15f 读不出\"预备\"，变形显得突兀 |\n| 变形时长 | 24f | 太快像切换、太慢像融化；24f 是\"生长\"的速度 |\n| 目标形态 | 520×300 圆角矩形卡（demo 值） | 圆角越大与圆同构性越好、中段越顺 |\n| 内容淡入 | 变形落定后 2f 起，12f 淡入 | 变形没完就进内容 = 内容跟着轮廓扭 |\n\n## 已知坑\n- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿，\n  首次实战须以真实素材回验\n- **变形中途别叠其他动画**——morph 本身就是全部信息，同时飞入/\n  变色/晃动会把\"长出来\"稀释成\"乱动\"\n- 位图截图不能 morph：本卡只对矢量轮廓成立，页面截图请回\n  入场库常规手法\n- path 插值两端命令数必须严格同构（同为 M+8C），命令数不齐\n  中途会闪跳；改目标形状时先对齐锚点数再调参\n- 变形中段呈\"桶形\"过渡是正常形态（QA 判例），不是穿帮；\n  出现自交/尖刺才是锚点方位角没对齐\n\n## 参考实现\ndemos/ui-entrance/morph-from-primitive/\n（MorphFromPrimitive.tsx）\n\n## Reference implementation (Remotion)\n\n- demos/ui-entrance/morph-from-primitive/MorphFromPrimitive.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/ui-entrance/morph-from-primitive/MorphFromPrimitive.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/ui-entrance/morph-from-primitive/MorphFromPrimitive.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/ui-entrance/morph-from-primitive/MorphFromPrimitive.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/ui-entrance/morph-from-primitive/MorphFromPrimitive.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/ui-entrance/morph-from-primitive/MorphFromPrimitive.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/ui-entrance/morph-from-primitive/MorphFromPrimitive.tsx =====\n// morph-from-primitive｜原型变形\n// 正圆呼吸一拍(anticipation) → path d 逐数值插值变形成 520×300 圆角矩形卡片轮廓\n// → 卡片内部灰阶内容条淡入。全部帧驱动、确定性。\nimport React from 'react';\nimport { useCurrentFrame, interpolate, Easing } from 'remotion';\nimport { G } from '../../_fixtures/Fixtures';\n\nconst CX = 960;\nconst CY = 540;\nconst R = 130;\nconst RECT_W = 520;\nconst RECT_H = 300;\nconst RECT_R = 20;\n\n// ---- 同构 path：M + 8 段 cubic（4 直边段 + 4 圆角段），圆与圆角矩形共用锚点拓扑 ----\n// 圆角矩形锚点（中心坐标系，顺时针，从右边上端点起）：\n// A0(hw,-ihh) 右边上 → A1(hw,ihh) 右边下 → A2(iw,hh) 底边右 → A3(-iw,hh) 底边左\n// → A4(-hw,ihh) 左边下 → A5(-hw,-ihh) 左边上 → A6(-iw,-hh) 顶边左 → A7(iw,-hh) 顶边右 → A0\nconst hw = RECT_W / 2; // 260\nconst hh = RECT_H / 2; // 150\nconst iw = hw - RECT_R; // 240\nconst ihh = hh - RECT_R; // 130\nconst KAPPA = 0.5522847498;\n\ntype Seg = [number, number, number, number, number, number]; // c1x c1y c2x c2y x y\ntype Shape = { start: [number, number]; segs: Seg[] };\n\nconst line = (from: [number, number], to: [number, number]): Seg => [\n  from[0] + (to[0] - from[0]) / 3,\n  from[1] + (to[1] - from[1]) / 3,\n  from[0] + ((to[0] - from[0]) * 2) / 3,\n  from[1] + ((to[1] - from[1]) * 2) / 3,\n  to[0],\n  to[1],\n];\n// 90° 圆角弧 from → to（弧心 c）：c1 = from + k·(to−c)，c2 = to + k·(from−c)\nconst corner = (\n  from: [number, number],\n  to: [number, number],\n  c: [number, number]\n): Seg => [\n  from[0] + KAPPA * (to[0] - c[0]),\n  from[1] + KAPPA * (to[1] - c[1]),\n  to[0] + KAPPA * (from[0] - c[0]),\n  to[1] + KAPPA * (from[1] - c[1]),\n  to[0],\n  to[1],\n];\n\nconst rectAnchors: [number, number][] = [\n  [hw, -ihh],\n  [hw, ihh],\n  [iw, hh],\n  [-iw, hh],\n  [-hw, ihh],\n  [-hw, -ihh],\n  [-iw, -hh],\n  [iw, -hh],\n];\nconst cornerCenters: [number, number][] = [\n  [iw, ihh], // A1→A2 右下\n  [-iw, ihh], // A3→A4 左下\n  [-iw, -ihh], // A5→A6 左上\n  [iw, -ihh], // A7→A0 右上\n];\n\nconst rectShape: Shape = {\n  start: rectAnchors[0],\n  segs: [\n    line(rectAnchors[0], rectAnchors[1]),\n    corner(rectAnchors[1], rectAnchors[2], cornerCenters[0]),\n    line(rectAnchors[2], rectAnchors[3]),\n    corner(rectAnchors[3], rectAnchors[4], cornerCenters[1]),\n    line(rectAnchors[4], rectAnchors[5]),\n    corner(rectAnchors[5], rectAnchors[6], cornerCenters[2]),\n    line(rectAnchors[6], rectAnchors[7]),\n    corner(rectAnchors[7], rectAnchors[0], cornerCenters[3]),\n  ],\n};\n\n// 圆：锚点取矩形锚点的同方位角，段间用精确弧公式 k = 4/3·tan(Δθ/4)\nconst angles = rectAnchors.map(([x, y]) => Math.atan2(y, x));\nconst circAnchors: [number, number][] = angles.map((a) => [\n  R * Math.cos(a),\n  R * Math.sin(a),\n]);\nconst arcSeg = (i: number): Seg => {\n  const a1 = angles[i];\n  let a2 = angles[(i + 1) % 8];\n  if (a2 <= a1) a2 += Math.PI * 2;\n  const k = (4 / 3) * Math.tan((a2 - a1) / 4);\n  const p1 = circAnchors[i];\n  const p2 = circAnchors[(i + 1) % 8];\n  return [\n    p1[0] - k * R * Math.sin(a1),\n    p1[1] + k * R * Math.cos(a1),\n    p2[0] + k * R * Math.sin(a2 % (Math.PI * 2)),\n    p2[1] - k * R * Math.cos(a2 % (Math.PI * 2)),\n    p2[0],\n    p2[1],\n  ];\n};\nconst circShape: Shape = {\n  start: circAnchors[0],\n  segs: [0, 1, 2, 3, 4, 5, 6, 7].map(arcSeg),\n};\n\nconst lerp = (a: number, b: number, t: number) => a + (b - a) * t;\nconst f2 = (n: number) => n.toFixed(2);\n\nconst morphPath = (t: number): string => {\n  const sx = lerp(circShape.start[0], rectShape.start[0], t);\n  const sy = lerp(circShape.start[1], rectShape.start[1], t);\n  let d = `M ${f2(CX + sx)} ${f2(CY + sy)}`;\n  for (let i = 0; i < 8; i++) {\n    const a = circShape.segs[i];\n    const b = rectShape.segs[i];\n    const v = a.map((n, j) => lerp(n, b[j], t));\n    d += ` C ${f2(CX + v[0])} ${f2(CY + v[1])} ${f2(CX + v[2])} ${f2(\n      CY + v[3]\n    )} ${f2(CX + v[4])} ${f2(CY + v[5])}`;\n  }\n  return d + ' Z';\n};\n\n// ---- 时间轴（30fps，总 140f）----\n// 0–10 静置圆 | 10–30 呼吸一拍 scale 1→1.06→1 | 30–54 变形 24f\n// | 56–68 内容条淡入 12f | 68–140 真静止 72f\nexport const MorphFromPrimitive: React.FC = () => {\n  const frame = useCurrentFrame();\n\n  const breath = interpolate(frame, [10, 20, 30], [1, 1.12, 1], {\n    easing: Easing.inOut(Easing.cubic),\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n  });\n\n  const t = interpolate(frame, [30, 54], [0, 1], {\n    easing: Easing.inOut(Easing.cubic),\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n  });\n\n  const contentOpacity = interpolate(frame, [56, 68], [0, 1], {\n    easing: Easing.out(Easing.cubic),\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n  });\n\n  const d = morphPath(t);\n\n  return (\n    <div style={{ width: 1920, height: 1080, background: G.bg, position: 'relative' }}>\n      <svg\n        width={1920}\n        height={1080}\n        viewBox=\"0 0 1920 1080\"\n        style={{ position: 'absolute', inset: 0 }}\n      >\n        <g transform={`translate(${CX} ${CY}) scale(${breath}) translate(${-CX} ${-CY})`}>\n          <path d={d} fill=\"none\" stroke={G.ink} strokeWidth={3} />\n        </g>\n      </svg>\n      {/* 卡片内容条（参考 line-boil 描边卡内容），变形完成后淡入 */}\n      <div\n        style={{\n          position: 'absolute',\n          left: CX - RECT_W / 2,\n          top: CY - RECT_H / 2,\n          width: RECT_W,\n          height: RECT_H,\n          boxSizing: 'border-box',\n          padding: 36,\n          display: 'flex',\n          flexDirection: 'column',\n          gap: 18,\n          opacity: contentOpacity,\n        }}\n      >\n        <div style={{ height: 26, width: '58%', background: G.bar, borderRadius: 13 }} />\n        <div style={{ height: 16, width: '86%', background: G.line, borderRadius: 8 }} />\n        <div style={{ height: 16, width: '72%', background: G.line, borderRadius: 8 }} />\n        <div style={{ marginTop: 'auto', display: 'flex', gap: 14, alignItems: 'center' }}>\n          <div style={{ width: 40, height: 40, borderRadius: 20, background: G.mid }} />\n          <div style={{ height: 16, width: 120, background: G.line, borderRadius: 8 }} />\n        </div>\n      </div>\n    </div>\n  );\n};\n\n\n"
}