discourse/public/javascripts/media-optimization-worker.js
Rafael dos Santos Silva e5df5fe10b
FEATURE: Client-side JXL, HEIC, and animated GIF conversion (#39014)
## Summary

- Extends client-side image optimization to convert **JXL → JPEG**,
**HEIC → JPEG**, and **animated GIF → animated WEBP** using jSquash WASM
packages before upload
- Transparent JXL/HEIC images are converted to **WEBP** instead of JPEG,
so transparency is preserved (WEBP compresses much better than PNG for
this case)
- Gated as an **upcoming change** via
`composer_media_optimization_image_convert_enabled` (experimental,
`feature,all_members`, opt-in per group through the upcoming changes
admin UI)
- Sends raw file bytes to Web Worker for formats browsers can't decode
natively (JXL, HEIC), with new `"convert"` and `"convertAnimated"`
worker message types
- Adds JXL to `authorized_extensions` and `supported_images`; adds
HEIC/HEIF to `supported_images` for consistency
- Skips GIF→WEBP when output is larger than input
- Falls back to filename when MIME type is missing (browsers may not
recognize JXL/HEIC)

### Note on jSquash packages

This depends on Discourse-scoped forks of the jSquash packages
(`@discourse/jxl`, `@discourse/heic`, `@discourse/webp`,
`@discourse/gif`, `@discourse/jpeg`, `@discourse/resize`). The following
upstream PRs would let us move back to the canonical `@jsquash/*`
packages, but the upstream maintainer is currently unresponsive:

- https://github.com/jamsinclair/jSquash/pull/101 (`@jsquash/heic`)
- https://github.com/jamsinclair/jSquash/pull/103 (`@jsquash/webp`
animated support)
- https://github.com/jamsinclair/jSquash/pull/104 (`@jsquash/gif`)

---------

Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-05-11 12:02:50 -03:00

121 lines
3.1 KiB
JavaScript
Vendored

// The media optimization bundle uses webpack's import-scripts chunk loader,
// so worker-only dynamic chunks can be loaded without a DOM shim.
onmessage = async function (e) {
switch (e.data.type) {
case "compress":
try {
globalThis.debugMode = e.data.settings.debug_mode;
let optimized = await globalThis.optimize(
e.data.file,
e.data.fileName,
e.data.width,
e.data.height,
e.data.originalFileSize,
e.data.settings
);
postMessage(
{
type: "file",
file: optimized,
fileName: e.data.fileName,
fileId: e.data.fileId,
},
[optimized]
);
} catch (error) {
console.error(error);
postMessage({
type: "error",
file: e.data.file,
fileName: e.data.fileName,
fileId: e.data.fileId,
});
}
break;
case "convert":
try {
globalThis.debugMode = e.data.settings.debug_mode;
let converted = await globalThis.convert(
e.data.file,
e.data.fileName,
e.data.fileType,
e.data.originalFileSize,
e.data.settings
);
postMessage(
{
type: "file",
file: converted.data,
fileName: e.data.fileName,
fileId: e.data.fileId,
outputType: converted.outputType,
},
[converted.data]
);
} catch (error) {
console.error(error);
postMessage({
type: "error",
file: e.data.file,
fileName: e.data.fileName,
fileId: e.data.fileId,
});
}
break;
case "convertAnimated":
try {
globalThis.debugMode = e.data.settings.debug_mode;
let animatedResult = await globalThis.convertAnimated(
e.data.file,
e.data.fileName,
e.data.originalFileSize,
e.data.settings
);
if (animatedResult) {
postMessage(
{
type: "file",
file: animatedResult.data,
fileName: e.data.fileName,
fileId: e.data.fileId,
outputType: animatedResult.outputType,
},
[animatedResult.data]
);
} else {
postMessage({
type: "skipped",
fileName: e.data.fileName,
fileId: e.data.fileId,
});
}
} catch (error) {
console.error(error);
postMessage({
type: "error",
file: e.data.file,
fileName: e.data.fileName,
fileId: e.data.fileId,
});
}
break;
case "install":
try {
await loadLibs(e.data.settings);
postMessage({ type: "installed" });
} catch (error) {
console.error(error);
postMessage({ type: "installFailed", errorMessage: error.message });
}
break;
default:
logIfDebug(`Sorry, we are out of ${e}.`);
}
};
async function loadLibs(settings) {
importScripts(settings.mediaOptimizationBundle);
}