openvibers/openvibe.media Commits

Apply EXIF orientation before image metadata is dropped (#387)

OP OpenVibers committed 3 days ago · d78ec54583 · 5 files, +36 −2

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 82de73c..f029918 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,10 @@
## Unreleased
++- Image re-encoding in avatar ingestion and thumbnail variants now shares `server/images/orientation.js`, which applies
++ EXIF orientation to pixels before Sharp drops the source metadata. Test: `test/image-orientation.test.js` covers all
++ eight EXIF orientation values.
++
- A first-party `ai` tenant for OpenVibe.AI's shared image task (`image.picture`): found Commons/Openverse pictures
and made illustrations are stored by Media reference in the blog or wiki tenant for the person the run acts for.
Network grants AI `media.object.upload` there (the AI, blog and wiki namespaces). `server/auth.js`.
ddiff --git a/server/avatars/ingest.js b/server/avatars/ingest.js
iindex 1e19f47..6bf415f 100644
---- a/server/avatars/ingest.js
++++ b/server/avatars/ingest.js
@@@ -33,6 +33,7 @@ const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
++const { applyExifOrientation } = require('../images/orientation');
const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE = 512;
// The house shapes: a square picture everywhere a person appears; a wide one for a channel banner.
@@@ -107,7 +108,7 @@ async function toAvatar(buf, { shape = 'avatar' } = {}) {
const img = sharp(buf, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });
const meta = await img.metadata();
if (!meta.width || !meta.height || meta.width < 16 || meta.height < 16) throw new Error('That picture is too small');
-- const out = await img.rotate().resize(size.width, size.height, { fit: 'cover', position: 'attention' }).webp({ quality: 88 }).toBuffer();
++ const out = await applyExifOrientation(img).resize(size.width, size.height, { fit: 'cover', position: 'attention' }).webp({ quality: 88 }).toBuffer();
return { buffer: out, width: size.width, height: size.height, shape };
}
ddiff --git a/server/images/orientation.js b/server/images/orientation.js
nnew file mode 100644
iindex 0000000..2486b13
---- /dev/null
++++ b/server/images/orientation.js
@@@ -0,0 +1,8 @@
++'use strict';
++
++/** Apply the source image's EXIF orientation to its pixels before any metadata is discarded. */
++function applyExifOrientation(image) {
++ return image.rotate();
++}
++
++module.exports = { applyExifOrientation };
ddiff --git a/server/thumbnails/variants.js b/server/thumbnails/variants.js
iindex 2844d11..7b8698a 100644
---- a/server/thumbnails/variants.js
++++ b/server/thumbnails/variants.js
@@@ -18,6 +18,7 @@
*/
const fs = require('fs');
const path = require('path');
++const { applyExifOrientation } = require('../images/orientation');
// 64 and 96 are avatar sizes (a 32 or 48 px avatar on a 2x screen); 320–640 are card thumbnails.
const LADDER = [64, 96, 160, 320, 480, 640];
@@@ -80,7 +81,7 @@ function createVariants({ dir, maxBytes = 512 * 1024 * 1024, concurrency = 2, qu
async function make(srcPath, key, spec) {
const file = path.join(dir, key);
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
-- let img = lib()(srcPath, { limitInputPixels: 40_000_000, failOn: 'error' }).rotate().resize({ width: spec.w, withoutEnlargement: true });
++ let img = applyExifOrientation(lib()(srcPath, { limitInputPixels: 40_000_000, failOn: 'error' })).resize({ width: spec.w, withoutEnlargement: true });
img = spec.fmt === 'avif' ? img.avif({ quality: 50, effort: 2 }) : spec.fmt === 'jpeg' ? img.jpeg({ quality: 80, mozjpeg: true }) : img.webp({ quality: 75 });
await img.toFile(tmp);
fs.renameSync(tmp, file);
ddiff --git a/test/image-orientation.test.js b/test/image-orientation.test.js
nnew file mode 100644
iindex 0000000..3e33aec
---- /dev/null
++++ b/test/image-orientation.test.js
@@@ -0,0 +1,20 @@
++'use strict';
++
++const assert = require('assert');
++const sharp = require('sharp');
++const { applyExifOrientation } = require('../server/images/orientation');
++
++async function main() {
++ for (let orientation = 1; orientation <= 8; orientation++) {
++ const source = await sharp({ create: { width: 30, height: 20, channels: 3, background: '#d22' } })
++ .jpeg().withMetadata({ orientation }).toBuffer();
++ const output = await applyExifOrientation(sharp(source, { failOn: 'error' })).jpeg().toBuffer();
++ const meta = await sharp(output).metadata();
++ const swapped = orientation >= 5;
++ assert.deepStrictEqual([meta.width, meta.height], swapped ? [20, 30] : [30, 20], `EXIF orientation ${orientation}`);
++ assert.strictEqual(meta.orientation, undefined, `orientation ${orientation} must be in pixels, not retained as metadata`);
++ }
++ console.log('✅ EXIF orientations 1–8 are applied to pixels and removed from output metadata');
++}
++
++main().catch((err) => { console.error(err); process.exitCode = 1; });