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)

OP OpenVibers committed 22 days ago · ca7df2c9ea · 12 files, +276 −25

Changes

ddiff --git a/migrations/0012_clip_ai_window.sql b/migrations/0012_clip_ai_window.sql
nnew file mode 100644
iindex 00000000..c2037693
---- /dev/null
++++ b/migrations/0012_clip_ai_window.sql
@@@ -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";
ddiff --git a/public/js/ov-clip-jobs.js b/public/js/ov-clip-jobs.js
iindex fc86f202..da7a5337 100644
---- a/public/js/ov-clip-jobs.js
++++ b/public/js/ov-clip-jobs.js
@@@ -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 };
})();
ddiff --git a/public/js/stream-player.js b/public/js/stream-player.js
iindex c26ecfa8..d250e9a4 100644
---- a/public/js/stream-player.js
++++ b/public/js/stream-player.js
@@@ -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');
}
ddiff --git a/server/ai/ai-media-analyze.js b/server/ai/ai-media-analyze.js
iindex 0037a132..06219be0 100644
---- a/server/ai/ai-media-analyze.js
++++ b/server/ai/ai-media-analyze.js
@@@ -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++;
ddiff --git a/server/ai/backfill-job.js b/server/ai/backfill-job.js
iindex b5435811..800a9eea 100644
---- a/server/ai/backfill-job.js
++++ b/server/ai/backfill-job.js
@@@ -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; }
}
ddiff --git a/server/db/database.js b/server/db/database.js
iindex b5a9c354..943e3a8a 100644
---- a/server/db/database.js
++++ b/server/db/database.js
@@@ -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,
ddiff --git a/server/media-proxy/clip-notify.js b/server/media-proxy/clip-notify.js
iindex a6c40e69..ecb26443 100644
---- a/server/media-proxy/clip-notify.js
++++ b/server/media-proxy/clip-notify.js
@@@ -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 processing
const 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 {
ddiff --git a/server/media-proxy/clips.js b/server/media-proxy/clips.js
iindex 3865348e..d14b5be8 100644
---- a/server/media-proxy/clips.js
++++ b/server/media-proxy/clips.js
@@@ -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).
ddiff --git a/server/media-proxy/outcomes.js b/server/media-proxy/outcomes.js
iindex 8bb13004..4d17f952 100644
---- a/server/media-proxy/outcomes.js
++++ b/server/media-proxy/outcomes.js
@@@ -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',
ddiff --git a/test/clip-cut-job.test.js b/test/clip-cut-job.test.js
iindex 0e882a80..99a5947f 100644
---- a/test/clip-cut-job.test.js
++++ b/test/clip-cut-job.test.js
@@@ -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);
ddiff --git a/test/clip-trim-ai-state.test.js b/test/clip-trim-ai-state.test.js
nnew file mode 100644
iindex 00000000..a923554f
---- /dev/null
++++ b/test/clip-trim-ai-state.test.js
@@@ -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); });
ddiff --git a/test/security-idor.test.js b/test/security-idor.test.js
iindex 7d3dc92d..d4ae0cf0 100644
---- a/test/security-idor.test.js
++++ b/test/security-idor.test.js
@@@ -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);