openvibers/openvibe.mediahub Commits

Pics: alt text, written by the uploader or drafted by OpenVibe.AI when they ask (#79)

OP OpenVibers committed 28 days ago · 9c1c080b16 · 14 files, +339 −17

Alt text (migration 0014) is what the picture page, the thumbnails and every embed snippet carry (the title when
there is none). "Write alt text with AI" sends the 640-pixel thumbnail to OpenVibe.AI's pics.describe_image only on
that press; the draft (alt, a caption labelled Described by AI, tags) stays until the uploader edits it. A site-wide
daily cap (MEDIAHUB_PICS_AI_PER_DAY, 300) and the pics.image.describe budget; POST /api/v1/images/:slug/describe and
alt on PATCH. The home page names resize/convert and alt text.

Changes

ddiff --git a/.env.example b/.env.example
iindex b1d0166..ce20d7f 100644
---- a/.env.example
++++ b/.env.example
@@@ -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
ddiff --git a/README.md b/README.md
iindex 3b77e4c..c354a64 100644
---- a/README.md
++++ b/README.md
@@@ -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
(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.
++- **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
disallows `/i/` and `/a/`. The page has the links to share and embed: the page, the direct link, Markdown, BBCode
and 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 |
ddiff --git a/migrations/0014_pics_alt.sql b/migrations/0014_pics_alt.sql
nnew file mode 100644
iindex 0000000..628235c
---- /dev/null
++++ b/migrations/0014_pics_alt.sql
@@@ -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'));
ddiff --git a/public/css/pics.css b/public/css/pics.css
iindex 483ea6e..1407eee 100644
---- a/public/css/pics.css
++++ b/public/css/pics.css
@@@ -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; }
ddiff --git a/server/app.js b/server/app.js
iindex 4ce22c9..cd00d72 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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);
ddiff --git a/server/config.js b/server/config.js
iindex f0027b4..b4e0c1e 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex f1f20a1..914de1d 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -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 },
};
ddiff --git a/server/pics/api.js b/server/pics/api.js
iindex b71e055..6aeb60a 100644
---- a/server/pics/api.js
++++ b/server/pics/api.js
@@@ -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) => {
ddiff --git a/server/pics/describe.js b/server/pics/describe.js
nnew file mode 100644
iindex 0000000..e072cc0
---- /dev/null
++++ b/server/pics/describe.js
@@@ -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 };
ddiff --git a/server/pics/pages.js b/server/pics/pages.js
iindex c6c404d..3bb33e0 100644
---- a/server/pics/pages.js
++++ b/server/pics/pages.js
@@@ -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" name
if (!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="res
r.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, {
ddiff --git a/server/pics/service.js b/server/pics/service.js
iindex 7ca38e4..e5c332b 100644
---- a/server/pics/service.js
++++ b/server/pics/service.js
@@@ -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,
};
}
ddiff --git a/server/pics/store.js b/server/pics/store.js
iindex ab1d5aa..4296fef 100644
---- a/server/pics/store.js
++++ b/server/pics/store.js
@@@ -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]);
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex 7c88754..73c863c 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -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`);
ddiff --git a/test/pics-describe.test.js b/test/pics-describe.test.js
nnew file mode 100644
iindex 0000000..7aa4e25
---- /dev/null
++++ b/test/pics-describe.test.js
@@@ -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('![An orange sky over a green field with a white sun](https://openvibe.pics/i/'), '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();
++})();