openvibers/openvibe.live Commits
Clip trim fixes: a trimmed clip gets its own AI overview and transcript (clip_ai_state.ai_window, a new analysis key), cutting footage follows the channel's delete rule, the editor's Save ignores a second press, and a reload mid-trim resumes as a trim instead of reopening the editor (#296)
Changes
nnew file mode 100644@@@ -0,0 +1,8 @@++-- phase: expand++-- The clip window Live's AI state describes (server/media-proxy/outcomes.js, db.setClipAiWindow): '<start>-<end>',++-- seconds of the source recording to a tenth, from Media's clip.ready. A clip trimmed in place (Media POST++-- /clips/:id/trim) keeps its id with a narrower window; when clip.ready names a window other than this one, the clip's++-- overview and transcript describe footage it no longer has, so they are cleared and media.analyze runs again under a++-- new key. NULL until the clip's next clip.ready (or a trim from the clip editor) records it. Nullable, no default:++-- the N-1 release neither reads nor writes it.++ALTER TABLE clip_ai_state ADD COLUMN IF NOT EXISTS ai_window text COLLATE "C";@@@ -5,9 +5,12 @@* and survives a reload: pending cuts are kept in localStorage (ov_clip_jobs, 30 minutes at most) and* the next page that loads this reattaches to them.*-- * OVClipJobs.follow(clipId, jobId, { onReady(clip), onFailed(message), onRetry(job) })-- * OVClipJobs.resume({ onReady, onFailed, onRetry }) follow what an earlier page left pending-- * OVClipJobs.pending() [{ clipId, jobId, at }]++ * OVClipJobs.follow(clipId, jobId, { onReady(clip), onFailed(message), onRetry(job) }, kind?)++ * OVClipJobs.resume({ onReady, onFailed, onRetry }, byKind?) follow what an earlier page left pending++ * OVClipJobs.pending() [{ clipId, jobId, kind, at }]++ *++ * `kind` names what the cut is for ('trim': a clip cut down in its editor), so a reload resumes it with that kind's++ * handlers (byKind[kind]) instead of a new clip's (which open the editor again); without one, the first handlers.*/(function () {'use strict';@@@ -23,9 +26,11 @@}function save(list) { try { localStorage.setItem(KEY, JSON.stringify(list.slice(-10))); } catch (e) { /* storage off: this page still follows */ } }function forget(jobId) { save(load().filter(function (e) { return e.jobId !== jobId; })); delete followed[jobId]; }-- function remember(clipId, jobId) {++ function remember(clipId, jobId, kind) {var list = load().filter(function (e) { return e.jobId !== jobId; });-- list.push({ clipId: clipId, jobId: jobId, at: Date.now() });++ var entry = { clipId: clipId, jobId: jobId, at: Date.now() };++ if (kind) entry.kind = String(kind);++ list.push(entry);save(list);}function headers() {@@@ -34,11 +39,11 @@return t ? { Authorization: 'Bearer ' + t } : {};}-- function follow(clipId, jobId, h) {++ function follow(clipId, jobId, h, kind) {h = h || {};if (!clipId || !jobId || followed[jobId]) return;followed[jobId] = true;-- remember(clipId, jobId);++ remember(clipId, jobId, kind);var started = Date.now(), tries = 0, retried = 0;(function poll() {if (Date.now() - started > MAX_AGE_MS) { forget(jobId); return; }@@@ -58,7 +63,12 @@})();}-- function resume(h) { load().forEach(function (e) { follow(e.clipId, e.jobId, h); }); }++ function resume(h, byKind) {++ load().forEach(function (e) {++ var own = e.kind && byKind && Object.prototype.hasOwnProperty.call(byKind, e.kind) ? byKind[e.kind] : null;++ follow(e.clipId, e.jobId, own || h, e.kind);++ });++ }window.OVClipJobs = { follow: follow, resume: resume, pending: load };})();@@@ -902,7 +902,7 @@ function _applyLoaderThumb(stream) {function initPlayer(stream) {// Clips this browser asked for before a reload are still being cut: reattach to them.-- if (window.OVClipJobs && !initPlayer._clipsResumed) { initPlayer._clipsResumed = true; window.OVClipJobs.resume(_clipCutHandlers); }++ if (window.OVClipJobs && !initPlayer._clipsResumed) { initPlayer._clipsResumed = true; window.OVClipJobs.resume(_clipCutHandlers, { trim: _clipTrimHandlers }); }destroyPlayer();// Camera overlay is a separate viewer session against another slot's live stream;// attach it alongside whichever protocol the main stream uses, and let it fail@@@ -3143,6 +3143,13 @@ const _clipCutHandlers = {onRetry: (job) => toast(`Still working on your clip (attempt ${job.attempts + 1} of ${job.max_attempts})…`, 'info'),};function _followClipCut(clipId, jobId) { window.OVClipJobs.follow(clipId, jobId, _clipCutHandlers); }++// A trim saved in the clip editor is cut again on the server: its outcome is a toast only (the editor is done), also++// when a reload resumes it (ov-clip-jobs.js keeps its kind, 'trim').++const _clipTrimHandlers = {++ onReady: () => toast('Clip trimmed & saved!', 'success'),++ onFailed: (msg) => toast('The trimmed clip could not be cut: ' + msg, 'error'),++ onRetry: (job) => _clipCutHandlers.onRetry(job),++};/*** Show a modal prompt for the user to title their clip after creation.@@@ -3332,6 +3339,8 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {const close = () => { try { video.pause(); } catch {} overlay.remove(); };const doSave = async () => {++ // Enter in the title field also saves: a second press while the first save runs would trim the narrowed clip again.++ if (saveBtn.disabled) return;const title = titleInput.value.trim();saveBtn.disabled = true;saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';@@@ -3348,11 +3357,7 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {// A trim is cut again on the server: follow that cut like the first one.if (data.job_id && window.OVClipJobs) {toast('Saving your trimmed clip…', 'info');-- window.OVClipJobs.follow(clipId, data.job_id, {-- onReady: () => toast('Clip trimmed & saved!', 'success'),-- onFailed: (msg) => toast('The trimmed clip could not be cut: ' + msg, 'error'),-- onRetry: _clipCutHandlers.onRetry,-- });++ window.OVClipJobs.follow(clipId, data.job_id, _clipTrimHandlers, 'trim');} else {toast(data.unchanged ? 'Clip saved!' : 'Clip trimmed & saved!', 'success');}@@@ -33,13 +33,18 @@ async function analyze(kind, item) {inFlight.add(key);const bump = kind === 'vod' ? db.bumpVodTranscriptAttempt : db.bumpClipTranscriptAttempt;const status = kind === 'vod' ? db.setVodTranscriptStatus : db.setClipTranscriptStatus;++ // A clip trimmed while this ran: its clip.ready cleared the state and moved the attempt on (db.setClipAiWindow), and++ // it gets an analysis of its own. This one describes the old window, so it stores nothing, not even a retry.++ let attemptNo = null;++ const superseded = async () => kind === 'clip' && attemptNo != null++ && (Number((await db.getClipAiState(item.id) || {}).transcript_attempts) || 0) !== attemptNo;try {const state = kind === 'vod' ? await db.getVodAiState(item.id) : await db.getClipAiState(item.id);-- const mediaUrl = await source(kind, item);-- if (!mediaUrl) throw new Error('Media did not provide a readable URL');// One AI run per attempt: a retry after a failure starts a new run, while a run still going when the poll// budget ends is re-joined later by the same key (never a second analysis of the same recording at once).-- const attemptNo = Number(state && state.transcript_attempts) || 0;++ attemptNo = Number(state && state.transcript_attempts) || 0;++ const mediaUrl = await source(kind, item);++ if (!mediaUrl) throw new Error('Media did not provide a readable URL');const run = await aiService.run('media.analyze', { media_url: mediaUrl, language: 'en', allow_paid: false }, {target: { service: 'live', type: kind, id: String(item.id) },attribution: aiService.ownerRef(item.user_id),@@@ -49,13 +54,14 @@ async function analyze(kind, item) {waitMs: Math.max(0, Number(process.env.LIVE_MEDIA_ANALYSIS_WAIT_MS ?? 60000) || 0), pollMs: 0,});if (run && (run.status === 'queued' || run.status === 'running')) {-- await status(item.id, 'retry', 'OpenVibe.AI is still analysing it', 15);++ if (!await superseded()) await status(item.id, 'retry', 'OpenVibe.AI is still analysing it', 15);return null;}const out = aiService.usable(run);if (!out || !out.overview || typeof out.overview.text !== 'string' || !out.overview.text.trim() || !out.transcript || !Array.isArray(out.transcript.segments)) {throw new Error((run && run.error && (run.error.code || run.error.detail)) || (run && run.status) || 'no usable media.analyze result');}++ if (await superseded()) return null;// The state setters derive ai_overview_short and store segments in the// existing [{start,end,text}] JSON format. AI signals/highlights have no// corresponding state columns; keep all other Live state untouched.@@@ -86,6 +92,7 @@ async function analyze(kind, item) {}return out;} catch (e) {++ if (await superseded().catch(() => false)) return null;const attempt = await bump(item.id);await status(item.id, 'retry', e.message, retryMinutes(attempt));failures++;@@@ -33,6 +33,11 @@ async function _clipMeta(row) {try {const c = await media.getClip(row.id);if (!c) return null;++ // Being cut (a trim narrows a clip in place): its file is the old cut until clip.ready, which queues it again.++ if (c.status === 'processing') {++ try { await db.setClipTranscriptStatus(row.id, 'retry', 'the clip is being cut', 5); } catch { /* */ }++ return null;++ }return { ...c, id: row.id, duration_seconds: c.duration_seconds ?? c.duration ?? 0 };} catch { return null; }}@@@ -529,6 +529,33 @@ async function getVodAiState(vodId) {async function getClipAiState(clipId) {return await get('SELECT * FROM clip_ai_state WHERE clip_id = ?', [clipId]);}++/** A clip's window of its source as clip_ai_state.ai_window keeps it: '<start>-<end>' seconds to a tenth, or null. */++function clipAiWindow(clip) {++ const start = Number(clip && clip.start_time), end = Number(clip && clip.end_time);++ return clip && clip.start_time != null && clip.end_time != null && Number.isFinite(start) && Number.isFinite(end) && end > start++ ? `${start.toFixed(1)}-${end.toFixed(1)}` : null;++}++/**++ * Record the window a clip's AI state describes (migration 0012). A clip trimmed in place keeps its id with another++ * window: when `window` differs from the recorded one, its overview and transcript describe footage it no longer has,++ * so they are cleared and transcript_attempts moves on (media.analyze's idempotency key names it, so the next analysis++ * is a new run, not a replay of the old one). A row with no window yet just records it. `seedOnly` records it only++ * where none is recorded (the clip editor, before a trim, so a clip older than the column is compared too).++ * → true when the window changed and the state was cleared.++ */++async function setClipAiWindow(clipId, window, { seedOnly = false } = {}) {++ if (!window) return false;++ await _ensureClipAiState(clipId);++ if (!seedOnly) {++ const r = await run(`UPDATE clip_ai_state SET ai_overview = NULL, ai_overview_short = NULL, ai_transcript_json = NULL,++ transcript_partial_json = NULL, transcript_progress_sec = 0, transcript_attempts = COALESCE(transcript_attempts, 0) + 1,++ transcript_error = NULL, transcript_next_at = NULL, ai_window = ?++ WHERE clip_id = ? AND ai_window IS NOT NULL AND ai_window <> ?`, [window, clipId, window]);++ if (r && r.changes) return true;++ }++ await run('UPDATE clip_ai_state SET ai_window = ? WHERE clip_id = ? AND ai_window IS NULL', [window, clipId]);++ return false;++}/** A VOD or clip OpenVibe.Media deleted: Live's own rows about it go (server/media-proxy/purge.js). */async function forgetMediaItem(kind, id) {return await getDb().tx(async () => {@@@ -3583,7 +3610,7 @@ module.exports = {getConcurrencyBaseline,getHomeStatSeries, homeSeriesLocal, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,getVodAiState, getClipAiState, forgetMediaItem,-- scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,++ scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies, clipAiWindow, setClipAiWindow,getDb, initDb, run, get, all, tx, close,getTimelineSpeechSince,getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,@@@ -28,12 +28,16 @@ async function _sendOne(clipId) {let clip = null;try { clip = await media.getClip(clipId); } catch { /* Media hiccup — retry next sweep */ }if (!clip) return; // keep pending; a deleted clip 404s forever → mark below-- if (clip.status && !['ready', 'done', 'ok'].includes(String(clip.status))) return; // still processingconst streamId = clip.stream_id || null;const srcStream = streamId ? await db.getStreamById(streamId) : null;-- // Only announce clips of a CURRENTLY-LIVE stream — that's when there's an audience.++ // Only announce clips of a CURRENTLY-LIVE stream — that's when there's an audience. Decided before waiting for the++ // cut, so a clip that never becomes ready (a trim whose cut failed for good) stops being asked about once its++ // stream ends.if (!srcStream || !srcStream.is_live) { await db.markClipNotifiedState(clipId); return; }++ // Still being cut (a trim from the clip editor makes the clip processing until its new cut is in): wait for it, so++ // the line carries the trimmed clip's length and thumbnail.++ if (clip.status && !['ready', 'done', 'ok'].includes(String(clip.status))) return;if (clip.visibility === 'private') { await db.markClipNotifiedState(clipId); return; }if (srcStream.managed_stream_id) {try {@@@ -390,8 +390,10 @@ router.delete('/:id', requireAuth, async (req, res) => {/*** The clip editor's Save (trim & name a clip just made): the title, then, when the kept part is not the whole clip,* Media narrows the clip in place and cuts it again from its source (Media's POST /clips/:id/trim): the same id, so-- * its links, comments and chat announcement stay. The same rule as the title: whoever made the clip, the channel's-- * streamer and mods, staff.++ * its links, comments and chat announcement stay. Saving the title (the whole clip kept) is the title's rule: whoever++ * made the clip, the channel's streamer and mods, staff. Cutting footage away is the delete rule: a clip's maker on a++ * channel that does not let makers delete their clips (clips_allow_creator_delete) cannot trim it down to a second++ * either, and is refused before anything is written.* body { start, end, title? }, seconds into the clip → { id, title, unchanged, status, job_id }; a job_id is the cut* the editor follows (GET /api/clips/:id/job).*/@@@ -406,6 +408,15 @@ router.post('/:id/trim', requireAuth, async (req, res) => {if (!Number.isFinite(start) || !Number.isFinite(end)) return res.status(400).json({ error: 'start and end are required' });const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';if (title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });++ // The whole clip, as Media decides it (within 0.05 s of both ends): only the title is saved.++ const len = Number(clip.duration_seconds ?? clip.duration) || 0;++ const whole = start < 0.05 && Math.min(end, len) > len - 0.05;++ if (!whole) {++ if (!(await canActorDeleteClip(req.user, clip))) return await refuse(req, res, clip, 'This channel does not let clip makers cut footage from their clips');++ // A clip older than clip_ai_state.ai_window gets its current window recorded now, so the trimmed clip's++ // clip.ready is told apart from it and its old overview and transcript go (outcomes.js).++ try { await db.setClipAiWindow(clip.id, db.clipAiWindow(clip), { seedOnly: true }); } catch { /* best effort */ }++ }if (title && title !== clip.title) await media.updateClip(clip.id, { title });const out = await media.trimClip(clip.id, { start_s: start, end_s: end });// A pending chat announcement goes out now with the title (after the cut, when there is one).@@@ -139,6 +139,9 @@ async function apply(event, data) {// Schedule the chat announce with a grace period (creator titles the clip first); the// clip-notify sweeper fires it (survives restarts).try { await require('./clip-notify').scheduleClipNotify(data.id); } catch { /* */ }++ // A clip trimmed in place keeps its id with a narrower window: an overview and transcript of the old window++ // are cleared, and the analysis below runs under a new key (db.setClipAiWindow).++ try { await db.setClipAiWindow(data.id, db.clipAiWindow(data)); } catch { /* */ }try { await db.setClipTranscriptStatus(data.id, 'pending'); } catch { /* */ }return {outcome: 'clip_ready',@@@ -5,8 +5,9 @@* manage it): status, attempts, the error once it failed. Anyone else, a job of another clip, or* a job that is not a clip.cut gets 404; a malformed job id 400.* - public/js/ov-clip-jobs.js (run here with a stand-in fetch, localStorage and timers) follows a job-- * to ready or failed, says so once per retry, keeps the pending cut across a "reload" and resumes it,-- * and forgets a job the server no longer knows.++ * to ready or failed, says so once per retry, keeps the pending cut across a "reload" and resumes it++ * (a trim's cut with the trim handlers, by its kind), and forgets a job the server no longer knows.++ * - the clip editor's Save ignores a second press while it runs (a second trim would narrow the clip again).** node test/clip-cut-job.test.js*/@@@ -134,6 +135,28 @@ console.error = () => {};assert.deepStrictEqual(seen2, ['gave up'], 'the resumed cut reports its failure');assert.deepStrictEqual(plain(w2.OVClipJobs.pending()), []);++ // A trim's cut keeps its kind: a reload resumes it with the trim handlers (a toast), not a new clip's (the editor).++ const w5 = makeWindow();++ vm.runInNewContext(src, w5);++ script.mjob_5 = [{ status: 200, body: { job: { status: 'running', attempts: 0 } } }];++ w5.OVClipJobs.follow(15, 'mjob_5', {}, 'trim');++ await settle();++ timers.length = 0; // reloaded mid-cut++ assert.deepStrictEqual(JSON.parse(store.get('ov_clip_jobs')).map((e) => [e.jobId, e.kind]), [['mjob_5', 'trim']], 'the kind is kept');++ const w6 = makeWindow();++ vm.runInNewContext(src, w6);++ script.mjob_5 = [{ status: 200, body: { job: { status: 'succeeded' }, clip: { id: 15 } } }];++ const seen5 = [];++ w6.OVClipJobs.resume({ onReady: () => seen5.push('editor reopened') }, { trim: { onReady: () => seen5.push('trim saved') } });++ await settle(); await drain();++ assert.deepStrictEqual(seen5, ['trim saved'], 'a resumed trim does not reopen the editor');++++ // The editor's Save ignores a second press (Enter in the title field) while the first save runs.++ const player = fs.readFileSync(path.join(__dirname, '../public/js/stream-player.js'), 'utf8');++ assert.ok(/const doSave = async \(\) => \{\s*(\/\/[^\n]*\n\s*)*if \(saveBtn\.disabled\) return;/.test(player), 'doSave returns while a save is running');++ assert.ok(/OVClipJobs\.resume\(_clipCutHandlers, \{ trim: _clipTrimHandlers \}\)/.test(player) && /follow\(clipId, data\.job_id, _clipTrimHandlers, 'trim'\)/.test(player),++ 'the editor follows its trim as a trim, and a reload resumes it so');++// A job the server no longer knows (or no longer shows this person) is dropped silently.const w3 = makeWindow();vm.runInNewContext(src, w3);nnew file mode 100644@@@ -0,0 +1,122 @@++'use strict';++// A clip trimmed in place (Media POST /clips/:id/trim, the clip editor's Save) keeps its id with a narrower window. Its++// clip.ready names the new window: Live's overview and transcript of the old window are cleared and media.analyze runs++// under a new idempotency key (a new AI run, not a replay of the first one); a clip.ready with the same window keeps++// them. An analysis still running for the old window when the trim's clip.ready lands stores nothing. A clip older than++// the recorded window (clip_ai_state.ai_window, migration 0012) gets it from its next clip.ready, or from the clip++// editor before a trim (seedOnly).++//++// node test/clip-trim-ai-state.test.js++const assert = require('assert');++const crypto = require('crypto');++++process.env.NODE_ENV = 'test';++process.env.LIVE_MEDIA_ANALYSIS = 'ai';++process.env.LIVE_MEDIA_ANALYSIS_WAIT_MS = '0';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++++const eventId = () => { const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; let s = ''; for (let i = 0; i < 26; i++) s += A[crypto.randomInt(32)]; return `evt_${s}`; };++++(async () => {++ const db = require('../server/db/database');++ await db.initDb();++ const media = require('../server/media-client');++ media.signedMediaUrl = async (kind, id) => ({ url: `https://openvibe.media/${kind}/${id}?signature=test` });++ media.getClip = async (id) => ({ id, status: 'ready', user_id: 7 });++ const aiService = require('../server/ai/ai-service');++ const keys = [];++ let gate = null;++ aiService.run = async (workflow, input, opts) => {++ keys.push(opts.idempotencyKey);++ const n = keys.length;++ if (gate) await gate.promise;++ return { status: 'succeeded', output: {++ overview: { text: `Overview ${n}.` },++ transcript: { available: true, text: `Line ${n}.`, segments: [{ start: 0, end: 1, text: `Line ${n}.` }] },++ streams: { video: true, audio: true }, gaps: [],++ } };++ };++ const hold = () => { let release; const promise = new Promise((r) => { release = r; }); gate = { promise, release }; return gate; };++ const remote = require('../server/ai/ai-media-analyze');++ const realAnalyze = remote.analyze;++ let analyses = [];++ remote.analyze = (...a) => { const p = realAnalyze(...a); analyses.push(p); return p; };++ const settle = async () => { const all = analyses; analyses = []; await Promise.all(all); };++ const until = async (fn, what) => { for (let i = 0; i < 400 && !fn(); i++) await new Promise((r) => setTimeout(r, 5)); assert.ok(fn(), what); };++ const outcomes = require('../server/media-proxy/outcomes');++ const ready = async (data) => { await outcomes.handle({ event: 'clip.ready', data: { user_id: 7, ...data }, eventId: eventId() }); };++ const state = async (id) => await db.getClipAiState(id);++++ // ── A new clip: its window is recorded and it is analysed ──++ await ready({ id: 501, start_time: 5, end_time: 20 });++ await settle();++ let s = await state(501);++ assert.deepStrictEqual([s.ai_window, s.ai_overview, Number(s.transcript_attempts) || 0], ['5.0-20.0', 'Overview 1.', 0]);++ assert.ok(s.ai_transcript_json && JSON.parse(s.ai_transcript_json)[0].text === 'Line 1.');++ assert.strictEqual(keys[0], 'live.media.analyze:clip:501:0');++++ // ── clip.ready again with the same window (a re-cut of the same footage): nothing is cleared ──++ await ready({ id: 501, start_time: 5, end_time: 20.04 });++ await settle();++ s = await state(501);++ assert.deepStrictEqual([s.ai_window, s.ai_overview], ['5.0-20.0', 'Overview 1.'], 'the same window keeps the overview');++++ // ── Trimmed to 8-14 s: the old window's overview and transcript go, and the analysis is a new run ──++ const g = hold();++ await ready({ id: 501, start_time: 8, end_time: 14 });++ s = await state(501);++ assert.deepStrictEqual([s.ai_window, s.ai_overview, s.ai_overview_short, s.ai_transcript_json], ['8.0-14.0', null, null, null], 'cleared');++ assert.strictEqual(Number(s.transcript_attempts), 1, 'the attempt moved on');++ g.release(); gate = null;++ await settle();++ s = await state(501);++ assert.strictEqual(keys[keys.length - 1], 'live.media.analyze:clip:501:1', 'a key the first run never had');++ assert.strictEqual(s.ai_overview, `Overview ${keys.length}.`, 'the trimmed clip\'s own overview');++ assert.strictEqual(JSON.parse(s.ai_transcript_json)[0].text, `Line ${keys.length}.`);++++ // ── An analysis of the old window still running when the trim lands stores nothing ──++ const g2 = hold();++ await ready({ id: 502, start_time: 0, end_time: 10 });++ const stale = analyses.slice();++ await until(() => keys[keys.length - 1] === 'live.media.analyze:clip:502:0', 'the first analysis is running');++ await ready({ id: 502, start_time: 2, end_time: 6 }); // its own analysis waits (one per clip at a time)++ g2.release(); gate = null;++ await Promise.all(stale); await settle();++ s = await state(502);++ assert.deepStrictEqual([s.ai_window, s.ai_overview, s.ai_transcript_json, s.transcript_status], ['2.0-6.0', null, null, 'pending'],++ 'the old window\'s result is dropped and the clip waits for its own analysis');++ await remote.analyze('clip', { id: 502, user_id: 7 }); // the backfill's next tick++ s = await state(502);++ assert.strictEqual(keys[keys.length - 1], 'live.media.analyze:clip:502:1');++ assert.strictEqual(s.ai_overview, `Overview ${keys.length}.`);++++ // ── A clip from before the window was recorded: its next clip.ready records it and keeps what it has ──++ await db.run("INSERT INTO clip_ai_state (clip_id, ai_overview, ai_overview_short, transcript_status) VALUES (503, 'Older', 'Older', 'done')");++ await ready({ id: 503, start_time: 0, end_time: 9 });++ await settle();++ s = await state(503);++ assert.deepStrictEqual([s.ai_window, s.ai_overview], ['0.0-9.0', 'Older'], 'recorded, nothing cleared');++ // …and the clip editor records it before a trim (seedOnly never clears, never overwrites a recorded window).++ await db.run("INSERT INTO clip_ai_state (clip_id, ai_overview, ai_overview_short) VALUES (504, 'Older', 'Older')");++ assert.strictEqual(await db.setClipAiWindow(504, db.clipAiWindow({ start_time: 0, end_time: 9 }), { seedOnly: true }), false);++ assert.strictEqual(await db.setClipAiWindow(504, '1.0-2.0', { seedOnly: true }), false);++ s = await state(504);++ assert.deepStrictEqual([s.ai_window, s.ai_overview], ['0.0-9.0', 'Older'], 'seeded once, overview kept');++ const g3 = hold();++ await ready({ id: 504, start_time: 3, end_time: 9 });++ s = await state(504);++ assert.deepStrictEqual([s.ai_window, s.ai_overview], ['3.0-9.0', null], 'the trim\'s clip.ready clears it');++ g3.release(); gate = null;++ await settle();++++ // ── A clip.ready without a window (nothing to compare) changes nothing ──++ await ready({ id: 501 });++ await settle();++ assert.strictEqual((await state(501)).ai_window, '8.0-14.0');++ assert.strictEqual(db.clipAiWindow({ start_time: 4, end_time: 4 }), null, 'an empty window is no window');++++ quiet('clip trim AI state: all checks passed');++ process.exit(0);++})().catch((err) => { quiet(err); process.exit(1); });@@@ -187,6 +187,32 @@ async function check(name, fn) {assert.deepStrictEqual(mediaCalls.map(([k, ...a]) => [k, ...a.slice(0, 2)]), [['updateClip', 200, { title: 'Alice trimmed' }], ['trimClip', 200, { start_s: 1, end_s: 4 }]]);mediaCalls.length = 0;});++ await check('clip trim: a maker who may not delete their clip on the channel saves its title but cannot cut it down', async () => {++ // Bob clipped Alice's stream; her channel does not let clip makers delete their clips (the default).++ CLIPS[201] = { id: 201, user_id: BOB, channel_user_id: ALICE, vod_id: 100, title: 'Bob clip', visibility: 'public', is_public: 1, status: 'ready', start_time: 10, end_time: 20, duration_seconds: 10 };++ mediaCalls.length = 0;++ const titled = await call('POST', '/api/clips/201/trim', BOB, { start: 0, end: 10, title: 'Bob titled' });++ assert.strictEqual(titled.status, 200, titled.text);++ assert.deepStrictEqual(mediaCalls.map(([k, ...a]) => [k, ...a.slice(0, 2)]), [['updateClip', 201, { title: 'Bob titled' }], ['trimClip', 201, { start_s: 0, end_s: 10 }]], 'the whole clip: the title rule');++ mediaCalls.length = 0;++ const cut = await call('POST', '/api/clips/201/trim', BOB, { start: 0, end: 1, title: 'Bob one second' });++ assert.strictEqual(cut.status, 403, cut.text);++ assert.deepStrictEqual(mediaCalls, [], 'refused before anything is written (not even the title)');++ assert.strictEqual(((await db.getClipAiState(201)) || {}).ai_window ?? null, null, 'no window recorded for a refused trim');++ // Where the channel lets makers delete their clips, they may trim them too; the clip's window is recorded first.++ await raw.prepare('UPDATE channels SET clips_allow_creator_delete = 1 WHERE id = ?').run(chanA.id);++ const allowed = await call('POST', '/api/clips/201/trim', BOB, { start: 0, end: 1 });++ assert.strictEqual(allowed.status, 200, allowed.text);++ assert.deepStrictEqual(mediaCalls.map(([k, ...a]) => [k, ...a.slice(0, 2)]), [['trimClip', 201, { start_s: 0, end_s: 1 }]]);++ assert.strictEqual((await db.getClipAiState(201)).ai_window, '10.0-20.0', 'the pre-trim window, for clip.ready to compare');++ await raw.prepare('UPDATE channels SET clips_allow_creator_delete = 0 WHERE id = ?').run(chanA.id);++ // Alice's channel mods and Alice herself are never held to the maker rule.++ mediaCalls.length = 0;++ assert.strictEqual((await call('POST', '/api/clips/201/trim', ALICE, { start: 2, end: 5 })).status, 200);++ assert.deepStrictEqual(mediaCalls.map(([k]) => k), ['trimClip']);++ mediaCalls.length = 0;++ delete CLIPS[201];++ });await check('streams: Bob cannot edit, end or delete Alice\'s stream', async () => {const before = await row('streams', streamA);
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+-- phase: expand
+-- The clip window Live's AI state describes (server/media-proxy/outcomes.js, db.setClipAiWindow): '<start>-<end>',
+-- seconds of the source recording to a tenth, from Media's clip.ready. A clip trimmed in place (Media POST
+-- /clips/:id/trim) keeps its id with a narrower window; when clip.ready names a window other than this one, the clip's
+-- overview and transcript describe footage it no longer has, so they are cleared and media.analyze runs again under a
+-- new key. NULL until the clip's next clip.ready (or a trim from the clip editor) records it. Nullable, no default:
+-- the N-1 release neither reads nor writes it.
+ALTER TABLE clip_ai_state ADD COLUMN IF NOT EXISTS ai_window text COLLATE "C";
@@ -5,9 +5,12 @@
@@ -5,9 +5,12 @@
* and survives a reload: pending cuts are kept in localStorage (ov_clip_jobs, 30 minutes at most) and
* and survives a reload: pending cuts are kept in localStorage (ov_clip_jobs, 30 minutes at most) and
* the next page that loads this reattaches to them.
* the next page that loads this reattaches to them.
*
*
- * OVClipJobs.follow(clipId, jobId, { onReady(clip), onFailed(message), onRetry(job) })
- * OVClipJobs.resume({ onReady, onFailed, onRetry }) follow what an earlier page left pending
- * OVClipJobs.pending() [{ clipId, jobId, at }]
+ * OVClipJobs.follow(clipId, jobId, { onReady(clip), onFailed(message), onRetry(job) }, kind?)
+ * OVClipJobs.resume({ onReady, onFailed, onRetry }, byKind?) follow what an earlier page left pending
+ * OVClipJobs.pending() [{ clipId, jobId, kind, at }]
+ *
+ * `kind` names what the cut is for ('trim': a clip cut down in its editor), so a reload resumes it with that kind's
+ * handlers (byKind[kind]) instead of a new clip's (which open the editor again); without one, the first handlers.
*/
*/
(function () {
(function () {
'use strict';
'use strict';
@@ -23,9 +26,11 @@
@@ -23,9 +26,11 @@
}
}
function save(list) { try { localStorage.setItem(KEY, JSON.stringify(list.slice(-10))); } catch (e) { /* storage off: this page still follows */ } }
function save(list) { try { localStorage.setItem(KEY, JSON.stringify(list.slice(-10))); } catch (e) { /* storage off: this page still follows */ } }
function forget(jobId) { save(load().filter(function (e) { return e.jobId !== jobId; })); delete followed[jobId]; }
function forget(jobId) { save(load().filter(function (e) { return e.jobId !== jobId; })); delete followed[jobId]; }
- function remember(clipId, jobId) {
+ function remember(clipId, jobId, kind) {
var list = load().filter(function (e) { return e.jobId !== jobId; });
var list = load().filter(function (e) { return e.jobId !== jobId; });
- list.push({ clipId: clipId, jobId: jobId, at: Date.now() });
+ var entry = { clipId: clipId, jobId: jobId, at: Date.now() };
+ if (kind) entry.kind = String(kind);
+ list.push(entry);
save(list);
save(list);
}
}
function headers() {
function headers() {
@@ -34,11 +39,11 @@
@@ -34,11 +39,11 @@
return t ? { Authorization: 'Bearer ' + t } : {};
return t ? { Authorization: 'Bearer ' + t } : {};
}
}
- function follow(clipId, jobId, h) {
+ function follow(clipId, jobId, h, kind) {
h = h || {};
h = h || {};
if (!clipId || !jobId || followed[jobId]) return;
if (!clipId || !jobId || followed[jobId]) return;
followed[jobId] = true;
followed[jobId] = true;
- remember(clipId, jobId);
+ remember(clipId, jobId, kind);
var started = Date.now(), tries = 0, retried = 0;
var started = Date.now(), tries = 0, retried = 0;
(function poll() {
(function poll() {
if (Date.now() - started > MAX_AGE_MS) { forget(jobId); return; }
if (Date.now() - started > MAX_AGE_MS) { forget(jobId); return; }
@@ -58,7 +63,12 @@
@@ -58,7 +63,12 @@
})();
})();
}
}
- function resume(h) { load().forEach(function (e) { follow(e.clipId, e.jobId, h); }); }
+ function resume(h, byKind) {
+ load().forEach(function (e) {
+ var own = e.kind && byKind && Object.prototype.hasOwnProperty.call(byKind, e.kind) ? byKind[e.kind] : null;
+ follow(e.clipId, e.jobId, own || h, e.kind);
+ });
+ }
window.OVClipJobs = { follow: follow, resume: resume, pending: load };
window.OVClipJobs = { follow: follow, resume: resume, pending: load };
})();
})();
@@ -902,7 +902,7 @@ function _applyLoaderThumb(stream) {
@@ -902,7 +902,7 @@ function _applyLoaderThumb(stream) {
function initPlayer(stream) {
function initPlayer(stream) {
// Clips this browser asked for before a reload are still being cut: reattach to them.
// Clips this browser asked for before a reload are still being cut: reattach to them.
- if (window.OVClipJobs && !initPlayer._clipsResumed) { initPlayer._clipsResumed = true; window.OVClipJobs.resume(_clipCutHandlers); }
+ if (window.OVClipJobs && !initPlayer._clipsResumed) { initPlayer._clipsResumed = true; window.OVClipJobs.resume(_clipCutHandlers, { trim: _clipTrimHandlers }); }
destroyPlayer();
destroyPlayer();
// Camera overlay is a separate viewer session against another slot's live stream;
// Camera overlay is a separate viewer session against another slot's live stream;
// attach it alongside whichever protocol the main stream uses, and let it fail
// attach it alongside whichever protocol the main stream uses, and let it fail
@@ -3143,6 +3143,13 @@ const _clipCutHandlers = {
@@ -3143,6 +3143,13 @@ const _clipCutHandlers = {
onRetry: (job) => toast(`Still working on your clip (attempt ${job.attempts + 1} of ${job.max_attempts})…`, 'info'),
onRetry: (job) => toast(`Still working on your clip (attempt ${job.attempts + 1} of ${job.max_attempts})…`, 'info'),
};
};
function _followClipCut(clipId, jobId) { window.OVClipJobs.follow(clipId, jobId, _clipCutHandlers); }
function _followClipCut(clipId, jobId) { window.OVClipJobs.follow(clipId, jobId, _clipCutHandlers); }
+// A trim saved in the clip editor is cut again on the server: its outcome is a toast only (the editor is done), also
+// when a reload resumes it (ov-clip-jobs.js keeps its kind, 'trim').
+const _clipTrimHandlers = {
+ onReady: () => toast('Clip trimmed & saved!', 'success'),
+ onFailed: (msg) => toast('The trimmed clip could not be cut: ' + msg, 'error'),
+ onRetry: (job) => _clipCutHandlers.onRetry(job),
+};
/**
/**
* Show a modal prompt for the user to title their clip after creation.
* Show a modal prompt for the user to title their clip after creation.
@@ -3332,6 +3339,8 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
@@ -3332,6 +3339,8 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
const close = () => { try { video.pause(); } catch {} overlay.remove(); };
const close = () => { try { video.pause(); } catch {} overlay.remove(); };
const doSave = async () => {
const doSave = async () => {
+ // Enter in the title field also saves: a second press while the first save runs would trim the narrowed clip again.
+ if (saveBtn.disabled) return;
const title = titleInput.value.trim();
const title = titleInput.value.trim();
saveBtn.disabled = true;
saveBtn.disabled = true;
saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
saveBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Saving...';
@@ -3348,11 +3357,7 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
@@ -3348,11 +3357,7 @@ function _buildClipTrimModal(clipId, dur, defaultTitle) {
// A trim is cut again on the server: follow that cut like the first one.
// A trim is cut again on the server: follow that cut like the first one.
if (data.job_id && window.OVClipJobs) {
if (data.job_id && window.OVClipJobs) {
toast('Saving your trimmed clip…', 'info');
toast('Saving your trimmed clip…', 'info');
- window.OVClipJobs.follow(clipId, data.job_id, {
- onReady: () => toast('Clip trimmed & saved!', 'success'),
- onFailed: (msg) => toast('The trimmed clip could not be cut: ' + msg, 'error'),
- onRetry: _clipCutHandlers.onRetry,
- });
+ window.OVClipJobs.follow(clipId, data.job_id, _clipTrimHandlers, 'trim');
} else {
} else {
toast(data.unchanged ? 'Clip saved!' : 'Clip trimmed & saved!', 'success');
toast(data.unchanged ? 'Clip saved!' : 'Clip trimmed & saved!', 'success');
}
}
@@ -33,13 +33,18 @@ async function analyze(kind, item) {
@@ -33,13 +33,18 @@ async function analyze(kind, item) {
inFlight.add(key);
inFlight.add(key);
const bump = kind === 'vod' ? db.bumpVodTranscriptAttempt : db.bumpClipTranscriptAttempt;
const bump = kind === 'vod' ? db.bumpVodTranscriptAttempt : db.bumpClipTranscriptAttempt;
const status = kind === 'vod' ? db.setVodTranscriptStatus : db.setClipTranscriptStatus;
const status = kind === 'vod' ? db.setVodTranscriptStatus : db.setClipTranscriptStatus;
+ // A clip trimmed while this ran: its clip.ready cleared the state and moved the attempt on (db.setClipAiWindow), and
+ // it gets an analysis of its own. This one describes the old window, so it stores nothing, not even a retry.
+ let attemptNo = null;
+ const superseded = async () => kind === 'clip' && attemptNo != null
+ && (Number((await db.getClipAiState(item.id) || {}).transcript_attempts) || 0) !== attemptNo;
try {
try {
const state = kind === 'vod' ? await db.getVodAiState(item.id) : await db.getClipAiState(item.id);
const state = kind === 'vod' ? await db.getVodAiState(item.id) : await db.getClipAiState(item.id);
- const mediaUrl = await source(kind, item);
- if (!mediaUrl) throw new Error('Media did not provide a readable URL');
// One AI run per attempt: a retry after a failure starts a new run, while a run still going when the poll
// One AI run per attempt: a retry after a failure starts a new run, while a run still going when the poll
// budget ends is re-joined later by the same key (never a second analysis of the same recording at once).
// budget ends is re-joined later by the same key (never a second analysis of the same recording at once).
- const attemptNo = Number(state && state.transcript_attempts) || 0;
+ attemptNo = Number(state && state.transcript_attempts) || 0;
+ const mediaUrl = await source(kind, item);
+ if (!mediaUrl) throw new Error('Media did not provide a readable URL');
const run = await aiService.run('media.analyze', { media_url: mediaUrl, language: 'en', allow_paid: false }, {
const run = await aiService.run('media.analyze', { media_url: mediaUrl, language: 'en', allow_paid: false }, {
target: { service: 'live', type: kind, id: String(item.id) },
target: { service: 'live', type: kind, id: String(item.id) },
attribution: aiService.ownerRef(item.user_id),
attribution: aiService.ownerRef(item.user_id),
@@ -49,13 +54,14 @@ async function analyze(kind, item) {
@@ -49,13 +54,14 @@ async function analyze(kind, item) {
waitMs: Math.max(0, Number(process.env.LIVE_MEDIA_ANALYSIS_WAIT_MS ?? 60000) || 0), pollMs: 0,
waitMs: Math.max(0, Number(process.env.LIVE_MEDIA_ANALYSIS_WAIT_MS ?? 60000) || 0), pollMs: 0,
});
});
if (run && (run.status === 'queued' || run.status === 'running')) {
if (run && (run.status === 'queued' || run.status === 'running')) {
- await status(item.id, 'retry', 'OpenVibe.AI is still analysing it', 15);
+ if (!await superseded()) await status(item.id, 'retry', 'OpenVibe.AI is still analysing it', 15);
return null;
return null;
}
}
const out = aiService.usable(run);
const out = aiService.usable(run);
if (!out || !out.overview || typeof out.overview.text !== 'string' || !out.overview.text.trim() || !out.transcript || !Array.isArray(out.transcript.segments)) {
if (!out || !out.overview || typeof out.overview.text !== 'string' || !out.overview.text.trim() || !out.transcript || !Array.isArray(out.transcript.segments)) {
throw new Error((run && run.error && (run.error.code || run.error.detail)) || (run && run.status) || 'no usable media.analyze result');
throw new Error((run && run.error && (run.error.code || run.error.detail)) || (run && run.status) || 'no usable media.analyze result');
}
}
+ if (await superseded()) return null;
// The state setters derive ai_overview_short and store segments in the
// The state setters derive ai_overview_short and store segments in the
// existing [{start,end,text}] JSON format. AI signals/highlights have no
// existing [{start,end,text}] JSON format. AI signals/highlights have no
// corresponding state columns; keep all other Live state untouched.
// corresponding state columns; keep all other Live state untouched.
@@ -86,6 +92,7 @@ async function analyze(kind, item) {
@@ -86,6 +92,7 @@ async function analyze(kind, item) {
}
}
return out;
return out;
} catch (e) {
} catch (e) {
+ if (await superseded().catch(() => false)) return null;
const attempt = await bump(item.id);
const attempt = await bump(item.id);
await status(item.id, 'retry', e.message, retryMinutes(attempt));
await status(item.id, 'retry', e.message, retryMinutes(attempt));
failures++;
failures++;
@@ -33,6 +33,11 @@ async function _clipMeta(row) {
@@ -33,6 +33,11 @@ async function _clipMeta(row) {
try {
try {
const c = await media.getClip(row.id);
const c = await media.getClip(row.id);
if (!c) return null;
if (!c) return null;
+ // Being cut (a trim narrows a clip in place): its file is the old cut until clip.ready, which queues it again.
+ if (c.status === 'processing') {
+ try { await db.setClipTranscriptStatus(row.id, 'retry', 'the clip is being cut', 5); } catch { /* */ }
+ return null;
+ }
return { ...c, id: row.id, duration_seconds: c.duration_seconds ?? c.duration ?? 0 };
return { ...c, id: row.id, duration_seconds: c.duration_seconds ?? c.duration ?? 0 };
} catch { return null; }
} catch { return null; }
}
}
@@ -529,6 +529,33 @@ async function getVodAiState(vodId) {
@@ -529,6 +529,33 @@ async function getVodAiState(vodId) {
async function getClipAiState(clipId) {
async function getClipAiState(clipId) {
return await get('SELECT * FROM clip_ai_state WHERE clip_id = ?', [clipId]);
return await get('SELECT * FROM clip_ai_state WHERE clip_id = ?', [clipId]);
}
}
+/** A clip's window of its source as clip_ai_state.ai_window keeps it: '<start>-<end>' seconds to a tenth, or null. */
+function clipAiWindow(clip) {
+ const start = Number(clip && clip.start_time), end = Number(clip && clip.end_time);
+ return clip && clip.start_time != null && clip.end_time != null && Number.isFinite(start) && Number.isFinite(end) && end > start
+ ? `${start.toFixed(1)}-${end.toFixed(1)}` : null;
+}
+/**
+ * Record the window a clip's AI state describes (migration 0012). A clip trimmed in place keeps its id with another
+ * window: when `window` differs from the recorded one, its overview and transcript describe footage it no longer has,
+ * so they are cleared and transcript_attempts moves on (media.analyze's idempotency key names it, so the next analysis
+ * is a new run, not a replay of the old one). A row with no window yet just records it. `seedOnly` records it only
+ * where none is recorded (the clip editor, before a trim, so a clip older than the column is compared too).
+ * → true when the window changed and the state was cleared.
+ */
+async function setClipAiWindow(clipId, window, { seedOnly = false } = {}) {
+ if (!window) return false;
+ await _ensureClipAiState(clipId);
+ if (!seedOnly) {
+ const r = await run(`UPDATE clip_ai_state SET ai_overview = NULL, ai_overview_short = NULL, ai_transcript_json = NULL,
+ transcript_partial_json = NULL, transcript_progress_sec = 0, transcript_attempts = COALESCE(transcript_attempts, 0) + 1,
+ transcript_error = NULL, transcript_next_at = NULL, ai_window = ?
+ WHERE clip_id = ? AND ai_window IS NOT NULL AND ai_window <> ?`, [window, clipId, window]);
+ if (r && r.changes) return true;
+ }
+ await run('UPDATE clip_ai_state SET ai_window = ? WHERE clip_id = ? AND ai_window IS NULL', [window, clipId]);
+ return false;
+}
/** A VOD or clip OpenVibe.Media deleted: Live's own rows about it go (server/media-proxy/purge.js). */
/** A VOD or clip OpenVibe.Media deleted: Live's own rows about it go (server/media-proxy/purge.js). */
async function forgetMediaItem(kind, id) {
async function forgetMediaItem(kind, id) {
return await getDb().tx(async () => {
return await getDb().tx(async () => {
@@ -3583,7 +3610,7 @@ module.exports = {
@@ -3583,7 +3610,7 @@ module.exports = {
getConcurrencyBaseline,
getConcurrencyBaseline,
getHomeStatSeries, homeSeriesLocal, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,
getHomeStatSeries, homeSeriesLocal, HOME_SERIES_KEYS, vibesStatsSince, _computeHomeStats,
getVodAiState, getClipAiState, forgetMediaItem,
getVodAiState, getClipAiState, forgetMediaItem,
- scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies,
+ scheduleClipNotifyState, bumpClipNotifyNowState, markClipNotifiedState, getDueClipNotifies, clipAiWindow, setClipAiWindow,
getDb, initDb, run, get, all, tx, close,
getDb, initDb, run, get, all, tx, close,
getTimelineSpeechSince,
getTimelineSpeechSince,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
getDonationGoalsForWidget, getAllDonationGoals, getActiveDonationGoals, getDonationGoalById,
@@ -28,12 +28,16 @@ async function _sendOne(clipId) {
@@ -28,12 +28,16 @@ async function _sendOne(clipId) {
let clip = null;
let clip = null;
try { clip = await media.getClip(clipId); } catch { /* Media hiccup — retry next sweep */ }
try { clip = await media.getClip(clipId); } catch { /* Media hiccup — retry next sweep */ }
if (!clip) return; // keep pending; a deleted clip 404s forever → mark below
if (!clip) return; // keep pending; a deleted clip 404s forever → mark below
- if (clip.status && !['ready', 'done', 'ok'].includes(String(clip.status))) return; // still processing
const streamId = clip.stream_id || null;
const streamId = clip.stream_id || null;
const srcStream = streamId ? await db.getStreamById(streamId) : null;
const srcStream = streamId ? await db.getStreamById(streamId) : null;
- // Only announce clips of a CURRENTLY-LIVE stream — that's when there's an audience.
+ // Only announce clips of a CURRENTLY-LIVE stream — that's when there's an audience. Decided before waiting for the
+ // cut, so a clip that never becomes ready (a trim whose cut failed for good) stops being asked about once its
+ // stream ends.
if (!srcStream || !srcStream.is_live) { await db.markClipNotifiedState(clipId); return; }
if (!srcStream || !srcStream.is_live) { await db.markClipNotifiedState(clipId); return; }
+ // Still being cut (a trim from the clip editor makes the clip processing until its new cut is in): wait for it, so
+ // the line carries the trimmed clip's length and thumbnail.
+ if (clip.status && !['ready', 'done', 'ok'].includes(String(clip.status))) return;
if (clip.visibility === 'private') { await db.markClipNotifiedState(clipId); return; }
if (clip.visibility === 'private') { await db.markClipNotifiedState(clipId); return; }
if (srcStream.managed_stream_id) {
if (srcStream.managed_stream_id) {
try {
try {
@@ -390,8 +390,10 @@ router.delete('/:id', requireAuth, async (req, res) => {
@@ -390,8 +390,10 @@ router.delete('/:id', requireAuth, async (req, res) => {
/**
/**
* The clip editor's Save (trim & name a clip just made): the title, then, when the kept part is not the whole clip,
* The clip editor's Save (trim & name a clip just made): the title, then, when the kept part is not the whole clip,
* Media narrows the clip in place and cuts it again from its source (Media's POST /clips/:id/trim): the same id, so
* Media narrows the clip in place and cuts it again from its source (Media's POST /clips/:id/trim): the same id, so
- * its links, comments and chat announcement stay. The same rule as the title: whoever made the clip, the channel's
- * streamer and mods, staff.
+ * its links, comments and chat announcement stay. Saving the title (the whole clip kept) is the title's rule: whoever
+ * made the clip, the channel's streamer and mods, staff. Cutting footage away is the delete rule: a clip's maker on a
+ * channel that does not let makers delete their clips (clips_allow_creator_delete) cannot trim it down to a second
+ * either, and is refused before anything is written.
* body { start, end, title? }, seconds into the clip → { id, title, unchanged, status, job_id }; a job_id is the cut
* body { start, end, title? }, seconds into the clip → { id, title, unchanged, status, job_id }; a job_id is the cut
* the editor follows (GET /api/clips/:id/job).
* the editor follows (GET /api/clips/:id/job).
*/
*/
@@ -406,6 +408,15 @@ router.post('/:id/trim', requireAuth, async (req, res) => {
@@ -406,6 +408,15 @@ router.post('/:id/trim', requireAuth, async (req, res) => {
if (!Number.isFinite(start) || !Number.isFinite(end)) return res.status(400).json({ error: 'start and end are required' });
if (!Number.isFinite(start) || !Number.isFinite(end)) return res.status(400).json({ error: 'start and end are required' });
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
const title = typeof req.body?.title === 'string' ? req.body.title.trim() : '';
if (title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });
if (title.length > 200) return res.status(400).json({ error: 'Title must be 1-200 characters' });
+ // The whole clip, as Media decides it (within 0.05 s of both ends): only the title is saved.
+ const len = Number(clip.duration_seconds ?? clip.duration) || 0;
+ const whole = start < 0.05 && Math.min(end, len) > len - 0.05;
+ if (!whole) {
+ if (!(await canActorDeleteClip(req.user, clip))) return await refuse(req, res, clip, 'This channel does not let clip makers cut footage from their clips');
+ // A clip older than clip_ai_state.ai_window gets its current window recorded now, so the trimmed clip's
+ // clip.ready is told apart from it and its old overview and transcript go (outcomes.js).
+ try { await db.setClipAiWindow(clip.id, db.clipAiWindow(clip), { seedOnly: true }); } catch { /* best effort */ }
+ }
if (title && title !== clip.title) await media.updateClip(clip.id, { title });
if (title && title !== clip.title) await media.updateClip(clip.id, { title });
const out = await media.trimClip(clip.id, { start_s: start, end_s: end });
const out = await media.trimClip(clip.id, { start_s: start, end_s: end });
// A pending chat announcement goes out now with the title (after the cut, when there is one).
// A pending chat announcement goes out now with the title (after the cut, when there is one).
@@ -139,6 +139,9 @@ async function apply(event, data) {
@@ -139,6 +139,9 @@ async function apply(event, data) {
// Schedule the chat announce with a grace period (creator titles the clip first); the
// Schedule the chat announce with a grace period (creator titles the clip first); the
// clip-notify sweeper fires it (survives restarts).
// clip-notify sweeper fires it (survives restarts).
try { await require('./clip-notify').scheduleClipNotify(data.id); } catch { /* */ }
try { await require('./clip-notify').scheduleClipNotify(data.id); } catch { /* */ }
+ // A clip trimmed in place keeps its id with a narrower window: an overview and transcript of the old window
+ // are cleared, and the analysis below runs under a new key (db.setClipAiWindow).
+ try { await db.setClipAiWindow(data.id, db.clipAiWindow(data)); } catch { /* */ }
try { await db.setClipTranscriptStatus(data.id, 'pending'); } catch { /* */ }
try { await db.setClipTranscriptStatus(data.id, 'pending'); } catch { /* */ }
return {
return {
outcome: 'clip_ready',
outcome: 'clip_ready',
@@ -5,8 +5,9 @@
@@ -5,8 +5,9 @@
* manage it): status, attempts, the error once it failed. Anyone else, a job of another clip, or
* manage it): status, attempts, the error once it failed. Anyone else, a job of another clip, or
* a job that is not a clip.cut gets 404; a malformed job id 400.
* a job that is not a clip.cut gets 404; a malformed job id 400.
* - public/js/ov-clip-jobs.js (run here with a stand-in fetch, localStorage and timers) follows a job
* - public/js/ov-clip-jobs.js (run here with a stand-in fetch, localStorage and timers) follows a job
- * to ready or failed, says so once per retry, keeps the pending cut across a "reload" and resumes it,
- * and forgets a job the server no longer knows.
+ * to ready or failed, says so once per retry, keeps the pending cut across a "reload" and resumes it
+ * (a trim's cut with the trim handlers, by its kind), and forgets a job the server no longer knows.
+ * - the clip editor's Save ignores a second press while it runs (a second trim would narrow the clip again).
*
*
* node test/clip-cut-job.test.js
* node test/clip-cut-job.test.js
*/
*/
@@ -134,6 +135,28 @@ console.error = () => {};
@@ -134,6 +135,28 @@ console.error = () => {};
assert.deepStrictEqual(seen2, ['gave up'], 'the resumed cut reports its failure');
assert.deepStrictEqual(seen2, ['gave up'], 'the resumed cut reports its failure');
assert.deepStrictEqual(plain(w2.OVClipJobs.pending()), []);
assert.deepStrictEqual(plain(w2.OVClipJobs.pending()), []);
+ // A trim's cut keeps its kind: a reload resumes it with the trim handlers (a toast), not a new clip's (the editor).
+ const w5 = makeWindow();
+ vm.runInNewContext(src, w5);
+ script.mjob_5 = [{ status: 200, body: { job: { status: 'running', attempts: 0 } } }];
+ w5.OVClipJobs.follow(15, 'mjob_5', {}, 'trim');
+ await settle();
+ timers.length = 0; // reloaded mid-cut
+ assert.deepStrictEqual(JSON.parse(store.get('ov_clip_jobs')).map((e) => [e.jobId, e.kind]), [['mjob_5', 'trim']], 'the kind is kept');
+ const w6 = makeWindow();
+ vm.runInNewContext(src, w6);
+ script.mjob_5 = [{ status: 200, body: { job: { status: 'succeeded' }, clip: { id: 15 } } }];
+ const seen5 = [];
+ w6.OVClipJobs.resume({ onReady: () => seen5.push('editor reopened') }, { trim: { onReady: () => seen5.push('trim saved') } });
+ await settle(); await drain();
+ assert.deepStrictEqual(seen5, ['trim saved'], 'a resumed trim does not reopen the editor');
+
+ // The editor's Save ignores a second press (Enter in the title field) while the first save runs.
+ const player = fs.readFileSync(path.join(__dirname, '../public/js/stream-player.js'), 'utf8');
+ assert.ok(/const doSave = async \(\) => \{\s*(\/\/[^\n]*\n\s*)*if \(saveBtn\.disabled\) return;/.test(player), 'doSave returns while a save is running');
+ assert.ok(/OVClipJobs\.resume\(_clipCutHandlers, \{ trim: _clipTrimHandlers \}\)/.test(player) && /follow\(clipId, data\.job_id, _clipTrimHandlers, 'trim'\)/.test(player),
+ 'the editor follows its trim as a trim, and a reload resumes it so');
+
// A job the server no longer knows (or no longer shows this person) is dropped silently.
// A job the server no longer knows (or no longer shows this person) is dropped silently.
const w3 = makeWindow();
const w3 = makeWindow();
vm.runInNewContext(src, w3);
vm.runInNewContext(src, w3);
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -0,0 +1,122 @@
+'use strict';
+// A clip trimmed in place (Media POST /clips/:id/trim, the clip editor's Save) keeps its id with a narrower window. Its
+// clip.ready names the new window: Live's overview and transcript of the old window are cleared and media.analyze runs
+// under a new idempotency key (a new AI run, not a replay of the first one); a clip.ready with the same window keeps
+// them. An analysis still running for the old window when the trim's clip.ready lands stores nothing. A clip older than
+// the recorded window (clip_ai_state.ai_window, migration 0012) gets it from its next clip.ready, or from the clip
+// editor before a trim (seedOnly).
+//
+// node test/clip-trim-ai-state.test.js
+const assert = require('assert');
+const crypto = require('crypto');
+
+process.env.NODE_ENV = 'test';
+process.env.LIVE_MEDIA_ANALYSIS = 'ai';
+process.env.LIVE_MEDIA_ANALYSIS_WAIT_MS = '0';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+
+const eventId = () => { const A = '0123456789ABCDEFGHJKMNPQRSTVWXYZ'; let s = ''; for (let i = 0; i < 26; i++) s += A[crypto.randomInt(32)]; return `evt_${s}`; };
+
+(async () => {
+ const db = require('../server/db/database');
+ await db.initDb();
+ const media = require('../server/media-client');
+ media.signedMediaUrl = async (kind, id) => ({ url: `https://openvibe.media/${kind}/${id}?signature=test` });
+ media.getClip = async (id) => ({ id, status: 'ready', user_id: 7 });
+ const aiService = require('../server/ai/ai-service');
+ const keys = [];
+ let gate = null;
+ aiService.run = async (workflow, input, opts) => {
+ keys.push(opts.idempotencyKey);
+ const n = keys.length;
+ if (gate) await gate.promise;
+ return { status: 'succeeded', output: {
+ overview: { text: `Overview ${n}.` },
+ transcript: { available: true, text: `Line ${n}.`, segments: [{ start: 0, end: 1, text: `Line ${n}.` }] },
+ streams: { video: true, audio: true }, gaps: [],
+ } };
+ };
+ const hold = () => { let release; const promise = new Promise((r) => { release = r; }); gate = { promise, release }; return gate; };
+ const remote = require('../server/ai/ai-media-analyze');
+ const realAnalyze = remote.analyze;
+ let analyses = [];
+ remote.analyze = (...a) => { const p = realAnalyze(...a); analyses.push(p); return p; };
+ const settle = async () => { const all = analyses; analyses = []; await Promise.all(all); };
+ const until = async (fn, what) => { for (let i = 0; i < 400 && !fn(); i++) await new Promise((r) => setTimeout(r, 5)); assert.ok(fn(), what); };
+ const outcomes = require('../server/media-proxy/outcomes');
+ const ready = async (data) => { await outcomes.handle({ event: 'clip.ready', data: { user_id: 7, ...data }, eventId: eventId() }); };
+ const state = async (id) => await db.getClipAiState(id);
+
+ // ── A new clip: its window is recorded and it is analysed ──
+ await ready({ id: 501, start_time: 5, end_time: 20 });
+ await settle();
+ let s = await state(501);
+ assert.deepStrictEqual([s.ai_window, s.ai_overview, Number(s.transcript_attempts) || 0], ['5.0-20.0', 'Overview 1.', 0]);
+ assert.ok(s.ai_transcript_json && JSON.parse(s.ai_transcript_json)[0].text === 'Line 1.');
+ assert.strictEqual(keys[0], 'live.media.analyze:clip:501:0');
+
+ // ── clip.ready again with the same window (a re-cut of the same footage): nothing is cleared ──
+ await ready({ id: 501, start_time: 5, end_time: 20.04 });
+ await settle();
+ s = await state(501);
+ assert.deepStrictEqual([s.ai_window, s.ai_overview], ['5.0-20.0', 'Overview 1.'], 'the same window keeps the overview');
+
+ // ── Trimmed to 8-14 s: the old window's overview and transcript go, and the analysis is a new run ──
+ const g = hold();
+ await ready({ id: 501, start_time: 8, end_time: 14 });
+ s = await state(501);
+ assert.deepStrictEqual([s.ai_window, s.ai_overview, s.ai_overview_short, s.ai_transcript_json], ['8.0-14.0', null, null, null], 'cleared');
+ assert.strictEqual(Number(s.transcript_attempts), 1, 'the attempt moved on');
+ g.release(); gate = null;
+ await settle();
+ s = await state(501);
+ assert.strictEqual(keys[keys.length - 1], 'live.media.analyze:clip:501:1', 'a key the first run never had');
+ assert.strictEqual(s.ai_overview, `Overview ${keys.length}.`, 'the trimmed clip\'s own overview');
+ assert.strictEqual(JSON.parse(s.ai_transcript_json)[0].text, `Line ${keys.length}.`);
+
+ // ── An analysis of the old window still running when the trim lands stores nothing ──
+ const g2 = hold();
+ await ready({ id: 502, start_time: 0, end_time: 10 });
+ const stale = analyses.slice();
+ await until(() => keys[keys.length - 1] === 'live.media.analyze:clip:502:0', 'the first analysis is running');
+ await ready({ id: 502, start_time: 2, end_time: 6 }); // its own analysis waits (one per clip at a time)
+ g2.release(); gate = null;
+ await Promise.all(stale); await settle();
+ s = await state(502);
+ assert.deepStrictEqual([s.ai_window, s.ai_overview, s.ai_transcript_json, s.transcript_status], ['2.0-6.0', null, null, 'pending'],
+ 'the old window\'s result is dropped and the clip waits for its own analysis');
+ await remote.analyze('clip', { id: 502, user_id: 7 }); // the backfill's next tick
+ s = await state(502);
+ assert.strictEqual(keys[keys.length - 1], 'live.media.analyze:clip:502:1');
+ assert.strictEqual(s.ai_overview, `Overview ${keys.length}.`);
+
+ // ── A clip from before the window was recorded: its next clip.ready records it and keeps what it has ──
+ await db.run("INSERT INTO clip_ai_state (clip_id, ai_overview, ai_overview_short, transcript_status) VALUES (503, 'Older', 'Older', 'done')");
+ await ready({ id: 503, start_time: 0, end_time: 9 });
+ await settle();
+ s = await state(503);
+ assert.deepStrictEqual([s.ai_window, s.ai_overview], ['0.0-9.0', 'Older'], 'recorded, nothing cleared');
+ // …and the clip editor records it before a trim (seedOnly never clears, never overwrites a recorded window).
+ await db.run("INSERT INTO clip_ai_state (clip_id, ai_overview, ai_overview_short) VALUES (504, 'Older', 'Older')");
+ assert.strictEqual(await db.setClipAiWindow(504, db.clipAiWindow({ start_time: 0, end_time: 9 }), { seedOnly: true }), false);
+ assert.strictEqual(await db.setClipAiWindow(504, '1.0-2.0', { seedOnly: true }), false);
+ s = await state(504);
+ assert.deepStrictEqual([s.ai_window, s.ai_overview], ['0.0-9.0', 'Older'], 'seeded once, overview kept');
+ const g3 = hold();
+ await ready({ id: 504, start_time: 3, end_time: 9 });
+ s = await state(504);
+ assert.deepStrictEqual([s.ai_window, s.ai_overview], ['3.0-9.0', null], 'the trim\'s clip.ready clears it');
+ g3.release(); gate = null;
+ await settle();
+
+ // ── A clip.ready without a window (nothing to compare) changes nothing ──
+ await ready({ id: 501 });
+ await settle();
+ assert.strictEqual((await state(501)).ai_window, '8.0-14.0');
+ assert.strictEqual(db.clipAiWindow({ start_time: 4, end_time: 4 }), null, 'an empty window is no window');
+
+ quiet('clip trim AI state: all checks passed');
+ process.exit(0);
+})().catch((err) => { quiet(err); process.exit(1); });
@@ -187,6 +187,32 @@ async function check(name, fn) {
@@ -187,6 +187,32 @@ async function check(name, fn) {
assert.deepStrictEqual(mediaCalls.map(([k, ...a]) => [k, ...a.slice(0, 2)]), [['updateClip', 200, { title: 'Alice trimmed' }], ['trimClip', 200, { start_s: 1, end_s: 4 }]]);
assert.deepStrictEqual(mediaCalls.map(([k, ...a]) => [k, ...a.slice(0, 2)]), [['updateClip', 200, { title: 'Alice trimmed' }], ['trimClip', 200, { start_s: 1, end_s: 4 }]]);
mediaCalls.length = 0;
mediaCalls.length = 0;
});
});
+ await check('clip trim: a maker who may not delete their clip on the channel saves its title but cannot cut it down', async () => {
+ // Bob clipped Alice's stream; her channel does not let clip makers delete their clips (the default).
+ CLIPS[201] = { id: 201, user_id: BOB, channel_user_id: ALICE, vod_id: 100, title: 'Bob clip', visibility: 'public', is_public: 1, status: 'ready', start_time: 10, end_time: 20, duration_seconds: 10 };
+ mediaCalls.length = 0;
+ const titled = await call('POST', '/api/clips/201/trim', BOB, { start: 0, end: 10, title: 'Bob titled' });
+ assert.strictEqual(titled.status, 200, titled.text);
+ assert.deepStrictEqual(mediaCalls.map(([k, ...a]) => [k, ...a.slice(0, 2)]), [['updateClip', 201, { title: 'Bob titled' }], ['trimClip', 201, { start_s: 0, end_s: 10 }]], 'the whole clip: the title rule');
+ mediaCalls.length = 0;
+ const cut = await call('POST', '/api/clips/201/trim', BOB, { start: 0, end: 1, title: 'Bob one second' });
+ assert.strictEqual(cut.status, 403, cut.text);
+ assert.deepStrictEqual(mediaCalls, [], 'refused before anything is written (not even the title)');
+ assert.strictEqual(((await db.getClipAiState(201)) || {}).ai_window ?? null, null, 'no window recorded for a refused trim');
+ // Where the channel lets makers delete their clips, they may trim them too; the clip's window is recorded first.
+ await raw.prepare('UPDATE channels SET clips_allow_creator_delete = 1 WHERE id = ?').run(chanA.id);
+ const allowed = await call('POST', '/api/clips/201/trim', BOB, { start: 0, end: 1 });
+ assert.strictEqual(allowed.status, 200, allowed.text);
+ assert.deepStrictEqual(mediaCalls.map(([k, ...a]) => [k, ...a.slice(0, 2)]), [['trimClip', 201, { start_s: 0, end_s: 1 }]]);
+ assert.strictEqual((await db.getClipAiState(201)).ai_window, '10.0-20.0', 'the pre-trim window, for clip.ready to compare');
+ await raw.prepare('UPDATE channels SET clips_allow_creator_delete = 0 WHERE id = ?').run(chanA.id);
+ // Alice's channel mods and Alice herself are never held to the maker rule.
+ mediaCalls.length = 0;
+ assert.strictEqual((await call('POST', '/api/clips/201/trim', ALICE, { start: 2, end: 5 })).status, 200);
+ assert.deepStrictEqual(mediaCalls.map(([k]) => k), ['trimClip']);
+ mediaCalls.length = 0;
+ delete CLIPS[201];
+ });
await check('streams: Bob cannot edit, end or delete Alice\'s stream', async () => {
await check('streams: Bob cannot edit, end or delete Alice\'s stream', async () => {
const before = await row('streams', streamA);
const before = await row('streams', streamA);