0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/spec/system/composer_image_optimization_spec.rb
Rafael dos Santos Silva 22902d0233
DEV: Make WASM the only client-side JPEG/PNG decode path (#40955)
Builds on #39941, which added the hidden
`composer_media_optimization_image_wasm_decode_enabled` setting to route
JPEG/PNG composer uploads through the WASM `convert` worker path. After
validating that path works well, this makes it the **only** path and
removes the legacy main-thread canvas pipeline.

Previously JPEG/PNG were decoded on the main thread via
`createImageBitmap`/`OffscreenCanvas` (`media-optimization-utils.js`)
and sent to the worker's `compress` branch. That pipeline is flaky on
iOS and silently downscales images past the 16M-pixel canvas cap.
JPEG/PNG now always take the worker `convert` branch and are decoded in
WASM (`@discourse/jpeg`, `@discourse/png`) alongside JXL/HEIC.

### Changes

- Removed the `composer_media_optimization_image_wasm_decode_enabled`
site setting (and its translation key).
- Deleted `media-optimization-utils.js` (the canvas/`createImageBitmap`
decode helpers).
- Removed the `compress` worker message branch from the worker service
and `entrypoint.js`, and the now-unused `optimize` function from
`codecs.js`.
- Dropped the `OffscreenCanvas`/`createImageBitmap` capability guards in
the upload-processor initializer — the WASM path requires neither, so
optimization is no longer gated on those APIs being present.

### Notes

- The worker message protocol is otherwise unchanged; JPEG/PNG simply
take the `convert` branch with `fileType` derived from MIME or filename.
- Transparent PNGs are re-encoded as WEBP by the shared `convert` logic,
matching existing JXL/HEIC behaviour.
- The separate `composer_ios_media_optimisation_image_enabled` gate is
left in place.
2026-07-07 13:54:28 -03:00

170 lines
6.3 KiB
Ruby
Vendored

# frozen_string_literal: true
describe "Composer image optimization for uploads using media-optimization-worker" do
fab!(:current_user) { Fabricate(:user, refresh_auto_groups: true) }
let(:composer) { PageObjects::Components::Composer.new }
let(:topic) { PageObjects::Pages::Topic.new }
before do
sign_in(current_user)
SiteSetting.composer_media_optimization_debug_mode = true
SiteSetting.enable_upload_debug_mode = true
# 1 byte to ensure all images are optimized in tests
SiteSetting.composer_media_optimization_image_bytes_optimization_threshold = 1
# Disable server-side optimizations
SiteSetting.png_to_jpg_quality = 100
end
describe "jpeg images" do
it "optimizes an uploaded jpeg image in the composer" do
visit "/new-topic"
expect(composer).to be_opened
file = file_from_fixtures("huge.jpg", "images")
attach_file("file-uploader", [file.path], make_visible: true)
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_css(".image-wrapper", count: 1)
upload = Upload.find_by(original_filename: File.basename(file.path))
# Original huge.jpg is 466_479 bytes
expect(upload.filesize).to eq(22_302)
end
it "skips resizing the image if its width is < composer_media_optimization_image_resize_dimensions_threshold" do
SiteSetting.composer_media_optimization_image_resize_dimensions_threshold = 100
visit "/new-topic"
expect(composer).to be_opened
file = file_from_fixtures("logo.jpg", "images")
attach_file("file-uploader", [file.path], make_visible: true)
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_css(".image-wrapper", count: 1)
upload = Upload.find_by(original_filename: File.basename(file.path))
# Original logo.jpg is 29_327 bytes, weirdly resizing this one causes it to increase in size
expect(upload.filesize).to eq(143_862)
# Re-upload without resizing
SiteSetting.composer_media_optimization_image_resize_dimensions_threshold = 20_000
visit "/new-topic"
expect(composer).to be_opened
file = file_from_fixtures("logo.jpg", "images")
attach_file("file-uploader", [file.path], make_visible: true)
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_css(".image-wrapper", count: 1)
upload = Upload.find_by(original_filename: File.basename(file.path))
# Original logo.jpg is 29_327 bytes
expect(upload.filesize).to eq(26_182)
end
end
describe "png images" do
it "optimizes an uploaded png image in the composer" do
visit "/new-topic"
expect(composer).to be_opened
file = file_from_fixtures("large_and_unoptimized.png", "images")
converted_file_path = file.path.gsub("unoptimized", "notransparent")
# We do a conversion here to simulate a PNG with no transparency, the
# fixture has transparency and transparent PNGs are converted to WEBP
# rather than JPEG (see the transparent PNG test below).
Discourse::Utils.execute_command(
*["convert", file.path, "-background", "white", "-alpha", "remove", converted_file_path],
timeout: 3,
)
attach_file("file-uploader", [converted_file_path], make_visible: true)
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_css(".image-wrapper", count: 1)
upload =
Upload.find_by(original_filename: File.basename(converted_file_path).gsub("png", "jpg"))
# Original large_and_unoptimized PNG with no transparency is 312_889 bytes
expect(upload.filesize).to eq(271_246)
ensure
FileUtils.rm(converted_file_path)
end
it "converts a png with transparent pixels to webp in the composer" do
visit "/new-topic"
expect(composer).to be_opened
file = file_from_fixtures("large_and_unoptimized.png", "images")
attach_file("file-uploader", [file.path], make_visible: true)
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_css(".image-wrapper", count: 1)
upload = Upload.find_by(original_filename: File.basename(file.path).gsub("png", "webp"))
# Original large_and_unoptimized.png is 421_730 bytes and 2032px wide;
# transparent PNGs are resized and re-encoded as WEBP instead of JPEG.
expect(upload.filesize).to eq(126_626)
end
it "keeps the original transparent png when webp is not an authorized extension" do
SiteSetting.authorized_extensions = "jpg|jpeg|png|gif"
visit "/new-topic"
expect(composer).to be_opened
file = file_from_fixtures("large_and_unoptimized.png", "images")
attach_file("file-uploader", [file.path], make_visible: true)
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_css(".image-wrapper", count: 1)
upload = Upload.find_by(original_filename: File.basename(file.path))
# Original large_and_unoptimized.png is 421_730 bytes, uploaded untouched
# because converting it to WEBP would produce an unauthorized extension.
expect(upload.filesize).to eq(421_730)
end
it "keeps an animated png untouched in the composer" do
visit "/new-topic"
expect(composer).to be_opened
file = file_from_fixtures("large_and_unoptimized.png", "images")
apng_path = file.path.gsub("unoptimized", "animated")
# Emulate an APNG by inserting an acTL chunk right after IHDR; the
# optimizer's decoder would only keep the first frame, so it must skip
# animated PNGs entirely.
png = File.binread(file.path)
actl_data = [2, 0].pack("N2")
actl_chunk =
[actl_data.bytesize].pack("N") + "acTL" + actl_data +
[Zlib.crc32("acTL" + actl_data)].pack("N")
ihdr_end = 8 + 4 + 4 + 13 + 4 # signature + IHDR length/type/data/crc
File.binwrite(apng_path, png[0...ihdr_end] + actl_chunk + png[ihdr_end..])
attach_file("file-uploader", [apng_path], make_visible: true)
expect(composer).to have_no_in_progress_uploads
expect(composer.preview).to have_css(".image-wrapper", count: 1)
upload = Upload.find_by(original_filename: File.basename(apng_path))
expect(upload.filesize).to eq(File.size(apng_path))
ensure
FileUtils.rm(apng_path)
end
end
end