@three-flatland/devtools 0.1.0-alpha.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +82 -0
- package/dist/buffers-modal.cjs +491 -0
- package/dist/buffers-modal.cjs.map +1 -0
- package/dist/buffers-modal.d.cts +62 -0
- package/dist/buffers-modal.d.ts +62 -0
- package/dist/buffers-modal.js +467 -0
- package/dist/buffers-modal.js.map +1 -0
- package/dist/buffers-view.cjs +269 -0
- package/dist/buffers-view.cjs.map +1 -0
- package/dist/buffers-view.d.cts +39 -0
- package/dist/buffers-view.d.ts +39 -0
- package/dist/buffers-view.js +245 -0
- package/dist/buffers-view.js.map +1 -0
- package/dist/create-pane.cjs +203 -0
- package/dist/create-pane.cjs.map +1 -0
- package/dist/create-pane.d.cts +66 -0
- package/dist/create-pane.d.ts +66 -0
- package/dist/create-pane.js +178 -0
- package/dist/create-pane.js.map +1 -0
- package/dist/dashboard/app.tsx +42 -0
- package/dist/dashboard/client.ts +21 -0
- package/dist/dashboard/export.ts +84 -0
- package/dist/dashboard/hooks.ts +75 -0
- package/dist/dashboard/index.html +1149 -0
- package/dist/dashboard/index.tsx +18 -0
- package/dist/dashboard/panels/batches.tsx +282 -0
- package/dist/dashboard/panels/buffers.tsx +425 -0
- package/dist/dashboard/panels/env.tsx +85 -0
- package/dist/dashboard/panels/header-stats.tsx +30 -0
- package/dist/dashboard/panels/producer-select.tsx +81 -0
- package/dist/dashboard/panels/protocol-log.tsx +743 -0
- package/dist/dashboard/panels/registry.tsx +642 -0
- package/dist/dashboard/panels/stats.tsx +431 -0
- package/dist/dashboard/protocol-store.ts +312 -0
- package/dist/dashboard/tsconfig.json +19 -0
- package/dist/dashboard/vendor/hooks.module.js +1 -0
- package/dist/dashboard/vendor/jsx-runtime.js +22 -0
- package/dist/dashboard/vendor/preact.module.js +12 -0
- package/dist/devtools-client.cjs +870 -0
- package/dist/devtools-client.cjs.map +1 -0
- package/dist/devtools-client.d.cts +362 -0
- package/dist/devtools-client.d.ts +362 -0
- package/dist/devtools-client.js +852 -0
- package/dist/devtools-client.js.map +1 -0
- package/dist/index.cjs +42 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +8 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +13 -0
- package/dist/index.js.map +1 -0
- package/dist/perf-trace.cjs +103 -0
- package/dist/perf-trace.cjs.map +1 -0
- package/dist/perf-trace.d.cts +27 -0
- package/dist/perf-trace.d.ts +27 -0
- package/dist/perf-trace.js +79 -0
- package/dist/perf-trace.js.map +1 -0
- package/dist/plugins.cjs +44 -0
- package/dist/plugins.cjs.map +1 -0
- package/dist/plugins.d.cts +7 -0
- package/dist/plugins.d.ts +7 -0
- package/dist/plugins.js +9 -0
- package/dist/plugins.js.map +1 -0
- package/dist/provider-switcher.cjs +95 -0
- package/dist/provider-switcher.cjs.map +1 -0
- package/dist/provider-switcher.d.cts +18 -0
- package/dist/provider-switcher.d.ts +18 -0
- package/dist/provider-switcher.js +71 -0
- package/dist/provider-switcher.js.map +1 -0
- package/dist/react/devtools-provider.cjs +59 -0
- package/dist/react/devtools-provider.cjs.map +1 -0
- package/dist/react/devtools-provider.d.cts +39 -0
- package/dist/react/devtools-provider.d.ts +39 -0
- package/dist/react/devtools-provider.js +38 -0
- package/dist/react/devtools-provider.js.map +1 -0
- package/dist/react/use-fps-graph.cjs +49 -0
- package/dist/react/use-fps-graph.cjs.map +1 -0
- package/dist/react/use-fps-graph.d.cts +15 -0
- package/dist/react/use-fps-graph.d.ts +15 -0
- package/dist/react/use-fps-graph.js +25 -0
- package/dist/react/use-fps-graph.js.map +1 -0
- package/dist/react/use-pane-button.cjs +58 -0
- package/dist/react/use-pane-button.cjs.map +1 -0
- package/dist/react/use-pane-button.d.cts +10 -0
- package/dist/react/use-pane-button.d.ts +10 -0
- package/dist/react/use-pane-button.js +34 -0
- package/dist/react/use-pane-button.js.map +1 -0
- package/dist/react/use-pane-folder.cjs +48 -0
- package/dist/react/use-pane-folder.cjs.map +1 -0
- package/dist/react/use-pane-folder.d.cts +15 -0
- package/dist/react/use-pane-folder.d.ts +15 -0
- package/dist/react/use-pane-folder.js +24 -0
- package/dist/react/use-pane-folder.js.map +1 -0
- package/dist/react/use-pane-input.cjs +70 -0
- package/dist/react/use-pane-input.cjs.map +1 -0
- package/dist/react/use-pane-input.d.cts +44 -0
- package/dist/react/use-pane-input.d.ts +44 -0
- package/dist/react/use-pane-input.js +46 -0
- package/dist/react/use-pane-input.js.map +1 -0
- package/dist/react/use-pane-radio-grid.cjs +78 -0
- package/dist/react/use-pane-radio-grid.cjs.map +1 -0
- package/dist/react/use-pane-radio-grid.d.cts +35 -0
- package/dist/react/use-pane-radio-grid.d.ts +35 -0
- package/dist/react/use-pane-radio-grid.js +54 -0
- package/dist/react/use-pane-radio-grid.js.map +1 -0
- package/dist/react/use-pane.cjs +45 -0
- package/dist/react/use-pane.cjs.map +1 -0
- package/dist/react/use-pane.d.cts +40 -0
- package/dist/react/use-pane.d.ts +40 -0
- package/dist/react/use-pane.js +21 -0
- package/dist/react/use-pane.js.map +1 -0
- package/dist/react.cjs +50 -0
- package/dist/react.cjs.map +1 -0
- package/dist/react.d.cts +12 -0
- package/dist/react.d.ts +12 -0
- package/dist/react.js +19 -0
- package/dist/react.js.map +1 -0
- package/dist/registry-view.cjs +371 -0
- package/dist/registry-view.cjs.map +1 -0
- package/dist/registry-view.d.cts +24 -0
- package/dist/registry-view.d.ts +24 -0
- package/dist/registry-view.js +347 -0
- package/dist/registry-view.js.map +1 -0
- package/dist/stats-graph.cjs +297 -0
- package/dist/stats-graph.cjs.map +1 -0
- package/dist/stats-graph.d.cts +40 -0
- package/dist/stats-graph.d.ts +40 -0
- package/dist/stats-graph.js +273 -0
- package/dist/stats-graph.js.map +1 -0
- package/dist/stats-row.cjs +153 -0
- package/dist/stats-row.cjs.map +1 -0
- package/dist/stats-row.d.cts +24 -0
- package/dist/stats-row.d.ts +24 -0
- package/dist/stats-row.js +129 -0
- package/dist/stats-row.js.map +1 -0
- package/dist/theme.cjs +260 -0
- package/dist/theme.cjs.map +1 -0
- package/dist/theme.d.cts +10 -0
- package/dist/theme.d.ts +10 -0
- package/dist/theme.js +235 -0
- package/dist/theme.js.map +1 -0
- package/dist/vite-plugin.cjs +113 -0
- package/dist/vite-plugin.cjs.map +1 -0
- package/dist/vite-plugin.d.cts +9 -0
- package/dist/vite-plugin.d.ts +9 -0
- package/dist/vite-plugin.js +88 -0
- package/dist/vite-plugin.js.map +1 -0
- package/package.json +124 -0
|
@@ -0,0 +1,425 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
/**
|
|
3
|
+
* Buffers panel — live preview of registered debug textures.
|
|
4
|
+
*
|
|
5
|
+
* Same transport as the Tweakpane modal: when an entry is selected the
|
|
6
|
+
* panel subscribes in `stream` mode so the provider ships VP9-encoded
|
|
7
|
+
* frames through the worker. Decoded frames draw into a canvas which
|
|
8
|
+
* aspect-fits to the available main area and rescales on ResizeObserver.
|
|
9
|
+
*
|
|
10
|
+
* Thumbnail mode (downsampled `buffer:raw` payloads) is used as a fast
|
|
11
|
+
* fallback when WebCodecs isn't available — the worker converts to RGBA8
|
|
12
|
+
* and the panel paints via putImageData. Both paths share `fitCanvas`
|
|
13
|
+
* so CSS sizing stays consistent across the mode switch.
|
|
14
|
+
*
|
|
15
|
+
* Multiple dashboard consumers (or the tweakpane modal) subscribing to
|
|
16
|
+
* different entries is fine — the producer unions selections server-side.
|
|
17
|
+
*/
|
|
18
|
+
import { useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
19
|
+
import type { BufferChunkPayload } from '../../devtools-client.js'
|
|
20
|
+
import { getClient } from '../client.js'
|
|
21
|
+
import { useDevtoolsState } from '../hooks.js'
|
|
22
|
+
|
|
23
|
+
const VP9_CODEC = 'vp09.00.10.08'
|
|
24
|
+
const CODEC_AVAILABLE = typeof globalThis.VideoDecoder !== 'undefined'
|
|
25
|
+
|
|
26
|
+
const MIN_ZOOM = 0.5
|
|
27
|
+
const MAX_ZOOM = 64
|
|
28
|
+
|
|
29
|
+
interface PixelProbe {
|
|
30
|
+
x: number
|
|
31
|
+
y: number
|
|
32
|
+
r: number
|
|
33
|
+
g: number
|
|
34
|
+
b: number
|
|
35
|
+
a: number
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function BuffersPanel() {
|
|
39
|
+
const state = useDevtoolsState()
|
|
40
|
+
const client = getClient()
|
|
41
|
+
const [selectedName, setSelectedName] = useState<string | null>(null)
|
|
42
|
+
const [filter, setFilter] = useState('')
|
|
43
|
+
|
|
44
|
+
const canvasRef = useRef<HTMLCanvasElement | null>(null)
|
|
45
|
+
const mainRef = useRef<HTMLDivElement | null>(null)
|
|
46
|
+
|
|
47
|
+
const [zoom, setZoom] = useState(1)
|
|
48
|
+
const [pan, setPan] = useState({ x: 0, y: 0 })
|
|
49
|
+
const [probe, setProbe] = useState<PixelProbe | null>(null)
|
|
50
|
+
const dragStateRef = useRef<{ active: boolean; startX: number; startY: number; panX: number; panY: number }>({
|
|
51
|
+
active: false,
|
|
52
|
+
startX: 0,
|
|
53
|
+
startY: 0,
|
|
54
|
+
panX: 0,
|
|
55
|
+
panY: 0,
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// Stable sorted buffer list.
|
|
59
|
+
const entries = useMemo(() => {
|
|
60
|
+
const arr = Array.from(state.buffers.values())
|
|
61
|
+
arr.sort((a, b) => a.name.localeCompare(b.name))
|
|
62
|
+
return arr
|
|
63
|
+
}, [state.buffers, state.buffers.size])
|
|
64
|
+
|
|
65
|
+
const needle = filter.trim().toLowerCase()
|
|
66
|
+
const visible = needle.length > 0
|
|
67
|
+
? entries.filter((e) => e.name.toLowerCase().includes(needle) || (e.label?.toLowerCase().includes(needle) ?? false))
|
|
68
|
+
: entries
|
|
69
|
+
|
|
70
|
+
// Only clear the selection if the currently-selected name has
|
|
71
|
+
// vanished from the registry. Never auto-pick — selection must be
|
|
72
|
+
// explicit so we don't silently subscribe a producer in stream mode
|
|
73
|
+
// the moment a buffer shows up. Unselected = nothing streaming, no
|
|
74
|
+
// GPU readback or VP9 encode on the producer side.
|
|
75
|
+
const effectiveSelected = selectedName !== null && visible.some((e) => e.name === selectedName)
|
|
76
|
+
? selectedName
|
|
77
|
+
: null
|
|
78
|
+
|
|
79
|
+
// Subscribe/unsubscribe to stream mode based on selection. The dashboard
|
|
80
|
+
// is a fresh DevtoolsClient consumer so this subscription is additive
|
|
81
|
+
// to whatever the tweakpane pane happens to want — the provider unions
|
|
82
|
+
// selections across consumers.
|
|
83
|
+
useEffect(() => {
|
|
84
|
+
if (effectiveSelected === null) {
|
|
85
|
+
client.setBuffers({})
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
client.setBuffers({ [effectiveSelected]: { mode: 'stream' } })
|
|
89
|
+
return () => { client.setBuffers({}) }
|
|
90
|
+
}, [client, effectiveSelected])
|
|
91
|
+
|
|
92
|
+
// WebCodecs decoder lifecycle — one instance per active selection.
|
|
93
|
+
// We keep decoder state in refs (not component state) because it's
|
|
94
|
+
// imperative by nature and should not trigger re-renders.
|
|
95
|
+
const decoderRef = useRef<VideoDecoder | null>(null)
|
|
96
|
+
const decoderDimsRef = useRef({ w: 0, h: 0 })
|
|
97
|
+
const waitingForKeyFrameRef = useRef(true)
|
|
98
|
+
const canvasSrcRef = useRef({ w: 0, h: 0 })
|
|
99
|
+
|
|
100
|
+
const fitCanvas = (srcW: number, srcH: number): void => {
|
|
101
|
+
const canvas = canvasRef.current
|
|
102
|
+
const main = mainRef.current
|
|
103
|
+
if (canvas === null || main === null || srcW <= 0 || srcH <= 0) return
|
|
104
|
+
const rect = main.getBoundingClientRect()
|
|
105
|
+
const maxW = Math.max(1, rect.width - 16)
|
|
106
|
+
const maxH = Math.max(1, rect.height - 16)
|
|
107
|
+
const srcAspect = srcW / srcH
|
|
108
|
+
const boxAspect = maxW / maxH
|
|
109
|
+
let cssW: number
|
|
110
|
+
let cssH: number
|
|
111
|
+
if (srcAspect > boxAspect) {
|
|
112
|
+
cssW = maxW
|
|
113
|
+
cssH = Math.max(1, Math.round(maxW / srcAspect))
|
|
114
|
+
} else {
|
|
115
|
+
cssH = maxH
|
|
116
|
+
cssW = Math.max(1, Math.round(maxH * srcAspect))
|
|
117
|
+
}
|
|
118
|
+
canvas.style.width = `${cssW}px`
|
|
119
|
+
canvas.style.height = `${cssH}px`
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const stopDecoder = (): void => {
|
|
123
|
+
const d = decoderRef.current
|
|
124
|
+
if (d !== null && d.state !== 'closed') {
|
|
125
|
+
try { d.close() } catch { /* may already be errored */ }
|
|
126
|
+
}
|
|
127
|
+
decoderRef.current = null
|
|
128
|
+
decoderDimsRef.current = { w: 0, h: 0 }
|
|
129
|
+
waitingForKeyFrameRef.current = true
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const startDecoder = (w: number, h: number): void => {
|
|
133
|
+
stopDecoder()
|
|
134
|
+
if (!CODEC_AVAILABLE) return
|
|
135
|
+
const canvas = canvasRef.current
|
|
136
|
+
if (canvas === null) return
|
|
137
|
+
const ctx = canvas.getContext('2d')
|
|
138
|
+
if (ctx === null) return
|
|
139
|
+
waitingForKeyFrameRef.current = true
|
|
140
|
+
const decoder = new globalThis.VideoDecoder({
|
|
141
|
+
output: (frame) => {
|
|
142
|
+
if (canvas.width !== frame.codedWidth || canvas.height !== frame.codedHeight) {
|
|
143
|
+
canvas.width = frame.codedWidth
|
|
144
|
+
canvas.height = frame.codedHeight
|
|
145
|
+
}
|
|
146
|
+
canvasSrcRef.current = { w: frame.codedWidth, h: frame.codedHeight }
|
|
147
|
+
fitCanvas(frame.codedWidth, frame.codedHeight)
|
|
148
|
+
ctx.drawImage(frame as unknown as CanvasImageSource, 0, 0)
|
|
149
|
+
frame.close()
|
|
150
|
+
},
|
|
151
|
+
error: () => {
|
|
152
|
+
stopDecoder()
|
|
153
|
+
},
|
|
154
|
+
})
|
|
155
|
+
decoder.configure({ codec: VP9_CODEC, codedWidth: w, codedHeight: h })
|
|
156
|
+
decoderRef.current = decoder
|
|
157
|
+
decoderDimsRef.current = { w, h }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Wire chunk listener. Filters for the active selection; reconfigures
|
|
161
|
+
// the decoder when the source dimensions change (common on resize of
|
|
162
|
+
// the producer's render target).
|
|
163
|
+
useEffect(() => {
|
|
164
|
+
if (effectiveSelected === null) {
|
|
165
|
+
stopDecoder()
|
|
166
|
+
return
|
|
167
|
+
}
|
|
168
|
+
const activeName = effectiveSelected
|
|
169
|
+
const unsub = client.addChunkListener((chunk: BufferChunkPayload) => {
|
|
170
|
+
if (chunk.name !== activeName) return
|
|
171
|
+
if (
|
|
172
|
+
decoderRef.current === null ||
|
|
173
|
+
chunk.width !== decoderDimsRef.current.w ||
|
|
174
|
+
chunk.height !== decoderDimsRef.current.h
|
|
175
|
+
) {
|
|
176
|
+
startDecoder(chunk.width, chunk.height)
|
|
177
|
+
}
|
|
178
|
+
const d = decoderRef.current
|
|
179
|
+
if (d === null) return
|
|
180
|
+
if (waitingForKeyFrameRef.current && !chunk.keyFrame) return
|
|
181
|
+
if (chunk.keyFrame) waitingForKeyFrameRef.current = false
|
|
182
|
+
try {
|
|
183
|
+
const enc = new EncodedVideoChunk({
|
|
184
|
+
type: chunk.keyFrame ? 'key' : 'delta',
|
|
185
|
+
timestamp: chunk.capturedAt * 1000,
|
|
186
|
+
data: chunk.data,
|
|
187
|
+
})
|
|
188
|
+
d.decode(enc)
|
|
189
|
+
} catch {
|
|
190
|
+
stopDecoder()
|
|
191
|
+
}
|
|
192
|
+
})
|
|
193
|
+
return () => {
|
|
194
|
+
unsub()
|
|
195
|
+
stopDecoder()
|
|
196
|
+
}
|
|
197
|
+
}, [client, effectiveSelected])
|
|
198
|
+
|
|
199
|
+
// Thumbnail fallback: when WebCodecs isn't available the worker sends
|
|
200
|
+
// `buffer:raw` payloads which land on `state.buffers[name].pixels`.
|
|
201
|
+
// Paint those when the stream path isn't decoding.
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (CODEC_AVAILABLE) return
|
|
204
|
+
if (effectiveSelected === null) return
|
|
205
|
+
const snap = state.buffers.get(effectiveSelected)
|
|
206
|
+
if (snap === undefined || snap.pixels === null) return
|
|
207
|
+
const canvas = canvasRef.current
|
|
208
|
+
if (canvas === null) return
|
|
209
|
+
const ctx = canvas.getContext('2d')
|
|
210
|
+
if (ctx === null) return
|
|
211
|
+
if (canvas.width !== snap.width || canvas.height !== snap.height) {
|
|
212
|
+
canvas.width = snap.width
|
|
213
|
+
canvas.height = snap.height
|
|
214
|
+
}
|
|
215
|
+
canvasSrcRef.current = {
|
|
216
|
+
w: snap.srcWidth > 0 ? snap.srcWidth : snap.width,
|
|
217
|
+
h: snap.srcHeight > 0 ? snap.srcHeight : snap.height,
|
|
218
|
+
}
|
|
219
|
+
fitCanvas(canvasSrcRef.current.w, canvasSrcRef.current.h)
|
|
220
|
+
if (snap.pixels instanceof Uint8Array) {
|
|
221
|
+
const img = ctx.createImageData(snap.width, snap.height)
|
|
222
|
+
img.data.set(snap.pixels.subarray(0, img.data.length))
|
|
223
|
+
ctx.putImageData(img, 0, 0)
|
|
224
|
+
}
|
|
225
|
+
}, [state.buffers, effectiveSelected])
|
|
226
|
+
|
|
227
|
+
// Re-fit on layout changes (panel resize, sidebar collapse, etc.).
|
|
228
|
+
useEffect(() => {
|
|
229
|
+
const main = mainRef.current
|
|
230
|
+
if (main === null) return
|
|
231
|
+
const obs = new ResizeObserver(() => {
|
|
232
|
+
const { w, h } = canvasSrcRef.current
|
|
233
|
+
if (w > 0 && h > 0) fitCanvas(w, h)
|
|
234
|
+
})
|
|
235
|
+
obs.observe(main)
|
|
236
|
+
return () => obs.disconnect()
|
|
237
|
+
}, [])
|
|
238
|
+
|
|
239
|
+
// Reset view when selection changes so each buffer opens 1:1 centered.
|
|
240
|
+
useEffect(() => {
|
|
241
|
+
setZoom(1)
|
|
242
|
+
setPan({ x: 0, y: 0 })
|
|
243
|
+
setProbe(null)
|
|
244
|
+
}, [effectiveSelected])
|
|
245
|
+
|
|
246
|
+
const onWheel = (e: WheelEvent): void => {
|
|
247
|
+
e.preventDefault()
|
|
248
|
+
const main = mainRef.current
|
|
249
|
+
if (main === null) return
|
|
250
|
+
const factor = e.deltaY < 0 ? 1.2 : 1 / 1.2
|
|
251
|
+
const nextZoom = Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, zoom * factor))
|
|
252
|
+
if (nextZoom === zoom) return
|
|
253
|
+
// Anchor zoom to the cursor: compute the canvas-space point under
|
|
254
|
+
// the cursor, then adjust pan so that same point remains under it.
|
|
255
|
+
const rect = main.getBoundingClientRect()
|
|
256
|
+
const cx = e.clientX - rect.left - rect.width / 2
|
|
257
|
+
const cy = e.clientY - rect.top - rect.height / 2
|
|
258
|
+
const scale = nextZoom / zoom
|
|
259
|
+
setPan({
|
|
260
|
+
x: cx - (cx - pan.x) * scale,
|
|
261
|
+
y: cy - (cy - pan.y) * scale,
|
|
262
|
+
})
|
|
263
|
+
setZoom(nextZoom)
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const onMouseDown = (e: MouseEvent): void => {
|
|
267
|
+
if (e.button !== 0) return
|
|
268
|
+
dragStateRef.current = {
|
|
269
|
+
active: true,
|
|
270
|
+
startX: e.clientX,
|
|
271
|
+
startY: e.clientY,
|
|
272
|
+
panX: pan.x,
|
|
273
|
+
panY: pan.y,
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
useEffect(() => {
|
|
277
|
+
const onMove = (e: MouseEvent): void => {
|
|
278
|
+
if (!dragStateRef.current.active) return
|
|
279
|
+
setPan({
|
|
280
|
+
x: dragStateRef.current.panX + (e.clientX - dragStateRef.current.startX),
|
|
281
|
+
y: dragStateRef.current.panY + (e.clientY - dragStateRef.current.startY),
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
const onUp = (): void => {
|
|
285
|
+
dragStateRef.current.active = false
|
|
286
|
+
}
|
|
287
|
+
window.addEventListener('mousemove', onMove)
|
|
288
|
+
window.addEventListener('mouseup', onUp)
|
|
289
|
+
return () => {
|
|
290
|
+
window.removeEventListener('mousemove', onMove)
|
|
291
|
+
window.removeEventListener('mouseup', onUp)
|
|
292
|
+
}
|
|
293
|
+
}, [])
|
|
294
|
+
|
|
295
|
+
// Pixel probe: translate the cursor's screen-space position into the
|
|
296
|
+
// canvas's backing-buffer coordinates, then `getImageData(1,1)` at
|
|
297
|
+
// that point. Drawn into a tooltip overlay. Runs on every mousemove
|
|
298
|
+
// over the canvas — keep the read cheap (1×1 pixel).
|
|
299
|
+
const onCanvasMove = (e: MouseEvent): void => {
|
|
300
|
+
const canvas = canvasRef.current
|
|
301
|
+
if (canvas === null) return
|
|
302
|
+
const ctx = canvas.getContext('2d', { willReadFrequently: true })
|
|
303
|
+
if (ctx === null) return
|
|
304
|
+
const rect = canvas.getBoundingClientRect()
|
|
305
|
+
const u = (e.clientX - rect.left) / rect.width
|
|
306
|
+
const v = (e.clientY - rect.top) / rect.height
|
|
307
|
+
if (u < 0 || u > 1 || v < 0 || v > 1) { setProbe(null); return }
|
|
308
|
+
const px = Math.min(canvas.width - 1, Math.max(0, Math.floor(u * canvas.width)))
|
|
309
|
+
const py = Math.min(canvas.height - 1, Math.max(0, Math.floor(v * canvas.height)))
|
|
310
|
+
try {
|
|
311
|
+
const data = ctx.getImageData(px, py, 1, 1).data
|
|
312
|
+
setProbe({ x: px, y: py, r: data[0]!, g: data[1]!, b: data[2]!, a: data[3]! })
|
|
313
|
+
} catch {
|
|
314
|
+
// Some frame formats (e.g. YUV) produce canvases that refuse
|
|
315
|
+
// readback; silently drop the probe.
|
|
316
|
+
setProbe(null)
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const onCanvasLeave = (): void => setProbe(null)
|
|
320
|
+
|
|
321
|
+
const resetView = (): void => {
|
|
322
|
+
setZoom(1)
|
|
323
|
+
setPan({ x: 0, y: 0 })
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const selectedSnap = effectiveSelected !== null ? state.buffers.get(effectiveSelected) ?? null : null
|
|
327
|
+
|
|
328
|
+
return (
|
|
329
|
+
<section class="panel buffers-panel">
|
|
330
|
+
<header class="panel-header buffers-header">
|
|
331
|
+
<span>Buffers</span>
|
|
332
|
+
<input
|
|
333
|
+
type="text"
|
|
334
|
+
class="protocol-filter"
|
|
335
|
+
placeholder="filter…"
|
|
336
|
+
value={filter}
|
|
337
|
+
onInput={(e) => setFilter((e.target as HTMLInputElement).value)}
|
|
338
|
+
/>
|
|
339
|
+
<span class="registry-count">{entries.length}</span>
|
|
340
|
+
</header>
|
|
341
|
+
<div class="buffers-layout">
|
|
342
|
+
<ul class="buffers-list">
|
|
343
|
+
{visible.length === 0 ? (
|
|
344
|
+
<li class="panel-empty">No buffers{needle.length > 0 ? ' match' : ' yet'}.</li>
|
|
345
|
+
) : (
|
|
346
|
+
visible.map((e) => (
|
|
347
|
+
<li key={e.name}>
|
|
348
|
+
<button
|
|
349
|
+
type="button"
|
|
350
|
+
class={
|
|
351
|
+
'buffers-row' +
|
|
352
|
+
(e.name === effectiveSelected ? ' buffers-row-selected' : '')
|
|
353
|
+
}
|
|
354
|
+
onClick={() => setSelectedName((prev) => (prev === e.name ? null : e.name))}
|
|
355
|
+
title={e.name === effectiveSelected ? 'Click again to stop streaming' : 'Stream this buffer'}
|
|
356
|
+
>
|
|
357
|
+
<span class="buffers-name">{e.name}</span>
|
|
358
|
+
<span class="buffers-meta">
|
|
359
|
+
{(e.srcWidth > 0 ? e.srcWidth : e.width)}×
|
|
360
|
+
{(e.srcHeight > 0 ? e.srcHeight : e.height)} · {e.pixelType}
|
|
361
|
+
</span>
|
|
362
|
+
</button>
|
|
363
|
+
</li>
|
|
364
|
+
))
|
|
365
|
+
)}
|
|
366
|
+
</ul>
|
|
367
|
+
<div
|
|
368
|
+
class="buffers-stage"
|
|
369
|
+
ref={mainRef}
|
|
370
|
+
onWheel={selectedSnap !== null ? onWheel : undefined}
|
|
371
|
+
onMouseDown={selectedSnap !== null ? onMouseDown : undefined}
|
|
372
|
+
>
|
|
373
|
+
{selectedSnap === null ? (
|
|
374
|
+
<div class="panel-empty">
|
|
375
|
+
{entries.length === 0 ? 'No buffers registered.' : 'Pick a buffer to stream.'}
|
|
376
|
+
</div>
|
|
377
|
+
) : (
|
|
378
|
+
<>
|
|
379
|
+
<canvas
|
|
380
|
+
ref={canvasRef}
|
|
381
|
+
class="buffers-canvas"
|
|
382
|
+
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom})` }}
|
|
383
|
+
onMouseMove={onCanvasMove}
|
|
384
|
+
onMouseLeave={onCanvasLeave}
|
|
385
|
+
/>
|
|
386
|
+
<div class="buffers-info">
|
|
387
|
+
<span>{selectedSnap.name}</span>
|
|
388
|
+
<span>
|
|
389
|
+
{selectedSnap.srcWidth > 0 ? selectedSnap.srcWidth : selectedSnap.width}×
|
|
390
|
+
{selectedSnap.srcHeight > 0 ? selectedSnap.srcHeight : selectedSnap.height}
|
|
391
|
+
</span>
|
|
392
|
+
<span>{selectedSnap.pixelType}</span>
|
|
393
|
+
<span>{selectedSnap.display}</span>
|
|
394
|
+
{!CODEC_AVAILABLE && <span class="buffers-warn">no WebCodecs — thumbnail fallback</span>}
|
|
395
|
+
<span class="buffers-zoom">{zoom === 1 ? '1.0×' : `${zoom < 10 ? zoom.toFixed(1) : Math.round(zoom)}×`}</span>
|
|
396
|
+
</div>
|
|
397
|
+
{probe !== null && (
|
|
398
|
+
<div class="buffers-probe">
|
|
399
|
+
<span>({probe.x}, {probe.y})</span>
|
|
400
|
+
<span class="probe-chip" style={{ background: `rgb(${probe.r}, ${probe.g}, ${probe.b})` }} />
|
|
401
|
+
<span>{probe.r} {probe.g} {probe.b} {probe.a}</span>
|
|
402
|
+
</div>
|
|
403
|
+
)}
|
|
404
|
+
{(zoom !== 1 || pan.x !== 0 || pan.y !== 0) && (
|
|
405
|
+
<button
|
|
406
|
+
type="button"
|
|
407
|
+
class="buffers-reset"
|
|
408
|
+
onClick={resetView}
|
|
409
|
+
title="Reset zoom & pan"
|
|
410
|
+
>⟲</button>
|
|
411
|
+
)}
|
|
412
|
+
<button
|
|
413
|
+
type="button"
|
|
414
|
+
class="buffers-close"
|
|
415
|
+
onClick={() => setSelectedName(null)}
|
|
416
|
+
title="Stop streaming"
|
|
417
|
+
aria-label="Stop streaming"
|
|
418
|
+
>×</button>
|
|
419
|
+
</>
|
|
420
|
+
)}
|
|
421
|
+
</div>
|
|
422
|
+
</div>
|
|
423
|
+
</section>
|
|
424
|
+
)
|
|
425
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
/**
|
|
3
|
+
* Environment popover — compact info chip in the header that opens a
|
|
4
|
+
* floating list of runtime details on click. Lives in the header so it
|
|
5
|
+
* doesn't eat vertical space in the main content area; this info is
|
|
6
|
+
* reference-only, not something you watch tick by tick.
|
|
7
|
+
*/
|
|
8
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
9
|
+
import { useDevtoolsState } from '../hooks.js'
|
|
10
|
+
|
|
11
|
+
function fmtMB(v: number | undefined): string {
|
|
12
|
+
return v === undefined ? '—' : `${Math.round(v)} MB`
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function fmtBool(v: boolean | undefined | null): string {
|
|
16
|
+
if (v === undefined) return '—'
|
|
17
|
+
if (v === null) return 'disjoint'
|
|
18
|
+
return v ? 'yes' : 'no'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function EnvPopover() {
|
|
22
|
+
const s = useDevtoolsState()
|
|
23
|
+
const [open, setOpen] = useState(false)
|
|
24
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
if (!open) return
|
|
28
|
+
const onDocClick = (e: MouseEvent) => {
|
|
29
|
+
if (rootRef.current !== null && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
|
30
|
+
}
|
|
31
|
+
const onKey = (e: KeyboardEvent) => {
|
|
32
|
+
if (e.key === 'Escape') setOpen(false)
|
|
33
|
+
}
|
|
34
|
+
document.addEventListener('mousedown', onDocClick)
|
|
35
|
+
document.addEventListener('keydown', onKey)
|
|
36
|
+
return () => {
|
|
37
|
+
document.removeEventListener('mousedown', onDocClick)
|
|
38
|
+
document.removeEventListener('keydown', onKey)
|
|
39
|
+
}
|
|
40
|
+
}, [open])
|
|
41
|
+
|
|
42
|
+
const canvas = s.canvasWidth !== undefined && s.canvasHeight !== undefined
|
|
43
|
+
? `${s.canvasWidth}×${s.canvasHeight}${s.canvasPixelRatio !== undefined ? ` @${s.canvasPixelRatio}x` : ''}`
|
|
44
|
+
: '—'
|
|
45
|
+
|
|
46
|
+
const summary = s.backendName !== undefined
|
|
47
|
+
? s.backendName
|
|
48
|
+
: 'Env'
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div class="env-popover" ref={rootRef}>
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
class={`env-trigger${open ? ' env-trigger-open' : ''}`}
|
|
55
|
+
onClick={() => setOpen((o) => !o)}
|
|
56
|
+
title="Runtime environment"
|
|
57
|
+
>
|
|
58
|
+
<span>Env</span>
|
|
59
|
+
<span class="env-trigger-summary">{summary}</span>
|
|
60
|
+
<span class="env-caret">{open ? '▲' : '▼'}</span>
|
|
61
|
+
</button>
|
|
62
|
+
{open && (
|
|
63
|
+
<div class="env-menu">
|
|
64
|
+
<Row label="three-flatland" value={s.threeFlatlandVersion ?? '—'} />
|
|
65
|
+
<Row label="three revision" value={s.threeRevision ?? '—'} />
|
|
66
|
+
<Row label="backend" value={s.backendName ?? '—'} />
|
|
67
|
+
<Row label="canvas" value={canvas} />
|
|
68
|
+
<Row label="gpu mode" value={fmtBool(s.gpuModeEnabled)} />
|
|
69
|
+
<Row label="timestamps" value={fmtBool(s.backendTrackTimestamp)} />
|
|
70
|
+
<Row label="disjoint" value={fmtBool(s.backendDisjoint)} />
|
|
71
|
+
<Row label="heap" value={`${fmtMB(s.heapUsedMB)} / ${fmtMB(s.heapLimitMB)}`} />
|
|
72
|
+
</div>
|
|
73
|
+
)}
|
|
74
|
+
</div>
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function Row({ label, value }: { label: string; value: string }) {
|
|
79
|
+
return (
|
|
80
|
+
<div class="env-row">
|
|
81
|
+
<span class="env-label">{label}</span>
|
|
82
|
+
<span class="env-value">{value}</span>
|
|
83
|
+
</div>
|
|
84
|
+
)
|
|
85
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
/**
|
|
3
|
+
* Header byte counters — running totals of inbound/outbound bytes seen
|
|
4
|
+
* since session start. Updates ride the shared dashboard rAF tick so
|
|
5
|
+
* high message rates don't thrash the header on their own.
|
|
6
|
+
*/
|
|
7
|
+
import { getProtocolStore } from '../protocol-store.js'
|
|
8
|
+
import { useFrameTick } from '../hooks.js'
|
|
9
|
+
|
|
10
|
+
function fmtBytes(n: number): string {
|
|
11
|
+
if (n < 1024) return `${n} B`
|
|
12
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`
|
|
13
|
+
if (n < 1024 * 1024 * 1024) return `${(n / (1024 * 1024)).toFixed(1)} MB`
|
|
14
|
+
return `${(n / (1024 * 1024 * 1024)).toFixed(2)} GB`
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function HeaderStats() {
|
|
18
|
+
useFrameTick()
|
|
19
|
+
const store = getProtocolStore()
|
|
20
|
+
return (
|
|
21
|
+
<div class="header-stats">
|
|
22
|
+
<span title="Bytes received from providers">
|
|
23
|
+
<span class="header-arrow-in">↓</span> {fmtBytes(store.bytesIn)}
|
|
24
|
+
</span>
|
|
25
|
+
<span title="Bytes sent to providers">
|
|
26
|
+
<span class="header-arrow-out">↑</span> {fmtBytes(store.bytesOut)}
|
|
27
|
+
</span>
|
|
28
|
+
</div>
|
|
29
|
+
)
|
|
30
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/** @jsxImportSource preact */
|
|
2
|
+
/**
|
|
3
|
+
* Producer dropdown — custom-styled selector, since native `<select>`
|
|
4
|
+
* open state is still OS-controlled in all shipping browsers (Chrome's
|
|
5
|
+
* `appearance: base-select` is behind a flag).
|
|
6
|
+
*
|
|
7
|
+
* Closes on outside click and on Escape. The currently-selected provider
|
|
8
|
+
* comes from `DevtoolsState.selectedProviderId`.
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState } from 'preact/hooks'
|
|
11
|
+
import { getClient } from '../client.js'
|
|
12
|
+
import { useDevtoolsState } from '../hooks.js'
|
|
13
|
+
|
|
14
|
+
export function ProducerSelect() {
|
|
15
|
+
const state = useDevtoolsState()
|
|
16
|
+
const client = getClient()
|
|
17
|
+
const [open, setOpen] = useState(false)
|
|
18
|
+
const rootRef = useRef<HTMLDivElement | null>(null)
|
|
19
|
+
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
if (!open) return
|
|
22
|
+
const onDocClick = (e: MouseEvent) => {
|
|
23
|
+
if (rootRef.current !== null && !rootRef.current.contains(e.target as Node)) setOpen(false)
|
|
24
|
+
}
|
|
25
|
+
const onKey = (e: KeyboardEvent) => {
|
|
26
|
+
if (e.key === 'Escape') setOpen(false)
|
|
27
|
+
}
|
|
28
|
+
document.addEventListener('mousedown', onDocClick)
|
|
29
|
+
document.addEventListener('keydown', onKey)
|
|
30
|
+
return () => {
|
|
31
|
+
document.removeEventListener('mousedown', onDocClick)
|
|
32
|
+
document.removeEventListener('keydown', onKey)
|
|
33
|
+
}
|
|
34
|
+
}, [open])
|
|
35
|
+
|
|
36
|
+
const selected = state.providers.find((p) => p.id === state.selectedProviderId) ?? null
|
|
37
|
+
const label = selected !== null ? selected.name : 'No producer'
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div class="producer-select" ref={rootRef}>
|
|
41
|
+
<button
|
|
42
|
+
type="button"
|
|
43
|
+
class={`producer-button${open ? ' producer-button-open' : ''}`}
|
|
44
|
+
onClick={() => setOpen((o) => !o)}
|
|
45
|
+
disabled={state.providers.length === 0}
|
|
46
|
+
>
|
|
47
|
+
<span class={state.serverAlive && selected !== null ? 'dot dot-live' : 'dot'} />
|
|
48
|
+
<span class="producer-label">{label}</span>
|
|
49
|
+
{selected !== null && <span class="producer-kind">{selected.kind}</span>}
|
|
50
|
+
<span class="producer-caret">{open ? '▲' : '▼'}</span>
|
|
51
|
+
</button>
|
|
52
|
+
{open && (
|
|
53
|
+
<ul class="producer-menu">
|
|
54
|
+
{state.providers.length === 0 ? (
|
|
55
|
+
<li class="panel-empty">No producers announced.</li>
|
|
56
|
+
) : (
|
|
57
|
+
state.providers.map((p) => {
|
|
58
|
+
const active = p.id === state.selectedProviderId
|
|
59
|
+
return (
|
|
60
|
+
<li key={p.id}>
|
|
61
|
+
<button
|
|
62
|
+
type="button"
|
|
63
|
+
class={`producer-option${active ? ' producer-option-active' : ''}`}
|
|
64
|
+
onClick={() => {
|
|
65
|
+
client.selectProvider(p.id)
|
|
66
|
+
setOpen(false)
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<span class="producer-check">{active ? '✓' : ''}</span>
|
|
70
|
+
<span class="producer-label">{p.name}</span>
|
|
71
|
+
<span class="producer-kind">{p.kind}</span>
|
|
72
|
+
</button>
|
|
73
|
+
</li>
|
|
74
|
+
)
|
|
75
|
+
})
|
|
76
|
+
)}
|
|
77
|
+
</ul>
|
|
78
|
+
)}
|
|
79
|
+
</div>
|
|
80
|
+
)
|
|
81
|
+
}
|