Skip to content

Add opt-in on-demand (streaming) decode mode - #126

Open
tuanchauict wants to merge 22 commits into
line:masterfrom
tuanchauict:feature/on-demand-decoding
Open

Add opt-in on-demand (streaming) decode mode#126
tuanchauict wants to merge 22 commits into
line:masterfrom
tuanchauict:feature/on-demand-decoding

Conversation

@tuanchauict

Copy link
Copy Markdown

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 optional decodeMode parameter (defaulting to EAGER), 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:

  • DecodeMode enum (public).
  • Apng / ApngDecoderJni route to eager or streaming JNI by mode.
  • ApngDrawable drives ON_DEMAND drawing through a background one-ahead decoder (OnDemandRenderer), keeping draw() on the main thread.

Sample app

  • On-demand toggle (auto-reloads + GCs on flip so memory reflects the active mode).
  • A generated 120-frame 400×400 APNG (large_anim.png) to make the memory difference observable.
  • On-screen native-heap / Java-heap / CPU stats panel.

Docs

README gains a Decode modes section with the trade-off table and opt-in usage.

Testing

⚠️ Verified by code review + native syntax-checks only in my environment (corporate proxy blocks the Gradle dependency hosts, and no kotlinc/gradle was available). The real build and on-device validation — eager-vs-on-demand golden-frame equality, memory regression, loop-wrap/seek correctness — still need to run on a full toolchain.

Demo with Large APNG (Pixel 9 Pro OS 15):

Screen_recording_20260601_172309.mp4

🤖 Generated with Claude Code

tuanchauict and others added 14 commits June 1, 2026 16:04
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>
@CLAassistant

CLAassistant commented Jun 1, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

tuanchauict and others added 8 commits June 1, 2026 17:39
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants