openvibers/openvibe.live Commits
AI clips are never filed as something a person made (roadmap 33.4/33.6/33.7). The auto-clip job cuts in the streamer's name, so every 'clips by user X' list showed the AI's clips as the streamer's own: the clip page now says 'AI clip · from <streamer>'s stream' with what made it (and 'Clipped by <clipper>' only for a person's clip, built with DOM nodes); GET /api/clips/mine asks Media for auto_generated=0 whatever the query says and drops AI rows an old Media still returns; /api/clips/my-stream takes ?auto_generated=0|1 and labels AI rows; the channel endpoint returns people's clips (by and of the streamer) apart from a new aiClips list (aiClipsTotal/Limit/Offset, ai_label, the streamer as source) and Clips Taken asks for people's clips. The channel Clips tab lists people's clips, then an AI Moments section with 'AI clip' cards and its own pagination; the dashboard's Clips of My Stream shows people's clips and a new AI Moments sub-tab lists the auto-clips of my stream with a count. test/clip-attribution.test.js
Changes
@@@ -1373,3 +1373,11 @@ body.controls-docked .messenger-toggle { z-index: 1100; }animation: i18nSlideIn 0.4s ease both;}.ch-about-bio-translated-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent-light); margin-bottom: 4px; }++++/* AI Moments on the Clips tab: listed after people's clips, each card labelled (roadmap 33.6). */++.ch-ai-clips { margin-top: 22px; }++.ch-ai-badge {++ position: absolute; top: 8px; left: 8px; z-index: 2; padding: 2px 8px; border-radius: 999px;++ font-size: 0.68rem; font-weight: 700; letter-spacing: .02em; color: #fff;++ background: linear-gradient(135deg, #6d28d9, #0e7490);++}@@@ -484,3 +484,11 @@@media (max-width: 480px) {.stream-config-card { padding: 10px; }}++++/* AI Moments of my stream (auto-clips), kept apart from the clips people took (roadmap 33.7). */++.dash-ai-badge {++ display: inline-block; padding: 1px 7px; border-radius: 999px; margin-right: 4px;++ font-size: 0.68rem; font-weight: 700; color: #fff;++ background: linear-gradient(135deg, #6d28d9, #0e7490);++}++.dash-subtab-count { margin-left: 6px; font-size: 0.72rem; opacity: .8; }@@@ -394,3 +394,13 @@.comments-elsewhere { margin: 10px 0 0; font-size: .85rem; color: var(--text-muted); }.comments-elsewhere:empty { display: none; }.comment-item.comment-deleted .comment-text { font-style: italic; }++++/* AI clip attribution on the clip page ("AI clip · from <streamer>'s stream", roadmap 33.4). */++.ai-badge {++ display: inline-block; padding: 1px 8px; border-radius: 999px;++ font-size: 0.72rem; font-weight: 700; letter-spacing: .02em; color: #fff;++ background: linear-gradient(135deg, #6d28d9, #0e7490); /* white text at 4.5:1 or better */++}++.clp-ai-attribution a { color: var(--accent); font-weight: 600; text-decoration: none; }++.clp-ai-attribution a:hover { text-decoration: underline; }++.clp-ai-how { font-size: 0.8rem; margin-top: 2px; }@@@ -372,9 +372,15 @@<!-- Clips (clips OF this streamer's streams) --><div class="ch-tab-panel" id="ch-panel-clips" role="tabpanel">-- <p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> All clips created <b>of</b> this streamer — by anyone who clipped their streams.</p>++ <p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Clips people took <b>of</b> this streamer's streams.</p><div class="stream-grid" id="ch-clips-of-grid"><p class="muted">No clips yet</p></div><div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>++ <!-- What the AI cut from these streams, after what people made (roadmap 33.6). -->++ <div class="ch-ai-clips" id="ch-ai-clips-section" hidden>++ <p class="ch-panel-hint"><i class="fa-solid fa-wand-magic-sparkles"></i> <b>AI Moments</b> — clips OpenVibe's AI cut automatically when chat reacted. No one clipped these.</p>++ <div class="stream-grid" id="ch-ai-clips-grid"></div>++ <div id="ch-ai-clips-pagination" class="pastes-pagination" style="display:none"></div>++ </div></div><!-- Clips Taken (clips this streamer made of others) -->@@@ -25,11 +25,13 @@<button class="ch-tab active" data-cdtab="videos" onclick="switchDashContentTab('videos', this)"><i class="fa-solid fa-video"></i> Videos</button><button class="ch-tab" data-cdtab="streamclips" onclick="switchDashContentTab('streamclips', this)"><i class="fa-solid fa-scissors"></i> Clips of My Stream</button><button class="ch-tab" data-cdtab="myclips" onclick="switchDashContentTab('myclips', this)"><i class="fa-solid fa-film"></i> My Clips</button>++ <button class="ch-tab" data-cdtab="aimoments" onclick="switchDashContentTab('aimoments', this)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments<span class="dash-subtab-count" id="dash-ai-moments-count" hidden></span></button><button class="ch-tab" data-cdtab="pastes" onclick="switchDashContentTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes</button></div><div class="dash-subpanel active" id="dash-content-videos"><div class="dash-grid" id="dash-grid-content-videos"></div></div><div class="dash-subpanel" id="dash-content-streamclips"><div class="dash-grid" id="dash-grid-content-streamclips"></div></div><div class="dash-subpanel" id="dash-content-myclips"><div class="dash-grid" id="dash-grid-content-myclips"></div></div>++ <div class="dash-subpanel" id="dash-content-aimoments"><div class="dash-grid" id="dash-grid-content-aimoments"></div></div><div class="dash-subpanel" id="dash-content-pastes"><div class="dash-grid" id="dash-grid-content-pastes"></div></div></div>@@@ -362,7 +364,7 @@<h3 style="margin:0"><i class="fa-solid fa-clapperboard"></i> Clips of My Stream</h3><button class="btn btn-small btn-outline sel-all-btn" data-sel-container="dash-stream-clips" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button></div>-- <p class="muted">Clips viewers have taken from your streams. You can publish or delete them.</p>++ <p class="muted">Clips people have taken from your streams. You can publish or delete them. Clips the AI cut are under AI Moments.</p><label class="staff-inline-toggle" style="margin-bottom:12px"><input type="checkbox" id="dash-clips-allow-creator-delete" onchange="saveClipSettings()"><span>Let clip creators delete their own clips of my channel <span class="muted" style="font-weight:400">(off = only you, your mods, and staff can delete)</span></span>@@@ -370,6 +372,13 @@<div id="dash-stream-clips" class="vod-grid"></div></div>++ <!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) -->++ <div class="dash-card dash-card-wide" id="dash-card-aimoments">++ <h3 style="margin:0 0 6px"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments of My Stream</h3>++ <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them.</p>++ <div id="dash-ai-clips" class="vod-grid"></div>++ </div>++<!-- My Pastes --><div class="dash-card dash-card-wide" id="dash-card-pastes"><div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">@@@ -33,6 +33,7 @@ const CHANNEL_CLIPS_PAGE_SIZE = 12;const channelVodsPageByUser = Object.create(null);const channelClipsPageByUser = Object.create(null);const channelClipsOfPageByUser = Object.create(null);++const channelAiClipsPageByUser = Object.create(null);// Channel VOD filter/sort statelet currentChannelVodFilter = null; // numeric managed stream id or null = alllet currentChannelVodOrder = 'newest'; // newest|oldest|views|peak_viewers@@@ -282,7 +283,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {const total = meta.total || clips.length;const page = Math.floor(offset / pageSize) + 1;-- if (total === 0) { grid.innerHTML = '<p class="muted">No clips yet</p>'; return; }++ if (total === 0) { grid.innerHTML = '<p class="muted">No one has clipped these streams yet</p>'; return; }if (clips.length) {grid.innerHTML = clips.map(cl => `@@@ -306,6 +307,64 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {renderVodsPagination('ch-clips-of-pagination', page, total, pageSize, 'setChannelClipsOfPage', 'clips of streams');}++// ── AI Moments on the Clips tab: auto-clips of this channel's streams, after people's clips ──++// Built with DOM nodes (titles come from the AI). Each card says it is an AI clip from this++// streamer's stream; none is credited to a clipper (roadmap 33.4/33.6).++function renderChannelAiClipsSection(username, clips, meta = {}) {++ const section = document.getElementById('ch-ai-clips-section');++ const grid = document.getElementById('ch-ai-clips-grid');++ if (!section || !grid) return;++ const pageSize = meta.limit || CHANNEL_CLIPS_PAGE_SIZE;++ const offset = meta.offset || 0;++ const total = meta.total || clips.length;++ section.hidden = !(total > 0);++ grid.textContent = '';++ if (!total) { renderVodsPagination('ch-ai-clips-pagination', 1, 0, pageSize, 'setChannelAiClipsPage', 'AI clips'); return; }++ for (const cl of clips) {++ const href = `/clip/${Number(cl.id)}`;++ const card = document.createElement('a');++ card.className = 'stream-card';++ card.href = href;++ card.addEventListener('click', (event) => handleLinkClick(event, href));++ const thumb = document.createElement('div');++ thumb.className = 'stream-card-thumb';++ thumb.innerHTML = thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${Number(cl.id)}`);++ const badge = document.createElement('span');++ badge.className = 'ch-ai-badge';++ badge.textContent = 'AI clip';++ const dur = document.createElement('span');++ dur.className = 'stream-card-viewers';++ dur.innerHTML = '<i class="fa-solid fa-clock"></i> ';++ dur.append(formatDuration(cl.duration_seconds));++ thumb.append(badge, dur);++ const info = document.createElement('div');++ info.className = 'stream-card-info';++ const title = document.createElement('div');++ title.className = 'stream-card-title';++ title.textContent = cl.title || 'AI clip';++ const by = document.createElement('div');++ by.className = 'stream-card-streamer muted';++ by.textContent = `from ${cl.source_streamer_display_name || cl.source_streamer_username || username}'s stream · ${formatDateTime(cl.created_at)}`;++ info.append(title, by);++ if (cl.ai_overview_short) info.insertAdjacentHTML('beforeend', _cardAiHTML(cl.ai_overview_short, cl.ai_overview));++ card.append(thumb, info);++ grid.append(card);++ }++ renderVodsPagination('ch-ai-clips-pagination', Math.floor(offset / pageSize) + 1, total, pageSize, 'setChannelAiClipsPage', 'AI clips');++}++++async function setChannelAiClipsPage(page) {++ if (!currentChannelUsername) return;++ const safePage = Math.max(1, page | 0);++ if (safePage === (channelAiClipsPageByUser[currentChannelUsername] || 1)) return;++ channelAiClipsPageByUser[currentChannelUsername] = safePage;++ const offset = (safePage - 1) * CHANNEL_CLIPS_PAGE_SIZE;++ const data = await api(`/streams/channel/${currentChannelUsername}?aiClipsLimit=${CHANNEL_CLIPS_PAGE_SIZE}&aiClipsOffset=${offset}`);++ renderChannelAiClipsSection(currentChannelUsername, data.aiClips || [], { total: data.aiClipsTotal || 0, limit: CHANNEL_CLIPS_PAGE_SIZE, offset });++ const top = document.getElementById('ch-ai-clips-section');++ if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });++}++async function refreshChannelVodsPage(username = currentChannelUsername) {if (!username) return;@@@ -707,6 +766,12 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionIlimit: data.clipsOfLimit || CHANNEL_CLIPS_PAGE_SIZE,offset: data.clipsOfOffset || 0,});++ // …then what the AI cut from them, labelled as such++ renderChannelAiClipsSection(username, data.aiClips || [], {++ total: data.aiClipsTotal || (data.aiClips || []).length,++ limit: data.aiClipsLimit || CHANNEL_CLIPS_PAGE_SIZE,++ offset: data.aiClipsOffset || 0,++ });// Analytics is loaded lazily when its tab is first opened (see switchChannelTab).@@@ -2041,7 +2106,7 @@ function _applyChannelTabMeta(data) {}};setBadge('ch-tab-badge-videos', data.vodTotal);-- setBadge('ch-tab-badge-clips', data.clipsOfTotal);++ setBadge('ch-tab-badge-clips', (Number(data.clipsOfTotal) || 0) + (Number(data.aiClipsTotal) || 0));setBadge('ch-tab-badge-clips-taken', data.clipsTakenTotal);setBadge('ch-tab-badge-pastes', data.pasteTotal);setBadge('ch-tab-badge-ai-timeline', data.aiEventTotal);@@@ -888,6 +888,44 @@ async function recutClip(clipId) {}}++/**++ * The clip page's attribution line, from the clip's own origin (Media's auto_generated):++ * a person's clip → "Clipped by <clipper>"++ * an auto-clip → "AI clip · from <streamer>'s stream" and what made it; never "Clipped by"++ * the streamer the job filed it under.++ */++function _renderClipAttribution(el, cl) {++ el.textContent = '';++ el.classList.toggle('clp-ai-attribution', !!cl.auto_generated);++ const icon = (cls) => { const i = document.createElement('i'); i.className = cls; i.setAttribute('aria-hidden', 'true'); return i; };++ if (!cl.auto_generated) {++ const who = document.createElement('strong');++ who.textContent = cl.display_name || cl.username || 'Unknown';++ el.append(icon('fa-solid fa-scissors'), ' Clipped by ', who);++ return;++ }++ const streamerName = cl.source_streamer_display_name || cl.streamer_display_name || cl.source_streamer_username || cl.streamer_username || cl.display_name || cl.username;++ const streamerUser = cl.source_streamer_username || cl.streamer_username || cl.username;++ const badge = document.createElement('span');++ badge.className = 'ai-badge';++ badge.textContent = 'AI clip';++ el.append(icon('fa-solid fa-wand-magic-sparkles'), ' ', badge, ' · from ');++ if (streamerName && streamerUser) {++ const a = document.createElement('a');++ const href = channelPath(streamerUser);++ a.href = href;++ a.textContent = streamerName;++ a.addEventListener('click', (event) => handleLinkClick(event, href));++ el.append(a, "'s stream");++ } else {++ el.append('a live stream');++ }++ const how = document.createElement('div');++ how.className = 'muted clp-ai-how';++ how.textContent = 'Cut automatically by OpenVibe\'s auto-clip workflow when chat reacted. No one clipped it.';++ el.append(how);++}++async function loadClipPlayer(clipId) {try {// Clean up chat replay@@@ -1044,11 +1082,10 @@ async function loadClipPlayer(clipId) {streamerLink.onclick = (event) => handleLinkClick(event, targetUrl);}-- // Show "Clipped by" info++ // Who made it. An auto-clip was cut by OpenVibe's AI when chat reacted, not by the++ // streamer it is filed under (roadmap 33.4): "AI clip · from <streamer>'s stream".const clippedByEl = document.getElementById('clp-clipped-by');-- if (clippedByEl) {-- clippedByEl.innerHTML = `<i class="fa-solid fa-scissors"></i> Clipped by <strong>${esc(cl.display_name || cl.username || 'Unknown')}</strong>`;-- }++ if (clippedByEl) _renderClipAttribution(clippedByEl, cl);// Show delete button per the server-authoritative can_delete flag (streamer /// channel mod / staff, or the creator only if the channel opted in).@@@ -24,6 +24,7 @@ const _DASH_CONTENT_SUBCARDS = {videos: ['dash-my-videos-card'],streamclips: ['dash-card-streamclips'],myclips: ['dash-card-myclips'],++ aimoments: ['dash-card-aimoments'],pastes: ['dash-card-pastes'],};window._dashTabLoaders = window._dashTabLoaders || {};@@@ -58,6 +59,7 @@ const _DASH_CONTENT_LOADERS = {videos: () => _call('loadDashVods'),streamclips: () => { _call('loadDashStreamClips'); _call('loadClipSettings'); },myclips: () => _call('loadDashMyClips'),++ aimoments: () => _call('loadDashAiClips'),pastes: () => _call('loadDashPastes'),};function switchDashContentTab(sub, btn) {@@@ -98,7 +100,7 @@ window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call(window._dashTabLoaders.controls = () => { _call('loadDashConfigs'); _call('loadControlSettings'); _call('loadDashboardCameras'); _call('loadDashTokens'); };window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadPowerchatStatus'); };window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };--window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); };++window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };// moderation loader is registered by dashboard-moderation.js/**@@@ -711,6 +713,7 @@ function _dashReloadMedia() {loadDashVods();if (typeof loadDashMyClips === 'function') loadDashMyClips();if (typeof loadDashStreamClips === 'function') loadDashStreamClips();++ if (document.getElementById('dash-ai-clips')?.childElementCount) loadDashAiClips();}function _dashSelInit() { if (typeof _selSetContext === 'function') _selSetContext(true, _dashReloadMedia); }function _dashSelSync() { if (typeof _selSyncAllBtns === 'function') _selSyncAllBtns(); }@@@ -881,7 +884,8 @@ async function loadDashStreamClips() {if (!list) return;_dashSelInit();try {-- const data = await api(`/clips/my-stream?limit=${DASH_PAGE_SIZE}&offset=${dashStreamClipsPage * DASH_PAGE_SIZE}`);++ // People's clips only; the AI's are under AI Moments (loadDashAiClips).++ const data = await api(`/clips/my-stream?auto_generated=0&limit=${DASH_PAGE_SIZE}&offset=${dashStreamClipsPage * DASH_PAGE_SIZE}`);const clips = data.clips || [];dashStreamClipsTotal = data.total ?? clips.length;if (!clips.length && dashStreamClipsPage === 0) {@@@ -917,6 +921,64 @@ async function loadDashStreamClips() {function dashStreamClipsGoPage(page) { dashStreamClipsPage = page; loadDashStreamClips(); }++/* ── AI Moments of My Stream (auto-clips) ─────────────────────────++ What the AI cut from my streams. Never in "My Clips" (the streamer did not make them) and never++ credited to a clipper; the count rides on the sub-tab. Built with DOM nodes: titles come from the AI. */++let dashAiClipsPage = 0;++let dashAiClipsTotal = 0;++function _setDashAiMomentsCount(n) {++ const el = document.getElementById('dash-ai-moments-count');++ if (!el) return;++ const num = Number(n) || 0;++ el.textContent = num ? String(num) : '';++ el.hidden = !num;++}++async function loadDashAiMomentsCount() {++ try { const d = await api('/clips/my-stream?auto_generated=1&limit=1'); _setDashAiMomentsCount(d.total); } catch { /* the count is a hint */ }++}++async function loadDashAiClips() {++ const list = document.getElementById('dash-ai-clips');++ if (!list) return;++ _dashSelInit();++ try {++ const data = await api(`/clips/my-stream?auto_generated=1&limit=${DASH_PAGE_SIZE}&offset=${dashAiClipsPage * DASH_PAGE_SIZE}`);++ const clips = data.clips || [];++ dashAiClipsTotal = data.total ?? clips.length;++ _setDashAiMomentsCount(dashAiClipsTotal);++ list.textContent = '';++ if (!clips.length && dashAiClipsPage === 0) {++ const p = document.createElement('p');++ p.className = 'muted';++ p.textContent = 'The AI has not cut any clips from your streams.';++ list.append(p);++ _dashSelSync();++ return;++ }++ list.innerHTML = clips.map(cl => _dashWrap('clip', cl.id, `++ <div class="stream-card" style="display:inline-block;width:240px;margin-right:12px;vertical-align:top">++ <div class="stream-card-thumb" style="height:135px">++ ${typeof thumbImg === 'function' ? thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title) : '<i class="fa-solid fa-scissors"></i>'}++ ${_dashVisBadge(cl.visibility, cl.is_public)}++ <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(cl.duration_seconds)}</span>++ </div>++ <div class="stream-card-info">++ <div class="stream-card-title">${esc(cl.title || 'AI clip')}</div>++ <div class="muted" style="font-size:0.8rem;margin-bottom:4px"><span class="dash-ai-badge">AI clip</span> ${new Date(cl.created_at).toLocaleDateString()}</div>++ <div style="display:flex;gap:6px">++ ${!cl.is_public++ ? `<button class="btn btn-small btn-success" onclick="dashToggleClipVisibility(${Number(cl.id)}, true, 'ai')"><i class="fa-solid fa-eye"></i> Publish</button>`++ : `<button class="btn btn-small btn-outline" onclick="dashToggleClipVisibility(${Number(cl.id)}, false, 'ai')"><i class="fa-solid fa-eye-slash"></i> Unlist</button>`}++ <button class="btn btn-small" onclick="navigate('/clip/${Number(cl.id)}')"><i class="fa-solid fa-play"></i></button>++ <button class="btn btn-small btn-danger" onclick="dashDeleteClip(${Number(cl.id)}, 'ai')"><i class="fa-solid fa-trash"></i></button>++ </div>++ </div>++ </div>++ `)).join('') + dashPaginationHtml('dashAiClips', dashAiClipsPage, dashAiClipsTotal);++ _dashSelSync();++ } catch { list.innerHTML = '<p class="muted">Failed to load AI Moments</p>'; }++}++function dashAiClipsGoPage(page) { dashAiClipsPage = page; loadDashAiClips(); }++async function loadClipSettings() {const el = document.getElementById('dash-clips-allow-creator-delete');if (!el) return;@@@ -956,6 +1018,7 @@ async function dashToggleClipVisibility(clipId, makePublic, source) {});toast(makePublic ? 'Clip published' : 'Clip unlisted', 'success');if (source === 'mine') loadDashMyClips();++ else if (source === 'ai') loadDashAiClips();else loadDashStreamClips();} catch (e) { toast(e.message || 'Failed to update visibility', 'error'); }}@@@ -966,6 +1029,7 @@ async function dashDeleteClip(clipId, source) {await api(`/clips/${clipId}`, { method: 'DELETE' });toast('Clip deleted', 'success');if (source === 'mine') loadDashMyClips();++ else if (source === 'ai') loadDashAiClips();else loadDashStreamClips();} catch (e) { toast(e.message || 'Delete failed', 'error'); }}@@@ -31,6 +31,9 @@ function refuse(req, res, clip, message) {return res.status(403).json({ error: message });}++/** An auto-clip: cut by Live's auto-clip job (Media's auto_generated), not by a person. */++const isAiClip = (c) => !!(c && (c.auto_generated === true || Number(c.auto_generated) === 1));++function mediaErr(res, err, fallback) {if (err && err.name === 'MediaApiError' && err.status) {return res.status(err.status).json(err.body || { error: err.message });@@@ -121,8 +124,12 @@ router.get('/mine', requireAuth, async (req, res) => {try {// App-key call: Live already authenticated the owner, and Media only// honors include_private for the owning app.-- const out = await media.listClips({ ...req.query, user_id: req.user.id, include_private: 1 });-- const raw = (out?.clips || (Array.isArray(out) ? out : [])).map(withUserFields);++ // "Clips I made" holds people's clips only (roadmap 33.7): the auto-clip job cuts in the++ // streamer's name, but the streamer did not make those. They are the AI Moments of the++ // streamer's stream (/my-stream?auto_generated=1). Checked again per row in case Media++ // ignores the filter.++ const out = await media.listClips({ ...req.query, user_id: req.user.id, include_private: 1, auto_generated: 0 });++ const raw = (out?.clips || (Array.isArray(out) ? out : [])).filter((c) => !isAiClip(c)).map(withUserFields);const clips = [];for (const c of raw) clips.push({ ...c, can_delete: await canActorDeleteClip(req.user, c) });res.json({ clips, total: out?.total ?? clips.length, limit: out?.limit ?? clips.length, offset: out?.offset ?? 0 });@@@ -133,9 +140,15 @@ router.get('/mine', requireAuth, async (req, res) => {router.get('/my-stream', requireAuth, async (req, res) => {try {-- // Clips others took of my streams (channel_user_id filter); app-key call.-- const out = await media.listClips({ ...req.query, channel_user_id: req.user.id, include_private: 1 });-- const clips = (out?.clips || (Array.isArray(out) ? out : [])).map(withUserFields).map(c => ({ ...c, can_delete: true }));++ // Clips of my streams (channel_user_id filter); app-key call. ?auto_generated=0 is the++ // clips people took, =1 the AI Moments (auto-clips); without it, both.++ const q = { ...req.query, channel_user_id: req.user.id, include_private: 1 };++ const want = q.auto_generated === '0' || q.auto_generated === '1' ? Number(q.auto_generated) : null;++ if (want === null) delete q.auto_generated; else q.auto_generated = want;++ const out = await media.listClips(q);++ const clips = (out?.clips || (Array.isArray(out) ? out : []))++ .filter((c) => want === null || isAiClip(c) === (want === 1))++ .map(withUserFields).map(c => ({ ...c, can_delete: true, ai_label: isAiClip(c) ? 'AI clip' : null }));res.json({ clips, total: out?.total ?? clips.length, limit: out?.limit ?? clips.length, offset: out?.offset ?? 0 });} catch (err) {mediaErr(res, err, 'Failed to list stream clips');@@@ -265,6 +265,8 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {const clipOffset = Math.max(parseInt(req.query.clipOffset || '0', 10), 0);const clipsOfLimit = Math.min(Math.max(parseInt(req.query.clipsOfLimit || '12', 10), 1), 48);const clipsOfOffset = Math.max(parseInt(req.query.clipsOfOffset || '0', 10), 0);++ const aiClipsLimit = Math.min(Math.max(parseInt(req.query.aiClipsLimit || '12', 10), 1), 48);++ const aiClipsOffset = Math.max(parseInt(req.query.aiClipsOffset || '0', 10), 0);// The 15s live/offline status poll passes ?pollOnly=1 — it only reads live streams// + viewer counts, so skip the 6 heavy VOD/clip listing+count queries entirely.const pollOnly = req.query.pollOnly === '1' || req.query.pollOnly === 'true';@@@ -273,16 +275,22 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {// channel_user_id/order) follow the inherited query shapes.const media = require('../media-client');const _rows = (r, k) => (r && r[k]) || (Array.isArray(r) ? r : []);-- let vods = [], vodTotal = 0, clips = [], clipTotal = 0, clipsOfStreams = [], clipsOfTotal = 0;++ let vods = [], vodTotal = 0, clips = [], clipTotal = 0, clipsOfStreams = [], clipsOfTotal = 0, aiClips = [], aiClipsTotal = 0;if (!pollOnly) {-- const [vr, cr, cor] = await Promise.all([++ // People's clips and the AI's are separate lists (roadmap 33.6: made, then derived). The++ // auto-clip job cuts in the streamer's name, so without auto_generated=0 its clips would++ // be listed as clips the streamer made. Rows are checked again in case Media ignores it.++ const isAi = (c) => !!(c && (c.auto_generated === true || Number(c.auto_generated) === 1));++ const [vr, cr, cor, air] = await Promise.all([media.listVods({ user_id: channel.user_id, include_private: canSeeHidden ? 1 : 0, managed_stream_id: vodManagedStreamId || undefined, order: vodOrderBy, limit: vodLimit, offset: vodOffset }).catch(() => null),-- media.listClips({ user_id: channel.user_id, include_private: canSeeHidden ? 1 : 0, limit: clipLimit, offset: clipOffset }).catch(() => null),-- media.listClips({ channel_user_id: channel.user_id, limit: clipsOfLimit, offset: clipsOfOffset }).catch(() => null),++ media.listClips({ user_id: channel.user_id, include_private: canSeeHidden ? 1 : 0, auto_generated: 0, limit: clipLimit, offset: clipOffset }).catch(() => null),++ media.listClips({ channel_user_id: channel.user_id, auto_generated: 0, limit: clipsOfLimit, offset: clipsOfOffset }).catch(() => null),++ media.listClips({ channel_user_id: channel.user_id, auto_generated: 1, limit: aiClipsLimit, offset: aiClipsOffset }).catch(() => null),]);vods = _rows(vr, 'vods'); vodTotal = vr?.total ?? vods.length;-- clips = _rows(cr, 'clips'); clipTotal = cr?.total ?? clips.length;-- clipsOfStreams = _rows(cor, 'clips'); clipsOfTotal = cor?.total ?? clipsOfStreams.length;++ clips = _rows(cr, 'clips').filter((c) => !isAi(c)); clipTotal = cr?.total ?? clips.length;++ clipsOfStreams = _rows(cor, 'clips').filter((c) => !isAi(c)); clipsOfTotal = cor?.total ?? clipsOfStreams.length;++ aiClips = _rows(air, 'clips').filter(isAi); aiClipsTotal = air?.total ?? aiClips.length;// Media only stores our numeric user ids — resolve clip creator names// locally so cards don't render "by Unknown".const nameClip = (c) => {@@@ -294,6 +302,8 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {};clips.forEach(nameClip);clipsOfStreams.forEach(nameClip);++ // An AI clip has no clipper: it is "from <streamer>'s stream".++ for (const c of aiClips) { c.ai_label = 'AI clip'; c.source_streamer_username = channel.username; c.source_streamer_display_name = channel.display_name || channel.username; }// AI overviews are Live-owned (vod_ai_state / clip_ai_state). Attach the// full text too — the card expander swaps the short teaser for it.const aiShort = (rows, table, col) => {@@@ -309,6 +319,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {aiShort(vods, 'vod_ai_state', 'vod_id');aiShort(clips, 'clip_ai_state', 'clip_id');aiShort(clipsOfStreams, 'clip_ai_state', 'clip_id');++ aiShort(aiClips, 'clip_ai_state', 'clip_id');}const followerCount = db.getFollowerCount(channel.user_id);const isFollowing = req.user ? db.isFollowing(req.user.id, channel.user_id) : false;@@@ -489,7 +500,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {? _slotClipVis.every(v => v === 'private'): ((channel.default_clip_visibility || 'public') === 'private');const videos_tab_hidden = !pollOnly && allVodPrivate && vodTotal === 0;-- const clips_tab_hidden = !pollOnly && allClipPrivate && clipTotal === 0 && clipsOfTotal === 0;++ const clips_tab_hidden = !pollOnly && allClipPrivate && clipTotal === 0 && clipsOfTotal === 0 && aiClipsTotal === 0;res.json({videos_tab_hidden,@@@ -527,6 +538,12 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {clipsOfLimit,clipsOfOffset,clipsOfHasMore: clipsOfOffset + clipsOfStreams.length < clipsOfTotal,++ // AI Moments of this channel: auto-clips, listed after people's clips and labelled.++ aiClips,++ aiClipsTotal,++ aiClipsLimit,++ aiClipsOffset,++ aiClipsHasMore: aiClipsOffset + aiClips.length < aiClipsTotal,});} catch (err) {console.error('[Channels] Get error:', err.message);@@@ -581,6 +598,7 @@ router.get('/channel/:username/clips-taken', optionalAuth, async (req, res) => {const r = await media.listClips({user_id: user.id, include_private: canSeeHidden ? 1 : 0, order: orderBy,source_streamer_id: sourceStreamerId || undefined, hide_self: hideSelf ? 1 : 0,++ auto_generated: 0, // the auto-clip job cuts in the streamer's name; those clips are not ones they tooklimit, offset,}).catch(() => null);const clips = (r?.clips || (Array.isArray(r) ? r : [])).map(c => {nnew file mode 100644@@@ -0,0 +1,207 @@++/**++ * AI clips are never filed as something a person made (roadmap 33.4, 33.6, 33.7).++ *++ * The auto-clip job cuts in the streamer's name (Media user_id = the streamer), so every list that++ * asks Media for "clips by user X" used to show the AI's clips as the streamer's own. Checked here:++ * - GET /api/clips/mine ("clips I made") holds people's clips only, whatever the query says, and++ * drops an AI row even when Media ignores the filter;++ * - GET /api/clips/my-stream splits into the clips people took (?auto_generated=0) and the AI++ * Moments (?auto_generated=1), each labelled;++ * - the channel page lists people's clips (by and of the streamer) apart from its AI clips, which++ * come back as their own labelled list with a total; "Clips Taken" asks for people's clips;++ * - the clip page's attribution line says "AI clip · from <streamer>'s stream" for an auto-clip,++ * and "Clipped by <clipper>" only for a person's clip (public/js/app-media.js, run against a++ * small stand-in DOM).++ *++ * node test/clip-attribution.test.js++ */++'use strict';++const assert = require('assert');++const fs = require('fs');++const os = require('os');++const path = require('path');++const http = require('http');++const vm = require('vm');++++const tmp = path.join(os.tmpdir(), `ov-clip-attribution-${process.pid}.db`);++process.env.DB_PATH = tmp;++process.env.NODE_ENV = 'test';++const quiet = console.log;++console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };++console.warn = () => {};++console.error = () => {};++++const db = require('../server/db/database');++db.initDb();++const raw = db.getDb();++++// ── Sign-in stub (before any router captures the middleware) ──++const auth = require('../server/auth/auth');++const signIn = (req) => {++ const id = Number(req.headers['x-test-user'] || 0);++ const u = id ? db.getUserById(id) : null;++ if (u) { req.user = u; req.authSource = 'network'; }++ return u;++};++auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));++auth.optionalAuth = (req, res, next) => { signIn(req); next(); };++++const addUser = (id, username, display) => raw.prepare(++ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)++ VALUES (?, ?, ?, ?, 'x', 'streamer', '2025-01-01 00:00:00')`).run(id, username, display, `${username}@x`);++addUser(3, 'alice', 'Alice');++addUser(4, 'bob', 'Bob');++db.ensureChannel(3);++++// ── OpenVibe.Media stand-in. `honour` = whether it applies auto_generated (an old Media did not). ──++const media = require('../server/media-client');++const asked = [];++let honour = true;++const CLIPS = [++ { id: 1, user_id: 3, channel_user_id: 3, title: 'AI: chat lost it', visibility: 'public', is_public: true, status: 'ready', auto_generated: true },++ { id: 2, user_id: 3, channel_user_id: 3, title: 'AI: the lights went out', visibility: 'public', is_public: true, status: 'ready', auto_generated: true },++ { id: 3, user_id: 4, channel_user_id: 3, title: 'Bob clipped Alice', visibility: 'public', is_public: true, status: 'ready', auto_generated: false },++ { id: 4, user_id: 3, channel_user_id: 4, title: 'Alice clipped Bob', visibility: 'public', is_public: true, status: 'ready', auto_generated: false },++];++media.listClips = async (q = {}) => {++ asked.push({ ...q });++ const rows = CLIPS.filter((c) => (q.user_id == null || String(c.user_id) === String(q.user_id))++ && (q.channel_user_id == null || String(c.channel_user_id) === String(q.channel_user_id))++ && (!q.hide_self || c.user_id !== c.channel_user_id)++ && (!honour || q.auto_generated == null || Number(c.auto_generated) === Number(q.auto_generated)));++ return { clips: rows.map((c) => ({ ...c })), total: rows.length };++};++media.listVods = async () => ({ vods: [], total: 0 });++media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };++media.getClip = async () => { throw new media.MediaApiError('not found', 404, null); };++const pastesClient = require('../server/pastes-client');++pastesClient.request = async () => ({ pastes: [], total: 0 });++pastesClient.listPastes = async () => ({ pastes: [], total: 0 });++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/clips', require('../server/media-proxy/clips'));++app.use('/api/streams', require('../server/streaming/routes'));++const server = http.createServer(app).listen(0);++function call(p, user) {++ return new Promise((resolve, reject) => {++ const headers = {};++ if (user) headers['x-test-user'] = String(user);++ const req = http.request({ port: server.address().port, path: p, method: 'GET', headers }, (res) => {++ let text = '';++ res.on('data', (c) => { text += c; });++ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });++ });++ req.on('error', reject);++ req.end();++ });++}++const ids = (rows) => (rows || []).map((c) => c.id).sort();++++// ── A small stand-in DOM, enough for _renderClipAttribution ──++class El {++ constructor(tag) { this.tagName = tag; this.children = []; this.className = ''; this.attrs = {}; this.listeners = {}; this.href = ''; this.classList = { toggle: (c, on) => { const set = new Set(this.className.split(/\s+/).filter(Boolean)); if (on) set.add(c); else set.delete(c); this.className = [...set].join(' '); } }; }++ set textContent(v) { this.children = v ? [String(v)] : []; }++ get textContent() { return this.children.map((c) => (typeof c === 'string' ? c : c.textContent)).join(''); }++ append(...nodes) { this.children.push(...nodes); }++ setAttribute(k, v) { this.attrs[k] = v; }++ addEventListener(t, fn) { this.listeners[t] = fn; }++ find(tag) { for (const c of this.children) { if (typeof c === 'string') continue; if (c.tagName === tag) return c; const f = c.find(tag); if (f) return f; } return null; }++}++function loadAttribution() {++ const src = fs.readFileSync(path.join(__dirname, '../public/js/app-media.js'), 'utf8');++ const start = src.indexOf('function _renderClipAttribution(');++ assert.ok(start > 0, 'app-media.js defines _renderClipAttribution');++ const end = src.indexOf('\n}\n', start) + 3;++ const ctx = { document: { createElement: (t) => new El(t) }, channelPath: (u) => `/@${u}`, handleLinkClick: () => false };++ vm.createContext(ctx);++ vm.runInContext(`${src.slice(start, end)}\nthis._renderClipAttribution = _renderClipAttribution;`, ctx);++ return ctx._renderClipAttribution;++}++++let failures = 0;++async function check(name, fn) {++ try { asked.length = 0; honour = true; await fn(); quiet(' ✓', name); }++ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack.split('\n').slice(0, 3).join('\n ')); }++}++++(async () => {++ await new Promise((r) => server.once('listening', r));++ quiet('Clip attribution');++++ await check('"My Clips" asks Media for people\'s clips only, even when the query asks for AI ones', async () => {++ const r = await call('/api/clips/mine?auto_generated=1', 3);++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(ids(r.json.clips), [4], 'only the clip Alice took');++ assert.strictEqual(asked[0].auto_generated, 0);++ assert.strictEqual(String(asked[0].user_id), '3');++ });++++ await check('"My Clips" drops AI rows a Media that ignores the filter still returns', async () => {++ honour = false;++ const r = await call('/api/clips/mine', 3);++ assert.deepStrictEqual(ids(r.json.clips), [4]);++ });++++ await check('clips of my stream split into what people took and the AI Moments, each labelled', async () => {++ const people = await call('/api/clips/my-stream?auto_generated=0', 3);++ assert.deepStrictEqual(ids(people.json.clips), [3]);++ assert.ok(people.json.clips.every((c) => c.ai_label === null));++ const ai = await call('/api/clips/my-stream?auto_generated=1', 3);++ assert.deepStrictEqual(ids(ai.json.clips), [1, 2]);++ assert.ok(ai.json.clips.every((c) => c.ai_label === 'AI clip'));++ assert.strictEqual(ai.json.total, 2, 'the count the dashboard shows');++ honour = false;++ const aiLoose = await call('/api/clips/my-stream?auto_generated=1', 3);++ assert.deepStrictEqual(ids(aiLoose.json.clips), [1, 2], 'split holds when Media ignores the filter');++ const both = await call('/api/clips/my-stream', 3);++ assert.deepStrictEqual(ids(both.json.clips), [1, 2, 3], 'no filter: both, labelled');++ });++++ await check("the channel page lists people's clips apart from its AI clips", async () => {++ const r = await call('/api/streams/channel/alice');++ assert.strictEqual(r.status, 200);++ assert.deepStrictEqual(ids(r.json.clips), [4], 'clips by Alice: only the one she took');++ assert.deepStrictEqual(ids(r.json.clipsOfStreams), [3], "clips of Alice's streams: only people's");++ assert.deepStrictEqual(ids(r.json.aiClips), [1, 2], 'the AI clips are their own list');++ assert.strictEqual(r.json.aiClipsTotal, 2);++ assert.ok(r.json.aiClips.every((c) => c.ai_label === 'AI clip' && c.source_streamer_username === 'alice'));++ const clipAsks = asked.filter((q) => 'auto_generated' in q);++ assert.strictEqual(clipAsks.length, 3, 'every clip list says which kind it wants');++ honour = false;++ const loose = await call('/api/streams/channel/alice');++ assert.deepStrictEqual(ids(loose.json.clips), [4]);++ assert.deepStrictEqual(ids(loose.json.clipsOfStreams), [3]);++ assert.deepStrictEqual(ids(loose.json.aiClips), [1, 2]);++ });++++ await check('"Clips Taken" asks for the clips a person took', async () => {++ const r = await call('/api/streams/channel/alice/clips-taken?includeSelf=1');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(asked[0].auto_generated, 0);++ assert.ok(!r.json.clips.some((c) => c.auto_generated), 'no AI clip');++ });++++ await check('the clip page says "AI clip · from Alice\'s stream" for an auto-clip, never "Clipped by"', () => {++ const render = loadAttribution();++ const el = new El('div');++ render(el, { auto_generated: true, username: 'alice', display_name: 'Alice', source_streamer_username: 'alice', source_streamer_display_name: 'Alice' });++ const text = el.textContent;++ assert.ok(text.startsWith(" AI clip · from Alice's stream"), text);++ assert.ok(!/Clipped by/.test(text), text);++ assert.ok(/No one clipped it/.test(text));++ assert.strictEqual(el.find('a').href, '/@alice', 'links the channel');++ assert.ok(/clp-ai-attribution/.test(el.className));++ const person = new El('div');++ render(person, { auto_generated: false, username: 'bob', display_name: 'Bob' });++ assert.strictEqual(person.textContent, ' Clipped by Bob');++ assert.ok(!/clp-ai-attribution/.test(person.className));++ });++++ server.close();++ try { fs.unlinkSync(tmp); } catch { /* */ }++ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }++ quiet(failures ? `\n${failures} check(s) failed` : '\nclip attribution: all checks passed');++ process.exit(failures ? 1 : 0);++})();
@@ -1373,3 +1373,11 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
@@ -1373,3 +1373,11 @@ body.controls-docked .messenger-toggle { z-index: 1100; }
animation: i18nSlideIn 0.4s ease both;
animation: i18nSlideIn 0.4s ease both;
}
}
.ch-about-bio-translated-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent-light); margin-bottom: 4px; }
.ch-about-bio-translated-label { font-size: 0.72rem; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; color: var(--accent-light); margin-bottom: 4px; }
+
+/* AI Moments on the Clips tab: listed after people's clips, each card labelled (roadmap 33.6). */
+.ch-ai-clips { margin-top: 22px; }
+.ch-ai-badge {
+ position: absolute; top: 8px; left: 8px; z-index: 2; padding: 2px 8px; border-radius: 999px;
+ font-size: 0.68rem; font-weight: 700; letter-spacing: .02em; color: #fff;
+ background: linear-gradient(135deg, #6d28d9, #0e7490);
+}
@@ -484,3 +484,11 @@
@@ -484,3 +484,11 @@
@media (max-width: 480px) {
@media (max-width: 480px) {
.stream-config-card { padding: 10px; }
.stream-config-card { padding: 10px; }
}
}
+
+/* AI Moments of my stream (auto-clips), kept apart from the clips people took (roadmap 33.7). */
+.dash-ai-badge {
+ display: inline-block; padding: 1px 7px; border-radius: 999px; margin-right: 4px;
+ font-size: 0.68rem; font-weight: 700; color: #fff;
+ background: linear-gradient(135deg, #6d28d9, #0e7490);
+}
+.dash-subtab-count { margin-left: 6px; font-size: 0.72rem; opacity: .8; }
@@ -394,3 +394,13 @@
@@ -394,3 +394,13 @@
.comments-elsewhere { margin: 10px 0 0; font-size: .85rem; color: var(--text-muted); }
.comments-elsewhere { margin: 10px 0 0; font-size: .85rem; color: var(--text-muted); }
.comments-elsewhere:empty { display: none; }
.comments-elsewhere:empty { display: none; }
.comment-item.comment-deleted .comment-text { font-style: italic; }
.comment-item.comment-deleted .comment-text { font-style: italic; }
+
+/* AI clip attribution on the clip page ("AI clip · from <streamer>'s stream", roadmap 33.4). */
+.ai-badge {
+ display: inline-block; padding: 1px 8px; border-radius: 999px;
+ font-size: 0.72rem; font-weight: 700; letter-spacing: .02em; color: #fff;
+ background: linear-gradient(135deg, #6d28d9, #0e7490); /* white text at 4.5:1 or better */
+}
+.clp-ai-attribution a { color: var(--accent); font-weight: 600; text-decoration: none; }
+.clp-ai-attribution a:hover { text-decoration: underline; }
+.clp-ai-how { font-size: 0.8rem; margin-top: 2px; }
@@ -372,9 +372,15 @@
@@ -372,9 +372,15 @@
<!-- Clips (clips OF this streamer's streams) -->
<!-- Clips (clips OF this streamer's streams) -->
<div class="ch-tab-panel" id="ch-panel-clips" role="tabpanel">
<div class="ch-tab-panel" id="ch-panel-clips" role="tabpanel">
- <p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> All clips created <b>of</b> this streamer — by anyone who clipped their streams.</p>
+ <p class="ch-panel-hint" id="ch-clips-of-header"><i class="fa-solid fa-scissors"></i> Clips people took <b>of</b> this streamer's streams.</p>
<div class="stream-grid" id="ch-clips-of-grid"><p class="muted">No clips yet</p></div>
<div class="stream-grid" id="ch-clips-of-grid"><p class="muted">No clips yet</p></div>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
<div id="ch-clips-of-pagination" class="pastes-pagination" style="display:none"></div>
+ <!-- What the AI cut from these streams, after what people made (roadmap 33.6). -->
+ <div class="ch-ai-clips" id="ch-ai-clips-section" hidden>
+ <p class="ch-panel-hint"><i class="fa-solid fa-wand-magic-sparkles"></i> <b>AI Moments</b> — clips OpenVibe's AI cut automatically when chat reacted. No one clipped these.</p>
+ <div class="stream-grid" id="ch-ai-clips-grid"></div>
+ <div id="ch-ai-clips-pagination" class="pastes-pagination" style="display:none"></div>
+ </div>
</div>
</div>
<!-- Clips Taken (clips this streamer made of others) -->
<!-- Clips Taken (clips this streamer made of others) -->
@@ -25,11 +25,13 @@
@@ -25,11 +25,13 @@
<button class="ch-tab active" data-cdtab="videos" onclick="switchDashContentTab('videos', this)"><i class="fa-solid fa-video"></i> Videos</button>
<button class="ch-tab active" data-cdtab="videos" onclick="switchDashContentTab('videos', this)"><i class="fa-solid fa-video"></i> Videos</button>
<button class="ch-tab" data-cdtab="streamclips" onclick="switchDashContentTab('streamclips', this)"><i class="fa-solid fa-scissors"></i> Clips of My Stream</button>
<button class="ch-tab" data-cdtab="streamclips" onclick="switchDashContentTab('streamclips', this)"><i class="fa-solid fa-scissors"></i> Clips of My Stream</button>
<button class="ch-tab" data-cdtab="myclips" onclick="switchDashContentTab('myclips', this)"><i class="fa-solid fa-film"></i> My Clips</button>
<button class="ch-tab" data-cdtab="myclips" onclick="switchDashContentTab('myclips', this)"><i class="fa-solid fa-film"></i> My Clips</button>
+ <button class="ch-tab" data-cdtab="aimoments" onclick="switchDashContentTab('aimoments', this)"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments<span class="dash-subtab-count" id="dash-ai-moments-count" hidden></span></button>
<button class="ch-tab" data-cdtab="pastes" onclick="switchDashContentTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes</button>
<button class="ch-tab" data-cdtab="pastes" onclick="switchDashContentTab('pastes', this)"><i class="fa-solid fa-paste"></i> Pastes</button>
</div>
</div>
<div class="dash-subpanel active" id="dash-content-videos"><div class="dash-grid" id="dash-grid-content-videos"></div></div>
<div class="dash-subpanel active" id="dash-content-videos"><div class="dash-grid" id="dash-grid-content-videos"></div></div>
<div class="dash-subpanel" id="dash-content-streamclips"><div class="dash-grid" id="dash-grid-content-streamclips"></div></div>
<div class="dash-subpanel" id="dash-content-streamclips"><div class="dash-grid" id="dash-grid-content-streamclips"></div></div>
<div class="dash-subpanel" id="dash-content-myclips"><div class="dash-grid" id="dash-grid-content-myclips"></div></div>
<div class="dash-subpanel" id="dash-content-myclips"><div class="dash-grid" id="dash-grid-content-myclips"></div></div>
+ <div class="dash-subpanel" id="dash-content-aimoments"><div class="dash-grid" id="dash-grid-content-aimoments"></div></div>
<div class="dash-subpanel" id="dash-content-pastes"><div class="dash-grid" id="dash-grid-content-pastes"></div></div>
<div class="dash-subpanel" id="dash-content-pastes"><div class="dash-grid" id="dash-grid-content-pastes"></div></div>
</div>
</div>
@@ -362,7 +364,7 @@
@@ -362,7 +364,7 @@
<h3 style="margin:0"><i class="fa-solid fa-clapperboard"></i> Clips of My Stream</h3>
<h3 style="margin:0"><i class="fa-solid fa-clapperboard"></i> Clips of My Stream</h3>
<button class="btn btn-small btn-outline sel-all-btn" data-sel-container="dash-stream-clips" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
<button class="btn btn-small btn-outline sel-all-btn" data-sel-container="dash-stream-clips" style="display:none" onclick="_selAllToggle(this.dataset.selContainer)"><i class="fa-solid fa-check-double"></i> <span class="sel-all-label">Select all</span></button>
</div>
</div>
- <p class="muted">Clips viewers have taken from your streams. You can publish or delete them.</p>
+ <p class="muted">Clips people have taken from your streams. You can publish or delete them. Clips the AI cut are under AI Moments.</p>
<label class="staff-inline-toggle" style="margin-bottom:12px">
<label class="staff-inline-toggle" style="margin-bottom:12px">
<input type="checkbox" id="dash-clips-allow-creator-delete" onchange="saveClipSettings()">
<input type="checkbox" id="dash-clips-allow-creator-delete" onchange="saveClipSettings()">
<span>Let clip creators delete their own clips of my channel <span class="muted" style="font-weight:400">(off = only you, your mods, and staff can delete)</span></span>
<span>Let clip creators delete their own clips of my channel <span class="muted" style="font-weight:400">(off = only you, your mods, and staff can delete)</span></span>
@@ -370,6 +372,13 @@
@@ -370,6 +372,13 @@
<div id="dash-stream-clips" class="vod-grid"></div>
<div id="dash-stream-clips" class="vod-grid"></div>
</div>
</div>
+ <!-- AI Moments of my stream: auto-clips, kept apart from the clips people took (roadmap 33.7) -->
+ <div class="dash-card dash-card-wide" id="dash-card-aimoments">
+ <h3 style="margin:0 0 6px"><i class="fa-solid fa-wand-magic-sparkles"></i> AI Moments of My Stream</h3>
+ <p class="muted">Clips OpenVibe's AI cut from your streams when chat reacted. Nobody clipped these, so they are not in "My Clips". You can publish, unlist or delete them.</p>
+ <div id="dash-ai-clips" class="vod-grid"></div>
+ </div>
+
<!-- My Pastes -->
<!-- My Pastes -->
<div class="dash-card dash-card-wide" id="dash-card-pastes">
<div class="dash-card dash-card-wide" id="dash-card-pastes">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
<div style="display:flex;align-items:center;justify-content:space-between;gap:8px;flex-wrap:wrap">
@@ -33,6 +33,7 @@ const CHANNEL_CLIPS_PAGE_SIZE = 12;
@@ -33,6 +33,7 @@ const CHANNEL_CLIPS_PAGE_SIZE = 12;
const channelVodsPageByUser = Object.create(null);
const channelVodsPageByUser = Object.create(null);
const channelClipsPageByUser = Object.create(null);
const channelClipsPageByUser = Object.create(null);
const channelClipsOfPageByUser = Object.create(null);
const channelClipsOfPageByUser = Object.create(null);
+const channelAiClipsPageByUser = Object.create(null);
// Channel VOD filter/sort state
// Channel VOD filter/sort state
let currentChannelVodFilter = null; // numeric managed stream id or null = all
let currentChannelVodFilter = null; // numeric managed stream id or null = all
let currentChannelVodOrder = 'newest'; // newest|oldest|views|peak_viewers
let currentChannelVodOrder = 'newest'; // newest|oldest|views|peak_viewers
@@ -282,7 +283,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
@@ -282,7 +283,7 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
const total = meta.total || clips.length;
const total = meta.total || clips.length;
const page = Math.floor(offset / pageSize) + 1;
const page = Math.floor(offset / pageSize) + 1;
- if (total === 0) { grid.innerHTML = '<p class="muted">No clips yet</p>'; return; }
+ if (total === 0) { grid.innerHTML = '<p class="muted">No one has clipped these streams yet</p>'; return; }
if (clips.length) {
if (clips.length) {
grid.innerHTML = clips.map(cl => `
grid.innerHTML = clips.map(cl => `
@@ -306,6 +307,64 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
@@ -306,6 +307,64 @@ function renderChannelClipsOfSection(username, clips, meta = {}) {
renderVodsPagination('ch-clips-of-pagination', page, total, pageSize, 'setChannelClipsOfPage', 'clips of streams');
renderVodsPagination('ch-clips-of-pagination', page, total, pageSize, 'setChannelClipsOfPage', 'clips of streams');
}
}
+// ── AI Moments on the Clips tab: auto-clips of this channel's streams, after people's clips ──
+// Built with DOM nodes (titles come from the AI). Each card says it is an AI clip from this
+// streamer's stream; none is credited to a clipper (roadmap 33.4/33.6).
+function renderChannelAiClipsSection(username, clips, meta = {}) {
+ const section = document.getElementById('ch-ai-clips-section');
+ const grid = document.getElementById('ch-ai-clips-grid');
+ if (!section || !grid) return;
+ const pageSize = meta.limit || CHANNEL_CLIPS_PAGE_SIZE;
+ const offset = meta.offset || 0;
+ const total = meta.total || clips.length;
+ section.hidden = !(total > 0);
+ grid.textContent = '';
+ if (!total) { renderVodsPagination('ch-ai-clips-pagination', 1, 0, pageSize, 'setChannelAiClipsPage', 'AI clips'); return; }
+ for (const cl of clips) {
+ const href = `/clip/${Number(cl.id)}`;
+ const card = document.createElement('a');
+ card.className = 'stream-card';
+ card.href = href;
+ card.addEventListener('click', (event) => handleLinkClick(event, href));
+ const thumb = document.createElement('div');
+ thumb.className = 'stream-card-thumb';
+ thumb.innerHTML = thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title, `/api/thumbnails/generate/clip/${Number(cl.id)}`);
+ const badge = document.createElement('span');
+ badge.className = 'ch-ai-badge';
+ badge.textContent = 'AI clip';
+ const dur = document.createElement('span');
+ dur.className = 'stream-card-viewers';
+ dur.innerHTML = '<i class="fa-solid fa-clock"></i> ';
+ dur.append(formatDuration(cl.duration_seconds));
+ thumb.append(badge, dur);
+ const info = document.createElement('div');
+ info.className = 'stream-card-info';
+ const title = document.createElement('div');
+ title.className = 'stream-card-title';
+ title.textContent = cl.title || 'AI clip';
+ const by = document.createElement('div');
+ by.className = 'stream-card-streamer muted';
+ by.textContent = `from ${cl.source_streamer_display_name || cl.source_streamer_username || username}'s stream · ${formatDateTime(cl.created_at)}`;
+ info.append(title, by);
+ if (cl.ai_overview_short) info.insertAdjacentHTML('beforeend', _cardAiHTML(cl.ai_overview_short, cl.ai_overview));
+ card.append(thumb, info);
+ grid.append(card);
+ }
+ renderVodsPagination('ch-ai-clips-pagination', Math.floor(offset / pageSize) + 1, total, pageSize, 'setChannelAiClipsPage', 'AI clips');
+}
+
+async function setChannelAiClipsPage(page) {
+ if (!currentChannelUsername) return;
+ const safePage = Math.max(1, page | 0);
+ if (safePage === (channelAiClipsPageByUser[currentChannelUsername] || 1)) return;
+ channelAiClipsPageByUser[currentChannelUsername] = safePage;
+ const offset = (safePage - 1) * CHANNEL_CLIPS_PAGE_SIZE;
+ const data = await api(`/streams/channel/${currentChannelUsername}?aiClipsLimit=${CHANNEL_CLIPS_PAGE_SIZE}&aiClipsOffset=${offset}`);
+ renderChannelAiClipsSection(currentChannelUsername, data.aiClips || [], { total: data.aiClipsTotal || 0, limit: CHANNEL_CLIPS_PAGE_SIZE, offset });
+ const top = document.getElementById('ch-ai-clips-section');
+ if (top) top.scrollIntoView({ behavior: 'smooth', block: 'start' });
+}
+
async function refreshChannelVodsPage(username = currentChannelUsername) {
async function refreshChannelVodsPage(username = currentChannelUsername) {
if (!username) return;
if (!username) return;
@@ -707,6 +766,12 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
@@ -707,6 +766,12 @@ async function loadChannelPage(username, managedStreamRef = null, legacySessionI
limit: data.clipsOfLimit || CHANNEL_CLIPS_PAGE_SIZE,
limit: data.clipsOfLimit || CHANNEL_CLIPS_PAGE_SIZE,
offset: data.clipsOfOffset || 0,
offset: data.clipsOfOffset || 0,
});
});
+ // …then what the AI cut from them, labelled as such
+ renderChannelAiClipsSection(username, data.aiClips || [], {
+ total: data.aiClipsTotal || (data.aiClips || []).length,
+ limit: data.aiClipsLimit || CHANNEL_CLIPS_PAGE_SIZE,
+ offset: data.aiClipsOffset || 0,
+ });
// Analytics is loaded lazily when its tab is first opened (see switchChannelTab).
// Analytics is loaded lazily when its tab is first opened (see switchChannelTab).
@@ -2041,7 +2106,7 @@ function _applyChannelTabMeta(data) {
@@ -2041,7 +2106,7 @@ function _applyChannelTabMeta(data) {
}
}
};
};
setBadge('ch-tab-badge-videos', data.vodTotal);
setBadge('ch-tab-badge-videos', data.vodTotal);
- setBadge('ch-tab-badge-clips', data.clipsOfTotal);
+ setBadge('ch-tab-badge-clips', (Number(data.clipsOfTotal) || 0) + (Number(data.aiClipsTotal) || 0));
setBadge('ch-tab-badge-clips-taken', data.clipsTakenTotal);
setBadge('ch-tab-badge-clips-taken', data.clipsTakenTotal);
setBadge('ch-tab-badge-pastes', data.pasteTotal);
setBadge('ch-tab-badge-pastes', data.pasteTotal);
setBadge('ch-tab-badge-ai-timeline', data.aiEventTotal);
setBadge('ch-tab-badge-ai-timeline', data.aiEventTotal);
@@ -888,6 +888,44 @@ async function recutClip(clipId) {
@@ -888,6 +888,44 @@ async function recutClip(clipId) {
}
}
}
}
+/**
+ * The clip page's attribution line, from the clip's own origin (Media's auto_generated):
+ * a person's clip → "Clipped by <clipper>"
+ * an auto-clip → "AI clip · from <streamer>'s stream" and what made it; never "Clipped by"
+ * the streamer the job filed it under.
+ */
+function _renderClipAttribution(el, cl) {
+ el.textContent = '';
+ el.classList.toggle('clp-ai-attribution', !!cl.auto_generated);
+ const icon = (cls) => { const i = document.createElement('i'); i.className = cls; i.setAttribute('aria-hidden', 'true'); return i; };
+ if (!cl.auto_generated) {
+ const who = document.createElement('strong');
+ who.textContent = cl.display_name || cl.username || 'Unknown';
+ el.append(icon('fa-solid fa-scissors'), ' Clipped by ', who);
+ return;
+ }
+ const streamerName = cl.source_streamer_display_name || cl.streamer_display_name || cl.source_streamer_username || cl.streamer_username || cl.display_name || cl.username;
+ const streamerUser = cl.source_streamer_username || cl.streamer_username || cl.username;
+ const badge = document.createElement('span');
+ badge.className = 'ai-badge';
+ badge.textContent = 'AI clip';
+ el.append(icon('fa-solid fa-wand-magic-sparkles'), ' ', badge, ' · from ');
+ if (streamerName && streamerUser) {
+ const a = document.createElement('a');
+ const href = channelPath(streamerUser);
+ a.href = href;
+ a.textContent = streamerName;
+ a.addEventListener('click', (event) => handleLinkClick(event, href));
+ el.append(a, "'s stream");
+ } else {
+ el.append('a live stream');
+ }
+ const how = document.createElement('div');
+ how.className = 'muted clp-ai-how';
+ how.textContent = 'Cut automatically by OpenVibe\'s auto-clip workflow when chat reacted. No one clipped it.';
+ el.append(how);
+}
+
async function loadClipPlayer(clipId) {
async function loadClipPlayer(clipId) {
try {
try {
// Clean up chat replay
// Clean up chat replay
@@ -1044,11 +1082,10 @@ async function loadClipPlayer(clipId) {
@@ -1044,11 +1082,10 @@ async function loadClipPlayer(clipId) {
streamerLink.onclick = (event) => handleLinkClick(event, targetUrl);
streamerLink.onclick = (event) => handleLinkClick(event, targetUrl);
}
}
- // Show "Clipped by" info
+ // Who made it. An auto-clip was cut by OpenVibe's AI when chat reacted, not by the
+ // streamer it is filed under (roadmap 33.4): "AI clip · from <streamer>'s stream".
const clippedByEl = document.getElementById('clp-clipped-by');
const clippedByEl = document.getElementById('clp-clipped-by');
- if (clippedByEl) {
- clippedByEl.innerHTML = `<i class="fa-solid fa-scissors"></i> Clipped by <strong>${esc(cl.display_name || cl.username || 'Unknown')}</strong>`;
- }
+ if (clippedByEl) _renderClipAttribution(clippedByEl, cl);
// Show delete button per the server-authoritative can_delete flag (streamer /
// Show delete button per the server-authoritative can_delete flag (streamer /
// channel mod / staff, or the creator only if the channel opted in).
// channel mod / staff, or the creator only if the channel opted in).
@@ -24,6 +24,7 @@ const _DASH_CONTENT_SUBCARDS = {
@@ -24,6 +24,7 @@ const _DASH_CONTENT_SUBCARDS = {
videos: ['dash-my-videos-card'],
videos: ['dash-my-videos-card'],
streamclips: ['dash-card-streamclips'],
streamclips: ['dash-card-streamclips'],
myclips: ['dash-card-myclips'],
myclips: ['dash-card-myclips'],
+ aimoments: ['dash-card-aimoments'],
pastes: ['dash-card-pastes'],
pastes: ['dash-card-pastes'],
};
};
window._dashTabLoaders = window._dashTabLoaders || {};
window._dashTabLoaders = window._dashTabLoaders || {};
@@ -58,6 +59,7 @@ const _DASH_CONTENT_LOADERS = {
@@ -58,6 +59,7 @@ const _DASH_CONTENT_LOADERS = {
videos: () => _call('loadDashVods'),
videos: () => _call('loadDashVods'),
streamclips: () => { _call('loadDashStreamClips'); _call('loadClipSettings'); },
streamclips: () => { _call('loadDashStreamClips'); _call('loadClipSettings'); },
myclips: () => _call('loadDashMyClips'),
myclips: () => _call('loadDashMyClips'),
+ aimoments: () => _call('loadDashAiClips'),
pastes: () => _call('loadDashPastes'),
pastes: () => _call('loadDashPastes'),
};
};
function switchDashContentTab(sub, btn) {
function switchDashContentTab(sub, btn) {
@@ -98,7 +100,7 @@ window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call(
@@ -98,7 +100,7 @@ window._dashTabLoaders.chatai = () => { _call('updateDashObsOverlayUrl'); _call(
window._dashTabLoaders.controls = () => { _call('loadDashConfigs'); _call('loadControlSettings'); _call('loadDashboardCameras'); _call('loadDashTokens'); };
window._dashTabLoaders.controls = () => { _call('loadDashConfigs'); _call('loadControlSettings'); _call('loadDashboardCameras'); _call('loadDashTokens'); };
window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadPowerchatStatus'); };
window._dashTabLoaders.money = () => { _call('loadDashFunds'); _call('loadDashGoals'); _call('loadPowerchatStatus'); };
window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };
window._dashTabLoaders.points = () => { _call('loadDashPointsConfig'); _call('loadDashRewards'); _call('loadDashRedemptions'); };
-window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); };
+window._dashTabLoaders.content = () => { switchDashContentTab('videos', document.querySelector('#dash-content-subtabs .ch-tab[data-cdtab="videos"]')); _call('loadDashAiMomentsCount'); };
// moderation loader is registered by dashboard-moderation.js
// moderation loader is registered by dashboard-moderation.js
/**
/**
@@ -711,6 +713,7 @@ function _dashReloadMedia() {
@@ -711,6 +713,7 @@ function _dashReloadMedia() {
loadDashVods();
loadDashVods();
if (typeof loadDashMyClips === 'function') loadDashMyClips();
if (typeof loadDashMyClips === 'function') loadDashMyClips();
if (typeof loadDashStreamClips === 'function') loadDashStreamClips();
if (typeof loadDashStreamClips === 'function') loadDashStreamClips();
+ if (document.getElementById('dash-ai-clips')?.childElementCount) loadDashAiClips();
}
}
function _dashSelInit() { if (typeof _selSetContext === 'function') _selSetContext(true, _dashReloadMedia); }
function _dashSelInit() { if (typeof _selSetContext === 'function') _selSetContext(true, _dashReloadMedia); }
function _dashSelSync() { if (typeof _selSyncAllBtns === 'function') _selSyncAllBtns(); }
function _dashSelSync() { if (typeof _selSyncAllBtns === 'function') _selSyncAllBtns(); }
@@ -881,7 +884,8 @@ async function loadDashStreamClips() {
@@ -881,7 +884,8 @@ async function loadDashStreamClips() {
if (!list) return;
if (!list) return;
_dashSelInit();
_dashSelInit();
try {
try {
- const data = await api(`/clips/my-stream?limit=${DASH_PAGE_SIZE}&offset=${dashStreamClipsPage * DASH_PAGE_SIZE}`);
+ // People's clips only; the AI's are under AI Moments (loadDashAiClips).
+ const data = await api(`/clips/my-stream?auto_generated=0&limit=${DASH_PAGE_SIZE}&offset=${dashStreamClipsPage * DASH_PAGE_SIZE}`);
const clips = data.clips || [];
const clips = data.clips || [];
dashStreamClipsTotal = data.total ?? clips.length;
dashStreamClipsTotal = data.total ?? clips.length;
if (!clips.length && dashStreamClipsPage === 0) {
if (!clips.length && dashStreamClipsPage === 0) {
@@ -917,6 +921,64 @@ async function loadDashStreamClips() {
@@ -917,6 +921,64 @@ async function loadDashStreamClips() {
function dashStreamClipsGoPage(page) { dashStreamClipsPage = page; loadDashStreamClips(); }
function dashStreamClipsGoPage(page) { dashStreamClipsPage = page; loadDashStreamClips(); }
+/* ── AI Moments of My Stream (auto-clips) ─────────────────────────
+ What the AI cut from my streams. Never in "My Clips" (the streamer did not make them) and never
+ credited to a clipper; the count rides on the sub-tab. Built with DOM nodes: titles come from the AI. */
+let dashAiClipsPage = 0;
+let dashAiClipsTotal = 0;
+function _setDashAiMomentsCount(n) {
+ const el = document.getElementById('dash-ai-moments-count');
+ if (!el) return;
+ const num = Number(n) || 0;
+ el.textContent = num ? String(num) : '';
+ el.hidden = !num;
+}
+async function loadDashAiMomentsCount() {
+ try { const d = await api('/clips/my-stream?auto_generated=1&limit=1'); _setDashAiMomentsCount(d.total); } catch { /* the count is a hint */ }
+}
+async function loadDashAiClips() {
+ const list = document.getElementById('dash-ai-clips');
+ if (!list) return;
+ _dashSelInit();
+ try {
+ const data = await api(`/clips/my-stream?auto_generated=1&limit=${DASH_PAGE_SIZE}&offset=${dashAiClipsPage * DASH_PAGE_SIZE}`);
+ const clips = data.clips || [];
+ dashAiClipsTotal = data.total ?? clips.length;
+ _setDashAiMomentsCount(dashAiClipsTotal);
+ list.textContent = '';
+ if (!clips.length && dashAiClipsPage === 0) {
+ const p = document.createElement('p');
+ p.className = 'muted';
+ p.textContent = 'The AI has not cut any clips from your streams.';
+ list.append(p);
+ _dashSelSync();
+ return;
+ }
+ list.innerHTML = clips.map(cl => _dashWrap('clip', cl.id, `
+ <div class="stream-card" style="display:inline-block;width:240px;margin-right:12px;vertical-align:top">
+ <div class="stream-card-thumb" style="height:135px">
+ ${typeof thumbImg === 'function' ? thumbImg(cl.thumbnail_url, 'fa-scissors', cl.title) : '<i class="fa-solid fa-scissors"></i>'}
+ ${_dashVisBadge(cl.visibility, cl.is_public)}
+ <span class="stream-card-viewers"><i class="fa-solid fa-clock"></i> ${formatDuration(cl.duration_seconds)}</span>
+ </div>
+ <div class="stream-card-info">
+ <div class="stream-card-title">${esc(cl.title || 'AI clip')}</div>
+ <div class="muted" style="font-size:0.8rem;margin-bottom:4px"><span class="dash-ai-badge">AI clip</span> ${new Date(cl.created_at).toLocaleDateString()}</div>
+ <div style="display:flex;gap:6px">
+ ${!cl.is_public
+ ? `<button class="btn btn-small btn-success" onclick="dashToggleClipVisibility(${Number(cl.id)}, true, 'ai')"><i class="fa-solid fa-eye"></i> Publish</button>`
+ : `<button class="btn btn-small btn-outline" onclick="dashToggleClipVisibility(${Number(cl.id)}, false, 'ai')"><i class="fa-solid fa-eye-slash"></i> Unlist</button>`}
+ <button class="btn btn-small" onclick="navigate('/clip/${Number(cl.id)}')"><i class="fa-solid fa-play"></i></button>
+ <button class="btn btn-small btn-danger" onclick="dashDeleteClip(${Number(cl.id)}, 'ai')"><i class="fa-solid fa-trash"></i></button>
+ </div>
+ </div>
+ </div>
+ `)).join('') + dashPaginationHtml('dashAiClips', dashAiClipsPage, dashAiClipsTotal);
+ _dashSelSync();
+ } catch { list.innerHTML = '<p class="muted">Failed to load AI Moments</p>'; }
+}
+function dashAiClipsGoPage(page) { dashAiClipsPage = page; loadDashAiClips(); }
+
async function loadClipSettings() {
async function loadClipSettings() {
const el = document.getElementById('dash-clips-allow-creator-delete');
const el = document.getElementById('dash-clips-allow-creator-delete');
if (!el) return;
if (!el) return;
@@ -956,6 +1018,7 @@ async function dashToggleClipVisibility(clipId, makePublic, source) {
@@ -956,6 +1018,7 @@ async function dashToggleClipVisibility(clipId, makePublic, source) {
});
});
toast(makePublic ? 'Clip published' : 'Clip unlisted', 'success');
toast(makePublic ? 'Clip published' : 'Clip unlisted', 'success');
if (source === 'mine') loadDashMyClips();
if (source === 'mine') loadDashMyClips();
+ else if (source === 'ai') loadDashAiClips();
else loadDashStreamClips();
else loadDashStreamClips();
} catch (e) { toast(e.message || 'Failed to update visibility', 'error'); }
} catch (e) { toast(e.message || 'Failed to update visibility', 'error'); }
}
}
@@ -966,6 +1029,7 @@ async function dashDeleteClip(clipId, source) {
@@ -966,6 +1029,7 @@ async function dashDeleteClip(clipId, source) {
await api(`/clips/${clipId}`, { method: 'DELETE' });
await api(`/clips/${clipId}`, { method: 'DELETE' });
toast('Clip deleted', 'success');
toast('Clip deleted', 'success');
if (source === 'mine') loadDashMyClips();
if (source === 'mine') loadDashMyClips();
+ else if (source === 'ai') loadDashAiClips();
else loadDashStreamClips();
else loadDashStreamClips();
} catch (e) { toast(e.message || 'Delete failed', 'error'); }
} catch (e) { toast(e.message || 'Delete failed', 'error'); }
}
}
@@ -31,6 +31,9 @@ function refuse(req, res, clip, message) {
@@ -31,6 +31,9 @@ function refuse(req, res, clip, message) {
return res.status(403).json({ error: message });
return res.status(403).json({ error: message });
}
}
+/** An auto-clip: cut by Live's auto-clip job (Media's auto_generated), not by a person. */
+const isAiClip = (c) => !!(c && (c.auto_generated === true || Number(c.auto_generated) === 1));
+
function mediaErr(res, err, fallback) {
function mediaErr(res, err, fallback) {
if (err && err.name === 'MediaApiError' && err.status) {
if (err && err.name === 'MediaApiError' && err.status) {
return res.status(err.status).json(err.body || { error: err.message });
return res.status(err.status).json(err.body || { error: err.message });
@@ -121,8 +124,12 @@ router.get('/mine', requireAuth, async (req, res) => {
@@ -121,8 +124,12 @@ router.get('/mine', requireAuth, async (req, res) => {
try {
try {
// App-key call: Live already authenticated the owner, and Media only
// App-key call: Live already authenticated the owner, and Media only
// honors include_private for the owning app.
// honors include_private for the owning app.
- const out = await media.listClips({ ...req.query, user_id: req.user.id, include_private: 1 });
- const raw = (out?.clips || (Array.isArray(out) ? out : [])).map(withUserFields);
+ // "Clips I made" holds people's clips only (roadmap 33.7): the auto-clip job cuts in the
+ // streamer's name, but the streamer did not make those. They are the AI Moments of the
+ // streamer's stream (/my-stream?auto_generated=1). Checked again per row in case Media
+ // ignores the filter.
+ const out = await media.listClips({ ...req.query, user_id: req.user.id, include_private: 1, auto_generated: 0 });
+ const raw = (out?.clips || (Array.isArray(out) ? out : [])).filter((c) => !isAiClip(c)).map(withUserFields);
const clips = [];
const clips = [];
for (const c of raw) clips.push({ ...c, can_delete: await canActorDeleteClip(req.user, c) });
for (const c of raw) clips.push({ ...c, can_delete: await canActorDeleteClip(req.user, c) });
res.json({ clips, total: out?.total ?? clips.length, limit: out?.limit ?? clips.length, offset: out?.offset ?? 0 });
res.json({ clips, total: out?.total ?? clips.length, limit: out?.limit ?? clips.length, offset: out?.offset ?? 0 });
@@ -133,9 +140,15 @@ router.get('/mine', requireAuth, async (req, res) => {
@@ -133,9 +140,15 @@ router.get('/mine', requireAuth, async (req, res) => {
router.get('/my-stream', requireAuth, async (req, res) => {
router.get('/my-stream', requireAuth, async (req, res) => {
try {
try {
- // Clips others took of my streams (channel_user_id filter); app-key call.
- const out = await media.listClips({ ...req.query, channel_user_id: req.user.id, include_private: 1 });
- const clips = (out?.clips || (Array.isArray(out) ? out : [])).map(withUserFields).map(c => ({ ...c, can_delete: true }));
+ // Clips of my streams (channel_user_id filter); app-key call. ?auto_generated=0 is the
+ // clips people took, =1 the AI Moments (auto-clips); without it, both.
+ const q = { ...req.query, channel_user_id: req.user.id, include_private: 1 };
+ const want = q.auto_generated === '0' || q.auto_generated === '1' ? Number(q.auto_generated) : null;
+ if (want === null) delete q.auto_generated; else q.auto_generated = want;
+ const out = await media.listClips(q);
+ const clips = (out?.clips || (Array.isArray(out) ? out : []))
+ .filter((c) => want === null || isAiClip(c) === (want === 1))
+ .map(withUserFields).map(c => ({ ...c, can_delete: true, ai_label: isAiClip(c) ? 'AI clip' : null }));
res.json({ clips, total: out?.total ?? clips.length, limit: out?.limit ?? clips.length, offset: out?.offset ?? 0 });
res.json({ clips, total: out?.total ?? clips.length, limit: out?.limit ?? clips.length, offset: out?.offset ?? 0 });
} catch (err) {
} catch (err) {
mediaErr(res, err, 'Failed to list stream clips');
mediaErr(res, err, 'Failed to list stream clips');
@@ -265,6 +265,8 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -265,6 +265,8 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
const clipOffset = Math.max(parseInt(req.query.clipOffset || '0', 10), 0);
const clipOffset = Math.max(parseInt(req.query.clipOffset || '0', 10), 0);
const clipsOfLimit = Math.min(Math.max(parseInt(req.query.clipsOfLimit || '12', 10), 1), 48);
const clipsOfLimit = Math.min(Math.max(parseInt(req.query.clipsOfLimit || '12', 10), 1), 48);
const clipsOfOffset = Math.max(parseInt(req.query.clipsOfOffset || '0', 10), 0);
const clipsOfOffset = Math.max(parseInt(req.query.clipsOfOffset || '0', 10), 0);
+ const aiClipsLimit = Math.min(Math.max(parseInt(req.query.aiClipsLimit || '12', 10), 1), 48);
+ const aiClipsOffset = Math.max(parseInt(req.query.aiClipsOffset || '0', 10), 0);
// The 15s live/offline status poll passes ?pollOnly=1 — it only reads live streams
// The 15s live/offline status poll passes ?pollOnly=1 — it only reads live streams
// + viewer counts, so skip the 6 heavy VOD/clip listing+count queries entirely.
// + viewer counts, so skip the 6 heavy VOD/clip listing+count queries entirely.
const pollOnly = req.query.pollOnly === '1' || req.query.pollOnly === 'true';
const pollOnly = req.query.pollOnly === '1' || req.query.pollOnly === 'true';
@@ -273,16 +275,22 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -273,16 +275,22 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
// channel_user_id/order) follow the inherited query shapes.
// channel_user_id/order) follow the inherited query shapes.
const media = require('../media-client');
const media = require('../media-client');
const _rows = (r, k) => (r && r[k]) || (Array.isArray(r) ? r : []);
const _rows = (r, k) => (r && r[k]) || (Array.isArray(r) ? r : []);
- let vods = [], vodTotal = 0, clips = [], clipTotal = 0, clipsOfStreams = [], clipsOfTotal = 0;
+ let vods = [], vodTotal = 0, clips = [], clipTotal = 0, clipsOfStreams = [], clipsOfTotal = 0, aiClips = [], aiClipsTotal = 0;
if (!pollOnly) {
if (!pollOnly) {
- const [vr, cr, cor] = await Promise.all([
+ // People's clips and the AI's are separate lists (roadmap 33.6: made, then derived). The
+ // auto-clip job cuts in the streamer's name, so without auto_generated=0 its clips would
+ // be listed as clips the streamer made. Rows are checked again in case Media ignores it.
+ const isAi = (c) => !!(c && (c.auto_generated === true || Number(c.auto_generated) === 1));
+ const [vr, cr, cor, air] = await Promise.all([
media.listVods({ user_id: channel.user_id, include_private: canSeeHidden ? 1 : 0, managed_stream_id: vodManagedStreamId || undefined, order: vodOrderBy, limit: vodLimit, offset: vodOffset }).catch(() => null),
media.listVods({ user_id: channel.user_id, include_private: canSeeHidden ? 1 : 0, managed_stream_id: vodManagedStreamId || undefined, order: vodOrderBy, limit: vodLimit, offset: vodOffset }).catch(() => null),
- media.listClips({ user_id: channel.user_id, include_private: canSeeHidden ? 1 : 0, limit: clipLimit, offset: clipOffset }).catch(() => null),
- media.listClips({ channel_user_id: channel.user_id, limit: clipsOfLimit, offset: clipsOfOffset }).catch(() => null),
+ media.listClips({ user_id: channel.user_id, include_private: canSeeHidden ? 1 : 0, auto_generated: 0, limit: clipLimit, offset: clipOffset }).catch(() => null),
+ media.listClips({ channel_user_id: channel.user_id, auto_generated: 0, limit: clipsOfLimit, offset: clipsOfOffset }).catch(() => null),
+ media.listClips({ channel_user_id: channel.user_id, auto_generated: 1, limit: aiClipsLimit, offset: aiClipsOffset }).catch(() => null),
]);
]);
vods = _rows(vr, 'vods'); vodTotal = vr?.total ?? vods.length;
vods = _rows(vr, 'vods'); vodTotal = vr?.total ?? vods.length;
- clips = _rows(cr, 'clips'); clipTotal = cr?.total ?? clips.length;
- clipsOfStreams = _rows(cor, 'clips'); clipsOfTotal = cor?.total ?? clipsOfStreams.length;
+ clips = _rows(cr, 'clips').filter((c) => !isAi(c)); clipTotal = cr?.total ?? clips.length;
+ clipsOfStreams = _rows(cor, 'clips').filter((c) => !isAi(c)); clipsOfTotal = cor?.total ?? clipsOfStreams.length;
+ aiClips = _rows(air, 'clips').filter(isAi); aiClipsTotal = air?.total ?? aiClips.length;
// Media only stores our numeric user ids — resolve clip creator names
// Media only stores our numeric user ids — resolve clip creator names
// locally so cards don't render "by Unknown".
// locally so cards don't render "by Unknown".
const nameClip = (c) => {
const nameClip = (c) => {
@@ -294,6 +302,8 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -294,6 +302,8 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
};
};
clips.forEach(nameClip);
clips.forEach(nameClip);
clipsOfStreams.forEach(nameClip);
clipsOfStreams.forEach(nameClip);
+ // An AI clip has no clipper: it is "from <streamer>'s stream".
+ for (const c of aiClips) { c.ai_label = 'AI clip'; c.source_streamer_username = channel.username; c.source_streamer_display_name = channel.display_name || channel.username; }
// AI overviews are Live-owned (vod_ai_state / clip_ai_state). Attach the
// AI overviews are Live-owned (vod_ai_state / clip_ai_state). Attach the
// full text too — the card expander swaps the short teaser for it.
// full text too — the card expander swaps the short teaser for it.
const aiShort = (rows, table, col) => {
const aiShort = (rows, table, col) => {
@@ -309,6 +319,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -309,6 +319,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
aiShort(vods, 'vod_ai_state', 'vod_id');
aiShort(vods, 'vod_ai_state', 'vod_id');
aiShort(clips, 'clip_ai_state', 'clip_id');
aiShort(clips, 'clip_ai_state', 'clip_id');
aiShort(clipsOfStreams, 'clip_ai_state', 'clip_id');
aiShort(clipsOfStreams, 'clip_ai_state', 'clip_id');
+ aiShort(aiClips, 'clip_ai_state', 'clip_id');
}
}
const followerCount = db.getFollowerCount(channel.user_id);
const followerCount = db.getFollowerCount(channel.user_id);
const isFollowing = req.user ? db.isFollowing(req.user.id, channel.user_id) : false;
const isFollowing = req.user ? db.isFollowing(req.user.id, channel.user_id) : false;
@@ -489,7 +500,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -489,7 +500,7 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
? _slotClipVis.every(v => v === 'private')
? _slotClipVis.every(v => v === 'private')
: ((channel.default_clip_visibility || 'public') === 'private');
: ((channel.default_clip_visibility || 'public') === 'private');
const videos_tab_hidden = !pollOnly && allVodPrivate && vodTotal === 0;
const videos_tab_hidden = !pollOnly && allVodPrivate && vodTotal === 0;
- const clips_tab_hidden = !pollOnly && allClipPrivate && clipTotal === 0 && clipsOfTotal === 0;
+ const clips_tab_hidden = !pollOnly && allClipPrivate && clipTotal === 0 && clipsOfTotal === 0 && aiClipsTotal === 0;
res.json({
res.json({
videos_tab_hidden,
videos_tab_hidden,
@@ -527,6 +538,12 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
@@ -527,6 +538,12 @@ router.get('/channel/:username', optionalAuth, async (req, res) => {
clipsOfLimit,
clipsOfLimit,
clipsOfOffset,
clipsOfOffset,
clipsOfHasMore: clipsOfOffset + clipsOfStreams.length < clipsOfTotal,
clipsOfHasMore: clipsOfOffset + clipsOfStreams.length < clipsOfTotal,
+ // AI Moments of this channel: auto-clips, listed after people's clips and labelled.
+ aiClips,
+ aiClipsTotal,
+ aiClipsLimit,
+ aiClipsOffset,
+ aiClipsHasMore: aiClipsOffset + aiClips.length < aiClipsTotal,
});
});
} catch (err) {
} catch (err) {
console.error('[Channels] Get error:', err.message);
console.error('[Channels] Get error:', err.message);
@@ -581,6 +598,7 @@ router.get('/channel/:username/clips-taken', optionalAuth, async (req, res) => {
@@ -581,6 +598,7 @@ router.get('/channel/:username/clips-taken', optionalAuth, async (req, res) => {
const r = await media.listClips({
const r = await media.listClips({
user_id: user.id, include_private: canSeeHidden ? 1 : 0, order: orderBy,
user_id: user.id, include_private: canSeeHidden ? 1 : 0, order: orderBy,
source_streamer_id: sourceStreamerId || undefined, hide_self: hideSelf ? 1 : 0,
source_streamer_id: sourceStreamerId || undefined, hide_self: hideSelf ? 1 : 0,
+ auto_generated: 0, // the auto-clip job cuts in the streamer's name; those clips are not ones they took
limit, offset,
limit, offset,
}).catch(() => null);
}).catch(() => null);
const clips = (r?.clips || (Array.isArray(r) ? r : [])).map(c => {
const clips = (r?.clips || (Array.isArray(r) ? r : [])).map(c => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
+/**
+ * AI clips are never filed as something a person made (roadmap 33.4, 33.6, 33.7).
+ *
+ * The auto-clip job cuts in the streamer's name (Media user_id = the streamer), so every list that
+ * asks Media for "clips by user X" used to show the AI's clips as the streamer's own. Checked here:
+ * - GET /api/clips/mine ("clips I made") holds people's clips only, whatever the query says, and
+ * drops an AI row even when Media ignores the filter;
+ * - GET /api/clips/my-stream splits into the clips people took (?auto_generated=0) and the AI
+ * Moments (?auto_generated=1), each labelled;
+ * - the channel page lists people's clips (by and of the streamer) apart from its AI clips, which
+ * come back as their own labelled list with a total; "Clips Taken" asks for people's clips;
+ * - the clip page's attribution line says "AI clip · from <streamer>'s stream" for an auto-clip,
+ * and "Clipped by <clipper>" only for a person's clip (public/js/app-media.js, run against a
+ * small stand-in DOM).
+ *
+ * node test/clip-attribution.test.js
+ */
+'use strict';
+const assert = require('assert');
+const fs = require('fs');
+const os = require('os');
+const path = require('path');
+const http = require('http');
+const vm = require('vm');
+
+const tmp = path.join(os.tmpdir(), `ov-clip-attribution-${process.pid}.db`);
+process.env.DB_PATH = tmp;
+process.env.NODE_ENV = 'test';
+const quiet = console.log;
+console.log = (...a) => { if (!/^\[/.test(String(a[0]))) quiet(...a); };
+console.warn = () => {};
+console.error = () => {};
+
+const db = require('../server/db/database');
+db.initDb();
+const raw = db.getDb();
+
+// ── Sign-in stub (before any router captures the middleware) ──
+const auth = require('../server/auth/auth');
+const signIn = (req) => {
+ const id = Number(req.headers['x-test-user'] || 0);
+ const u = id ? db.getUserById(id) : null;
+ if (u) { req.user = u; req.authSource = 'network'; }
+ return u;
+};
+auth.requireAuth = (req, res, next) => (signIn(req) ? next() : res.status(401).json({ error: 'Authentication required' }));
+auth.optionalAuth = (req, res, next) => { signIn(req); next(); };
+
+const addUser = (id, username, display) => raw.prepare(
+ `INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
+ VALUES (?, ?, ?, ?, 'x', 'streamer', '2025-01-01 00:00:00')`).run(id, username, display, `${username}@x`);
+addUser(3, 'alice', 'Alice');
+addUser(4, 'bob', 'Bob');
+db.ensureChannel(3);
+
+// ── OpenVibe.Media stand-in. `honour` = whether it applies auto_generated (an old Media did not). ──
+const media = require('../server/media-client');
+const asked = [];
+let honour = true;
+const CLIPS = [
+ { id: 1, user_id: 3, channel_user_id: 3, title: 'AI: chat lost it', visibility: 'public', is_public: true, status: 'ready', auto_generated: true },
+ { id: 2, user_id: 3, channel_user_id: 3, title: 'AI: the lights went out', visibility: 'public', is_public: true, status: 'ready', auto_generated: true },
+ { id: 3, user_id: 4, channel_user_id: 3, title: 'Bob clipped Alice', visibility: 'public', is_public: true, status: 'ready', auto_generated: false },
+ { id: 4, user_id: 3, channel_user_id: 4, title: 'Alice clipped Bob', visibility: 'public', is_public: true, status: 'ready', auto_generated: false },
+];
+media.listClips = async (q = {}) => {
+ asked.push({ ...q });
+ const rows = CLIPS.filter((c) => (q.user_id == null || String(c.user_id) === String(q.user_id))
+ && (q.channel_user_id == null || String(c.channel_user_id) === String(q.channel_user_id))
+ && (!q.hide_self || c.user_id !== c.channel_user_id)
+ && (!honour || q.auto_generated == null || Number(c.auto_generated) === Number(q.auto_generated)));
+ return { clips: rows.map((c) => ({ ...c })), total: rows.length };
+};
+media.listVods = async () => ({ vods: [], total: 0 });
+media.request = async () => { throw new media.MediaApiError('stubbed', 0, null); };
+media.getClip = async () => { throw new media.MediaApiError('not found', 404, null); };
+const pastesClient = require('../server/pastes-client');
+pastesClient.request = async () => ({ pastes: [], total: 0 });
+pastesClient.listPastes = async () => ({ pastes: [], total: 0 });
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/clips', require('../server/media-proxy/clips'));
+app.use('/api/streams', require('../server/streaming/routes'));
+const server = http.createServer(app).listen(0);
+function call(p, user) {
+ return new Promise((resolve, reject) => {
+ const headers = {};
+ if (user) headers['x-test-user'] = String(user);
+ const req = http.request({ port: server.address().port, path: p, method: 'GET', headers }, (res) => {
+ let text = '';
+ res.on('data', (c) => { text += c; });
+ res.on('end', () => { let json = null; try { json = JSON.parse(text); } catch { /* */ } resolve({ status: res.statusCode, json }); });
+ });
+ req.on('error', reject);
+ req.end();
+ });
+}
+const ids = (rows) => (rows || []).map((c) => c.id).sort();
+
+// ── A small stand-in DOM, enough for _renderClipAttribution ──
+class El {
+ constructor(tag) { this.tagName = tag; this.children = []; this.className = ''; this.attrs = {}; this.listeners = {}; this.href = ''; this.classList = { toggle: (c, on) => { const set = new Set(this.className.split(/\s+/).filter(Boolean)); if (on) set.add(c); else set.delete(c); this.className = [...set].join(' '); } }; }
+ set textContent(v) { this.children = v ? [String(v)] : []; }
+ get textContent() { return this.children.map((c) => (typeof c === 'string' ? c : c.textContent)).join(''); }
+ append(...nodes) { this.children.push(...nodes); }
+ setAttribute(k, v) { this.attrs[k] = v; }
+ addEventListener(t, fn) { this.listeners[t] = fn; }
+ find(tag) { for (const c of this.children) { if (typeof c === 'string') continue; if (c.tagName === tag) return c; const f = c.find(tag); if (f) return f; } return null; }
+}
+function loadAttribution() {
+ const src = fs.readFileSync(path.join(__dirname, '../public/js/app-media.js'), 'utf8');
+ const start = src.indexOf('function _renderClipAttribution(');
+ assert.ok(start > 0, 'app-media.js defines _renderClipAttribution');
+ const end = src.indexOf('\n}\n', start) + 3;
+ const ctx = { document: { createElement: (t) => new El(t) }, channelPath: (u) => `/@${u}`, handleLinkClick: () => false };
+ vm.createContext(ctx);
+ vm.runInContext(`${src.slice(start, end)}\nthis._renderClipAttribution = _renderClipAttribution;`, ctx);
+ return ctx._renderClipAttribution;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { asked.length = 0; honour = true; await fn(); quiet(' ✓', name); }
+ catch (e) { failures++; quiet(' ✗', name, '\n ', e.stack.split('\n').slice(0, 3).join('\n ')); }
+}
+
+(async () => {
+ await new Promise((r) => server.once('listening', r));
+ quiet('Clip attribution');
+
+ await check('"My Clips" asks Media for people\'s clips only, even when the query asks for AI ones', async () => {
+ const r = await call('/api/clips/mine?auto_generated=1', 3);
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(ids(r.json.clips), [4], 'only the clip Alice took');
+ assert.strictEqual(asked[0].auto_generated, 0);
+ assert.strictEqual(String(asked[0].user_id), '3');
+ });
+
+ await check('"My Clips" drops AI rows a Media that ignores the filter still returns', async () => {
+ honour = false;
+ const r = await call('/api/clips/mine', 3);
+ assert.deepStrictEqual(ids(r.json.clips), [4]);
+ });
+
+ await check('clips of my stream split into what people took and the AI Moments, each labelled', async () => {
+ const people = await call('/api/clips/my-stream?auto_generated=0', 3);
+ assert.deepStrictEqual(ids(people.json.clips), [3]);
+ assert.ok(people.json.clips.every((c) => c.ai_label === null));
+ const ai = await call('/api/clips/my-stream?auto_generated=1', 3);
+ assert.deepStrictEqual(ids(ai.json.clips), [1, 2]);
+ assert.ok(ai.json.clips.every((c) => c.ai_label === 'AI clip'));
+ assert.strictEqual(ai.json.total, 2, 'the count the dashboard shows');
+ honour = false;
+ const aiLoose = await call('/api/clips/my-stream?auto_generated=1', 3);
+ assert.deepStrictEqual(ids(aiLoose.json.clips), [1, 2], 'split holds when Media ignores the filter');
+ const both = await call('/api/clips/my-stream', 3);
+ assert.deepStrictEqual(ids(both.json.clips), [1, 2, 3], 'no filter: both, labelled');
+ });
+
+ await check("the channel page lists people's clips apart from its AI clips", async () => {
+ const r = await call('/api/streams/channel/alice');
+ assert.strictEqual(r.status, 200);
+ assert.deepStrictEqual(ids(r.json.clips), [4], 'clips by Alice: only the one she took');
+ assert.deepStrictEqual(ids(r.json.clipsOfStreams), [3], "clips of Alice's streams: only people's");
+ assert.deepStrictEqual(ids(r.json.aiClips), [1, 2], 'the AI clips are their own list');
+ assert.strictEqual(r.json.aiClipsTotal, 2);
+ assert.ok(r.json.aiClips.every((c) => c.ai_label === 'AI clip' && c.source_streamer_username === 'alice'));
+ const clipAsks = asked.filter((q) => 'auto_generated' in q);
+ assert.strictEqual(clipAsks.length, 3, 'every clip list says which kind it wants');
+ honour = false;
+ const loose = await call('/api/streams/channel/alice');
+ assert.deepStrictEqual(ids(loose.json.clips), [4]);
+ assert.deepStrictEqual(ids(loose.json.clipsOfStreams), [3]);
+ assert.deepStrictEqual(ids(loose.json.aiClips), [1, 2]);
+ });
+
+ await check('"Clips Taken" asks for the clips a person took', async () => {
+ const r = await call('/api/streams/channel/alice/clips-taken?includeSelf=1');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(asked[0].auto_generated, 0);
+ assert.ok(!r.json.clips.some((c) => c.auto_generated), 'no AI clip');
+ });
+
+ await check('the clip page says "AI clip · from Alice\'s stream" for an auto-clip, never "Clipped by"', () => {
+ const render = loadAttribution();
+ const el = new El('div');
+ render(el, { auto_generated: true, username: 'alice', display_name: 'Alice', source_streamer_username: 'alice', source_streamer_display_name: 'Alice' });
+ const text = el.textContent;
+ assert.ok(text.startsWith(" AI clip · from Alice's stream"), text);
+ assert.ok(!/Clipped by/.test(text), text);
+ assert.ok(/No one clipped it/.test(text));
+ assert.strictEqual(el.find('a').href, '/@alice', 'links the channel');
+ assert.ok(/clp-ai-attribution/.test(el.className));
+ const person = new El('div');
+ render(person, { auto_generated: false, username: 'bob', display_name: 'Bob' });
+ assert.strictEqual(person.textContent, ' Clipped by Bob');
+ assert.ok(!/clp-ai-attribution/.test(person.className));
+ });
+
+ server.close();
+ try { fs.unlinkSync(tmp); } catch { /* */ }
+ for (const ext of ['-wal', '-shm']) { try { fs.unlinkSync(tmp + ext); } catch { /* */ } }
+ quiet(failures ? `\n${failures} check(s) failed` : '\nclip attribution: all checks passed');
+ process.exit(failures ? 1 : 0);
+})();