Repository navigation
Add opt-in minimum frame duration for animated images - #988
LOCKEDFILE wants to merge 5 commits into
Conversation
|
This repository now uses |
rickdijk
left a comment
There was a problem hiding this comment.
Thanks for this! Clamping near-zero frame delays to 100ms matches what browsers do, and it would fix GIFs that play too fast here on mobile. A few things before we can merge:
- The description promises a configurable option, but the clamp is hardcoded and always on. That's a silent behavior change for anyone who uses fast frames on purpose. Could you make it opt-in instead, e.g. a
Duration? minFrameDurationonCachedNetworkImageProvider/CachedNetworkImage(defaultnull, so nothing changes) passed toMultiImageStreamCompleter? - This applies to every animated format (WebP, APNG), not only GIF. Please mention that in the docs for the new parameter.
- Please pull 10ms and 100ms out into named constants with a comment pointing at the browser convention, and clamp
_nextFrame!.durationbefore assigning it rather than reassigning_frameDuration. - Please add a test in the existing completer tests using a fake codec with 0ms, 10ms and 11ms frames, to pin the boundary.
- Please rebase onto
main, bump the version (minor, since this adds API) and add a dated CHANGELOG entry.
Note that Image.network doesn't clamp, so if you'd like this to be the default everywhere, it's worth raising with flutter/flutter too.
3ed6019 to
feb5b72
Compare
|
Thanks for the feedback. I’ve updated the PR:
|
rickdijk
left a comment
There was a problem hiding this comment.
Thanks for the quick turnaround. Opt-in with a null default, the docs mentioning WebP and APNG, the named constants, the version bump and the changelog entry all look good. CI hasn't run on this PR yet, so I ran it locally on feb5b72, and three things still need fixing:
-
The new test fails.
flutter testreports+51 -1, and the failure isminimum frame duration clamps 0ms and 10ms, not 11ms. Details are inline. -
dart format --set-exit-if-changedfails oncached_network_image_provider.dartandmulti_image_stream_completer.dart, so the CI format job will be red. Rundart formaton those two files only (notdart format .). -
The setting doesn't do what its name says. I measured how long frames are held with this branch:
minFrameDuration0, 5 and 10ms frames 11ms frame 50ms frame nullunchanged 11 50 20ms 100 11 50 100ms 100 11 50 150ms 150 11 50 Any value below 100ms behaves exactly like 100ms, and frames longer than 10ms never get raised to the minimum. More on this inline.
flutter analyze is clean. Nothing breaks for existing users: with the default null, frame timing and provider equality are unchanged.
|
Addressed the frame timing and formatting feedback. minFrameDuration now applies the provided value only to frames of 10ms or less; longer frames retain their codec duration. Updated the timing, provider equality, and widget propagation tests. flutter analyze and flutter test pass. |
|
Please resolve the conflicts @LOCKEDFILE |
|
Resolved. Thank you! |
rickdijk
left a comment
There was a problem hiding this comment.
Thanks, this is close. The rule is settled, the clone/idle fix and the hashCode formatting are in, and there are now tests for provider equality and for the widget passing the value through. I resolved those threads. I ran everything locally on e4da85d: flutter test passes (78 tests) and flutter analyze is clean. CI still hasn't run on this PR.
Still needed before merge:
dart format --set-exit-if-changedfails ontest/image_widget_test.dart, so the CI format job will be red. Rundart format test/image_widget_test.dart(notdart format .).- A
minFrameDurationof 10ms or less makes frames shorter. Details are inline. - The version should be a minor bump, 4.1.0, since this adds public API. Please update the CHANGELOG heading to match.
- The docs don't yet say "pass 100ms to match browsers", which was the point of keeping the 10ms threshold. Please add that to the three doc comments, and mention the option in both
README.mdandcached_network_image/README.md. They are separate files.
The PR description still describes the original hardcoded 100ms clamp. Could you update it to describe the opt-in setting?
Smaller points are inline.
| maxHeight == other.maxHeight && | ||
| maxWidth == other.maxWidth; | ||
| maxWidth == other.maxWidth && | ||
| minFrameDuration == other.minFrameDuration; |
There was a problem hiding this comment.
Adding minFrameDuration to equality means CachedNetworkImage.evictFromCache no longer reaches these entries: it evicts CachedNetworkImageProvider(url, scale: scale), which is never == to a provider with a minFrameDuration. So for a widget with minFrameDuration: Duration(milliseconds: 100), evictFromCache(url) removes the file on disk but leaves the decoded animation in the ImageCache, and the stale image keeps showing.
maxWidth/maxHeight already have this gap, so a full fix is arguably separate, but this PR widens it. Either give evictFromCache a minFrameDuration parameter (and document that callers must pass the same value), or mention the limitation in the doc comment.
| final frame = _nextFrame!; | ||
| final codec = _codec!; | ||
| _nextFrame = null; | ||
| _frameDuration = |
There was a problem hiding this comment.
This is a max restricted to short frames, spelled as three conditions with two !s. A local reads more directly and matches the doc comment ("the greater of"):
final minimum = minFrameDuration;
_frameDuration =
minimum != null &&
frame.duration <= _browserZeroDelayThreshold &&
frame.duration < minimum
? minimum
: frame.duration;| ); | ||
|
|
||
| @override | ||
| String toString() => 'CachedNetworkImageProvider("$url", scale: $scale)'; |
There was a problem hiding this comment.
Nit: two providers that differ only in minFrameDuration are now separate ImageCache entries, but print identically here, which is what the cache diagnostics and the informationCollector show. Consider adding minFrameDuration to toString when it is non-null.
| } | ||
| } | ||
|
|
||
| /// Minimum duration for short frames in animated images (GIF, WebP and APNG). |
There was a problem hiding this comment.
Nit: this doc comment is copied verbatim into the widget, the provider and the completer, and the rule has already changed twice in review. A /// {@template cached_network_image.minFrameDuration} here with /// {@macro cached_network_image.minFrameDuration} on the other two keeps them in step.
| expect(tester.takeException(), 'failure message'); | ||
| }); | ||
|
|
||
| testWidgets('minimum frame duration applies only through 10ms', ( |
There was a problem hiding this comment.
This test covers the same 20ms case as the table-driven test below, so every rule change has to be made twice. Unlike the table test it also never disposes the emitted images (images.add(info.image)) or closes codecStream. Consider dropping it in favour of the table, or add the same cleanup the table test has (info.dispose() after checking isCloneOf, and addTearDown(codecStream.close)).
rickdijk
left a comment
There was a problem hiding this comment.
Re-checked on 46ab187. dart format --set-exit-if-changed on the changed files, flutter analyze and flutter test (86 tests) all pass locally, and the branch contains current main. All earlier review threads are addressed and resolved.
| Criterion | Verdict | Why |
|---|---|---|
| Real value | pass | Fixes GIFs with 0–10ms delays playing far faster than in browsers, opt-in |
| Fixes it | pass | Short frames are held for max(codec, minFrameDuration); the web loader uses the same completer, so it applies there too |
| Breaks something | pass | Additive, null default keeps timing and equality unchanged; minor bump to 4.1.0 matches. See the inline note on evictFromCache |
| Tests | concern | Timing, equality and widget pass-through are covered; provider → completer is not |
| Guidelines | concern | One package, version, changelog, both READMEs and the template are in order; CI has not run |
Recommendation: close to mergeable. The inline points are small; the evictFromCache one is the only behavioral one.
- No test checks that
CachedNetworkImageProviderhandsminFrameDurationto theMultiImageStreamCompleterit builds, in eitherloadImageor the deprecatedloadBuffer. DroppingminFrameDuration: key.minFrameDurationfrom either would still pass every test. A provider test that resolves an animated image with a short-frame codec and checks the hold, or that inspects the completer, would pin it. - No CI workflow has run on this PR, so the six example builds (the unchecked "All projects build" item) are unverified. A maintainer needs to approve the workflow run before merge.
✨ What kind of change does this PR introduce? (Bug fix, feature, docs update...)
Adds opt-in animated-image timing through
Duration? minFrameDurationonCachedNetworkImageandCachedNetworkImageProvider. This additive API is included in version 4.1.0.Animated images use codec-provided frame durations without a configurable adjustment for near-zero delays.
🆕 What is the new behavior (if this is a feature change)?
The default
nullpreserves existing timing. When configured, frames with codec durations of 10ms or less use the greater of their original duration andminFrameDuration. Longer frames retain their original timing; small, zero and negative settings never shorten a frame.Pass 100ms to match the browser convention for short frames:
The setting applies to animated GIF, WebP and APNG images, is passed to
MultiImageStreamCompleter, and participates in provider equality and hashing. The three API comments and both READMEs document the behavior.💥 Does this PR introduce a breaking change?
No
🐛 Recommendations for testing
On Flutter 3.47.8 / Dart 3.13.7,
flutter pub get, formatting checks for the affected Dart files,flutter analyzeand all 86 tests passed. The tests include 40 scheduling combinations covering null, small and negative settings, 100ms timing, and the 10ms threshold. Existing widget propagation and provider equality/hashCode tests also passed.Cross-platform example builds were not run locally; verify them in CI.
📝 Links to relevant issues/docs
🤔 Checklist before submitting
The current PR base is already included through a merge from
main.