@supersoniks/concorde 4.9.97-visual-stack.0 → 4.9.98-visual-stack.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.
Files changed (80) hide show
  1. package/build-infos.json +1 -1
  2. package/concorde-3d.bundle.js +143 -143
  3. package/concorde-3d.es.js +1393 -1377
  4. package/concorde-core.bundle.js +1 -1
  5. package/concorde-core.es.js +1 -1
  6. package/concorde-hugging-face-infer.bundle.js +1 -1
  7. package/concorde-hugging-face-infer.es.js +1 -1
  8. package/concorde-shader.bundle.js +19 -19
  9. package/concorde-shader.es.js +2317 -2172
  10. package/concorde-webgpu.bundle.js +30 -30
  11. package/concorde-webgpu.es.js +2439 -2346
  12. package/dist/concorde-3d.bundle.js +143 -143
  13. package/dist/concorde-3d.es.js +1393 -1377
  14. package/dist/concorde-core.bundle.js +1 -1
  15. package/dist/concorde-core.es.js +1 -1
  16. package/dist/concorde-hugging-face-infer.bundle.js +1 -1
  17. package/dist/concorde-hugging-face-infer.es.js +1 -1
  18. package/dist/concorde-shader.bundle.js +19 -19
  19. package/dist/concorde-shader.es.js +2317 -2172
  20. package/dist/concorde-webgpu.bundle.js +30 -30
  21. package/dist/concorde-webgpu.es.js +2439 -2346
  22. package/dist/js/core/components/functional/3d/3d.js +11 -1
  23. package/dist/js/core/components/functional/3d/3d.js.map +1 -1
  24. package/dist/js/core/components/functional/3d/constants.js +2 -0
  25. package/dist/js/core/components/functional/3d/constants.js.map +1 -1
  26. package/dist/js/core/components/functional/3d/frame-source.js +22 -0
  27. package/dist/js/core/components/functional/3d/frame-source.js.map +1 -0
  28. package/dist/js/core/components/functional/3d/reload.js +3 -0
  29. package/dist/js/core/components/functional/3d/reload.js.map +1 -1
  30. package/dist/js/core/components/functional/shader/gl-runtime.js +152 -17
  31. package/dist/js/core/components/functional/shader/gl-runtime.js.map +1 -1
  32. package/dist/js/core/components/functional/shader/shader.js +55 -6
  33. package/dist/js/core/components/functional/shader/shader.js.map +1 -1
  34. package/dist/js/core/components/functional/shader/types.js.map +1 -1
  35. package/dist/js/core/components/functional/webgpu/webgpu.js +9 -0
  36. package/dist/js/core/components/functional/webgpu/webgpu.js.map +1 -1
  37. package/dist/js/core/utils/mediaRef.js +49 -0
  38. package/dist/js/core/utils/mediaRef.js.map +1 -1
  39. package/dist/types/core/components/functional/3d/3d.d.ts +7 -2
  40. package/dist/types/core/components/functional/3d/3d.d.ts.map +1 -1
  41. package/dist/types/core/components/functional/3d/constants.d.ts +1 -1
  42. package/dist/types/core/components/functional/3d/constants.d.ts.map +1 -1
  43. package/dist/types/core/components/functional/3d/frame-source.d.ts +23 -0
  44. package/dist/types/core/components/functional/3d/frame-source.d.ts.map +1 -0
  45. package/dist/types/core/components/functional/3d/reload.d.ts +3 -0
  46. package/dist/types/core/components/functional/3d/reload.d.ts.map +1 -1
  47. package/dist/types/core/components/functional/3d/types.d.ts +7 -0
  48. package/dist/types/core/components/functional/3d/types.d.ts.map +1 -1
  49. package/dist/types/core/components/functional/shader/gl-runtime.d.ts +20 -1
  50. package/dist/types/core/components/functional/shader/gl-runtime.d.ts.map +1 -1
  51. package/dist/types/core/components/functional/shader/shader.d.ts +14 -3
  52. package/dist/types/core/components/functional/shader/shader.d.ts.map +1 -1
  53. package/dist/types/core/components/functional/shader/types.d.ts +6 -0
  54. package/dist/types/core/components/functional/shader/types.d.ts.map +1 -1
  55. package/dist/types/core/components/functional/webgpu/webgpu.d.ts +4 -0
  56. package/dist/types/core/components/functional/webgpu/webgpu.d.ts.map +1 -1
  57. package/dist/types/core/utils/mediaRef.d.ts +19 -0
  58. package/dist/types/core/utils/mediaRef.d.ts.map +1 -1
  59. package/package.json +13 -1
  60. package/src/core/components/functional/3d/3d.md +12 -2
  61. package/src/core/components/functional/3d/3d.ts +12 -1
  62. package/src/core/components/functional/3d/constants.ts +2 -0
  63. package/src/core/components/functional/3d/frame-source.ts +30 -0
  64. package/src/core/components/functional/3d/reload.ts +6 -0
  65. package/src/core/components/functional/3d/types.ts +5 -0
  66. package/src/core/components/functional/shader/gl-runtime.ts +205 -24
  67. package/src/core/components/functional/shader/shader.md +10 -8
  68. package/src/core/components/functional/shader/shader.spec.ts +83 -3
  69. package/src/core/components/functional/shader/shader.ts +61 -6
  70. package/src/core/components/functional/shader/types.ts +3 -1
  71. package/src/core/components/functional/webgpu/webgpu.ts +9 -0
  72. package/src/core/utils/mediaRef.spec.ts +36 -0
  73. package/src/core/utils/mediaRef.ts +63 -0
  74. package/src/docs/_misc/visual-stack.md +18 -2
  75. package/src/docs/components/docs-demo-sources.ts +14 -0
  76. package/src/docs/docs.ts +1 -0
  77. package/src/docs/example/docs-3d-shader-live-demo.ts +98 -0
  78. package/src/docs/search/docs-search.json +2347 -877
  79. package/src/tsconfig.json +9 -0
  80. package/src/tsconfig.tsbuildinfo +1 -1
@@ -1,3 +1,7 @@
1
+ import {
2
+ isFrameSource,
3
+ type SonicFrameSource,
4
+ } from "@supersoniks/concorde/core/utils/mediaRef";
1
5
  import { buildFragmentSource, FULLSCREEN_VERTEX } from "./shadertoy-prelude";
2
6
  import {
3
7
  ChannelSource,
@@ -55,6 +59,10 @@ type ExternalTexture = {
55
59
  height: number;
56
60
  url: string;
57
61
  video?: HTMLVideoElement;
62
+ /** Source live (`#élément`) — image / vidéo / canvas / SonicFrameSource. */
63
+ source?: TexImageSource | SonicFrameSource | null;
64
+ lastSeq: number;
65
+ lastSrc?: string;
58
66
  };
59
67
 
60
68
  export type FrameUniforms = {
@@ -65,10 +73,91 @@ export type FrameUniforms = {
65
73
  params: [number, number, number, number];
66
74
  };
67
75
 
76
+ /**
77
+ * Décide si un slot élément doit être re-uploadé cette frame.
78
+ * Exporté pour les tests unitaires (stratégie par type).
79
+ */
80
+ export function shouldUploadElementFrame(
81
+ source: unknown,
82
+ lastSeq: number,
83
+ lastSrc?: string,
84
+ ): { upload: boolean; texSource: TexImageSource | null; nextSrc?: string } {
85
+ if (!source) return { upload: false, texSource: null };
86
+ if (
87
+ typeof HTMLImageElement !== "undefined" &&
88
+ source instanceof HTMLImageElement
89
+ ) {
90
+ const src = source.currentSrc || source.src || "";
91
+ if (!src || !source.complete || source.naturalWidth < 1) {
92
+ return { upload: false, texSource: null };
93
+ }
94
+ if (lastSrc === src && lastSeq > 0) {
95
+ return { upload: false, texSource: null, nextSrc: src };
96
+ }
97
+ return { upload: true, texSource: source, nextSrc: src };
98
+ }
99
+ if (
100
+ typeof HTMLVideoElement !== "undefined" &&
101
+ source instanceof HTMLVideoElement
102
+ ) {
103
+ if (source.readyState < 2 || source.videoWidth < 1) {
104
+ return { upload: false, texSource: null };
105
+ }
106
+ return { upload: true, texSource: source };
107
+ }
108
+ if (
109
+ typeof HTMLCanvasElement !== "undefined" &&
110
+ source instanceof HTMLCanvasElement
111
+ ) {
112
+ if (source.width < 1 || source.height < 1) {
113
+ return { upload: false, texSource: null };
114
+ }
115
+ return { upload: true, texSource: source };
116
+ }
117
+ if (isFrameSource(source)) {
118
+ if (source.frameSeq === lastSeq) {
119
+ return { upload: false, texSource: null };
120
+ }
121
+ const canvas = source.getFrameCanvas();
122
+ if (!canvas || canvas.width < 1 || canvas.height < 1) {
123
+ return { upload: false, texSource: null };
124
+ }
125
+ return { upload: true, texSource: canvas };
126
+ }
127
+ return { upload: false, texSource: null };
128
+ }
129
+
68
130
  function isVideoUrl(url: string): boolean {
69
131
  return /\.(webm|mp4|ogg|mov)(\?|#|$)/i.test(url);
70
132
  }
71
133
 
134
+ function texImageSourceSize(
135
+ src: TexImageSource,
136
+ ): { width: number; height: number } {
137
+ if (
138
+ typeof HTMLVideoElement !== "undefined" &&
139
+ src instanceof HTMLVideoElement
140
+ ) {
141
+ return { width: src.videoWidth || 1, height: src.videoHeight || 1 };
142
+ }
143
+ if (
144
+ typeof HTMLImageElement !== "undefined" &&
145
+ src instanceof HTMLImageElement
146
+ ) {
147
+ return {
148
+ width: src.naturalWidth || src.width || 1,
149
+ height: src.naturalHeight || src.height || 1,
150
+ };
151
+ }
152
+ if ("width" in src && "height" in src) {
153
+ return {
154
+ width: Math.max(1, Number(src.width) || 1),
155
+ height: Math.max(1, Number(src.height) || 1),
156
+ };
157
+ }
158
+ return { width: 1, height: 1 };
159
+ }
160
+
72
161
  function compileShader(
73
162
  gl: WebGL2RenderingContext,
74
163
  type: number,
@@ -221,9 +310,12 @@ export function parseChannelSource(
221
310
  ): ChannelSource {
222
311
  const v = (value ?? "").trim();
223
312
  if (!v) {
224
- return fallbackUrl
225
- ? { kind: "url", url: fallbackUrl }
226
- : { kind: "none" };
313
+ const fb = (fallbackUrl ?? "").trim();
314
+ if (fb.startsWith("#")) return { kind: "element", key: fb };
315
+ return fb ? { kind: "url", url: fb } : { kind: "none" };
316
+ }
317
+ if (v.startsWith("#")) {
318
+ return { kind: "element", key: v };
227
319
  }
228
320
  const lower = v.toLowerCase();
229
321
  if (lower === "self") return { kind: "self" };
@@ -256,6 +348,7 @@ export class ShaderToyRuntime {
256
348
  private passChannels = new Map<ShaderPassId, PassChannels>();
257
349
  private activePasses: ShaderPassId[] = [];
258
350
  private blackTexture: WebGLTexture | null = null;
351
+ private elementResolver: ((key: string) => Element | null) | null = null;
259
352
 
260
353
  constructor(canvas: HTMLCanvasElement) {
261
354
  const gl = canvas.getContext("webgl2", {
@@ -383,35 +476,115 @@ export class ShaderToyRuntime {
383
476
  await Promise.all(unique.map((url) => this.loadTexture(url)));
384
477
  }
385
478
 
386
- /** Upload the current video frame for any channel backed by a video element. */
387
- updateVideoTextures() {
479
+ /** Alloue un slot texture vide pour un canal `#élément` (upload live ensuite). */
480
+ ensureElementTexture(key: string) {
481
+ if (this.external.has(key)) return;
388
482
  const gl = this.gl;
389
- for (const ext of this.external.values()) {
390
- const video = ext.video;
391
- if (!video || video.readyState < 2) continue;
483
+ const tex = gl.createTexture();
484
+ if (!tex) return;
485
+ gl.bindTexture(gl.TEXTURE_2D, tex);
486
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
487
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
488
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
489
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
490
+ gl.texImage2D(
491
+ gl.TEXTURE_2D,
492
+ 0,
493
+ gl.RGBA8,
494
+ 1,
495
+ 1,
496
+ 0,
497
+ gl.RGBA,
498
+ gl.UNSIGNED_BYTE,
499
+ new Uint8Array([0, 0, 0, 255]),
500
+ );
501
+ this.external.set(key, {
502
+ texture: tex,
503
+ width: 1,
504
+ height: 1,
505
+ url: key,
506
+ lastSeq: 0,
507
+ });
508
+ }
509
+
510
+ /**
511
+ * Upload live des canaux élément / vidéo.
512
+ * `resolveEl(key)` fournit l’Element (résolution `#id` côté host).
513
+ */
514
+ updateElementTextures(resolveEl?: (key: string) => Element | null) {
515
+ const gl = this.gl;
516
+ for (const [key, ext] of this.external) {
517
+ let source: unknown = ext.video ?? ext.source ?? null;
518
+ // Canaux #id : re-résoudre chaque frame (élément peut apparaître / partir).
519
+ if (key.startsWith("#") && resolveEl) {
520
+ source = resolveEl(key);
521
+ ext.source = source as TexImageSource | SonicFrameSource | null;
522
+ }
523
+ if (
524
+ typeof HTMLVideoElement !== "undefined" &&
525
+ source instanceof HTMLVideoElement
526
+ ) {
527
+ ext.video = source;
528
+ }
529
+ const decision = shouldUploadElementFrame(
530
+ source,
531
+ ext.lastSeq,
532
+ ext.lastSrc,
533
+ );
534
+ if (!decision.upload || !decision.texSource) continue;
535
+ const size = texImageSourceSize(decision.texSource);
392
536
  gl.bindTexture(gl.TEXTURE_2D, ext.texture);
393
537
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 1);
394
- gl.texImage2D(
395
- gl.TEXTURE_2D,
396
- 0,
397
- gl.RGBA8,
398
- gl.RGBA,
399
- gl.UNSIGNED_BYTE,
400
- video,
401
- );
538
+ if (ext.width === size.width && ext.height === size.height && ext.lastSeq > 0) {
539
+ gl.texSubImage2D(
540
+ gl.TEXTURE_2D,
541
+ 0,
542
+ 0,
543
+ 0,
544
+ gl.RGBA,
545
+ gl.UNSIGNED_BYTE,
546
+ decision.texSource,
547
+ );
548
+ } else {
549
+ gl.texImage2D(
550
+ gl.TEXTURE_2D,
551
+ 0,
552
+ gl.RGBA8,
553
+ gl.RGBA,
554
+ gl.UNSIGNED_BYTE,
555
+ decision.texSource,
556
+ );
557
+ ext.width = size.width;
558
+ ext.height = size.height;
559
+ }
402
560
  gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, 0);
403
- ext.width = video.videoWidth || ext.width;
404
- ext.height = video.videoHeight || ext.height;
561
+ if (isFrameSource(source)) {
562
+ ext.lastSeq = source.frameSeq;
563
+ } else {
564
+ ext.lastSeq = (ext.lastSeq || 0) + 1;
565
+ }
566
+ if (decision.nextSrc !== undefined) ext.lastSrc = decision.nextSrc;
405
567
  }
406
568
  }
407
569
 
570
+ /** @deprecated alias — préférer `updateElementTextures`. */
571
+ updateVideoTextures() {
572
+ this.updateElementTextures();
573
+ }
574
+
408
575
  setVideosPlaying(playing: boolean) {
409
576
  for (const ext of this.external.values()) {
410
- if (!ext.video) continue;
577
+ const video =
578
+ ext.video ||
579
+ (typeof HTMLVideoElement !== "undefined" &&
580
+ ext.source instanceof HTMLVideoElement
581
+ ? ext.source
582
+ : null);
583
+ if (!video) continue;
411
584
  if (playing) {
412
- void ext.video.play().catch(() => {});
585
+ void video.play().catch(() => {});
413
586
  } else {
414
- ext.video.pause();
587
+ video.pause();
415
588
  }
416
589
  }
417
590
  }
@@ -450,6 +623,7 @@ export class ShaderToyRuntime {
450
623
  width: img.naturalWidth || img.width,
451
624
  height: img.naturalHeight || img.height,
452
625
  url,
626
+ lastSeq: 0,
453
627
  });
454
628
  resolve();
455
629
  };
@@ -503,6 +677,7 @@ export class ShaderToyRuntime {
503
677
  height: video.videoHeight,
504
678
  url,
505
679
  video,
680
+ lastSeq: 0,
506
681
  });
507
682
  void video.play().catch(() => {});
508
683
  resolve();
@@ -524,8 +699,9 @@ export class ShaderToyRuntime {
524
699
  height: 1,
525
700
  };
526
701
  if (source.kind === "none") return black;
527
- if (source.kind === "url") {
528
- const ext = this.external.get(source.url);
702
+ if (source.kind === "url" || source.kind === "element") {
703
+ const key = source.kind === "url" ? source.url : source.key;
704
+ const ext = this.external.get(key);
529
705
  return ext
530
706
  ? { texture: ext.texture, width: ext.width, height: ext.height }
531
707
  : black;
@@ -620,10 +796,15 @@ export class ShaderToyRuntime {
620
796
  }
621
797
  }
622
798
 
799
+ /** Host fournit la résolution `#id` / live Element. */
800
+ setElementResolver(fn: ((key: string) => Element | null) | null) {
801
+ this.elementResolver = fn;
802
+ }
803
+
623
804
  render(frame: FrameUniforms) {
624
805
  const gl = this.gl;
625
806
  if (!this.vao || this.width < 1 || this.height < 1) return;
626
- this.updateVideoTextures();
807
+ this.updateElementTextures(this.elementResolver ?? undefined);
627
808
  gl.bindVertexArray(this.vao);
628
809
  gl.viewport(0, 0, this.width, this.height);
629
810
  gl.disable(gl.DEPTH_TEST);
@@ -24,7 +24,7 @@ CDN (after the core): `concorde-shader.bundle.js` — see [CDN addons](#docs/_ge
24
24
 
25
25
  Uniforms supportés : `iResolution`, `iTime`, `iTimeDelta`, `iFrame`, `iFrameRate`, `iMouse`, `iDate`, `iChannel0–3`, `iChannelResolution`, `iChannelTime`, `iSampleRate`, `uParam0–3`.
26
26
 
27
- Textures `channel0–3` : images **ou** vidéos (`.webm` / `.mp4` / …).
27
+ Textures `channel0–3` : images, vidéos (`.webm` / `.mp4` / …), **ou** éléments page via `#id` (`<img>`, `<video>`, `<canvas>`, `sonic-3d` / `sonic-webgpu` / `sonic-shader`).
28
28
 
29
29
  ## Multipass sur une photo
30
30
 
@@ -34,20 +34,22 @@ Buffer A simule une height-map d’ondes (ripple) ; Image réfracte la photo via
34
34
 
35
35
  Photo + depth parallax (HF): [Visual stack](#docs/_misc/visual-stack.md/visual-stack).
36
36
 
37
+ Live 3D → shader (no JPEG): [Visual stack](#docs/_misc/visual-stack.md/visual-stack).
38
+
37
39
  ## Chaînage d’effets (officiel)
38
40
 
39
- **Règle :** brancher des passes dans **un** `sonic-shader` (buffers `A–D` → `Image`), pas deux composants via dataProvider.
41
+ **Règle :** brancher des passes dans **un** `sonic-shader` (buffers `A–D` → `Image`), pas deux composants via dataProvider — **sauf** pont live `#élément` (copie canvas chaque frame).
40
42
 
41
43
  | Besoin | Mécanisme |
42
44
  |--------|-----------|
43
45
  | Sortie d’un effet → entrée du suivant | `image-ch0="buffer-a"` (ou `buffer-b`… / `self`) — **même contexte GPU**, FBO ping-pong |
44
- | Params / sync UI | `dataProvider` → `uParam0…3` **et** canaux (`channel0`, `bufferACh0`…) — string URL ou `SonicMediaRef` |
46
+ | Scene 3D / autre canvas → post | `channel0="#plateau"` — copie `texImage2D` chaque `frameSeq` |
47
+ | Params / sync UI | `dataProvider` → `uParam0…3` **et** canaux (`channel0`, `bufferACh0`…) — string URL, `#id`, ou `SonicMediaRef` (`url` / `element`) |
45
48
  | Hors viewport | `release-offscreen` (défaut) : libère textures/FBO, garde le contexte |
46
49
 
47
- Les passes intermédiaires sont **déjà offscreen** (FBO). Pas besoin d’un second `sonic-shader` invisible.
48
-
49
- Deux instances séparées ne partagent pas leurs textures WebGL (contextes distincts). Un bus texture via DP n’existe pas (et un `toBlob`/`dataURL` ne serait pas temps réel).
50
+ Les passes intermédiaires sont **déjà offscreen** (FBO). Pas besoin d’un second `sonic-shader` invisible pour du multipass interne.
50
51
 
52
+ Deux instances séparées ne partagent pas leurs textures WebGL (contextes distincts). Le pont live `#id` **copie** le canvas source ; `frame-out` JPEG reste pour export / freeze.
51
53
  Démo pédagogique : Buffer&nbsp;A = Sobel → Image = overlay + `param0`.
52
54
 
53
55
  <docs-lit-demo for="docs-shader-chain-demo"></docs-lit-demo>
@@ -143,8 +145,8 @@ Live Harris → patches → WebGPU: [Visual stack](#docs/_misc/visual-stack.md/v
143
145
  | `image` | `string` | GLSL pass Image (**requis**, propriété JS) |
144
146
  | `bufferA`…`bufferD` / `buffer-a`… | `string` | Passes multipass |
145
147
  | `common` | `string` | GLSL partagé |
146
- | `channel0`…`channel3` | `string` | URLs textures (toutes passes) |
147
- | `imageCh0`… / `image-ch0`… | `string` | Override canal : URL, `self`, `buffer-a`…, `none` |
148
+ | `channel0`…`channel3` | `string` | URLs, `#id` élément, ou refs buffers (toutes passes) |
149
+ | `imageCh0`… / `image-ch0`… | `string` | Override canal : URL, `#id`, `self`, `buffer-a`…, `none` |
148
150
  | `play` | `boolean` | Boucle RAF (défaut `true`) |
149
151
  | `playOnHover` / `play-on-hover` | `boolean` | Anime seulement au hover / transition |
150
152
  | `mouse` | `boolean` | Pointeur → `iMouse` (défaut `true`) |
@@ -1,9 +1,12 @@
1
1
  import { expect, describe, it, vi, afterEach, beforeAll } from "vitest";
2
2
  import TestUtils from "@supersoniks/concorde/test-utils/TestUtils";
3
- import { parseChannelSource } from "./gl-runtime";
3
+ import {
4
+ parseChannelSource,
5
+ shouldUploadElementFrame,
6
+ } from "./gl-runtime";
4
7
  import { buildFragmentSource } from "./shadertoy-prelude";
5
8
  import "./shader";
6
- import { SonicShader } from "./shader";
9
+ import { SonicShader, channelFormValue } from "./shader";
7
10
 
8
11
  describe("parseChannelSource", () => {
9
12
  it("résout self / buffers / url / fallback", () => {
@@ -26,6 +29,81 @@ describe("parseChannelSource", () => {
26
29
  });
27
30
  expect(parseChannelSource("none", "")).toEqual({ kind: "none" });
28
31
  });
32
+
33
+ it("résout #id comme élément (pas URL)", () => {
34
+ expect(parseChannelSource("#plateau", "")).toEqual({
35
+ kind: "element",
36
+ key: "#plateau",
37
+ });
38
+ expect(parseChannelSource("", "#scene")).toEqual({
39
+ kind: "element",
40
+ key: "#scene",
41
+ });
42
+ });
43
+ });
44
+
45
+ describe("channelFormValue", () => {
46
+ it("passe { element } → #id", () => {
47
+ expect(channelFormValue({ element: "#plateau" })).toBe("#plateau");
48
+ expect(channelFormValue({ element: "plateau" })).toBe("#plateau");
49
+ expect(channelFormValue({ url: "https://x/a.png" })).toBe(
50
+ "https://x/a.png",
51
+ );
52
+ expect(channelFormValue({ blob: new Blob() })).toBeUndefined();
53
+ });
54
+ });
55
+
56
+ describe("shouldUploadElementFrame", () => {
57
+ it("canvas : upload chaque frame si taille ok", () => {
58
+ const canvas = document.createElement("canvas");
59
+ canvas.width = 4;
60
+ canvas.height = 4;
61
+ expect(shouldUploadElementFrame(canvas, 0).upload).toBe(true);
62
+ expect(shouldUploadElementFrame(canvas, 3).upload).toBe(true);
63
+ });
64
+
65
+ it("FrameSource : skip si frameSeq inchangé, upload sinon", () => {
66
+ const canvas = document.createElement("canvas");
67
+ canvas.width = 2;
68
+ canvas.height = 2;
69
+ const src = {
70
+ frameSeq: 5,
71
+ getFrameCanvas: () => canvas,
72
+ };
73
+ expect(shouldUploadElementFrame(src, 5).upload).toBe(false);
74
+ const up = shouldUploadElementFrame(src, 4);
75
+ expect(up.upload).toBe(true);
76
+ expect(up.texSource).toBe(canvas);
77
+ });
78
+
79
+ it("source absente → pas d’upload (texture noire côté runtime)", () => {
80
+ expect(shouldUploadElementFrame(null, 0).upload).toBe(false);
81
+ expect(
82
+ shouldUploadElementFrame(
83
+ { frameSeq: 1, getFrameCanvas: () => null },
84
+ 0,
85
+ ).upload,
86
+ ).toBe(false);
87
+ });
88
+
89
+ it("image : upload au changement de src seulement", () => {
90
+ const img = document.createElement("img");
91
+ Object.defineProperty(img, "complete", { value: true });
92
+ Object.defineProperty(img, "naturalWidth", { value: 8 });
93
+ Object.defineProperty(img, "naturalHeight", { value: 8 });
94
+ Object.defineProperty(img, "currentSrc", {
95
+ value: "https://example.test/a.png",
96
+ });
97
+ const first = shouldUploadElementFrame(img, 0);
98
+ expect(first.upload).toBe(true);
99
+ expect(first.nextSrc).toBe("https://example.test/a.png");
100
+ const again = shouldUploadElementFrame(
101
+ img,
102
+ 1,
103
+ "https://example.test/a.png",
104
+ );
105
+ expect(again.upload).toBe(false);
106
+ });
29
107
  });
30
108
 
31
109
  describe("buildFragmentSource", () => {
@@ -207,7 +285,7 @@ describe("SonicShader", () => {
207
285
  expect(el.param0).toBe(0.99);
208
286
  });
209
287
 
210
- it("applique les canaux depuis dataProvider (url | MediaRef)", async () => {
288
+ it("applique les canaux depuis dataProvider (url | MediaRef | element)", async () => {
211
289
  const { PublisherManager } = await import(
212
290
  "@supersoniks/concorde/utils"
213
291
  );
@@ -217,6 +295,7 @@ describe("SonicShader", () => {
217
295
  bufferACh0: { url: "https://example.test/depth.png" },
218
296
  imageCh0: "buffer-a",
219
297
  imageCh1: "https://example.test/color.jpg",
298
+ channel0: { element: "#plateau" },
220
299
  param0: 0.5,
221
300
  });
222
301
  const el = TestUtils.bootstrap(
@@ -228,6 +307,7 @@ describe("SonicShader", () => {
228
307
  expect(el.bufferACh0).toBe("https://example.test/depth.png");
229
308
  expect(el.imageCh0).toBe("buffer-a");
230
309
  expect(el.imageCh1).toBe("https://example.test/color.jpg");
310
+ expect(el.channel0).toBe("#plateau");
231
311
  expect(el.param0).toBe(0.5);
232
312
  });
233
313
 
@@ -1,7 +1,7 @@
1
1
  import { css, html, LitElement } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
3
  import HTML from "@supersoniks/concorde/core/utils/HTML";
4
- import { revokeMediaUrls, toMediaUrl } from "@supersoniks/concorde/core/utils/mediaRef";
4
+ import { revokeMediaUrls, toMediaUrl, mediaElementKey, lookupLiveElement } from "@supersoniks/concorde/core/utils/mediaRef";
5
5
  import { PublisherManager } from "@supersoniks/concorde/utils";
6
6
  import {
7
7
  parseChannelSource,
@@ -104,16 +104,24 @@ function toNum(v: unknown, fallback: number): number {
104
104
  return fallback;
105
105
  }
106
106
 
107
- /** Valeur canal depuis DP : string (url ou buffer-a) | MediaRef → url. */
107
+ /** Valeur canal depuis DP : string (url, buffer-a, #id) | MediaRef → url | { element }. */
108
108
  function channelFormValue(value: unknown): string | undefined {
109
109
  if (value === null || value === "") return "";
110
110
  if (typeof value === "string") return value.trim();
111
111
  const url = toMediaUrl(value);
112
112
  if (url) return url;
113
- if (value && typeof value === "object") return undefined;
113
+ if (value && typeof value === "object") {
114
+ const el = (value as { element?: unknown }).element;
115
+ const key = mediaElementKey(el);
116
+ if (key) return key;
117
+ return undefined;
118
+ }
114
119
  return "";
115
120
  }
116
121
 
122
+ /** Exposé pour tests. */
123
+ export { channelFormValue };
124
+
117
125
  function parseRois(value: unknown): ShaderRoi[] {
118
126
  if (!Array.isArray(value)) return [];
119
127
  const out: ShaderRoi[] = [];
@@ -364,6 +372,8 @@ export class SonicShader extends LitElement {
364
372
  private startTime = 0;
365
373
  private lastTime = 0;
366
374
  private frame = 0;
375
+ /** Incrémenté après chaque render (contrat SonicFrameSource). */
376
+ frameSeq = 0;
367
377
  private mouseVec: [number, number, number, number] = [0, 0, 0, 0];
368
378
  /** Cible pointeur (px drawing buffer, origine bas-gauche). */
369
379
  private mouseTarget: [number, number] = [0, 0];
@@ -702,6 +712,27 @@ export class SonicShader extends LitElement {
702
712
  );
703
713
  }
704
714
 
715
+ /** Contrat SonicFrameSource — canvas lisible pour un autre sonic-shader. */
716
+ getFrameCanvas(): HTMLCanvasElement | null {
717
+ if (!this.ready || !this.runtime || this.runtime.isLost) return null;
718
+ return this.getCanvas();
719
+ }
720
+
721
+ /** Résout `#id` dans getRootNode() puis document ; clés live `__sonic_frame_`. */
722
+ private lookupElement(key: string): Element | null {
723
+ if (!key.startsWith("#")) return null;
724
+ const live = lookupLiveElement(key);
725
+ if (live) return live;
726
+ const id = key.slice(1);
727
+ if (!id || id.startsWith("__sonic_frame_")) return null;
728
+ const root = this.getRootNode();
729
+ if (root instanceof Document || root instanceof ShadowRoot) {
730
+ const el = root.getElementById(id);
731
+ if (el) return el;
732
+ }
733
+ return typeof document !== "undefined" ? document.getElementById(id) : null;
734
+ }
735
+
705
736
  private bindContextEvents() {
706
737
  const canvas = this.getCanvas();
707
738
  canvas?.addEventListener("webglcontextlost", this.onContextLost);
@@ -743,6 +774,7 @@ export class SonicShader extends LitElement {
743
774
  try {
744
775
  this.errorMessage = "";
745
776
  this.runtime = new ShaderToyRuntime(canvas);
777
+ this.runtime.setElementResolver((key) => this.lookupElement(key));
746
778
  this.bindContextEvents();
747
779
  this.syncSize();
748
780
  await this.rebuild();
@@ -827,6 +859,18 @@ export class SonicShader extends LitElement {
827
859
  return urls;
828
860
  }
829
861
 
862
+ private collectElementKeys(
863
+ channels: Map<ShaderPassId, PassChannels>,
864
+ ): string[] {
865
+ const keys: string[] = [];
866
+ for (const chs of channels.values()) {
867
+ for (const c of chs) {
868
+ if (c.kind === "element") keys.push(c.key);
869
+ }
870
+ }
871
+ return [...new Set(keys)];
872
+ }
873
+
830
874
  private async rebuild() {
831
875
  if (!this.runtime || this.runtime.isLost || !this.image.trim()) return;
832
876
  const token = ++this.rebuildToken;
@@ -853,10 +897,15 @@ export class SonicShader extends LitElement {
853
897
  this.syncSize();
854
898
  const urls = this.collectUrls(channels);
855
899
  await this.runtime.ensureTextures(urls);
900
+ for (const key of this.collectElementKeys(channels)) {
901
+ this.runtime.ensureElementTexture(key);
902
+ }
856
903
  if (token !== this.rebuildToken || !this.runtime || this.runtime.isLost) {
857
904
  return;
858
905
  }
859
- this.applyAspectRatio(this.channel0 || urls[0]);
906
+ const aspectHint =
907
+ this.channel0.startsWith("#") ? "" : this.channel0 || urls[0];
908
+ this.applyAspectRatio(aspectHint);
860
909
  this.syncSize();
861
910
  this.runtime.setSources(sources, channels);
862
911
  if (token !== this.rebuildToken || !this.runtime || this.runtime.isLost) {
@@ -964,6 +1013,7 @@ export class SonicShader extends LitElement {
964
1013
  mouse: this.mouseVec,
965
1014
  params,
966
1015
  });
1016
+ this.frameSeq++;
967
1017
  this.maybePublishOut(now, time, params);
968
1018
  void this.maybeExtract(now, time);
969
1019
  }
@@ -1531,7 +1581,7 @@ export class SonicShader extends LitElement {
1531
1581
  }
1532
1582
  }
1533
1583
 
1534
- export { parseChannelSource, ShaderToyRuntime } from "./gl-runtime";
1584
+ export { parseChannelSource, ShaderToyRuntime, shouldUploadElementFrame } from "./gl-runtime";
1535
1585
  export { buildFragmentSource } from "./shadertoy-prelude";
1536
1586
  export type {
1537
1587
  ChannelSource,
@@ -1548,10 +1598,15 @@ export type {
1548
1598
  SonicShaderConfig,
1549
1599
  SonicShaderOutput,
1550
1600
  } from "./types";
1551
- export type { SonicMediaRef } from "@supersoniks/concorde/core/utils/mediaRef";
1601
+ export type {
1602
+ SonicMediaRef,
1603
+ SonicFrameSource,
1604
+ } from "@supersoniks/concorde/core/utils/mediaRef";
1552
1605
  export {
1553
1606
  toMediaUrl,
1554
1607
  cloneMediaRef,
1608
+ isFrameSource,
1609
+ mediaElementKey,
1555
1610
  } from "@supersoniks/concorde/core/utils/mediaRef";
1556
1611
  export {
1557
1612
  findPeaks,
@@ -9,7 +9,9 @@ export type ChannelSource =
9
9
  | { kind: "url"; url: string }
10
10
  | { kind: "buffer"; buffer: Exclude<ShaderPassId, "image"> }
11
11
  | { kind: "self" }
12
- | { kind: "none" };
12
+ | { kind: "none" }
13
+ /** Élément page (`#id`) — upload live via `updateElementTextures`. */
14
+ | { kind: "element"; key: string; el?: Element | null };
13
15
 
14
16
  export type PassChannels = [
15
17
  ChannelSource,
@@ -138,6 +138,8 @@ export class SonicWebGpu extends LitElement {
138
138
  raf = 0;
139
139
  startTime = 0;
140
140
  frame = 0;
141
+ /** Contrat SonicFrameSource — incrémenté après chaque draw. */
142
+ frameSeq = 0;
141
143
  mouseVec: [number, number, number, number] = [0, 0, 0, 0];
142
144
  /** Cible normalisée (0–1, origine haut-gauche) depuis dataProvider — tracking / DP. */
143
145
  remoteTarget: { x: number; y: number } | null = null;
@@ -596,6 +598,7 @@ export class SonicWebGpu extends LitElement {
596
598
  params: [this.param0, this.param1, this.param2, this.param3],
597
599
  });
598
600
  this.frame++;
601
+ this.frameSeq++;
599
602
  this.maybePublishOut(now, time);
600
603
  } catch (e) {
601
604
  console.warn(`${LOG_PREFIX} frame skip:`, e);
@@ -620,6 +623,12 @@ export class SonicWebGpu extends LitElement {
620
623
  outFrame.maybePublishOut(this as never, now, time);
621
624
  }
622
625
 
626
+ /** Contrat SonicFrameSource — canvas WebGPU / fallback. */
627
+ getFrameCanvas(): HTMLCanvasElement | null {
628
+ if (!this.ready || this.errorMessage) return null;
629
+ return this.canvasEl;
630
+ }
631
+
623
632
  private revokeFrameUrl() {
624
633
  outFrame.revokeFrameUrl(this as never);
625
634
  }