Add opt-in on-demand (streaming) decode mode - #126
Open
tuanchauict wants to merge 22 commits into
Open
Conversation
Move saveFrame/blendOver/blendSource and the per-frame dispose/blend/save
logic out of ApngDecoder.cpp into a new ApngCompose.{h,cpp}, exposed as a
single composeFrame() entry point. The eager decode loop now calls
composeFrame() instead of an inline block, so the upcoming streaming decoder
can reuse identical pixel logic with no divergence.
No behavioral change to the eager path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
MemoryStreamSource serves libpng's read callback from a byte buffer already read fully into native memory, with a resettable cursor. Unlike StreamSource (a one-shot Java InputStream wrapper), it can rewind to the start, which the upcoming streaming decoder needs to re-decode from frame 0 on loop-wrap and backward seeks. The buffer is non-owning; the caller keeps the bytes alive. The eager path keeps using StreamSource. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ApngStreamDecoder holds a single running composition canvas (3 x frame) plus the buffered encoded bytes instead of all N composed frames, so resident memory is ~constant in frame count. - create(): buffers the bytes, runs a lightweight metadata pre-scan (walks every fcTL recording durations while discarding pixels) so frame count, loop count and all durations are known up front for ApngDrawable's time->index mapping, then opens a playback session positioned before frame 0. - composeNext(): reads one frame and composites it via the shared composeFrame() (identical pixel logic to the eager decoder), including the first/hidden-first frame fixups. - seekTo(): steps forward frame by frame; rewinds the libpng session over the buffered bytes for backward seeks and loop-wrap (frames are deltas). - blitInto(): copies the composed premultiplied frame to the output bitmap. Reuses MemoryStreamSource for replayable reads. Not internally synchronized; the JNI layer will guard each decoder with its own mutex. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Bridges ApngStreamDecoder to Kotlin alongside the existing eager entry points: - gStreamMap holds each decoder with its own mutex; gLock guards only the map, so a slow off-UI-thread decode never blocks lookups or other images. - decodeStream: reads the InputStream fully into native bytes, creates the streaming decoder, fills the shared DecodeResult (allFrameByteCount now reports the streaming footprint, ~constant in frame count). - drawStream: locks the bitmap, seekTo(index) + blitInto under the per-decoder mutex. - recycleStream / copyStream mirror recycle / copy; copyStream clones by re-buffering the bytes + a fresh session (no N-frame copy). Adds ApngStreamDecoder::getEncoded() to support the clone. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Introduce DecodeMode { EAGER, ON_DEMAND } and thread it through the decoder
bridge:
- ApngDecoderJni gains decodeStream/drawStream/recycleStream/copyStream
externals.
- Apng carries its decodeMode and routes draw/recycle/copy to the matching JNI
entry points via a new drawFrame() helper; decode(stream, mode) and copy()
pick the eager or streaming path accordingly.
EAGER remains the default, so existing call sites are unchanged. Wiring the
mode through ApngDrawable's public API and the background decoder follows.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ApngDrawable now plumbs decodeMode (default EAGER) through every decode() overload down to Apng.decode, and renders the two modes differently: - EAGER keeps the existing synchronous path: draw() composes-and-draws a ready frame on the render thread (a memory copy). - ON_DEMAND uses a new OnDemandRenderer: a HandlerThread composes the requested frame into a back buffer via the streaming decoder, then posts a swap to the main thread which promotes it to the front buffer and invalidates. draw() always blits the most recent ready frame, so the UI thread never blocks on decoding; if a frame isn't ready the previous one is shown until the worker catches up. Draws and swaps run on the main thread while the worker only ever writes the back buffer, so the two threads never touch the same bitmap. recycle() releases the worker thread before recycling the native decoder; the native shared_ptr ownership keeps an in-flight decode safe across recycle. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wire a Switch into the sample MainActivity so each load can choose between DecodeMode.EAGER (default) and DecodeMode.ON_DEMAND, exercising the streaming decoder end to end. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Generate a 400x400 / 120-frame APNG (large_anim.png) and a Load button for it. Eager decoding holds all 120 composed frames in native memory (~76 MB) while on-demand keeps roughly a single canvas, making the memory trade-off observable via the existing Run GC / allocationByteCount logging. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a monospace stats line that samples every 500ms: native heap allocated (where composed APNG frames live), Java heap used, app CPU% normalized to one core, plus the active decode mode and current frame. This quantifies the EAGER-vs-ON_DEMAND memory trade-off directly in the UI. CPU is read from /proc/self/stat (utime+stime) over the sample interval; the sampler runs only between onResume and onPause. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Flipping the On-demand switch now recycles the current drawable, forces GC, and reloads the same image, so the stats reflect only the active mode instead of the previous load lingering in native memory. startLoad also recycles the previous drawable up front for the same reason. Reformat the stats into a labeled, monospace, padded panel (MODE, native heap, java heap, CPU) so the EAGER-vs-ON_DEMAND difference is readable at a glance. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The switch was the last child of the horizontally-scrolling load-button row, so it sat off-screen. Lift it out to a dedicated first row above the action buttons. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The stats panel already shows MODE, so keep the status line to just isApng. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Wrap the controls in a ScrollView + vertical LinearLayout so all rows (toggle, load actions, commands, stats) stay reachable on small screens. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a Decode modes section covering the memory/seek trade-offs and the opt-in DecodeMode.ON_DEMAND usage, plus a pointer to the sample app's toggle and stats panel. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New :sample-compose module that loads an APNG through a Coil 3 Decoder backed by ApngDrawable and renders it with AsyncImage. A toggle flips between EAGER and ON_DEMAND (a fresh ImageLoader per mode forces a re-decode), and buttons switch between the bundled small and 120-frame assets. Wires up the Compose compiler plugin, Compose BOM, and Coil in the version catalog, and registers the module in settings. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add a ResourceStatsPanel that samples native heap, Java heap, and app CPU every 500ms, matching the View sample. Clear the previous ImageLoader's cache and nudge GC when the decode mode flips so the stats reflect only the active mode. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The previous DisposableEffect only cleared the outgoing loader's cache on dispose; that drops the reference but does not reclaim the native frames until the old ApngDrawable is finalized. Add a LaunchedEffect keyed on the decode mode that GCs shortly after the switch settles, so the stats panel reflects the new mode's footprint instead of lingering frames. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two review blockers in ApngDrawable: 1. OnDemandRenderer back-buffer race. `pendingIndex` was cleared only by the main-thread swap, so a draw for a different frame could dispatch a second decode while an earlier frame's swap was still queued. The worker would then overwrite the back buffer that the pending swap was about to promote, so the front could be filled with torn/locked pixels. Replace `pendingIndex` with a `busy` gate: at most one decode is ever in flight, set on dispatch and cleared only by its swap, so the worker is the sole writer of the back buffer for the whole dispatch→swap window. The next draw after a swap re-requests the latest frame, so playback still converges on the current frame. 2. Adding the `decodeMode` default parameter changed the single JVM signature of each `decode(...)` overload, breaking existing Java callers at source and binary level. Add @jvmoverloads so the previous signatures are regenerated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Apng unconditionally allocated a full w*h ARGB_8888 bitmap and decoded frame 0 into it on construction. In ON_DEMAND the drawable renders through the renderer's own two ping-pong buffers, so that third bitmap was wasted resident memory plus a redundant frame-0 decode. Allocate it only for EAGER and make byteCount/isRecycled/config fall back to computed values when it is absent. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- readStreamFully: null-check the cached `read` method id before calling it. - copyStream: correct the comment — cloning re-buffers bytes (constant memory) but the pre-scan re-decodes every frame, so it is O(frames) in CPU. - ApngStreamDecoder: `= delete` the copy ctor/assignment (modern idiom). - saveFrame: restore the `uint32_t **const source` qualifier. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The decode-modes table sold the memory win but was silent on latency: the initial decode still walks every frame (libpng must read each frame to reach the next fcTL), and copy() re-scans all frames. Add a note so callers do not expect ON_DEMAND to speed up decoding. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
isApng peeked and scanned the entire stream for the acTL chunk, materializing a lot for large assets (and reading plain PNGs to the end just to conclude "not animated"). acTL must precede the first IDAT in a valid APNG, so scan only the pre-IDAT header region. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
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
Adds a penfeizhou-style on-demand (streaming, low-memory) decoder to the library as an opt-in mode, alongside the existing eager decoder which remains the default and is unchanged.
DecodeMode.EAGER(default): all frames composed up front — O(1) draw/seek, memory ∝frames × w × h × 4. Existing behavior, untouched.DecodeMode.ON_DEMAND: one composition canvas + buffered encoded bytes — roughly constant memory in frame count. Forward playback decodes one frame per displayed frame; backward seeks are O(target) since APNG frames are deltas replayed from the start.Every
ApngDrawable.decode(...)overload gains an optionaldecodeModeparameter (defaulting toEAGER), so existing call sites are unaffected.Implementation
Native (C++ / JNI):
ApngCompose— shared frame-compositing extracted from the eager decoder (dispose/blend ops), so eager and streaming produce identical pixels.MemoryStreamSource— replayable in-native libpng read source.ApngStreamDecoder— live streaming session: metadata pre-scan for durations,seekTo(rewind = reopen session, then compose forward),composeNext,blitInto.ApngDecoderJni— streaming JNI entry points (decodeStream/drawStream/recycleStream/copyStream) with per-decoder mutex + shared_ptr ownership.Kotlin:
DecodeModeenum (public).Apng/ApngDecoderJniroute to eager or streaming JNI by mode.ApngDrawabledrives ON_DEMAND drawing through a background one-ahead decoder (OnDemandRenderer), keepingdraw()on the main thread.Sample app
large_anim.png) to make the memory difference observable.Docs
README gains a Decode modes section with the trade-off table and opt-in usage.
Testing
Demo with Large APNG (Pixel 9 Pro OS 15):
Screen_recording_20260601_172309.mp4
🤖 Generated with Claude Code