egaki 0.8.0 → 0.9.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.
@@ -0,0 +1,706 @@
1
+ 'use client'
2
+
3
+ /**
4
+ * AngledScreen — WebGL depth-of-field 3D screen via Remotion HtmlInCanvas.
5
+ *
6
+ * Shader-based successor to BasicAngledScreen (angled-screen.tsx). Children
7
+ * DOM is captured per frame with the HTML-in-canvas API (texElementImage2D)
8
+ * and rendered as a 3D-tilted plane in a WebGL2 fragment shader:
9
+ *
10
+ * screen pixel → ray from CSS-style perspective camera → ray/plane
11
+ * intersection → plane-local UV → sample DOM texture
12
+ *
13
+ * The intersection distance gives true per-pixel depth, which drives:
14
+ * - bokeh blur (poisson-disc, radius grows with distance from focus)
15
+ * - fog toward backgroundColor (like the original Three.js plugin's
16
+ * scene fog, unframer-private/plugin-angled-screen BokehPass setup)
17
+ * - film grain (from the plugin's filmGrainShader, u_time-seeded)
18
+ *
19
+ * Prop names match BasicAngledScreen where semantics carry over:
20
+ * perspective/rotateX/rotateY/rotateZ/translateZ are interpreted exactly
21
+ * like the CSS transform version. Depth of field uses the exact Three.js
22
+ * BokehShader algorithm and the plugin's defaults (aperture 0.5,
23
+ * maxblur 0.12, focus at the plane center): blur grows linearly with the
24
+ * signed distance from the focus plane and saturates at maxBlur, sampled
25
+ * with the 41-tap Martins Upitis ring kernel in screen space.
26
+ *
27
+ * Requires Chrome 149+ with chrome://flags/#canvas-draw-element enabled.
28
+ * Falls back to the CSS BasicAngledScreen when unsupported.
29
+ *
30
+ * EXPORT PATH (DOM-composer fallback), hard-won — don't re-debug:
31
+ * When Remotion's nested HTML-in-canvas probe fails (Chromium changes the
32
+ * API shape between builds), web-renderer composes exports/stills with the
33
+ * DOM composer instead of native capture. Three things must line up for the
34
+ * shader output to survive that path:
35
+ * 1. The scaffold wrapper is visibility:hidden, and Chromium creates no
36
+ * paint records for hidden subtrees — captureElementImage() throws
37
+ * "missing paint record" and HtmlInCanvas SILENTLY skips onInit/onPaint
38
+ * (canRetryMissingPaintRecord swallows it). Fix: the egaki render entry
39
+ * points wrap the composition root in a visibility:visible div
40
+ * (wrapForWebRenderer in render-client.ts), restoring paint records
41
+ * while preserving deeper visibility:hidden semantics (e.g.
42
+ * LayoutTransition's hidden inactive instances). Do NOT force
43
+ * visibility on this component's canvas — that leaks into preview.
44
+ * 2. remotion's HtmlInCanvas has a scoping bug (present through 4.0.495):
45
+ * it registers delayRender() on the GLOBAL window scope but the
46
+ * scaffold's waitForReady() polls its own delayRenderScope — so the
47
+ * renderer never waits for the nested canvas paint. Fix: this component
48
+ * registers its own properly-scoped useDelayRender() handle per frame
49
+ * while isRendering, calls requestPaint(), and releases the handle only
50
+ * after onPaint has drawn and blitted the frame.
51
+ * TODO: remove the scoped-delayRender workaround (and re-evaluate the
52
+ * visibility wrapper in render-client.ts) once this upstream issue is
53
+ * fixed: https://github.com/remotion-dev/remotion/issues/9367
54
+ * 3. The DOM composer cannot read the transferred placeholder canvas, so
55
+ * every painted GL frame is blitted into a plain 2D mirror canvas that
56
+ * the composer picks up like any regular canvas.
57
+ *
58
+ * Coordinate mapping notes (hard-won, don't re-derive):
59
+ * - GL space is y-up, z toward viewer, camera at (0, 0, perspective).
60
+ * - CSS rotateX(+)/rotateY(+) match standard GL Rx/Ry; CSS rotateZ is
61
+ * visually clockwise (y-down), so we negate it for GL.
62
+ * - UNPACK_FLIP_Y_WEBGL=true puts DOM top at v=1, matching y-up plane UVs.
63
+ */
64
+
65
+ import {
66
+ HtmlInCanvas,
67
+ useCurrentFrame,
68
+ useDelayRender,
69
+ useRemotionEnvironment,
70
+ useVideoConfig,
71
+ type HtmlInCanvasOnInit,
72
+ type HtmlInCanvasOnPaint,
73
+ } from 'remotion'
74
+ import { useCallback, useLayoutEffect, useRef, type CSSProperties, type ReactNode } from 'react'
75
+ import { useTweakpane } from './tweakpane-hook.tsx'
76
+ import { compileGlShader, linkGlProgram, parseHexColor } from './shader-renderer.tsx'
77
+ import { BasicAngledScreen } from './angled-screen.tsx'
78
+
79
+ export interface AngledScreenProps {
80
+ children: ReactNode
81
+
82
+ // --- 3D transform (same semantics as BasicAngledScreen) ---
83
+ /** CSS perspective distance in px. Smaller = more dramatic. Default 1200. */
84
+ perspective?: number
85
+ /** X-axis rotation in degrees (tilts top/bottom). Default 8. */
86
+ rotateX?: number
87
+ /** Y-axis rotation in degrees (angles left/right). Default -12. */
88
+ rotateY?: number
89
+ /** Z-axis rotation in degrees. Default 0. */
90
+ rotateZ?: number
91
+ /** Shift the plane horizontally in px. Positive = right. Default 0. */
92
+ translateX?: number
93
+ /** Push the plane forward/back in px. Positive = closer/larger. Default 0. */
94
+ translateZ?: number
95
+
96
+ // --- Depth of field (Three.js BokehShader model, same as the Framer plugin) ---
97
+ /** Enable depth-based bokeh blur. Default true. */
98
+ bokeh?: boolean
99
+ /** Aperture — bigger values for shallower depth of field. Blur grows
100
+ * linearly with distance from the focus plane, scaled by this.
101
+ * Same semantics as Three's BokehPass. Default 0.5 (plugin value). */
102
+ aperture?: number
103
+ /** Max blur in screen-UV units (fraction of frame size), the saturation
104
+ * cap of the blur. Same as Three's `maxblur`. Default 0.12 (plugin value). */
105
+ maxBlur?: number
106
+ /** Focus distance as a fraction of `perspective`. Default 0 = auto: focus
107
+ * tracks the nearest visible point of the plane, so the near side stays
108
+ * sharp and blur ramps progressively toward the far edge only. */
109
+ focus?: number
110
+
111
+ // --- Atmosphere (from the original Three.js plugin) ---
112
+ /** Fog toward backgroundColor at the far depth (0-1). Default 0.35. */
113
+ fog?: number
114
+ /** Film grain intensity. Default 0.02. */
115
+ grainIntensity?: number
116
+
117
+ /** Background color behind the tilted screen. Default '#000000'. */
118
+ backgroundColor?: string
119
+
120
+ // --- Content sizing ---
121
+ /** Width of the inner content wrapper. Default '80%'. */
122
+ width?: string | number
123
+ /** Height of the inner content wrapper. Default 'auto'. */
124
+ height?: string | number
125
+
126
+ /** Render normalized depth as grayscale for debugging the DOF ramp. */
127
+ debug?: boolean
128
+
129
+ /** Optional inline style on the outer container. */
130
+ style?: CSSProperties
131
+ }
132
+
133
+ const VERTEX_SHADER = `#version 300 es
134
+ precision highp float;
135
+ in vec2 a_position;
136
+ in vec2 a_texCoord;
137
+ out vec2 v_uv;
138
+ void main() {
139
+ v_uv = a_texCoord;
140
+ gl_Position = vec4(a_position, 0.0, 1.0);
141
+ }
142
+ `
143
+
144
+ const FRAGMENT_SHADER = `#version 300 es
145
+ precision highp float;
146
+
147
+ uniform sampler2D u_tex;
148
+ uniform mat3 u_rot; // plane local -> world rotation
149
+ uniform vec2 u_planeSize; // logical composition px
150
+ uniform float u_perspective; // camera z in px
151
+ uniform float u_translateX; // plane center x in px
152
+ uniform float u_translateZ; // plane center z in px
153
+ uniform float u_depthMin; // nearest corner distance to camera
154
+ uniform float u_depthMax; // farthest corner distance to camera
155
+ uniform float u_bokehEnabled;
156
+ uniform float u_aperture; // Three BokehShader aperture (plugin: 0.5)
157
+ uniform float u_maxblur; // Three BokehShader maxblur, screen-UV units (plugin: 0.12)
158
+ uniform float u_focusDist; // focus distance in px along the ray
159
+ uniform float u_aspect; // width / height, for circular bokeh
160
+ uniform float u_fog;
161
+ uniform float u_grain;
162
+ uniform vec4 u_background;
163
+ uniform float u_debug;
164
+ uniform float u_time;
165
+
166
+ in vec2 v_uv;
167
+ out vec4 fragColor;
168
+
169
+ float rand(vec2 co) {
170
+ return fract(sin(dot(co, vec2(12.9898, 78.233))) * 43758.5453);
171
+ }
172
+
173
+ // Three.js BokehShader ring kernel (Martins Upitis lens blur):
174
+ // 16 taps at radius 1.0, 8 at 0.9, 8 at 0.7, 8 at 0.4, plus center = 41.
175
+ const vec2 RING1[16] = vec2[](
176
+ vec2(0.0, 0.4), vec2(0.15, 0.37), vec2(0.29, 0.29), vec2(-0.37, 0.15),
177
+ vec2(0.40, 0.0), vec2(0.37, -0.15), vec2(0.29, -0.29), vec2(-0.15, -0.37),
178
+ vec2(0.0, -0.4), vec2(-0.15, 0.37), vec2(-0.29, 0.29), vec2(0.37, 0.15),
179
+ vec2(-0.4, 0.0), vec2(-0.37, -0.15), vec2(-0.29, -0.29), vec2(0.15, -0.37)
180
+ );
181
+ const vec2 RING9[8] = vec2[](
182
+ vec2(0.15, 0.37), vec2(-0.37, 0.15), vec2(0.37, -0.15), vec2(-0.15, -0.37),
183
+ vec2(-0.15, 0.37), vec2(0.37, 0.15), vec2(-0.37, -0.15), vec2(0.15, -0.37)
184
+ );
185
+ const vec2 RING7[8] = vec2[](
186
+ vec2(0.29, 0.29), vec2(0.40, 0.0), vec2(0.29, -0.29), vec2(0.0, -0.4),
187
+ vec2(-0.29, 0.29), vec2(-0.4, 0.0), vec2(-0.29, -0.29), vec2(0.0, 0.4)
188
+ );
189
+
190
+ // Intersect the camera ray through screen uv suv with the tilted plane.
191
+ // Returns ray distance t, or -1.0 on miss. Outputs plane uv + edge coverage.
192
+ float intersectPlane(vec2 suv, out vec2 uv, out float coverage) {
193
+ vec2 screen = (suv - 0.5) * u_planeSize;
194
+ vec3 C = vec3(0.0, 0.0, u_perspective);
195
+ vec3 dir = normalize(vec3(screen, 0.0) - C);
196
+ vec3 n = u_rot * vec3(0.0, 0.0, 1.0);
197
+ vec3 q0 = vec3(u_translateX, 0.0, u_translateZ);
198
+ float denom = dot(n, dir);
199
+ if (abs(denom) < 1e-6) return -1.0;
200
+ float t = dot(n, q0 - C) / denom;
201
+ if (t <= 0.0) return -1.0;
202
+ vec3 local = transpose(u_rot) * (C + t * dir - q0);
203
+ uv = local.xy / u_planeSize + 0.5;
204
+ vec2 edgePx = (0.5 - abs(uv - 0.5)) * u_planeSize;
205
+ coverage = clamp(min(edgePx.x, edgePx.y) + 0.5, 0.0, 1.0);
206
+ return t;
207
+ }
208
+
209
+ // Full scene color at a screen position: plane composited over background.
210
+ // Screen-space sampling like Three's BokehPass (which blurs the rendered
211
+ // frame), so blur also softens the plane edges into the background.
212
+ vec4 sampleScene(vec2 suv) {
213
+ vec2 uv;
214
+ float coverage;
215
+ float t = intersectPlane(suv, uv, coverage);
216
+ if (t < 0.0 || coverage <= 0.0) return u_background;
217
+ vec4 c = texture(u_tex, uv);
218
+ c = vec4(mix(u_background.rgb, c.rgb, c.a), 1.0);
219
+ return mix(u_background, c, coverage);
220
+ }
221
+
222
+ void main() {
223
+ vec4 bg = u_background;
224
+
225
+ vec2 uv;
226
+ float coverage;
227
+ float t = intersectPlane(v_uv, uv, coverage);
228
+ bool hit = t > 0.0 && coverage > 0.0;
229
+
230
+ // Normalized depth across the visible plane (0 = near, 1 = far) — used
231
+ // for fog and the debug view.
232
+ float depth01 = hit
233
+ ? clamp((t - u_depthMin) / max(u_depthMax - u_depthMin, 1e-3), 0.0, 1.0)
234
+ : 1.0;
235
+
236
+ if (u_debug > 0.5) {
237
+ fragColor = hit ? mix(bg, vec4(vec3(depth01), 1.0), coverage) : bg;
238
+ return;
239
+ }
240
+
241
+ // Three.js BokehShader blur factor, made one-sided: only content BEYOND
242
+ // the focus plane blurs (the far side of the tilted screen). The near side
243
+ // stays tack sharp. Background pixels (no hit) count as far away, so the
244
+ // plane silhouette melts softly where it is out of focus.
245
+ float factor = hit ? max(t - u_focusDist, 0.0) / u_perspective : 1.0;
246
+ vec2 dofblur = vec2(clamp(factor * u_aperture, 0.0, u_maxblur)) * u_bokehEnabled;
247
+
248
+ vec2 aspectcorrect = vec2(1.0, u_aspect);
249
+ vec2 dofblur9 = dofblur * 0.9;
250
+ vec2 dofblur7 = dofblur * 0.7;
251
+ vec2 dofblur4 = dofblur * 0.4;
252
+
253
+ vec4 col = sampleScene(v_uv);
254
+ for (int i = 0; i < 16; i++) {
255
+ col += sampleScene(v_uv + RING1[i] * aspectcorrect * dofblur);
256
+ }
257
+ for (int i = 0; i < 8; i++) {
258
+ col += sampleScene(v_uv + RING9[i] * aspectcorrect * dofblur9);
259
+ }
260
+ for (int i = 0; i < 8; i++) {
261
+ col += sampleScene(v_uv + RING7[i] * aspectcorrect * dofblur7);
262
+ }
263
+ for (int i = 0; i < 8; i++) {
264
+ col += sampleScene(v_uv + RING7[i] * aspectcorrect * dofblur4);
265
+ }
266
+ col /= 41.0;
267
+
268
+ // Fog toward background with depth (plugin's THREE.Fog equivalent)
269
+ col.rgb = mix(col.rgb, bg.rgb, u_fog * depth01 * (hit ? 1.0 : 0.0));
270
+
271
+ // Film grain (plugin's filmGrainShader)
272
+ col.rgb += (rand(v_uv + fract(u_time)) - 0.5) * u_grain;
273
+ col.a = 1.0;
274
+
275
+ fragColor = col;
276
+ }
277
+ `
278
+
279
+ interface GlState {
280
+ gl: WebGL2RenderingContext
281
+ program: WebGLProgram
282
+ texture: WebGLTexture
283
+ locs: Record<string, WebGLUniformLocation | null>
284
+ /** The OffscreenCanvas the WebGL context renders into. */
285
+ canvas: OffscreenCanvas
286
+ }
287
+
288
+ /** Chromium's html-in-canvas layout canvas exposes a non-standard
289
+ * requestPaint() (behind chrome://flags/#canvas-draw-element). */
290
+ type LayoutSubtreeCanvas = HTMLCanvasElement & { requestPaint?: () => void }
291
+
292
+ const UNIFORM_NAMES = [
293
+ 'u_tex',
294
+ 'u_rot',
295
+ 'u_planeSize',
296
+ 'u_perspective',
297
+ 'u_translateX',
298
+ 'u_translateZ',
299
+ 'u_depthMin',
300
+ 'u_depthMax',
301
+ 'u_bokehEnabled',
302
+ 'u_aperture',
303
+ 'u_maxblur',
304
+ 'u_focusDist',
305
+ 'u_aspect',
306
+ 'u_fog',
307
+ 'u_grain',
308
+ 'u_background',
309
+ 'u_debug',
310
+ 'u_time',
311
+ ] as const
312
+
313
+ /**
314
+ * Build the CSS-order rotation matrix M = Rx * Ry * Rz(-z) in GL coords
315
+ * (y up, z toward viewer). Returns row-major 3x3 as number[][].
316
+ */
317
+ function rotationMatrix(rxDeg: number, ryDeg: number, rzDeg: number): number[][] {
318
+ const rx = (rxDeg * Math.PI) / 180
319
+ const ry = (ryDeg * Math.PI) / 180
320
+ const rz = (-rzDeg * Math.PI) / 180
321
+ const cx = Math.cos(rx)
322
+ const sx = Math.sin(rx)
323
+ const cy = Math.cos(ry)
324
+ const sy = Math.sin(ry)
325
+ const cz = Math.cos(rz)
326
+ const sz = Math.sin(rz)
327
+ const Rx = [
328
+ [1, 0, 0],
329
+ [0, cx, -sx],
330
+ [0, sx, cx],
331
+ ]
332
+ const Ry = [
333
+ [cy, 0, sy],
334
+ [0, 1, 0],
335
+ [-sy, 0, cy],
336
+ ]
337
+ const Rz = [
338
+ [cz, -sz, 0],
339
+ [sz, cz, 0],
340
+ [0, 0, 1],
341
+ ]
342
+ const mul = (a: number[][], b: number[][]) =>
343
+ a.map((row, i) => row.map((_, j) => a[i]![0]! * b[0]![j]! + a[i]![1]! * b[1]![j]! + a[i]![2]! * b[2]![j]!))
344
+ return mul(mul(Rx, Ry), Rz)
345
+ }
346
+
347
+ export function AngledScreen(props: AngledScreenProps) {
348
+ const frame = useCurrentFrame()
349
+ const { width: compW, height: compH, fps } = useVideoConfig()
350
+ const glRef = useRef<GlState | null>(null)
351
+ // Mirror canvas: the web-renderer's DOM composer (used for exports and SDK
352
+ // screenshots whenever nested HTML-in-canvas capture is unavailable)
353
+ // cannot read the HtmlInCanvas offscreen and paints the raw children
354
+ // instead. Every painted GL frame is blitted into this plain 2D canvas
355
+ // overlaid on top; the DOM composer draws it like any regular canvas.
356
+ // It is always mounted (not only during export) because sdk.screenshot()
357
+ // captures the live tree without ExportContext.
358
+ const mirrorRef = useRef<HTMLCanvasElement | null>(null)
359
+ const mirrorLoggedRef = useRef(false)
360
+
361
+ // Export sync: while web-renderer renders (isRendering), hold a scoped
362
+ // delayRender handle per frame and force a paint on the layout canvas.
363
+ // Released in onPaint after the GL frame is blitted to the mirror, so
364
+ // waitForReady() blocks until the shader output for THIS frame exists.
365
+ // Needed because remotion's own "waiting for first paint" delayRender
366
+ // registers on the window scope, which the export scaffold ignores.
367
+ // TODO: remove once fixed upstream:
368
+ // https://github.com/remotion-dev/remotion/issues/9367
369
+ const { isRendering } = useRemotionEnvironment()
370
+ const { delayRender, continueRender } = useDelayRender()
371
+ const continueRenderRef = useRef(continueRender)
372
+ continueRenderRef.current = continueRender
373
+ const layoutCanvasRef = useRef<LayoutSubtreeCanvas | null>(null)
374
+ const pendingPaintHandleRef = useRef<number | null>(null)
375
+
376
+ const tp = useTweakpane('AngledScreen', {
377
+ perspective: { value: props.perspective ?? 1200, min: 100, max: 3000, step: 10 },
378
+ rotateX: { value: props.rotateX ?? 8, min: -90, max: 90, step: 0.5 },
379
+ rotateY: { value: props.rotateY ?? -12, min: -90, max: 90, step: 0.5 },
380
+ rotateZ: { value: props.rotateZ ?? 0, min: -180, max: 180, step: 0.5 },
381
+ translateX: { value: props.translateX ?? 0, min: -1000, max: 1000, step: 1 },
382
+ translateZ: { value: props.translateZ ?? 0, min: -500, max: 500, step: 1 },
383
+ bokeh: props.bokeh ?? true,
384
+ aperture: { value: props.aperture ?? 0.5, min: 0, max: 2, step: 0.01 },
385
+ maxBlur: { value: props.maxBlur ?? 0.12, min: 0, max: 0.3, step: 0.005 },
386
+ // 0 = auto: focus tracks the nearest visible point of the plane, so only
387
+ // the far side blurs, progressively. Any other value = fraction of
388
+ // perspective, like Three's focus uniform.
389
+ focus: {
390
+ value: props.focus ?? 0,
391
+ min: 0,
392
+ max: 2,
393
+ step: 0.01,
394
+ },
395
+ fog: { value: props.fog ?? 0.35, min: 0, max: 1, step: 0.01 },
396
+ grainIntensity: { value: props.grainIntensity ?? 0.02, min: 0, max: 0.2, step: 0.005 },
397
+ backgroundColor: props.backgroundColor ?? '#000000',
398
+ debug: props.debug ?? false,
399
+ })
400
+
401
+ const { perspective, rotateX, rotateY, rotateZ, translateX, translateZ } = tp
402
+ const { aperture, maxBlur, focus, fog, grainIntensity } = tp
403
+ const { bokeh, debug, backgroundColor } = tp
404
+ const time = frame / fps
405
+
406
+ const onInit: HtmlInCanvasOnInit = useCallback(({ canvas }) => {
407
+ const gl = canvas.getContext('webgl2', {
408
+ alpha: true,
409
+ premultipliedAlpha: true,
410
+ antialias: false,
411
+ // Required so the mirror canvas (export path) can drawImage() the GL
412
+ // buffer after the draw call instead of getting a cleared buffer.
413
+ preserveDrawingBuffer: true,
414
+ })
415
+ if (!gl) {
416
+ throw new Error('[AngledScreen] WebGL2 unavailable')
417
+ }
418
+
419
+ gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, true)
420
+
421
+ const vs = compileGlShader(gl, gl.VERTEX_SHADER, VERTEX_SHADER)
422
+ const fs = compileGlShader(gl, gl.FRAGMENT_SHADER, FRAGMENT_SHADER)
423
+ const program = linkGlProgram(gl, vs, fs)
424
+ gl.deleteShader(vs)
425
+ gl.deleteShader(fs)
426
+
427
+ const texture = gl.createTexture()!
428
+ gl.bindTexture(gl.TEXTURE_2D, texture)
429
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR)
430
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR)
431
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE)
432
+ gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE)
433
+
434
+ // Fullscreen quad: interleaved position + uv, 6 vertices
435
+ const buffer = gl.createBuffer()!
436
+ gl.bindBuffer(gl.ARRAY_BUFFER, buffer)
437
+ // prettier-ignore
438
+ gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([
439
+ -1, -1, 0, 0, 1, -1, 1, 0, -1, 1, 0, 1,
440
+ -1, 1, 0, 1, 1, -1, 1, 0, 1, 1, 1, 1,
441
+ ]), gl.STATIC_DRAW)
442
+
443
+ const vao = gl.createVertexArray()!
444
+ gl.bindVertexArray(vao)
445
+ const posLoc = gl.getAttribLocation(program, 'a_position')
446
+ const uvLoc = gl.getAttribLocation(program, 'a_texCoord')
447
+ gl.enableVertexAttribArray(posLoc)
448
+ gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 16, 0)
449
+ gl.enableVertexAttribArray(uvLoc)
450
+ gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 16, 8)
451
+
452
+ const locs: Record<string, WebGLUniformLocation | null> = {}
453
+ for (const name of UNIFORM_NAMES) {
454
+ locs[name] = gl.getUniformLocation(program, name)
455
+ }
456
+
457
+ gl.useProgram(program)
458
+ gl.bindVertexArray(vao)
459
+
460
+ glRef.current = { gl, program, texture, locs, canvas }
461
+
462
+ return () => {
463
+ gl.deleteProgram(program)
464
+ gl.deleteTexture(texture)
465
+ gl.deleteVertexArray(vao)
466
+ gl.deleteBuffer(buffer)
467
+ glRef.current = null
468
+ }
469
+ }, [])
470
+
471
+ const onPaint: HtmlInCanvasOnPaint = useCallback(
472
+ ({ elementImage }) => {
473
+ const state = glRef.current
474
+ if (!state) return
475
+ const { gl, locs } = state
476
+
477
+ gl.viewport(0, 0, gl.drawingBufferWidth, gl.drawingBufferHeight)
478
+
479
+ gl.activeTexture(gl.TEXTURE0)
480
+ gl.bindTexture(gl.TEXTURE_2D, state.texture)
481
+ // The HTML-in-canvas API changed shape between Chromium builds:
482
+ // Chrome 149-151: texElementImage2D(target, level, internalformat, format, type, element)
483
+ // Chrome 152+: texElementImage2D(target, internalformat, element)
484
+ // where internalformat must be sized (RGBA8, SRGB8_ALPHA8, RGBA16F, RGBA32F)
485
+ // Detect via Function.length (3 = new signature).
486
+ if (gl.texElementImage2D.length <= 3) {
487
+ ;(gl.texElementImage2D as any)(gl.TEXTURE_2D, gl.RGBA8, elementImage)
488
+ } else {
489
+ gl.texElementImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, elementImage)
490
+ }
491
+
492
+ // CPU-side scene setup: rotation matrix + VISIBLE depth statistics.
493
+ // Depth is sampled on a 7x7 grid of rays across the screen and
494
+ // summarized with percentiles. Percentiles are area-weighted, which
495
+ // matters: rays toward the plane's vanishing direction produce huge t
496
+ // values, and using the raw corner min/max would compress the blur
497
+ // ramp so much that most of the image reads sharp. Quantiles keep the
498
+ // ramp perceptually spread across the screen.
499
+ const M = rotationMatrix(rotateX, rotateY, rotateZ)
500
+ const hw = compW / 2
501
+ const hh = compH / 2
502
+ // Plane normal (third column of M)
503
+ const nx = M[0]![2]!
504
+ const ny = M[1]![2]!
505
+ const nz = M[2]![2]!
506
+ const depths: number[] = []
507
+ for (let gy = 0; gy < 7; gy++) {
508
+ for (let gx = 0; gx < 7; gx++) {
509
+ // Ray from camera C=(0,0,perspective) through screen point (sx,sy,0)
510
+ const sx = ((gx / 6) * 2 - 1) * hw
511
+ const sy = ((gy / 6) * 2 - 1) * hh
512
+ const len = Math.hypot(sx, sy, perspective)
513
+ const denom = (nx * sx + ny * sy - nz * perspective) / len
514
+ if (Math.abs(denom) < 1e-6) continue
515
+ // t = dot(n, q0 - C) / dot(n, dir), q0 = (translateX,0,translateZ)
516
+ const t = (nx * translateX + nz * (translateZ - perspective)) / denom
517
+ if (t > 0 && Number.isFinite(t)) depths.push(t)
518
+ }
519
+ }
520
+ depths.sort((a, b) => a - b)
521
+ const quantile = (q: number) =>
522
+ depths[Math.min(depths.length - 1, Math.round(q * (depths.length - 1)))] ?? perspective
523
+ const depthMin = depths.length > 0 ? depths[0]! : perspective
524
+ const depthMax = depths.length > 0 ? quantile(0.95) : perspective + 1
525
+
526
+ if (locs.u_tex) gl.uniform1i(locs.u_tex, 0)
527
+ if (locs.u_rot) {
528
+ // GLSL mat3 is column-major; M is row-major → transpose on flatten
529
+ // prettier-ignore
530
+ gl.uniformMatrix3fv(locs.u_rot, false, [
531
+ M[0]![0]!, M[1]![0]!, M[2]![0]!,
532
+ M[0]![1]!, M[1]![1]!, M[2]![1]!,
533
+ M[0]![2]!, M[1]![2]!, M[2]![2]!,
534
+ ])
535
+ }
536
+ if (locs.u_planeSize) gl.uniform2f(locs.u_planeSize, compW, compH)
537
+ if (locs.u_perspective) gl.uniform1f(locs.u_perspective, perspective)
538
+ if (locs.u_translateX) gl.uniform1f(locs.u_translateX, translateX)
539
+ if (locs.u_translateZ) gl.uniform1f(locs.u_translateZ, translateZ)
540
+ if (locs.u_depthMin) gl.uniform1f(locs.u_depthMin, depthMin)
541
+ if (locs.u_depthMax) gl.uniform1f(locs.u_depthMax, depthMax)
542
+ // focus=0 (auto): the focus plane sits at the 40th depth percentile,
543
+ // so roughly the near half of the image reads sharp and blur ramps
544
+ // progressively from there toward the far edge. The effective aperture
545
+ // is auto-scaled so blur reaches maxBlur at the 95th percentile
546
+ // regardless of scene scale (aperture prop acts as a multiplier
547
+ // relative to its 0.5 default: higher = saturates earlier).
548
+ const focusDist = focus > 0 ? focus * perspective : quantile(0.4)
549
+ const apertureEff =
550
+ focus > 0
551
+ ? aperture
552
+ : (aperture / 0.5) * ((maxBlur * perspective) / Math.max(depthMax - focusDist, 1))
553
+ if (locs.u_bokehEnabled) gl.uniform1f(locs.u_bokehEnabled, bokeh ? 1 : 0)
554
+ if (locs.u_aperture) gl.uniform1f(locs.u_aperture, apertureEff)
555
+ if (locs.u_maxblur) gl.uniform1f(locs.u_maxblur, maxBlur)
556
+ if (locs.u_focusDist) gl.uniform1f(locs.u_focusDist, focusDist)
557
+ if (locs.u_aspect) gl.uniform1f(locs.u_aspect, compW / compH)
558
+ if (locs.u_fog) gl.uniform1f(locs.u_fog, fog)
559
+ if (locs.u_grain) gl.uniform1f(locs.u_grain, grainIntensity)
560
+ if (locs.u_background) gl.uniform4fv(locs.u_background, parseHexColor(backgroundColor))
561
+ if (locs.u_debug) gl.uniform1f(locs.u_debug, debug ? 1 : 0)
562
+ if (locs.u_time) gl.uniform1f(locs.u_time, time)
563
+
564
+ gl.drawArrays(gl.TRIANGLES, 0, 6)
565
+
566
+ // Export path: mirror the GL frame into the overlay 2D canvas so the
567
+ // DOM composer (used when nested HTML-in-canvas capture is
568
+ // unavailable) picks up the shader output instead of the raw children.
569
+ const mirror = mirrorRef.current
570
+ if (mirror) {
571
+ if (mirror.width !== state.canvas.width || mirror.height !== state.canvas.height) {
572
+ mirror.width = state.canvas.width
573
+ mirror.height = state.canvas.height
574
+ }
575
+ const ctx2d = mirror.getContext('2d')
576
+ if (ctx2d) {
577
+ ctx2d.drawImage(state.canvas, 0, 0)
578
+ if (!mirrorLoggedRef.current) {
579
+ mirrorLoggedRef.current = true
580
+ console.info(
581
+ '[egaki] AngledScreen: mirror canvas active — GL frames are blitted to a 2D canvas so DOM-composer captures (export/screenshot) include the shader output.',
582
+ )
583
+ }
584
+ }
585
+ }
586
+
587
+ // Release the export-sync handle now that this frame's shader output
588
+ // (including the mirror blit) is complete.
589
+ const pending = pendingPaintHandleRef.current
590
+ if (pending !== null) {
591
+ pendingPaintHandleRef.current = null
592
+ continueRenderRef.current(pending)
593
+ }
594
+ },
595
+ [
596
+ compW, compH, perspective, rotateX, rotateY, rotateZ, translateX, translateZ,
597
+ bokeh, aperture, maxBlur, focus, fog, grainIntensity,
598
+ backgroundColor, debug, time,
599
+ ],
600
+ )
601
+
602
+ const supported = typeof document !== 'undefined' && HtmlInCanvas.isSupported()
603
+
604
+ // Per-frame export sync (see comment on pendingPaintHandleRef). Layout
605
+ // effect so the handle is registered synchronously inside the renderer's
606
+ // flushSync time update, before waitForReady() first checks the scope.
607
+ // Gated on `supported`: the CSS BasicAngledScreen fallback never paints,
608
+ // so registering a handle there would hang the export until timeout.
609
+ // Also gated on computed visibility: instances under visibility:hidden
610
+ // ancestors (LayoutTransition ghosts and inactive timed instances) get no
611
+ // Chromium paint records, so their HtmlInCanvas never fires paint and a
612
+ // registered handle would hang the export. Premounted sections are fine —
613
+ // remotion premount hides via opacity:0, which keeps paint records.
614
+ useLayoutEffect(() => {
615
+ if (!isRendering || !supported) return
616
+ const canvas = layoutCanvasRef.current
617
+ if (!canvas || getComputedStyle(canvas).visibility === 'hidden') return
618
+ const handle = delayRender('AngledScreen: waiting for shader paint')
619
+ pendingPaintHandleRef.current = handle
620
+ layoutCanvasRef.current?.requestPaint?.()
621
+ return () => {
622
+ if (pendingPaintHandleRef.current === handle) {
623
+ pendingPaintHandleRef.current = null
624
+ }
625
+ continueRenderRef.current(handle)
626
+ }
627
+ }, [frame, isRendering, supported, delayRender])
628
+ if (!supported) {
629
+ if (typeof console !== 'undefined' && !(globalThis as any).__egakiAngledScreenFallbackWarned) {
630
+ ;(globalThis as any).__egakiAngledScreenFallbackWarned = true
631
+ console.warn(
632
+ '[egaki] AngledScreen: HTML-in-canvas is not supported in this browser — ' +
633
+ 'falling back to the CSS BasicAngledScreen (no true depth-of-field). ' +
634
+ 'Use Chrome 149+ and enable chrome://flags/#canvas-draw-element for the WebGL version.',
635
+ )
636
+ }
637
+ return (
638
+ <BasicAngledScreen
639
+ perspective={perspective}
640
+ rotateX={rotateX}
641
+ rotateY={rotateY}
642
+ rotateZ={rotateZ}
643
+ translateZ={translateZ}
644
+ bokeh={bokeh}
645
+ bokehBlur={maxBlur * compW * 0.4}
646
+ bokehOffset={0.5}
647
+ backgroundColor={backgroundColor}
648
+ width={props.width}
649
+ height={props.height}
650
+ debug={debug}
651
+ style={{
652
+ // BasicAngledScreen has no translateX — approximate with a CSS shift
653
+ transform: translateX !== 0 ? `translateX(${translateX}px)` : undefined,
654
+ ...props.style,
655
+ }}
656
+ >
657
+ {props.children}
658
+ </BasicAngledScreen>
659
+ )
660
+ }
661
+
662
+ return (
663
+ <div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden', ...props.style }}>
664
+ <HtmlInCanvas
665
+ ref={layoutCanvasRef}
666
+ width={compW}
667
+ height={compH}
668
+ pixelDensity={Math.min(typeof window !== 'undefined' ? window.devicePixelRatio || 1 : 1, 2)}
669
+ onInit={onInit}
670
+ onPaint={onPaint}
671
+ >
672
+ <div
673
+ style={{
674
+ position: 'absolute',
675
+ inset: 0,
676
+ display: 'flex',
677
+ alignItems: 'center',
678
+ justifyContent: 'center',
679
+ }}
680
+ >
681
+ <div
682
+ style={{
683
+ width: typeof props.width === 'number' ? `${props.width}px` : (props.width ?? '80%'),
684
+ height: typeof props.height === 'number' ? `${props.height}px` : (props.height ?? 'auto'),
685
+ position: 'relative',
686
+ borderRadius: '12px',
687
+ overflow: 'hidden',
688
+ }}
689
+ >
690
+ {props.children}
691
+ </div>
692
+ </div>
693
+ </HtmlInCanvas>
694
+ <canvas
695
+ ref={mirrorRef}
696
+ style={{
697
+ position: 'absolute',
698
+ inset: 0,
699
+ width: '100%',
700
+ height: '100%',
701
+ pointerEvents: 'none',
702
+ }}
703
+ />
704
+ </div>
705
+ )
706
+ }