Chỉnh sửa phần upload ảnh
This commit is contained in:
@@ -3,3 +3,6 @@ node_modules/
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode
|
||||
ARCHITECTURE.md
|
||||
LICENSE
|
||||
README.md
|
||||
+28
-17
@@ -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
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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;
|
||||
@@ -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.
@@ -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;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 = '<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">
|
||||
@@ -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)));
|
||||
|
||||
Reference in New Issue
Block a user