openvibers/openvibe.media Commits

Repair historical image orientation metadata (#390)

OP OpenVibers committed 3 days ago · cc3bdce920 · 6 files, +234 −2

* Add historical image orientation repair

* Classify orientation backfill storage fetch

* Retry pending CI verification

* Merge origin/main into fix/exif-orientation-backfill-v2

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex b3209c6..134fe09 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 WebRTC
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
ddiff --git a/scripts/orientation-backfill.js b/scripts/orientation-backfill.js
nnew file mode 100644
iindex 0000000..4c9373a
---- /dev/null
++++ b/scripts/orientation-backfill.js
@@@ -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 };
ddiff --git a/server/images/orientation.js b/server/images/orientation.js
iindex 5319d32..871b939 100644
---- a/server/images/orientation.js
++++ b/server/images/orientation.js
@@@ -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 };
ddiff --git a/server/objects/orientation-backfill.js b/server/objects/orientation-backfill.js
nnew file mode 100644
iindex 0000000..3947111
---- /dev/null
++++ b/server/objects/orientation-backfill.js
@@@ -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 };
ddiff --git a/test/image-orientation.test.js b/test/image-orientation.test.js
iindex a652d3b..ceee948 100644
---- a/test/image-orientation.test.js
++++ b/test/image-orientation.test.js
@@@ -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');
}
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 04da1fb..47c7b43 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -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',