fundus 0.0.2
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/dist/build-CZYFLLvl.js +191 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +1420 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -0
- package/dist/core/image-Dqmz8FXS.js +54 -0
- package/dist/core/index.d.ts +499 -0
- package/dist/core/index.js +962 -0
- package/dist/core/reference-graph-CjuHbLDw.d.ts +280 -0
- package/dist/core/testing.d.ts +27 -0
- package/dist/core/testing.js +80 -0
- package/dist/editor/200.html +37 -0
- package/dist/editor/_app/immutable/assets/0.DLhIsYA_.css +1 -0
- package/dist/editor/_app/immutable/assets/2.DIbbW7U1.css +1 -0
- package/dist/editor/_app/immutable/assets/3.C-9vFQ9A.css +1 -0
- package/dist/editor/_app/immutable/assets/4.BS8ElpOV.css +1 -0
- package/dist/editor/_app/immutable/assets/ConfirmDialog.CHEISE-L.css +1 -0
- package/dist/editor/_app/immutable/assets/LibraryContextMenu.CDjCmdV-.css +1 -0
- package/dist/editor/_app/immutable/chunks/BfPE5wdt.js +1 -0
- package/dist/editor/_app/immutable/chunks/Bjy-W4x2.js +81 -0
- package/dist/editor/_app/immutable/chunks/CNRz3sSw.js +3 -0
- package/dist/editor/_app/immutable/chunks/CO4RBJfa.js +1 -0
- package/dist/editor/_app/immutable/chunks/Cx6qtxJg.js +1 -0
- package/dist/editor/_app/immutable/chunks/DjdrdRzT.js +1 -0
- package/dist/editor/_app/immutable/chunks/DjvOiDmq.js +1 -0
- package/dist/editor/_app/immutable/chunks/n7mvYc42.js +5 -0
- package/dist/editor/_app/immutable/chunks/xihTtKlq.js +1 -0
- package/dist/editor/_app/immutable/entry/app.BkqbfI8n.js +2 -0
- package/dist/editor/_app/immutable/entry/start.C8HHOLly.js +1 -0
- package/dist/editor/_app/immutable/nodes/0.xdMI5GEU.js +2 -0
- package/dist/editor/_app/immutable/nodes/1.D9p2tfoJ.js +1 -0
- package/dist/editor/_app/immutable/nodes/2.BrA0Hyzu.js +3 -0
- package/dist/editor/_app/immutable/nodes/3.WO7tZH_d.js +104 -0
- package/dist/editor/_app/immutable/nodes/4.DQtx17aW.js +2 -0
- package/dist/editor/_app/version.json +1 -0
- package/dist/index.d.ts +370 -0
- package/dist/index.js +4 -0
- package/dist/operations-DYM-5KVl.js +1797 -0
- package/dist/runtime/chroma-key-gl.d.ts +23 -0
- package/dist/runtime/chroma-key-gl.js +290 -0
- package/dist/runtime/components/ChromaKeyVideo.svelte +225 -0
- package/dist/runtime/components/Image.svelte +18 -0
- package/dist/runtime/components/Slice.svelte +35 -0
- package/dist/runtime/components/SliceCanvas.svelte +162 -0
- package/dist/runtime/components/SliceDom.svelte +104 -0
- package/dist/runtime/components/Video.svelte +60 -0
- package/dist/runtime/geometry.d.ts +75 -0
- package/dist/runtime/geometry.js +209 -0
- package/dist/runtime/index.d.ts +9 -0
- package/dist/runtime/index.js +9 -0
- package/dist/runtime/kind-handlers.d.ts +46 -0
- package/dist/runtime/kind-handlers.js +86 -0
- package/dist/runtime/manifest.d.ts +60 -0
- package/dist/runtime/manifest.js +117 -0
- package/dist/runtime/observe-border-box-size.d.ts +5 -0
- package/dist/runtime/observe-border-box-size.js +12 -0
- package/dist/runtime/preload-registry.d.ts +26 -0
- package/dist/runtime/preload-registry.js +68 -0
- package/dist/start-Bhnqt9Dc.js +279 -0
- package/dist/start-D2IXOrni.js +2 -0
- package/package.json +65 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { ChromaKeyParameters } from "#fundus/core";
|
|
2
|
+
|
|
3
|
+
//#region src/chroma-key-gl.d.ts
|
|
4
|
+
declare class ChromaKeyGl {
|
|
5
|
+
#private;
|
|
6
|
+
/** Returns null when WebGL2 is unavailable or the shader fails to compile. */
|
|
7
|
+
static create(canvas: HTMLCanvasElement): ChromaKeyGl | null;
|
|
8
|
+
private constructor();
|
|
9
|
+
/**
|
|
10
|
+
* Set (or clear) the grayscale shape mask multiplied onto the output alpha. The
|
|
11
|
+
* mask is stretched over the full video frame, so author it at the video's aspect
|
|
12
|
+
* ratio. Pass null to remove the mask.
|
|
13
|
+
*/
|
|
14
|
+
setMask(image: TexImageSource | null): void;
|
|
15
|
+
/**
|
|
16
|
+
* Upload the video's current frame and render it keyed. Sizes the drawing buffer to
|
|
17
|
+
* the video's intrinsic size on first draw (and after a size change).
|
|
18
|
+
*/
|
|
19
|
+
draw(video: HTMLVideoElement, parameters: ChromaKeyParameters): void;
|
|
20
|
+
dispose(): void;
|
|
21
|
+
}
|
|
22
|
+
//#endregion
|
|
23
|
+
export { ChromaKeyGl };
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
//#region src/chroma-key-gl.ts
|
|
2
|
+
const VERTEX_SHADER = `#version 300 es
|
|
3
|
+
in vec2 a_pos;
|
|
4
|
+
out vec2 v_uv;
|
|
5
|
+
void main() {
|
|
6
|
+
// Fullscreen clip-space triangle pair; v flipped so the video isn't mirrored on Y
|
|
7
|
+
// (GL texture rows are bottom-up, and flipping here beats a per-frame UNPACK flip).
|
|
8
|
+
v_uv = vec2(a_pos.x * 0.5 + 0.5, 0.5 - a_pos.y * 0.5);
|
|
9
|
+
gl_Position = vec4(a_pos, 0.0, 1.0);
|
|
10
|
+
}`;
|
|
11
|
+
const FRAGMENT_SHADER = `#version 300 es
|
|
12
|
+
precision highp float;
|
|
13
|
+
|
|
14
|
+
in vec2 v_uv;
|
|
15
|
+
out vec4 fragColor;
|
|
16
|
+
|
|
17
|
+
uniform sampler2D u_texture;
|
|
18
|
+
uniform sampler2D u_mask;
|
|
19
|
+
uniform vec2 u_texelSize;
|
|
20
|
+
uniform vec3 u_keyColor;
|
|
21
|
+
uniform vec4 u_tintColor;
|
|
22
|
+
uniform float u_colorCutoff;
|
|
23
|
+
uniform float u_colorFeathering;
|
|
24
|
+
uniform float u_maskFeathering;
|
|
25
|
+
uniform float u_sharpening;
|
|
26
|
+
uniform float u_despill;
|
|
27
|
+
uniform float u_despillLuminanceAdd;
|
|
28
|
+
|
|
29
|
+
float rgb2y(vec3 c) {
|
|
30
|
+
return 0.299 * c.r + 0.587 * c.g + 0.114 * c.b;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
float rgb2cb(vec3 c) {
|
|
34
|
+
return 0.5 - 0.168736 * c.r - 0.331264 * c.g + 0.5 * c.b;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
float rgb2cr(vec3 c) {
|
|
38
|
+
return 0.5 + 0.5 * c.r - 0.418688 * c.g - 0.081312 * c.b;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
float colorclose(float Cb_p, float Cr_p, float Cb_key, float Cr_key, float tola, float tolb) {
|
|
42
|
+
float temp = (Cb_key - Cb_p) * (Cb_key - Cb_p) + (Cr_key - Cr_p) * (Cr_key - Cr_p);
|
|
43
|
+
float tola2 = tola * tola;
|
|
44
|
+
float tolb2 = tolb * tolb;
|
|
45
|
+
if (temp < tola2) return 0.0;
|
|
46
|
+
if (temp < tolb2) return (temp - tola2) / (tolb2 - tola2);
|
|
47
|
+
return 1.0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
float maskedTex2D(sampler2D tex, vec2 uv) {
|
|
51
|
+
vec4 color = texture(tex, uv);
|
|
52
|
+
|
|
53
|
+
float key_cb = rgb2cb(u_keyColor);
|
|
54
|
+
float key_cr = rgb2cr(u_keyColor);
|
|
55
|
+
float pix_cb = rgb2cb(color.rgb);
|
|
56
|
+
float pix_cr = rgb2cr(color.rgb);
|
|
57
|
+
|
|
58
|
+
return colorclose(pix_cb, pix_cr, key_cb, key_cr, u_colorCutoff, u_colorFeathering);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
void main() {
|
|
62
|
+
vec2 pixelWidth = vec2(u_texelSize.x, 0.0);
|
|
63
|
+
vec2 pixelHeight = vec2(0.0, u_texelSize.y);
|
|
64
|
+
|
|
65
|
+
// Unmodified MainTex
|
|
66
|
+
vec4 color = texture(u_texture, v_uv);
|
|
67
|
+
|
|
68
|
+
// Unfeathered mask
|
|
69
|
+
float mask = maskedTex2D(u_texture, v_uv);
|
|
70
|
+
|
|
71
|
+
// Feathering & smoothing
|
|
72
|
+
float c = mask;
|
|
73
|
+
float r = maskedTex2D(u_texture, v_uv + pixelWidth);
|
|
74
|
+
float l = maskedTex2D(u_texture, v_uv - pixelWidth);
|
|
75
|
+
float d = maskedTex2D(u_texture, v_uv + pixelHeight);
|
|
76
|
+
float u = maskedTex2D(u_texture, v_uv - pixelHeight);
|
|
77
|
+
float rd = maskedTex2D(u_texture, v_uv + pixelWidth + pixelHeight) * 0.707;
|
|
78
|
+
float dl = maskedTex2D(u_texture, v_uv - pixelWidth + pixelHeight) * 0.707;
|
|
79
|
+
float lu = maskedTex2D(u_texture, v_uv - pixelHeight - pixelWidth) * 0.707;
|
|
80
|
+
float ur = maskedTex2D(u_texture, v_uv + pixelWidth - pixelHeight) * 0.707;
|
|
81
|
+
float blurContribution = (r + l + d + u + rd + dl + lu + ur + c) * 0.12774655;
|
|
82
|
+
float smoothedMask = smoothstep(u_sharpening, 1.0, mix(c, blurContribution, u_maskFeathering));
|
|
83
|
+
vec4 result = color * smoothedMask;
|
|
84
|
+
|
|
85
|
+
// Despill
|
|
86
|
+
float v = (2.0 * result.b + result.r) / 4.0;
|
|
87
|
+
if (result.g > v) result.g = mix(result.g, v, u_despill);
|
|
88
|
+
vec4 dif = color - result;
|
|
89
|
+
float desaturatedDif = rgb2y(dif.xyz);
|
|
90
|
+
result += mix(0.0, desaturatedDif, u_despillLuminanceAdd);
|
|
91
|
+
|
|
92
|
+
// Fundus mask pass: a grayscale shape mask multiplies
|
|
93
|
+
// the keyed alpha.
|
|
94
|
+
float shapeMask = texture(u_mask, v_uv).r;
|
|
95
|
+
vec4 keyed = vec4(result.xyz, smoothedMask * shapeMask) * u_tintColor;
|
|
96
|
+
|
|
97
|
+
// Premultiplied output: keyed matches the upstream SrcAlpha/OneMinusSrcAlpha
|
|
98
|
+
// blending; multiplying rgb by alpha is that blend's exact premultiplied
|
|
99
|
+
// equivalent. Straight-alpha output (premultipliedAlpha: false) is NOT an option:
|
|
100
|
+
// iOS WKWebView composites the WebGL canvas as premultiplied regardless of the
|
|
101
|
+
// flag, and the despill luminance-add above leaves fully-transparent background
|
|
102
|
+
// pixels with nonzero rgb (the upstream blend ignores rgb
|
|
103
|
+
// at alpha 0) which then composite additively — a visible bright veil over the
|
|
104
|
+
// whole canvas rect on iOS.
|
|
105
|
+
fragColor = vec4(keyed.rgb * keyed.a, keyed.a);
|
|
106
|
+
}`;
|
|
107
|
+
function compileProgram(gl) {
|
|
108
|
+
const compile = (type, source) => {
|
|
109
|
+
const shader = gl.createShader(type);
|
|
110
|
+
gl.shaderSource(shader, source);
|
|
111
|
+
gl.compileShader(shader);
|
|
112
|
+
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) {
|
|
113
|
+
const log = gl.getShaderInfoLog(shader);
|
|
114
|
+
gl.deleteShader(shader);
|
|
115
|
+
throw new Error(`Chroma-key shader compilation failed: ${log}.`);
|
|
116
|
+
}
|
|
117
|
+
return shader;
|
|
118
|
+
};
|
|
119
|
+
const program = gl.createProgram();
|
|
120
|
+
gl.attachShader(program, compile(gl.VERTEX_SHADER, VERTEX_SHADER));
|
|
121
|
+
gl.attachShader(program, compile(gl.FRAGMENT_SHADER, FRAGMENT_SHADER));
|
|
122
|
+
gl.linkProgram(program);
|
|
123
|
+
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) {
|
|
124
|
+
const log = gl.getProgramInfoLog(program);
|
|
125
|
+
gl.deleteProgram(program);
|
|
126
|
+
throw new Error(`Chroma-key program linking failed: ${log}.`);
|
|
127
|
+
}
|
|
128
|
+
return program;
|
|
129
|
+
}
|
|
130
|
+
var ChromaKeyGl = class ChromaKeyGl {
|
|
131
|
+
#gl;
|
|
132
|
+
#canvas;
|
|
133
|
+
#program;
|
|
134
|
+
#quadBuffer;
|
|
135
|
+
#texture;
|
|
136
|
+
#uniforms;
|
|
137
|
+
#maskTexture;
|
|
138
|
+
/** Last mask image, kept so the texture can be re-uploaded after a context restore. */
|
|
139
|
+
#maskImage = null;
|
|
140
|
+
/** True between webglcontextlost and webglcontextrestored — draw no-ops meanwhile. */
|
|
141
|
+
#contextLost = false;
|
|
142
|
+
/** Returns null when WebGL2 is unavailable or the shader fails to compile. */
|
|
143
|
+
static create(canvas) {
|
|
144
|
+
const gl = canvas.getContext("webgl2", {
|
|
145
|
+
premultipliedAlpha: true,
|
|
146
|
+
alpha: true,
|
|
147
|
+
depth: false,
|
|
148
|
+
stencil: false,
|
|
149
|
+
antialias: false
|
|
150
|
+
});
|
|
151
|
+
if (!gl) return null;
|
|
152
|
+
try {
|
|
153
|
+
return new ChromaKeyGl(gl, canvas);
|
|
154
|
+
} catch {
|
|
155
|
+
return null;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
constructor(gl, canvas) {
|
|
159
|
+
this.#gl = gl;
|
|
160
|
+
this.#canvas = canvas;
|
|
161
|
+
canvas.addEventListener("webglcontextlost", this.#handleContextLost);
|
|
162
|
+
canvas.addEventListener("webglcontextrestored", this.#handleContextRestored);
|
|
163
|
+
this.#initResources();
|
|
164
|
+
}
|
|
165
|
+
#handleContextLost = (event) => {
|
|
166
|
+
event.preventDefault();
|
|
167
|
+
this.#contextLost = true;
|
|
168
|
+
};
|
|
169
|
+
#handleContextRestored = () => {
|
|
170
|
+
try {
|
|
171
|
+
this.#initResources();
|
|
172
|
+
this.#contextLost = false;
|
|
173
|
+
} catch {
|
|
174
|
+
this.#contextLost = true;
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
/** (Re)create all GL resources on the current context — at construction and after restore. */
|
|
178
|
+
#initResources() {
|
|
179
|
+
const gl = this.#gl;
|
|
180
|
+
this.#program = compileProgram(gl);
|
|
181
|
+
gl.useProgram(this.#program);
|
|
182
|
+
const location = (name) => gl.getUniformLocation(this.#program, name);
|
|
183
|
+
this.#uniforms = {
|
|
184
|
+
texelSize: location("u_texelSize"),
|
|
185
|
+
keyColor: location("u_keyColor"),
|
|
186
|
+
tintColor: location("u_tintColor"),
|
|
187
|
+
colorCutoff: location("u_colorCutoff"),
|
|
188
|
+
colorFeathering: location("u_colorFeathering"),
|
|
189
|
+
maskFeathering: location("u_maskFeathering"),
|
|
190
|
+
sharpening: location("u_sharpening"),
|
|
191
|
+
despill: location("u_despill"),
|
|
192
|
+
despillLuminanceAdd: location("u_despillLuminanceAdd")
|
|
193
|
+
};
|
|
194
|
+
gl.uniform1i(gl.getUniformLocation(this.#program, "u_texture"), 0);
|
|
195
|
+
gl.uniform1i(gl.getUniformLocation(this.#program, "u_mask"), 1);
|
|
196
|
+
gl.uniform4f(this.#uniforms.tintColor, 1, 1, 1, 1);
|
|
197
|
+
this.#quadBuffer = gl.createBuffer();
|
|
198
|
+
gl.bindBuffer(gl.ARRAY_BUFFER, this.#quadBuffer);
|
|
199
|
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
|
|
200
|
+
-1,
|
|
201
|
+
-1,
|
|
202
|
+
1,
|
|
203
|
+
-1,
|
|
204
|
+
-1,
|
|
205
|
+
1,
|
|
206
|
+
1,
|
|
207
|
+
1
|
|
208
|
+
]), gl.STATIC_DRAW);
|
|
209
|
+
const positionLocation = gl.getAttribLocation(this.#program, "a_pos");
|
|
210
|
+
gl.enableVertexAttribArray(positionLocation);
|
|
211
|
+
gl.vertexAttribPointer(positionLocation, 2, gl.FLOAT, false, 0, 0);
|
|
212
|
+
this.#texture = gl.createTexture();
|
|
213
|
+
gl.bindTexture(gl.TEXTURE_2D, this.#texture);
|
|
214
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
215
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
216
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
217
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
218
|
+
this.#maskTexture = gl.createTexture();
|
|
219
|
+
gl.bindTexture(gl.TEXTURE_2D, this.#maskTexture);
|
|
220
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
|
|
221
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
|
|
222
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
|
|
223
|
+
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
|
|
224
|
+
this.#uploadMask();
|
|
225
|
+
gl.disable(gl.DEPTH_TEST);
|
|
226
|
+
gl.disable(gl.BLEND);
|
|
227
|
+
gl.clearColor(0, 0, 0, 0);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Set (or clear) the grayscale shape mask multiplied onto the output alpha. The
|
|
231
|
+
* mask is stretched over the full video frame, so author it at the video's aspect
|
|
232
|
+
* ratio. Pass null to remove the mask.
|
|
233
|
+
*/
|
|
234
|
+
setMask(image) {
|
|
235
|
+
this.#maskImage = image;
|
|
236
|
+
if (!this.#contextLost) this.#uploadMask();
|
|
237
|
+
}
|
|
238
|
+
#uploadMask() {
|
|
239
|
+
const gl = this.#gl;
|
|
240
|
+
gl.bindTexture(gl.TEXTURE_2D, this.#maskTexture);
|
|
241
|
+
if (this.#maskImage) gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, this.#maskImage);
|
|
242
|
+
else gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, 1, 1, 0, gl.RGBA, gl.UNSIGNED_BYTE, new Uint8Array([
|
|
243
|
+
255,
|
|
244
|
+
255,
|
|
245
|
+
255,
|
|
246
|
+
255
|
|
247
|
+
]));
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Upload the video's current frame and render it keyed. Sizes the drawing buffer to
|
|
251
|
+
* the video's intrinsic size on first draw (and after a size change).
|
|
252
|
+
*/
|
|
253
|
+
draw(video, parameters) {
|
|
254
|
+
if (this.#contextLost || video.videoWidth === 0) return;
|
|
255
|
+
const gl = this.#gl;
|
|
256
|
+
if (this.#canvas.width !== video.videoWidth || this.#canvas.height !== video.videoHeight) {
|
|
257
|
+
this.#canvas.width = video.videoWidth;
|
|
258
|
+
this.#canvas.height = video.videoHeight;
|
|
259
|
+
gl.viewport(0, 0, video.videoWidth, video.videoHeight);
|
|
260
|
+
}
|
|
261
|
+
gl.activeTexture(gl.TEXTURE1);
|
|
262
|
+
gl.bindTexture(gl.TEXTURE_2D, this.#maskTexture);
|
|
263
|
+
gl.activeTexture(gl.TEXTURE0);
|
|
264
|
+
gl.bindTexture(gl.TEXTURE_2D, this.#texture);
|
|
265
|
+
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, video);
|
|
266
|
+
gl.useProgram(this.#program);
|
|
267
|
+
gl.uniform2f(this.#uniforms.texelSize, 1 / video.videoWidth, 1 / video.videoHeight);
|
|
268
|
+
gl.uniform3fv(this.#uniforms.keyColor, parameters.keyColor);
|
|
269
|
+
gl.uniform1f(this.#uniforms.colorCutoff, parameters.colorCutoff);
|
|
270
|
+
gl.uniform1f(this.#uniforms.colorFeathering, parameters.colorFeathering);
|
|
271
|
+
gl.uniform1f(this.#uniforms.maskFeathering, parameters.maskFeathering);
|
|
272
|
+
gl.uniform1f(this.#uniforms.sharpening, parameters.sharpening);
|
|
273
|
+
gl.uniform1f(this.#uniforms.despill, parameters.despill);
|
|
274
|
+
gl.uniform1f(this.#uniforms.despillLuminanceAdd, parameters.despillLuminanceAdd);
|
|
275
|
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
|
276
|
+
gl.drawArrays(gl.TRIANGLE_STRIP, 0, 4);
|
|
277
|
+
}
|
|
278
|
+
dispose() {
|
|
279
|
+
this.#canvas.removeEventListener("webglcontextlost", this.#handleContextLost);
|
|
280
|
+
this.#canvas.removeEventListener("webglcontextrestored", this.#handleContextRestored);
|
|
281
|
+
const gl = this.#gl;
|
|
282
|
+
gl.deleteBuffer(this.#quadBuffer);
|
|
283
|
+
gl.deleteTexture(this.#texture);
|
|
284
|
+
gl.deleteTexture(this.#maskTexture);
|
|
285
|
+
gl.deleteProgram(this.#program);
|
|
286
|
+
gl.getExtension("WEBGL_lose_context")?.loseContext();
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
//#endregion
|
|
290
|
+
export { ChromaKeyGl };
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Plays a greenscreen video keyed onto a transparent canvas. Video and mask
|
|
3
|
+
sources are read at mount; keying parameters are read for every frame.
|
|
4
|
+
-->
|
|
5
|
+
<script lang="ts">
|
|
6
|
+
import { onMount, type Snippet } from 'svelte';
|
|
7
|
+
import type { ChromaKeyVideoManifestEntry } from '#fundus/core';
|
|
8
|
+
import { ChromaKeyGl } from '../chroma-key-gl';
|
|
9
|
+
import { resolveRetainedSource } from '../preload-registry';
|
|
10
|
+
|
|
11
|
+
interface Props {
|
|
12
|
+
src: ChromaKeyVideoManifestEntry;
|
|
13
|
+
/** Loop playback (default true for keyed clips). */
|
|
14
|
+
loop?: boolean;
|
|
15
|
+
/**
|
|
16
|
+
* Playback on/off (default true) — false pauses the video AND the render loop,
|
|
17
|
+
* so an off-screen instance costs nothing. The canvas keeps showing the last
|
|
18
|
+
* keyed frame; a keying-parameter change repaints that still frame in place.
|
|
19
|
+
*/
|
|
20
|
+
playing?: boolean;
|
|
21
|
+
/** Rendered instead of the canvas when WebGL2 is unavailable; overrides the entry's fallback image. */
|
|
22
|
+
fallback?: Snippet;
|
|
23
|
+
/** Bindable handle on the (hidden) source element, so a host can drive playback itself. */
|
|
24
|
+
videoElement?: HTMLVideoElement;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
src,
|
|
30
|
+
loop = true,
|
|
31
|
+
playing = true,
|
|
32
|
+
fallback,
|
|
33
|
+
videoElement = $bindable(),
|
|
34
|
+
class: className = ''
|
|
35
|
+
}: Props = $props();
|
|
36
|
+
|
|
37
|
+
let canvasElement = $state<HTMLCanvasElement>();
|
|
38
|
+
let webglUnavailable = $state(false);
|
|
39
|
+
|
|
40
|
+
let renderer: ChromaKeyGl | null = null;
|
|
41
|
+
|
|
42
|
+
// Render loop: requestVideoFrameCallback fires once per *presented* video frame
|
|
43
|
+
// (and never while paused), so upload + draw happen only when there is a new
|
|
44
|
+
// frame — rAF is the fallback for browsers without it.
|
|
45
|
+
let frameHandle = 0;
|
|
46
|
+
let loopRunning = false;
|
|
47
|
+
|
|
48
|
+
function drawCurrentFrame() {
|
|
49
|
+
const video = videoElement;
|
|
50
|
+
if (renderer && video && video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA) {
|
|
51
|
+
renderer.draw(video, src.chromaKey);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function onFrame() {
|
|
56
|
+
drawCurrentFrame();
|
|
57
|
+
if (loopRunning) scheduleNextFrame();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function scheduleNextFrame() {
|
|
61
|
+
const video = videoElement!;
|
|
62
|
+
frameHandle = video.requestVideoFrameCallback
|
|
63
|
+
? video.requestVideoFrameCallback(onFrame)
|
|
64
|
+
: requestAnimationFrame(onFrame);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function startLoop() {
|
|
68
|
+
if (loopRunning) return;
|
|
69
|
+
loopRunning = true;
|
|
70
|
+
scheduleNextFrame();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function stopLoop() {
|
|
74
|
+
if (!loopRunning) return;
|
|
75
|
+
loopRunning = false;
|
|
76
|
+
const video = videoElement;
|
|
77
|
+
if (video?.cancelVideoFrameCallback) video.cancelVideoFrameCallback(frameHandle);
|
|
78
|
+
else cancelAnimationFrame(frameHandle);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Playback control only starts/stops the element; the render loop follows the
|
|
82
|
+
// element's actual `play`/`pause` events (see the listener effect below), so it
|
|
83
|
+
// stops correctly no matter who pauses — the `playing` prop OR a host driving
|
|
84
|
+
// the bound element directly (e.g. the editor's PlaybackControls scrubber).
|
|
85
|
+
function resumePlayback() {
|
|
86
|
+
if (!playing || webglUnavailable) return;
|
|
87
|
+
videoElement?.play().catch(() => {});
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function pausePlayback() {
|
|
91
|
+
videoElement?.pause();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
onMount(() => {
|
|
95
|
+
renderer = ChromaKeyGl.create(canvasElement!);
|
|
96
|
+
if (!renderer) {
|
|
97
|
+
webglUnavailable = true;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const activeRenderer = renderer;
|
|
101
|
+
|
|
102
|
+
const maskSource = src.mask?.src;
|
|
103
|
+
if (maskSource) {
|
|
104
|
+
const image = new Image();
|
|
105
|
+
image.src = maskSource;
|
|
106
|
+
image
|
|
107
|
+
.decode()
|
|
108
|
+
.then(() => {
|
|
109
|
+
if (renderer === activeRenderer) activeRenderer.setMask(image);
|
|
110
|
+
})
|
|
111
|
+
.catch(() => {
|
|
112
|
+
console.warn(`Failed to load chroma-key mask "${maskSource}".`);
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return () => {
|
|
117
|
+
stopLoop();
|
|
118
|
+
activeRenderer.dispose();
|
|
119
|
+
renderer = null;
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// The render loop tracks the element's real playback state: play starts it,
|
|
124
|
+
// pause/ended stop it (so a paused instance costs nothing, even on the rAF
|
|
125
|
+
// fallback that fires regardless of playback). A seek while paused repaints the
|
|
126
|
+
// single new frame so scrubbing updates the canvas without running the loop.
|
|
127
|
+
$effect(() => {
|
|
128
|
+
const video = videoElement;
|
|
129
|
+
if (!video) return;
|
|
130
|
+
function onPlay() {
|
|
131
|
+
startLoop();
|
|
132
|
+
}
|
|
133
|
+
function onStop() {
|
|
134
|
+
stopLoop();
|
|
135
|
+
drawCurrentFrame();
|
|
136
|
+
}
|
|
137
|
+
function onSeeked() {
|
|
138
|
+
if (!loopRunning) drawCurrentFrame();
|
|
139
|
+
}
|
|
140
|
+
video.addEventListener('play', onPlay);
|
|
141
|
+
video.addEventListener('pause', onStop);
|
|
142
|
+
video.addEventListener('ended', onStop);
|
|
143
|
+
video.addEventListener('seeked', onSeeked);
|
|
144
|
+
return () => {
|
|
145
|
+
video.removeEventListener('play', onPlay);
|
|
146
|
+
video.removeEventListener('pause', onStop);
|
|
147
|
+
video.removeEventListener('ended', onStop);
|
|
148
|
+
video.removeEventListener('seeked', onSeeked);
|
|
149
|
+
};
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
// Drive playback from `playing` (runs after onMount, so the renderer exists —
|
|
153
|
+
// or WebGL is known to be unavailable and nothing starts).
|
|
154
|
+
$effect(() => {
|
|
155
|
+
if (webglUnavailable) return;
|
|
156
|
+
if (playing) resumePlayback();
|
|
157
|
+
else pausePlayback();
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// Retune a still frame live: the draw loop only runs during playback, so a
|
|
161
|
+
// keying change while paused (the editor tuning a bubble on a still frame)
|
|
162
|
+
// would otherwise leave the canvas stale until the next play/seek. Reading the
|
|
163
|
+
// keying fields here makes this a reactive dependency; when the loop is already
|
|
164
|
+
// running its per-frame draw handles it, so only repaint when it isn't.
|
|
165
|
+
$effect(() => {
|
|
166
|
+
const key = src.chromaKey;
|
|
167
|
+
// Touch every keying field so the effect re-runs on any of them.
|
|
168
|
+
void [
|
|
169
|
+
key.keyColor,
|
|
170
|
+
key.colorCutoff,
|
|
171
|
+
key.colorFeathering,
|
|
172
|
+
key.maskFeathering,
|
|
173
|
+
key.sharpening,
|
|
174
|
+
key.despill,
|
|
175
|
+
key.despillLuminanceAdd
|
|
176
|
+
];
|
|
177
|
+
if (!loopRunning) drawCurrentFrame();
|
|
178
|
+
});
|
|
179
|
+
</script>
|
|
180
|
+
|
|
181
|
+
<div class="fundus-chroma-key-video {className}">
|
|
182
|
+
{#if webglUnavailable}
|
|
183
|
+
{#if fallback}
|
|
184
|
+
{@render fallback()}
|
|
185
|
+
{:else if src.fallback}
|
|
186
|
+
<img src={src.fallback.src} alt="" />
|
|
187
|
+
{/if}
|
|
188
|
+
{:else}
|
|
189
|
+
<canvas bind:this={canvasElement}></canvas>
|
|
190
|
+
<video
|
|
191
|
+
bind:this={videoElement}
|
|
192
|
+
src={resolveRetainedSource(src.src)}
|
|
193
|
+
{loop}
|
|
194
|
+
muted
|
|
195
|
+
playsinline
|
|
196
|
+
preload="auto"
|
|
197
|
+
></video>
|
|
198
|
+
{/if}
|
|
199
|
+
</div>
|
|
200
|
+
|
|
201
|
+
<style>
|
|
202
|
+
.fundus-chroma-key-video {
|
|
203
|
+
position: relative;
|
|
204
|
+
width: 100%;
|
|
205
|
+
height: 100%;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
canvas,
|
|
209
|
+
img {
|
|
210
|
+
display: block;
|
|
211
|
+
width: 100%;
|
|
212
|
+
height: 100%;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/* The texture source: must stay rendered (display: none can stall decoding /
|
|
216
|
+
frame callbacks in some browsers), so hide it visually instead. */
|
|
217
|
+
video {
|
|
218
|
+
position: absolute;
|
|
219
|
+
inset: 0;
|
|
220
|
+
width: 100%;
|
|
221
|
+
height: 100%;
|
|
222
|
+
visibility: hidden;
|
|
223
|
+
pointer-events: none;
|
|
224
|
+
}
|
|
225
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
Thin <img> wrapper for an image manifest entry: renders the delivered proxy
|
|
3
|
+
with its intrinsic width/height attributes so layout never shifts while the
|
|
4
|
+
file loads.
|
|
5
|
+
-->
|
|
6
|
+
<script lang="ts">
|
|
7
|
+
import type { ImageManifestEntry } from '#fundus/core';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
src: ImageManifestEntry;
|
|
11
|
+
alt?: string;
|
|
12
|
+
class?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
let { src, alt = '', class: className = '' }: Props = $props();
|
|
16
|
+
</script>
|
|
17
|
+
|
|
18
|
+
<img src={src.src} width={src.width} height={src.height} {alt} class={className} />
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The slice component hosts render: one component, backend chosen
|
|
3
|
+
per use. Dispatches between the two private implementations — SliceDom
|
|
4
|
+
(default) and SliceCanvas — which share the same geometry
|
|
5
|
+
(`computeRenderCells`) and therefore render the same cells.
|
|
6
|
+
-->
|
|
7
|
+
<script lang="ts">
|
|
8
|
+
import type { SliceManifestEntry } from '#fundus/core';
|
|
9
|
+
import SliceDom from './SliceDom.svelte';
|
|
10
|
+
import SliceCanvas from './SliceCanvas.svelte';
|
|
11
|
+
|
|
12
|
+
interface Props {
|
|
13
|
+
src: SliceManifestEntry;
|
|
14
|
+
/**
|
|
15
|
+
* Rendering backend. `dom` (default) paints one absolutely-positioned
|
|
16
|
+
* `<img>` window per cell; `canvas` draws every cell into a single
|
|
17
|
+
* `<canvas>`. Both produce the same cells — but the DOM backend can
|
|
18
|
+
* flicker in some situations (cells are separate elements the browser
|
|
19
|
+
* composites independently, so a resize mid-animation can tear), so
|
|
20
|
+
* hosts pick `canvas` where that hurts.
|
|
21
|
+
*/
|
|
22
|
+
backend?: 'dom' | 'canvas';
|
|
23
|
+
/** Accessible label for the rendered graphic. */
|
|
24
|
+
alt?: string;
|
|
25
|
+
class?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let { src, backend = 'dom', alt = '', class: className = '' }: Props = $props();
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
{#if backend === 'canvas'}
|
|
32
|
+
<SliceCanvas {src} {alt} class={className} />
|
|
33
|
+
{:else}
|
|
34
|
+
<SliceDom {src} {alt} class={className} />
|
|
35
|
+
{/if}
|