openvibers/openvibe.mediahub Commits
Pics: alt text, written by the uploader or drafted by OpenVibe.AI when they ask (#79)
Changes
@@@ -77,6 +77,9 @@ MEDIAHUB_PICS_URL=https://openvibe.pics#MEDIAHUB_PICS_ALBUMS_MAX=200#MEDIAHUB_PICS_ALBUM_IMAGES_MAX=500#MEDIAHUB_PICS_REPORTS_TO_HIDE=3++# Alt text drafted by OpenVibe.AI when the uploader asks (needs OV_AI_INTERNAL_URL): off with off; a daily cap.++#MEDIAHUB_PICS_AI=off++#MEDIAHUB_PICS_AI_PER_DAY=300# Signed-out uploads stay off (unset) until the DMCA designated agent and the CSAM hash-matching account exist.#MEDIAHUB_PICS_ANONYMOUS=1#MEDIAHUB_PICS_ANONYMOUS_MAX_IMAGE_BYTES=10485760@@@ -152,6 +152,13 @@ An open, ad-free picture host: drop a picture, get a link to share or embed. Buiat 320, 640, 1280, 1920 or 2560 px (never wider than the picture), made here with sharp from the stored copy(Media's own variants stop at 640 px), kept in a 64 MB LRU and served with the direct link's 10-minute cache; ananimated picture keeps its frames as WebP. The picture page lists them; `pics.image.variant` budgets the misses.++- **Alt text** ([server/pics/describe.js](server/pics/describe.js)): what a screen reader says for a picture, used in++ its page, the thumbnails and every embed snippet (the title when there is none). The uploader writes it, or presses++ "Write alt text with AI": only then does the 640-pixel thumbnail go to OpenVibe.AI's `pics.describe_image` (which++ may use an outside model provider and keeps only the image's hash), and the draft (alt text, a caption labelled++ "Described by AI", tags) is kept until the uploader edits it. At most `MEDIAHUB_PICS_AI_PER_DAY` (300) pictures a++ day for the site, `pics.image.describe` per person; off with `MEDIAHUB_PICS_AI=off` or without `OV_AI_INTERNAL_URL`.++ Network grants `media-hub` the `pics.describe_image` namespace on `ai.run.create`.- **Unlisted:** `/i/<slug>` (8 random characters) is reachable by its link only, noindex, never listed; robots.txtdisallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCodeand HTML.@@@ -174,7 +181,8 @@ API on the pics host (`/api/v1`, a person; app, agent and service tokens are ref|---|---|| `POST /images` | upload: the picture is the body (`Content-Type: image/*`; optional `X-Title`, `X-Filename`, URI-encoded); 201 with the links || `GET /images/:slug` | metadata and links (hidden: uploader and staff only; 410 when taken down) |--| `PATCH /images/:slug` | the uploader: `{ title, description }` |++| `PATCH /images/:slug` | the uploader: `{ title, description, alt }` |++| `POST /images/:slug/describe` | the uploader: OpenVibe.AI drafts the alt text, a caption and tags (the picture goes to OpenVibe.AI only on this call) || `DELETE /images/:slug` | the uploader (signed out: `X-Delete-Key`) || `POST /images/:slug/reports` | anyone: `{ reason, note }`; `reason` one of `child_safety`, `illegal`, `copyright`, `sexual`, `violence`, `harassment`, `private_info`, `spam`, `other` || `GET /me/images` | your pictures, newest first, with your usage |nnew file mode 100644@@@ -0,0 +1,11 @@++-- phase: expand++-- OpenVibe.Pics alt text (server/pics/describe.js): what a screen reader says for a picture, in the page, the++-- thumbnails and every embed snippet. The uploader writes it, or asks OpenVibe.AI for a draft (pics.describe_image,++-- only when they press the button) and keeps or edits it; the caption and tags come with that draft.++ALTER TABLE pics_images ADD COLUMN alt text NOT NULL DEFAULT ''; -- <= 160 characters (enforced in code)++ALTER TABLE pics_images ADD COLUMN alt_source text COLLATE "C"; -- 'person' | 'ai' (the draft as AI wrote it) | NULL none++ALTER TABLE pics_images ADD COLUMN ai_caption text; -- <= 400 characters, labelled as written by AI++ALTER TABLE pics_images ADD COLUMN ai_tags text; -- JSON array of up to six lowercase words++ALTER TABLE pics_images ADD COLUMN ai_described_at text COLLATE "C"; -- the last draft (the daily cap counts these)++CREATE INDEX pics_images_ai_described ON pics_images (ai_described_at) WHERE ai_described_at IS NOT NULL;++ALTER TABLE pics_images ADD CONSTRAINT pics_images_alt_source CHECK (alt_source IS NULL OR alt_source IN ('person', 'ai'));@@@ -86,6 +86,8 @@.ovp-figure img { display: block; max-width: 100%; max-height: 78vh; width: auto; height: auto; object-fit: contain; }.ovp-meta { margin: 10px 0 0; color: var(--ovp-faint); font-size: .88rem; }.ovp-desc { max-width: 70ch; white-space: pre-line; overflow-wrap: anywhere; }++.ovp-ai { max-width: 70ch; margin: 8px 0 0; color: var(--ovp-dim); overflow-wrap: anywhere; }++.ovp-ai-label { display: inline-block; margin-right: 6px; padding: 1px 8px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--ovp-pink) 45%, var(--ovp-line-strong)); color: var(--ovp-accent-text); font-size: .74rem; font-weight: 700; vertical-align: 1px; }.ovp-share { margin-top: 1.6rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 10px 16px; }.ovp-share h2 { grid-column: 1 / -1; margin-bottom: 0; }.ovp-copy { display: grid; gap: 4px; min-width: 0; }@@@ -72,13 +72,14 @@ async function createApp(opts = {}) {const account = createAccount({ db: s.db, config, media, log });const playlists = createPlaylists({ s, config, log });const pics = createPics({ s, media, config, log });++ const picsDescribe = opts.picsDescribe || require('./pics/describe').createPicDescriber({ s, media, config, fetchImpl, log });// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });const syncKeys = createSyncKeys({ s, config });const drops = createDrops({ s, media, config, uploads, log });const videoSearch = require('./video/search-index').createVideoSearchIndex({ s, config, videoAi, fetchImpl, log });-- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, videoAi, syncKeys, drops, videoSearch, fetchImpl, log };++ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, picsDescribe, videoUploads, videoAi, syncKeys, drops, videoSearch, fetchImpl, log };// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.ctx.indexnow = opts.indexnow || (() => {const warn = (...a) => (log.warn || console.warn)(...a);@@@ -113,6 +113,10 @@ function load(env = process.env) {anonymousPerDay: size(env.MEDIAHUB_PICS_ANONYMOUS_PER_DAY, 20),// A signed-out upload is deleted after this many days without a view.anonymousIdleDays: Math.max(1, size(env.MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS, 30)),++ // Alt text drafted by OpenVibe.AI when the uploader asks (server/pics/describe.js): off with++ // MEDIAHUB_PICS_AI=off or without OV_AI_INTERNAL_URL; at most this many drafts a day for the whole site.++ ai: env.MEDIAHUB_PICS_AI !== 'off',++ aiPerDay: size(env.MEDIAHUB_PICS_AI_PER_DAY, 300),},// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one@@@ -59,6 +59,8 @@ const BUDGETS = {// Resize and convert (server/pics/variants.js): each new variant is decoded and encoded here.'pics.image.variant': { minute: 60, hour: 600 },'pics.image.write': { minute: 60, hour: 600 },++ // Alt text from OpenVibe.AI (server/pics/describe.js): one model run each, and the site has a daily cap besides.++ 'pics.image.describe': { minute: 5, hour: 20 },'pics.album.create': { minute: 10, hour: 100 },'pics.report.create': { minute: 5, hour: 30 },};@@@ -8,7 +8,9 @@* POST /images person upload: the picture is the body (Content-Type image/*);* X-Title and X-Filename headers (URI-encoded) are optional* GET /images/:slug anyone a picture's metadata and links (hidden: uploader and staff only)-- * PATCH /images/:slug uploader { title, description }++ * PATCH /images/:slug uploader { title, description, alt }++ * POST /images/:slug/describe uploader OpenVibe.AI drafts its alt text, a caption and tags (the picture++ * goes to OpenVibe.AI; only on this call)* DELETE /images/:slug uploader (signed out: the X-Delete-Key header from the upload)* POST /images/:slug/reports anyone { reason, note }* GET /me/images?before= person your pictures, newest first@@@ -96,7 +98,15 @@ function createPicsApi(ctx) {const row = await loadOwn(req, res);if (!row) return undefined;const b = req.body || {};-- return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description }), { owner: true }));++ return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description, alt: b.alt }), { owner: true }));++ });++++ r.post('/images/:slug/describe', person, limits.budget('pics.image.describe'), async (req, res) => {++ const row = await loadOwn(req, res);++ if (!row) return undefined;++ const out = await ctx.picsDescribe.describe(row);++ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);++ return res.json(pics.toWire(out.row, { owner: true }));});r.delete('/images/:slug', anyone, limits.budget('pics.image.write'), async (req, res) => {nnew file mode 100644@@@ -0,0 +1,86 @@++'use strict';++++/**++ * Alt text for OpenVibe.Pics: the uploader presses "Write alt text with AI" and OpenVibe.AI's pics.describe_image looks++ * at the picture's 640-pixel thumbnail (the first frame of an animation) and drafts alt text, a caption and tags. Only++ * on that press, never by itself: the picture goes to OpenVibe.AI, which may hand it to an outside model provider, and++ * the run keeps only the image's hash (cache_mode none). The draft becomes the picture's alt text (alt_source 'ai')++ * until the uploader edits it.++ *++ * describe(row) → { ok: true, row } | { ok: false, status, code, detail }++ *++ * At most MEDIAHUB_PICS_AI_PER_DAY drafts a day for the whole site (300), on top of each person's pics.image.describe++ * budget. Off without OV_AI_INTERNAL_URL and the OAuth client secret, or with MEDIAHUB_PICS_AI=off.++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++const store = require('./store');++++const clean = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);++++/** AI's output → the columns kept: alt (160), caption (400), up to six short lowercase tags. */++function draftOf(out) {++ if (!out || typeof out !== 'object') return null;++ const alt = clean(out.alt, 160).replace(/^(an? )?(image|picture|photo) of /i, '');++ if (!alt) return null;++ const tags = [...new Set((Array.isArray(out.tags) ? out.tags : []).map((t) => clean(t, 30).toLowerCase()).filter(Boolean))].slice(0, 6);++ return { alt: alt.charAt(0).toUpperCase() + alt.slice(1), caption: clean(out.caption, 400) || null, tags };++}++++function createPicDescriber({ s, media, config, fetchImpl = globalThis.fetch, log = console }) {++ const url = String((config.ai && config.ai.internalUrl) || '').replace(/\/+$/, '');++ const enabled = Boolean(url && config.oauth.clientSecret && config.pics.ai !== false);++ const perDay = Math.max(0, Number(config.pics.aiPerDay) || 0);++ const tokens = enabled ? createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.ai', fetch: fetchImpl,++ }) : null;++ const no = (status, code, detail) => ({ ok: false, status, code, detail });++++ async function describe(row) {++ if (!enabled) return no(503, 'pics.ai_off', 'Alt text from AI is not available here.');++ if (row.state !== 'active') return no(409, 'pics.not_active', 'A hidden picture cannot be described.');++ const dayStart = `${new Date(s.now()).toISOString().slice(0, 10)}T00:00:00.000Z`;++ if (Number(await s.db.value('SELECT count(*) FROM pics_images WHERE ai_described_at >= $1', [dayStart])) >= perDay) {++ return no(429, 'pics.ai_daily_cap', 'OpenVibe.Pics has written all the alt text it can today. Try again tomorrow, or write it yourself.');++ }++ const got = await media.bytes(row.thumb_object_id || row.object_id);++ if (!got.ok) return no(502, 'pics.media_unavailable', 'The picture could not be read just now. Try again in a minute.');++ const bytes = Buffer.from(await got.res.arrayBuffer());++ const type = row.thumb_object_id ? 'image/webp' : row.mime_type;++ try {++ const token = await tokens.getToken();++ const res = await fetchImpl(`${url}/api/v1/runs?wait=45000`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` },++ body: JSON.stringify({++ workflow: 'pics.describe_image',++ input: { image: { data_url: `data:${type};base64,${bytes.toString('base64')}` }, ...(row.title ? { title: clean(row.title, 120) } : {}) },++ target: { service: 'media-hub', type: 'picture', id: row.id },++ idempotency_key: `media-hub.pics.describe:${row.id}:${s.now()}`,++ }),++ signal: AbortSignal.timeout(60_000),++ });++ const body = await res.json().catch(() => null);++ const run = body && body.run;++ if (res.status === 202 || (run && (run.status === 'queued' || run.status === 'running'))) {++ return no(503, 'pics.ai_slow', 'OpenVibe.AI is taking longer than usual. Try again in a minute.');++ }++ const draft = res.ok && run && run.status === 'succeeded' ? draftOf(run.output) : null;++ if (!draft) {++ log.warn(`[OpenVibe.Pics] describe ${row.id}: OpenVibe.AI answered ${res.status}${body && body.code ? ` (${body.code})` : ''}${run && run.error ? ` ${run.error.code || ''}` : ''}`);++ return no(502, 'pics.ai_failed', 'OpenVibe.AI could not describe this picture. Try again later, or write the alt text yourself.');++ }++ const saved = await store.updateImage(s, row.id, {++ alt: draft.alt, alt_source: 'ai', ai_caption: draft.caption, ai_tags: JSON.stringify(draft.tags), ai_described_at: s.iso(),++ });++ return { ok: true, row: saved };++ } catch (err) {++ log.warn(`[OpenVibe.Pics] describe ${row.id}:`, err.message);++ return no(502, 'pics.ai_failed', 'OpenVibe.AI could not be reached. Try again in a minute.');++ }++ }++++ return { enabled, describe };++}++++module.exports = { createPicDescriber, draftOf };@@@ -48,6 +48,8 @@ function createPicsRoutes(ctx) {const base = cfg.url;const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();const r = asyncRouter();++ const describeOn = Boolean(ctx.picsDescribe && ctx.picsDescribe.enabled);++ const tagsOf = (row) => pics.tagsOf(row);const variants = createPicVariants({ media, maxBytes: Number(process.env.MEDIAHUB_PICS_VARIANT_CACHE_BYTES) || 64 * 1024 * 1024 });const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));@@@ -178,7 +180,7 @@ function createPicsRoutes(ctx) {// ── Pages: forms are urlencoded, uploads multipart ──────r.use(express.urlencoded({ extended: false, limit: '64kb' }));-- const thumb = (row) => html`<a class="ovp-tile${row.state !== 'active' ? ' is-hidden' : ''}" href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="${row.title || 'Picture'}" loading="lazy" decoding="async" width="${Math.min(Number(row.width), 640)}" height="${Math.round(Math.min(Number(row.width), 640) * Number(row.height) / Math.max(1, Number(row.width)))}">${row.state !== 'active' ? html`<span class="ovp-flag">hidden</span>` : ''}</a>`;++ const thumb = (row) => html`<a class="ovp-tile${row.state !== 'active' ? ' is-hidden' : ''}" href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="${pics.altOf(row)}" loading="lazy" decoding="async" width="${Math.min(Number(row.width), 640)}" height="${Math.round(Math.min(Number(row.width), 640) * Number(row.height) / Math.max(1, Number(row.width)))}">${row.state !== 'active' ? html`<span class="ovp-flag">hidden</span>` : ''}</a>`;const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);function uploadForm(req, { compact = false } = {}) {@@@ -214,6 +216,8 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><<div class="ovp-feature"><h3>Unlisted by default</h3><p>Only people with the link see a picture. Nothing is listed, ranked or indexed.</p></div><div class="ovp-feature"><h3>Clean files</h3><p>Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.</p></div><div class="ovp-feature"><h3>Albums</h3><p>Put pictures together and share the album with one link.</p></div>++<div class="ovp-feature"><h3>Any size, any format</h3><p>Every picture also comes at 320 to 2560 pixels wide, as WebP, AVIF, JPEG or PNG, each with a link of its own.</p></div>++<div class="ovp-feature"><h3>Alt text</h3><p>Say what a picture shows for people who cannot see it, or let AI draft it when you ask. It goes into every embed snippet.</p></div></section>`,});});@@@ -252,7 +256,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><return html`<div class="ovp-copy"><label for="${id}">${label}</label><div class="ovp-copy-row"><input id="${id}" type="text" readonly value="${value}" spellcheck="false"><button class="ovp-btn" type="button" data-copy="${id}">Copy</button></div></div>`;}-- async function picturePage(req, res, row, { deleteKey = null, fresh = false, message = null } = {}) {++ async function picturePage(req, res, row, { deleteKey = null, fresh = false, message = null, tone = 'ok' } = {}, status = 200) {const who = whoOf(req);const owner = pics.isOwner(row, who);const L = pics.links(row);@@@ -266,14 +270,15 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><image: hidden ? undefined : L.direct, ogType: 'article',scripts: ['js/pics.js'],private: deleteKey ? true : undefined,-- body: html`${message ? notice(message, 'ok') : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}++ body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}<article class="ovp-picture"><h1 class="ovp-title">${title}</h1>--<figure class="ovp-figure"><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${title}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>++<figure class="ovp-figure"><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure><p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}++${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}</article><section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>${copyRow('Link to this page', L.page, 'c-page')}${copyRow('Direct link (for <img> and forums)', L.direct, 'c-direct')}${copyRow('Markdown', L.markdown, 'c-md')}${copyRow('BBCode (forums)', L.bbcode, 'c-bb')}${copyRow('HTML', L.html, 'c-html')}@@@ -287,7 +292,11 @@ ${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours"><form class="ovp-form" method="post" action="/i/${row.slug}/edit"><label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}"><label for="e-desc">Description</label><textarea id="e-desc" name="description" rows="3" maxlength="2000">${row.description}</textarea>++<label for="e-alt">Alt text</label><input id="e-alt" name="alt" type="text" maxlength="160" value="${row.alt || ''}" aria-describedby="e-alt-help">++<p class="muted" id="e-alt-help">What the picture shows, in one sentence, for people who cannot see it. Screen readers say it, and the embed snippets above carry it.${row.alt_source === 'ai' ? ' This one was written by AI: check it says what you want.' : ''}</p><p><button class="ovp-btn ovp-btn--primary" type="submit">Save</button></p></form>++${describeOn && !hidden ? html`<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/describe"><button class="ovp-btn" type="submit">${row.ai_described_at ? 'Write it again with AI' : 'Write alt text with AI'}</button> <span class="muted">Sends this picture to OpenVibe.AI, which may use an outside model provider, to draft alt text, a caption and tags. Nothing of the picture is kept there.</span></form>` : ''}++${tagsOf(row).length ? html`<p class="muted">AI tags: ${tagsOf(row).join(', ')}</p>` : ''}<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/album"><label for="e-album">Add to an album</label><select id="e-album" name="album">${albums.map((a) => html`<option value="${a.slug}">${a.title}</option>`)}<option value="">A new album…</option></select>@@@ -304,7 +313,7 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name<label for="r-note">Anything staff should know (optional)</label><textarea id="r-note" name="note" rows="2" maxlength="500"></textarea><p><button class="ovp-btn" type="submit">Send the report</button> <span class="muted">Copyright owners can also send a notice through <a href="/dmca">the DMCA page</a>.</span></p></form></details>` : ''}`,-- });++ }, status);}r.get('/i/:slug', async (req, res) => {@@@ -323,10 +332,19 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" nameif (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;const row = await ownPicture(req, res);if (!row) return undefined;-- await pics.edit(row, { title: req.body.title, description: req.body.description });++ await pics.edit(row, { title: req.body.title, description: req.body.description, alt: req.body.alt });return res.redirect(303, `/i/${row.slug}`);});++ r.post('/i/:slug/describe', mustSignIn('Sign in to change your picture.'), budgeted('pics.image.describe'), async (req, res) => {++ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;++ const row = await ownPicture(req, res);++ if (!row) return undefined;++ const out = await ctx.picsDescribe.describe(row);++ if (!out.ok) return picturePage(req, res, row, { message: out.detail, tone: 'warn' }, out.status);++ return picturePage(req, res, out.row, { message: 'AI wrote the alt text below. Check it, change it if you like, and save.' });++ });++r.post('/i/:slug/album', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;const row = await ownPicture(req, res);@@@ -505,7 +523,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="resr.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({name: SITE_NAME,summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, no ads.',-- details: 'Pictures are unlisted (reachable only by their link). Each one is re-encoded with its metadata removed. API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',++ details: 'Pictures are unlisted (reachable only by their link). Each one is re-encoded with its metadata removed, and can be had resized and converted at /i/<slug>/w/<320|640|1280|1920|2560>.<webp|avif|jpg|png>. Alt text: the uploader writes it or asks OpenVibe.AI for a draft (POST /api/v1/images/<slug>/describe). API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',sections: [{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }, { title: 'Terms', url: `${base}/terms` }, { title: 'DMCA', url: `${base}/dmca` }] }],})));r.get('/updates', (req, res) => page(req, res, {@@@ -33,7 +33,7 @@ const REASON_TEXT = {sexual: 'Sexual content shared without consent', violence: 'Graphic violence or threats', harassment: 'Harassment or hate',private_info: 'Someone\'s private information', spam: 'Spam or a scam', other: 'Something else',};--const LIMITS = { title: 120, description: 2000, albumTitle: 120, note: 500 };++const LIMITS = { title: 120, description: 2000, alt: 160, albumTitle: 120, note: 500 };// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.const DHASH_DISTANCE = 10;@@@ -127,13 +127,21 @@ function createPics({ s, media, config, log = console }) {return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };}-- async function edit(row, { title, description }) {++ async function edit(row, { title, description, alt }) {++ // Alt text the uploader changes is theirs; an AI draft left as it was stays labelled as AI's.++ const altNow = alt !== undefined ? cleanLine(alt, LIMITS.alt) : undefined;++ const altPatch = altNow === undefined || altNow === (row.alt || '') ? {} : { alt: altNow, alt_source: altNow ? 'person' : null };return store.updateImage(s, row.id, {...(title !== undefined ? { title: cleanLine(title, LIMITS.title) } : {}),...(description !== undefined ? { description: cleanText(description, LIMITS.description) } : {}),++ ...altPatch,});}++ /** What a screen reader says for it: its alt text, else its title. */++ const altOf = (row) => row.alt || row.title || 'Picture on OpenVibe.Pics';++ const tagsOf = (row) => { try { const v = JSON.parse(row.ai_tags || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } };++const hide = (row) => store.updateImage(s, row.id, { state: 'hidden' });async function removeObjects(row) {@@@ -220,7 +228,7 @@ function createPics({ s, media, config, log = console }) {const base = cfg.url;const page = `${base}/i/${row.slug}`;const direct = `${base}/i/${row.slug}.${row.ext}`;-- const alt = row.title || 'Picture on OpenVibe.Pics';++ const alt = altOf(row);return {page, direct, thumb: `${base}/i/${row.slug}/thumb.webp`,markdown: `![${alt.replace(/[[\]]/g, '')}](${direct})`,@@@ -232,6 +240,8 @@ function createPics({ s, media, config, log = console }) {const toWire = (row, { owner = false } = {}) => ({id: row.id, slug: row.slug, title: row.title, description: row.description, mime_type: row.mime_type, ext: row.ext,width: Number(row.width), height: Number(row.height), bytes: Number(row.bytes), animated: Boolean(row.animated),++ alt: row.alt || '', alt_source: row.alt_source || null,++ ai: row.ai_described_at ? { caption: row.ai_caption || null, tags: tagsOf(row), described_at: row.ai_described_at } : null,state: row.state, created_at: row.created_at, links: links(row),...(owner ? { report_count: Number(row.report_count) } : {}),});@@@ -239,7 +249,7 @@ function createPics({ s, media, config, log = console }) {return {cfg, REASONS, REASON_TEXT, LIMITS, SLUG_RE, isSlug: (v) => SLUG_RE.test(String(v || '')),addressId, canSee, isOwner, upload, edit, remove, deleteKeyMatches, report, moderate, viewed, sweep,-- createAlbum, addToAlbum, links, toWire, isStaff,++ createAlbum, addToAlbum, links, toWire, isStaff, altOf, tagsOf,};}@@@ -6,7 +6,7 @@* nobody but its uploader and staff (server/pics/service.js decides who).*/--const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id'];++const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id', 'alt', 'alt_source', 'ai_caption', 'ai_tags', 'ai_described_at'];const imageBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_images WHERE slug = $1', [slug]);const imageById = (s, id) => s.db.maybe('SELECT * FROM pics_images WHERE id = $1', [id]);@@@ -25,7 +25,7 @@ const READ = 'media-hub.api.read';'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write','media-hub.share.create', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create','media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',-- 'pics.album.create', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',++ 'pics.album.create', 'pics.image.describe', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',]);for (const [name, own] of Object.entries(BUDGETS)) {assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++/**++ * Alt text on OpenVibe.Pics (server/pics/describe.js): only the uploader's press sends a picture to OpenVibe.AI++ * (pics.describe_image, the 640-pixel WebP thumbnail as a data URL); the draft becomes the alt text in the page, the++ * thumbnails and the embed snippets, with the caption labelled as AI's. An edit makes the alt text the uploader's;++ * the site's daily cap and AI failures answer plainly; a hidden picture is never sent.++ */++const assert = require('assert');++const http = require('http');++const sharp = require('sharp');++const { boot, check, done } = require('./helpers/boot');++const { draftOf } = require('../server/pics/describe');++++function startAi(handler) {++ const runs = [];++ const server = http.createServer((req, res) => {++ let raw = '';++ req.on('data', (c) => { raw += c; });++ req.on('end', () => {++ const body = raw ? JSON.parse(raw) : null;++ runs.push({ method: req.method, path: req.url, body, auth: req.headers.authorization });++ const out = handler(body);++ res.writeHead(out.status || 200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify(out.body || {}));++ });++ });++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ runs, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));++}++++/** A request with Host: openvibe.pics (Node's fetch drops a Host header). */++function picsClient(t) {++ return (p, o = {}) => new Promise((resolve, reject) => {++ const headers = { Host: 'openvibe.pics', 'sec-fetch-site': 'same-origin', ...(o.headers || {}) };++ if (o.as) headers.cookie = t.signIn(o.as);++ let body = o.body;++ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }++ if (o.form !== undefined) { body = Buffer.from(new URLSearchParams(o.form).toString()); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));++ const u = new URL(t.base + p);++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, text, json: () => JSON.parse(text) }); });++ });++ req.on('error', reject);++ if (body != null) req.write(body);++ req.end();++ });++}++++const OUTPUT = { alt: 'A picture of an orange sky over a green field with a white sun', caption: 'Bright blocks of colour: orange above, a green field below.', tags: ['Sky', 'field', 'sky', 'sun'], gaps: [] };++++(async () => {++ await check('the draft: "image of" goes, the first letter is upper case, tags are lowercase and unique; no alt, no draft', () => {++ assert.deepStrictEqual(draftOf(OUTPUT), { alt: 'An orange sky over a green field with a white sun', caption: 'Bright blocks of colour: orange above, a green field below.', tags: ['sky', 'field', 'sun'] });++ assert.strictEqual(draftOf({ caption: 'x' }), null);++ assert.strictEqual(draftOf(null), null);++ });++++ let mode = 'ok';++ const ai = await startAi(() => {++ if (mode === 'fail') return { status: 503, body: { code: 'ai.unavailable' } };++ if (mode === 'slow') return { status: 202, body: { run: { id: 'run_2', status: 'running' } } };++ return { status: 201, body: { run: { id: 'run_1', status: 'succeeded', output: OUTPUT } } };++ });++ const t0 = await boot({ env: { OV_AI_INTERNAL_URL: ai.url, MEDIAHUB_PICS_AI_PER_DAY: '2' } });++ const t = { ...t0, get: picsClient(t0) };++ try {++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++ const picture = (bg) => sharp({ create: { width: 900, height: 600, channels: 3, background: bg } }).jpeg().toBuffer();++ const up = async (as, bg, title) => (await t.get('/api/v1/images', { as, method: 'POST', body: await picture(bg), headers: { 'content-type': 'image/jpeg', 'x-title': encodeURIComponent(title) } })).json();++ const a = await up(kim, '#f80', 'Sunset');++ const b = await up(kim, '#08f', 'Sea');++ const c = await up(kim, '#0f8', 'Grass');++++ await check('before anyone asks, nothing goes to OpenVibe.AI and the title is the alt text', async () => {++ const pg = await t.get(`/i/${a.slug}`, { as: kim });++ assert.ok(pg.text.includes('alt="Sunset"'));++ assert.ok(pg.text.includes('Write alt text with AI'), 'the uploader sees the button');++ assert.ok(!(await t.get(`/i/${a.slug}`, { as: lee })).text.includes('Write alt text with AI'), 'nobody else does');++ assert.strictEqual(ai.runs.length, 0);++ });++++ await check('the uploader asks: the thumbnail goes as a WebP data URL to pics.describe_image; the draft is the alt text everywhere', async () => {++ const r = await t.get(`/i/${a.slug}/describe`, { as: kim, form: {} });++ assert.strictEqual(r.status, 200, r.text.slice(0, 300));++ assert.ok(r.text.includes('AI wrote the alt text below'));++ assert.strictEqual(ai.runs.length, 1);++ const run = ai.runs[0];++ assert.strictEqual(run.body.workflow, 'pics.describe_image');++ assert.match(run.body.input.image.data_url, /^data:image\/webp;base64,/);++ assert.strictEqual(run.body.input.title, 'Sunset');++ assert.deepStrictEqual(run.body.target, { service: 'media-hub', type: 'picture', id: a.id });++ assert.match(run.auth, /^Bearer /);++++ const pg = await t.get(`/i/${a.slug}`);++ assert.ok(pg.text.includes('alt="An orange sky over a green field with a white sun"'), 'the picture');++ assert.ok(pg.text.includes('Described by AI') && pg.text.includes('Bright blocks of colour'), 'the caption, labelled');++ assert.ok(pg.text.includes(', 'the Markdown snippet');++ const wire = (await t.get(`/api/v1/images/${a.slug}`)).json();++ assert.strictEqual(wire.alt_source, 'ai');++ assert.deepStrictEqual(wire.ai.tags, ['sky', 'field', 'sun']);++ assert.ok((await t.get('/mine', { as: kim })).text.includes('alt="An orange sky over a green field with a white sun"'), 'the thumbnails');++ });++++ await check('only the uploader can ask; a hidden picture is never sent', async () => {++ const r = await t.get(`/api/v1/images/${a.slug}/describe`, { as: lee, method: 'POST', json: {} });++ assert.strictEqual(r.status, 403);++ await t0.ctx.s.db.exec("UPDATE pics_images SET state = 'hidden' WHERE id = $1", [c.id]);++ const h = await t.get(`/api/v1/images/${c.slug}/describe`, { as: kim, method: 'POST', json: {} });++ assert.strictEqual(h.status, 409);++ assert.strictEqual(h.json().code, 'pics.not_active');++ await t0.ctx.s.db.exec("UPDATE pics_images SET state = 'active' WHERE id = $1", [c.id]);++ assert.strictEqual(ai.runs.length, 1);++ });++++ await check('saving the form unchanged keeps it AI\'s; an edit makes it the uploader\'s and the AI caption goes', async () => {++ await t.get(`/i/${a.slug}/edit`, { as: kim, form: { title: 'Sunset', description: '', alt: 'An orange sky over a green field with a white sun' } });++ assert.strictEqual((await t.get(`/api/v1/images/${a.slug}`)).json().alt_source, 'ai');++ await t.get(`/i/${a.slug}/edit`, { as: kim, form: { title: 'Sunset', description: '', alt: 'Sunset over the fields behind our house' } });++ const wire = (await t.get(`/api/v1/images/${a.slug}`)).json();++ assert.deepStrictEqual([wire.alt, wire.alt_source], ['Sunset over the fields behind our house', 'person']);++ assert.ok(!(await t.get(`/i/${a.slug}`)).text.includes('Described by AI'));++ const api = await t.get(`/api/v1/images/${a.slug}`, { as: kim, method: 'PATCH', json: { alt: '' } });++ assert.deepStrictEqual([api.json().alt, api.json().alt_source], ['', null], 'cleared: back to the title');++ });++++ await check('a failure or a slow run answers plainly and keeps what was there', async () => {++ mode = 'fail';++ const f = await t.get(`/api/v1/images/${b.slug}/describe`, { as: kim, method: 'POST', json: {} });++ assert.deepStrictEqual([f.status, f.json().code], [502, 'pics.ai_failed']);++ mode = 'slow';++ const s = await t.get(`/i/${b.slug}/describe`, { as: kim, form: {} });++ assert.strictEqual(s.status, 503);++ assert.ok(s.text.includes('taking longer than usual'));++ assert.strictEqual((await t.get(`/api/v1/images/${b.slug}`)).json().alt, '');++ });++++ await check('the daily cap for the whole site', async () => {++ mode = 'ok';++ assert.strictEqual((await t.get(`/api/v1/images/${b.slug}/describe`, { as: kim, method: 'POST', json: {} })).status, 200);++ const capped = await t.get(`/api/v1/images/${c.slug}/describe`, { as: kim, method: 'POST', json: {} });++ assert.deepStrictEqual([capped.status, capped.json().code], [429, 'pics.ai_daily_cap']);++ });++ } finally {++ await t0.close();++ await ai.close();++ }++++ await check('off: no button and 503 without OV_AI_INTERNAL_URL', async () => {++ const t1 = await boot();++ const t = { ...t1, get: picsClient(t1) };++ try {++ const kim = t.network.addUser('kim');++ const img = await sharp({ create: { width: 400, height: 300, channels: 3, background: '#333' } }).png().toBuffer();++ const up = (await t.get('/api/v1/images', { as: kim, method: 'POST', body: img, headers: { 'content-type': 'image/png' } })).json();++ assert.ok(!(await t.get(`/i/${up.slug}`, { as: kim })).text.includes('Write alt text with AI'));++ const r = await t.get(`/api/v1/images/${up.slug}/describe`, { as: kim, method: 'POST', json: {} });++ assert.deepStrictEqual([r.status, r.json().code], [503, 'pics.ai_off']);++ } finally {++ await t1.close();++ }++ });++++ done();++})();
@@ -77,6 +77,9 @@ MEDIAHUB_PICS_URL=https://openvibe.pics
@@ -77,6 +77,9 @@ MEDIAHUB_PICS_URL=https://openvibe.pics
#MEDIAHUB_PICS_ALBUMS_MAX=200
#MEDIAHUB_PICS_ALBUMS_MAX=200
#MEDIAHUB_PICS_ALBUM_IMAGES_MAX=500
#MEDIAHUB_PICS_ALBUM_IMAGES_MAX=500
#MEDIAHUB_PICS_REPORTS_TO_HIDE=3
#MEDIAHUB_PICS_REPORTS_TO_HIDE=3
+# Alt text drafted by OpenVibe.AI when the uploader asks (needs OV_AI_INTERNAL_URL): off with off; a daily cap.
+#MEDIAHUB_PICS_AI=off
+#MEDIAHUB_PICS_AI_PER_DAY=300
# Signed-out uploads stay off (unset) until the DMCA designated agent and the CSAM hash-matching account exist.
# Signed-out uploads stay off (unset) until the DMCA designated agent and the CSAM hash-matching account exist.
#MEDIAHUB_PICS_ANONYMOUS=1
#MEDIAHUB_PICS_ANONYMOUS=1
#MEDIAHUB_PICS_ANONYMOUS_MAX_IMAGE_BYTES=10485760
#MEDIAHUB_PICS_ANONYMOUS_MAX_IMAGE_BYTES=10485760
@@ -152,6 +152,13 @@ An open, ad-free picture host: drop a picture, get a link to share or embed. Bui
@@ -152,6 +152,13 @@ An open, ad-free picture host: drop a picture, get a link to share or embed. Bui
at 320, 640, 1280, 1920 or 2560 px (never wider than the picture), made here with sharp from the stored copy
at 320, 640, 1280, 1920 or 2560 px (never wider than the picture), made here with sharp from the stored copy
(Media's own variants stop at 640 px), kept in a 64 MB LRU and served with the direct link's 10-minute cache; an
(Media's own variants stop at 640 px), kept in a 64 MB LRU and served with the direct link's 10-minute cache; an
animated picture keeps its frames as WebP. The picture page lists them; `pics.image.variant` budgets the misses.
animated picture keeps its frames as WebP. The picture page lists them; `pics.image.variant` budgets the misses.
+- **Alt text** ([server/pics/describe.js](server/pics/describe.js)): what a screen reader says for a picture, used in
+ its page, the thumbnails and every embed snippet (the title when there is none). The uploader writes it, or presses
+ "Write alt text with AI": only then does the 640-pixel thumbnail go to OpenVibe.AI's `pics.describe_image` (which
+ may use an outside model provider and keeps only the image's hash), and the draft (alt text, a caption labelled
+ "Described by AI", tags) is kept until the uploader edits it. At most `MEDIAHUB_PICS_AI_PER_DAY` (300) pictures a
+ day for the site, `pics.image.describe` per person; off with `MEDIAHUB_PICS_AI=off` or without `OV_AI_INTERNAL_URL`.
+ Network grants `media-hub` the `pics.describe_image` namespace on `ai.run.create`.
- **Unlisted:** `/i/<slug>` (8 random characters) is reachable by its link only, noindex, never listed; robots.txt
- **Unlisted:** `/i/<slug>` (8 random characters) is reachable by its link only, noindex, never listed; robots.txt
disallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCode
disallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCode
and HTML.
and HTML.
@@ -174,7 +181,8 @@ API on the pics host (`/api/v1`, a person; app, agent and service tokens are ref
@@ -174,7 +181,8 @@ API on the pics host (`/api/v1`, a person; app, agent and service tokens are ref
|---|---|
|---|---|
| `POST /images` | upload: the picture is the body (`Content-Type: image/*`; optional `X-Title`, `X-Filename`, URI-encoded); 201 with the links |
| `POST /images` | upload: the picture is the body (`Content-Type: image/*`; optional `X-Title`, `X-Filename`, URI-encoded); 201 with the links |
| `GET /images/:slug` | metadata and links (hidden: uploader and staff only; 410 when taken down) |
| `GET /images/:slug` | metadata and links (hidden: uploader and staff only; 410 when taken down) |
-| `PATCH /images/:slug` | the uploader: `{ title, description }` |
+| `PATCH /images/:slug` | the uploader: `{ title, description, alt }` |
+| `POST /images/:slug/describe` | the uploader: OpenVibe.AI drafts the alt text, a caption and tags (the picture goes to OpenVibe.AI only on this call) |
| `DELETE /images/:slug` | the uploader (signed out: `X-Delete-Key`) |
| `DELETE /images/:slug` | the uploader (signed out: `X-Delete-Key`) |
| `POST /images/:slug/reports` | anyone: `{ reason, note }`; `reason` one of `child_safety`, `illegal`, `copyright`, `sexual`, `violence`, `harassment`, `private_info`, `spam`, `other` |
| `POST /images/:slug/reports` | anyone: `{ reason, note }`; `reason` one of `child_safety`, `illegal`, `copyright`, `sexual`, `violence`, `harassment`, `private_info`, `spam`, `other` |
| `GET /me/images` | your pictures, newest first, with your usage |
| `GET /me/images` | your pictures, newest first, with your usage |
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+-- phase: expand
+-- OpenVibe.Pics alt text (server/pics/describe.js): what a screen reader says for a picture, in the page, the
+-- thumbnails and every embed snippet. The uploader writes it, or asks OpenVibe.AI for a draft (pics.describe_image,
+-- only when they press the button) and keeps or edits it; the caption and tags come with that draft.
+ALTER TABLE pics_images ADD COLUMN alt text NOT NULL DEFAULT ''; -- <= 160 characters (enforced in code)
+ALTER TABLE pics_images ADD COLUMN alt_source text COLLATE "C"; -- 'person' | 'ai' (the draft as AI wrote it) | NULL none
+ALTER TABLE pics_images ADD COLUMN ai_caption text; -- <= 400 characters, labelled as written by AI
+ALTER TABLE pics_images ADD COLUMN ai_tags text; -- JSON array of up to six lowercase words
+ALTER TABLE pics_images ADD COLUMN ai_described_at text COLLATE "C"; -- the last draft (the daily cap counts these)
+CREATE INDEX pics_images_ai_described ON pics_images (ai_described_at) WHERE ai_described_at IS NOT NULL;
+ALTER TABLE pics_images ADD CONSTRAINT pics_images_alt_source CHECK (alt_source IS NULL OR alt_source IN ('person', 'ai'));
@@ -86,6 +86,8 @@
@@ -86,6 +86,8 @@
.ovp-figure img { display: block; max-width: 100%; max-height: 78vh; width: auto; height: auto; object-fit: contain; }
.ovp-figure img { display: block; max-width: 100%; max-height: 78vh; width: auto; height: auto; object-fit: contain; }
.ovp-meta { margin: 10px 0 0; color: var(--ovp-faint); font-size: .88rem; }
.ovp-meta { margin: 10px 0 0; color: var(--ovp-faint); font-size: .88rem; }
.ovp-desc { max-width: 70ch; white-space: pre-line; overflow-wrap: anywhere; }
.ovp-desc { max-width: 70ch; white-space: pre-line; overflow-wrap: anywhere; }
+.ovp-ai { max-width: 70ch; margin: 8px 0 0; color: var(--ovp-dim); overflow-wrap: anywhere; }
+.ovp-ai-label { display: inline-block; margin-right: 6px; padding: 1px 8px; border-radius: 999px; border: 1px solid color-mix(in srgb, var(--ovp-pink) 45%, var(--ovp-line-strong)); color: var(--ovp-accent-text); font-size: .74rem; font-weight: 700; vertical-align: 1px; }
.ovp-share { margin-top: 1.6rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 10px 16px; }
.ovp-share { margin-top: 1.6rem; display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 10px 16px; }
.ovp-share h2 { grid-column: 1 / -1; margin-bottom: 0; }
.ovp-share h2 { grid-column: 1 / -1; margin-bottom: 0; }
.ovp-copy { display: grid; gap: 4px; min-width: 0; }
.ovp-copy { display: grid; gap: 4px; min-width: 0; }
@@ -72,13 +72,14 @@ async function createApp(opts = {}) {
@@ -72,13 +72,14 @@ async function createApp(opts = {}) {
const account = createAccount({ db: s.db, config, media, log });
const account = createAccount({ db: s.db, config, media, log });
const playlists = createPlaylists({ s, config, log });
const playlists = createPlaylists({ s, config, log });
const pics = createPics({ s, media, config, log });
const pics = createPics({ s, media, config, log });
+ const picsDescribe = opts.picsDescribe || require('./pics/describe').createPicDescriber({ s, media, config, fetchImpl, log });
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
// OpenVibe.Video uploads (server/video/uploads.js): Media VODs in this tenant.
const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });
const videoUploads = require('./video/uploads').createUploads({ s, media, config, log });
const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });
const videoAi = opts.videoAi || require('./video/ai-edit').createAiEdit({ s, media, config, fetchImpl, log });
const syncKeys = createSyncKeys({ s, config });
const syncKeys = createSyncKeys({ s, config });
const drops = createDrops({ s, media, config, uploads, log });
const drops = createDrops({ s, media, config, uploads, log });
const videoSearch = require('./video/search-index').createVideoSearchIndex({ s, config, videoAi, fetchImpl, log });
const videoSearch = require('./video/search-index').createVideoSearchIndex({ s, config, videoAi, fetchImpl, log });
- const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, videoUploads, videoAi, syncKeys, drops, videoSearch, fetchImpl, log };
+ const ctx = { config, s, store, keys, sso, principal, media, identity, drive, uploads, account, playlists, pics, picsDescribe, videoUploads, videoAi, syncKeys, drops, videoSearch, fetchImpl, log };
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
// IndexNow for openvibe.video: off without a key; a malformed key warns and stays off rather than stopping the site.
ctx.indexnow = opts.indexnow || (() => {
ctx.indexnow = opts.indexnow || (() => {
const warn = (...a) => (log.warn || console.warn)(...a);
const warn = (...a) => (log.warn || console.warn)(...a);
@@ -113,6 +113,10 @@ function load(env = process.env) {
@@ -113,6 +113,10 @@ function load(env = process.env) {
anonymousPerDay: size(env.MEDIAHUB_PICS_ANONYMOUS_PER_DAY, 20),
anonymousPerDay: size(env.MEDIAHUB_PICS_ANONYMOUS_PER_DAY, 20),
// A signed-out upload is deleted after this many days without a view.
// A signed-out upload is deleted after this many days without a view.
anonymousIdleDays: Math.max(1, size(env.MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS, 30)),
anonymousIdleDays: Math.max(1, size(env.MEDIAHUB_PICS_ANONYMOUS_IDLE_DAYS, 30)),
+ // Alt text drafted by OpenVibe.AI when the uploader asks (server/pics/describe.js): off with
+ // MEDIAHUB_PICS_AI=off or without OV_AI_INTERNAL_URL; at most this many drafts a day for the whole site.
+ ai: env.MEDIAHUB_PICS_AI !== 'off',
+ aiPerDay: size(env.MEDIAHUB_PICS_AI_PER_DAY, 300),
},
},
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
// What one person may keep and do here. Media's own quotas are per tenant (this whole service is one
@@ -59,6 +59,8 @@ const BUDGETS = {
@@ -59,6 +59,8 @@ const BUDGETS = {
// Resize and convert (server/pics/variants.js): each new variant is decoded and encoded here.
// Resize and convert (server/pics/variants.js): each new variant is decoded and encoded here.
'pics.image.variant': { minute: 60, hour: 600 },
'pics.image.variant': { minute: 60, hour: 600 },
'pics.image.write': { minute: 60, hour: 600 },
'pics.image.write': { minute: 60, hour: 600 },
+ // Alt text from OpenVibe.AI (server/pics/describe.js): one model run each, and the site has a daily cap besides.
+ 'pics.image.describe': { minute: 5, hour: 20 },
'pics.album.create': { minute: 10, hour: 100 },
'pics.album.create': { minute: 10, hour: 100 },
'pics.report.create': { minute: 5, hour: 30 },
'pics.report.create': { minute: 5, hour: 30 },
};
};
@@ -8,7 +8,9 @@
@@ -8,7 +8,9 @@
* POST /images person upload: the picture is the body (Content-Type image/*);
* POST /images person upload: the picture is the body (Content-Type image/*);
* X-Title and X-Filename headers (URI-encoded) are optional
* X-Title and X-Filename headers (URI-encoded) are optional
* GET /images/:slug anyone a picture's metadata and links (hidden: uploader and staff only)
* GET /images/:slug anyone a picture's metadata and links (hidden: uploader and staff only)
- * PATCH /images/:slug uploader { title, description }
+ * PATCH /images/:slug uploader { title, description, alt }
+ * POST /images/:slug/describe uploader OpenVibe.AI drafts its alt text, a caption and tags (the picture
+ * goes to OpenVibe.AI; only on this call)
* DELETE /images/:slug uploader (signed out: the X-Delete-Key header from the upload)
* DELETE /images/:slug uploader (signed out: the X-Delete-Key header from the upload)
* POST /images/:slug/reports anyone { reason, note }
* POST /images/:slug/reports anyone { reason, note }
* GET /me/images?before= person your pictures, newest first
* GET /me/images?before= person your pictures, newest first
@@ -96,7 +98,15 @@ function createPicsApi(ctx) {
@@ -96,7 +98,15 @@ function createPicsApi(ctx) {
const row = await loadOwn(req, res);
const row = await loadOwn(req, res);
if (!row) return undefined;
if (!row) return undefined;
const b = req.body || {};
const b = req.body || {};
- return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description }), { owner: true }));
+ return res.json(pics.toWire(await pics.edit(row, { title: b.title, description: b.description, alt: b.alt }), { owner: true }));
+ });
+
+ r.post('/images/:slug/describe', person, limits.budget('pics.image.describe'), async (req, res) => {
+ const row = await loadOwn(req, res);
+ if (!row) return undefined;
+ const out = await ctx.picsDescribe.describe(row);
+ if (!out.ok) return problem(req, res, out.status, out.code, out.detail);
+ return res.json(pics.toWire(out.row, { owner: true }));
});
});
r.delete('/images/:slug', anyone, limits.budget('pics.image.write'), async (req, res) => {
r.delete('/images/:slug', anyone, limits.budget('pics.image.write'), async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,86 @@
@@ -0,0 +1,86 @@
+'use strict';
+
+/**
+ * Alt text for OpenVibe.Pics: the uploader presses "Write alt text with AI" and OpenVibe.AI's pics.describe_image looks
+ * at the picture's 640-pixel thumbnail (the first frame of an animation) and drafts alt text, a caption and tags. Only
+ * on that press, never by itself: the picture goes to OpenVibe.AI, which may hand it to an outside model provider, and
+ * the run keeps only the image's hash (cache_mode none). The draft becomes the picture's alt text (alt_source 'ai')
+ * until the uploader edits it.
+ *
+ * describe(row) → { ok: true, row } | { ok: false, status, code, detail }
+ *
+ * At most MEDIAHUB_PICS_AI_PER_DAY drafts a day for the whole site (300), on top of each person's pics.image.describe
+ * budget. Off without OV_AI_INTERNAL_URL and the OAuth client secret, or with MEDIAHUB_PICS_AI=off.
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+const store = require('./store');
+
+const clean = (v, max) => String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
+
+/** AI's output → the columns kept: alt (160), caption (400), up to six short lowercase tags. */
+function draftOf(out) {
+ if (!out || typeof out !== 'object') return null;
+ const alt = clean(out.alt, 160).replace(/^(an? )?(image|picture|photo) of /i, '');
+ if (!alt) return null;
+ const tags = [...new Set((Array.isArray(out.tags) ? out.tags : []).map((t) => clean(t, 30).toLowerCase()).filter(Boolean))].slice(0, 6);
+ return { alt: alt.charAt(0).toUpperCase() + alt.slice(1), caption: clean(out.caption, 400) || null, tags };
+}
+
+function createPicDescriber({ s, media, config, fetchImpl = globalThis.fetch, log = console }) {
+ const url = String((config.ai && config.ai.internalUrl) || '').replace(/\/+$/, '');
+ const enabled = Boolean(url && config.oauth.clientSecret && config.pics.ai !== false);
+ const perDay = Math.max(0, Number(config.pics.aiPerDay) || 0);
+ const tokens = enabled ? createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.ai', fetch: fetchImpl,
+ }) : null;
+ const no = (status, code, detail) => ({ ok: false, status, code, detail });
+
+ async function describe(row) {
+ if (!enabled) return no(503, 'pics.ai_off', 'Alt text from AI is not available here.');
+ if (row.state !== 'active') return no(409, 'pics.not_active', 'A hidden picture cannot be described.');
+ const dayStart = `${new Date(s.now()).toISOString().slice(0, 10)}T00:00:00.000Z`;
+ if (Number(await s.db.value('SELECT count(*) FROM pics_images WHERE ai_described_at >= $1', [dayStart])) >= perDay) {
+ return no(429, 'pics.ai_daily_cap', 'OpenVibe.Pics has written all the alt text it can today. Try again tomorrow, or write it yourself.');
+ }
+ const got = await media.bytes(row.thumb_object_id || row.object_id);
+ if (!got.ok) return no(502, 'pics.media_unavailable', 'The picture could not be read just now. Try again in a minute.');
+ const bytes = Buffer.from(await got.res.arrayBuffer());
+ const type = row.thumb_object_id ? 'image/webp' : row.mime_type;
+ try {
+ const token = await tokens.getToken();
+ const res = await fetchImpl(`${url}/api/v1/runs?wait=45000`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` },
+ body: JSON.stringify({
+ workflow: 'pics.describe_image',
+ input: { image: { data_url: `data:${type};base64,${bytes.toString('base64')}` }, ...(row.title ? { title: clean(row.title, 120) } : {}) },
+ target: { service: 'media-hub', type: 'picture', id: row.id },
+ idempotency_key: `media-hub.pics.describe:${row.id}:${s.now()}`,
+ }),
+ signal: AbortSignal.timeout(60_000),
+ });
+ const body = await res.json().catch(() => null);
+ const run = body && body.run;
+ if (res.status === 202 || (run && (run.status === 'queued' || run.status === 'running'))) {
+ return no(503, 'pics.ai_slow', 'OpenVibe.AI is taking longer than usual. Try again in a minute.');
+ }
+ const draft = res.ok && run && run.status === 'succeeded' ? draftOf(run.output) : null;
+ if (!draft) {
+ log.warn(`[OpenVibe.Pics] describe ${row.id}: OpenVibe.AI answered ${res.status}${body && body.code ? ` (${body.code})` : ''}${run && run.error ? ` ${run.error.code || ''}` : ''}`);
+ return no(502, 'pics.ai_failed', 'OpenVibe.AI could not describe this picture. Try again later, or write the alt text yourself.');
+ }
+ const saved = await store.updateImage(s, row.id, {
+ alt: draft.alt, alt_source: 'ai', ai_caption: draft.caption, ai_tags: JSON.stringify(draft.tags), ai_described_at: s.iso(),
+ });
+ return { ok: true, row: saved };
+ } catch (err) {
+ log.warn(`[OpenVibe.Pics] describe ${row.id}:`, err.message);
+ return no(502, 'pics.ai_failed', 'OpenVibe.AI could not be reached. Try again in a minute.');
+ }
+ }
+
+ return { enabled, describe };
+}
+
+module.exports = { createPicDescriber, draftOf };
@@ -48,6 +48,8 @@ function createPicsRoutes(ctx) {
@@ -48,6 +48,8 @@ function createPicsRoutes(ctx) {
const base = cfg.url;
const base = cfg.url;
const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();
const host = (() => { try { return new URL(base).hostname; } catch { return 'openvibe.pics'; } })();
const r = asyncRouter();
const r = asyncRouter();
+ const describeOn = Boolean(ctx.picsDescribe && ctx.picsDescribe.enabled);
+ const tagsOf = (row) => pics.tagsOf(row);
const variants = createPicVariants({ media, maxBytes: Number(process.env.MEDIAHUB_PICS_VARIANT_CACHE_BYTES) || 64 * 1024 * 1024 });
const variants = createPicVariants({ media, maxBytes: Number(process.env.MEDIAHUB_PICS_VARIANT_CACHE_BYTES) || 64 * 1024 * 1024 });
const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));
const hasSessionCookie = (req) => Boolean(req.cookies && (req.cookies[ACCESS_COOKIE] || req.cookies[REFRESH_COOKIE]));
@@ -178,7 +180,7 @@ function createPicsRoutes(ctx) {
@@ -178,7 +180,7 @@ function createPicsRoutes(ctx) {
// ── Pages: forms are urlencoded, uploads multipart ──────
// ── Pages: forms are urlencoded, uploads multipart ──────
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
- const thumb = (row) => html`<a class="ovp-tile${row.state !== 'active' ? ' is-hidden' : ''}" href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="${row.title || 'Picture'}" loading="lazy" decoding="async" width="${Math.min(Number(row.width), 640)}" height="${Math.round(Math.min(Number(row.width), 640) * Number(row.height) / Math.max(1, Number(row.width)))}">${row.state !== 'active' ? html`<span class="ovp-flag">hidden</span>` : ''}</a>`;
+ const thumb = (row) => html`<a class="ovp-tile${row.state !== 'active' ? ' is-hidden' : ''}" href="/i/${row.slug}"><img src="/i/${row.slug}/thumb.webp" alt="${pics.altOf(row)}" loading="lazy" decoding="async" width="${Math.min(Number(row.width), 640)}" height="${Math.round(Math.min(Number(row.width), 640) * Number(row.height) / Math.max(1, Number(row.width)))}">${row.state !== 'active' ? html`<span class="ovp-flag">hidden</span>` : ''}</a>`;
const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);
const grid = (rows, empty) => (rows.length ? html`<div class="ovp-grid">${rows.map(thumb)}</div>` : html`<p class="muted">${empty}</p>`);
function uploadForm(req, { compact = false } = {}) {
function uploadForm(req, { compact = false } = {}) {
@@ -214,6 +216,8 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -214,6 +216,8 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
<div class="ovp-feature"><h3>Unlisted by default</h3><p>Only people with the link see a picture. Nothing is listed, ranked or indexed.</p></div>
<div class="ovp-feature"><h3>Unlisted by default</h3><p>Only people with the link see a picture. Nothing is listed, ranked or indexed.</p></div>
<div class="ovp-feature"><h3>Clean files</h3><p>Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.</p></div>
<div class="ovp-feature"><h3>Clean files</h3><p>Every picture is re-encoded, and its GPS position, camera serial and other hidden details are removed before anyone sees it.</p></div>
<div class="ovp-feature"><h3>Albums</h3><p>Put pictures together and share the album with one link.</p></div>
<div class="ovp-feature"><h3>Albums</h3><p>Put pictures together and share the album with one link.</p></div>
+<div class="ovp-feature"><h3>Any size, any format</h3><p>Every picture also comes at 320 to 2560 pixels wide, as WebP, AVIF, JPEG or PNG, each with a link of its own.</p></div>
+<div class="ovp-feature"><h3>Alt text</h3><p>Say what a picture shows for people who cannot see it, or let AI draft it when you ask. It goes into every embed snippet.</p></div>
</section>`,
</section>`,
});
});
});
});
@@ -252,7 +256,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -252,7 +256,7 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
return html`<div class="ovp-copy"><label for="${id}">${label}</label><div class="ovp-copy-row"><input id="${id}" type="text" readonly value="${value}" spellcheck="false"><button class="ovp-btn" type="button" data-copy="${id}">Copy</button></div></div>`;
return html`<div class="ovp-copy"><label for="${id}">${label}</label><div class="ovp-copy-row"><input id="${id}" type="text" readonly value="${value}" spellcheck="false"><button class="ovp-btn" type="button" data-copy="${id}">Copy</button></div></div>`;
}
}
- async function picturePage(req, res, row, { deleteKey = null, fresh = false, message = null } = {}) {
+ async function picturePage(req, res, row, { deleteKey = null, fresh = false, message = null, tone = 'ok' } = {}, status = 200) {
const who = whoOf(req);
const who = whoOf(req);
const owner = pics.isOwner(row, who);
const owner = pics.isOwner(row, who);
const L = pics.links(row);
const L = pics.links(row);
@@ -266,14 +270,15 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
@@ -266,14 +270,15 @@ ${signedIn(req) ? html`<section class="ovp-section" aria-labelledby="h-latest"><
image: hidden ? undefined : L.direct, ogType: 'article',
image: hidden ? undefined : L.direct, ogType: 'article',
scripts: ['js/pics.js'],
scripts: ['js/pics.js'],
private: deleteKey ? true : undefined,
private: deleteKey ? true : undefined,
- body: html`${message ? notice(message, 'ok') : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}
+ body: html`${message ? notice(message, tone) : ''}${fresh || req.query.new ? notice('Uploaded. Copy a link below to share it.', 'ok') : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
${deleteKey ? html`<div class="notice warn" role="status"><b>Keep this delete link.</b> It is the only way to delete this picture, and it is shown only now:<br><code class="ovp-key">${base}/i/${row.slug}/delete?key=${deleteKey}</code></div>` : ''}
${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}
${hidden ? notice(row.state === 'hidden' ? 'This picture is hidden while staff look at reports about it. Only you and staff can see it.' : 'This picture was taken down.', 'warn') : ''}
<article class="ovp-picture">
<article class="ovp-picture">
<h1 class="ovp-title">${title}</h1>
<h1 class="ovp-title">${title}</h1>
-<figure class="ovp-figure"><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${title}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
+<figure class="ovp-figure"><a href="/i/${row.slug}.${row.ext}"><img src="/i/${row.slug}.${row.ext}" alt="${pics.altOf(row)}" width="${row.width}" height="${row.height}" decoding="async"></a></figure>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
<p class="ovp-meta">${Number(row.width)} × ${Number(row.height)} · ${KIND[row.ext] || row.ext}${row.animated ? ' (animated)' : ''} · ${formatBytes(row.bytes)} · uploaded ${time(row.created_at)}</p>
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
${row.description ? html`<p class="ovp-desc">${row.description}</p>` : ''}
+${row.ai_caption && row.alt_source === 'ai' ? html`<p class="ovp-ai"><span class="ovp-ai-label">Described by AI</span> ${row.ai_caption}</p>` : ''}
</article>
</article>
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
<section class="ovp-share" aria-labelledby="h-share"><h2 id="h-share">Share and embed</h2>
${copyRow('Link to this page', L.page, 'c-page')}${copyRow('Direct link (for <img> and forums)', L.direct, 'c-direct')}${copyRow('Markdown', L.markdown, 'c-md')}${copyRow('BBCode (forums)', L.bbcode, 'c-bb')}${copyRow('HTML', L.html, 'c-html')}
${copyRow('Link to this page', L.page, 'c-page')}${copyRow('Direct link (for <img> and forums)', L.direct, 'c-direct')}${copyRow('Markdown', L.markdown, 'c-md')}${copyRow('BBCode (forums)', L.bbcode, 'c-bb')}${copyRow('HTML', L.html, 'c-html')}
@@ -287,7 +292,11 @@ ${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours">
@@ -287,7 +292,11 @@ ${owner ? html`<section class="ovp-section ovp-owner" aria-labelledby="h-yours">
<form class="ovp-form" method="post" action="/i/${row.slug}/edit">
<form class="ovp-form" method="post" action="/i/${row.slug}/edit">
<label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}">
<label for="e-title">Title</label><input id="e-title" name="title" type="text" maxlength="120" value="${row.title}">
<label for="e-desc">Description</label><textarea id="e-desc" name="description" rows="3" maxlength="2000">${row.description}</textarea>
<label for="e-desc">Description</label><textarea id="e-desc" name="description" rows="3" maxlength="2000">${row.description}</textarea>
+<label for="e-alt">Alt text</label><input id="e-alt" name="alt" type="text" maxlength="160" value="${row.alt || ''}" aria-describedby="e-alt-help">
+<p class="muted" id="e-alt-help">What the picture shows, in one sentence, for people who cannot see it. Screen readers say it, and the embed snippets above carry it.${row.alt_source === 'ai' ? ' This one was written by AI: check it says what you want.' : ''}</p>
<p><button class="ovp-btn ovp-btn--primary" type="submit">Save</button></p></form>
<p><button class="ovp-btn ovp-btn--primary" type="submit">Save</button></p></form>
+${describeOn && !hidden ? html`<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/describe"><button class="ovp-btn" type="submit">${row.ai_described_at ? 'Write it again with AI' : 'Write alt text with AI'}</button> <span class="muted">Sends this picture to OpenVibe.AI, which may use an outside model provider, to draft alt text, a caption and tags. Nothing of the picture is kept there.</span></form>` : ''}
+${tagsOf(row).length ? html`<p class="muted">AI tags: ${tagsOf(row).join(', ')}</p>` : ''}
<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/album">
<form class="ovp-form ovp-inline" method="post" action="/i/${row.slug}/album">
<label for="e-album">Add to an album</label>
<label for="e-album">Add to an album</label>
<select id="e-album" name="album">${albums.map((a) => html`<option value="${a.slug}">${a.title}</option>`)}<option value="">A new album…</option></select>
<select id="e-album" name="album">${albums.map((a) => html`<option value="${a.slug}">${a.title}</option>`)}<option value="">A new album…</option></select>
@@ -304,7 +313,7 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
@@ -304,7 +313,7 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
<label for="r-note">Anything staff should know (optional)</label><textarea id="r-note" name="note" rows="2" maxlength="500"></textarea>
<label for="r-note">Anything staff should know (optional)</label><textarea id="r-note" name="note" rows="2" maxlength="500"></textarea>
<p><button class="ovp-btn" type="submit">Send the report</button> <span class="muted">Copyright owners can also send a notice through <a href="/dmca">the DMCA page</a>.</span></p>
<p><button class="ovp-btn" type="submit">Send the report</button> <span class="muted">Copyright owners can also send a notice through <a href="/dmca">the DMCA page</a>.</span></p>
</form></details>` : ''}`,
</form></details>` : ''}`,
- });
+ }, status);
}
}
r.get('/i/:slug', async (req, res) => {
r.get('/i/:slug', async (req, res) => {
@@ -323,10 +332,19 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
@@ -323,10 +332,19 @@ ${pics.REASONS.map((r) => html`<label class="ovp-radio"><input type="radio" name
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
const row = await ownPicture(req, res);
const row = await ownPicture(req, res);
if (!row) return undefined;
if (!row) return undefined;
- await pics.edit(row, { title: req.body.title, description: req.body.description });
+ await pics.edit(row, { title: req.body.title, description: req.body.description, alt: req.body.alt });
return res.redirect(303, `/i/${row.slug}`);
return res.redirect(303, `/i/${row.slug}`);
});
});
+ r.post('/i/:slug/describe', mustSignIn('Sign in to change your picture.'), budgeted('pics.image.describe'), async (req, res) => {
+ if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
+ const row = await ownPicture(req, res);
+ if (!row) return undefined;
+ const out = await ctx.picsDescribe.describe(row);
+ if (!out.ok) return picturePage(req, res, row, { message: out.detail, tone: 'warn' }, out.status);
+ return picturePage(req, res, out.row, { message: 'AI wrote the alt text below. Check it, change it if you like, and save.' });
+ });
+
r.post('/i/:slug/album', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {
r.post('/i/:slug/album', mustSignIn('Sign in to use albums.'), budgeted('pics.image.write'), async (req, res) => {
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
if (!sameSite(req, res, `/i/${req.params.slug}`)) return undefined;
const row = await ownPicture(req, res);
const row = await ownPicture(req, res);
@@ -505,7 +523,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
@@ -505,7 +523,7 @@ ${row.state === 'hidden' ? html`<button class="ovp-btn" name="action" value="res
r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
r.get('/llms.txt', (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 })).send(seo.llmsTxt({
name: SITE_NAME,
name: SITE_NAME,
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, no ads.',
summary: 'Picture hosting on the OpenVibe network: upload a picture, get a link to share or embed, albums, no ads.',
- details: 'Pictures are unlisted (reachable only by their link). Each one is re-encoded with its metadata removed. API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',
+ details: 'Pictures are unlisted (reachable only by their link). Each one is re-encoded with its metadata removed, and can be had resized and converted at /i/<slug>/w/<320|640|1280|1920|2560>.<webp|avif|jpg|png>. Alt text: the uploader writes it or asks OpenVibe.AI for a draft (POST /api/v1/images/<slug>/describe). API: POST /api/v1/images with the picture as the body (an OpenVibe sign-in token as a Bearer).',
sections: [{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }, { title: 'Terms', url: `${base}/terms` }, { title: 'DMCA', url: `${base}/dmca` }] }],
sections: [{ title: 'Start here', links: [{ title: SITE_NAME, url: `${base}/` }, { title: 'Terms', url: `${base}/terms` }, { title: 'DMCA', url: `${base}/dmca` }] }],
})));
})));
r.get('/updates', (req, res) => page(req, res, {
r.get('/updates', (req, res) => page(req, res, {
@@ -33,7 +33,7 @@ const REASON_TEXT = {
@@ -33,7 +33,7 @@ const REASON_TEXT = {
sexual: 'Sexual content shared without consent', violence: 'Graphic violence or threats', harassment: 'Harassment or hate',
sexual: 'Sexual content shared without consent', violence: 'Graphic violence or threats', harassment: 'Harassment or hate',
private_info: 'Someone\'s private information', spam: 'Spam or a scam', other: 'Something else',
private_info: 'Someone\'s private information', spam: 'Spam or a scam', other: 'Something else',
};
};
-const LIMITS = { title: 120, description: 2000, albumTitle: 120, note: 500 };
+const LIMITS = { title: 120, description: 2000, alt: 160, albumTitle: 120, note: 500 };
// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:
// A difference hash this close (of 64 bits) to a blocked one is the same picture, resized, re-compressed or retouched:
// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.
// measured 2026-10-15, such copies of real photos land 0-10 bits away and different photos 23-42.
const DHASH_DISTANCE = 10;
const DHASH_DISTANCE = 10;
@@ -127,13 +127,21 @@ function createPics({ s, media, config, log = console }) {
@@ -127,13 +127,21 @@ function createPics({ s, media, config, log = console }) {
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };
return { ok: true, image: row, ...(deleteKey ? { deleteKey } : {}) };
}
}
- async function edit(row, { title, description }) {
+ async function edit(row, { title, description, alt }) {
+ // Alt text the uploader changes is theirs; an AI draft left as it was stays labelled as AI's.
+ const altNow = alt !== undefined ? cleanLine(alt, LIMITS.alt) : undefined;
+ const altPatch = altNow === undefined || altNow === (row.alt || '') ? {} : { alt: altNow, alt_source: altNow ? 'person' : null };
return store.updateImage(s, row.id, {
return store.updateImage(s, row.id, {
...(title !== undefined ? { title: cleanLine(title, LIMITS.title) } : {}),
...(title !== undefined ? { title: cleanLine(title, LIMITS.title) } : {}),
...(description !== undefined ? { description: cleanText(description, LIMITS.description) } : {}),
...(description !== undefined ? { description: cleanText(description, LIMITS.description) } : {}),
+ ...altPatch,
});
});
}
}
+ /** What a screen reader says for it: its alt text, else its title. */
+ const altOf = (row) => row.alt || row.title || 'Picture on OpenVibe.Pics';
+ const tagsOf = (row) => { try { const v = JSON.parse(row.ai_tags || '[]'); return Array.isArray(v) ? v : []; } catch { return []; } };
+
const hide = (row) => store.updateImage(s, row.id, { state: 'hidden' });
const hide = (row) => store.updateImage(s, row.id, { state: 'hidden' });
async function removeObjects(row) {
async function removeObjects(row) {
@@ -220,7 +228,7 @@ function createPics({ s, media, config, log = console }) {
@@ -220,7 +228,7 @@ function createPics({ s, media, config, log = console }) {
const base = cfg.url;
const base = cfg.url;
const page = `${base}/i/${row.slug}`;
const page = `${base}/i/${row.slug}`;
const direct = `${base}/i/${row.slug}.${row.ext}`;
const direct = `${base}/i/${row.slug}.${row.ext}`;
- const alt = row.title || 'Picture on OpenVibe.Pics';
+ const alt = altOf(row);
return {
return {
page, direct, thumb: `${base}/i/${row.slug}/thumb.webp`,
page, direct, thumb: `${base}/i/${row.slug}/thumb.webp`,
markdown: `![${alt.replace(/[[\]]/g, '')}](${direct})`,
markdown: `![${alt.replace(/[[\]]/g, '')}](${direct})`,
@@ -232,6 +240,8 @@ function createPics({ s, media, config, log = console }) {
@@ -232,6 +240,8 @@ function createPics({ s, media, config, log = console }) {
const toWire = (row, { owner = false } = {}) => ({
const toWire = (row, { owner = false } = {}) => ({
id: row.id, slug: row.slug, title: row.title, description: row.description, mime_type: row.mime_type, ext: row.ext,
id: row.id, slug: row.slug, title: row.title, description: row.description, mime_type: row.mime_type, ext: row.ext,
width: Number(row.width), height: Number(row.height), bytes: Number(row.bytes), animated: Boolean(row.animated),
width: Number(row.width), height: Number(row.height), bytes: Number(row.bytes), animated: Boolean(row.animated),
+ alt: row.alt || '', alt_source: row.alt_source || null,
+ ai: row.ai_described_at ? { caption: row.ai_caption || null, tags: tagsOf(row), described_at: row.ai_described_at } : null,
state: row.state, created_at: row.created_at, links: links(row),
state: row.state, created_at: row.created_at, links: links(row),
...(owner ? { report_count: Number(row.report_count) } : {}),
...(owner ? { report_count: Number(row.report_count) } : {}),
});
});
@@ -239,7 +249,7 @@ function createPics({ s, media, config, log = console }) {
@@ -239,7 +249,7 @@ function createPics({ s, media, config, log = console }) {
return {
return {
cfg, REASONS, REASON_TEXT, LIMITS, SLUG_RE, isSlug: (v) => SLUG_RE.test(String(v || '')),
cfg, REASONS, REASON_TEXT, LIMITS, SLUG_RE, isSlug: (v) => SLUG_RE.test(String(v || '')),
addressId, canSee, isOwner, upload, edit, remove, deleteKeyMatches, report, moderate, viewed, sweep,
addressId, canSee, isOwner, upload, edit, remove, deleteKeyMatches, report, moderate, viewed, sweep,
- createAlbum, addToAlbum, links, toWire, isStaff,
+ createAlbum, addToAlbum, links, toWire, isStaff, altOf, tagsOf,
};
};
}
}
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
* nobody but its uploader and staff (server/pics/service.js decides who).
* nobody but its uploader and staff (server/pics/service.js decides who).
*/
*/
-const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id'];
+const IMAGE_COLS = ['title', 'description', 'state', 'report_count', 'last_viewed_at', 'thumb_object_id', 'alt', 'alt_source', 'ai_caption', 'ai_tags', 'ai_described_at'];
const imageBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_images WHERE slug = $1', [slug]);
const imageBySlug = (s, slug) => s.db.maybe('SELECT * FROM pics_images WHERE slug = $1', [slug]);
const imageById = (s, id) => s.db.maybe('SELECT * FROM pics_images WHERE id = $1', [id]);
const imageById = (s, id) => s.db.maybe('SELECT * FROM pics_images WHERE id = $1', [id]);
@@ -25,7 +25,7 @@ const READ = 'media-hub.api.read';
@@ -25,7 +25,7 @@ const READ = 'media-hub.api.read';
'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
'media-hub.drop.start', 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
'media-hub.share.create', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
'media-hub.share.create', 'media-hub.sync.upload.complete', 'media-hub.sync.upload.start', 'media-hub.sync_key.create',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
- 'pics.album.create', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',
+ 'pics.album.create', 'pics.image.describe', 'pics.image.upload', 'pics.image.variant', 'pics.image.write', 'pics.report.create',
]);
]);
for (const [name, own] of Object.entries(BUDGETS)) {
for (const [name, own] of Object.entries(BUDGETS)) {
assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
assert.ok(own.minute > 0 && own.hour >= own.minute, `${name}: minute and hour`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+/**
+ * Alt text on OpenVibe.Pics (server/pics/describe.js): only the uploader's press sends a picture to OpenVibe.AI
+ * (pics.describe_image, the 640-pixel WebP thumbnail as a data URL); the draft becomes the alt text in the page, the
+ * thumbnails and the embed snippets, with the caption labelled as AI's. An edit makes the alt text the uploader's;
+ * the site's daily cap and AI failures answer plainly; a hidden picture is never sent.
+ */
+const assert = require('assert');
+const http = require('http');
+const sharp = require('sharp');
+const { boot, check, done } = require('./helpers/boot');
+const { draftOf } = require('../server/pics/describe');
+
+function startAi(handler) {
+ const runs = [];
+ const server = http.createServer((req, res) => {
+ let raw = '';
+ req.on('data', (c) => { raw += c; });
+ req.on('end', () => {
+ const body = raw ? JSON.parse(raw) : null;
+ runs.push({ method: req.method, path: req.url, body, auth: req.headers.authorization });
+ const out = handler(body);
+ res.writeHead(out.status || 200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(out.body || {}));
+ });
+ });
+ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({ runs, url: `http://127.0.0.1:${server.address().port}`, close: () => new Promise((r) => server.close(r)) })));
+}
+
+/** A request with Host: openvibe.pics (Node's fetch drops a Host header). */
+function picsClient(t) {
+ return (p, o = {}) => new Promise((resolve, reject) => {
+ const headers = { Host: 'openvibe.pics', 'sec-fetch-site': 'same-origin', ...(o.headers || {}) };
+ if (o.as) headers.cookie = t.signIn(o.as);
+ let body = o.body;
+ if (o.json !== undefined) { body = Buffer.from(JSON.stringify(o.json)); headers['content-type'] = 'application/json'; }
+ if (o.form !== undefined) { body = Buffer.from(new URLSearchParams(o.form).toString()); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ if (body != null) headers['content-length'] = String(Buffer.byteLength(body));
+ const u = new URL(t.base + p);
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method: o.method || (body != null ? 'POST' : 'GET'), headers }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => { const text = Buffer.concat(chunks).toString('utf8'); resolve({ status: res.statusCode, text, json: () => JSON.parse(text) }); });
+ });
+ req.on('error', reject);
+ if (body != null) req.write(body);
+ req.end();
+ });
+}
+
+const OUTPUT = { alt: 'A picture of an orange sky over a green field with a white sun', caption: 'Bright blocks of colour: orange above, a green field below.', tags: ['Sky', 'field', 'sky', 'sun'], gaps: [] };
+
+(async () => {
+ await check('the draft: "image of" goes, the first letter is upper case, tags are lowercase and unique; no alt, no draft', () => {
+ assert.deepStrictEqual(draftOf(OUTPUT), { alt: 'An orange sky over a green field with a white sun', caption: 'Bright blocks of colour: orange above, a green field below.', tags: ['sky', 'field', 'sun'] });
+ assert.strictEqual(draftOf({ caption: 'x' }), null);
+ assert.strictEqual(draftOf(null), null);
+ });
+
+ let mode = 'ok';
+ const ai = await startAi(() => {
+ if (mode === 'fail') return { status: 503, body: { code: 'ai.unavailable' } };
+ if (mode === 'slow') return { status: 202, body: { run: { id: 'run_2', status: 'running' } } };
+ return { status: 201, body: { run: { id: 'run_1', status: 'succeeded', output: OUTPUT } } };
+ });
+ const t0 = await boot({ env: { OV_AI_INTERNAL_URL: ai.url, MEDIAHUB_PICS_AI_PER_DAY: '2' } });
+ const t = { ...t0, get: picsClient(t0) };
+ try {
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+ const picture = (bg) => sharp({ create: { width: 900, height: 600, channels: 3, background: bg } }).jpeg().toBuffer();
+ const up = async (as, bg, title) => (await t.get('/api/v1/images', { as, method: 'POST', body: await picture(bg), headers: { 'content-type': 'image/jpeg', 'x-title': encodeURIComponent(title) } })).json();
+ const a = await up(kim, '#f80', 'Sunset');
+ const b = await up(kim, '#08f', 'Sea');
+ const c = await up(kim, '#0f8', 'Grass');
+
+ await check('before anyone asks, nothing goes to OpenVibe.AI and the title is the alt text', async () => {
+ const pg = await t.get(`/i/${a.slug}`, { as: kim });
+ assert.ok(pg.text.includes('alt="Sunset"'));
+ assert.ok(pg.text.includes('Write alt text with AI'), 'the uploader sees the button');
+ assert.ok(!(await t.get(`/i/${a.slug}`, { as: lee })).text.includes('Write alt text with AI'), 'nobody else does');
+ assert.strictEqual(ai.runs.length, 0);
+ });
+
+ await check('the uploader asks: the thumbnail goes as a WebP data URL to pics.describe_image; the draft is the alt text everywhere', async () => {
+ const r = await t.get(`/i/${a.slug}/describe`, { as: kim, form: {} });
+ assert.strictEqual(r.status, 200, r.text.slice(0, 300));
+ assert.ok(r.text.includes('AI wrote the alt text below'));
+ assert.strictEqual(ai.runs.length, 1);
+ const run = ai.runs[0];
+ assert.strictEqual(run.body.workflow, 'pics.describe_image');
+ assert.match(run.body.input.image.data_url, /^data:image\/webp;base64,/);
+ assert.strictEqual(run.body.input.title, 'Sunset');
+ assert.deepStrictEqual(run.body.target, { service: 'media-hub', type: 'picture', id: a.id });
+ assert.match(run.auth, /^Bearer /);
+
+ const pg = await t.get(`/i/${a.slug}`);
+ assert.ok(pg.text.includes('alt="An orange sky over a green field with a white sun"'), 'the picture');
+ assert.ok(pg.text.includes('Described by AI') && pg.text.includes('Bright blocks of colour'), 'the caption, labelled');
+ assert.ok(pg.text.includes(', 'the Markdown snippet');
+ const wire = (await t.get(`/api/v1/images/${a.slug}`)).json();
+ assert.strictEqual(wire.alt_source, 'ai');
+ assert.deepStrictEqual(wire.ai.tags, ['sky', 'field', 'sun']);
+ assert.ok((await t.get('/mine', { as: kim })).text.includes('alt="An orange sky over a green field with a white sun"'), 'the thumbnails');
+ });
+
+ await check('only the uploader can ask; a hidden picture is never sent', async () => {
+ const r = await t.get(`/api/v1/images/${a.slug}/describe`, { as: lee, method: 'POST', json: {} });
+ assert.strictEqual(r.status, 403);
+ await t0.ctx.s.db.exec("UPDATE pics_images SET state = 'hidden' WHERE id = $1", [c.id]);
+ const h = await t.get(`/api/v1/images/${c.slug}/describe`, { as: kim, method: 'POST', json: {} });
+ assert.strictEqual(h.status, 409);
+ assert.strictEqual(h.json().code, 'pics.not_active');
+ await t0.ctx.s.db.exec("UPDATE pics_images SET state = 'active' WHERE id = $1", [c.id]);
+ assert.strictEqual(ai.runs.length, 1);
+ });
+
+ await check('saving the form unchanged keeps it AI\'s; an edit makes it the uploader\'s and the AI caption goes', async () => {
+ await t.get(`/i/${a.slug}/edit`, { as: kim, form: { title: 'Sunset', description: '', alt: 'An orange sky over a green field with a white sun' } });
+ assert.strictEqual((await t.get(`/api/v1/images/${a.slug}`)).json().alt_source, 'ai');
+ await t.get(`/i/${a.slug}/edit`, { as: kim, form: { title: 'Sunset', description: '', alt: 'Sunset over the fields behind our house' } });
+ const wire = (await t.get(`/api/v1/images/${a.slug}`)).json();
+ assert.deepStrictEqual([wire.alt, wire.alt_source], ['Sunset over the fields behind our house', 'person']);
+ assert.ok(!(await t.get(`/i/${a.slug}`)).text.includes('Described by AI'));
+ const api = await t.get(`/api/v1/images/${a.slug}`, { as: kim, method: 'PATCH', json: { alt: '' } });
+ assert.deepStrictEqual([api.json().alt, api.json().alt_source], ['', null], 'cleared: back to the title');
+ });
+
+ await check('a failure or a slow run answers plainly and keeps what was there', async () => {
+ mode = 'fail';
+ const f = await t.get(`/api/v1/images/${b.slug}/describe`, { as: kim, method: 'POST', json: {} });
+ assert.deepStrictEqual([f.status, f.json().code], [502, 'pics.ai_failed']);
+ mode = 'slow';
+ const s = await t.get(`/i/${b.slug}/describe`, { as: kim, form: {} });
+ assert.strictEqual(s.status, 503);
+ assert.ok(s.text.includes('taking longer than usual'));
+ assert.strictEqual((await t.get(`/api/v1/images/${b.slug}`)).json().alt, '');
+ });
+
+ await check('the daily cap for the whole site', async () => {
+ mode = 'ok';
+ assert.strictEqual((await t.get(`/api/v1/images/${b.slug}/describe`, { as: kim, method: 'POST', json: {} })).status, 200);
+ const capped = await t.get(`/api/v1/images/${c.slug}/describe`, { as: kim, method: 'POST', json: {} });
+ assert.deepStrictEqual([capped.status, capped.json().code], [429, 'pics.ai_daily_cap']);
+ });
+ } finally {
+ await t0.close();
+ await ai.close();
+ }
+
+ await check('off: no button and 503 without OV_AI_INTERNAL_URL', async () => {
+ const t1 = await boot();
+ const t = { ...t1, get: picsClient(t1) };
+ try {
+ const kim = t.network.addUser('kim');
+ const img = await sharp({ create: { width: 400, height: 300, channels: 3, background: '#333' } }).png().toBuffer();
+ const up = (await t.get('/api/v1/images', { as: kim, method: 'POST', body: img, headers: { 'content-type': 'image/png' } })).json();
+ assert.ok(!(await t.get(`/i/${up.slug}`, { as: kim })).text.includes('Write alt text with AI'));
+ const r = await t.get(`/api/v1/images/${up.slug}/describe`, { as: kim, method: 'POST', json: {} });
+ assert.deepStrictEqual([r.status, r.json().code], [503, 'pics.ai_off']);
+ } finally {
+ await t1.close();
+ }
+ });
+
+ done();
+})();