{
  "slug": "orbit-ring-title-open",
  "title": "Orbit ring title opening",
  "tagline": "Eight content cards orbit a 700×375 ellipse at constant speed while a centered title resolves into focus and settles.",
  "description": "Eight content cards orbit a 700×375 ellipse at constant speed while a centered title resolves into focus and settles.",
  "category": "intro",
  "tags": [
    "shotcraft",
    "remotion",
    "intro",
    "opening",
    "ui-entrance"
  ],
  "tier": "free",
  "license": "Apache-2.0",
  "spec": {
    "width": 1920,
    "height": 1080,
    "fps": 30,
    "duration": 4.33,
    "aspect": "16:9"
  },
  "kind": "remotion",
  "params_schema": {
    "type": "object",
    "properties": {}
  },
  "default_params": {},
  "preview": {
    "poster": "https://gimgs.net/motion/media/orbit-ring-title-open/poster-13a3a923b84f.jpg",
    "video": "https://gimgs.net/motion/media/orbit-ring-title-open/video-633aed6e9aca.mp4"
  },
  "variants": [
    {
      "key": "orbit-ring-title-open",
      "label": "orbit-ring-title-open",
      "description": "Eight 16:9 content cards spaced 45° apart orbit a 700×375 ellipse at constant speed (cards never tilt; depth comes only from a sin θ-driven ±9% scale and z-order); card content stays frozen on its first frame while the ring expands, then all eight start playing together after f24; the centered title de-blurs and settles downward letter by letter, a yellow marker block sweeps in from the left to fill behind the keyword the instant it lands, a mono subline floats up after, and in the final beat the whole line defocuses and fades while the ring keeps turning to hand off to the next shot.",
      "use": "",
      "video": "https://gimgs.net/motion/media/orbit-ring-title-open/video-633aed6e9aca.mp4",
      "poster": "https://gimgs.net/motion/media/orbit-ring-title-open/poster-13a3a923b84f.jpg"
    }
  ],
  "credits": {
    "author": "video-shotcraft (Vincent Wei)",
    "url": "https://github.com/Vincentwei1021/video-shotcraft"
  },
  "page_url": "https://gimgs.net/motion/orbit-ring-title-open.html",
  "json_url": "https://gimgs.net/motion/orbit-ring-title-open.json",
  "template_url": "https://gimgs.net/motion/orbit-ring-title-open/template.html",
  "play_url": null,
  "markdown_url": "https://gimgs.net/motion/orbit-ring-title-open.md",
  "updated_at": "2026-09-24T07:40:09.495Z",
  "prompt": "## When to use\n\nOpening shot #1 = \"this product is a whole system\"; the showcase shot for asset libraries/template libraries/feature matrices/case studies; when a real moving product visual needs to carry a claim instead of a static grid of screenshots\n\n## Intention\n\nOpening with \"we have a whole system\" is usually done as a grid of screenshots — but a grid is static, and viewers read \"a pile of images,\" not \"a moving product.\" This card turns the display into a **ring tour**: eight cards orbit an ellipse at constant speed, always one entering and one exiting frame, the picture carries its own \"there's more\" implication, while the title stays locked dead-center, unaffected. The key trade-off is that **the card content has to actually be playing**. Static screenshots on the ring just read as a \"carousel\"; real motion inside the cards instantly turns the same ring into \"the product at work.\" But if all eight cards start playing at once, eight animations fight for attention and none of them reads — so during the entrance the card content is **frozen on its first frame**, and only after the ring opens up and the title starts do they all begin playing together. The viewer's attention order is therefore: recognize this is a ring of product shots → read the title → then notice the cards are all moving.\n\n## Duration & energy\n\n- Duration: About 4.3s (130f@30fps; a 1.9s steady orbit between the title settling at f50 and exiting at f108)\n- Energy: Medium (the only continuous motion is the constant orbit; the marker-block sweep is the film's single impact moment)\n\n## Shot recipe (original Chinese — timing, easing, parameter table, known pitfalls)\n\n## 意图\n开场想说\"我们有一整套东西\"，最常见的做法是把截图排成网格——但网格是静止的，观众\n读到的是\"一堆图片\"，不是\"一套会动的产品\"。这张卡把陈列改成**环形巡回**：八张卡沿\n椭圆匀速公转，永远有卡在进画、有卡在出画，画面自带\"还有更多\"的暗示，而标题稳稳\n钉在正中间不受影响。\n\n关键的取舍是**卡内容要真的在播**。静态截图上环，观众只会认出\"轮播图\"；卡里跑着真\n动效，同一个环立刻变成\"产品在工作\"。但八张卡同时开播会八个动效抢戏，谁也读不进去\n——所以入场期把卡内容**冻结在首帧**，等环撑开、标题起手之后再统一开播。观众的注意\n力顺序因此是：先认出这是一圈产品画面 → 读标题 → 才注意到卡里都在动。\n\n## 动效核心\n- **环上定位**：`θ = -π/2 + i·2π/8 + t·0.3`，`x = 960 + cos θ·RX·ringScale`、\n  `y = 540 + sin θ·RY·ringScale`。RY/RX ≈ 0.54 的椭圆读作\"俯视的环\"；正圆会读作转盘\n- **卡身永不倾斜**：卡只跟着 θ 换位置，自身 transform 只有缩放，没有任何旋转。\n  纵深完全交给 `depth = sin θ`（+1 = 画面下方，近；−1 = 上方，远）：\n  `scale = 1 + 0.09·depth`、`zIndex = 10 + round(depth·5)`。缩放和 z 序必须由同一个\n  depth 驱动，否则会出现\"远处的卡压住近处的卡\"\n- **环入场**：f0–21 `ringScale 0.62→1`（outCubic）从中心撑开；同时逐卡淡入，\n  第 i 张的窗口是 `[0.06+0.05i, 0.42+0.05i]s`，最后一张 f23 才满。撑开与淡入叠在\n  一起，读作\"环长出来\"而不是\"卡片一张张贴上来\"\n- **冻结首帧再开播**：卡内容包在 `<Freeze frame={max(0, frame - 24)}>` 里。f24 之前\n  八张卡都停在自己的第 0 帧，f24 起同时进入正常时间轴。24f = 环撑开(21f) + 一点余量\n- **卡内坐标解耦**：卡内容按 960×540 作画，外层用 `scale((CW·s)/960)` 缩到卡宽 +\n  `transform-origin: top left`。内容组件不需要知道自己被缩小了，落地换成真实截图或\n  库内其他镜头卡组件时不用改一行\n- **标题 blur-slide 逐字**：每字两条通道同起不同终——解糊走满 0.9s\n  （`blur (1-p)·fontSize/6`），下沉只占前 0.3s（`translateY (1-p)·fontSize·0.22`）。\n  字间错峰 1 帧（0.0333s），缓动 `cubic-bezier(.22,1,.36,1)`。位移先停、模糊后停，\n  字才有\"落定后继续对上焦\"的手感\n- **马克块横扫**：`spring(frame-40, damping 14)` 驱动 `scaleX`，\n  `transform-origin: left center`，色块 `position:absolute; inset:.06em -.08em; zIndex:0`\n  垫在高亮词组底下，词自身 `position:relative; zIndex:1`。缓动前载，f40（1.33s）时末\n  字的 ease 值已 ≈0.95，感知上\"字全到位了才扫\"，实际不必等到 f50 数学收敛\n- **退场**：f108–118 整行 `opacity 1→0` + `blur 0→6.5px`，副行同窗同曲线。之后 12f\n  只有环在转——这段空档就是交棒位，接 crash-zoom / 穿浅场直切都不用额外铺垫\n\n## 参数表\n| 参数 | 典型值 | 调节手感 |\n|------|--------|----------|\n| 椭圆半径 | RX=700 / RY=375（卡 380×214） | RY/RX≈0.54 是\"俯视环\"的甜点；比值 →1 读作正圆转盘，→0.3 卡会在上下缘挤成一条 |\n| 卡数 | 8（方位角间隔 45°） | 8 张在这个半径上任意转角互不遮挡；≥10 张必须同步加大 RX/RY，否则近处卡压边 |\n| 公转速度 | 0.3 rad/s（4.3s 转约 74°） | **别因为\"看起来慢\"就调快**：>0.5 rad/s 时卡扫过画面边缘会有频闪感，且喧宾夺主 |\n| 环入场 | f0–21，scale .62→1，outCubic | <15f 像弹窗；>30f 标题起手时环还在长，两件事互相盖 |\n| 逐卡淡入 | 窗口 `[0.06+0.05i, 0.42+0.05i]`s | 步长 0.05s 刚够读出\"依次\"；>0.1s 最后一张会晚到标题之后 |\n| 开播帧 | PLAY_START=24f | 必须 ≥ 环撑开结束帧；提前会让八个动效在入场期一起抢戏 |\n| 纵深缩放 | ±9% | >15% 顶部卡会缩成图标，环读作\"透视隧道\"；=0 则整环扁平，失去前后关系 |\n| 标题 | 64px / 600 字重 / 字距 −0.05em | 环一直在动，标题必须够重才压得住；<48px 或 400 字重会被环吃掉 |\n| 逐字节奏 | lead .35s，解糊 .9s，下沉 .3s，错峰 1f | 错峰 1f 是\"整体解糊\"的手感；≥3f 会读成逐字打字机 |\n| 马克块 | f40 起，spring damping 14，#facc15 | damping <10 会回弹出色块边缘；起扫早于末字 ease 0.9 时读作\"色块追着字跑\" |\n| 副行 | mono 28px，字距 0.35em，1.0–1.5s 淡入 | 大字距 mono 是\"产品代号\"的读法；跟标题同字体会读作第二行标题 |\n| 退场 | f108–118，blur 0→6.5px | 整行统一失焦；**逐字退场会读作第二次入场** |\n\n## 已知坑\n- **卡不能跟着 θ 倾斜**。给卡加 `rotate(θ)` 后环立刻从\"陈列\"变成\"过山车\"，而且卡内\n  文字会歪，可读性直接归零。卡只换位置，不换角度\n- 卡内容首帧必须自带静态骨架（底色/边框/标签/灰底轨迹）。冻结期显示的就是第 0 帧，\n  如果卡的动效是\"从零画出来\"，入场那 24f 就是八张空白卡。demo 里每个占位卡都补了\n  灰底 sparkline / 淡稿线 / 常驻提示符，就是为了让首帧不空\n- `zIndex` 必须跟着 depth 走。只做缩放不排 z 序时，DOM 顺序会让后面的卡压住前面的，\n  近大远小的立体感当场作废\n- 马克色块和字的层级是一对：色块 `zIndex:0` + 字 `position:relative; zIndex:1`。漏掉\n  字上的 relative，色块会盖住字（stacking context 里 static 元素排在 positioned 之后）\n- 标题 `white-space: nowrap` 是马克块的前提——`inset:.06em -.08em` 假设高亮词组在同\n  一行盒里。换行后色块会跨行断裂成两截\n- 环是背景层但它一直在动，所以**不要在环上再叠第二个动的东西**（粒子、扫光、渐变\n  呼吸）。已经有一个持续运动在场，第二个会让画面读不出主次\n- 与 `carousel-3d` 的分工：那张卡是真 3D（`rotateY + translateZ` + backface-hidden），\n  卡会转到环背面，适合无缝 loop 的纯陈列；这张卡是 2D 椭圆伪纵深，卡永远正面朝观众，\n  适合\"环托底 + 前景标题\"的开场。要卡片翻面用前者，要标题压得住用后者\n- demo 的八张卡是原创通用 DOM 占位（灰阶骨架 + 一条强调色），不含任何产品截图；\n  落地时整块替换成你自己的镜头卡组件或真实页面截图即可，环的语法一行都不用改\n\n## 参考实现\ndemos/opening/orbit-ring-title-open/\n（OrbitRingTitleOpen.tsx）\n\n## Reference implementation (Remotion)\n\n- demos/opening/orbit-ring-title-open/OrbitRingTitleOpen.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/opening/orbit-ring-title-open/OrbitRingTitleOpen.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/opening/orbit-ring-title-open/OrbitRingTitleOpen.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/opening/orbit-ring-title-open/OrbitRingTitleOpen.tsx",
      "url": "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/opening/orbit-ring-title-open/OrbitRingTitleOpen.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": 130,
    "repo": "https://github.com/Vincentwei1021/video-shotcraft",
    "files": [
      "https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/opening/orbit-ring-title-open/OrbitRingTitleOpen.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/opening/orbit-ring-title-open/OrbitRingTitleOpen.tsx =====\n// orbit-ring-title-open — 环形卡阵标题开场\n// 八张 16:9 内容卡按 45° 均布在 700×375 椭圆上匀速公转（卡身永不倾斜，纵深只由\n// sin(θ) 给出 ±9% 缩放与 z 序），入场期卡内容冻结首帧、环撑开落定后统一开播；\n// 居中标题逐字解糊下沉落定，关键词全部到位那一刻黄色马克块自左横扫铺满，\n// mono 副行随后浮出；末段整幕失焦淡出，环继续转着交棒下一镜。\n//\n// 参数以 1920×1080 标定（不走 DesignStage：环半径/卡尺寸/字号都是成片实测值）。\nimport React from 'react';\nimport {\n  AbsoluteFill,\n  Easing,\n  Freeze,\n  interpolate,\n  spring,\n  useCurrentFrame,\n  useVideoConfig,\n} from 'remotion';\n\nexport const ORBIT_RING_TITLE_OPEN_DURATION = 130; // 4.33s @30fps\n\n// ---- 环 ----\nconst RX = 700; // 椭圆长半轴\nconst RY = 375; // 椭圆短半轴（RY/RX≈0.54 → 读作俯视的环，不是正圆转盘）\nconst CW = 380; // 卡宽（16:9）\nconst CH = 214;\nconst N = 8;\nconst ROT_SPEED = 0.3; // rad/s，正值 = 顺时针\nconst RING_IN = 0.7; // s：从中心撑开到满径\nconst RING_SCALE_FROM = 0.62;\nconst PLAY_START = 24; // f：环撑开 + 逐卡淡入收尾，之后卡内容才开播\nconst DEPTH_SCALE = 0.09; // 近大远小幅度（底部近、顶部远）\n\n// ---- 标题（blur-slide 逐字：位移先停、解糊后停）----\nconst HEADLINE = '让镜头卡替你想好每一个动效';\nconst H_SIZE = 64;\nconst H_LEAD = 0.35; // s：首字起手\nconst H_DUR = 0.9; // s：单字解糊行程\nconst H_TRAVEL = 0.3; // s：单字下沉行程（只占解糊的前 1/3）\nconst H_STAGGER = 0.0333; // s：字间错峰 = 1 帧\nconst H_EASE = Easing.bezier(0.22, 1, 0.36, 1);\n\n// ---- 马克块（marker-highlight）----\nconst HL_START = 8; // 「每一个动效」起始字序\nconst MARKER_AT_F = 40; // 末字视觉到位那一刻起扫\nconst MARKER_COLOR = '#facc15';\n\n// ---- 副行与退场 ----\nconst KICKER = 'VIDEO-SHOTCRAFT';\nconst KICKER_IN: [number, number] = [1.0, 1.5];\nconst EXIT_AT = 3.6;\nconst EXIT_DUR = 0.34;\nconst EXIT_BLUR = 6.5;\n\nconst INK = '#1d1d1f';\nconst INK_DIM = '#7a7a7a';\nconst SANS = '-apple-system, \"PingFang SC\", BlinkMacSystemFont, sans-serif';\nconst MONO = 'Menlo, \"SF Mono\", monospace';\n// 彩色 pastel mesh：四个大半径径向渐变叠在米白底上，纯 CSS，无素材依赖\nconst MESH_BG =\n  'radial-gradient(52% 44% at 18% 22%, rgba(122,90,248,0.20) 0%, rgba(122,90,248,0) 70%),' +\n  'radial-gradient(46% 42% at 84% 18%, rgba(255,138,178,0.20) 0%, rgba(255,138,178,0) 70%),' +\n  'radial-gradient(58% 50% at 78% 84%, rgba(96,190,255,0.20) 0%, rgba(96,190,255,0) 70%),' +\n  'radial-gradient(50% 46% at 24% 88%, rgba(255,196,112,0.20) 0%, rgba(255,196,112,0) 70%),' +\n  'linear-gradient(180deg, #f7f6f9 0%, #f2f1f5 100%)';\n\n// ── 卡内占位内容 ───────────────────────────────────────────────────────────\n// 每张卡是一段 960×540 的独立小动效（各自从 frame 0 起播，被 <Freeze> 统一压住）。\n// 落地时把这 8 个组件换成你自己的镜头卡/真实截图组件即可，环的语法不变。\nconst F = (frame: number, a: number, b: number, ease = Easing.out(Easing.cubic)) =>\n  interpolate(frame, [a, b], [0, 1], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n    easing: ease,\n  });\n\nconst Pad: React.FC<{ children: React.ReactNode; bg?: string }> = ({ children, bg = '#ffffff' }) => (\n  <div style={{ position: 'absolute', inset: 0, background: bg, padding: 56, fontFamily: SANS }}>\n    {children}\n  </div>\n);\n\nconst Bar: React.FC<{ w: number; h?: number; color?: string; radius?: number }> = ({\n  w,\n  h = 16,\n  color = '#e3e3e8',\n  radius = 8,\n}) => <div style={{ width: w, height: h, borderRadius: radius, background: color }} />;\n\n/** 1 马克笔扫读：一行字被黄块自左盖过 */\nconst TileSweep: React.FC = () => {\n  const f = useCurrentFrame();\n  const s = F(f, 14, 34);\n  return (\n    <Pad>\n      <div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>\n        <Bar w={300} h={14} />\n        <div style={{ position: 'relative', width: 640, height: 40 }}>\n          <div\n            style={{\n              position: 'absolute',\n              left: -8,\n              top: 2,\n              width: 636 * s,\n              height: 36,\n              background: MARKER_COLOR,\n              borderRadius: 6,\n            }}\n          />\n          <div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>\n            <Bar w={620} h={22} color=\"#2a2a30\" radius={4} />\n          </div>\n        </div>\n        <Bar w={480} h={14} />\n      </div>\n    </Pad>\n  );\n};\n\n/** 2 指标 + sparkline 描线 */\nconst SPARK = 'M8 118 L118 92 L228 104 L338 56 L448 68 L558 20 L632 34';\nconst TileMetric: React.FC = () => {\n  const f = useCurrentFrame();\n  const draw = F(f, 10, 46);\n  const val = 128 + Math.round(F(f, 8, 40, Easing.out(Easing.quad)) * 84);\n  return (\n    <Pad>\n      <div style={{ fontSize: 30, color: INK_DIM, letterSpacing: '0.06em' }}>SESSIONS</div>\n      <div\n        style={{\n          fontSize: 132,\n          fontWeight: 700,\n          color: INK,\n          fontVariantNumeric: 'tabular-nums',\n          lineHeight: 1.1,\n        }}\n      >\n        {val}\n      </div>\n      <svg width={640} height={140} viewBox=\"0 0 640 140\" style={{ marginTop: 12 }}>\n        {/* 灰底轨迹：首帧就有内容，描线只是把它点亮 */}\n        <path d={SPARK} fill=\"none\" stroke=\"#e3e3e8\" strokeWidth={8} strokeLinecap=\"round\" />\n        <path\n          d={SPARK}\n          fill=\"none\"\n          stroke=\"#7A5AF8\"\n          strokeWidth={8}\n          strokeLinecap=\"round\"\n          strokeDasharray={900}\n          strokeDashoffset={900 * (1 - draw)}\n        />\n      </svg>\n    </Pad>\n  );\n};\n\n/** 3 竖向步骤清单逐条勾选 */\nconst TileSteps: React.FC = () => {\n  const f = useCurrentFrame();\n  return (\n    <Pad>\n      <div style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>\n        {[0, 1, 2, 3].map((i) => {\n          const on = F(f, 8 + i * 9, 22 + i * 9);\n          return (\n            <div key={i} style={{ display: 'flex', alignItems: 'center', gap: 26, opacity: 0.35 + on * 0.65 }}>\n              <div\n                style={{\n                  width: 44,\n                  height: 44,\n                  borderRadius: 22,\n                  background: on > 0.6 ? '#7A5AF8' : '#e3e3e8',\n                }}\n              />\n              <Bar w={200 + i * 110} h={22} color=\"#2a2a30\" radius={6} />\n            </div>\n          );\n        })}\n      </div>\n    </Pad>\n  );\n};\n\n/** 4 路线描线 + 落点钉针 */\nconst TileRoute: React.FC = () => {\n  const f = useCurrentFrame();\n  const draw = F(f, 6, 42);\n  const pin = spring({ frame: f - 36, fps: 30, config: { damping: 12 } });\n  return (\n    <Pad bg=\"#f4f4f7\">\n      <svg width={848} height={428} viewBox=\"0 0 848 428\">\n        <path\n          d=\"M40 380 C 200 380 190 210 340 200 C 500 190 500 90 700 70\"\n          fill=\"none\"\n          stroke=\"#c9c9d2\"\n          strokeWidth={10}\n          strokeLinecap=\"round\"\n        />\n        <path\n          d=\"M40 380 C 200 380 190 210 340 200 C 500 190 500 90 700 70\"\n          fill=\"none\"\n          stroke=\"#7A5AF8\"\n          strokeWidth={10}\n          strokeLinecap=\"round\"\n          strokeDasharray={1100}\n          strokeDashoffset={1100 * (1 - draw)}\n        />\n        <g transform={`translate(700 70) scale(${pin})`}>\n          <circle r={26} fill=\"#7A5AF8\" />\n          <circle r={10} fill=\"#ffffff\" />\n        </g>\n      </svg>\n    </Pad>\n  );\n};\n\n/** 5 深底终端打字 */\nconst TileTerminal: React.FC = () => {\n  const f = useCurrentFrame();\n  const rows = [520, 400, 610, 300];\n  return (\n    <Pad bg=\"#1d1d1f\">\n      <div style={{ display: 'flex', gap: 14, marginBottom: 34 }}>\n        {['#ff5f57', '#febc2e', '#28c840'].map((c) => (\n          <div key={c} style={{ width: 20, height: 20, borderRadius: 10, background: c }} />\n        ))}\n      </div>\n      <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>\n        {/* 提示符常驻：首帧的终端里已经有东西，打字才是动效本身 */}\n        <Bar w={120} h={18} color=\"#7A5AF8\" radius={4} />\n        {rows.map((w, i) => {\n          const p = F(f, 8 + i * 11, 20 + i * 11, Easing.linear);\n          return <Bar key={i} w={w * p} h={18} color=\"#4a4a52\" radius={4} />;\n        })}\n      </div>\n    </Pad>\n  );\n};\n\n/** 6 线稿描画（铅笔速写） */\nconst TileSketch: React.FC = () => {\n  const f = useCurrentFrame();\n  const strokes = [\n    'M80 380 L768 380',\n    'M140 380 L200 90 L648 90 L708 380',\n    'M280 90 L280 380',\n    'M500 90 L500 380',\n  ];\n  return (\n    <Pad>\n      <svg width={848} height={428} viewBox=\"0 0 848 428\">\n        {/* 淡稿：首帧是一张有底稿的纸，描线把它落成实线 */}\n        {strokes.map((d, i) => (\n          <path key={`g${i}`} d={d} fill=\"none\" stroke=\"#e7e7ec\" strokeWidth={7} strokeLinecap=\"round\" />\n        ))}\n        {strokes.map((d, i) => {\n          const p = F(f, 6 + i * 10, 24 + i * 10);\n          return (\n            <path\n              key={i}\n              d={d}\n              fill=\"none\"\n              stroke={INK}\n              strokeWidth={7}\n              strokeLinecap=\"round\"\n              strokeDasharray={900}\n              strokeDashoffset={900 * (1 - p)}\n            />\n          );\n        })}\n      </svg>\n    </Pad>\n  );\n};\n\n/** 7 卡片缩成胶囊（主体让位） */\nconst TileShrink: React.FC = () => {\n  const f = useCurrentFrame();\n  const p = F(f, 16, 44);\n  return (\n    <Pad bg=\"#f4f4f7\">\n      <div\n        style={{\n          position: 'absolute',\n          left: 56,\n          top: 56,\n          width: 848 - 700 * p,\n          height: 428 - 350 * p,\n          borderRadius: 16 + 30 * p,\n          background: '#ffffff',\n          boxShadow: '0 0 0 1px rgba(29,29,31,0.08)',\n        }}\n      />\n      <div\n        style={{\n          position: 'absolute',\n          left: 220,\n          top: 130,\n          display: 'flex',\n          flexDirection: 'column',\n          gap: 22,\n          opacity: p,\n        }}\n      >\n        <Bar w={420} h={20} color=\"#2a2a30\" radius={6} />\n        <Bar w={330} h={20} />\n        <Bar w={380} h={20} />\n      </div>\n    </Pad>\n  );\n};\n\n/** 8 冲击字卡 */\nconst TileImpact: React.FC = () => {\n  const f = useCurrentFrame();\n  const p = spring({ frame: f - 8, fps: 30, config: { damping: 13, mass: 0.7 } });\n  return (\n    <Pad>\n      <div\n        style={{\n          position: 'absolute',\n          inset: 0,\n          display: 'flex',\n          flexDirection: 'column',\n          alignItems: 'center',\n          justifyContent: 'center',\n          gap: 26,\n        }}\n      >\n        {/* 常驻小行 = 首帧的锚，大字砸下来才有对比 */}\n        <div style={{ fontSize: 30, color: INK_DIM, letterSpacing: '0.4em' }}>2026 · Q3</div>\n        <div\n          style={{\n            fontSize: 128,\n            fontWeight: 800,\n            letterSpacing: '-0.04em',\n            color: INK,\n            transform: `scale(${0.72 + p * 0.28})`,\n            opacity: p,\n          }}\n        >\n          GO LIVE\n        </div>\n      </div>\n    </Pad>\n  );\n};\n\nconst TILES: { Comp: React.FC; bg: string }[] = [\n  { Comp: TileSweep, bg: '#ffffff' },\n  { Comp: TileShrink, bg: '#f4f4f7' },\n  { Comp: TileMetric, bg: '#ffffff' },\n  { Comp: TileRoute, bg: '#f4f4f7' },\n  { Comp: TileSteps, bg: '#ffffff' },\n  { Comp: TileTerminal, bg: '#1d1d1f' },\n  { Comp: TileSketch, bg: '#ffffff' },\n  { Comp: TileImpact, bg: '#ffffff' },\n];\n\n// ── 主体 ───────────────────────────────────────────────────────────────────\nexport const OrbitRingTitleOpen: React.FC = () => {\n  const frame = useCurrentFrame();\n  const { fps } = useVideoConfig();\n  const t = frame / fps;\n\n  // 环入场：从中心撑开 + 逐卡淡入\n  const ringIn = interpolate(t, [0, RING_IN], [0, 1], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n    easing: Easing.out(Easing.cubic),\n  });\n  const ringScale = RING_SCALE_FROM + (1 - RING_SCALE_FROM) * ringIn;\n  const rot = t * ROT_SPEED;\n\n  const markerScale = spring({ frame: frame - MARKER_AT_F, fps, config: { damping: 14 } });\n  const kickerIn = interpolate(t, KICKER_IN, [0, 1], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n  });\n  const exitQ = interpolate(t, [EXIT_AT, EXIT_AT + EXIT_DUR], [0, 1], {\n    extrapolateLeft: 'clamp',\n    extrapolateRight: 'clamp',\n  });\n\n  const chars = Array.from(HEADLINE);\n  // 逐字通道：位移只占前 H_TRAVEL，解糊走满 H_DUR（同起不同终 = blur-slide 的手感）\n  const charStyle = (i: number): React.CSSProperties => {\n    const at = H_LEAD + i * H_STAGGER;\n    const pMain = interpolate(t, [at, at + H_DUR], [0, 1], {\n      extrapolateLeft: 'clamp',\n      extrapolateRight: 'clamp',\n      easing: H_EASE,\n    });\n    const pTravel = interpolate(t, [at, at + H_TRAVEL], [0, 1], {\n      extrapolateLeft: 'clamp',\n      extrapolateRight: 'clamp',\n      easing: H_EASE,\n    });\n    return {\n      display: 'inline-block',\n      whiteSpace: 'pre',\n      position: 'relative',\n      zIndex: 1,\n      transformOrigin: '50% 55%',\n      opacity: pMain,\n      filter: `blur(${(1 - pMain) * (H_SIZE / 6)}px)`,\n      transform: `translateY(${(1 - pTravel) * H_SIZE * 0.22}px)`,\n    };\n  };\n\n  return (\n    <AbsoluteFill style={{ background: MESH_BG, fontFamily: SANS }}>\n      {TILES.map((tile, i) => {\n        const theta = -Math.PI / 2 + (i * Math.PI * 2) / N + rot;\n        const x = 960 + Math.cos(theta) * RX * ringScale;\n        const y = 540 + Math.sin(theta) * RY * ringScale;\n        const depth = Math.sin(theta); // +1 = 画面下方（近），−1 = 上方（远）\n        const s = 1 + DEPTH_SCALE * depth;\n        const op = interpolate(t, [0.06 + i * 0.05, 0.42 + i * 0.05], [0, 1], {\n          extrapolateLeft: 'clamp',\n          extrapolateRight: 'clamp',\n        });\n        return (\n          <div\n            key={i}\n            style={{\n              position: 'absolute',\n              left: x - (CW * s) / 2,\n              top: y - (CH * s) / 2,\n              width: CW * s,\n              height: CH * s,\n              borderRadius: 10,\n              overflow: 'hidden',\n              background: tile.bg,\n              opacity: op,\n              zIndex: 10 + Math.round(depth * 5),\n              boxShadow: '0 0 0 1px rgba(29,29,31,0.08), 0 14px 34px rgba(16,24,40,0.12)',\n            }}\n          >\n            {/* 卡内按 960×540 作画再整体缩到卡宽——内容组件不需要知道自己被缩小了 */}\n            <div\n              style={{\n                position: 'absolute',\n                left: 0,\n                top: 0,\n                width: 960,\n                height: 540,\n                transform: `scale(${(CW * s) / 960})`,\n                transformOrigin: 'top left',\n              }}\n            >\n              <Freeze frame={Math.max(0, frame - PLAY_START)}>\n                <tile.Comp />\n              </Freeze>\n            </div>\n          </div>\n        );\n      })}\n\n      {/* 标题：整行退场时统一失焦淡出（逐字退场会读作第二次入场） */}\n      <AbsoluteFill style={{ alignItems: 'center', justifyContent: 'center', zIndex: 30 }}>\n        <div\n          style={{\n            fontSize: H_SIZE,\n            fontWeight: 600,\n            letterSpacing: '-0.05em',\n            color: INK,\n            whiteSpace: 'nowrap',\n            opacity: 1 - exitQ,\n            filter: exitQ > 0.01 ? `blur(${exitQ * EXIT_BLUR}px)` : undefined,\n          }}\n        >\n          {chars.slice(0, HL_START).map((ch, i) => (\n            <span key={i} style={charStyle(i)}>\n              {ch}\n            </span>\n          ))}\n          {/* 高亮组：马克色块垫在这一段字底下，随字全部到位横扫铺开 */}\n          <span style={{ position: 'relative', display: 'inline-block' }}>\n            <span\n              aria-hidden\n              style={{\n                position: 'absolute',\n                inset: '0.06em -0.08em',\n                background: MARKER_COLOR,\n                transformOrigin: 'left center',\n                transform: `scaleX(${markerScale})`,\n                borderRadius: 6,\n                zIndex: 0,\n              }}\n            />\n            {chars.slice(HL_START).map((ch, j) => (\n              <span key={j} style={charStyle(HL_START + j)}>\n                {ch}\n              </span>\n            ))}\n          </span>\n        </div>\n      </AbsoluteFill>\n\n      <div\n        style={{\n          position: 'absolute',\n          left: 0,\n          right: 0,\n          top: '50%',\n          marginTop: 88,\n          textAlign: 'center',\n          fontFamily: MONO,\n          fontSize: 28,\n          fontWeight: 600,\n          letterSpacing: '0.35em',\n          color: INK_DIM,\n          opacity: kickerIn * (1 - exitQ),\n          filter: exitQ > 0.01 ? `blur(${exitQ * EXIT_BLUR}px)` : undefined,\n          zIndex: 30,\n        }}\n      >\n        {KICKER}\n      </div>\n    </AbsoluteFill>\n  );\n};\n\n\n"
}