@glissade/scene 0.1.0
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 +202 -0
- package/dist/index.d.ts +89 -0
- package/dist/index.js +102 -0
- package/dist/layout.d.ts +46 -0
- package/dist/layout.js +168 -0
- package/dist/layoutEngine.d.ts +395 -0
- package/dist/layoutEngine.js +655 -0
- package/package.json +34 -0
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
import { TARGET_PATH, computed, signal, vec2Signal } from "@glissade/core";
|
|
2
|
+
//#region src/matrix.ts
|
|
3
|
+
const IDENTITY = [
|
|
4
|
+
1,
|
|
5
|
+
0,
|
|
6
|
+
0,
|
|
7
|
+
1,
|
|
8
|
+
0,
|
|
9
|
+
0
|
|
10
|
+
];
|
|
11
|
+
const z = (v) => v === 0 ? 0 : v;
|
|
12
|
+
function multiply(m1, m2) {
|
|
13
|
+
const [a1, b1, c1, d1, e1, f1] = m1;
|
|
14
|
+
const [a2, b2, c2, d2, e2, f2] = m2;
|
|
15
|
+
return [
|
|
16
|
+
z(a1 * a2 + c1 * b2),
|
|
17
|
+
z(b1 * a2 + d1 * b2),
|
|
18
|
+
z(a1 * c2 + c1 * d2),
|
|
19
|
+
z(b1 * c2 + d1 * d2),
|
|
20
|
+
z(a1 * e2 + c1 * f2 + e1),
|
|
21
|
+
z(b1 * e2 + d1 * f2 + f1)
|
|
22
|
+
];
|
|
23
|
+
}
|
|
24
|
+
/** Compose translate × rotate × scale (rotation in degrees). */
|
|
25
|
+
function fromTRS(position, rotationDeg, scale) {
|
|
26
|
+
const r = rotationDeg * Math.PI / 180;
|
|
27
|
+
const cos = Math.cos(r);
|
|
28
|
+
const sin = Math.sin(r);
|
|
29
|
+
const [sx, sy] = scale;
|
|
30
|
+
return [
|
|
31
|
+
z(cos * sx),
|
|
32
|
+
z(sin * sx),
|
|
33
|
+
z(-sin * sy),
|
|
34
|
+
z(cos * sy),
|
|
35
|
+
z(position[0]),
|
|
36
|
+
z(position[1])
|
|
37
|
+
];
|
|
38
|
+
}
|
|
39
|
+
function applyToPoint(m, p) {
|
|
40
|
+
return [m[0] * p[0] + m[2] * p[1] + m[4], m[1] * p[0] + m[3] * p[1] + m[5]];
|
|
41
|
+
}
|
|
42
|
+
function matEquals(a, b) {
|
|
43
|
+
return a[0] === b[0] && a[1] === b[1] && a[2] === b[2] && a[3] === b[3] && a[4] === b[4] && a[5] === b[5];
|
|
44
|
+
}
|
|
45
|
+
//#endregion
|
|
46
|
+
//#region src/displayList.ts
|
|
47
|
+
function createDisplayListBuilder(size) {
|
|
48
|
+
const commands = [];
|
|
49
|
+
const resources = [];
|
|
50
|
+
const interned = /* @__PURE__ */ new Map();
|
|
51
|
+
return {
|
|
52
|
+
push: (cmd) => {
|
|
53
|
+
commands.push(cmd);
|
|
54
|
+
},
|
|
55
|
+
resource: (res) => {
|
|
56
|
+
const k = JSON.stringify(res);
|
|
57
|
+
const hit = interned.get(k);
|
|
58
|
+
if (hit !== void 0) return hit;
|
|
59
|
+
const id = resources.length;
|
|
60
|
+
resources.push(res);
|
|
61
|
+
interned.set(k, id);
|
|
62
|
+
return id;
|
|
63
|
+
},
|
|
64
|
+
finish: () => ({
|
|
65
|
+
commands,
|
|
66
|
+
resources,
|
|
67
|
+
size
|
|
68
|
+
})
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
//#endregion
|
|
72
|
+
//#region src/text.ts
|
|
73
|
+
/** §3.6 measurement quantum. */
|
|
74
|
+
function quantize(v) {
|
|
75
|
+
return Math.round(v * 2) / 2;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Estimating fallback measurer — used only when no backend has been injected
|
|
79
|
+
* (e.g. evaluating for IR-level tests). Deterministic but not metrically
|
|
80
|
+
* faithful; mount(), the CLI, and exporters always inject the real one.
|
|
81
|
+
*/
|
|
82
|
+
const estimatingMeasurer = { measureText(text, font) {
|
|
83
|
+
return {
|
|
84
|
+
width: text.length * font.size * .52,
|
|
85
|
+
ascent: font.size * .8,
|
|
86
|
+
descent: font.size * .2
|
|
87
|
+
};
|
|
88
|
+
} };
|
|
89
|
+
let wordSegmenter;
|
|
90
|
+
function segmentWords(text) {
|
|
91
|
+
if (wordSegmenter === void 0) wordSegmenter = typeof Intl !== "undefined" && "Segmenter" in Intl ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
|
|
92
|
+
if (wordSegmenter) {
|
|
93
|
+
const raw = [...wordSegmenter.segment(text)].map((s) => s.segment);
|
|
94
|
+
const glued = [];
|
|
95
|
+
for (const seg of raw) if (glued.length > 0 && /^[^\p{L}\p{N}\s]+$/u.test(seg)) glued[glued.length - 1] += seg;
|
|
96
|
+
else glued.push(seg);
|
|
97
|
+
return glued;
|
|
98
|
+
}
|
|
99
|
+
return text.split(/(\s+)/).filter((w) => w.length > 0);
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* Greedy line breaking: explicit '\n' always breaks; otherwise word segments
|
|
103
|
+
* flow until maxWidth is exceeded (Intl.Segmenter boundaries, so CJK wraps
|
|
104
|
+
* without spaces). A segment wider than maxWidth gets its own line (no
|
|
105
|
+
* intra-word breaking in v1).
|
|
106
|
+
*/
|
|
107
|
+
function breakLines(text, font, maxWidth, measurer) {
|
|
108
|
+
const paragraphs = text.split("\n");
|
|
109
|
+
if (maxWidth === void 0 || maxWidth <= 0) return paragraphs;
|
|
110
|
+
const lines = [];
|
|
111
|
+
for (const para of paragraphs) {
|
|
112
|
+
const words = segmentWords(para);
|
|
113
|
+
let line = "";
|
|
114
|
+
for (const word of words) {
|
|
115
|
+
const candidate = line + word;
|
|
116
|
+
if (line !== "" && quantize(measurer.measureText(candidate.trimEnd(), font).width) > maxWidth) {
|
|
117
|
+
lines.push(line.trimEnd());
|
|
118
|
+
line = word.trimStart() === "" ? "" : word;
|
|
119
|
+
} else line = candidate;
|
|
120
|
+
}
|
|
121
|
+
lines.push(line.trimEnd());
|
|
122
|
+
}
|
|
123
|
+
return lines;
|
|
124
|
+
}
|
|
125
|
+
//#endregion
|
|
126
|
+
//#region src/node.ts
|
|
127
|
+
/**
|
|
128
|
+
* Scene-graph node base (DESIGN.md §3.1): every animatable property is a
|
|
129
|
+
* signal; transforms are computed matrix signals; emit() is pure — it reads
|
|
130
|
+
* signals and ctx only, and produces IR commands, never canvas calls.
|
|
131
|
+
*/
|
|
132
|
+
function initScalar(sig, init) {
|
|
133
|
+
if (typeof init === "function") sig.bindSource(init);
|
|
134
|
+
else if (init !== void 0) sig.set(init);
|
|
135
|
+
return sig;
|
|
136
|
+
}
|
|
137
|
+
function initVec2(sig, init) {
|
|
138
|
+
if (typeof init === "function") sig.bindSource(init);
|
|
139
|
+
else if (init !== void 0) sig.set(init);
|
|
140
|
+
return sig;
|
|
141
|
+
}
|
|
142
|
+
var Node = class {
|
|
143
|
+
id;
|
|
144
|
+
position;
|
|
145
|
+
rotation;
|
|
146
|
+
scale;
|
|
147
|
+
opacity;
|
|
148
|
+
blend;
|
|
149
|
+
zIndex;
|
|
150
|
+
filters;
|
|
151
|
+
parent = null;
|
|
152
|
+
localMatrix;
|
|
153
|
+
worldMatrix;
|
|
154
|
+
/** Track-target paths → bindable signals; subclasses register their own props. */
|
|
155
|
+
targets = /* @__PURE__ */ new Map();
|
|
156
|
+
constructor(props = {}) {
|
|
157
|
+
this.id = props.id;
|
|
158
|
+
this.position = initVec2(vec2Signal([0, 0]), props.position);
|
|
159
|
+
this.rotation = initScalar(signal(0), props.rotation);
|
|
160
|
+
this.scale = initVec2(vec2Signal([1, 1]), props.scale);
|
|
161
|
+
this.opacity = initScalar(signal(1), props.opacity);
|
|
162
|
+
this.blend = initScalar(signal("source-over"), props.blend);
|
|
163
|
+
this.zIndex = initScalar(signal(0), props.zIndex);
|
|
164
|
+
this.filters = initScalar(signal([]), void 0);
|
|
165
|
+
this.localMatrix = computed(() => fromTRS(this.position(), this.rotation(), this.scale()), { equals: matEquals });
|
|
166
|
+
this.worldMatrix = computed(() => this.parent ? multiply(this.parent.worldMatrix(), this.localMatrix()) : this.localMatrix(), { equals: matEquals });
|
|
167
|
+
this.registerTarget("position", this.position);
|
|
168
|
+
this.registerTarget("position.x", this.position.x);
|
|
169
|
+
this.registerTarget("position.y", this.position.y);
|
|
170
|
+
this.registerTarget("rotation", this.rotation);
|
|
171
|
+
this.registerTarget("scale", this.scale);
|
|
172
|
+
this.registerTarget("scale.x", this.scale.x);
|
|
173
|
+
this.registerTarget("scale.y", this.scale.y);
|
|
174
|
+
this.registerTarget("opacity", this.opacity);
|
|
175
|
+
this.registerTarget("zIndex", this.zIndex);
|
|
176
|
+
}
|
|
177
|
+
registerTarget(path, sig) {
|
|
178
|
+
this.targets.set(path, sig);
|
|
179
|
+
if (this.id !== void 0) sig[TARGET_PATH] = `${this.id}/${path}`;
|
|
180
|
+
}
|
|
181
|
+
resolveTarget(path) {
|
|
182
|
+
return this.targets.get(path);
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Natural size for flex flow (§3.2); null = not flowable (a Layout parent
|
|
186
|
+
* emits such children absolutely, untouched).
|
|
187
|
+
*/
|
|
188
|
+
intrinsicSize(measurer) {
|
|
189
|
+
return null;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Vector from the node origin to its intrinsic box's TOP-LEFT, so Layout
|
|
193
|
+
* can place any anchor correctly. Default: center-anchored (every shape).
|
|
194
|
+
* Text overrides — it draws from a left/center/right baseline origin.
|
|
195
|
+
*/
|
|
196
|
+
flowOffset(measurer) {
|
|
197
|
+
const size = this.intrinsicSize(measurer) ?? {
|
|
198
|
+
w: 0,
|
|
199
|
+
h: 0
|
|
200
|
+
};
|
|
201
|
+
return {
|
|
202
|
+
x: -size.w / 2,
|
|
203
|
+
y: -size.h / 2
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/** §3.5 predicate: composite-as-a-unit when opacity/blend demand it. */
|
|
207
|
+
requiresGroup() {
|
|
208
|
+
return this.opacity() < 1 || this.blend() !== "source-over";
|
|
209
|
+
}
|
|
210
|
+
emit(out, ctx) {
|
|
211
|
+
const opacity = this.opacity();
|
|
212
|
+
if (opacity <= 0) return;
|
|
213
|
+
const local = this.localMatrix();
|
|
214
|
+
const isIdentity = matEquals(local, IDENTITY);
|
|
215
|
+
const group = this.requiresGroup();
|
|
216
|
+
out.push({ op: "save" });
|
|
217
|
+
if (!isIdentity) out.push({
|
|
218
|
+
op: "transform",
|
|
219
|
+
m: local
|
|
220
|
+
});
|
|
221
|
+
if (group) out.push({
|
|
222
|
+
op: "pushGroup",
|
|
223
|
+
opacity,
|
|
224
|
+
blend: this.blend(),
|
|
225
|
+
filters: this.filters()
|
|
226
|
+
});
|
|
227
|
+
this.draw(out, ctx);
|
|
228
|
+
if (group) out.push({ op: "popGroup" });
|
|
229
|
+
out.push({ op: "restore" });
|
|
230
|
+
}
|
|
231
|
+
};
|
|
232
|
+
//#endregion
|
|
233
|
+
//#region src/nodes.ts
|
|
234
|
+
/**
|
|
235
|
+
* Built-in nodes for M1 (DESIGN.md §3.1): Group, Rect, Circle, Text.
|
|
236
|
+
* Path/Image/Video/Layout arrive with their milestones.
|
|
237
|
+
*/
|
|
238
|
+
var Group = class extends Node {
|
|
239
|
+
children;
|
|
240
|
+
constructor(props = {}) {
|
|
241
|
+
super(props);
|
|
242
|
+
this.children = props.children ?? [];
|
|
243
|
+
for (const child of this.children) child.parent = this;
|
|
244
|
+
}
|
|
245
|
+
add(child) {
|
|
246
|
+
child.parent = this;
|
|
247
|
+
this.children.push(child);
|
|
248
|
+
return this;
|
|
249
|
+
}
|
|
250
|
+
draw(out, ctx) {
|
|
251
|
+
const sorted = this.children.map((node, i) => ({
|
|
252
|
+
node,
|
|
253
|
+
i
|
|
254
|
+
})).sort((a, b) => a.node.zIndex() - b.node.zIndex() || a.i - b.i).map((e) => e.node);
|
|
255
|
+
for (const child of sorted) child.emit(out, ctx);
|
|
256
|
+
}
|
|
257
|
+
};
|
|
258
|
+
var Shape = class extends Node {
|
|
259
|
+
fill;
|
|
260
|
+
stroke;
|
|
261
|
+
strokeWidth;
|
|
262
|
+
constructor(props = {}) {
|
|
263
|
+
super(props);
|
|
264
|
+
this.fill = initProp(signal(""), props.fill);
|
|
265
|
+
this.stroke = initProp(signal(""), props.stroke);
|
|
266
|
+
this.strokeWidth = initProp(signal(0), props.strokeWidth);
|
|
267
|
+
this.registerTarget("fill", this.fill);
|
|
268
|
+
this.registerTarget("stroke", this.stroke);
|
|
269
|
+
this.registerTarget("strokeWidth", this.strokeWidth);
|
|
270
|
+
}
|
|
271
|
+
draw(out) {
|
|
272
|
+
const segs = this.pathSegs();
|
|
273
|
+
const path = out.resource({
|
|
274
|
+
kind: "path",
|
|
275
|
+
segs
|
|
276
|
+
});
|
|
277
|
+
const fill = this.fill();
|
|
278
|
+
if (fill) out.push({
|
|
279
|
+
op: "fillPath",
|
|
280
|
+
path,
|
|
281
|
+
paint: {
|
|
282
|
+
kind: "color",
|
|
283
|
+
color: fill
|
|
284
|
+
}
|
|
285
|
+
});
|
|
286
|
+
const stroke = this.stroke();
|
|
287
|
+
const width = this.strokeWidth();
|
|
288
|
+
if (stroke && width > 0) out.push({
|
|
289
|
+
op: "strokePath",
|
|
290
|
+
path,
|
|
291
|
+
paint: {
|
|
292
|
+
kind: "color",
|
|
293
|
+
color: stroke
|
|
294
|
+
},
|
|
295
|
+
stroke: { width }
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
function initProp(sig, init) {
|
|
300
|
+
if (typeof init === "function") sig.bindSource(init);
|
|
301
|
+
else if (init !== void 0) sig.set(init);
|
|
302
|
+
return sig;
|
|
303
|
+
}
|
|
304
|
+
var Rect = class extends Shape {
|
|
305
|
+
width;
|
|
306
|
+
height;
|
|
307
|
+
/** Corner radius; clamped to half the smaller dimension. radius = h/2 makes a pill. */
|
|
308
|
+
cornerRadius;
|
|
309
|
+
constructor(props = {}) {
|
|
310
|
+
super(props);
|
|
311
|
+
this.width = initProp(signal(0), props.width);
|
|
312
|
+
this.height = initProp(signal(0), props.height);
|
|
313
|
+
this.cornerRadius = initProp(signal(0), props.cornerRadius);
|
|
314
|
+
this.registerTarget("width", this.width);
|
|
315
|
+
this.registerTarget("height", this.height);
|
|
316
|
+
this.registerTarget("cornerRadius", this.cornerRadius);
|
|
317
|
+
}
|
|
318
|
+
intrinsicSize() {
|
|
319
|
+
return {
|
|
320
|
+
w: this.width(),
|
|
321
|
+
h: this.height()
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
pathSegs() {
|
|
325
|
+
const w = this.width();
|
|
326
|
+
const h = this.height();
|
|
327
|
+
const x = -w / 2;
|
|
328
|
+
const y = -h / 2;
|
|
329
|
+
const r = Math.min(Math.max(0, this.cornerRadius()), w / 2, h / 2);
|
|
330
|
+
if (r <= 0) return [
|
|
331
|
+
[
|
|
332
|
+
"M",
|
|
333
|
+
x,
|
|
334
|
+
y
|
|
335
|
+
],
|
|
336
|
+
[
|
|
337
|
+
"L",
|
|
338
|
+
x + w,
|
|
339
|
+
y
|
|
340
|
+
],
|
|
341
|
+
[
|
|
342
|
+
"L",
|
|
343
|
+
x + w,
|
|
344
|
+
y + h
|
|
345
|
+
],
|
|
346
|
+
[
|
|
347
|
+
"L",
|
|
348
|
+
x,
|
|
349
|
+
y + h
|
|
350
|
+
],
|
|
351
|
+
["Z"]
|
|
352
|
+
];
|
|
353
|
+
const HALF = Math.PI / 2;
|
|
354
|
+
return [
|
|
355
|
+
[
|
|
356
|
+
"M",
|
|
357
|
+
x + r,
|
|
358
|
+
y
|
|
359
|
+
],
|
|
360
|
+
[
|
|
361
|
+
"L",
|
|
362
|
+
x + w - r,
|
|
363
|
+
y
|
|
364
|
+
],
|
|
365
|
+
[
|
|
366
|
+
"E",
|
|
367
|
+
x + w - r,
|
|
368
|
+
y + r,
|
|
369
|
+
r,
|
|
370
|
+
r,
|
|
371
|
+
0,
|
|
372
|
+
-HALF,
|
|
373
|
+
0
|
|
374
|
+
],
|
|
375
|
+
[
|
|
376
|
+
"L",
|
|
377
|
+
x + w,
|
|
378
|
+
y + h - r
|
|
379
|
+
],
|
|
380
|
+
[
|
|
381
|
+
"E",
|
|
382
|
+
x + w - r,
|
|
383
|
+
y + h - r,
|
|
384
|
+
r,
|
|
385
|
+
r,
|
|
386
|
+
0,
|
|
387
|
+
0,
|
|
388
|
+
HALF
|
|
389
|
+
],
|
|
390
|
+
[
|
|
391
|
+
"L",
|
|
392
|
+
x + r,
|
|
393
|
+
y + h
|
|
394
|
+
],
|
|
395
|
+
[
|
|
396
|
+
"E",
|
|
397
|
+
x + r,
|
|
398
|
+
y + h - r,
|
|
399
|
+
r,
|
|
400
|
+
r,
|
|
401
|
+
0,
|
|
402
|
+
HALF,
|
|
403
|
+
Math.PI
|
|
404
|
+
],
|
|
405
|
+
[
|
|
406
|
+
"L",
|
|
407
|
+
x,
|
|
408
|
+
y + r
|
|
409
|
+
],
|
|
410
|
+
[
|
|
411
|
+
"E",
|
|
412
|
+
x + r,
|
|
413
|
+
y + r,
|
|
414
|
+
r,
|
|
415
|
+
r,
|
|
416
|
+
0,
|
|
417
|
+
Math.PI,
|
|
418
|
+
Math.PI + HALF
|
|
419
|
+
],
|
|
420
|
+
["Z"]
|
|
421
|
+
];
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
var Circle = class extends Shape {
|
|
425
|
+
radius;
|
|
426
|
+
constructor(props = {}) {
|
|
427
|
+
super(props);
|
|
428
|
+
this.radius = initProp(signal(0), props.radius);
|
|
429
|
+
this.registerTarget("radius", this.radius);
|
|
430
|
+
}
|
|
431
|
+
intrinsicSize() {
|
|
432
|
+
const d = this.radius() * 2;
|
|
433
|
+
return {
|
|
434
|
+
w: d,
|
|
435
|
+
h: d
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
pathSegs() {
|
|
439
|
+
const r = this.radius();
|
|
440
|
+
return [[
|
|
441
|
+
"E",
|
|
442
|
+
0,
|
|
443
|
+
0,
|
|
444
|
+
r,
|
|
445
|
+
r,
|
|
446
|
+
0,
|
|
447
|
+
0,
|
|
448
|
+
Math.PI * 2
|
|
449
|
+
], ["Z"]];
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
var ImageNode = class extends Node {
|
|
453
|
+
assetId;
|
|
454
|
+
width;
|
|
455
|
+
height;
|
|
456
|
+
constructor(props) {
|
|
457
|
+
super(props);
|
|
458
|
+
this.assetId = props.assetId;
|
|
459
|
+
this.width = initProp(signal(0), props.width);
|
|
460
|
+
this.height = initProp(signal(0), props.height);
|
|
461
|
+
this.registerTarget("width", this.width);
|
|
462
|
+
this.registerTarget("height", this.height);
|
|
463
|
+
}
|
|
464
|
+
intrinsicSize() {
|
|
465
|
+
return {
|
|
466
|
+
w: this.width(),
|
|
467
|
+
h: this.height()
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
draw(out) {
|
|
471
|
+
const w = this.width();
|
|
472
|
+
const h = this.height();
|
|
473
|
+
if (w <= 0 || h <= 0) return;
|
|
474
|
+
const image = out.resource({
|
|
475
|
+
kind: "image",
|
|
476
|
+
assetId: this.assetId
|
|
477
|
+
});
|
|
478
|
+
out.push({
|
|
479
|
+
op: "drawImage",
|
|
480
|
+
image,
|
|
481
|
+
dst: {
|
|
482
|
+
x: -w / 2,
|
|
483
|
+
y: -h / 2,
|
|
484
|
+
w,
|
|
485
|
+
h
|
|
486
|
+
}
|
|
487
|
+
});
|
|
488
|
+
}
|
|
489
|
+
};
|
|
490
|
+
/**
|
|
491
|
+
* Pure given a warmed VideoFrameSource (§3.8): emit() does only the
|
|
492
|
+
* frame-indexed media-time arithmetic — mediaT = trimStart + (t - at) * rate —
|
|
493
|
+
* and references the exact source-grid frame; backends resolve it.
|
|
494
|
+
*/
|
|
495
|
+
var Video = class extends Node {
|
|
496
|
+
assetId;
|
|
497
|
+
at;
|
|
498
|
+
trimStart;
|
|
499
|
+
playbackRate;
|
|
500
|
+
clipDuration;
|
|
501
|
+
sourceFps;
|
|
502
|
+
width;
|
|
503
|
+
height;
|
|
504
|
+
constructor(props) {
|
|
505
|
+
super(props);
|
|
506
|
+
this.assetId = props.assetId;
|
|
507
|
+
this.at = props.at ?? 0;
|
|
508
|
+
this.trimStart = props.trimStart ?? 0;
|
|
509
|
+
this.playbackRate = props.playbackRate ?? 1;
|
|
510
|
+
this.clipDuration = props.clipDuration;
|
|
511
|
+
this.sourceFps = props.sourceFps;
|
|
512
|
+
this.width = initProp(signal(0), props.width);
|
|
513
|
+
this.height = initProp(signal(0), props.height);
|
|
514
|
+
this.registerTarget("width", this.width);
|
|
515
|
+
this.registerTarget("height", this.height);
|
|
516
|
+
}
|
|
517
|
+
/** Frame-indexed media time for timeline time t; null when outside the clip. */
|
|
518
|
+
mediaTime(t) {
|
|
519
|
+
const local = (t - this.at) * this.playbackRate;
|
|
520
|
+
if (local < 0) return null;
|
|
521
|
+
if (this.clipDuration !== void 0 && t - this.at >= this.clipDuration) return null;
|
|
522
|
+
const mediaT = this.trimStart + local;
|
|
523
|
+
if (this.sourceFps !== void 0) return Math.floor(mediaT * this.sourceFps + 1e-9) / this.sourceFps;
|
|
524
|
+
return mediaT;
|
|
525
|
+
}
|
|
526
|
+
draw(out, ctx) {
|
|
527
|
+
const mediaT = this.mediaTime(ctx.time);
|
|
528
|
+
if (mediaT === null) return;
|
|
529
|
+
const w = this.width();
|
|
530
|
+
const h = this.height();
|
|
531
|
+
if (w <= 0 || h <= 0) return;
|
|
532
|
+
const image = out.resource({
|
|
533
|
+
kind: "videoFrame",
|
|
534
|
+
assetId: this.assetId,
|
|
535
|
+
mediaT
|
|
536
|
+
});
|
|
537
|
+
out.push({
|
|
538
|
+
op: "drawImage",
|
|
539
|
+
image,
|
|
540
|
+
dst: {
|
|
541
|
+
x: -w / 2,
|
|
542
|
+
y: -h / 2,
|
|
543
|
+
w,
|
|
544
|
+
h
|
|
545
|
+
}
|
|
546
|
+
});
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
var Text = class extends Node {
|
|
550
|
+
text;
|
|
551
|
+
fill;
|
|
552
|
+
fontSize;
|
|
553
|
+
fontFamily;
|
|
554
|
+
fontWeight;
|
|
555
|
+
align;
|
|
556
|
+
width;
|
|
557
|
+
lineHeight;
|
|
558
|
+
constructor(props = {}) {
|
|
559
|
+
super(props);
|
|
560
|
+
this.text = initProp(signal(""), props.text);
|
|
561
|
+
this.fill = initProp(signal("#000000"), props.fill);
|
|
562
|
+
this.fontSize = initProp(signal(16), props.fontSize);
|
|
563
|
+
this.fontFamily = props.fontFamily ?? "sans-serif";
|
|
564
|
+
this.fontWeight = props.fontWeight ?? 400;
|
|
565
|
+
this.align = props.align ?? "left";
|
|
566
|
+
this.width = initProp(signal(0), props.width);
|
|
567
|
+
this.lineHeight = props.lineHeight ?? 1.25;
|
|
568
|
+
this.registerTarget("width", this.width);
|
|
569
|
+
this.registerTarget("text", this.text);
|
|
570
|
+
this.registerTarget("fill", this.fill);
|
|
571
|
+
this.registerTarget("fontSize", this.fontSize);
|
|
572
|
+
}
|
|
573
|
+
intrinsicSize(measurer) {
|
|
574
|
+
const text = this.text();
|
|
575
|
+
if (!text) return {
|
|
576
|
+
w: 0,
|
|
577
|
+
h: 0
|
|
578
|
+
};
|
|
579
|
+
const font = {
|
|
580
|
+
family: this.fontFamily,
|
|
581
|
+
size: this.fontSize(),
|
|
582
|
+
weight: this.fontWeight
|
|
583
|
+
};
|
|
584
|
+
const maxWidth = this.width();
|
|
585
|
+
const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, measurer);
|
|
586
|
+
const widest = Math.max(...lines.map((l) => quantize(measurer.measureText(l, font).width)), 0);
|
|
587
|
+
return {
|
|
588
|
+
w: maxWidth > 0 ? maxWidth : widest,
|
|
589
|
+
h: quantize(font.size * this.lineHeight) * lines.length
|
|
590
|
+
};
|
|
591
|
+
}
|
|
592
|
+
/** Text draws from a baseline origin at its align edge, not a center (§3.6). */
|
|
593
|
+
flowOffset(measurer) {
|
|
594
|
+
const size = this.intrinsicSize(measurer);
|
|
595
|
+
const font = {
|
|
596
|
+
family: this.fontFamily,
|
|
597
|
+
size: this.fontSize(),
|
|
598
|
+
weight: this.fontWeight
|
|
599
|
+
};
|
|
600
|
+
const firstLine = breakLines(this.text(), font, this.width() > 0 ? this.width() : void 0, measurer)[0] ?? "";
|
|
601
|
+
const ascent = measurer.measureText(firstLine, font).ascent;
|
|
602
|
+
return {
|
|
603
|
+
x: this.align === "left" ? 0 : this.align === "center" ? -size.w / 2 : -size.w,
|
|
604
|
+
y: -ascent
|
|
605
|
+
};
|
|
606
|
+
}
|
|
607
|
+
draw(out, ctx) {
|
|
608
|
+
const text = this.text();
|
|
609
|
+
if (!text) return;
|
|
610
|
+
const font = {
|
|
611
|
+
family: this.fontFamily,
|
|
612
|
+
size: this.fontSize(),
|
|
613
|
+
weight: this.fontWeight
|
|
614
|
+
};
|
|
615
|
+
const maxWidth = this.width();
|
|
616
|
+
const lines = breakLines(text, font, maxWidth > 0 ? maxWidth : void 0, ctx.measurer);
|
|
617
|
+
const step = quantize(font.size * this.lineHeight);
|
|
618
|
+
for (let i = 0; i < lines.length; i++) {
|
|
619
|
+
if (!lines[i]) continue;
|
|
620
|
+
out.push({
|
|
621
|
+
op: "fillText",
|
|
622
|
+
text: lines[i],
|
|
623
|
+
font,
|
|
624
|
+
paint: {
|
|
625
|
+
kind: "color",
|
|
626
|
+
color: this.fill()
|
|
627
|
+
},
|
|
628
|
+
x: 0,
|
|
629
|
+
y: i * step,
|
|
630
|
+
...this.align !== "left" ? { align: this.align } : {}
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
//#endregion
|
|
636
|
+
//#region src/layoutEngine.ts
|
|
637
|
+
var LayoutEngineMissingError = class extends Error {
|
|
638
|
+
constructor() {
|
|
639
|
+
super("a Layout node was evaluated but no LayoutEngine is registered — await loadYogaLayoutEngine() from '@glissade/scene/layout' before mounting/rendering (the engine is wasm and loads async; evaluate() never awaits, §2.5)");
|
|
640
|
+
this.name = "LayoutEngineMissingError";
|
|
641
|
+
}
|
|
642
|
+
};
|
|
643
|
+
let engine = null;
|
|
644
|
+
function setLayoutEngine(e) {
|
|
645
|
+
engine = e;
|
|
646
|
+
}
|
|
647
|
+
function getLayoutEngine() {
|
|
648
|
+
return engine;
|
|
649
|
+
}
|
|
650
|
+
function requireLayoutEngine() {
|
|
651
|
+
if (!engine) throw new LayoutEngineMissingError();
|
|
652
|
+
return engine;
|
|
653
|
+
}
|
|
654
|
+
//#endregion
|
|
655
|
+
export { applyToPoint as _, Circle as a, multiply as b, Rect as c, Node as d, breakLines as f, IDENTITY as g, createDisplayListBuilder as h, setLayoutEngine as i, Text as l, quantize as m, getLayoutEngine as n, Group as o, estimatingMeasurer as p, requireLayoutEngine as r, ImageNode as s, LayoutEngineMissingError as t, Video as u, fromTRS as v, matEquals as y };
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@glissade/scene",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "glissade scene graph: nodes, transforms, DisplayList emission. Renderer-agnostic; zero DOM/Node dependencies.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"default": "./dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"./layout": {
|
|
14
|
+
"types": "./dist/layout.d.ts",
|
|
15
|
+
"default": "./dist/layout.js"
|
|
16
|
+
}
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"yoga-layout": "^3.2.1",
|
|
23
|
+
"@glissade/core": "0.1.0"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "git+https://github.com/tyevco/glissade.git",
|
|
28
|
+
"directory": "packages/scene"
|
|
29
|
+
},
|
|
30
|
+
"scripts": {
|
|
31
|
+
"build": "tsdown",
|
|
32
|
+
"typecheck": "tsc --noEmit"
|
|
33
|
+
}
|
|
34
|
+
}
|