The tool is this repository (below <仓库>), the CLI is <仓库>/bin/wb, and all parameters are in <仓库>/config.json. Each episode's content is in config.json under dirs.projects(default <仓库>/episodes/<日期 标题>/), and intermediate artifacts and the final video are in dirs.build(default <仓库>/build/<日期 标题>/)。
This file covers only the process and rules. For details, consult references/as needed; everything is inside this repository and does not depend on external directories:
| File | What it covers |
|---|---|
references/dsl.md |
Scene and cover API (one element per line of code) |
references/scene-patterns.md |
Layout coordinate patterns (ready to copy) |
references/stickers.md |
Stickers, real logos, real-person comic portraits |
references/publish.md |
Title formulas, platform specs and mechanics, how to write for Douyin |
references/voices-volc.md |
Volcano TTS: the two call domains, the pairing between resourceId and voice, voices verified to work, quick error reference |
references/voices-mi.md |
Xiaomi MiMo TTS: endpoint and assistant role rules, no speed parameter, how to align without timestamps, nine verified voices, how to choose among the three channels |
references/fact-check.md |
Fact-checking and source ledger |
references/compliance.md |
Pre-publish compliance self-check |
references/delivery-qa.md |
Final-video machine and visual acceptance, cover acceptance, delivery boundaries |
Hard rules
- Fully local pipeline. Voiceover uses speech synthesis; choose one of the three for
config.jsonintts.engine:volc(Volcano Engine,seed-tts-2.0, credentials in<仓库>/.env; the voice can be an official voice or your own voice clone;returns word-level timestamps, making subtitles the most accurate; use it by default)/mi(Xiaomi MiMomimo-v2.5-tts, credentialsMI_TTS_API_KEY, fixed set of nine voices;the API has no speed parameter and returns no timestamps, so speed relies on localatempoand the timeline is estimated via silence detection, seereferences/voices-mi.md)/edge(local edge-tts, free, no credentials, withtts.edge.pythonpointing to the interpreter whereedge_ttsis installed); default speed is 1.2x. Stickers use the localcodexCLI for image generation (when codex dispatches to a model without image-generation capability, switch to the built-in ImageGen tool to produce a four-panel grid and then use--rekeyto split it); real logos use official SVGs from Wikimedia Commons (wb logo); background music goes inassets/bgm.mp3, and if absent, output a video without music. Change configuration, not code.After changing the TTS configuration, first runwb voice: it echoes the engine/endpoint/model/voice and actually synthesizes one sentence, which is easier than hitting an error halfway through rendering. The three engines each have their own voice field (Volcanotts.voice/ Xiaomitts.mi.voice/ edgetts.edge.voice); do not substitute one for another. - One source of truth. The episode directory is the project directory;
scenes.jsis the only hand-written source (narration, scenes, and covers are all in it);scenes/、旁白稿.md、字幕.srt、封面-*.pngare generated artifacts: do not edit by hand or copy out.README.md(sources) and发布.md(copy) are written by humans. Videos are not placed in the episode directory: the final video is in<dirs.build>/<期>/outputs/final.mp4, and intermediate artifacts in the same directory'swork/,旁白稿.mdautomatically include a link to the final video. - Narration first, visuals second. Narrator follows
|to cut beats, 3 per beat4 sentences paired with one set of elements.Duration is a variable, not a fixed 3 minutes: before starting, confirm the target duration with the user, then convert by "1 minute ≈ 8 beats ≈ 200 characters (1.2x speech rate) ≈ 1.52 scenes". Short film 23 minutes (68 scenes), medium film 510 minutes (1525 scenes), long film 1540 minutes (4090 scenes) are all fine—keep each scene in the comfort zone of "34 beat / 80120 characters / 10~20 elements per screen",lengthen by adding scenes, don't write one scene long and full. Long films additionally need act divisions and breathing points, see "Making Long Films". - Canvas 1920×1080. y≥960 is the subtitle area, the top-right 320×130 is the watermark area, elements must not enter;
wb scenes's ⚠ must be cleared to zero. - Use real logos for companies, products, and models; use stickers for people, equipment, and objects; use Excalidraw for text, arrows, and boxes. When talking about a specific company, use the official logo for the protagonist (
wb logo), don't use a generated anthropomorphic robot as a stand-in, viewers can't tell who it is. When talking about a specific public figure, use a cartoon portrait based on a real photo (wb image ... --ref=照片 --likeness, seereferences/stickers.md), don't use a generic figure as a stand-in. Excalidraw draws ugly people, always use stickers for people and objects. - Verify facts before writing. Numbers, dates, and prices must have sources, write them into the episode directory
README.md; estimated values are marked "reportedly/estimated" in both narration and copy. For verification actions seereferences/fact-check.md。 - Brand layer comes automatically, no need to write it each episode. The handwritten watermark in the top-right (drawn stroke by stroke in the first scene), the end-card brand card (5.5 seconds, silent), and the brand mark on the cover are all generated by the tool, with name, brand color, and slogan in
config.json'sbrand; titles and accent colors preferC.brand。 - Delivery four-piece set: final video + three covers (4:3 landscape / 3:4 portrait / 9:16 Douyin vertical) +
发布.md, main title, WeChat Channels intro, and Douyin description pasted directly into the reply.
Command
W=<仓库>/bin/wb
$W new "<标题>" # 建期目录:scenes.js 模板 + 发布.md 模板
$W logo "<标题>" logo-x="Commons 文件名.svg" ... [--vs=x,y] # 真实 Logo → assets/<name>.png;--vs 拼 "A VS B" 封面图;--search="词" 先看候选
$W image "<标题>" name="英文描述" ... # 贴纸,一次 ≤4 张(四宫格同风格)→ assets/<name>.png
$W stills "<标题>" # 每 beat 静帧 → <后台>/<期>/work/frames/,逐张看
$W cover "<标题>" # 封面-4x3.png / 封面-3x4.png / 封面-9x16.png → 期目录(画幅见 config cover.ratios)
$W captions "<标题>" # 核对字幕:每条的时间、文本,以及高亮分段([a·b·c] 里 · 的位置就是色块跳的节拍)
$W voice # 配音自检:回显 engine / 端点 / 模型 / 音色,并真合成一句 → build/_voice-check.wav
$W build "<标题>" # scenes → tts → render → mix → cover → clean,出 outputs/final.mp4 / 字幕.srt / 旁白稿.md
Steps:scenes / tts / voice / captions / render [scene|99-brand] / mix [gain] / clean / open / list。wb build After the video is produced, automatically clean: delete work/frames still frames and work/out old segments no longer in the scene table, keep each scene segment and master (needed for single-scene re-render, re-mixing music). Episode parameter uses title substring (wb build 薄肌)。wb render <期> 03-xxx only re-renders one segment and automatically reassembles the master, then wb mix gives the new final video; TTS is cached, only modified narration is re-dubbed.
One episode's workflow
1. Topic selection and verification
- Clarify topic, audience, tone (default calm science-explainer tone, account tone written into
references/publish.md)。 - Search 2~3 rounds;official pages take priority and read the full text, second-hand site numbers are only clues.
- Numbers, dates, sources listed in
README.md"Sources", with the framing ("240x = 60 million / 250 thousand") in a separate section. Full actions seereferences/fact-check.md。
2. Write narration (scenes.js)
- Colloquial short sentences, one information point per sentence. Use Chinese reading for numbers to help TTS ("three hundred dollars"); subtitles use the original text, so Arabic numerals are also fine, but
@、iOSwords that TTS would mispronounce need consideration. - Structure: opening definition/contrast → breakdown (three elements/two-column comparison/timeline) → how to calculate/how to do it → numbers and thresholds → caveats/boundaries → one-sentence summary + comment-section question.
- Every beat needs something that can be drawn; merge abstract sentences into the adjacent beat. Write at normal speed; the final video plays at 1.2x speed.
3. Produce logos and stickers
- First list the companies/products/models appearing in this episode →
wb logo。wb logo --search="<公司> logo"check Commons candidates, pick the official current version (for year-tagged ones, take the latest), and fetch them all in one command per episode: logo (logo-<名>, square, for the main visual) + wordmark (logo-<名>-word, horizontal/wide, used as labels/headers); for a two-company comparison also add--vs=a,bto outputlogos-vs.pngas the cover main image. Sources are automatically recorded inassets/logos.json; copy them into the README "Visual assets". If Commons doesn't have it, go to the official website's press kit / brand page to find the SVG, confirm the license, then download. - Then list the objects in this episode (people, instruments, equipment, props),
wb imageat most 4 at a time, and write the English description with pose/clothing/color (Excalidraw five colors: pale yellow/blue/green/red/grey). - Check each one
assets/<name>.png: stray dots, fragments from neighboring cells, broken blocks in the subject →--singleregenerate that single image; if it's just a cutout issue →--rekey. The cover protagonist sticker is also produced at this step (for company-themed covers use the logo; don't produce a separate one).
4. Draw the scenes
- Copy the layout coordinates from
references/scene-patterns.mdand fine-tune; stickers.image(x, y, 'name', { h: 370, align: 'center' }), human figure 360380 high; for logos also uses.image: main visual logo h 260300, inside cards/bars h 100~130, wordmark used as a labelw。 - 10~20 elements per screen; if a beat can't fit them, delete elements—don't count on drawing them all (scheduling can overflow into at most the first 35% of the next segment; beyond that, compress everything).
wb stillsaftercheck each still frame: overlaps, out-of-bounds, text too wide, too crowded. Revise until satisfied.
5. Cover
scenes.jsat the endcoverfunction:s.coverLayout({ ratio, title, sub, sticker }),build(__dirname, scenes, { cover });wb coveraccording to configcover.ratiosoutput 4:3 / 3:4 / 9:16 (9:16 for Douyin; the title and stickers are pressed into the central safe area because the Douyin cover gets cropped top and bottom to about 1080×1464).- Title ≤2 lines, ≤8 characters per line: the first line states the subject, the second line states the hook (automatic brand color + marker highlight); the subtitle holds the numbers; the sticker uses the protagonist (for company themes pass
sticker: 'logos-vs'or a singlelogo-x; wide images automatically scale by width in vertical format). - Check the three pngs: text doesn't collide with stickers, the highlight sits on the hook line, the brand mark is in the corner. For details see
references/delivery-qa.md's "Cover acceptance".
6. Render and accept
wb build. Sample 2~3 frames to check (ffmpeg -ss <t> -i final.mp4 -frames:v 1 x.png): subtitles at the bottom,the follow-along highlight sits on exactly the characters being read aloud(the color block hugs the text tightly, doesn't swallow punctuation, doesn't cut off English words likeMiMo), the watermark is in the top right, stickers erase normally; check the ending at99-brand. When sampling frames,look at the original image before drawing conclusions: shrinking dozens of frames into one long image makes it easy to misjudge ink-colored text as "not highlighted".wb captions <期>: without rendering images you can verify each subtitle's timing, text, and highlight segments ([a·b·c]in·'s position is exactly the beat where the color block jumps; roughly 0.2~0.6 seconds per segment is normal).- Check
字幕.srtFirst few notes: original text spelling, numbers not split.No highlighting in the SRT(plain text format can't hold it), highlighting is only burned into the picture. - Machine checks and delivery boundaries follow
references/delivery-qa.md。 README.mdand are written accordingly; duration is based onffprobe.
7. Publishing copy and titles
- According to
references/publish.md: 5 title candidates (number contrast / event subject / conclusion first / life-unit conversion / question) pick 1 as primary; WeChat Channels intro, Douyin description (first 55 characters hook + 3~5 hashtags at the end + AIGC label), Xiaohongshu title+body+tags, Bilibili title, WeChat official account summary, pinned comment. - Numbers match
README.md; compliance self-check completed (references/compliance.md)。 - fill in
发布.md, deliver the four-piece set.
Making long videos (>5 minutes)
There is no duration limit at the code level. Rendering outputs mp4 per scene in segments, then uses ffmpeg concat to stitch into the master (lib/render.js); the number of scenes is unlimited; TTS is also synthesized and cached scene by scene (lib/tts-volc.mjs). So "how long you can make it" depends only on content planning, not on the tool. Tested (2026-09-30, plan domain streaming endpoint): a single submission of 2200 characters / 6600 bytes (≈6.5 minutes of audio) also returns normally, so even if a certain scene is written very long it won't get stuck at the interface.
| Target duration | Number of scenes | Total characters | Acts |
|---|---|---|---|
| 2~3 minutes | 6~8 | 500~700 | No acts needed |
| 5~10 minutes | 15~25 | 1200~2400 | 2~3 acts |
| 15~25 minutes | 40~60 | 3500~6000 | 3~5 acts |
| 30~40 minutes | 75~90 | 7000~9000 | 5~8 acts |
Four extra things long videos do compared to short videos:
- Acts. Add a "chapter card" scene at the start of each act (a regular
new Scene('02-act2', ...)is fine: act title + a one-sentence guide) so viewers know where they are; the timestamps in the publishing copy are also split by act. The end card is automatically appended by the tool, but chapter cards must be written by hand. - A breathing point every 2~3 minutes. Whiteboard explanations get tiring after 20 straight minutes: a breathing point can be a one-sentence summary, a pitfall-in-action moment, or a comparison table. Don't just lay it all out flat.
- Switch the background music to a playlist. A 2~3 minute track paired with a 20 minute video has to loop a dozen or so times, which immediately breaks immersion—
config.json'sbgm.playlistfill in several tracks ([{ "file": "assets/bgm-1.mp3" }, { "file": "assets/bgm-2.mp3", "trim": [0, 90] }]), they will be stitched in order into one whole segment and then looped as a whole, instantly reducing the sense of repetition; when playlist is empty it still falls back tobgm.filesingle-track looping. - Don't mention duration in the cover label.
cover.seriesTagDefaults to "whiteboard explained clearly"; if you want to emphasize duration, pass it inscenes.js's cover according to this episode's actual duration astag, don't hardcode "3 minutes" in config.
Time cost and size (measured on this machine with 4-way parallelism + extrapolation; 1080p30):
| Duration | Frame output | wb build Full process |
Final video + intermediate artifacts |
|---|---|---|---|
| 2.7 minutes ("DeepSeek Harness Desktop" measured 8 scenes) | ~1.8 minutes | ~3 minutes | 12 MB + 24 MB |
| 10 minutes | ~7 minutes | ~10 minutes | ~46 MB + ~90 MB |
| 30 minutes | ~21 minutes | ~30 minutes | ~140 MB + ~270 MB |
The frame rate is converted based on the measured value on this machine of ≈44 frames/sec (4 parallel lanes); the size is based on the measured value of ≈4.5 MB/min. Whiteboard-style frames are white and have little motion, so they are much smaller than live-action footage. If the machine is under strain, lower
render.workersto 2.
For long videos, be sure to use resumable runs: both TTS and rendering are cached per scene, so changing one line of narration or one scene does not require rerunning the whole video —
wb tts <期> <场景名> # 只重配改过的那一幕(缓存命中时几乎瞬时)
wb render <期> <场景名> # 只重渲这一段,并自动重拼 master
wb mix <期> # 出片
⚠️ wb build willre-render the entire episode(even if all TTS hits the cache); for long videos, do not use it for small changes. Also, wb captions <期> is especially useful on long videos: without rendering images, you can check the timing and highlight segmentation of 90 subtitles one by one.
How to handle modification requests
| The user says | Solution |
|---|---|
| Change a line of narration / add a segment | Change scenes.js → wb build(only re-synthesize the changed scenes) |
| A fake robot was used / a real logo is wanted | wb logo Get the official SVG → in scenes.js replace the s.image name with logo-* → wb stills → wb render && wb mix && wb cover |
| Change the sticker / the character looks ugly | wb image Regenerate → wb stills → wb render && wb mix(if used in the cover, then also wb cover) |
| Drawing too fast/too slow, text pops out | config render.pen:speed stroke px/s,charSeconds seconds-per-character range,minSeconds per-element minimum,gapSeconds pen-lift gap → wb render && wb mix |
| Visuals cannot keep up with narration(the audio is speaking, but the drawing is not finished; the later it gets, the more obvious it becomes) | Measure first:node lib/diag-schedule.mjs <期目录绝对路径> prints the "stroke start offset" for each beat; > 0.35s means it is lagging, and it willaccumulate into every later beat. Fix (change config only):render.pen.overflow set 0(disable "if a beat cannot be finished, it overflows into the next beat"),speed raise to 1400~1600,charSeconds lower the upper limit to ~0.14,fillMaxSeconds ~0.8、gapSeconds 0.09; run diag again to verify ≤0.1s, and finally wb render && wb mix. Beats with >20 elements will be compressed and become noticeably faster; if you want more room, delete a few elements (this is more worthwhile than making the whole video 23 seconds later) |
| Subtitles and speech are misaligned(Xiaomi channel) | node lib/diag-captions.mjs <期目录绝对路径> Check the "anchor rate" and start offset. If the anchor rate is not 100%, then some subtitle timings are relying on character-count interpolation; the source is almost alwaysa long short-sentence being forcibly split by captions.maxChars Hard split: increase maxChars so it can fit the longest short sentence (this episode: 25 characters → 26), or add a comma to that line of narration so it naturally splits into two short sentences (adding punctuation does not change the semantics, but you must rerun wb tts). See details in references/voices-mi.md |
| Rendering too slow / machine under strain | config render.workers(default 4 lanes, one Chromium per lane; 1 = serial); the output frames are independent of the number of lanes and are identical frame by frame |
| Speech speed fast/slow | config tts.speed(default 1.2) → wb build, all scenes are automatically re-synthesized, and subtitles stay in sync. ⚠️ Volcano is native speed change;Xiaomi is local atempo post-processing(the API has no speech-rate parameter) |
| Change the voice engine (Volcano / Xiaomi / edge) | config tts.engine change "volc" / "mi" / "edge" → must rerun wb tts: the three providers have different audio, speech rates, and pauses; subtitles follow the audio, and the cache is also separated by engine. After changing, run wb voice Preview |
| Want a longer / shorter video | Without changing code or config, only change scenes.js the scene count (converted per "1 minute ≈ 1.52 scenes ≈ 200 characters", see "Making long videos") → wb build. ⚠️This isrewriting contentwork, not tuning parameters: to lengthen, add scenes (each scene still 34 beats), don't stuff more narration into existing scenes. For >5 minutes remember to split into acts + add breathing points + change bgm.playlist |
| Music major/minor | config bgm.gain → wb mix; for temporary preview wb mix <期> 0.4 |
| Subtitle font size/position | config captions.fontSize / baselineY → wb render && wb mix |
| Subtitle follow-along highlight (toggle / color / granularity / semi-transparent) | config captions.highlight → wb render && wb mix。enabled turn it off to get the original static subtitles;color leave empty to use the brand color;opacity + keepHalo:true gives a semi-transparent marker effect;minUnitChars/maxUnitChars adjusts granularity (default 2/4, target 3 characters). After changing, first wb captions <期> to check the segmentation:[用的是·小米 ·MiMo ·的配音] in · is exactly where the color blocks jump in sequence | subtitle text and 字幕.srt are both unaffected |
| Watermark position/turn off/change logo | config brand.watermark.position/enabled、brand.logo(transparent-background png) → wb render && wb mix |
| End card slogan / CTA / duration / none | config brand.slogan、brand.endCard.* → wb render <期> 99-brand && wb mix |
| Cover copy / sticker / aspect ratio | scenes.js at the end, the cover function → wb cover; aspect ratio config cover.ratios(including 9:16 for Douyin), labels cover.seriesTag(tag: '' removed) |
| Title / publish copy / switch platform | Change 发布.md, no need to re-render the video |
| Changed the diagram in Obsidian | wb render && wb mix directly reads .excalidraw.md; adding/removing elements changes the stroke order, for structural changes go back to scenes.js |
| Change voice / change track | First wb voice to confirm the tier, then change the voice field of the respective engine: Volcano tts.voice(2.0 voices use resourceId: seed-tts-2.0, voice cloning uses volc.megatts.default) / Xiaomi tts.mi.voice(only nine, see references/voices-mi.md)/ edge tts.edge.voice; or .env the VOLC_TTS_VOICE、MI_TTS_VOICE / for music change bgm.playlist(multiple tracks concatenated in order, for long videos) or assets/bgm.mp3(single track loop; before changing tracks, first measure levels with volumedetect then set gain); temporary track change BGM=路径, temporary list change BGM_PLAYLIST='[{...}]' |
| Music rotates over a fixed few tracks | config bgm.playlist fill in [{ "file": "...", "trim": [起, 止] }];trim omit to use the whole track,gain can balance loudness per track → wb mix. When the list is non-empty, bgm.file / bgm.trim is ignored |
TTS reports Invalid X-Api-Key / not granted |
first suspectthe calling domainnot the quota:ark- Keys starting with planonly go to tts.endpoint, change config references/voices-volc.md error quick reference |
Xiaomi reports Unknown voice / assistant role / 404 |
three pitfalls: voice not among the nine, body text not placed in the assistant role, path not {baseUrl}/chat/completions, see references/voices-mi.md error quick reference |
| Change account branding | config brand.name/accent/slogan, leave the rest untouched |
Platform notes: Windows, macOS and Linux
Platform differences are handled in the tool layer. Follow these rules in new scripts:
- Path expansion:
lib/paths.cjs、lib/scene-dsl.js、lib/render.jsthe~values all userequire('os').homedir()。Do not useprocess.env.HOME: Git Bash represents it as the MSYS path/c/Users/x, which Windows Node resolves asC:\c\Users\xand fails with ENOENT. file://URL: Chromium must loadrender.htmland local images usingrequire('url').pathToFileURL(p).href, rather than manually constructingfile://+path.join(Windows backslashes are interpreted as escapes).- ffmpeg concat manifest: paths written into
list.txtmust usep.replace(/\\/g, '/')。 - Chromium:
lib/chromium.cjs'sresolveChromium()automatically selects the highest locally installed version in ms-playwright, avoiding a dead end where the required Playwright version is absent andnpx playwright installcannot install it either. npm installmay finish with exit 1 even though dependencies were installed:postinstallrunsplaywright install chromium, which reportsDownload failure, code=1if Chromium cannot be downloaded; at that pointnode_modules/already contains playwright, roughjs and lz-string, so video production still works.Do not reinstall repeatedly because of this exit 1; to suppress it, usenpm install --ignore-scripts, since the browser is selected throughresolveChromium()。- On Windows, Codex has only
codex.cmd,spawn('codex')fails with ENOENT;lib/gen-image.mjs'sresolveCodex()locatesnode_modules/@openai/codex/bin/codex.jsand runs it throughprocess.execPath. - Node networking: the Commons API requires a proxy on some networks;
lib/proxy.mjs'sinstallProxyFetch()adds an HTTPS CONNECT tunnel to Node fetch (reads theHTTPS_PROXY/HTTP_PROXYenvironment variable, which may also be set in.env; without configuration it does nothing).lib/fetch-logo.mjs、lib/gen-image.mjsentry points call it. - Local SVG to PNG conversion: for logos from outside Commons, such as official-site SVGs, use
lib/svg2png.mjsthrough Playwright to create transparent PNGs. bin/wb:ROOTBefore passing it to Node, usecygpath -mto convert it intoC:/...(Git Bash's/c/...is unreadable by Node; withoutcygpath, pass it unchanged);openusesexplorer.exeon Windows andxdg-open。- When moving an episode directory outside the repository:
config.json'sdirs.projects/dirs.buildsupports absolute paths, such asD:/临时/2026/9月/2026年9月29日. Restore temporary changes to./episodes/./buildafterward, and do not commit machine-specific absolute paths. Also,wb newgeneratesscenes.jswith__WB_ROOT__replaced by the repository's absolute path, buta manually copiedscenes.jshasrequire('../../lib/scene-dsl')on line 4 relative to the episode directory and will fail with ENOENT after moving. Rewrite it as the repository's absolute path with forward slashes, or usewb newto regenerate it. - Use Git Bash on Windows; quote paths containing Chinese characters or spaces.
Known pitfalls
- Logos: many official sites block retrieval with verification pages. Obtain logos through the Commons API (
wb logo), rather than scraping official sites. The final I in OpenAI's 2025 wordmark is a vertical stroke, not a cropping error. The Claude star SVG has slightly jagged edges that are imperceptible below 300 pixels in height. - Volcengine TTS occasionally returns only half the narration:
tts-volc.mjsvalidates character counts and retries automatically; regenerate all narration withFORCE_TTS=1. Cache keys include text, voice, speed, volume, style andAPI domain; changing any one regenerates the narration automatically. - The two Volcengine TTS API domains are separate(tested on 2026-09-29): API keys issued in the console beginning with
ark-work only through theplandomain (/api/v3/plan/tts/unidirectional); direct-purchase or legacy credentials usestandarddomain (/api/v3/tts/unidirectional)。The wrong domain reports45000010 Invalid X-Api-Key, resembling an invalid key or insufficient quota even though the domain is the problem. Checktts.endpointbefore suspecting credentials.ark.cn-beijing.volces.com/api/plan/v3is a separate Ark endpoint, not TTS; do not use it as the baseURL. The voice andresourceIdmust also match (2.0 voices ↔seed-tts-2.0); mismatches report55000000 mismatched with speaker。lib/tts/tts.mjs'sexplainFailure()translates these errors into actionable next steps;wb voiceperforms an early self-check. - Visuals lagging narration is the main synchronization problem in this scheduling scheme(tested on 2026-09-29 with the Xiaomi route):
schedule()(lib/render.html) limits each beat's drawing duration tolimit = 本拍结束 + overflow × 下一拍时长, whilethe next beat starts atmax(本拍起点, 上一笔结束). If one beat cannot finish, every following beat is delayed;the error accumulates. With the tested defaults (speed:1000 / overflow:0.35), the worst beat lagged by 3.62 seconds: narration had reached beat three while visuals were still drawing beat two. The fix changesonlyconfig.json'srender.pen:overflow: 0+speed: 1450+charSeconds:[0.05,0.14]+fillMaxSeconds: 0.8+gapSeconds: 0.09and reduced the measured maximum start offset to +0.07s。Measure after changing it; do not rely on feel:node lib/diag-schedule.mjs <期目录绝对路径>prints each beat's start offset, end offset and compression ratio; target a start offset no greater than 0.35 seconds for every beat (diag-schedule.mjsaccepts thePEN_OVERRIDE='{...}'environment variable for A/B tests with old parameters, without changing config). - Xiaomi MiMo TTS (
tts.engine: "mi") differs from Volcengine throughout(tested on 2026-09-29): 1. UsePOST {baseUrl}/chat/completions,The text body must appear in anassistantrole message(user alone reportsmessages must contain an assistant role);/audio/speech、/ttsall return 404. 2. The API has no speed parameter: top-levelspeed/audio.speed/audio.speech_ratevalues are ignored (audio.speed=0.5and omitting it returnexactly the same bytes),tts.speedis implemented locally throughatempopost-processing. 3. No word-level timestamps are returned(audio.transcriptis always null, and the speech-recognition model returns only text). Sentence timing is estimated using punctuation-delimited clauses,silencedetectpauses and monotonic DP alignment. Measured boundary error was approximately 0.05 seconds, with per-sentence speed CV of 0.144;work/audio/<scene>.json'salignfields indicatesilence-estimate(Volcengine usesnative)。Clause boundaries exclude the enumeration comma(ALIGN_VERSION = 3): pauses there are weak and unstable. Of 28 tested instances, ten had no pause; DP could only interpolate by character count, moving a clause start 1.16 seconds early. Changing this set requiresupdatinglib/tts/mi.mjs'sclausesOfandlib/captions.cjs'sPUNCT/splitTexttogether (both must match character for character; unequal segment counts otherwise degrade the entire video to proportional timing), thenALIGN_VERSION +1. 4. There are nine fixed voices (mimo_default/冰糖/茉莉/苏打/白桦/Mia/Chloe/Milo/Dean); an invalid name reportsUnknown voiceand lists every available value. 5. The silence threshold-35dB/0.10sis theoptimum found by a parameter sweep, not a sensitivity to adjust by intuition. Greater sensitivity worsened speed consistency in reproduction. See details inreferences/voices-mi.md。 - After editing narration, first run
wb scenes, thenwb tts:scenes.jsis the handwritten source, whereas TTS readsscenes/script.json(generated bywb scenes). Editing onlyscenes.jsand immediately runningwb tts,still reads the old script.json, creating the false impression that edits did not apply (encountered on 2026-09-29).wb buildincludesscenesin its full pipeline, so build is unaffected. - Use two diagnostic scripts to verify subtitle and visual synchronization(both in
lib/; neither changes artifacts and both may be rerun):node lib/diag-captions.mjs <期目录绝对路径>: subtitle count, voiced coverage, median start offset andanchor rate(whether starts fall exactly on spoken-clause boundaries; ideally 100%).node lib/diag-schedule.mjs <期目录绝对路径>: each beat's narration window versus actual drawing window, start/end offsets and compression ratio. Both scriptsaccept the episode directory's absolute path directly(without usingdirs.projects), but still resolve the build-output directory throughconfig.json'sdirs.build;when producing videos across directories, ensuredirs.buildmatches the actual build-output directory containing the video, or measurements use audio from the previous build (the first diagnostic pass made this mistake and wasted a parameter-adjustment round).
- Word-level subtitle timestamps depend on
enable_subtitle: true(effective for Volcengine 2.0/ICL 2.0 voices; 1.0 voices useenable_timestamp)。Retesting on the plan domain: disablingenable_subtitleand directly runningwords=0degrades subtitle timing to character-count estimation. Do not disable it merely to save response data. Xiaomi has no such switch because it supplies no timestamps. - Subtitle text must use the original script; TTS words may turn
@grokintoatgrok. Subtitle timing comes from TTS word timestamps, so rerun TTS after changing narration. - Subtitle follow-along highlighting(
config captions.highlight, enabled by default). When changingrender.html, note three points (tested on 2026-09-29): 1. Do not useRange.getBoundingClientRect()measures a local character range in SVG text, but Chromium measures only the first character, turning MiMo into M. The color block becomes half the text width, and remaining highlighted characters are painted the paper color, appearing tolose characters. Usetspan.getExtentOfChar(i)(returns user coordinates, which map 1:1 to this page's canvas coordinates), and index bycode points[...str]rather than UTF-16 offsets. 2. Volcengine'swordListdrops spaces(用的是小米 MiMo 的配音becomes...小米MiMo的配音), so reconstructing text fromw.wordis not the original script. Byte-for-byte checks would reject the match and fall back to proportional estimation; character limits may also split English letters into[米 MiM]·[o 的配音]。captions.cjs'swordsToUnits()aligns bynon-whitespace charactersand takes text from original-script slices for this reason. 3. Adjust granularity only by character count, rather than merging short durations: duration-based merging destroys native word boundaries ([小米][MiMo]merges into[米 Mi][Mo 的]). Volcengine's token list ischaracter by character(individual characters such as subtitle letters). Greedy grouping targeting three characters produces[用的是]·[小米]·[MiMo]·[的配音]。 - SVG dashes restart at each subpath's
M, and rough.js adds a second stroke. Applying one dashoffset to the entire path makes all edges grow simultaneously and draws each twice. The renderer already splits subpaths into nodes and duplicate strokes into A/B layers; do not revert it. - rough.js
toPathshas no dasharray; set dashed lines manually inrender.html. - Text width is estimated; accurate centering requires
align:'center'. Long left-aligned text must remain within 1920 pixels. - Playwright screenshots occasionally time out after 30 seconds. The renderer retries three times; if it still fails, rerun
wb render。 - Do not run
wb imageconcurrently: if Codex does not save at the requested path, fallback retrieval uses the most recently generated image, so concurrent jobs may take each other's images. - Parallel frame output depends on two behaviors; preserve both:
render.html'sseek()reinsert the background rectangle in place for every frame to force a full-screen repaint. Otherwise Chromium repaints only changed regions and frames depend on output order. Each worker opens its own Chromium. After changing the renderer, runrender.workers=1and4separately;ffmpeg -f framemd5must find zero differing frames. - Using
require()runstemplates/scenes.jscreates artifacts under templates/, so do not test that way. Usewb new。 - Rendering time depends on the machine: a reference 2.5-minute video takes about 35 seconds to generate frames with four workers. On this machine, a tested two-minute video took about 82 seconds for all frames, and
wb buildtook about 2.5 minutes overall (even a complete TTS cache hit rerenders and cleans the entire episode). To save time when changing one scene, usewb render <期> <场景>+wb mix. Do not use&for long background jobs; use the execution environment's built-in background facility. - Three minutes is an old guideline, not a limit(verified and corrected on 2026-09-30):
lib/render.jsproduces MP4 segments by scene and combines the master withffmpeg concat;lib/tts-volc.mjscomposites and caches each scene. Neither limits the scene count. Six–eight scenes or two–2.5 minutes were only content suggestions. The tested Volcengine plan-domainstreamingendpoint accepted 2,200 Chinese characters, or 6,600 bytes, yielding approximately 6.5 minutes of audio without error. Longer videos therefore need more scenes rather than code changes. The real costs areframe-output time (approximately 44 frames per second) and disk space (approximately 4.5 MB per minute), pluswb buildrerendering the entire episode. Usewb tts/render <期> <场景>to resume long videos from checkpoints. Warning:do not hard-code duration in cover labels. The defaultcover.seriesTagpreviously said Explain It Clearly on a Whiteboard in 3 Minutes, contradicting longer videos; it now says Explain It Clearly on a Whiteboard. Also,bgmlooping one song through a 20-minute video becomes distracting. For long videos, fill inbgm.playlist(multiple tracks are concatenated in order, then looped as a whole). wb build's cleanup step may be blocked by a safety hook. After the finished video, subtitles and covers are saved, deletingwork/framesin batches of more than a few dozen files may make some agent sandboxes raiseSAFE_DELETE_BULK_CONFIRM_REQUIRED. Delivery is unaffected; clean up manually once or usewb clean.- edge-tts engine(
tts.engine: "edge"):lib/tts-edge.pyreadswork/audio/and writes<scene>.mp3+<scene>.json; WordBoundary events in the JSON formwordListin the same format as Volcengine word timestamps, so the subtitle pipeline stays unchanged. Cache keys include text, voice and speed;mismatches regenerate narration automatically(force regeneration withFORCE_TTS=1). The interpreter is specified byconfig.json'stts.edge.python(point it to the installation containingedge_tts); leave it empty to usepython3/pythonfrom PATH. Edge cannot reliably read Chinese polyphonic characters; spell numbers and English model names phonetically in Chinese in the narration.