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

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.
Files changed (42) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/assets/codecs/types.d.ts +2 -0
  4. package/dist/assets/codecs/wav.d.ts +3 -0
  5. package/dist/assets/codecs/wav.js +26 -6
  6. package/dist/assets/store.d.ts +18 -0
  7. package/dist/assets/store.js +82 -6
  8. package/dist/audio/mix.d.ts +7 -1
  9. package/dist/audio/mix.js +14 -3
  10. package/dist/evaluate/evaluate.js +123 -10
  11. package/dist/evaluate/tree.d.ts +24 -1
  12. package/dist/index.d.ts +5 -4
  13. package/dist/index.js +5 -4
  14. package/dist/layout/layout.js +130 -82
  15. package/dist/paint/geometry.d.ts +12 -4
  16. package/dist/paint/geometry.js +25 -11
  17. package/dist/paint/list.d.ts +28 -1
  18. package/dist/paint/list.js +26 -2
  19. package/dist/paint/morph.js +11 -7
  20. package/dist/paint/paint.d.ts +18 -7
  21. package/dist/paint/paint.js +181 -16
  22. package/dist/paint/stroke.d.ts +26 -0
  23. package/dist/paint/stroke.js +233 -0
  24. package/dist/raster/raster.d.ts +3 -0
  25. package/dist/raster/raster.js +5 -0
  26. package/dist/raster/wasm-bytes.js +2 -2
  27. package/dist/render/compose.d.ts +1 -1
  28. package/dist/render/compose.js +33 -11
  29. package/dist/render/renderer.d.ts +7 -6
  30. package/dist/render/renderer.js +28 -7
  31. package/dist/render/size.d.ts +6 -0
  32. package/dist/render/size.js +7 -0
  33. package/dist/render/sounds.d.ts +4 -1
  34. package/dist/render/sounds.js +6 -2
  35. package/dist/style/computed.d.ts +14 -1
  36. package/dist/style/parse.js +2 -4
  37. package/dist/style/resolve.js +53 -4
  38. package/dist/text/fonts.d.ts +4 -1
  39. package/dist/text/fonts.js +9 -1
  40. package/dist/text/ttf.js +5 -1
  41. package/package.json +6 -4
  42. package/raster.wasm +0 -0
@@ -1,7 +1,7 @@
1
1
  import { type Child, type ClipPlan, type FilmPlan } from "@tongue-core/film/model";
2
2
  import { type SceneEnv, type SceneFrame } from "../evaluate/scene-frame.js";
3
3
  import type { Picture } from "../evaluate/tree.js";
4
- import type { Painter } from "../paint/paint.js";
4
+ import { type Painter } from "../paint/paint.js";
5
5
  /** A clip's picture at one of its frames. */
6
6
  type ClipFrame = {
7
7
  readonly kind: "scene";
@@ -1,7 +1,9 @@
1
1
  import { node, sourceSeconds, trackAt } from "@tongue-core/film/model";
2
2
  import { buildScene } from "../evaluate/scene-frame.js";
3
3
  import { identity, mul, translate } from "../paint/geometry.js";
4
+ import { parseColor } from "../style/parse.js";
4
5
  import { paintTransition } from "../paint/morph.js";
6
+ import { premul } from "../paint/paint.js";
5
7
  /** The markup a scene or inline clip draws (scenes render inside the frame's time, so lazily). */
6
8
  export function contentOf(clip) {
7
9
  const s = clip.source;
@@ -25,12 +27,12 @@ export function clipFrame(clip, local, env) {
25
27
  throw new Error(`Clip "${clip.id}" plays audio; it has no picture.`);
26
28
  }
27
29
  /** Draws a clip's frame, faded by `alpha`, moved by `outer` (surface pixels). */
28
- function draw(p, f, env, alpha, outer = identity) {
30
+ function draw(p, f, env, alpha, blend, outer = identity) {
29
31
  if (alpha <= 0.001)
30
32
  return;
31
- const layered = alpha < 0.999;
33
+ const layered = alpha < 0.999 || blend !== "normal";
32
34
  if (layered)
33
- p.layer(p.surfaceRect(), { opacity: alpha, brightness: 1, saturate: 1, blur: 0 });
35
+ p.layer(p.surfaceRect(), { opacity: alpha, brightness: 1, saturate: 1, blur: 0, blend });
34
36
  if (f.kind === "scene")
35
37
  p.scene(f.frame, {}, outer);
36
38
  else
@@ -41,7 +43,7 @@ function draw(p, f, env, alpha, outer = identity) {
41
43
  function transition(p, t, from, to, fromLocal, toLocal, elapsed, progress, env) {
42
44
  const a = clipFrame(from, fromLocal, env);
43
45
  const b = clipFrame(to, toLocal, env);
44
- if (t.style === "morph" && a.kind === "scene" && b.kind === "scene" && from.opacity === 1 && to.opacity === 1) {
46
+ if (t.style === "morph" && a.kind === "scene" && b.kind === "scene" && from.opacity === 1 && to.opacity === 1 && from.blend === "normal" && to.blend === "normal") {
45
47
  paintTransition(p, a.frame, b.frame, { elapsed, frames: t.frames, progress, fps: env.fps, others: t.others });
46
48
  return;
47
49
  }
@@ -49,18 +51,18 @@ function transition(p, t, from, to, fromLocal, toLocal, elapsed, progress, env)
49
51
  switch (t.style) {
50
52
  case "morph": // nothing keyed on one side (or a faded clip): a crossfade
51
53
  case "fade":
52
- draw(p, a, env, from.opacity);
53
- draw(p, b, env, to.opacity * progress);
54
+ draw(p, a, env, from.opacity, from.blend);
55
+ draw(p, b, env, to.opacity * progress, to.blend);
54
56
  return;
55
57
  case "wipe": {
56
- draw(p, a, env, from.opacity);
58
+ draw(p, a, env, from.opacity, from.blend);
57
59
  const { w, h } = surface;
58
60
  const shown = t.direction === "left" ? { x: w * (1 - progress), y: 0, w: w * progress, h }
59
61
  : t.direction === "right" ? { x: 0, y: 0, w: w * progress, h }
60
62
  : t.direction === "up" ? { x: 0, y: h * (1 - progress), w, h: h * progress }
61
63
  : { x: 0, y: 0, w, h: h * progress };
62
64
  p.clipSurface(shown);
63
- draw(p, b, env, to.opacity);
65
+ draw(p, b, env, to.opacity, to.blend);
64
66
  p.unclip();
65
67
  return;
66
68
  }
@@ -68,8 +70,28 @@ function transition(p, t, from, to, fromLocal, toLocal, elapsed, progress, env)
68
70
  // The incoming clip moves in from the side opposite `direction`, pushing the other out.
69
71
  const dx = t.direction === "left" ? -surface.w : t.direction === "right" ? surface.w : 0;
70
72
  const dy = t.direction === "up" ? -surface.h : t.direction === "down" ? surface.h : 0;
71
- draw(p, a, env, from.opacity, translate(dx * progress, dy * progress));
72
- draw(p, b, env, to.opacity, translate(-dx * (1 - progress), -dy * (1 - progress)));
73
+ draw(p, a, env, from.opacity, from.blend, translate(dx * progress, dy * progress));
74
+ draw(p, b, env, to.opacity, to.blend, translate(-dx * (1 - progress), -dy * (1 - progress)));
75
+ return;
76
+ }
77
+ case "dissolve": {
78
+ // The outgoing clip stays whole; the incoming one shows through the noise, its edge on top.
79
+ const stage = { x: 0, y: 0, w: env.width, h: env.height };
80
+ const soft = 0.035;
81
+ const ridged = t.dissolve.noise === "ridged";
82
+ const threshold = -soft + (1 + 2 * soft) * progress;
83
+ const noise = (band, width, color) => ({ kind: "noise", ridged, band, size: t.dissolve.size, threshold, soft: width, color });
84
+ draw(p, a, env, from.opacity, from.blend);
85
+ const plain = { opacity: 1, brightness: 1, saturate: 1, blur: 0 };
86
+ p.layer(surface, plain);
87
+ draw(p, b, env, to.opacity, to.blend);
88
+ p.layer(surface, plain);
89
+ p.fillStage(stage, noise(false, soft, { r: 1, g: 1, b: 1, a: 1 }));
90
+ p.popMask(false);
91
+ p.popLayer();
92
+ const edge = t.dissolve.edge;
93
+ if (edge !== null && progress > 0 && progress < 1)
94
+ p.fillStage(stage, noise(true, soft * 2, premul(parseColor(edge))));
73
95
  return;
74
96
  }
75
97
  }
@@ -81,7 +103,7 @@ export function composeFrame(p, plan, frame, env) {
81
103
  continue;
82
104
  const state = trackAt(track, frame);
83
105
  if (state.kind === "clip")
84
- draw(p, clipFrame(state.clip, state.local, env), env, state.clip.opacity);
106
+ draw(p, clipFrame(state.clip, state.local, env), env, state.clip.opacity, state.clip.blend);
85
107
  else if (state.kind === "transition")
86
108
  transition(p, state.transition, state.from, state.to, state.fromLocal, state.toLocal, state.elapsed, state.progress, env);
87
109
  }
@@ -2,6 +2,7 @@ import { type FilmPlan } from "@tongue-core/film/model";
2
2
  import { type LoadedAssets } from "../assets/store.js";
3
3
  import { type VideoFrameRequest, type VideoFrameSource } from "../media/video.js";
4
4
  import type { RasterBackend } from "../raster/backend.js";
5
+ import { type FrameSize } from "./size.js";
5
6
  export interface FrameOptions {
6
7
  /** Of the film's size (default 1). */
7
8
  readonly scale?: number;
@@ -27,12 +28,6 @@ export interface DrawnFrame {
27
28
  /** The video frames the frame shows. */
28
29
  readonly videoFrames: ReadonlyArray<VideoFrameRequest>;
29
30
  }
30
- export interface FrameSize {
31
- readonly width: number;
32
- readonly height: number;
33
- }
34
- /** A film's frame size at a render scale: rounded to even numbers (what video encoders take). */
35
- export declare function sizeAtScale(film: FrameSize, scale: number): FrameSize;
36
31
  export declare class FilmRenderer {
37
32
  readonly plan: FilmPlan;
38
33
  readonly assets: LoadedAssets;
@@ -65,6 +60,12 @@ export declare class FilmRenderer {
65
60
  /** Puts an image in the raster now (decoded, mip-mapped), so the first frame that shows it doesn't
66
61
  * wait for that: what a player does with an image as it arrives. */
67
62
  preloadImage(name: string): void;
63
+ /** Releases the raster's decoded copy when a host evicts a remote image. */
64
+ forgetImage(name: string): void;
65
+ /** Keeps only the images `out` draws in the raster and returns their names. Decoded images are
66
+ * the raster's biggest cost, so a host with little memory (a Worker) calls this before drawing,
67
+ * and drops the files of everything else. A later frame that shows one loads it again. */
68
+ keepOnly(out: DrawnFrame): ReadonlyArray<string>;
68
69
  /** Renders a frame into the raster; read it with rgba8(), yuv420() or png(). */
69
70
  render(frame: number, options?: FrameOptions): void;
70
71
  /** Rasterizes a frame already built with displayList(). */
@@ -1,4 +1,5 @@
1
1
  import { sourceSeconds, trackAt } from "@tongue-core/film/model";
2
+ import { encodeQoi } from "../assets/codecs/qoi.js";
2
3
  import { imageOf, imageSize, videoOf } from "../assets/store.js";
3
4
  import { frameAtSeconds, PLACEHOLDER_SIZE, placeholderFrame, postersOnly } from "../media/video.js";
4
5
  import { scaling } from "../paint/geometry.js";
@@ -7,11 +8,10 @@ import { Painter, premul } from "../paint/paint.js";
7
8
  import { Raster, RasterError } from "../raster/raster.js";
8
9
  import { outlineInEm } from "../text/fonts.js";
9
10
  import { composeFrame } from "./compose.js";
10
- /** A film's frame size at a render scale: rounded to even numbers (what video encoders take). */
11
- export function sizeAtScale(film, scale) {
12
- const even = (v) => Math.max(2, Math.round((v * scale) / 2) * 2);
13
- return { width: even(film.width), height: even(film.height) };
14
- }
11
+ import { sizeAtScale } from "./size.js";
12
+ const IMAGE_KEY = "image:";
13
+ /** What an image slot holds once its image is let go. */
14
+ const NOTHING = encodeQoi({ width: 1, height: 1, data: new Uint8Array(4) });
15
15
  export class FilmRenderer {
16
16
  plan;
17
17
  assets;
@@ -77,7 +77,7 @@ export class FilmRenderer {
77
77
  const slotOf = (picture) => {
78
78
  if (picture.kind === "image") {
79
79
  const image = imageOf(this.assets, picture.asset);
80
- return { id: this.idOf(`image:${picture.asset.name}`), key: "image", qoi: () => image.qoi, width: image.width, height: image.height };
80
+ return { id: this.idOf(`${IMAGE_KEY}${picture.asset.name}`), key: "image", qoi: () => image.qoi, width: image.width, height: image.height };
81
81
  }
82
82
  videoFrames.set(`${picture.asset.name}:${picture.frame}`, { asset: picture.asset, frame: picture.frame });
83
83
  return this.videoSlot(picture.asset, picture.frame, source, slotIndex(picture.asset, picture.frame));
@@ -162,12 +162,33 @@ export class FilmRenderer {
162
162
  const image = this.assets.images.get(name);
163
163
  if (!image)
164
164
  return;
165
- const id = this.idOf(`image:${name}`);
165
+ const id = this.idOf(`${IMAGE_KEY}${name}`);
166
166
  if (this.loaded.get(id) === "image" && this.backend.hasImage(id))
167
167
  return;
168
168
  this.backend.loadImage(id, image.qoi);
169
169
  this.loaded.set(id, "image");
170
170
  }
171
+ /** Releases the raster's decoded copy when a host evicts a remote image. */
172
+ forgetImage(name) {
173
+ const id = this.imageIds.get(`${IMAGE_KEY}${name}`);
174
+ if (id !== undefined && this.loaded.delete(id))
175
+ this.backend.loadImage(id, NOTHING);
176
+ }
177
+ /** Keeps only the images `out` draws in the raster and returns their names. Decoded images are
178
+ * the raster's biggest cost, so a host with little memory (a Worker) calls this before drawing,
179
+ * and drops the files of everything else. A later frame that shows one loads it again. */
180
+ keepOnly(out) {
181
+ const kept = [];
182
+ for (const [key, id] of this.imageIds) {
183
+ if (!key.startsWith(IMAGE_KEY))
184
+ continue;
185
+ if (out.images.has(id))
186
+ kept.push(key.slice(IMAGE_KEY.length));
187
+ else if (this.loaded.delete(id))
188
+ this.backend.loadImage(id, NOTHING); // replacing an image frees it
189
+ }
190
+ return kept;
191
+ }
171
192
  /** Renders a frame into the raster; read it with rgba8(), yuv420() or png(). */
172
193
  render(frame, options = {}) {
173
194
  this.draw(this.displayList(frame, options));
@@ -0,0 +1,6 @@
1
+ export interface FrameSize {
2
+ readonly width: number;
3
+ readonly height: number;
4
+ }
5
+ /** A film's frame size at a render scale: rounded to even numbers (what video encoders take). */
6
+ export declare function sizeAtScale(film: FrameSize, scale: number): FrameSize;
@@ -0,0 +1,7 @@
1
+ // A film's frame size at a render scale. Its own module, with nothing else in it, so code that only
2
+ // sizes frames (the player's page, deciding how many fit its cache) doesn't carry the renderer.
3
+ /** A film's frame size at a render scale: rounded to even numbers (what video encoders take). */
4
+ export function sizeAtScale(film, scale) {
5
+ const even = (v) => Math.max(2, Math.round((v * scale) / 2) * 2);
6
+ return { width: even(film.width), height: even(film.height) };
7
+ }
@@ -9,4 +9,7 @@ export declare function collectSounds(plan: FilmPlan, range?: {
9
9
  readonly to: number;
10
10
  }): ReadonlyArray<SoundEvent>;
11
11
  /** The film's audio clips with their decoded audio (clips whose audio isn't loaded are left out). */
12
- export declare function audioClips(plan: FilmPlan, assets: LoadedAssets): ReadonlyArray<ClipAudio>;
12
+ export declare function audioClips(plan: FilmPlan, assets: LoadedAssets, range?: {
13
+ readonly from: number;
14
+ readonly to: number;
15
+ }): ReadonlyArray<ClipAudio>;
@@ -34,12 +34,16 @@ export function collectSounds(plan, range) {
34
34
  return [...seen.values()].sort((a, b) => a.at - b.at);
35
35
  }
36
36
  /** The film's audio clips with their decoded audio (clips whose audio isn't loaded are left out). */
37
- export function audioClips(plan, assets) {
37
+ export function audioClips(plan, assets, range) {
38
38
  return plan.tracks.filter((t) => t.kind === "audio").flatMap((t) => t.clips).flatMap((clip) => {
39
39
  const s = clip.source;
40
40
  if (s.kind !== "audio")
41
41
  return [];
42
- const pcm = assets.audio.get(s.asset.name);
42
+ if (range && (clip.end <= range.from || clip.start >= range.to))
43
+ return [];
44
+ const from = range ? s.in + Math.max(0, (range.from - clip.start) / plan.fps) * s.speed : s.in;
45
+ const to = range ? s.in + Math.min(clip.duration / plan.fps, (range.to - clip.start) / plan.fps) * s.speed : s.out;
46
+ const pcm = assets.audioWindow?.(s.asset.name, Math.max(s.in, from - 0.2 * s.speed), to + 0.2 * s.speed) ?? assets.audio.get(s.asset.name);
43
47
  return pcm ? [{ pcm, start: clip.start, end: clip.end, in: s.in, speed: s.speed, gain: s.gain, fadeIn: s.fadeIn, fadeOut: s.fadeOut }] : [];
44
48
  });
45
49
  }
@@ -1,4 +1,4 @@
1
- import type { Color, FontAsset } from "@tongue-core/film/model";
1
+ import type { BlendMode, Color, FontAsset } from "@tongue-core/film/model";
2
2
  export type { Color };
3
3
  export interface GradientStop {
4
4
  readonly offset: number;
@@ -23,6 +23,8 @@ export interface Shadow {
23
23
  readonly blur: number;
24
24
  readonly spread: number;
25
25
  readonly color: Color;
26
+ /** Cast inside the box instead of outside it. */
27
+ readonly inset: boolean;
26
28
  }
27
29
  export interface Side {
28
30
  readonly width: number;
@@ -58,6 +60,9 @@ export interface TextStyle {
58
60
  readonly font: FontAsset | null;
59
61
  readonly fontSize: number;
60
62
  readonly fontWeight: number;
63
+ readonly italic: boolean;
64
+ readonly axes: Readonly<Record<string, number>>;
65
+ readonly shadows: ReadonlyArray<Shadow>;
61
66
  readonly lineHeight: LineHeight;
62
67
  readonly letterSpacing: number;
63
68
  readonly textAlign: "left" | "center" | "right";
@@ -71,6 +76,8 @@ export interface Transform {
71
76
  readonly rotate: number;
72
77
  readonly turn: number;
73
78
  readonly tilt: number;
79
+ /** Where the box pivots, as fractions of its size. */
80
+ readonly origin: readonly [number, number];
74
81
  }
75
82
  export interface Computed {
76
83
  readonly display: Display;
@@ -112,6 +119,12 @@ export interface Computed {
112
119
  readonly blur: number;
113
120
  readonly brightness: number;
114
121
  readonly saturate: number;
122
+ /** Camera distance for direct children that turn or tilt; null: they turn alone. */
123
+ readonly perspective: number | null;
124
+ readonly perspectiveOrigin: readonly [number, number];
125
+ readonly contrast: number;
126
+ readonly hueRotate: number;
127
+ readonly blend: BlendMode;
115
128
  readonly transform: Transform;
116
129
  readonly text: TextStyle;
117
130
  readonly textOverflow: "clip" | "ellipsis";
@@ -128,11 +128,9 @@ export function parseShadows(input) {
128
128
  return cached;
129
129
  const list = input.trim() === "none" ? [] : splitTop(input).map((one) => {
130
130
  const tokens = splitTop(one, " ");
131
- if (tokens.includes("inset"))
132
- throw new StyleError("Inset shadows are not supported.");
133
131
  const colorToken = tokens.find((t) => isColor(t));
134
- const lengths = tokens.filter((t) => t !== colorToken).map(parseLength);
135
- return { x: lengths[0] ?? 0, y: lengths[1] ?? 0, blur: lengths[2] ?? 0, spread: lengths[3] ?? 0, color: colorToken ? parseColor(colorToken) : { r: 0, g: 0, b: 0, a: 0.3 } };
132
+ const lengths = tokens.filter((t) => t !== colorToken && t !== "inset").map(parseLength);
133
+ return { x: lengths[0] ?? 0, y: lengths[1] ?? 0, blur: lengths[2] ?? 0, spread: lengths[3] ?? 0, color: colorToken ? parseColor(colorToken) : { r: 0, g: 0, b: 0, a: 0.3 }, inset: tokens.includes("inset") };
136
134
  });
137
135
  shadows.set(input, list);
138
136
  return list;
@@ -35,6 +35,22 @@ function pick(r, key, options) {
35
35
  throw new StyleError(`${key}: expected one of ${options.join(", ")}, got ${JSON.stringify(v)}.`);
36
36
  return found;
37
37
  }
38
+ /** A pair of finite numbers, or undefined when the property is absent. */
39
+ function pair(r, key) {
40
+ const v = r[key];
41
+ if (v === undefined)
42
+ return undefined;
43
+ if (Array.isArray(v) && v.length === 2 && typeof v[0] === "number" && Number.isFinite(v[0]) && typeof v[1] === "number" && Number.isFinite(v[1]))
44
+ return [v[0], v[1]];
45
+ throw new StyleError(`${key}: expected [x, y] as fractions of the box, like [0.5, 0.5], got ${JSON.stringify(v)}.`);
46
+ }
47
+ function perspectiveOf(v) {
48
+ if (v === undefined)
49
+ return null;
50
+ if (!(v > 0))
51
+ throw new StyleError(`perspective: expected a camera distance above 0, got ${v}.`);
52
+ return v;
53
+ }
38
54
  const numOrString = (r, key) => {
39
55
  const v = r[key];
40
56
  if (v === undefined || typeof v === "number" || typeof v === "string")
@@ -57,6 +73,9 @@ export const rootText = {
57
73
  font: null,
58
74
  fontSize: 16,
59
75
  fontWeight: 400,
76
+ italic: false,
77
+ axes: {},
78
+ shadows: [],
60
79
  lineHeight: { kind: "multiple", v: 1.25 },
61
80
  letterSpacing: 0,
62
81
  textAlign: "left",
@@ -70,11 +89,18 @@ const zero = { kind: "px", v: 0 };
70
89
  /** The text style an element passes to its children. */
71
90
  export function textStyleOf(r, parent, tag) {
72
91
  const bold = tag === "strong" || tag === "b" ? 700 : null;
92
+ // <em> leans when the font has an italic file to lean with, and otherwise stays as it is.
93
+ const emphasis = (font) => tag === "em" && font?.italic !== undefined;
73
94
  if (!r)
74
- return bold === null ? parent : { ...parent, fontWeight: bold };
95
+ return bold === null && !emphasis(parent.font) ? parent : { ...parent, fontWeight: bold ?? parent.fontWeight, italic: parent.italic || emphasis(parent.font) };
75
96
  const family = r["fontFamily"];
76
97
  if (family !== undefined && !isFontAsset(family))
77
98
  throw new StyleError("fontFamily: expected a font asset from font().");
99
+ const fontStyle = pick(r, "fontStyle", ["normal", "italic"]);
100
+ const textShadow = text(r, "textShadow");
101
+ const shadows = textShadow === undefined ? parent.shadows : parseShadows(textShadow);
102
+ if (shadows.some((s) => s.inset))
103
+ throw new StyleError("textShadow: inset is for boxShadow; text shadows are always outside.");
78
104
  const lh = r["lineHeight"];
79
105
  let lineHeight = parent.lineHeight;
80
106
  if (typeof lh === "number")
@@ -88,6 +114,10 @@ export function textStyleOf(r, parent, tag) {
88
114
  font: family ?? parent.font,
89
115
  fontSize: num(r, "fontSize") ?? parent.fontSize,
90
116
  fontWeight: num(r, "fontWeight") ?? bold ?? parent.fontWeight,
117
+ italic: fontStyle === undefined ? parent.italic || emphasis(family ?? parent.font) : fontStyle === "italic",
118
+ // Axes belong to a font: a new family starts without the ones it inherited.
119
+ axes: axesOf(r, family === undefined || family === parent.font ? parent.axes : {}),
120
+ shadows,
91
121
  lineHeight,
92
122
  letterSpacing: num(r, "letterSpacing") ?? parent.letterSpacing,
93
123
  textAlign: pick(r, "textAlign", ["left", "center", "right"]) ?? parent.textAlign,
@@ -95,6 +125,20 @@ export function textStyleOf(r, parent, tag) {
95
125
  color: color === undefined ? parent.color : parseColor(color),
96
126
  };
97
127
  }
128
+ function axesOf(r, parent) {
129
+ const v = r["fontAxes"];
130
+ if (v === undefined)
131
+ return parent;
132
+ if (!isRecord(v))
133
+ throw new StyleError(`fontAxes: expected axis tags and numbers, like { SOFT: 100 }, got ${JSON.stringify(v)}.`);
134
+ const out = { ...parent };
135
+ for (const [tag, value] of Object.entries(v)) {
136
+ if (typeof value !== "number" || !Number.isFinite(value))
137
+ throw new StyleError(`fontAxes: ${tag} must be a number, got ${JSON.stringify(value)}.`);
138
+ out[tag] = value;
139
+ }
140
+ return out;
141
+ }
98
142
  function sides(r) {
99
143
  const all = text(r, "border");
100
144
  const base = all === undefined ? noSide : parseBorder(all);
@@ -193,7 +237,12 @@ export function computeStyle(r, parentText, tag) {
193
237
  blur: num(r, "blur") ?? 0,
194
238
  brightness: num(r, "brightness") ?? 1,
195
239
  saturate: num(r, "saturate") ?? 1,
196
- transform: { x: num(r, "x") ?? 0, y: num(r, "y") ?? 0, scale: num(r, "scale") ?? 1, rotate: num(r, "rotate") ?? 0, turn: num(r, "turn") ?? 0, tilt: num(r, "tilt") ?? 0 },
240
+ perspective: perspectiveOf(num(r, "perspective")),
241
+ perspectiveOrigin: pair(r, "perspectiveOrigin") ?? [0.5, 0.5],
242
+ contrast: num(r, "contrast") ?? 1,
243
+ hueRotate: num(r, "hueRotate") ?? 0,
244
+ blend: pick(r, "blend", ["normal", "multiply", "screen", "overlay", "soft-light", "color-dodge"]) ?? "normal",
245
+ transform: { x: num(r, "x") ?? 0, y: num(r, "y") ?? 0, scale: num(r, "scale") ?? 1, rotate: num(r, "rotate") ?? 0, turn: num(r, "turn") ?? 0, tilt: num(r, "tilt") ?? 0, origin: pair(r, "transformOrigin") ?? [0.5, 0.5] },
197
246
  text: textStyle,
198
247
  textOverflow: pick(r, "textOverflow", ["clip", "ellipsis"]) ?? "clip",
199
248
  objectFit: pick(r, "objectFit", ["cover", "contain", "fill"]) ?? "fill",
@@ -215,8 +264,8 @@ export function defaults(textStyle, display) {
215
264
  columns: [], columnSpan: 1,
216
265
  overflowHidden: false, zIndex: 0,
217
266
  background: [], border: [noSide, noSide, noSide, noSide], radius: [zero, zero, zero, zero], shadows: [],
218
- opacity: 1, blur: 0, brightness: 1, saturate: 1,
219
- transform: { x: 0, y: 0, scale: 1, rotate: 0, turn: 0, tilt: 0 },
267
+ opacity: 1, blur: 0, brightness: 1, saturate: 1, perspective: null, perspectiveOrigin: [0.5, 0.5], contrast: 1, hueRotate: 0, blend: "normal",
268
+ transform: { x: 0, y: 0, scale: 1, rotate: 0, turn: 0, tilt: 0, origin: [0.5, 0.5] },
220
269
  text: textStyle, textOverflow: "clip", objectFit: "fill", objectPosition: [0.5, 0.5],
221
270
  };
222
271
  }
@@ -3,6 +3,9 @@ import type { FontFile, FontInstance, PathVerb } from "./font-types.js";
3
3
  export declare class FontMissing extends Error {
4
4
  readonly name = "FontMissing";
5
5
  }
6
+ export declare class FontAxisError extends Error {
7
+ readonly name = "FontAxisError";
8
+ }
6
9
  export interface ResolvedFont {
7
10
  /** Small integer used in display lists; stable for the life of this library. */
8
11
  readonly id: number;
@@ -18,7 +21,7 @@ export declare class FontLibrary {
18
21
  private readonly byId;
19
22
  add(asset: FontAsset, file: FontFile): void;
20
23
  has(asset: FontAsset): boolean;
21
- resolve(asset: FontAsset, weight: number, size: number): ResolvedFont;
24
+ resolve(asset: FontAsset, weight: number, size: number, axes?: Readonly<Record<string, number>>): ResolvedFont;
22
25
  instanceById(id: number): FontInstance | undefined;
23
26
  }
24
27
  /** A glyph outline in em units (what the rasterizer's define_glyph expects). */
@@ -1,6 +1,9 @@
1
1
  export class FontMissing extends Error {
2
2
  name = "FontMissing";
3
3
  }
4
+ export class FontAxisError extends Error {
5
+ name = "FontAxisError";
6
+ }
4
7
  /** Loaded font files and the instances the film asks for. Optical size follows the font size
5
8
  * (like CSS `font-optical-sizing: auto`), so a 150px headline gets the display cut. */
6
9
  export class FontLibrary {
@@ -13,7 +16,7 @@ export class FontLibrary {
13
16
  has(asset) {
14
17
  return this.files.has(asset.name);
15
18
  }
16
- resolve(asset, weight, size) {
19
+ resolve(asset, weight, size, axes = {}) {
17
20
  const file = this.files.get(asset.name);
18
21
  if (!file)
19
22
  throw new FontMissing(`Font "${asset.name}" is not loaded (was it listed in the film's assets?).`);
@@ -25,6 +28,11 @@ export class FontLibrary {
25
28
  if (axis.tag === "opsz")
26
29
  settings["opsz"] = Math.round(Math.min(axis.max, Math.max(axis.min, size)));
27
30
  }
31
+ for (const [tag, value] of Object.entries(axes)) {
32
+ if (!file.axes.some((a) => a.tag === tag))
33
+ throw new FontAxisError(`fontAxes: font "${asset.name}" has no axis "${tag}"${file.axes.length > 0 ? `; it has ${file.axes.map((a) => a.tag).join(", ")}` : ": it isn't a variable font"}.`);
34
+ settings[tag] = value;
35
+ }
28
36
  const instance = file.instance(settings);
29
37
  let id = this.ids.get(instance.key);
30
38
  if (id === undefined) {
package/dist/text/ttf.js CHANGED
@@ -2,7 +2,11 @@ import { Bin, FontError, readTables } from "./sfnt.js";
2
2
  import { Gvar, Hvar, ItemVariationStore, normalize, readAvar, readFvar } from "./variation.js";
3
3
  const empty = { kind: "empty" };
4
4
  const maxDepth = 8;
5
+ /** Each parsed file's own number. Two files of one family (an upright and an italic) share a name
6
+ * and axis values, so that can't tell their instances apart: their glyph ids mean different shapes. */
7
+ let filesParsed = 0;
5
8
  export function parseFont(bytes) {
9
+ const file = ++filesParsed;
6
10
  const records = readTables(bytes);
7
11
  const table = (tag) => {
8
12
  const r = records.get(tag);
@@ -67,7 +71,7 @@ export function parseFont(bytes) {
67
71
  const instance = (settings) => {
68
72
  const values = axes.map((axis) => clamp(settings[axis.tag] ?? axis.defaultValue, axis.min, axis.max));
69
73
  const parts = axes.map((axis, i) => `${axis.tag}=${formatValue(values[i] ?? axis.defaultValue)}`).sort();
70
- const key = parts.length > 0 ? `${familyName} ${parts.join(" ")}` : familyName;
74
+ const key = parts.length > 0 ? `${file}:${familyName} ${parts.join(" ")}` : `${file}:${familyName}`;
71
75
  const cached = instances.get(key);
72
76
  if (cached)
73
77
  return cached;
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@tongue-core/engine",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.0-rc.10",
4
4
  "description": "Frame to pixels for Tongue films: evaluate, layout, paint and rasterize, deterministic everywhere.",
5
5
  "type": "module",
6
- "license": "UNLICENSED",
6
+ "license": "MIT",
7
7
  "author": "Backpine",
8
8
  "repository": {
9
9
  "type": "git",
@@ -15,16 +15,18 @@
15
15
  ".": {
16
16
  "types": "./dist/index.d.ts",
17
17
  "default": "./dist/index.js"
18
- }
18
+ },
19
+ "./raster.wasm": "./raster.wasm"
19
20
  },
20
21
  "main": "./dist/index.js",
21
22
  "types": "./dist/index.d.ts",
22
23
  "files": [
23
24
  "dist",
25
+ "raster.wasm",
24
26
  "README.md"
25
27
  ],
26
28
  "dependencies": {
27
- "@tongue-core/film": "0.1.0-rc.1"
29
+ "@tongue-core/film": "0.1.0-rc.10"
28
30
  },
29
31
  "publishConfig": {
30
32
  "access": "public"
package/raster.wasm ADDED
Binary file