WebFramePainter
Fills a JS byte array with one frame's RGBA, tightly packed, no row padding.
This seam exists because of a hard boundary and a hard measurement, and it is the only shape that satisfies both.
The boundary: :kiteplayer-output must not depend on KiteFFmpeg or FFmpeg, which the module's own build file states and the boundary scans enforce. So the renderer cannot read a frame's pixels itself, exactly as AndroidSurfaceVideoRenderer cannot and takes a converter function instead.
The measurement: the Android seam hands back a Kotlin ByteArray, and on the web that is the slow path by a factor of twenty. Kotlin/Wasm has no bulk typed-array bridge, so pixels in a ByteArray cross one byte per JS call, which the web spike measured at 160 to 240 ms per 1080p frame against a 33.3 ms budget. Converting in C and writing straight into the array a canvas is about to draw measured 8.5 to 9.7 ms.
So this asks for a FILL rather than a return. The implementation lives wherever KiteFFmpeg is already a dependency, and the pixels never enter Kotlin memory at all.