diff --git a/app/back-end/helpers/specs/webp.spec.js b/app/back-end/helpers/specs/webp.spec.js new file mode 100644 index 00000000..dfd564f4 --- /dev/null +++ b/app/back-end/helpers/specs/webp.spec.js @@ -0,0 +1,65 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { Jimp } = require('jimp'); +const Image = require('../../image.js'); +const WebpHelper = require('../webp.js'); + +describe('WebP Helper', function() { + this.timeout(10000); + + it('should encode and decode a Jimp bitmap', async function() { + let fixturePath = path.join(__dirname, 'mock-data', 'avatar.png'); + let image = await Jimp.read(fixturePath); + let encoded = await WebpHelper.encode(image.bitmap, { quality: 60 }); + let decoded = await WebpHelper.decode(encoded); + + assert.strictEqual('RIFF', encoded.subarray(0, 4).toString()); + assert.strictEqual('WEBP', encoded.subarray(8, 12).toString()); + assert.strictEqual(image.bitmap.width, decoded.width); + assert.strictEqual(image.bitmap.height, decoded.height); + assert.strictEqual(decoded.data.length, decoded.width * decoded.height * 4); + }); + + it('should preserve the WebP destination when Jimp is used', async function() { + let tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'publii-webp-')); + let sourcePath = path.join(__dirname, 'mock-data', 'avatar.png'); + let destinationPath = path.join(tempDir, 'avatar-small.webp'); + let fallbackDestinationPath = path.join(tempDir, 'avatar-small.png'); + let image = new Image({ + db: null, + appDir: path.join(__dirname, '..', '..', '..'), + sitesDir: tempDir, + appConfig: { resizeEngine: 'jimp' } + }, { + site: 'test', + id: '1', + path: sourcePath + }); + + try { + let result = await image.processWithJimp({ + originalPath: sourcePath, + destinationPath, + fallbackDestinationPath, + sourceExtension: '.png', + format: 'webp', + width: 100, + height: 100, + crop: false, + forceWebp: true, + imagesQuality: 60, + alphaQuality: 100, + webpLossless: false + }); + + assert.strictEqual(destinationPath, result); + assert.strictEqual(true, fs.existsSync(destinationPath)); + assert.strictEqual(false, fs.existsSync(fallbackDestinationPath)); + assert.strictEqual('WEBP', fs.readFileSync(destinationPath).subarray(8, 12).toString()); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); +}); diff --git a/app/back-end/helpers/webp.js b/app/back-end/helpers/webp.js new file mode 100644 index 00000000..d59ad197 --- /dev/null +++ b/app/back-end/helpers/webp.js @@ -0,0 +1,79 @@ +const fs = require('fs'); +const path = require('path'); + +let encoderPromise = null; +let decoderPromise = null; + +function getWebpPackageDir() { + return path.dirname(require.resolve('@jsquash/webp/package.json')); +} + +function loadWasm(relativePath) { + let wasmPath = path.join(getWebpPackageDir(), relativePath); + let wasmBuffer = fs.readFileSync(wasmPath); + + return WebAssembly.compile(wasmBuffer); +} + +async function getEncoder() { + if (!encoderPromise) { + encoderPromise = (async () => { + let encoder = await import('@jsquash/webp/encode.js'); + let encoderDir = path.join('codec', 'enc'); + let simdWasmPath = path.join(encoderDir, 'webp_enc_simd.wasm'); + let simdWasm = fs.readFileSync(path.join(getWebpPackageDir(), simdWasmPath)); + let wasmPath = WebAssembly.validate(simdWasm) + ? simdWasmPath + : path.join(encoderDir, 'webp_enc.wasm'); + + await encoder.init(await loadWasm(wasmPath)); + + return encoder.default; + })(); + } + + return encoderPromise; +} + +async function getDecoder() { + if (!decoderPromise) { + decoderPromise = (async () => { + let decoder = await import('@jsquash/webp/decode.js'); + let wasmPath = path.join('codec', 'dec', 'webp_dec.wasm'); + + await decoder.init(await loadWasm(wasmPath)); + + return decoder.default; + })(); + } + + return decoderPromise; +} + +async function encode(bitmap, options) { + let encoder = await getEncoder(); + let data = { + ...bitmap, + data: new Uint8ClampedArray(bitmap.data) + }; + let result = await encoder(data, options); + + return Buffer.from(result); +} + +async function decode(buffer) { + let decoder = await getDecoder(); + let data = buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength); + let result = await decoder(data); + + return { + data: Buffer.from(result.data), + width: result.width, + height: result.height + }; +} + +module.exports = { + encode, + decode +}; diff --git a/app/back-end/image.js b/app/back-end/image.js index 1bb99970..ee8d1d87 100644 --- a/app/back-end/image.js +++ b/app/back-end/image.js @@ -13,6 +13,7 @@ const Utils = require('./helpers/utils.js'); const slug = require('./helpers/slug'); const { Jimp } = require('jimp'); const sharpQueue = require('./helpers/sharp-queue.js'); +const WebpHelper = require('./helpers/webp.js'); // Default config const defaultAstCurrentSiteConfig = require('./../config/AST.currentSite.config'); @@ -269,7 +270,7 @@ class Image extends Model { webpLossless = !!siteConfig.advanced.webpLossless; } - if (siteConfig?.advanced?.forceWebp && !this.shouldUseJimp()) { + if (siteConfig?.advanced?.forceWebp) { forceWebp = !!siteConfig.advanced.forceWebp; } @@ -341,7 +342,7 @@ class Image extends Model { let destinationPath = fallbackDestinationPath; let shouldBeChangedToWebp = false; - if (!this.shouldUseJimp() && ['.png', '.jpg', '.jpeg'].indexOf(extLower) > -1) { + if (['.png', '.jpg', '.jpeg'].indexOf(extLower) > -1) { shouldBeChangedToWebp = true; } @@ -428,20 +429,29 @@ class Image extends Model { async processWithJimp(job) { const { originalPath, + destinationPath, fallbackDestinationPath, sourceExtension, + format, width, height, crop, - imagesQuality + forceWebp, + imagesQuality, + alphaQuality, + webpLossless } = job; + let image; + if (sourceExtension === '.webp') { - console.log('jimp cannot process webp source, skipping:', originalPath); - return; - } + let source = await fs.readFile(originalPath); + let bitmap = await WebpHelper.decode(source); - let image = await Jimp.read(originalPath); + image = Jimp.fromBitmap(bitmap); + } else { + image = await Jimp.read(originalPath); + } if (crop) { if (width === null || height === null) { @@ -469,6 +479,18 @@ class Image extends Model { } } + if (format === 'webp' || forceWebp) { + let buffer = await WebpHelper.encode(image.bitmap, { + quality: imagesQuality, + alpha_quality: alphaQuality, + lossless: webpLossless ? 1 : 0 + }); + + await fs.writeFile(destinationPath, buffer); + + return destinationPath; + } + await image.write(fallbackDestinationPath, { quality: imagesQuality }); return fallbackDestinationPath; @@ -511,10 +533,6 @@ class Image extends Model { allowedImageExtension(extension) { let allowedExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG', '.webp', '.WEBP']; - if (this.shouldUseJimp()) { - allowedExtensions = ['.jpg', '.jpeg', '.png', '.JPG', '.JPEG', '.PNG']; - } - return allowedExtensions.indexOf(extension) > -1; } diff --git a/app/default-files/default-languages/en-gb/translations.json b/app/default-files/default-languages/en-gb/translations.json index 67a20097..abb9ffe8 100644 --- a/app/default-files/default-languages/en-gb/translations.json +++ b/app/default-files/default-languages/en-gb/translations.json @@ -719,8 +719,7 @@ "continueSync": "Continue and sync", "continueSyncNoRemoteFiles": "Publii is unable to find remote files list on your server. If you will continue the sync process, ALL generated website files will be uploaded to your server. You can cancel the sync process to check the reason of your issue and then try again.", "convertToWebp": "Convert to WebP format", - "convertToWebpInfo": "Enable this option if you want to convert all JPG, JPEG and PNG images to WebP format. Once enabled, your image thumbnails will need to be regenerated. Note, this feature works only if you’re using the Sharp library for generating thumbnails.", - "convertToWebpJimpWarning": "This option does not work with the currently used image resizing engine, Jimp. Instead, switch to Sharp in the app's settings.", + "convertToWebpInfo": "Enable this option if you want to convert all JPG, JPEG and PNG images to WebP format. Once enabled, your image thumbnails will need to be regenerated.", "cookieGroups": "Cookie Groups", "cookieBanner": "Cookie Banner", "cookieBasic": "Basic", @@ -855,9 +854,8 @@ "howToPrepareYourThemeForGDPRInfo": "Enabling this option will display a cookie banner on your website. Please read the GDPR Cookie Banner Configuration article for more information on how to configure the banner correctly. ", "howYtNoCookiesWorks": "When enabled, the YouTube domain for the embed URL e.g. 'www.youtube.com' will be automatically changed to 'www.youtube-nocookie.com', to prevent the embedded player from personalising the YouTube browsing experience of the visitors, and also ensures that no information is used to customise advertising shown to the visitor outside of your site. Learn more about Enhanced Privacy Mode by visiting YouTube help page.", "howVimeoNoTrackWorks": "When enabled, the additional dnt=1 parameter will be added to the Vimeo embed URL (player.vimeo.com/video) to prevent the embedded player from tracking any session data, including all cookies and analytics. Learn more about Vimeo player parameters by visiting Vimeo help page.", - "imageResizeEngineInfo": "The Sharp resize engine is much faster than Jimp, but can cause issues with some images. If you are encountering problems when creating or regenerating thumbnails, please try switching to the Jimp resize engine. Should you wish to use WebP images, then you’ll need to use the Sharp resize engine.", + "imageResizeEngineInfo": "The Sharp resize engine is much faster than Jimp, but can cause issues with some images. If you are encountering problems when creating or regenerating thumbnails, please try switching to the Jimp resize engine. Both engines support WebP images.", "imagesResizeEngine": "Images resize engine:", - "imageResizeEngineWarning": "Jimp resize engine and the option to convert images to WebP format won't work properly. Make sure that all of your websites has the “Website Speed / Convert to WebP format” disabled, and make sure to regenerate thumbnails for consistency.", "indentSize": "Indent size (spaces)", "internalPage": "Internal page", "leaveBlankForDefaultBackupsDir": "Leave blank to use the default backups directory", diff --git a/app/default-files/default-languages/pl/translations.json b/app/default-files/default-languages/pl/translations.json index c7f60249..7724a8d2 100644 --- a/app/default-files/default-languages/pl/translations.json +++ b/app/default-files/default-languages/pl/translations.json @@ -718,8 +718,7 @@ "continueSync": "Kontynuuj i synchronizuj", "continueSyncNoRemoteFiles": "Publii nie może znaleźć listy plików na twoim serwerze. Jeśli będziesz kontynuować proces synchronizacji, WSZYSTKIE wygenerowane pliki strony internetowej zostaną przesłane na twój serwer. Możesz anulować proces synchronizacji, aby sprawdzić przyczynę problemu, a następnie spróbować ponownie.", "convertToWebp": "Konwertuj do WebP", - "convertToWebpInfo": "Włącz tę opcję, jeśli chcesz konwertować wszystkie grafiki w formatach JPG, JPEG oraz PNG do formatu WebP. Po włączeniu tej opcji konieczna jest ponowna regeneracja miniaturek. Opcja ta zadziała tylko gdy używasz biblioteki Sharp do tworzenia miniaturek.", - "convertToWebpJimpWarning": "Ta opcja nie działa z obecnie używanym silnikiem tworzenia miniaturek - Jimp. Proszę użyć silnika Sharp aby móc używać tej opcji.", + "convertToWebpInfo": "Włącz tę opcję, jeśli chcesz konwertować wszystkie grafiki w formatach JPG, JPEG oraz PNG do formatu WebP. Po włączeniu tej opcji konieczna jest ponowna regeneracja miniaturek.", "cookieGroups": "Grupy plików cookies", "cookieBanner": "Banner cookies", "cookieBasic": "Podstawowy", @@ -854,9 +853,8 @@ "howToPrepareYourThemeForGDPRInfo": "Włączenie tej opcji spowoduje wyświetlenie banera plików cookie w Twojej witrynie. Aby uzyskać więcej informacji na temat prawidłowej konfiguracji banera zapoznaj się z następującym artykułem GDPR Cookie Banner Configuration ", "howYtNoCookiesWorks": "Po włączeniu tej opcji, domena adresu URL umieszczonego filmu 'www.youtube.com' zostanie automatycznie zmieniona na 'www.youtube-nocookie.com', aby uniemożliwić personalizowanie sposobu przeglądania YouTube ani w tym odtwarzaczu, ani w samym serwisie, a także zapewni, że żadne dane nie są wykorzystywane do dostosowywania reklam wyświetlanych widzom poza Twoją witryną. Dowiedz się więcej o rozszerzonym trybie prywatności odwiedzając stronę pomocy YouTube..", "howVimeoNoTrackWorks": "Po włączeniu tej opcji, dodatkowy parametr dnt=1 zostanie dodany do adresu URL umieszczonego odtwarzacza Vimeo (player.vimeo.com/video), aby uniemożliwić śledzenie jakichkolwiek danych sesji, w tym wszystkich plików cookie i danych analitycznych. Dowiedz się więcej o parametrach odtwarzacza Vimeo odwiedzając stronę pomocy Vimeo.", - "imageResizeEngineInfo": "Silnik zmiany rozmiaru obrazów The Sharp jest znacznie szybszy niż Jimp, ale może powodować problemy z niektórymi obrazami. Jeśli napotkasz problemy podczas tworzenia lub ponownego generowania miniatur, spróbuj przełączyć się na mechanizm zmiany rozmiaru Jimp. Jeśli chcesz używać obrazów WebP, musisz użyć mechanizmu zmiany rozmiaru Sharp.", + "imageResizeEngineInfo": "Silnik zmiany rozmiaru obrazów Sharp jest znacznie szybszy niż Jimp, ale może powodować problemy z niektórymi obrazami. Jeśli napotkasz problemy podczas tworzenia lub ponownego generowania miniatur, spróbuj przełączyć się na mechanizm zmiany rozmiaru Jimp. Oba silniki obsługują obrazy WebP.", "imagesResizeEngine": "Silnik zmiany rozmiaru obrazów:", - "imageResizeEngineWarning": "Jeśli używany jest silnik Jimp, opcja wykorzystania grafik WebP nie będzie działać poprawnie. Upewnij się, że wszystkie Twoje strony mają opcję 'Szybkość strony / Konwertuj do WebP' wyłączoną i wygeneruj miniatury ponownie aby zapewnić poprawne wyświetlanie grafik.", "indentSize": "Rozmiar wcięć (spacje)", "internalPage": "Strona wewnętrzna", "leaveBlankForDefaultBackupsDir": "Pozostaw puste, aby użyć domyślnego katalogu kopii zapasowych", diff --git a/app/package-lock.json b/app/package-lock.json index f188d8db..1f761f64 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -12,6 +12,7 @@ "@aws-sdk/client-s3": "3.1054.0", "@gitbeaker/rest": "43.8.0", "@google-cloud/storage": "7.19.0", + "@jsquash/webp": "1.5.0", "@octokit/rest": "22.0.0", "adm-zip": "0.5.10", "archiver": "5.3.1", @@ -1651,6 +1652,15 @@ "node": ">=18" } }, + "node_modules/@jsquash/webp": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jsquash/webp/-/webp-1.5.0.tgz", + "integrity": "sha512-KggLoj2MnRSfIqTeKe1EmbljTX2vuV7mh79k89PCL1pyqiDULcPM1L47twxXt0hkb68F70bXiL31MxsuoZtKFw==", + "license": "Apache-2.0", + "dependencies": { + "wasm-feature-detect": "^1.2.11" + } + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", @@ -6299,6 +6309,12 @@ "resolved": "https://registry.npmjs.org/vuex/-/vuex-3.1.1.tgz", "integrity": "sha512-ER5moSbLZuNSMBFnEBVGhQ1uCBNJslH9W/Dw2W7GZN23UQA69uapP5GTT9Vm8Trc0PzBSVt6LzF3hGjmv41xcg==" }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/app/package.json b/app/package.json index b4d8b1ec..2f085e85 100644 --- a/app/package.json +++ b/app/package.json @@ -28,6 +28,7 @@ "@aws-sdk/client-s3": "3.1054.0", "@gitbeaker/rest": "43.8.0", "@google-cloud/storage": "7.19.0", + "@jsquash/webp": "1.5.0", "@octokit/rest": "22.0.0", "adm-zip": "0.5.10", "archiver": "5.3.1", diff --git a/app/src/components/AppSettings.vue b/app/src/components/AppSettings.vue index 173e9ebf..b54aef2b 100644 --- a/app/src/components/AppSettings.vue +++ b/app/src/components/AppSettings.vue @@ -56,13 +56,6 @@ id="images-resize-engine" :items="imageResizeEngines" v-model="imageResizeEnginesSelected"> -
@@ -571,9 +564,6 @@ export default { 'var(--font-serif)': this.$t('settings.editorFontFamilySerif') }; }, - showWebpWarning () { - return this.imageResizeEnginesSelected === 'jimp'; - }, isSitesLocationExists () { return this.sitesLocationExists; }, diff --git a/app/src/components/Settings.vue b/app/src/components/Settings.vue index 3d93b82d..5260a0d7 100644 --- a/app/src/components/Settings.vue +++ b/app/src/components/Settings.vue @@ -1825,14 +1825,6 @@ v-model="advanced.forceWebp" slot="field" /> - -