openvibers/openvibe.media Commits
Apply EXIF orientation before image metadata is dropped (#387)
Changes
@@@ -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 picturesand 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`.@@@ -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 };}nnew file mode 100644@@@ -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 };@@@ -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, quasync 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);nnew file mode 100644@@@ -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; });
@@ -2,6 +2,10 @@
@@ -2,6 +2,10 @@
## 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.
+
- 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.
Network grants AI `media.object.upload` there (the AI, blog and wiki namespaces). `server/auth.js`.
Network grants AI `media.object.upload` there (the AI, blog and wiki namespaces). `server/auth.js`.
@@ -33,6 +33,7 @@ const fs = require('fs');
@@ -33,6 +33,7 @@ const fs = require('fs');
const path = require('path');
const path = require('path');
const crypto = require('crypto');
const crypto = require('crypto');
const sharp = require('sharp');
const sharp = require('sharp');
+const { applyExifOrientation } = require('../images/orientation');
const MAX_BYTES = 8 * 1024 * 1024, DEADLINE_MS = 10_000, MAX_REDIRECTS = 3, SIZE = 512;
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.
// 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' } = {}) {
@@ -107,7 +108,7 @@ async function toAvatar(buf, { shape = 'avatar' } = {}) {
const img = sharp(buf, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });
const img = sharp(buf, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });
const meta = await img.metadata();
const meta = await img.metadata();
if (!meta.width || !meta.height || meta.width < 16 || meta.height < 16) throw new Error('That picture is too small');
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 };
return { buffer: out, width: size.width, height: size.height, shape };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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 };
@@ -18,6 +18,7 @@
@@ -18,6 +18,7 @@
*/
*/
const fs = require('fs');
const fs = require('fs');
const path = require('path');
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.
// 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];
const LADDER = [64, 96, 160, 320, 480, 640];
@@ -80,7 +81,7 @@ function createVariants({ dir, maxBytes = 512 * 1024 * 1024, concurrency = 2, qu
@@ -80,7 +81,7 @@ function createVariants({ dir, maxBytes = 512 * 1024 * 1024, concurrency = 2, qu
async function make(srcPath, key, spec) {
async function make(srcPath, key, spec) {
const file = path.join(dir, key);
const file = path.join(dir, key);
const tmp = `${file}.${process.pid}.${Date.now()}.tmp`;
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 });
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);
await img.toFile(tmp);
fs.renameSync(tmp, file);
fs.renameSync(tmp, file);
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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; });