mcpField
E
explainer-high-energy

explainer-high-energy

github.com/barty-bart创意与媒体9131.0k 次安装2 周前更新
代码仓库

关于 explainer-high-energy

用于制作 12–20 秒高能产品解说视频的创意技能,涵盖需求收集、场景规划、确定性 HTML 动画、合成音轨与渲染,并要求避免编造产品事实。

主要功能

  1. 01单页 HTML 视频构建
  2. 02按节拍规划场景与转场
  3. 03通过 seek 确定性渲染帧
  4. 04合成音轨与音效
  5. 05品牌字体和配色指引
  6. 06确保产品声明准确

适用场景

  1. 01制作简短的产品发布解说视频
  2. 02将产品功能转为动态视频
  3. 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 步:获取需求(发一条消息,然后开始制作)

用一条消息询问所有问题,然后自行完成剩余工作。只有产品信息是必填项;任何未填写的内容都由你自行决定,并说明你的选择。

  1. 产品:名称,以及几句话介绍它的功能。最好提供网站 URL。
  2. 观众必须记住的一件事(例如:“输入一个想法,生成一个实时应用”)。
  3. 品牌:颜色、字体、Logo 文件,或“使用网站上的品牌元素”。如果没有,请使用下方的默认配色。
  4. 格式:16:9(1920×1080)或 9:16(1080×1920)。默认为 16:9。
  5. 时长:此风格的默认时长见下方。
  6. 音乐:使用对方的曲目(文件路径),或由你合成配乐。默认:合成。
  7. 创作方向(可选):对方想要的场景、标语、行动号召,以及任何需要避免的内容。

如果对方提供了 URL,请在开始撰写前读取该网页(通过网页抓取),了解产品的真实措辞、功能、颜色和字体名称。UI 标签和标语应使用产品的实际用语。

绝不编造事实。 不得虚构客户数量、收入、评分、推荐语或引言。只有用户或网站明确提供时才可使用数字;否则请使用相对条形图、进度条和占位骨架线。UI 中的示例内容(提示词、文件名、项目名称、收据上的价格)可以合理虚构;但关于产品本身的宣传(统计数据、用户数、评分、“免费”等优惠)不可以。行动号召只能使用用户或网站提供的网址或优惠;如果没有,就以产品名称和标语收尾。

Logo。 只有在用户提供文件,或这是用户自己的产品且用户要求使用 Logo 时,才可使用 Logo。否则,请使用品牌字体将名称作为文本呈现。只有在用户提供第三方 Logo(用于集成)时才可使用;否则,请使用带圆角的中性方块,并在其中写上名称。

此风格的默认时长:15 秒(128 BPM,8 小节)。范围为 12–20 秒。

第 2 步:规划场景,然后开始制作

结构(6–8 个场景,每个场景 1.5–3 秒,按 120–140 BPM 节拍网格编排)

从这些场景类型中挑选,并将产品融入其中。先写节拍表(场景、在节拍网格上的起始时间、承接内容、引出内容、音效)。

  1. 火花开场(暗色)。近乎全黑的画布上浮现一张淡淡的网格。一枚微小的强调色标记出现、脉动,拉伸成贯穿画面的细横线,旁边带有小号等宽标签(product / launch — 2026);横线轻晃一次,随后垂直扩展为铺满全屏的强调色。这片色彩成为下一个场景的背景。
  2. 品牌重击。产品名称采用粗重的无衬线字体、全大写并放大,字母通过遮罩按连续的 1/16 拍逐个显现;上方放置小号等宽引导语(INTRODUCING — 2026),下方放一行斜体衬线文字(用 2–4 个词说明它是什么)。字重可以从纤细渐变为极粗。句点或某个字母上的点脱离并放大,变成开启下一场景的形状。
  3. 动感三连。用三个简短词语或短语呈现价值主张,每个占两拍(手写词占三拍,以便观众读完),每个词都采用独特处理方式和独立背景,并在拍点上硬切(在此场景内,背景切换本身就是节奏,因此不计作剪辑):奶油色背景上的深色文字;黑色背景上的强调色文字,文字以描边形式重复并铺满画面;电光蓝背景上的白色文字,置于取景器角标内,配有小号 1920 × 1080 标签;最后一个词用手写体自行绘出。每个词快速模糊滑入,落位时略微过冲。
  4. 标志性视觉。呈现一个象征产品的“这是怎么做到的”瞬间:从中心开始涟漪扩散的 3D 方块场(用画布和自定义透视投影绘制,并根据高度套用调色板上色)、凝聚成环后向外迸发的生成式粒子流,或在不同形状间变幻的液态团块。选择最契合产品理念的一种(构建模块 → 方块,数据/AI → 粒子,灵活性 → 液态)。播放过程中,镜头缓慢倾斜或环绕。
  5. 产品微交互。展示产品中的一项真实交互,将其呈现为略微倾斜的 3D 悬浮 UI 卡片,后方叠放两张半透明的卡片:光标快速拨动一个切换开关,按下主按钮,进度条以强调色填充并显示百分比,按钮变为 Done ✓,右上角滑入一条提示。使用产品中的真实标签。
  6. 效果证明(仅在有真实数据时)。一个大数字递增显示(类似 +312%,数字旋转时带有模糊效果)、一个环形图、错落上升的条形图(其中一条使用强调色),以及一条在图表上绘制的折线和一个工具提示。没有真实数据 → 跳过此场景,或用猛然弹入的列表展示产品的真实能力。
  7. 液态/变形过渡。上一场景中的形状收缩成一个点;这个点在暗色背景上变成环形脉冲,随后成为 Logo 中的点。
  8. 片尾卡(停留 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。

  1. 静帧。 node render.mjs demo.html check.png --stills 0.5,1.2,…,每个节拍表行对应一个时间点(表格以便于阅读的大小显示,每行 3 项)。查看图片。修复任何被裁切、重叠、难以辨认、不符合品牌风格、取景不佳或空白的内容。再次检查。
  2. 草稿。 node render.mjs demo.html draft.mp4 30 1 4(速度快:30 fps,无动态模糊,4 个并行工作进程;最后一个数字设为机器的核心数)。这是审核版:将其与 HTML 一起发送给用户,并在制作最终版前征求修改意见。
  3. 最终版。 node render.mjs demo.html demo.mp4 60 8 8。动态模糊会使渲染时间按子帧数倍增;60 秒的最终版在笔记本电脑上可能需要 20–60 分钟,因此请在开始前告知。它会生成 demo.mp4(音频已归一化至约 −16 LUFS)和 demo_silent.mp4。
  4. 交付 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();

评论

支持 Markdown