player: sustain 1080p60 playback on Android - #9
Merged
Conversation
Wrap the reusable RGBA output buffer in an FFmpeg frame and scale directly into it. This removes the full-frame copy previously performed after every conversion while preserving the existing buffer pool lifecycle. Verify that exactly one wrapped buffer remains pinned during decoding and that Close returns the native memory accounting to its baseline. Assisted-by: OpenAI Codex
Release playback state while Oto reads and decodes its next frame, while serializing all native decoder access behind a dedicated mutex. This keeps Ebitengine controls and video presentation responsive during audio read-ahead. Invalidate frames that complete across seek, reset, or close boundaries so stale data cannot re-enter playback queues. Closed controllers also avoid retaining the final decoded video buffer. Assisted-by: OpenAI Codex
Exercise an in-flight audio read while video state, seek, and Close are accessed concurrently. Verify stale frames are discarded across decoder generations, recyclable seek buffers return to the pool, and Close does not retain its final decoded video buffer. Assisted-by: OpenAI Codex
mcuadros
marked this pull request as ready for review
August 21, 2026 12:18
tinne26
approved these changes
Aug 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
This change removes two independent bottlenecks from local playback:
There are no public API changes. Stream playback, media selection, playback controls, audio conversion, and hardware-decoder selection are outside this diff.
Root cause
The previous video path called
Scaler.Scale, wrapped the scaler-owned frame, and copied its contents into a pooled Go slice. A 1920x1080 RGBA frame is 8,294,400 bytes; at 60 FPS that extra pass moved approximately 475 MiB/s beforeebiten.Image.WritePixelsuploaded the frame.For media with audio, Ebitengine/Oto pulls PCM through
ffiLocalController.Read. That callback previously held the controller mutex acrossdecoder.ReadFrame. A native decode can consume most of a frame interval, so Ebitengine's update thread could not inspect the video queue or advance presentation while audio read-ahead was decoding. On the physical Android test this limited the example to approximately 23 TPS / 27 FPS even though MediaCodec and swscale were individually fast enough.Implementation
1. Scale into the pooled output buffer
ffiDecoder.convertVideoFrameLockednow:width * height * 4slice frombackendVideoBufferPool;ffmpeg.FrameusingFrame.WrapBuffer;Scaler.ScaleToto write RGBA directly into it; andbackendFramewithout another copy.The output remains tightly packed RGBA, so the contract consumed by
ebiten.Image.WritePixelsis unchanged.Buffer ownership and native lifetime
backendFrame.Video.RGBAowns the pooled slice afterScaleToreturns.backendVideoBufferPoolonly when the queued or displayed frame is replaced, discarded, or closed.ffiDecoder.scaleTargetretains FFmpeg's reference to the most recently wrapped slice.WrapBufferunreferences the previous target before installing the next one.ScaleTofailure frees the native reference before returning the slice to the pool.ffiDecoder.Closefrees the final wrapped reference.The native integration test compares
ffmpeg.WrappedBufferMemoryUsagebefore decode, during decode, and afterClose: exactly one wrapped video buffer may remain pinned while the decoder is open, and the accounting must return to its original baseline after close.2. Separate playback state from native decoder serialization
ffiLocalControllernow gives each lock one responsibility:mutexreadMutexio.Readerimplementation.decoderMutexReadFrame,Seek, andClosecall.When the audio reader needs another decoded frame, it records
decodeGeneration, releases the playback mutex, performs the native read underdecoderMutex, and reacquires the playback mutex before touching queues or state. This keepsCurrentVideoFrameand the Ebitengine update loop responsive without allowing concurrent native decoder access.decodeGenerationincrements whenever playback is reset or closed. If a read began before a seek, stop, replay, loop reset, or close and completes afterwards, its frame belongs to the old generation: it is discarded, its RGBA buffer is recycled when appropriate, and it cannot enter the new playback state.Closemarks the controller closed and invalidates the generation before waiting for the serialized decoder close. A read that was already in flight therefore returns EOF after the native call and cannot repopulate a cleared pool.Review guide
The commits are intentionally separated by concern:
backend: scale video into pooled buffersplayer: keep decoding outside playback locktest: cover concurrent audio decodingThe most important invariants to verify are:
ReadFrame,Seek, andClosenever overlap on the native decoder;Tests
The new deterministic tests use a decoder whose
ReadFrameblocks until released:TestFFmpegAudioDecodeDoesNotBlockVideoPlaybackprovesCurrentVideoFrameremains available while audio decoding is in flight.TestFFmpegAudioReadDiscardsFrameDecodedBeforeSeekstarts a seek during the blocked read and verifies the stale frame is rejected and recycled.TestFFmpegAudioReadDoesNotRetainFrameDecodedBeforeClosecloses during the blocked read and verifies the stale frame is rejected without repopulating the cleared pool.TestFFmpegBackendMediaverifies the wrapped-buffer pin count during real decoding and after decoder close.Validation performed:
go test -race -count=1 ./...go vet ./...checkptrandGOEXPERIMENT=cgocheck2native integration testsgo test ./...after updating the branch against currentorigin/mainPhysical Android result
Tested with a clean APK on a physical Android 14 arm64 tablet using a 20-second 1920x1080, 60 FPS H.264/AAC file:
This is evidence for the tested device and media, not a general performance guarantee for every codec, resolution, FFmpeg build, or Android device.