feat: 新增两张镜头卡——环形卡阵标题开场 / 首词推近组句 (#68)

来源是 video-talkcraft 宣传片的前两个镜头(S1/S2),提炼成通用镜头卡 +
自包含参考实现,参数按成片实测值(1920×1080 坐标系)原样落卡。

- opening/orbit-ring-title-open(标签 ui-entrance):八张 16:9 内容卡按 45°
  均布在 700×375 椭圆上匀速公转,卡身永不倾斜、纵深只由 sin θ 给 ±9% 缩放与
  z 序;环撑开期间卡内容 <Freeze> 冻结首帧、f24 起八张统一开播(否则八个动效
  互相抢戏);标题 blur-slide 逐字(解糊 .9s / 下沉 .3s / 错峰 1f),关键词
  ease≈0.95 那一刻马克块 spring 横扫,末段整行失焦淡出留 12f 交棒位
- typography/lead-word-zoom-assemble(标签 opening):首词 2.3× 起手 + hold
  期间再推近 6%,缩回与整行左滑共用一条 ZOOM_EASE 读作一次运动,后续词从槽位
  右侧 .5em 被推入;transform-origin 横向钉首词中心、纵向钉基线(零尺寸
  inline-block 尺子实测 + delayRender 挂起),这对支点是不抖基线的命门
- demo 均为自包含(只依赖 remotion),tsc --strict 与首帧冒烟渲染通过;
  样片 130f / 84f @1920×1080 30fps h264 已上传 gallery-media release
- 顺带补齐 PR #65 三张新卡漏掉的 stylesZh 标签,并把 README×3 / SKILL.md
  的对外计数从 152/209 更新到 157/214(What's new 里的历史条目不动)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Yihao
2026-09-02 02:08:43 +08:00
committed by GitHub
parent 00371a361f
commit 6c116cbd24
15 changed files with 1166 additions and 36 deletions
+5 -5
View File
@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/88911?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-88911" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/88911/daily?language=TypeScript" alt="Vincentwei1021%2Fvideo-shotcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/88911?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-88911" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/88911/weekly?language=TypeScript" alt="Vincentwei1021%2Fvideo-shotcraft | Trendshift" width="250" height="55"/></a>
**An agent skill for crafting cinematic product videos: 152 shot recipe cards · 209 styles · 209 motion previews · a production-ready template**
**An agent skill for crafting cinematic product videos: 157 shot recipe cards · 214 styles · 214 motion previews · a production-ready template**
[English](README.md) | [中文](README_CN.md) | [日本語](README_JA.md)
@@ -27,7 +27,7 @@ sound-designs a cinematic promo, marketing, launch, or demo video with
[Remotion](https://www.remotion.dev/) — real page captures, 2.5D camera moves,
beat-synced cuts, and film-grade SFX included.
🖼️ [**Browse all 209 motion previews in the live Gallery »**](https://vincentwei1021.github.io/video-shotcraft/)
🖼️ [**Browse all 214 motion previews in the live Gallery »**](https://vincentwei1021.github.io/video-shotcraft/)
## ✨ What's new
@@ -151,8 +151,8 @@ With these three flags, frame renders from the bundled template work.
| Content | Description |
| --- | --- |
| 152 shot recipe cards | Purpose, energy, suggested duration, parameters, implementation notes, and known pitfalls |
| 209 motion previews | Covering 209 styles; searchable and filterable in the online Gallery |
| 157 shot recipe cards | Purpose, energy, suggested duration, parameters, implementation notes, and known pitfalls |
| 214 motion previews | Covering 214 styles; searchable and filterable in the online Gallery |
| Remotion implementations | Tuned TSX demos containing the actual easing and timing parameters for each card |
| Complete video template | A validated 36.2-second, 1920×1080, 30fps product promo with 10 shots |
| Components and assets | 2.5D page camera, captions, flash cuts, digit rolls, SFX, and capture scripts |
@@ -170,7 +170,7 @@ video-shotcraft/
├── SKILL.md # Agent entry point and core production rules
├── references/
│ ├── pipeline.md # End-to-end production workflow
│ ├── shots/ # 152 shot recipe cards in 10 functional categories
│ ├── shots/ # 157 shot recipe cards in 10 functional categories
│ ├── sequences/ # Reusable full-video structures and sequence patterns
│ ├── aesthetic-rules.md # Visual QA criteria
│ ├── music-beat-sync.md # BGM analysis and beat-sync methodology
+5 -5
View File
@@ -16,7 +16,7 @@
<a href="https://trendshift.io/repositories/88911?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-88911" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/88911/daily?language=TypeScript" alt="Vincentwei1021%2Fvideo-shotcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/88911?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-88911" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/88911/weekly?language=TypeScript" alt="Vincentwei1021%2Fvideo-shotcraft | Trendshift" width="250" height="55"/></a>
**让 agent 帮你制作电影感产品视频的 skill152 张镜头配方卡 · 209 个样式 · 209 条动态样片 · 已验收成片模板**
**让 agent 帮你制作电影感产品视频的 skill157 张镜头配方卡 · 214 个样式 · 214 条动态样片 · 已验收成片模板**
[English](README.md) | [中文](README_CN.md) | [日本語](README_JA.md)
@@ -27,7 +27,7 @@
和声音设计,产出一支电影感的宣传片 / 营销视频 / 发布视频 / 功能演示——
真实页面截图、2.5D 运镜、节奏卡点和电影级 SFX 全部包含。
🖼️ [**在线 Gallery浏览全部 209 条动态样片 »**](https://vincentwei1021.github.io/video-shotcraft/)
🖼️ [**在线 Gallery浏览全部 214 条动态样片 »**](https://vincentwei1021.github.io/video-shotcraft/)
## ✨ 最近更新
@@ -140,8 +140,8 @@ agent 会替换成目标产品的截图、文案和品牌信息,复现同等
| 内容 | 说明 |
| --- | --- |
| 152 张镜头配方卡 | 记录用途、能量、建议时长、参数、实现要点与已知坑 |
| 209 条动态样片 | 覆盖 209 个样式,可在在线 Gallery 中直接预览、搜索和筛选 |
| 157 张镜头配方卡 | 记录用途、能量、建议时长、参数、实现要点与已知坑 |
| 214 条动态样片 | 覆盖 214 个样式,可在在线 Gallery 中直接预览、搜索和筛选 |
| Remotion 参考实现 | 每张卡对应经过调校的 TSX demo包含实际缓动和时序参数 |
| 完整成片模板 | 36.2 秒、1920×1080、30fps、10 镜头的纸墨琥珀风产品宣传片 |
| 组件与素材 | 2.5D 页面相机、字幕、闪切、数字滚动、音效和素材采集脚本 |
@@ -158,7 +158,7 @@ video-shotcraft/
├── SKILL.md # Agent 使用入口与核心制作规则
├── references/
│ ├── pipeline.md # 完整制作流水线
│ ├── shots/ # 152 张镜头配方卡
│ ├── shots/ # 157 张镜头配方卡
│ ├── sequences/ # 可复用的全片结构与桥段模板
│ ├── aesthetic-rules.md # 视觉验收准则
│ ├── music-beat-sync.md # BGM 节奏分析与卡点方法
+5 -5
View File
@@ -15,7 +15,7 @@
<a href="https://trendshift.io/repositories/88911?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-88911" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/88911/daily?language=TypeScript" alt="Vincentwei1021%2Fvideo-shotcraft | Trendshift" width="250" height="55"/></a>
<a href="https://trendshift.io/repositories/88911?utm_source=trendshift-badge&utm_medium=badge&utm_campaign=badge-trendshift-88911" target="_blank" rel="noopener noreferrer"><img src="https://trendshift.io/api/badge/trendshift/repositories/88911/weekly?language=TypeScript" alt="Vincentwei1021%2Fvideo-shotcraft | Trendshift" width="250" height="55"/></a>
**映画のような製品動画を制作するためのエージェントスキル152 種類のショットレシピカード · 209 種類のスタイル · 209 本のモーションプレビュー · 実制作に対応したテンプレート**
**映画のような製品動画を制作するためのエージェントスキル157 種類のショットレシピカード · 214 種類のスタイル · 214 本のモーションプレビュー · 実制作に対応したテンプレート**
[English](README.md) | [中文](README_CN.md) | [日本語](README_JA.md)
@@ -27,7 +27,7 @@
マーケティング、ローンチ、デモ動画を制作します。実際のページキャプチャ、2.5D カメラワーク、
ビートに同期したカット、映画品質の SFX も含まれます。
🖼️ [**ライブ Gallery で 209 本のモーションプレビューをすべて見る »**](https://vincentwei1021.github.io/video-shotcraft/)
🖼️ [**ライブ Gallery で 214 本のモーションプレビューをすべて見る »**](https://vincentwei1021.github.io/video-shotcraft/)
## ✨ 最新情報
@@ -151,8 +151,8 @@ Use video-shotcraft to make a promo for my product with the Ink Press template.
| 内容 | 説明 |
| --- | --- |
| 152 種類のショットレシピカード | 目的、エネルギー、推奨時間、パラメータ、実装上の注意点、既知の落とし穴 |
| 209 本のモーションプレビュー | 209 種類のスタイルを網羅し、オンライン Gallery で検索と絞り込みが可能 |
| 157 種類のショットレシピカード | 目的、エネルギー、推奨時間、パラメータ、実装上の注意点、既知の落とし穴 |
| 214 本のモーションプレビュー | 214 種類のスタイルを網羅し、オンライン Gallery で検索と絞り込みが可能 |
| Remotion 実装 | 各カードの実際のイージングとタイミングパラメータを含む、調整済みの TSX デモ |
| 完全な動画テンプレート | 検証済みの 36.2 秒、1920×1080、30fps、10 ショットの製品プロモーション |
| コンポーネントとアセット | 2.5D ページカメラ、キャプション、フラッシュカット、数字ロール、SFX、キャプチャスクリプト |
@@ -170,7 +170,7 @@ video-shotcraft/
├── SKILL.md # Agent entry point and core production rules
├── references/
│ ├── pipeline.md # End-to-end production workflow
│ ├── shots/ # 152 shot recipe cards
│ ├── shots/ # 157 shot recipe cards
│ ├── sequences/ # Reusable full-video structures and sequence patterns
│ ├── aesthetic-rules.md # Visual QA criteria
│ ├── music-beat-sync.md # BGM analysis and beat-sync methodology
+2 -2
View File
@@ -5,7 +5,7 @@ description: Create cinematic product videos from shot recipe cards, a validated
# video-shotcraft电影感产品视频制作
一个自包含的制作能力库152 张镜头配方卡(附 demo 实现源码与动态样片
一个自包含的制作能力库157 张镜头配方卡(附 demo 实现源码与动态样片
画廊)、一支已验收的完整宣传片模板、可复用组件与音频资产、六阶段工作流。
当前 focus 是 web/桌面产品宣传片,但镜头卡本身是通用动效词汇——
也可以单独抽卡做任意视频里的单个镜头。
@@ -248,5 +248,5 @@ SFX只有完整分镜确认后才进入最终素材采集。用户从 Gallery
- `gallery/` 静态画廊:优先直接给用户在线版
https://vincentwei1021.github.io/video-shotcraft/library.html
本地跑则先 `gallery/fetch-media.sh` 拉样片mp4 不在 git 里),再
`cd gallery && python3 -m http.server 4178`。152 卡 209 条动态样片
`cd gallery && python3 -m http.server 4178`。157 卡 214 条动态样片
可浏览/搜索/多选复制卡名——适合让用户看着样片挑镜头。
@@ -0,0 +1,522 @@
// orbit-ring-title-open — 环形卡阵标题开场
// 八张 16:9 内容卡按 45° 均布在 700×375 椭圆上匀速公转(卡身永不倾斜,纵深只由
// sin(θ) 给出 ±9% 缩放与 z 序),入场期卡内容冻结首帧、环撑开落定后统一开播;
// 居中标题逐字解糊下沉落定,关键词全部到位那一刻黄色马克块自左横扫铺满,
// mono 副行随后浮出;末段整幕失焦淡出,环继续转着交棒下一镜。
//
// 参数以 1920×1080 标定(不走 DesignStage环半径/卡尺寸/字号都是成片实测值)。
import React from 'react';
import {
AbsoluteFill,
Easing,
Freeze,
interpolate,
spring,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
export const ORBIT_RING_TITLE_OPEN_DURATION = 130; // 4.33s @30fps
// ---- 环 ----
const RX = 700; // 椭圆长半轴
const RY = 375; // 椭圆短半轴RY/RX≈0.54 → 读作俯视的环,不是正圆转盘)
const CW = 380; // 卡宽16:9
const CH = 214;
const N = 8;
const ROT_SPEED = 0.3; // rad/s正值 = 顺时针
const RING_IN = 0.7; // s从中心撑开到满径
const RING_SCALE_FROM = 0.62;
const PLAY_START = 24; // f环撑开 + 逐卡淡入收尾,之后卡内容才开播
const DEPTH_SCALE = 0.09; // 近大远小幅度(底部近、顶部远)
// ---- 标题blur-slide 逐字:位移先停、解糊后停)----
const HEADLINE = '让镜头卡替你想好每一个动效';
const H_SIZE = 64;
const H_LEAD = 0.35; // s首字起手
const H_DUR = 0.9; // s单字解糊行程
const H_TRAVEL = 0.3; // s单字下沉行程只占解糊的前 1/3
const H_STAGGER = 0.0333; // s字间错峰 = 1 帧
const H_EASE = Easing.bezier(0.22, 1, 0.36, 1);
// ---- 马克块marker-highlight----
const HL_START = 8; // 「每一个动效」起始字序
const MARKER_AT_F = 40; // 末字视觉到位那一刻起扫
const MARKER_COLOR = '#facc15';
// ---- 副行与退场 ----
const KICKER = 'VIDEO-SHOTCRAFT';
const KICKER_IN: [number, number] = [1.0, 1.5];
const EXIT_AT = 3.6;
const EXIT_DUR = 0.34;
const EXIT_BLUR = 6.5;
const INK = '#1d1d1f';
const INK_DIM = '#7a7a7a';
const SANS = '-apple-system, "PingFang SC", BlinkMacSystemFont, sans-serif';
const MONO = 'Menlo, "SF Mono", monospace';
// 彩色 pastel mesh四个大半径径向渐变叠在米白底上纯 CSS无素材依赖
const MESH_BG =
'radial-gradient(52% 44% at 18% 22%, rgba(122,90,248,0.20) 0%, rgba(122,90,248,0) 70%),' +
'radial-gradient(46% 42% at 84% 18%, rgba(255,138,178,0.20) 0%, rgba(255,138,178,0) 70%),' +
'radial-gradient(58% 50% at 78% 84%, rgba(96,190,255,0.20) 0%, rgba(96,190,255,0) 70%),' +
'radial-gradient(50% 46% at 24% 88%, rgba(255,196,112,0.20) 0%, rgba(255,196,112,0) 70%),' +
'linear-gradient(180deg, #f7f6f9 0%, #f2f1f5 100%)';
// ── 卡内占位内容 ───────────────────────────────────────────────────────────
// 每张卡是一段 960×540 的独立小动效(各自从 frame 0 起播,被 <Freeze> 统一压住)。
// 落地时把这 8 个组件换成你自己的镜头卡/真实截图组件即可,环的语法不变。
const F = (frame: number, a: number, b: number, ease = Easing.out(Easing.cubic)) =>
interpolate(frame, [a, b], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: ease,
});
const Pad: React.FC<{ children: React.ReactNode; bg?: string }> = ({ children, bg = '#ffffff' }) => (
<div style={{ position: 'absolute', inset: 0, background: bg, padding: 56, fontFamily: SANS }}>
{children}
</div>
);
const Bar: React.FC<{ w: number; h?: number; color?: string; radius?: number }> = ({
w,
h = 16,
color = '#e3e3e8',
radius = 8,
}) => <div style={{ width: w, height: h, borderRadius: radius, background: color }} />;
/** 1 马克笔扫读:一行字被黄块自左盖过 */
const TileSweep: React.FC = () => {
const f = useCurrentFrame();
const s = F(f, 14, 34);
return (
<Pad>
<div style={{ display: 'flex', flexDirection: 'column', gap: 26 }}>
<Bar w={300} h={14} />
<div style={{ position: 'relative', width: 640, height: 40 }}>
<div
style={{
position: 'absolute',
left: -8,
top: 2,
width: 636 * s,
height: 36,
background: MARKER_COLOR,
borderRadius: 6,
}}
/>
<div style={{ position: 'absolute', inset: 0, display: 'flex', alignItems: 'center' }}>
<Bar w={620} h={22} color="#2a2a30" radius={4} />
</div>
</div>
<Bar w={480} h={14} />
</div>
</Pad>
);
};
/** 2 指标 + sparkline 描线 */
const SPARK = 'M8 118 L118 92 L228 104 L338 56 L448 68 L558 20 L632 34';
const TileMetric: React.FC = () => {
const f = useCurrentFrame();
const draw = F(f, 10, 46);
const val = 128 + Math.round(F(f, 8, 40, Easing.out(Easing.quad)) * 84);
return (
<Pad>
<div style={{ fontSize: 30, color: INK_DIM, letterSpacing: '0.06em' }}>SESSIONS</div>
<div
style={{
fontSize: 132,
fontWeight: 700,
color: INK,
fontVariantNumeric: 'tabular-nums',
lineHeight: 1.1,
}}
>
{val}
</div>
<svg width={640} height={140} viewBox="0 0 640 140" style={{ marginTop: 12 }}>
{/* 灰底轨迹:首帧就有内容,描线只是把它点亮 */}
<path d={SPARK} fill="none" stroke="#e3e3e8" strokeWidth={8} strokeLinecap="round" />
<path
d={SPARK}
fill="none"
stroke="#7A5AF8"
strokeWidth={8}
strokeLinecap="round"
strokeDasharray={900}
strokeDashoffset={900 * (1 - draw)}
/>
</svg>
</Pad>
);
};
/** 3 竖向步骤清单逐条勾选 */
const TileSteps: React.FC = () => {
const f = useCurrentFrame();
return (
<Pad>
<div style={{ display: 'flex', flexDirection: 'column', gap: 34 }}>
{[0, 1, 2, 3].map((i) => {
const on = F(f, 8 + i * 9, 22 + i * 9);
return (
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 26, opacity: 0.35 + on * 0.65 }}>
<div
style={{
width: 44,
height: 44,
borderRadius: 22,
background: on > 0.6 ? '#7A5AF8' : '#e3e3e8',
}}
/>
<Bar w={200 + i * 110} h={22} color="#2a2a30" radius={6} />
</div>
);
})}
</div>
</Pad>
);
};
/** 4 路线描线 + 落点钉针 */
const TileRoute: React.FC = () => {
const f = useCurrentFrame();
const draw = F(f, 6, 42);
const pin = spring({ frame: f - 36, fps: 30, config: { damping: 12 } });
return (
<Pad bg="#f4f4f7">
<svg width={848} height={428} viewBox="0 0 848 428">
<path
d="M40 380 C 200 380 190 210 340 200 C 500 190 500 90 700 70"
fill="none"
stroke="#c9c9d2"
strokeWidth={10}
strokeLinecap="round"
/>
<path
d="M40 380 C 200 380 190 210 340 200 C 500 190 500 90 700 70"
fill="none"
stroke="#7A5AF8"
strokeWidth={10}
strokeLinecap="round"
strokeDasharray={1100}
strokeDashoffset={1100 * (1 - draw)}
/>
<g transform={`translate(700 70) scale(${pin})`}>
<circle r={26} fill="#7A5AF8" />
<circle r={10} fill="#ffffff" />
</g>
</svg>
</Pad>
);
};
/** 5 深底终端打字 */
const TileTerminal: React.FC = () => {
const f = useCurrentFrame();
const rows = [520, 400, 610, 300];
return (
<Pad bg="#1d1d1f">
<div style={{ display: 'flex', gap: 14, marginBottom: 34 }}>
{['#ff5f57', '#febc2e', '#28c840'].map((c) => (
<div key={c} style={{ width: 20, height: 20, borderRadius: 10, background: c }} />
))}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}>
{/* 提示符常驻:首帧的终端里已经有东西,打字才是动效本身 */}
<Bar w={120} h={18} color="#7A5AF8" radius={4} />
{rows.map((w, i) => {
const p = F(f, 8 + i * 11, 20 + i * 11, Easing.linear);
return <Bar key={i} w={w * p} h={18} color="#4a4a52" radius={4} />;
})}
</div>
</Pad>
);
};
/** 6 线稿描画(铅笔速写) */
const TileSketch: React.FC = () => {
const f = useCurrentFrame();
const strokes = [
'M80 380 L768 380',
'M140 380 L200 90 L648 90 L708 380',
'M280 90 L280 380',
'M500 90 L500 380',
];
return (
<Pad>
<svg width={848} height={428} viewBox="0 0 848 428">
{/* 淡稿:首帧是一张有底稿的纸,描线把它落成实线 */}
{strokes.map((d, i) => (
<path key={`g${i}`} d={d} fill="none" stroke="#e7e7ec" strokeWidth={7} strokeLinecap="round" />
))}
{strokes.map((d, i) => {
const p = F(f, 6 + i * 10, 24 + i * 10);
return (
<path
key={i}
d={d}
fill="none"
stroke={INK}
strokeWidth={7}
strokeLinecap="round"
strokeDasharray={900}
strokeDashoffset={900 * (1 - p)}
/>
);
})}
</svg>
</Pad>
);
};
/** 7 卡片缩成胶囊(主体让位) */
const TileShrink: React.FC = () => {
const f = useCurrentFrame();
const p = F(f, 16, 44);
return (
<Pad bg="#f4f4f7">
<div
style={{
position: 'absolute',
left: 56,
top: 56,
width: 848 - 700 * p,
height: 428 - 350 * p,
borderRadius: 16 + 30 * p,
background: '#ffffff',
boxShadow: '0 0 0 1px rgba(29,29,31,0.08)',
}}
/>
<div
style={{
position: 'absolute',
left: 220,
top: 130,
display: 'flex',
flexDirection: 'column',
gap: 22,
opacity: p,
}}
>
<Bar w={420} h={20} color="#2a2a30" radius={6} />
<Bar w={330} h={20} />
<Bar w={380} h={20} />
</div>
</Pad>
);
};
/** 8 冲击字卡 */
const TileImpact: React.FC = () => {
const f = useCurrentFrame();
const p = spring({ frame: f - 8, fps: 30, config: { damping: 13, mass: 0.7 } });
return (
<Pad>
<div
style={{
position: 'absolute',
inset: 0,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: 26,
}}
>
{/* 常驻小行 = 首帧的锚,大字砸下来才有对比 */}
<div style={{ fontSize: 30, color: INK_DIM, letterSpacing: '0.4em' }}>2026 · Q3</div>
<div
style={{
fontSize: 128,
fontWeight: 800,
letterSpacing: '-0.04em',
color: INK,
transform: `scale(${0.72 + p * 0.28})`,
opacity: p,
}}
>
GO LIVE
</div>
</div>
</Pad>
);
};
const TILES: { Comp: React.FC; bg: string }[] = [
{ Comp: TileSweep, bg: '#ffffff' },
{ Comp: TileShrink, bg: '#f4f4f7' },
{ Comp: TileMetric, bg: '#ffffff' },
{ Comp: TileRoute, bg: '#f4f4f7' },
{ Comp: TileSteps, bg: '#ffffff' },
{ Comp: TileTerminal, bg: '#1d1d1f' },
{ Comp: TileSketch, bg: '#ffffff' },
{ Comp: TileImpact, bg: '#ffffff' },
];
// ── 主体 ───────────────────────────────────────────────────────────────────
export const OrbitRingTitleOpen: React.FC = () => {
const frame = useCurrentFrame();
const { fps } = useVideoConfig();
const t = frame / fps;
// 环入场:从中心撑开 + 逐卡淡入
const ringIn = interpolate(t, [0, RING_IN], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
});
const ringScale = RING_SCALE_FROM + (1 - RING_SCALE_FROM) * ringIn;
const rot = t * ROT_SPEED;
const markerScale = spring({ frame: frame - MARKER_AT_F, fps, config: { damping: 14 } });
const kickerIn = interpolate(t, KICKER_IN, [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const exitQ = interpolate(t, [EXIT_AT, EXIT_AT + EXIT_DUR], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
const chars = Array.from(HEADLINE);
// 逐字通道:位移只占前 H_TRAVEL解糊走满 H_DUR同起不同终 = blur-slide 的手感)
const charStyle = (i: number): React.CSSProperties => {
const at = H_LEAD + i * H_STAGGER;
const pMain = interpolate(t, [at, at + H_DUR], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: H_EASE,
});
const pTravel = interpolate(t, [at, at + H_TRAVEL], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: H_EASE,
});
return {
display: 'inline-block',
whiteSpace: 'pre',
position: 'relative',
zIndex: 1,
transformOrigin: '50% 55%',
opacity: pMain,
filter: `blur(${(1 - pMain) * (H_SIZE / 6)}px)`,
transform: `translateY(${(1 - pTravel) * H_SIZE * 0.22}px)`,
};
};
return (
<AbsoluteFill style={{ background: MESH_BG, fontFamily: SANS }}>
{TILES.map((tile, i) => {
const theta = -Math.PI / 2 + (i * Math.PI * 2) / N + rot;
const x = 960 + Math.cos(theta) * RX * ringScale;
const y = 540 + Math.sin(theta) * RY * ringScale;
const depth = Math.sin(theta); // +1 = 画面下方1 = 上方(远)
const s = 1 + DEPTH_SCALE * depth;
const op = interpolate(t, [0.06 + i * 0.05, 0.42 + i * 0.05], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<div
key={i}
style={{
position: 'absolute',
left: x - (CW * s) / 2,
top: y - (CH * s) / 2,
width: CW * s,
height: CH * s,
borderRadius: 10,
overflow: 'hidden',
background: tile.bg,
opacity: op,
zIndex: 10 + Math.round(depth * 5),
boxShadow: '0 0 0 1px rgba(29,29,31,0.08), 0 14px 34px rgba(16,24,40,0.12)',
}}
>
{/* 卡内按 960×540 作画再整体缩到卡宽——内容组件不需要知道自己被缩小了 */}
<div
style={{
position: 'absolute',
left: 0,
top: 0,
width: 960,
height: 540,
transform: `scale(${(CW * s) / 960})`,
transformOrigin: 'top left',
}}
>
<Freeze frame={Math.max(0, frame - PLAY_START)}>
<tile.Comp />
</Freeze>
</div>
</div>
);
})}
{/* 标题:整行退场时统一失焦淡出(逐字退场会读作第二次入场) */}
<AbsoluteFill style={{ alignItems: 'center', justifyContent: 'center', zIndex: 30 }}>
<div
style={{
fontSize: H_SIZE,
fontWeight: 600,
letterSpacing: '-0.05em',
color: INK,
whiteSpace: 'nowrap',
opacity: 1 - exitQ,
filter: exitQ > 0.01 ? `blur(${exitQ * EXIT_BLUR}px)` : undefined,
}}
>
{chars.slice(0, HL_START).map((ch, i) => (
<span key={i} style={charStyle(i)}>
{ch}
</span>
))}
{/* 高亮组:马克色块垫在这一段字底下,随字全部到位横扫铺开 */}
<span style={{ position: 'relative', display: 'inline-block' }}>
<span
aria-hidden
style={{
position: 'absolute',
inset: '0.06em -0.08em',
background: MARKER_COLOR,
transformOrigin: 'left center',
transform: `scaleX(${markerScale})`,
borderRadius: 6,
zIndex: 0,
}}
/>
{chars.slice(HL_START).map((ch, j) => (
<span key={j} style={charStyle(HL_START + j)}>
{ch}
</span>
))}
</span>
</div>
</AbsoluteFill>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
marginTop: 88,
textAlign: 'center',
fontFamily: MONO,
fontSize: 28,
fontWeight: 600,
letterSpacing: '0.35em',
color: INK_DIM,
opacity: kickerIn * (1 - exitQ),
filter: exitQ > 0.01 ? `blur(${exitQ * EXIT_BLUR}px)` : undefined,
zIndex: 30,
}}
>
{KICKER}
</div>
</AbsoluteFill>
);
};
@@ -0,0 +1,239 @@
// lead-word-zoom-assemble — 首词推近落位组句
// 首词以 2.3 倍字号占据画面中央hold 期间继续向观众推近 6%,随后一条曲线同时
// 完成「缩回终字号」与「整行左滑到最终排布」,后续词从右侧各自槽位外 0.5em 推入;
// 整行上移的同一时窗里副行浮出,停一拍后整幕 crash-zoom 推近失焦交棒下一镜。
//
// 手感命门是支点transform-origin 横向钉在**首词中心**、纵向钉在**基线**——
// 这两点是全程唯一不该移动的锚,挂载时实测一次(基线用零尺寸 inline-block 的
// offsetTop 读出),否则缩放过程中字会逐帧抖基线。
// 参数以 1920×1080 标定。
import React, { Fragment, useEffect, useRef, useState } from 'react';
import {
AbsoluteFill,
continueRender,
delayRender,
Easing,
getRemotionEnvironment,
interpolate,
useCurrentFrame,
useVideoConfig,
} from 'remotion';
export const LEAD_WORD_ZOOM_ASSEMBLE_DURATION = 84; // 2.8s @30fps
// ---- 编舞常量 ----
const TEXT = 'Introducing Lumen Deck';
const HIGHLIGHT_WORD = 'Lumen'; // 精确匹配的词换强调色:品牌词落位那一下自带高亮
const FONT_SIZE = 96;
const INITIAL_SCALE = 2.3; // 首词起手放大倍数
const INTRO_DURATION = 6; // f首词淡入
const HOLD_DURATION = 12; // f首词在峰值停留推近发生在这一段
const PUSH_SCALE = 1.06; // hold 期间继续推近的倍数
const RECEDE_DURATION = 12; // f缩回终字号
const ASSEMBLE_DURATION = 24; // f整行左滑到位
const WORD_DELAY = 6; // f缩回起点后第二个词开始推入
const WORD_STAGGER = 4; // f后续词之间的错峰
const WORD_DURATION = 12; // f单词推入行程
const WORD_PUSH = 0.5; // em后续词起始位置在自己槽位右侧多远
const WORD_FADE = 2; // f后续词淡入刻意极短——是被推进来的不是淡进来的
const LETTER_SPACING = '-0.03em';
// ---- 场景层 ----
const LIFT: [number, number] = [34, 50]; // f整行上移 + 副行同帧出现
const LIFT_DISTANCE = -56; // px
const SUBLINE = 'One shot card, one motion recipe — copy, paste, render.';
const CRASH_FRAMES = 12; // f段尾 crash-zoom 占用的收尾帧数
const CRASH_SCALE = 0.2;
const CRASH_BLUR = 9;
const INK = '#1d1d1f';
const INK_DIM = '#7a7a7a';
const ACCENT = '#7A5AF8';
const SANS = '-apple-system, "PingFang SC", BlinkMacSystemFont, sans-serif';
const MESH_BG =
'radial-gradient(52% 44% at 18% 22%, rgba(122,90,248,0.20) 0%, rgba(122,90,248,0) 70%),' +
'radial-gradient(46% 42% at 84% 18%, rgba(255,138,178,0.20) 0%, rgba(255,138,178,0) 70%),' +
'radial-gradient(58% 50% at 78% 84%, rgba(96,190,255,0.20) 0%, rgba(96,190,255,0) 70%),' +
'radial-gradient(50% 46% at 24% 88%, rgba(255,196,112,0.20) 0%, rgba(255,196,112,0) 70%),' +
'linear-gradient(180deg, #f7f6f9 0%, #f2f1f5 100%)';
/** 首词向观众漂移:先快后停,峰值处挂住 */
const PUSH_EASE = Easing.bezier(0.25, 1, 0.5, 1);
/** 缩回与左滑共用同一条曲线——两个动作必须读作一次运动,不能各走各的 */
const ZOOM_EASE = Easing.bezier(0.5, 0, 0.05, 1);
/** 后续词推入槽位:起步快,落地长而软 */
const WORD_EASE = Easing.bezier(0.22, 0.8, 0.36, 1);
const TextReveal: React.FC = () => {
const frame = useCurrentFrame();
const { width } = useVideoConfig();
// 整行宽度、首词中心占比、基线位置:只在挂载时量一次,量到之前挂起渲染
const lineRef = useRef<HTMLSpanElement>(null);
const leadRef = useRef<HTMLSpanElement>(null);
const baselineRef = useRef<HTMLSpanElement>(null);
const [handle] = useState(() => delayRender('lead-word-zoom-assemble: measure line'));
const [metrics, setMetrics] = useState<{
lineWidth: number;
leadRatio: number;
baseline: number;
} | null>(null);
useEffect(() => {
const line = lineRef.current;
const lead = leadRef.current;
if (!line || !lead) {
continueRender(handle);
return;
}
setMetrics({
lineWidth: line.offsetWidth,
leadRatio: (lead.offsetLeft + lead.offsetWidth / 2) / line.offsetWidth,
baseline: baselineRef.current?.offsetTop ?? line.offsetHeight * 0.8,
});
}, [handle]);
useEffect(() => {
if (metrics) continueRender(handle);
}, [metrics, handle]);
const words = TEXT.split(' ').filter(Boolean);
const ready = metrics !== null;
const leadRatio = metrics?.leadRatio ?? 0.14;
const lineWidth = metrics?.lineWidth ?? width * 0.5;
const baseline = metrics?.baseline ?? FONT_SIZE * 0.88;
const zoomStart = HOLD_DURATION;
// 首词从"画面正中"走到"行内自己的位置",位移就是这段偏心距
const slideDistance = lineWidth * (0.5 - leadRatio);
return (
<AbsoluteFill style={{ alignItems: 'center', justifyContent: 'center' }}>
<span
ref={lineRef}
style={{
position: 'relative',
display: 'inline-block',
fontSize: FONT_SIZE,
fontWeight: 600,
color: INK,
letterSpacing: LETTER_SPACING,
lineHeight: 1.1,
whiteSpace: 'nowrap',
fontFamily: SANS,
transformOrigin: `${leadRatio * 100}% ${baseline}px`,
scale:
interpolate(frame, [0, HOLD_DURATION], [INITIAL_SCALE, INITIAL_SCALE * PUSH_SCALE], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: PUSH_EASE,
}) +
interpolate(
frame,
[zoomStart, zoomStart + RECEDE_DURATION],
[0, 1 - INITIAL_SCALE * PUSH_SCALE],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: ZOOM_EASE },
),
translate: `${interpolate(
frame,
[zoomStart, zoomStart + ASSEMBLE_DURATION],
[slideDistance, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: ZOOM_EASE },
)}px`,
opacity: ready ? 1 : 0,
textRendering: 'geometricPrecision',
...(getRemotionEnvironment().isRendering ? null : { willChange: 'transform' as const }),
}}
>
{words.map((word, i) => {
const isLead = i === 0;
const pushStart = zoomStart + WORD_DELAY + (i - 1) * WORD_STAGGER;
const opacity = isLead
? interpolate(frame, [0, INTRO_DURATION], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
})
: interpolate(frame, [pushStart, pushStart + WORD_FADE], [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
});
return (
<Fragment key={i}>
<span
ref={isLead ? leadRef : undefined}
style={{
display: 'inline-block',
opacity,
color: word === HIGHLIGHT_WORD ? ACCENT : undefined,
translate: isLead
? undefined
: `${interpolate(
frame,
[pushStart, pushStart + WORD_DURATION],
[WORD_PUSH * FONT_SIZE, 0],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: WORD_EASE },
)}px`,
}}
>
{word}
</span>
{/* 词间空格必须放在 inline-block 之外——跟在词里会被行盒裁掉,词会黏在一起 */}
{i < words.length - 1 ? ' ' : null}
</Fragment>
);
})}
{/* 基线尺:零尺寸 inline-block它的 offsetTop 就是基线 */}
<span ref={baselineRef} style={{ display: 'inline-block', width: 0, height: 0 }} />
</span>
</AbsoluteFill>
);
};
export const LeadWordZoomAssemble: React.FC = () => {
const frame = useCurrentFrame();
const { durationInFrames } = useVideoConfig();
const lift = interpolate(frame, LIFT, [0, 1], {
extrapolateLeft: 'clamp',
extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
});
const crash = interpolate(
frame,
[durationInFrames - CRASH_FRAMES, durationInFrames - 1],
[0, 1],
{ extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.in(Easing.quad) },
);
return (
<AbsoluteFill style={{ background: MESH_BG, fontFamily: SANS }}>
<AbsoluteFill
style={{
transform: `scale(${1 + crash * CRASH_SCALE})`,
filter: crash > 0.01 ? `blur(${crash * CRASH_BLUR}px)` : undefined,
opacity: 1 - crash * 0.55,
}}
>
<AbsoluteFill style={{ transform: `translateY(${lift * LIFT_DISTANCE}px)` }}>
<TextReveal />
</AbsoluteFill>
<div
style={{
position: 'absolute',
left: 0,
right: 0,
top: '50%',
marginTop: 62,
textAlign: 'center',
fontSize: 32,
color: INK_DIM,
opacity: lift,
transform: `translateY(${(1 - lift) * 16}px)`,
}}
>
{SUBLINE}
</div>
</AbsoluteFill>
</AbsoluteFill>
);
};
File diff suppressed because one or more lines are too long
+10 -10
View File
@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark">
<meta name="google-site-verification" content="Qu8vMAC99goV9xnwf_0ZlGGimtmj24TWFNVUiTz2mhM">
<meta name="description" content="video-shotcraft is an AI agent skill for Claude Code &amp; Codex that makes cinematic product promo videos with Remotion. 155 shot recipe cards organized by function, tuned demos, and a production-ready template.">
<meta name="description" content="video-shotcraft is an AI agent skill for Claude Code &amp; Codex that makes cinematic product promo videos with Remotion. 157 shot recipe cards organized by function, tuned demos, and a production-ready template.">
<link rel="canonical" href="https://vincentwei1021.github.io/video-shotcraft/">
<meta property="og:type" content="website">
<meta property="og:site_name" content="video-shotcraft">
<meta property="og:title" content="video-shotcraft — AI Video Skill for Claude Code &amp; Codex">
<meta property="og:description" content="An agent skill for crafting cinematic product videos with Remotion: 155 shot recipe cards, 212 motion previews, and a production-ready promo template.">
<meta property="og:description" content="An agent skill for crafting cinematic product videos with Remotion: 157 shot recipe cards, 214 motion previews, and a production-ready promo template.">
<meta property="og:url" content="https://vincentwei1021.github.io/video-shotcraft/">
<meta property="og:image" content="https://vincentwei1021.github.io/video-shotcraft/og-image.jpg?v=20260726b">
<meta property="og:image:width" content="1280">
@@ -18,7 +18,7 @@
<meta property="og:image:alt" content="video-shotcraft Gallery — shot recipes and motion samples">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="video-shotcraft — AI Video Skill for Claude Code &amp; Codex">
<meta name="twitter:description" content="An agent skill for crafting cinematic product videos with Remotion: 155 shot recipe cards, 212 motion previews, and a production-ready promo template.">
<meta name="twitter:description" content="An agent skill for crafting cinematic product videos with Remotion: 157 shot recipe cards, 214 motion previews, and a production-ready promo template.">
<meta name="twitter:image" content="https://vincentwei1021.github.io/video-shotcraft/og-image.jpg?v=20260726b">
<link rel="icon" href="./logo-mark.svg" type="image/svg+xml">
<title>video-shotcraft — AI Video Skill for Claude Code &amp; Codex | Cinematic Product Videos with Remotion</title>
@@ -27,7 +27,7 @@
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "video-shotcraft",
"description": "An AI agent skill for Claude Code and Codex that crafts cinematic product promo videos with Remotion: 155 shot recipe cards organized by function, 212 styles, 212 motion previews, and a production-ready 36.2-second video template.",
"description": "An AI agent skill for Claude Code and Codex that crafts cinematic product promo videos with Remotion: 157 shot recipe cards organized by function, 214 styles, 214 motion previews, and a production-ready 36.2-second video template.",
"url": "https://vincentwei1021.github.io/video-shotcraft/",
"sameAs": ["https://github.com/Vincentwei1021/video-shotcraft"],
"applicationCategory": "MultimediaApplication",
@@ -431,7 +431,7 @@
<main class="hero">
<p class="hero-kicker" data-l10n="kicker">An agent skill for Claude Code &amp; Codex</p>
<h1 data-l10n="heroTitle">155 cinematic shot recipes.<br><em>One prompt</em> to a finished promo.</h1>
<h1 data-l10n="heroTitle">157 cinematic shot recipes.<br><em>One prompt</em> to a finished promo.</h1>
<p class="hero-sub" data-l10n="heroSub">Every motion you see behind this page is a tuned, ready-to-use shot recipe card — your agent picks the shots, animates real product screenshots with Remotion, syncs the beats, and delivers the film.</p>
<div class="hero-actions">
<a class="button-primary" href="./library.html" data-l10n="ctaLibrary">Browse all motions</a>
@@ -443,9 +443,9 @@
</a>
</div>
<ul class="hero-stats" aria-label="Library statistics">
<li><strong id="statCards">155</strong><span data-l10n="statCards">recipe cards</span></li>
<li><strong id="statStyles">212</strong><span data-l10n="statStyles">styles</span></li>
<li><strong id="statSamples">212</strong><span data-l10n="statSamples">motion previews</span></li>
<li><strong id="statCards">157</strong><span data-l10n="statCards">recipe cards</span></li>
<li><strong id="statStyles">214</strong><span data-l10n="statStyles">styles</span></li>
<li><strong id="statSamples">214</strong><span data-l10n="statSamples">motion previews</span></li>
<li><strong>36.2s</strong><span data-l10n="statTemplate">ready template</span></li>
</ul>
</main>
@@ -458,7 +458,7 @@
const L10N = {
en: {
kicker: 'An agent skill for Claude Code & Codex',
heroTitle: '155 cinematic shot recipes.<br><em>One prompt</em> to a finished promo.',
heroTitle: '157 cinematic shot recipes.<br><em>One prompt</em> to a finished promo.',
heroSub: 'Every motion you see behind this page is a tuned, ready-to-use shot recipe card — your agent picks the shots, animates real product screenshots with Remotion, syncs the beats, and delivers the film.',
ctaLibrary: 'Browse all motions',
ctaStar: 'Star on GitHub',
@@ -471,7 +471,7 @@
},
zh: {
kicker: '一个 Claude Code / Codex 的 agent skill',
heroTitle: '155 张镜头配方卡<br>宣传片<em>一键直出</em>',
heroTitle: '157 张镜头配方卡<br>宣传片<em>一键直出</em>',
heroSub: '页面背后滚动的每个动效都是一张调校好、即取即用的镜头配方卡。agent 负责选镜头、给真实产品截图做动画、卡上节奏,直接交付成片。',
ctaLibrary: '浏览全部动效',
ctaStar: '收藏 GitHub',
+17 -7
View File
@@ -5,12 +5,12 @@
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="dark light">
<meta name="google-site-verification" content="Qu8vMAC99goV9xnwf_0ZlGGimtmj24TWFNVUiTz2mhM">
<meta name="description" content="video-shotcraft is an AI agent skill for Claude Code &amp; Codex that makes cinematic product promo videos with Remotion. Browse 155 shot recipe cards and 212 motion previews.">
<meta name="description" content="video-shotcraft is an AI agent skill for Claude Code &amp; Codex that makes cinematic product promo videos with Remotion. Browse 157 shot recipe cards and 214 motion previews.">
<link rel="canonical" href="https://vincentwei1021.github.io/video-shotcraft/library.html">
<meta property="og:type" content="website">
<meta property="og:site_name" content="video-shotcraft">
<meta property="og:title" content="video-shotcraft — AI Video Skill for Claude Code &amp; Codex">
<meta property="og:description" content="An agent skill for crafting cinematic product videos with Remotion: 155 shot recipe cards, 212 motion previews, and a production-ready promo template.">
<meta property="og:description" content="An agent skill for crafting cinematic product videos with Remotion: 157 shot recipe cards, 214 motion previews, and a production-ready promo template.">
<meta property="og:url" content="https://vincentwei1021.github.io/video-shotcraft/library.html">
<meta property="og:image" content="https://vincentwei1021.github.io/video-shotcraft/og-image.jpg?v=20260726b">
<meta property="og:image:width" content="1280">
@@ -18,7 +18,7 @@
<meta property="og:image:alt" content="video-shotcraft Gallery — shot recipes and motion samples">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="video-shotcraft — AI Video Skill for Claude Code &amp; Codex">
<meta name="twitter:description" content="An agent skill for crafting cinematic product videos with Remotion: 155 shot recipe cards, 212 motion previews, and a production-ready promo template.">
<meta name="twitter:description" content="An agent skill for crafting cinematic product videos with Remotion: 157 shot recipe cards, 214 motion previews, and a production-ready promo template.">
<meta name="twitter:image" content="https://vincentwei1021.github.io/video-shotcraft/og-image.jpg?v=20260726b">
<link rel="icon" href="./logo-mark.svg" type="image/svg+xml">
<title>video-shotcraft — AI Video Skill for Claude Code &amp; Codex | Remotion Shot Library</title>
@@ -27,7 +27,7 @@
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"name": "video-shotcraft",
"description": "An AI agent skill for Claude Code and Codex that crafts cinematic product promo videos with Remotion: 155 shot recipe cards, 212 styles, 212 motion previews, and a production-ready 36.2-second video template.",
"description": "An AI agent skill for Claude Code and Codex that crafts cinematic product promo videos with Remotion: 157 shot recipe cards, 214 styles, 214 motion previews, and a production-ready 36.2-second video template.",
"url": "https://vincentwei1021.github.io/video-shotcraft/",
"sameAs": ["https://github.com/Vincentwei1021/video-shotcraft"],
"applicationCategory": "MultimediaApplication",
@@ -164,9 +164,9 @@
</div>
<dl class="stats" aria-label="Shot library statistics" data-i18n-aria-label="statsLabel">
<div><dt data-i18n="cards">Cards</dt><dd id="cardCount">155</dd></div>
<div><dt data-i18n="styles">Styles</dt><dd id="styleCount">212</dd></div>
<div><dt data-i18n="samples">Samples</dt><dd id="previewCount">212</dd></div>
<div><dt data-i18n="cards">Cards</dt><dd id="cardCount">157</dd></div>
<div><dt data-i18n="styles">Styles</dt><dd id="styleCount">214</dd></div>
<div><dt data-i18n="samples">Samples</dt><dd id="previewCount">214</dd></div>
</dl>
</section>
@@ -511,6 +511,11 @@
<p>A page flips 180 degrees into an integration hub where five app icons pop in together and rainbow light tubes connect with pulses flowing through them.</p>
<p>Styles: integration-hub-map</p>
</article>
<article class="shot-card" id="lead-word-zoom-assemble">
<h3>lead-word-zoom-assemble</h3>
<p>The lead word fills the frame at 2.3x and keeps drifting toward the viewer, then a single curve recedes it to size while the line slides left and the trailing words are pushed into their slots — pivoting on the lead word centre and the measured baseline.</p>
<p>Styles: lead-word-zoom-assemble</p>
</article>
<article class="shot-card" id="letterspace-materialize">
<h3>letterspace-materialize</h3>
<p>Every letter of a widely tracked wordmark draws itself in parallel, growing like handwriting and finishing together on the same frame.</p>
@@ -591,6 +596,11 @@
<p>A full-screen metric rolls each digit like an odometer, overshoots into place, and pulses when every column locks.</p>
<p>Styles: odometer-digit-roll</p>
</article>
<article class="shot-card" id="orbit-ring-title-open">
<h3>orbit-ring-title-open</h3>
<p>Eight 16:9 content cards orbit an ellipse at a constant rate with their contents frozen on the first frame, then all start playing together while the centred headline de-blurs into place and a marker block sweeps across the key phrase.</p>
<p>Styles: orbit-ring-title-open</p>
</article>
<article class="shot-card" id="outline-word-fill">
<h3>outline-word-fill</h3>
<p>An outlined word snaps down from triple size while a dashed circle and horizontal guides close in, then the stroke flips to solid white in a single frame with one brief glow.</p>
+3 -1
View File
@@ -1,6 +1,6 @@
# video-shotcraft
> video-shotcraft is an AI agent skill for Claude Code and Codex that crafts cinematic product promo videos with Remotion. It ships 155 shot recipe cards covering 212 styles with 212 motion previews, reusable Remotion components, SFX assets, a six-stage production methodology, and a production-ready 36.2-second promo template (Ink Press).
> video-shotcraft is an AI agent skill for Claude Code and Codex that crafts cinematic product promo videos with Remotion. It ships 157 shot recipe cards covering 214 styles with 214 motion previews, reusable Remotion components, SFX assets, a six-stage production methodology, and a production-ready 36.2-second promo template (Ink Press).
Each shot recipe card documents a motion technique: purpose, energy level, suggested duration, tuned parameters, implementation notes, and known pitfalls, paired with a tuned Remotion (TSX) reference implementation.
@@ -24,6 +24,7 @@ Each shot recipe card documents a motion technique: purpose, energy level, sugge
- [icon-field-colorize](https://vincentwei1021.github.io/video-shotcraft/library.html#icon-field-colorize): A grid of grayscale icons staggers in to fill the screen, holds a beat, then brand-color bands ripple down and light the whole field at once.
- [letterspace-materialize](https://vincentwei1021.github.io/video-shotcraft/library.html#letterspace-materialize): Every letter of a widely tracked wordmark draws itself in parallel, growing like handwriting and finishing together on the same frame.
- [magician-card-flourish](https://vincentwei1021.github.io/video-shotcraft/library.html#magician-card-flourish): A blue starburst flashes on black for a third of a second, then a card shoots out of the flash, spinning along an arc toward camera before freezing near full frame.
- [orbit-ring-title-open](https://vincentwei1021.github.io/video-shotcraft/library.html#orbit-ring-title-open): Eight 16:9 content cards orbit an ellipse at a constant rate with their contents frozen on the first frame, then all start playing together while the centred headline de-blurs into place and a marker block sweeps across the key phrase.
- [spotlight-hero-card](https://vincentwei1021.github.io/video-shotcraft/library.html#spotlight-hero-card): A spotlight selects one card, the camera pushes diagonally, the card lifts for two outline passes, then returns to the page.
- [stroke-segment-build](https://vincentwei1021.github.io/video-shotcraft/library.html#stroke-segment-build): Disconnected title strokes illuminate in a scrambled order and become readable only when the final segments snap into place.
- [text-as-mask](https://vincentwei1021.github.io/video-shotcraft/library.html#text-as-mask): Product footage moves inside oversized title letters before the type expands until the footage takes over the frame.
@@ -38,6 +39,7 @@ Each shot recipe card documents a motion technique: purpose, energy level, sugge
- [flying-words](https://vincentwei1021.github.io/video-shotcraft/library.html#flying-words): Keywords spawn deep in a perspective tunnel and rush past the camera along the z axis, fading in and out on a lifecycle curve across two seamless loops.
- [glitch-cycle](https://vincentwei1021.github.io/video-shotcraft/library.html#glitch-cycle): Status phrases cycle through one monospace slot, each dissolving into noise at both ends with RGB split and jitter, while a progress bar tracks the whole run.
- [gradient-word-sweep](https://vincentwei1021.github.io/video-shotcraft/library.html#gradient-word-sweep): A gradient light sweeps left to right across a keyword, charging each character with glow that decays behind the wavefront until the whole word breathes.
- [lead-word-zoom-assemble](https://vincentwei1021.github.io/video-shotcraft/library.html#lead-word-zoom-assemble): The lead word fills the frame at 2.3x and keeps drifting toward the viewer, then a single curve recedes it to size while the line slides left and the trailing words are pushed into their slots — pivoting on the lead word centre and the measured baseline.
- [marker-underline-title](https://vincentwei1021.github.io/video-shotcraft/library.html#marker-underline-title): After the headline settles, a marker stroke draws itself under the keyword — tapered, rough-edged, and slightly tilted to follow the italic.
- [outline-word-fill](https://vincentwei1021.github.io/video-shotcraft/library.html#outline-word-fill): An outlined word snaps down from triple size while a dashed circle and horizontal guides close in, then the stroke flips to solid white in a single frame with one brief glow.
- [paper-title-card](https://vincentwei1021.github.io/video-shotcraft/library.html#paper-title-card): A sentence stamps onto paper word by word, highlights one amber italic term, and closes with a short rule.
+89
View File
@@ -0,0 +1,89 @@
---
name: lead-word-zoom-assemble
一句话: 首词以 2.3 倍字号占据画面中央、hold 期间继续推近 6%,随后一条曲线同时完成「缩回终字号」与「整行左滑归位」,后续词各自从槽位右侧 0.5em 被推进来;支点横向钉首词中心、纵向钉基线(挂载时实测),整行上移的同一时窗副行浮出,停一拍后整幕 crash-zoom 推近失焦交棒
适用: 品牌名/产品名的 Introducing 字卡;发布会式开场第二镜;一句话主张需要"先让一个词占满画面、再把整句补齐"的场合
时长: 约 2.8s84f@30fps;组句 f32 完成、上移与副行 f3450、静止 22f、末 12f crash
能量: 中高(起手就是满画面的大字,但全程只有一次运动,没有二次冲击)
标签: opening
---
## 意图
字卡最容易犯的错是"整句一起淡入"——观众同时收到 5 个词,一个都没记住。这张卡把
阅读顺序做成**物理顺序**:先只给一个词,而且大到占满画面、还在继续朝观众推近,观众
被迫先读它;然后这个词退回自己在句子里的尺寸和位置,整句围绕它长出来。落定时观众
已经读过两遍主词。
真正的手感命门不是缩放曲线,而是**支点**。首词从"画面正中"走到"行内第三个字符的
位置",如果支点取行盒中心,缩放和左滑会各自把字往两边拽,末尾几帧基线抖得肉眼可见。
`transform-origin` 横向钉在首词中心、纵向钉在基线,这两点全程不动,整行就只是
"收小",没有任何漂移。
## 动效核心
- **两段缩放叠加成一条 scale**
`scale = interpolate(f, [0,12], [2.3, 2.3·1.06], PUSH_EASE)
+ interpolate(f, [12,24], [0, 1-2.3·1.06], ZOOM_EASE)`。
第一段是 hold 期间的推近(`cubic-bezier(.25,1,.5,1)`,减速后挂在峰值),第二段是缩回
(从峰值一路减到终字号 1。写成相加而不是分段判断两段之间没有速度断点
- **缩回与左滑共用 ZOOM_EASE**`cubic-bezier(.5,0,.05,1)`,重 ease-in-out、零回弹
`translate = interpolate(f, [12,36], [slideDistance, 0], ZOOM_EASE)`。两个动作必须读作
**一次**运动;各走各的曲线时会读成"先缩小、再滑走"两拍
- **偏心距怎么来**`slideDistance = lineWidth · (0.5 - leadRatio)``leadRatio` =
首词中心在整行里的占比。首词要停在画面正中,整行就得先向右偏这么多
- **支点实测**:挂载时 `delayRender` 挂起渲染,量三个值——整行 `offsetWidth`
首词 `offsetLeft + offsetWidth/2`、基线。基线用一个**零尺寸 inline-block 尺子**读出
`<span style={{display:'inline-block',width:0,height:0}}>``offsetTop` 就是基线),
`lineHeight × 0.8` 之类的估算准得多。量到之后 `continueRender``transform-origin`
写成 `${leadRatio*100}% ${baseline}px`
- **后续词是被推进来的,不是淡进来的**:第 i 词i≥1
`pushStart = 12 + 6 + (i-1)·4` 起,`translate``0.5em → 0`
`cubic-bezier(.22,.8,.36,1)`,起步快、落地长),透明度只用 2f 淡完。淡入必须极短,
否则词会"浮现"而不是"被塞进槽位"
- **词间空格必须放在 inline-block 之外**。跟在词 span 内部尾随时会被行盒裁掉,
落定后所有词黏成一团(上游原组件的 bug这里已修
- **场景层的三拍收尾**f3450 整行 `translateY 0→56px`outCubic副行在**同一时窗**
淡入 + 上移 16px——上移让出的空间当帧被副行填掉没有空当f5072 完全静止 22f
f7283 整幕 `scale 1→1.2` + `blur 0→9px` + `opacity 1→0.45`ease-in-quad推近失焦
交棒下一镜
- **强调词**精确匹配某个词换强调色demo 里是 `Lumen`)。品牌词推入落位那一下自带
高亮,不用再加第二个动作
## 参数表
| 参数 | 典型值 | 调节手感 |
|------|--------|----------|
| 首词起手倍数 | initialScale 2.3 | 2.3 让首词横向溢出画面一点点(读作"太大了装不下"<1.8 只是"稍大的字",冲击消失 |
| 推近 | pushScale 1.06,占满 hold 12f | 6% 是"还在朝观众来"的最小可感量;>1.15 会在缩回时读作反向弹跳 |
| hold | 12f | 首词独占画面的时长;<8f 观众读不完一个长单词,>20f 整句节奏塌 |
| 缩回 | 12fZOOM_EASE | 与左滑同曲线;单独给缩回一条更快的曲线会让整行"先缩完再滑",断成两拍 |
| 左滑 | assembleDuration 24f | 比缩回长一倍:字号已经落定、位置还在走,这段"滑行尾巴"是电影感的来源 |
| 后续词延迟 | wordDelay 6f缩回起点之后 | 首词开始缩回才放后续词;同帧放会挤在一起看不清谁在动 |
| 词间错峰 | wordStagger 4f | 4f 读作"依次被推进"0 则整段后缀一起进,回到"整句淡入" |
| 单词推入 | wordDuration 12fwordPush 0.5em | 0.5em 是"刚从槽位外面">1em 会读作从画外飞入,与首词的收束方向打架 |
| 词淡入 | wordFade 2f | 刻意极短。>6f 词会读作"浮现",推入的力学感消失 |
| 字号/字重/字距 | 96px / 600 / 0.03em | 负字距在放大 2.3 倍时才不会散架;正字距的大字会读作"标语横幅" |
| 上移 + 副行 | f345056px副行同窗 | 两件事必须同窗:先上移再出副行会多出一个空当拍 |
| 静止 | 22f | 落定后的可读时间,低于 15f 观众来不及读副行 |
| crash | 末 12fscale→1.2blur→9px | 这是转场的**前半**,动机要和下一镜的第一帧对上;单独播时看起来像"没结束"是正常的 |
## 已知坑
- **支点错了就抖基线**`transform-origin` 必须是「首词中心 % + 基线 px」这一对。
取行盒中心(默认 `50% 50%`)时缩放与左滑互相拉扯,末尾几帧基线肉眼可见地跳
- 基线别估算。`lineHeight × 0.8` 这类经验值在不同字体上差几像素,放大 2.3 倍后就是
十几像素的抖动。零尺寸 inline-block 尺子的 `offsetTop` 是唯一可靠的读法
- `delayRender` 期间整行 `opacity: 0`——**必须**如此。否则未量到支点的第一帧会闪一下
错位的大字,成片里就是一帧脏帧
- 布局是挂载时实测的,所以**结果随渲染环境的字体而变**:跨平台字体回退不同时整行宽度
会整体漂移(首词占比也跟着变)。介意的话把量到的三个值写死成常量
- `scale` / `translate` 用的是独立 CSS 属性(不是 `transform` 简写)。混用会让其中一个
被覆盖;要合并成 `transform` 就必须自己保证 `translate → scale` 的顺序
- 词间空格放进词 span 内部会被行盒裁掉(见上)。这个 bug 在小字号下不明显96px 时
所有词会明显黏连
- 首词必须是**短词**。"Introducing" 这类 10 字符左右刚好;首词超过 14 字符时 2.3 倍
放大会溢出画面两侧,观众读不到词尾
- 强调色只给一个词。两个词都上色时观众不知道该记哪个,而且推入落位的高亮变成了
"彩色标题",动作的意义被抹掉
- 末段 crash 是转场的前半式,不是收尾。单独当片尾用会显得"断了"——要收尾就把
crash 换成静止 hold 或淡出
## 参考实现
demos/typography/lead-word-zoom-assemble/
LeadWordZoomAssemble.tsx
+85
View File
@@ -0,0 +1,85 @@
---
name: orbit-ring-title-open
一句话: 八张 16:9 内容卡按 45° 均布在 700×375 椭圆上匀速公转(卡身永不倾斜,纵深只由 sin θ 给出 ±9% 缩放与 z 序环撑开期间卡内容冻结首帧、f24 之后八张一起开播居中标题逐字解糊下沉落定关键词到位那一刻黄色马克块自左横扫铺满mono 副行随后浮出,末段整行失焦淡出、环继续转着交棒下一镜
适用: 开场第一镜 = 「这个产品有一整套东西」;素材库/模板库/功能矩阵/案例集的门面拍;需要用真实运动的产品画面托住一句主张,而不是把截图摆成静态九宫格
时长: 约 4.3s130f@30fps;标题落定 f50 到退场 f108 之间是 1.9s 稳态巡回)
能量: 中(唯一的持续运动是匀速公转,全片只有马克块横扫一次冲击)
标签: ui-entrance
---
## 意图
开场想说"我们有一整套东西",最常见的做法是把截图排成网格——但网格是静止的,观众
读到的是"一堆图片",不是"一套会动的产品"。这张卡把陈列改成**环形巡回**:八张卡沿
椭圆匀速公转,永远有卡在进画、有卡在出画,画面自带"还有更多"的暗示,而标题稳稳
钉在正中间不受影响。
关键的取舍是**卡内容要真的在播**。静态截图上环,观众只会认出"轮播图";卡里跑着真
动效,同一个环立刻变成"产品在工作"。但八张卡同时开播会八个动效抢戏,谁也读不进去
——所以入场期把卡内容**冻结在首帧**,等环撑开、标题起手之后再统一开播。观众的注意
力顺序因此是:先认出这是一圈产品画面 → 读标题 → 才注意到卡里都在动。
## 动效核心
- **环上定位**`θ = -π/2 + i·2π/8 + t·0.3``x = 960 + cos θ·RX·ringScale`
`y = 540 + sin θ·RY·ringScale`。RY/RX ≈ 0.54 的椭圆读作"俯视的环";正圆会读作转盘
- **卡身永不倾斜**:卡只跟着 θ 换位置,自身 transform 只有缩放,没有任何旋转。
纵深完全交给 `depth = sin θ`+1 = 画面下方1 = 上方,远):
`scale = 1 + 0.09·depth``zIndex = 10 + round(depth·5)`。缩放和 z 序必须由同一个
depth 驱动,否则会出现"远处的卡压住近处的卡"
- **环入场**f021 `ringScale 0.62→1`outCubic从中心撑开同时逐卡淡入
第 i 张的窗口是 `[0.06+0.05i, 0.42+0.05i]s`,最后一张 f23 才满。撑开与淡入叠在
一起,读作"环长出来"而不是"卡片一张张贴上来"
- **冻结首帧再开播**:卡内容包在 `<Freeze frame={max(0, frame - 24)}>` 里。f24 之前
八张卡都停在自己的第 0 帧f24 起同时进入正常时间轴。24f = 环撑开(21f) + 一点余量
- **卡内坐标解耦**:卡内容按 960×540 作画,外层用 `scale((CW·s)/960)` 缩到卡宽 +
`transform-origin: top left`。内容组件不需要知道自己被缩小了,落地换成真实截图或
库内其他镜头卡组件时不用改一行
- **标题 blur-slide 逐字**:每字两条通道同起不同终——解糊走满 0.9s
`blur (1-p)·fontSize/6`),下沉只占前 0.3s`translateY (1-p)·fontSize·0.22`)。
字间错峰 1 帧0.0333s),缓动 `cubic-bezier(.22,1,.36,1)`。位移先停、模糊后停,
字才有"落定后继续对上焦"的手感
- **马克块横扫**`spring(frame-40, damping 14)` 驱动 `scaleX`
`transform-origin: left center`,色块 `position:absolute; inset:.06em -.08em; zIndex:0`
垫在高亮词组底下,词自身 `position:relative; zIndex:1`。缓动前载f401.33s)时末
字的 ease 值已 ≈0.95,感知上"字全到位了才扫",实际不必等到 f50 数学收敛
- **退场**f108118 整行 `opacity 1→0` + `blur 0→6.5px`,副行同窗同曲线。之后 12f
只有环在转——这段空档就是交棒位,接 crash-zoom / 穿浅场直切都不用额外铺垫
## 参数表
| 参数 | 典型值 | 调节手感 |
|------|--------|----------|
| 椭圆半径 | RX=700 / RY=375卡 380×214 | RY/RX≈0.54 是"俯视环"的甜点;比值 →1 读作正圆转盘→0.3 卡会在上下缘挤成一条 |
| 卡数 | 8方位角间隔 45° | 8 张在这个半径上任意转角互不遮挡≥10 张必须同步加大 RX/RY否则近处卡压边 |
| 公转速度 | 0.3 rad/s4.3s 转约 74° | **别因为"看起来慢"就调快**>0.5 rad/s 时卡扫过画面边缘会有频闪感,且喧宾夺主 |
| 环入场 | f021scale .62→1outCubic | <15f 像弹窗;>30f 标题起手时环还在长,两件事互相盖 |
| 逐卡淡入 | 窗口 `[0.06+0.05i, 0.42+0.05i]`s | 步长 0.05s 刚够读出"依次">0.1s 最后一张会晚到标题之后 |
| 开播帧 | PLAY_START=24f | 必须 ≥ 环撑开结束帧;提前会让八个动效在入场期一起抢戏 |
| 纵深缩放 | ±9% | >15% 顶部卡会缩成图标,环读作"透视隧道"=0 则整环扁平,失去前后关系 |
| 标题 | 64px / 600 字重 / 字距 0.05em | 环一直在动,标题必须够重才压得住;<48px 或 400 字重会被环吃掉 |
| 逐字节奏 | lead .35s,解糊 .9s,下沉 .3s,错峰 1f | 错峰 1f 是"整体解糊"的手感≥3f 会读成逐字打字机 |
| 马克块 | f40 起spring damping 14#facc15 | damping <10 会回弹出色块边缘;起扫早于末字 ease 0.9 时读作"色块追着字跑" |
| 副行 | mono 28px字距 0.35em1.01.5s 淡入 | 大字距 mono 是"产品代号"的读法;跟标题同字体会读作第二行标题 |
| 退场 | f108118blur 0→6.5px | 整行统一失焦;**逐字退场会读作第二次入场** |
## 已知坑
- **卡不能跟着 θ 倾斜**。给卡加 `rotate(θ)` 后环立刻从"陈列"变成"过山车",而且卡内
文字会歪,可读性直接归零。卡只换位置,不换角度
- 卡内容首帧必须自带静态骨架(底色/边框/标签/灰底轨迹)。冻结期显示的就是第 0 帧,
如果卡的动效是"从零画出来",入场那 24f 就是八张空白卡。demo 里每个占位卡都补了
灰底 sparkline / 淡稿线 / 常驻提示符,就是为了让首帧不空
- `zIndex` 必须跟着 depth 走。只做缩放不排 z 序时DOM 顺序会让后面的卡压住前面的,
近大远小的立体感当场作废
- 马克色块和字的层级是一对:色块 `zIndex:0` + 字 `position:relative; zIndex:1`。漏掉
字上的 relative色块会盖住字stacking context 里 static 元素排在 positioned 之后)
- 标题 `white-space: nowrap` 是马克块的前提——`inset:.06em -.08em` 假设高亮词组在同
一行盒里。换行后色块会跨行断裂成两截
- 环是背景层但它一直在动,所以**不要在环上再叠第二个动的东西**(粒子、扫光、渐变
呼吸)。已经有一个持续运动在场,第二个会让画面读不出主次
- 与 `carousel-3d` 的分工:那张卡是真 3D`rotateY + translateZ` + backface-hidden
卡会转到环背面,适合无缝 loop 的纯陈列;这张卡是 2D 椭圆伪纵深,卡永远正面朝观众,
适合"环托底 + 前景标题"的开场。要卡片翻面用前者,要标题压得住用后者
- demo 的八张卡是原创通用 DOM 占位(灰阶骨架 + 一条强调色),不含任何产品截图;
落地时整块替换成你自己的镜头卡组件或真实页面截图即可,环的语法一行都不用改
## 参考实现
demos/opening/orbit-ring-title-open/
OrbitRingTitleOpen.tsx
+9
View File
@@ -309,8 +309,12 @@ window.GALLERY_I18N = {
'platform-hinge-rise': '平台铰接升起',
'ring-diagram-annotation-reveal': '环图收束标注',
'cycle-glass-node-morph': '循环玻璃节点接管',
'orbit-ring-title-open': '环形卡阵标题开场',
'lead-word-zoom-assemble': '首词推近组句',
},
cardsEn: {
'orbit-ring-title-open': 'Eight 16:9 content cards orbit an ellipse at a constant rate with their contents frozen on the first frame, then all start playing together while the centred headline de-blurs into place and a marker block sweeps across the key phrase.',
'lead-word-zoom-assemble': 'The lead word fills the frame at 2.3x and keeps drifting toward the viewer, then a single curve recedes it to size while the line slides left and the trailing words are pushed into their slots — pivoting on the lead word centre and the measured baseline.',
'platform-hinge-rise': 'A support platform establishes first, two subjects hinge upward from adjacent lower pivots, and a restrained outcome wedge rises last to turn evidence into a conclusion.',
'ring-diagram-annotation-reveal': 'A full-frame subject collapses into a concentric mechanism diagram, adds segmented rings and inward arrows, then shifts left to make room for a readable annotation column.',
'cycle-glass-node-morph': 'One subject shrinks through a diagonal wipe into a three-stage cycle, then a continuous push-in lets three glass nodes rise and take over the labels before diagnostic markers lock the system state.',
@@ -677,6 +681,11 @@ window.GALLERY_I18N = {
'scanline-annotate-focus': '扫描取景标注',
'scanline-assemble-flyin': '扫描装配飞入',
'assemble-then-type-flyin': '骨架装配落字',
'platform-hinge-rise': '平台铰接升起',
'ring-diagram-annotation-reveal': '环图收束标注',
'cycle-glass-node-morph': '循环玻璃节点接管',
'orbit-ring-title-open': '环形卡阵标题开场',
'lead-word-zoom-assemble': '首词推近组句',
},
stylesEn: {
'beat-cut-accelerando': 'Six full-screen hard cuts with halving intervals (16-12-8-6-4f) accelerate toward the target, then freeze dead on the main frame with a gentle push.',
@@ -0,0 +1,85 @@
---
name: orbit-ring-title-open
一句话: 八张 16:9 内容卡按 45° 均布在 700×375 椭圆上匀速公转(卡身永不倾斜,纵深只由 sin θ 给出 ±9% 缩放与 z 序环撑开期间卡内容冻结首帧、f24 之后八张一起开播居中标题逐字解糊下沉落定关键词到位那一刻黄色马克块自左横扫铺满mono 副行随后浮出,末段整行失焦淡出、环继续转着交棒下一镜
适用: 开场第一镜 = 「这个产品有一整套东西」;素材库/模板库/功能矩阵/案例集的门面拍;需要用真实运动的产品画面托住一句主张,而不是把截图摆成静态九宫格
时长: 约 4.3s130f@30fps;标题落定 f50 到退场 f108 之间是 1.9s 稳态巡回)
能量: 中(唯一的持续运动是匀速公转,全片只有马克块横扫一次冲击)
标签: ui-entrance
---
## 意图
开场想说"我们有一整套东西",最常见的做法是把截图排成网格——但网格是静止的,观众
读到的是"一堆图片",不是"一套会动的产品"。这张卡把陈列改成**环形巡回**:八张卡沿
椭圆匀速公转,永远有卡在进画、有卡在出画,画面自带"还有更多"的暗示,而标题稳稳
钉在正中间不受影响。
关键的取舍是**卡内容要真的在播**。静态截图上环,观众只会认出"轮播图";卡里跑着真
动效,同一个环立刻变成"产品在工作"。但八张卡同时开播会八个动效抢戏,谁也读不进去
——所以入场期把卡内容**冻结在首帧**,等环撑开、标题起手之后再统一开播。观众的注意
力顺序因此是:先认出这是一圈产品画面 → 读标题 → 才注意到卡里都在动。
## 动效核心
- **环上定位**`θ = -π/2 + i·2π/8 + t·0.3``x = 960 + cos θ·RX·ringScale`
`y = 540 + sin θ·RY·ringScale`。RY/RX ≈ 0.54 的椭圆读作"俯视的环";正圆会读作转盘
- **卡身永不倾斜**:卡只跟着 θ 换位置,自身 transform 只有缩放,没有任何旋转。
纵深完全交给 `depth = sin θ`+1 = 画面下方1 = 上方,远):
`scale = 1 + 0.09·depth``zIndex = 10 + round(depth·5)`。缩放和 z 序必须由同一个
depth 驱动,否则会出现"远处的卡压住近处的卡"
- **环入场**f021 `ringScale 0.62→1`outCubic从中心撑开同时逐卡淡入
第 i 张的窗口是 `[0.06+0.05i, 0.42+0.05i]s`,最后一张 f23 才满。撑开与淡入叠在
一起,读作"环长出来"而不是"卡片一张张贴上来"
- **冻结首帧再开播**:卡内容包在 `<Freeze frame={max(0, frame - 24)}>` 里。f24 之前
八张卡都停在自己的第 0 帧f24 起同时进入正常时间轴。24f = 环撑开(21f) + 一点余量
- **卡内坐标解耦**:卡内容按 960×540 作画,外层用 `scale((CW·s)/960)` 缩到卡宽 +
`transform-origin: top left`。内容组件不需要知道自己被缩小了,落地换成真实截图或
库内其他镜头卡组件时不用改一行
- **标题 blur-slide 逐字**:每字两条通道同起不同终——解糊走满 0.9s
`blur (1-p)·fontSize/6`),下沉只占前 0.3s`translateY (1-p)·fontSize·0.22`)。
字间错峰 1 帧0.0333s),缓动 `cubic-bezier(.22,1,.36,1)`。位移先停、模糊后停,
字才有"落定后继续对上焦"的手感
- **马克块横扫**`spring(frame-40, damping 14)` 驱动 `scaleX`
`transform-origin: left center`,色块 `position:absolute; inset:.06em -.08em; zIndex:0`
垫在高亮词组底下,词自身 `position:relative; zIndex:1`。缓动前载f401.33s)时末
字的 ease 值已 ≈0.95,感知上"字全到位了才扫",实际不必等到 f50 数学收敛
- **退场**f108118 整行 `opacity 1→0` + `blur 0→6.5px`,副行同窗同曲线。之后 12f
只有环在转——这段空档就是交棒位,接 crash-zoom / 穿浅场直切都不用额外铺垫
## 参数表
| 参数 | 典型值 | 调节手感 |
|------|--------|----------|
| 椭圆半径 | RX=700 / RY=375卡 380×214 | RY/RX≈0.54 是"俯视环"的甜点;比值 →1 读作正圆转盘→0.3 卡会在上下缘挤成一条 |
| 卡数 | 8方位角间隔 45° | 8 张在这个半径上任意转角互不遮挡≥10 张必须同步加大 RX/RY否则近处卡压边 |
| 公转速度 | 0.3 rad/s4.3s 转约 74° | **别因为"看起来慢"就调快**>0.5 rad/s 时卡扫过画面边缘会有频闪感,且喧宾夺主 |
| 环入场 | f021scale .62→1outCubic | <15f 像弹窗;>30f 标题起手时环还在长,两件事互相盖 |
| 逐卡淡入 | 窗口 `[0.06+0.05i, 0.42+0.05i]`s | 步长 0.05s 刚够读出"依次">0.1s 最后一张会晚到标题之后 |
| 开播帧 | PLAY_START=24f | 必须 ≥ 环撑开结束帧;提前会让八个动效在入场期一起抢戏 |
| 纵深缩放 | ±9% | >15% 顶部卡会缩成图标,环读作"透视隧道"=0 则整环扁平,失去前后关系 |
| 标题 | 64px / 600 字重 / 字距 0.05em | 环一直在动,标题必须够重才压得住;<48px 或 400 字重会被环吃掉 |
| 逐字节奏 | lead .35s,解糊 .9s,下沉 .3s,错峰 1f | 错峰 1f 是"整体解糊"的手感≥3f 会读成逐字打字机 |
| 马克块 | f40 起spring damping 14#facc15 | damping <10 会回弹出色块边缘;起扫早于末字 ease 0.9 时读作"色块追着字跑" |
| 副行 | mono 28px字距 0.35em1.01.5s 淡入 | 大字距 mono 是"产品代号"的读法;跟标题同字体会读作第二行标题 |
| 退场 | f108118blur 0→6.5px | 整行统一失焦;**逐字退场会读作第二次入场** |
## 已知坑
- **卡不能跟着 θ 倾斜**。给卡加 `rotate(θ)` 后环立刻从"陈列"变成"过山车",而且卡内
文字会歪,可读性直接归零。卡只换位置,不换角度
- 卡内容首帧必须自带静态骨架(底色/边框/标签/灰底轨迹)。冻结期显示的就是第 0 帧,
如果卡的动效是"从零画出来",入场那 24f 就是八张空白卡。demo 里每个占位卡都补了
灰底 sparkline / 淡稿线 / 常驻提示符,就是为了让首帧不空
- `zIndex` 必须跟着 depth 走。只做缩放不排 z 序时DOM 顺序会让后面的卡压住前面的,
近大远小的立体感当场作废
- 马克色块和字的层级是一对:色块 `zIndex:0` + 字 `position:relative; zIndex:1`。漏掉
字上的 relative色块会盖住字stacking context 里 static 元素排在 positioned 之后)
- 标题 `white-space: nowrap` 是马克块的前提——`inset:.06em -.08em` 假设高亮词组在同
一行盒里。换行后色块会跨行断裂成两截
- 环是背景层但它一直在动,所以**不要在环上再叠第二个动的东西**(粒子、扫光、渐变
呼吸)。已经有一个持续运动在场,第二个会让画面读不出主次
- 与 `carousel-3d` 的分工:那张卡是真 3D`rotateY + translateZ` + backface-hidden
卡会转到环背面,适合无缝 loop 的纯陈列;这张卡是 2D 椭圆伪纵深,卡永远正面朝观众,
适合"环托底 + 前景标题"的开场。要卡片翻面用前者,要标题压得住用后者
- demo 的八张卡是原创通用 DOM 占位(灰阶骨架 + 一条强调色),不含任何产品截图;
落地时整块替换成你自己的镜头卡组件或真实页面截图即可,环的语法一行都不用改
## 参考实现
demos/opening/orbit-ring-title-open/
OrbitRingTitleOpen.tsx
@@ -0,0 +1,89 @@
---
name: lead-word-zoom-assemble
一句话: 首词以 2.3 倍字号占据画面中央、hold 期间继续推近 6%,随后一条曲线同时完成「缩回终字号」与「整行左滑归位」,后续词各自从槽位右侧 0.5em 被推进来;支点横向钉首词中心、纵向钉基线(挂载时实测),整行上移的同一时窗副行浮出,停一拍后整幕 crash-zoom 推近失焦交棒
适用: 品牌名/产品名的 Introducing 字卡;发布会式开场第二镜;一句话主张需要"先让一个词占满画面、再把整句补齐"的场合
时长: 约 2.8s84f@30fps;组句 f32 完成、上移与副行 f3450、静止 22f、末 12f crash
能量: 中高(起手就是满画面的大字,但全程只有一次运动,没有二次冲击)
标签: opening
---
## 意图
字卡最容易犯的错是"整句一起淡入"——观众同时收到 5 个词,一个都没记住。这张卡把
阅读顺序做成**物理顺序**:先只给一个词,而且大到占满画面、还在继续朝观众推近,观众
被迫先读它;然后这个词退回自己在句子里的尺寸和位置,整句围绕它长出来。落定时观众
已经读过两遍主词。
真正的手感命门不是缩放曲线,而是**支点**。首词从"画面正中"走到"行内第三个字符的
位置",如果支点取行盒中心,缩放和左滑会各自把字往两边拽,末尾几帧基线抖得肉眼可见。
`transform-origin` 横向钉在首词中心、纵向钉在基线,这两点全程不动,整行就只是
"收小",没有任何漂移。
## 动效核心
- **两段缩放叠加成一条 scale**
`scale = interpolate(f, [0,12], [2.3, 2.3·1.06], PUSH_EASE)
+ interpolate(f, [12,24], [0, 1-2.3·1.06], ZOOM_EASE)`。
第一段是 hold 期间的推近(`cubic-bezier(.25,1,.5,1)`,减速后挂在峰值),第二段是缩回
(从峰值一路减到终字号 1。写成相加而不是分段判断两段之间没有速度断点
- **缩回与左滑共用 ZOOM_EASE**`cubic-bezier(.5,0,.05,1)`,重 ease-in-out、零回弹
`translate = interpolate(f, [12,36], [slideDistance, 0], ZOOM_EASE)`。两个动作必须读作
**一次**运动;各走各的曲线时会读成"先缩小、再滑走"两拍
- **偏心距怎么来**`slideDistance = lineWidth · (0.5 - leadRatio)``leadRatio` =
首词中心在整行里的占比。首词要停在画面正中,整行就得先向右偏这么多
- **支点实测**:挂载时 `delayRender` 挂起渲染,量三个值——整行 `offsetWidth`
首词 `offsetLeft + offsetWidth/2`、基线。基线用一个**零尺寸 inline-block 尺子**读出
`<span style={{display:'inline-block',width:0,height:0}}>``offsetTop` 就是基线),
`lineHeight × 0.8` 之类的估算准得多。量到之后 `continueRender``transform-origin`
写成 `${leadRatio*100}% ${baseline}px`
- **后续词是被推进来的,不是淡进来的**:第 i 词i≥1
`pushStart = 12 + 6 + (i-1)·4` 起,`translate``0.5em → 0`
`cubic-bezier(.22,.8,.36,1)`,起步快、落地长),透明度只用 2f 淡完。淡入必须极短,
否则词会"浮现"而不是"被塞进槽位"
- **词间空格必须放在 inline-block 之外**。跟在词 span 内部尾随时会被行盒裁掉,
落定后所有词黏成一团(上游原组件的 bug这里已修
- **场景层的三拍收尾**f3450 整行 `translateY 0→56px`outCubic副行在**同一时窗**
淡入 + 上移 16px——上移让出的空间当帧被副行填掉没有空当f5072 完全静止 22f
f7283 整幕 `scale 1→1.2` + `blur 0→9px` + `opacity 1→0.45`ease-in-quad推近失焦
交棒下一镜
- **强调词**精确匹配某个词换强调色demo 里是 `Lumen`)。品牌词推入落位那一下自带
高亮,不用再加第二个动作
## 参数表
| 参数 | 典型值 | 调节手感 |
|------|--------|----------|
| 首词起手倍数 | initialScale 2.3 | 2.3 让首词横向溢出画面一点点(读作"太大了装不下"<1.8 只是"稍大的字",冲击消失 |
| 推近 | pushScale 1.06,占满 hold 12f | 6% 是"还在朝观众来"的最小可感量;>1.15 会在缩回时读作反向弹跳 |
| hold | 12f | 首词独占画面的时长;<8f 观众读不完一个长单词,>20f 整句节奏塌 |
| 缩回 | 12fZOOM_EASE | 与左滑同曲线;单独给缩回一条更快的曲线会让整行"先缩完再滑",断成两拍 |
| 左滑 | assembleDuration 24f | 比缩回长一倍:字号已经落定、位置还在走,这段"滑行尾巴"是电影感的来源 |
| 后续词延迟 | wordDelay 6f缩回起点之后 | 首词开始缩回才放后续词;同帧放会挤在一起看不清谁在动 |
| 词间错峰 | wordStagger 4f | 4f 读作"依次被推进"0 则整段后缀一起进,回到"整句淡入" |
| 单词推入 | wordDuration 12fwordPush 0.5em | 0.5em 是"刚从槽位外面">1em 会读作从画外飞入,与首词的收束方向打架 |
| 词淡入 | wordFade 2f | 刻意极短。>6f 词会读作"浮现",推入的力学感消失 |
| 字号/字重/字距 | 96px / 600 / 0.03em | 负字距在放大 2.3 倍时才不会散架;正字距的大字会读作"标语横幅" |
| 上移 + 副行 | f345056px副行同窗 | 两件事必须同窗:先上移再出副行会多出一个空当拍 |
| 静止 | 22f | 落定后的可读时间,低于 15f 观众来不及读副行 |
| crash | 末 12fscale→1.2blur→9px | 这是转场的**前半**,动机要和下一镜的第一帧对上;单独播时看起来像"没结束"是正常的 |
## 已知坑
- **支点错了就抖基线**`transform-origin` 必须是「首词中心 % + 基线 px」这一对。
取行盒中心(默认 `50% 50%`)时缩放与左滑互相拉扯,末尾几帧基线肉眼可见地跳
- 基线别估算。`lineHeight × 0.8` 这类经验值在不同字体上差几像素,放大 2.3 倍后就是
十几像素的抖动。零尺寸 inline-block 尺子的 `offsetTop` 是唯一可靠的读法
- `delayRender` 期间整行 `opacity: 0`——**必须**如此。否则未量到支点的第一帧会闪一下
错位的大字,成片里就是一帧脏帧
- 布局是挂载时实测的,所以**结果随渲染环境的字体而变**:跨平台字体回退不同时整行宽度
会整体漂移(首词占比也跟着变)。介意的话把量到的三个值写死成常量
- `scale` / `translate` 用的是独立 CSS 属性(不是 `transform` 简写)。混用会让其中一个
被覆盖;要合并成 `transform` 就必须自己保证 `translate → scale` 的顺序
- 词间空格放进词 span 内部会被行盒裁掉(见上)。这个 bug 在小字号下不明显96px 时
所有词会明显黏连
- 首词必须是**短词**。"Introducing" 这类 10 字符左右刚好;首词超过 14 字符时 2.3 倍
放大会溢出画面两侧,观众读不到词尾
- 强调色只给一个词。两个词都上色时观众不知道该记哪个,而且推入落位的高亮变成了
"彩色标题",动作的意义被抹掉
- 末段 crash 是转场的前半式,不是收尾。单独当片尾用会显得"断了"——要收尾就把
crash 换成静止 hold 或淡出
## 参考实现
demos/typography/lead-word-zoom-assemble/
LeadWordZoomAssemble.tsx