You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
333 lines
11 KiB
333 lines
11 KiB
// server.js
|
|
|
|
// 1. 모듈 불러오기
|
|
const express = require('express');
|
|
const path = require('path');
|
|
const { spawn } = require('child_process');
|
|
const multer = require('multer');
|
|
const fs = require('fs');
|
|
|
|
// 2. Express 앱 생성
|
|
const app = express();
|
|
const port = 3000;
|
|
|
|
// ========================================================
|
|
// [설정] 경로 및 폴더 초기화
|
|
// ========================================================
|
|
|
|
// AI 모델용 업로드 경로
|
|
const uploadPath = '/mnt/user_data/applications/misc/networks/cuuva';
|
|
fs.mkdirSync(uploadPath, { recursive: true });
|
|
|
|
// 관심 인물(POI) 폴더 경로 (vip_tracks)
|
|
const vipTracksPath = '/mnt/user_data/applications/misc/vip_tracks';
|
|
fs.mkdirSync(vipTracksPath, { recursive: true });
|
|
|
|
|
|
// ========================================================
|
|
// [설정] Multer (파일 업로드 핸들러)
|
|
// ========================================================
|
|
|
|
// 1) AI 모델용 스토리지
|
|
const modelStorage = multer.diskStorage({
|
|
destination: (req, file, cb) => { cb(null, uploadPath); },
|
|
filename: (req, file, cb) => { cb(null, file.originalname); }
|
|
});
|
|
|
|
const modelFileFilter = (req, file, cb) => {
|
|
if (!file.originalname.endsWith('.aiwbin')) {
|
|
return cb(new Error('Invalid file type: Only .aiwbin files are allowed'), false);
|
|
}
|
|
cb(null, true);
|
|
};
|
|
|
|
const uploadModel = multer({ storage: modelStorage, fileFilter: modelFileFilter });
|
|
|
|
|
|
// 2) 관심 인물(POI) 이미지용 스토리지
|
|
const poiStorage = multer.diskStorage({
|
|
destination: (req, file, cb) => { cb(null, vipTracksPath); },
|
|
filename: (req, file, cb) => {
|
|
// 임시 저장을 위해 충돌 없는 이름을 생성
|
|
const ext = path.extname(file.originalname);
|
|
cb(null, `temp_${Date.now()}_${Math.round(Math.random() * 1E9)}${ext}`);
|
|
}
|
|
});
|
|
|
|
const poiFileFilter = (req, file, cb) => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
if (!['.jpg', '.jpeg', '.png'].includes(ext)) {
|
|
return cb(new Error('이미지 파일만 업로드 가능합니다 (.jpg, .png)'), false);
|
|
}
|
|
cb(null, true);
|
|
};
|
|
|
|
const uploadPoi = multer({ storage: poiStorage, fileFilter: poiFileFilter });
|
|
|
|
|
|
// ========================================================
|
|
// [미들웨어 및 라우트 설정]
|
|
// ========================================================
|
|
|
|
app.use(express.static(path.join(__dirname, 'public')));
|
|
app.use(express.json());
|
|
|
|
// POI 이미지 서빙
|
|
app.get('/poi-images/:folder/:filename', (req, res) => {
|
|
const { folder, filename } = req.params;
|
|
|
|
if (folder.includes('..') || filename.includes('..')) {
|
|
return res.status(400).send('Invalid path');
|
|
}
|
|
|
|
const filePath = path.join(vipTracksPath, folder, filename);
|
|
|
|
if (fs.existsSync(filePath)) {
|
|
res.sendFile(filePath);
|
|
} else {
|
|
res.status(404).send('Image not found');
|
|
}
|
|
});
|
|
|
|
// 페이지 라우팅
|
|
app.get('/', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
|
});
|
|
|
|
app.get('/dashboard', (req, res) => {
|
|
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
|
|
});
|
|
|
|
|
|
// ========================================================
|
|
// [API] AI 모델 관련
|
|
// ========================================================
|
|
|
|
app.post('/set-model', (req, res) => {
|
|
const { model } = req.body;
|
|
if (!model) return res.status(400).json({ status: 'error', message: '모델 값이 없습니다.' });
|
|
|
|
const scriptCommand = '/mnt/user_data/feat_control/feat_on.sh';
|
|
const args = [model];
|
|
const scriptProcess = spawn(scriptCommand, args);
|
|
|
|
let stdoutData = '';
|
|
scriptProcess.stdout.on('data', (data) => { stdoutData += data.toString(); });
|
|
|
|
scriptProcess.on('close', (code) => {
|
|
if (code === 0) {
|
|
res.json({ status: 'success', message: `모델이 ${model}(으)로 변경됨`, output: stdoutData });
|
|
} else {
|
|
res.status(500).json({ status: 'error', message: '쉘 스크립트 실행 실패' });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.post('/zoom-control', (req, res) => {
|
|
const { zoom } = req.body;
|
|
if (!zoom || (zoom !== 'IN' && zoom !== 'OUT')) {
|
|
return res.status(400).json({ status: 'error', message: 'Invalid zoom state' });
|
|
}
|
|
|
|
const scriptPath = '/mnt/user_data/feat_control/zoom_control.sh';
|
|
const scriptProcess = spawn(scriptPath, [zoom]);
|
|
|
|
let output = '';
|
|
scriptProcess.stdout.on('data', (data) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
scriptProcess.stderr.on('data', (data) => {
|
|
output += data.toString();
|
|
});
|
|
|
|
scriptProcess.on('close', (code) => {
|
|
if (code === 0) {
|
|
res.json({ status: 'success', message: `Zoom set to ${zoom}`, output });
|
|
} else {
|
|
res.status(500).json({ status: 'error', message: `Script failed with code ${code}`, output });
|
|
}
|
|
});
|
|
});
|
|
|
|
app.post('/upload-model', uploadModel.single('modelFile'), (req, res) => {
|
|
if (!req.file) return res.status(400).json({ status: 'error', message: '파일 없음' });
|
|
res.json({ status: 'success', message: '업로드 성공', filePath: req.file.path });
|
|
});
|
|
|
|
function isVersionGreater(v1, v2) {
|
|
if (!v2) return true;
|
|
const parts1 = v1.replace('v', '').split('.').map(Number);
|
|
const parts2 = v2.replace('v', '').split('.').map(Number);
|
|
if (parts1[0] > parts2[0]) return true;
|
|
if (parts1[0] < parts2[0]) return false;
|
|
if (parts1[1] > parts2[1]) return true;
|
|
return false;
|
|
}
|
|
|
|
app.get('/list-models', (req, res) => {
|
|
const modelRegex = /^CUUVA_([A-Z]+)_v(\d+\.\d+)\.aiwbin$/;
|
|
const latestModels = {};
|
|
|
|
fs.readdir(uploadPath, (err, files) => {
|
|
if (err) return res.status(500).json({ status: 'error' });
|
|
files.forEach(file => {
|
|
const match = file.match(modelRegex);
|
|
if (match) {
|
|
const role = match[1];
|
|
const version = `v${match[2]}`;
|
|
const existing = latestModels[role];
|
|
if (!existing || isVersionGreater(version, existing.version)) {
|
|
latestModels[role] = { file: file, version: version };
|
|
}
|
|
}
|
|
});
|
|
res.json(latestModels);
|
|
});
|
|
});
|
|
|
|
app.post('/delete-model', (req, res) => {
|
|
const { filename } = req.body;
|
|
if (!filename) return res.status(400).json({ status: 'error', message: '파일명 누락' });
|
|
const safeFilename = path.basename(filename);
|
|
if (!safeFilename.endsWith('.aiwbin')) return res.status(400).json({ status: 'error', message: '잘못된 파일' });
|
|
const filePath = path.join(uploadPath, safeFilename);
|
|
fs.unlink(filePath, (err) => {
|
|
if (err && err.code !== 'ENOENT') return res.status(500).json({ status: 'error' });
|
|
res.json({ status: 'success', message: '삭제 완료' });
|
|
});
|
|
});
|
|
|
|
|
|
// ========================================================
|
|
// [API] 관심 인물(POI) 관리 관련
|
|
// ========================================================
|
|
|
|
app.get('/poi/list', (req, res) => {
|
|
fs.readdir(vipTracksPath, { withFileTypes: true }, (err, entries) => {
|
|
if (err) return res.json([]);
|
|
|
|
const result = entries
|
|
.filter(dirent => dirent.isDirectory())
|
|
.map(dirent => {
|
|
const name = dirent.name;
|
|
const dirPath = path.join(vipTracksPath, name);
|
|
let imageFiles = [];
|
|
|
|
try {
|
|
const files = fs.readdirSync(dirPath);
|
|
imageFiles = files.filter(file => /\.(jpg|jpeg|png)$/i.test(file));
|
|
} catch (e) {
|
|
imageFiles = [];
|
|
}
|
|
|
|
return { name, images: imageFiles };
|
|
});
|
|
|
|
res.json(result);
|
|
});
|
|
});
|
|
|
|
app.post('/poi/register', (req, res) => {
|
|
const { name } = req.body;
|
|
const nameRegex = /^[a-zA-Z0-9]+$/;
|
|
|
|
if (!name || !nameRegex.test(name)) {
|
|
return res.status(400).json({ status: 'error', message: '이름은 영문과 숫자만 가능합니다.' });
|
|
}
|
|
const targetDir = path.join(vipTracksPath, name);
|
|
if (fs.existsSync(targetDir)) {
|
|
return res.status(400).json({ status: 'error', message: '이미 등록된 이름입니다.' });
|
|
}
|
|
try {
|
|
fs.mkdirSync(targetDir);
|
|
res.json({ status: 'success' });
|
|
} catch (err) {
|
|
res.status(500).json({ status: 'error', message: '폴더 생성 실패' });
|
|
}
|
|
});
|
|
|
|
app.post('/poi/delete', (req, res) => {
|
|
const { name } = req.body;
|
|
const safeName = path.basename(name);
|
|
const targetDir = path.join(vipTracksPath, safeName);
|
|
|
|
try {
|
|
if (fs.existsSync(targetDir)) {
|
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
}
|
|
res.json({ status: 'success' });
|
|
} catch (err) {
|
|
res.status(500).json({ status: 'error', message: '삭제 실패' });
|
|
}
|
|
});
|
|
|
|
app.post('/poi/delete-image', (req, res) => {
|
|
const { name, imageName } = req.body;
|
|
if (!name || !imageName) return res.status(400).json({ status: 'error', message: '정보 누락' });
|
|
const safeName = path.basename(name);
|
|
const safeImageName = path.basename(imageName);
|
|
const targetFile = path.join(vipTracksPath, safeName, safeImageName);
|
|
|
|
if (fs.existsSync(targetFile)) {
|
|
try {
|
|
fs.unlinkSync(targetFile);
|
|
res.json({ status: 'success', message: '이미지가 삭제되었습니다.' });
|
|
} catch (err) {
|
|
res.status(500).json({ status: 'error', message: '파일 삭제 오류' });
|
|
}
|
|
} else {
|
|
res.status(404).json({ status: 'error', message: '이미지가 없습니다.' });
|
|
}
|
|
});
|
|
|
|
// [수정됨] 다중 파일 업로드 처리 (uploadPoi.array 사용)
|
|
app.post('/poi/upload-image', uploadPoi.array('poiFile'), (req, res) => {
|
|
// 다중 파일인 경우 req.files에 배열로 들어옴
|
|
if (!req.files || req.files.length === 0) {
|
|
return res.status(400).json({ status: 'error', message: '파일 업로드 실패 (jpg, png만 가능)' });
|
|
}
|
|
|
|
const poiName = req.body.poiName;
|
|
|
|
// poiName이 없으면 임시 파일 모두 삭제
|
|
if (!poiName) {
|
|
req.files.forEach(file => {
|
|
if(fs.existsSync(file.path)) fs.unlinkSync(file.path);
|
|
});
|
|
return res.status(400).json({ status: 'error', message: '대상 인물 정보 누락' });
|
|
}
|
|
|
|
const targetDir = path.join(vipTracksPath, poiName);
|
|
|
|
try {
|
|
if (!fs.existsSync(targetDir)) {
|
|
fs.mkdirSync(targetDir);
|
|
}
|
|
|
|
// 업로드된 모든 파일을 순회하며 이동
|
|
req.files.forEach(file => {
|
|
const ext = path.extname(file.originalname).toLowerCase();
|
|
// 파일명 중복 방지를 위해 난수 추가
|
|
const newFileName = `img_${Date.now()}_${Math.floor(Math.random() * 100000)}${ext}`;
|
|
const targetPath = path.join(targetDir, newFileName);
|
|
|
|
fs.renameSync(file.path, targetPath);
|
|
});
|
|
|
|
res.json({ status: 'success', message: '이미지 등록 완료' });
|
|
|
|
} catch (err) {
|
|
console.error(err);
|
|
// 에러 발생 시 남아있는 임시 파일 정리
|
|
req.files.forEach(file => {
|
|
if(fs.existsSync(file.path)) try { fs.unlinkSync(file.path); } catch(e){}
|
|
});
|
|
res.status(500).json({ status: 'error', message: '파일 이동 실패' });
|
|
}
|
|
});
|
|
|
|
// 서버 실행
|
|
app.listen(port, () => {
|
|
console.log(`Server running on http://localhost:${port}`);
|
|
}); |