Skip to content

反例台账:写出来像对、其实是错的写法 ​

本页是反例集,按内容组织:每行四样东西——错法片段、为什么会错的一句话机制、对照的正确写法、能抓住它的钉子。

钉子的含义是固定的,只有两种:tests/*.test.ts 里真实存在的用例,或一条能跑的门禁命令。 查不到防线的写法统一进文末「无自动防线」,那里写「靠评审拦截」——不编测试名。

组按「模型与新人最容易在哪个环节自己发明语义」排。四层与双池的定义在 mental-model.md,术语对齐在 glossary.md,本页只管对照。 数值一律带单位:毫秒、帧/秒、像素、0..1、dB。

时间模型 ​

本组判据:语义层只有毫秒一条轴,fps 只决定采样密度,总长是链算出来的结果而不是输入参数。

错法(✗)为什么错正确写法(✓)钉子
if (t >= 12345) drawAct2();(在回调里按绝对毫秒反查该画哪一幕)段随链推进,画哪一幕由链的位置决定;反查把链退化成手写数组env.cutTo(3000, (prev, env) => { env.step(3000, ...) })tests/transition-overlap.test.ts「两段在窗口内同帧求值,总长按共享段扣减」
const p = ctx.t / ctx.totalTime; 驱动单元内动画跨单元比值不受本段窗口约束,改一段时长全片动画一起变形ctx.progress(= localT / duration,0..1)tests/wait-update.test.ts「与并行 step 同窗:回调点 = 采样网格点,自身不额外产帧」(progress = 0 / 0.25 / 0.5 / 0.75)
想"放快四倍"就把 fps: 240 往上报fps 是采样密度,调高只是采样点更密,时长纹丝不动缩短窗口毫秒:env.step(750, ...)tests/engine-timeline.test.ts「单元 fps 覆盖基础帧率:高帧率单元采样点更密」
t += 1000 / fps; 逐帧累加时间戳浮点累加在 fps 不整除时与真值分叉,误差随帧数放大ctx.t,或按帧号乘:frameIndex * (1000 / fps)tests/time-math.test.ts「乘法与累加在 fps 不整除时不同(乘法是唯一正解)」
拿子单元的 ctx.frameIndex 与父单元的同号帧当同一时刻单元有效帧率三级继承,帧号密度不同,跨单元不可比用毫秒对齐:ctx.ttests/time-math.test.ts「timestampAt = frameIndex * (1000/fps),恒等乘法」
env.wait(500, () => draw()); 想每帧画一次wait 不产帧,纯空窗没有采样点要逐帧就用 env.step(500, (ctx) => { ... })tests/wait-update.test.ts「纯空窗:没有任何产帧单元 → 零帧零回调」
跨幕动画用 ctx.f(n) 一帧帧推f(n) 的参照系是本单元有效帧率,只在本单元内有意义跨幕用 coverProgress(atMs, fromMs, durationMs)tests/cover-progress.test.ts「窗内线性推进,且首尾相接」

登记期 vs 运行期 ​

本组判据:登记期(env)只登记、不执行画面;运行期(ctx)才有画布、声音句柄与推进能力。两边能力集刻意不同。

错法(✗)为什么错正确写法(✓)钉子
在 cutTo 体里读"当前帧时刻":cutTo(1000, (prev, env) => { const p = env.localT; })登记体只跑一次(干跑),没有当前帧这回事,更没有 localT时间只能在 env.step 回调里读tests/validate-pool.test.ts「body 干跑抛错 → spec_violation」(干跑 = 登记体不产帧地跑一遍)
把 env.spawn(...) 的返回值存起来后面 kill()登记期返回的是占位死句柄(alive === false),真句柄只在运行期产生运行期 ctx.spawn(...) 的返回值才是活句柄tests/dsl-chain.test.ts「spawn 返回占位死 Handle(alive=false;真 Handle 在运行期 ctx.spawn)」
把 AudioHandle 存在模块变量里,到了别的帧再 stop()句柄操作面只在本回调内有效,操作以当前回调时间为生效时刻在发它的那一次回调里决定何时收:h.stop(200)tests/audio.test.ts「回调之外操作句柄报 spec_violation(操作面只在 step/loop 窗口内有效)」
以为运行期 ctx.spawn 的异步 setup 会卡住帧或丢资产帧内只登记链壳,异步等待发生在帧与帧之间,链从 spawn 之后第一个采样点整点开始直接写 setup: async () => { ... },让引擎在帧边界物化tests/spawn-async-setup.test.ts「链从 spawn 之后的第一个采样点整点开始,内容本地时间从 0 起算」
在 run 里读 env.assetsassets 只挂在 SetupEnv 上,run 阶段拿不到资产加载写在 start({ window, setup, run }) 的 setup 里pnpm typecheck(setup 之外的 env 没有 assets 成员)
以为 setup 每帧都跑、或每个 spawn 实例各跑一次它是一次推演里的单次准备,资产只解码一次把一次性准备全放 setup,逐帧逻辑放 run / steptests/asset-contract.test.ts「同一场景在一次推演里只跑一次 setup(资产只解码一次)」
env.transitionTo(next) 之后继续 env.step(...) 想画下一幕内容过场登记后后续节点在运行期被短路,登记了也不会执行下一幕的内容写进 next 自己的 cutTo 体tests/dsl-chain.test.ts「transitionTo 登记:其后节点在运行期被短路(编译 break),登记本身不抛」
每帧都发同一句声音,用 if (ctx.frameIndex === 0) 之类自己判首帧判首帧在多单元/多 spawn 下会重复,且改 fps 就失效ctx.once("unit", (c) => c.audio(clip("hit")))tests/once.test.ts「unit:整个 step 窗口只发一次(默认作用域)」

池与窗口 ​

本组判据:总毫秒池记时长(Σstep + Σwait + Σpar-max),帧窗池记采样点(只由 step/loop 的采样密度贡献)。

错法(✗)为什么错正确写法(✓)钉子
以为 wait 不占时间、可以白给wait 占总毫秒池,只是不产帧有画面就 step,纯等待才 waittests/validate-pool.test.ts「池消耗 = Σstep + Σwait + Σpar-max」
env.loop({...}); env.step(500, ...) 想让 step 接在 loop 之后loop 在后台重复,不推进父游标后续内容要接在 loop 之后,就把它放进 loop 体内或另起一段tests/engine-timeline.test.ts「loop 不推进 cursor:后续 step 仍在 loop 登记处的时间上开始」
以为不带 ms 的 loop 也占满时长无 ms 的 loop 靠 next() / stop() 自管节奏,池消耗为 0 毫秒要固定迭代窗就写 ms:env.loop({ ms: 100, times: 3 })tests/validate-pool.test.ts「loop 池:ms×times;无 ms(自管节奏)= 0 不占池」
以为 par 的两条分支时长相加分支共享同一段时间线,取最长的一条并行分支按最长的安排预算tests/validate-pool.test.ts「池消耗 = Σstep + Σwait + Σpar-max」
以为 at 越窗会被夹到窗口尾部越窗是静态违规,引擎不猜作者意图让 at 落在本段窗口内:env.step(100, fn, { at: 900 }) 且窗口 ≥ 1000 毫秒tests/validate-pool.test.ts「f(90) 越窗:at 越窗 → window_overflow」
把 forever: true 当"跑满 N 毫秒就停"的定时器forever 是寿命铺到总时间线尾,或等显式 kill()要有限时长就写 duration,要保活才写 forevertests/engine-timeline.test.ts「跨段背景:forever spawn 存活到时间线尽头」
env.spawn(bg, { forever: true, duration: 500 }) 两个都写寿命二义:既保活又限时,编译期直接拒二者取一tests/validate-pool.test.ts「spawn forever 与 duration 并存 → spec_violation(寿命二义)」

生命周期 ​

本组判据:消亡不是开关。要么瞬间出池,要么开一个长度毫秒的消亡窗口,窗口内继续逐帧求值。

错法(✗)为什么错正确写法(✓)钉子
handle.kill({ fadeOut: 100 }) 之后以为下一帧就没了消亡窗口内目标仍在池、仍逐帧求值,窗口结束才出池在回调里读 ctx.disposing / ctx.disposeProgress,自己画消失过程tests/dispose.test.ts「窗口内继续求值并暴露 disposing/disposeProgress,窗口结束出池」(t=150 毫秒时 progress=0.5)
handle.kill() 之后期待画面有淡出缺省是立即出池,没有补间要淡出必须显式给 fadeOuttests/dispose.test.ts「不带 fadeOut = 立即死(之后不再绘制)」
用新 key 再 spawn 一次,想"延长"同一条背景新 key 就是新实例,两条链会重叠同 key 续接:ctx.spawn(bg, { key: "bg" })tests/engine-timeline.test.ts「同 key 二次 spawn 续接(不重叠新建)」
spawn 时 duration: -100 想表达"从尾部反向算"负时长是非法值,不是相对写法写正的毫秒数,或省略走场景自身时长tests/validate-pool.test.ts「spawn 负 duration → spec_violation」
以为 scheduler.kill(name) 只能杀 spawn 链具名消杀同时覆盖 step / loop 单元与 spawn 链给单元起 id:env.step(500, fn, { id: "title" }),再 ctx.scheduler.kill("title")tests/dispose.test.ts「par 分支里按 id 杀另一个分支的单元,带消亡窗口」
宿主 dispose() 之后再 start(),期待继续发声终点之后的投递一律不生效,不静默降级重建前先 begin(program)tests/web-audio-player.test.ts「dispose 之后再 start:不再投递任何消息」
kill 与 dispose 当成两套语义各写一遍两者同义,都接受同一个 KillOptions只挑一个名字用,团队内统一tests/dispose.test.ts「主链单元自消杀 + fadeOut:窗口内继续画,窗口结束停」

音频 ​

本组判据:声音的时钟独立于画面。它不占帧窗池、不因幕/段/链边界被自动杀,预览与导出走同一套混音内核。

错法(✗)为什么错正确写法(✓)钉子
用同一套 at 语义贯穿登记期与运行期两套语义并存:登记期 at 相对登记位置,运行期 at 相对发射时刻env.audio(clip, { at: 100 }) 是位置;ctx.audio(clip, { at: 200 }) 是延迟tests/audio-emit-delay.test.ts「at 相对发射时刻:t=100 发射 + at 200 → 起播 300」与「登记期 at 仍相对登记位置(两种语义并存)」
在幕边界手动 ctx.audio(...).stop() 收声音引擎不因幕、段、链边界自动杀声音,手动收会截断尾音用 ctx.once 决定何时发,用句柄 stop({ fadeOut }) 或 dispose 决定何时收tests/audio-warp-ops.test.ts「loop 到终界:endMs 夹在总时间线尾(引擎不因幕边界杀声音)」
rate: -1 想倒放变速恒正,倒放不支持;负值在构造处就被拒只做加速/减速,反向段落提前在源里准备好tests/audio-warp-combinator.test.ts「其它组合子守住量纲与范围(rate>0 / pan∈[-1,1] / gain≥0)」
curve([300, 1], [100, 0]) 折点写反折点时间必须严格递增,乱序是违规不是自动排序时间升序写:curve([100, 0], [300, 1])tests/envelope.test.ts「curve 折点时间未严格递增 → spec_violation」
以为登记期 audio / bgm 占帧窗池或推进游标音频声明不改 step 模型,不消耗帧窗池声音按需声明,画面时长仍只由 step / wait 决定tests/dsl-chain.test.ts「audio / bgm 登记不抛、不推进 cursor(不占帧窗池)」
把 { db: -6 } 当线性系数 0.6 自己算混音dB 与线性幅度是同一量的两种记法,换算只有一处实现交给平台侧:作者写 { db: -6 },适配器收线性幅度tests/gain-units.test.ts「dB 写法与等值线性写法产出同一份样本」
预览与导出各写一套混音(或按块大小分别调参)双端同源,逐样本相同;块尺寸只影响循环次数只写一份音频图,两个端各自注入 AudioClock / AudioOutputtests/audio-parity.test.ts「恒速正弦:预览路径与导出路径完全相同」与「块尺寸只影响循环次数:128 / 512 / 整段给出同一份样本」
用 if (t === 300) h.setGain(0.2) 事先算好"第几帧该改"句柄操作的生效时刻就是求值时刻,提前算等于自己排出第二份生效时刻表在要生效的那一帧回调里调用tests/audio.test.ts「句柄操作记录到 voice.ops(stop / setGain / setRate,时刻=求值时刻)」

捕获与导出 ​

本组判据:帧载体是借出物,所有权随 writeFrame 转移;写帧、写音、finalize 的次序是纪律,不是建议。

错法(✗)为什么错正确写法(✓)钉子
用 frames: "allow-blank" 当性能开关,想把每帧回读省掉它只把「没有帧来源」从报错降级成全 0 占位位图;有 2D 上下文时照样整幅回读预览注入 painter 自绘:painter: () => ({ data: new Uint8ClampedArray(4), width, height })tests/frame-source.test.ts「显式 frames:"allow-blank" 才给零位图」(该用例前提是 getContext: () => null,无源才有占位);回读路径见同文件「缺省从绘制目标画布回读」
边写帧边写音频块,或先写音频再补帧次序固定:帧全写完 → 音频块按时间顺序连续提交 → finalize让引擎排序,不要在导出器里自行交错tests/export-audio.test.ts「帧全部写完后才写音频块,音频写完才 finalize」
有声音但导出器没有 writeAudio,就静默只出视频静默降级会让成品少一条轨而没人报错要么实现 writeAudio,要么让引擎报 spec_violationtests/export-audio.test.ts「有声音但导出器不支持音轨 → spec_violation」
有声音但不注入 AudioClock没有采样率与声道,音轨无法布局注入 audio?: AudioClock,布局取自它声明的 sampleRate / channelstests/export-audio.test.ts「有声音但没有 AudioClock → spec_violation」
把 fps 与 width / height 之外的尺寸帧塞给导出器写帧前先校验尺寸,晚校验会把错帧写进编码器让 RenderOptions 的尺寸与导出尺寸一致tests/webcodecs-exporter.test.ts「尺寸与导出尺寸不一致:写帧前报 spec_violation」
在 FrameHost 里跨 begin 保留上一段推演的状态begin 是每次推演的重置点,状态不得跨 begin 沿用每次 begin(info) 重建内部计数器与缓冲tests/frame-host.test.ts「重播会重新 begin:跨帧状态不串到下一段推演」
用异步 painter 时并发取帧(不 await、任其重叠)引擎等取帧完成再交帧,取帧慢不丢帧且严格串行;并发会把帧序打乱直接把 async 函数当 painter 返回,让引擎等tests/async-painter.test.ts「取帧慢也不丢帧,且严格串行(不重叠取帧)」
begin 失败后继续 frame(...),或失败时仍调 close()begin 失败即整段推演失败,不交帧也不调 close把 begin 的错误原样上抛tests/frame-host.test.ts「begin 失败:整个推演失败,不交帧也不调 close」
透明档下少清一次屏(「反正要透明,留点旧像素也没关系」)两档的区别只是「干净」是哪种干净;漏清会让上一帧的像素留成鬼影,输出就与上一帧有关了每帧照样清,透明档 = 清成全透明tests/alpha-frames.test.ts「Canvas2D 合成器按 alpha 档创建上下文」
用 mp4 导出器出透明帧,指望透明区域自己留着H.264 结构上没有 alpha 通道,透明处会被填黑;声明 alpha: "alpha" 却配 mp4 封装,首帧前就报 spec_violation要透明产物就配 ApngExporter,或用 FrameSequenceExporter 拿原始 RGBA 自己落盘tests/alpha-frames.test.ts「声明 "alpha" 却用 mp4 封装:首帧前报错,且不写任何帧」
在导出器里写「拿不到位图就 getImageData 兜底」导出器回读把编码降级成事后补救,还掩盖了「这台设备没有 2D 上下文」的装配错误在帧来源这一侧声明要位图:createReadbackHost(inner) 包一层tests/readback-host.test.ts「既没有 2D 上下文、也画不进画布的对象明确报错」
只在入口写 alpha: "alpha",导出器实例按缺省建(不带 alpha: true)入口声明的是「这一趟要透明画布」,实例声明的是「这份产物装得下 alpha」,两者对不上就白推演一趟;引擎在首帧前就拦下两处都给:alpha: "alpha" + new WebmExporter({ ..., alpha: true })tests/webm-exporter.test.ts「声明 transparent 的实例把能力如实报告给引擎」
用 format: "webm" 就以为自动带 alphaWebM 的 alpha 是并行侧通道,不是封装自带;不声明就不编码那一侧,产物是不透明的显式声明实例的 alpha: true,轨道上才会出现 AlphaMode 与 BlockAdditionstests/webm-muxer.test.ts「透明档在轨道上声明 AlphaMode」
用文件探针看透明 WebM 的像素格式,断言它不是透明alpha 走的是侧通道,探针看到的就是普通 yuv 像素格式——这不代表产物没有 alpha判据看容器里的 AlphaMode 与块附加数据tests/webm-muxer.test.ts「透明块走 BlockGroup」
把任意采样率的音频交给 WebM 导出器Opus 的采样率写死在码流头里,48kHz 之外要么重采样要么报错,静默改采样率会让音高整体偏掉让 AudioClock 声明 48kHz;导出端不做静默重采样tests/webm-exporter.test.ts「采样率不是 48kHz:首块即报 spec_violation」
透明档给导出的帧来源是设备句柄句柄不承诺 CPU 可读,alpha 侧通道需要逐像素 alpha,导出器又不回读像素用 createReadbackHost 包一层拿到位图,或把 alpha 声明改成 "opaque"tests/webm-exporter.test.ts「透明档只收位图:设备句柄明确报错」
声明与封装都对了,产物还是不透明,怀疑引擎丢了 alpha声明、封装只管「画布」与「容器」;场景自己铺的整幅不透明底会把 alpha 通道填满,任何封装都留不出透明区透明产物是素材:把铺满整幅的暗底做成一层,剧本里不加这一层就没有它示例见 examples/browser/ledger/scrim(遮罩层)与 examples/browser/host.ts(makeMaterialStage 不装配这一层)

契约纪律 ​

本组判据:契约是零实现的类型层,能力靠注入。三条红线:不引运行期依赖、不开平行类型、不绕开组合根。

错法(✗)为什么错正确写法(✓)钉子
手写 { url: "a.mp3", kind: "audio" } 当音频传AudioClip 带 brand,结构相同的普通对象不是合法的片段用工厂:env.audio(clip("a.mp3"))pnpm typecheck(结构相同的对象在编译期就被拦下)
业务代码自己 new TimelineEngine(...) 并手动拼装配装配出现在两处,缺一个依赖就静默跑偏走 render(scene, options) 的注入面(exporter / assets / painter / audio)tests/dsl-chain.test.ts「assets.load 未装配 AssetSource → spec_violation;经组合根 render 注入默认实现 → fail-fast」
以为可选注入缺了会自己降级缺装配是 fail-fast,不是降级需要的能力就显式注入,不赌缺省tests/frame-source.test.ts「无 painter 且无 2D 上下文 → spec_violation(不静默交空白帧)」

无自动防线 ​

下面这些写法目前没有测试或门禁能抓到,靠评审拦截。列在这里是为了让评审有明确清单,而不是让它们变成可接受的写法。

错法(✗)为什么错现状
把 spawn 的 at 当绝对时间线时刻at 相对登记位置;同族的 step.at 有越窗防线,spawn.at 暂无靠评审拦截;越窗语义可参照 tests/validate-pool.test.ts「f(90) 越窗:at 越窗 → window_overflow」
在 packages/contracts 里写实现、或读运行时全局(window / navigator)契约零实现依赖,加了实现就变成第二个组合根靠评审拦截:只允许 interface / type / declare
从别的包的内部路径 import(不经包入口 index.ts)绕过公共出口,内部改名就断靠评审拦截:包入口是唯一合法入口
同一概念另开一份平行类型(自己再定义一遍帧载体或错误形状)两份声明迟早分叉,端口之间对不上靠评审拦截:先检索,有就复用
以为 validate 通过就等于运行时不会出错它只做静态检查并干跑登记体,不替代运行期校验靠评审拦截:错误仍按 RenderError 的 kind 分别处理
跨帧持有 FrameHandle,或 writeFrame 之后再 close()所有权随 writeFrame 转移,句柄交给引擎后立即释放靠评审拦截:导出实现不得跨帧留句柄
以为 mp4 容器字节能逐字节复现判据是帧指纹序列,不是容器字节;容器含编码器与封装侧差异靠评审拦截:确定性结论只对指纹序列成立

画面是时间的函数,总时长是编译产物。