Skip to content

fix: prevent iOS/macOS thumbnail decoder wedge that froze the editor#175

Merged
hm21 merged 2 commits into
stablefrom
fix/thumbnail-generation-darwin
Jul 9, 2026
Merged

fix: prevent iOS/macOS thumbnail decoder wedge that froze the editor#175
hm21 merged 2 commits into
stablefrom
fix/thumbnail-generation-darwin

Conversation

@hm21

@hm21 hm21 commented Jul 9, 2026

Copy link
Copy Markdown
Owner

Problem

Loading many clips (40+) into the editor could freeze it "step by step". The native iOS/macOS thumbnail generator's getThumbnails completion handler used a single unsynchronized completed counter as both the write index into the results buffer and the completion count.

AVAssetImageGenerator.generateCGImagesAsynchronously(forTimes:) does not guarantee serial or in-order callbacks — on modern iOS (observed on iOS 26.5) they can fire concurrently. That caused:

  1. Wedge/hang — lost completed += 1 updates could miss == totalCount, so continuation.resume never fired. The Dart getThumbnails call has no timeout, so its Future hung forever. The consuming app runs every thumbnail batch through one global serial queue whose release is in a finally, so a single wedged call blocked thumbnail generation for every clip for the rest of the session.
  2. Misalignment — mapping completion order to array slots assigned thumbnails to the wrong timestamps on out-of-order delivery.
  3. Shifted framescompactMap { $0 } dropped failed frames, shifting all later frames.

Fix

  • Derive the result index from each callback's requestedTime (matched to its position in the requested times), never from a running counter.
  • Serialize all shared-state mutation (results buffer + completion count) under a lock, and resume the continuation exactly once — even when frames fail or cancel.
  • Failed/cancelled frames are represented positionally as an empty Data() instead of being compacted out, so result[i] maps index-for-index to the requested timestamps[i].
  • On iOS 16+/macOS 13+, use the ordered AVAssetImageGenerator.images(for:) async sequence; older OSes keep the hardened callback path. Also guards against an empty-times hang.

Public method signature and the onProgress/onComplete/onError contract are unchanged. Applied to both the UIKit and AppKit paths.

Behavior note

Individual frame failures now surface as empty entries via onComplete rather than firing onError per frame (which previously also double-delivered the method-channel result alongside onComplete). onError still covers operation-level failures (e.g. file not found).

Tests

  • RunnerTests (iOS + macOS): author a video with one distinct solid color per second, request thumbnails for many shuffled timestamps, decode each result and assert it maps back to its correct timestamp's color band. Covers the ordered path, the legacy concurrent path directly, and positional failure handling — each guarded against hangs.
  • Integration test (video_thumbnail_test.dart): a large shuffled batch must stay index-aligned to its timestamps (sorted-vs-shuffled byte comparison), and many sequential batches must never wedge.

Verified on macOS

  • Native RunnerTests: all 10 pass (incl. 3 new).
  • flutter test integration_test/video_thumbnail_test.dart -d macos: the new wedge-regression group passes.

macOS 26.5 exercises the ordered path — the same path iOS 26.5 uses in production.

Release

Bumps package to 2.5.1 with a changelog entry.

hm21 added 2 commits July 9, 2026 13:45
getThumbnails used a single unsynchronized 'completed' counter as both the
write index and the completion count. AVAssetImageGenerator's
generateCGImagesAsynchronously(forTimes:) does not guarantee serial or
in-order callbacks (observed concurrent on iOS 26.5), so the counter could
miss == totalCount and never resume the continuation, leaving the Dart
Future hung forever and wedging thumbnail generation for the whole session;
it also mapped frames to the wrong timestamps and compacted out failures.

Frames are now placed by their requested timestamp (matched to the position
in the requested times), all shared state is mutated under a lock, and the
continuation resumes exactly once even on failure/cancel. Failed frames are
returned as an empty, positionally-stable entry instead of being dropped.
iOS 16+/macOS 13+ use the ordered AVAssetImageGenerator.images(for:) async
sequence; older OSes keep the hardened callback path.

Adds RunnerTests (iOS + macOS) that verify timestamp->frame alignment under
shuffled/concurrent delivery and an integration test covering large shuffled
batches and repeated batches with no wedge. Bumps to 2.5.1.
@hm21 hm21 merged commit e292a3f into stable Jul 9, 2026
1 check passed
@hm21 hm21 deleted the fix/thumbnail-generation-darwin branch July 9, 2026 12:10
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.

1 participant