openvibers/openvibe.media Commits
Turn a picture by hand, and ask AI which way is up when EXIF did not say (#420)
Changes
@@@ -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 heldnnew file mode 100644@@@ -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 };@@@ -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.nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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;nnew file mode 100644@@@ -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; });nnew file mode 100644@@@ -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; });
@@ -653,6 +653,14 @@ model current. Full reference: **[docs/object-model.md](docs/object-model.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_SIGNING_SECRET`). App key, or a Network service token with
`media.object.upload` / `media.object.read` for namespace `:app`;
`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).
`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`,
- **Retention holds** — `media_holds` (`moderation`, `dmca`, `creator_pin`,
`admin`, `evidence`; reason, placed by/at, released by/at, note; `/:id/holds`
`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
and the staff routes `/api/v1/:app/admin/storage/holds`, app key only): a held
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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 };
@@ -192,6 +192,23 @@ const config = {
@@ -192,6 +192,23 @@ const config = {
internalUrl: (process.env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000').replace(/\/$/, ''),
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:
// 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
// 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.
// OV_BILLING_URL (or no OV_OAUTH_CLIENT_SECRET) readings are aggregated and stay queued in billing_readings.
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,108 @@
@@ -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 };
@@ -15,6 +15,7 @@
@@ -15,6 +15,7 @@
* GET / cursor list (?namespace&kind&visibility&status&owner&user_id&include_deleted&limit&cursor)
* 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
* 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/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/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
* 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)
* 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 } = {}) {
@@ -481,6 +482,16 @@ async function completeUpload(req, res, obj, { expectedHash = null } = {}) {
});
});
await namespaces.reconcileChain(req.appRow, obj.namespace);
await namespaces.reconcileChain(req.appRow, obj.namespace);
const current = await model.getObject(obj.id);
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') {
if (current.review_status === 'pending') {
const reviewed = await review.reviewObject(current);
const reviewed = await review.reviewObject(current);
return res.json(await model.objectPublic(reviewed));
return res.json(await model.objectPublic(reviewed));
@@ -828,6 +839,32 @@ router.post('/:id/restore', remove, limits('media.object.restore', { minute: 60,
@@ -828,6 +839,32 @@ router.post('/:id/restore', remove, limits('media.object.restore', { minute: 60,
res.json(await model.objectPublic(back));
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) => {
router.get('/:id/download', read, limits('media.object.download'), async (req, res) => {
const obj = await load(req, res, 'read');
const obj = await load(req, res, 'read');
if (!obj) return;
if (!obj) return;
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -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; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -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; });