Motion · Intro
Đèn spotlight rọi thẻ hero
Đèn spotlight quét qua trang khóa vào một thẻ, đẩy máy nghiêng 45° rồi thẻ bật lên lơ lửng, luồng sáng quét quanh viền hai vòng, rồi áp lại vị trí cũ.
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
Mở đầu sản phẩm kiểu "một nhân vật chính duy nhất"; dựng một đối tượng cốt lõi (thẻ/mục/module) thành nhân vật chính của toàn phim
Ý đồ
Mở đầu chỉ nói một điều: chiếc thẻ này chính là đơn vị nguyên tử của sản phẩm. Đèn spotlight thay khán giả dẫn hướng ánh nhìn, việc bật lên lơ lửng cho nó thể tích và trọng lượng, luồng sáng theo viền là ẩn dụ "quét/kiểm tra", cuối cùng áp lại vị trí cũ thể hiện nó thuộc về trang này.
Thời lượng & năng lượng
- Thời lượng: khoảng 4.6s (82–220f)
- Năng lượng: Trung bình (cảnh có chất lượng thị giác cao nhất, nhịp chậm mà chắc)
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)
意图
开场只讲一件事:这一张卡就是产品的原子单位。聚光灯替观众做视线引导,弹起悬浮给它体积和重量,轮廓光束是"扫描/检视"的隐喻,最后贴回原位表示它属于这个页面。
动效核心
- 游走聚光灯经几个中间站后锁定卡心,光池收拢 + 锁定瞬间脉冲,外部 vignette 压暗
- 相机从全页正视推进到斜侧特写(左侧机位主导),焦点略偏让卡落在画面偏右
- 卡片 rise(带过冲)→ 悬停 sin bob → reseat(落地微压)
- SVG rounded-rect 轮廓光束跑两圈:第一圈快而亮、第二圈慢而弱
- 卡起飞后原位铺页面底色补丁 + 强调色(模板片为琥珀)呼吸描边,落地瞬间增亮消失
- 可选加配 3D 悬空注记(hover-3d-annotation 手法):悬停期在卡左侧浮现大衬线两行注记(字号 37、宽 230px),translateZ 92 + sin 3px bob(周期 44f,与卡 40f 相近但不同步——"共感"而非镜像),关键词背后 12f 长出马克笔荧光条 oklch(88% 0.095 85),向页面投椭圆软影;注记必须活在同一 3D 空间、同一台相机透视(C3)——第一版平面叠字被否决后重做(平面叠字破坏 3D 空间统一);出场早于卡片 reseat
参数表
| 参数 | 典型值 | 调节手感 | |------|--------|----------| | 选卡 | cards[3],中心 x 恰为 960(页面中心) | 主角卡先按"推进后构图居中"选,不是随便挑一张 | | 相机 | 静止全页 zoom 0.78(82–114f)→ 16f 推进 zoom 2.6;rotY 34° 主导 + rotX 仅 8°,persp 1200;焦点取卡心左移 30px;reseat 触地后 cx/cy/zoom/rot 全锁死 ≥15f | 侧向水平机位读感优于俯拍("从左侧拍摄而不是从下方",Q6);收尾禁用 zoom 2.6→2.58 一类尾漂,呼吸必须是真静止(R1) | | 聚光灯 | 4 个中间站(86→130f),光池半径 620→420→360 收拢,锁定 +6% 脉冲;vignette 0.16→0.42 | 中间站让"随机照射"可信;直奔目标读作程序化 | | 动作弧 | rise 10f(bezier(0.2,1.25,0.3,1) 过冲)→ 悬停 54f(sin bob 振幅 4px 周期 40f,z=110px·lift)→ reseat 18f,落地 press 0.997 | 锁定→落地 ≈98f≈3.3s——质感镜头要"放慢到 3 秒"(R3),初版几乎总是偏快 | | 轮廓光束 | lap1 142→156f(strokeWidth 5+2.5 双层)、lap2 162→182f(3.5+1.75、整体 opacity 0.62);strokeDasharray "0.14 1" 走 dashoffset | 两圈快慢有别才读作"持续扫描",一圈是眨眼;光束只给主角一次(Q4) | | 双层影 | 0 8·lift px …, 0 46·lift px 90·lift px 随高度生长 | 影子不随高度长,悬浮就不成立 |
声音
弹起钉 whoosh-big(模板片钉在 f127),光束扫描钉 sparkle(f141),reseat 钉 transition-snap(f204)——弹起/光效/落定各有专属音(S2、S4)。
已知坑
- 开场多卡群舞撑不起第一印象(Q5)——开场曾反复推倒多次才收敛到单卡;直接从单主角+完整动作弧起稿
- 推进特写下卡上文字会糊,根因是纹理栅格化分辨率不是 DoF——配套技法展开见审美准则 Q2
- 逐卡 glint 闪烁被两次否决("不需要每个卡片都闪烁一下",Q4),光效严格只给主角
参考实现
demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx(原 template/src/aifl/live/SceneOpen.tsx 82–220 段)
Mã nguồn tham chiếu (Remotion)
- demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx — https://raw.githubusercontent.com/Vincentwei1021/video-shotcraft/main/demos/opening/spotlight-hero-card/SpotlightHeroCard.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/spotlight-hero-card.jsongit clone https://github.com/Vincentwei1021/video-shotcraft
# component: demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx
npx remotion render <CompositionId> out.mp4claude mcp add --transport http gimgs https://gimgs.net/mcp
# then: search_motion_templates / get_motion_template("spotlight-hero-card")Prompt
Use the video-shotcraft shot recipe "Đèn spotlight rọi thẻ hero" (Remotion composition, Apache-2.0) published on gimgs.net. Recipe JSON (prompt = timing/easing/parameter table/pitfalls, files = TSX source): https://gimgs.net/motion/spotlight-hero-card.json Raw TSX: https://gimgs.net/motion/spotlight-hero-card/template.html Repository: https://github.com/Vincentwei1021/video-shotcraft (component: demos/opening/spotlight-hero-card/SpotlightHeroCard.tsx; imports demos/_fixtures and assets/lib) Spec: 1920x1080, 30 fps, 5.33s 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/opening/spotlight-hero-card/SpotlightHeroCard.tsx =====
// spotlight-hero-card —— 聚光灯锁定一张卡 → 斜 45° 推进 → 卡片弹起悬浮 →
// 轮廓光束两圈 → 贴回原位。单一主角式产品开场,把核心对象立成全片主角。
// 参考实现从 template SceneOpen 的 82–220 段剥离,self-contained:
// 游走聚光灯经 4 个中间站后锁定卡心(光池收拢 + 锁定脉冲 + vignette 压暗);
// 相机全页正视 → 16f 斜侧推进(rotY 34° 主导 + rotX 8°);卡 rise(带过冲)→
// 悬停 sin bob(54f)→ reseat(18f 落地微压);SVG rounded-rect 轮廓光束两圈
// (lap1 快而亮 / lap2 慢而弱);悬停期左侧 3D 注记 + 马克笔荧光条。
// 主角卡 = layout.projects.cards[3],中心 x 恰为 960(页面中心)。
import { AbsoluteFill, Img, interpolate, staticFile, useCurrentFrame, Easing } from 'remotion';
import { PageCam2D, CamKey2D } from '../../_fixtures/PageCam2D';
import layout from '../../_textures/live-layout.json';
export const SPOTLIGHT_HERO_CARD_DURATION = 139; // 82–220f,落在 shot 内 offset 0
const SERIF = 'ui-serif, Georgia, "Times New Roman", serif';
const INK = 'oklch(18% 0.006 82)';
const AMBER = 'oklch(52% 0.115 65)';
const SLOT_AMBER = 'oklch(58% 0.13 65)';
const BEAM_CORE = 'rgba(255,248,232,0.98)';
const PATCH = 'oklch(97.5% 0.008 82)';
const PAGE_H = layout.projects.pageH;
const MAIN = 3;
const CARD = layout.projects.cards[MAIN];
const MCX = CARD.x + CARD.w / 2; // ≈ 960
const MCY = CARD.y + CARD.h / 2; // 772
const RADIUS = 16;
// Camera: straight-on full page (0→32, static while the spotlight roves and
// locks), then a 16-frame push-in swinging to a LEFT-side view — rotY dominant,
// card's left edge near, right edge recedes; slight downward tilt; then held to
// the end with an almost-imperceptible pull. Focal point sits a touch LEFT of
// the card centre so the card lands slightly right of screen-centre.
const CAM_KEYS: CamKey2D[] = [
{ frame: 0, cx: 960, cy: 540, zoom: 0.78, rotX: 0, rotY: 0, rotZ: 0, persp: 1200 },
{ frame: 32, cx: 960, cy: 540, zoom: 0.78, rotX: 0, rotY: 0, rotZ: 0, persp: 1200 },
{ frame: 48, cx: MCX - 30, cy: MCY, zoom: 2.6, rotX: 8, rotY: 34, rotZ: 2, persp: 1200 },
{ frame: 138, cx: MCX - 30, cy: MCY, zoom: 2.6, rotX: 8, rotY: 34, rotZ: 2, persp: 1200 },
];
const PUSH_EASE = Easing.bezier(0.35, 0, 0.2, 1);
const POP_EASE = Easing.bezier(0.2, 1.25, 0.3, 1);
const RESEAT_EASE = Easing.bezier(0.4, 0, 0.3, 1.05);
export const SpotlightHeroCard: React.FC = () => {
const frame = useCurrentFrame();
// --- macro fades in (0 → 8) ---
const macroIn = interpolate(frame, [0, 8], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),
});
// --- roving spotlight (screen space): waypoints then locks on hero card
// centre (50%, 67% in the straight-on view), rides the push-in ---
const spotEase = Easing.bezier(0.4, 0, 0.3, 1);
const spotX = interpolate(frame, [4, 8, 16, 22, 28, 48], [25, 25, 70, 42, 50, 50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: spotEase,
});
const spotY = interpolate(frame, [4, 8, 16, 22, 28, 48], [30, 30, 45, 60, 67, 50], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: spotEase,
});
const spotOn = interpolate(frame, [2, 10], [0, 1], { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' });
const poolBase = interpolate(frame, [22, 32, 48], [620, 420, 360], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.4, 0, 0.3, 1),
});
const poolPulse = interpolate(frame, [32, 36, 41], [0, 0.06, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
const poolRx = poolBase * (1 + poolPulse);
const poolRy = poolBase * 0.8 * (1 + poolPulse);
const vignette = interpolate(frame, [22, 32, 48], [0.16, 0.34, 0.42], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
// --- hero card pop-up: rise (48→58 overshoot), hover (58→112, 54f sin bob),
// reseat (112→130, 18f gentle) — lock(32)→touchdown(130) ≈ 98f ≈ 3.3s ---
const rise = interpolate(frame, [48, 58], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: POP_EASE,
});
const reseat = interpolate(frame, [112, 130], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: RESEAT_EASE,
});
const lift = rise * (1 - reseat);
const bob = Math.sin(((frame - 58) / 40) * Math.PI * 2) * 4 * lift;
const z = 110 * lift + bob;
const landed = frame >= 130;
const press = interpolate(frame, [126, 129, 130], [1, 0.997, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
const shadow = `0 ${8 * lift}px ${10 + 12 * lift}px rgba(40,30,20,${0.18 * lift}), 0 ${46 * lift}px ${90 * lift}px rgba(40,30,20,${0.22 * lift})`;
// vacated-slot amber outline: alive while airborne, brightens as it lands
const slotVis = Math.min(1, rise * 2) * (1 - reseat);
const landPulse = interpolate(frame, [126, 130, 134], [0, 1, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
const slotEdge = Math.min(1, 0.4 * (1 - reseat)) + landPulse * 0.6;
// --- perimeter beam: TWO laps. Lap 1 (60→74) fast & bright; lap 2 (80→100)
// slower & weaker; a faint amber ring lingers and fades before reseat. ---
const beam1Prog = interpolate(frame, [60, 74], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.linear,
});
const beam1On = frame >= 59 && frame <= 75;
const beam2Prog = interpolate(frame, [80, 100], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.4, 0, 0.4, 1),
});
const beam2On = frame >= 79 && frame <= 101;
const beamTrail = interpolate(frame, [100, 112], [0.35, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
const bw = CARD.w + 6;
const bh = CARD.h + 6;
// hi-res hero crossfades in as the push-in starts (32→38)
const hiresIn = interpolate(frame, [32, 38], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
return (
<AbsoluteFill style={{ backgroundColor: '#faf7f2' }}>
<AbsoluteFill style={{ opacity: macroIn }}>
<PageCam2D src="textures/live/projects-full.png" pageH={PAGE_H} keys={CAM_KEYS} ease={PUSH_EASE}>
{/* rim light along the near (bottom) edge of the tilted plane */}
<div
style={{
position: 'absolute', left: 0, right: 0, bottom: 0, height: 8,
background: 'rgba(255,255,255,0.85)', filter: 'blur(6px)',
opacity: 0.6 * Math.min(1, lift + Math.max(0, (frame - 32) / 16)),
pointerEvents: 'none',
}}
/>
{/* hero card: lifts off its slot, hovers, beam runs, settles back */}
<div style={{ transformStyle: 'preserve-3d' }}>
{/* vacated-slot patch + breathing amber outline */}
{slotVis > 0.02 ? (
<div
style={{
position: 'absolute', left: CARD.x - 2, top: CARD.y - 2,
width: CARD.w + 4, height: CARD.h + 4, background: PATCH,
borderRadius: RADIUS,
boxShadow: `inset 0 0 26px rgba(180,120,50,${0.12 * slotEdge})`,
opacity: slotVis,
}}
>
<div
style={{
position: 'absolute', inset: 0, borderRadius: RADIUS,
border: `1.5px solid ${SLOT_AMBER}`, opacity: slotEdge, pointerEvents: 'none',
}}
/>
</div>
) : null}
{/* the levitating card */}
<div
style={{
position: 'absolute', left: CARD.x, top: CARD.y,
width: CARD.w, height: CARD.h,
transform: `translateZ(${z}px) scale(${press})`,
transformOrigin: 'center center', transformStyle: 'preserve-3d',
}}
>
<div
style={{
position: 'absolute', inset: 0, borderRadius: RADIUS, overflow: 'hidden',
boxShadow: landed ? 'none' : shadow,
}}
>
<Img
src={staticFile(`textures/live/${CARD.file}`)}
style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', display: 'block' }}
/>
{/* 4x hi-res capture layered on top, crisp under push-in zoom */}
<Img
src={staticFile('textures/live/card4-hires.png')}
style={{
position: 'absolute', inset: 0, width: '100%', height: '100%',
display: 'block', opacity: hiresIn,
}}
/>
<div
style={{
position: 'absolute', inset: 0,
background: 'linear-gradient(160deg, rgba(255,255,255,0.5), transparent 40%)',
opacity: lift, pointerEvents: 'none',
}}
/>
</div>
{/* 1px white inner stroke (lit paper edge) */}
<div
style={{
position: 'absolute', inset: 0, borderRadius: RADIUS,
boxShadow: `inset 0 0 0 1px rgba(255,255,255,${0.7 * lift})`, pointerEvents: 'none',
}}
/>
{/* perimeter beam: SVG rounded-rect, pathLength=1 travelling arc */}
{(beam1On || beam2On) && lift > 0.4 ? (
<svg
width={bw} height={bh} viewBox={`0 0 ${bw} ${bh}`}
style={{
position: 'absolute', left: -3, top: -3, overflow: 'visible', pointerEvents: 'none',
opacity: beam1On ? 1 : 0.62,
filter: `drop-shadow(0 0 6px ${AMBER}) drop-shadow(0 0 18px rgba(255,240,210,0.55))`,
}}
>
<rect
x={2} y={2} width={bw - 4} height={bh - 4} rx={RADIUS} fill="none"
stroke={AMBER} strokeWidth={beam1On ? 5 : 3.5} strokeLinecap="round"
pathLength={1} strokeDasharray="0.14 1"
strokeDashoffset={-(beam1On ? beam1Prog : beam2Prog)}
/>
<rect
x={2} y={2} width={bw - 4} height={bh - 4} rx={RADIUS} fill="none"
stroke={BEAM_CORE} strokeWidth={beam1On ? 2.5 : 1.75} strokeLinecap="round"
pathLength={1} strokeDasharray="0.14 1"
strokeDashoffset={-(beam1On ? beam1Prog : beam2Prog)}
/>
</svg>
) : null}
{/* faint amber ring left behind, fades out */}
{beamTrail > 0.01 ? (
<div
style={{
position: 'absolute', inset: -3, borderRadius: RADIUS + 3,
border: `1.5px solid ${AMBER}`, opacity: beamTrail, pointerEvents: 'none',
}}
/>
) : null}
</div>
</div>
{/* 3D floating annotation LEFT of the hovering card */}
{frame >= 60 && frame <= 130 ? (() => {
const noteIn = interpolate(frame, [60, 70], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.2, 0.75, 0.3, 1),
});
const noteOut = interpolate(frame, [116, 126], [1, 0], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp',
});
const noteVis = noteIn * noteOut;
const noteZ = 92 + Math.sin(((frame - 60) / 44) * Math.PI * 2) * 3;
const hl = interpolate(frame, [74, 86], [0, 1], {
extrapolateLeft: 'clamp', extrapolateRight: 'clamp', easing: Easing.bezier(0.3, 0, 0.2, 1),
});
return (
<div style={{ transformStyle: 'preserve-3d', pointerEvents: 'none' }}>
<div
style={{
position: 'absolute', left: 566, top: 736, width: 210, height: 74,
transform: 'translateZ(2px)',
background: 'radial-gradient(ellipse at 50% 50%, rgba(40,30,20,0.3), transparent 70%)',
filter: 'blur(12px)', opacity: 0.55 * noteVis,
}}
/>
<div
style={{
position: 'absolute', left: 556, top: 668, width: 230,
transform: `translateZ(${noteZ}px) translateY(${(1 - noteIn) * 26}px)`,
opacity: noteVis, filter: `blur(${(1 - noteIn) * 4}px)`,
}}
>
<div style={{ fontFamily: SERIF, fontSize: 37, fontWeight: 600, color: INK, lineHeight: 1.16, letterSpacing: '-0.012em' }}>
One card,
</div>
<div style={{ position: 'relative', display: 'inline-block' }}>
<div
style={{
position: 'absolute', left: -5, top: '12%', bottom: '4%',
width: `calc(${hl} * (100% + 10px))`,
background: 'oklch(88% 0.095 85)', borderRadius: 4,
}}
/>
<div style={{ position: 'relative', fontFamily: SERIF, fontStyle: 'italic', fontSize: 37, fontWeight: 600, color: INK, lineHeight: 1.16, letterSpacing: '-0.012em' }}>
one project.
</div>
</div>
</div>
</div>
);
})() : null}
</PageCam2D>
{/* roving / locking spotlight: warm pool + dim outside */}
<AbsoluteFill
style={{
background: `radial-gradient(${poolRx}px ${poolRy}px at ${spotX}% ${spotY}%, rgba(255,241,214,0.42), rgba(255,241,214,0.10) 45%, rgba(70,56,38,${vignette * spotOn}) 100%)`,
pointerEvents: 'none', opacity: spotOn,
}}
/>
<AbsoluteFill
style={{
background: `radial-gradient(300px 220px at ${spotX - 6}% ${spotY + 10}%, rgba(255,246,228,0.18), transparent 70%)`,
pointerEvents: 'none', opacity: spotOn * 0.7,
}}
/>
</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/opening/spotlight-hero-card/SpotlightHeroCard.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

Chữ làm mặt nạ video
Chữ làm mặt nạ video — tiêu đề chữ siêu đậm để lộ hình sản phẩm lướt chậm bên trong, cuối đoạn chữ phóng to 26 lần tràn khung, hình bên trong tiếp quản toàn màn hình.

Nét chữ rời ghép thành chữ
Nét đứt thành chữ — tiêu đề tách thành hơn chục đoạn nét rời không liền nhau, sáng lên không theo thứ tự; 70% đầu không đọc được, đoạn cuối vào vị trí thì ý nghĩa "bật" thành hình.

Vòng quỹ đạo mở tiêu đề
Tám thẻ nội dung xoay quanh hình elip 700×375 với tốc độ đều trong khi tiêu đề giữa màn hình rõ nét dần rồi an vị.

Ảo thuật lật bài xuất hiện
Trên nền đen tuyền, một tia sáng hình sao xanh chớp lên 0.3s, rồi một lá bài bắn ra từ điểm chớp sáng — xoay cực nhanh theo cung bay về phía máy quay, tốc độ xoay giảm dần khi tới gần, đóng băng cứng gần đầy khung hình, sau đó có một lần quét ánh sáng.