@tongue-core/engine 0.1.0-rc.1 → 0.1.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Backpine
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -17,6 +17,23 @@ renderer.render(120, { scale: 0.5 });
17
17
  const rgba = renderer.rgba8(); // or yuv420(), png()
18
18
  ```
19
19
 
20
+ **In a Cloudflare Worker**, which may not compile WebAssembly from bytes, import the raster as a
21
+ module (`@tongue-core/engine/raster.wasm`, the same bytes as the embedded copy) and pass it in:
22
+
23
+ ```ts
24
+ import { FilmRenderer, Raster } from "@tongue-core/engine";
25
+ import raster from "@tongue-core/engine/raster.wasm"; // a WebAssembly.Module in a Worker
26
+
27
+ const renderer = await FilmRenderer.create(plan, assets, await Raster.create(raster));
28
+ ```
29
+
30
+ With little memory (a Worker), hold only what each frame draws: `displayList` throws `ImageNotLoaded`
31
+ naming the image a frame needs, and `renderer.keepOnly(list)` frees the rest from the raster before
32
+ `renderer.draw(list)`. `apps/frames-worker/src/runner.ts` does this.
33
+
34
+ TypeScript needs `declare module "*.wasm" { const module: WebAssembly.Module; export default module; }`
35
+ somewhere in the Worker's sources.
36
+
20
37
  - **Frames are independent.** Nothing carries over between frames except caches, so frames
21
38
  render in any order, on any number of machines.
22
39
  - **Video frames come from the host** (`FrameOptions.video`, a `VideoFrameSource`): ffmpeg on a
@@ -76,6 +76,11 @@ export declare class AssetShelf implements LoadedAssets {
76
76
  /** Whether the file at `path` is on the shelf. */
77
77
  has(path: string): boolean;
78
78
  }
79
+ /** A frame needs an image that isn't here: a host loading on demand fetches `asset` and draws again. */
80
+ export declare class ImageNotLoaded extends AssetError {
81
+ readonly asset: ImageAsset;
82
+ constructor(asset: ImageAsset);
83
+ }
79
84
  export declare function imageOf(assets: LoadedAssets, asset: ImageAsset): LoadedImage;
80
85
  export declare function videoOf(assets: LoadedAssets, asset: VideoAsset): LoadedVideo;
81
86
  export declare function audioOf(assets: LoadedAssets, asset: AudioAsset): PcmAudio | null;
@@ -119,10 +119,18 @@ export class AssetShelf {
119
119
  return this.held.has(path);
120
120
  }
121
121
  }
122
+ /** A frame needs an image that isn't here: a host loading on demand fetches `asset` and draws again. */
123
+ export class ImageNotLoaded extends AssetError {
124
+ asset;
125
+ constructor(asset) {
126
+ super(`Image "${asset.name}" is not loaded (was it declared with image() and imported?).`);
127
+ this.asset = asset;
128
+ }
129
+ }
122
130
  export function imageOf(assets, asset) {
123
131
  const image = assets.images.get(asset.name);
124
132
  if (!image)
125
- throw new AssetError(`Image "${asset.name}" is not loaded (was it declared with image() and imported?).`);
133
+ throw new ImageNotLoaded(asset);
126
134
  return image;
127
135
  }
128
136
  export function videoOf(assets, asset) {
@@ -1,4 +1,4 @@
1
- import { composePose, easeOf, enterPose, enterSpec, exitLength, exitPose, exitSpec, flatten, frameOf, isAt, isImageAsset, isVideoAsset, keysOf, rest, timeAt, withTime, } from "@tongue-core/film/model";
1
+ import { composePose, easeOf, enterPose, enterSpec, exitLength, exitPose, exitSpec, flatten, frameOf, isAt, isImageAsset, isVideoAsset, keysOf, node, rest, timeAt, withTime, } from "@tongue-core/film/model";
2
2
  import { schedule, typedAt } from "./typing.js";
3
3
  import { parseColor, StyleError } from "../style/parse.js";
4
4
  import { computeStyle, defaults, overrideOf, recordOf, rootText, textStyleOf } from "../style/resolve.js";
@@ -25,6 +25,7 @@ export function evaluate(content, frame, env) {
25
25
  class Evaluator {
26
26
  frame;
27
27
  env;
28
+ parent;
28
29
  camera = null;
29
30
  pointer = null;
30
31
  sounds = [];
@@ -33,14 +34,23 @@ class Evaluator {
33
34
  keyStack = [];
34
35
  /** Where each narration's word matching has got to (Says match words in order). */
35
36
  sayCursors = new Map();
36
- constructor(frame, env) {
37
+ /** Set for one of MotionBlur's samples: it is a moment between frames, not the frame. */
38
+ sampling;
39
+ /** `parent`: the evaluator a MotionBlur sample passes its sounds to; `null` drops them. */
40
+ constructor(frame, env, parent = undefined) {
37
41
  this.frame = frame;
38
42
  this.env = env;
43
+ this.parent = parent;
44
+ this.sampling = parent !== undefined;
39
45
  }
40
46
  controls() {
41
47
  return { camera: this.camera, pointer: this.pointer, sounds: this.sounds, switches: this.switches };
42
48
  }
43
49
  sound(kind, at) {
50
+ if (this.sampling) {
51
+ this.parent?.sound(kind, at);
52
+ return;
53
+ }
44
54
  if (at >= this.frame && at < this.frame + this.env.soundWindow)
45
55
  this.sounds.push({ kind, at });
46
56
  }
@@ -78,6 +88,8 @@ class Evaluator {
78
88
  case "element": return this.element(c.tag, c.props, c.children, ctx);
79
89
  case "show": return this.show(c, ctx);
80
90
  case "camera": {
91
+ if (this.sampling)
92
+ throw new EvalError("<Camera> can't go inside <MotionBlur>: put the MotionBlur inside the camera's world.");
81
93
  this.camera = { node: c, shots: nodesOf(c.children).flatMap((s) => (s.kind === "shot" ? [s] : [])) };
82
94
  return [];
83
95
  }
@@ -91,6 +103,8 @@ class Evaluator {
91
103
  return [{ kind: "box", box: { ...this.stageBox(c.style, kids, ctx.text), pose: ctx.pose } }];
92
104
  }
93
105
  case "center": return this.center(c.style, c.children, ctx);
106
+ case "mask": return this.mask(c, ctx);
107
+ case "motion-blur": return this.motionBlur(c, ctx);
94
108
  case "shot":
95
109
  case "move":
96
110
  case "click": return [];
@@ -104,7 +118,8 @@ class Evaluator {
104
118
  element(tag, props, children, ctx) {
105
119
  const styleRecord = recordOf(props["style"], `<${tag}> style`);
106
120
  const id = typeof props["id"] === "string" ? props["id"] : null;
107
- const morph = typeof props["morph"] === "string" ? props["morph"] : null;
121
+ // Every sample would hold the same key, and a key means one element. Inside a blur there is none.
122
+ const morph = !this.sampling && typeof props["morph"] === "string" ? props["morph"] : null;
108
123
  this.noteKey(morph);
109
124
  const style = computeStyle(styleRecord, ctx.text, tag);
110
125
  if (style.display === "none")
@@ -129,7 +144,7 @@ class Evaluator {
129
144
  }
130
145
  const hovered = overrideOf(props["hovered"], `<${tag}> hovered`);
131
146
  const pressed = overrideOf(props["pressed"], `<${tag}> pressed`);
132
- const shell = { tag, id, morph, style, hovered, pressed, pose: ctx.pose, screen: false, grow: ctx.grow };
147
+ const shell = { tag, id, morph, style, hovered, pressed, pose: ctx.pose, screen: false, grow: ctx.grow, corners: cornersOf(props["corners"], style, tag) };
133
148
  if (tag === "img") {
134
149
  const src = props["src"];
135
150
  if (!isImageAsset(src))
@@ -145,10 +160,7 @@ class Evaluator {
145
160
  const paths = flatten(children).flatMap((k) => {
146
161
  if (typeof k === "string" || k.kind !== "element" || k.tag !== "path")
147
162
  return [];
148
- const d = k.props["d"], fill = k.props["fill"], rule = k.props["fillRule"];
149
- if (typeof d !== "string")
150
- throw new StyleError("<path> needs d.");
151
- return [{ verbs: parsePath(d), fill: typeof fill === "string" ? parseColor(fill) : style.text.color, rule: rule === "evenodd" ? "evenodd" : "nonzero" }];
163
+ return [pathOf(k.props, style.text)];
152
164
  });
153
165
  return [{ kind: "box", box: { ...shell, content: { kind: "svg", view: parseViewBox(view), paths } } }];
154
166
  }
@@ -158,6 +170,8 @@ class Evaluator {
158
170
  /** The frame a <video> shows now: playback starts at `start` (scene frames) and runs through
159
171
  * the file from `in` to `out` at `speed`, optionally reversed, looped or frozen. */
160
172
  videoPicture(props) {
173
+ if (this.sampling)
174
+ throw new EvalError("<video> can't go inside <MotionBlur>: footage has its own blur. Put it beside the MotionBlur.");
161
175
  const src = props["src"];
162
176
  if (!isVideoAsset(src))
163
177
  throw new EvalError("<video> needs src={a video asset}.");
@@ -253,6 +267,8 @@ class Evaluator {
253
267
  return out;
254
268
  }
255
269
  pointerNode(p) {
270
+ if (this.sampling)
271
+ throw new EvalError("<Pointer> can't go inside <MotionBlur>: it belongs to the scene.");
256
272
  const steps = nodesOf(p.steps);
257
273
  const moves = steps.flatMap((s) => (s.kind === "move" ? [s] : []));
258
274
  const clicks = steps.flatMap((s) => (s.kind === "click" ? [s] : []));
@@ -340,10 +356,52 @@ class Evaluator {
340
356
  const from = f.from === null ? -Infinity : frameOf(f.from);
341
357
  if (this.frame < from)
342
358
  return [];
343
- this.noteKey(f.morph);
359
+ const morph = this.sampling ? null : f.morph;
360
+ this.noteKey(morph);
344
361
  const pose = composePose(ctx.pose, enterPose(enterSpec(f.enter), this.frame - from, this.env.fps));
345
362
  const style = computeStyle({ position: "absolute", inset: 0, background: f.color }, ctx.text, "div");
346
- return [{ kind: "box", box: { tag: "fill", id: null, morph: f.morph, hovered: null, pressed: null, pose, screen: true, grow: 1, style, content: { kind: "boxes", children: [] } } }];
363
+ return [{ kind: "box", box: { tag: "fill", id: null, morph, hovered: null, pressed: null, pose, screen: true, grow: 1, style, content: { kind: "boxes", children: [] } } }];
364
+ }
365
+ mask(m, ctx) {
366
+ const style = computeStyle({ position: "absolute", inset: 0, ...m.style }, ctx.text, "div");
367
+ const inner = { text: style.text, pose: rest, grow: 1 };
368
+ const part = (content, tag) => ({
369
+ tag, id: null, morph: null, hovered: null, pressed: null, pose: rest, screen: false, grow: 1,
370
+ style: computeStyle({ position: "absolute", inset: 0, display: "stack" }, style.text, "div"),
371
+ content: { kind: "boxes", children: this.asBoxes(this.children(content, inner), style.text) },
372
+ });
373
+ const box = {
374
+ tag: "mask", id: null, morph: null, hovered: null, pressed: null, pose: ctx.pose, screen: false, grow: ctx.grow, style,
375
+ content: { kind: "boxes", children: [part(m.children, "mask-content"), part([m.by], "mask-shape")] },
376
+ group: { kind: "mask", invert: m.invert },
377
+ };
378
+ return [{ kind: "box", box }];
379
+ }
380
+ /** The same content at several moments around this frame, centered on it. Each sample has its own
381
+ * evaluator at its own time. Only the middle one's sounds count: the rest are the same sounds. */
382
+ motionBlur(n, ctx) {
383
+ if (!(n.shutter >= 0 && n.shutter <= 720))
384
+ throw new EvalError(`<MotionBlur> shutter: expected 0 to 720 degrees, got ${n.shutter}.`);
385
+ if (!Number.isInteger(n.samples) || n.samples < 2 || n.samples > 64)
386
+ throw new EvalError(`<MotionBlur> samples: expected a whole number from 2 to 64, got ${n.samples}.`);
387
+ const style = computeStyle({ position: "absolute", inset: 0, ...n.style }, ctx.text, "div");
388
+ const inner = { text: style.text, pose: rest, grow: 1 };
389
+ const open = n.shutter / 360;
390
+ const children = Array.from({ length: n.samples }, (_, i) => {
391
+ const at = this.frame + (i / (n.samples - 1) - 0.5) * open;
392
+ const ev = new Evaluator(at, this.env, i === n.samples >> 1 ? this : null);
393
+ const items = withTime(timeAt(at, this.env.fps), () => ev.children([node({ kind: "component", name: "MotionBlur", render: n.render })], inner));
394
+ return {
395
+ tag: "motion-sample", id: null, morph: null, hovered: null, pressed: null, pose: rest, screen: false, grow: 1,
396
+ style: computeStyle({ position: "absolute", inset: 0, display: "stack" }, style.text, "div"),
397
+ content: { kind: "boxes", children: ev.asBoxes(items, style.text) },
398
+ };
399
+ });
400
+ const box = {
401
+ tag: "motion-blur", id: null, morph: null, hovered: null, pressed: null, pose: ctx.pose, screen: false, grow: ctx.grow, style,
402
+ content: { kind: "boxes", children }, group: { kind: "samples" },
403
+ };
404
+ return [{ kind: "box", box }];
347
405
  }
348
406
  center(styleRecord, children, ctx) {
349
407
  const style = computeStyle(styleRecord, ctx.text, "div");
@@ -361,6 +419,61 @@ class Evaluator {
361
419
  }
362
420
  }
363
421
  const rootTextFallback = rootText;
422
+ const point = (v) => Array.isArray(v) && v.length === 2 && typeof v[0] === "number" && Number.isFinite(v[0]) && typeof v[1] === "number" && Number.isFinite(v[1]) ? [v[0], v[1]] : null;
423
+ function cornersOf(v, style, tag) {
424
+ if (v === undefined)
425
+ return undefined;
426
+ const list = Array.isArray(v) ? v : [];
427
+ const [a, b, c, d] = list.map(point);
428
+ if (list.length !== 4 || !a || !b || !c || !d)
429
+ throw new StyleError(`<${tag}> corners: expected four [x, y] points, top left, top right, bottom right, bottom left; got ${JSON.stringify(v)}.`);
430
+ if (style.transform.turn !== 0 || style.transform.tilt !== 0)
431
+ throw new StyleError(`<${tag}> has corners and turn or tilt: corners already say where it lands, so remove turn and tilt.`);
432
+ return [a, b, c, d];
433
+ }
434
+ const CAPS = ["butt", "round", "square"];
435
+ const JOINS = ["miter", "round", "bevel"];
436
+ function oneOf(v, options, fallback, what) {
437
+ if (v === undefined)
438
+ return fallback;
439
+ const found = options.find((o) => o === v);
440
+ if (found === undefined)
441
+ throw new StyleError(`<path> ${what}: expected one of ${options.join(", ")}, got ${JSON.stringify(v)}.`);
442
+ return found;
443
+ }
444
+ function trimOf(v) {
445
+ if (v === undefined)
446
+ return [0, 1];
447
+ if (typeof v === "number" && Number.isFinite(v))
448
+ return [0, v];
449
+ if (Array.isArray(v) && v.length === 2 && typeof v[0] === "number" && Number.isFinite(v[0]) && typeof v[1] === "number" && Number.isFinite(v[1]))
450
+ return [v[0], v[1]];
451
+ throw new StyleError(`<path> trim: expected a fraction or [from, to], got ${JSON.stringify(v)}.`);
452
+ }
453
+ /** One `<path>`, its props read once. Paths aren't styled elements, so this is their boundary. */
454
+ function pathOf(props, text) {
455
+ const d = props["d"], fill = props["fill"], rule = props["fillRule"], stroke = props["stroke"], width = props["strokeWidth"];
456
+ if (typeof d !== "string")
457
+ throw new StyleError("<path> needs d.");
458
+ if (stroke !== undefined && typeof stroke !== "string")
459
+ throw new StyleError("<path> stroke: expected a color.");
460
+ if (width !== undefined && (typeof width !== "number" || !Number.isFinite(width) || width < 0))
461
+ throw new StyleError(`<path> strokeWidth: expected a number of at least 0, got ${JSON.stringify(width)}.`);
462
+ if (props["trim"] !== undefined && stroke === undefined)
463
+ throw new StyleError("<path> trim needs a stroke: it says how much of the outline to draw.");
464
+ return {
465
+ verbs: parsePath(d),
466
+ fill: fill === "none" ? null : typeof fill === "string" ? parseColor(fill) : stroke === undefined ? text.color : null,
467
+ rule: rule === "evenodd" ? "evenodd" : "nonzero",
468
+ stroke: stroke === undefined ? null : {
469
+ color: parseColor(stroke),
470
+ width: width ?? 1,
471
+ cap: oneOf(props["strokeCap"], CAPS, "butt", "strokeCap"),
472
+ join: oneOf(props["strokeJoin"], JOINS, "miter", "strokeJoin"),
473
+ trim: trimOf(props["trim"]),
474
+ },
475
+ };
476
+ }
364
477
  function textOf(n) {
365
478
  switch (n.kind) {
366
479
  case "element":
@@ -1,6 +1,7 @@
1
1
  import type { FullPose, ImageAsset, VideoAsset } from "@tongue-core/film/model";
2
2
  import type { PathVerb } from "../text/font-types.js";
3
3
  import type { Color, Computed, Override, TextStyle } from "../style/computed.js";
4
+ import type { StrokeCap, StrokeJoin } from "../paint/stroke.js";
4
5
  export interface Owner {
5
6
  readonly id: string | null;
6
7
  readonly morph: string | null;
@@ -21,10 +22,19 @@ export interface SayState {
21
22
  /** Pose of each word, in order; words past the end of the list are at rest. */
22
23
  readonly words: ReadonlyArray<FullPose>;
23
24
  }
25
+ export interface SvgStroke {
26
+ readonly color: Color;
27
+ readonly width: number;
28
+ readonly cap: StrokeCap;
29
+ readonly join: StrokeJoin;
30
+ /** The stretch of the outline to draw, as fractions of its length. */
31
+ readonly trim: readonly [number, number];
32
+ }
24
33
  export interface SvgPath {
25
34
  readonly verbs: ReadonlyArray<PathVerb>;
26
- readonly fill: Color;
35
+ readonly fill: Color | null;
27
36
  readonly rule: "nonzero" | "evenodd";
37
+ readonly stroke: SvgStroke | null;
28
38
  }
29
39
  /** What an image box shows: a photo, or one frame of a video. */
30
40
  export type Picture = {
@@ -68,4 +78,17 @@ export interface Box {
68
78
  /** Fraction of its laid-out height the box takes (Show `expand`); below 1 it clips. */
69
79
  readonly grow: number;
70
80
  readonly content: Content;
81
+ /** Drawn onto this four-sided shape, in its parent's coordinates. */
82
+ readonly corners?: Quad;
83
+ /** Paints its children as a group instead of one after another. */
84
+ readonly group?: BoxGroup;
71
85
  }
86
+ export type Quad = readonly [readonly [number, number], readonly [number, number], readonly [number, number], readonly [number, number]];
87
+ export type BoxGroup = {
88
+ readonly kind: "mask";
89
+ readonly invert: boolean;
90
+ }
91
+ /** Motion blur: its children are the same content at different moments, averaged. */
92
+ | {
93
+ readonly kind: "samples";
94
+ };
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  export { FilmRenderer, sizeAtScale, type FrameOptions, type FrameSize, type DrawnFrame, type ImageUpload } from "./render/renderer.js";
2
2
  export { collectSounds, audioClips } from "./render/sounds.js";
3
3
  export { mix, mixWindow, SAMPLE_RATE, SOUND_SECONDS, type MixInput, type WindowInput, type ClipAudio } from "./audio/mix.js";
4
- export { loadAssets, AssetShelf, imageOf, videoOf, audioOf, parseVideoInfo, parseProxyInfo, qoiSize, imageSize, AssetError, type LoadedAssets, type LoadedImage, type LoadedVideo, type VideoInfo, type ProxyInfo, type Fetcher, type LoadOptions } from "./assets/store.js";
4
+ export { loadAssets, AssetShelf, ImageNotLoaded, imageOf, videoOf, audioOf, parseVideoInfo, parseProxyInfo, qoiSize, imageSize, AssetError, type LoadedAssets, type LoadedImage, type LoadedVideo, type VideoInfo, type ProxyInfo, type Fetcher, type LoadOptions } from "./assets/store.js";
5
5
  export { importedPath, importedFiles, videoInfoPath, videoPosterPath, videoProxyDir, videoProxyPath, PROXY_SEGMENT_SECONDS, PROXY_KEYFRAME_SECONDS, type AssetKey } from "./assets/paths.js";
6
6
  export { demuxMp4, sampleData, Mp4Error, type Mp4Video, type Mp4Sample } from "./media/mp4.js";
7
7
  export { yuvToRgba, yuvSize, packedPlanes, encodeYuvImage, isYuvImage, yuvImageSize, yuvImageLayout, writeYuvHeader, YUV_HEADER, type YuvFrame, type YuvFormat, type YuvPlane } from "./media/yuv.js";
package/dist/index.js CHANGED
@@ -4,7 +4,7 @@
4
4
  export { FilmRenderer, sizeAtScale } from "./render/renderer.js";
5
5
  export { collectSounds, audioClips } from "./render/sounds.js";
6
6
  export { mix, mixWindow, SAMPLE_RATE, SOUND_SECONDS } from "./audio/mix.js";
7
- export { loadAssets, AssetShelf, imageOf, videoOf, audioOf, parseVideoInfo, parseProxyInfo, qoiSize, imageSize, AssetError } from "./assets/store.js";
7
+ export { loadAssets, AssetShelf, ImageNotLoaded, imageOf, videoOf, audioOf, parseVideoInfo, parseProxyInfo, qoiSize, imageSize, AssetError } from "./assets/store.js";
8
8
  export { importedPath, importedFiles, videoInfoPath, videoPosterPath, videoProxyDir, videoProxyPath, PROXY_SEGMENT_SECONDS, PROXY_KEYFRAME_SECONDS } from "./assets/paths.js";
9
9
  export { demuxMp4, sampleData, Mp4Error } from "./media/mp4.js";
10
10
  export { yuvToRgba, yuvSize, packedPlanes, encodeYuvImage, isYuvImage, yuvImageSize, yuvImageLayout, writeYuvHeader, YUV_HEADER } from "./media/yuv.js";