@tongue-core/engine 0.1.0-rc.2 → 0.1.0-rc.4
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/README.md +4 -0
- package/dist/assets/store.d.ts +5 -0
- package/dist/assets/store.js +9 -1
- package/dist/evaluate/evaluate.js +123 -10
- package/dist/evaluate/tree.d.ts +24 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/layout/layout.js +130 -82
- package/dist/paint/geometry.d.ts +12 -4
- package/dist/paint/geometry.js +25 -11
- package/dist/paint/list.d.ts +28 -1
- package/dist/paint/list.js +26 -2
- package/dist/paint/morph.js +11 -7
- package/dist/paint/paint.d.ts +18 -7
- package/dist/paint/paint.js +181 -16
- package/dist/paint/stroke.d.ts +26 -0
- package/dist/paint/stroke.js +233 -0
- package/dist/raster/raster.d.ts +3 -0
- package/dist/raster/raster.js +5 -0
- package/dist/raster/wasm-bytes.js +2 -2
- package/dist/render/compose.d.ts +1 -1
- package/dist/render/compose.js +33 -11
- package/dist/render/renderer.d.ts +4 -0
- package/dist/render/renderer.js +21 -2
- package/dist/style/computed.d.ts +14 -1
- package/dist/style/parse.js +2 -4
- package/dist/style/resolve.js +53 -4
- package/dist/text/fonts.d.ts +4 -1
- package/dist/text/fonts.js +9 -1
- package/dist/text/ttf.js +5 -1
- package/package.json +2 -2
- package/raster.wasm +0 -0
package/README.md
CHANGED
|
@@ -27,6 +27,10 @@ import raster from "@tongue-core/engine/raster.wasm"; // a WebAssem
|
|
|
27
27
|
const renderer = await FilmRenderer.create(plan, assets, await Raster.create(raster));
|
|
28
28
|
```
|
|
29
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
|
+
|
|
30
34
|
TypeScript needs `declare module "*.wasm" { const module: WebAssembly.Module; export default module; }`
|
|
31
35
|
somewhere in the Worker's sources.
|
|
32
36
|
|
package/dist/assets/store.d.ts
CHANGED
|
@@ -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;
|
package/dist/assets/store.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
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":
|
package/dist/evaluate/tree.d.ts
CHANGED
|
@@ -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";
|
package/dist/layout/layout.js
CHANGED
|
@@ -8,6 +8,8 @@ const hExtra = (b) => b.style.padding[1] + b.style.padding[3] + b.style.border[1
|
|
|
8
8
|
const vExtra = (b) => b.style.padding[0] + b.style.padding[2] + b.style.border[0].width + b.style.border[2].width;
|
|
9
9
|
const clampTo = (v, min, max) => Math.max(min ?? -Infinity, Math.min(max ?? Infinity, v));
|
|
10
10
|
// A grid with no column template is one flexible column.
|
|
11
|
+
/** Slack for fitting a line: widths from percentages add up to a hair over the room they were cut from. */
|
|
12
|
+
const FITS_WITHIN = 1e-6;
|
|
11
13
|
const oneColumn = [{ kind: "fr", v: 1 }];
|
|
12
14
|
const inFlow = (b) => b.style.display !== "none" && b.style.position !== "absolute";
|
|
13
15
|
export function layout(root, width, height, env) {
|
|
@@ -31,11 +33,14 @@ class Engine {
|
|
|
31
33
|
text: n.paragraph ? { paragraph: n.paragraph, x: x + n.textX, y: y + n.textY } : null,
|
|
32
34
|
};
|
|
33
35
|
}
|
|
34
|
-
fontFor(box,
|
|
35
|
-
const
|
|
36
|
-
if (!
|
|
36
|
+
fontFor(box, style) {
|
|
37
|
+
const family = style.font;
|
|
38
|
+
if (!family)
|
|
37
39
|
throw new LayoutError(`<${box.tag}> has text but no fontFamily (set one on the Stage or an ancestor).`);
|
|
38
|
-
|
|
40
|
+
const file = style.italic ? family.italic : family;
|
|
41
|
+
if (!file)
|
|
42
|
+
throw new LayoutError(`<${box.tag}> is italic, but font "${family.name}" has no italic file: declare it with font("${family.source}", { italic: "…" }).`);
|
|
43
|
+
return this.env.fonts.resolve(file, style.fontWeight, style.fontSize, style.axes);
|
|
39
44
|
}
|
|
40
45
|
runs(box) {
|
|
41
46
|
if (box.content.kind !== "text")
|
|
@@ -43,7 +48,7 @@ class Engine {
|
|
|
43
48
|
return box.content.runs.map((r) => ({
|
|
44
49
|
text: r.text,
|
|
45
50
|
style: r.style,
|
|
46
|
-
font: this.fontFor(box, r.style
|
|
51
|
+
font: this.fontFor(box, r.style),
|
|
47
52
|
owner: r.owner,
|
|
48
53
|
visible: r.visible,
|
|
49
54
|
}));
|
|
@@ -55,7 +60,7 @@ class Engine {
|
|
|
55
60
|
align: t.textAlign,
|
|
56
61
|
whiteSpace: t.whiteSpace,
|
|
57
62
|
ellipsis: box.style.textOverflow === "ellipsis" && box.style.overflowHidden,
|
|
58
|
-
strut: box.style.text.font ? { style: t, font: this.fontFor(box, t
|
|
63
|
+
strut: box.style.text.font ? { style: t, font: this.fontFor(box, t) } : null,
|
|
59
64
|
});
|
|
60
65
|
}
|
|
61
66
|
natural(box) {
|
|
@@ -121,7 +126,8 @@ class Engine {
|
|
|
121
126
|
else {
|
|
122
127
|
const kids = box.content.children.filter(inFlow);
|
|
123
128
|
const outer = (k) => this.minContent(k) + marginOf(k.style.margin[1], 0) + marginOf(k.style.margin[3], 0);
|
|
124
|
-
|
|
129
|
+
// A row that wraps can narrow until each item has a line of its own.
|
|
130
|
+
if (s.display === "flex" && s.direction === "row" && !s.wrap)
|
|
125
131
|
w = kids.reduce((sum, k) => sum + outer(k), 0) + s.columnGap * Math.max(0, kids.length - 1);
|
|
126
132
|
else if (s.display === "grid")
|
|
127
133
|
w = this.gridIntrinsic(box, kids, (k) => this.minContent(k));
|
|
@@ -353,8 +359,11 @@ class Engine {
|
|
|
353
359
|
const s = box.style;
|
|
354
360
|
const row = s.direction === "row";
|
|
355
361
|
const gap = row ? s.columnGap : s.rowGap;
|
|
362
|
+
const crossGap = row ? s.rowGap : s.columnGap;
|
|
356
363
|
const mainAvail = row ? innerW : innerH;
|
|
357
364
|
const crossAvail = row ? innerH : innerW;
|
|
365
|
+
// A column with no height has nothing to wrap against: everything fits.
|
|
366
|
+
const wraps = s.wrap && mainAvail !== null;
|
|
358
367
|
const alignOf = (k) => (k.style.alignSelf === "auto" ? s.align : k.style.alignSelf);
|
|
359
368
|
const items = kids.map((k) => {
|
|
360
369
|
const ks = k.style;
|
|
@@ -367,7 +376,8 @@ class Engine {
|
|
|
367
376
|
const crossFor = () => {
|
|
368
377
|
if (row)
|
|
369
378
|
return 0;
|
|
370
|
-
|
|
379
|
+
// Wrapped columns are as wide as their widest item, so an item can't fill one yet.
|
|
380
|
+
const stretch = !wraps && alignOf(k) === "stretch" && ks.width.kind === "auto";
|
|
371
381
|
return stretch ? Math.max(0, innerW - crossMargin) : this.widthIn(k, innerW, "fit");
|
|
372
382
|
};
|
|
373
383
|
let basis = px(ks.basis, mainAvail) ?? specifiedMain;
|
|
@@ -383,83 +393,121 @@ class Engine {
|
|
|
383
393
|
const autoMin = minMain ?? (ks.overflowHidden ? 0 : row ? this.minContent(k) : 0);
|
|
384
394
|
return { box: k, basis, hyp: clampTo(basis, autoMin, maxMain), minMain: autoMin, maxMain, mainMargin, crossMargin, m, crossFor };
|
|
385
395
|
});
|
|
386
|
-
|
|
387
|
-
const
|
|
388
|
-
|
|
389
|
-
const
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
const
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
396
|
+
// Each line sizes its own items along the main axis, then lays them out to learn its cross size.
|
|
397
|
+
const lines = (s.wrap && mainAvail !== null ? breakLines(items, mainAvail, gap) : [items]).map((line) => {
|
|
398
|
+
const totalGap = gap * Math.max(0, line.length - 1);
|
|
399
|
+
const sizes = line.map((i) => i.hyp);
|
|
400
|
+
if (mainAvail !== null) {
|
|
401
|
+
const free = mainAvail - totalGap - line.reduce((sum, i, n) => sum + (sizes[n] ?? 0) + i.mainMargin, 0);
|
|
402
|
+
const growTotal = line.reduce((sum, i) => sum + i.box.style.grow, 0);
|
|
403
|
+
const shrinkTotal = line.reduce((sum, i) => sum + i.box.style.shrink * i.basis, 0);
|
|
404
|
+
if (free > 0 && growTotal > 0)
|
|
405
|
+
line.forEach((i, n) => { sizes[n] = clampTo((sizes[n] ?? 0) + (free * i.box.style.grow) / growTotal, i.minMain, i.maxMain); });
|
|
406
|
+
else if (free < 0 && shrinkTotal > 0)
|
|
407
|
+
line.forEach((i, n) => { sizes[n] = clampTo((sizes[n] ?? 0) + (free * i.box.style.shrink * i.basis) / shrinkTotal, i.minMain, i.maxMain); });
|
|
408
|
+
}
|
|
409
|
+
const first = line.map((i, n) => {
|
|
410
|
+
const main = sizes[n] ?? 0;
|
|
411
|
+
if (row)
|
|
412
|
+
return this.layout(i.box, { width: innerW, height: innerH }, { w: main });
|
|
413
|
+
return this.layout(i.box, { width: innerW, height: innerH }, { w: i.crossFor(), h: main });
|
|
414
|
+
});
|
|
415
|
+
const natural = first.reduce((mx, node, n) => Math.max(mx, (row ? node.h : node.w) + (line[n]?.crossMargin ?? 0)), 0);
|
|
416
|
+
return { items: line, first, totalGap, natural };
|
|
403
417
|
});
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
418
|
+
// A line that can't wrap fills the box. Wrapped lines take their tallest item, then share what
|
|
419
|
+
// is left of a definite size equally (CSS's align-content: stretch, the default).
|
|
420
|
+
const naturalTotal = lines.reduce((sum, l) => sum + l.natural, 0) + crossGap * Math.max(0, lines.length - 1);
|
|
421
|
+
const share = wraps && crossAvail !== null && lines.length > 0 ? Math.max(0, crossAvail - naturalTotal) / lines.length : 0;
|
|
422
|
+
const crossSizes = lines.map((l) => (wraps ? l.natural + share : crossAvail ?? l.natural));
|
|
423
|
+
const placed = [];
|
|
424
|
+
let lineStart = 0, mainEnd = 0;
|
|
425
|
+
lines.forEach((line, lineIndex) => {
|
|
426
|
+
const crossSize = crossSizes[lineIndex] ?? 0;
|
|
427
|
+
const nodes = line.items.map((i, n) => {
|
|
428
|
+
const node = line.first[n];
|
|
429
|
+
if (!node)
|
|
430
|
+
throw new LayoutError("flex item missing");
|
|
431
|
+
const stretch = alignOf(i.box) === "stretch" && (row ? i.box.style.height.kind === "auto" : i.box.style.width.kind === "auto");
|
|
432
|
+
if (!stretch)
|
|
433
|
+
return node;
|
|
434
|
+
const target = Math.max(0, crossSize - i.crossMargin);
|
|
435
|
+
if (row && node.h !== target)
|
|
436
|
+
return this.layout(i.box, { width: innerW, height: target }, { w: node.w, h: target });
|
|
437
|
+
if (!row && node.w !== target)
|
|
438
|
+
return this.layout(i.box, { width: innerW, height: innerH }, { w: target, h: node.h });
|
|
411
439
|
return node;
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
else if (s.justify === "space-evenly" && count > 0) {
|
|
434
|
-
pos = space / (count + 1);
|
|
435
|
-
between = gap + space / (count + 1);
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
const perAuto = autoMargins > 0 && space > 0 ? space / autoMargins : 0;
|
|
439
|
-
nodes.forEach((node, n) => {
|
|
440
|
-
const i = items[n];
|
|
441
|
-
if (!i)
|
|
442
|
-
return;
|
|
443
|
-
const ms = i.box.style.margin;
|
|
444
|
-
const [before, after] = row ? [ms[3], ms[1]] : [ms[0], ms[2]];
|
|
445
|
-
const lead = (row ? (i.m[3] ?? 0) : (i.m[0] ?? 0)) + (before.kind === "auto" ? perAuto : 0);
|
|
446
|
-
const trail = (row ? (i.m[1] ?? 0) : (i.m[2] ?? 0)) + (after.kind === "auto" ? perAuto : 0);
|
|
447
|
-
const main = row ? node.w : node.h;
|
|
448
|
-
const cross = row ? node.h : node.w;
|
|
449
|
-
const crossLead = row ? (i.m[0] ?? 0) : (i.m[3] ?? 0);
|
|
450
|
-
const a = alignOf(i.box);
|
|
451
|
-
const crossOffset = a === "center" ? (crossSize - cross - i.crossMargin) / 2 : a === "end" ? crossSize - cross - i.crossMargin : 0;
|
|
452
|
-
if (row) {
|
|
453
|
-
node.x = left + pos + lead;
|
|
454
|
-
node.y = top + crossLead + crossOffset;
|
|
455
|
-
}
|
|
456
|
-
else {
|
|
457
|
-
node.y = top + pos + lead;
|
|
458
|
-
node.x = left + crossLead + crossOffset;
|
|
440
|
+
});
|
|
441
|
+
const used = nodes.reduce((sum, node, n) => sum + (row ? node.w : node.h) + (line.items[n]?.mainMargin ?? 0), 0) + line.totalGap;
|
|
442
|
+
const space = mainAvail === null ? 0 : mainAvail - used;
|
|
443
|
+
const autoMargins = line.items.reduce((c, i) => c + (row ? [i.box.style.margin[1], i.box.style.margin[3]] : [i.box.style.margin[0], i.box.style.margin[2]]).filter((d) => d.kind === "auto").length, 0);
|
|
444
|
+
let pos = 0, between = gap;
|
|
445
|
+
if (autoMargins === 0 && space > 0) {
|
|
446
|
+
const count = line.items.length;
|
|
447
|
+
if (s.justify === "center")
|
|
448
|
+
pos = space / 2;
|
|
449
|
+
else if (s.justify === "end")
|
|
450
|
+
pos = space;
|
|
451
|
+
else if (s.justify === "space-between" && count > 1)
|
|
452
|
+
between = gap + space / (count - 1);
|
|
453
|
+
else if (s.justify === "space-around" && count > 0) {
|
|
454
|
+
pos = space / count / 2;
|
|
455
|
+
between = gap + space / count;
|
|
456
|
+
}
|
|
457
|
+
else if (s.justify === "space-evenly" && count > 0) {
|
|
458
|
+
pos = space / (count + 1);
|
|
459
|
+
between = gap + space / (count + 1);
|
|
460
|
+
}
|
|
459
461
|
}
|
|
460
|
-
|
|
462
|
+
const perAuto = autoMargins > 0 && space > 0 ? space / autoMargins : 0;
|
|
463
|
+
nodes.forEach((node, n) => {
|
|
464
|
+
const i = line.items[n];
|
|
465
|
+
if (!i)
|
|
466
|
+
return;
|
|
467
|
+
const ms = i.box.style.margin;
|
|
468
|
+
const [before, after] = row ? [ms[3], ms[1]] : [ms[0], ms[2]];
|
|
469
|
+
const lead = (row ? (i.m[3] ?? 0) : (i.m[0] ?? 0)) + (before.kind === "auto" ? perAuto : 0);
|
|
470
|
+
const trail = (row ? (i.m[1] ?? 0) : (i.m[2] ?? 0)) + (after.kind === "auto" ? perAuto : 0);
|
|
471
|
+
const main = row ? node.w : node.h;
|
|
472
|
+
const cross = row ? node.h : node.w;
|
|
473
|
+
const crossLead = lineStart + (row ? (i.m[0] ?? 0) : (i.m[3] ?? 0));
|
|
474
|
+
const a = alignOf(i.box);
|
|
475
|
+
const crossOffset = a === "center" ? (crossSize - cross - i.crossMargin) / 2 : a === "end" ? crossSize - cross - i.crossMargin : 0;
|
|
476
|
+
if (row) {
|
|
477
|
+
node.x = left + pos + lead;
|
|
478
|
+
node.y = top + crossLead + crossOffset;
|
|
479
|
+
}
|
|
480
|
+
else {
|
|
481
|
+
node.y = top + pos + lead;
|
|
482
|
+
node.x = left + crossLead + crossOffset;
|
|
483
|
+
}
|
|
484
|
+
pos += lead + main + trail + (n < nodes.length - 1 ? between : 0);
|
|
485
|
+
});
|
|
486
|
+
mainEnd = Math.max(mainEnd, pos);
|
|
487
|
+
lineStart += crossSize + crossGap;
|
|
488
|
+
placed.push(...nodes);
|
|
461
489
|
});
|
|
462
|
-
const
|
|
463
|
-
|
|
490
|
+
const crossTotal = crossSizes.reduce((sum, size) => sum + size, 0) + crossGap * Math.max(0, lines.length - 1);
|
|
491
|
+
const height = row ? innerH ?? crossTotal : innerH ?? mainEnd;
|
|
492
|
+
return { nodes: placed, height };
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
/** CSS's line breaking: an item starts a new line when it wouldn't fit on this one, and every line
|
|
496
|
+
* holds at least one item, however wide. */
|
|
497
|
+
function breakLines(items, room, gap) {
|
|
498
|
+
const lines = [];
|
|
499
|
+
let used = 0;
|
|
500
|
+
for (const item of items) {
|
|
501
|
+
const outer = item.hyp + item.mainMargin;
|
|
502
|
+
const line = lines.at(-1);
|
|
503
|
+
if (line && used + gap + outer <= room + FITS_WITHIN) {
|
|
504
|
+
line.push(item);
|
|
505
|
+
used += gap + outer;
|
|
506
|
+
}
|
|
507
|
+
else {
|
|
508
|
+
lines.push([item]);
|
|
509
|
+
used = outer;
|
|
510
|
+
}
|
|
464
511
|
}
|
|
512
|
+
return lines;
|
|
465
513
|
}
|
package/dist/paint/geometry.d.ts
CHANGED
|
@@ -9,7 +9,7 @@ export declare const apply: (m: Affine, x: number, y: number) => {
|
|
|
9
9
|
x: number;
|
|
10
10
|
y: number;
|
|
11
11
|
};
|
|
12
|
-
/** Scale, rotate about a point (CSS transform-origin
|
|
12
|
+
/** Scale, rotate about a point (CSS transform-origin). */
|
|
13
13
|
export declare function about(cx: number, cy: number, scale: number, rotate: number): Affine;
|
|
14
14
|
/** The transform that maps rectangle `from` onto rectangle `to`. */
|
|
15
15
|
export declare const fitRect: (from: Rect, to: Rect) => Affine;
|
|
@@ -21,6 +21,14 @@ export declare const inflate: (r: Rect, by: number) => Rect;
|
|
|
21
21
|
export declare const contains: (r: Rect, x: number, y: number) => boolean;
|
|
22
22
|
/** Uniform scale of an affine (for radii and blur under a camera). */
|
|
23
23
|
export declare const scaleOf: (m: Affine) => number;
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
export interface Point {
|
|
25
|
+
readonly x: number;
|
|
26
|
+
readonly y: number;
|
|
27
|
+
}
|
|
28
|
+
/** The rect → quad homography for a CSS rotateY(turn)/rotateX(tilt) with perspective `depth`, in
|
|
29
|
+
* the same coordinate space. It pivots on `pivot` and looks at `vanish`, both by default the
|
|
30
|
+
* rect's center. */
|
|
31
|
+
export declare function perspective(r: Rect, turn: number, tilt: number, depth: number, pivot?: Point, vanish?: Point): Homography;
|
|
32
|
+
/** The homography putting a rect on a four-sided shape; null when the shape has collapsed to a
|
|
33
|
+
* line or a point, which nothing can be drawn on. */
|
|
34
|
+
export declare function ontoQuad(r: Rect, quad: ReadonlyArray<Point>): Homography | null;
|