feat: media processing pipeline, spatial indexing, canvas-based video rendering

- Background media worker: polls media_jobs table, runs ffprobe+ffmpeg
  with concurrency limit, generates video posters, emits socket events
- Non-blocking video upload: stores file + enqueues job, returns immediately
- Poster hydration on board load: GET /boards/:id injects poster/dimensions
  from DB into canvas_state video objects
- SpatialGrid: fixed-cell (512px) spatial hash for O(nearby) culling instead
  of O(all) item scanning, eliminates setTimeout violations
- Canvas-based video rendering: draws video frames to offscreen canvas then
  uploads to GPU, completely eliminates GL_INVALID_OPERATION errors from
  PixiJS VideoSource auto-update mechanism
- Server poster upgrade path: culling ticker and applyProcessedMedia() both
  upgrade client-captured posters to server posters when available
- Pause restores server poster (paused video behaves like an image)
- Selection drag-end persistence: onObjectDragEnd broadcasts + saves + undo
- Live media:job:update socket handler patches scene data + VideoSprite
  dimensions without broadcast fanout
This commit is contained in:
Hiren Kangad
2026-03-10 14:30:01 +05:30
parent fdcee3536f
commit de418b2ba5
12 changed files with 761 additions and 110 deletions
+24 -1
View File
@@ -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,
+28 -33
View File
@@ -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 <video> element for thumbnails.
* Videos: stored immediately, poster/metadata extracted async by media-worker.
* Images: dimensions extracted inline (fast, no ffmpeg).
*/
async function uploadMedia(boardId, imageId, buffer, mimetype) {
const ext = MIME_TO_EXT[mimetype] || '.bin';
@@ -100,33 +99,15 @@ async function uploadMedia(boardId, imageId, buffer, mimetype) {
await putBuffer(minioPath, buffer, mimetype);
const isVideo = VIDEO_MIME_TYPES.includes(mimetype);
let width = null, height = null, duration = null, posterAssetKey = null;
let width = null, height = null;
if (isVideo) {
// Extract metadata and poster frame server-side
const [meta, posterBuf] = await Promise.all([
probeVideo(buffer),
extractPoster(buffer),
]);
if (meta) {
width = meta.width;
height = meta.height;
duration = meta.duration;
}
if (posterBuf) {
const posterPath = `boards/${boardId}/${imageId}_poster.jpg`;
await putBuffer(posterPath, posterBuf, 'image/jpeg');
posterAssetKey = posterPath;
}
} else {
if (!isVideo) {
const dims = await getImageDimensions(buffer, mimetype);
width = dims.width;
height = dims.height;
}
return { assetKey: minioPath, minioPath, width, height, duration, posterAssetKey };
return { assetKey: minioPath, minioPath, width, height, isVideo };
}
/**
@@ -146,10 +127,10 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
const { buffer, originalname, mimetype, size } = req.file;
const mediaType = classifyMedia(mimetype);
const { assetKey, minioPath, width, height, duration, posterAssetKey } = await uploadMedia(board.id, imageId, buffer, mimetype);
const { assetKey, minioPath, width, height, isVideo } = await uploadMedia(board.id, imageId, buffer, mimetype);
const publicUrl = getImageUrl(minioPath);
// Save record
// Save image record first (media_jobs FK references images)
const image = createImage({
id: imageId,
boardId: board.id,
@@ -165,6 +146,13 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
mediaType,
});
// Enqueue background processing after image record exists
let jobId = null;
if (isVideo) {
jobId = uuidv4();
createMediaJob({ id: jobId, imageId, boardId: board.id, type: 'poster' });
}
return res.status(201).json({
id: image.id,
url: publicUrl,
@@ -175,8 +163,8 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
mime_type: image.mime_type,
asset_key: image.asset_key,
media_type: image.media_type,
duration: duration || undefined,
poster_asset_key: posterAssetKey || undefined,
processing: jobId ? 'queued' : undefined,
job_id: jobId || undefined,
});
} catch (err) {
if (err.code === 'LIMIT_FILE_SIZE') {
@@ -253,10 +241,10 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
const imageId = uuidv4();
const mediaType = classifyMedia(mimeType);
const { assetKey, minioPath, width, height, duration, posterAssetKey } = await uploadMedia(board.id, imageId, buffer, mimeType);
const { assetKey, minioPath, width, height, isVideo } = await uploadMedia(board.id, imageId, buffer, mimeType);
const publicUrl = getImageUrl(minioPath);
// Save record
// Save image record first (media_jobs FK references images)
const image = createImage({
id: imageId,
boardId: board.id,
@@ -272,6 +260,13 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
mediaType,
});
// Enqueue background processing after image record exists
let jobId = null;
if (isVideo) {
jobId = uuidv4();
createMediaJob({ id: jobId, imageId, boardId: board.id, type: 'poster' });
}
return res.status(201).json({
id: image.id,
url: publicUrl,
@@ -282,8 +277,8 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
mime_type: image.mime_type,
asset_key: image.asset_key,
media_type: image.media_type,
duration: duration || undefined,
poster_asset_key: posterAssetKey || undefined,
processing: jobId ? 'queued' : undefined,
job_id: jobId || undefined,
});
} catch (err) {
console.error('[upload] from-url error:', err);