Skip to content

Add opt-in minimum frame duration for animated images - #988

Open
LOCKEDFILE wants to merge 5 commits into
Baseflow:mainfrom
LOCKEDFILE:minimumFrameDuration
Open

LOCKEDFILE wants to merge 5 commits into
Baseflow:mainfrom
LOCKEDFILE:minimumFrameDuration

Conversation

@LOCKEDFILE

@LOCKEDFILE LOCKEDFILE commented Nov 15, 2024 •

Copy link
Copy Markdown

✨ What kind of change does this PR introduce? (Bug fix, feature, docs update...)

Adds opt-in animated-image timing through Duration? minFrameDuration on CachedNetworkImage and CachedNetworkImageProvider. This additive API is included in version 4.1.0.

⤵️ What is the current behavior?

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 null preserves existing timing. When configured, frames with codec durations of 10ms or less use the greater of their original duration and minFrameDuration. 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:

CachedNetworkImage(
  imageUrl: animationUrl,
  minFrameDuration: const Duration(milliseconds: 100),
)

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 analyze and 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

  • All projects build
  • Follows style guide lines (code style guide)
  • Relevant documentation was updated
  • Rebased onto current main

The current PR base is already included through a merge from main.

@LOCKEDFILE LOCKEDFILE changed the title Set minimum frame duration (-> 100ms) GIF image set minimum frame duration (-> 100ms) Nov 15, 2024
@rickdijk

Copy link
Copy Markdown
Collaborator

This repository now uses main as its only branch, so I have retargeted this PR from develop to main. Nothing about the review changes. If GitHub now shows a conflict, a rebase onto main will clear it, and we will follow up on that separately.

@rickdijk
rickdijk changed the base branch from develop to main September 23, 2026 17:50

@rickdijk rickdijk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. 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? minFrameDuration on CachedNetworkImageProvider / CachedNetworkImage (default null, so nothing changes) passed to MultiImageStreamCompleter?
  2. This applies to every animated format (WebP, APNG), not only GIF. Please mention that in the docs for the new parameter.
  3. Please pull 10ms and 100ms out into named constants with a comment pointing at the browser convention, and clamp _nextFrame!.duration before assigning it rather than reassigning _frameDuration.
  4. Please add a test in the existing completer tests using a fake codec with 0ms, 10ms and 11ms frames, to pin the boundary.
  5. 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.

@LOCKEDFILE
LOCKEDFILE force-pushed the minimumFrameDuration branch from 3ed6019 to feb5b72 Compare October 27, 2026 09:11
@LOCKEDFILE

Copy link
Copy Markdown
Author

Thanks for the feedback. I’ve updated the PR:

  • Added an opt-in Duration? minFrameDuration to CachedNetworkImageProvider and CachedNetworkImage, defaulting to null.
  • Documented that the setting applies to animated formats such as WebP and APNG.
  • Added named constants for the 10 ms threshold and 100 ms browser minimum, and applied the clamp before assigning the frame duration.
  • Added a fake-codec test covering 0 ms, 10 ms, and 11 ms frames.
  • Bumped the package version to 4.1.0, added a dated changelog entry, and rebased the branch onto main.

@rickdijk rickdijk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. The new test fails. flutter test reports +51 -1, and the failure is minimum frame duration clamps 0ms and 10ms, not 11ms. Details are inline.

  2. dart format --set-exit-if-changed fails on cached_network_image_provider.dart and multi_image_stream_completer.dart, so the CI format job will be red. Run dart format on those two files only (not dart format .).

  3. The setting doesn't do what its name says. I measured how long frames are held with this branch:

    minFrameDuration 0, 5 and 10ms frames 11ms frame 50ms frame
    null unchanged 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.

@LOCKEDFILE

Copy link
Copy Markdown
Author

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.

@rickdijk

rickdijk commented Nov 2, 2026

Copy link
Copy Markdown
Collaborator

Please resolve the conflicts @LOCKEDFILE

@LOCKEDFILE
LOCKEDFILE requested a review from rickdijk November 2, 2026 08:34
@LOCKEDFILE

Copy link
Copy Markdown
Author

Resolved. Thank you!

@rickdijk rickdijk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:

  1. dart format --set-exit-if-changed fails on test/image_widget_test.dart, so the CI format job will be red. Run dart format test/image_widget_test.dart (not dart format .).
  2. A minFrameDuration of 10ms or less makes frames shorter. Details are inline.
  3. The version should be a minor bump, 4.1.0, since this adds public API. Please update the CHANGELOG heading to match.
  4. 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.md and cached_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.

Comment thread cached_network_image/pubspec.yaml Resolved
Comment thread cached_network_image/CHANGELOG.md Resolved
@LOCKEDFILE LOCKEDFILE changed the title GIF image set minimum frame duration (-> 100ms) Add opt-in minimum frame duration for animated images Nov 6, 2026
@LOCKEDFILE
LOCKEDFILE requested a review from rickdijk November 6, 2026 03:39
maxHeight == other.maxHeight &&
maxWidth == other.maxWidth;
maxWidth == other.maxWidth &&
minFrameDuration == other.minFrameDuration;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 =

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)';

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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).

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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', (

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 rickdijk left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 CachedNetworkImageProvider hands minFrameDuration to the MultiImageStreamCompleter it builds, in either loadImage or the deprecated loadBuffer. Dropping minFrameDuration: key.minFrameDuration from 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.

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