openvibers/openvibe.media Commits

Turn a picture by hand, and ask AI which way is up when EXIF did not say (#420)

OP OpenVibers committed 1 days ago · f5318f1fbb · 8 files, +578 −0

* Turn a picture a quarter by hand, and ask AI which way is up when EXIF did not say

* Only a picture marked unknown is offered to the detector

* A picture seen while the detector is unconfigured is still marked unknown

* An EXIF orientation of 1 already settled it: skip it too

Changes

ddiff --git a/README.md b/README.md
iindex b5327e9..f4f0fa7 100644
---- a/README.md
++++ b/README.md
@@@ -653,6 +653,14 @@ model current. Full reference: **[docs/object-model.md](docs/object-model.md)**.
`MEDIA_SIGNING_SECRET`). App key, or a Network service token with
`media.object.upload` / `media.object.read` for namespace `:app`;
`X-OV-Subject` sets the owner. `POST /:id/review` applies a review (see Unsafe-image review).
++ `POST /:id/rotate { degrees | quarter_turns }` turns a ready JPEG/PNG/WebP picture clockwise by 90, 180 or
++ 270 degrees; only its owner (the `X-OV-Subject` or app user it belongs to) may. The stored pixels are turned,
++ the uploader's file is kept beside them as `<file>.original`, and every size follows because the variants are
++ made from the stored file. Each turn is recorded with who and when in the object's `metadata.rotation_audit`.
++ A picture whose EXIF did not settle its orientation is marked `orientation_status: orientation unknown`;
++ with the owner's detector on in OpenVibe.AI (`pics.orientation`; off by default) `server/images/ai-orientation.js`
++ asks it about a 256 px copy and turns the picture only above the owner's confidence. `node
++ scripts/orientation-detect.js [--apply] [--all]` walks the unknown pictures in batches (dry run counts, no spend).
- **Retention holds** — `media_holds` (`moderation`, `dmca`, `creator_pin`,
`admin`, `evidence`; reason, placed by/at, released by/at, note; `/:id/holds`
and the staff routes `/api/v1/:app/admin/storage/holds`, app key only): a held
ddiff --git a/scripts/orientation-detect.js b/scripts/orientation-detect.js
nnew file mode 100644
iindex 0000000..6dcff3f
---- /dev/null
++++ b/scripts/orientation-detect.js
@@@ -0,0 +1,80 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * Run the owner's rotation detector over pictures whose EXIF did not settle the orientation.
++ *
++ * node scripts/orientation-detect.js [--batch 50] [--after <med_id>] [--all] [--apply]
++ *
++ * Dry run by default: it counts the pictures marked "orientation unknown" and spends nothing. --apply asks
++ * OpenVibe.AI for each and turns a picture only above the owner's confidence (the detector's own rule) and only
++ * when the owner has the feature on. --all walks every batch. It never prints metadata, storage keys or people.
++ */
++
++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', ''));
++ if (!Number.isInteger(batch) || batch < 1 || batch > 500) throw new Error('--batch must be an integer from 1 to 500');
++ if (after && !/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(after)) throw new Error('--after must be a Media object id');
++ return { batch, after, all: has('all'), apply: has('apply') };
++}
++
++const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
++const STATUSES = ['unknown', 'turned', 'upright', 'below-confidence', 'off', 'unavailable', 'unreadable', 'errors'];
++
++async function main() {
++ const opts = parseArgs(process.argv.slice(2));
++ const dbApi = require('../server/db/database');
++ await dbApi.initDb();
++ const config = require('../server/config');
++ const model = require('../server/objects/model');
++ const { createDetector } = require('../server/images/ai-orientation');
++ const detector = createDetector({ config, db: dbApi });
++ const totals = Object.fromEntries([['scanned', 0], ['found', 0], ...STATUSES.map((s) => [s, 0])]);
++ totals.batches = 0;
++ let cursor = opts.after;
++ let done = false;
++ try {
++ do {
++ const rows = await dbApi.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 > ?
++ ORDER BY id LIMIT ?`, [...IMAGE_TYPES, cursor, opts.batch]);
++ totals.batches++;
++ for (const row of rows) {
++ totals.scanned++;
++ cursor = row.id;
++ const md = model.parseJson(row.metadata, {});
++ if (md.orientation_status !== 'orientation unknown') continue;
++ totals.found++;
++ if (!opts.apply) continue;
++ try {
++ const out = await detector.detect(row, { apply: true });
++ const key = !out.scanned ? (out.reason === 'disabled' ? 'off' : (out.reason === 'budget' ? 'off' : 'unavailable'))
++ : out.status === 'turned' ? 'turned'
++ : out.status === 'upright' ? 'upright'
++ : out.status === 'below_confidence' ? 'below-confidence'
++ : out.status === 'unreadable' ? 'unreadable' : 'unavailable';
++ totals[key] = (totals[key] || 0) + 1;
++ } catch (err) {
++ totals.errors++;
++ console.error(`orientation-detect: ${row.id}: ${String((err && err.message) || err).slice(0, 180)}`);
++ }
++ }
++ done = rows.length < opts.batch;
++ if (!opts.all) break;
++ } while (!done);
++ totals.done = done;
++ totals.after = cursor;
++ 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-detect: ${err.message}`); process.exitCode = 1; });
++
++module.exports = { parseArgs };
ddiff --git a/server/config.js b/server/config.js
iindex 9c4519c..5e11bcf 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -192,6 +192,23 @@ const config = {
internalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
},
++ // OpenVibe.AI, for the picture-orientation detector (server/images/ai-orientation.js): Media asks the
++ // `pics.orientation` workflow with its own Network service token. The internal URL defaults to the host's own
++ // AI service; with no OV_OAUTH_CLIENT_SECRET there is no token and the detector stays off.
++ ai: {
++ internalUrl: (process.env.OV_AI_INTERNAL_URL || 'http://127.0.0.1:4700').replace(/\/$/, ''),
++ audience: process.env.OV_AI_AUDIENCE || 'openvibe.ai',
++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'media',
++ clientSecret: process.env.OV_OAUTH_CLIENT_SECRET || '',
++ timeoutMs: intEnv('MEDIA_AI_TIMEOUT_MS', 60000),
++ },
++ orientation: {
++ // On upload and in the batch script; MEDIA_ORIENTATION=off stops both. Whether a picture is actually turned
++ // is the owner's setting in AI (off by default), not this one.
++ enabled: !['0', 'false', 'off'].includes(String(process.env.MEDIA_ORIENTATION || '').toLowerCase()),
++ batch: Math.max(1, Math.min(500, intEnv('MEDIA_ORIENTATION_BATCH', 50))),
++ },
++
// Usage readings → OpenVibe.Billing (plan T5 step 14; server/billing.js). Off by default:
// MEDIA_BILLING_INTERVAL_MS=0 starts no timer and aggregates/sends nothing. With an interval but no
// OV_BILLING_URL (or no OV_OAUTH_CLIENT_SECRET) readings are aggregated and stay queued in billing_readings.
ddiff --git a/server/images/ai-orientation.js b/server/images/ai-orientation.js
nnew file mode 100644
iindex 0000000..f7ea748
---- /dev/null
++++ b/server/images/ai-orientation.js
@@@ -0,0 +1,154 @@
++'use strict';
++/**
++ * The model that says which way is up (pictures, 2026-11-11 part 3).
++ *
++ * Media makes a 256 px copy of a stored picture and asks OpenVibe.AI's `pics.orientation` detector, which answers
++ * 0, 90, 180 or 270 degrees with a confidence, under the owner's method, cap, budget and confidence threshold. It runs
++ * only on a picture whose EXIF did not settle the orientation (no orientation value, or details already removed) and
++ * only when the owner has turned the detector on: when it is off, the answer is "off" and nothing changes.
++ *
++ * Above the owner's confidence the picture is turned with the same code as a hand turn, and the answer, the model, the
++ * confidence and the run's receipt are kept in its metadata. Below it, nothing is turned; the picture stays marked
++ * "orientation unknown" so a later run may look again.
++ */
++const fs = require('fs');
++const model = require('../objects/model');
++const { rotateObject } = require('./rotate');
++
++const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
++const THUMB = 256;
++const OFF_TTL_MS = 60_000;
++
++let tokenClient = null;
++let lastOffAt = 0;
++
++/** The local copy of the picture, or null: a remote-only object is left to the owner's next repair. */
++async function localFile(obj) {
++ const locations = await model.listLocations(obj.id);
++ const local = locations.find((l) => l.provider === 'local' && l.state === 'present' && l.key && fs.existsSync(l.key));
++ return local ? local.key : null;
++}
++
++/** A 256 px JPEG copy for the model: the pixels as stored, no EXIF turn (the detector's question is the pixels). */
++async function thumbnail(file, { sharp = null } = {}) {
++ const lib = sharp || require('sharp');
++ const bytes = await lib(file, { limitInputPixels: 40_000_000, failOn: 'error', animated: false })
++ .resize({ width: THUMB, height: THUMB, fit: 'inside', withoutEnlargement: true })
++ .jpeg({ quality: 80 })
++ .toBuffer();
++ return { dataUrl: `data:image/jpeg;base64,${bytes.toString('base64')}`, bytes };
++}
++
++function createDetector({ config, db, log = console, fetchImpl = globalThis.fetch, sharp = null, clock = { now: () => Date.now() } }) {
++ const ai = config.ai || {};
++ const enabled = Boolean(ai.internalUrl && ai.clientSecret) && config.orientation.enabled !== false;
++
++ function tokensFor() {
++ if (!enabled) return null;
++ if (!tokenClient) {
++ const { createServiceTokenClient } = require('openvibe-sdk/auth');
++ tokenClient = createServiceTokenClient({
++ tokenUrl: `${config.network.internalUrl}/oauth/token`,
++ clientId: ai.clientId || 'media', clientSecret: ai.clientSecret,
++ audience: ai.audience || 'openvibe.ai', scope: 'ai.run.create', fetch: fetchImpl,
++ });
++ }
++ return tokenClient;
++ }
++
++ async function ask(dataUrl) {
++ const tokens = tokensFor();
++ const token = await tokens.getToken();
++ const res = await fetchImpl(`${ai.internalUrl}/api/v1/vision/orientation`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` },
++ body: JSON.stringify({ image: { data_url: dataUrl } }),
++ signal: AbortSignal.timeout(ai.timeoutMs || 60000),
++ });
++ const body = await res.json().catch(() => null);
++ return { status: res.status, body };
++ }
++
++ /**
++ * Look at one object and, above the owner's confidence, turn it. `apply` false is a dry look (the try page and the
++ * batch's first pass): it answers what the model said and changes nothing.
++ * → { scanned: true, status, rotation, confidence, turned } | { scanned: false, reason }
++ */
++ async function detect(obj, { apply = true } = {}) {
++ if (!obj || obj.lifecycle_status !== 'ready' || !IMAGE_TYPES.includes(String(obj.mime_type || '').toLowerCase())) return { scanned: false, reason: 'not_image' };
++ const md = model.parseJson(obj.metadata, {});
++ if (md.orientation_status && md.orientation_status !== 'orientation unknown') return { scanned: false, reason: 'already_settled' };
++ const file = await localFile(obj);
++ if (!file) return { scanned: false, reason: 'no_local_copy' };
++ // EXIF did settle it: the pixels are the source of truth and the detector is not asked.
++ const meta = await (sharp || require('sharp'))(file, { limitInputPixels: 40_000_000, failOn: 'error', animated: false }).metadata();
++ if (meta.orientation && Number(meta.orientation) >= 1 && Number(meta.orientation) <= 8) {
++ if (apply && md.orientation_status !== 'orientation applied') {
++ await model.updateObject(obj.id, { metadata: { ...md, orientation_status: 'orientation applied', orientation_source: 'exif' } });
++ }
++ return { scanned: false, reason: 'exif' };
++ }
++ // A no-EXIF picture is "orientation unknown" from the moment it is seen: a later batch can find it even when
++ // the detector is off now or the answer is below the threshold.
++ if (apply && md.orientation_status !== 'orientation unknown') {
++ await model.updateObject(obj.id, { metadata: { ...md, orientation_status: 'orientation unknown', orientation_source: 'none' } });
++ }
++ // Marked before the detector is even configured: a picture seen while the feature is off or unconfigured is
++ // still found by the batch on the day the owner turns it on.
++ if (!enabled) return { scanned: false, reason: 'disabled' };
++ if (!apply) return { scanned: true, rotation: null, confidence: null, turned: false };
++ // A detector that just answered "off" is not asked again for a minute: one uploader's picture does not need
++ // an AI request while the owner has the feature off.
++ if (clock.now() - lastOffAt < OFF_TTL_MS) return { scanned: false, reason: 'off' };
++ let answer;
++ try {
++ const thumb = await thumbnail(file, { sharp });
++ answer = await ask(thumb.dataUrl);
++ } catch (err) {
++ log.warn(`[Orientation] ${obj.id}: OpenVibe.AI did not answer (${String((err && err.message) || err).slice(0, 160)})`);
++ return { scanned: true, status: 'unavailable', turned: false };
++ }
++ if (answer.status === 429) return { scanned: false, reason: 'budget' };
++ if (!answer.body || answer.body.method === 'off') { lastOffAt = clock.now(); return { scanned: false, reason: 'off' }; }
++ if (!answer.body.answered) return { scanned: true, status: 'unavailable', turned: false };
++ const rotation = Number(answer.body.rotation);
++ const confidence = Number(answer.body.confidence);
++ const threshold = Number(answer.body.setting && answer.body.setting.confidence);
++ const ranAt = new Date(clock.now()).toISOString();
++ const attempted = {
++ orientation_checked_at: ranAt,
++ orientation_model_rotation: rotation,
++ orientation_model_confidence: confidence,
++ orientation_model: answer.body.model || null,
++ orientation_receipt: answer.body.receipt_id || null,
++ orientation_cost_usd: answer.body.cost_usd == null ? null : Number(answer.body.cost_usd),
++ };
++ if (![0, 90, 180, 270].includes(rotation) || !Number.isFinite(confidence)) {
++ await model.updateObject(obj.id, { metadata: { ...md, ...attempted, orientation_status: 'orientation unknown', orientation_reason: 'model answer unusable' } });
++ return { scanned: true, status: 'unreadable', rotation: null, confidence: null, turned: false };
++ }
++ if (rotation !== 0 && Number.isFinite(threshold) && confidence >= threshold) {
++ const turned = await rotateObject(obj, rotation, {
++ by: 'svc:media:orientation',
++ extraMetadata: { ...attempted, orientation_source: 'model' },
++ });
++ try {
++ const app = await db.getApp(obj.app_id);
++ if (app) await require('../objects/namespaces').reconcileChain(app, obj.namespace);
++ } catch { /* the hourly reconcile catches it */ }
++ return { scanned: true, status: 'turned', rotation, confidence, turned: true, object: turned };
++ }
++ await model.updateObject(obj.id, {
++ metadata: {
++ ...md, ...attempted,
++ orientation_status: rotation === 0 ? 'orientation checked' : 'orientation unknown',
++ orientation_source: rotation === 0 ? 'model' : 'below_confidence',
++ },
++ });
++ return { scanned: true, status: rotation === 0 ? 'upright' : 'below_confidence', rotation, confidence, turned: false };
++ }
++
++ return { enabled, detect };
++}
++
++module.exports = { createDetector, thumbnail };
ddiff --git a/server/images/rotate.js b/server/images/rotate.js
nnew file mode 100644
iindex 0000000..7e7179f
---- /dev/null
++++ b/server/images/rotate.js
@@@ -0,0 +1,108 @@
++'use strict';
++/**
++ * Turning a stored picture by hand (pictures, 2026-11-11 part 4).
++ *
++ * A quarter turn is applied to the stored pixels, so every size of the picture follows: the variants are made from
++ * the same file, and their cache key carries its name and modification time, so the next request makes new ones.
++ * The file the uploader gave us is never destroyed: before the first turn it is copied beside the stored copy as
++ * `<file>.original`, and every later turn rotates the stored copy from there. Each turn is recorded with who made it
++ * and when in the object's metadata, which is what the audit reads.
++ */
++const fs = require('fs');
++const path = require('path');
++const crypto = require('crypto');
++const model = require('../objects/model');
++
++const IMAGE_TYPES = ['image/jpeg', 'image/png', 'image/webp'];
++
++class RotateError extends Error {
++ constructor(code, status, message) {
++ super(message);
++ this.code = code;
++ this.status = status;
++ }
++}
++
++/** The clockwise turn a request asks for: degrees (90, 180, 270, -90, -180, -270) or quarter_turns (±1, ±2, ±3). */
++function degreesOf(body) {
++ const b = body || {};
++ let degrees = null;
++ if (b.quarter_turns != null) {
++ const q = Number(b.quarter_turns);
++ if (!Number.isInteger(q) || q === 0 || Math.abs(q) > 3) throw new RotateError('media.rotate.invalid', 422, 'quarter_turns must be ±1, ±2 or ±3');
++ degrees = q * 90;
++ } else if (b.degrees != null) {
++ degrees = Number(b.degrees);
++ } else {
++ throw new RotateError('media.rotate.invalid', 422, 'say how far to turn it: degrees (90, 180 or 270) or quarter_turns (±1, ±2, ±3)');
++ }
++ if (!Number.isFinite(degrees) || degrees % 90 !== 0 || Math.abs(degrees) >= 360) {
++ throw new RotateError('media.rotate.invalid', 422, 'a turn is 90, 180 or 270 degrees');
++ }
++ return ((degrees % 360) + 360) % 360;
++}
++
++const sha256File = (file) => 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')));
++});
++
++/** The local copy the bytes are stored in, or a refusal: a picture on a remote copy is not turned in place. */
++async function localCopy(obj) {
++ const locations = await model.listLocations(obj.id);
++ const local = locations.find((l) => l.provider === 'local' && l.state === 'present' && l.key && fs.existsSync(l.key));
++ if (!local) throw new RotateError('media.rotate.no_local_copy', 409, 'This picture is not stored on this host, so it cannot be turned in place');
++ return local;
++}
++
++/**
++ * Turn one stored picture `degrees` clockwise. `by` is the audited actor (`user:usr_…` or `svc:media`), and
++ * `extraMetadata` is merged into the same update (the detector's confidence, model and receipt).
++ * Returns the updated object row.
++ */
++async function rotateObject(obj, degrees, { by = 'unknown', extraMetadata = null, sharp = null } = {}) {
++ if (!obj) throw new RotateError('media.object.not_found', 404, 'No such object');
++ if (!IMAGE_TYPES.includes(String(obj.mime_type || '').toLowerCase())) throw new RotateError('media.rotate.not_image', 415, 'Only a JPEG, PNG or WebP picture can be turned');
++ if (obj.lifecycle_status !== 'ready') throw new RotateError('media.rotate.not_ready', 409, 'The picture is not ready');
++ if (obj.legacy_ref) throw new RotateError('media.rotate.legacy', 409, 'A legacy picture is turned through its own product');
++ const local = await localCopy(obj);
++ const lib = sharp || require('sharp');
++ const tmp = path.join(path.dirname(local.key), `.${path.basename(local.key)}.${process.pid}.${Date.now()}.rotate`);
++ // The first turn keeps the uploader's file beside the stored copy; no later turn touches it again.
++ const original = `${local.key}.original`;
++ try { fs.copyFileSync(local.key, original, fs.constants.COPYFILE_EXCL); } catch { /* already kept */ }
++ try {
++ const image = lib(local.key, { limitInputPixels: 40_000_000, failOn: 'error', animated: false });
++ const meta = await image.metadata();
++ const format = meta.format === 'jpg' ? 'jpeg' : meta.format;
++ if (!['jpeg', 'png', 'webp'].includes(format) || !meta.width || !meta.height) throw new RotateError('media.rotate.not_image', 415, 'The stored file is not a decodable picture');
++ const turned = lib(local.key, { limitInputPixels: 40_000_000, failOn: 'error', animated: false }).rotate(degrees);
++ const out = format === 'jpeg' ? turned.jpeg({ quality: 92, mozjpeg: true }) : format === 'png' ? turned.png() : turned.webp({ quality: 92 });
++ await out.toFile(tmp);
++ const size = fs.statSync(tmp).size;
++ const hash = await sha256File(tmp);
++ fs.renameSync(tmp, local.key);
++ const md = model.parseJson(obj.metadata, {});
++ const previous = Number(md.rotation) || 0;
++ const rotation = (previous + degrees) % 360;
++ const audit = Array.isArray(md.rotation_audit) ? md.rotation_audit.slice(-19) : [];
++ audit.push({ by: String(by).slice(0, 200), degrees, at: new Date().toISOString() });
++ const metadata = {
++ ...md,
++ ...(extraMetadata || {}),
++ rotation,
++ orientation_status: extraMetadata && extraMetadata.orientation_source === 'model' ? 'orientation applied by model' : 'rotated by hand',
++ rotation_audit: audit,
++ rotation_original_kept: true,
++ };
++ await model.updateObject(obj.id, { size_bytes: size, content_hash: hash, metadata });
++ await model.upsertLocation(obj.id, { provider: 'local', key: local.key, state: 'present', size_bytes: size, checksum: hash, verified: true });
++ return await model.getObject(obj.id);
++ } catch (err) {
++ try { fs.unlinkSync(tmp); } catch { /* nothing written */ }
++ if (err instanceof RotateError) throw err;
++ throw new RotateError('media.rotate.failed', 422, `The picture could not be turned: ${String(err.message || err).slice(0, 200)}`);
++ }
++}
++
++module.exports = { rotateObject, degreesOf, RotateError, IMAGE_TYPES };
ddiff --git a/server/objects/routes.js b/server/objects/routes.js
iindex 4e289dd..db2ab07 100644
---- a/server/objects/routes.js
++++ b/server/objects/routes.js
@@@ -15,6 +15,7 @@
* GET / cursor list (?namespace&kind&visibility&status&owner&user_id&include_deleted&limit&cursor)
* DELETE /:id soft delete (bytes kept MEDIA_DELETE_RETENTION_DAYS); 409 when held
* POST /:id/restore undo a soft delete inside the retention period
++ * POST /:id/rotate turn a ready JPEG/PNG/WebP picture 90/180/270 degrees (owner only; audited)
* GET /:id/download 302 to the public location, or a signed short-lived URL for private objects
* GET /:id/holds retention holds (?all=1 includes released), and the holds a clip inherits from its VOD
* POST /:id/holds place a hold { kind, reason, note, placed_by | created_by } (app key only)
@@@ -481,6 +482,16 @@ async function completeUpload(req, res, obj, { expectedHash = null } = {}) {
});
await namespaces.reconcileChain(req.appRow, obj.namespace);
const current = await model.getObject(obj.id);
++ // A new picture whose EXIF did not settle its orientation is offered to the owner's detector (pictures, part 3).
++ // It is a network call to OpenVibe.AI, so it never holds the upload's answer; the detector itself records
++ // "orientation unknown" even when the owner has it off, so a later batch finds the picture.
++ if (config.orientation.enabled && current && current.kind === 'file' && /^image\/(jpeg|png|webp)$/i.test(current.mime_type || '')) {
++ setImmediate(() => {
++ require('../images/ai-orientation').createDetector({ config, db }).detect(current).catch((err) => {
++ console.warn(`[Orientation] upload ${current.id}: ${String((err && err.message) || err).slice(0, 200)}`);
++ });
++ });
++ }
if (current.review_status === 'pending') {
const reviewed = await review.reviewObject(current);
return res.json(await model.objectPublic(reviewed));
@@@ -828,6 +839,32 @@ router.post('/:id/restore', remove, limits('media.object.restore', { minute: 60,
res.json(await model.objectPublic(back));
});
++// A quarter turn by hand (pictures, 2026-11-11 part 4): the owner of the picture, or a first-party service acting for
++// them with X-OV-Subject. The stored file is turned and the original is kept beside it; every size follows because
++// the variants are made from the same file. The who and when stay in the object's metadata.
++router.post('/:id/rotate', upload, limits('media.object.rotate', { minute: 60, hour: 1200 }), async (req, res) => {
++ try {
++ const obj = await load(req, res, 'write');
++ if (!obj) return;
++ const subject = subjectFrom(req);
++ if (subject === undefined) return problem(res, 400, 'media.rotate.invalid', 'X-OV-Subject must be a user subject');
++ const owner = (req.authType === 'user' && obj.owner_user_id != null && obj.owner_user_id === req.userId)
++ || (Boolean(subject) && Boolean(obj.owner_subject) && subject === obj.owner_subject);
++ if (!owner) return problem(res, 403, 'media.rotate.forbidden', 'Only the picture\'s owner can turn it');
++ const { rotateObject, degreesOf } = require('../images/rotate');
++ const degrees = degreesOf(req.body);
++ const by = subject ? `user:${subject}` : `user_id:${req.userId}`;
++ const updated = await rotateObject(obj, degrees, { by });
++ await namespaces.reconcileChain(req.appRow, obj.namespace);
++ const out = await model.objectPublic(updated);
++ return res.json({ ...out, rotation: Number(model.parseJson(updated.metadata, {}).rotation) || 0 });
++ } catch (err) {
++ if (err && err.code && err.status) return problem(res, err.status, err.code, err.message);
++ console.error('[Objects] rotate error:', err.message);
++ return problem(res, 500, 'media.object.rotate_failed', 'Failed to turn the picture');
++ }
++});
++
router.get('/:id/download', read, limits('media.object.download'), async (req, res) => {
const obj = await load(req, res, 'read');
if (!obj) return;
ddiff --git a/test/image-orientation-detect.test.js b/test/image-orientation-detect.test.js
nnew file mode 100644
iindex 0000000..c0199b2
---- /dev/null
++++ b/test/image-orientation-detect.test.js
@@@ -0,0 +1,76 @@
++'use strict';
++// The model-backed detector: a picture whose EXIF did not settle it is marked unknown, asked about at 256 px, and
++// turned only above the owner's confidence; below it nothing changes and the receipt is still kept.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const crypto = require('crypto');
++const sharp = require('sharp');
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-detect-'));
++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++ process.env.VOD_PATH = dir('vods'); process.env.FILES_PATH = dir('files'); process.env.OBJECTS_PATH = dir('objects');
++ process.env.THUMBNAILS_PATH = dir('thumbnails'); process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++ process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret'; process.env.MEDIA_OBJECT_MAX_MB = '1';
++
++ const db = require('../server/db/database');
++ const config = require('../server/config');
++ const model = require('../server/objects/model');
++ const { createDetector } = require('../server/images/ai-orientation');
++ await db.initDb();
++ await db.upsertApp({ app_id: 'media-hub', api_key: 'mh-key' });
++ const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
++
++ let answer = { rotation: 90, confidence: 0.9 };
++ const fetchImpl = async (url) => {
++ if (String(url).includes('/oauth/token')) {
++ return new Response(JSON.stringify({ access_token: 'test-token', token_type: 'Bearer', expires_in: 3600 }), { status: 200, headers: { 'content-type': 'application/json' } });
++ }
++ return new Response(JSON.stringify({ method: 'vision', answered: true, ...answer, setting: { confidence: 0.8 }, model: 'see', receipt_id: 'rcpt_test', cost_usd: 0.0001 }), { status: 200, headers: { 'content-type': 'application/json' } });
++ };
++ const detector = createDetector({ config: { ...config, ai: { internalUrl: 'http://ai.test', clientSecret: 's', clientId: 'media', audience: 'openvibe.ai', timeoutMs: 5000 }, orientation: { enabled: true } }, db, fetchImpl, log: { warn: () => {} } });
++
++ async function picture() {
++ const file = await sharp({ create: { width: 30, height: 20, channels: 3, background: '#3c3' } }).jpeg().toBuffer();
++ const id = await model.createObject({ app_id: 'media-hub', namespace: 'media-hub', kind: 'file', owner_subject: SUBJECT, visibility: 'private', lifecycle_status: 'ready', mime_type: 'image/jpeg', size_bytes: file.length, content_hash: sha(file) });
++ const key = path.join(config.objects.path, `${id}.jpg`);
++ fs.writeFileSync(key, file);
++ await model.updateObject(id, { canonical_provider: 'local', canonical_key: key });
++ await model.upsertLocation(id, { provider: 'local', key, state: 'present', size_bytes: file.length, checksum: sha(file), verified: true });
++ return { id, key, obj: await model.getObject(id) };
++ }
++
++ try {
++ // Above the confidence: marked unknown first, then turned, with the model's answer and receipt kept.
++ const a = await picture();
++ const out = await detector.detect(a.obj);
++ assert.strictEqual(out.status, 'turned', JSON.stringify(out));
++ assert.strictEqual(out.rotation, 90);
++ const md = model.parseJson((await model.getObject(a.id)).metadata, {});
++ assert.strictEqual(md.orientation_status, 'orientation applied by model');
++ assert.strictEqual(md.orientation_model_rotation, 90);
++ assert.strictEqual(md.orientation_model_confidence, 0.9);
++ assert.strictEqual(md.orientation_receipt, 'rcpt_test');
++ assert.strictEqual(md.rotation, 90);
++ const turned = await sharp(a.key).metadata();
++ assert.deepStrictEqual([turned.width, turned.height], [20, 30]);
++
++ // Below the confidence: the answer is kept, the picture is not turned, and it stays unknown for another look.
++ answer = { rotation: 180, confidence: 0.4 };
++ const b = await picture();
++ const low = await detector.detect(b.obj);
++ assert.deepStrictEqual([low.status, low.turned, low.rotation], ['below_confidence', false, 180]);
++ const lowMd = model.parseJson((await model.getObject(b.id)).metadata, {});
++ assert.strictEqual(lowMd.orientation_status, 'orientation unknown');
++ assert.strictEqual(lowMd.orientation_model_confidence, 0.4);
++ const untouched = await sharp(b.key).metadata();
++ assert.deepStrictEqual([untouched.width, untouched.height], [30, 20]);
++ console.log('image orientation detector: 256 px ask, confidence gate, receipt kept');
++ } finally {
++ await db.close().catch(() => {});
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/image-rotate.test.js b/test/image-rotate.test.js
nnew file mode 100644
iindex 0000000..6b54c72
---- /dev/null
++++ b/test/image-rotate.test.js
@@@ -0,0 +1,98 @@
++'use strict';
++// POST /api/v2/:app/objects/:id/rotate: the owner of a stored picture turns it a quarter at a time; the stored
++// bytes are turned, the uploader's file is kept beside them, and the turn is recorded in the object's metadata.
++const assert = require('assert');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++const crypto = require('crypto');
++const express = require('express');
++const sharp = require('sharp');
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-rotate-'));
++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++ process.env.VOD_PATH = dir('vods'); process.env.FILES_PATH = dir('files'); process.env.OBJECTS_PATH = dir('objects');
++ process.env.THUMBNAILS_PATH = dir('thumbnails'); process.env.OV_NETWORK_URL = 'https://openvibe.network';
++ process.env.MEDIA_PUBLIC_URL = 'https://media.test'; process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
++ process.env.MEDIA_OBJECT_MAX_MB = '1'; process.env.MEDIA_PUBLIC_OBJECT_MAX_MB = '2';
++ process.env.MEDIA_ORIENTATION = 'off';
++
++ const db = require('../server/db/database');
++ const auth = require('../server/auth');
++ const model = require('../server/objects/model');
++ const objectRoutes = require('../server/objects/routes');
++
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048, publicKeyEncoding: { type: 'spki', format: 'pem' }, privateKeyEncoding: { type: 'pkcs8', format: 'pem' } });
++ auth._setNetworkPublicKeyForTests(keys.publicKey);
++ await db.upsertApp({ app_id: 'media-hub', api_key: 'mh-key' });
++ const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ const OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPX';
++
++ const app = express();
++ app.put('/api/v2/:app/objects/:id/content', ...objectRoutes.contentHandlers);
++ app.use(express.json());
++ app.use('/api/v2/:app/objects', objectRoutes);
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const call = async (method, p, { bearer = 'mh-key', body, headers = {}, raw } = {}) => {
++ const h = { ...headers };
++ if (bearer) h.authorization = `Bearer ${bearer}`;
++ let payload;
++ if (raw !== undefined) payload = raw;
++ else if (body !== undefined) { h['content-type'] = 'application/json'; payload = JSON.stringify(body); }
++ const res = await fetch(p.startsWith('http') ? p : base + p, { method, headers: h, body: payload, redirect: 'manual' });
++ const text = await res.text();
++ let json = null; try { json = JSON.parse(text); } catch { /* not json */ }
++ return { status: res.status, body: json, text };
++ };
++ const sha = (b) => crypto.createHash('sha256').update(b).digest('hex');
++ const file = await sharp({ create: { width: 30, height: 20, channels: 3, background: '#c33' } }).jpeg().toBuffer();
++
++ try {
++ let r = await call('POST', '/api/v2/media-hub/objects', { headers: { 'X-OV-Subject': SUBJECT }, body: { kind: 'file', mime_type: 'image/jpeg', size_bytes: file.length, filename: 'p.jpg', visibility: 'private', content_hash: sha(file) } });
++ assert.strictEqual(r.status, 201, r.text);
++ const id = r.body.id;
++ r = await call('PUT', `${base}/api/v2/media-hub/objects/${id}/content`, { bearer: 'mh-key', headers: { 'Content-Type': 'application/octet-stream' }, raw: file });
++ assert.strictEqual(r.status, 200, r.text);
++ r = await call('POST', `/api/v2/media-hub/objects/${id}/complete`, { headers: { 'X-OV-Subject': SUBJECT }, body: {} });
++ assert.strictEqual(r.status, 200, r.text);
++
++ // Only the person the picture belongs to may turn it.
++ r = await call('POST', `/api/v2/media-hub/objects/${id}/rotate`, { headers: { 'X-OV-Subject': OTHER }, body: { degrees: 90 } });
++ assert.deepStrictEqual([r.status, r.body.code], [403, 'media.rotate.forbidden']);
++ r = await call('POST', `/api/v2/media-hub/objects/${id}/rotate`, { headers: { 'X-OV-Subject': SUBJECT }, body: { degrees: 45 } });
++ assert.deepStrictEqual([r.status, r.body.code], [422, 'media.rotate.invalid']);
++
++ r = await call('POST', `/api/v2/media-hub/objects/${id}/rotate`, { headers: { 'X-OV-Subject': SUBJECT }, body: { quarter_turns: 1 } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.rotation, 90);
++ assert.strictEqual(r.body.metadata.orientation_status, 'rotated by hand');
++ assert.strictEqual(r.body.metadata.rotation_audit.length, 1);
++ assert.strictEqual(r.body.metadata.rotation_audit[0].by, `user:${SUBJECT}`);
++ assert.strictEqual(r.body.metadata.rotation_original_kept, true);
++
++ const loc = (await model.listLocations(id)).find((l) => l.provider === 'local');
++ const turned = await sharp(loc.key).metadata();
++ assert.deepStrictEqual([turned.width, turned.height], [20, 30], 'the stored pixels are turned');
++ assert.ok(fs.existsSync(`${loc.key}.original`), "the uploader's file is kept beside the stored copy");
++ const original = await sharp(`${loc.key}.original`).metadata();
++ assert.deepStrictEqual([original.width, original.height], [30, 20]);
++
++ // A second quarter turn accumulates, and the kept original is still the first one.
++ r = await call('POST', `/api/v2/media-hub/objects/${id}/rotate`, { headers: { 'X-OV-Subject': SUBJECT }, body: { degrees: 90 } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(r.body.rotation, 180);
++ assert.strictEqual(r.body.metadata.rotation_audit.length, 2);
++ const twice = await sharp(loc.key).metadata();
++ assert.deepStrictEqual([twice.width, twice.height], [30, 20]);
++ assert.strictEqual((await sharp(`${loc.key}.original`).metadata()).width, 30);
++ console.log('image rotate: owner term only, pixels turned, original kept, audit recorded');
++ } finally {
++ await new Promise((r) => server.close(r));
++ await db.close().catch(() => {});
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exitCode = 1; });