Chỉnh sửa phần upload ảnh

This commit is contained in:
2026-06-09 11:06:25 +07:00
parent 913867720f
commit 2fba77d50c
10 changed files with 171 additions and 27 deletions
+3
View File
@@ -3,3 +3,6 @@ node_modules/
*.log
.DS_Store
.vscode
ARCHITECTURE.md
LICENSE
README.md
+27 -16
View File
@@ -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);
}
}
});
@@ -94,20 +100,13 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => {
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
});
+29
View File
@@ -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 };
+54
View File
@@ -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;
+3
View File
@@ -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();
Binary file not shown.
+7 -7
View File
@@ -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;
}
};
+5 -2
View File
@@ -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,
+22
View File
@@ -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);
}
+20 -1
View File
@@ -1351,6 +1351,14 @@ async function loadMyScenes() {
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 = '<span class="status-badge processing">⏳ Đang xử lý 8K...</span>';
} else if (scene.status === 'failed') {
statusBadge = '<span class="status-badge failed">❌ Lỗi xử lý</span>';
}
card.innerHTML = `
<div class="scene-card-overlay">
<div class="scene-card-info">
@@ -1361,9 +1369,10 @@ async function loadMyScenes() {
<span>👤 ${scene.createdBy?.username || 'Bạn'}</span>
<span>📅 ${formatSystemDate(scene.createdAt)}</span>
</div>
${statusBadge}
</div>
<div class="media-actions" style="border: none; padding: 0;">
<button class="edit-btn-small" id="edit-scene-${scene._id}">Sửa</button>
<button class="edit-btn-small" id="edit-scene-${scene._id}" ${scene.status === 'processing' ? 'disabled style="opacity:0.5; cursor:not-allowed;"' : ''}>Sửa</button>
<button class="delete-btn-small" id="delete-scene-${scene._id}">Xóa</button>
</div>
</div>
@@ -1523,6 +1532,13 @@ async function loadMyAssets() {
const card = document.createElement('div');
card.className = `media-card ${isTrash ? 'trash-item' : ''}`;
let statusBadge = '';
if (scene?.status === 'processing') {
statusBadge = '<span class="status-badge processing" style="position:static; margin-top:5px; display:inline-block;">⏳ Đang nén 8K...</span>';
} else if (scene?.status === 'failed') {
statusBadge = '<span class="status-badge failed" style="position:static; margin-top:5px; display:inline-block;">❌ Lỗi</span>';
}
// Build inner HTML without the onclick for edit/delete buttons
let innerHtml = `
<div class="media-thumb">
@@ -1533,6 +1549,7 @@ async function loadMyAssets() {
<strong>${scene ? (scene.name || scene.title) : 'Chưa gắn Scene'}</strong>
<p class="desc">${scene?.description || 'Không có mô tả'}</p>
${parentNames ? `<p class="parent-link">🔗 Liên kết từ: ${parentNames}</p>` : ''}
${statusBadge}
<span class="date">Tải lên: ${formatSystemDate(asset.createdAt)}</span>
</div>
<div class="media-actions">
@@ -1545,6 +1562,7 @@ async function loadMyAssets() {
const editButton = document.createElement('button');
editButton.className = 'edit-btn-small';
editButton.innerText = 'Sửa Scene';
if (scene.status === 'processing') editButton.disabled = true;
dashboardReturnTab = 'media-library';
const isChild = asset.parentScenes && asset.parentScenes.length > 0;
editButton.addEventListener('click', () => openEditFromMedia(scene, isChild));
@@ -1907,6 +1925,7 @@ async function submitEditScene(e) {
formData.append('lat', document.getElementById('edit-modal-lat').value);
formData.append('lng', document.getElementById('edit-modal-lng').value);
formData.append('privacy', document.getElementById('edit-modal-privacy').value);
formData.append('shareExpireDays', document.getElementById('share-link-expire').value);
// Đính kèm dữ liệu chia sẻ nâng cao
formData.append('sharedWithUsers', JSON.stringify(sharedUsersData.map(u => u._id || u)));