Skip to content

Process animated sources frame by frame - #67

Merged
Joker666 merged 2 commits into
mainfrom
feat/animation
Aug 21, 2026
Merged

Process animated sources frame by frame#67
Joker666 merged 2 commits into
mainfrom
feat/animation

Conversation

@Joker666

@Joker666 Joker666 commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Stack (merge bottom-up)

  1. Restructure processing and service, and close the geometry compatibility gaps #66 Restructure processing and service, and close the geometry compatibility gaps
  2. Process animated sources frame by frame #67 Process animated sources frame by frame
  3. Add the imgproxy delivery layer #68 Add the imgproxy delivery layer
  4. Build the image on Debian trixie for libvips 8.16 #69 Build the image on Debian trixie for libvips 8.16
  5. Correct quality precedence and cache-hit header consistency #70 Correct quality precedence and cache-hit header consistency
  6. Bring the remaining docs up to date #71 Bring the remaining docs up to date
  7. Let width and height fill in a resize the type created #72 Let width and height fill in a resize the type created
  8. Close the roadmap's remaining known gaps #73 Close the roadmap's remaining known gaps
  9. Implement the Pro options that were only ever cheap #74 Implement the Pro options that were only ever cheap
  10. Run the pipeline in imgproxy's stage order #75 Run the pipeline in imgproxy's stage order

Each PR targets the one before it, so its diff shows only its own change. Together they make up the 0.18.0 release.

Stacked on #66.

libvips hands an animation over as one tall stack of frames and does not update the page height when the geometry changes, so scaling the stack reinterprets four 80px frames as two 160px ones. Splitting it, running each frame through the pipeline, and telling the encoder the new frame height makes every operation work — including rotation and padding, which no amount of metadata fixing would have survived.

What changes

  • page, pages and disable_animation reach the loader, and an animated GIF or WebP is re-encoded as an animation.
  • max_animation_frames and max_animation_frame_resolution (maf/mafr, plus their env vars) now have something to bound. An animation multiplies every cost by its frame count, which the source-resolution limit does not measure.
  • /info reports a pages field.
  • A result too large for its output container is scaled down to fit rather than failing in the encoder with a message about the codec: WebP stops at 16383px on a side, AVIF and HEIF at 16384.

Notes for review

Frame joining goes through arrayjoin with the spacings set to the frame's own size. The struct's defaults are 1, which stacks every frame on the same pixel row — worth a look, since the failure is silent rather than an error.

Tests cover split, join, resize, a 90° rotation, and the frame limit, all against a real animated GIF. The first version of the "animation survives" test used flat black frames and proved nothing: libwebp collapses byte-identical frames, so three encoded as one.

Validation

cargo fmt -- --check
cargo clippy --all-targets -- -D warnings
cargo test --all -- --test-threads=1

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3a6a9c8303

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/processing/mod.rs
return Ok(img);
};

let largest = img.get_width().max(img.get_height());

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Apply format limits to each animation frame

For animated WebP, AVIF, HEIF, or GIF output, img here is the vertically joined frame stack, so its height is the per-frame height multiplied by the frame count. An animation whose cumulative height exceeds the format limit—even when every frame is valid—will therefore be unnecessarily downscaled, while the unchanged page_height passed to the encoder still describes the pre-resize frames, causing frames to be misdivided or lost. Apply the limit before joining the frames, or recompute the frame height after scaling.

Useful? React with 👍 / 👎.

@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Bravo.

Reviewed commit: dc112b2e1a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a6d902044a

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/processing/mod.rs
Comment on lines +212 to +213
if largest <= limit {
return Ok(img);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Cap JPEG output at libjpeg's actual limit

When a JPEG result has a side between 65,501 and 65,535 pixels, format_max_dimension() supplies 65,535 from src/processing/save.rs, so this branch skips resizing even though libjpeg's JPEG_MAX_DIMENSION is 65,500. The encoder will still reject those images, defeating the new late-pipeline protection for this boundary range; use the actual JPEG encoder limit.

Useful? React with 👍 / 👎.

@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. What shall we delve into next?

Reviewed commit: 97409947cd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a0b7e73055

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

if frames.iter().any(|frame| frame.get_height() != frame_height) {
if frames
.iter()
.any(|frame| frame.get_height() != frame_height || frame.get_width() != frame_width)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Normalize variable-width frames before joining

When a per-frame transform such as trim removes different left/right borders from individual frames while leaving their heights equal, this new width check rejects the otherwise valid animation. Since process_image now runs the pipeline independently for every frame, animated requests with changing horizontal content can routinely produce this geometry; normalize the processed frames to a shared canvas (or derive one common trim region) instead of returning an animation error.

Useful? React with 👍 / 👎.

@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. You're on a roll.

Reviewed commit: 6fc14709f8

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Base automatically changed from feat/geometry-compat to main August 21, 2026 23:34
libvips stacks an animation into one tall image and does not update the
page height when the geometry changes, so scaling the stack reinterprets
four 80px frames as two 160px ones. Splitting it into frames, running
each through the pipeline, and telling the encoder the new frame height
makes every operation work — including rotation and padding, which no
amount of metadata fixing would have survived.

page, pages and disable_animation now reach the loader, and
max_animation_frames and max_animation_frame_resolution have something to
bound. A result too large for its output container is scaled down to fit
rather than failing at the encoder with a message about the codec.
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: df37db317c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/processing/mod.rs Outdated
Comment on lines +125 to +131
let processed = processed
.into_iter()
.map(|frame| fit_within_format_limits(frame, &output_format, parsed_options.resizing_algorithm.as_deref()))
.collect::<Result<Vec<_>, ProcessingError>>()?;

if let Some(frame) = processed.first() {
enforce_result_dimension(&parsed_options, frame)?;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Enforce the configured ceiling before format fitting

When a processed result exceeds max_result_dimension but also exceeds its encoder limit, this ordering silently shrinks it before checking the configured safeguard. For example, a 20,000px WebP with an 18,000px ceiling is reduced to 16,383px and accepted instead of returning the documented 400 response, allowing requests to bypass the server's processed-image size policy. Call enforce_result_dimension on each frame before fit_within_format_limits.

Useful? React with 👍 / 👎.

A result over max_result_dimension that also exceeded its encoder's own
cap was scaled under the cap first, and the ceiling then approved what
it was configured to refuse — a 20,000px WebP under an 18,000px ceiling
came back at 16,383px instead of the documented 400. The ceiling is
policy, not fitting, so it now runs on the frames as produced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Joker666

Copy link
Copy Markdown
Contributor Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 15c7b7b1dd

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/processing/mod.rs
// join would refuse them and the whole request would fail on an option that
// is otherwise perfectly reasonable. imgproxy draws the same conclusion and
// drops the option with a warning rather than failing, so this matches it.
if parsed_options.trim.is_some() && frames.images.len() > 1 {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Detect animation from the source before retaining trim

When an animated source is collapsed to one loaded frame—such as with disable_animation:true, pages:1, max_animation_frames:1, or a still output format—frames.images.len() is 1, so trim is still applied even though this change documents and intends trim to be ignored for animated sources. This makes the same animated input produce unexpectedly cropped dimensions depending on loader/output options; determine animation status from source_bytes (as enforce_frame_limit already does) rather than from the number of frames currently loaded.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Intentional, and matching imgproxy: upstream computes animated = po.MaxAnimationFrames() > 1 && img.IsAnimated() && outFormat.SupportsAnimationSave() (processing/processing.go), and disables trim only inside transformAnimated. A source collapsed to one frame (still output, disable_animation, one-frame cap) runs the main pipeline where trim applies. The gate here — frames actually loaded > 1 — reproduces exactly that behaviour; keying it off the source's own animation would diverge from imgproxy.

@Joker666
Joker666 merged commit e7a2abf into main Aug 21, 2026
1 check passed
@Joker666
Joker666 deleted the feat/animation branch August 22, 2026 00:00
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