explainer-engaging

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Engaging explainer

引人入胜的讲解动画

A one-take visual story. A single hero element (in the reference style, a white ball that starts as a toggle's knob) travels through a chain of small interface "stations", and each station reacts to it: it rolls along a slider and pushes it to 100, falls off the end and bounces down a stack of notifications that pop up as it passes, lands on a keyboard and bounces key to key typing a word, hits return and is launched back into the air. The camera never cuts: it keeps the hero near the centre and zooms in for each interaction, and at the climax it pulls far out so the viewer sees the whole course laid out at once, before diving back in to where it all started.
For a product, the stations are the steps of its workflow, laid out spatially on one canvas, and the hero is the thing that moves through that workflow. The pull-out is the "now you get how it works" moment.
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 soundtrack.
一段一镜到底的视觉叙事。单个 核心元素(在参考风格中,它是一个从开关旋钮起始的白色小球)穿行于一系列小型界面“站点”之间,每个站点都会对它作出反应:它沿滑块滚动,把滑块推到 100;然后从末端掉落,在一叠通知卡片上弹跳,经过时卡片会依次弹出;接着落到键盘上,在各按键之间弹跳并逐键输入一个单词;按下 return 后,它再次被抛入空中。镜头绝不切换:始终让核心元素位于画面中央附近,并在每次互动时拉近;到达高潮时则大幅拉远,让观众一次看到完整路径,之后再俯冲回一切开始的地方。
对于产品,这些站点就是其工作流的各个步骤,以空间方式铺展在同一张画布上,而核心元素就是穿行于工作流的对象。拉远镜头就是“现在你明白它如何运作了”的时刻。
你将其编写为一个 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.
  1. Product: name, and what it does in a few sentences. A website URL is even better.
  2. The one thing the viewer must remember (e.g. "type an idea, get a live app").
  3. Brand: colours, fonts, logo file, or "use the website". If none, use the default palette below.
  4. Format: 16:9 (1920×1080) or 9:16 (1080×1920). Default 16:9.
  5. Length: default is given below for this style.
  6. Music: their own track (a file path), or let you synthesise one. Default: synthesise.
  7. 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.
Also ask for (optional): the workflow in 3–6 steps (what the user does → what the product does → the result). If they gave a URL, derive it from the site.
Default length for this style: 12 seconds, seamless loop. Range 10–30 (longer = more stations, not slower stations).
用一条消息问完所有问题,然后自行推进。只有“产品”是必填项;其他项如果留空,请自行决定并说明你的选择。
  1. 产品:名称,以及用几句话说明其用途。提供网站 URL 更好。
  2. 唯一需要记住的信息(例如,“输入一个想法,即可获得实时应用”)。
  3. 品牌:颜色、字体、logo 文件,或“使用网站”。如果没有,则使用下方默认配色。
  4. 格式:16:9(1920×1080)或 9:16(1080×1920)。默认 16:9。
  5. 长度:此风格会给出默认值。
  6. 音乐:用户提供音轨(文件路径),或由我们合成。默认:合成。
  7. 方向(可选):希望呈现的场景、口号、行动号召(CTA),以及任何需要避免的内容。
如果提供了 URL,请先通过 web fetch 读取产品真实的措辞、功能、颜色和字体名称,然后再编写任何内容。使用其真实措辞作为 UI 标签和口号。
绝不编造事实。 不得虚构客户数量、营收、评分、评价或引言。只有用户或网站明确提供时才能使用数字;否则应使用相对比例条、进度和占位骨架线。UI 中的示例内容(提示词、文件名、项目名、收据上的价格)可以采用,但必须可信;不得虚构产品本身的信息(统计数据、用户数、评分、“免费”之类的优惠)。行动号召只能使用用户或网站提供的 URL 或优惠;如无,则以产品名称和口号收尾。
Logo。 仅在用户提供文件,或这是用户自己的产品且用户明确要求时,才使用 logo。否则,应使用品牌字体将名称写为文字。第三方 logo(集成项)仅在用户提供时使用;否则使用带名称的中性圆角方块。
还可以询问(可选):3–6 步工作流(用户做什么 → 产品做什么 → 最终结果)。如果用户提供了 URL,则从网站中推导。
这种风格的默认长度:12 秒,无缝循环。范围 10–30 秒(时长越长,站点越多,而不是让每个站点停留得更慢)。

Step 2: Design the course, then build

第 2 步:设计路径,然后构建

1. Choose the hero

1. 选择核心元素

The hero is one simple, readable object the product's story is about, and it never disappears or gets replaced (it may change shape for a moment, then return): a ball/knob (default, works for anything), a text caret or cursor, a file or document tile, a message bubble, a coin or order, a data point. It should fit a station's interface naturally (a knob, a thumb, a key cap, a pin).
核心元素是围绕产品故事选取的单一、简单、易读对象,并且在全程中不会消失或被替换(可以短暂改变形状,随后恢复):小球/旋钮(默认,适用于任何产品)、文本插入符或光标、文件或文档卡片、消息气泡、硬币或订单、数据点。它应能自然地融入站点界面(旋钮、滑块拇指、键帽、图钉)。

2. Map the workflow onto stations

2. 将工作流映射到站点

Each step becomes one station: a compact real interface element that the hero physically interacts with, and that visibly changes because of it. Draw from these mechanics:
MechanicThe hero...The station reacts
Toggle / buttonis the knob, or presses itswitches on, colour fills
Slider / progressrolls along it as the thumbvalue counts up in a tooltip, track fills
Notification stackfalls and bounces on each cardeach card pops in as it is hit, with a message that narrates the story
Keyboard / inputbounces key to keyeach key presses down, letters appear in the input above
List / tableslides down the rowseach row highlights, ticks, or sorts
Chartbecomes the line's leading dotthe line draws behind it, bars rise as it passes
Chatrides in as a bubblethe reply types out
Deploy / send buttonis launched by ita toast or URL appears
The station labels and messages tell the story in a few words each, with some wit (the reference uses
Slider · Reached 100
,
Gravity · Something is falling
,
Messages · What now?
, then types
retry
). For a product, write them from its real workflow (e.g. a coding tool: prompt typed →
Agent · Writing 12 files
→ progress →
Preview · Ready
→ Deploy pressed →
Live at myapp.app
).
每个步骤成为一个站点:紧凑、真实的界面元素,核心元素会与其发生物理互动,而且它会因此产生可见变化。可使用以下机制:
机制核心元素……站点反应
开关/按钮成为旋钮,或按下按钮切换为开启,颜色填充
滑块/进度条作为滑块拇指沿轨道滚动tooltip 中的数值递增,轨道填满
通知卡片堆掉落并弹跳到每张卡片上每张卡片在被击中时弹出,并显示一段叙述故事的消息
键盘/输入框在各按键之间弹跳每个按键依次按下,输入框上方出现字母
列表/表格沿各行向下滑动每行依次高亮、勾选或排序
图表成为折线前方的引导点折线在后方绘制,柱状图随其经过而升起
聊天作为气泡进入回复内容被逐字输入
Deploy/Send 按钮被它触发射出出现 toast 或 URL
站点标签和消息应各自用寥寥数语讲述故事,并带一点机智(参考风格使用
Slider · Reached 100
、
Gravity · Something is falling
、
Messages · What now?
,随后输入
retry
)。对于产品,请根据其真实工作流来写(例如,编程工具:输入提示词 →
Agent · Writing 12 files
→ 进度 →
Preview · Ready
→ 按下 Deploy →
Live at myapp.app
)。

3. Lay out the world

3. 布局整个场景

Place the stations on one large canvas (about 3× the frame), in the order the hero visits them, so the path zig-zags (right along the top, down through the middle, across the bottom). Gravity should make sense: falls go down, launches go up. Leave each station's spot fixed for the whole video. The world is a dark canvas with a faint dot grid that moves with the camera (the grid is what makes the camera moves readable).
将站点放置在一张大画布上(约为画面的 3 倍),顺序与核心元素的访问顺序一致,使路径呈之字形(先沿顶部向右,再穿过中部,最后横跨底部)。重力方向必须合理:下落向下,弹射向上。每个站点的位置在整段视频中保持固定。世界以深色画布为背景,带有会随镜头移动的微弱点阵网格(正是点阵让镜头运动变得清晰可辨)。

4. Script the hero's path

4. 编写核心元素路径

The hero's position is a pure function of time, built from segments between contact points (each contact is on a beat):
  • Rolling/sliding along a station: position by
    ease
    or a spring along the track; the station's value is derived from the hero's position.
  • Hops and falls between contacts: a parabola (
    x
    linear in time,
    y = y0 + v·t + ½·g·t²
    , solve
    v
    so it lands on the next contact at the beat). Gravity constant across the whole video.
  • Impacts: squash on contact (scale 1.25 × 0.8 for about 60 ms, spring back), stretch along the velocity in flight (up to 1.15 × 0.9), plus a small push-down of whatever was hit.
  • The launch: the last station fires the hero up and back toward the start in one long arc, while every station resets in reverse behind it (values count down, notifications dismiss, the input clears) so the loop closes. Run the launch arc in slow motion: first choose the apex height you want (the top of the course), which fixes the real flight time under your gravity, then warp time so that flight fills the reveal window, slowest at the apex (often 5–10× there; e.g. speed
    1 − 0.9·sin(πu)^0.6
    over the arc). Schedule the station resets during the reveal hold, after the viewer has seen the full course, not at the moment of launch.
  • Seam: put the loop point at the hero's landing on the first station.
    seek(duration)
    must look exactly like
    seek(0)
    ; the landing squash and key press also show at time 0. For sound, render the soundtrack inside
    buildAudio
    into your own slightly longer
    OfflineAudioContext
    , fold the tail past
    duration
    back onto the start, and play that buffer into the given context.
  • Gravity is side-on. Lay the stations out so falls make sense from the side: notification cards form a staircase (each card offset sideways by more than its width, so the hero never passes through one), and the keyboard is seen head-on with the hero hopping between keys in small arcs.
  • The empty slot: when the hero was part of a station (a knob, a thumb) and leaves, the station keeps a dim outline of the slot it left, which fills again when the hero returns.
核心元素的位置是时间的纯函数,由各接触点之间的分段构成(每次接触都落在节拍上):
  • 滚动/滑动:核心元素沿站点滚动或滑动时,位置由
    ease
    或弹簧沿轨道决定;站点数值由核心元素的位置推导。
  • 跳跃和下落:在接触点之间跳跃和下落时,使用抛物线(
    x
    随时间线性变化,
    y = y0 + v·t + ½·g·t²
    ,求解
    v
    使其在节拍上落到下一个接触点)。全片重力常数保持一致。
  • 撞击:接触时压扁(缩放 1.25 × 0.8,持续约 60 ms,再弹回),飞行时沿速度方向拉伸(最高 1.15 × 0.9),同时被击中的对象会略微向下压。
  • 发射:最后一个站点将核心元素向上弹射,并在一条长弧线上送回起点;与此同时,所有站点在它身后逆向重置(数值倒数、通知消失、输入清空),从而闭合循环。发射弧线采用慢动作:先选择想要的最高点(路径顶端),这会根据重力确定实际飞行时间,再对时间进行变速处理,使飞行占满揭示窗口;顶点处速度最慢(通常为 5–10 倍慢;例如,在弧线上采用速度
    1 − 0.9·sin(πu)^0.6
    )。将站点重置安排在揭示停留期间,即观众已经看完完整路径之后,而不是在发射瞬间进行。
  • 接缝:将循环点放在核心元素落到第一个站点的时刻。
    seek(duration)
    的画面必须与
    seek(0)
    完全一致;落地压扁和按键动作也要显示在时间 0。声音方面,在
    buildAudio
    中将音轨渲染到一个稍长的自有
    OfflineAudioContext
    中,把越过
    duration
    的尾音折回开头,再将该缓冲区播放到传入的上下文中。
  • 重力应从侧面呈现。 将站点布局为下落符合逻辑:通知卡片形成阶梯(每张卡片在横向上偏移超过自身宽度,确保核心元素不会穿过卡片),键盘则正面呈现,让核心元素在小弧线中从一个按键跳到另一个。
  • 空槽位:当核心元素原本属于某个站点(旋钮、滑块拇指)并离开后,站点保留它所离开槽位的暗色轮廓;当核心元素返回时,轮廓重新填满。

5. The camera

5. 镜头

The camera is the hard part; get it right before polishing anything else.
  • Follow: camera target = a blend of the hero position (plus a lead of about 0.15 s of its velocity) and a fixed anchor for the current station, smoothed by a critically damped spring (
    w
    ≈ 6) so it never jerks on impacts. Weight toward the anchor about 0.7 during an interaction (the station stays framed), 0.15 during falls and hops (the hero leads), 0.85 on the payoff hold. The smoothing needs history, so bake it at load (see the page contract), starting at time 0 from the first station's fixed framing and blending into following over the first 0.8 s. The hero never leaves the middle half of the frame.
  • Zoom: tight during each interaction: the station fills about half the frame width and the hero is 50–70 px on screen (at 1080p). Slightly wider during falls and hops so the next station is already visible. Frame the station, not empty canvas.
  • The payoff hold: before the launch, hold on the final result (the toast, the live URL) for 0.4–0.6 s, fully in frame.
  • The reveal: on the launch, the camera stops following and eases to a fixed framing that fits the whole course (about 0.3×), with a slight 3D tilt (
    perspective
    +
    rotateX
    of 15–25°) for depth. Hold about 1 s while the hero arcs across it; scale the hero up and give it a soft halo so it stays the brightest, most visible thing (at least 24 px on screen). Then dive to a fixed framing of the first station, timed so the hero lands there as the camera arrives, and blend back into following.
  • Real motion blur: the hero and the world smear on fast moves; that is part of the look.
镜头是最难的部分;先把它做好,再打磨其他内容。
  • 跟随:镜头目标 = 核心元素位置(加上约 0.15 秒速度的前瞻量)与当前站点固定锚点的混合值,再通过临界阻尼弹簧(
    w
    ≈ 6)平滑,确保撞击时不会突然抖动。互动期间权重偏向锚点约 0.7(站点保持在画面中),下落和跳跃期间为 0.15(核心元素领先),最终成果停留期间为 0.85。由于平滑处理需要历史记录,因此应在加载时烘焙(见页面契约),从时间 0 的第一个站点固定构图开始,并在最初 0.8 秒内平滑过渡到跟随状态。核心元素始终不会离开画面中间一半区域。
  • 变焦:每次互动时紧凑取景:站点约占画面宽度一半,1080p 下核心元素在屏幕上为 50–70 px。下落和跳跃时稍微拉宽,让下一个站点已经可见。取景应聚焦站点,而非空白画布。
  • 最终成果停留:发射前,在最终结果(toast、实时 URL)上停留 0.4–0.6 s,并确保完整入画。
  • 揭示:发射时镜头停止跟随,平滑过渡到可容纳完整路径的固定构图(约为 0.3×),并加入轻微 3D 倾斜(
    perspective
    +
    rotateX
    15–25°)以增强纵深。核心元素划过全图时保持约 1 s;放大核心元素并添加柔和光晕,使其仍是屏幕上最亮、最醒目的对象(至少 24 px)。随后俯冲到第一个站点的固定构图,并让时间与核心元素落到该处同步,镜头到达后再平滑恢复跟随。
  • 真实的动态模糊:核心元素和场景在快速移动时产生真实的动态模糊;这是整体风格的一部分。

Look

视觉风格

  • Default palette (dark iOS-like): canvas
    #0C0C0E
    , dot grid
    #1E1E22
    , surfaces
    #1C1C1F
    /
    #2C2C2E
    , text
    #F2F2F7
    , secondary
    #8E8E93
    , accent
    #0A84FF
    , hero white
    #FFFFFF
    . Brand colours replace the accent; the hero stays the brightest thing on screen. A light variant (canvas
    #F2F2F4
    , hero = accent) is fine if the brand is light.
  • Real-looking system UI: toggles, sliders with tick marks, notification cards with an app icon square, a title, a body line and
    now
    ; a keyboard with key caps and a return key. Font: SF-like (Inter or Geist).
  • A cursor may start the chain (clicks the first station) and then gets out of the way.
  • Banned: cuts, fades to black, text overlays that explain what is happening (the stations' own labels are the only text), more than one hero, stations that do not react, physics that look floaty.
  • 默认配色(类 iOS 暗色):画布
    #0C0C0E
    ,点阵网格
    #1E1E22
    ,表面
    #1C1C1F
    /
    #2C2C2E
    ,文本
    #F2F2F7
    ,次要文本
    #8E8E93
    ,强调色
    #0A84FF
    ,核心元素白色
    #FFFFFF
    。品牌色用于替换强调色;核心元素始终是屏幕上最亮的物体。也可以使用浅色变体(画布
    #F2F2F4
    ,核心元素 = 强调色),前提是品牌本身适合浅色风格。
  • 真实感的系统 UI:开关、带刻度的滑块、包含应用图标方块、标题、正文行和
    now
    的通知卡片,以及带键帽和 return 键的键盘。字体:类 SF 字体(Inter 或 Geist)。
  • 光标可以作为链路起点(点击第一个站点),随后让开画面。
  • 禁止项:切镜、淡出至黑、用于解释正在发生什么的文字叠加层(站点自身的标签是唯一允许的文字)、多个核心元素、不会作出反应的站点,以及显得漂浮不实的物理效果。

Sound

声音

A tight, minimal groove at 110–120 BPM (soft kick, clicky hats, a muted pluck bass), and the hero's contacts are the main instrument: each impact plays a short pitched tick or marimba note (pitch rises as the story advances), key presses click, slider movement plays fine ticks that speed up with the value, notifications get a soft pop, the launch gets a whoosh up and the dive a whoosh down. Every sound lands exactly on its visual contact.
紧凑、极简的律动,110–120 BPM(柔和底鼓、点击感强的 hi-hat、低沉的拨弦贝斯),并且核心元素的接触声是主要乐器:每次撞击播放一个短促的有音高滴答声或 marimba 音(随着故事推进,音高逐渐升高),按键发出点击声,滑块移动发出细密滴答声且速度随数值加快,通知发出轻柔 pop 声,发射时是向上掠过的 whoosh,俯冲时是向下掠过的 whoosh。每个声音都应准确落在对应的视觉接触点上。

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
  • window.VIDEO = {w, h, duration}
    and
    window.seek(t)
    , a pure function of time: it sets every element for time
    t
    (seconds). No CSS transitions or animations, no
    setTimeout
    , no state carried from the previous frame. Any frame must render correctly on its own, in any order. Measuring layout inside
    seek
    (e.g.
    offsetLeft
    ,
    measureText
    ) is fine; it is deterministic.
  • 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
    duration
    at 1/240 s, store the values, and have
    seek
    read them back with interpolation. For loops, start the simulation from the state the loop begins in (see the style's seam rule).
  • Loops:
    seek(t)
    uses
    t % duration
    ; effects that straddle the seam (a landing squash, a sound tail) are drawn or mixed at both ends.
  • With
    ?render
    in the URL: no controls, no preview scaling; the stage is exactly
    w × h
    at the top-left. Without it: the stage is scaled with CSS to fit the window and the preview controls are shown.
  • Fonts: embed them. Download the woff2 files (Google Fonts or the brand's) and inline them as base64
    @font-face
    , so the render never falls back silently. For a weight morph, use a variable font with a range (
    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
    filter: blur()
    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.
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
    full.slice(0, n(t))
    with a caret blinking from
    t
    . Count-ups: format
    lerp(a, b, ease(seg(t, …)))
    .
  • A camera is a wrapper element with
    transform: translate() scale() rotate()
    driven by
    kf
    or
    track
    .
Sound
  • window.buildAudio(ctx)
    schedules the whole soundtrack (music + effects) from time 0 on whatever
    AudioContext
    or
    OfflineAudioContext
    it 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 with
    fetch
    +
    decodeAudioData
    , set the beat grid to its tempo (measure it), and add only the effects on top.
  • 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,选择最适合的实现即可。

Step 3: Check, render, deliver

页面契约

Setup once in the working folder:
npm i playwright
, then
npx playwright install chromium
if Chromium isn't installed yet; ffmpeg must be on the PATH. Save the script at the bottom of this file as
render.mjs
.
  1. Stills.
    node render.mjs demo.html check.png --stills 0.5,1.2,…
    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.
  2. Draft.
    node render.mjs demo.html draft.mp4 30 1 4
    (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.
  3. Final.
    node render.mjs demo.html demo.mp4 60 8 8
    . 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
    demo.mp4
    (audio normalised to about −16 LUFS) and
    demo_silent.mp4
    .
  4. 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();
window.VIDEO = {w, h, duration}
和
window.seek(t)
,其中
seek(t)
必须是时间的纯函数:它根据时间
t
(秒)设置所有元素。不得使用 CSS transitions 或 animations,不得使用
setTimeout
,不得保留前一帧的状态。任意一帧都必须能独立正确渲染,且渲染顺序不限。在
seek
中测量布局(例如
offsetLeft
、
measureText
)没有问题;只需保证结果确定。
随机性(颗粒、粒子、音频噪声)必须来自带种子的生成器,绝不能使用
Math.random
。
任何需要历史记录的内容(滞后于移动物体的镜头、模拟)都应在加载时一次性烘焙:以 1/240 s 步长从 0 推进至
duration
,存储数值,然后由
seek
读回并进行插值。对于循环,应从循环开始时的状态启动模拟(参见该风格的接缝规则)。
循环:
seek(t)
使用
t % duration
;跨越接缝的效果(落地压扁、声音尾音)应在首尾两端同时呈现。
URL 中含
?render
时:隐藏控件和预览缩放;舞台以左上角为原点,尺寸严格为
w × h
。不含该参数时:使用 CSS 将舞台缩放至适配窗口,并显示预览控件。
字体:必须嵌入。 下载 woff2 文件(Google Fonts 或品牌字体),并以 base64
@font-face
内联,确保渲染时不会悄悄回退到其他字体。若需要字重形变,请使用带范围(
wght@100..900
)的可变字体。
图层: 场景图层使用透明背景,并在后方放置一个舞台背景提供基础颜色,避免后续图层意外遮住前面的图层。绝对定位的文本应设置
white-space: nowrap
。
速度: 大面积 CSS
filter: blur()
和大型模糊渐变会严重拖慢截图速度。应在加载时将渐变和光晕绘制到 canvas 上(或做成图片),然后只移动该图。
—

辅助函数:粘贴这些并基于它们构建一切

—
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) / 0x100000000);
  • 输入:可见字符串为
    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
    :
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)
  • 在脚本顶部将节拍表写成注释:每行对应一个时刻,并注明时间、发生的事件和声音。
—

第 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 个并行 worker;将最后一个数字设置为机器的核心数)。这是审阅版:发送给用户,同时附上 HTML,并征求修改意见,然后再制作最终版。
  3. 最终版。
    node render.mjs demo.html demo.mp4 60 8 8
    。动态模糊会按子帧数倍增渲染时间;最终 60 s 版本在笔记本电脑上可能需要 20–60 分钟,因此开始前应告知用户。它会生成
    demo.mp4
    (音频标准化至约 −16 LUFS)和
    demo_silent.mp4
    。
  4. 交付 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'), 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();