Motion · UI xuất hiện
Hiện hình từ khung xương
Hiện hình ba tầng từ phác thảo đến khung xương đến nội dung — nét vẽ tay được thay bằng thanh xám khung xương rồi rõ nét thành UI thật.
Nguồn: video-shotcraft (Vincent Wei) ↗ · Apache-2.0 · Composition Remotion (React/TSX) — có video preview, không có player trong trình duyệt
Khi nào dùng
Câu chuyện ra mắt "từ không thành có" cho UI sản phẩm; đoạn lần đầu để giao diện sản phẩm xuất hiện sau phần mở đầu
Ý đồ
Kho cảnh vào UI đầy rẫy kiểu "cả trang tới cùng một lúc" (bay vào/rơi xuống/quét). Cảnh này ngược lại tách màn ra mắt thành ba tầng độ trung thực: nét vẽ tay (ý tưởng) → thanh xám khung xương (cấu trúc) → nội dung thật (sản phẩm), mỗi bước nhảy là một khoảnh khắc sướng của "thành thật", người xem đi theo cung kể chuyện "từ phác thảo đến sản phẩm". Việc khung xương hiện thành nội dung mượn ngữ pháp trạng thái loading — người dùng ngày nào cũng thấy skeleton screen, nhìn thanh xám là biết ngay "nội dung sắp tới", kỳ vọng đó có sẵn miễn phí. Phân công với document-typewriter-reveal: đó là một tài liệu được viết ra từng khối (nội dung là nhân vật chính, cần được đọc); cảnh này là một giao diện dần dần thành thật (cấu trúc là nhân vật chính, chữ là tầng chất liệu cuối cùng). Phân công với ai-stream-response: đó là các dòng bằng chứng "chảy vào" từng cái (danh sách đang lớn lên); cảnh này là chỗ giữ đã định sẵn "hiện hình" (bố cục đã cố định từ đầu, độ trung thực mới là thứ được nâng cấp).
Thời lượng & năng lượng
- Thời lượng: ~5.7s (172f: nét vẽ tay 1s + đổi sang thật 0.3s + khung xương cuộn vào 1.2s + đẩy máy hiện hình 3s)
- Năng lượng: Trung bình (ra mắt kiểu kể chuyện, trọng tâm là hai lần nhảy vọt "thành thật")
Công thức cảnh quay (bản gốc tiếng Trung — timing, easing, bảng tham số, lỗi hay gặp)
意图
UI 登场库全是"整页一次到位"(飞入/落位/擦出)。本卡把登场拆成 三级保真度跃迁:手绘涂鸦(想法)→ 灰条骨架(结构)→ 真实内容 (产品),每次跃迁都是一拍"变成真的"的爽点,观众跟着走完"从草图 到产品"的叙事弧。骨架→内容的显影借的是加载态语法——用户天天见 skeleton screen,看到灰条就知道"内容要来了",预期免费。与 document-typewriter-reveal 分工:那是一份文档被逐块写出来(内容是 主角、要读字);本卡是一个界面逐级变真(结构是主角、字是最后 一级质感)。与 ai-stream-response 分工:那是证据行逐条"汇入"(列表 在生长),本卡是已就位的占位"显影"(布局早定,保真度在升级)。
动效核心
- 涂鸦 = 种子抖动折线/blob 路径(mulberry32),SVG 圆头粗 stroke
(11–14px);每 5f 换一套种子做"煮沸"——静止的手绘读作贴图
- 换真一拍:涂鸦层 8f Easing.in 加速缩退(scale -14%)+ 淡出,
骨架窗口同时 spring(damping 16, stiffness 160) 弹入,交叠不留空场
- 骨架行 translateY 520→0 逐行错峰 6f 滚入(ease-out cubic)
- 显影 = 骨架/内容双层叠放 opacity 互换 + 头像 scale 0.7→1 长出,
伴随镜头 1→1.34 inOut 推近(76f)——推近给显影一个"凑近看清"的 视线动机
- 文字逐词 2.5f 步进上浮 14px 进场;末行末词 +14f 晚半拍("pink?"
式收尾顿点)
参数表
| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 煮沸周期 | 每 5f 换种子,抖幅 ±6–10px | <3f 抖成噪点;>8f 读作卡顿而非手绘活性 | | 换真一拍 | 涂鸦 8f 加速缩退,骨架 spring 同帧弹入 | 这拍必须快而果断——拖长成 crossfade,"跃迁"就没了 | | 骨架滚入 | 每行错峰 6f,各 22f ease-out | 骨架期要短暂停留(观众登记"结构")再显影,粘连读不出三级 | | 显影节拍 | 每行错峰 13f,行内 12f;镜头推近同段起 | 逐行有节奏地变真;全屏同帧显影读作换图 | | 逐词进场 | 2.5f/词,上浮 14px + 淡入 | 词级进场是最后一级质感;整行淡入降一档也成立 | | 晚半拍 | 末行末词 +14f | 收尾顿点让"加载完成"有句号;全部齐落平淡 |
已知坑
- demo 在灰阶/占位素材上调校通过——参数是调校起点非实战定稿,
首次实战须以真实素材回验
- 三级布局必须严格同构:涂鸦的窗口/侧栏/消息行位置要和骨架、骨架要和真内容对得上——错位后"显影"变成"换了个页面",叙事断
- 实战真内容层贴产品真实截图/排版时,骨架灰条的行高与槽位要按截图量(document-typewriter-reveal 卡"烤入纹理对齐"同族教训)
- 涂鸦线稿别画细节:粗圆头 blob + 波浪线足够,画得太像 UI 第一级和第二级就没差了
- mock 文案合规:不出现客户/成员真名(Q1 通例)
- 声音:换真一拍钉一声 pop/whoosh,逐行显影各一声极轻 tick,末词晚半拍处配一声轻 chime 收束
参考实现
demos/ui-entrance/skeleton-reveal/ (SkeletonReveal.tsx) 原片出处:slack-promo 4–4.5s + 10–15s
Mã nguồn tham chiếu (Remotion)
- demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx
Component import fixture chung từ demos/_fixtures và helper từ assets/lib của repo; xem SKILL.md trong repo để biết quy trình render.
Mọi thứ trên trang này đều có bản máy đọc. Đưa agent link JSON, hoặc thêm MCP server gimgs rồi bảo nó tìm motion template.
- Tải JSON — trong đó có công thức cảnh quay (timing, easing, bảng tham số, lỗi hay gặp) và toàn bộ source TSX.
- Clone repo video-shotcraft; component import fixture chung từ demos/_fixtures và helper từ assets/lib.
- Đăng ký component thành <Composition> trong project Remotion rồi render bằng `npx remotion render`, hoặc dựng lại công thức bằng engine của bạn.
curl -s https://gimgs.net/motion/skeleton-reveal.jsongit clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx
npx remotion render <CompositionId> out.mp4claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("skeleton-reveal")Prompt
Use the video-shotcraft shot recipe "Hiện hình từ khung xương" (Remotion composition, Apache-2.0) published on gimgs.net. Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/skeleton-reveal.json Raw TSX: https://gimgs.net/motion/skeleton-reveal/template.html Repository: https://github.com/Vincentwei1021/video-shotcraft (component: demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx; imports demos/_fixtures and assets/lib) Spec: 1920x1080, 30 fps, 5.78s 1. Read the JSON and follow its "prompt" section (when to use, intention, parameter table, known pitfalls). 2. Adapt it for: <describe what you want>. 3. Either clone the repo, register the component as a <Composition> in a Remotion project and run npx remotion render <CompositionId> out.mp4 — or re-implement the recipe in your own engine (HTML/WAAPI works: keep the same timing and easing).
// ===== demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx =====
// skeleton-reveal —— slack-promo 4–15s 合并
// 三级显影:全屏手绘粗笔触涂鸦占位(圆头 blob 线稿,煮沸抖动)→ 一拍内被
// 灰条骨架 UI 窗口替换 → 骨架消息列表滚入,镜头推近时灰条逐行"显影"成
// 头像+文字内容(最后一行末词晚半拍到)。
import React from 'react';
import {
AbsoluteFill,
useCurrentFrame,
useVideoConfig,
interpolate,
spring,
Easing,
} from 'remotion';
const mulberry32 = (a: number) => () => {
let t = (a += 0x6d2b79f5);
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
const INK = '#2f2f2f';
const PAPER = '#f2f0ea';
// ——— 手绘涂鸦工具:把折线/圆加种子抖动,画粗圆头 stroke ———
const wobbleLine = (
x1: number, y1: number, x2: number, y2: number,
seed: number, amp = 7, segs = 8,
) => {
const rnd = mulberry32(seed);
const pts: string[] = [];
for (let i = 0; i <= segs; i++) {
const t = i / segs;
const jx = (rnd() - 0.5) * amp * 2;
const jy = (rnd() - 0.5) * amp * 2;
pts.push(`${i === 0 ? 'M' : 'L'} ${x1 + (x2 - x1) * t + jx} ${y1 + (y2 - y1) * t + jy}`);
}
return pts.join(' ');
};
const wobbleBlobRect = (
x: number, y: number, w: number, h: number,
seed: number, amp = 10, r = 70,
) => {
// 圆头 blob 矩形:四条边中段抖动,四角大圆弧
const rnd = mulberry32(seed);
const j = () => (rnd() - 0.5) * amp * 2;
return [
`M ${x + r + j()} ${y + j()}`,
`L ${x + w / 2 + j()} ${y + j()}`, `L ${x + w - r + j()} ${y + j()}`,
`Q ${x + w + j()} ${y + j()} ${x + w + j()} ${y + r + j()}`,
`L ${x + w + j()} ${y + h / 2 + j()}`, `L ${x + w + j()} ${y + h - r + j()}`,
`Q ${x + w + j()} ${y + h + j()} ${x + w - r + j()} ${y + h + j()}`,
`L ${x + w / 2 + j()} ${y + h + j()}`, `L ${x + r + j()} ${y + h + j()}`,
`Q ${x + j()} ${y + h + j()} ${x + j()} ${y + h - r + j()}`,
`L ${x + j()} ${y + h / 2 + j()}`, `L ${x + j()} ${y + r + j()}`,
`Q ${x + j()} ${y + j()} ${x + r + j()} ${y + j()}`,
].join(' ');
};
const wobbleCircle = (cx: number, cy: number, r: number, seed: number, amp = 6) => {
const rnd = mulberry32(seed);
const n = 14;
const pts: string[] = [];
for (let i = 0; i <= n; i++) {
const a = (i / n) * Math.PI * 2;
const rr = r + (rnd() - 0.5) * amp * 2;
pts.push(`${i === 0 ? 'M' : 'L'} ${cx + Math.cos(a) * rr} ${cy + Math.sin(a) * rr}`);
}
return pts.join(' ') + ' Z';
};
const Doodle: React.FC<{ boil: number }> = ({ boil }) => {
const S = boil * 977; // 每次煮沸换一套抖动种子
const stroke = {
fill: 'none' as const,
stroke: INK,
strokeLinecap: 'round' as const,
strokeLinejoin: 'round' as const,
};
return (
<svg width={1920} height={1080} style={{ position: 'absolute', inset: 0 }}>
{/* 窗口大 blob */}
<path d={wobbleBlobRect(250, 140, 1420, 800, S + 1)} {...stroke} strokeWidth={14} />
{/* 侧栏分隔 */}
<path d={wobbleLine(600, 160, 600, 920, S + 2)} {...stroke} strokeWidth={12} />
{/* 侧栏 logo blob + 短线 */}
<path d={wobbleCircle(420, 260, 52, S + 3)} {...stroke} strokeWidth={12} />
{Array.from({ length: 6 }).map((_, i) => (
<path
key={`sb${i}`}
d={wobbleLine(330, 400 + i * 82, 470 + ((i * 53) % 70), 400 + i * 82, S + 10 + i)}
{...stroke}
strokeWidth={11}
/>
))}
{/* 主区消息行:圆头像 blob + 波浪文字线 */}
{Array.from({ length: 4 }).map((_, i) => {
const y = 320 + i * 160;
return (
<g key={`row${i}`}>
<path d={wobbleCircle(720, y, 44, S + 30 + i)} {...stroke} strokeWidth={12} />
<path d={wobbleLine(810, y - 28, 1180 + ((i * 97) % 220), y - 28, S + 40 + i)} {...stroke} strokeWidth={11} />
<path d={wobbleLine(810, y + 24, 1420 - ((i * 71) % 260), y + 24, S + 50 + i)} {...stroke} strokeWidth={11} />
</g>
);
})}
</svg>
);
};
// ——— 骨架/内容消息行 ———
const NAMES = ['Ana', 'Ben', 'Kai', 'Mia'];
const AVA = ['#5a5a58', '#7a7a78', '#4a4a48', '#8f8f8d'];
const MSGS = [
'Morning! Kicking off the rebrand today',
'Logo drafts are ready for review',
'Nice — shipping the deck this afternoon',
'Love it. Can we make it pink?',
];
const Row: React.FC<{ i: number; dev: number; wordAt: (w: number, n: number) => number }> = ({
i, dev, wordAt,
}) => {
const words = MSGS[i].split(' ');
return (
<div style={{ position: 'relative', height: 96 }}>
{/* 骨架层 */}
<div style={{ position: 'absolute', inset: 0, display: 'flex', gap: 22, opacity: 1 - dev }}>
<div style={{ width: 72, height: 72, borderRadius: 16, background: '#d5d5d3' }} />
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: 14, paddingTop: 6 }}>
<div style={{ height: 18, width: 180 + ((i * 67) % 90), background: '#d5d5d3', borderRadius: 9 }} />
<div style={{ height: 16, width: `${58 + ((i * 31) % 30)}%`, background: '#e2e2e0', borderRadius: 8 }} />
</div>
</div>
{/* 内容层(逐词显影) */}
<div style={{ position: 'absolute', inset: 0, display: 'flex', gap: 22, opacity: dev > 0.02 ? 1 : 0 }}>
<div
style={{
width: 72, height: 72, borderRadius: 16, background: AVA[i],
color: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'center',
fontSize: 32, fontWeight: 800, opacity: dev,
transform: `scale(${0.7 + 0.3 * dev})`,
}}
>
{NAMES[i][0]}
</div>
<div style={{ flex: 1, paddingTop: 2 }}>
<div style={{ fontSize: 26, fontWeight: 800, color: INK, opacity: dev }}>
{NAMES[i]}
<span style={{ fontWeight: 400, fontSize: 19, color: '#9a9a98', marginLeft: 12 }}>9:0{i + 1} AM</span>
</div>
<div style={{ fontSize: 27, color: '#3c3c3a', marginTop: 8 }}>
{words.map((w, wi) => {
const p = wordAt(wi, words.length);
return (
<span
key={wi}
style={{
display: 'inline-block',
marginRight: 9,
opacity: p,
transform: `translateY(${(1 - p) * 14}px)`,
}}
>
{w}
</span>
);
})}
</div>
</div>
</div>
</div>
);
};
export const SkeletonReveal: React.FC = () => {
const f = useCurrentFrame();
const { fps } = useVideoConfig();
const SWAP = 32; // 涂鸦 → 骨架的那一拍
// 涂鸦:煮沸抖动 + 一拍内被拉回替换(快速缩退 + 淡出,加速离场)
const boil = Math.floor(f / 5);
const doodleOut = interpolate(f, [SWAP, SWAP + 8], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.in(Easing.cubic),
});
const doodleVisible = f < SWAP + 9;
// 骨架窗口:swap 时弹入
const winIn = spring({ frame: f - SWAP, fps, config: { damping: 16, stiffness: 160, mass: 0.7 } });
// 骨架列表滚入(第二拍)
const rowSlide = (i: number) =>
interpolate(f, [SWAP + 12 + i * 6, SWAP + 34 + i * 6], [520, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
});
// 镜头推近
const zoom = interpolate(f, [66, 142], [1, 1.34], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.inOut(Easing.cubic),
});
// 逐行显影
const devAt = (i: number) =>
interpolate(f, [80 + i * 13, 92 + i * 13], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.out(Easing.quad),
});
// 逐词进场;最后一行最后一个词晚半拍(+14 帧)
const wordAt = (row: number) => (w: number, n: number) => {
const isLastWordOfLastRow = row === 3 && w === n - 1;
const start = 82 + row * 13 + w * 2.5 + (isLastWordOfLastRow ? 14 : 0);
return interpolate(f, [start, start + 9], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
easing: Easing.out(Easing.cubic),
});
};
return (
<AbsoluteFill style={{ background: PAPER, fontFamily: 'Helvetica, Arial, sans-serif', overflow: 'hidden' }}>
{/* 第三/二拍:骨架 UI 窗口 */}
{f >= SWAP && (
<AbsoluteFill
style={{
transform: `scale(${zoom})`,
transformOrigin: '58% 46%',
}}
>
<div
style={{
position: 'absolute', left: 250, top: 140, width: 1420, height: 800,
background: '#ffffff', border: '2px solid #d8d8d6', borderRadius: 22,
overflow: 'hidden', display: 'flex',
boxShadow: '0 12px 40px rgba(0,0,0,0.10)',
opacity: Math.min(1, winIn * 2),
transform: `scale(${interpolate(winIn, [0, 1], [1.08, 1])})`,
}}
>
{/* 侧栏 */}
<div style={{ width: 300, background: '#3a3a3a', padding: '30px 26px', boxSizing: 'border-box', display: 'flex', flexDirection: 'column', gap: 22 }}>
<div style={{ width: 46, height: 46, borderRadius: 12, background: '#777775' }} />
{Array.from({ length: 7 }).map((_, i) => (
<div key={i} style={{ height: 13, width: `${55 + ((i * 37) % 40)}%`, background: '#5a5a58', borderRadius: 7 }} />
))}
</div>
{/* 主区 */}
<div style={{ flex: 1, display: 'flex', flexDirection: 'column' }}>
<div style={{ height: 72, borderBottom: '2px solid #e4e4e2', display: 'flex', alignItems: 'center', padding: '0 34px' }}>
<div style={{ height: 18, width: 230, background: '#d5d5d3', borderRadius: 9 }} />
</div>
<div style={{ flex: 1, padding: '30px 40px', display: 'flex', flexDirection: 'column', gap: 32, overflow: 'hidden' }}>
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} style={{ transform: `translateY(${rowSlide(i)}px)`, opacity: rowSlide(i) > 500 ? 0 : 1 }}>
<Row i={i} dev={devAt(i)} wordAt={wordAt(i)} />
</div>
))}
</div>
</div>
</div>
</AbsoluteFill>
)}
{/* 第一拍:手绘涂鸦占位(在骨架之上,一拍内缩退让位) */}
{doodleVisible && (
<AbsoluteFill
style={{
background: PAPER,
opacity: 1 - doodleOut,
transform: `scale(${1 - doodleOut * 0.14})`,
transformOrigin: '50% 50%',
}}
>
<Doodle boil={boil} />
</AbsoluteFill>
)}
</AbsoluteFill>
);
};
- Read the recipePrompt = when to use, intention, parameter table with typical values and how each one feels, known pitfalls. Treat values as calibrated starting points.
- Get the codetool: git
git clone https://github.com/Vincentwei1021/video-shotcraft && cd video-shotcraft && npm install - Register + rendertool: remotion
npx remotion render <CompositionId> out.mp4 # component: demos/ui-entrance/skeleton-reveal/SkeletonReveal.tsx - Or re-implementPort the timing/easing from the recipe to HTML + WAAPI (gimgs motion contract) or any engine; keep the same segment windows and easing.
| param | type | default | description |
|---|---|---|---|
| — | |||
Cùng danh mục

Hiện hình cả bức tường: ba kiểu
Ba kiểu vào hình hàng loạt cả bức tường — bento thắp sáng từng ô, lưới lật sóng, và bản vẽ blueprint hiện nét; tất cả hiện hình tại chỗ không dịch chuyển, bổ trợ cho deck-deal-flyin kiểu bay vào dịch chuyển.

Cột nhập theo bậc thang giá trị
16 cột vào hình với delay là bậc thang thời gian, đồng thời chiều cao/sắc màu/dịch chuyển/độ mờ mỗi thuộc tính tự trải thành bậc thang giá trị từ đầu đến cuối; nhịp hai đổi gốc lệch nhịp về tâm, biên độ xung lấy tâm làm đỉnh trải lại.

Biến hình SVG lặp vòng
Một viền khép kín 140 điểm biến hình mượt sang viền khác rồi biến lại, hai hình trước hết được lấy mẫu lại cùng số điểm trong tọa độ cực, nội suy bán kính từng điểm + inOutCubic, giữa chừng chèn hơi thở scale nhẹ, tự xoay chậm và sắc màu trôi 185°→305°.

Thẻ UI đổ mưa lướt sát mặt đất
Từ góc máy thấp lướt sát mặt đất, thẻ UI đổ như mưa rào rồi dừng cứng không nảy, sau đó cả trang dựng lên và quay thẳng vào camera.