openvibers/openvibe.live Commits
The Rundown: an episode picture made from its own words (#852)
Changes
@@@ -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 itA 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 filtersnnew file mode 100644@@@ -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);@@@ -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 };@@@ -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) {@@@ -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 streamapp.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).nnew file mode 100644@@@ -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 };@@@ -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.@@@ -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 alreadyreturn { called: true, episode: true };}nnew file mode 100644@@@ -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 },++};@@@ -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_nsfwFROM 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]));nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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); });
@@ -45,6 +45,41 @@ Speech-to-text hears a stream's whole audio track. On 2026-10-18 the Arena's "qu
@@ -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.
- **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).
- **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
## 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`).
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`:
@@ -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_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_episodes`: one per stream, holding the title, logline, recap, chapters, VOD and minutes.
- `rundown_progress`: how far the desk has read each stream.
- `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.
- `rundown_reactions`: readers' reactions.
Migration `0030_rundown_audiences.sql` (expand only, 2026-11-06) holds what the sharing system does not know:
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
@@ -203,9 +239,14 @@ When an episode is written within 6 hours of its stream ending, it is announced
| Key | Default | Effect |
| Key | Default | Effect |
|---|---|---|
|---|---|---|
| `rundown_enabled` | `true` | `false` stops the desk (pages keep what was written) |
| `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 |
| `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
## The bar: search and filters
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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);
@@ -140,6 +140,56 @@ function ownerRef(ownerUserId) {
@@ -140,6 +140,56 @@ function ownerRef(ownerUserId) {
return id > 0 ? { service: 'live', type: 'user', id: String(id) } : undefined;
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
* 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,
* allow-listed OpenVibe hosts); files, buffers and any other URL are downscaled here, as before,
@@ -197,4 +247,4 @@ async function complete(o = {}, { toVisionJpeg } = {}) {
@@ -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 };
@@ -70,7 +70,7 @@ function canModerateContentOwner(actor, contentOwner) {
@@ -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;
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
// money_writes_frozen (the Billing-cutover freeze) is owner-only too; every admin can READ it at
// GET /api/admin/money.
// 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.
// 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']);
const SENSITIVE_KEY_EXACT = new Set(['youtube_api_key', 'ytdlp_extra_args']);
function isSensitiveSettingKey(key) {
function isSensitiveSettingKey(key) {
@@ -895,6 +895,8 @@ app.get('/embed/:username', require('./streaming/channel-embed').page);
@@ -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/cam', require('./cam/routes')); // the cam button: a viewer takes a still of a live stream
app.use('/api/comments', commentRoutes);
app.use('/api/comments', commentRoutes);
app.use('/api/admin/openre', require('./openre/routes'));
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/admin', adminRoutes);
app.use('/api/mod', require('./admin/mod-routes'));
app.use('/api/mod', require('./admin/mod-routes'));
app.use('/api/robotstreamer', robotStreamerRoutes);
app.use('/api/robotstreamer', robotStreamerRoutes);
@@ -1612,6 +1614,8 @@ async function start() {
@@ -1612,6 +1614,8 @@ async function start() {
try { require('./rundown/desk').start(); } catch (e) { console.warn('[Rundown] desk not started:', e.message); }
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.
// 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); }
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); }
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
// 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).
// are pruned on the same minute (server/agent-streams/sweep.js).
new file mode 100644
new file mode 100644
@@ -0,0 +1,109 @@
@@ -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 };
@@ -75,6 +75,7 @@ async function deleteEpisode(streamId, user) {
@@ -75,6 +75,7 @@ async function deleteEpisode(streamId, user) {
await db.run("DELETE FROM rundown_reactions WHERE item_type = 'episode' AND item_id = ?", [id]);
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]);
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_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 recountTopics(segs.map((s) => s.topic_id).filter(Boolean));
await forgetShare(id);
await forgetShare(id);
await markDeleted(id, user.id);
await markDeleted(id, user.id);
@@ -91,6 +92,7 @@ async function deleteAll(user, { keepOut = false } = {}) {
@@ -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 (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 (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_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));
await recountTopics(segs.map((s) => s.topic_id).filter(Boolean));
for (const id of epIds) { await forgetShare(id); await markDeleted(id, user.id); }
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.
// A take without an episode (a stream still live) is a write-up too: the desk must not make it again.
@@ -371,6 +371,7 @@ async function writeEpisode(stream, user) {
@@ -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).
// 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_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_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);
await setProgress(stream.id, stream.read_to_sec, 1);
return { called: false };
return { called: false };
}
}
@@ -396,6 +397,9 @@ async function writeEpisode(stream, user) {
@@ -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())]);
[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 setProgress(stream.id, stream.read_to_sec, 1);
await ensureShare(stream.id, user.id);
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
if (!rewrite) await announce(stream, user, title).catch(() => {}); // written again (a re-read): announced once already
return { called: true, episode: true };
return { called: true, episode: true };
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,334 @@
@@ -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 },
+};
@@ -187,6 +187,7 @@ async function episodesOut(rows, voter) {
@@ -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,
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 })),
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),
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)) || [],
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,
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,
audience: a.audience || 'public', audience_chip: a.chip || null,
@@ -197,7 +198,7 @@ async function episodesOut(rows, voter) {
@@ -197,7 +198,7 @@ async function episodesOut(rows, voter) {
// The columns are qualified: the audience joins bring a second table with the same names.
// 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 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() {
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,
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,
@@ -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); }
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')}
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))
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,
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
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]));
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]));
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,292 @@
@@ -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); });