openvibers/openvibe.live Commits
Clip cuts follow their Media job (WS-G task 3): a clip the server cuts answers with its clip.cut job id; the channel page and the broadcast dashboard follow it (public/js/ov-clip-jobs.js) and open the trim editor or say it failed, with a notice per retry; a reload reattaches (pending cuts in localStorage, resumed when the player starts); GET /api/clips/:id/job for the clip's maker or manager, only that clip's cut job; re-cut passes its job id
Changes
@@@ -51,7 +51,15 @@"feeds"]},++ "clipJobs": {++ "js": [++ "/js/ov-clip-jobs.js"++ ]++ },"player": {++ "deps": [++ "clipJobs"++ ],"js": ["/js/stream-player.js","/js/stream-pip.js"@@@ -192,7 +200,8 @@"deps": ["chatExtras","guide",-- "aiViewers"++ "aiViewers",++ "clipJobs"],"js": ["/js/broadcast-state.js",@@@ -5795,7 +5795,17 @@ async function createBroadcastClip() {const data = await res.json();if (!res.ok) throw new Error(data.error || 'Failed to create clip');-- showToast(`Clip created! "${title}"`, 'success');++ // The server cuts it (Media's clip.cut job): say when it is ready, even after a reload.++ const made = data.clip || {};++ if (made.job_id && made.status === 'processing' && window.OVClipJobs) {++ showToast(`Clip "${title}" created — cutting it now…`, 'success');++ window.OVClipJobs.follow(made.id, made.job_id, {++ onReady: () => showToast(`Clip "${title}" is ready`, 'success'),++ onFailed: (msg) => showToast(`Clip "${title}" could not be cut: ${msg}`, 'error'),++ });++ } else {++ showToast(`Clip created! "${title}"`, 'success');++ }// Reset panelconst titleInput = document.getElementById('bc-clip-title');nnew file mode 100644@@@ -0,0 +1,64 @@++/**++ * Following a clip cut on the server (OpenVibe.Media's clip.cut job; roadmap WS-G task 3).++ *++ * A clip the server cuts answers with its job id. The page follows the job until the clip is ready++ * and survives a reload: pending cuts are kept in localStorage (ov_clip_jobs, 30 minutes at most) and++ * the next page that loads this reattaches to them.++ *++ * OVClipJobs.follow(clipId, jobId, { onReady(clip), onFailed(message), onRetry(job) })++ * OVClipJobs.resume({ onReady, onFailed, onRetry }) follow what an earlier page left pending++ * OVClipJobs.pending() [{ clipId, jobId, at }]++ */++(function () {++ 'use strict';++ var KEY = 'ov_clip_jobs';++ var MAX_AGE_MS = 30 * 60 * 1000;++ var followed = {};++++ function load() {++ try {++ var list = JSON.parse(localStorage.getItem(KEY) || '[]');++ return Array.isArray(list) ? list.filter(function (e) { return e && e.clipId && e.jobId && Date.now() - e.at < MAX_AGE_MS; }) : [];++ } catch (e) { return []; }++ }++ function save(list) { try { localStorage.setItem(KEY, JSON.stringify(list.slice(-10))); } catch (e) { /* storage off: this page still follows */ } }++ function forget(jobId) { save(load().filter(function (e) { return e.jobId !== jobId; })); delete followed[jobId]; }++ function remember(clipId, jobId) {++ var list = load().filter(function (e) { return e.jobId !== jobId; });++ list.push({ clipId: clipId, jobId: jobId, at: Date.now() });++ save(list);++ }++ function headers() {++ var t = null;++ try { t = localStorage.getItem('token'); } catch (e) { /* none */ }++ return t ? { Authorization: 'Bearer ' + t } : {};++ }++++ function follow(clipId, jobId, h) {++ h = h || {};++ if (!clipId || !jobId || followed[jobId]) return;++ followed[jobId] = true;++ remember(clipId, jobId);++ var started = Date.now(), tries = 0, retried = 0;++ (function poll() {++ if (Date.now() - started > MAX_AGE_MS) { forget(jobId); return; }++ fetch('/api/clips/' + encodeURIComponent(clipId) + '/job?job=' + encodeURIComponent(jobId), { headers: headers(), credentials: 'same-origin' })++ .then(function (r) { return r.status === 404 || r.status === 403 ? { gone: true } : (r.ok ? r.json() : null); })++ .then(function (d) {++ if (d && d.gone) { forget(jobId); return; }++ var job = d && d.job;++ if (job && job.status === 'succeeded') { forget(jobId); if (h.onReady) h.onReady(d.clip || { id: clipId }); return; }++ if (job && (job.status === 'failed' || job.status === 'cancelled')) { forget(jobId); if (h.onFailed) h.onFailed(job.error || 'The clip could not be cut'); return; }++ // A failed attempt waits for its retry (the server backs off): say so once per attempt.++ if (job && job.status === 'queued' && job.attempts > retried) { retried = job.attempts; if (h.onRetry) h.onRetry(job); }++ tries++;++ setTimeout(poll, job && job.attempts > 0 ? 5000 : Math.min(4000, 800 + tries * 200));++ })++ .catch(function () { tries++; setTimeout(poll, Math.min(15000, 2000 * tries)); });++ })();++ }++++ function resume(h) { load().forEach(function (e) { follow(e.clipId, e.jobId, h); }); }++++ window.OVClipJobs = { follow: follow, resume: resume, pending: load };++})();@@@ -899,6 +899,8 @@ function _applyLoaderThumb(stream) {}function initPlayer(stream) {++ // Clips this browser asked for before a reload are still being cut: reattach to them.++ if (window.OVClipJobs && !initPlayer._clipsResumed) { initPlayer._clipsResumed = true; window.OVClipJobs.resume(_clipCutHandlers); }destroyPlayer();// Camera overlay is a separate viewer session against another slot's live stream;// attach it alongside whichever protocol the main stream uses, and let it fail@@@ -3442,10 +3444,17 @@ async function _serverLiveClipAttempt(isRetry) {toast(data.error || 'Failed to create clip', 'error');return 'handled';}-- const clipId = data.clip && data.clip.id;-- if (data.deduplicated) toast('That moment is already clipped — opening it', 'info');-- else toast('Clip created! — Trim & title it', 'success');-- if (clipId) openClipTrimEditor(clipId);++ const clip = data.clip || {};++ const clipId = clip.id;++ const dedup = data.deduplicated || clip.deduplicated;++ if (dedup) toast('That moment is already clipped — opening it', 'info');++ if (clipId && clip.job_id && clip.status === 'processing' && !dedup && window.OVClipJobs) {++ toast('Clip created — cutting it on the server…', 'success');++ _followClipCut(clipId, clip.job_id);++ } else {++ if (!dedup) toast('Clip created! — Trim & title it', 'success');++ if (clipId) openClipTrimEditor(clipId);++ }return 'ok';} catch (err) {clearSlow();@@@ -3454,6 +3463,15 @@ async function _serverLiveClipAttempt(isRetry) {}}++// A clip the server cuts (Media's clip.cut job, WS-G task 3): follow the job, then open the trim editor.++// A reload reattaches: ov-clip-jobs.js keeps the pending cut and initPlayer resumes it.++const _clipCutHandlers = {++ onReady: (clip) => { toast('Your clip is ready — Trim & title it', 'success'); openClipTrimEditor(clip.id); },++ onFailed: (msg) => toast('The clip could not be cut: ' + msg, 'error'),++ onRetry: (job) => toast(`Still working on your clip (attempt ${job.attempts + 1} of ${job.max_attempts})…`, 'info'),++};++function _followClipCut(clipId, jobId) { window.OVClipJobs.follow(clipId, jobId, _clipCutHandlers); }++/*** Show a modal prompt for the user to title their clip after creation.* @param {number} clipId - The newly created clip's ID@@@ -24,6 +24,7 @@ const MEDIA_APP_ID = process.env.MEDIA_APP_ID || 'live';const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';const API_BASE = `${MEDIA_URL}/api/v1/${MEDIA_APP_ID}`;++const API_V2_BASE = `${MEDIA_URL}/api/v2/${MEDIA_APP_ID}`; // media jobs (GET /jobs/:id)class MediaApiError extends Error {constructor(message, status, body) {@@@ -66,8 +67,8 @@ function _qs(query) {* Core request helper. `body` may be a plain object (JSON) or FormData (multipart).* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.*/--async function request(method, apiPath, { body, query, actingUser, headers = {}, timeoutMs = 30000 } = {}) {-- const url = `${API_BASE}${apiPath}${_qs(query)}`;++async function request(method, apiPath, { body, query, actingUser, headers = {}, timeoutMs = 30000, base = API_BASE } = {}) {++ const url = `${base}${apiPath}${_qs(query)}`;const opts = {method,headers: { Accept: 'application/json', ..._authHeader({ actingUser }), ...headers },@@@ -196,6 +197,11 @@ function deleteVod(vodId, opts = {}) {// ── Clips ────────────────────────────────────────────────────────────────────/** POST /clips { vod_id, start_s, end_s, title?, user_id? } → { id, status } */++/** A media job of this app (e.g. a clip's clip.cut, whose id createClip/recutClip answer) → { job } */++function getJob(jobId, opts = {}) {++ return request('GET', `/jobs/${encodeURIComponent(jobId)}`, { ...opts, base: API_V2_BASE });++}++function createClip({ vod_id, start_s, end_s, title, user_id, ...extra } = {}, opts = {}) {return request('POST', '/clips', { body: { vod_id, start_s, end_s, title, user_id, ...extra }, ...opts });}@@@ -400,7 +406,7 @@ module.exports = {uploadVodChunk, completeVodChunks, finalizeVod,getVod, listVods, updateVod, deleteVod,// clips-- createClip, getClip, listClips, updateClip, deleteClip, recutClip,++ createClip, getClip, listClips, updateClip, deleteClip, recutClip, getJob,// pastescreatePaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, deletePaste,// files + thumbnails@@@ -374,11 +374,39 @@ router.post('/:id/recut', requireAuth, async (req, res) => {if (!clip) return res.status(404).json({ error: 'Clip not found' });if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to re-cut this clip');const out = await media.recutClip(clip.id);-- res.json({ message: 'Re-cutting clip', status: out?.status || 'processing' });++ res.json({ message: 'Re-cutting clip', status: out?.status || 'processing', job_id: out?.job_id || null });} catch (err) {mediaErr(res, err, 'Failed to re-cut clip');}});++/**++ * The cut of a clip the server makes (Media's clip.cut job, WS-G task 3): what the clip UI follows and++ * reattaches to after a reload (public/js/ov-clip-jobs.js). Whoever made the clip, or may manage it, sees++ * its job, and only a clip.cut job of that very clip; anything else is 404.++ * GET /api/clips/:id/job?job=<mjob_…> → { job: { id, status, attempts, max_attempts, error, run_after }, clip: { id, status, title, duration_seconds } }++ */++router.get('/:id/job', requireAuth, async (req, res) => {++ try {++ const jobId = String(req.query.job || '');++ if (!/^mjob_[0-9A-Za-z]{10,40}$/.test(jobId)) return res.status(400).json({ error: 'job is required' });++ let clip;++ try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }++ if (!clip) return res.status(404).json({ error: 'Clip not found' });++ const mine = clip.user_id != null && String(clip.user_id) === String(req.user.id);++ if (!mine && !(await canActorDeleteClip(req.user, clip))) return res.status(404).json({ error: 'Clip not found' });++ let out;++ try { out = await media.getJob(jobId); } catch (err) { return mediaErr(res, err, 'Job not found'); }++ const job = out && out.job;++ if (!job || job.type !== 'clip.cut' || Number(job.params && job.params.clip_id) !== Number(clip.id)) return res.status(404).json({ error: 'Job not found' });++ res.set('Cache-Control', 'private, no-store').json({++ job: { id: job.id, status: job.status, attempts: job.attempts, max_attempts: job.max_attempts, error: job.status === 'failed' ? job.error || null : null, run_after: job.run_after || null },++ clip: { id: clip.id, status: clip.status, title: clip.title, duration_seconds: clip.duration_seconds },++ });++ } catch (err) {++ mediaErr(res, err, 'Failed to read the clip job');++ }++});++module.exports = router;module.exports._internals = { clipChannelOwnerId };nnew file mode 100644@@@ -0,0 +1,161 @@++/**++ * Following a clip cut on the server (Media's clip.cut job, roadmap WS-G task 3).++ *++ * - GET /api/clips/:id/job?job= answers the job of that clip's cut to whoever made the clip (or may++ * manage it): status, attempts, the error once it failed. Anyone else, a job of another clip, or++ * a job that is not a clip.cut gets 404; a malformed job id 400.++ * - public/js/ov-clip-jobs.js (run here with a stand-in fetch, localStorage and timers) follows a job++ * to ready or failed, says so once per retry, keeps the pending cut across a "reload" and resumes it,++ * and forgets a job the server no longer knows.++ *++ * node test/clip-cut-job.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-cut-job-${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();++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) => raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)++ VALUES (?, ?, ?, ?, 'x', 'user', '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`);++addUser(3, 'maker');++addUser(4, 'stranger');++++// ── OpenVibe.Media stand-in ──++const media = require('../server/media-client');++const CLIPS = { 10: { id: 10, user_id: 3, channel_user_id: 9, status: 'processing', title: 'Mine', duration_seconds: 0 }, 11: { id: 11, user_id: 3, channel_user_id: 9, status: 'ready', title: 'Other', duration_seconds: 7 } };++const JOBS = {++ mjob_01M3AAAAAAAAAAAAAAAAAAAAAA: { id: 'mjob_01M3AAAAAAAAAAAAAAAAAAAAAA', type: 'clip.cut', status: 'running', attempts: 0, max_attempts: 4, params: { clip_id: 10 }, error: null },++ mjob_01M3BBBBBBBBBBBBBBBBBBBBBB: { id: 'mjob_01M3BBBBBBBBBBBBBBBBBBBBBB', type: 'object.sprite', status: 'succeeded', attempts: 1, max_attempts: 3, params: {}, error: null },++};++media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw new media.MediaApiError('not found', 404, null); return c; };++media.getJob = async (id) => { const j = JOBS[id]; if (!j) throw new media.MediaApiError('No such job', 404, null); return { job: j }; };++++const express = require('express');++const app = express();++app.use(express.json());++app.use('/api/clips', require('../server/media-proxy/clips'));++const server = http.createServer(app).listen(0);++const base = () => `http://127.0.0.1:${server.address().port}`;++const get = async (p, user) => { const r = await fetch(base() + p, { headers: user ? { 'x-test-user': String(user) } : {} }); return { status: r.status, body: await r.json().catch(() => null) }; };++++(async () => {++ await new Promise((r) => server.once('listening', r));++ const A = 'mjob_01M3AAAAAAAAAAAAAAAAAAAAAA', B = 'mjob_01M3BBBBBBBBBBBBBBBBBBBBBB';++++ // ── The route ──++ let r = await get(`/api/clips/10/job?job=${A}`, 3);++ assert.strictEqual(r.status, 200, JSON.stringify(r.body));++ assert.deepStrictEqual(r.body.job, { id: A, status: 'running', attempts: 0, max_attempts: 4, error: null, run_after: null });++ assert.deepStrictEqual(r.body.clip, { id: 10, status: 'processing', title: 'Mine', duration_seconds: 0 });++ assert.strictEqual((await get(`/api/clips/10/job?job=${A}`, 4)).status, 404, 'someone else: as if missing');++ assert.strictEqual((await get(`/api/clips/10/job?job=${A}`)).status, 401);++ assert.strictEqual((await get(`/api/clips/11/job?job=${A}`, 3)).status, 404, 'the job of another clip');++ assert.strictEqual((await get(`/api/clips/10/job?job=${B}`, 3)).status, 404, 'not a clip.cut');++ assert.strictEqual((await get('/api/clips/10/job?job=mjob_01M3CCCCCCCCCCCCCCCCCCCCCC', 3)).status, 404, 'unknown job');++ assert.strictEqual((await get('/api/clips/10/job?job=../x', 3)).status, 400);++ JOBS[A] = { ...JOBS[A], status: 'failed', attempts: 1, error: 'No decodable footage in that window' };++ r = await get(`/api/clips/10/job?job=${A}`, 3);++ assert.strictEqual(r.body.job.error, 'No decodable footage in that window', 'a failed cut says why');++++ // ── The client helper, against a scripted server ──++ const src = fs.readFileSync(path.join(__dirname, '../public/js/ov-clip-jobs.js'), 'utf8');++ const store = new Map();++ const script = {}; // jobId -> list of answers, one per poll++ const polls = [];++ const timers = [];++ const makeWindow = () => {++ const w = {++ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },++ fetch: async (url) => {++ const m = /\/api\/clips\/(\d+)\/job\?job=([\w]+)/.exec(url);++ polls.push(m[2]);++ const next = (script[m[2]] || []).shift() || { status: 200, body: { job: { status: 'running', attempts: 0 } } };++ return { status: next.status, ok: next.status < 300, json: async () => next.body };++ },++ setTimeout: (fn) => { timers.push(fn); return timers.length; },++ Date,++ };++ w.window = w;++ return w;++ };++ const drain = async () => { for (let i = 0; i < 50 && timers.length; i++) { const fn = timers.shift(); fn(); await new Promise((r2) => setImmediate(r2)); await new Promise((r2) => setImmediate(r2)); } };++ const plain = (v) => JSON.parse(JSON.stringify(v)); // values from the page's realm++ const settle = () => new Promise((r2) => setImmediate(() => setImmediate(r2)));++++ // A cut that retries once, then is ready.++ let w = makeWindow();++ vm.runInNewContext(src, w);++ const seen = [];++ script.mjob_1 = [++ { status: 200, body: { job: { status: 'running', attempts: 0 } } },++ { status: 200, body: { job: { status: 'queued', attempts: 1, max_attempts: 4 } } },++ { status: 200, body: { job: { status: 'queued', attempts: 1, max_attempts: 4 } } },++ { status: 200, body: { job: { status: 'succeeded', attempts: 2 }, clip: { id: 10, status: 'ready' } } },++ ];++ w.OVClipJobs.follow(10, 'mjob_1', { onReady: (c) => seen.push(['ready', c.id]), onFailed: (m) => seen.push(['failed', m]), onRetry: (j) => seen.push(['retry', j.attempts]) });++ assert.deepStrictEqual(plain(w.OVClipJobs.pending().map((e) => e.jobId)), ['mjob_1'], 'kept while pending');++ await settle(); await drain();++ assert.deepStrictEqual(seen, [['retry', 1], ['ready', 10]], 'one retry notice, then ready');++ assert.deepStrictEqual(plain(w.OVClipJobs.pending()), [], 'forgotten once done');++++ // A reload: the new page resumes what the old one left pending, and a failure is reported.++ w = makeWindow();++ vm.runInNewContext(src, w);++ script.mjob_2 = [{ status: 200, body: { job: { status: 'running', attempts: 0 } } }];++ w.OVClipJobs.follow(12, 'mjob_2', {});++ await settle();++ timers.length = 0; // the page goes away mid-cut++ assert.deepStrictEqual(JSON.parse(store.get('ov_clip_jobs')).map((e) => e.jobId), ['mjob_2']);++ const w2 = makeWindow();++ vm.runInNewContext(src, w2);++ script.mjob_2 = [{ status: 200, body: { job: { status: 'failed', attempts: 4, error: 'gave up' } } }];++ const seen2 = [];++ w2.OVClipJobs.resume({ onFailed: (m) => seen2.push(m) });++ await settle(); await drain();++ assert.deepStrictEqual(seen2, ['gave up'], 'the resumed cut reports its failure');++ assert.deepStrictEqual(plain(w2.OVClipJobs.pending()), []);++++ // A job the server no longer knows (or no longer shows this person) is dropped silently.++ const w3 = makeWindow();++ vm.runInNewContext(src, w3);++ script.mjob_3 = [{ status: 404, body: { error: 'Job not found' } }];++ const seen3 = [];++ w3.OVClipJobs.follow(13, 'mjob_3', { onReady: () => seen3.push('ready'), onFailed: () => seen3.push('failed') });++ await settle(); await drain();++ assert.deepStrictEqual([seen3, plain(w3.OVClipJobs.pending())], [[], []]);++++ // Following the same job twice polls it once.++ const w4 = makeWindow();++ vm.runInNewContext(src, w4);++ polls.length = 0;++ script.mjob_4 = [{ status: 200, body: { job: { status: 'succeeded' }, clip: { id: 14 } } }];++ w4.OVClipJobs.follow(14, 'mjob_4', {});++ w4.OVClipJobs.follow(14, 'mjob_4', {});++ await settle(); await drain();++ assert.deepStrictEqual(polls, ['mjob_4']);++++ server.close();++ try { fs.unlinkSync(tmp); } catch { /* */ }++ quiet('clip cut job: all checks passed');++})().catch((err) => { quiet(err); process.exit(1); });
@@ -51,7 +51,15 @@
@@ -51,7 +51,15 @@
"feeds"
"feeds"
]
]
},
},
+ "clipJobs": {
+ "js": [
+ "/js/ov-clip-jobs.js"
+ ]
+ },
"player": {
"player": {
+ "deps": [
+ "clipJobs"
+ ],
"js": [
"js": [
"/js/stream-player.js",
"/js/stream-player.js",
"/js/stream-pip.js"
"/js/stream-pip.js"
@@ -192,7 +200,8 @@
@@ -192,7 +200,8 @@
"deps": [
"deps": [
"chatExtras",
"chatExtras",
"guide",
"guide",
- "aiViewers"
+ "aiViewers",
+ "clipJobs"
],
],
"js": [
"js": [
"/js/broadcast-state.js",
"/js/broadcast-state.js",
@@ -5795,7 +5795,17 @@ async function createBroadcastClip() {
@@ -5795,7 +5795,17 @@ async function createBroadcastClip() {
const data = await res.json();
const data = await res.json();
if (!res.ok) throw new Error(data.error || 'Failed to create clip');
if (!res.ok) throw new Error(data.error || 'Failed to create clip');
- showToast(`Clip created! "${title}"`, 'success');
+ // The server cuts it (Media's clip.cut job): say when it is ready, even after a reload.
+ const made = data.clip || {};
+ if (made.job_id && made.status === 'processing' && window.OVClipJobs) {
+ showToast(`Clip "${title}" created — cutting it now…`, 'success');
+ window.OVClipJobs.follow(made.id, made.job_id, {
+ onReady: () => showToast(`Clip "${title}" is ready`, 'success'),
+ onFailed: (msg) => showToast(`Clip "${title}" could not be cut: ${msg}`, 'error'),
+ });
+ } else {
+ showToast(`Clip created! "${title}"`, 'success');
+ }
// Reset panel
// Reset panel
const titleInput = document.getElementById('bc-clip-title');
const titleInput = document.getElementById('bc-clip-title');
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+/**
+ * Following a clip cut on the server (OpenVibe.Media's clip.cut job; roadmap WS-G task 3).
+ *
+ * A clip the server cuts answers with its job id. The page follows the job until the clip is ready
+ * and survives a reload: pending cuts are kept in localStorage (ov_clip_jobs, 30 minutes at most) and
+ * the next page that loads this reattaches to them.
+ *
+ * OVClipJobs.follow(clipId, jobId, { onReady(clip), onFailed(message), onRetry(job) })
+ * OVClipJobs.resume({ onReady, onFailed, onRetry }) follow what an earlier page left pending
+ * OVClipJobs.pending() [{ clipId, jobId, at }]
+ */
+(function () {
+ 'use strict';
+ var KEY = 'ov_clip_jobs';
+ var MAX_AGE_MS = 30 * 60 * 1000;
+ var followed = {};
+
+ function load() {
+ try {
+ var list = JSON.parse(localStorage.getItem(KEY) || '[]');
+ return Array.isArray(list) ? list.filter(function (e) { return e && e.clipId && e.jobId && Date.now() - e.at < MAX_AGE_MS; }) : [];
+ } catch (e) { return []; }
+ }
+ function save(list) { try { localStorage.setItem(KEY, JSON.stringify(list.slice(-10))); } catch (e) { /* storage off: this page still follows */ } }
+ function forget(jobId) { save(load().filter(function (e) { return e.jobId !== jobId; })); delete followed[jobId]; }
+ function remember(clipId, jobId) {
+ var list = load().filter(function (e) { return e.jobId !== jobId; });
+ list.push({ clipId: clipId, jobId: jobId, at: Date.now() });
+ save(list);
+ }
+ function headers() {
+ var t = null;
+ try { t = localStorage.getItem('token'); } catch (e) { /* none */ }
+ return t ? { Authorization: 'Bearer ' + t } : {};
+ }
+
+ function follow(clipId, jobId, h) {
+ h = h || {};
+ if (!clipId || !jobId || followed[jobId]) return;
+ followed[jobId] = true;
+ remember(clipId, jobId);
+ var started = Date.now(), tries = 0, retried = 0;
+ (function poll() {
+ if (Date.now() - started > MAX_AGE_MS) { forget(jobId); return; }
+ fetch('/api/clips/' + encodeURIComponent(clipId) + '/job?job=' + encodeURIComponent(jobId), { headers: headers(), credentials: 'same-origin' })
+ .then(function (r) { return r.status === 404 || r.status === 403 ? { gone: true } : (r.ok ? r.json() : null); })
+ .then(function (d) {
+ if (d && d.gone) { forget(jobId); return; }
+ var job = d && d.job;
+ if (job && job.status === 'succeeded') { forget(jobId); if (h.onReady) h.onReady(d.clip || { id: clipId }); return; }
+ if (job && (job.status === 'failed' || job.status === 'cancelled')) { forget(jobId); if (h.onFailed) h.onFailed(job.error || 'The clip could not be cut'); return; }
+ // A failed attempt waits for its retry (the server backs off): say so once per attempt.
+ if (job && job.status === 'queued' && job.attempts > retried) { retried = job.attempts; if (h.onRetry) h.onRetry(job); }
+ tries++;
+ setTimeout(poll, job && job.attempts > 0 ? 5000 : Math.min(4000, 800 + tries * 200));
+ })
+ .catch(function () { tries++; setTimeout(poll, Math.min(15000, 2000 * tries)); });
+ })();
+ }
+
+ function resume(h) { load().forEach(function (e) { follow(e.clipId, e.jobId, h); }); }
+
+ window.OVClipJobs = { follow: follow, resume: resume, pending: load };
+})();
@@ -899,6 +899,8 @@ function _applyLoaderThumb(stream) {
@@ -899,6 +899,8 @@ function _applyLoaderThumb(stream) {
}
}
function initPlayer(stream) {
function initPlayer(stream) {
+ // Clips this browser asked for before a reload are still being cut: reattach to them.
+ if (window.OVClipJobs && !initPlayer._clipsResumed) { initPlayer._clipsResumed = true; window.OVClipJobs.resume(_clipCutHandlers); }
destroyPlayer();
destroyPlayer();
// Camera overlay is a separate viewer session against another slot's live stream;
// Camera overlay is a separate viewer session against another slot's live stream;
// attach it alongside whichever protocol the main stream uses, and let it fail
// attach it alongside whichever protocol the main stream uses, and let it fail
@@ -3442,10 +3444,17 @@ async function _serverLiveClipAttempt(isRetry) {
@@ -3442,10 +3444,17 @@ async function _serverLiveClipAttempt(isRetry) {
toast(data.error || 'Failed to create clip', 'error');
toast(data.error || 'Failed to create clip', 'error');
return 'handled';
return 'handled';
}
}
- const clipId = data.clip && data.clip.id;
- if (data.deduplicated) toast('That moment is already clipped — opening it', 'info');
- else toast('Clip created! — Trim & title it', 'success');
- if (clipId) openClipTrimEditor(clipId);
+ const clip = data.clip || {};
+ const clipId = clip.id;
+ const dedup = data.deduplicated || clip.deduplicated;
+ if (dedup) toast('That moment is already clipped — opening it', 'info');
+ if (clipId && clip.job_id && clip.status === 'processing' && !dedup && window.OVClipJobs) {
+ toast('Clip created — cutting it on the server…', 'success');
+ _followClipCut(clipId, clip.job_id);
+ } else {
+ if (!dedup) toast('Clip created! — Trim & title it', 'success');
+ if (clipId) openClipTrimEditor(clipId);
+ }
return 'ok';
return 'ok';
} catch (err) {
} catch (err) {
clearSlow();
clearSlow();
@@ -3454,6 +3463,15 @@ async function _serverLiveClipAttempt(isRetry) {
@@ -3454,6 +3463,15 @@ async function _serverLiveClipAttempt(isRetry) {
}
}
}
}
+// A clip the server cuts (Media's clip.cut job, WS-G task 3): follow the job, then open the trim editor.
+// A reload reattaches: ov-clip-jobs.js keeps the pending cut and initPlayer resumes it.
+const _clipCutHandlers = {
+ onReady: (clip) => { toast('Your clip is ready — Trim & title it', 'success'); openClipTrimEditor(clip.id); },
+ onFailed: (msg) => toast('The clip could not be cut: ' + msg, 'error'),
+ onRetry: (job) => toast(`Still working on your clip (attempt ${job.attempts + 1} of ${job.max_attempts})…`, 'info'),
+};
+function _followClipCut(clipId, jobId) { window.OVClipJobs.follow(clipId, jobId, _clipCutHandlers); }
+
/**
/**
* Show a modal prompt for the user to title their clip after creation.
* Show a modal prompt for the user to title their clip after creation.
* @param {number} clipId - The newly created clip's ID
* @param {number} clipId - The newly created clip's ID
@@ -24,6 +24,7 @@ const MEDIA_APP_ID = process.env.MEDIA_APP_ID || 'live';
@@ -24,6 +24,7 @@ const MEDIA_APP_ID = process.env.MEDIA_APP_ID || 'live';
const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';
const MEDIA_API_KEY = process.env.MEDIA_API_KEY || '';
const API_BASE = `${MEDIA_URL}/api/v1/${MEDIA_APP_ID}`;
const API_BASE = `${MEDIA_URL}/api/v1/${MEDIA_APP_ID}`;
+const API_V2_BASE = `${MEDIA_URL}/api/v2/${MEDIA_APP_ID}`; // media jobs (GET /jobs/:id)
class MediaApiError extends Error {
class MediaApiError extends Error {
constructor(message, status, body) {
constructor(message, status, body) {
@@ -66,8 +67,8 @@ function _qs(query) {
@@ -66,8 +67,8 @@ function _qs(query) {
* Core request helper. `body` may be a plain object (JSON) or FormData (multipart).
* Core request helper. `body` may be a plain object (JSON) or FormData (multipart).
* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.
* Returns parsed JSON (or null for empty responses). Throws MediaApiError on !ok.
*/
*/
-async function request(method, apiPath, { body, query, actingUser, headers = {}, timeoutMs = 30000 } = {}) {
- const url = `${API_BASE}${apiPath}${_qs(query)}`;
+async function request(method, apiPath, { body, query, actingUser, headers = {}, timeoutMs = 30000, base = API_BASE } = {}) {
+ const url = `${base}${apiPath}${_qs(query)}`;
const opts = {
const opts = {
method,
method,
headers: { Accept: 'application/json', ..._authHeader({ actingUser }), ...headers },
headers: { Accept: 'application/json', ..._authHeader({ actingUser }), ...headers },
@@ -196,6 +197,11 @@ function deleteVod(vodId, opts = {}) {
@@ -196,6 +197,11 @@ function deleteVod(vodId, opts = {}) {
// ── Clips ────────────────────────────────────────────────────────────────────
// ── Clips ────────────────────────────────────────────────────────────────────
/** POST /clips { vod_id, start_s, end_s, title?, user_id? } → { id, status } */
/** POST /clips { vod_id, start_s, end_s, title?, user_id? } → { id, status } */
+/** A media job of this app (e.g. a clip's clip.cut, whose id createClip/recutClip answer) → { job } */
+function getJob(jobId, opts = {}) {
+ return request('GET', `/jobs/${encodeURIComponent(jobId)}`, { ...opts, base: API_V2_BASE });
+}
+
function createClip({ vod_id, start_s, end_s, title, user_id, ...extra } = {}, opts = {}) {
function createClip({ vod_id, start_s, end_s, title, user_id, ...extra } = {}, opts = {}) {
return request('POST', '/clips', { body: { vod_id, start_s, end_s, title, user_id, ...extra }, ...opts });
return request('POST', '/clips', { body: { vod_id, start_s, end_s, title, user_id, ...extra }, ...opts });
}
}
@@ -400,7 +406,7 @@ module.exports = {
@@ -400,7 +406,7 @@ module.exports = {
uploadVodChunk, completeVodChunks, finalizeVod,
uploadVodChunk, completeVodChunks, finalizeVod,
getVod, listVods, updateVod, deleteVod,
getVod, listVods, updateVod, deleteVod,
// clips
// clips
- createClip, getClip, listClips, updateClip, deleteClip, recutClip,
+ createClip, getClip, listClips, updateClip, deleteClip, recutClip, getJob,
// pastes
// pastes
createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, deletePaste,
createPaste, getPaste, listPastes, listPastesNeedingAi, setPasteAi, deletePaste,
// files + thumbnails
// files + thumbnails
@@ -374,11 +374,39 @@ router.post('/:id/recut', requireAuth, async (req, res) => {
@@ -374,11 +374,39 @@ router.post('/:id/recut', requireAuth, async (req, res) => {
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!clip) return res.status(404).json({ error: 'Clip not found' });
if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to re-cut this clip');
if (!(await canActorDeleteClip(req.user, clip))) return refuse(req, res, clip, 'Not authorized to re-cut this clip');
const out = await media.recutClip(clip.id);
const out = await media.recutClip(clip.id);
- res.json({ message: 'Re-cutting clip', status: out?.status || 'processing' });
+ res.json({ message: 'Re-cutting clip', status: out?.status || 'processing', job_id: out?.job_id || null });
} catch (err) {
} catch (err) {
mediaErr(res, err, 'Failed to re-cut clip');
mediaErr(res, err, 'Failed to re-cut clip');
}
}
});
});
+/**
+ * The cut of a clip the server makes (Media's clip.cut job, WS-G task 3): what the clip UI follows and
+ * reattaches to after a reload (public/js/ov-clip-jobs.js). Whoever made the clip, or may manage it, sees
+ * its job, and only a clip.cut job of that very clip; anything else is 404.
+ * GET /api/clips/:id/job?job=<mjob_…> → { job: { id, status, attempts, max_attempts, error, run_after }, clip: { id, status, title, duration_seconds } }
+ */
+router.get('/:id/job', requireAuth, async (req, res) => {
+ try {
+ const jobId = String(req.query.job || '');
+ if (!/^mjob_[0-9A-Za-z]{10,40}$/.test(jobId)) return res.status(400).json({ error: 'job is required' });
+ let clip;
+ try { clip = await media.getClip(req.params.id); } catch (err) { return mediaErr(res, err, 'Clip not found'); }
+ if (!clip) return res.status(404).json({ error: 'Clip not found' });
+ const mine = clip.user_id != null && String(clip.user_id) === String(req.user.id);
+ if (!mine && !(await canActorDeleteClip(req.user, clip))) return res.status(404).json({ error: 'Clip not found' });
+ let out;
+ try { out = await media.getJob(jobId); } catch (err) { return mediaErr(res, err, 'Job not found'); }
+ const job = out && out.job;
+ if (!job || job.type !== 'clip.cut' || Number(job.params && job.params.clip_id) !== Number(clip.id)) return res.status(404).json({ error: 'Job not found' });
+ res.set('Cache-Control', 'private, no-store').json({
+ job: { id: job.id, status: job.status, attempts: job.attempts, max_attempts: job.max_attempts, error: job.status === 'failed' ? job.error || null : null, run_after: job.run_after || null },
+ clip: { id: clip.id, status: clip.status, title: clip.title, duration_seconds: clip.duration_seconds },
+ });
+ } catch (err) {
+ mediaErr(res, err, 'Failed to read the clip job');
+ }
+});
+
module.exports = router;
module.exports = router;
module.exports._internals = { clipChannelOwnerId };
module.exports._internals = { clipChannelOwnerId };
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+/**
+ * Following a clip cut on the server (Media's clip.cut job, roadmap WS-G task 3).
+ *
+ * - GET /api/clips/:id/job?job= answers the job of that clip's cut to whoever made the clip (or may
+ * manage it): status, attempts, the error once it failed. Anyone else, a job of another clip, or
+ * a job that is not a clip.cut gets 404; a malformed job id 400.
+ * - public/js/ov-clip-jobs.js (run here with a stand-in fetch, localStorage and timers) follows a job
+ * to ready or failed, says so once per retry, keeps the pending cut across a "reload" and resumes it,
+ * and forgets a job the server no longer knows.
+ *
+ * node test/clip-cut-job.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-cut-job-${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();
+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) => raw.prepare(`INSERT INTO users (id, username, display_name, email, password_hash, role, created_at)
+ VALUES (?, ?, ?, ?, 'x', 'user', '2025-01-01 00:00:00')`).run(id, username, username, `${username}@x`);
+addUser(3, 'maker');
+addUser(4, 'stranger');
+
+// ── OpenVibe.Media stand-in ──
+const media = require('../server/media-client');
+const CLIPS = { 10: { id: 10, user_id: 3, channel_user_id: 9, status: 'processing', title: 'Mine', duration_seconds: 0 }, 11: { id: 11, user_id: 3, channel_user_id: 9, status: 'ready', title: 'Other', duration_seconds: 7 } };
+const JOBS = {
+ mjob_01M3AAAAAAAAAAAAAAAAAAAAAA: { id: 'mjob_01M3AAAAAAAAAAAAAAAAAAAAAA', type: 'clip.cut', status: 'running', attempts: 0, max_attempts: 4, params: { clip_id: 10 }, error: null },
+ mjob_01M3BBBBBBBBBBBBBBBBBBBBBB: { id: 'mjob_01M3BBBBBBBBBBBBBBBBBBBBBB', type: 'object.sprite', status: 'succeeded', attempts: 1, max_attempts: 3, params: {}, error: null },
+};
+media.getClip = async (id) => { const c = CLIPS[Number(id)]; if (!c) throw new media.MediaApiError('not found', 404, null); return c; };
+media.getJob = async (id) => { const j = JOBS[id]; if (!j) throw new media.MediaApiError('No such job', 404, null); return { job: j }; };
+
+const express = require('express');
+const app = express();
+app.use(express.json());
+app.use('/api/clips', require('../server/media-proxy/clips'));
+const server = http.createServer(app).listen(0);
+const base = () => `http://127.0.0.1:${server.address().port}`;
+const get = async (p, user) => { const r = await fetch(base() + p, { headers: user ? { 'x-test-user': String(user) } : {} }); return { status: r.status, body: await r.json().catch(() => null) }; };
+
+(async () => {
+ await new Promise((r) => server.once('listening', r));
+ const A = 'mjob_01M3AAAAAAAAAAAAAAAAAAAAAA', B = 'mjob_01M3BBBBBBBBBBBBBBBBBBBBBB';
+
+ // ── The route ──
+ let r = await get(`/api/clips/10/job?job=${A}`, 3);
+ assert.strictEqual(r.status, 200, JSON.stringify(r.body));
+ assert.deepStrictEqual(r.body.job, { id: A, status: 'running', attempts: 0, max_attempts: 4, error: null, run_after: null });
+ assert.deepStrictEqual(r.body.clip, { id: 10, status: 'processing', title: 'Mine', duration_seconds: 0 });
+ assert.strictEqual((await get(`/api/clips/10/job?job=${A}`, 4)).status, 404, 'someone else: as if missing');
+ assert.strictEqual((await get(`/api/clips/10/job?job=${A}`)).status, 401);
+ assert.strictEqual((await get(`/api/clips/11/job?job=${A}`, 3)).status, 404, 'the job of another clip');
+ assert.strictEqual((await get(`/api/clips/10/job?job=${B}`, 3)).status, 404, 'not a clip.cut');
+ assert.strictEqual((await get('/api/clips/10/job?job=mjob_01M3CCCCCCCCCCCCCCCCCCCCCC', 3)).status, 404, 'unknown job');
+ assert.strictEqual((await get('/api/clips/10/job?job=../x', 3)).status, 400);
+ JOBS[A] = { ...JOBS[A], status: 'failed', attempts: 1, error: 'No decodable footage in that window' };
+ r = await get(`/api/clips/10/job?job=${A}`, 3);
+ assert.strictEqual(r.body.job.error, 'No decodable footage in that window', 'a failed cut says why');
+
+ // ── The client helper, against a scripted server ──
+ const src = fs.readFileSync(path.join(__dirname, '../public/js/ov-clip-jobs.js'), 'utf8');
+ const store = new Map();
+ const script = {}; // jobId -> list of answers, one per poll
+ const polls = [];
+ const timers = [];
+ const makeWindow = () => {
+ const w = {
+ localStorage: { getItem: (k) => (store.has(k) ? store.get(k) : null), setItem: (k, v) => store.set(k, String(v)), removeItem: (k) => store.delete(k) },
+ fetch: async (url) => {
+ const m = /\/api\/clips\/(\d+)\/job\?job=([\w]+)/.exec(url);
+ polls.push(m[2]);
+ const next = (script[m[2]] || []).shift() || { status: 200, body: { job: { status: 'running', attempts: 0 } } };
+ return { status: next.status, ok: next.status < 300, json: async () => next.body };
+ },
+ setTimeout: (fn) => { timers.push(fn); return timers.length; },
+ Date,
+ };
+ w.window = w;
+ return w;
+ };
+ const drain = async () => { for (let i = 0; i < 50 && timers.length; i++) { const fn = timers.shift(); fn(); await new Promise((r2) => setImmediate(r2)); await new Promise((r2) => setImmediate(r2)); } };
+ const plain = (v) => JSON.parse(JSON.stringify(v)); // values from the page's realm
+ const settle = () => new Promise((r2) => setImmediate(() => setImmediate(r2)));
+
+ // A cut that retries once, then is ready.
+ let w = makeWindow();
+ vm.runInNewContext(src, w);
+ const seen = [];
+ script.mjob_1 = [
+ { status: 200, body: { job: { status: 'running', attempts: 0 } } },
+ { status: 200, body: { job: { status: 'queued', attempts: 1, max_attempts: 4 } } },
+ { status: 200, body: { job: { status: 'queued', attempts: 1, max_attempts: 4 } } },
+ { status: 200, body: { job: { status: 'succeeded', attempts: 2 }, clip: { id: 10, status: 'ready' } } },
+ ];
+ w.OVClipJobs.follow(10, 'mjob_1', { onReady: (c) => seen.push(['ready', c.id]), onFailed: (m) => seen.push(['failed', m]), onRetry: (j) => seen.push(['retry', j.attempts]) });
+ assert.deepStrictEqual(plain(w.OVClipJobs.pending().map((e) => e.jobId)), ['mjob_1'], 'kept while pending');
+ await settle(); await drain();
+ assert.deepStrictEqual(seen, [['retry', 1], ['ready', 10]], 'one retry notice, then ready');
+ assert.deepStrictEqual(plain(w.OVClipJobs.pending()), [], 'forgotten once done');
+
+ // A reload: the new page resumes what the old one left pending, and a failure is reported.
+ w = makeWindow();
+ vm.runInNewContext(src, w);
+ script.mjob_2 = [{ status: 200, body: { job: { status: 'running', attempts: 0 } } }];
+ w.OVClipJobs.follow(12, 'mjob_2', {});
+ await settle();
+ timers.length = 0; // the page goes away mid-cut
+ assert.deepStrictEqual(JSON.parse(store.get('ov_clip_jobs')).map((e) => e.jobId), ['mjob_2']);
+ const w2 = makeWindow();
+ vm.runInNewContext(src, w2);
+ script.mjob_2 = [{ status: 200, body: { job: { status: 'failed', attempts: 4, error: 'gave up' } } }];
+ const seen2 = [];
+ w2.OVClipJobs.resume({ onFailed: (m) => seen2.push(m) });
+ await settle(); await drain();
+ assert.deepStrictEqual(seen2, ['gave up'], 'the resumed cut reports its failure');
+ assert.deepStrictEqual(plain(w2.OVClipJobs.pending()), []);
+
+ // A job the server no longer knows (or no longer shows this person) is dropped silently.
+ const w3 = makeWindow();
+ vm.runInNewContext(src, w3);
+ script.mjob_3 = [{ status: 404, body: { error: 'Job not found' } }];
+ const seen3 = [];
+ w3.OVClipJobs.follow(13, 'mjob_3', { onReady: () => seen3.push('ready'), onFailed: () => seen3.push('failed') });
+ await settle(); await drain();
+ assert.deepStrictEqual([seen3, plain(w3.OVClipJobs.pending())], [[], []]);
+
+ // Following the same job twice polls it once.
+ const w4 = makeWindow();
+ vm.runInNewContext(src, w4);
+ polls.length = 0;
+ script.mjob_4 = [{ status: 200, body: { job: { status: 'succeeded' }, clip: { id: 14 } } }];
+ w4.OVClipJobs.follow(14, 'mjob_4', {});
+ w4.OVClipJobs.follow(14, 'mjob_4', {});
+ await settle(); await drain();
+ assert.deepStrictEqual(polls, ['mjob_4']);
+
+ server.close();
+ try { fs.unlinkSync(tmp); } catch { /* */ }
+ quiet('clip cut job: all checks passed');
+})().catch((err) => { quiet(err); process.exit(1); });