flutter_handbreak 1.0.4
flutter_handbreak: ^1.0.4 copied to clipboard
Lightweight Flutter video & image compression inspired by HandBrake — quality-first, hardware-accelerated, any-format fallback. Easy one-liner API with presets & progress.
Changelog #
1.0.22 — 2026-08-31 (image: keep HEIC + lower full-res quality for real savings) #
- Fix iOS full-resolution image compression producing larger files than the
original HEIC photo (
keepOriginalIfSmaller→wasKeptOriginal/0 saved):- Example app full-res image quality
82 → 62so JPEG re-encode at native 12 MP is smaller than the source HEIC (~50% more efficient than JPEG). - iOS pipeline now preserves HEIC when the source is HEIC and
format:auto(public.heicwritten instead of converting HEIC→JPEG, which always inflates the file).
- Example app full-res image quality
1.0.21 — 2026-08-31 (iOS concurrent transfers — final 9.7% stall fix) #
- 🔴 root cause identified: the single-threaded pump deadlocked when
the audio queue filled while video was being pumped first —
AVAssetReaderstops all outputs once any output's bounded queue saturates. - Fix (Apple's official ReaderWriter concurrency pattern):
- Each reader output now drives its own serial queue via
requestMediaDataWhenReady. AVAssetWriterinterleaves tracks itself — no manual ordering needed.- A
DispatchGroupwaits for all transfers; a per-outputrequestMediaDataWhenReadycallback pumps until EOF or stall. - The watchdog is now purely time-based (30 s idle watchdog, 90 s finalize cap) and cancels both writer and reader on stall.
- Early reader failure detection inside the writer callback avoids lost callbacks.
- Each reader output now drives its own serial queue via
- The common path remains decode→encode with rotation as metadata; composition path still handles resize/fps correctly.
- Swift typecheck + 104/104 Dart tests green.
1.0.20 — 2026-08-31 (iOS interleaved pump — 20% freeze fix) #
- 🔴 root-cause fix for the 20% freeze: pumping video-to-completion first
then audio lets the reader's unconsumed AUDIO output queue fill up —
AVAssetReader stops delivering to ALL outputs once one output's bounded
internal queue is full, so
copyNextSampleBuffer()on video blocks mid-stream (~20% for short clips) and progress freezes. This is the classic multi-output AVAssetReader deadlock. - Fix (HandBrake sync.c interleaver): video and audio outputs are now drained CONTINUOUSLY in one loop — one video sample, then one audio sample per iteration, both marked finished at EOF. Neither queue can fill, video PTS advances steadily to 100%, and the writer receives interleaved samples exactly as it expects.
- Verified end-to-end: common path (decode→encode, rotation as metadata), composition path (resize/fps), audio passthrough/transcode/remove, plus the existing fail-fast watchdog. Swift typecheck + 104/104 Dart tests green.
1.0.19 — 2026-08-31 (iOS: decode-to-raw before encode — crash fix) #
- 🔴 crash fix: the no-renderer path fed COMPRESSED samples from the
source track into the encoding writer input → NSInvalidArgumentException
("Input buffer must be in an uncompressed format when outputSettings is
not nil"). The reader now decodes to raw NV12
(
kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange) — the classic decode → encode pipeline; the writer encodes from uncompressed frames with the plan's explicit bitrate. Audio passthrough stays compressed (writer passthrough input), audio transcode stays PCM → AAC. - Swift typecheck + 104/104 Dart tests green.
1.0.18 — 2026-08-31 (iOS: no-renderer common path — 20% stall eliminated) #
- 🔴 stall fix: encodes of rotated videos froze at ~20%. The common path
(rotation + "keep original resolution") went through
AVAssetReaderVideoCompositionOutput— the pixel renderer — which is a known AVFoundation stall point (rotation + renderSize + reader hangs). - Fix (HandBrake-accurate, mirrors the Android storage-dims approach):
- rotation is now carried as track metadata
(
AVAssetWriterInput.transform) — exactly like the source container and Android's orientation hint; players rotate for display. NO renderer. - the composition/renderer is used ONLY for real pixel work (resize or fps-cap).
- the reader/writer now use SOURCE tracks directly (compressed samples in, explicit bitrate encode out — HandBrake's param-pass philosophy).
- iOS probe now emits
rawWidth/rawHeight(storage dims) so the Dart plan sizes the encoder at storage dimensions (parity with Android);srcW/srcHin the pipeline are storage dims.
- rotation is now carried as track metadata
(
- Swift typecheck + 104/104 Dart tests green.
1.0.17 — 2026-08-31 (iOS encode stall hardening — fail fast, never hang) #
- 🔴 hang fix: encodes could freeze mid-way (e.g. progress stuck at 20%):
the pump's backpressure loop spun up to 150 s, a blocked
copyNextSampleBuffer()was never unblocked (reader not cancelled), and the watchdog had a false-stall (video track finished → PTS frozen → it killed the write during audio muxing). - Fixes:
- pump checks writer/reader status every spin — a failed codec surfaces
as an immediate
ENCODING_ERRORwith the underlying message; spin budget reduced to 30 s. - watchdog is two-phase: video phase = no PTS progress for 30 s ⇒ stall;
audio/finalize phase = writing must complete within 90 s ⇒ stall. On
stall it cancels BOTH the writer and the reader so a blocked
copyNextSampleBuffer()unblocks and the pump exits fast. - final safety valve: 120 s cap on the finish-wait loop.
- stalled outcomes report
TIMEOUTwith a clear message.
- pump checks writer/reader status every spin — a failed codec surfaces
as an immediate
- Swift typecheck + 104/104 Dart tests green.
1.0.16 — 2026-08-31 (iOS crash: composition required for rotated sources) #
- 🔴 crash fix: rotated videos (no resize/fps-cap) crashed with
AVAssetReaderVideoCompositionOutput.videoComposition needs to be set(NSInternalInconsistencyException) — the video composition was only built for resize/fps-cap, but the reader REQUIRES it whenever the track has a rotation transform. The composition is now built for rotation too (layer instruction carries the transform; renderSize = plan dims). - Swift typecheck + 104/104 Dart tests green.
1.0.15 — 2026-08-31 (iOS: explicit reader/writer encode — no more larger files) #
- 🔴 iOS never compressed:
AVAssetExportSessionpresets ignore the plan's bitrate/quality entirely — Apple re-encodes at its fixed high-quality ladders, so output was always LARGER than the source (0% saved). - Fix (HandBrake-style, mirrors libx264 param pass): replaced the export
session with an
AVAssetReader+AVAssetWriterpipeline that sets explicit compression properties —AVVideoAverageBitRateKeyfrom the plan (ABR uses the plan bitrate, e.g. the example's ~70% source-bitrate target; CQ maps CRF → bitrate via the same bpp table as Android), 2s keyframe interval, H.264 High / HEVC Main AutoLevel profiles. - Rotation/resize/fps-cap still rendered through the video composition; audio follows the plan (passthrough copy or AAC transcode).
- Progress via video PTS; watchdog + cancellation retained (writer-based).
- Swift typecheck + 104/104 Dart tests green.
1.0.14 — 2026-08-31 (same-resolution compression must actually save space) #
- Example "saved 0" fix: with "Keep original resolution" ON, re-encoding
at full resolution with preset quality produced larger outputs for
already-efficient sources (video wrote the bigger file, image kept the
original → 0% saved). Same-resolution video now targets ~70% of the
source bitrate (HandBrake target-size philosophy — smaller by
construction, clamped 64k–40M) and sets
keepOriginalIfSmalleras a final guard so a larger file never replaces the original. - Honest status messages: kept-original and zero-savings outcomes now explain WHY ("source already compressed / HEIC / efficient"), instead of "0.0% saved".
VideoCompressionOptions.copyWithnow exposeskeepOriginalIfSmaller.- Tests green (104/104), analyze clean.
1.0.13 — 2026-08-31 (rotation vs storage dimensions — distorted-output fix) #
- 🔴 distorted-output fix: the probe reports rotation-corrected dimensions (e.g. 1080×1920 for a rotated 1920×1080 file), but the DECODER always outputs STORAGE-dimension frames (1920×1080). The encode pipeline was sized from the display dims — the encoder expected portrait frames while receiving landscape → squashed/stretched, wrong-ratio, unusable output (affects every tier, surface and ByteBuffer alike).
- Fix (HandBrake-accurate): the encoder is now sized from STORAGE
(decoded) dimensions —
rawWidth/rawHeightadded to the probe andVideoStreamInfo(displaywidth/heightstay rotation-corrected for app logic). Rotation is carried by the container orientation hint, exactly like the source file; players rotate for display. The CPU-path scaler now maps decoded → encoded dims 1:1 — no aspect distortion at any tier. - Regression test: rotated source encodes at storage dims, not display dims.
- Android JVM tests + 103/103 Dart tests green.
1.0.12 — 2026-08-31 (Android input-layout fix — corrupt video output) #
- 🔴 corrupt-output fix: the ByteBuffer encode path decided the input
layout by comparing the negotiated color format to planar (0x13) only.
Encoders that negotiate
COLOR_FormatYUV420Flexible(notably thec2.android.avc.encodersoftware component used by the fallback tier) follow the Android byte-buffer convention of PLANAR I420 — we fed NV12, producing garbled/unusable video. Detection now follows the convention (HandBrake/libx264 also feeds planar): anything that is not explicitlyCOLOR_FormatYUV420SemiPlanaris fed as I420; the negotiated format is logged for diagnostics. - Research-backed: Exynos encoders reject CQ (already handled by tiered VBR fallback); vendor VP9/AV1 encoders are documented as distorted/broken on some SoCs.
- Android JVM tests green.
1.0.11 — 2026-08-31 (preserve-resolution compression) #
- New API:
VideoCompressionOptions.preserveResolution— HandBrake-style "same as source": compresses at the exact source width/height (rotation- corrected), ignoring maxWidth/maxHeight/targetWidth/targetHeight/scale. Size reduction comes from bitrate/CQ alone, never from resizing. - Example app: "Keep original resolution" toggle (default ON) — video
uses
preserveResolution; image compression drops the 2048 px cap so photos keep their native dimensions (safe: decode guard allows up to 100 MP without caps). - Tests:
preserveResolutionkeeps 4K dimensions despite 720p caps, and portrait rotation-corrected sources stay intact. 100/100 Dart tests green.
1.0.10 — 2026-08-31 (Android CPU-path frame scaling — BufferOverflow fix) #
- 🔴 crash fix: the ByteBuffer encode path fed full-source-resolution
frames into an encoder configured at the resized dimensions
(
BufferOverflowException @ DirectByteBufferinfeedEncoderNv12— the encoder's input buffers are sized for the target resolution). Surface input scales internally; the CPU path must scale explicitly. Added a tightly packed YUV420 nearest-neighbor scaler (HandBrake swscale stand-in) — decoded frames are now scaled to the encoder's exact size before feeding (NV12 or I420 output matching the negotiated layout). Now every tier of the encode pipeline works for any source resolution. - Android JVM tests green, AAR builds (zero
.so).
1.0.9 — 2026-08-31 (Android 3-tier encode fallback + self-describing errors) #
- Tiered encode pipeline (HandBrake-style "always produce a result"):
- Tier 0: CQ + Surface input (vendor/HW encoder preferred).
- Tier 1: VBR + ByteBuffer YUV — no CQ/KEY_QUALITY, no input surface.
- Tier 2: VBR + ByteBuffer + forced software encoder/decoder
(
c2.android/OMX.google) — always present, cannot be broken by vendor codec bugs; AV1 falls back to H.264 software when no AV1 software encoder exists. - Retries on ANY codec-side failure (CodecException, IllegalStateException, NPE, …); deterministic input failures (bad path, no track, truncated media), cancellation, stalls and validation errors never retry.
- Self-describing errors:
Unknown errorreplaced withExceptionClass: message @ File.method:lineacross every native error path — the next failure, if any, is immediately diagnosable. - Android JVM tests green, AAR builds (zero
.so), analyze clean.
1.0.8 — 2026-08-31 (Android YUV conversion crash + negotiated-format fix) #
- 🔴 crash fix:
Yuv.toNv12threwBufferUnderflowExceptionon Exynos devices (HEVC decode → ByteBuffer encode). DecoderImageplanes violate naive assumptions: shared backing buffers with non-zero base position, restrictive limits, vendor-specific interleave order (NV21) and strides. The converter is now fully defensive — base-offset aware, bounds-clamped (never throws on odd geometry), detects NV12/NV21/planar layouts, and down-converts 10-bit (16-bit) chroma to 8-bit. - 🔴 quality fix: the encoder is queried for its negotiated input color
format; when a vendor substitutes planar (e.g.
0x13) for our flexible request, frames are fed as I420 instead of NV12 — previously the layout mismatch produced scrambled chroma. - Verified against device logs: surface encode rejected by
OMX.Exynos.AVC. Encoder(configure -38) → degraded ByteBuffer path now completes. - Android JVM tests green.
1.0.7 — 2026-08-31 (Android encode runtime-crash retry) #
- 🔴 Android: a runtime
MediaCodec.CodecException(e.g.Error 0x80001001buffer-manager error) failed the job outright. The transcoder now retries the full encode once with a maximally-compatible degraded profile — VBR (no CQ, noKEY_QUALITY) and ByteBuffer YUV input (no input surface). This avoids both known device breakers: surface+CQ pipelines and strict c2KEY_QUALITYhandling. The CPU-fallback path also now inherits the retry profile instead of forcing CQ unconditionally. - Android JVM tests green, analyze clean.
1.0.6 — 2026-08-31 (example: picker UX, findable outputs) #
- Image picking: "Pick Image from Camera Roll" button (
image_picker) — previously only videos could be picked, so photos captured on the device could not be compressed. - Unique output files: example outputs are now
<name>_<preset>_handbreak_ out_<timestamp>.mp4in the app Documents dir — repeated compressions (e.g. a different preset) no longer collide with the previous output (OutputCreationException), and every result is preserved for comparison. - Findable results: Documents-dir output persists across runs; iOS exposes
it in the Files app (
UIFileSharingEnabled+LSSupportsOpeningDocuments InPlace). Result card shows the full path, an Open Output button (open_filex— Android intent / iOS QuickLook), a Copy Path button, and a quality summary of the compressed file (resolution, codec, fps, bitrate, size) parsed fromoutputMediaInfo. - 96/96 Dart tests green (incl. example widget smoke test), analyze clean.
1.0.5 — 2026-08-31 (channel map cast fix) #
- 🔴 crash fix:
CompressionResult.fromMapcastm['outputMediaInfo'] as Map<String, dynamic>?threwMap<Object?, Object?> is not a subtype of Map<String, dynamic>— the method channel decodes nested maps asMap<Object?, Object?>, and a strict cast on the runtime type fails. Now converted with the sameMap<String, dynamic>.from(...)pattern used elsewhere; no other strictas Map<String, ...>casts remain in the library. - Regression test:
CompressionResult.fromMap accepts channel-decoded nested mapsreproduces the exact native payload shape. - 95/95 Dart unit tests green, analyze clean.
1.0.4 — 2026-08-31 (real-world input hardening) #
- 🔴 rotation from container metadata:
KEY_ROTATIONis frequently absent from AndroidMediaExtractoroutput (esp. HEVC / MOV); the probe now falls back toMediaMetadataRetriever's rotation (tkhd matrix) so portrait detection & dimension swap work on camera-roll files. - 🔴 truncated-input guard: moov-fronted files parse cleanly even when the
media data is cut short — the probe now compares the last sample PTS
against the declared duration and throws
InvalidInputinstead of silently reporting a full-length file. - 🔴 CQ encode fix: strict c2 codecs reject
BITRATE_MODE_CQconfigs withoutKEY_QUALITY(EINVAL); the transcoder now setsKEY_QUALITYderived from the resolved CRF, and retries with plain VBR if a device refuses CQ outright. - 🔴 iOS progress stream: stream handler no longer captures the sink in a
throwing escape position;
FlutterEventSinkis explicitly@escaping, eliminating a crash on progress events. - 🔴 progress-stream lifecycle: Dart side ignores events/errors after the
controller is closed and
_markTerminalnow keeps jobs in the terminated set (previously removed — stale stream registrations could leak native channel subscriptions). - iOS podspec renamed to
flutter_handbreak.podspec(washandbreak) — must match the package name for CocoaPods integration. - Example upgraded to a standalone app: Android/iOS host projects,
camera-roll picking (
image_picker), self-contained integration lanes with bundled fixtures, and a real widget smoke test (template counter test was broken). tool/verify.sh:readlink -fis GNU-only — FLUTTER_ROOT now resolves viapwd -P, so the Android lane works on macOS.- Verification: analyze clean, 94 Dart unit tests, Swift typecheck, Android
JVM tests green, AAR rebuilt (still zero
.so).
1.0.3 — 2026-08-23 (deep multimedia correctness) #
- 🔴 muxer ordering: audio-transcode track registration counted in
pendingTracks—MediaMuxer.start()now waits for BOTH video and audio track registration (previously a race where the video encoder's format-changed couldstart()before the audio track existed →addTrackafterstart()threw IllegalStateException on transcode jobs). - 🔴 bounded waits: iOS export wait is now watchdog-bounded — 30 s without
session progress cancels the export and fails with
TIMEOUTinstead of hanging forever (done.wait()was unbounded). - 🔴 explicit queue capacity:
JobManagernow uses a structuralThreadPoolExecutor(1, ArrayBlockingQueue(8))— no semaphore, no soft counters; overflow is rejected synchronously (QUEUE_FULL). - 🔴 cancellation semantics:
CANCELLING(requested) is distinct fromCANCELLED(terminal).cancelJobrequests; the worker drains, then the plugin marks the terminal state. iOS mirrors this with acancelRequestedflag +.cancellingstate. - Integration/stress lane:
integration_test/device harness (probe, rotation, A/V sync, keep-original, cancellation storms, queue bounds, decompression-bomb) with fixture manifest — ready to run on hardware, documented as UNVERIFIED until run. - Claims tightened: README image/filter claims match implementation;
install example pinned to
^1.0.2; archive size updated to measured ~102 KB / zero.so; explicit "Verification status" section added. tool/verify.shextended with the iOS Swift typecheck lane.- JVM test suite: 16/16 passing (bounded-queue determinism fixed).
1.0.2 — 2026-08-23 (first real Kotlin compile — critical) #
- The Android plugin now actually compiles. First standalone Gradle build
(unit tests + release AAR) surfaced genuine compile errors that a consumer's
app build would have hit:
Options.containerproperty missing (unresolved reference)Plan.containerFallbackNote/hwFallbackNotereferenced but never parsedMUXER_OUTPUT_THREE_GPPabsent from compileSdk 34 android.jar → version-guarded literal- nullable result values vs
Map<String, Any>→ relaxed toMap<String, Any?> - missing
returnintranscodeterminal statement kotlin.math.absimport missingmainHandler.postBoolean/Unit mismatch in waitForResult
- 16 JVM unit tests now execute (Gradle
testDebugUnitTest): Downmix, ResolutionHelper, JobManager (non-blocking submit, idempotent cancel, bounded queue, queued-job cancellation, state machine). All pass. assembleReleaseverified: 92 KB AAR, classes.jar only, zero native .so binaries (lightweight claim now measured, not assumed).- JVM test expectations corrected (stereo→mono output size, 5ch→stereo frame math, ExecutionException-wrapped task cancellation).
1.0.1 — 2026-08-23 (final hardening pass) #
- P0: unbounded codec feed loops eliminated — NV12/PCM/EOS delivery is now bounded (15 s stall deadline), cancellation-aware, and fails with a dedicated stall error instead of hanging forever on a broken encoder.
- P1: per-lane stall watchdog (audio activity can no longer mask a dead video
lane); Android
disposeJobnow cancels running work (parity with iOS); iOS orientation applied exactly once (track transform zeroed when a videoComposition owns the transform — fixes double-rotation risk on resize/fps-cap);waitForResultmoved to its own executor so probing a new file never blocks behind a running job. - P2: bounded admission queue (max 8 queued,
QUEUE_FULLerror); 100 MP decompression-bomb guard for images (fail safely, instruct caller); overwrite policy enforced on the extension-renamed image output; codec wait timeout 10 s → 1 s (fast cancellation); fractional FPS rounded. - P3:
CompressionTimeoutExceptionadded to the typed error hierarchy (nativeTIMEOUT); docs updated with device-UNVERIFIED items. - Tests: +2 JVM queue tests (bounded admission, queued-job cancellation), +1 Dart error-parity case. 94 Dart + 17 JVM cases.
1.0.0 — 2026-08-23 #
- First stable release (published on pub.dev as flutter_handbreak).
- Documentation restructure: clean README + detailed docs under
doc/(API reference, quality model, platform matrix, architecture, migration roadmap). - Full validation: analyze clean, 94 unit tests green, Swift typecheck clean, pub.dev dry-run 0 warnings.
0.3.1 — 2026-08-23 (audit v3: crash & concurrency hardening) #
- Android: semaphore no longer acquired on the main thread (ANR fix) — queued jobs wait on the worker.
- Android: probe + capabilities moved off the platform thread; engine detach now cancels running jobs.
- Android: audio transcode never drops decoded PCM (retry-until-fed, cancellation-aware).
- Android: negative/non-monotonic PTS normalized before mux (MediaMuxer rejection fix).
- Android: half-created decoder released on configure failure (no leak).
- Android: job-scoped temp files (
output.hbtmp.<jobId>) — no cross-job collision. - Android/iOS: requested HEIC/AVIF that a device cannot encode now falls back to JPEG and reports it (
qualityWarning) instead of silently lying about the codec. - iOS: image decode no longer loads the entire file into memory (URL-based ImageIO source).
- iOS: job task access synchronized (TSAN race fixed); duration validation parity with Android.
- iOS: capabilities probe moved off the main thread.
- Formal job state machine on both natives; state surfaced in progress payloads.
- Dart: probe + capabilities resolved in parallel.
0.3.0 — 2026-08-23 #
- Rename package to
flutter_handbreak(keephandbreak.dartalias for compatibility) - Add lightweight
FlutterHandbreakfacade:compressVideo(path, quality: 80, preset: ...)one-liner - HandBrake-inspired credit banner + funding metadata for pub.dev
- Exclude internal docs (PRODUCTION_REVIEW) from published archive — clean open-source
- Fix facade unused import, example dependency rename
0.2.0 — 2026-08-22 (production hardening) #
Full audit of v0.2 hardening (internal, not shipped).
Architecture #
- EncodePlanResolver: all encode policy (dimensions, fps gate, container fallback, rate control, hardware decision, audio plan, filter ordering) resolved once in pure Dart with 30+ unit tests; natives execute the plan instead of re-deriving heuristics.
Fixed #
- Android: audio was dropped entirely → now passthrough or AAC-LC transcode, PTS-interleaved.
- Android: framerate gate corrupted bitstream (empty-buffer queue) → decoder-output PTS gate, drop-only, deterministic.
- Android: byte-buffer fallback was a silent hang stub → real YUV_420_888→NV12 converter (semi-planar + planar strides) feeding encoder.
- iOS: Swift would not compile (inout-capture handler, missing UIKit import) → clean rewrite across Support/HardwareProbe/MediaProbe/JobManager/pipelines; typechecks on iOS SDK.
- iOS: EXIF orientation computed but ignored → transform-aware thumbnail decode (all 8 cases).
- iOS: fake
usedHardwareAcceleration→ real VTCompressionSession hardware-required probes; unenforceable softwareOnly recorded honestly in result notes. - MKV/WebM requests that mobile muxers cannot write now fall back to MP4 with an explicit
note in
qualityWarninginstead of failing at mux time. - Dart:
ImageCompressor.compresstyped result; platform registration viaensureInitialized()(no private-class sniffing); progress streams self-terminate (no leak). - Hardware-decode capability no longer counts non-video decoders.
Added #
- Container/audio/hardware fallback matrix with surfaced notes (
ResolvedPlan). - Stall watchdog on Android pipeline (fails loudly after 30 s without progress).
- Tests: resolver fallbacks, filter canonical order, validation helpers, error-code parity.
0.1.0 — 2026-08-22 #
- Initial release, Phase 1.
- Dart API: VideoCompressor / ImageCompressor, HandbreakProbe, presets, quality mapper, hardware detection abstraction.
- Android: MediaExtractor probe, MediaCodec encode (H.264), MediaMuxer, Bitmap image path, JobManager with cancellation & temp cleanup, progress via EventChannel.
- iOS: AVFoundation/VideoToolbox pipeline, ImageIO/CoreGraphics image path, JobManager.
- Docs: ARCHITECTURE.md (HandBrake analysis), THIRD_PARTY_LICENSES, example app, benchmark harness.