From e1b71aa774ac2eed7b7f1a47cc53b1dd7141a819 Mon Sep 17 00:00:00 2001 From: Hiren Kangad Date: Mon, 9 Mar 2026 23:00:09 +0530 Subject: [PATCH] feat(refboard): add LOD tier generation and video support to upload route - Upload route now generates 3-tier LOD (thumb/medium/full) for images via lod-generator service, storing each tier at boards/{boardId}/{imageId}/ - Video uploads (mp4, webm, quicktime) supported as single-file storage - New DB columns: asset_key (prefix path) and media_type ('image'/'video') - Backward compatible: minio_path and public_url still populated (point to full tier) - SVG and GIF skip LOD processing (single file stored) - Added putBuffer() and MIME_TO_EXT export to minio.js --- backend/db.js | 18 ++++++-- backend/minio.js | 16 +++++++ backend/routes/upload.js | 98 +++++++++++++++++++++++++++++++++++----- 3 files changed, 117 insertions(+), 15 deletions(-) diff --git a/backend/db.js b/backend/db.js index d62279d..1b02235 100644 --- a/backend/db.js +++ b/backend/db.js @@ -96,6 +96,16 @@ try { } catch { db.exec("ALTER TABLE boards ADD COLUMN object_count INTEGER NOT NULL DEFAULT 0"); } +try { + db.prepare("SELECT asset_key FROM images LIMIT 0").get(); +} catch { + db.exec("ALTER TABLE images ADD COLUMN asset_key TEXT"); +} +try { + db.prepare("SELECT media_type FROM images LIMIT 0").get(); +} catch { + db.exec("ALTER TABLE images ADD COLUMN media_type TEXT DEFAULT 'image'"); +} // --------------------- // User helpers @@ -320,11 +330,11 @@ function saveBoardCanvas(boardId, canvasState, thumbnail) { // --------------------- // Images // --------------------- -function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy }) { +function createImage({ id, boardId, filename, mimeType, fileSize, width, height, minioPath, publicUrl, uploadedBy, assetKey, mediaType }) { db.prepare(` - INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy); + INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by, asset_key, media_type) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy, assetKey || null, mediaType || 'image'); return db.prepare('SELECT * FROM images WHERE id = ?').get(id); } diff --git a/backend/minio.js b/backend/minio.js index 8473fc8..f804ee7 100644 --- a/backend/minio.js +++ b/backend/minio.js @@ -23,6 +23,9 @@ const MIME_TO_EXT = { 'image/gif': '.gif', 'image/webp': '.webp', 'image/svg+xml': '.svg', + 'video/mp4': '.mp4', + 'video/webm': '.webm', + 'video/quicktime': '.mov', }; /** @@ -65,6 +68,17 @@ async function uploadImage(boardId, imageId, buffer, mimeType) { return objectName; } +/** + * Upload a raw buffer to MinIO at the given object name. + * Returns the object name. + */ +async function putBuffer(objectName, buffer, contentType) { + await minioClient.putObject(MINIO_BUCKET, objectName, buffer, buffer.length, { + 'Content-Type': contentType, + }); + return objectName; +} + /** * Delete a single object by its minio path. */ @@ -106,8 +120,10 @@ module.exports = { minioClient, initBucket, uploadImage, + putBuffer, deleteImage, deleteBoardImages, getImageUrl, MINIO_BUCKET, + MIME_TO_EXT, }; diff --git a/backend/routes/upload.js b/backend/routes/upload.js index b7f89b5..c72a500 100644 --- a/backend/routes/upload.js +++ b/backend/routes/upload.js @@ -7,11 +7,12 @@ const http = require('http'); const { URL } = require('url'); const { authMiddleware } = require('../auth'); const { getBoard, getCollectionMember, createImage } = require('../db'); -const { uploadImage, getImageUrl } = require('../minio'); +const { putBuffer, getImageUrl, MIME_TO_EXT } = require('../minio'); +const { generateLOD } = require('../services/lod-generator'); const router = Router(); -const ALLOWED_MIME_TYPES = [ +const IMAGE_MIME_TYPES = [ 'image/png', 'image/jpeg', 'image/gif', @@ -19,6 +20,14 @@ const ALLOWED_MIME_TYPES = [ 'image/svg+xml', ]; +const VIDEO_MIME_TYPES = [ + 'video/mp4', + 'video/webm', + 'video/quicktime', +]; + +const ALLOWED_MIME_TYPES = [...IMAGE_MIME_TYPES, ...VIDEO_MIME_TYPES]; + const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB // Multer config: memory storage, 50MB limit, image types only @@ -72,9 +81,60 @@ async function getImageDimensions(buffer, mimeType) { } } +/** + * Detect whether a MIME type is image or video. + */ +function classifyMedia(mimeType) { + if (VIDEO_MIME_TYPES.includes(mimeType)) return 'video'; + return 'image'; +} + +/** + * Upload LOD tiers for an image to MinIO. + * Stores: {assetKey}/thumb.webp, {assetKey}/medium.webp, {assetKey}/full{ext} + * Returns the minioPath for the full tier (backward compat) and dimensions. + */ +async function uploadImageWithLOD(boardId, imageId, buffer, mimetype) { + const assetKey = `boards/${boardId}/${imageId}`; + const originalExt = MIME_TO_EXT[mimetype] || '.bin'; + + // SVG and GIF skip LOD — store single file + if (mimetype === 'image/svg+xml' || mimetype === 'image/gif') { + const fullPath = `${assetKey}/full${originalExt}`; + await putBuffer(fullPath, buffer, mimetype); + const dims = await getImageDimensions(buffer, mimetype); + return { assetKey, minioPath: fullPath, ...dims }; + } + + const lod = await generateLOD(buffer, originalExt); + + // Upload all 3 tiers in parallel + await Promise.all([ + putBuffer(`${assetKey}/thumb${lod.thumb.ext}`, lod.thumb.buffer, lod.thumb.ext === '.webp' ? 'image/webp' : mimetype), + putBuffer(`${assetKey}/medium${lod.medium.ext}`, lod.medium.buffer, lod.medium.ext === '.webp' ? 'image/webp' : mimetype), + putBuffer(`${assetKey}/full${lod.full.ext}`, lod.full.buffer, mimetype), + ]); + + // minioPath points to full tier for backward compat + const minioPath = `${assetKey}/full${lod.full.ext}`; + return { assetKey, minioPath, width: lod.full.width, height: lod.full.height }; +} + +/** + * Upload a video file to MinIO (single file, no LOD). + * Returns { assetKey, minioPath, width: null, height: null }. + */ +async function uploadVideo(boardId, imageId, buffer, mimetype) { + const assetKey = `boards/${boardId}/${imageId}`; + const ext = MIME_TO_EXT[mimetype] || '.bin'; + const fullPath = `${assetKey}/full${ext}`; + await putBuffer(fullPath, buffer, mimetype); + return { assetKey, minioPath: fullPath, width: null, height: null }; +} + /** * POST /api/upload/boards/:boardId/images - * Upload an image file to a board. + * Upload an image or video file to a board. */ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) => { try { @@ -87,12 +147,16 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) const imageId = uuidv4(); const { buffer, originalname, mimetype, size } = req.file; + const mediaType = classifyMedia(mimetype); - // Get dimensions - const { width, height } = await getImageDimensions(buffer, mimetype); + let assetKey, minioPath, width, height; + + if (mediaType === 'video') { + ({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimetype)); + } else { + ({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimetype)); + } - // Upload to MinIO - const minioPath = await uploadImage(board.id, imageId, buffer, mimetype); const publicUrl = getImageUrl(minioPath); // Save record @@ -107,6 +171,8 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) minioPath, publicUrl, uploadedBy: req.user.id, + assetKey, + mediaType, }); return res.status(201).json({ @@ -117,6 +183,8 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res) height: image.height, file_size: image.file_size, mime_type: image.mime_type, + asset_key: image.asset_key, + media_type: image.media_type, }); } catch (err) { if (err.code === 'LIMIT_FILE_SIZE') { @@ -191,12 +259,16 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => { const { buffer, mimeType, filename } = await downloadImage(url); const imageId = uuidv4(); + const mediaType = classifyMedia(mimeType); - // Get dimensions - const { width, height } = await getImageDimensions(buffer, mimeType); + let assetKey, minioPath, width, height; + + if (mediaType === 'video') { + ({ assetKey, minioPath, width, height } = await uploadVideo(board.id, imageId, buffer, mimeType)); + } else { + ({ assetKey, minioPath, width, height } = await uploadImageWithLOD(board.id, imageId, buffer, mimeType)); + } - // Upload to MinIO - const minioPath = await uploadImage(board.id, imageId, buffer, mimeType); const publicUrl = getImageUrl(minioPath); // Save record @@ -211,6 +283,8 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => { minioPath, publicUrl, uploadedBy: req.user.id, + assetKey, + mediaType, }); return res.status(201).json({ @@ -221,6 +295,8 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => { height: image.height, file_size: image.file_size, mime_type: image.mime_type, + asset_key: image.asset_key, + media_type: image.media_type, }); } catch (err) { console.error('[upload] from-url error:', err);