openvibers/openvibe.media Commits
Repair historical image orientation metadata (#390)
Changes
@@@ -2,6 +2,11 @@## Unreleased++- `scripts/orientation-backfill.js` inspects stored JPEG, PNG and WebP originals in batches (dry run by default), bakes++ any surviving EXIF orientation into pixels, removes the embedded details, updates object/location hashes and sizes,++ and rebuilds 320/640 WebP variants. Images without source orientation data are marked `orientation unknown` for the++ owner's later AI review. `--apply` writes one batch; `--all` walks the range.++- **A recording that does not decode is never published (2026-11-10).** A recording is a stream copy of a WebRTCingest, and WebRTC does not retransmit to a recording consumer: a lost RTP fragment arrives as a truncated H.264access unit, the muxer writes it, ffprobe calls the file healthy, and Chrome stops the whole playback on itnnew file mode 100644@@@ -0,0 +1,64 @@++#!/usr/bin/env node++'use strict';++++/**++ * Repair stored image orientation and mark images whose source carries no orientation data.++ *++ * node scripts/orientation-backfill.js [--batch 50] [--after <med_id>] [--before 'YYYY-MM-DD HH:MM:SS'] [--all] [--apply]++ *++ * Dry run by default. --all walks every batch; --apply persists one batch, or all batches with --all.++ * It never calls an AI provider and never prints object metadata or storage URLs.++ */++++function parseArgs(args) {++ const has = (name) => args.includes(`--${name}`);++ const arg = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : fallback; };++ const batch = Number(arg('batch', '50'));++ const after = String(arg('after', ''));++ const before = String(arg('before', require('../server/objects/orientation-backfill').CUTOVER));++ if (!Number.isInteger(batch) || batch < 1 || batch > 200) throw new Error('--batch must be an integer from 1 to 200');++ if (after && !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(after)) throw new Error('--after must be a Media object id');++ if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(before)) throw new Error('--before must be YYYY-MM-DD HH:MM:SS');++ return { batch, after, before, all: has('all'), apply: has('apply') };++}++++async function main() {++ const opts = parseArgs(process.argv.slice(2));++ const dbApi = require('../server/db/database');++ await dbApi.initDb();++ const db = {++ all: dbApi.all,++ run: dbApi.run,++ getApp: dbApi.getApp,++ };++ const { runBatch } = require('../server/objects/orientation-backfill');++ const variants = require('../server/thumbnails/variants').createVariants({++ dir: require('../server/config').thumbnails.variants.objectsPath,++ maxBytes: require('../server/config').thumbnails.variants.maxBytes,++ concurrency: 1,++ });++ const totals = { scanned: 0, already_classified: 0, normalize: 0, orientation_unknown: 0, no_exif: 0, unsupported: 0, unreadable: 0, changed: 0, variant_errors: 0, errors: 0, batches: 0, after: opts.after, before: opts.before };++ let cursor = opts.after;++ let done = false;++ try {++ do {++ const result = await runBatch({ db, after: cursor, batch: opts.batch, apply: opts.apply, before: opts.before, variants });++ for (const key of ['scanned', 'already_classified', 'normalize', 'orientation_unknown', 'no_exif', 'unsupported', 'unreadable', 'changed', 'variant_errors', 'errors']) totals[key] += result[key];++ totals.batches++;++ cursor = result.next_after;++ totals.after = cursor;++ done = result.done;++ if (!opts.all || result.done) break;++ } while (true);++ totals.done = done;++ totals.mode = opts.apply ? 'apply' : 'dry-run';++ console.log(JSON.stringify(totals));++ if (totals.errors) process.exitCode = 1;++ } finally {++ await dbApi.close();++ }++}++++if (require.main === module) main().catch((err) => { console.error(`orientation-backfill: ${err.message}`); process.exitCode = 1; });++++module.exports = { parseArgs };@@@ -16,4 +16,18 @@ async function normalizeImage(input, mimeType, { sharp = require('sharp') } = {}return await output.toBuffer();}--module.exports = { applyExifOrientation, normalizeImage };++/** File-backed counterpart used by the historical-image repair so large originals are streamed by Sharp. */++async function normalizeImageFile(inputPath, outputPath, 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(inputPath, { 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 });++ await output.toFile(outputPath);++ return { width: meta.width, height: meta.height, orientation: meta.orientation || 1, hadExif: !!(meta.exif && meta.exif.length), hadXmp: !!(meta.xmp && meta.xmp.length), hadIptc: !!(meta.iptc && meta.iptc.length) };++}++++module.exports = { applyExifOrientation, normalizeImage, normalizeImageFile };nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++++const fs = require('fs');++const path = require('path');++const crypto = require('crypto');++const model = require('./model');++const namespaces = require('./namespaces');++const { normalizeImageFile } = require('../images/orientation');++++const CUTOVER = '2026-11-10 10:16:29';++const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];++++function hashFile(file) {++ return new Promise((resolve, reject) => {++ const hash = crypto.createHash('sha256');++ fs.createReadStream(file).on('data', (part) => hash.update(part)).on('error', reject).on('end', () => resolve(hash.digest('hex')));++ });++}++++function parseMetadata(value) { return model.parseJson(value, {}); }++function newCounts() {++ return { scanned: 0, already_classified: 0, normalize: 0, orientation_unknown: 0, no_exif: 0, unsupported: 0, unreadable: 0, changed: 0, variant_errors: 0, errors: 0 };++}++++async function remoteDownload(vs, loc, file) {++ const url = await vs.presignGet(loc.provider, loc.key, 120, { contentType: 'application/octet-stream' });++ if (!url) throw new Error('remote source unavailable');++ const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });++ if (!response.ok || !response.body) throw new Error('remote source unavailable');++ const stream = fs.createWriteStream(file);++ for await (const chunk of response.body) if (!stream.write(chunk)) await new Promise((resolve) => stream.once('drain', resolve));++ await new Promise((resolve, reject) => stream.end((err) => err ? reject(err) : resolve()));++}++++async function runBatch({ db, after = '', batch = 50, apply = false, before = CUTOVER, variants = null, normalize = normalizeImageFile, now = () => new Date() } = {}) {++ const out = newCounts();++ const rows = await db.all(`SELECT id, app_id, namespace, kind, mime_type, size_bytes, content_hash, metadata, created_at++ FROM media_objects WHERE lifecycle_status = 'ready' AND lower(mime_type) IN (?, ?, ?) AND id > ? AND created_at <= ?++ ORDER BY id LIMIT ?`, [...IMAGE_TYPES, after, before, batch]);++ const vs = () => require('../vod/vod-storage');++ const scratch = path.join(require('../config').objects.path, '.tmp');++ fs.mkdirSync(scratch, { recursive: true });++ const localVariants = variants || require('../thumbnails/variants').createVariants({++ dir: require('../config').thumbnails.variants.objectsPath,++ maxBytes: require('../config').thumbnails.variants.maxBytes,++ concurrency: 1,++ });++++ for (const obj of rows) {++ out.scanned++;++ const metadata = parseMetadata(obj.metadata);++ if (metadata.orientation_status === 'orientation unknown' || metadata.orientation_status === 'orientation applied') {++ out.already_classified++;++ continue;++ }++ const locations = await model.listLocations(obj.id);++ const local = locations.find((l) => l.provider === 'local' && l.state === 'present' && fs.existsSync(l.key));++ const remote = locations.find((l) => l.provider !== 'local' && l.state === 'present');++ const nonce = crypto.randomBytes(8).toString('hex');++ const source = local ? local.key : path.join(scratch, `${obj.id}.${nonce}.source`);++ const normalized = path.join(scratch, `${obj.id}.${nonce}.normalized`);++ const backup = path.join(scratch, `${obj.id}.${nonce}.original`);++ try {++ if (!local && remote) await remoteDownload(vs(), remote, source);++ if (!local && !remote) {++ out.unreadable++;++ out.orientation_unknown++;++ if (apply) await markUnknown(obj, metadata, 'source unavailable', db);++ continue;++ }++ const probe = require('sharp')(source, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });++ const image = await probe.metadata();++ const hasExif = !!(image.exif && image.exif.length);++ const hasDetails = hasExif || !!(image.xmp && image.xmp.length) || !!(image.iptc && image.iptc.length);++ if (hasDetails || (image.orientation && image.orientation !== 1)) {++ out.normalize++;++ if (!apply) continue;++ await normalize(source, normalized, obj.mime_type);++ const size = fs.statSync(normalized).size;++ const checksum = await hashFile(normalized);++ if (local) fs.copyFileSync(local.key, backup);++ else fs.copyFileSync(source, backup);++ const changedRemote = [];++ try {++ for (const loc of locations.filter((l) => l.provider !== 'local' && l.state === 'present')) {++ await vs().uploadFile(loc.provider, loc.key, normalized, obj.mime_type);++ changedRemote.push(loc);++ }++ if (local) {++ fs.renameSync(normalized, local.key);++ const stamp = now(); fs.utimesSync(local.key, stamp, stamp);++ }++ const nextMetadata = { ...metadata, orientation_status: 'orientation applied', orientation_applied: true };++ await require('../db/database').getDb().tx(async () => {++ await model.updateObject(obj.id, { content_hash: checksum, size_bytes: size, metadata: nextMetadata });++ for (const loc of locations) {++ if (loc.provider === 'local' || changedRemote.some((r) => r.id === loc.id)) {++ await db.run('UPDATE media_locations SET checksum = ?, size_bytes = ?, verified_at = ov_now(), updated_at = ov_now() WHERE id = ?', [checksum, size, loc.id]);++ }++ }++ });++ const app = await db.getApp(obj.app_id);++ if (app) await namespaces.reconcileChain(app, obj.namespace);++ out.changed++;++ if (local) {++ try {++ await localVariants.get(local.key, { w: 320, fmt: 'webp' });++ await localVariants.get(local.key, { w: 640, fmt: 'webp' });++ } catch { out.variant_errors++; }++ }++ } catch (err) {++ for (const loc of changedRemote.reverse()) {++ try { await vs().uploadFile(loc.provider, loc.key, backup, obj.mime_type); } catch { /* the failed object stays visible in the error count */ }++ }++ if (local && fs.existsSync(backup)) fs.copyFileSync(backup, local.key);++ throw err;++ }++ } else {++ out.orientation_unknown++;++ out.no_exif++;++ if (apply) await markUnknown(obj, metadata, 'no orientation data in the original', db);++ }++ } catch {++ out.errors++;++ } finally {++ for (const file of [normalized, backup, local ? null : source]) if (file) { try { fs.unlinkSync(file); } catch { /* absent */ } }++ }++ }++ return { ...out, next_after: rows.length ? rows[rows.length - 1].id : after, done: rows.length < batch };++}++++async function markUnknown(obj, metadata, reason, db) {++ await model.updateObject(obj.id, { metadata: { ...metadata, orientation_status: 'orientation unknown', orientation_reason: reason } });++}++++module.exports = { runBatch, CUTOVER, IMAGE_TYPES };@@@ -1,8 +1,11 @@'use strict';const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');const sharp = require('sharp');--const { applyExifOrientation, normalizeImage } = require('../server/images/orientation');++const { applyExifOrientation, normalizeImage, normalizeImageFile } = require('../server/images/orientation');async function main() {for (let orientation = 1; orientation <= 8; orientation++) {@@@ -21,6 +24,15 @@ async function main() {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');++ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'media-orientation-test-'));++ try {++ const input = path.join(scratch, 'source.jpg'), output = path.join(scratch, 'oriented.jpg');++ fs.writeFileSync(input, marker);++ await normalizeImageFile(input, output, 'image/jpeg');++ const fileMeta = await sharp(output).metadata();++ assert.deepStrictEqual([fileMeta.width, fileMeta.height], [10, 20], 'file-backed normalization rotates pixels');++ assert.strictEqual(fileMeta.orientation, undefined, 'file-backed normalization removes EXIF');++ } finally { fs.rmSync(scratch, { recursive: true, force: true }); }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');}@@@ -35,6 +35,7 @@ const https = require('https');'server/public/descriptions.js': 'the app\'s internal URL (APP_INTERNAL_URLS), a numeric media id in a fixed path','server/jobs/pack.js': 'a presigned GET on B2/R2 (operator-configured buckets) for a segment\'s durable copy','server/objects/routes.js': 'a ranged GET on a presigned B2/R2 URL (operator-configured buckets) for a packed segment\'s chunk',++ 'server/objects/orientation-backfill.js': 'a presigned GET on an operator-configured B2/R2 provider for a historical object location; provider and key come from Media storage metadata','server/s3/routes.js': 'a ranged GET on a presigned B2/R2 URL (operator-configured buckets) for an S3 GetObject whose bytes are not on local disk','server/jobs/previews.js': 'a (ranged) GET on a presigned B2/R2 URL (operator-configured buckets) for a timeline segment, to cut a sprite frame','server/drill.js': 'restore drill: only this server itself',
@@ -2,6 +2,11 @@
@@ -2,6 +2,11 @@
## Unreleased
## Unreleased
+- `scripts/orientation-backfill.js` inspects stored JPEG, PNG and WebP originals in batches (dry run by default), bakes
+ any surviving EXIF orientation into pixels, removes the embedded details, updates object/location hashes and sizes,
+ and rebuilds 320/640 WebP variants. Images without source orientation data are marked `orientation unknown` for the
+ owner's later AI review. `--apply` writes one batch; `--all` walks the range.
+
- **A recording that does not decode is never published (2026-11-10).** A recording is a stream copy of a WebRTC
- **A recording that does not decode is never published (2026-11-10).** A recording is a stream copy of a WebRTC
ingest, and WebRTC does not retransmit to a recording consumer: a lost RTP fragment arrives as a truncated H.264
ingest, and WebRTC does not retransmit to a recording consumer: a lost RTP fragment arrives as a truncated H.264
access unit, the muxer writes it, ffprobe calls the file healthy, and Chrome stops the whole playback on it
access unit, the muxer writes it, ffprobe calls the file healthy, and Chrome stops the whole playback on it
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * Repair stored image orientation and mark images whose source carries no orientation data.
+ *
+ * node scripts/orientation-backfill.js [--batch 50] [--after <med_id>] [--before 'YYYY-MM-DD HH:MM:SS'] [--all] [--apply]
+ *
+ * Dry run by default. --all walks every batch; --apply persists one batch, or all batches with --all.
+ * It never calls an AI provider and never prints object metadata or storage URLs.
+ */
+
+function parseArgs(args) {
+ const has = (name) => args.includes(`--${name}`);
+ const arg = (name, fallback) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : fallback; };
+ const batch = Number(arg('batch', '50'));
+ const after = String(arg('after', ''));
+ const before = String(arg('before', require('../server/objects/orientation-backfill').CUTOVER));
+ if (!Number.isInteger(batch) || batch < 1 || batch > 200) throw new Error('--batch must be an integer from 1 to 200');
+ if (after && !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(after)) throw new Error('--after must be a Media object id');
+ if (!/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}$/.test(before)) throw new Error('--before must be YYYY-MM-DD HH:MM:SS');
+ return { batch, after, before, all: has('all'), apply: has('apply') };
+}
+
+async function main() {
+ const opts = parseArgs(process.argv.slice(2));
+ const dbApi = require('../server/db/database');
+ await dbApi.initDb();
+ const db = {
+ all: dbApi.all,
+ run: dbApi.run,
+ getApp: dbApi.getApp,
+ };
+ const { runBatch } = require('../server/objects/orientation-backfill');
+ const variants = require('../server/thumbnails/variants').createVariants({
+ dir: require('../server/config').thumbnails.variants.objectsPath,
+ maxBytes: require('../server/config').thumbnails.variants.maxBytes,
+ concurrency: 1,
+ });
+ const totals = { scanned: 0, already_classified: 0, normalize: 0, orientation_unknown: 0, no_exif: 0, unsupported: 0, unreadable: 0, changed: 0, variant_errors: 0, errors: 0, batches: 0, after: opts.after, before: opts.before };
+ let cursor = opts.after;
+ let done = false;
+ try {
+ do {
+ const result = await runBatch({ db, after: cursor, batch: opts.batch, apply: opts.apply, before: opts.before, variants });
+ for (const key of ['scanned', 'already_classified', 'normalize', 'orientation_unknown', 'no_exif', 'unsupported', 'unreadable', 'changed', 'variant_errors', 'errors']) totals[key] += result[key];
+ totals.batches++;
+ cursor = result.next_after;
+ totals.after = cursor;
+ done = result.done;
+ if (!opts.all || result.done) break;
+ } while (true);
+ totals.done = done;
+ totals.mode = opts.apply ? 'apply' : 'dry-run';
+ console.log(JSON.stringify(totals));
+ if (totals.errors) process.exitCode = 1;
+ } finally {
+ await dbApi.close();
+ }
+}
+
+if (require.main === module) main().catch((err) => { console.error(`orientation-backfill: ${err.message}`); process.exitCode = 1; });
+
+module.exports = { parseArgs };
@@ -16,4 +16,18 @@ async function normalizeImage(input, mimeType, { sharp = require('sharp') } = {}
@@ -16,4 +16,18 @@ async function normalizeImage(input, mimeType, { sharp = require('sharp') } = {}
return await output.toBuffer();
return await output.toBuffer();
}
}
-module.exports = { applyExifOrientation, normalizeImage };
+/** File-backed counterpart used by the historical-image repair so large originals are streamed by Sharp. */
+async function normalizeImageFile(inputPath, outputPath, 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(inputPath, { 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 });
+ await output.toFile(outputPath);
+ return { width: meta.width, height: meta.height, orientation: meta.orientation || 1, hadExif: !!(meta.exif && meta.exif.length), hadXmp: !!(meta.xmp && meta.xmp.length), hadIptc: !!(meta.iptc && meta.iptc.length) };
+}
+
+module.exports = { applyExifOrientation, normalizeImage, normalizeImageFile };
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const model = require('./model');
+const namespaces = require('./namespaces');
+const { normalizeImageFile } = require('../images/orientation');
+
+const CUTOVER = '2026-11-10 10:16:29';
+const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
+
+function hashFile(file) {
+ return new Promise((resolve, reject) => {
+ const hash = crypto.createHash('sha256');
+ fs.createReadStream(file).on('data', (part) => hash.update(part)).on('error', reject).on('end', () => resolve(hash.digest('hex')));
+ });
+}
+
+function parseMetadata(value) { return model.parseJson(value, {}); }
+function newCounts() {
+ return { scanned: 0, already_classified: 0, normalize: 0, orientation_unknown: 0, no_exif: 0, unsupported: 0, unreadable: 0, changed: 0, variant_errors: 0, errors: 0 };
+}
+
+async function remoteDownload(vs, loc, file) {
+ const url = await vs.presignGet(loc.provider, loc.key, 120, { contentType: 'application/octet-stream' });
+ if (!url) throw new Error('remote source unavailable');
+ const response = await fetch(url, { signal: AbortSignal.timeout(120_000) });
+ if (!response.ok || !response.body) throw new Error('remote source unavailable');
+ const stream = fs.createWriteStream(file);
+ for await (const chunk of response.body) if (!stream.write(chunk)) await new Promise((resolve) => stream.once('drain', resolve));
+ await new Promise((resolve, reject) => stream.end((err) => err ? reject(err) : resolve()));
+}
+
+async function runBatch({ db, after = '', batch = 50, apply = false, before = CUTOVER, variants = null, normalize = normalizeImageFile, now = () => new Date() } = {}) {
+ const out = newCounts();
+ const rows = await db.all(`SELECT id, app_id, namespace, kind, mime_type, size_bytes, content_hash, metadata, created_at
+ FROM media_objects WHERE lifecycle_status = 'ready' AND lower(mime_type) IN (?, ?, ?) AND id > ? AND created_at <= ?
+ ORDER BY id LIMIT ?`, [...IMAGE_TYPES, after, before, batch]);
+ const vs = () => require('../vod/vod-storage');
+ const scratch = path.join(require('../config').objects.path, '.tmp');
+ fs.mkdirSync(scratch, { recursive: true });
+ const localVariants = variants || require('../thumbnails/variants').createVariants({
+ dir: require('../config').thumbnails.variants.objectsPath,
+ maxBytes: require('../config').thumbnails.variants.maxBytes,
+ concurrency: 1,
+ });
+
+ for (const obj of rows) {
+ out.scanned++;
+ const metadata = parseMetadata(obj.metadata);
+ if (metadata.orientation_status === 'orientation unknown' || metadata.orientation_status === 'orientation applied') {
+ out.already_classified++;
+ continue;
+ }
+ const locations = await model.listLocations(obj.id);
+ const local = locations.find((l) => l.provider === 'local' && l.state === 'present' && fs.existsSync(l.key));
+ const remote = locations.find((l) => l.provider !== 'local' && l.state === 'present');
+ const nonce = crypto.randomBytes(8).toString('hex');
+ const source = local ? local.key : path.join(scratch, `${obj.id}.${nonce}.source`);
+ const normalized = path.join(scratch, `${obj.id}.${nonce}.normalized`);
+ const backup = path.join(scratch, `${obj.id}.${nonce}.original`);
+ try {
+ if (!local && remote) await remoteDownload(vs(), remote, source);
+ if (!local && !remote) {
+ out.unreadable++;
+ out.orientation_unknown++;
+ if (apply) await markUnknown(obj, metadata, 'source unavailable', db);
+ continue;
+ }
+ const probe = require('sharp')(source, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });
+ const image = await probe.metadata();
+ const hasExif = !!(image.exif && image.exif.length);
+ const hasDetails = hasExif || !!(image.xmp && image.xmp.length) || !!(image.iptc && image.iptc.length);
+ if (hasDetails || (image.orientation && image.orientation !== 1)) {
+ out.normalize++;
+ if (!apply) continue;
+ await normalize(source, normalized, obj.mime_type);
+ const size = fs.statSync(normalized).size;
+ const checksum = await hashFile(normalized);
+ if (local) fs.copyFileSync(local.key, backup);
+ else fs.copyFileSync(source, backup);
+ const changedRemote = [];
+ try {
+ for (const loc of locations.filter((l) => l.provider !== 'local' && l.state === 'present')) {
+ await vs().uploadFile(loc.provider, loc.key, normalized, obj.mime_type);
+ changedRemote.push(loc);
+ }
+ if (local) {
+ fs.renameSync(normalized, local.key);
+ const stamp = now(); fs.utimesSync(local.key, stamp, stamp);
+ }
+ const nextMetadata = { ...metadata, orientation_status: 'orientation applied', orientation_applied: true };
+ await require('../db/database').getDb().tx(async () => {
+ await model.updateObject(obj.id, { content_hash: checksum, size_bytes: size, metadata: nextMetadata });
+ for (const loc of locations) {
+ if (loc.provider === 'local' || changedRemote.some((r) => r.id === loc.id)) {
+ await db.run('UPDATE media_locations SET checksum = ?, size_bytes = ?, verified_at = ov_now(), updated_at = ov_now() WHERE id = ?', [checksum, size, loc.id]);
+ }
+ }
+ });
+ const app = await db.getApp(obj.app_id);
+ if (app) await namespaces.reconcileChain(app, obj.namespace);
+ out.changed++;
+ if (local) {
+ try {
+ await localVariants.get(local.key, { w: 320, fmt: 'webp' });
+ await localVariants.get(local.key, { w: 640, fmt: 'webp' });
+ } catch { out.variant_errors++; }
+ }
+ } catch (err) {
+ for (const loc of changedRemote.reverse()) {
+ try { await vs().uploadFile(loc.provider, loc.key, backup, obj.mime_type); } catch { /* the failed object stays visible in the error count */ }
+ }
+ if (local && fs.existsSync(backup)) fs.copyFileSync(backup, local.key);
+ throw err;
+ }
+ } else {
+ out.orientation_unknown++;
+ out.no_exif++;
+ if (apply) await markUnknown(obj, metadata, 'no orientation data in the original', db);
+ }
+ } catch {
+ out.errors++;
+ } finally {
+ for (const file of [normalized, backup, local ? null : source]) if (file) { try { fs.unlinkSync(file); } catch { /* absent */ } }
+ }
+ }
+ return { ...out, next_after: rows.length ? rows[rows.length - 1].id : after, done: rows.length < batch };
+}
+
+async function markUnknown(obj, metadata, reason, db) {
+ await model.updateObject(obj.id, { metadata: { ...metadata, orientation_status: 'orientation unknown', orientation_reason: reason } });
+}
+
+module.exports = { runBatch, CUTOVER, IMAGE_TYPES };
@@ -1,8 +1,11 @@
@@ -1,8 +1,11 @@
'use strict';
'use strict';
const assert = require('assert');
const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
const sharp = require('sharp');
const sharp = require('sharp');
-const { applyExifOrientation, normalizeImage } = require('../server/images/orientation');
+const { applyExifOrientation, normalizeImage, normalizeImageFile } = require('../server/images/orientation');
async function main() {
async function main() {
for (let orientation = 1; orientation <= 8; orientation++) {
for (let orientation = 1; orientation <= 8; orientation++) {
@@ -21,6 +24,15 @@ async function main() {
@@ -21,6 +24,15 @@ async function main() {
const sharedMeta = await sharp(shared).metadata();
const sharedMeta = await sharp(shared).metadata();
assert.strictEqual(normalizedMeta.orientation, undefined, 'no EXIF orientation remains');
assert.strictEqual(normalizedMeta.orientation, undefined, 'no EXIF orientation remains');
assert.deepStrictEqual([sharedMeta.width, sharedMeta.height], [10, 20], 'shared helper rotates pixels');
assert.deepStrictEqual([sharedMeta.width, sharedMeta.height], [10, 20], 'shared helper rotates pixels');
+ const scratch = fs.mkdtempSync(path.join(os.tmpdir(), 'media-orientation-test-'));
+ try {
+ const input = path.join(scratch, 'source.jpg'), output = path.join(scratch, 'oriented.jpg');
+ fs.writeFileSync(input, marker);
+ await normalizeImageFile(input, output, 'image/jpeg');
+ const fileMeta = await sharp(output).metadata();
+ assert.deepStrictEqual([fileMeta.width, fileMeta.height], [10, 20], 'file-backed normalization rotates pixels');
+ assert.strictEqual(fileMeta.orientation, undefined, 'file-backed normalization removes EXIF');
+ } finally { fs.rmSync(scratch, { recursive: true, force: true }); }
await assert.rejects(normalizeImage(marker, 'image/png'), /does not match/);
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');
}
}
@@ -35,6 +35,7 @@ const https = require('https');
@@ -35,6 +35,7 @@ const https = require('https');
'server/public/descriptions.js': 'the app\'s internal URL (APP_INTERNAL_URLS), a numeric media id in a fixed path',
'server/public/descriptions.js': 'the app\'s internal URL (APP_INTERNAL_URLS), a numeric media id in a fixed path',
'server/jobs/pack.js': 'a presigned GET on B2/R2 (operator-configured buckets) for a segment\'s durable copy',
'server/jobs/pack.js': 'a presigned GET on B2/R2 (operator-configured buckets) for a segment\'s durable copy',
'server/objects/routes.js': 'a ranged GET on a presigned B2/R2 URL (operator-configured buckets) for a packed segment\'s chunk',
'server/objects/routes.js': 'a ranged GET on a presigned B2/R2 URL (operator-configured buckets) for a packed segment\'s chunk',
+ 'server/objects/orientation-backfill.js': 'a presigned GET on an operator-configured B2/R2 provider for a historical object location; provider and key come from Media storage metadata',
'server/s3/routes.js': 'a ranged GET on a presigned B2/R2 URL (operator-configured buckets) for an S3 GetObject whose bytes are not on local disk',
'server/s3/routes.js': 'a ranged GET on a presigned B2/R2 URL (operator-configured buckets) for an S3 GetObject whose bytes are not on local disk',
'server/jobs/previews.js': 'a (ranged) GET on a presigned B2/R2 URL (operator-configured buckets) for a timeline segment, to cut a sprite frame',
'server/jobs/previews.js': 'a (ranged) GET on a presigned B2/R2 URL (operator-configured buckets) for a timeline segment, to cut a sprite frame',
'server/drill.js': 'restore drill: only this server itself',
'server/drill.js': 'restore drill: only this server itself',