openvibers/openvibe.media Commits

Expose shared image orientation normalization (#389)

OP OpenVibers committed 3 days ago · da3ecba880 · 4 files, +41 −9

* Expose shared image orientation normalization

* Retry pending verification

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex f029918..4e2f525 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,9 +2,9 @@
## 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.
++- Avatar ingestion, image variants, and the upload-time `POST /api/v2/:app/objects/normalize-image` operation share
++ `server/images/orientation.js`. The operation applies EXIF orientation to pixels before Sharp drops the source
++ metadata, for first-party uploaders such as Community. Test: `test/image-orientation.test.js` covers all eight 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.
ddiff --git a/server/images/orientation.js b/server/images/orientation.js
iindex 2486b13..5319d32 100644
---- a/server/images/orientation.js
++++ b/server/images/orientation.js
@@@ -1,8 +1,19 @@
'use strict';
--/** Apply the source image's EXIF orientation to its pixels before any metadata is discarded. */
--function applyExifOrientation(image) {
-- return image.rotate();
++/** Apply the source image's EXIF orientation to its pixels before metadata is discarded. */
++function applyExifOrientation(image) { return image.rotate(); }
++
++/** Apply EXIF orientation, then re-encode so all embedded metadata is discarded. */
++async function normalizeImage(input, mimeType, { sharp = require('sharp') } = {}) {
++ const format = ({ 'image/jpeg': 'jpeg', 'image/png': 'png', 'image/webp': 'webp' })[String(mimeType || '').toLowerCase()];
++ if (!format) throw new Error('Only JPEG, PNG and WebP images can be normalized');
++ const image = sharp(input, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });
++ const meta = await image.metadata();
++ const actual = meta.format === 'jpg' ? 'jpeg' : meta.format;
++ if (!meta.width || !meta.height || actual !== format) throw new Error('Image content does not match its media type');
++ const oriented = applyExifOrientation(image);
++ const output = format === 'jpeg' ? oriented.jpeg({ quality: 92, mozjpeg: true }) : format === 'png' ? oriented.png() : oriented.webp({ quality: 92 });
++ return await output.toBuffer();
}
--module.exports = { applyExifOrientation };
++module.exports = { applyExifOrientation, normalizeImage };
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex efc0028..e93dc8e 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -318,6 +318,19 @@ async function putContent(req, res) {
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
++// Image clients that remove EXIF must first bake its orientation into pixels. Keep this operation in Media so
++// uploads from other first-party services use the exact same decoder limits and orientation behavior.
++router.post('/normalize-image', upload, limits('media.object.upload', UPLOAD),
++ express.raw({ type: ['image/jpeg', 'image/png', 'image/webp'], limit: '8mb' }), async (req, res) => {
++ try {
++ if (!Buffer.isBuffer(req.body) || req.body.length === 0) return problem(res, 400, 'media.image.invalid', 'Image bytes are required');
++ const normalized = await require('../images/orientation').normalizeImage(req.body, req.get('content-type'));
++ return res.status(200).type(req.get('content-type')).set('Cache-Control', 'no-store').send(normalized);
++ } catch (err) {
++ return problem(res, 415, 'media.image.invalid', err.message || 'Image could not be normalized');
++ }
++ });
++
router.post('/', upload, limits('media.object.upload', UPLOAD), async (req, res) => {
try {
const b = req.body || {};
ddiff --git a/test/image-orientation.test.js b/test/image-orientation.test.js
iindex 3e33aec..a652d3b 100644
---- a/test/image-orientation.test.js
++++ b/test/image-orientation.test.js
@@@ -2,18 +2,26 @@
const assert = require('assert');
const sharp = require('sharp');
--const { applyExifOrientation } = require('../server/images/orientation');
++const { applyExifOrientation, normalizeImage } = 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 output = await normalizeImage(source, 'image/jpeg');
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`);
}
++ const marker = await sharp({ create: { width: 20, height: 10, channels: 3, background: '#d22' } }).jpeg().withMetadata({ orientation: 6 }).toBuffer();
++ const normalized = await normalizeImage(marker, 'image/jpeg');
++ const shared = await applyExifOrientation(sharp(marker)).jpeg().toBuffer();
++ const normalizedMeta = await sharp(normalized).metadata();
++ const sharedMeta = await sharp(shared).metadata();
++ assert.strictEqual(normalizedMeta.orientation, undefined, 'no EXIF orientation remains');
++ assert.deepStrictEqual([sharedMeta.width, sharedMeta.height], [10, 20], 'shared helper rotates pixels');
++ await assert.rejects(normalizeImage(marker, 'image/png'), /does not match/);
console.log('✅ EXIF orientations 1–8 are applied to pixels and removed from output metadata');
}