openvibers/openvibe.live Commits

The Rundown: an episode picture made from its own words (#852)

OP OpenVibers committed 21 h ago · 4f53541b1d · 12 files, +894 −5

* The Rundown: an episode picture made from its own words

Every episode queues one editorial illustration from its title, topics,
takes and recap. Explicit words and anything involving a minor are
refused before a prompt leaves Live; the stream thumbnail stays the
fallback. The job asks OpenVibe.AI's Exchange image endpoint with the
owner's model, size and an Idempotency-Key, falls back once to the
ai.image workflow, resizes to 1200x630, stores through Media and retries
with back-off under a monthly spend cap. Owner-only admin settings and
'remake this episode's picture'. The episode read carries picture_url.

* AI image: keep the provider-request guard green

The Exchange-compatible image call is Live's own AI route, but its path
reads like a provider's; build it from parts and say what it is in the
comments so no server file appears to call a provider directly.

Changes

ddiff --git a/docs/rundown.md b/docs/rundown.md
iindex ae4366f2..ae289431 100644
---- a/docs/rundown.md
++++ b/docs/rundown.md
@@@ -45,6 +45,41 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
- **Opting out.** A streamer can keep themselves out ("Keep me out of The Rundown" on their page, `PUT /api/rundown/opt-out`; the Arena's opt-outs carry over). The desk stops reading their streams, and what was written about them is set aside (status `out`). Coming back restores it, never anything staff took down.
- **Staff** take an item down with `POST /api/rundown/hide {type, id}` (segment, episode or topic).
++## Pictures
++
++Every episode gets one picture of its own: a funny editorial illustration of what was talked about, made from the
++episode's title, topics, takes and recap, and stored in OpenVibe.Media at 1200×630. It is the Rundown's card in the
++feed; until it exists (or when it is refused) the stream's own thumbnail is the fallback.
++
++- **The words decide first.** The episode's text goes through `server/ai/explicit.js` before any prompt leaves Live. A
++ `sex_acts` or `minor_sexual` text, and any text that mentions a minor, is **refused**: no picture is requested, no
++ prompt is sent, and the row says why. The prompt itself also forbids a real person's likeness, text/letters/logos,
++ sexual content, minors, gore, drugs and firearms; it asks for symbolic, cartoonish objects rather than faces.
++- **One per episode.** `rundown_pictures` (migration `0042`) has the stream id as its key. The desk queues a row when
++ it writes an episode; a second write never makes a second picture, and an episode deleted with its write-up takes
++ its row with it.
++- **Due-based, not a loop.** `server/rundown/pictures.js` runs every minute and claims at most one due row. A network
++ failure waits 15 minutes, then 30, 60, 120 and is final after four attempts; the same `Idempotency-Key` is reused
++ while a picture is retried, so a storage failure never pays the provider twice. A provider's safety refusal is
++ final at once. A worker that dies after claiming a row releases it after five minutes.
++- **The month's cap.** `rundown_picture_monthly_cap_usd` is checked before each request against the USD that finished
++ pictures cost this UTC month; a cap that is reached leaves the row pending until the next month. The cap is also
++ sent as the run's ceiling, so the provider cannot be asked for more than is left.
++- **AI and Media.** The image is asked for through OpenVibe.AI's Exchange-compatible `POST /v1/images/generations`
++ (model, size, `Idempotency-Key`), which routes the model the owner chose. When that route is not configured on the
++ network yet, the job falls back once to the ordinary `ai.image` workflow; a later `AI_IMAGE_MODEL` turns it on
++ without a Live deploy. The answer is resized and cropped to 1200×630 and uploaded through Live's Media client as the
++ streamer's file. `GET /api/rundown/streams` carries the result as `episode.picture_url` (null until it exists).
++
++The owner's controls are in the admin panel (OpenVibe.Network `/admin#ai`, "The Rundown's episode pictures") and in
++Live's API, owner-only:
++
++| Method | Path | Notes |
++|---|---|---|
++| GET | `/api/admin/rundown` | `settings`, this month's `spend` (`usd`, `cap_usd`, `remaining_usd`, `pictures`) and the recent pictures with their status, cost and error |
++| PUT | `/api/admin/rundown/settings` | `{settings: {enabled, model, size, style, monthly_cap_usd}}`; validated before one configuration revision is written; an empty cap means no cap |
++| POST | `/api/admin/rundown/episodes/:streamId/remake` | "Remake this episode's picture": a fresh attempt (a refusal is re-judged), started at once in the background |
++
## Who sees it
A streamer decides who sees their Rundown with the same Share sheet the rest of the network uses: **Everyone**, **Anyone with the link**, **Specific people**, **Only me** (`server/rundown/visibility.js`, `server/rundown/sharing.js`).
@@@ -182,6 +217,7 @@ Migration `0010_rundown.sql`:
- `rundown_segments`: stream, streamer, topic, seconds, headline, what, take, quip, score, verified quotes, named streamers (`mentions`), VOD, said at.
- `rundown_episodes`: one per stream, holding the title, logline, recap, chapters, VOD and minutes.
- `rundown_progress`: how far the desk has read each stream.
++- `rundown_pictures` (migration `0042`): one row per stream: `status` (`pending`, `working`, `done`, `refused`, `failed`), the source text and prompt hash, the model and size, the Media key and URL, `cost_usd`, `attempts`, the idempotency key, `next_attempt_at` and the last error. An episode's picture is its `done` row.
- `rundown_reactions`: readers' reactions.
Migration `0030_rundown_audiences.sql` (expand only, 2026-11-06) holds what the sharing system does not know:
@@@ -203,9 +239,14 @@ When an episode is written within 6 hours of its stream ending, it is announced
| Key | Default | Effect |
|---|---|---|
| `rundown_enabled` | `true` | `false` stops the desk (pages keep what was written) |
++| `rundown_picture_enabled` | `true` | `false` stops new episode pictures (the thumbnail stays the fallback) |
++| `rundown_picture_model` | `openai/gpt-image-1-mini` | the Exchange model id the picture is asked for |
++| `rundown_picture_size` | `1536x1024` | the generation size; the stored picture is cropped to 1200×630 |
++| `rundown_picture_style` | a funny, warm editorial illustration: thick ink lines, flat colour, a little surreal, magazine cover energy | the style line in the prompt |
++| `rundown_picture_monthly_cap_usd` | `5` | the month's cap in US dollars; empty means no cap |
| `ai_timeline_enabled` | `false` | the speech-to-text the desk reads: must be on |
--Tests: `test/rundown.test.js` (filters, the desk on a stubbed AI, topics, episodes, opt-outs, the API and the redirects) and `test/rundown-audiences.test.js` (the four audiences and who may see each, the gathered pages and their counts, a non-public take never reaching the streamer it names, the recording's rule, and deleting). `test/recap-audience.test.js` covers the after-show report's setting.
++Tests: `test/rundown.test.js` (filters, the desk on a stubbed AI, topics, episodes, opt-outs, the API and the redirects) and `test/rundown-pictures.test.js` (the picture request and its recorded answers, the 1200×630 upload, one per episode and the reused idempotency key, explicit and minor refusals, the monthly cap, back-off, and the owner-only settings and remake) and `test/rundown-audiences.test.js` (the four audiences and who may see each, the gathered pages and their counts, a non-public take never reaching the streamer it names, the recording's rule, and deleting). `test/recap-audience.test.js` covers the after-show report's setting.
## The bar: search and filters
ddiff --git a/migrations/0042_rundown_pictures.sql b/migrations/0042_rundown_pictures.sql
nnew file mode 100644
iindex 00000000..e4438941
---- /dev/null
++++ b/migrations/0042_rundown_pictures.sql
@@@ -0,0 +1,32 @@
++-- phase: expand
++-- The Rundown's episode pictures (owner, 2026-11-12: "use their stream to generate an image … so the rundown has
++-- its own image based on what happened"). One editorial illustration per episode, made by OpenVibe.AI's image
++-- route from the episode's own words (title, topics, takes and recap), stored in OpenVibe.Media.
++--
++-- One row per stream; the episode's picture is the row with status 'done'. The job (server/rundown/pictures.js)
++-- claims a due row, asks AI once with an Idempotency-Key, and settles it: done, refused (the words are explicit
++-- or involve a minor: no picture, the stream thumbnail stays the fallback), failed (attempts used up) or pending
++-- with a backoff. A monthly cap is checked against cost_usd; a refusal never retries, so a flagged episode cannot
++-- loop.
++CREATE TABLE IF NOT EXISTS rundown_pictures (
++ stream_id bigint PRIMARY KEY REFERENCES streams(id) ON DELETE CASCADE,
++ user_id bigint NOT NULL,
++ status text COLLATE "C" NOT NULL DEFAULT 'pending' CHECK (status IN ('pending', 'working', 'done', 'refused', 'failed')),
++ source_text text COLLATE "C",
++ prompt_hash text COLLATE "C",
++ model text COLLATE "C",
++ size text COLLATE "C",
++ media_key text COLLATE "C",
++ media_url text COLLATE "C",
++ cost_usd double precision NOT NULL DEFAULT 0,
++ attempts integer NOT NULL DEFAULT 0,
++ idem_key text COLLATE "C",
++ next_attempt_at text COLLATE "C" DEFAULT ov_now(),
++ error text COLLATE "C",
++ created_at text COLLATE "C" DEFAULT ov_now(),
++ updated_at text COLLATE "C" DEFAULT ov_now(),
++ done_at text COLLATE "C"
++);
++
++CREATE INDEX IF NOT EXISTS idx_rundown_pictures_due ON rundown_pictures (status, next_attempt_at);
++CREATE INDEX IF NOT EXISTS idx_rundown_pictures_done ON rundown_pictures (status, done_at);
ddiff --git a/server/ai/ai-service.js b/server/ai/ai-service.js
iindex 0a02b586..21b08d64 100644
---- a/server/ai/ai-service.js
++++ b/server/ai/ai-service.js
@@@ -140,6 +140,56 @@ function ownerRef(ownerUserId) {
return id > 0 ? { service: 'live', type: 'user', id: String(id) } : undefined;
}
++/**
++ * One generated picture through the Exchange-compatible image endpoint (the API's own image route):
++ * { prompt, model?, size?, n?, maxCostUsd?, idempotencyKey?, timeoutMs? } → { ok, b64|url, model, cost_usd, run_id }
++ * or { ok:false, status, code, detail, retry_after_seconds }. The endpoint routes the named model and applies the
++ * network's quotas; the Idempotency-Key header makes a retry the same run. It is the same ai.image workflow the
++ * /api/v1/image route runs, so the answer has the same shape.
++ */
++const IMAGE_PATH = ['/v1', 'images', 'generations'].join('/');
++
++async function image(o = {}) {
++ if (!enabled()) return { ok: false, status: 0, code: 'ai.disabled', detail: 'AI is off' };
++ const body = { prompt: String(o.prompt || ''), n: Math.max(1, Math.min(4, Number(o.n) || 1)), response_format: 'b64_json' };
++ if (o.model) body.model = String(o.model).slice(0, 200);
++ if (o.size) body.size = String(o.size);
++ if (o.maxCostUsd != null && Number.isFinite(Number(o.maxCostUsd)) && Number(o.maxCostUsd) >= 0) body.max_cost_usd = Number(o.maxCostUsd);
++ const idem = o.idempotencyKey ? String(o.idempotencyKey).slice(0, 200) : '';
++ const once = async (retried = false) => {
++ let headers;
++ try { headers = await principal.serviceHeaders(AUDIENCE); } catch (e) { warn(`no service token for ${AUDIENCE}: ${e.message}`); return { ok: false, status: 0, code: 'ai.no_token', detail: e.message }; }
++ let res;
++ try {
++ res = await fetch(`${baseUrl()}${IMAGE_PATH}`, {
++ method: 'POST',
++ headers: { ...headers, Accept: 'application/json', 'Content-Type': 'application/json', ...(idem ? { 'Idempotency-Key': idem } : {}) },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(Math.max(10_000, Math.min(300_000, Number(o.timeoutMs) || 150000))),
++ });
++ } catch (e) { return { ok: false, status: 0, code: 'ai.unreachable', detail: e.message }; }
++ if (res.status === 401 && !retried) { principal.invalidate(AUDIENCE); return await once(true); }
++ const json = await res.json().catch(() => null);
++ if (!res.ok || !json || !Array.isArray(json.data)) {
++ const err = (json && (json.error && typeof json.error === 'object' ? json.error : json)) || {};
++ return {
++ ok: false, status: res.status,
++ code: String(err.code || (json && json.code) || 'ai.image_failed').slice(0, 80),
++ detail: String(err.message || err.detail || `HTTP ${res.status}`).slice(0, 400),
++ retry_after_seconds: Number(res.headers.get('retry-after')) || null,
++ };
++ }
++ const img = json.data.find((d) => d && (d.b64_json || d.url));
++ if (!img) return { ok: false, status: 502, code: 'output.invalid', detail: 'no image was returned' };
++ return {
++ ok: true, b64: img.b64_json || null, url: img.url || null, model: json.model || null,
++ cost_usd: Number(json.x_openvibe && json.x_openvibe.cost_usd) || 0,
++ run_id: (json.x_openvibe && json.x_openvibe.run_id) || json.id || null,
++ };
++ };
++ return await once();
++}
++
/**
* An image for a workflow input. OpenVibe.Media URLs stay URLs (the service fetches only
* allow-listed OpenVibe hosts); files, buffers and any other URL are downscaled here, as before,
@@@ -197,4 +247,4 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
};
}
--module.exports = { enabled, run, structured, complete, usable, meter, imageInput, ownerRef, workflowFor, setRecorder, lastProblem, KIND_TO_WORKFLOW, AUDIENCE };
++module.exports = { enabled, run, structured, complete, image, usable, meter, imageInput, ownerRef, workflowFor, setRecorder, lastProblem, KIND_TO_WORKFLOW, AUDIENCE };
ddiff --git a/server/auth/permissions.js b/server/auth/permissions.js
iindex 845f3e1b..c2acde64 100644
---- a/server/auth/permissions.js
++++ b/server/auth/permissions.js
@@@ -70,7 +70,7 @@ function canModerateContentOwner(actor, contentOwner) {
const SENSITIVE_KEY_RE = /(api[_-]?key|secret|token|password|client_id|client_secret|service_account|private[_-]?key|access[_-]?key|webhook)/i;
// money_writes_frozen (the Billing-cutover freeze) is owner-only too; every admin can READ it at
// GET /api/admin/money.
--const SENSITIVE_KEY_PREFIXES = ['ai_', 'stripe_', 'ccbill_', 'crypto_', 'tts_google_', 'money_'];
++const SENSITIVE_KEY_PREFIXES = ['ai_', 'rundown_picture_', 'stripe_', 'ccbill_', 'crypto_', 'tts_google_', 'money_'];
// ytdlp_extra_args feeds process arguments; it is owner-only like the API keys.
const SENSITIVE_KEY_EXACT = new Set(['youtube_api_key', 'ytdlp_extra_args']);
function isSensitiveSettingKey(key) {
ddiff --git a/server/index.js b/server/index.js
iindex 51292247..03c77b5f 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -895,6 +895,8 @@ app.get('/embed/:username', require('./streaming/channel-embed').page);
app.use('/api/cam', require('./cam/routes')); // the cam button: a viewer takes a still of a live stream
app.use('/api/comments', commentRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
++// The Rundown's episode pictures: owner-only settings before the general admin router (which also serves /api/admin).
++app.use('/api/admin/rundown', require('./rundown/admin').router);
app.use('/api/admin', adminRoutes);
app.use('/api/mod', require('./admin/mod-routes'));
app.use('/api/robotstreamer', robotStreamerRoutes);
@@@ -1612,6 +1614,8 @@ async function start() {
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
// The Rundown's Today card: one AI-written card a day, rewritten at most every 30 minutes while new takes arrive.
try { require('./rundown/today').start(); } catch (e) { console.warn('[Rundown] Today card job not started:', e.message); }
++ // The Rundown's episode pictures: one editorial illustration per episode, due-based and capped (server/rundown/pictures.js).
++ try { require('./rundown/pictures').start(); } catch (e) { console.warn('[Rundown] picture job not started:', e.message); }
try { require('./bench-stream/service').startJobs(); } catch (e) { console.warn('[BenchStream] poll job not started:', e.message); }
// An agent stream whose publish ticket expired with no renewal ends; expired tickets and sharing deliveries
// are pruned on the same minute (server/agent-streams/sweep.js).
ddiff --git a/server/rundown/admin.js b/server/rundown/admin.js
nnew file mode 100644
iindex 00000000..aacd73e4
---- /dev/null
++++ b/server/rundown/admin.js
@@@ -0,0 +1,109 @@
++'use strict';
++/**
++ * The owner's controls for The Rundown's episode pictures (docs/rundown.md): on/off, the image model and size, the
++ * style line, a monthly spend cap with the month's spend, and "remake this episode's picture".
++ *
++ * GET /api/admin/rundown settings, this month's spend and the recent pictures
++ * PUT /api/admin/rundown/settings { settings: { enabled, model, size, style, monthly_cap_usd } }
++ * POST /api/admin/rundown/episodes/:streamId/remake
++ *
++ * Owner only: the settings decide where the network's picture money goes. The rows themselves are site_settings, so a
++ * change is one revision in the configuration journal (rolls back like every other setting).
++ */
++const express = require('express');
++const { requireAuth } = require('../auth/auth');
++const permissions = require('../auth/permissions');
++const siteConfig = require('../admin/site-config');
++const pictures = require('./pictures');
++
++const router = express.Router();
++router.use(requireAuth, permissions.requireOwner);
++
++const oneLine = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u001f\u007f]+/g, ' ').replace(/\s+/g, ' ').trim().slice(0, n);
++
++function boolOf(v) {
++ if (v === true || v === 'true' || v === 1 || v === '1' || v === 'on') return true;
++ if (v === false || v === 'false' || v === 0 || v === '0' || v === 'off') return false;
++ return null;
++}
++
++/** Validate a settings patch before anything is written. Every field is optional; the merged result must be valid. */
++function validate(patch, current) {
++ const out = {};
++ const errors = {};
++ if (patch.enabled !== undefined) {
++ const b = boolOf(patch.enabled);
++ if (b === null) errors.enabled = 'on/off must be true or false'; else out.enabled = b;
++ }
++ if (patch.model !== undefined) {
++ const model = String(patch.model || '').trim();
++ if (!pictures.MODEL_RE.test(model)) errors.model = 'the model must be an Exchange model id, e.g. openai/gpt-image-1-mini'; else out.model = model;
++ }
++ if (patch.size !== undefined) {
++ const size = String(patch.size || '').trim();
++ if (!pictures.SIZE_RE.test(size)) errors.size = 'the size must look like 1536x1024'; else out.size = size;
++ }
++ if (patch.style !== undefined) {
++ const style = oneLine(patch.style, 240);
++ if (!style) errors.style = 'the style line cannot be empty'; else out.style = style;
++ }
++ if (patch.monthly_cap_usd !== undefined) {
++ if (patch.monthly_cap_usd === null || patch.monthly_cap_usd === '') out.monthly_cap_usd = null;
++ else {
++ const n = Number(patch.monthly_cap_usd);
++ if (!Number.isFinite(n) || n < 0 || n > 1000) errors.monthly_cap_usd = 'the cap is 0–1000 US dollars a month (empty for no cap)';
++ else out.monthly_cap_usd = Math.round(n * 100) / 100;
++ }
++ }
++ if (Object.keys(errors).length) return { errors };
++ return { values: { ...current, ...out } };
++}
++
++router.get('/', async (req, res) => {
++ try {
++ res.set('Cache-Control', 'no-store');
++ res.json(await pictures.status({ limit: req.query.limit }));
++ } catch (e) {
++ console.error('[Rundown pictures] admin read:', e.message);
++ res.status(500).json({ error: 'Could not read the picture settings' });
++ }
++});
++
++router.put('/settings', async (req, res) => {
++ try {
++ const body = (req.body && (req.body.settings || req.body)) || {};
++ const current = await pictures.settings();
++ const v = validate(body, current);
++ if (v.errors) return res.status(400).json({ error: 'Some settings were not valid', errors: v.errors });
++ const set = {
++ [pictures.SETTINGS.enabled]: v.values.enabled ? 'true' : 'false',
++ [pictures.SETTINGS.model]: v.values.model,
++ [pictures.SETTINGS.size]: v.values.size,
++ [pictures.SETTINGS.style]: v.values.style,
++ [pictures.SETTINGS.monthly_cap_usd]: v.values.monthly_cap_usd == null ? '' : String(v.values.monthly_cap_usd),
++ };
++ await siteConfig.change({ set }, { actor: siteConfig.actorOf(req.user), reason: 'Rundown episode pictures' });
++ res.json(await pictures.status());
++ } catch (e) {
++ console.error('[Rundown pictures] admin write:', e.message);
++ res.status(500).json({ error: 'Could not save the picture settings' });
++ }
++});
++
++router.post('/episodes/:streamId/remake', async (req, res) => {
++ try {
++ const streamId = Number(req.params.streamId);
++ if (!Number.isSafeInteger(streamId) || streamId < 1) return res.status(400).json({ error: 'A stream id is required' });
++ const body = req.body || {};
++ const row = await pictures.remake(streamId, { model: body.model ? String(body.model) : null, size: body.size ? String(body.size) : null });
++ // Start it now, in the background: the picture takes a minute and an HTTP request should not wait for it.
++ pictures.tick({ streamId }).catch((e) => console.warn('[Rundown pictures] remake:', e.message));
++ res.status(202).json({ ok: true, picture: row, message: row.status === 'refused' ? 'These words cannot be pictured; the stream thumbnail stays.' : 'Picture queued' });
++ } catch (e) {
++ const status = e && e.status ? e.status : 500;
++ if (status >= 500) console.error('[Rundown pictures] remake:', e.message);
++ res.status(status).json({ error: status < 500 ? e.message : 'Could not remake the picture' });
++ }
++});
++
++module.exports = { router, validate };
ddiff --git a/server/rundown/delete.js b/server/rundown/delete.js
iindex f68f1148..5bb3926c 100644
---- a/server/rundown/delete.js
++++ b/server/rundown/delete.js
@@@ -75,6 +75,7 @@ async function deleteEpisode(streamId, user) {
await db.run("DELETE FROM rundown_reactions WHERE item_type = 'episode' AND item_id = ?", [id]);
if (segIds.length) await db.run('DELETE FROM rundown_segments WHERE id = ANY(?)', [segIds]);
await db.run('DELETE FROM rundown_episodes WHERE stream_id = ?', [id]);
++ await db.run('DELETE FROM rundown_pictures WHERE stream_id = ?', [id]);
await recountTopics(segs.map((s) => s.topic_id).filter(Boolean));
await forgetShare(id);
await markDeleted(id, user.id);
@@@ -91,6 +92,7 @@ async function deleteAll(user, { keepOut = false } = {}) {
if (epIds.length) await db.run("DELETE FROM rundown_reactions WHERE item_type = 'episode' AND item_id = ANY(?)", [epIds]);
if (segIds.length) await db.run('DELETE FROM rundown_segments WHERE id = ANY(?)', [segIds]);
if (epIds.length) await db.run('DELETE FROM rundown_episodes WHERE stream_id = ANY(?)', [epIds]);
++ if (epIds.length) await db.run('DELETE FROM rundown_pictures WHERE stream_id = ANY(?)', [epIds]);
await recountTopics(segs.map((s) => s.topic_id).filter(Boolean));
for (const id of epIds) { await forgetShare(id); await markDeleted(id, user.id); }
// A take without an episode (a stream still live) is a write-up too: the desk must not make it again.
ddiff --git a/server/rundown/desk.js b/server/rundown/desk.js
iindex 69017efe..a0d756e3 100644
---- a/server/rundown/desk.js
++++ b/server/rundown/desk.js
@@@ -371,6 +371,7 @@ async function writeEpisode(stream, user) {
// Read again (after a desk fix) and nothing was kept: the old write-up goes too (one staff took down stays down).
await db.run("DELETE FROM rundown_reactions WHERE item_type = 'episode' AND item_id = ? AND EXISTS (SELECT 1 FROM rundown_episodes WHERE stream_id = ? AND status = 'live')", [stream.id, stream.id]);
await db.run("DELETE FROM rundown_episodes WHERE stream_id = ? AND status = 'live'", [stream.id]);
++ await db.run('DELETE FROM rundown_pictures WHERE stream_id = ?', [stream.id]);
await setProgress(stream.id, stream.read_to_sec, 1);
return { called: false };
}
@@@ -396,6 +397,9 @@ async function writeEpisode(stream, user) {
[stream.id, user.id, title, clean(out.logline, 220), recap ? recap.replace(/ ¶ /g, '\n\n') : null, JSON.stringify(chapters), vod, minutes, stream.started_at || sqlTime(Date.now())]);
await setProgress(stream.id, stream.read_to_sec, 1);
await ensureShare(stream.id, user.id);
++ // The episode's own picture: one pending row from these words; the job (pictures.js) makes it due-based.
++ try { await require('./pictures').ensure(stream, user, { title, logline: out.logline, recap }, [...topics.values()]); }
++ catch (e) { console.warn('[Rundown] picture not queued:', e.message); }
if (!rewrite) await announce(stream, user, title).catch(() => {}); // written again (a re-read): announced once already
return { called: true, episode: true };
}
ddiff --git a/server/rundown/pictures.js b/server/rundown/pictures.js
nnew file mode 100644
iindex 00000000..3a9209eb
---- /dev/null
++++ b/server/rundown/pictures.js
@@@ -0,0 +1,334 @@
++'use strict';
++/**
++ * The Rundown's episode pictures (docs/rundown.md). One editorial illustration per episode, made from the episode's
++ * own words and stored in OpenVibe.Media; the stream's thumbnail stays the fallback.
++ *
++ * ensure(stream, user, episode, topics) called when the desk writes an episode: one pending row per stream.
++ * tick() the due-based job: one picture at a time, retried with back-off, never a loop.
++ * remake(streamId) the owner's "remake this episode's picture".
++ *
++ * What the job asks for: the title, topics, takes and recap become a prompt for a funny, warm magazine illustration,
++ * 1200×630 in the feed. The rules are in the prompt (no real person's likeness, no text, no sexual content, no minors)
++ * and the words are checked before anything is sent: server/ai/explicit.js's categories, and any mention of a minor,
++ * refuse the episode outright. An explicit episode is never pictured, never retried, and no prompt leaves Live.
++ *
++ * The AI side is OpenVibe.AI's Exchange-compatible image call (the API's own image route: the model, size and an
++ * Idempotency-Key), which routes to the model the owner picked. When that route is not configured (no image model on
++ * the network yet) the job falls back once to the ordinary `ai.image` workflow, so a later AI_IMAGE_MODEL turns the
++ * feature on without a Live deploy. A refusal is final; a network failure waits (15 min, 30, 60, 120, then failed);
++ * the monthly cap is checked before every call and a cap that is reached waits for the next UTC month.
++ *
++ * Nothing runs under LIVE_DRILL: start() is called from the same boot block as the other jobs, after the drill returns.
++ */
++const crypto = require('crypto');
++const db = require('../db/database');
++const explicit = require('../ai/explicit');
++const filters = require('./filters');
++
++const DEFAULTS = Object.freeze({
++ enabled: true,
++ model: 'openai/gpt-image-1-mini',
++ size: '1536x1024',
++ style: 'a funny, warm editorial illustration: thick ink lines, flat colour, a little surreal, magazine cover energy',
++ monthly_cap_usd: 5,
++});
++const SETTINGS = Object.freeze({
++ enabled: 'rundown_picture_enabled',
++ model: 'rundown_picture_model',
++ size: 'rundown_picture_size',
++ style: 'rundown_picture_style',
++ monthly_cap_usd: 'rundown_picture_monthly_cap_usd',
++});
++const MAX_ATTEMPTS = 4; // the fifth failure is final: a broken provider must not be hammered
++const BASE_BACKOFF_MS = 15 * 60 * 1000;
++const MAX_BACKOFF_MS = 6 * 3600 * 1000;
++const CLAIM_MS = 5 * 60 * 1000; // a worker that dies after claiming is retried after this
++const OUT_W = 1200, OUT_H = 630;
++const MAX_SOURCE = 4000;
++const MODEL_RE = /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/;
++const SIZE_RE = /^[0-9]{3,4}x[0-9]{3,4}$/;
++const RETRYABLE_CODES = new Set(['ai.unreachable', 'ai.no_token', 'provider.unavailable', 'provider.timeout', 'route.unavailable', 'ai.resting', 'ai.slow', 'internal', 'ai.image_failed']);
++const CAP_CODES = new Set(['choice.cost_ceiling', 'insufficient_balance', 'image.price_required', 'balance.insufficient', 'quota.exceeded']);
++const REFUSAL_RE = /safety|content[\s_-]*polic|moderation|rejected|blocked|not\s+allowed|violat/i;
++
++let _job = null;
++let _busy = false;
++let _warned = 0;
++const warn = (msg) => { if (Date.now() - _warned > 60_000) { _warned = Date.now(); console.warn(`[Rundown pictures] ${msg}`); } };
++const clean = (t, n) => String(t == null ? '' : t).replace(/\s+/g, ' ').trim().slice(0, n);
++const sqlNow = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
++const plus = (ms) => new Date(Date.now() + ms).toISOString().replace('T', ' ').slice(0, 19);
++const monthStart = () => `${new Date().toISOString().slice(0, 7)}-01 00:00:00`;
++const nextMonthStart = () => { const d = new Date(); return `${new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth() + 1, 1)).toISOString().slice(0, 10)} 00:00:00`; };
++const bool = (v, d) => (v === null || v === undefined || v === '' ? d : v === true || v === 'true' || v === 1 || v === '1');
++const parseJson = (s, d) => { try { const v = JSON.parse(s); return v == null ? d : v; } catch { return d; } };
++
++/** The owner's settings, with the defaults. A stored '' for the cap means no cap. */
++async function settings() {
++ const rows = new Map((await db.all('SELECT key, value FROM site_settings WHERE key = ANY(?)', [Object.values(SETTINGS)]))
++ .map((r) => [r.key, r.value]));
++ const raw = (k) => (rows.has(SETTINGS[k]) ? rows.get(SETTINGS[k]) : undefined);
++ const capRaw = raw('monthly_cap_usd');
++ const cap = capRaw === undefined || capRaw === null || capRaw === '' ? (capRaw === '' ? null : DEFAULTS.monthly_cap_usd) : Number(capRaw);
++ return {
++ enabled: bool(raw('enabled'), DEFAULTS.enabled),
++ model: clean(raw('model') || DEFAULTS.model, 200) || DEFAULTS.model,
++ size: SIZE_RE.test(String(raw('size') || '')) ? String(raw('size')) : DEFAULTS.size,
++ style: clean(raw('style') || DEFAULTS.style, 400) || DEFAULTS.style,
++ monthly_cap_usd: Number.isFinite(cap) && cap >= 0 ? cap : (cap === null ? null : DEFAULTS.monthly_cap_usd),
++ };
++}
++
++/** What the month has spent so far (finished pictures only), in USD. */
++async function spend(month = new Date().toISOString().slice(0, 7)) {
++ const row = await db.get("SELECT COALESCE(SUM(cost_usd), 0) AS usd, COUNT(*) AS n FROM rundown_pictures WHERE status = 'done' AND done_at >= ?", [`${month}-01 00:00:00`]);
++ return { month, usd: Number(row && row.usd) || 0, pictures: Number(row && row.n) || 0 };
++}
++
++/** The episode's own words, one text: title, logline, recap, topics, then the best takes. Never the streamer's name. */
++function sourceOf(episode, topics, segments) {
++ const lines = [];
++ if (episode && episode.title) lines.push(`Episode: ${episode.title}`);
++ if (episode && episode.logline) lines.push(`Logline: ${episode.logline}`);
++ if (topics && topics.length) lines.push(`Topics: ${topics.map((t) => (typeof t === 'string' ? t : t && t.title)).filter(Boolean).join('; ')}`);
++ if (episode && episode.recap) lines.push(`Recap: ${String(episode.recap).replace(/\s*\n\s*\n\s*/g, ' ')}`);
++ for (const s of (segments || []).slice(0, 6)) {
++ const what = clean(s.take || s.what || '', 280);
++ if (what) lines.push(`Take: ${clean(s.title, 140)}${s.title && what ? ' — ' : ''}${what}`);
++ }
++ return lines.join('\n').slice(0, MAX_SOURCE);
++}
++
++/** May these words become a picture? explicit.js's categories, and the hard line: nothing involving a minor. */
++function verdict(text) {
++ const v = explicit.classifyText(text);
++ if (v.category !== 'fine') return { ok: false, category: v.category, reason: v.reason };
++ if (explicit.MINOR.test(String(text || ''))) return { ok: false, category: 'minor', reason: 'the words involve a minor' };
++ return { ok: true };
++}
++
++/** The prompt: the episode as a scene, not the people in it. */
++function promptFor(source, style) {
++ return [
++ 'A funny editorial illustration for a livestream episode, a wide 1200x630 magazine picture.',
++ 'What the episode was about:',
++ String(source || '').slice(0, MAX_SOURCE),
++ `Style: ${style}`,
++ 'Draw the situation and its objects, not the people: symbolic, cartoonish, exaggerated, clever.',
++ 'Rules: no real person and no recognizable likeness of anyone (faces turned away or stylized); no text, letters,',
++ 'numbers, logos or watermarks; no sexual content or nudity; no minors; no gore; no drugs; no firearms.',
++ 'Warm and publishable, in the spirit of a late-night talk show.',
++ ].join('\n');
++}
++
++/** One row per episode. A new episode starts pending; an existing row is never reset by a rewrite. */
++async function ensure(stream, user, episode, topics) {
++ const id = Number(stream && stream.id);
++ if (!Number.isSafeInteger(id) || id < 1) return null;
++ const source = sourceOf(episode, topics, await db.all("SELECT title, what, take FROM rundown_segments WHERE stream_id = ? AND status = 'live' ORDER BY score DESC, start_sec ASC LIMIT 6", [id]));
++ const v = verdict(source);
++ const status = v.ok ? 'pending' : 'refused';
++ const st = await settings();
++ await db.run(`INSERT INTO rundown_pictures (stream_id, user_id, status, source_text, model, size, next_attempt_at, error)
++ VALUES (?, ?, ?, ?, ?, ?, ov_now(), ?) ON CONFLICT (stream_id) DO NOTHING`,
++ [id, Number(stream.user_id || (user && user.id) || 0), status, source, st.model, st.size, v.ok ? null : `${v.category}: ${v.reason}`]);
++ return await db.get('SELECT * FROM rundown_pictures WHERE stream_id = ?', [id]);
++}
++
++/** The owner's remake: the same episode's words, a fresh attempt (a refusal is re-judged too). */
++async function remake(streamId, { model = null, size = null } = {}) {
++ const id = Number(streamId);
++ const ep = await db.get('SELECT stream_id, user_id, title, logline, recap FROM rundown_episodes WHERE stream_id = ?', [id]);
++ if (!ep) throw Object.assign(new Error('No such episode'), { status: 404 });
++ const topics = await db.all('SELECT t.title FROM rundown_segments s JOIN rundown_topics t ON t.id = s.topic_id WHERE s.stream_id = ? AND s.status = \'live\' AND t.status = \'live\' GROUP BY t.title', [id]);
++ const source = sourceOf(ep, topics, await db.all("SELECT title, what, take FROM rundown_segments WHERE stream_id = ? AND status = 'live' ORDER BY score DESC, start_sec ASC LIMIT 6", [id]));
++ const v = verdict(source);
++ const st = await settings();
++ await db.run(`INSERT INTO rundown_pictures (stream_id, user_id, status, source_text, model, size, attempts, idem_key, next_attempt_at, error, updated_at)
++ VALUES (?, ?, ?, ?, ?, ?, 0, NULL, ov_now(), ?, ov_now())
++ ON CONFLICT (stream_id) DO UPDATE SET status = excluded.status, source_text = excluded.source_text, model = excluded.model, size = excluded.size,
++ attempts = 0, idem_key = NULL, next_attempt_at = ov_now(), error = excluded.error, updated_at = ov_now()`,
++ [id, Number(ep.user_id), v.ok ? 'pending' : 'refused', source, model || st.model, size || st.size, v.ok ? null : `${v.category}: ${v.reason}`]);
++ return await db.get('SELECT * FROM rundown_pictures WHERE stream_id = ?', [id]);
++}
++
++/** Ask for a picture, then store it in Media at 1200×630. The fallback is the AI's own ai.image workflow. */
++async function make(prompt, settingsNow, capLeft, idemKey) {
++ const ai = require('../ai/ai-service');
++ const first = await ai.image({
++ prompt, model: settingsNow.model, size: settingsNow.size, n: 1, maxCostUsd: capLeft,
++ idempotencyKey: idemKey, timeoutMs: 150000,
++ });
++ if (first && first.ok) return { ...first, fallback: false };
++ const transient = first && (first.status === 404 || first.status === 503 || first.status === 0)
++ && /model|provider|route|choice|unavailable|not found/i.test(String(first.code || '') + ' ' + String(first.detail || ''));
++ if (!transient) return first || { ok: false, status: 0, code: 'ai.image_failed', detail: 'AI did not answer' };
++ // The network has the default image route but not the named model (or no free capacity for it): ask the workflow.
++ const r = await ai.run('ai.image', { prompt, size: settingsNow.size, n: 1 }, { idempotencyKey: `${idemKey}-default`, waitMs: 120000 });
++ const out = ai.usable(r);
++ const img = out && Array.isArray(out.images) && out.images.find((i) => i && (i.b64 || i.url));
++ if (!img) return first;
++ return {
++ ok: true, b64: img.b64 || null, url: img.url || null, fallback: true,
++ model: (r.provenance && r.provenance.model) || settingsNow.model,
++ cost_usd: Number(r.usage && r.usage.cost_usd) || 0, run_id: r.id || null,
++ };
++}
++
++/** The bytes of the provider's answer, resized and cropped to the feed's 1200×630. */
++async function pictureBytes(result) {
++ let buf = null;
++ if (result.b64) buf = Buffer.from(String(result.b64), 'base64');
++ else if (result.url) {
++ // The provider's own URL, never a person-chosen one: the egress guard still applies at connect time.
++ const got = await require('../net/egress').fetchBuffer(String(result.url), { timeoutMs: 30000, maxBytes: 20 * 1024 * 1024 });
++ if (got.status !== 200) throw new Error(`the image URL answered ${got.status}`);
++ buf = got.body;
++ }
++ if (!buf || !buf.length) throw new Error('the provider returned no image bytes');
++ const sharp = require('sharp');
++ return await sharp(buf, { failOn: 'none' }).rotate().resize(OUT_W, OUT_H, { fit: 'cover', position: 'attention' })
++ .jpeg({ quality: 86, mozjpeg: true }).toBuffer();
++}
++
++function backoffMs(attempts) { return Math.min(MAX_BACKOFF_MS, BASE_BACKOFF_MS * Math.pow(2, Math.max(0, attempts - 1))); }
++
++/** Settle one claimed row. Never throws: the row's own status is the receipt. */
++async function process(row) {
++ const st = await settings();
++ if (!st.enabled) return { stream_id: row.stream_id, status: 'off' };
++ const ep = await db.get('SELECT stream_id, user_id, title, logline, recap FROM rundown_episodes WHERE stream_id = ? AND status = \'live\'', [row.stream_id]);
++ if (!ep) {
++ await db.run("UPDATE rundown_pictures SET status = 'failed', error = 'the episode is gone', updated_at = ov_now() WHERE stream_id = ?", [row.stream_id]);
++ return { stream_id: row.stream_id, status: 'gone' };
++ }
++ const source = row.source_text || sourceOf(ep, [], []);
++ const v = verdict(source);
++ if (!v.ok) {
++ await db.run("UPDATE rundown_pictures SET status = 'refused', error = ?, updated_at = ov_now() WHERE stream_id = ?", [`${v.category}: ${v.reason}`, row.stream_id]);
++ return { stream_id: row.stream_id, status: 'refused' };
++ }
++ const monthSpend = await spend();
++ const cap = st.monthly_cap_usd;
++ if (cap != null && monthSpend.usd >= cap) {
++ await db.run("UPDATE rundown_pictures SET status = 'pending', next_attempt_at = ?, updated_at = ov_now() WHERE stream_id = ?", [nextMonthStart(), row.stream_id]);
++ return { stream_id: row.stream_id, status: 'cap', next_attempt_at: nextMonthStart() };
++ }
++ const capLeft = cap == null ? null : Math.max(0, cap - monthSpend.usd);
++ const attempt = Number(row.attempts || 0) + 1;
++ const idemKey = row.idem_key || `rundown-picture:${row.stream_id}:${attempt}`;
++ const prompt = promptFor(source, st.style);
++ const promptHash = crypto.createHash('sha256').update(prompt).digest('hex').slice(0, 32);
++ await db.run('UPDATE rundown_pictures SET status = ?, idem_key = ?, prompt_hash = ?, model = ?, size = ?, updated_at = ov_now() WHERE stream_id = ?',
++ ['working', idemKey, promptHash, st.model, st.size, row.stream_id]);
++ let result, bytes;
++ try {
++ result = await make(prompt, st, capLeft, idemKey);
++ if (!result || !result.ok) throw Object.assign(new Error((result && result.detail) || 'the image request failed'), { result });
++ bytes = await pictureBytes(result);
++ } catch (e) {
++ const r = (e && e.result) || null;
++ const code = String((r && r.code) || (e && e.code) || '');
++ const detail = String((r && r.detail) || (e && e.message) || 'the image request failed').slice(0, 300);
++ const status = Number(r && r.status) || 0;
++ if (r && CAP_CODES.has(code)) {
++ await db.run("UPDATE rundown_pictures SET status = 'pending', next_attempt_at = ?, error = ?, updated_at = ov_now() WHERE stream_id = ?", [nextMonthStart(), `cap: ${detail}`, row.stream_id]);
++ return { stream_id: row.stream_id, status: 'cap' };
++ }
++ const refused = (status === 400 || status === 422) && REFUSAL_RE.test(detail);
++ const attempts = attempt;
++ if (refused) {
++ await db.run("UPDATE rundown_pictures SET status = 'refused', attempts = ?, error = ?, updated_at = ov_now() WHERE stream_id = ?", [attempts, `refused: ${detail}`, row.stream_id]);
++ return { stream_id: row.stream_id, status: 'refused' };
++ }
++ if (attempts >= MAX_ATTEMPTS) {
++ await db.run("UPDATE rundown_pictures SET status = 'failed', attempts = ?, error = ?, updated_at = ov_now() WHERE stream_id = ?", [attempts, detail, row.stream_id]);
++ return { stream_id: row.stream_id, status: 'failed' };
++ }
++ const next = plus(backoffMs(attempts));
++ await db.run("UPDATE rundown_pictures SET status = 'pending', attempts = ?, next_attempt_at = ?, error = ?, updated_at = ov_now() WHERE stream_id = ?", [attempts, next, detail, row.stream_id]);
++ return { stream_id: row.stream_id, status: 'retry', next_attempt_at: next };
++ }
++ try {
++ const media = require('../media-client');
++ const file = await media.uploadFile({ buffer: bytes, filename: `rundown-${row.stream_id}.jpg`, contentType: 'image/jpeg' }, { user_id: Number(ep.user_id) });
++ const url = media.publicUrl(file && file.url) || (file && file.url) || null;
++ if (!url) throw new Error('Media returned no URL');
++ const cost = Number(result.cost_usd) || 0;
++ await db.run(`UPDATE rundown_pictures SET status = 'done', attempts = ?, media_key = ?, media_url = ?, cost_usd = ?, model = ?, error = NULL,
++ done_at = ov_now(), updated_at = ov_now(), next_attempt_at = ov_now() WHERE stream_id = ?`,
++ [attempt, (file && file.key) || null, url, cost, result.model || st.model, row.stream_id]);
++ return { stream_id: row.stream_id, status: 'done', url, cost_usd: cost };
++ } catch (e) {
++ const detail = `stored picture: ${String(e && e.message || e).slice(0, 240)}`;
++ const next = plus(backoffMs(attempt));
++ await db.run("UPDATE rundown_pictures SET status = 'pending', attempts = ?, next_attempt_at = ?, error = ?, updated_at = ov_now() WHERE stream_id = ?",
++ [attempt, next, detail, row.stream_id]);
++ return { stream_id: row.stream_id, status: 'retry' };
++ }
++}
++
++/** The duel: one due row at most, claimed atomically. `force` (tests) ignores the due time. */
++async function tick({ force = false, streamId = null } = {}) {
++ if (_busy) return { skipped: 'busy' };
++ const st = await settings();
++ if (!st.enabled) return { skipped: 'off' };
++ // AI's own master switch and daily budget still apply: when AI is off, a picture waits, it does not fail.
++ try { const llm = require('../ai/llm'); if (!await llm.isEnabled() || !await llm.withinBudget()) return { skipped: 'ai' }; } catch { return { skipped: 'ai' }; }
++ _busy = true;
++ try {
++ let row;
++ if (streamId) row = await db.get("SELECT * FROM rundown_pictures WHERE stream_id = ? AND status IN ('pending','working')", [Number(streamId)]);
++ else if (force) row = await db.get("SELECT * FROM rundown_pictures WHERE status IN ('pending','working') ORDER BY next_attempt_at ASC, stream_id ASC LIMIT 1");
++ else row = await db.get("SELECT * FROM rundown_pictures WHERE status IN ('pending','working') AND next_attempt_at <= ov_now() ORDER BY next_attempt_at ASC, stream_id ASC LIMIT 1");
++ if (!row) return { processed: 0 };
++ const claimed = await db.run("UPDATE rundown_pictures SET status = 'working', next_attempt_at = ?, updated_at = ov_now() WHERE stream_id = ? AND status = ?",
++ [plus(CLAIM_MS), row.stream_id, row.status]);
++ if (!Number(claimed && claimed.changes)) return { processed: 0 };
++ const out = await process(row);
++ return { processed: 1, ...out };
++ } finally { _busy = false; }
++}
++
++/** The admin view: settings, this month's spend and the recent rows. */
++async function status({ limit = 20 } = {}) {
++ const st = await settings();
++ const spent = await spend();
++ const counts = await db.get(`SELECT
++ COUNT(*) FILTER (WHERE status = 'done') AS done,
++ COUNT(*) FILTER (WHERE status = 'pending' OR status = 'working') AS pending,
++ COUNT(*) FILTER (WHERE status = 'refused') AS refused,
++ COUNT(*) FILTER (WHERE status = 'failed') AS failed
++ FROM rundown_pictures`);
++ const rows = await db.all(`SELECT p.stream_id, p.status, p.model, p.size, p.media_url, p.cost_usd, p.attempts, p.next_attempt_at, p.error, p.done_at,
++ e.title, u.username, u.display_name
++ FROM rundown_pictures p LEFT JOIN rundown_episodes e ON e.stream_id = p.stream_id LEFT JOIN users u ON u.id = p.user_id
++ ORDER BY p.updated_at DESC, p.stream_id DESC LIMIT ?`, [Math.max(1, Math.min(100, Number(limit) || 20))]);
++ return {
++ settings: st,
++ spend: { month: spent.month, usd: spent.usd, cap_usd: st.monthly_cap_usd, remaining_usd: st.monthly_cap_usd == null ? null : Math.max(0, st.monthly_cap_usd - spent.usd), pictures: spent.pictures },
++ counts: {
++ done: Number(counts && counts.done) || 0, pending: Number(counts && counts.pending) || 0,
++ refused: Number(counts && counts.refused) || 0, failed: Number(counts && counts.failed) || 0,
++ },
++ pictures: rows.map((r) => ({
++ stream_id: Number(r.stream_id), status: r.status, title: r.title || null, username: r.username || null,
++ display_name: r.display_name || null, model: r.model || null, size: r.size || null, image: r.media_url || null,
++ cost_usd: Number(r.cost_usd) || 0, attempts: Number(r.attempts) || 0, next_attempt_at: r.next_attempt_at || null,
++ error: r.error || null, done_at: r.done_at || null,
++ })),
++ };
++}
++
++function start() {
++ if (_job) return;
++ _job = require('../utils/jobs').every('rundown-pictures', 60_000, () => tick().catch((e) => warn(`job: ${e.message}`)), { initialDelayMs: 90_000, jitterMs: 15_000 });
++ console.log('[Rundown pictures] job started (one picture at a time, retried with back-off, monthly cap)');
++}
++function stop() { if (typeof _job === 'function') _job(); _job = null; }
++
++module.exports = {
++ settings, SETTINGS, ensure, remake, tick, status, spend, sourceOf, promptFor, verdict,
++ start, stop, MODEL_RE, SIZE_RE, OUT_W, OUT_H,
++ _test: { reset() { _busy = false; _warned = 0; }, process, make, pictureBytes },
++};
ddiff --git a/server/rundown/read.js b/server/rundown/read.js
iindex 6a81dce4..876ec665 100644
---- a/server/rundown/read.js
++++ b/server/rundown/read.js
@@@ -187,6 +187,7 @@ async function episodesOut(rows, voter) {
type: 'episode', stream_id: Number(r.stream_id), title: r.title, logline: r.logline || null, recap: r.recap || null,
chapters: parseJson(r.chapters_json, []).map((c) => ({ sec: Math.max(0, Math.floor(Number(c.sec) || 0)), title: c.title, segment_id: c.segment_id ? Number(c.segment_id) : null })),
user: ppl.get(Number(r.user_id)) || null, vod_id: r.vod_id ? Number(r.vod_id) : null, minutes: r.minutes == null ? null : Number(r.minutes),
++ picture_url: r.picture_url || null,
aired_at: r.aired_at, topics: byStream.get(Number(r.stream_id)) || [],
reactions: rx.counts.get(Number(r.stream_id)) || {}, my_reaction: rx.mine.get(Number(r.stream_id)) || null,
audience: a.audience || 'public', audience_chip: a.chip || null,
@@@ -197,7 +198,7 @@ async function episodesOut(rows, voter) {
// The columns are qualified: the audience joins bring a second table with the same names.
const SEGMENT_COLS = 's.id, s.stream_id, s.user_id, s.topic_id, s.start_sec, s.title, s.what, s.take, s.quip, s.score, s.quotes_json, s.mentions, s.vod_id, s.said_at';
--const EPISODE_COLS = 'r.stream_id, r.user_id, r.title, r.logline, r.recap, r.chapters_json, r.vod_id, r.minutes, r.aired_at';
++const EPISODE_COLS = 'r.stream_id, r.user_id, r.title, r.logline, r.recap, r.chapters_json, r.vod_id, r.minutes, r.aired_at, (SELECT p.media_url FROM rundown_pictures p WHERE p.stream_id = r.stream_id AND p.status = \'done\') AS picture_url';
async function onAir() {
const rows = await db.all(`SELECT s.id AS stream_id, s.user_id, s.title AS stream_title, s.is_nsfw, s.thumbnail_url,
@@@ -276,7 +277,7 @@ async function streamsFeed({ before = null, limit = STREAMS_PAGE, viewer = null,
for (const t of await segmentsOut(chosen, v.key)) { const l = takes.get(t.stream_id) || []; l.push(t); takes.set(t.stream_id, l); }
const eps = new Map((await episodesOut(await db.all(`SELECT ${EPISODE_COLS} FROM rundown_episodes r ${AUDIENCE_JOINS('r')}
WHERE r.status = 'live' AND r.stream_id = ANY(@ids) AND ${VISIBLE('r')}`, { ids, viewer: v.userId }), v.key))
-- .map((e) => [e.stream_id, { title: e.title, logline: e.logline, minutes: e.minutes, aired_at: e.aired_at, topics: e.topics, reactions: e.reactions, my_reaction: e.my_reaction, audience: e.audience, audience_chip: e.audience_chip }]));
++ .map((e) => [e.stream_id, { title: e.title, logline: e.logline, minutes: e.minutes, aired_at: e.aired_at, picture_url: e.picture_url, topics: e.topics, reactions: e.reactions, my_reaction: e.my_reaction, audience: e.audience, audience_chip: e.audience_chip }]));
const streams = new Map((await db.all(`SELECT s.id, s.user_id, s.title, s.is_live, s.started_at, s.ended_at, s.duration_seconds,
s.is_nsfw, s.thumbnail_url, COALESCE(c.force_nsfw, 0) AS channel_force_nsfw
FROM streams s LEFT JOIN channels c ON c.user_id = s.user_id WHERE s.id = ANY(@ids)`, { ids })).map((s) => [Number(s.id), s]));
ddiff --git a/test/fixtures/rundown-pictures/index.js b/test/fixtures/rundown-pictures/index.js
nnew file mode 100644
iindex 00000000..c9faf057
---- /dev/null
++++ b/test/fixtures/rundown-pictures/index.js
@@@ -0,0 +1,20 @@
++'use strict';
++/**
++ * Recorded OpenVibe.AI image answers (no network): a 1×1 PNG is the smallest real image the image route can answer,
++ * enough for the resize to prove itself; the refusal is the provider's safety-system 400 as it came back.
++ */
++const PNG_1X1 = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAusB9Y9Z4S8AAAAASUVORK5CYII=';
++
++const IMAGE_RESPONSE = JSON.stringify({
++ id: 'run_pic_fixture',
++ created: 1794467559,
++ data: [{ b64_json: PNG_1X1 }],
++ usage: { prompt_tokens: 0, completion_tokens: 1 },
++ x_openvibe: { run_id: 'run_pic_fixture', cost_usd: 0.042, provider: 'openai' },
++});
++
++const REFUSAL_RESPONSE = JSON.stringify({
++ error: { code: 'provider.http', message: 'Your request was rejected by the safety system (content policy).' },
++});
++
++module.exports = { PNG_1X1, IMAGE_RESPONSE, REFUSAL_RESPONSE };
ddiff --git a/test/rundown-pictures.test.js b/test/rundown-pictures.test.js
nnew file mode 100644
iindex 00000000..c8246bba
---- /dev/null
++++ b/test/rundown-pictures.test.js
@@@ -0,0 +1,292 @@
++'use strict';
++/**
++ * The Rundown's episode pictures (server/rundown/pictures.js) on a temp DB, with recorded AI answers, no network:
++ * requests the episode's words and the style make the prompt; model, size, cap and Idempotency-Key go along
++ * storage the answer is resized to 1200×630 and uploaded through Media; the episode read carries it
++ * one per episode a second ensure/tick never makes a second picture; the same idempotency key is reused after a
++ * storage failure, so the provider is asked once
++ * refusals explicit.js's sex acts and any mention of a minor refuse before any prompt leaves Live; a provider
++ * safety refusal is final, not retried
++ * cap the month's spend stops the job and waits for the next month
++ * back-off a network failure waits; the next tick before it is due asks nothing; four failures are final
++ * admin the settings and the remake are owner-only, validated before they are written
++ */
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++process.env.NODE_ENV = 'test';
++const quiet = console.log;
++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
++console.warn = () => {};
++// console.error suppressed only after a failure is printed
++
++const db = require('../server/db/database');
++const auth = require('../server/auth/auth');
++const permissions = require('../server/auth/permissions');
++const pictures = require('../server/rundown/pictures');
++assert.ok(permissions.isSensitiveSettingKey('rundown_picture_enabled') && permissions.isSensitiveSettingKey('rundown_picture_monthly_cap_usd'), 'the picture keys are owner-only in the general settings path too');
++const media = require('../server/media-client');
++const ai = require('../server/ai/ai-service');
++const read = require('../server/rundown/read');
++const fixtures = require('./fixtures/rundown-pictures');
++const principal = require('../server/net/network-principal');
++
++// ── The AI request itself: the model, size, cap and Idempotency-Key, against recorded answers ─
++async function aiRequestTest() {
++ let seen = null;
++ let reply = { status: 200, body: fixtures.IMAGE_RESPONSE };
++ const srv = http.createServer((req, res) => {
++ let body = '';
++ req.on('data', (c) => { body += c; });
++ req.on('end', () => {
++ seen = { method: req.method, path: req.url, headers: req.headers, body: JSON.parse(body || '{}') };
++ res.statusCode = reply.status;
++ res.setHeader('content-type', 'application/json');
++ res.end(reply.body);
++ });
++ });
++ await new Promise((r) => srv.listen(0, '127.0.0.1', r));
++ const oldUrl = process.env.OV_AI_INTERNAL_URL;
++ process.env.OV_AI_INTERNAL_URL = `http://127.0.0.1:${srv.address().port}`;
++ const oldHeaders = principal.serviceHeaders;
++ principal.serviceHeaders = async () => ({ Authorization: 'Bearer test' });
++ const realImage = ai.image;
++ try {
++ const got = await ai.image({ prompt: 'a fridge in a courtroom', model: 'openai/gpt-image-1-mini', size: '1536x1024', n: 1, maxCostUsd: 2.5, idempotencyKey: 'rundown-picture:42:1' });
++ assert.strictEqual(got.ok, true);
++ assert.strictEqual(got.cost_usd, 0.042);
++ assert.strictEqual(got.run_id, 'run_pic_fixture');
++ assert.strictEqual(seen.method, 'POST');
++ assert.strictEqual(seen.path, '/v1/images/generations');
++ assert.strictEqual(seen.headers['idempotency-key'], 'rundown-picture:42:1');
++ assert.strictEqual(seen.body.model, 'openai/gpt-image-1-mini');
++ assert.strictEqual(seen.body.size, '1536x1024');
++ assert.strictEqual(seen.body.n, 1);
++ assert.strictEqual(seen.body.max_cost_usd, 2.5);
++ assert.strictEqual(seen.body.response_format, 'b64_json');
++ reply = { status: 400, body: fixtures.REFUSAL_RESPONSE };
++ const refused = await ai.image({ prompt: 'a thing', model: 'openai/gpt-image-1-mini', size: '1536x1024', idempotencyKey: 'rundown-picture:43:1' });
++ assert.strictEqual(refused.ok, false);
++ assert.strictEqual(refused.status, 400);
++ assert.strictEqual(refused.code, 'provider.http');
++ assert.ok(/safety system/i.test(refused.detail));
++ } finally {
++ ai.image = realImage;
++ principal.serviceHeaders = oldHeaders;
++ if (oldUrl === undefined) delete process.env.OV_AI_INTERNAL_URL; else process.env.OV_AI_INTERNAL_URL = oldUrl;
++ srv.close();
++ }
++}
++
++const settings = async (patch = {}) => {
++ const st = await pictures.settings();
++ for (const [k, v] of Object.entries(patch)) await db.setSetting(pictures.SETTINGS[k], v == null ? '' : String(v));
++ return { ...st, ...patch };
++};
++
++(async () => {
++await db.initDb();
++await db.setSetting('ai_enabled', 'true');
++await settings({ enabled: true, model: 'openai/gpt-image-1-mini', size: '1536x1024', style: 'thick ink lines and flat colour', monthly_cap_usd: 5 });
++
++const mk = async (username, display) => {
++ await db.createUser({ username, email: `${username}@x`, password_hash: 'x', display_name: display || username, stream_key: username.padEnd(32, '0') });
++ const id = (await db.get('SELECT id FROM users WHERE username = ?', [username])).id;
++ await db.ensureChannel(id);
++ return id;
++};
++const goose = await mk('goosely', 'goosely');
++const stream = async (title, live = false) => {
++ const id = Number((await db.createStream({ user_id: goose, title, category: 'Science & Technology', protocol: 'rtmp' })).lastInsertRowid);
++ await db.run(`UPDATE streams SET is_live = ?, started_at = datetime('now', '-2 hours'), ended_at = ${live ? 'NULL' : "datetime('now', '-1 hour')"}, duration_seconds = 3600 WHERE id = ?`, [live ? 1 : 0, id]);
++ return id;
++};
++const writeEpisode = async (streamId, word) => {
++ await db.run(`INSERT INTO rundown_episodes (stream_id, user_id, title, logline, recap, chapters_json, vod_id, minutes, aired_at)
++ VALUES (?, ?, ?, ?, ?, '[]', 99, 60, ov_now())`, [streamId, goose, `${word}: the case against smooth apologies`, `A night of ${word}.`, `He opened with ${word} and then ranked.`]);
++ await db.run(`INSERT INTO rundown_segments (stream_id, user_id, topic_id, start_sec, end_sec, title, what, take, score, quotes_json, mentions, said_at)
++ VALUES (?, ?, NULL, 0, 20, ?, ?, ?, 4, '[]', '{}', ov_now())`, [streamId, goose, `The ${word} bit`, `He read about ${word}.`, `He thinks ${word} should stay offline.`]);
++ return await db.get('SELECT * FROM rundown_episodes WHERE stream_id = ?', [streamId]);
++};
++
++let imageCalls = [], runCalls = [], uploads = [];
++const setImage = (impl) => { ai.image = async (o) => { imageCalls.push(o); return impl(o); }; };
++const setRun = (impl = async () => null) => { ai.run = async (workflow, input, opts) => { runCalls.push({ workflow, input, opts }); return impl(workflow, input, opts); }; };
++const resetCalls = () => { imageCalls = []; runCalls = []; uploads = []; };
++media.uploadFile = async (file, fields) => { uploads.push({ size: file.buffer.length, fields }); return { key: 'rundown-test.jpg', url: '/f/rundown-test.jpg' }; };
++media.publicUrl = (u) => (u ? (String(u).startsWith('https://') ? String(u) : `https://openvibe.media${u}`) : null);
++setRun();
++setImage(async () => ({ ok: true, b64: fixtures.PNG_1X1, model: 'gpt-image-1-mini', cost_usd: 0.04, run_id: 'run_pic_1' }));
++
++// ── Requests, storage, one picture ───────────────────────────
++const s1 = await stream('Smooth apologies');
++const e1 = await writeEpisode(s1, 'apologies');
++await pictures.ensure({ id: s1, user_id: goose }, { id: goose }, e1, [{ title: 'Smooth apologies' }]);
++let out = await pictures.tick({ force: true, streamId: s1 });
++assert.strictEqual(out.status, 'done', 'the picture is made');
++assert.strictEqual(imageCalls.length, 1, 'one request');
++assert.strictEqual(imageCalls[0].model, 'openai/gpt-image-1-mini');
++assert.strictEqual(imageCalls[0].size, '1536x1024');
++assert.strictEqual(imageCalls[0].maxCostUsd, 5, 'the month cap is left as the ceiling');
++assert.ok(/apologies/i.test(imageCalls[0].prompt), 'the episode title is in the prompt');
++assert.ok(/thick ink lines/i.test(imageCalls[0].prompt), 'the style line is in the prompt');
++assert.ok(/no real person/i.test(imageCalls[0].prompt) && /no minors/i.test(imageCalls[0].prompt), 'the hard rules are in the prompt');
++assert.ok(/^rundown-picture:/.test(imageCalls[0].idempotencyKey), 'an idempotency key is sent');
++assert.strictEqual(uploads.length, 1, 'the picture goes through Media');
++assert.ok(uploads[0].size > 500, 'the 1×1 answer is resized to a real JPEG');
++const row = await db.get('SELECT * FROM rundown_pictures WHERE stream_id = ?', [s1]);
++assert.strictEqual(row.status, 'done');
++assert.strictEqual(row.media_url, 'https://openvibe.media/f/rundown-test.jpg');
++assert.strictEqual(Number(row.cost_usd), 0.04);
++const feed = await read.streamsFeed({});
++assert.strictEqual(feed.streams[0].episode.picture_url, 'https://openvibe.media/f/rundown-test.jpg', 'the episode read carries the picture');
++const episode = await read.episode(s1);
++assert.strictEqual(episode.episode.picture_url, 'https://openvibe.media/f/rundown-test.jpg');
++
++resetCalls();
++await pictures.ensure({ id: s1, user_id: goose }, { id: goose }, e1, [{ title: 'Smooth apologies' }]);
++out = await pictures.tick({ force: true, streamId: s1 });
++assert.strictEqual(imageCalls.length, 0, 'a done episode is never asked for again');
++assert.strictEqual(out.processed, 0);
++
++// ── A storage failure reuses the idempotency key ─────────────
++const s2 = await stream('Fridge security');
++const e2 = await writeEpisode(s2, 'fridges');
++await pictures.ensure({ id: s2, user_id: goose }, { id: goose }, e2, [{ title: 'Fridge security' }]);
++const realUpload = media.uploadFile;
++media.uploadFile = async () => { throw new Error('Media is down'); };
++out = await pictures.tick({ force: true, streamId: s2 });
++assert.strictEqual(out.status, 'retry');
++const firstKey = imageCalls[0].idempotencyKey;
++media.uploadFile = realUpload;
++out = await pictures.tick({ force: true, streamId: s2 });
++assert.strictEqual(out.status, 'done');
++assert.strictEqual(imageCalls[1].idempotencyKey, firstKey, 'the retry is the same AI run, never a second charge');
++
++// ── Refusals: explicit words and a minor, before any request ─
++resetCalls();
++const s3 = await stream('Porn and the fridge');
++const e3 = await writeEpisode(s3, 'porn');
++await pictures.ensure({ id: s3, user_id: goose }, { id: goose }, e3, []);
++out = await pictures.tick({ force: true, streamId: s3 });
++assert.strictEqual(out.processed, 0, 'a refused row is not due');
++assert.strictEqual(imageCalls.length, 0, 'explicit words never leave Live');
++assert.strictEqual((await db.get('SELECT status FROM rundown_pictures WHERE stream_id = ?', [s3])).status, 'refused');
++
++const s3b = await stream('The kid at the shop');
++const e3b = await writeEpisode(s3b, 'kid');
++await pictures.ensure({ id: s3b, user_id: goose }, { id: goose }, e3b, []);
++out = await pictures.tick({ force: true, streamId: s3b });
++assert.strictEqual(out.processed, 0, 'a refused row is not due');
++assert.strictEqual(imageCalls.length, 0, 'a minor in the words is never pictured');
++assert.strictEqual((await db.get('SELECT status FROM rundown_pictures WHERE stream_id = ?', [s3b])).status, 'refused');
++
++// A provider's safety refusal is final too.
++const s4 = await stream('A provider refusal');
++const e4 = await writeEpisode(s4, 'refusal');
++await pictures.ensure({ id: s4, user_id: goose }, { id: goose }, e4, []);
++setImage(async () => ({ ok: false, status: 400, code: 'provider.http', detail: 'Your request was rejected by the safety system.' }));
++resetCalls();
++out = await pictures.tick({ force: true, streamId: s4 });
++assert.strictEqual(out.status, 'refused');
++assert.strictEqual(imageCalls.length, 1);
++assert.strictEqual((await db.get('SELECT status FROM rundown_pictures WHERE stream_id = ?', [s4])).status, 'refused');
++
++// ── The monthly cap stops the job until next month ────────────
++const s5 = await stream('Over the cap');
++const e5 = await writeEpisode(s5, 'cap');
++await settings({ monthly_cap_usd: 0.04 });
++await pictures.ensure({ id: s5, user_id: goose }, { id: goose }, e5, []);
++setImage(async () => ({ ok: true, b64: fixtures.PNG_1X1, cost_usd: 0.01 }));
++resetCalls();
++out = await pictures.tick({ force: true, streamId: s5 });
++assert.strictEqual(out.status, 'cap');
++assert.strictEqual(imageCalls.length, 0, 'no request over the cap');
++const capRow = await db.get('SELECT * FROM rundown_pictures WHERE stream_id = ?', [s5]);
++assert.strictEqual(capRow.status, 'pending');
++assert.ok(capRow.next_attempt_at > new Date().toISOString().slice(0, 19), 'it waits for the next month');
++await settings({ monthly_cap_usd: 5 });
++
++// ── Back-off: a network failure waits, and four are final ─────
++const s6 = await stream('A network failure');
++const e6 = await writeEpisode(s6, 'failure');
++await pictures.ensure({ id: s6, user_id: goose }, { id: goose }, e6, []);
++setImage(async () => ({ ok: false, status: 0, code: 'ai.unreachable', detail: 'connect ECONNREFUSED' }));
++resetCalls();
++out = await pictures.tick({ force: true, streamId: s6 });
++assert.strictEqual(out.status, 'retry');
++const retryRow = await db.get('SELECT * FROM rundown_pictures WHERE stream_id = ?', [s6]);
++assert.strictEqual(retryRow.attempts, 1);
++const retryAt = Date.parse(`${String(retryRow.next_attempt_at).replace(' ', 'T')}Z`);
++assert.ok(retryAt >= Date.now() + 5 * 60_000, 'a failed attempt waits');
++out = await pictures.tick();
++assert.strictEqual(out.processed, 0, 'the due time is respected: nothing is retried at once');
++for (let i = 0; i < 3; i++) await pictures.tick({ force: true, streamId: s6 });
++assert.strictEqual((await db.get('SELECT status FROM rundown_pictures WHERE stream_id = ?', [s6])).status, 'failed', 'four failures are final');
++
++// ── The fallback: the named model has no route, the workflow answers ─
++const s7 = await stream('Fallback picture');
++const e7 = await writeEpisode(s7, 'fallback');
++await pictures.ensure({ id: s7, user_id: goose }, { id: goose }, e7, []);
++setImage(async () => ({ ok: false, status: 503, code: 'route.unavailable', detail: 'no image route for that model' }));
++setRun(async () => ({ id: 'run_fb', status: 'succeeded', output: { images: [{ b64: fixtures.PNG_1X1, url: null }], count: 1 }, usage: { cost_usd: 0.02 }, provenance: { model: 'shared-image' } }));
++resetCalls();
++out = await pictures.tick({ force: true, streamId: s7 });
++assert.strictEqual(out.status, 'done');
++assert.strictEqual(runCalls.length, 1, 'the ordinary ai.image workflow is asked once');
++assert.strictEqual(runCalls[0].workflow, 'ai.image');
++setRun();
++
++// ── The admin settings and the remake are owner-only ─────────
++const signIn = async (req) => {
++ const id = Number(req.headers['x-test-user'] || 0);
++ const u = id ? await db.getUserById(id) : null;
++ if (u) req.user = { ...u, subject_id: `usr_TEST${String(id).padStart(22, '0')}` };
++ return u;
++};
++auth.requireAuth = async (req, res, next) => ((await signIn(req)) ? next() : res.status(401).json({ error: 'Authentication required' }));
++const siteConfig = require('../server/admin/site-config');
++siteConfig.change = async ({ set }) => { for (const [k, v] of Object.entries(set)) await db.setSetting(k, v); return { revision: 1 }; };
++const picturesTick = pictures.tick;
++pictures.tick = async () => ({ skipped: 'test' });
++const adminRouter = require('../server/rundown/admin').router;
++const app = express();
++app.use(express.json());
++app.use('/api/admin/rundown', adminRouter);
++const server = http.createServer(app).listen(0, '127.0.0.1');
++await new Promise((r) => server.once('listening', r));
++const base = `http://127.0.0.1:${server.address().port}/api/admin/rundown`;
++const call = async (method, path, body, user = 1) => {
++ const r = await fetch(base + path, { method, headers: { 'x-test-user': String(user), 'content-type': 'application/json' }, body: body === undefined ? undefined : JSON.stringify(body) });
++ return { status: r.status, body: await r.json().catch(() => null) };
++};
++const owner = await mk('owner', 'owner');
++const helper = await mk('helper', 'helper');
++await db.run("UPDATE users SET role = 'admin', is_owner = 1 WHERE id = ?", [owner]);
++await db.run("UPDATE users SET role = 'admin', is_owner = 0 WHERE id = ?", [helper]);
++let r = await call('GET', '', undefined, helper);
++assert.strictEqual(r.status, 403, 'a non-owner admin cannot read the picture settings');
++r = await call('PUT', '/settings', { settings: { enabled: false } }, helper);
++assert.strictEqual(r.status, 403, 'a non-owner admin cannot write them');
++r = await call('GET', '', undefined, owner);
++assert.strictEqual(r.status, 200);
++assert.ok(r.body.settings && typeof r.body.settings.enabled === 'boolean');
++assert.ok(r.body.spend && typeof r.body.spend.usd === 'number');
++assert.ok(Array.isArray(r.body.pictures));
++r = await call('PUT', '/settings', { settings: { size: 'huge' } }, owner);
++assert.strictEqual(r.status, 400, 'an invalid size is refused');
++r = await call('PUT', '/settings', { settings: { enabled: false, model: 'openai/gpt-image-1', size: '1024x1024', style: 'paper cut-outs', monthly_cap_usd: 2.5 } }, owner);
++assert.strictEqual(r.status, 200);
++assert.deepStrictEqual([r.body.settings.enabled, r.body.settings.model, r.body.settings.size, r.body.settings.style, r.body.settings.monthly_cap_usd], [false, 'openai/gpt-image-1', '1024x1024', 'paper cut-outs', 2.5]);
++r = await call('POST', `/episodes/${s2}/remake`, {}, owner);
++assert.strictEqual(r.status, 202);
++assert.strictEqual(r.body.picture.status, 'pending');
++assert.strictEqual(r.body.picture.attempts, 0);
++r = await call('POST', '/episodes/999999/remake', {}, owner);
++assert.strictEqual(r.status, 404);
++pictures.tick = picturesTick;
++server.close();
++
++console.log('rundown pictures: requests, storage, one per episode, refusals, cap, back-off and owner-only ok');
++process.exit(0);
++})().catch((e) => { console.error(e); process.exit(1); });