Skip to content

Add selective multi-mip tiled decoding, 16K tile support, per-frame RC, and codec callbacks#228

Draft
jsperrier1 wants to merge 15 commits into
AcademySoftwareFoundation:mainfrom
alejandro-arango-epicgames:tmv_multi_mip_16k
Draft

Add selective multi-mip tiled decoding, 16K tile support, per-frame RC, and codec callbacks#228
jsperrier1 wants to merge 15 commits into
AcademySoftwareFoundation:mainfrom
alejandro-arango-epicgames:tmv_multi_mip_16k

Conversation

@jsperrier1

Copy link
Copy Markdown

Title: Add selective multi-mip tiled decoding, 16K tile support, per-frame RC, and codec callbacks


Summary

This PR adds a set of decoder/encoder capabilities developed for a tiled, multi-resolution ("mip") streaming workflow, ported onto current main. The headline addition is a selective multi-mip tile decoder that decodes an arbitrary subset of tiles across multiple mip levels of an access unit in a single call, with coalesced I/O and multi-threaded tile decode. Supporting changes extend tile limits to 16K, add per-frame rate control, and introduce pluggable memory/logging/CPU-trace callbacks.

Library (src/, inc/) and app (app/) changes only. The test harness and media for this feature are substantial and are being finished on a separate branch; I'd like to land the library first and follow up with tests. Happy to reorder if you'd prefer them together.

Motivation

For large images (up to 16K) encoded as tiled mip pyramids, clients often need only a viewport-dependent subset of tiles at a chosen resolution rather than a full-frame decode. The existing API decodes whole access units. This adds a random-access path that locates the requested mip frames, reads only the needed tile payloads (coalescing adjacent reads), and decodes the selected tiles in parallel.

What's included

Selective multi-mip decode (core)

  • oapvd_decode_selective_multi_mips() — decode a set of {mip level, tile coords} requests in one traversal, via a caller-supplied random-access oapvd_istream_t (seek/read/tell). Per-request status is reported per mip; coalesced block I/O minimizes seeks; tiles decode across worker threads.
  • Decodes into caller output buffers with either standard planar or tile-major ("tiled") layout, with optional per-tile destination-slot routing for bounded output buffers.

16K tile support

  • Tile grid limits raised to 64x64 (OAPV_MAX_TILE_ROWS/OAPV_MAX_TILE_COLS), supporting 16K at 256x256 tiles. Tile arrays are allocated dynamically rather than fixed.

Per-frame rate control

  • RC parameters can vary per frame within a sequence (rc_param_frm[]), loaded per frame during encode.

Bitstream: tile_size_present_in_fh_flag

  • Now a real, parameterized frame-header flag (oapve_param_t), defaulting to 0 (unchanged/base bitstream). When enabled it writes per-tile sizes into the frame header so a decoder can index individual tiles — required for the selective path. The encoder app sets it under --tmv-mips.

Codec callbacks

  • oapv_set_logging_callback() / oapv_set_logging_verbosity() (OAPV_LOG_ERROR|WARNING|INFO|DEBUG), default WARNING.
  • oapv_set_memory_callbacks() — pluggable allocators (NULL resets to libc).
  • oapv_set_cputrace_callbacks() — optional CPU event tracing (NULL resets to no-op).
  • Usage contract (call before creating codec instances; not safe while live) documented in inc/oapv.h.

Encoder app (app/)

  • --tmv-mips — enables the tiled/mip workflow: sets tile_size_present_in_fh_flag and emits per-AU tiled output.
  • Per-AU output and .oapv/.apv output-extension handling.

Public API additions (inc/oapv.h)

  • oapvd_decode_selective_multi_mips, oapvd_istream_t
  • oapv_mip_request_t, oapv_multi_mip_decode_t
  • oapv_set_logging_callback, oapv_set_logging_verbosity, oapv_log_callback_t, OAPV_LOG_*
  • oapv_set_memory_callbacks, oapv_memory_callbacks_t
  • oapv_set_cputrace_callbacks, oapv_cputrace_callbacks_t
  • OAPV_MAX_TILE_ROWS, OAPV_MAX_TILE_COLS

Backward compatibility

  • No change to existing bitstreams or the normal decode path by default: tile_size_present_in_fh_flag defaults to 0, callbacks default to prior behavior, and the standard oapvd_decode() path is untouched (the selective decoder uses its own frame-prepare that skips imgb validation because it decodes into caller buffers).
  • All existing ctest cases pass unchanged.

Robustness / hardening

Given the recent fuzz-hardening work upstream (#224, #226), the selective path validates its inputs in the same spirit:

  • Every istream->read() return is checked; a short read flags the affected coalesced block, its tiles are skipped (never decoded from stale memory), and the call returns OAPV_ERR_MALFORMED_BITSTREAM.
  • Per-tile sizes are validated against the PBU payload bounds before being used to compute offsets.
  • Header parsing cannot loop indefinitely when a header exceeds the 64 KB grow cap.
  • Missing tile_size_present_in_fh_flag is a hard OAPV_ERR_UNSUPPORTED for the selective path rather than a silent fallback.
  • Bounded logging (vsnprintf); NULL-safe callback setters.

Testing

The dedicated test suite (a data-driven decoder_test, multi-mip performance/error-handling programs, and media) lives on a separate branch and is not part of this PR. Validation performed there against this code: existing ctest suite 16/16; a 4K selective-decode case matrix (single/sparse/contiguous/strip tile sets across mip levels); multi-mip error-handling and performance programs; and a truncated/corrupt-input check that returns cleanly (no crash/hang) at 1/4/32 threads. I can bring a minimal subset of tests into this PR if you'd prefer coverage to land together.

Notes for reviewers

  • The selective decoder requires random-access input (oapvd_istream_t), distinct from the existing in-memory AU API.
  • Feedback welcome on: the callback API surface, whether the 16K tile-limit bump warrants a level/profile note, and whether you'd like (a subset of) tests included here vs. a follow-up.

jsperrier1 and others added 15 commits July 13, 2026 20:50
Replays the TMV feature branch (tmv_tiled_output, 51 commits since the
AcademySoftwareFoundation#139 fork point) on top of AcademySoftwareFoundation/openapv main, which
had diverged with a codebase-wide API rename and input-validation
hardening. The TMV line had been developed on a re-initialized git
history with no shared ancestry, so this is a port rather than a merge.

Conflict resolution highlights:
- Adopt upstream's renamed encoder/decoder API (num_c, c_sft,
  fn_blk_from_pic/fn_blk_to_pic, oapv_validate_tile_topology,
  4-arg args_set_variable_by_key_long, 5-arg oapvd_vlc_tile_header,
  3-arg oapv_bsr_read_direct, 3-arg dec_frm_prepare) throughout the
  TMV-added code.
- Keep TMV features: dynamic tile arrays (encoder + decoder), per-frame
  RC (rc_param_frm), fn_imgb_to_blk_rc sampler, selective/disk-I/O
  multi-mip decoder + tile-status enum + tile buffer structs, --tmv-mips
  encoder option, per-AU output files, 64x64 tile limits.
- Keep upstream hardening: ndata[0] bitstream-overflow flag path,
  fps/band validation, payload-bounds checks, 16-bit imgb_write range.
- Keep TMV's total_bits u64 overflow fix (16k rate control).
- Recognize .oapv (not just .apv) as a single-file output extension so
  the per-AU split path only triggers for extension-less output names.
- Add dec_frm_prepare_selective for the selective/multi-mip decode paths:
  upstream's hardened dec_frm_prepare validates the imgb against frame
  dimensions/format/capacity, which rejected the dummy imgb those paths
  pass (they decode into their own work buffers). The selective variant
  sets up ctx metadata + tile geometry without imgb validation, leaving
  upstream's dec_frm_prepare intact for the normal full-frame path.

Builds clean (gcc 13, all targets). Encode/decode roundtrip validated
(125-frame 320x240 422p10). All 16 ctest tests pass. Selective multi-mip
decode API (oapvd_decode_selective_multi_mips) validated via decoder_test
run_4k_tests set: all real cases decode correctly; the only non-OK
results are the intentional negative tests (non-existent mip -> NOT_FOUND)
and a sparse-corner layout that trips the validator's zero-column heuristic.

Original 51-commit TMV history preserved in branch pr_candidate_backup.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
These two public selective-decode entry points are unused by the Unreal
integration, which relies solely on oapvd_decode_selective_multi_mips
(oapvd_decode_selective_multi was itself just a single-mip wrapper around
it). Removing them and their now-dead internal helpers:

- oapvd_decode_selective (standalone disk-I/O tile-cache decoder)
- oapvd_decode_selective_multi (single-mip wrapper)
- exclusive helpers: oapvd_build_tile_cache, calculate_tile_offsets,
  oapvd_get_tile_offset, oapvd_invalidate_tile_cache (+ its call in
  _multi_mips), and the tile_offsets_cache fields in oapvd_ctx / their
  free block in dec_flush
- already-dead orphans in the same subsystem: oapvd_locate_mip_frame,
  dec_tile_to_views, create_tile_views, init_tile_buffer_manager,
  get_tile_buffer, return_tile_buffer

Tests that drove the removed APIs (decoder_test single/multi cases, the
per-mip perf-comparison baseline, and the sequential baseline in
test_multi_mip_performance) are repointed to a small local wrapper that
calls oapvd_decode_selective_multi_mips with a single-mip request -- the
exact behavior the removed _multi provided -- so coverage is preserved.

Net: -715/+74 lines in src, no API behavior change for the retained path.
Builds clean; decoder_test (single/multi/multimip) and both dedicated
multi-mip test executables pass; single-tile output verified bit-for-bit
correct (perfect chroma) through the retained API.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With oapvd_decode_selective / _multi removed, oapv_selective_decode_t had
no remaining public consumer -- only the test-local single-mip wrappers
still referenced it. Remove the type from the public header and rework
the two test wrappers (decoder_test.c, test_multi_mip_performance.c) to
operate directly on oapv_mip_request_t: the helper now wraps the caller's
mip_request as a one-entry multi-mip decode, and status/frame metadata are
filled in place (frame_width_mb_aligned / frame_height_mb_aligned /
tile_width_mb_aligned / tile_height_mb_aligned) instead of being copied
back into a separate struct.

Builds clean; decoder_test (30/30 applicable cases), both dedicated
multi-mip test executables, and ctest (16/16) all pass; single-tile
output still bit-for-bit correct through oapvd_decode_selective_multi_mips.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
BSW_FLUSH_4BYTE is only reachable via BSW_WRITE_32BITS, which is unused
in both the TMV work and upstream (the encoder VLC path writes exclusively
through BSW_WRITE_64BITS -> BSW_FLUSH_8BYTE). During the port the TMV
ndata[0] overflow variant of BSW_FLUSH_4BYTE was kept, leaving it
inconsistent with BSW_FLUSH_8BYTE and divergent from upstream for no
functional benefit (the macro is dead code).

Restore upstream's BSW_FLUSH_4BYTE verbatim (advance cur past end on
overflow, same style as BSW_FLUSH_8BYTE). The live 64-bit path is
unchanged and still detects overflow via the post-encode size check and
oapv_bsw_sink() setting ndata[0]. Encoder VLC flush macros are now
byte-identical to upstream.

Encode/decode roundtrip (125 frames), decoder_test, and ctest (16/16) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The 64-bit bitstream writer (upstream AcademySoftwareFoundation#184) tracks free bits in a 64-bit
code accumulator, so leftbits must reset to 64. Two TMV-added graceful
overflow branches still reset it to 32 (stale from the pre-64-bit code):

- bsw_flush(): overflow branch
- oapv_bsw_write1(): buffer-full branch

Both sit on the ndata[0] error path, but leftbits = 32 is inconsistent
with the 64-bit accumulator and makes BSW_GET_SINK_BYTE ((64-leftbits+7)>>3)
report 4 phantom pending bytes instead of 0 after the code register was
zeroed. The normal paths in both functions (and oapv_bsw_init/_sink)
already use 64. Upstream's oapv_bs.c uses 64 exclusively.

(The remaining leftbits = 32 in oapv_vlc.c is inside BSW_FLUSH_4BYTE and
matches upstream verbatim -- a genuine 4-byte flush, left as-is.)

Encode/decode roundtrip (125 frames) and ctest (16/16) pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The multi-mip istream parser assembles three 32-bit big-endian fields
(au_size, signature, pbu_size) from u8[4] buffers. `buf[0] << 24`
promotes the u8 to int, and for byte values >= 0x80 the result exceeds
INT_MAX -> signed integer overflow (undefined behavior; works on
mainstream compilers but flagged by UBSan / risky under aggressive
optimization). Cast each byte to u32 before shifting.

Not a code-accumulator issue (the 64-bit bitstream register class came
back clean in a full sweep of the TMV-added writer/reader code); this is
the same "shift assuming 32-bit int" family in the disk-I/O format parse.

Selective decode, error-handling test, roundtrip (125 frames), and ctest
(16/16) all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This standalone multi-mip test has no build target in CMakeLists.txt (the
built test programs are decoder_test, test_multi_mip_performance, and
test_multi_mip_error_handling), so it is never compiled or run. Its
coverage -- a single-mip and a multi-mip oapvd_decode_selective_multi_mips
call with the two-phase metadata/decode pattern -- is fully subsumed by
decoder_test's multimip_* cases and the two dedicated multi-mip test
executables. Removing the dead, superseded source.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The TMV fork hardcoded fh->tile_size_present_in_fh_flag = 1 in
oapve_set_frame_header, unconditionally changing the emitted bitstream so
the selective/tiled decoder could index tiles from the frame header. That
can't ship upstream, where the flag defaults to 0.

Make it a proper encoder parameter:
- oapve_param_t gains tile_size_present_in_fh_flag (public API), defaulted
  to 0 in oapve_param_default (matches the base bitstream / upstream).
- oapve_set_frame_header now copies it from param instead of forcing 1.
- The encoder app couples it to --tmv-mips: that workflow produces per-AU
  tiled output intended for selective/tiled decoding, which needs per-tile
  sizes in the frame header. Without --tmv-mips the flag stays 0.

Callers that need indexable output (e.g. the Unreal integration) set
param.tile_size_present_in_fh_flag = 1 via the API directly.

Validated: default encode is base bitstream (roundtrip 125 frames, ctest
16/16); --tmv-mips encode produces decodable per-AU tiled output; selective
decode on existing tiled media unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The full-validation chroma check took the bounding box of all non-zero
luma and flagged any chroma column that was entirely zero across that box.
For sparse / partial tile selections (e.g. 16k_tile_limit_validation
decodes only 4 corner tiles + center) the bounding box spans the whole
frame while ~95% of columns are legitimately undecoded gaps, so the check
reported spurious "Chroma stripe artifacts detected" despite a correct
decode (verified visually: the decoded tiles are clean, the rest is
zero-filled).

Gate the check on the luma plane: only evaluate rows where the co-located
luma sample is non-zero, and only count columns that have any decoded luma
content. A column is flagged only when chroma is zero across the rows where
luma exists -- the actual "chroma dropped where luma present" defect the
check is meant to catch. Undecoded gaps no longer count.

Result: 16k_tile_limit_validation, multimip_sparse, and the other sparse
cases now report SUCCESS; contiguous decodes (e.g. single_tile_mip0_origin)
still validate exactly as before. Zero false warnings across the 4K suite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
run_multimips_tests.bat was almost entirely redundant: 5 of its 6 cases
(multi_all_mip9, multimip_single_center, multimip_2x2_blocks,
multimip_sparse, multimip_performance_comparison) were already run by
run_4k_tests.bat against the same media. Only invalid_test was unique.

- Add invalid_test to run_4k_tests.bat's multi-mip section.
- Extend PNG conversion to also process output\*.y4m (the multi-mip tests
  emit Y4M; run_4k previously converted only .raw, so those outputs were
  never visualized) using convert_y4m_to_png.py with the tile converter
  as fallback -- the capability run_multimips provided.
- Remove the now-fully-subsumed run_multimips_tests.bat.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The generation script only ever uses the first 10 frames (4K path,
NUMFRAMES=10) and one more frame for the 16K path. The 16K tests only
exercise tile addressing / chroma at 16K resolution, so the source frame
is arbitrary -- repoint the 16K path from the 9.22s frame to frame 0 so
the whole clip can be trimmed to 10 frames.

- koala.mp4: re-encoded to the first 10 frames (h264, crf 12), 75MB -> 5.9MB
  (under GitHub's 50MB limit, no LFS needed).
- generate_koala_tiled.bat: 16K extraction now takes frame 0; header/comments
  updated.

Verified end-to-end from the trimmed source: 10-frame YUV extract -> 4K
--tmv-mips encode (10 frames) -> decode (primary + 7 mips); 16K frame-0
extraction works. Committed .apv1 test media is unchanged and still valid.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Fixes the reviewer's findings on the selective multi-mip decode path:

- Thread race: spawn num_threads-1 workers so the main thread's core
  (core[num_threads-1]) is no longer shared with a worker (matches the
  regular decode path). Sharing corrupted coef/prev_dc/q_mat state.
- Double-free: drop the duplicate oapv_mfree(mip_contexts) in the
  mip_infos OOM path.
- I/O hardening: check every istream->read() return; on a short read
  flag the coalesced block, mark its tiles ERROR so workers skip them
  (never decoding a partly-filled buffer), and return
  OAPV_ERR_MALFORMED_BITSTREAM. Validate fh->tile_size[] against the
  PBU payload bounds before computing offsets. No calloc on the hot
  path (would memset multi-MB blocks needlessly).
- Infinite loop: oapvd_parse_frame_headers no longer spins when the
  header buffer cannot grow (clamped at 64 KB with pbu_size > 64 KB);
  the two grow-and-retry blocks are merged and bail on no progress.
- Missing tile_size flag: hard-fail a mip with OAPV_ERR_UNSUPPORTED
  when tile_size_present_in_fh_flag is absent, instead of computing
  offsets from a 65536 fallback.
- log_msg: vsprintf -> vsnprintf; default verbosity WARNING (was
  DEBUG, which printed the perf summary on every decode).
- Return code: per-mip outcomes go only to mip_req->status; the call
  returns OAPV_OK unless a fatal (shared) error occurs.
- Dangling pointer: clear ctx->imgb after dec_frm_prepare_selective.
- NULL-safe setters: oapv_set_memory_callbacks / _cputrace_callbacks
  now reset to defaults on NULL; usage contract documented in oapv.h.
- Dead code: remove oapv_tile_data_t, oapv_tile_buffer_mgr_t, the
  unused tiles_ready/io_complete worker fields, and the commented
  ON_DECODING block.

ctest 16/16, error-handling + perf tests, and 68/68 decoder_test 4K
cases pass; truncated-file decode returns -202 cleanly at 1/4/32
threads.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Prepares the library/app-only PR branch: removes the TMV test harness
(decoder_test, multi-mip perf/error tests, convert_*.py, run_*.bat,
framelist), all bundled test media (koala.mp4, koala_tiled/*.apv1,
AlphaTransition), the decoder_test/test_multi_mip CMake targets, the
Windows build.bat helper, and the doc/draft-lim-apv-07.txt spec draft.

Leaves only the library (src/, inc/) and app/ changes plus upstream's
own test fixtures. The full with-tests reference remains on
pr_candidate; the test suite continues on its own branch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jsperrier1

Copy link
Copy Markdown
Author

Reference presentation: https://youtu.be/qs6wo67YzME?si=NKcoVqIwD2s_6xpd

@kpchoi

kpchoi commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

This is kind of super patch.
It would be recommended that one PR has one feature.
Anyway, let's solve one by one from the easiest feature by discussion, first.

@kpchoi

kpchoi commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Proposal: 16k tile support

Tile grid limits raised to 64x64 (OAPV_MAX_TILE_ROWS/OAPV_MAX_TILE_COLS), supporting 16K at 256x256 tiles. Tile arrays are allocated dynamically rather than fixed.

Discussion

The max tile rows and max tile coloums are defined in section 9.4.1 of RFC 9984;
https://www.rfc-editor.org/rfc/rfc9924.html#section-9.4.1

The value of TileCols MUST be less than or equal to 20.
The value of TileRows MUST be less than or equal to 20.

Therefore, oapv.h defines the following values;

#define OAPV_MAX_TILE_ROWS              (20) // max number of tiles in row
#define OAPV_MAX_TILE_COLS              (20) // max number of tiles in column
#define OAPV_MAX_TILES                  (OAPV_MAX_TILE_ROWS * OAPV_MAX_TILE_COLS)

Basically, it is kind of 'spec violation' if the max values are changed.
Unfortunately, changing the values is not a straightforward decision.

@kpchoi

kpchoi commented Jul 21, 2026

Copy link
Copy Markdown
Collaborator

Proposal: Callbacks

Codec callbacks
oapv_set_logging_callback() / oapv_set_logging_verbosity() (OAPV_LOG_ERROR|WARNING|INFO|DEBUG), default WARNING.
oapv_set_memory_callbacks() — pluggable allocators (NULL resets to libc).
oapv_set_cputrace_callbacks() — optional CPU event tracing (NULL resets to no-op).
Usage contract (call before creating codec instances; not safe while live) documented in inc/oapv.h.

Discussion

Could explain main purposes of the callbacks?
Basically, liboapv doesn't log any message now.
And, memory callback is for replacing malloc() function, for example?

@kpchoi

kpchoi commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Proposal: Rate control per-frame

RC parameters can vary per frame within a sequence (rc_param_frm[]), loaded per frame during encode.

Discussion

In order to support indivisual multi-frames rate controlling, the oapve_rc_param_t rc_param; should be changed to oapve_rc_param_t rc_param[OAPV_MAX_NUM_FRAMES];.
But you implementation seems to be different way.
@mss-park , could you make a code patch only for this issue and raise PR?

@kpchoi

kpchoi commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Tile-based decoding

Discussion

There is new API to support partial decoding based on tile position;
OAPV_EXPORT int oapvd_decode_frame(oapvd_t did, oapv_bitb_t *bitb, oapv_imgb_t *imgb, oapvd_stat_t *stat, oapv_tile_info_t * part);

Have you checked the function can supports the requested feature?

@mss-park

Copy link
Copy Markdown
Collaborator

Proposal: Rate control per-frame

RC parameters can vary per frame within a sequence (rc_param_frm[]), loaded per frame during encode.

Discussion

In order to support indivisual multi-frames rate controlling, the oapve_rc_param_t rc_param; should be changed to oapve_rc_param_t rc_param[OAPV_MAX_NUM_FRAMES];. But you implementation seems to be different way. @mss-park , could you make a code patch only for this issue and raise PR?

I agree that the rate control model should be managed on a per-frame basis. However, in order to support multiple frames within a single AU (Access Unit), further changes seem to be required. Could you please clarify if your goal is to encode multiple frames within a single AU?

@jsperrier1

jsperrier1 commented Jul 22, 2026

Copy link
Copy Markdown
Author

Rate control per-frame


Could you please clarify if your goal is to encode multiple frames within a single AU?

Yes, it is the goal. In particular for TMV use case, we encode mip 0 as the primary full resolution frame and the lower resolution mips (1, 2, 3, ... N) as non-primary frames in the same AU. For interop: Non-TMV compliant players ignore non-primary frames and can still play the sequence normally (although only confirmed with ffplay).

@jsperrier1

jsperrier1 commented Jul 22, 2026

Copy link
Copy Markdown
Author

Callbacks


Could explain main purposes of the callbacks?

Memory allocator callbacks:

And, memory callback is for replacing malloc() function, for example?

Correct. It is the usually clean method of supporting Unreal engine's custom memory allocators. UE supports hot module reload (live coding) and other complex operations. It is preferable to have the cleanest interface possible.

log and cpu trace callbacks:

The approach we are proposing for TMV is to have a more complex "request-based" decoding API. We can elaborate that in parallel in the decoding api discussion, but for the purpose of this answer, the idea is that the complexity of the AU parsing and tile decoding worker thread management of TMV implementation is pushed in oapv itself, to facilitate implementation of a TMV-like player outside of Unreal. Because the decoding api is now request-based and more complex, profiling and error management is also more complex. In UE, the general approach in that case is to expose log and cpu trace to ease trouble shooting for errors or performance issues. We often ask users to run the problematic levels of their game with verbose logging and using Unreal Insights to capture traces, then send us the logs and traces so we can figure out what is wrong.

So, those callbacks are not critical to this implementation (and we can debate the pertinence of each log individually), but have been proven to provide great help for long term user support and maintenance.

@jsperrier1

Copy link
Copy Markdown
Author

Tile-based decoding


Have you checked the function can supports the requested feature?

For the TMV player implementation, we want to play very high resolution content in a 3d viewport where only a part of the content is visible, either a lower resolution mip or part of the content at high resolution. The typical example is viewing a 16K video on a 4K display. The API we need should allow of partial read of the AU (or frame), i.e. we don't want to have to read the entire compressed frame or access unit if we don't have to. It will be typically the case that only a sub-set of the compressed byte stream is needed for decoding the requested mips/tiles.

The existing oapvd_decode_frame API requires the access unit to be loaded in memory. In most cases, we can't afford to read the whole AU or even encoded frame. This is why, in oapvd_decode_selective_multi_mips, we are using read callbacks (oapvd_istream_t) to allow for a selective reading of the byte stream itself.

The other issue is with the performance of tile decoding related to batching patterns. If we batch work per frame (i.e. per mip in tmv context), we have very inefficient use of the worker thread pool:
image

We don't want the decoder to wait on all worker thread to finish decoding a "mip" before starting to decode tiles from the next mips. This is why oapvd_decode_selective_multi_mips is also "request based", i.e. a single call to oapvd_decode_selective_multi_mips will batch all of the requested mips (frames) as efficiently as possible in terms of both reading and decoding:
image

oapvd_decode_frame is a middle ground that we can't use. Either we would have to go with an even lower level api like oapvd_decode_tile and the entire AU partial reading, parsing and worker thread management would be part of UE, or we go with the current proposal with is to push all that in opav so it can be reused to implement TMV-like player outside of UE.
That was the thinking behind this.

@kpchoi

kpchoi commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Callbacks

Could explain main purposes of the callbacks?

Memory allocator callbacks:

And, memory callback is for replacing malloc() function, for example?

Correct. It is the usually clean method of supporting Unreal engine's custom memory allocators. UE supports hot module reload (live coding) and other complex operations. It is preferable to have the cleanest interface possible.

log and cpu trace callbacks:

The approach we are proposing for TMV is to have a more complex "request-based" decoding API. We can elaborate that in parallel in the decoding api discussion, but for the purpose of this answer, the idea is that the complexity of the AU parsing and tile decoding worker thread management of TMV implementation is pushed in oapv itself, to facilitate implementation of a TMV-like player outside of Unreal. Because the decoding api is now request-based and more complex, profiling and error management is also more complex. In UE, the general approach in that case is to expose log and cpu trace to ease trouble shooting for errors or performance issues. We often ask users to run the problematic levels of their game with verbose logging and using Unreal Insights to capture traces, then send us the logs and traces so we can figure out what is wrong.

So, those callbacks are not critical to this implementation (and we can debate the pertinence of each log individually), but have been proven to provide great help for long term user support and maintenance.

@jsperrier1 — this really helps clarify the intent. Let me split my response into the two callback groups, since I think they need to be handled quite differently.

Memory callbacks

I fully understand the motivation (clean integration with Unreal's custom allocators, live coding, etc.), and I agree it's a reasonable thing to want.

My main concern is security. Since liboapv parses untrusted bitstreams, allowing the allocator to be replaced can
easily become a serious vulnerability if it isn't done very carefully. Given the fuzz-hardening work we've been
doing recently (#224, #226), I want to be cautious here.

I don't want to just say no, though — I'd like to think more about whether there's a safe way to achieve the same
goal, and come back to you with a proposal. Let me take some time on this.

Logging and CPU-trace callbacks

Looking at the code, all of the log_msg() calls and the CPU-trace hooks live inside the new selective /
tile-based partial decoding path (oapvd_decode_selective_multi_mips) — none of the existing encode/decode paths
emit anything. So these callbacks really only have meaning in the context of that new decoding API.

Because of that, I'd prefer to review them together with the tile-based partial decoding discussion rather than
as a standalone feature. And if it turns out they aren't strictly necessary for that feature, I'd like to drop
them to keep the surface minimal. We can absolutely revisit the individual logs there, as you suggested.

@kpchoi

kpchoi commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Callbacks

Could explain main purposes of the callbacks?

Memory allocator callbacks:

And, memory callback is for replacing malloc() function, for example?

Correct. It is the usually clean method of supporting Unreal engine's custom memory allocators. UE supports hot module reload (live coding) and other complex operations. It is preferable to have the cleanest interface possible.
log and cpu trace callbacks:
The approach we are proposing for TMV is to have a more complex "request-based" decoding API. We can elaborate that in parallel in the decoding api discussion, but for the purpose of this answer, the idea is that the complexity of the AU parsing and tile decoding worker thread management of TMV implementation is pushed in oapv itself, to facilitate implementation of a TMV-like player outside of Unreal. Because the decoding api is now request-based and more complex, profiling and error management is also more complex. In UE, the general approach in that case is to expose log and cpu trace to ease trouble shooting for errors or performance issues. We often ask users to run the problematic levels of their game with verbose logging and using Unreal Insights to capture traces, then send us the logs and traces so we can figure out what is wrong.
So, those callbacks are not critical to this implementation (and we can debate the pertinence of each log individually), but have been proven to provide great help for long term user support and maintenance.

@jsperrier1 — this really helps clarify the intent. Let me split my response into the two callback groups, since I think they need to be handled quite differently.

Memory callbacks

I fully understand the motivation (clean integration with Unreal's custom allocators, live coding, etc.), and I agree it's a reasonable thing to want.

My main concern is security. Since liboapv parses untrusted bitstreams, allowing the allocator to be replaced can easily become a serious vulnerability if it isn't done very carefully. Given the fuzz-hardening work we've been doing recently (#224, #226), I want to be cautious here.

I don't want to just say no, though — I'd like to think more about whether there's a safe way to achieve the same goal, and come back to you with a proposal. Let me take some time on this.

Logging and CPU-trace callbacks

Looking at the code, all of the log_msg() calls and the CPU-trace hooks live inside the new selective / tile-based partial decoding path (oapvd_decode_selective_multi_mips) — none of the existing encode/decode paths emit anything. So these callbacks really only have meaning in the context of that new decoding API.

Because of that, I'd prefer to review them together with the tile-based partial decoding discussion rather than as a standalone feature. And if it turns out they aren't strictly necessary for that feature, I'd like to drop them to keep the surface minimal. We can absolutely revisit the individual logs there, as you suggested.

@jsperrier1 , BTW, does it require to use the custom memory allocator for oapvm_xxx functions (metadata related functions)? The memory allocator could be introduced for encoder and decoder, but metadata manager seems to be a little difficult because oapvm_create() function doesn't have the cdesc parameter. The current API needs to be changed if it should be supported.

@kpchoi

kpchoi commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator

Callbacks

Could explain main purposes of the callbacks?

Memory allocator callbacks:

And, memory callback is for replacing malloc() function, for example?

Correct. It is the usually clean method of supporting Unreal engine's custom memory allocators. UE supports hot module reload (live coding) and other complex operations. It is preferable to have the cleanest interface possible.
log and cpu trace callbacks:
The approach we are proposing for TMV is to have a more complex "request-based" decoding API. We can elaborate that in parallel in the decoding api discussion, but for the purpose of this answer, the idea is that the complexity of the AU parsing and tile decoding worker thread management of TMV implementation is pushed in oapv itself, to facilitate implementation of a TMV-like player outside of Unreal. Because the decoding api is now request-based and more complex, profiling and error management is also more complex. In UE, the general approach in that case is to expose log and cpu trace to ease trouble shooting for errors or performance issues. We often ask users to run the problematic levels of their game with verbose logging and using Unreal Insights to capture traces, then send us the logs and traces so we can figure out what is wrong.
So, those callbacks are not critical to this implementation (and we can debate the pertinence of each log individually), but have been proven to provide great help for long term user support and maintenance.

@jsperrier1 — this really helps clarify the intent. Let me split my response into the two callback groups, since I think they need to be handled quite differently.

Memory callbacks

I fully understand the motivation (clean integration with Unreal's custom allocators, live coding, etc.), and I agree it's a reasonable thing to want.
My main concern is security. Since liboapv parses untrusted bitstreams, allowing the allocator to be replaced can easily become a serious vulnerability if it isn't done very carefully. Given the fuzz-hardening work we've been doing recently (#224, #226), I want to be cautious here.
I don't want to just say no, though — I'd like to think more about whether there's a safe way to achieve the same goal, and come back to you with a proposal. Let me take some time on this.

Logging and CPU-trace callbacks

Looking at the code, all of the log_msg() calls and the CPU-trace hooks live inside the new selective / tile-based partial decoding path (oapvd_decode_selective_multi_mips) — none of the existing encode/decode paths emit anything. So these callbacks really only have meaning in the context of that new decoding API.
Because of that, I'd prefer to review them together with the tile-based partial decoding discussion rather than as a standalone feature. And if it turns out they aren't strictly necessary for that feature, I'd like to drop them to keep the surface minimal. We can absolutely revisit the individual logs there, as you suggested.

@jsperrier1 , BTW, does it require to use the custom memory allocator for oapvm_xxx functions (metadata related functions)? The memory allocator could be introduced for encoder and decoder, but metadata manager seems to be a little difficult because oapvm_create() function doesn't have the cdesc parameter. The current API needs to be changed if it should be supported.

Again, Hi @jsperrier1,

The memory allocator will not use a process-global callback approach. A global, mutable set of function pointers
invoked on every allocation is a control-flow-hijack target in a library that parses untrusted bitstreams — the
same reason glibc removed __malloc_hook/__free_hook — and adding one is undesirable, especially after the recent
fuzz-hardening (#224, #226).

Instead, a per-instance allocator has been implemented and pushed here:
https://github.com/AcademySoftwareFoundation/openapv/tree/add_memory_interface_functions
You could refer this link how it works.
https://github.com/AcademySoftwareFoundation/openapv/tree/add_memory_interface_functions#custom-memory-allocator

The allocator interface is supplied by the caller through the codec descriptor (oapve_cdesc_t / oapvd_cdesc_t)
and copied into the codec instance, so no global state is involved. It carries an opaque udata that is passed
back to every callback (so a host allocator such as Unreal's can be wired in without globals), requires all four
callbacks plus a magic value (partial or invalid interfaces are rejected), and falls back to the standard C
library when unset. All internal encoder/decoder allocations are routed through it.

This has not been extended to the metadata API (oapvm_*). oapvm_create() has no descriptor to carry an allocator,
and changing its signature would break existing users (e.g. FFmpeg's liboapvenc). Metadata allocations are
small, so keeping them on the standard C library should be sufficient; a backward-compatible variant can be added
later if a concrete need arises.

Anyway, let me know your opinons.

@jsperrier1

Copy link
Copy Markdown
Author

Memory allocator callbacks

Instead, a per-instance allocator has been implemented and pushed here: https://github.com/AcademySoftwareFoundation/openapv/tree/add_memory_interface_functions

fine by me.

This has not been extended to the metadata API (oapvm_*). oapvm_create() [...]

I am little on the fence for that one. Metadata allocations are byte-stream driven, so are attacker-influenced and not necessarily small.

For my opinion, given that having all allocations instrumented is used in integration validation and troubleshooting. I would prefer not to leave stones unturned and have all byte-stream driven allocations instrumented. We have had memory corruptions to debug during integration (happens regularly). This is why I added the global hook to make sure I catch everything.

Knowing meta data allocations are not instrumented, it becomes a special case that needs to be tracked and managed. For instance, I would have to make sure it is somehow disabled to make sure corruption doesn't come from that. Ideally, we should make sure that a NILL mid means to skip meta data parsing rather than failing the whole decode.

For oapvd_decode_selective_multi_mips, we are currently not parsing the meta data so it is fine for TMV (for now, although, it probably should). But we also have the Electra/Protron player that has a vanilla OpenApv decoder which would parse meta data.

My vote would be to extend memory hooks to metadata api to avoid making it a special case.

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.

3 participants