explainer-high-energy
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseHigh-energy explainer
高能量宣传短片
A 12–20 second hype piece that hits like a showreel. Every half-second something lands on the beat, but it never feels like random cuts, because a shape carries the eye across every transition: a spark becomes a line, the line floods the screen into the next background, a full stop detaches and becomes the ball the next scene is built around.
You write it as one HTML page that renders any frame from a time value, then render it to MP4 with motion blur and a synthesised soundtrack.
一段 12–20 秒、像作品集一样有冲击力的燃爆短片。每隔半秒都会有元素精准踩上节拍,但画面绝不会显得像随意剪切,因为一个图形会带着视线穿越每次转场:火花变成线条,线条铺满屏幕并化作下一个背景,句号脱离出来,化为下一场景围绕构建的圆球。
你将它写成一个 HTML 页面,页面可根据时间值渲染任意帧,再将其渲染为带运动模糊和合成音轨的 MP4。
Step 1: Get the brief (one message, then go)
第 1 步:获取需求简报(一条消息问完,然后自行推进)
Ask everything in one message, then work on your own. Only the product is required; for anything left blank, decide yourself and say what you chose.
- Product: name, and what it does in a few sentences. A website URL is even better.
- The one thing the viewer must remember (e.g. "type an idea, get a live app").
- Brand: colours, fonts, logo file, or "use the website". If none, use the default palette below.
- Format: 16:9 (1920×1080) or 9:16 (1080×1920). Default 16:9.
- Length: default is given below for this style.
- Music: their own track (a file path), or let you synthesise one. Default: synthesise.
- Direction (optional): scenes they want, a tagline, a call to action, anything to avoid.
If they gave a URL, read it (web fetch) for the product's real wording, features, colours and font names before writing anything. Use their real words for UI labels and taglines.
Never invent facts. No made-up customer counts, revenue, ratings, testimonials or quotes. Numbers only if the user or the website states them; otherwise use relative bars, progress and placeholder skeleton lines. Example content inside the UI (a prompt, a file name, a project name, prices on a receipt) is fine and should be believable; claims about the product itself (stats, user counts, ratings, offers like "free") are not. Calls to action use only a URL or offer the user or the website gave; with none, end on the name and tagline.
Logos. Use a logo only if the user supplies the file or it is their own product and they asked for it. Otherwise write the name as text in the brand font. Third-party logos (integrations) only if supplied; otherwise neutral rounded tiles with the name.
Default length for this style: 15 seconds (128 BPM, 8 bars). Range 12–20.
在一条消息中问完所有问题,然后自行推进。只有产品信息是必填项;其他留空项由你自行决定,并说明你的选择。
- 产品:名称,以及用几句话说明其作用。最好能提供网站 URL。
- 观众唯一需要记住的一点(例如“输入一个想法,即可获得实时应用”)。
- 品牌:颜色、字体、logo 文件,或“使用网站上的设计”。如未提供,则使用下方默认调色板。
- 画幅:16:9(1920×1080)或 9:16(1080×1920)。默认使用 16:9。
- 时长:此样式的默认时长见下文。
- 音乐:用户提供自己的曲目(文件路径),或由你合成。默认使用合成音乐。
- 创意方向(可选):希望呈现的场景、标语、行动号召,以及任何需要避免的内容。
如果对方提供了 URL,在动笔前先读取该 URL(web fetch),了解产品真实的措辞、功能、颜色和字体名称。使用他们的真实文案作为 UI 标签和标语。
绝不编造事实。 不得虚构客户数量、营收、评分、评价或引述。只有用户或网站明确提供时才能使用数字;否则使用相对比例条、进度条和占位骨架线。UI 内的示例内容(提示词、文件名、项目名、收据上的价格)可以真实可信;但不得将统计数据、用户数、评分、“免费”等优惠作为产品自身的事实。行动号召只能使用用户或网站提供的 URL 或优惠;如两者都没有,则以产品名称和标语收尾。
Logo。 只有在用户提供 logo 文件,或这是用户自己的产品且用户明确要求时,才使用 logo。否则用品牌字体将名称写成文字。第三方 logo(集成项)也仅在用户提供时使用;否则用带名称的中性圆角磁贴代替。
此样式的默认时长为 15 秒(128 BPM,8 小节)。范围为 12–20 秒。
Step 2: Plan the scenes, then build
第 2 步:先规划场景,再开始构建
The structure (6–8 scenes of 1.5–3 s, on a 120–140 BPM grid)
结构(6–8 个场景,每个 1.5–3 秒,使用 120–140 BPM 网格)
Pick from these scene types and map the product onto them. Write the beat sheet first (scene, start time on the beat grid, what carries in, what carries out, sound).
- Spark open (dark). Near-black canvas with a faint grid. A tiny accent mark appears, pulses, stretches into a thin horizontal line across the frame with a small mono label (), wobbles once, then the line expands vertically into a full-screen flood of the accent colour. That flood is the next background.
product / launch — 2026 - Brand slam. The product name in a heavy grotesk, all caps, huge, letters revealed by masks on consecutive 1/16 beats, a small mono kicker above () and an italic serif line below (what it is, 2–4 words). The weight can morph thin → black. The full stop or a letter's dot detaches and grows into the shape that starts the next scene.
INTRODUCING — 2026 - Kinetic triplet. The value proposition as three short words or phrases, two beats each (three for a handwritten word, so it can be read), each with its own treatment and its own background, swapped hard on the beat (inside this scene the swaps are the rhythm, so they do not count as cuts): dark on cream; accent on black with the word repeated as an outline pattern filling the frame; white on electric blue inside viewfinder corner brackets with a tiny label; a last word in handwritten script that draws itself on. Each word enters with a fast blur/slide and lands with a slight overshoot.
1920 × 1080 - Signature visual. One "how did they make that" moment that symbolises the product: a 3D field of cubes rippling from the centre (drawn on canvas with your own perspective projection, coloured by height with the palette), a generative particle flow that condenses into a ring and bursts outward, or a liquid blob morphing between shapes. Pick the one that fits the product's idea (building blocks → cubes, data/AI → particles, flexibility → liquid). The camera tilts or orbits slowly while it plays.
- Product micro-moment. One real interaction from the product as a floating UI card, tilted slightly in 3D with two ghost cards stacked behind it: the cursor flicks a toggle, presses the main button, a progress bar fills in the accent colour with a percentage, the button turns to , a toast slides in at the top right. Use the product's real labels.
Done ✓ - Proof (only if real numbers exist). A big count-up (style, digits blurring while they spin), a donut ring, staggered bars rising with one accent bar, a line drawing over them with a tooltip. No real numbers → skip this scene or show the product's real capabilities as a list that slams in.
+312% - Liquid/morph bridge. The shape from the previous scene collapses to a dot, the dot becomes a ring pulse on dark, which becomes the dot in the logo.
- End card (hold 2 s). Logo or name with the accent dot, italic serif tagline, a tracked mono line typing in letter by letter (the real URL or call to action if given, e.g. ; otherwise a short line from the brief), a hairline rule.
PRODUCT.COM
Not every product needs every scene; 6 strong ones beat 8 weak ones. Keep to one idea per scene.
从以下场景类型中进行选择,并将产品映射到这些场景。先写节拍表(场景、在节拍网格中的开始时间、带入和带出的元素、声音)。
- Spark open(深色)。以近黑色画布和若隐若现的网格开场。一个微小的强调色标记出现、脉动,并拉伸成横贯画面的细线,旁边配有一个小型 mono 标签()。线条轻微晃动一次,随后纵向扩张,以强调色形成铺满全屏的色浪,并成为下一个场景的背景。
product / launch — 2026 - Brand slam。 以厚重的 grotesk 字体将产品名称以全大写形式巨大呈现,字母在连续的 1/16 拍上通过遮罩依次显露;上方配一个小型 mono 引导语(),下方配一行斜体衬线文案(用 2–4 个词说明产品是什么)。字重可以从细变为黑体。全角句号或字母上的圆点会脱离并放大,成为下一场景的起始图形。
INTRODUCING — 2026 - Kinetic triplet。 用三个简短词语或短语表达价值主张,每个占两拍(手写词为保证可读性可占三拍),每个元素采用不同处理方式和不同背景,并在 beat 上硬切替换(此场景内部的替换本身就是节奏,因此不算镜头切换):深色文字配奶油色背景;强调色文字配黑色背景,并以轮廓图案重复填满画面;白色文字配电光蓝背景,外加取景器四角框和一个小型 标签;最后一个词使用手写体并自行描画出现。每个词都通过快速模糊或滑移进入,并略微 overshoot 后稳定落地。
1920 × 1080 - Signature visual。 设置一个让人产生“他们是怎么做到的”之感的视觉时刻,用它象征产品:可以从中心泛起涟漪的 3D 立方体阵列(在 canvas 上使用自建透视投影绘制,并按高度使用调色板着色)、凝聚成环形后向外爆发的生成式粒子流,或在不同形态间变形的液态 blob。根据产品理念选择最合适的一种(构建模块 → 立方体,数据/AI → 粒子,灵活性 → 液态)。播放期间,镜头缓慢倾斜或环绕。
- Product micro-moment。 将产品中的一项真实交互呈现为浮动 UI 卡片,卡片以 3D 效果略微倾斜,后方叠放两张幽灵卡片:光标拨动 toggle、按下主按钮、进度条以强调色填充并显示百分比、按钮变成 ,右上角滑入一条 toast。使用产品真实存在的标签。
Done ✓ - Proof(仅在存在真实数字时使用)。使用大型数字递增动画(类似 ,数字旋转时带模糊)、圆环、交错升起的柱状条,其中一条使用强调色,再叠加折线图和 tooltip。如果没有真实数字,则跳过此场景,或让产品真实功能列表以强烈冲击方式进入画面。
+312% - Liquid/morph bridge。 上一场景的图形坍缩为一个点,该点在深色背景上化为环形脉冲,再变成 logo 中的圆点。
- End card(停留 2 秒)。使用 logo 或名称,并搭配强调色圆点、斜体衬线标语,以及一行带较宽字距的 mono 文案,逐字母键入(如果提供了真实 URL 或行动号召,例如 ;否则使用需求简报中的简短文案),最后加一条发丝线分隔。
PRODUCT.COM
并非每个产品都需要采用全部场景;6 个有力场景胜过 8 个平庸场景。始终坚持每个场景只表达一个概念。
Rules that make it feel expensive
营造高级感的规则
- Transitions are objects, not cuts. Every scene hands over to the next through a shape (flood, wipe by a growing bar, a dot that grows into the next background, a word that zooms through the camera). A hard cut between scenes is allowed only on a downbeat, at most twice.
- Everything lands on the grid. Big changes on beats, typography on 1/8 or 1/16 notes. Overshoot slightly (≈ 0.6–0.7) for impacts, then settle.
z - HUD frame on every scene: thin corner brackets, top right, a category label bottom left in tiny mono (
SCENE 02 / 07,KINETIC TYPE,PRODUCT), and a running timecode bottom right with a red dot. Small (12–14 px at 1080p) and about 50% opacity; it should feel like a camera viewfinder.DATA - Texture: a soft radial vignette on every flat background, very subtle film grain (pre-generate a few seeded grain tiles at load and cycle them), and real motion blur.
- Type: a heavy grotesk for slams (e.g. Inter Tight 900 or Archivo Black), an italic serif for the human line (Instrument Serif italic), a mono for HUD and captions (JetBrains Mono or Geist Mono). If the brand has fonts, use theirs for the name and keep the mono for the HUD.
- Default palette: ink , accent
#0B0B10, cream#FF4F1A, electric blue#EDEBE4, white. The brand's main colour replaces the accent and its second colour replaces the blue; a one-colour brand uses a deep shade of its colour as the second.#2F47FF - Banned: stock-template looks, glows on everything, emoji, more than 3 colours in one scene (not counting black and white), text smaller than readable at a glance, any scene without motion.
- 转场靠物体完成,而不是硬切。 每个场景都要通过一个图形传递给下一个场景(满屏色彩、用增长条进行 wipe、放大成为下一背景的圆点、从镜头中穿过的单词)。仅允许在 downbeat 上硬切,且最多两次。
- 一切都精准落在节拍网格上。 大型变化落在 beat 上,文字动画落在 1/8 或 1/16 拍上。冲击效果略微 overshoot(≈ 0.6–0.7),然后稳定下来。
z - 每个场景都使用 HUD 边框: 细线四角框、右上角的 、左下角使用微型 mono 字体的分类标签(
SCENE 02 / 07、KINETIC TYPE、PRODUCT),以及右下角带红点的实时时间码。尺寸要小(1080p 下为 12–14 px),透明度约 50%,应让人感觉像摄影机取景器。DATA - 纹理: 每个纯色背景都加入柔和的径向暗角、非常轻微的胶片颗粒(加载时预先生成几张带种子的颗粒贴图并循环使用),以及真实的运动模糊。
- 字体: 冲击标题使用厚重 grotesk 字体(例如 Inter Tight 900 或 Archivo Black),人性化文案使用斜体衬线字体(Instrument Serif italic),HUD 和字幕使用 mono 字体(JetBrains Mono 或 Geist Mono)。如果品牌有指定字体,名称使用品牌字体,HUD 仍使用 mono 字体。
- 默认调色板: ink 、accent
#0B0B10、cream#FF4F1A、electric blue#EDEBE4、white。品牌主色替换 accent,第二品牌色替换 electric blue;单色品牌则使用其主色的深色版本作为第二色。#2F47FF - 禁止项: 模板化 stock 风格、所有元素都发光、emoji、单个场景使用超过 3 种颜色(黑白不计)、文字小到无法一眼读清,以及任何完全没有运动的场景。
Sound
声音
128 BPM (or the user's track). Punchy: kick on every beat, clap on 2 and 4, a sub bass following the scene roots, a filtered riser into the brand slam and the end card, an impact (low boom + noise burst) on each flood, short tonal blips on each typed word, a whoosh (band-passed noise sweep) on every carried transition, a soft ring-out on the end card. Mix the music under the hits.
128 BPM(或用户提供的曲目)。要有强烈冲击力:每个 beat 打 kick,2 和 4 拍打 clap,sub bass 跟随场景根音,品牌 slam 和 end card 前加入 filtered riser,每次满屏色彩转换时加入 impact(低频轰鸣加噪声爆发),每个键入的单词配短促 tonal blip,每次图形承接式转场加入 whoosh(带通噪声扫频),end card 配柔和 ring-out。将音乐铺在冲击音之下。
How the page must be built
页面构建方式
One self-contained HTML file (inline CSS and JS; images only if the user supplied them). Draw with DOM + CSS transforms, SVG, or one full-screen canvas, whatever fits.
The page contract
- and
window.VIDEO = {w, h, duration}, a pure function of time: it sets every element for timewindow.seek(t)(seconds). No CSS transitions or animations, not, no state carried from the previous frame. Any frame must render correctly on its own, in any order. Measuring layout insidesetTimeout(e.g.seek,offsetLeft) is fine; it is deterministic.measureText - Randomness (grain, particles, noise in audio) comes from a seeded generator, never .
Math.random - Anything that needs a history (a camera that lags behind a moving object, a simulation) is baked once at load: step it from 0 to at 1/240 s, store the values, and have
durationread them back with interpolation. For loops, start the simulation from the state the loop begins in (see the style's seam rule).seek - Loops: uses
seek(t); effects that straddle the seam (a landing squash, a sound tail) are drawn or mixed at both ends.t % duration - With in the URL: no controls, no preview scaling; the stage is exactly
?renderat the top-left. Without it: the stage is scaled with CSS to fit the window and the preview controls are shown.w × h - Fonts: embed them. Download the woff2 files (Google Fonts or the brand's) and inline them as base64 , so the render never falls back silently. For a weight morph, use a variable font with a range (
@font-face).wght@100..900 - Layers: give scene layers transparent backgrounds and let one stage background behind them provide the base colour, so a later layer never hides an earlier one by accident. Absolutely positioned text gets .
white-space: nowrap - Speed: large CSS and big blurred gradients are very slow to screenshot. Draw gradients and glows on a canvas once at load (or as an image) and move that.
filter: blur()
Helpers: paste these and build everything on them
js
// 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);- Typing: the visible string is with a caret blinking from
full.slice(0, n(t)). Count-ups: formatt.lerp(a, b, ease(seg(t, …))) - A camera is a wrapper element with driven by
transform: translate() scale() rotate()orkf.track
Sound
- schedules the whole soundtrack (music + effects) from time 0 on whatever
window.buildAudio(ctx)orAudioContextit is given, using the same timeline numbers as the visuals. Synthesise with oscillators, filtered noise (from a seeded buffer) and envelopes. If the user gave a music file, load it withOfflineAudioContext+fetch, set the beat grid to its tempo (measure it), and add only the effects on top.decodeAudioData - Preview playback: on the first play, render the soundtrack once into a buffer, then play from any scrubbed position. The clock drives :
seek
js
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)- Put the beat sheet as a comment at the top of the script: one row per moment with its time, what happens, and which sound.
使用一个自包含的 HTML 文件(内联 CSS 和 JS;仅在用户提供图片时使用图片)。可使用 DOM + CSS transforms、SVG,或单个全屏 canvas,选择最适合的方式即可。
页面契约
- 和
window.VIDEO = {w, h, duration}必须是时间的纯函数:它会设置时间window.seek(t)(秒)对应的每个元素状态。不使用 CSS transitions 或 animations,不使用t,也不保留上一帧的任何状态。任意帧都必须能够独立、任意顺序地正确渲染。可在setTimeout内测量布局(例如seek、offsetLeft),但必须保证确定性。measureText - 随机性(颗粒、粒子、音频噪声)必须来自带种子的生成器,绝不使用 。
Math.random - 任何需要历史状态的内容(滞后于移动物体的摄影机、simulation)必须在加载时一次性烘焙:以 1/240 秒为步长从 0 推进到 ,存储结果,然后由
duration读取并插值。对于循环,应从循环开始时的状态启动 simulation(参见此样式的接缝规则)。seek - 循环: 使用
seek(t);跨越接缝的效果(落地压缩、音效尾音)应在首尾两端都进行绘制或混音。t % duration - URL 中包含 时:不显示控件,不进行预览缩放;舞台在左上角保持精确的
?render。不包含该参数时:通过 CSS 缩放舞台以适应窗口,并显示预览控件。w × h - 字体必须嵌入。 下载 woff2 文件(Google Fonts 或品牌字体),并以 base64 内联,确保渲染时不会静默回退。字重变形需要使用
@font-face范围的 variable font。wght@100..900 - 图层: 场景图层使用透明背景,由后方一个 stage background 提供基础颜色,避免后续图层意外遮住先前图层。绝对定位的文字设置 。
white-space: nowrap - 速度: 大面积 CSS 和大型模糊渐变会导致截图非常缓慢。应在加载时将渐变和光晕一次性绘制到 canvas(或转为图片),然后只移动该图像。
filter: blur()
辅助函数:粘贴以下代码,并在其基础上构建全部内容
js
// 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)AudioContextOfflineAudioContextfetchdecodeAudioData- 预览播放: 第一次播放时,将整条音轨渲染到 buffer 中一次,然后从任意拖动位置开始播放。时钟驱动 :
seek
js
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)- 将节拍表作为注释放在脚本顶部:每行对应一个时刻,并注明时间、发生的内容和声音。
Step 3: Check, render, deliver
第 3 步:检查、渲染与交付
Setup once in the working folder: , then if Chromium isn't installed yet; ffmpeg must be on the PATH. Save the script at the bottom of this file as .
npm i playwrightnpx playwright install chromiumrender.mjs- Stills. with one time per row of the beat sheet (the sheet is at readable size, 3 per row). Look at it. Fix anything clipped, overlapping, unreadable, off-brand, badly framed or empty. Check again.
node render.mjs demo.html check.png --stills 0.5,1.2,… - Draft. (fast: 30 fps, no motion blur, 4 parallel workers; set the last number to the machine's core count). This is the review copy: send it to the user with the HTML and ask for changes before the final.
node render.mjs demo.html draft.mp4 30 1 4 - Final. . Motion blur multiplies render time by the subframe count; a 60 s final can take 20–60 minutes on a laptop, so say so before starting. It writes
node render.mjs demo.html demo.mp4 60 8 8(audio normalised to about −16 LUFS) anddemo.mp4.demo_silent.mp4 - Deliver the HTML (they can scrub it live), the MP4s, and a short note: the beat sheet, what is placeholder, and what to change for a second version. Offer one alternative direction in a sentence.
js
// 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();在工作目录中完成一次以下设置:,如果尚未安装 Chromium,则运行 ;ffmpeg 必须位于 PATH 中。将脚本以 保存到此文件末尾。
npm i playwrightnpx playwright install chromiumrender.mjs- 静帧。 运行 ,按照节拍表每个时刻取一个时间点(联络表以可读尺寸排布,每行 3 张)。查看结果,修复任何裁切、重叠、难以阅读、不符合品牌风格、构图不佳或内容空白的问题。再次检查。
node render.mjs demo.html check.png --stills 0.5,1.2,… - 草稿。 运行 (快速模式:30 fps、无运动模糊、4 个并行 worker;最后一个数字设为机器的 CPU 核心数)。这是审阅版本:将 HTML 和该版本一起发给用户,并在最终版前征求修改意见。
node render.mjs demo.html draft.mp4 30 1 4 - 最终版。 运行 。运动模糊会使渲染时间乘以 subframe 数量;一次 60 fps 的最终渲染在笔记本电脑上可能需要 20–60 分钟,因此开始前应告知用户。脚本会生成
node render.mjs demo.html demo.mp4 60 8 8(音频标准化至约 −16 LUFS)和demo.mp4。demo_silent.mp4 - 交付。 提供 HTML(用户可实时拖动预览)、MP4 文件,以及一份简短说明:包括节拍表、哪些内容为占位内容,以及第二版可以调整哪些部分。再用一句话提供一个替代创意方向。
js
// 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();