关于 explainer-high-energy
用于制作 12–20 秒高能产品解说视频的创意技能,涵盖需求收集、场景规划、确定性 HTML 动画、合成音轨与渲染,并要求避免编造产品事实。
主要功能
- 01单页 HTML 视频构建
- 02按节拍规划场景与转场
- 03通过 seek 确定性渲染帧
- 04合成音轨与音效
- 05品牌字体和配色指引
- 06确保产品声明准确
适用场景
- 01制作简短的产品发布解说视频
- 02将产品功能转为动态视频
- 03逐帧渲染一致的动画
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ barty-bart/ motion-graphics --skill explainer-high-energy安装后在 Claude Code 中输入/explainer-high-energy确认已加载。
附带文件README.md
详细介绍
高能量解说视频
一段 12–20 秒的热血短片,冲击力十足,堪比精彩集锦。每半秒都有内容踩着节拍登场,但画面绝不会像随机剪辑,因为一个形状会在每次转场时引导观众的视线:火花变成线条,线条铺满屏幕,化作下一个场景的背景;句号脱离原位,变成下一个场景围绕其展开的球体。
你需要将它写成一个 HTML 页面:只需传入时间值,就能渲染任意一帧;然后添加动态模糊和合成配乐,将其渲染为 MP4。
第 1 步:获取需求(发一条消息,然后开始制作)
用一条消息询问所有问题,然后自行完成剩余工作。只有产品信息是必填项;任何未填写的内容都由你自行决定,并说明你的选择。
- 产品:名称,以及几句话介绍它的功能。最好提供网站 URL。
- 观众必须记住的一件事(例如:“输入一个想法,生成一个实时应用”)。
- 品牌:颜色、字体、Logo 文件,或“使用网站上的品牌元素”。如果没有,请使用下方的默认配色。
- 格式:16:9(1920×1080)或 9:16(1080×1920)。默认为 16:9。
- 时长:此风格的默认时长见下方。
- 音乐:使用对方的曲目(文件路径),或由你合成配乐。默认:合成。
- 创作方向(可选):对方想要的场景、标语、行动号召,以及任何需要避免的内容。
如果对方提供了 URL,请在开始撰写前读取该网页(通过网页抓取),了解产品的真实措辞、功能、颜色和字体名称。UI 标签和标语应使用产品的实际用语。
绝不编造事实。 不得虚构客户数量、收入、评分、推荐语或引言。只有用户或网站明确提供时才可使用数字;否则请使用相对条形图、进度条和占位骨架线。UI 中的示例内容(提示词、文件名、项目名称、收据上的价格)可以合理虚构;但关于产品本身的宣传(统计数据、用户数、评分、“免费”等优惠)不可以。行动号召只能使用用户或网站提供的网址或优惠;如果没有,就以产品名称和标语收尾。
Logo。 只有在用户提供文件,或这是用户自己的产品且用户要求使用 Logo 时,才可使用 Logo。否则,请使用品牌字体将名称作为文本呈现。只有在用户提供第三方 Logo(用于集成)时才可使用;否则,请使用带圆角的中性方块,并在其中写上名称。
此风格的默认时长:15 秒(128 BPM,8 小节)。范围为 12–20 秒。
第 2 步:规划场景,然后开始制作
结构(6–8 个场景,每个场景 1.5–3 秒,按 120–140 BPM 节拍网格编排)
从这些场景类型中挑选,并将产品融入其中。先写节拍表(场景、在节拍网格上的起始时间、承接内容、引出内容、音效)。
- 火花开场(暗色)。近乎全黑的画布上浮现一张淡淡的网格。一枚微小的强调色标记出现、脉动,拉伸成贯穿画面的细横线,旁边带有小号等宽标签(
product / launch — 2026);横线轻晃一次,随后垂直扩展为铺满全屏的强调色。这片色彩成为下一个场景的背景。 - 品牌重击。产品名称采用粗重的无衬线字体、全大写并放大,字母通过遮罩按连续的 1/16 拍逐个显现;上方放置小号等宽引导语(
INTRODUCING — 2026),下方放一行斜体衬线文字(用 2–4 个词说明它是什么)。字重可以从纤细渐变为极粗。句点或某个字母上的点脱离并放大,变成开启下一场景的形状。 - 动感三连。用三个简短词语或短语呈现价值主张,每个占两拍(手写词占三拍,以便观众读完),每个词都采用独特处理方式和独立背景,并在拍点上硬切(在此场景内,背景切换本身就是节奏,因此不计作剪辑):奶油色背景上的深色文字;黑色背景上的强调色文字,文字以描边形式重复并铺满画面;电光蓝背景上的白色文字,置于取景器角标内,配有小号
1920 × 1080标签;最后一个词用手写体自行绘出。每个词快速模糊滑入,落位时略微过冲。 - 标志性视觉。呈现一个象征产品的“这是怎么做到的”瞬间:从中心开始涟漪扩散的 3D 方块场(用画布和自定义透视投影绘制,并根据高度套用调色板上色)、凝聚成环后向外迸发的生成式粒子流,或在不同形状间变幻的液态团块。选择最契合产品理念的一种(构建模块 → 方块,数据/AI → 粒子,灵活性 → 液态)。播放过程中,镜头缓慢倾斜或环绕。
- 产品微交互。展示产品中的一项真实交互,将其呈现为略微倾斜的 3D 悬浮 UI 卡片,后方叠放两张半透明的卡片:光标快速拨动一个切换开关,按下主按钮,进度条以强调色填充并显示百分比,按钮变为
Done ✓,右上角滑入一条提示。使用产品中的真实标签。 - 效果证明(仅在有真实数据时)。一个大数字递增显示(类似
+312%,数字旋转时带有模糊效果)、一个环形图、错落上升的条形图(其中一条使用强调色),以及一条在图表上绘制的折线和一个工具提示。没有真实数据 → 跳过此场景,或用猛然弹入的列表展示产品的真实能力。 - 液态/变形过渡。上一场景中的形状收缩成一个点;这个点在暗色背景上变成环形脉冲,随后成为 Logo 中的点。
- 片尾卡(停留 2 秒)。展示带有强调色点的 Logo 或名称、斜体衬线标语,以及一行逐字打出的字距加宽等宽文字(如果有真实 URL 或行动号召,则使用它,例如
PRODUCT.COM;否则使用简短的简介文案),再加一条细线。
并非每个产品都需要每个场景;6 个出色的场景胜过 8 个平庸的场景。每个场景只保留一个想法。
营造高级质感的规则
- 转场是形状,不是硬切。 每个场景都通过一个形状衔接到下一个场景(淹没、逐渐扩大的色条擦除、逐渐扩大的圆点变成下一个背景、文字穿过镜头并放大)。场景之间只有在重拍上才允许硬切,且最多两次。
- 所有元素都要对齐节拍网格。 大幅变化落在拍点上,排版变化落在八分音符或十六分音符上。冲击时稍微过冲(
z≈ 0.6–0.7),然后稳定下来。 - 每个场景都要有 HUD 取景框: 细角括号、右上角的
SCENE 02 / 07、左下角的分类标签(使用小号等宽字体,如KINETIC TYPE、PRODUCT、DATA),以及右下角带红点的递增时间码。字号要小(1080p 下为 12–14 px),不透明度约为 50%;整体应有摄像机取景器的感觉。 - 纹理: 每个纯色背景都添加柔和的径向暗角、非常细微的胶片颗粒(加载时预生成几张带固定种子的颗粒贴图并循环播放),以及真实的运动模糊。
- 字体: 冲击性文字使用粗黑体(例如 Inter Tight 900 或 Archivo Black),表现人物情感的文字使用斜体衬线体(Instrument Serif italic),HUD 和字幕使用等宽字体(JetBrains Mono 或 Geist Mono)。如果品牌有自己的字体,品牌名称使用品牌字体,HUD 仍使用等宽字体。
- 默认配色: 墨色
#0B0B10、强调色#FF4F1A、奶油色#EDEBE4、电光蓝#2F47FF、白色。品牌主色替换强调色,第二主色替换蓝色;单色品牌则以其颜色的深色调作为第二种颜色。 - 禁止: 套用素材模板的观感、到处使用辉光、表情符号、单个场景中使用超过 3 种颜色(黑白除外)、使用一眼看不清的小字,以及任何没有动效的场景。
音效
128 BPM(或使用用户提供的曲目)。节奏要有力:每拍加入底鼓,第 2 拍和第 4 拍加入拍手声,加入随场景根音变化的次低音、在品牌重击和片尾卡前加入滤波渐强音、每次淹没转场时加入冲击音(低沉轰鸣声 + 噪声爆发)、每个逐字出现的单词配上短促的音调提示音、每次延续式转场都加入呼啸声(带通噪声扫频),片尾卡加入柔和的余音。音乐音量要低于冲击音。
页面必须如何构建
使用一个独立的 HTML 文件(内嵌 CSS 和 JS;仅在用户提供图片时使用图片)。使用 DOM + CSS 变换、SVG 或一个全屏画布绘制,选择最合适的方式。
页面规范
window.VIDEO = {w, h, duration}和window.seek(t)必须是时间的纯函数:它会将每个元素设为时间t(秒)时的状态。不使用 CSS 过渡或动画,不使用setTimeout,也不保留上一帧的状态。任何帧都必须能够单独以任意顺序正确渲染。在seek中测量布局(例如offsetLeft、measureText)没有问题;这类测量是确定性的。- 随机效果(颗粒、粒子、音频中的噪声)必须来自带种子的生成器,绝不使用
Math.random。 - 任何需要历史状态的内容(例如跟随移动物体的延迟摄像机、模拟)都必须在加载时预先烘焙一次:以 1/240 秒为步长,从 0 模拟到
duration,存储数值,并让seek通过插值读取这些数值。对于循环,应从循环开始时的状态启动模拟(参见该风格的接缝规则)。 - 循环:
seek(t)使用t % duration;跨越接缝的效果(例如落地挤压、声音尾音)必须在两端绘制或混合。 - URL 中带有
?render时:不显示控件、不缩放预览;舞台在左上角,尺寸严格为w × h。不带?render时:使用 CSS 缩放舞台以适应窗口,并显示预览控件。 - 嵌入字体。 下载 woff2 文件(来自 Google Fonts 或品牌字体),并将其以内嵌 base64 的
@font-face形式放入页面,确保渲染时不会悄然回退到其他字体。若要改变字重,使用带有字重范围([email protected])的可变字体。 - 图层: 场景图层使用透明背景,并由其后的一个舞台背景提供底色,这样后续图层就不会意外遮住前面的图层。绝对定位的文本需设置
white-space: nowrap。 - 速度: 大面积 CSS
filter: blur()和大范围模糊渐变会大幅拖慢截图渲染。应在加载时将渐变和光效绘制到画布(或作为图像),然后移动它们。
辅助代码:粘贴以下代码,并基于它们构建
// damped spring step response 0 -> 1. w = speed (8 slow, 14 normal, 22 snappy), z = damping (1 = no overshoot)
function spring(t, w = 14, z = 0.82) {
if (t <= 0) return 0;
if (z >= 1) return 1 - Math.exp(-w * t) * (1 + w * t);
const wd = w * Math.sqrt(1 - z * z);
return 1 - Math.exp(-z * w * t) * (Math.cos(wd * t) + (z * w / wd) * Math.sin(wd * t));
}
// a value that changes target many times, springing to each: track(start, [[time, value, w?, z?], ...]) -> f(t)
const track = (v0, keys) => t => { let v = v0, p = v0; for (const [tk, val, w, z] of keys) { v += (val - p) * spring(t - tk, w, z); p = val; } return v; };
// precise eased keyframes (best for camera moves): kf([[t0, v0], [t1, v1, easeFn?], ...]) -> f(t)
const kf = keys => t => { if (t <= keys[0][0]) return keys[0][1]; for (let i = 1; i < keys.length; i++) { const [a, va] = keys[i - 1], [b, vb, e = ease] = keys[i]; if (t <= b) return lerp(va, vb, e((t - a) / (b - a))); } return keys[keys.length - 1][1]; };
const clamp = (x, a = 0, b = 1) => Math.min(b, Math.max(a, x));
const ease = x => (x = clamp(x), x < .5 ? 4 * x * x * x : 1 - Math.pow(-2 * x + 2, 3) / 2); // easeInOutCubic
const lerp = (a, b, k) => a + (b - a) * k;
const seg = (t, a, b) => clamp((t - a) / (b - a)); // 0..1 progress of t through [a, b]
const rng = s => () => (s = (s + 0x6D2B79F5) | 0, s = Math.imul(s ^ (s >>> 15), 1 | s), s ^= s + Math.imul(s ^ (s >>> 7), 61 | s), ((s ^ (s >>> 14)) >>> 0) / 4294967296);
- 打字效果:可见字符串为
full.slice(0, n(t)),光标根据t闪烁。递增计数:格式化lerp(a, b, ease(seg(t, …)))。 - 摄像机是一个包装元素,其
transform: translate() scale() rotate()由kf或track驱动。
声音
window.buildAudio(ctx)会使用与视觉效果相同的时间线数值,从时间 0 开始,基于传入的AudioContext或OfflineAudioContext调度整段音轨(音乐 + 音效)。使用振荡器、滤波噪声(来自带种子的缓冲区)和包络合成声音。如果用户提供了音乐文件,则使用fetch+decodeAudioData加载,将节拍网格设为其节奏(测量节奏),并仅在音乐上叠加音效。- **预览播放:**首次播放时,将音轨渲染到缓冲区一次,然后从任意拖动位置开始播放。时钟驱动
seek:
let buf, ac, src, startAt = 0, offset = 0, playing = false;
async function play() {
ac ??= new AudioContext();
if (!buf) { const off = new OfflineAudioContext(2, Math.ceil(48000 * VIDEO.duration), 48000); await buildAudio(off); buf = await off.startRendering(); }
src = ac.createBufferSource(); src.buffer = buf; src.connect(ac.destination);
src.start(0, offset % VIDEO.duration); startAt = ac.currentTime; playing = true;
}
function pause() { if (!playing) return; offset += ac.currentTime - startAt; src.stop(); playing = false; }
function now() { return playing ? (offset + ac.currentTime - startAt) % VIDEO.duration : offset; }
(function loop() { seek(now()); requestAnimationFrame(loop); })(); // the scrubber sets offset (pause, set, play)
- 在脚本顶部用注释写出节拍表:每个时刻占一行,注明时间、发生的内容和对应的声音。
第 3 步:检查、渲染、交付
在工作文件夹中完成一次性设置:npm i playwright,如果尚未安装 Chromium,再运行 npx playwright install chromium;ffmpeg 必须位于 PATH 中。将本文件底部的脚本保存为 render.mjs。
- 静帧。
node render.mjs demo.html check.png --stills 0.5,1.2,…,每个节拍表行对应一个时间点(表格以便于阅读的大小显示,每行 3 项)。查看图片。修复任何被裁切、重叠、难以辨认、不符合品牌风格、取景不佳或空白的内容。再次检查。 - 草稿。
node render.mjs demo.html draft.mp4 30 1 4(速度快:30 fps,无动态模糊,4 个并行工作进程;最后一个数字设为机器的核心数)。这是审核版:将其与 HTML 一起发送给用户,并在制作最终版前征求修改意见。 - 最终版。
node render.mjs demo.html demo.mp4 60 8 8。动态模糊会使渲染时间按子帧数倍增;60 秒的最终版在笔记本电脑上可能需要 20–60 分钟,因此请在开始前告知。它会生成demo.mp4(音频已归一化至约 −16 LUFS)和demo_silent.mp4。 - 交付 HTML(用户可以实时拖动播放进度)、MP4 文件,以及一段简短说明:节拍表、哪些内容是占位内容,以及第二版需要修改的内容。用一句话提供一个替代方向。
// render.mjs: turn a seek(t) page into an MP4 with motion blur and its own audio.
// node render.mjs page.html out.mp4 [fps=60] [subframes=4] [workers=4]
// node render.mjs page.html sheet.png --stills 0.5,1.2,3 (full-size contact sheet for checking)
// Optional: window.buildAudio(ctx) schedules the soundtrack on any (Offline)AudioContext.
import { chromium } from 'playwright';
import { spawn } from 'node:child_process';
import { writeFileSync, mkdirSync, rmSync } from 'node:fs';
import path from 'node:path';
import { pathToFileURL } from 'node:url';
const [, , html, out, a3 = '60', a4 = '4', a5 = '4'] = process.argv;
if (!html || !out) { console.log('usage: node render.mjs page.html out.mp4 [fps] [subframes] [workers]'); process.exit(1); }
const run = (args) => new Promise((res, rej) => { const p = spawn('ffmpeg', args, { stdio: 'inherit' }); p.on('close', c => c ? rej(new Error('ffmpeg ' + c)) : res()); });
const url = pathToFileURL(path.resolve(html)).href + '?render';
const proxy = process.env.HTTPS_PROXY || process.env.https_proxy;
const browser = await chromium.launch({ args: ['--allow-file-access-from-files'], ...(proxy ? { proxy: { server: proxy } } : {}) });
// Read the size first, then open every page at exactly that size (pages that measure themselves on load stay correct).
const probe = await browser.newPage();
await probe.goto(url);
await probe.waitForFunction(() => window.VIDEO && typeof window.seek === 'function');
const V = await probe.evaluate(() => window.VIDEO);
await probe.close();
async function openPage() {
const page = await browser.newPage({ viewport: { width: V.w, height: V.h }, deviceScaleFactor: 1 });
await page.goto(url);
await page.waitForFunction(() => window.VIDEO && typeof window.seek === 'function');
// load every declared font, even ones only used by scenes hidden at t=0
const bad = await page.evaluate(async () => {
await Promise.all([...document.fonts].map(f => f.load().catch(() => {})));
await document.fonts.ready;
return [...document.fonts].filter(f => f.status !== 'loaded').map(f => f.family);
});
if (bad.length) console.warn('WARNING: fonts did not load (fallback in use):', [...new Set(bad)].join(', '), '- embed them as base64.');
return page;
}
const shot = async (page, t) => { await page.evaluate(t => window.seek(t), t); return page.screenshot({ type: 'jpeg', quality: 95 }); };
if (a3 === '--stills') {
const page = await openPage();
const times = a4.split(',').map(Number), dir = out + '.frames';
mkdirSync(dir, { recursive: true });
for (let i = 0; i < times.length; i++) writeFileSync(`${dir}/${String(i).padStart(3, '0')}.jpg`, await shot(page, times[i]));
await browser.close();
const cols = Math.min(3, times.length), rows = Math.ceil(times.length / cols);
await run(['-v', 'error', '-y', '-framerate', '1', '-i', `${dir}/%03d.jpg`, '-vf', `scale=${Math.min(960, V.w)}:-1,tile=${cols}x${rows}:padding=6:color=gray`, '-frames:v', '1', out]);
rmSync(dir, { recursive: true });
console.log('stills ->', out); process.exit(0);
}
const fps = +a3, sub = Math.max(1, +a4), workers = Math.max(1, +a5), N = Math.round(V.duration * fps);
const base = out.replace(/\.mp4$/, ''), silent = base + '_silent.mp4';
const vf = sub > 1 ? ['-vf', `tmix=frames=${sub},select='eq(mod(n\\,${sub})\\,${sub - 1})',setpts=N/(${fps}*TB)`] : [];
const t0 = Date.now(); let doneFrames = 0;
const per = Math.ceil(N / workers), segs = [];
await Promise.all(Array.from({ length: workers }, async (_, w) => {
const a = w * per, b = Math.min(N, a + per); if (a >= b) return;
const seg = `${base}_seg${w}.mp4`; segs[w] = seg;
const page = await openPage();
const ff = spawn('ffmpeg', ['-v', 'error', '-y', '-f', 'image2pipe', '-framerate', String(fps * sub), '-i', '-', ...vf,
'-r', String(fps), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', '-crf', '16', '-preset', 'medium', seg], { stdio: ['pipe', 'inherit', 'inherit'] });
const closed = new Promise(r => ff.on('close', r));
for (let f = a; f < b; f++) {
for (let s = 0; s < sub; s++) { // 180-degree shutter: sub-frames spread over half a frame
const buf = await shot(page, (f + (sub > 1 ? (s / sub) * 0.5 : 0)) / fps);
if (!ff.stdin.write(buf)) await new Promise(r => ff.stdin.once('drain', r));
}
if (++doneFrames % 30 === 0) process.stdout.write(`\r${doneFrames}/${N} frames ${((Date.now() - t0) / 1000).toFixed(0)}s`);
}
ff.stdin.end(); await closed; await page.close();
}));
const list = base + '_segs.txt';
writeFileSync(list, segs.filter(Boolean).map(s => `file '${path.resolve(s)}'`).join('\n'));
await run(['-v', 'error', '-y', '-f', 'concat', '-safe', '0', '-i', list, '-c', 'copy', silent]);
segs.filter(Boolean).forEach(s => rmSync(s)); rmSync(list);
console.log(`\nvideo -> ${silent} (${((Date.now() - t0) / 1000).toFixed(0)}s)`);
const page = await openPage();
const hasAudio = await page.evaluate(() => typeof window.buildAudio === 'function');
if (hasAudio) {
const b64 = await page.evaluate(async (dur) => {
const sr = 48000, ctx = new OfflineAudioContext(2, Math.ceil(sr * dur), sr);
await window.buildAudio(ctx);
const b = await ctx.startRendering(), L = b.getChannelData(0), R = b.getChannelData(b.numberOfChannels > 1 ? 1 : 0);
const i16 = new Int16Array(L.length * 2);
for (let i = 0; i < L.length; i++) { i16[2 * i] = Math.max(-1, Math.min(1, L[i])) * 32767; i16[2 * i + 1] = Math.max(-1, Math.min(1, R[i])) * 32767; }
const u8 = new Uint8Array(i16.buffer); let s = '';
for (let i = 0; i < u8.length; i += 0x8000) s += String.fromCharCode.apply(null, u8.subarray(i, i + 0x8000));
return btoa(s);
}, V.duration);
const pcm = base + '.pcm'; writeFileSync(pcm, Buffer.from(b64, 'base64'));
await run(['-v', 'error', '-y', '-i', silent, '-f', 's16le', '-ar', '48000', '-ac', '2', '-i', pcm,
'-af', 'loudnorm=I=-16:TP=-1.5:LRA=11', '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-shortest', out]);
rmSync(pcm); console.log('with audio ->', out);
}
await browser.close();