高能量宣传片
一个 12–20 秒的爆款作品,像作品集一样冲击感十足。每半秒都有东西落在节拍上,但绝不会让人感觉是随意剪辑,因为一个形状贯穿所有转场,引导观众的视线:火花变成线条,线条充满屏幕成为下一个背景,句号脱离成为下一个场景围绕构建的球体。
你将其编写为一个 HTML 页面,该页面根据时间值渲染任何帧,然后将其渲染为带有运动模糊和合成音轨的 MP4。
步骤 1:获取简介(一条消息,然后开始)
在一条消息中询问所有内容,然后自行工作。只需要产品信息;对于任何留空的内容,自行决定并说明你的选择。
- 产品:名称,以及用几句话描述其功能。网站 URL 更佳。
- 观众必须记住的一件事(例如,“输入一个想法,得到一个实时应用”)。
- 品牌:颜色、字体、Logo 文件,或“使用网站”。如果没有,则使用下面的默认调色板。
- 格式:16:9(1920×1080)或 9:16(1080×1920)。默认为 16:9。
- 时长:此风格的默认时长如下所示。
- 音乐:他们自己的曲目(文件路径),或者让你合成一个。默认:合成。
- 方向(可选):他们想要的场景、标语、行动号召,任何需要避免的内容。
如果他们提供了 URL,则在编写任何内容之前先阅读它(网络抓取),以获取产品的实际措辞、功能、颜色和字体名称。对 UI 标签和标语使用他们的实际词汇。
切勿捏造事实。 不得出现编造的客户数量、收入、评分、推荐或引语。仅当用户或网站明确说明时才使用数字;否则使用相对条、进度和占位符骨架线条。UI 内部的内容示例(提示、文件名、项目名称、收据上的价格)是可以的,并且应该是可信的;关于产品本身的声明(统计数据、用户数量、评分、像“免费”这样的优惠)则不行。行动号召仅使用用户或网站提供的 URL 或报价;如果没有,则以名称和标语结束。
Logo。 仅当用户提供文件,或者这是他们自己的产品并且他们要求使用时才使用 Logo。否则,使用品牌字体将名称写为文本。第三方 Logo(集成)仅在提供时使用;否则使用带有名称的居中圆角方块。
此风格的默认时长:15 秒(128 BPM,8 小节)。范围为 12–20。
步骤 2:规划场景,然后构建
结构(6–8 个场景,每个 1.5-3 秒,基于 120-140 BPM 的网格)
从这些场景类型中选择,并将产品映射到它们上。首先编写节拍表(场景、节拍网格上的开始时间、引入什么、带出什么、声音)。
- 开场火花(黑暗)。近黑色画布,带有微弱的网格。出现一个小高亮标记,脉动,拉伸成一条细水平线横跨画面,带有一个小的等宽标签 (
产品 / 发布 — 2026),抖动一次,然后线条垂直扩展成整个屏幕的渐变,充满高亮颜色。该渐变就是下一个背景。 - 品牌冲击。 产品名称使用粗无衬线字体,全大写,巨大,通过在连续的 1/16 拍上添加遮罩逐字母显示,上方有一个小的等宽踢脚线 (
INTRODUCING — 2026),下方有一条斜体衬线行(它是什么,2–4 个词)。字重可以从细变为粗。句号或字母的点脱离并变大,成为下一个场景开始的形状。 - 动态文字三重奏。 价值主张以三个简短的单词或短语形式出现,每个两拍(手写体单词则为三拍,以便阅读),每个都有其自己的处理方式和自己的背景,在节拍上硬切换(在此场景内部,切换就是节奏,因此不算作剪辑):暗色背景上使用奶油色;黑色背景上使用高亮色,单词以轮廓图案重复填满画面;电动蓝背景上使用白色,在取景器角括号内带有一个小
1920 × 1080标签;最后一个词使用手写体脚本自行绘制。每个词以快速模糊/滑动进入,并带有轻微过冲。 - 标志性视觉。 一个“他们是怎么做出来的”时刻,象征产品:一个 3D 立方体场从中心泛起涟漪(在画布上使用您自己的透视投影绘制,根据调色板按高度着色),一个生成粒子流凝聚成一个环并向外爆发,或一个液态斑点在不同形状之间变形。选择适合产品概念的那个(积木块 → 立方体,数据/AI → 粒子,灵活性 → 液态)。在播放期间,摄像机缓慢倾斜或环绕。
- 产品微瞬间。 产品中的一次真实交互,显示为浮动 UI 卡片,略微 3D 倾斜,后面堆叠有两张幽灵卡片:光标拨动开关,按下主按钮,进度条以高亮色填充并显示百分比,按钮变为
完成 ✓,右上角滑入一个提示条。使用产品的实际标签。 - 证明(仅当存在真实数据时)。 一个大的计数动画(
+312%风格,数字在旋转时模糊),一个环形图,错落有致的条形图上升,其中一个为高亮色,一条线在它们上方绘制并带有一个工具提示。没有真实数字 → 跳过此场景,或将产品的实际功能显示为一个猛冲入内的列表。 - 液态/变形桥接。 上一个场景中的形状收缩成一个点,该点变成黑暗背景上的脉冲环,脉冲环变成 Logo 中的点。
- 结束卡片(保持 2 秒)。Logo 或名称,带有高亮色点,斜体衬线标语,一个间距等宽行逐字母输入(如果提供,为实际 URL 或行动号召,例如
PRODUCT.COM;否则为简介中的短行),一条细线。
并非每个产品都需要每个场景;6 个强场景胜过 8 个弱场景。保持每个场景一个想法。
让视频看起来高级的规则
- 转场是对象,不是剪辑。 每个场景通过一个形状(渐变、由增长条执行的擦除、变大成为下一个背景的点、穿过摄像机的词)传递给下一个场景。场景之间的硬剪辑仅在重拍上允许,最多两次。
- 一切都在网格上。 大的变化在节拍上进行,文字排版在 1/8 或 1/16 音符上。对冲击效果略微过冲(
z≈ 0.6-0.7),然后稳定。 - 每个场景上的 HUD 框架: 细角括号,右上角为
场景 02 / 07,左下角为微小的等宽类别标签(动态文字、产品、数据),右下角为运行时间码,带有一个红点。小尺寸(1080p 下为 12-14 像素),约 50% 不透明度;应感觉像摄像机取景器。 - 纹理: 每个纯色背景上添加柔和的径向渐晕,非常细微的胶片颗粒(在加载时预生成几个种子颗粒块并循环使用),以及真实的运动模糊。
- 字体: 粗无衬线字体用于冲击(例如 Inter Tight 900 或 Archivo Black),斜体衬线字体用于人情化行(Instrument Serif 斜体),等宽字体用于 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()和大型模糊渐变在截屏时非常慢。在加载时将渐变和发光效果一次绘制到画布(或作为图像)上并移动它。
辅助函数:粘贴它们并在此基础上构建所有内容
// 阻尼弹簧阶跃响应 0 -> 1。w = 速度(8 慢,14 正常,22 快),z = 阻尼(1 = 无过冲)
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));
}
// 一个值多次改变目标,每次弹簧过渡: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; };
// 精确缓动关键帧(最适合摄像机移动):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)); // t 在 [a, b] 中的 0..1 进度
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, …)))。 - 摄像机是一个包装器元素,带有由
kf或track驱动的transform: translate() scale() rotate()。
声音
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); })(); // 拖动条设置 offset(暂停,设置,播放)
- 将节拍表作为注释放在脚本顶部:每个时刻一行,包含其时间、发生的事情和哪个声音。
步骤 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 分钟,因此在开始之前告知用户。在依赖前述程序(playwright、ffmpeg)连接之前添加检查。
所有平台共享一个渲染脚本,它们在渲染子命令后接受不同的标志。
该脚本假定一个标准化的路径:
- HTML 文件是唯一必需的参数。
- 输出路径。
- fps(默认 30)。
- 子采样步长(1 = 无运动模糊,2+ = 运动模糊)。
- 并行工作线程数(默认 4)。
在发送最终文件之前,删除 ?render 版本并确认 HTML 与预览控件一起使用。将文件发送给用户,询问是否对输出满意或是否需要任何更改。
render.mjs
#!/usr/bin/env node
// 共享渲染脚本。
// 用法:node render.mjs <html> <output> [fps] [subsample] [workers]
import { chromium } from 'playwright';
import { execSync, spawn } from 'child_process';
import { writeFileSync, mkdirSync, existsSync } from 'fs';
import { join, dirname } from 'path';
const [html, out, fps = '30', subsample = '1', workers = '4'] = process.argv.slice(2);
if (!html || !out) { console.error('Usage: node render.mjs <html> <output> [fps] [subsample] [workers]'); process.exit(1); }
const isStills = out.endsWith('.png');
const stillTimes = process.argv.includes('--stills') ? process.argv[process.argv.indexOf('--stills') + 1].split(',').map(Number) : [];
async function render() {
const browser = await chromium.launch({ headless: true });
const page = await browser.newPage();
await page.goto(`file://${join(process.cwd(), html)}?render`);
await page.waitForFunction(() => window.VIDEO && typeof window.seek === 'function');
if (isStills) {
const frames = [];
for (const t of stillTimes) {
await page.evaluate(t => window.seek(t), t);
frames.push(await page.screenshot({ type: 'png' }));
}
writeFileSync(out, frames[0]);
// 简单的多静态图逻辑被省略;对于多个,可以输出到目录
console.log('Stills saved');
} else {
const w = await page.evaluate(() => window.VIDEO.w);
const h = await page.evaluate(() => window.VIDEO.h);
const duration = await page.evaluate(() => window.VIDEO.duration);
const totalFrames = Math.ceil(duration * Number(fps));
const frameDir = join(dirname(out), 'frames');
mkdirSync(frameDir, { recursive: true });
const workerCount = Number(workers);
const framesPerWorker = Math.ceil(totalFrames / workerCount);
const workersList = [];
for (let i = 0; i < workerCount; i++) {
const start = i * framesPerWorker;
const end = Math.min((i + 1) * framesPerWorker, totalFrames);
workersList.push(spawn('node', ['-e', `
const { chromium } = require('playwright');
(async () => {
const br = await chromium.launch({ headless: true });
const pg = await br.newPage();
await pg.goto('file://${join(process.cwd(), html)}?render');
await pg.waitForFunction(() => window.VIDEO && typeof window.seek === 'function');
for (let f = ${start}; f < ${end}; f++) {
const t = f / ${fps};
await pg.evaluate(t => window.seek(t), t);
const buf = await pg.screenshot({ type: 'png' });
require('fs').writeFileSync('${frameDir}/frame_' + String(f).padStart(6, '0') + '.png', buf);
}
await br.close();
})();
`], { stdio: 'inherit', shell: true }));
}
await Promise.all(workersList.map(w => new Promise((res, rej) => { w.on('exit', res); w.on('error', rej); })));
// 使用 ffmpeg 合成视频
execSync(`ffmpeg -y -framerate ${fps} -i ${frameDir}/frame_%06d.png -c:v libx264 -pix_fmt yuv420p -crf 18 -preset veryslow ${out}`, { stdio: 'inherit' });
// 清理帧
execSync(`rm -rf ${frameDir}`);
}
await browser.close();
}
render().catch(console.error);