main
dongjin kim 7 months ago
parent 267cfd805c
commit 2c4cc417cc

@ -1,608 +1,408 @@
// app.js
// DOM(HTML)이 모두 로드되었을 때 실행
document.addEventListener('DOMContentLoaded', () => {
// 탭 버튼과 콘텐츠 요소를 가져옵니다.
// =================================================
// 1. 탭 전환 로직
// =================================================
const tabVideo = document.getElementById('tab-video');
const tabModels = document.getElementById('tab-models');
const contentVideo = document.getElementById('content-video');
const contentModels = document.getElementById('content-models');
// 비디오 탭 클릭 시
if (tabVideo && tabModels) { // 탭 요소가 있는지 확인
if (tabVideo && tabModels) {
tabVideo.addEventListener('click', () => {
// 버튼 활성화
tabVideo.classList.add('active');
tabModels.classList.remove('active');
// 콘텐츠 표시
contentVideo.classList.add('active');
contentModels.classList.remove('active');
});
}
// AI 모델 탭 클릭 시
if (tabVideo && tabModels) { // 탭 요소가 있는지 확인
tabModels.addEventListener('click', () => {
// 버튼 활성화
tabModels.classList.add('active');
tabVideo.classList.remove('active');
// 콘텐츠 표시
contentModels.classList.add('active');
contentVideo.classList.remove('active');
// [추가] AI 모델 탭을 클릭할 때 목록 새로고침
loadModelList();
loadPoiList();
});
}
// ========== AI 모델 목록 로드 함수 ==========
// =================================================
// 2. AI 모델 관리 로직
// =================================================
function loadModelList() {
// 테이블 본문(tbody)을 선택
const tableBody = document.querySelector('.model-table tbody');
if (!tableBody) return; // 테이블이 없으면 중단
console.log('Loading model list...');
fetch('/list-models') // server.js에 추가한 엔드포인트
.then(response => response.json())
.then(models => {
// models = { "OBJDET": { "file": "...", "version": "..." }, ... }
// 테이블의 모든 'data-role' 행을 순회
tableBody.querySelectorAll('tr[data-role]').forEach(row => {
const role = row.dataset.role; // e.g., "OBJDET"
const modelData = models[role]; // 해당 역할의 모델 데이터 (없으면 undefined)
const fileCell = row.querySelector('.model-filename');
const versionCell = row.querySelector('.model-version');
const deleteButton = row.querySelector('.btn-delete'); // [수정] 삭제 버튼 선택
if (modelData) {
// 일치하는 파일이 있으면, 파일명과 버전 업데이트
if (fileCell) fileCell.textContent = modelData.file;
if (versionCell) versionCell.textContent = modelData.version;
// [수정] 삭제 버튼 표시
if (deleteButton) deleteButton.classList.remove('hidden');
} else {
// 일치하는 파일이 없으면, 기본값 '-'으로 설정
if (fileCell) fileCell.textContent = '-';
if (versionCell) versionCell.textContent = '-';
// [수정] 삭제 버튼 숨김
if (deleteButton) deleteButton.classList.add('hidden');
}
});
})
.catch(error => {
console.error('모델 목록 로드 실패:', error);
alert('모델 목록을 불러오는 데 실패했습니다.');
if (!tableBody) return;
fetch('/list-models').then(r=>r.json()).then(models=>{
tableBody.querySelectorAll('tr[data-role]').forEach(row => {
const role = row.dataset.role;
const d = models[role];
const f = row.querySelector('.model-filename');
const v = row.querySelector('.model-version');
const btn = row.querySelector('.btn-delete');
if(d){ f.textContent=d.file; v.textContent=d.version; btn.classList.remove('hidden'); }
else { f.textContent='-'; v.textContent='-'; btn.classList.add('hidden'); }
});
});
}
// ========== AI 모델 목록 로드 함수 끝 ==========
const modelTableBody = document.querySelector('.model-table tbody');
if(modelTableBody) {
modelTableBody.addEventListener('click', (e) => {
if(e.target.classList.contains('btn-delete')) {
const row = e.target.closest('tr');
const filename = row.querySelector('.model-filename').textContent;
if(filename && filename !== '-') deleteModelFile(filename);
}
});
}
// ========== [추가됨] 모델 삭제 요청 함수 ==========
function deleteModelFile(filename) {
console.log(`Requesting deletion of: ${filename}`);
fetch('/delete-model', { // server.js에 추가할 엔드포인트
if (!confirm(`정말로 이 모델 파일(${filename})을 삭제하시겠습니까?`)) return;
fetch('/delete-model', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({filename: filename}) // { "filename": "CUUVA_..." }
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
alert(`파일이 성공적으로 삭제되었습니다: ${filename}`);
loadModelList(); // 삭제 성공 시 목록 새로고침
} else {
alert(`삭제 실패: ${data.message}`);
}
})
.catch(error => {
console.error('Delete error:', error);
alert('삭제 중 오류가 발생했습니다.');
});
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({filename: filename})
}).then(res => res.json()).then(data => {
if (data.status === 'success') { alert('삭제되었습니다.'); loadModelList(); }
else { alert(`삭제 실패: ${data.message}`); }
});
}
// ========== 모델 삭제 요청 함수 끝 ==========
// ========== AI 모델 업로드/관리 로직 시작 ==========
if (contentModels) {
// 전역 업로드 관련 요소 가져오기
const globalFileInput = document.getElementById('global-file-input');
const fileNameDisplay = document.getElementById('file-name-display');
const globalUploadButton = document.getElementById('global-upload-button');
const globalUploadBtn = document.getElementById('global-upload-button');
const globalFileInput = document.getElementById('global-file-input');
const fileNameDisplay = document.getElementById('file-name-display');
const refreshButton = document.querySelector('.refresh-button');
// 새로고침 버튼 요소
const refreshButton = contentModels.querySelector('.refresh-button');
// [추가] 모델 테이블 본문 (이벤트 위임용)
const tableBody = contentModels.querySelector('.model-table tbody');
// '찾아보기'로 파일 선택 시
if (globalFileInput && fileNameDisplay) {
globalFileInput.addEventListener('change', () => {
if (globalFileInput.files.length > 0) {
const file = globalFileInput.files[0];
// 파일 확장자 확인
if (!file.name.endsWith('.aiwbin')) {
alert('잘못된 파일 형식입니다. .aiwbin 파일만 선택할 수 있습니다.');
globalFileInput.value = ''; // 파일 선택 초기화
fileNameDisplay.textContent = '선택된 파일 없음';
} else {
// 파일명 표시
fileNameDisplay.textContent = file.name;
}
if (globalFileInput) {
globalFileInput.addEventListener('change', () => {
fileNameDisplay.textContent = globalFileInput.files.length > 0 ? globalFileInput.files[0].name : '선택된 파일 없음';
});
}
if (globalUploadBtn) {
globalUploadBtn.addEventListener('click', () => {
if (!globalFileInput.files.length) return alert('파일을 선택해주세요.');
const formData = new FormData();
formData.append('modelFile', globalFileInput.files[0]);
fetch('/upload-model', { method: 'POST', body: formData })
.then(res => res.json())
.then(data => {
if (data.status === 'success') { alert('업로드 성공'); globalFileInput.value=''; fileNameDisplay.textContent='선택된 파일 없음'; loadModelList(); }
else { alert(`실패: ${data.message}`); }
});
});
}
if (refreshButton) refreshButton.addEventListener('click', loadModelList);
// =================================================
// 3. 관심 인물(POI) 관리 로직
// =================================================
const poiTableBody = document.querySelector('#poi-table tbody');
const poiEmptyMsg = document.getElementById('poi-empty-msg');
const btnPoiRegister = document.getElementById('btn-poi-register');
const btnPoiRefresh = document.getElementById('btn-poi-refresh');
// 모달 및 뷰어 요소
const poiModal = document.getElementById('poi-modal');
const poiNameInput = document.getElementById('poi-name-input');
const btnModalConfirm = document.getElementById('btn-modal-confirm');
const btnModalCancel = document.getElementById('btn-modal-cancel');
const imageViewerModal = document.getElementById('image-viewer-modal');
const fullImage = document.getElementById('full-image');
const closeViewerBtn = document.querySelector('.close-viewer');
// POI 목록 로드
function loadPoiList() {
if (!poiTableBody) return;
fetch('/poi/list')
.then(res => res.json())
.then(list => {
poiTableBody.innerHTML = '';
if (!list || list.length === 0) {
poiEmptyMsg.classList.remove('hidden');
poiTableBody.parentElement.classList.add('hidden');
} else {
fileNameDisplay.textContent = '선택된 파일 없음';
}
});
}
poiEmptyMsg.classList.add('hidden');
poiTableBody.parentElement.classList.remove('hidden');
list.forEach((item, index) => {
const tr = document.createElement('tr');
let previewHtml = '-';
if (item.images && item.images.length > 0) {
const imagesHtml = item.images.map(imgName => {
const imgSrc = `/poi-images/${item.name}/${imgName}`;
return `
<div class="preview-item">
<img src="${imgSrc}" class="poi-preview" onclick="openImageViewer('${imgSrc}')" title="클릭하여 확대">
<button class="btn-delete-small" onclick="deletePoiImage('${item.name}', '${imgName}')">삭제</button>
</div>
`;
}).join('');
previewHtml = `<div class="preview-container">${imagesHtml}</div>`;
}
// '업로드' 버튼 클릭 시
if (globalUploadButton && globalFileInput) {
globalUploadButton.addEventListener('click', () => {
// 파일이 선택되었는지 확인
if (globalFileInput.files.length === 0) {
alert('먼저 .aiwbin 파일을 선택해주세요.');
return;
const fileInputId = `poi-file-${item.name}`;
// [수정됨] multiple 속성 추가
tr.innerHTML = `
<td>${index + 1}</td>
<td>
<span style="font-weight:bold; font-size:1.1rem; margin-right:10px;">${item.name}</span>
<button class="btn-delete-small" onclick="deletePoi('${item.name}')">삭제</button>
</td>
<td>${previewHtml}</td>
<td>
<input type="file" id="${fileInputId}" class="hidden" accept=".jpg,.jpeg,.png" multiple onchange="uploadPoiImage('${item.name}', this)">
<label for="${fileInputId}" class="btn-browse" style="background:#fff; color:#333; border:1px solid #ccc;">찾아보기</label>
</td>
`;
poiTableBody.appendChild(tr);
});
}
})
.catch(err => console.error('POI Load Error:', err));
}
const file = globalFileInput.files[0];
// [전역 함수] 이미지 뷰어 열기
window.openImageViewer = function(src) {
if (fullImage && imageViewerModal) {
fullImage.src = src;
imageViewerModal.classList.remove('hidden');
}
};
// (이중 확인) 파일 확장자 확인
if (!file.name.endsWith('.aiwbin')) {
alert('잘못된 파일 형식입니다. .aiwbin 파일만 업로드할 수 있습니다.');
globalFileInput.value = '';
fileNameDisplay.textContent = '선택된 파일 없음';
return;
}
// 뷰어 닫기 로직
if (closeViewerBtn) {
closeViewerBtn.addEventListener('click', () => {
imageViewerModal.classList.add('hidden');
});
}
window.addEventListener('click', (e) => {
if (e.target === imageViewerModal) {
imageViewerModal.classList.add('hidden');
}
});
// FormData 객체 생성
const formData = new FormData();
formData.append('modelFile', file); // 'modelFile'은 server.js와 일치해야 함
// 업로드 중 버튼 비활성화
globalUploadButton.disabled = true;
globalUploadButton.textContent = '업로드 중...';
// [전역 함수] 인물 전체 삭제
window.deletePoi = function(name) {
if (!confirm(`관심인물 '${name}'을(를) 완전히 삭제하시겠습니까?\n등록된 모든 이미지가 함께 삭제됩니다.`)) return;
// Fetch API를 사용하여 파일 업로드
fetch('/upload-model', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.status === 'success') {
alert(`업로드 성공!\n파일: ${file.name}`);
globalFileInput.value = ''; // 파일 선택 초기화
fileNameDisplay.textContent = '선택된 파일 없음';
// 업로드 성공 시 모델 목록 새로고침
loadModelList();
} else {
alert(`업로드 실패: ${data.message}`);
}
})
.catch(error => {
console.error('Upload error:', error);
alert('업로드 중 오류가 발생했습니다.');
})
.finally(() => {
// 버튼 다시 활성화
globalUploadButton.disabled = false;
globalUploadButton.textContent = '업로드';
});
});
}
fetch('/poi/delete', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') loadPoiList();
else alert('삭제 실패');
});
};
// '새로고침' 버튼 클릭 시
if (refreshButton) {
refreshButton.addEventListener('click', () => {
loadModelList(); // 목록 새로고침 함수 호출
});
}
// [전역 함수] 특정 이미지만 삭제
window.deletePoiImage = function(name, imageName) {
if (!confirm(`해당 이미지를 삭제하시겠습니까?`)) return;
// ========== [추가됨] 삭제 버튼 이벤트 리스너 (이벤트 위임) ==========
if (tableBody) {
tableBody.addEventListener('click', (event) => {
// 클릭된 요소가 'btn-delete' 클래스를 가지고 있는지 확인
if (event.target.classList.contains('btn-delete')) {
fetch('/poi/delete-image', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, imageName })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') loadPoiList();
else alert('이미지 삭제 실패');
});
};
// 클릭된 버튼에서 가장 가까운 <tr>(행)을 찾습니다.
const row = event.target.closest('tr');
if (!row) return;
// [수정됨] 다중 파일 업로드 처리
window.uploadPoiImage = function(name, input) {
if (!input.files || input.files.length === 0) return;
// 행에서 파일명 셀(.model-filename)을 찾아 파일명을 가져옵니다.
const fileCell = row.querySelector('.model-filename');
const filename = fileCell ? fileCell.textContent : null;
const formData = new FormData();
formData.append('poiName', name);
// 파일명이 없거나 '-' 이면 (파일이 할당되지 않음) 중단
if (!filename || filename === '-') {
alert('삭제할 파일이 없습니다.');
return;
}
// 선택된 모든 파일을 formData에 추가
for (let i = 0; i < input.files.length; i++) {
const file = input.files[i];
const ext = file.name.split('.').pop().toLowerCase();
// 행의 두 번째 셀(<td>)에서 역할 텍스트를 가져옵니다.
const roleText = row.cells[1] ? row.cells[1].textContent : '알 수 없는 역할';
if (!['jpg', 'jpeg', 'png'].includes(ext)) {
alert(`'${file.name}'은(는) 허용되지 않는 파일 형식입니다. (jpg, png만 가능)`);
input.value = '';
return;
}
// 사용자에게 삭제 확인을 받습니다.
if (confirm(`정말로 이 모델 파일을 삭제하시겠습니까?\n\n역할: ${roleText}\n파일: ${filename}`)) {
// 확인 시, 삭제 함수 호출
deleteModelFile(filename);
}
}
});
formData.append('poiFile', file);
}
// ========== 삭제 버튼 이벤트 리스너 끝 ==========
}
// ========== AI 모델 업로드/관리 로직 끝 ==========
fetch('/poi/upload-image', { method: 'POST', body: formData })
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
alert('이미지 등록 완료');
loadPoiList();
} else {
alert('업로드 실패: ' + (data.message || '오류'));
}
})
.catch(() => alert('오류 발생'));
input.value = '';
};
// ========== 로그아웃 버튼 처리 ==========
const logoutButton = document.getElementById('logout-button');
if (logoutButton) { // 로그아웃 버튼이 있는지 확인
logoutButton.addEventListener('click', () => {
console.log('Logout clicked');
// 이벤트 리스너
if (btnPoiRefresh) btnPoiRefresh.addEventListener('click', loadPoiList);
// [수정됨] 사용자에게 확인을 받습니다.
if (confirm('정말로 로그아웃하시겠습니까?')) {
// 확인을 누르면 로그인 페이지(루트 '/')로 이동
window.location.href = '/';
}
// 취소를 누르면 아무 일도 일어나지 않습니다.
if (btnPoiRegister) {
btnPoiRegister.addEventListener('click', () => {
poiNameInput.value = '';
poiModal.classList.remove('hidden');
});
}
// ========== 로그아웃 버튼 처리 끝 ==========
if (btnModalCancel) {
btnModalCancel.addEventListener('click', () => {
poiModal.classList.add('hidden');
});
}
// ========== dashboard.html 스크립트 추가 시작 ==========
const uri = "ws://10.10.11.246:8765"; // 필요하면 여기만 수정
if (btnModalConfirm) {
btnModalConfirm.addEventListener('click', () => {
const name = poiNameInput.value.trim();
if (!name) return alert('이름을 입력하세요.');
const regex = /^[a-zA-Z0-9]+$/;
if (!regex.test(name)) return alert('영문과 숫자만 입력 가능합니다.');
fetch('/poi/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name })
})
.then(res => res.json())
.then(data => {
if (data.status === 'success') {
poiModal.classList.add('hidden');
loadPoiList();
} else {
alert(data.message || '등록 실패');
}
});
});
}
// [수정] imgEl -> canvasEl로 변경
// =================================================
// 4. 비디오 & 웹소켓 (기존 기능)
// =================================================
const uri = "ws://10.10.11.246:8765";
const canvasEl = document.getElementById("frame");
const statusEl = document.getElementById("status");
const frameInfoEl = document.getElementById("frame-info");
const detListEl = document.getElementById("det-list");
const bboxContainerEl = document.getElementById("bbox-container");
const modelContainerEl = document.getElementById("current-model-container");
// [추가] 오버레이 표시용 요소
const modelDisplayEl = document.getElementById("current-model-display");
const fpsDisplayEl = document.getElementById("fps-display");
// [추가] 캔버스 2D 컨텍스트
let ctx = null;
if (canvasEl) {
ctx = canvasEl.getContext('2d');
}
// [추가] 클래스별 색상 팔레트 (원하는 색상으로 수정 가능)
const CLASS_COLORS = [
'#FF3838', // 0: Red
'#38BFFF', // 1: Blue
'#38FF4E', // 2: Green
'#FFF238', // 3: Yellow
'#FF9D38', // 4: Orange
'#C538FF', // 5: Purple
'#FF38C5', // 6: Pink
'#FFFFFF' // 7: White
];
// [추가] FPS 계산용 변수
if (canvasEl) ctx = canvasEl.getContext('2d');
let frameCount = 0;
let lastFpsCheckTime = performance.now();
let lastFrameMeta = null;
// [수정] canvasEl 및 ctx 존재 여부 확인
if (canvasEl && ctx && statusEl && frameInfoEl && detListEl && bboxContainerEl && modelContainerEl) {
function connect() {
const ws = new WebSocket(uri);
ws.binaryType = "arraybuffer";
ws.onopen = () => { statusEl.textContent = "연결됨"; statusEl.className = "status"; };
ws.onclose = () => { statusEl.textContent = "연결 종료. 재접속..."; statusEl.className = "status err"; setTimeout(connect, 2000); };
ws.onmessage = (event) => {
const data = event.data;
if (typeof data === "string") {
try {
const meta = JSON.parse(data);
if (meta.type === "frame") {
lastFrameMeta = meta;
document.getElementById("frame-info").textContent = ` | FRAME ${meta.w}x${meta.h}`;
} else if (meta.type === "det") {
showDetections(meta);
}
} catch(e){}
} else if (data instanceof ArrayBuffer && lastFrameMeta) {
frameCount++;
const now = performance.now();
if (now - lastFpsCheckTime >= 1000) {
fpsDisplayEl.textContent = `${Math.round(frameCount * 1000 / (now - lastFpsCheckTime))} FPS`;
frameCount = 0;
lastFpsCheckTime = now;
}
const blob = new Blob([data], {type: "image/jpeg"});
createImageBitmap(blob).then(bmp => {
canvasEl.width = canvasEl.clientWidth;
canvasEl.height = canvasEl.clientHeight;
const r = Math.min(canvasEl.width / bmp.width, canvasEl.height / bmp.height);
const dw = bmp.width * r;
const dh = bmp.height * r;
const dx = (canvasEl.width - dw) / 2;
const dy = (canvasEl.height - dh) / 2;
ctx.clearRect(0,0,canvasEl.width, canvasEl.height);
ctx.drawImage(bmp, dx, dy, dw, dh);
bmp.close();
updateBboxContainerPosition();
});
}
};
}
// 모델 변경 이벤트 리스너
modelContainerEl.addEventListener('change', (event) => {
if (event.target && event.target.name === 'current-model') {
// [추가] 모델 이름 즉시 업데이트
updateModelDisplay();
function showDetections(meta) {
bboxContainerEl.innerHTML = "";
const items = meta.items || [];
let lines = [];
items.forEach((it, i) => {
lines.push(`#${i} cls=${it.cls} score=${it.conf || 0}`);
});
detListEl.textContent = lines.join("\n");
}
const selectedModel = event.target.value;
console.log(`Model changed to: ${selectedModel}`);
function updateBboxContainerPosition() {
if (!canvasEl || !bboxContainerEl) return;
bboxContainerEl.style.top = canvasEl.offsetTop + 'px';
bboxContainerEl.style.left = canvasEl.offsetLeft + 'px';
bboxContainerEl.style.width = canvasEl.offsetWidth + 'px';
bboxContainerEl.style.height = canvasEl.offsetHeight + 'px';
}
if (modelContainerEl) {
modelContainerEl.addEventListener('change', (e) => {
if(e.target.name === 'current-model') {
updateModelDisplay();
fetch('/set-model', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({model: selectedModel}),
})
.then(response => response.json())
.then(data => {
console.log('Server response:', data);
if (data.status === 'success') {
logStatus(`모델 변경 완료: ${selectedModel}`);
} else {
logStatus(`모델 변경 실패: ${data.message}`, true);
}
})
.catch(error => {
console.error('Error setting model:', error);
logStatus('모델 변경 중 오류 발생', true);
});
}
});
// 박스 컨테이너 위치/크기 조절 함수
function updateBboxContainerPosition() {
// [수정] imgEl -> canvasEl
if (!canvasEl || !bboxContainerEl) return;
const top = canvasEl.offsetTop;
const left = canvasEl.offsetLeft;
const width = canvasEl.offsetWidth;
const height = canvasEl.offsetHeight;
bboxContainerEl.style.top = `${top}px`;
bboxContainerEl.style.left = `${left}px`;
bboxContainerEl.style.width = `${width}px`;
bboxContainerEl.style.height = `${height}px`;
}
function logStatus(msg, isError = false) {
statusEl.textContent = msg;
statusEl.className = "status" + (isError ? " err" : "");
}
function showFrameMeta(meta) {
frameInfoEl.textContent =
` | FRAME ch=${meta.ch} ts=${meta.ts_us} w=${meta.w} h=${meta.h}`;
}
// [추가] 선택된 AI 모델 이름을 상단 우측에 표시하는 함수
function updateModelDisplay() {
if (!modelContainerEl || !modelDisplayEl) return;
// 현재 선택된 라디오 버튼을 찾습니다.
const checkedRadio = modelContainerEl.querySelector('input[name="current-model"]:checked');
if (checkedRadio) {
// 해당 라디오 버튼의 ID로 label을 찾습니다.
const label = modelContainerEl.querySelector(`label[for="${checkedRadio.id}"]`);
if (label) {
modelDisplayEl.textContent = label.textContent; // "군중 위험 인식"
} else {
modelDisplayEl.textContent = checkedRadio.value; // "CROWD" (대체)
}
} else {
modelDisplayEl.textContent = "모델 선택 안됨";
}
}
// Detections 표시 함수 (수정됨)
function showDetections(meta) {
const items = meta.items || [];
let lines = [];
lines.push(`DET ch=${meta.ch} seq=${meta.seq} ts=${meta.ts_us} cnt=${items.length}`);
bboxContainerEl.innerHTML = "";
// [수정] imgEl -> canvasEl
if (!canvasEl || !lastFrameMeta) {
// [수정됨] x1,y1,x2,y2 형식에 맞게 텍스트 로그 수정
items.forEach((it, i) => {
// x1, y1, x2, y2 -> x, y, w, h
const x = it.x1;
const y = it.y1;
const w = it.x2 - it.x1;
const h = it.y2 - it.y1;
lines.push(
`#${i} cls=${it.cls} tid=${it.tid} tag=${it.tag}` // prob, resv 대신 tid, tag 사용
+ ` x=${x.toFixed(3)} y=${y.toFixed(3)}`
+ ` w=${w.toFixed(3)} h=${h.toFixed(3)}`
);
});
detListEl.textContent = lines.join("\n");
return;
}
updateBboxContainerPosition();
// [수정] imgEl -> canvasEl
const imgWidth = canvasEl.clientWidth;
const imgHeight = canvasEl.clientHeight;
const frameWidth = lastFrameMeta.w;
const frameHeight = lastFrameMeta.h;
if (frameWidth === 0 || frameHeight === 0 || imgWidth === 0 || imgHeight === 0) {
// [수정됨] x1,y1,x2,y2 형식에 맞게 텍스트 로그 수정
items.forEach((it, i) => {
// x1, y1, x2, y2 -> x, y, w, h
const x = it.x1;
const y = it.y1;
const w = it.x2 - it.x1;
const h = it.y2 - it.y1;
lines.push(
`#${i} cls=${it.cls} tid=${it.tid} tag=${it.tag}` // prob, resv 대신 tid, tag 사용
+ ` x=${x.toFixed(3)} y=${y.toFixed(3)}`
+ ` w=${w.toFixed(3)} h=${h.toFixed(3)}`
);
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({model: e.target.value})
});
detListEl.textContent = lines.join("\n");
return;
}
});
}
const widthRatio = imgWidth / frameWidth;
const heightRatio = imgHeight / frameHeight;
const ratio = Math.min(widthRatio, heightRatio);
const videoDisplayWidth = frameWidth * ratio;
const videoDisplayHeight = frameHeight * ratio;
const offsetX = (imgWidth - videoDisplayWidth) / 2;
const offsetY = (imgHeight - videoDisplayHeight) / 2;
// [수정됨] x1,y1,x2,y2 형식에 맞게 텍스트 로그 및 박스 계산 수정
items.forEach((it, i) => {
// x1, y1, x2, y2 -> x, y, w, h
const x = it.x1;
const y = it.y1;
const w = it.x2 - it.x1;
const h = it.y2 - it.y1;
// [추가] 현재 항목의 클래스(cls) 가져오기
const cls = it.cls;
lines.push(
`#${i} cls=${cls} tid=${it.tid} tag=${it.tag}` // prob, resv 대신 tid, tag 사용
+ ` x=${x.toFixed(3)} y=${y.toFixed(3)}`
+ ` w=${w.toFixed(3)} h=${h.toFixed(3)}`
);
// 계산된 x, y, w, h를 사용하여 박스 위치 계산
const boxLeft = (x * ratio) + offsetX;
const boxTop = (y * ratio) + offsetY;
const boxWidth = w * ratio;
const boxHeight = h * ratio;
// [추가] 클래스(cls) 값에 따라 색상 결정
// cls를 정수로 변환 (혹시 문자열일 경우 대비)
const classIndex = parseInt(cls, 10) || 0;
// 모듈러(%) 연산을 사용해 색상 배열을 순환
const color = CLASS_COLORS[classIndex % CLASS_COLORS.length];
const bbox = document.createElement('div');
bbox.className = 'bbox';
bbox.style.left = `${boxLeft}px`;
bbox.style.top = `${boxTop}px`;
bbox.style.width = `${boxWidth}px`;
bbox.style.height = `${boxHeight}px`;
// [추가] CSS의 'border' 대신 'borderColor'를 동적으로 설정
// 이렇게 하면 style.css의 'border-width', 'border-style'은 유지됩니다.
bbox.style.borderColor = color;
bboxContainerEl.appendChild(bbox);
});
detListEl.textContent = lines.join("\n");
}
// WebSocket 연결 함수
function connect() {
const ws = new WebSocket(uri);
ws.binaryType = "arraybuffer";
ws.onopen = () => {
logStatus(`연결됨: ${uri}`);
};
ws.onclose = (ev) => {
logStatus(`연결 종료 (code=${ev.code}) 재접속 대기중...`, true);
setTimeout(connect, 2000);
};
ws.onerror = (err) => {
logStatus("WebSocket 오류 발생", true);
console.error(err);
};
ws.onmessage = (event) => {
const data = event.data;
if (typeof data === "string") {
try {
const meta = JSON.parse(data);
const t = meta.type;
if (t === "frame") {
lastFrameMeta = meta;
showFrameMeta(meta);
} else if (t === "det") {
showDetections(meta);
}
} catch (e) {
console.error("JSON parse error:", e, data);
}
return;
}
// [수정] ArrayBuffer 처리: img.src 대신 createImageBitmap 및 canvas.drawImage 사용
if (data instanceof ArrayBuffer && lastFrameMeta) {
// ========== [수정됨] FPS 계산 로직 (이곳으로 이동) ==========
// WebSocket에서 ArrayBuffer(프레임)를 수신한 시점을 기준으로 FPS 계산
frameCount++;
const now = performance.now();
const delta = now - lastFpsCheckTime;
if (delta >= 1000 && fpsDisplayEl) { // 1초마다 업데이트
const fps = (frameCount * 1000) / delta;
fpsDisplayEl.textContent = `${Math.round(fps)} FPS`;
frameCount = 0;
lastFpsCheckTime = now;
}
// ========== FPS 계산 로직 끝 ==========
const blob = new Blob([data], {type: "image/jpeg"});
// createImageBitmap으로 비동기 디코딩 및 렌더링
createImageBitmap(blob)
.then(imageBitmap => {
// 캔버스 크기를 CSS에 정의된 표시 크기로 맞춥니다.
// (object-fit: contain을 시뮬레이션하기 위해 매번 필요)
canvasEl.width = canvasEl.clientWidth;
canvasEl.height = canvasEl.clientHeight;
const canvasWidth = canvasEl.width;
const canvasHeight = canvasEl.height;
const frameWidth = imageBitmap.width; // 실제 이미지 비트맵의 너비
const frameHeight = imageBitmap.height; // 실제 이미지 비트맵의 높이
// 'object-fit: contain' 로직
const widthRatio = canvasWidth / frameWidth;
const heightRatio = canvasHeight / frameHeight;
const ratio = Math.min(widthRatio, heightRatio);
const videoDisplayWidth = frameWidth * ratio;
const videoDisplayHeight = frameHeight * ratio;
const offsetX = (canvasWidth - videoDisplayWidth) / 2;
const offsetY = (canvasHeight - videoDisplayHeight) / 2;
// 캔버스를 지웁니다 (CSS의 background-color가 비친다)
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// 계산된 위치에 이미지를 그립니다.
ctx.drawImage(imageBitmap, offsetX, offsetY, videoDisplayWidth, videoDisplayHeight);
// 비트맵 메모리 해제
imageBitmap.close();
// 프레임이 그려진 *이후*에 바운딩 박스 컨테이너 위치 업데이트
updateBboxContainerPosition();
// [수정됨] FPS 계산 로직이 위로 이동했으므로 여기서는 제거됩니다.
})
.catch(e => {
console.error("createImageBitmap error:", e);
});
}
};
function updateModelDisplay() {
if(!modelContainerEl || !modelDisplayEl) return;
const checked = modelContainerEl.querySelector('input:checked');
if(checked) {
const label = modelContainerEl.querySelector(`label[for="${checked.id}"]`);
modelDisplayEl.textContent = label ? label.textContent : checked.value;
}
}
// [추가] 초기 모델 이름 설정
updateModelDisplay();
// WebSocket 연결 시작
connect();
// 창 크기 변경 시 이벤트
window.addEventListener('resize', updateBboxContainerPosition);
} // if (요소 확인) 끝
// ========== video-test.html 스크립트 추가 끝 ==========
// ========== 페이지 로드 시 모델 목록 즉시 로드 ==========
updateModelDisplay();
if (canvasEl) connect();
window.addEventListener('resize', updateBboxContainerPosition);
loadPoiList();
loadModelList();
});

@ -20,20 +20,15 @@
<main>
<div id="content-video" class="tab-content active">
<div class="video-container">
<div class="video-player">
<div id="video-wrap">
<div id="info">
<span id="status" class="status">연결 시도 중...</span>
<span id="frame-info"></span>
</div>
<span id="current-model-display" class="video-overlay top-right"></span>
<span id="current-model-display" class="video-overlay top-right hidden"></span>
<span id="fps-display" class="video-overlay bottom-right"></span>
<canvas id="frame"></canvas>
<div id="bbox-container"></div>
</div>
@ -65,13 +60,12 @@
<label for="model-viptrack" class="segmented-control-button">관심인물추적</label>
</div>
</div>
</div>
</div>
<div id="content-models" class="tab-content">
<div class="toolbar">
<label>AI 모델파일 관리 </label>
<label class="section-title">AI 모델파일 관리</label>
<input type="file" id="global-file-input" class="hidden-file-input" accept=".aiwbin">
<label for="global-file-input" class="btn-browse">찾아보기</label>
<span id="file-name-display">선택된 파일 없음</span>
@ -82,62 +76,77 @@
<thead>
<tr>
<th>번호</th>
<th>Al Model 역할</th>
<th>AI Model 역할</th>
<th>파일명</th>
<th>현재 버전</th>
<th>삭제</th>
</tr>
</thead>
<tbody>
<tr data-role="OBJDET">
<td>1</td>
<td>객체 탐지/ 분류</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td>
<button class="btn-delete">삭제</button>
</td>
<td>1</td><td>객체 탐지/ 분류</td><td class="model-filename">-</td><td class="model-version">v1.0</td><td><button class="btn-delete">삭제</button></td>
</tr>
<tr data-role="FIRE">
<td>2</td>
<td>화재(불꽃, 연기) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td>
<button class="btn-delete">삭제</button>
</td>
<td>2</td><td>화재(불꽃, 연기) 감지</td><td class="model-filename">-</td><td class="model-version">v1.0</td><td><button class="btn-delete">삭제</button></td>
</tr>
<tr data-role="ABNORM">
<td>3</td>
<td>이상행동(쓰러짐, 폭행) 감지</td>
<td class="model-filename">-</td>
<td class="model-version">v1.0</td>
<td>
<button class="btn-delete">삭제</button>
</td>
<td>3</td><td>이상행동(쓰러짐, 폭행) 감지</td><td class="model-filename">-</td><td class="model-version">v1.0</td><td><button class="btn-delete">삭제</button></td>
</tr>
<tr data-role="FACE">
<td>4</td>
<td>얼굴/ 인상착의 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td>
<button class="btn-delete">삭제</button>
</td>
<tr data-role="FACEATTR">
<td>4</td><td>얼굴/ 인상착의 인식</td><td class="model-filename">-</td><td class="model-version">-</td><td><button class="btn-delete">삭제</button></td>
</tr>
<tr data-role="LPR">
<td>5</td>
<td>차량 번호판/ 차종 인식</td>
<td class="model-filename">-</td>
<td class="model-version">-</td>
<td>
<button class="btn-delete">삭제</button>
</td>
<td>5</td><td>차량 번호판/ 차종 인식</td><td class="model-filename">-</td><td class="model-version">-</td><td><button class="btn-delete">삭제</button></td>
</tr>
</tbody>
</table>
<div class="divider"></div>
<div class="toolbar">
<label class="section-title">관심 인물파일 관리</label>
<div class="right-actions">
<button id="btn-poi-register" class="btn-white">등록</button>
<button id="btn-poi-refresh" class="btn-white">새로 고침</button>
</div>
</div>
<table class="model-table" id="poi-table">
<thead>
<tr>
<th style="width: 10%;">번호</th>
<th style="width: 30%;">관심인물명</th>
<th style="width: 30%;">미리 보기</th>
<th style="width: 30%;">파일 등록</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<div id="poi-empty-msg" class="empty-message hidden">
등록된 관심 인물이 없습니다
</div>
</div>
</main>
<div id="poi-modal" class="modal hidden">
<div class="modal-content">
<h3>관심인물 등록</h3>
<div class="modal-body">
<input type="text" id="poi-name-input" placeholder="관심인물명(영문숫자만 가능)" maxlength="20">
</div>
<div class="modal-actions">
<button id="btn-modal-confirm" class="btn-modal-confirm">확인</button>
<button id="btn-modal-cancel" class="btn-modal-cancel">취소</button>
</div>
</div>
</div>
<div id="image-viewer-modal" class="modal hidden" style="background-color: rgba(0,0,0,0.9);">
<span class="close-viewer">&times;</span>
<img class="modal-image" id="full-image">
</div>
<script src="app.js"></script>
</body>
</html>

@ -1,104 +1,72 @@
/* style.css */
/* 기본 및 공통 스타일 */
body {
font-family: Arial, sans-serif;
margin: 0;
padding: 0;
background-color: #f0f2f5; /* 기본 배경색 (로그인 페이지 등에서 사용) */
color: #333; /* 기본 글자색 (로그인 페이지 등에서 사용) */
/* [수정] flexbox 레이아웃 적용 */
background-color: #f0f2f5;
color: #333;
display: flex;
flex-direction: column;
height: 100vh; /* 전체 뷰포트 높이 사용 */
}
/* [추가] 로그인 페이지 좌측 상단 타이틀 */
.page-title {
position: absolute; /* 페이지 기준 절대 위치 */
top: 2rem; /* 위쪽 여백 */
left: 4rem; /* 왼쪽 여백 */
font-size: 2.5rem; /* 폰트 크기 */
color: #f0f0f0; /* 요청대로 밝은 색상 (흰색에 가까움) */
font-weight: 600;
/* 배경 이미지와 구분을 위한 텍스트 그림자 */
text-shadow: 0 2px 5px rgba(0, 0, 0, 0.6);
z-index: 10; /* 다른 요소들 위에 표시 */
height: 100vh;
}
/* ========== [Dark Theme 수정] ========== */
/* 버튼 기본 스타일 reset 및 공통 */
button {
cursor: pointer;
border-radius: 4px;
/* [수정] 다크 테마 기본 버튼 스타일 */
border: 1px solid #555;
background-color: #3a3a3a;
color: #e0e0e0;
padding: 5px 10px;
}
/* ====================================== */
/* 1. 로그인 페이지 스타일 (index.html) */
/* ... (기존 로그인 스타일 동일 - 다크 테마의 영향을 받지 않도록 고유 스타일 유지) ... */
/* 로그인 페이지 등 배경 스타일 */
.login-page {
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
/* ========== [수정됨] 배경 이미지 적용 ========== */
background-image: url('drone_background.png'); /* 배경 이미지 파일 경로 */
background-size: cover; /* 화면을 꽉 채우도록 (비율 유지) */
background-position: center; /* 이미지 중앙 정렬 */
background-repeat: no-repeat; /* 이미지 반복 안 함 */
/* background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); <-- 기존 배경 제거 */
/* ============================================ */
background: #f0f2f5;
}
.login-container {
/* [수정] 배경색을 body 기본 배경색과 유사하게 변경 */
background: #f0f2f5;
background: #ffffff;
padding: 2rem 3rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
text-align: center;
/* [추가] 너비를 기존보다 2배가량 넓게 설정 (예: 700px) */
width: 700px;
/* [추가] width 사용 시 padding이 너비에 포함되도록 설정 */
width: 400px;
box-sizing: border-box;
}
.login-container h1 {
font-size: 1.5rem;
color: #444; /* body의 color(#e0e0e0)를 상속받지 않도록 명시 */
margin-bottom: 2rem;
font-size: 2.2rem;
color: #333;
margin-bottom: 0.5rem;
font-weight: bold;
}
.login-subtext {
font-size: 1rem;
color: #888;
margin: 0 0 2.5rem 0;
}
.input-group {
margin-bottom: 1rem;
position: relative;
text-align: left;
}
.input-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #333; /* body의 color(#e0e0e0)를 상속받지 않도록 명시 */
}
.input-group input {
width: 100%;
padding: 0.8rem;
padding: 1rem 1rem 1rem 3rem;
border: 1px solid #ddd;
border-radius: 4px;
box-sizing: border-box;
background-color: #fff; /* 다크 테마 상속 방지 */
color: #333; /* 다크 테마 상속 방지 */
background-color: #fff;
color: #333;
font-size: 1rem;
}
.login-button {
width: 100%;
padding: 0.8rem;
width: auto;
padding: 0.8rem 2.5rem;
background-color: #5a67d8;
color: white;
border: none;
@ -106,488 +74,241 @@ button {
font-weight: bold;
margin-top: 1rem;
}
.login-button:hover {
background-color: #434190;
.login-button:hover { background-color: #434190; }
.footer-bar {
position: absolute; bottom: 0; left: 0; width: 100%; height: 50px;
background-color: #ffffff; opacity: 0.7; z-index: 100;
display: flex; justify-content: center; align-items: center; gap: 20px;
}
/* 2. 대시보드 페이지 스타일 (dashboard.html) */
/* 대시보드 헤더 */
header {
background-color: #333; /* 이미 어두움 */
background-color: #333;
padding: 0 1rem;
/* [수정] 크기 고정 */
flex-shrink: 0;
}
.tabs {
display: flex;
justify-content: space-between;
align-items: center;
}
.tabs { display: flex; justify-content: space-between; align-items: center; }
.tab-button {
background: none;
border: none;
color: #aaa; /* 이미 밝음 */
padding: 1rem 1.5rem;
font-size: 1rem;
font-weight: bold;
}
.tab-button.active {
color: white;
border-bottom: 3px solid #5a67d8;
background: none; border: none; color: #aaa;
padding: 1rem 1.5rem; font-size: 1rem; font-weight: bold;
}
.tab-button.active { color: white; border-bottom: 3px solid #5a67d8; }
.logout-button {
background-color: #d9534f;
color: white;
border: none;
padding: 0.5rem 1rem;
border-radius: 4px;
font-size: 0.9rem;
font-weight: bold;
cursor: pointer;
}
.logout-button:hover {
background-color: #c9302c;
background-color: #d9534f; color: white; border: none;
padding: 0.5rem 1rem; border-radius: 4px; font-weight: bold;
}
/* ========== [Dark Theme 수정] ========== */
/* 메인 컨텐츠 (다크 테마) */
main {
padding: 1rem;
/* [수정] 남은 공간을 모두 채움 */
flex-grow: 1;
display: flex;
flex-direction: column;
min-height: 0; /* 내용이 많을 때 축소 가능하도록 */
/* [추가] 다크 테마 배경 및 글자색 */
min-height: 0;
background-color: #1a1a1a;
color: #e0e0e0;
}
/* ====================================== */
.tab-content {
display: none;
}
.tab-content { display: none; }
.tab-content.active {
/* [수정] display: block -> flex */
display: flex;
flex-direction: column;
/* [수정] 부모(main) 공간을 채움 */
flex-grow: 1;
min-height: 0;
display: flex; flex-direction: column; flex-grow: 1; min-height: 0;
}
/* 2-1. Video 탭 (PDF 2페이지) */
/* (이 부분은 대부분 이미 어두운 테마 스타일을 가지고 있음) */
/* [추가] .video-container가 공간을 채우도록 */
.video-container {
display: flex;
flex-direction: column;
flex-grow: 1;
min-height: 0;
}
/* ========== .video-player 스타일 수정 ========== */
/* Video 스타일 */
.video-container { display: flex; flex-direction: column; flex-grow: 1; min-height: 0; }
.video-player {
width: 100%;
/* [제거] min-height: 720px; */
/* [추가] .video-container 공간을 채움 */
flex-grow: 1;
min-height: 0; /* 축소 가능하도록 */
background-color: #111; /* video-test.html 배경색 적용 (이미 어두움) */
color: #eee; /* video-test.html 글자색 적용 (이미 밝음) */
display: flex;
flex-direction: row; /* 가로 정렬로 변경 */
gap: 10px; /* video-test.html gap 적용 */
padding: 10px; /* video-test.html padding 적용 */
box-sizing: border-box;/* video-test.html box-sizing 적용 */
border-radius: 4px;
position: relative;
}
/* ========== [수정됨] #current-model <select> 스타일 -> Segmented Control 스타일로 수정 ========== */
width: 100%; flex-grow: 1; min-height: 0;
background-color: #111; color: #eee;
display: flex; flex-direction: row; gap: 10px;
padding: 10px; box-sizing: border-box; border-radius: 4px; position: relative;
}
#video-wrap { flex: 2; display: flex; flex-direction: column; gap: 4px; position: relative; }
#frame { flex: 1; min-height: 0; max-width: 100%; background: #000; border: 1px solid #333; z-index: 1; }
#det-wrap { flex: 1; display: flex; flex-direction: column; gap: 4px; z-index: 1; }
#det-list { flex: 1; font-size: 11px; background: #000; border: 1px solid #333; padding: 6px; overflow-y: auto; white-space: pre; }
#bbox-container { position: absolute; pointer-events: none; z-index: 5; top: 0; left: 0; }
.bbox { position: absolute; border: 2px solid red; box-sizing: border-box; }
/* Segmented Control (모델 선택) */
.segmented-control-container {
position: absolute;
top: 40px; /* #info (12px) 아래 여유 공간 */
left: 20px;
z-index: 10;
display: flex;
flex-wrap: wrap; /* 좁은 화면에서 줄바꿈 */
gap: 4px; /* 버튼 사이 간격 */
background-color: rgba(50, 50, 50, 0.7); /* 반투명 배경 (이미 어두움) */
padding: 4px;
border-radius: 6px;
}
/* 실제 라디오 버튼은 숨김 */
.segmented-control-container input[type="radio"] {
display: none;
position: absolute; top: 40px; left: 20px; z-index: 10;
display: flex; flex-wrap: wrap; gap: 4px;
background-color: rgba(50, 50, 50, 0.7); padding: 4px; border-radius: 6px;
}
/* 버튼처럼 보일 라벨 스타일 */
.segmented-control-container input[type="radio"] { display: none; }
.segmented-control-button {
display: inline-block;
padding: 6px 10px;
font-size: 0.85rem; /* 폰트 약간 작게 */
color: #eee;
background-color: #555;
border: 1px solid #777;
border-radius: 4px;
cursor: pointer;
transition: background-color 0.2s, color 0.2s;
user-select: none; /* 텍스트 선택 방지 */
display: inline-block; padding: 6px 10px; font-size: 0.85rem;
color: #eee; background-color: #555; border: 1px solid #777; border-radius: 4px;
cursor: pointer; user-select: none;
}
/* 마우스 오버시 */
.segmented-control-button:hover {
background-color: #666;
}
/* 선택된(checked) 라디오 버튼의 *다음* 형제(label) 스타일 */
.segmented-control-container input[type="radio"]:checked + .segmented-control-button {
background-color: #5a67d8; /* 활성 탭 색상과 유사하게 */
color: white;
border-color: #434190;
font-weight: bold;
}
/* ========== [수정됨] 스타일 변경 끝 ========== */
/* ========== video-test.html 스타일 추가 (이미 어두움) ========== */
#video-wrap {
flex: 2;
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
}
#info {
font-size: 12px;
color: #aaa;
position: relative;
z-index: 10;
}
#frame {
flex: 1;
min-height: 0;
max-width: 100%;
background: #000;
border: 1px solid #333;
position: relative;
z-index: 1;
}
#det-wrap {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
position: relative;
z-index: 1;
}
#det-title {
font-weight: bold;
font-size: 13px;
/* main으로부터 color: #e0e0e0 상속받음 */
}
#det-list {
flex: 1;
font-size: 11px;
background: #000;
border: 1px solid #333;
padding: 6px;
overflow-y: auto;
white-space: pre;
/* .video-player로부터 color: #eee 상속받음 */
}
.status {
font-size: 11px;
color: #6cf;
}
.status.err {
color: #f66;
}
/* 2-2. AI Models 탭 (PDF 3페이지) */
/* [수정됨] .toolbar 스타일 */
.toolbar {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
margin-bottom: 1rem;
background-color: #5a67d8; color: white; border-color: #434190; font-weight: bold;
}
/* [추가됨] 새로고침 버튼을 맨 오른쪽으로 밀기 */
.toolbar .refresh-button {
margin-left: auto;
/* Video Overlays */
.video-overlay {
position: absolute; z-index: 10;
background-color: rgba(0, 0, 0, 0.5); color: #ffffff;
padding: 4px 8px; border-radius: 4px; font-weight: bold; pointer-events: none;
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.video-overlay.top-right { top: 100px; right: 10px; font-size: 60px; }
.video-overlay.bottom-right { bottom: 70px; right: 10px; font-size: 28px; color: #FFEE00; }
/* ========== [Dark Theme 수정] ========== */
/* [추가됨] 파일명 표시 레이블 */
#file-name-display {
font-size: 0.9rem;
/* [수정] 글자색 */
color: #ccc;
/* [수정] 배경색 */
background-color: #2a2a2a;
/* [수정] 테두리 */
border: 1px solid #444;
padding: 5px 10px;
border-radius: 4px;
min-width: 350px;
text-align: left;
font-style: italic;
line-height: 1.4;
}
/* ========================================================== */
/* [설정 탭 - AI 모델 & 관심 인물 관리 스타일] */
/* ========================================================== */
.hidden-file-input {
display: none;
.toolbar {
display: flex; align-items: center; gap: 10px; margin-bottom: 1rem;
}
.section-title { font-size: 1.2rem; font-weight: bold; margin-right: 10px; }
.right-actions { margin-left: auto; display: flex; gap: 10px; }
/* 공통 테이블 스타일 */
.model-table {
width: 100%;
border-collapse: collapse;
/* [수정] 배경색 */
background: #2a2a2a;
box-shadow: none; /* 다크 모드에서는 그림자 제거 */
width: 100%; border-collapse: collapse; background: #2a2a2a;
}
.model-table th,
.model-table td {
/* [수정] 테두리 */
border: 1px solid #444;
padding: 0.8rem 1rem;
text-align: left;
/* main으로부터 color: #e0e0e0 상속받음 */
.model-table th, .model-table td {
border: 1px solid #444; padding: 0.8rem 1rem; vertical-align: middle;
}
.model-table th { background-color: #333; text-align: center; }
.model-table td:first-child { text-align: center; } /* 번호 */
.model-table td { text-align: center; } /* 기본 중앙 정렬 */
.model-table th {
/* [수정] 배경색 */
background-color: #333;
text-align: center;
}
/* ====================================== */
/* [수정됨] nth-child 선택자 수정 (컬럼 1, 4 중앙 정렬) */
.model-table td:nth-child(1),
.model-table td:nth-child(4) {
text-align: center;
}
/* [수정됨] nth-child 선택자 수정 (컬럼 5 '삭제' 중앙 정렬) */
.model-table td:nth-child(5) {
text-align: center;
/* 파일명 표시 input */
#file-name-display {
font-size: 0.9rem; color: #ccc; background-color: #2a2a2a;
border: 1px solid #444; padding: 5px 10px; border-radius: 4px;
min-width: 300px; text-align: left; font-style: italic;
}
.hidden-file-input { display: none; }
.hidden { display: none !important; }
/* ========== [Dark Theme 수정] ========== */
/* 버튼 스타일 */
.btn-browse {
cursor: pointer;
border-radius: 4px;
/* [수정] 테두리 */
border: 1px solid #666;
padding: 5px 10px;
display: inline-block;
font-size: 14px;
font-family: Arial, sans-serif;
line-height: normal;
/* [수정] 배경색 */
background-color: #4f4f4f;
/* [수정] 글자색 (기본 button 스타일에서 상속) */
/* color: #e0e0e0; */
}
.btn-browse:hover {
/* [수정] hover 배경색 */
background-color: #5a5a5a;
cursor: pointer; border-radius: 4px; border: 1px solid #666;
padding: 5px 10px; font-size: 14px; background-color: #4f4f4f; color: #e0e0e0;
}
/* ====================================== */
.btn-browse:hover { background-color: #5a5a5a; }
.btn-action { background-color: #4CAF50; color: white; border:none; }
.btn-delete { background-color: #f44336; color: white; border:none; }
.btn-action { background-color: #4CAF50; color: white; border: none; }
.btn-delete { background-color: #f44336; color: white; border: none; }
.refresh-button { margin-left: auto; }
/* [추가됨] 바운딩 박스 스타일 */
#bbox-container {
position: absolute;
pointer-events: none; /* 클릭 이벤트 방지 */
z-index: 5; /* 프레임(1) 위, 정보(10) 아래 */
top: 0;
left: 0;
/* 흰색 배경 버튼 (UI 디자인 반영) */
.btn-white {
background-color: #fff; color: #333; border: 1px solid #ccc; font-weight: bold;
}
.btn-white:hover { background-color: #f0f0f0; }
.bbox {
position: absolute;
border: 2px solid red;
box-sizing: border-box;
}
/* [추가] 요소 숨기기 위한 유틸리티 클래스 */
.hidden {
display: none;
/* 구분선 */
.divider {
height: 1px; background-color: #444; margin: 3rem 0 2rem 0; border: none;
}
/* ... (파일 상단 body, button 등 공통 스타일은 그대로 둡니다) ... */
/* ========================================================== */
/* [신규] 관심 인물(POI) 관리 전용 스타일 */
/* ========================================================== */
/* 빈 목록 메시지 */
.empty-message {
text-align: center; padding: 4rem 0; color: #888; font-size: 1.1rem;
}
/* 1. 로그인 페이지 스타일 (index.html) */
/* [수정] 이미지와 유사하게 변경 */
.login-page {
/* 미리보기 컨테이너 (세로 정렬) */
.preview-container {
display: flex;
justify-content: center;
flex-direction: column;
gap: 8px;
align-items: center;
height: 100vh;
/* [수정] 배경 이미지 제거 */
/* background-image: url('drone_background.png'); */
/* background-size: cover; */
/* background-position: center; */
/* background-repeat: no-repeat; */
/* body의 기본 배경색(#f0f2f5)이 적용됩니다. */
}
.login-container {
/* [수정] 배경색을 흰색으로 변경 */
background: #ffffff;
padding: 2rem 3rem;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
/* [수정] text-align 제거 (기본값 left) */
/* text-align: center; */
/* [수정] 너비를 좁게 설정 (예: 400px) */
width: 400px;
box-sizing: border-box;
}
.login-container h1 {
/* [수정] 폰트 크기 및 여백 조정 */
font-size: 2.2rem; /* 이미지처럼 크게 */
color: #333; /* 다크 테마 상속 방지 (기존 #444보다 진하게) */
margin-bottom: 0.5rem; /* 부제목과 가깝게 */
font-weight: bold;
}
/* [추가] 부제목 스타일 */
.login-subtext {
font-size: 1rem;
color: #888; /* 이미지처럼 연한 회색 */
margin-top: 0;
margin-bottom: 2.5rem; /* 입력창과 여백 */
}
.input-group {
margin-bottom: 1rem;
text-align: left;
/* [추가] 아이콘 배치를 위해 relative 속성 추가 */
position: relative;
}
.input-group label {
/* 이 스타일은 더 이상 사용되지 않지만, 다른 곳에서 쓸 수 있으므로 둡니다. */
display: block;
margin-bottom: 0.5rem;
font-weight: bold;
color: #333;
}
/* [추가] 아이콘 스타일 */
.input-group .icon {
position: absolute;
left: 1rem; /* input의 padding-left와 유사하게 */
top: 50%;
transform: translateY(-50%);
color: #aaa;
font-size: 1.1rem;
/* 아이콘이 입력 이벤트를 가로채지 않도록 */
pointer-events: none;
justify-content: center;
}
.input-group input {
width: 100%;
/* [수정] 아이콘 공간 확보를 위해 padding-left 늘림 */
padding: 1rem 1rem 1rem 3rem;
border: 1px solid #ddd;
/* 개별 이미지 아이템 (이미지 + 삭제버튼) */
.preview-item {
display: flex;
align-items: center;
gap: 10px;
background: rgba(255, 255, 255, 0.05);
padding: 4px;
border-radius: 4px;
box-sizing: border-box;
background-color: #fff;
color: #333;
font-size: 1rem; /* placeholder 글자 크기 */
}
.login-button {
/* [수정] width: 100% 제거 (auto로 변경) */
width: auto;
/* [수정] 이미지와 유사한 padding으로 변경 */
padding: 0.8rem 2.5rem;
background-color: #5a67d8;
color: white;
border: none;
font-size: 1rem;
font-weight: bold;
margin-top: 1rem;
}
.login-button:hover {
background-color: #434190;
/* 미리보기 이미지 크기 */
.poi-preview {
width: 50px; height: 50px; object-fit: cover;
border-radius: 4px; border: 1px solid #555; display: block; margin: 0 auto;
cursor: pointer; /* 클릭 가능 표시 */
transition: 0.3s;
}
.poi-preview:hover { opacity: 0.7; }
/* 삭제 버튼 공통 */
.btn-delete-small {
background-color: #fff; border: 1px solid #ccc; color: #333;
padding: 4px 10px; margin-left: 10px; cursor: pointer; font-size: 12px;
border-radius: 2px;
}
.btn-delete-small:hover { background-color: #eee; }
/* 모달 관련 스타일 (기존 유지) */
.modal {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background-color: rgba(0, 0, 0, 0.5);
display: flex; justify-content: center; align-items: center; z-index: 2000;
}
.modal-content {
background-color: #fff; padding: 2rem; width: 400px; box-shadow: 0 4px 10px rgba(0,0,0,0.3); color: #333;
}
.modal-content h3 { margin-top: 0; margin-bottom: 2rem; text-align: left; font-size: 1.2rem; font-weight: bold; }
.modal-body { margin-bottom: 2rem; }
.modal-body input { width: 100%; padding: 10px; border: 1px solid #ccc; text-align: center; box-sizing: border-box; }
.modal-actions { display: flex; justify-content: center; gap: 20px; }
.modal-actions button { width: 100px; padding: 8px 0; font-weight: bold; cursor: pointer; }
.btn-modal-confirm { background-color: #fff; color: #333; border: 1px solid #999; }
.btn-modal-cancel { background-color: #fff; color: #333; border: 1px solid #999; }
.btn-modal-confirm:hover, .btn-modal-cancel:hover { background-color: #eee; }
/* ========================================================== */
/* [신규] 이미지 뷰어 모달 스타일 */
/* ========================================================== */
/* 뷰어 모달 이미지 */
.modal-image {
margin: auto;
display: block;
max-width: 90%;
max-height: 90%;
border-radius: 5px;
animation-name: zoom;
animation-duration: 0.4s;
}
/* ... (기존 CSS 코드) ... */
/* [수정] 로그인 페이지 하단 흰색 바 */
.footer-bar {
position: absolute; /* 뷰포트(페이지 전체) 기준 */
bottom: 0; /* 하단에 고정 */
left: 0; /* 왼쪽 끝 */
width: 100%; /* 전체 너비 */
height: 50px; /* 요청한 높이 50px */
background-color: #ffffff; /* 흰색 배경 */
opacity: 0.5; /* [수정] 70% 투명도 (0.3 -> 0.7) */
z-index: 100;
/* [추가] Flexbox를 사용하여 중앙 정렬 및 간격 설정 */
display: flex;
justify-content: center; /* 수평 중앙 정렬 */
align-items: center; /* 수직 중앙 정렬 */
gap: 20px; /* 자식 요소(이미지) 사이의 간격 */
/* 확대 애니메이션 */
@keyframes zoom {
from {transform:scale(0)}
to {transform:scale(1)}
}
/* ====================================================== */
/* [수정] 비디오 오버레이 (FPS, 모델명) 스타일 */
/* ====================================================== */
.video-overlay {
/* 닫기 버튼 (X) */
.close-viewer {
position: absolute;
z-index: 10; /* #info와 동일한 레벨 */
background-color: rgba(0, 0, 0, 0.5); /* 반투명 검은 배경 */
color: #ffffff; /* 흰색 텍스트 */
padding: 4px 8px;
border-radius: 4px;
font-size: 14px; /* 기본 폰트 크기 */
top: 20px;
right: 35px;
color: #f1f1f1;
font-size: 40px;
font-weight: bold;
pointer-events: none; /* 클릭 이벤트 방지 */
white-space: nowrap; /* 줄바꿈 방지 */
/* [추가] 요청사항: 가독성을 위한 그림자 */
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.8);
}
.video-overlay.top-right {
top: 100px;
right: 10px;
/* [추가] 요청사항: AI 모델명 3배 크게 (14px * 3) */
font-size: 60px;
transition: 0.3s;
cursor: pointer;
z-index: 2001;
}
.video-overlay.bottom-right {
/* [수정] 요청사항: 10px -> 30px (조금 더 위로) */
bottom: 70px;
right: 10px;
/* [추가] 요청사항: FPS 표시 2배 크게 (14px * 2) */
font-size: 28px;
/* [추가] 요청사항: 폰트 색상 노란색 */
color: #FFEE00;
.close-viewer:hover,
.close-viewer:focus {
color: #bbb;
text-decoration: none;
cursor: pointer;
}

@ -1,249 +1,306 @@
// server.js
// 1. 모듈 불러오기
const express = require('express');
const path = require('path');
const { spawn } = require('child_process');
const multer = require('multer'); // 파일 업로드 처리를 위한 multer
const fs = require('fs'); // 파일 시스템 접근을 위한 fs
const multer = require('multer');
const fs = require('fs');
// 2. Express 앱 생성
const app = express();
const port = 3000;
// 업로드 경로 설정
const uploadPath = '/mnt/user_data/applications/misc/networks/cuuva';
// ========================================================
// [설정] 경로 및 폴더 초기화
// ========================================================
// 업로드 경로가 없으면 생성
// AI 모델용 업로드 경로
const uploadPath = '/mnt/user_data/applications/misc/networks/cuuva';
fs.mkdirSync(uploadPath, { recursive: true });
// Multer 저장소 설정
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, uploadPath);
},
filename: (req, file, cb) => {
cb(null, file.originalname);
}
// 관심 인물(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); }
});
// Multer 파일 필터 설정 (.aiwbin 확장자만 허용)
const fileFilter = (req, file, cb) => {
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);
};
// Multer 업로드 인스턴스 생성
const upload = multer({
storage: storage,
fileFilter: fileFilter
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);
};
// 3. 'public' 폴더의 파일들을 정적 파일로 제공하도록 설정합니다.
app.use(express.static(path.join(__dirname, 'public')));
const uploadPoi = multer({ storage: poiStorage, fileFilter: poiFileFilter });
// POST 요청의 JSON 본문(body)을 파싱하기 위한 미들웨어
// ========================================================
// [미들웨어 및 라우트 설정]
// ========================================================
app.use(express.static(path.join(__dirname, 'public')));
app.use(express.json());
// 4. 루트 경로('/')로 접속하면 'index.html'(로그인 페이지)를 보냅니다.
// 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'));
});
// 5. '/dashboard' 경로로 접속하면 'dashboard.html'(메인 페이지)를 보냅니다.
app.get('/dashboard', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'dashboard.html'));
});
// 6. 모델 변경 API 엔드포인트 (*** 수정된 부분 ***)
app.post('/set-model', (req, res) => {
const { model } = req.body; // app.js에서 보낸 { model: "OBJDET" } 등
if (!model) {
return res.status(400).json({ status: 'error', message: '모델 값이 없습니다.' });
}
// ========================================================
// [API] AI 모델 관련
// ========================================================
console.log(`[서버] 모델 변경 명령 수신: ${model}`);
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]; // OBJDET, ABNORM, CROWD 등
console.log(`[서버] 실행: ${scriptCommand} ${args.join(' ')}`);
// 스크립트 파일에 실행 권한(chmod +x)이 있다고 가정합니다.
const args = [model];
const scriptProcess = spawn(scriptCommand, args);
let stdoutData = '';
let stderrData = '';
scriptProcess.stdout.on('data', (data) => {
console.log(`Script stdout: ${data}`);
stdoutData += data.toString();
});
scriptProcess.stderr.on('data', (data) => {
console.error(`Script stderr: ${data}`);
stderrData += data.toString();
});
scriptProcess.stdout.on('data', (data) => { stdoutData += data.toString(); });
scriptProcess.on('close', (code) => {
console.log(`Script process exited with code ${code}`);
if (code === 0) {
res.json({ status: 'success', message: `모델이 ${model}(으)로 변경됨`, output: stdoutData });
} else {
res.status(500).json({ status: 'error', message: '쉘 스크립트 실행 실패', error: stderrData });
res.status(500).json({ status: 'error', message: '쉘 스크립트 실행 실패' });
}
});
scriptProcess.on('error', (err) => {
console.error('[서버] 쉘 프로세스 시작 실패:', err);
// "ENOENT" 오류는
// 1) 스크립트 경로가 잘못되었거나,
// 2) 스크립트 파일에 실행 권한이 없을 때 자주 발생합니다.
res.status(500).json({ status: 'error', message: '프로세스 시작 실패', error: err.message });
});
// --- 스크립트 실행 끝 ---
});
// 7. 모델 파일 업로드 엔드포인트
app.post('/upload-model', (req, res) => {
upload.single('modelFile')(req, res, function (err) {
if (err instanceof multer.MulterError) {
console.error('[Multer Error]:', err.message);
return res.status(500).json({ status: 'error', message: `업로드 오류: ${err.message}` });
}
else if (err) {
console.error('[File Filter Error]:', err.message);
return res.status(400).json({ status: 'error', message: err.message });
}
if (!req.file) {
return res.status(400).json({ status: 'error', message: '업로드할 파일을 찾을 수 없습니다.' });
}
console.log(`[서버] 파일 업로드 성공:`);
console.log(` - 원본 파일명: ${req.file.originalname}`);
console.log(` - 저장 경로: ${req.file.path}`);
res.json({
status: 'success',
message: `파일 '${req.file.originalname}'이(가) 성공적으로 업로드되었습니다.`,
filePath: req.file.path
});
});
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 });
});
// ========== 모델 목록 조회 로직 시작 ==========
/**
* 버전 문자열을 비교합니다. (: "v1.1" > "v1.0")
*/
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;
}
// 7-1. 모델 목록 조회 엔드포인트
app.get('/list-models', (req, res) => {
const modelRegex = /^CUUVA_([A-Z]+)_v(\d+\.\d+)\.aiwbin$/;
const latestModels = {};
fs.readdir(uploadPath, (err, files) => {
if (err) {
console.error('[서버] 모델 디렉토리 읽기 실패:', err);
return res.status(500).json({ status: 'error', message: '서버에서 파일 목록을 읽을 수 없습니다.' });
}
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
};
latestModels[role] = { file: file, version: version };
}
}
});
console.log('[서버] 최신 모델 목록 조회:', latestModels);
res.json(latestModels);
});
});
// ========== 모델 목록 조회 로직 끝 ==========
// ========== [추가됨] 모델 파일 삭제 엔드포인트 ==========
app.post('/delete-model', (req, res) => {
const { filename } = req.body; // { "filename": "CUUVA_..." }
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 (!filename) {
return res.status(400).json({ status: 'error', message: '파일 이름이 제공되지 않았습니다.' });
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: '폴더 생성 실패' });
}
});
// [보안] Directory Traversal 공격 방지
// path.basename은 파일명에서 상위 디렉토리(.., /) 등을 제거합니다.
const safeFilename = path.basename(filename);
app.post('/poi/delete', (req, res) => {
const { name } = req.body;
const safeName = path.basename(name);
const targetDir = path.join(vipTracksPath, safeName);
// 만약 ".." 같은 것이 포함되어 safeFilename이 원래 filename과 다르다면, 비정상 요청
if (safeFilename !== filename) {
return res.status(400).json({ status: 'error', message: '잘못된 파일 이름 형식입니다.' });
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: '삭제 실패' });
}
});
// [보안] .aiwbin 파일만 삭제하도록 다시 한번 확인
if (!safeFilename.endsWith('.aiwbin')) {
return res.status(400).json({ status: 'error', message: '.aiwbin 파일만 삭제할 수 있습니다.' });
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: '이미지가 없습니다.' });
}
});
// 최종 삭제할 파일 경로
const filePath = path.join(uploadPath, safeFilename);
// [수정됨] 다중 파일 업로드 처리 (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만 가능)' });
}
console.log(`[서버] 파일 삭제 시도: ${filePath}`);
const poiName = req.body.poiName;
// fs.unlink로 파일 삭제
fs.unlink(filePath, (err) => {
if (err) {
// 파일이 존재하지 않는 경우 (이미 삭제되었거나, 잘못된 요청)
if (err.code === 'ENOENT') {
console.error(`[서버] 삭제 실패: 파일 없음 ${filePath}`, err);
return res.status(404).json({ status: 'error', message: '삭제할 파일을 서버에서 찾을 수 없습니다.' });
}
// 기타 오류 (예: 권한 문제)
console.error(`[서버] 파일 삭제 오류 ${filePath}:`, err);
return res.status(500).json({ status: 'error', message: '파일 삭제 중 서버 오류가 발생했습니다.' });
// 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);
}
// 삭제 성공
console.log(`[서버] 파일 삭제 성공: ${filePath}`);
res.json({ status: 'success', message: `파일 '${safeFilename}'(이)가 성공적으로 삭제되었습니다.` });
});
});
// ========== 모델 파일 삭제 엔드포인트 끝 ==========
// 업로드된 모든 파일을 순회하며 이동
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: '파일 이동 실패' });
}
});
// 8. 설정한 3000번 포트에서 서버가 요청을 기다리도록 실행합니다.
// 서버 실행
app.listen(port, () => {
console.log(`Server running on http://localhost:${port}`);
});
//
Loading…
Cancel
Save