openvibers/openvibe.media Commits
Expose shared image orientation normalization (#389)
Changes
@@@ -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 picturesand made illustrations are stored by Media reference in the blog or wiki tenant for the person the run acts for.@@@ -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 };@@@ -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 || {};@@@ -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');}
@@ -2,9 +2,9 @@
@@ -2,9 +2,9 @@
## Unreleased
## 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
- 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.
and made illustrations are stored by Media reference in the blog or wiki tenant for the person the run acts for.
@@ -1,8 +1,19 @@
@@ -1,8 +1,19 @@
'use strict';
'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 };
@@ -318,6 +318,19 @@ async function putContent(req, res) {
@@ -318,6 +318,19 @@ async function putContent(req, res) {
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
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) => {
router.post('/', upload, limits('media.object.upload', UPLOAD), async (req, res) => {
try {
try {
const b = req.body || {};
const b = req.body || {};
@@ -2,18 +2,26 @@
@@ -2,18 +2,26 @@
const assert = require('assert');
const assert = require('assert');
const sharp = require('sharp');
const sharp = require('sharp');
-const { applyExifOrientation } = require('../server/images/orientation');
+const { applyExifOrientation, normalizeImage } = require('../server/images/orientation');
async function main() {
async function main() {
for (let orientation = 1; orientation <= 8; orientation++) {
for (let orientation = 1; orientation <= 8; orientation++) {
const source = await sharp({ create: { width: 30, height: 20, channels: 3, background: '#d22' } })
const source = await sharp({ create: { width: 30, height: 20, channels: 3, background: '#d22' } })
.jpeg().withMetadata({ orientation }).toBuffer();
.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 meta = await sharp(output).metadata();
const swapped = orientation >= 5;
const swapped = orientation >= 5;
assert.deepStrictEqual([meta.width, meta.height], swapped ? [20, 30] : [30, 20], `EXIF orientation ${orientation}`);
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`);
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');
console.log('✅ EXIF orientations 1–8 are applied to pixels and removed from output metadata');
}
}