diff --git a/.gitignore b/.gitignore index 6b4bd5f..bd3ddce 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ node_modules/ *.log .DS_Store .vscode +ARCHITECTURE.md +LICENSE +README.md \ No newline at end of file diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index fdb9e85..ab731da 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -15,6 +15,7 @@ const { protect, optionalAuth } = require('../middlewares/authMiddleware'); const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware'); const { resizeTo8K } = require('../utils/imageHelper'); const { getGPSCoordinates, injectGPSCoordinates } = require('../utils/exifHelper'); +const { imageQueue } = require('./imageQueue'); const router = express.Router(); @@ -37,11 +38,16 @@ const storage = multer.diskStorage({ const upload = multer({ storage: storage, fileFilter: (req, file, cb) => { - // Only accept images - if (file.mimetype.startsWith('image/')) { + // Chỉ chấp nhận các định dạng ảnh phổ biến đã được xử lý (Stitched) + const filetypes = /jpeg|jpg|png/; + const extname = filetypes.test(path.extname(file.originalname).toLowerCase()); + const mimetype = filetypes.test(file.mimetype) || + file.mimetype === 'application/octet-stream'; // Đôi khi trình duyệt gửi JPG dưới dạng octet-stream + + if (mimetype && extname) { cb(null, true); } else { - cb(new Error('Only image files are allowed!'), false); + cb(new Error('Chỉ chấp nhận các định dạng ảnh JPEG, PNG, DNG hoặc INSP!'), false); } } }); @@ -93,21 +99,14 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => { const tempFilePath = req.file.path; const processedFileName = `processed_${req.file.filename}.jpg`; const processedFilePath = path.join(uploadDir, processedFileName); - - // Lấy tọa độ GPS gốc từ ảnh vừa upload trước khi nén/xử lý + + // Lấy tọa độ GPS gốc từ metadata const originalGPS = await getGPSCoordinates(tempFilePath); - - // Thực hiện xử lý ảnh tuần tự để đảm bảo file được tạo thành công trước khi lưu DB - // 1. Resize to 8K (Sẽ convert từ DNG sang JPG nếu sharp hỗ trợ libraw, nếu không sẽ báo lỗi) - await resizeTo8K(tempFilePath, processedFilePath); - // 2. Inject GPS vào file JPG vừa tạo - await injectGPSCoordinates(processedFilePath, latitude, longitude); - // 3. Cleanup temp file (file gốc dng) - if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath); + const ext = path.extname(req.file.originalname).toLowerCase(); // 5. Save Asset to DB const asset = new Asset({ - filePath: processedFilePath, + filePath: tempFilePath, // Tạm thời dùng file gốc cho đến khi worker xử lý xong uploadedBy: req.user._id, coordinates: originalGPS ? { lat: originalGPS.lat, lng: originalGPS.lng } : { lat: latitude, lng: longitude } }); @@ -133,7 +132,7 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => { const scene = new Scene({ name: title, assetId: asset._id, - scene_url: processedFilePath, // Lưu đường dẫn ảnh trực tiếp + scene_url: tempFilePath, // Tạm thời gps: { lat: latitude, lng: longitude @@ -141,12 +140,24 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => { createdBy: req.user._id, privacy: privacy || 'private', shareToken, - sharedWith: parsedSharedWith + sharedWith: parsedSharedWith, + status: 'processing' // Đánh dấu đang xử lý }); await scene.save(); + // Đẩy tác vụ xử lý ảnh (Stitch + Resize) vào hàng đợi BullMQ + // Loại bỏ needsStitch và rotation vì người dùng đã stitch ảnh thủ công + await imageQueue.add('process-panorama', { + tempFilePath, + processedFilePath, + latitude, + longitude, + assetId: asset._id, + sceneId: scene._id + }); + res.status(201).json({ - message: 'Scene created successfully', + message: 'Scene đã được tạo! Ảnh đang được xử lý 8K ngầm...', scene }); diff --git a/backend/routes/imageQueue.js b/backend/routes/imageQueue.js new file mode 100644 index 0000000..614d8f9 --- /dev/null +++ b/backend/routes/imageQueue.js @@ -0,0 +1,29 @@ +const { Queue } = require('bullmq'); +const IORedis = require('ioredis'); + +// Cấu hình kết nối Redis (Mặc định localhost:6379) +const connection = new IORedis({ + maxRetriesPerRequest: null +}); + +/** + * Khởi tạo hàng đợi xử lý ảnh + */ +const imageQueue = new Queue('image-processing', { + connection, + defaultJobOptions: { + attempts: 3, // Thử lại tối đa 3 lần nếu lỗi + backoff: { type: 'exponential', delay: 5000 }, + // Tự động dọn dẹp Job để tối ưu bộ nhớ Redis + removeOnComplete: { + age: 3600, // Xóa các job hoàn thành sau 1 giờ (3600 giây) + count: 100 // Hoặc giữ tối đa 100 job hoàn thành gần nhất + }, + removeOnFail: { + age: 24 * 3600, // Giữ lại job lỗi trong 24 giờ để admin kiểm tra + count: 500 // Giữ tối đa 500 job lỗi + } + } +}); + +module.exports = { imageQueue, connection }; \ No newline at end of file diff --git a/backend/routes/imageWorker.js b/backend/routes/imageWorker.js new file mode 100644 index 0000000..170e069 --- /dev/null +++ b/backend/routes/imageWorker.js @@ -0,0 +1,54 @@ +const { Worker } = require('bullmq'); +const fs = require('fs'); +const path = require('path'); +const { connection } = require('./imageQueue'); +const { resizeTo8K } = require('../utils/imageHelper'); +const { injectGPSCoordinates } = require('../utils/exifHelper'); +const Asset = require('../models/Asset'); +const Scene = require('../models/Scene'); + +/** + * Khởi tạo Worker để xử lý hàng đợi 'image-processing' + */ +const imageWorker = new Worker('image-processing', async (job) => { + const { tempFilePath, processedFilePath, latitude, longitude, assetId, sceneId } = job.data; + + console.log(`[Worker] Bắt đầu xử lý Job ${job.id} cho Scene: ${sceneId}`); + + try { + // Quy trình tối giản: Chỉ Resize ảnh sang 8K (Sharp) + // Vì người dùng đã stitch ảnh từ Insta360 Studio, file upload đã là Equirectangular JPEG. + await resizeTo8K(tempFilePath, processedFilePath); + + // 3. Chèn GPS Metadata + await injectGPSCoordinates(processedFilePath, latitude, longitude); + + // 4. Cập nhật đường dẫn file thực tế vào Database + // Lúc này ảnh đã sẵn sàng để phục vụ (8K) + await Asset.findByIdAndUpdate(assetId, { filePath: processedFilePath }); + await Scene.findByIdAndUpdate(sceneId, { + scene_url: processedFilePath, + status: 'completed' // Xử lý xong + }); + + // 5. Dọn dẹp file tạm + if (fs.existsSync(tempFilePath)) fs.unlinkSync(tempFilePath); + + console.log(`[Worker] Hoàn tất xử lý Job ${job.id}`); + return { success: true }; + } catch (error) { + console.error(`[Worker Error] Job ${job.id} thất bại:`, error.message); + await Scene.findByIdAndUpdate(sceneId, { status: 'failed' }); // Đánh dấu lỗi + throw error; // Đẩy lỗi để BullMQ thực hiện retry + } +}, { connection }); + +imageWorker.on('completed', (job) => { + console.log(`Job ${job.id} đã hoàn thành thành công.`); +}); + +imageWorker.on('failed', (job, err) => { + console.error(`Job ${job.id} thất bại sau nhiều lần thử: ${err.message}`); +}); + +module.exports = imageWorker; \ No newline at end of file diff --git a/backend/server.js b/backend/server.js index a9e84be..473f14e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -6,6 +6,9 @@ const connectDB = require('./config/db'); const authRoutes = require('./routes/authRoutes'); const apiRoutes = require('./routes/apiRoutes'); +// Khởi động Image Processing Worker +require('./routes/imageWorker'); + // Connect to Database connectDB(); diff --git a/backend/uploads/temp/1780971557798_da18c0af.dng b/backend/uploads/temp/1780971557798_da18c0af.dng deleted file mode 100644 index 50f3be0..0000000 Binary files a/backend/uploads/temp/1780971557798_da18c0af.dng and /dev/null differ diff --git a/backend/utils/exifHelper.js b/backend/utils/exifHelper.js index 9acbabc..b544d79 100644 --- a/backend/utils/exifHelper.js +++ b/backend/utils/exifHelper.js @@ -1,24 +1,24 @@ const fs = require('fs'); -const exifr = require('exifr'); +const { exiftool } = require('exiftool-vendored'); const piexif = require('piexifjs'); /** - * Parses GPS coordinates from an image file using exifr + * Parses GPS coordinates from an image file (JPEG or DNG) using ExifTool * @param {string} filePath - Path to the image file * @returns {Promise<{lat: number, lng: number}|null>} GPS coordinates or null if not found */ const getGPSCoordinates = async (filePath) => { try { - const gps = await exifr.gps(filePath); - if (gps && typeof gps.latitude === 'number' && typeof gps.longitude === 'number') { + const tags = await exiftool.read(filePath); + if (tags && typeof tags.GPSLatitude === 'number' && typeof tags.GPSLongitude === 'number') { return { - lat: gps.latitude, - lng: gps.longitude + lat: tags.GPSLatitude, + lng: tags.GPSLongitude }; } return null; } catch (error) { - console.warn(`Could not read EXIF GPS from ${filePath}: ${error.message}`); + console.warn(`ExifTool could not read GPS from ${filePath}: ${error.message}`); return null; } }; diff --git a/backend/utils/imageHelper.js b/backend/utils/imageHelper.js index a55fedc..5f7a97b 100644 --- a/backend/utils/imageHelper.js +++ b/backend/utils/imageHelper.js @@ -7,10 +7,13 @@ const sharp = require('sharp'); */ const resizeTo8K = async (inputPath, outputPath) => { try { - await sharp(inputPath) + // Sử dụng failOn: 'none' để Sharp không dừng lại khi gặp lỗi metadata nhỏ trong tệp RAW/DNG. + // Khi libvips trên server hỗ trợ libraw, Sharp sẽ tự động render dữ liệu DNG này. + await sharp(inputPath, { failOn: 'none' }) + .removeAlpha() // Loại bỏ các kênh phụ/alpha thường gây lỗi 'multiband' trên tệp DNG .rotate() // Tự động xoay ảnh dựa trên EXIF orientation .resize(8192, 4096, { - fit: 'fill' // Ensures the output is exactly 8192x4096 + fit: 'cover' // Thay đổi thành 'cover' để giữ tỷ lệ 2:1 mà không làm méo ảnh }) .jpeg({ quality: 85, diff --git a/frontend/css/style.css b/frontend/css/style.css index 5e0e4e1..f370929 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -994,3 +994,25 @@ html, body { font-weight: bold; padding: 0 5px; } + +/* Status Badges */ +.status-badge { + font-size: 11px; + padding: 3px 8px; + border-radius: 12px; + font-weight: bold; + display: inline-flex; + align-items: center; +} + +.status-badge.processing { + background: rgba(255, 193, 7, 0.2); + color: #ffc107; + border: 1px solid rgba(255, 193, 7, 0.4); +} + +.status-badge.failed { + background: rgba(220, 53, 69, 0.2); + color: #dc3545; + border: 1px solid rgba(220, 53, 69, 0.4); +} diff --git a/frontend/js/main_map.js b/frontend/js/main_map.js index 2bdb76d..994d0b6 100644 --- a/frontend/js/main_map.js +++ b/frontend/js/main_map.js @@ -1350,6 +1350,14 @@ async function loadMyScenes() { const card = document.createElement('div'); card.className = 'scene-card'; card.style.backgroundImage = `url('${thumbUrl}')`; + + // Logic hiển thị badge trạng thái + let statusBadge = ''; + if (scene.status === 'processing') { + statusBadge = '⏳ Đang xử lý 8K...'; + } else if (scene.status === 'failed') { + statusBadge = '❌ Lỗi xử lý'; + } card.innerHTML = `
+ ${statusBadge}${scene?.description || 'Không có mô tả'}
${parentNames ? `🔗 Liên kết từ: ${parentNames}
` : ''} + ${statusBadge} Tải lên: ${formatSystemDate(asset.createdAt)}