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
This commit is contained in:
+14
-4
@@ -96,6 +96,16 @@ try {
|
|||||||
} catch {
|
} catch {
|
||||||
db.exec("ALTER TABLE boards ADD COLUMN object_count INTEGER NOT NULL DEFAULT 0");
|
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
|
// User helpers
|
||||||
@@ -320,11 +330,11 @@ function saveBoardCanvas(boardId, canvasState, thumbnail) {
|
|||||||
// ---------------------
|
// ---------------------
|
||||||
// Images
|
// 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(`
|
db.prepare(`
|
||||||
INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by)
|
INSERT INTO images (id, board_id, filename, mime_type, file_size, width, height, minio_path, public_url, uploaded_by, asset_key, media_type)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(id, boardId, filename, mimeType, fileSize, width || null, height || null, minioPath, publicUrl || null, uploadedBy);
|
`).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);
|
return db.prepare('SELECT * FROM images WHERE id = ?').get(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,9 @@ const MIME_TO_EXT = {
|
|||||||
'image/gif': '.gif',
|
'image/gif': '.gif',
|
||||||
'image/webp': '.webp',
|
'image/webp': '.webp',
|
||||||
'image/svg+xml': '.svg',
|
'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;
|
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.
|
* Delete a single object by its minio path.
|
||||||
*/
|
*/
|
||||||
@@ -106,8 +120,10 @@ module.exports = {
|
|||||||
minioClient,
|
minioClient,
|
||||||
initBucket,
|
initBucket,
|
||||||
uploadImage,
|
uploadImage,
|
||||||
|
putBuffer,
|
||||||
deleteImage,
|
deleteImage,
|
||||||
deleteBoardImages,
|
deleteBoardImages,
|
||||||
getImageUrl,
|
getImageUrl,
|
||||||
MINIO_BUCKET,
|
MINIO_BUCKET,
|
||||||
|
MIME_TO_EXT,
|
||||||
};
|
};
|
||||||
|
|||||||
+87
-11
@@ -7,11 +7,12 @@ const http = require('http');
|
|||||||
const { URL } = require('url');
|
const { URL } = require('url');
|
||||||
const { authMiddleware } = require('../auth');
|
const { authMiddleware } = require('../auth');
|
||||||
const { getBoard, getCollectionMember, createImage } = require('../db');
|
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 router = Router();
|
||||||
|
|
||||||
const ALLOWED_MIME_TYPES = [
|
const IMAGE_MIME_TYPES = [
|
||||||
'image/png',
|
'image/png',
|
||||||
'image/jpeg',
|
'image/jpeg',
|
||||||
'image/gif',
|
'image/gif',
|
||||||
@@ -19,6 +20,14 @@ const ALLOWED_MIME_TYPES = [
|
|||||||
'image/svg+xml',
|
'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
|
const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50MB
|
||||||
|
|
||||||
// Multer config: memory storage, 50MB limit, image types only
|
// 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
|
* 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) => {
|
router.post('/boards/:boardId/images', upload.single('image'), async (req, res) => {
|
||||||
try {
|
try {
|
||||||
@@ -87,12 +147,16 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
|
|||||||
|
|
||||||
const imageId = uuidv4();
|
const imageId = uuidv4();
|
||||||
const { buffer, originalname, mimetype, size } = req.file;
|
const { buffer, originalname, mimetype, size } = req.file;
|
||||||
|
const mediaType = classifyMedia(mimetype);
|
||||||
|
|
||||||
// Get dimensions
|
let assetKey, minioPath, width, height;
|
||||||
const { width, height } = await getImageDimensions(buffer, mimetype);
|
|
||||||
|
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);
|
const publicUrl = getImageUrl(minioPath);
|
||||||
|
|
||||||
// Save record
|
// Save record
|
||||||
@@ -107,6 +171,8 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
|
|||||||
minioPath,
|
minioPath,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
uploadedBy: req.user.id,
|
uploadedBy: req.user.id,
|
||||||
|
assetKey,
|
||||||
|
mediaType,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
@@ -117,6 +183,8 @@ router.post('/boards/:boardId/images', upload.single('image'), async (req, res)
|
|||||||
height: image.height,
|
height: image.height,
|
||||||
file_size: image.file_size,
|
file_size: image.file_size,
|
||||||
mime_type: image.mime_type,
|
mime_type: image.mime_type,
|
||||||
|
asset_key: image.asset_key,
|
||||||
|
media_type: image.media_type,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
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 { buffer, mimeType, filename } = await downloadImage(url);
|
||||||
|
|
||||||
const imageId = uuidv4();
|
const imageId = uuidv4();
|
||||||
|
const mediaType = classifyMedia(mimeType);
|
||||||
|
|
||||||
// Get dimensions
|
let assetKey, minioPath, width, height;
|
||||||
const { width, height } = await getImageDimensions(buffer, mimeType);
|
|
||||||
|
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);
|
const publicUrl = getImageUrl(minioPath);
|
||||||
|
|
||||||
// Save record
|
// Save record
|
||||||
@@ -211,6 +283,8 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
|
|||||||
minioPath,
|
minioPath,
|
||||||
publicUrl,
|
publicUrl,
|
||||||
uploadedBy: req.user.id,
|
uploadedBy: req.user.id,
|
||||||
|
assetKey,
|
||||||
|
mediaType,
|
||||||
});
|
});
|
||||||
|
|
||||||
return res.status(201).json({
|
return res.status(201).json({
|
||||||
@@ -221,6 +295,8 @@ router.post('/boards/:boardId/images/from-url', async (req, res) => {
|
|||||||
height: image.height,
|
height: image.height,
|
||||||
file_size: image.file_size,
|
file_size: image.file_size,
|
||||||
mime_type: image.mime_type,
|
mime_type: image.mime_type,
|
||||||
|
asset_key: image.asset_key,
|
||||||
|
media_type: image.media_type,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('[upload] from-url error:', err);
|
console.error('[upload] from-url error:', err);
|
||||||
|
|||||||
Reference in New Issue
Block a user