@solidrt/core 0.0.40 → 0.0.41

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/src/gpu.ts CHANGED
@@ -21,6 +21,26 @@
21
21
  // both. WITHIN one pipeline draw, `blend: "add"` accumulates overlapping
22
22
  // geometry additively (order-independent, no sorting); anything else draws
23
23
  // with GL blending disabled and overwrites.
24
+ //
25
+ // The pixel contract. Three facts hold for every texture and target:
26
+ //
27
+ // - Clip space is y-down. `gl_Position` y = -1 is the top of the target, +1
28
+ // the bottom (GL's row 0 is clip y = -1, and Impeller samples row 0 as the
29
+ // top). A vertex stage carrying camera-up geometry must negate y, or fold
30
+ // the flip into its projection, or it draws upside down: Vulkan's
31
+ // convention, not desktop GL's. The fragment path absorbs the same flip
32
+ // already, so `vUV` is 0..1 with top-left origin and a fragment-only shader
33
+ // never sees it.
34
+ // - Color is premultiplied alpha. A target's RGB is expected already
35
+ // multiplied by its A - `vec4(rgb * a, a)`, not `vec4(rgb, a)`, which
36
+ // composites as opaque. That is what Impeller composites and what
37
+ // `<texture blendMode>` blends; `clearColor` is premultiplied too, so the
38
+ // default transparent black needs no thought.
39
+ // - Values are non-linear RGBA8, with no color-space concept. Every texture
40
+ // and target holds 8-bit RGBA UNORM exactly as written; nothing converts to
41
+ // or from linear light. `filter: "linear"` averages and `blend: "add"`
42
+ // accumulates non-linear values - the usual approximation, stated so
43
+ // shaders written today stay correct if a format vocabulary arrives.
24
44
 
25
45
  import { createEffect, createSignal, getOwner, onCleanup, untrack } from "@solidjs/signals"
26
46
  import * as gpu from "flux:gpu"
@@ -30,13 +50,22 @@ import * as gpu from "flux:gpu"
30
50
  // signal changes inside a long-lived component, handed across owners, ...).
31
51
  // Without it, each rebuild would stack another onCleanup on the component
32
52
  // owner: a leak until unmount, then a double-free against manual destroys.
33
- export type CreateOptions = { manual?: boolean }
53
+ // `label` is a free-form debug name (WebGPU's label): surfaced by the dev
54
+ // tooling's GPU inventory and engine log messages, never interpreted, kept
55
+ // across id-stable resizes.
56
+ export type CreateOptions = { manual?: boolean; label?: string }
34
57
 
35
58
  // Sampling options every texture-producing create* helper accepts, applied at
36
59
  // creation as a property of the texture id (there is no set-sampler-later).
37
60
  export type SamplerOptions = { filter?: gpu.FilterMode; wrap?: gpu.WrapMode }
38
61
  export type { FilterMode, WrapMode } from "flux:gpu"
39
62
 
63
+ // The branded id types, one per id space (see flux:gpu): plain numbers at
64
+ // runtime, distinct types to the checker, so a cross-space slip like
65
+ // destroyBuffer(textureId) fails to compile. Exported so apps can annotate
66
+ // storage (`let ids: TextureId[]`).
67
+ export type { BufferId, ProgramId, RenderPipelineId, ShaderStageId, TextureId } from "flux:gpu"
68
+
40
69
  // Re-exported so callers that depend on @solidrt/core -- like @solidrt/components
41
70
  // -- need not import flux directly: destroyTexture for the manual-cleanup path
42
71
  // (textures made outside a reactive scope, e.g. after an await, are not
@@ -57,20 +86,64 @@ export {
57
86
  uploadTexture,
58
87
  } from "flux:gpu"
59
88
 
60
- // Pipeline plumbing re-exported raw: setDrawCount re-renders a pipeline after
61
- // its buffer gained or lost dynamic geometry; destroyBuffer is the manual
62
- // cleanup path for buffers created outside a reactive scope.
63
- export { destroyBuffer, setDrawCount } from "flux:gpu"
64
- export type { BlendMode, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
89
+ // Pipeline plumbing re-exported raw: setDraw re-renders a pipeline with an
90
+ // updated draw range (vertexCount after its buffer gained or lost dynamic
91
+ // geometry, firstVertex for a different window of a shared buffer,
92
+ // instanceCount for an instanced population; absent keys keep their current
93
+ // value, like params); destroyBuffer is the manual
94
+ // cleanup path for buffers created outside a reactive scope. renderTarget is
95
+ // the explicit render verb for `render: "manual"` targets - targets whose
96
+ // pass is state (accumulation, feedback) rather than a pure function of its
97
+ // inputs, which the runtime therefore never renders on its own; the app
98
+ // steps them, usually from onFrame. (`render: "manual"` is the render mode;
99
+ // the unrelated `manual: true` create option is the lifetime opt-out above.)
100
+ // copyTexture overwrites a manual target with another texture's pixels
101
+ // GPU-side (exact, same size): seed a loadOp "load" accumulator, snapshot a
102
+ // ping-pong buffer, reset state to a known image.
103
+ export { copyTexture, destroyBuffer, renderTarget, setDraw } from "flux:gpu"
104
+ export type { BlendMode, DrawRange, ShaderParams, Topology, VertexAttribute } from "flux:gpu"
105
+
106
+ // The device ceilings (max texture/target size, sampler inputs per pass,
107
+ // vertex attributes per pipeline), queried once at startup. Creates and binds
108
+ // validate against them and throw naming the limit; read these to size within
109
+ // the device instead (e.g. clamp a supersampled target to maxTextureSize).
110
+ export { limits } from "flux:gpu"
65
111
 
66
112
  // The raw shading layer, re-exported as-is - no reactive wrapper, the app
67
113
  // owns these lifetimes. compileShader compiles one stage from complete GLSL
68
114
  // ES (or with the standard header via { header: true }); linkProgram links a
69
- // vertex and a fragment stage into a program handle that backs any number of
70
- // createShaderTarget calls (and compiles nothing per target); destroyShader /
71
- // destroyProgram free by id space, either order safe against live targets.
72
- // createShader/createPipeline remain the fused conveniences on top.
73
- export { compileShader, destroyProgram, destroyShader, linkProgram } from "flux:gpu"
115
+ // vertex and a fragment stage into a program handle; createRenderPipeline
116
+ // pairs a program with draw state (vertex layout, topology, blend, depth -
117
+ // how it draws) into a pipeline handle that backs any number of
118
+ // createShaderTarget calls (and compiles nothing per pipeline or target);
119
+ // destroyShader / destroyProgram / destroyRenderPipeline free by id space,
120
+ // any order safe against live users. createShaderTexture/createPipelineTexture
121
+ // remain the fused conveniences on top.
122
+ export {
123
+ compileShader,
124
+ createRenderPipeline,
125
+ destroyProgram,
126
+ destroyRenderPipeline,
127
+ destroyShader,
128
+ linkProgram,
129
+ } from "flux:gpu"
130
+
131
+ /**
132
+ * Tags an inline GLSL source, returning it unchanged. Shaders small enough to
133
+ * belong beside the code that uses them stay in the file; the tag is what makes
134
+ * them legible there, because editors highlight GLSL inside a template literal
135
+ * only when a known tag marks it (the name matters - `glsl` is the one the
136
+ * grammars look for).
137
+ *
138
+ * Interpolated values are stringified verbatim, with no GLSL-aware formatting:
139
+ * `${2}` splices in the int literal `2`, which will not assign to a float. Pass
140
+ * anything that varies as a uniform instead of building it into the source.
141
+ *
142
+ * Raw semantics, so backslashes reach the compiler as written: the GLSL
143
+ * preprocessor continues a line with a trailing `\`, which a cooked template
144
+ * would reject as an invalid escape and silently pass through as `undefined`.
145
+ */
146
+ export let glsl = String.raw
74
147
 
75
148
  // captureSnapshot renders a node to a texture and readTexture reads any
76
149
  // texture's bytes back. A laid-out node captures its layout box; a `d-*` node
@@ -108,7 +181,7 @@ export function createTexture(
108
181
  width: number,
109
182
  height: number,
110
183
  opts?: CreateOptions & SamplerOptions,
111
- ): number {
184
+ ): gpu.TextureId {
112
185
  let id = gpu.createTexture(data, width, height, opts)
113
186
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
114
187
  return id
@@ -128,7 +201,7 @@ export function createMutableTexture(
128
201
  width: number,
129
202
  height: number,
130
203
  opts?: CreateOptions & SamplerOptions,
131
- ): number {
204
+ ): gpu.TextureId {
132
205
  let id = gpu.createMutableTexture(data, width, height, opts)
133
206
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
134
207
  return id
@@ -137,13 +210,17 @@ export function createMutableTexture(
137
210
  /**
138
211
  * Compiles a GLSL ES 3.00 fragment shader and renders it into a texture,
139
212
  * returning the texture id (usable anywhere a normal texture id is, e.g.
140
- * `<texture src>`). The fragment body may reference `vUV` (0..1, top-left
141
- * origin), `iResolution`, `iTime`, and any uniform it declares (`float`/`int`
213
+ * `<texture src>`) - hence the name: what comes back is a texture, not a
214
+ * shader object. The fragment body may reference `vUV` (0..1, top-left
215
+ * origin), `iResolution`, and any uniform it declares (`float`/`int`
142
216
  * scalars from a number, `vec2`/`vec3`/`vec4`/`mat4` from a flat number
143
217
  * array); drive their values with `<texture src={id} params={{...}} />`
144
218
  * (preferred) or, when there is no `<texture>` element for it, imperatively
145
- * with `setShaderParams`.
146
- * `textures` binds each declared `uniform sampler2D` to an existing texture id
219
+ * with `setShaderParams`. `params` is its own argument - it seeds the same
220
+ * live channel those two drive - and takes `null` (or nothing) for a shader
221
+ * without uniforms. A time-driven shader declares its own time uniform
222
+ * (`uniform float uTime;`) and the app drives it like any other value.
223
+ * `opts.textures` binds each declared `uniform sampler2D` to an existing texture id
147
224
  * (e.g. a camera or decoded image, or another shader/pipeline target) so the
148
225
  * shader can read it; bound inputs are live dependencies, so the shader
149
226
  * re-renders whenever a source changes - including a sampled target
@@ -151,72 +228,85 @@ export function createMutableTexture(
151
228
  * texture and shader program when the reactive owner is disposed (opt out
152
229
  * with `{ manual: true }`); create outside any reactive scope for
153
230
  * app-lifetime shaders. For a shader whose source or inputs change
154
- * reactively, use {@link createShaderMemo} instead.
231
+ * reactively, use {@link createShaderTextureMemo} instead.
155
232
  *
156
- * That preamble (`#version 300 es`, precision, `vUV`, `iResolution`, `iTime`,
233
+ * That preamble (`#version 300 es`, precision, `vUV`, `iResolution`,
157
234
  * `fragColor`) is injected only into sources that do not declare their own
158
- * `#version` line. A source starting with `#version 300 es` compiles exactly
235
+ * `#version` line, and declares exactly what the runtime provides - nothing
236
+ * app-driven. A source starting with `#version 300 es` compiles exactly
159
237
  * as written, so a shader carrying its own uniform names - one ported from
160
238
  * elsewhere - runs unchanged here without dropping to compileShader /
161
239
  * linkProgram. The built-in vertex stage still supplies `vUV`; declare
162
240
  * `in vec2 vUV;` yourself to read it.
163
241
  */
164
- export function createShader(
242
+ export function createShaderTexture(
165
243
  fragmentSrc: string,
166
244
  width: number,
167
245
  height: number,
168
- params?: gpu.ShaderParams,
169
- textures?: Record<string, number>,
170
- opts?: CreateOptions & SamplerOptions,
171
- ): number {
172
- let id = gpu.createShader(fragmentSrc, width, height, params, textures, opts)
246
+ params?: gpu.ShaderParams | null,
247
+ opts?: CreateOptions & SamplerOptions & { textures?: Record<string, gpu.TextureId> },
248
+ ): gpu.TextureId {
249
+ let id = gpu.createShaderTexture(fragmentSrc, width, height, params, opts)
173
250
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
174
251
  return id
175
252
  }
176
253
 
177
254
  /**
178
- * Creates a render target over a program from `linkProgram` and renders it
179
- * once, returning the texture id (usable anywhere a normal texture id is,
180
- * e.g. `<texture src>`; resize with `setShaderSize`, drive uniforms with
181
- * `<texture params>` or `setShaderParams`). Many targets may share one
182
- * program, and creating a target compiles nothing. The mesh options mirror
183
- * `createPipeline`: a raw-linked program carries its own vertex stage, so a
184
- * fullscreen pass is `{ vertexCount: 3 }` over a covering-triangle vertex
185
- * stage. Frees the target when the reactive owner is disposed (opt out with
186
- * `opts.manual`); the program is yours and outlives it.
255
+ * Creates a render target over a pipeline from `createRenderPipeline` and
256
+ * renders it once, returning the texture id (usable anywhere a normal
257
+ * texture id is, e.g. `<texture src>`; resize with `setShaderSize`, drive
258
+ * uniforms with `<texture params>` or `setShaderParams`). Many targets may
259
+ * share one pipeline, and creating a target compiles nothing. The target
260
+ * brings the per-target half: size, the concrete vertex `buffer` the
261
+ * pipeline's attribute layout describes, the draw range (`vertexCount`
262
+ * defaults to the rest of the buffer from `firstVertex` on, `instanceCount`
263
+ * repeats it as instances told apart by `gl_InstanceID`; a fullscreen pass
264
+ * over an attributeless pipeline is `{ vertexCount: 3 }` with a
265
+ * covering-triangle vertex stage), uniforms, and
266
+ * `clearColor`. Draw state (`attributes`, `topology`, `blend`, `depth`,
267
+ * `depthWrite`) lives on the pipeline and throws here. Frees the target when
268
+ * the reactive owner is disposed (opt out with `opts.manual`); the pipeline
269
+ * is yours and outlives it.
270
+ *
271
+ * `render: "manual"` makes it a manual target: the runtime never renders it
272
+ * (it starts cleared to `clearColor`), only an explicit `renderTarget(id)`
273
+ * does, in call order - which is what legalizes feedback state stepped by
274
+ * the app. `loadOp: "load"` (manual-only, throws otherwise) keeps the
275
+ * previous contents under each draw - single-target accumulation - while
276
+ * the default `"clear"` clears to `clearColor` per render; state that must
277
+ * read its own pixels (decay, blur, simulation) still ping-pongs across two
278
+ * manual targets, and `copyTexture` seeds either shape. Distinct from the
279
+ * `manual` lifetime option: `render` is who renders, `manual` is who frees.
187
280
  */
188
281
  export function createShaderTarget(
189
- program: number,
282
+ pipeline: gpu.RenderPipelineId,
190
283
  width: number,
191
284
  height: number,
285
+ params?: gpu.ShaderParams | null,
192
286
  opts?: {
193
- params?: gpu.ShaderParams
194
- textures?: Record<string, number>
195
- attributes?: gpu.VertexAttribute[]
196
- buffer?: number
197
- topology?: gpu.Topology
198
- vertexCount?: number
199
- depth?: boolean
200
- depthWrite?: boolean
201
- blend?: gpu.BlendMode
287
+ textures?: Record<string, gpu.TextureId>
288
+ buffer?: gpu.BufferId
202
289
  clearColor?: [number, number, number, number]
203
- } & CreateOptions &
290
+ render?: "auto" | "manual"
291
+ loadOp?: "clear" | "load"
292
+ } & gpu.DrawRange &
293
+ CreateOptions &
204
294
  SamplerOptions,
205
- ): number {
206
- let id = gpu.createShaderTarget(program, width, height, opts)
295
+ ): gpu.TextureId {
296
+ let id = gpu.createShaderTarget(pipeline, width, height, params, opts)
207
297
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
208
298
  return id
209
299
  }
210
300
 
211
- /** The reactive shader description `createShaderMemo` builds from. Sampling
212
- * (`filter`/`wrap`) is creation-time state, so changing it rebuilds at a
213
- * fresh id, like a fragment-source or sampler-binding change. */
301
+ /** The reactive shader description `createShaderTextureMemo` builds from.
302
+ * Sampling (`filter`/`wrap`) is creation-time state, so changing it rebuilds
303
+ * at a fresh id, like a fragment-source or sampler-binding change. */
214
304
  export type ShaderSpec = {
215
305
  fragmentSrc: string
216
306
  width: number
217
307
  height: number
218
308
  params?: gpu.ShaderParams
219
- textures?: Record<string, number>
309
+ textures?: Record<string, gpu.TextureId>
220
310
  } & SamplerOptions
221
311
 
222
312
  // Shallow name->value equality for params/textures records; treats undefined
@@ -261,12 +351,12 @@ function sameRecord(
261
351
  * covered: it throws at the call site, where an ordinary try/catch works and
262
352
  * there is no previous shader to fall back to.
263
353
  */
264
- export function createShaderMemo(
354
+ export function createShaderTextureMemo(
265
355
  spec: () => ShaderSpec,
266
356
  opts?: { onError?: (error: unknown) => void },
267
- ): () => number {
357
+ ): () => gpu.TextureId {
268
358
  let make = (s: ShaderSpec) =>
269
- gpu.createShader(s.fragmentSrc, s.width, s.height, s.params, s.textures, { filter: s.filter, wrap: s.wrap })
359
+ gpu.createShaderTexture(s.fragmentSrc, s.width, s.height, s.params, { textures: s.textures, filter: s.filter, wrap: s.wrap })
270
360
  let current = untrack(spec)
271
361
  let currentId = make(current)
272
362
  let [id, setId] = createSignal(currentId)
@@ -317,45 +407,56 @@ function toUint8(data: ArrayBuffer | ArrayBufferView): Uint8Array {
317
407
  /**
318
408
  * Compiles a GLSL ES 3.00 vertex+fragment pipeline and renders it into a
319
409
  * texture, returning the texture id (usable anywhere a normal texture id is,
320
- * e.g. `<texture src>`). Unlike `createShader` the vertex stage is yours:
410
+ * e.g. `<texture src>`) - named, like `createShaderTexture`, for what comes
411
+ * back. Unlike `createShaderTexture` the vertex stage is yours:
321
412
  * declare `in` attributes matching `opts.attributes` (one interleaved vertex
322
413
  * in `opts.buffer`, a {@link createBuffer} id) and your own varyings toward
323
- * the fragment stage. Both sources may reference `iResolution`/`iTime` and
324
- * any uniform they declare (`float`/`int` scalars from a number,
325
- * `vec2`/`vec3`/`vec4`/`mat4` from a flat number array); drive values with
326
- * `<texture src={id} params={{...}} />` or `setShaderParams`, exactly like a
327
- * fragment shader.
414
+ * the fragment stage. Clip space is y-down: `gl_Position` y = -1 is the top
415
+ * row of the target and +1 the bottom, so camera-up geometry must negate y
416
+ * (or fold the flip into its projection) to display up. Both sources may
417
+ * reference `iResolution` and any uniform they declare (`float`/`int`
418
+ * scalars from a number, `vec2`/`vec3`/`vec4`/`mat4` from a flat number
419
+ * array); drive values with `<texture src={id} params={{...}} />` or
420
+ * `setShaderParams`, exactly like a fragment shader.
328
421
  * `opts.depth` attaches a private depth buffer (cleared + tested per render);
329
422
  * `opts.depthWrite: false` (requires depth) keeps the test but stops the
330
423
  * draw from writing depth. `opts.blend: "add"` makes the draw accumulate
331
424
  * overlapping geometry additively (order-independent, no sorting) instead of
332
425
  * overwriting; a depth-tested additive pass is `{ depth: true, blend: "add",
333
426
  * depthWrite: false }` - each option only does what it says, neither implies
334
- * the other. `opts.vertexCount` defaults to the whole buffer and can be
335
- * changed later with `setDrawCount`. Frees the texture and GL program when the reactive
427
+ * the other. The draw range (`firstVertex`, `vertexCount`, `instanceCount` -
428
+ * see DrawRange) defaults to the whole buffer drawn once and can be changed
429
+ * later with `setDraw`; `instanceCount` is the standard answer to particles
430
+ * and repeated meshes, N copies of the range told apart by `gl_InstanceID`
431
+ * in the vertex stage. `opts.render: "manual"` and
432
+ * `opts.loadOp` behave exactly as on {@link createShaderTarget}: step the
433
+ * target with `renderTarget(id)`, and `loadOp: "load"` (manual-only) keeps
434
+ * the previous contents under each draw. Frees the texture and GL program when the reactive
336
435
  * owner is disposed (opt out with `opts.manual`); create outside any reactive
337
436
  * scope for app-lifetime pipelines.
338
437
  */
339
- export function createPipeline(
438
+ export function createPipelineTexture(
340
439
  vertexSrc: string,
341
440
  fragmentSrc: string,
342
441
  width: number,
343
442
  height: number,
443
+ params?: gpu.ShaderParams | null,
344
444
  opts?: {
345
- params?: gpu.ShaderParams
346
- textures?: Record<string, number>
445
+ textures?: Record<string, gpu.TextureId>
347
446
  attributes?: gpu.VertexAttribute[]
348
- buffer?: number
447
+ buffer?: gpu.BufferId
349
448
  topology?: gpu.Topology
350
- vertexCount?: number
351
449
  depth?: boolean
352
450
  depthWrite?: boolean
353
451
  blend?: gpu.BlendMode
354
452
  clearColor?: [number, number, number, number]
355
- } & CreateOptions &
453
+ render?: "auto" | "manual"
454
+ loadOp?: "clear" | "load"
455
+ } & gpu.DrawRange &
456
+ CreateOptions &
356
457
  SamplerOptions,
357
- ): number {
358
- let id = gpu.createPipeline(vertexSrc, fragmentSrc, width, height, opts)
458
+ ): gpu.TextureId {
459
+ let id = gpu.createPipelineTexture(vertexSrc, fragmentSrc, width, height, params, opts)
359
460
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyTexture(id))
360
461
  return id
361
462
  }
@@ -367,10 +468,10 @@ export function createPipeline(
367
468
  * creation, so reserve room up front for dynamic geometry. Freed automatically
368
469
  * when the reactive owner is disposed (opt out with `{ manual: true }`);
369
470
  * created outside a reactive scope you must call `destroyBuffer` yourself.
370
- * Destroy pipelines before their buffer.
471
+ * (Destruction order relative to pipelines does not matter.)
371
472
  */
372
- export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateOptions): number {
373
- let id = gpu.createBuffer(toUint8(data))
473
+ export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateOptions): gpu.BufferId {
474
+ let id = gpu.createBuffer(toUint8(data), opts)
374
475
  if (!opts?.manual && getOwner()) onCleanup(() => gpu.destroyBuffer(id))
375
476
  return id
376
477
  }
@@ -380,6 +481,6 @@ export function createBuffer(data: ArrayBuffer | ArrayBufferView, opts?: CreateO
380
481
  * pipeline drawing from the buffer re-renders with its last-applied params,
381
482
  * so geometry-only changes reach the screen without a params update.
382
483
  */
383
- export function writeBuffer(id: number, data: ArrayBuffer | ArrayBufferView, byteOffset?: number): void {
484
+ export function writeBuffer(id: gpu.BufferId, data: ArrayBuffer | ArrayBufferView, byteOffset?: number): void {
384
485
  gpu.writeBuffer(id, toUint8(data), byteOffset)
385
486
  }
package/src/image.ts CHANGED
@@ -1,10 +1,11 @@
1
1
  // CPU image codec plus the reactive load-and-upload convenience. decodeImage is
2
2
  // the raw primitive (no GPU involved); createImage is the owner-aware layer on
3
3
  // top that fetches/decodes/uploads for you and swaps the texture when the source
4
- // changes - the same relationship createTexture/createShader have to flux:gpu.
4
+ // changes - the same relationship createTexture/createShaderTexture have to
5
+ // flux:gpu.
5
6
 
6
7
  import { createMemo, onCleanup } from "@solidjs/signals"
7
- import { createTexture, destroyTexture } from "./gpu"
8
+ import { createTexture, destroyTexture, type TextureId } from "./gpu"
8
9
 
9
10
  export type DecodedImage = {
10
11
  data: Uint8Array
@@ -32,13 +33,13 @@ export type ImageSource = string | Uint8Array
32
33
  // Uint8Array sources bypass all of this: no key, per-mount texture.
33
34
  type ImageEntry = {
34
35
  refs: number
35
- texture: number
36
- promise: Promise<number>
36
+ texture: TextureId | undefined
37
+ promise: Promise<TextureId>
37
38
  }
38
39
 
39
40
  let imageCache = new Map<string, ImageEntry>()
40
41
 
41
- async function loadImage(url: string): Promise<number> {
42
+ async function loadImage(url: string): Promise<TextureId> {
42
43
  // Images are assets: cache to disk, no freshness. Use a versioned URL (or
43
44
  // fetch + decodeImage manually) when a URL's content must be re-checked.
44
45
  let res = await fetch(url, { cache: "force-cache" })
@@ -56,7 +57,7 @@ async function loadImage(url: string): Promise<number> {
56
57
  function acquireImage(url: string): ImageEntry {
57
58
  let entry = imageCache.get(url)
58
59
  if (!entry) {
59
- let e: ImageEntry = { refs: 0, texture: -1, promise: undefined as never }
60
+ let e: ImageEntry = { refs: 0, texture: undefined, promise: undefined as never }
60
61
  e.promise = loadImage(url).then(
61
62
  id => {
62
63
  // Everyone released while the load was in flight: nothing owns the
@@ -91,7 +92,7 @@ function releaseImage(url: string): void {
91
92
  if (!entry) return
92
93
  entry.refs--
93
94
  if (entry.refs > 0) return
94
- if (entry.texture >= 0) {
95
+ if (entry.texture !== undefined) {
95
96
  destroyTexture(entry.texture)
96
97
  imageCache.delete(url)
97
98
  }
@@ -122,10 +123,10 @@ function releaseImage(url: string): void {
122
123
  * `createImage` earns its async only for a fetched string URL or a reactive
123
124
  * source.
124
125
  */
125
- export function createImage(src: ImageSource | (() => ImageSource)): () => number {
126
+ export function createImage(src: ImageSource | (() => ImageSource)): () => TextureId {
126
127
  let getSrc = typeof src === "function" ? src : () => src
127
128
 
128
- return createMemo<number>(async () => {
129
+ return createMemo<TextureId>(async () => {
129
130
  let source = getSrc()
130
131
 
131
132
  if (typeof source === "string") {
@@ -138,9 +139,9 @@ export function createImage(src: ImageSource | (() => ImageSource)): () => numbe
138
139
  }
139
140
 
140
141
  // Byte sources decode and upload synchronously; this run owns the texture.
141
- let holder = { id: -1 }
142
+ let holder: { id: TextureId | undefined } = { id: undefined }
142
143
  onCleanup(() => {
143
- if (holder.id >= 0) destroyTexture(holder.id)
144
+ if (holder.id !== undefined) destroyTexture(holder.id)
144
145
  })
145
146
  let decoded: DecodedImage
146
147
  try {
package/src/index.ts CHANGED
@@ -13,8 +13,11 @@ export type { GamepadState } from "./gamepad"
13
13
  export { capabilities } from "./capabilities"
14
14
  export type { Capabilities, WindowSizeClass } from "./capabilities"
15
15
  export { createTexture } from "./gpu"
16
+ export type { TextureId } from "./gpu"
16
17
  export { createImage, decodeImage } from "./image"
17
18
  export type { DecodedImage, ImageSource } from "./image"
19
+ export { parseSvg, svg } from "./svg"
20
+ export type { SvgDocument, SvgDraw } from "./svg"
18
21
  export { createScroll } from "./scroll"
19
22
  export type { Scroll, ScrollAxis, ScrollOffset, ScrollOptions } from "./scroll"
20
23
  export type {
package/src/renderer.ts CHANGED
@@ -129,21 +129,22 @@ export function scanForOrphans(now: number): void {
129
129
  }
130
130
 
131
131
  // A property the native tree rejected must not take down the reactive system:
132
- // a typo'd or not-yet-implemented prop poisons only itself. Warn once per
133
- // element kind + property with a stack (the dev server remaps its frames to
134
- // the .tsx source), then ignore further writes of the same pair.
135
- let warnedUnknownProps = new Set<string>()
132
+ // a typo'd, not-yet-implemented, or detached-only prop poisons only itself.
133
+ // Warn once per element kind + property with a stack (the dev server remaps
134
+ // its frames to the .tsx source), then ignore further writes of the same pair.
135
+ let warnedRejectedProps = new Set<string>()
136
136
 
137
137
  function setTreeProperty(node: ProxyNode, name: string, value: unknown): void {
138
138
  try {
139
139
  tree.setProperty(node.id, name, value)
140
140
  } catch (e) {
141
- if (!String(e).includes("unknown property")) throw e
141
+ let message = String(e)
142
+ if (!message.includes("unknown property") && !message.includes("detached-only")) throw e
142
143
  let key = node.elementType + "." + name
143
- if (warnedUnknownProps.has(key)) return
144
- warnedUnknownProps.add(key)
144
+ if (warnedRejectedProps.has(key)) return
145
+ warnedRejectedProps.add(key)
145
146
  let stack = new Error().stack ?? ""
146
- console.warn(`Ignoring unknown property '${name}' on <${node.elementType}>\n${stack}`)
147
+ console.warn(`Ignoring property '${name}' on <${node.elementType}>: ${message}\n${stack}`)
147
148
  }
148
149
  }
149
150
 
@@ -105,8 +105,8 @@ declare module "srt:apps" {
105
105
  * installs nothing and leaves it alone. `size` is the version's
106
106
  * manifest-declared size (bundle plus assets) - claimed rather than walked,
107
107
  * so that listing stays cheap; `info()` reports what is actually on disk.
108
- * `icon` is the manifest-declared icon's SVG source, ready for an `<svg>`
109
- * src; absent when the app declares none (or the file is unreadable).
108
+ * `icon` is the manifest-declared icon's SVG source, ready for `parseSvg`;
109
+ * absent when the app declares none (or the file is unreadable).
110
110
  */
111
111
  export type InstalledApp = {
112
112
  id: string
@@ -3,7 +3,8 @@
3
3
  // final transcripts through onResult. With wakeWord the session starts
4
4
  // asleep behind an efficient wake word detector (livekit-wakeword) and only
5
5
  // transcribes after the wake word. startRecognition resolves once the models
6
- // are loaded and listening has begun; it rejects when loading fails.
6
+ // are loaded and listening has begun; it rejects when the microphone cannot be
7
+ // opened or the models fail to load.
7
8
  // Models are passed as bytes so any source composes: flux:fs file(), fetch
8
9
  // (incl. the dev-server file proxy), or a download cache layered on top.
9
10
  // Requires a runtime built with speech support.
package/src/svg.ts ADDED
@@ -0,0 +1,71 @@
1
+ // SVG documents as data: parseSvg turns a document string into a flat list of
2
+ // draws that map straight onto <d-path> elements. Vector currency is path
3
+ // data, the same way raster currency is a texture id - there is no document
4
+ // element that swallows the source; the app owns the parsed data and composes
5
+ // ordinary primitives from it.
6
+
7
+ import { parseSvg as fluxParseSvg } from "flux:svg"
8
+ import { parseColor, type Gradient } from "./color"
9
+
10
+ /**
11
+ * Tags an inline SVG source, returning it unchanged. Documents small enough to
12
+ * belong beside the code that uses them stay in the file; the tag is what
13
+ * makes them legible there, because editors highlight markup inside a template
14
+ * literal only when a known tag marks it (the name matters - `svg` is one the
15
+ * grammars look for). Raw semantics, like `glsl`.
16
+ */
17
+ export let svg = String.raw
18
+
19
+ /**
20
+ * One resolved draw in document coordinates. The keys match `PathProps`, so a
21
+ * draw spreads onto a `<d-path>` unchanged: `<d-path {...draw} />`. A source
22
+ * path with both a fill and a stroke yields two draws (fill first).
23
+ */
24
+ export type SvgDraw = {
25
+ d: string
26
+ color: string | Gradient
27
+ drawStyle: "fill" | "stroke"
28
+ fillRule?: "nonzero" | "evenodd"
29
+ strokeWidth?: number
30
+ strokeCap?: "butt" | "round" | "square"
31
+ strokeJoin?: "miter" | "round" | "bevel"
32
+ }
33
+
34
+ /** A parsed document: intrinsic size (viewBox/width-height) plus the flat draw list. */
35
+ export type SvgDocument = {
36
+ width: number
37
+ height: number
38
+ draws: SvgDraw[]
39
+ }
40
+
41
+ /**
42
+ * Parses an SVG document string (an imported `.svg` asset, an icon library's
43
+ * string export, or a template literal) into plain draw data: geometry
44
+ * flattened to absolute path data with every transform baked in, paints
45
+ * resolved to colors or gradients. Render it by wrapping the draws in a view
46
+ * that fits the document's coordinate space into its box:
47
+ *
48
+ * let doc = createMemo(() => parseSvg(src))
49
+ * <view repaintBoundary viewBox={[doc().width, doc().height]} width={48} height={48}>
50
+ * {doc().draws.map((draw) => <d-path {...draw} />)}
51
+ * </view>
52
+ *
53
+ * The plain repaintBoundary is the recommended default: the parsed subtree is
54
+ * static, so it never re-records alongside changing siblings. Each `<d-path>`
55
+ * hit-tests its exact outline; when the document should act as ONE hit target
56
+ * (the usual icon case), add `pointerEvents="all"` to the wrapper - the box
57
+ * then matches as a whole and the per-path outline tests are skipped.
58
+ *
59
+ * `opts.color` drives `currentColor` in the document (any CSS color string),
60
+ * which is how monochrome icon sets (Lucide, Feather, Heroicons, ...) get
61
+ * recolored; explicit fills/strokes still win. Parsing is synchronous and
62
+ * sandboxed (no network, file, or data-URI access) and throws on an invalid
63
+ * document. Parse once per document under a memo, not per instance.
64
+ *
65
+ * Unsupported and skipped: clipPath, masks, filters, patterns, embedded
66
+ * images, and SVG text.
67
+ */
68
+ export function parseSvg(src: string, opts?: { color?: string }): SvgDocument {
69
+ if (opts?.color != null) return fluxParseSvg(src, { color: parseColor(opts.color) })
70
+ return fluxParseSvg(src)
71
+ }