explainer-default

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Default explainer

默认讲解视频

A calm, premium product demo, the kind a well-funded startup puts on its homepage. Its whole trick is restraint: a bright, airy canvas, one short line of text at a time, and the product's own interface rebuilt cleanly and filmed up close while it is being used. The camera does the storytelling: it pushes into a line of text, rides along with typing, pulls back to reveal the result, and glides to the next feature.
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.
一段平静、精致的产品演示,就像一家资金充裕的初创公司放在主页面上那样。它全部的诀窍在于克制:明亮通透的画布,每次只呈现一行短文案,并以近距离“拍摄”干净重建后的产品自身界面,同时展示界面的使用过程。镜头负责叙事:推进到一行文案、跟随输入过程、拉远以揭示结果,再平滑移动到下一个功能。
你只需将其编写为一个 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 3–5 features or workflow steps to show, and anything the product connects to (integrations). If they gave a URL, take these from the site.
Default length for this style: 60 seconds. Range 45–90. Budget by length (drop optional sections first):
LengthFeaturesOptional sections
45 s2–3, about 9 s eachproof or integrations, not both
60 s3–4, about 10 s eachone of proof / integrations
90 s4–5, about 12 s eachboth
所有问题只需在一条消息中询问完毕,然后独立推进。只有产品信息是必填项;其他内容若留空,由你决定并说明选择。
  1. 产品:名称,以及用几句话说明其用途。提供网站 URL 更好。
  2. 唯一记忆点:观众必须记住的一件事(例如,“输入一个想法,即可获得实时应用”)。
  3. 品牌:颜色、字体、Logo 文件,或者“使用网站上的风格”。如果没有提供,则使用下方默认配色。
  4. 格式:16:9(1920×1080)或 9:16(1080×1920)。默认使用 16:9。
  5. 时长:此风格会提供默认值。
  6. 音乐:用户自己的音轨(文件路径),或者由你合成。默认使用合成音乐。
  7. 方向(可选):希望呈现的场景、标语、行动号召,以及任何需要避免的内容。
如果对方提供了 URL,请在编写任何内容前通过 Web Fetch 读取该网站,了解产品真实使用的措辞、功能、颜色和字体名称。界面文案和标语应采用他们真实使用的文字。
**绝不杜撰事实。**不得编造客户数量、营收、评分、用户评价或引述。数字只能在用户或网站明确给出时使用;否则应使用相对比例条、进度条和占位骨架线。UI 中的示例内容(提示词、文件名、项目名称、收据价格)可以存在,也应显得可信;但不要编造产品本身的主张(统计数据、用户数量、评分、“免费”之类的优惠)。行动号召只能使用 URL,或用户及网站提供的优惠;如果两者都没有,就以产品名称和标语收尾。
**Logo。**仅当用户提供 Logo 文件,或这是用户自己的产品且明确要求展示时,才使用 Logo。否则使用品牌字体将名称写成文字。第三方 Logo(集成项)仅在用户提供时使用;否则使用带名称的中性圆角方块。
还可以询问(可选):希望展示的 3–5 个功能或工作流步骤,以及产品连接的所有服务(集成项)。如果用户提供了 URL,则从网站中提取这些信息。
此风格的默认时长为 60 秒,范围为 45–90 秒。应根据时长分配预算(优先删减可选段落):
时长功能可选段落
45 秒2–3 个,每个约 9 秒佐证或集成项,二选一
60 秒3–4 个,每个约 10 秒佐证或集成项,二选一
90 秒4–5 个,每个约 12 秒两者都要

Step 2: Write the script, then build

第 2 步:先编写脚本,再构建

The structure

结构

Write the script and beat sheet first: every line of on-screen text, which UI moment follows it, and its time; add the times up against the budget above. On-screen text is the only "voice", so it has to tell the story by itself.
How scenes and camera fit together: each scene (a title line, a UI moment) is its own layer with its own camera. Inside a scene the camera pushes, rides and pulls back; between scenes the outgoing layer dissolves with a short blur (0.3–0.4 s) into the next one, which is already moving.
  1. Open (0–5 s). Plain light canvas. A small word fades up in the centre:
    Introducing
    . It dissolves into the brand moment: the soft brand gradient sweeps in (silk-like diagonal light bands drifting slowly) with the logo or name centred, small.
  2. The problem or the ask (≈5 s). A short line that the viewer recognises (
    Still building prospect lists by hand?
    ), or go straight to the product's input: a large rounded input box, empty with placeholder text, suggestion chips underneath.
  3. Feature chapters (3–5, about 8–15 s each). Each one is:
    • A title line: one short sentence, small (about 28 px at 1080p), centred on the plain canvas. It types or fades in word by word. Then the camera pushes in on it until the line spans about 75% of the frame width, and one key word or short phrase gets a highlight: a soft brand-colour box sweeping left to right behind it (
      Call the decision maker. Directly.
      ).
    • The UI moment: the product's real interface, rebuilt as clean vector UI (not screenshots), cropped to only the part that matters and shown big. The camera is close enough that the element fills most of the frame, and it rides along with the action: text typing into the input (the camera follows the caret; measure the text with
      measureText
      to know where it is), an agent's steps ticking off one by one (spinner → check,
      Found 247 matches
      ), result cards stacking up with the far ones blurred (depth of field), a table filling row by row, the cursor clicking a cell button and the value revealing, a new column being added and each cell "thinking" with a shimmer before its value appears, a popover opening with reasoning and sources.
    • The pull-back: after the action, the camera eases out to show the whole screen for a beat, so the viewer understands where they are, then glides on.
  4. Proof (optional, only real numbers). A sentence with a number that counts up, the number highlighted (
    Backed by a database of 300M+ profiles
    ), over a subtle dotted globe or grid.
  5. Integrations (optional).
    Built for your existing workflow.
    in the centre while 8–12 app tiles (rounded squares, real logos only if supplied; repeat the integrations if there are few) float in from the edges at different depths: near ones larger and slightly blurred, far ones small and sharp, all drifting with parallax. Here the text scales up on its own instead of a camera push, so the tiles stay in frame.
  6. Closer. On the brand gradient: one sentence with a rotating highlighted word that cycles through the use cases (
    Do all your [prospecting / research / outreach] in Product.
    ), then, only if the product really has one, another way to use it (e.g.
    Or just ask Claude
    for a product with a Claude connector), then the tagline with its key phrase highlighted.
  7. End card (3 s). Logo or name on the brand gradient, URL or call to action small below.
先编写脚本和节拍表(beat sheet):逐行列出屏幕上的每行文案、随后出现的 UI 时刻及其时间;将各段时间与上述预算相加并核对。屏幕文案是唯一的“声音”,因此必须独立讲清整个故事。
**场景与镜头的配合方式:**每个场景(标题文案、UI 时刻)都是独立图层,并拥有自己的镜头。在场景内部,镜头会推进、跟随和拉远;场景之间,出场图层会经过短暂的模糊(0.3–0.4 秒)溶解到下一图层,而下一图层此时已经在运动。
  1. **开场(0–5 秒)。**纯净的浅色画布。画面中央淡入一个小词:
    隆重推出
    。它溶解进入品牌展示时刻:柔和的品牌渐变扫入(类似丝绸的斜向光带缓慢漂移),Logo 或名称居中显示,并保持小巧。
  2. **问题或需求(约 5 秒)。**一句观众能够认出自己的话(
    还在手动整理潜在客户名单?
    ),也可以直接进入产品输入环节:一个大号圆角输入框,初始为空并带有占位文案,下方排列建议标签。
  3. **功能章节(3–5 个,每个约 8–15 秒)。**每个章节包括:
    • 标题文案:一句简短的话,在 1080p 下约为 28 px,居中显示在纯净画布上。它会逐词输入或淡入。随后,镜头向前推进,直到该行文案横跨画面宽度约 75%,并让一个关键词或短语成为高亮重点:在其后从左向右扫过柔和的品牌色块(
      直接联系决策者。
      )。
    • UI 时刻:根据你掌握的信息(或其网站)将产品的真实界面重建为干净的矢量 UI(不是截图),只裁切到重要部分并放大展示。镜头距离足够近,让目标元素占据画面大部分,并随操作一同移动:文字被输入到输入框(镜头跟随光标;使用
      measureText
      测量文字以确定位置)、Agent 的步骤逐项勾选(spinner → check,
      找到 247 条匹配结果
      )、结果卡片依次堆叠,较远的卡片保持模糊(景深)、表格逐行填充、光标点击单元格按钮并显示数值、新列被添加且每个单元格先以 shimmer 呈现“思考中”,随后数值出现,以及弹出层展示推理过程和来源。
    • 拉远:操作完成后,镜头平缓拉远,短暂展示整个屏幕,让观众明白当前所在位置,然后继续平滑移动。
  4. **佐证(可选,仅使用真实数字)。**一句包含数字的话,数字执行递增动画并高亮(
    由包含 300M+ 资料档案的数据库提供支持
    ),背景使用低调的点阵地球或网格。
  5. **集成项(可选)。**画面中央显示
    为现有工作流而打造。
    ,同时 8–12 个应用方块(圆角方块,仅在提供真实 Logo 时使用;如果集成项较少,可以重复展示)从画面边缘以不同深度飘入:近处方块更大且略微模糊,远处方块更小且清晰,所有方块以视差效果漂移。此处文案自行放大,而不使用镜头推进,以免方块移出画面。
  6. **临近结尾。**品牌渐变背景上显示一句话,其中一个轮换高亮的词会在各项用例之间切换(
    在 Product 中完成所有 [客户开发 / 研究 / 外联] 工作。
    );随后,仅当产品确实具备该功能时,再展示另一种用法(例如,对于带 Claude 连接器的产品,显示
    或者直接询问 Claude
    );最后展示标语,并高亮其中的关键短语。
  7. **尾卡(3 秒)。**品牌渐变背景上显示 Logo 或名称,下方以小字号显示 URL 或行动号召。

Rules that make it feel premium

营造高级感的规则

  • One thing at a time. Never more than one line of text plus one UI element on screen. Text and UI almost never share the frame: text scene, then UI scene.
  • The camera is the editor. No hard cuts. Every scene change is a camera move (push in, pull out, glide) or a soft blur-dissolve on the plain canvas. Camera moves are smooth eases (
    ease
    over 0.6–1.2 s) or heavily damped springs (
    z
    ≥ 0.95); nothing bounces.
  • Real UI, made clean. Rebuild the product's interface from what you know of it (or its website): its real layout, labels, colours, fonts and corner radii, simplified to what the scene needs. Content inside it is believable example data that fits the product. Soft shadows, 1 px borders, generous whitespace.
  • Cursor: a normal macOS-style arrow, moving on gentle curves, arriving a beat before it clicks, with a small press dip and a soft ripple on the clicked control. Hover states change.
  • Type: the brand's font if known; otherwise Inter or Geist. Small, regular weight for title lines (they become big through the camera, not the font size). Tight letter-spacing on large text.
  • Default palette: canvas
    #F7F7F8
    , text
    #111114
    , secondary
    #8A8A94
    , UI surfaces white with
    #E7E7EC
    borders, brand accent
    #2E6BFF
    , highlight box = accent at 15–20% opacity. Brand gradient: white → pale accent → accent, as slow diagonal silk bands (draw them on a canvas, not with CSS blur). Brand colours replace the accent.
  • Pacing: each title line holds about 2 s after it lands; UI moments run at real speed (typing at about 25 characters per second), with pauses of 0.5 s after each result so the viewer can read it.
  • Banned: busy backgrounds behind UI, more than one accent colour, bouncy easing, emoji, big drop shadows, glassmorphism everywhere, invented metrics or testimonials.
  • **一次只讲一件事。**屏幕上绝不能同时出现超过一行文案或超过一个 UI 元素。文字和 UI 几乎从不共处一帧:先呈现文字场景,再呈现 UI 场景。
  • **镜头就是剪辑师。**不使用硬切。每次场景变化都由镜头运动(推进、拉远、平滑移动)或浅色画布上的柔和模糊溶解完成。镜头运动采用平滑缓动(
    ease
    ,持续 0.6–1.2 秒)或高度阻尼弹簧(
    z
    ≥ 0.95);绝不回弹。
  • **真实 UI,精致呈现。**根据你掌握的信息(或其网站)重建产品界面:保留真实布局、文案、颜色、字体和圆角,仅简化到场景所需。内容使用符合产品情境的可信示例数据。柔和阴影、1 px 边框、充足留白。
  • **光标:**普通的 macOS 风格箭头,沿平缓曲线移动,在点击前稍作停顿,按下时有轻微下陷,并在被点击控件上产生柔和涟漪。悬停状态会发生变化。
  • **文字:**已知则使用品牌字体;否则使用 Inter 或 Geist。标题文案使用小号、常规字重(它们通过镜头放大,而不是通过放大字号)。大号文字使用紧凑字距。
  • **默认配色:**画布
    #F7F7F8
    ,文字
    #111114
    ,次要文字
    #8A8A94
    ,UI 表面为白色并使用
    #E7E7EC
    边框,品牌强调色
    #2E6BFF
    ,高亮方块使用 15–20% 不透明度的强调色。品牌渐变:白色 → 浅色强调色 → 强调色,以缓慢的斜向丝绸状光带呈现(在 canvas 上绘制,不要使用 CSS blur)。品牌颜色将替换强调色。
  • **节奏:**每行标题文案落定后保持约 2 秒;UI 时刻按真实速度播放(输入速度约 25 个字符/秒),每个结果后停顿 0.5 秒,让观众能够阅读。
  • **禁止:**UI 后方使用繁忙背景、超过一种强调色、回弹式缓动、emoji、大幅 drop shadow、到处使用 glassmorphism、编造指标或用户评价。

Sound

声音

An uplifting, gentle bed at 100–110 BPM (soft piano or pluck chords, light percussion entering after the open, a lift into the closer), quiet and warm. Sound effects small: key ticks while typing, soft clicks, a light "tick" per completed step, a soft whoosh on big camera moves, a gentle chime on the end card. The music should sit like a background in a well-made product video, not drive it.
一段振奋人心且柔和的 100–110 BPM 配乐(柔和钢琴或拨弦和弦,开场后加入轻打击乐,临近结尾时逐渐上扬),安静而温暖。音效要小:输入时的按键声、轻柔点击声、每个步骤完成时的轻微滴答声、大幅镜头移动时的柔和呼啸声,以及尾卡上的轻柔提示音。音乐应像优质产品视频中的背景声,而不能抢戏。

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 4 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)
    ,其中
    window.seek(t)
    必须是时间的纯函数:它会为时间
    t
    (秒)设置每个元素的所有状态。不得使用 CSS transition 或 animation,不得使用
    setTimeout
    ,不得保留上一帧的任何状态。任意帧都必须能够独立且按任意顺序正确渲染。在
    seek
    内测量布局(例如
    offsetLeft
    、
    measureText
    )没有问题;只要结果具有确定性即可。
  • 随机效果(颗粒、粒子、音频噪声)必须来自带种子的生成器,绝不能使用
    Math.random
    。
  • 任何需要历史信息的内容(落后于移动对象的镜头、模拟等)都要在加载时预烘焙一次:以 1/240 秒为步长从 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) / 4294967296);
  • 输入:可见字符串为
    full.slice(0, n(t))
    ,光标从
    t
    开始闪烁。数字递增:使用
    lerp(a, b, ease(seg(t, …)))
    格式化。
  • 镜头是一个包装元素,其
    transform: translate() scale() rotate()
    由
    kf
    或
    track
    驱动。
—

声音

—
  • window.buildAudio(ctx)
    会在任意(
    AudioContext
    或
    OfflineAudioContext
    )上从时间 0 开始安排完整配乐(音乐 + 音效),并使用与画面相同的时间线数值。使用振荡器、经滤波的噪声(来自带种子的缓冲区)和包络进行合成。如果用户提供音乐文件,使用
    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 4 8
    。运动模糊会使渲染时间乘以子帧数量;在笔记本电脑上,一段 60 秒的最终版可能需要 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 = 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();