0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-12 05:37:26 +08:00
discourse/spec/models/optimized_image_vips_spec.rb
2026-08-03 15:47:03 +08:00

329 lines
12 KiB
Ruby
Vendored

# frozen_string_literal: true
require "chunky_png"
RSpec.describe OptimizedImage do
let(:input) { Rails.root.join("spec/fixtures/images/large_and_unoptimized.png").to_s }
def normalized_channel_error(first:, second:)
first_image = ChunkyPNG::Image.from_file(first)
second_image = ChunkyPNG::Image.from_file(second)
raise ArgumentError if first_image.dimension != second_image.dimension
error =
first_image
.pixels
.zip(second_image.pixels)
.sum do |first_pixel, second_pixel|
[
ChunkyPNG::Color.r(first_pixel) - ChunkyPNG::Color.r(second_pixel),
ChunkyPNG::Color.g(first_pixel) - ChunkyPNG::Color.g(second_pixel),
ChunkyPNG::Color.b(first_pixel) - ChunkyPNG::Color.b(second_pixel),
ChunkyPNG::Color.a(first_pixel) - ChunkyPNG::Color.a(second_pixel),
].sum(&:abs)
end
error.fdiv(first_image.width * first_image.height * 4 * 255)
end
def vips_field_present?(path, field)
Vips.run("vipsheader", "--field", field, path, read: [path]).present?
rescue Discourse::Utils::CommandError
false
end
def parity(operation:, arguments:)
Dir.mktmpdir("optimized-image-parity") do |directory|
image_magick_output = File.join(directory, "image-magick.png")
vips_output = File.join(directory, "vips.png")
SiteSetting.use_vips_for_image_processing = false
described_class.public_send(operation, input, image_magick_output, *arguments)
SiteSetting.use_vips_for_image_processing = true
described_class.public_send(operation, input, vips_output, *arguments)
yield image_magick_output, vips_output
end
end
describe ".version" do
it "uses distinct cache versions for each image processor" do
image_magick_version = described_class.version
SiteSetting.use_vips_for_image_processing = true
expect({ image_magick: image_magick_version, vips: described_class.version }).to eq(
{ image_magick: OptimizedImage::VERSION, vips: OptimizedImage::VIPS_VERSION },
)
end
end
describe ".resize" do
it "keeps the ImageMagick path under the disabled setting" do
Dir.mktmpdir("optimized-image-selector") do |directory|
output = File.join(directory, "output.png")
expect(described_class.resize(input, output, 321, 123)).to eq(true)
expect(FastImage.size(output)).to eq([321, 123])
end
end
it "uses vips under the enabled setting" do
Dir.mktmpdir("optimized-image-selector") do |directory|
output = File.join(directory, "output.png")
SiteSetting.use_vips_for_image_processing = true
expect(described_class.resize(input, output, 321, 123)).to eq(true)
expect(FastImage.size(output)).to eq([321, 123])
end
end
it "processes every eligible raster format through the public resize API" do
Dir.mktmpdir("optimized-image-formats") do |directory|
source_directory = Rails.root.join("spec/fixtures/images")
inputs = {
jpeg: source_directory.join("logo.jpg").to_s,
png: input,
gif: source_directory.join("animated.gif").to_s,
webp: source_directory.join("animated.webp").to_s,
}
avif = File.join(directory, "input.avif")
Vips.run("vips", "copy", input, avif, read: [input], write: [directory])
inputs[:avif] = avif
SiteSetting.use_vips_for_image_processing = true
dimensions =
inputs.to_h do |format, source|
output = File.join(directory, "output.#{format}")
expect(described_class.resize(source, output, 96, 64, raise_on_error: true)).to eq(true)
[format, FastImage.size(output)]
end
expect(dimensions).to eq(inputs.transform_values { [96, 64] })
end
end
it "does not fall back to ImageMagick after a vips failure" do
Dir.mktmpdir("optimized-image-failure") do |directory|
malformed = File.join(directory, "malformed.png")
output = File.join(directory, "output.png")
File.binwrite(malformed, "not an image")
File.binwrite(output, "existing output")
SiteSetting.use_vips_for_image_processing = true
expect do
described_class.resize(malformed, output, 50, 50, raise_on_error: true)
end.to raise_error(Discourse::Utils::CommandError)
expect(File.binread(output)).to eq("existing output")
end
end
it "preserves or strips source metadata according to the site setting" do
Dir.mktmpdir("optimized-image-metadata") do |directory|
preserved = File.join(directory, "preserved.png")
stripped = File.join(directory, "stripped.png")
SiteSetting.use_vips_for_image_processing = true
SiteSetting.composer_media_optimization_image_enabled = false
SiteSetting.strip_image_metadata = false
described_class.resize(input, preserved, 320, 200)
SiteSetting.strip_image_metadata = true
described_class.resize(input, stripped, 320, 200)
expect(
{
preserved_xmp: vips_field_present?(preserved, "xmp-data"),
preserved_profile: vips_field_present?(preserved, "icc-profile-data"),
stripped_xmp: vips_field_present?(stripped, "xmp-data"),
stripped_profile: vips_field_present?(stripped, "icc-profile-data"),
},
).to eq(
preserved_xmp: true,
preserved_profile: true,
stripped_xmp: false,
stripped_profile: false,
)
end
end
it "uses the output format, quality, and palette options" do
Dir.mktmpdir("optimized-image-options") do |directory|
low_quality = File.join(directory, "low-quality.jpg")
high_quality = File.join(directory, "high-quality.jpg")
image_magick_palette = File.join(directory, "image-magick-palette.png")
vips_palette = File.join(directory, "vips-palette.png")
described_class.resize(input, image_magick_palette, 321, 123, colors: 12)
SiteSetting.use_vips_for_image_processing = true
described_class.resize(input, low_quality, 321, 123, format: "jpg", quality: 35)
described_class.resize(input, high_quality, 321, 123, format: "jpg", quality: 90)
described_class.resize(input, vips_palette, 321, 123, colors: 12)
image_magick_palette_colors =
ChunkyPNG::Image.from_file(image_magick_palette).pixels.uniq.length
vips_palette_colors = ChunkyPNG::Image.from_file(vips_palette).pixels.uniq.length
expect(
{
low_quality_format: FastImage.type(low_quality),
high_quality_format: FastImage.type(high_quality),
lower_quality_is_smaller: File.size(low_quality) < File.size(high_quality),
vips_palette_not_larger: vips_palette_colors <= image_magick_palette_colors,
image_magick_palette_colors:,
vips_palette_encoded:
ChunkyPNG::Datastream.from_file(vips_palette).palette_chunk.present?,
},
).to match(
{
low_quality_format: :jpeg,
high_quality_format: :jpeg,
lower_quality_is_smaller: true,
vips_palette_not_larger: true,
image_magick_palette_colors: be <= 256,
vips_palette_encoded: true,
},
)
end
end
it "stays within the visual and encoded-size parity thresholds" do
parity(operation: :resize, arguments: [321, 123]) do |image_magick_output, vips_output|
expect(
{
dimensions: [FastImage.size(image_magick_output), FastImage.size(vips_output)],
channel_error:
normalized_channel_error(first: image_magick_output, second: vips_output),
size_ratio: File.size(vips_output).fdiv(File.size(image_magick_output)),
},
).to match(
{
dimensions: [[321, 123], [321, 123]],
channel_error: be <= 0.03,
size_ratio: be_between(0.5, 2.0),
},
)
end
end
end
describe ".crop" do
it "produces an exact north-gravity crop for an uncommon aspect ratio" do
Dir.mktmpdir("optimized-image-crop") do |directory|
output = File.join(directory, "output.png")
SiteSetting.use_vips_for_image_processing = true
expect(described_class.crop(input, output, 173, 419)).to eq(true)
expect(FastImage.size(output)).to eq([173, 419])
end
end
it "stays within the north-crop visual and encoded-size parity thresholds" do
parity(operation: :crop, arguments: [321, 123]) do |image_magick_output, vips_output|
expect(
{
dimensions: [FastImage.size(image_magick_output), FastImage.size(vips_output)],
channel_error:
normalized_channel_error(first: image_magick_output, second: vips_output),
size_ratio: File.size(vips_output).fdiv(File.size(image_magick_output)),
},
).to match(
{
dimensions: [[321, 123], [321, 123]],
channel_error: be <= 0.03,
size_ratio: be_between(0.5, 2.0),
},
)
end
end
end
describe ".downsize" do
it "preserves aspect ratio for percentage, area, and shrink-only geometries" do
Dir.mktmpdir("optimized-image-downsize") do |directory|
percentage = File.join(directory, "percentage.png")
area = File.join(directory, "area.png")
shrink_only = File.join(directory, "shrink-only.png")
transparent_input = Rails.root.join("spec/fixtures/images/logo.png").to_s
SiteSetting.use_vips_for_image_processing = true
described_class.downsize(input, percentage, "50%")
described_class.downsize(input, area, "500000@")
described_class.downsize(transparent_input, shrink_only, "500x500>")
area_width, area_height = FastImage.size(area)
transparent_pixel =
Vips.run("vips", "getpoint", shrink_only, "0", "0", read: [shrink_only]).split.map(&:to_f)
expect(
{
percentage: FastImage.size(percentage),
area_within_limit: area_width * area_height <= 500_000,
shrink_only: FastImage.size(shrink_only),
transparent_bands:
Vips.run("vipsheader", "--field", "bands", shrink_only, read: [shrink_only]).to_i,
transparent_alpha: transparent_pixel.last,
},
).to match(
{
percentage: [1016, 656],
area_within_limit: true,
shrink_only: [244, 66],
transparent_bands: 4,
transparent_alpha: 0,
},
)
end
end
it "stays within the shrink-only visual and encoded-size parity thresholds" do
parity(operation: :downsize, arguments: ["333x222>"]) do |image_magick_output, vips_output|
expect(
{
dimensions: [FastImage.size(image_magick_output), FastImage.size(vips_output)],
channel_error:
normalized_channel_error(first: image_magick_output, second: vips_output),
size_ratio: File.size(vips_output).fdiv(File.size(image_magick_output)),
},
).to match(
{
dimensions: [[333, 215], [333, 215]],
channel_error: be <= 0.03,
size_ratio: be_between(0.5, 2.0),
},
)
end
end
end
describe ".create_for" do
it "does not reuse optimized images across processor versions" do
upload =
UploadCreator.new(file_from_fixtures("logo.png"), "logo.png").create_for(
Discourse.system_user.id,
)
image_magick = described_class.create_for(upload, 50, 50)
SiteSetting.use_vips_for_image_processing = true
vips = described_class.create_for(upload, 50, 50)
expect(
{
distinct_records: image_magick.id != vips.id,
image_magick_version: image_magick.version,
vips_version: vips.version,
},
).to eq(
{
distinct_records: true,
image_magick_version: OptimizedImage::VERSION,
vips_version: OptimizedImage::VIPS_VERSION,
},
)
ensure
vips&.destroy
upload&.destroy
end
end
end