openvibers/openvibe.live Commits

AI Moments thumbnails use the paste's real image URL (Community's create answer has none, so the job reads the paste back; Media's /p/<slug>/screenshot never knew Community pastes, so every moment since the move showed a broken image); deploy cards fold only when nobody has spoken in any room since, within 3 hours

OP OpenVibers committed 23 Sept 2026 · 047f206419 · 4 files, +30 −13


Changes

ddiff --git a/server/ai/ai-moments-job.js b/server/ai/ai-moments-job.js
iindex d16b2237..58dd3be8 100644
---- a/server/ai/ai-moments-job.js
++++ b/server/ai/ai-moments-job.js
@@@ -435,7 +435,8 @@ async function tick(opts = {}) {
// Post the image paste to OpenVibe.Media. Extra fields (slug/metadata/
// ai_summary/ai_tags/stream_id) are inherited-shape extensions the
// contract leaves open — Media ignores what it doesn't know.
-- const paste = await require('../pastes-client').createPaste({
++ const pastesClient = require('../pastes-client');
++ const paste = await pastesClient.createPaste({
slug, user_id: v.user_id,
title: title.slice(0, 80), content, language: 'text', visibility: 'public',
stream_id: v.stream_id, metadata,
@@@ -448,9 +449,15 @@ async function tick(opts = {}) {
}, { origin: 'ai' });
if (paste) {
pasteSlug = paste.slug || slug;
-- // Screenshot pastes must use the image endpoint — /raw serves the
-- // (empty) text content and broke the hero background rotation.
-- img = media.publicUrl(paste.screenshot_url) || `${media.pasteUrl(pasteSlug)}/screenshot`;
++ // Screenshot pastes must use the image itself — /raw serves the (empty) text
++ // content and broke the hero background rotation. Community's create answer has
++ // no screenshot_url, so read the paste back for it; Media's /p/<slug>/screenshot
++ // never knew Community pastes (every moment since the move had a broken image).
++ let shot = paste.screenshot_url || null;
++ if (!shot && pasteSlug) { try { shot = ((await pastesClient.getPaste(pasteSlug)) || {}).screenshot_url || null; } catch { /* fall back below */ } }
++ img = media.publicUrl(shot) || (pastesClient.onCommunity()
++ ? `${(process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/+$/, '')}/p/${encodeURIComponent(pasteSlug)}/screenshot`
++ : `${media.pasteUrl(pasteSlug)}/screenshot`);
}
} catch (e) { console.warn(`[AI-Moments] paste post failed for VOD ${v.vod_id}:`, e.message); }
try { fs.unlinkSync(screenshotPath); } catch { /* tmp frame */ }
ddiff --git a/server/ai/backfill-job.js b/server/ai/backfill-job.js
iindex 98ab840c..aa159d7e 100644
---- a/server/ai/backfill-job.js
++++ b/server/ai/backfill-job.js
@@@ -104,7 +104,7 @@ async function tick() {
// screenshot_url is RELATIVE ("/p/<slug>/screenshot") and the file
// lives on Media's disk, not ours — so it has to be fetched over
// HTTP from Media's public base, not opened as a path.
-- const img = p.slug ? media.pasteScreenshotUrl(p.slug) : null;
++ const img = media.publicUrl(p.screenshot_url) || (p.slug ? media.pasteScreenshotUrl(p.slug) : null); // Community pastes carry their image URL
if (img) r = await ai.analyzeImagePaste(img, p.title).catch(() => null);
} else {
r = await ai.analyzeTextPaste(p.content || '', p.title).catch(() => null);
ddiff --git a/server/chat/deploy-notice.js b/server/chat/deploy-notice.js
iindex a2ba4ff9..62f7bb77 100644
---- a/server/chat/deploy-notice.js
++++ b/server/chat/deploy-notice.js
@@@ -9,8 +9,8 @@
// Now:
// • Only commits that were never announced are announced (site setting `deploy_last_announced`).
// A restart with no new code says nothing.
--// • Consecutive deploys fold into ONE stored message: while the newest global chat row is a deploy
--// notice (nobody has spoken since) and it is under 12 hours old, that row is updated in place.
++// • Consecutive deploys fold into ONE stored message: while the newest chat row in ANY room is a
++// deploy notice (nobody has spoken anywhere since) and it is under 3 hours old, it is updated in place.
// • The row stores data, not prose: metadata { kind: 'deploy', commits[], deploys, first_at,
// updated_at }. Clients render times from ISO timestamps, so they are always right.
// • The live broadcast carries the row id; clients replace the card with that id instead of
@@@ -26,7 +26,8 @@ const path = require('path');
const REPO_DIR = path.join(__dirname, '..', '..');
const SETTING = 'deploy_last_announced';
--const FOLD_WINDOW_MS = 12 * 60 * 60 * 1000;
++// A card covers at most 3 hours of deploys, so its time range stays readable.
++const FOLD_WINDOW_MS = 3 * 60 * 60 * 1000;
const MAX_COMMITS = 40;
const ATTEMPTS_MS = [5000, 20000, 45000]; // clients reconnect with backoff after a restart
@@@ -63,7 +64,9 @@ const plainText = (meta) => `🚀 ${meta.commits.length} update${meta.commits.le
/** Insert a notice, or fold into the newest row when that row is itself a recent deploy notice. */
function persist(db, commits) {
const nowIso = new Date().toISOString();
-- const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_global = 1 AND is_deleted = 0 ORDER BY id DESC LIMIT 1');
++ // Newest across EVERY room: the global feed shows stream and channel messages too, so folding
++ // while people chatted in a stream left a card whose time range ran past the messages under it.
++ const newest = db.get('SELECT id, message_type, metadata FROM chat_messages WHERE is_deleted = 0 ORDER BY id DESC LIMIT 1');
let prev = null;
if (newest && newest.message_type === 'system' && newest.metadata) {
try { const m = JSON.parse(newest.metadata); if (m && m.kind === 'deploy' && Date.now() - Date.parse(m.first_at) < FOLD_WINDOW_MS) prev = m; } catch { /* not ours */ }
ddiff --git a/test/deploy-notice.test.js b/test/deploy-notice.test.js
iindex aa45e485..3a108434 100644
---- a/test/deploy-notice.test.js
++++ b/test/deploy-notice.test.js
@@@ -6,7 +6,7 @@ const dn = require('../server/chat/deploy-notice');
const rows = []; const settings = {};
const db = {
getSetting: (k) => settings[k], setSetting: (k, v) => { settings[k] = v; },
-- get: () => rows.filter(r => r.is_global && !r.is_deleted).slice(-1)[0] || undefined,
++ get: () => rows.filter(r => !r.is_deleted).slice(-1)[0] || undefined, // newest row of ANY room
run: (_sql, [message, metadata, id]) => { const r = rows.find(x => x.id === id); r.message = message; r.metadata = metadata; },
saveChatMessage: (m) => { const r = { id: rows.length + 1, is_global: 1, is_deleted: 0, message_type: m.message_type, message: m.message, metadata: m.metadata ? JSON.stringify(m.metadata) : null }; rows.push(r); return { lastInsertRowid: r.id }; },
};
@@@ -26,9 +26,16 @@ const third = dn.persist(db, [c(3)]);
assert.equal(rows.length, 3, 'after real chat, the next deploy starts a new notice');
assert.equal(third.meta.deploys, 1);
--const old = JSON.parse(rows[2].metadata); old.first_at = new Date(Date.now() - 13 * 3600 * 1000).toISOString(); rows[2].metadata = JSON.stringify(old);
--dn.persist(db, [c(4)]);
--assert.equal(rows.length, 4, 'a notice older than the fold window is not reopened');
++// A message in a STREAM room (not global, but shown in the global feed) also ends the card: folding
++// past it would give the card a time range running past the message shown under it.
++rows.push({ id: rows.length + 1, is_global: 0, is_deleted: 0, message_type: 'chat', message: 'hello brother', metadata: null });
++const fourth = dn.persist(db, [c(4)]);
++assert.equal(rows.length, 5, 'a stream-room message starts a new card too');
++assert.equal(fourth.meta.deploys, 1);
++
++const old = JSON.parse(rows[4].metadata); old.first_at = new Date(Date.now() - 4 * 3600 * 1000).toISOString(); rows[4].metadata = JSON.stringify(old);
++dn.persist(db, [c(5)]);
++assert.equal(rows.length, 6, 'a notice older than the 3-hour fold window is not reopened');
assert.deepEqual(dn.parseLog('zz\x1fab\x1f2026\x1fnope'), [], 'malformed git lines are dropped');
console.log('deploy notice: all checks passed');