剧本(Scene / SceneEnv / StepContext / LoopContext / WaitContext / LayerDeclaration / RandomStream)
用 TypeScript 声明一段「第几毫秒画什么」的能力面:start 交出一个不可变的 Scene,cutTo 把它接成链,登记体里的 env 只声明节点,逐帧回调拿到的 ctx 按单元类型给不同的能力集。端口在 packages/contracts/src/scene/entities.ts、nodes.ts、layers.ts、random.ts;编译与求值由 packages/core/src/app/runtime/ 承载,入口 start 由组合根 packages/core/src/index.ts 导出。
何时用 / 何时不用
- 用:要把一段画面写成「按采样点求值」的回调,因为
env.step(duration, fn)是唯一产帧的登记方式。 - 用:要把多段串成一条连续时间线,因为
cutTo返回新Scene,总长由链上窗口累加得出。 - 用:要在同一段时间上并行跑几条互不干扰的分支,因为
env.par的分支各有自己的游标进度,且能各自声明层。 - 用:要让一段内容按节奏重复、又不推进主链游标,因为
env.loop后台并行,只提供自己的采样点。 - 用:要在两段之间做重叠过渡而不是硬切,因为
transitionTo的overlapMs让两段在窗口内同时求值。 - 用:要画面按固定种子可复现地「随机」,因为
ctx.random是数据不是熵。 - 不用:要让一条链活过当前幕、跨段续接,改用
ctx.spawn与scheduler,见docs/contracts/scheduler.md。 - 不用:要加声音、路由总线,改用音频页的能力面,见
docs/contracts/audio.md。 - 不用:只想拿状态不进画面,改用
compute.computeAt,见docs/guides/debug-with-compute.md。
断言表
| 断言 | 判据 | 钉子 |
|---|---|---|
start 三种重载都只按毫秒定窗口:start(1000, …)、start(0, 1.5, …)、start({ window, run }) 分别得 1000 / 1500 / 500 毫秒 | scene.duration 三个值逐一相等;id 匹配 scene# 前缀 | tests/dsl-chain.test.ts |
cutTo 返回新 Scene,时长与 id 都与前一段独立 | b.duration 为 300 毫秒且 b.id !== a.id | tests/dsl-chain.test.ts |
setup 先于 run 只跑一次;异步 setup 引擎等它完成再建时间线 | 事件序为 ["setup","run"],其后 100 毫秒 @60 帧/秒仍出 6 帧 | tests/dsl-chain.test.ts |
登记期的 env.spawn 只交出占位句柄(alive 为假),真实例在运行期 ctx.spawn | 占位句柄 alive === false,kill / dispose 仍是函数 | tests/dsl-chain.test.ts |
loop 后台并行、不推进父游标:其后的 step 仍从 loop 登记处的时间开始 | 后续 step 窗口落在 [200, 400) 毫秒 | tests/engine-timeline.test.ts |
单元声明 fps 覆盖基础帧率,采样点更密但总长不变 | 基础 30 帧/秒、单元 60 帧/秒的 100 毫秒窗口出 6 帧 | tests/engine-timeline.test.ts |
wait 只在「同一时刻有别的单元产帧」的网格点上回调,自身不额外产帧 | 20 帧/秒下回调时刻为 0 / 50 / 100 / 150 毫秒,progress 为 0 / 0.25 / 0.5 / 0.75 | tests/wait-update.test.ts |
wait 能力集不含画布与产帧入口:回调里只能发声、消杀、抽随机 | 回调内 audio 与 scheduler.kill 可用,音频起播时刻等于该采样点 | tests/wait-update.test.ts |
layer 是声明:不占帧窗池、不推进游标 | 有层声明与无层声明两种剧本帧数相等 | tests/layers-decl.test.ts |
层区间穿过编译与引擎,按绝对故事时间交给宿主;z 升序从下往上 | layers[0] 为 { id: "a", z: 0, from: 100, to: 700 };宿主叠放次序为 [["x"],["y","x"],["x"]] | tests/layers-decl.test.ts |
非法层声明在登记期就报错(空 id / 空可见区间) | 报错文案分别匹配 id 不能为空、可见区间为空 | tests/layers-decl.test.ts |
| 随机是数据:同种子同步数得同一序列,换种子换序列 | 同 seed 两次推演同一串数;不同 seed 不同 | tests/random-plane.test.ts |
fork(label) 同 label 同序列,且与父流已抽多少次无关 | 父流先抽若干次后 fork 出的子流序列不变 | tests/random-plane.test.ts |
| 重叠过渡窗内两段同帧求值,总长按共享段扣减 | 500 + 400 毫秒两段、overlapMs 200 毫秒 → 700 毫秒、7 帧 | tests/transition-overlap.test.ts |
重叠窗非法值(0 / 负数 / NaN)在登记期即报 spec_violation | 报错文案匹配 overlapMs | tests/transition-overlap.test.ts |
用法
一条两幕的链:第一幕画 200 毫秒并声明一层,第二幕用 cutTo 接上(硬切)。
ts
import { render, start } from "@lingfan-video/core";
import { FrameSequenceExporter } from "@lingfan-video/infra";
const W = 320;
const H = 180;
const act1 = start(200, (env) => {
// 登记是声明:不消耗帧窗池、不推进游标
env.layer({ id: "field", z: 0, from: 0, to: 200 });
env.step(200, (ctx) => {
const dx = ctx.progress * W; // 0..1 → 像素
void dx;
void ctx.random.float(-1, 1); // [min, max),同种子同序列
});
});
const tail = act1.cutTo(300, (prev, env) => {
void prev; // 上一幕 run/body 的返回值
env.step(300, (ctx) => {
void ctx.t; // 全局毫秒
void ctx.localT; // 本单元内偏移
});
});
void tail; // cutTo 返回的是链尾:推演一律从链头 act1 出发
// render / compute / validateScene 收的都是链头
await render(act1, {
fps: 30,
width: W,
height: H,
painter: () => ({
data: new Uint8ClampedArray(W * H * 4),
width: W,
height: H,
}),
exporter: new FrameSequenceExporter(),
});把上面 cutTo 换成带重叠窗的过渡:transitionTo 是登记体里的动作(SceneEnv 成员),不是 Scene 上的方法,所以要在 start / cutTo 的回调里调,并且它之后同一个登记体里的节点在运行期会被短路。
ts
const next = start(400, (env) => {
env.step(400, () => {});
});
const head = start(300, (env) => {
env.step(300, () => {});
// 不传 overlapMs 或传 undefined = 硬切:任何时刻只有一段活跃
env.transitionTo(next, { overlapMs: 100 }); // 100 毫秒重叠窗,窗口内两段同时求值
env.step(200, () => {}); // 登记不报错,但运行期被短路,不再活跃
});并行分支与循环:分支各有自己的层声明,loop 不推进父游标。
ts
const withBranches = start(400, (env) => {
env.par([
(branch) => {
branch.layer({ id: "bg", z: 0 });
branch.step(400, () => {});
},
(branch) => {
branch.layer({ id: "fg", z: 10 });
branch.step(400, () => {});
},
]);
env.loop({ ms: 100, times: 4, fn: (ctx) => {
void ctx.iteration; // 0 起
void ctx.iterationT; // 当前迭代内偏移,毫秒
} });
});
void withBranches;env.f(n) 是单元内的帧号换算糖:n 从 0 起,返回毫秒。
ts
const scene = start(1000, (env) => {
env.step(1000, () => {}, { at: env.f(0) }); // at 缺省 = 当前 cursor
});
void scene;反例
- ✗ 写法:
const h = env.spawn(bg, { key: "bg" }); env.step(100, () => h.kill())—— 为什么错:登记期拿到的env.spawn是占位句柄(alive为假),句柄的真实例只能在运行期ctx.spawn之后拿到,kill占位句柄不会影响任何内容;✓ 改成:在step回调里const h = ctx.spawn(bg, { key: "bg" }),把h存进闭包,之后h.kill()。 - ✗ 写法:把
wait(ms, fn)当成「产帧的空档」,在回调里画像素或step—— 为什么错:WaitContext刻意不含canvas/step/loop/spawn/par,这段不产帧;若能在空窗里产帧,「空窗不产帧」的导出优化就失真,纯wait的窗口里回调根本不会被调用;✓ 改成:要画面就step,要「一边等一边更新非画面状态」才用wait,且只在别的单元产帧的采样点上会收到回调。 - ✗ 写法:
loop({ ms: 100, fn })之后在同一登记体里继续step,并以为游标被 loop 推进了 —— 为什么错:loop后台并行、不推进父游标,它只贡献自己的采样点;✓ 改成:要串行推进就按顺序step或用cutTo接幕,要并行就明确用loop/par。 - ✗ 写法:为了「多画一层」在
step回调里调env.layer—— 为什么错:layer属于登记期的LayerEnv,只在登记体里生效;逐帧回调里没有layer,层声明是编译期输入,不是运行期动作;✓ 改成:层在登记体里一次声明完,运行期只按ctx读数画像素。 - ✗ 写法:
ctx.random.next()与别处共享一条流,指望「整体固定种子就整体可复现」—— 为什么错:每个单元实例拿到独立子流,新增/删除一个单元会改变兄弟单元的抽取次数,若共享一条流则整场重新洗牌;✓ 改成:要互不干扰就用ctx.random.fork(label),同 label 同序列且与父流已抽次数无关。 - ✗ 写法:
transitionTo(next, { overlapMs: 400 })在两段各 300 毫秒上 —— 为什么错:overlapMs必须大于 0、不大于本段收窗长度、不大于下一段长度,吃满或超过都会在登记期报错;✓ 改成:取小于两段长度的小值(如 100 毫秒),并接受总长按重叠段扣减。
边界与陷阱
FrameFn(env.f)的参数是单元内帧号(0 起),返回毫秒:f(n) = n × (1000 / 单元有效帧率)。单元内语义,跨单元不可比——两个单元的f(2)一般不是同一毫秒。- 单元有效帧率三级继承:单元
StepOptions.fps> spawn 链SpawnOptionsLike.fps>RenderOptions.fps。声明fps只改采样密度,不改总长。 StepOptions缺省:at缺省为当前游标;hold缺省false(结束后不再发布状态到本段收窗;给true则保留最后一帧的值);fps缺省继承链/基础帧率;id缺省为编译键,只影响scheduler.kill(id)能否按名字找到它。step(fn)单参重载的时长是一个采样步,不是一整段;要定长窗口用step(durationMs, fn)。SceneEnv.fps是本段基础采样密度(帧/秒),SceneEnv.headless表示无呈现出片(不建显示面、只求值与编码),两者都在登记期可读。cutTo的三参重载cutTo(0, windowSec, body)用首参字面量0标记「单位是秒」,因此不能用变量传 0 走秒单位;start的同款重载同理。setup只在推演开始前跑一次,可返回 Promise,等待发生在第一帧求值之前、不参与任何帧时序;例外:运行期ctx.spawn的场景在帧内编译,不能等 I/O,其setup必须同步,否则明确报spec_violation。assets只在SetupEnv上可读,run阶段拿不到。transitionTo的overlapMs缺省不传 = 硬切(本段在 anchor 收窗、下一段从 anchor 开始,任何时刻只有一段活跃);给了 = 重叠窗,窗口内两段各自按本地时间推进,下一段localT从 0 开始。总长 = 两段之和 − overlap。LayerDeclaration缺省:z缺省按声明顺序、blend缺省普通 alpha 叠放("glow"是叠加/光效层,合成行为由宿主实现,引擎不解释像素)、from缺省从登记处开始、to缺省本段收窗。from/to相对登记位置(与音频at、自动化atMs同一约定),不是绝对时间线时刻。同id重复登记以最后一处为准。LayerDeclaration.id是剧本与宿主之间唯一的配对键:宿主按id找面,剧本声明了层而宿主没给面对应的面会明确报错,不静默少画一层。LoopSpec缺省:ms省略 = 无固定迭代窗(fn自管next()/stop(),iterationProgress恒 0);times省略 = 不按次数停(铺到时间线尾或被stop());fps/id缺省同StepOptions。WaitContext不含canvas/step/loop/spawn/par,只给t/frameIndex/totalTime/input/localT/duration/progress/scheduler与环境类(音频、随机、消亡、once)。WaitOptions.id缺省为编译键。OnceScope缺省"unit":该 step/loop 单元(含 loop 的全部迭代)只发一次;"iteration"每轮一次、"spawn"每实例一次(key作实例 id)、"scene"整场一次。once只做事件,不产帧。RandomStream边界:next()是[0,1);float(min,max)是[min,max)且要求max > min;int(min,max)是[min,max)内的整数且要求整数、max > min;chance(p)的p取 0..1;pick(items)要求非空数组。越界在调用点报错(文案含max > min/0..1/非空)。DEFAULT_PROGRAM_SEED是RenderOptions.seed的缺省值;非法种子(负数 / 小数 / 超 32 位 / NaN)在开工前就报spec_violation,不进入推演。StepContext.input是那一刻已生效事件的快照(atMs ≤ t的事件已生效),同帧所有单元共享同一份,不能被改写。- 单位自查:
duration/at/localT/iterationT/t/totalTime/deltaTime/from/to/overlapMs/f(n)返回值都是毫秒;iteration/frameIndex是帧(整数);fps是帧/秒;progress/iterationProgress/chance的入参是 0..1、无量纲;z是无量纲叠放次序;width/height是像素。
相关
- 端口源码:
packages/contracts/src/scene/entities.ts(Scene/SceneEnv/SetupEnv/StartSpec/FrameFn/CanvasEnv/TransitionOptions)、packages/contracts/src/scene/nodes.ts(StepContext/StepOptions/LoopContext/LoopSpec/WaitContext/WaitOptions/OnceScope/Disposal)、packages/contracts/src/scene/layers.ts(LayerDeclaration/LayerEnv)、packages/contracts/src/scene/random.ts(RandomStream/DEFAULT_PROGRAM_SEED) - 相邻页:
docs/contracts/time.md(毫秒与帧号的关系)、docs/contracts/feedback.md(FrameState与RenderError)、docs/contracts/scheduler.md(spawn/kill/forever)、docs/contracts/audio.md(env.audio/bgm/ 总线)、docs/contracts/index.md