diff --git a/backend/db.js b/backend/db.js index 0efa0d2..41b5cb2 100644 --- a/backend/db.js +++ b/backend/db.js @@ -95,6 +95,24 @@ db.exec(` ); CREATE INDEX IF NOT EXISTS idx_board_channel_links_board ON board_channel_links(board_id); + + CREATE TABLE IF NOT EXISTS media_jobs ( + id TEXT PRIMARY KEY, + image_id TEXT NOT NULL REFERENCES images(id) ON DELETE CASCADE, + board_id TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'poster', + status TEXT NOT NULL DEFAULT 'queued', + progress REAL DEFAULT 0, + error TEXT, + attempts INTEGER NOT NULL DEFAULT 0, + result_json TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + started_at TEXT, + finished_at TEXT + ); + + CREATE INDEX IF NOT EXISTS idx_media_jobs_status ON media_jobs(status); + CREATE INDEX IF NOT EXISTS idx_media_jobs_image ON media_jobs(image_id); `); // Migrations — add columns to existing tables @@ -123,6 +141,22 @@ try { } catch { db.exec("ALTER TABLE images ADD COLUMN mm_file_id TEXT"); } +try { + db.prepare("SELECT poster_asset_key FROM images LIMIT 0").get(); +} catch { + db.exec("ALTER TABLE images ADD COLUMN poster_asset_key TEXT"); +} +try { + db.prepare("SELECT duration FROM images LIMIT 0").get(); +} catch { + db.exec("ALTER TABLE images ADD COLUMN duration REAL"); +} +try { + db.prepare("SELECT native_width FROM images LIMIT 0").get(); +} catch { + db.exec("ALTER TABLE images ADD COLUMN native_width INTEGER"); + db.exec("ALTER TABLE images ADD COLUMN native_height INTEGER"); +} // --------------------- // User helpers @@ -406,6 +440,46 @@ function getImageByMmFileId(boardId, mmFileId) { return db.prepare('SELECT * FROM images WHERE board_id = ? AND mm_file_id = ?').get(boardId, mmFileId); } +// --------------------- +// Media Jobs +// --------------------- +function createMediaJob({ id, imageId, boardId, type }) { + db.prepare(` + INSERT INTO media_jobs (id, image_id, board_id, type, status) + VALUES (?, ?, ?, ?, 'queued') + `).run(id, imageId, boardId, type || 'poster'); + return db.prepare('SELECT * FROM media_jobs WHERE id = ?').get(id); +} + +function updateMediaJob(id, updates) { + const sets = []; + const values = []; + for (const [key, val] of Object.entries(updates)) { + const col = key.replace(/([A-Z])/g, '_$1').toLowerCase(); // camelCase → snake_case + sets.push(`${col} = ?`); + values.push(val); + } + if (sets.length === 0) return; + values.push(id); + db.prepare(`UPDATE media_jobs SET ${sets.join(', ')} WHERE id = ?`).run(...values); +} + +function getMediaJob(id) { + return db.prepare('SELECT * FROM media_jobs WHERE id = ?').get(id); +} + +function getPendingMediaJobs(limit = 10) { + return db.prepare('SELECT * FROM media_jobs WHERE status IN (?, ?) ORDER BY created_at ASC LIMIT ?') + .all('queued', 'retry', limit); +} + +function updateImageMedia(imageId, { posterAssetKey, duration, nativeWidth, nativeHeight }) { + db.prepare(` + UPDATE images SET poster_asset_key = ?, duration = ?, native_width = ?, native_height = ? + WHERE id = ? + `).run(posterAssetKey || null, duration || null, nativeWidth || null, nativeHeight || null, imageId); +} + module.exports = { db, // Users @@ -423,4 +497,6 @@ module.exports = { // Board–Channel Links createBoardChannelLink, getBoardChannelLinks, getBoardChannelLink, deleteBoardChannelLink, getAllBoardChannelLinks, getImageByMmFileId, + // Media Jobs + createMediaJob, updateMediaJob, getMediaJob, getPendingMediaJobs, updateImageMedia, }; diff --git a/backend/routes/boards.js b/backend/routes/boards.js index 60cc43a..f2b5ce5 100644 --- a/backend/routes/boards.js +++ b/backend/routes/boards.js @@ -120,10 +120,33 @@ router.get('/:boardId', (req, res) => { const images = getBoardImages(board.id); + // Hydrate video objects with poster/dimensions from DB + // (worker may have finished after canvas was last saved) + let canvasState = board.canvas_state ? JSON.parse(board.canvas_state) : {}; + if (canvasState.objects && Array.isArray(canvasState.objects)) { + const videoImages = new Map(); + for (const img of images) { + if (img.media_type === 'video' && img.poster_asset_key) { + videoImages.set(img.asset_key, img); + } + } + if (videoImages.size > 0) { + for (const obj of canvasState.objects) { + if (obj.type !== 'video') continue; + const dbImg = videoImages.get(obj.asset); + if (!dbImg) continue; + if (!obj.poster && dbImg.poster_asset_key) obj.poster = dbImg.poster_asset_key; + if (!obj.nativeW && dbImg.native_width) { obj.nativeW = dbImg.native_width; obj.w = dbImg.native_width; } + if (!obj.nativeH && dbImg.native_height) { obj.nativeH = dbImg.native_height; obj.h = dbImg.native_height; } + if (!obj.duration && dbImg.duration) obj.duration = dbImg.duration; + } + } + } + return res.json({ board: { ...board, - canvas_state: board.canvas_state ? JSON.parse(board.canvas_state) : {}, + canvas_state: canvasState, }, collection: { id: collection.id, diff --git a/backend/routes/upload.js b/backend/routes/upload.js index 3c9ed41..01c218b 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -6,9 +6,8 @@ const https = require('https'); const http = require('http'); const { URL } = require('url'); const { authMiddleware } = require('../auth'); -const { getBoard, getCollectionMember, createImage } = require('../db'); +const { getBoard, getCollectionMember, createImage, createMediaJob } = require('../db'); const { putBuffer, getImageUrl, MIME_TO_EXT, MAX_FILE_SIZE } = require('../minio'); -const { probeVideo, extractPoster } = require('../video-utils'); const router = Router(); @@ -91,8 +90,8 @@ function classifyMedia(mimeType) { /** * Upload a media file (image or video) to MinIO. - * For videos: extracts poster frame + metadata via ffmpeg at upload time - * so the board never needs a