Skip to content

fix(core): give a cloned image its own pipeline and meta data - #125

Open
nlemoine wants to merge 4 commits into
Intervention:developfrom
nlemoine:fix/core-clone
Open

fix(core): give a cloned image its own pipeline and meta data#125
nlemoine wants to merge 4 commits into
Intervention:developfrom
nlemoine:fix/core-clone

Conversation

@nlemoine

@nlemoine nlemoine commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Intervention\Image\Image::__clone() clones the driver, the core and the exif. The vips Core declares no __clone() of its own, so the shallow copy leaves both cores holding the same Jcupitt\Vips\Image and the same meta collection.

The vips image is immutable, so sharing it is fine on its own. The problem is the pipeline behind a decoded image: the decoders load with Access::SEQUENTIAL, so it can be walked once. Encode the clone, then the original, and the second one fails.

$manager = new ImageManager(new Driver());
$image = $manager->decodeBinary($jpegBytes);
$clone = clone $image;
$clone->encodeUsingFileExtension('jpg'); // fine
$image->encodeUsingFileExtension('jpg'); // EncoderException, "VipsJpeg: out of order read"

Same with decodePath(), PNG and grayscale sources. A resize on either image hides it, the resize modifiers reload from the stash instead of walking the shared pipeline.

Core::__clone() clones the meta collection, like the GD and Imagick cores do, and while the stash is in place it reopens the source for the clone, a fresh lazy load at no raster cost. For that to hold, everything that replaces the core's image now goes through setNative() so the stash is cleared, grayscale sources are stashed too, and the conversion the decoder applies (CanNormalizeSource, replacing CanNormalizeBands) is replayed wherever a stash is reopened, the clone and the thumbnail fast path of the resize modifiers. Without a stash the vips image stays shared, same as before.

Not covered: encoding the same decoded image twice fails the same way with no clone involved, that is the single pass of a sequential load, see #56. Same for a clone made after a modifier that cleared the stash. Core::ensureInMemory() before cloning renders it once for both. Maybe worth a README note, I left it alone.

Image::__clone() clones the core, but the vips Core declared no
__clone() of its own. The shallow copy left both cores holding the
same meta collection and the same vips image. The image itself is
immutable, but the decoders open the source for a single sequential
pass, so only one of the two images could be encoded, the other failed
with an out of order read.

The clone now gets its own meta collection, as the GD and Imagick
cores do, and while the stash is in place it reopens the source
instead of sharing the pipeline: a fresh lazy load at no raster cost,
with the same option string and band normalisation the decoder
applied. Without a stash the vips image stays shared.
setLoops() wrote the field on the vips image in place. The image is
shared with any clone, so the change leaked to the other image, and it
left the stash in place, so a clone reopening the source came back
with the loop count of the file. Work on a copy and hand it to
setNative(), which also clears the stash.
empty() replaced the vips image directly, so the stashed source stayed
in place and described an image that was gone. A later resize reloaded
the source, and a clone reopening the stash brought it back whole.
Route the replacement through setNative(), which clears the stash.
…rsion

A grayscale source (B_W, GREY16) was not stashed: the decoder converts
it to sRGB, and a resize reopening the stash through thumbnail() would
have come back grayscale. Without a stash a clone shares the single
sequential pipeline, so cloning a decoded grayscale image hit the very
failure Core::__clone() fixes for every other source.

Move the conversion the decoder applies, grayscale to sRGB and the alpha
band on 3-band sRGB, into CanNormalizeSource, and replay it wherever a
stashed source is reopened: Core::__clone() and the thumbnail fast path
of the resize and cover modifiers. Grayscale sources are stashed like
any other, which also gives them the shrink on load path.

Core::__clone() gets a helper for the reopen and starts the chained
operation count from zero, the pipeline is fresh. Its docblock now says
the reopen does I/O and can throw.
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