// 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 ( {/* 窗口大 blob */} {/* 侧栏分隔 */} {/* 侧栏 logo blob + 短线 */} {Array.from({ length: 6 }).map((_, i) => ( ))} {/* 主区消息行:圆头像 blob + 波浪文字线 */} {Array.from({ length: 4 }).map((_, i) => { const y = 320 + i * 160; return ( ); })} ); }; // ——— 骨架/内容消息行 ——— 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 (
{/* 骨架层 */}
{/* 内容层(逐词显影) */}
0.02 ? 1 : 0 }}>
{NAMES[i][0]}
{NAMES[i]} 9:0{i + 1} AM
{words.map((w, wi) => { const p = wordAt(wi, words.length); return ( {w} ); })}
); }; 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 ( {/* 第三/二拍:骨架 UI 窗口 */} {f >= SWAP && (
{/* 侧栏 */}
{Array.from({ length: 7 }).map((_, i) => (
))}
{/* 主区 */}
{Array.from({ length: 4 }).map((_, i) => (
500 ? 0 : 1 }}>
))}
)} {/* 第一拍:手绘涂鸦占位(在骨架之上,一拍内缩退让位) */} {doodleVisible && ( )} ); };