- {/* 비디오 결과이면서 video_loc가 있을 경우 비디오 태그, 그 외엔 이미지 태그 */}
- {data.score !== undefined && data.image_loc ? (
-
- ) : (
-
{ e.target.src = 'https://via.placeholder.com/600x400?text=No+Image'; }}
- />
+
+ {/* 왼쪽 섹션: 미디어 플레이어 전용 배치 존 */}
+
+
+ {isVideo && mediaLoc ? (
+
+ ) : mediaLoc ? (
+
{ e.target.src = 'https://via.placeholder.com/600x400?text=No+Image'; }} />
+ ) : (
+
미디어가 존재하지 않습니다.
+ )}
+
+
+ {isVideo && mediaLoc && (
+
navigate('/video-timeline', { state: { ...data } })}
+ style={{ marginTop: '20px', width: '95%', padding: '15px 0', backgroundColor: 'transparent', color: '#39FF14', border: '1px solid rgba(57, 255, 20, 0.4)', borderRadius: '6px', fontSize: '13px', fontWeight: '700', letterSpacing: '1.5px', textTransform: 'uppercase', cursor: 'pointer', transition: 'all 0.2s ease-in-out', boxShadow: '0 2px 8px rgba(57, 255, 20, 0.05)' }}
+ onMouseEnter={(e) => { e.target.style.backgroundColor = 'rgba(57, 255, 20, 0.06)'; e.target.style.borderColor = '#39FF14'; e.target.style.boxShadow = '0 0 15px rgba(57, 255, 20, 0.15)'; }}
+ onMouseLeave={(e) => { e.target.style.backgroundColor = 'transparent'; e.target.style.borderColor = 'rgba(57, 255, 20, 0.4)'; e.target.style.boxShadow = '0 2px 8px rgba(57, 255, 20, 0.05)'; }}
+ >
+ EXPAND TIMELINE ANALYSIS
+
)}
+ {/* 오른쪽 섹션: 스코어 보드 or WARNING 메시지 */}
-
-
FINAL ANALYSIS
-
{label}
-
{displayProb}
-
-
-
-
BRIGHTNESS
-
{Number(face_brightness).toFixed(1)}%
-
+ {isWarning ? (
+ // [수정] WARNING일 때 — 경고 메시지만 표시, 메트릭 숨김
+
+
⚠
+
UNDETECTED
+
+ {data.result_msg || data.message || "얼굴을 탐지하지 못했습니다."}
+
-
-
FACE RATIO
-
{Number(face_ratio).toFixed(1)}%
-
-
-
-
MODEL CONFIDENCE
-
{Number(face_conf).toFixed(1)}%
-
-
+ ) : (
+ <>
+ {/* 종합 리포트 판정 카드 */}
+
+
FINAL ANALYSIS
+
+ {label}
+
+
{displayProb}
-
{data.message || (isInvalid ? "분석 데이터를 불러오지 못했습니다." : "정상 분석 리포트입니다.")}
-
-
+
+ {/* 하단 메트릭 상세 그리드 구조 */}
+
+
+
+
BRIGHTNESS
+
{Number(face_brightness).toFixed(1)}%
+
+
+
+
+
FACE RATIO
+
{Number(face_ratio).toFixed(1)}%
+
+
+
+
+
MODEL CONFIDENCE
+
{Number(face_conf).toFixed(1)}%
+
+
+
+
+ {data.message || (isInvalid ? "분석 데이터를 불러오지 못했습니다." : "정상 분석 리포트입니다.")}
+
+
+
+
+ >
+ )}
+
);
diff --git a/client/src/pages/HeatmapPage.js b/client/src/pages/HeatmapPage.js
new file mode 100644
index 0000000..e51c082
--- /dev/null
+++ b/client/src/pages/HeatmapPage.js
@@ -0,0 +1,458 @@
+import React, { useState, useEffect, useRef } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import axios from 'axios';
+
+const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:8000";
+const POLL_INTERVAL = 2000;
+
+const BRANCH_CONFIG = {
+ low: {
+ branch_level: 'low',
+ explainer_type: 'layercam', // low branch: LayerCAM
+ display_type: 'heatmap_bbox',
+ overlay_ratio: 0.7,
+ threshold: 0.9,
+ aug_smooth: false,
+ eigen_smooth: true,
+ },
+ high: {
+ branch_level: 'high',
+ explainer_type: 'xgradcam', // high branch: XGradCAM (허용값: gradcamelementwise, layercam, xgradcam)
+ display_type: 'heatmap_bbox',
+ overlay_ratio: 0.7,
+ threshold: 0.9,
+ aug_smooth: false,
+ eigen_smooth: true,
+ },
+};
+
+// API 응답에서 이미지 경로 추출
+// POST /explain/video/{id}/frame/{idx} → 202, body = "task_id_string"
+// GET /explain/frame/result/{task_id} → 200, body = "image_path_string" or { result_loc, ... }
+const extractTaskId = (data) => {
+ if (typeof data === 'string') return data;
+ return data?.task_id || data?.id || null;
+};
+
+const extractImagePath = (data) => {
+ if (typeof data === 'string') return data;
+ return data?.result_loc || data?.image_loc || data?.heatmap_loc || data?.file_loc || null;
+};
+
+const toAbsoluteUrl = (path) => {
+ if (!path) return null;
+ if (path.startsWith('http') || path.startsWith('blob')) return path;
+ return `${apiUrl}${path}`;
+};
+
+const HeatmapPage = ({ sessionUser }) => {
+ const { state } = useLocation();
+ const navigate = useNavigate();
+
+ const { video_id, frame_index, timestamp, fake_score, model_type, video_name } = state || {};
+
+ const [activeBranch, setActiveBranch] = useState('high');
+ // status: 'idle' | 'submitting' | 'polling' | 'done' | 'error'
+ const [status, setStatus] = useState('idle');
+ const [heatmapSrc, setHeatmapSrc] = useState(null);
+ const [taskId, setTaskId] = useState(null);
+ const [errorMsg, setErrorMsg] = useState('');
+ const [errorDetail, setErrorDetail] = useState(''); // 개발용 상세 에러
+
+ const pollingRef = useRef(null);
+ const isMounted = useRef(true);
+
+ useEffect(() => {
+ isMounted.current = true;
+ return () => {
+ isMounted.current = false;
+ stopPolling();
+ };
+ }, []);
+
+ const stopPolling = () => {
+ if (pollingRef.current) { clearInterval(pollingRef.current); pollingRef.current = null; }
+ };
+
+ const handleBranchChange = (branch) => {
+ if (status === 'submitting' || status === 'polling') return;
+ stopPolling();
+ setActiveBranch(branch);
+ setStatus('idle');
+ setHeatmapSrc(null);
+ setTaskId(null);
+ setErrorMsg('');
+ setErrorDetail('');
+ };
+
+ // ── STEP 1: POST 접수 → task_id 수신 ──
+ const requestHeatmap = async () => {
+ if (video_id == null) {
+ setErrorMsg('video_id가 없습니다.');
+ setErrorDetail('state에 video_id가 전달되지 않았습니다.');
+ setStatus('error');
+ return;
+ }
+
+ stopPolling();
+ setStatus('submitting');
+ setHeatmapSrc(null);
+ setErrorMsg('');
+ setErrorDetail('');
+ setTaskId(null);
+
+ const body = {
+ model_type: model_type || 'fast',
+ ...BRANCH_CONFIG[activeBranch],
+ };
+
+ try {
+ // POST /explain/video/{video_id}/frame/{frame_index}
+ // Response 202: "task_id_string"
+ const res = await axios.post(
+ `/explain/video/${video_id}/frame/${frame_index ?? 0}`,
+ body
+ );
+
+ const tid = extractTaskId(res.data);
+ if (!tid) {
+ throw new Error(`task_id 추출 실패. 응답: ${JSON.stringify(res.data)}`);
+ }
+
+ setTaskId(tid);
+ setStatus('polling');
+ startPolling(tid);
+ } catch (e) {
+ if (!isMounted.current) return;
+ const msg = e.response?.data?.detail || e.response?.data || e.message || '요청 실패';
+ setErrorMsg('히트맵 생성 요청 실패');
+ setErrorDetail(typeof msg === 'string' ? msg : JSON.stringify(msg));
+ setStatus('error');
+ }
+ };
+
+ // ── STEP 2: GET 폴링 → 결과 이미지 수신 ──
+ const startPolling = (tid) => {
+ let networkErrorCount = 0;
+ const MAX_ERRORS = 5;
+
+ pollingRef.current = setInterval(async () => {
+ if (!isMounted.current) return;
+ try {
+ const res = await axios.get(`/explain/frame/result/${tid}`);
+ networkErrorCount = 0;
+ const d = res.data;
+
+ // 디버그: 실제 응답 구조 확인 (확인 후 제거 가능)
+ console.log('[heatmap polling] response:', JSON.stringify(d));
+
+ if (d === null || d === undefined) return;
+
+ let loc = null;
+
+ if (typeof d === 'string' && d.length > 0) {
+ // 응답이 문자열: 이미지 경로인지 확인
+ if (d.includes('/') || d.match(/\.(png|jpg|jpeg|webp)/i)) {
+ loc = d;
+ }
+ } else if (typeof d === 'object') {
+ // 응답이 객체: 가능한 모든 경로 필드 시도
+ loc = d.result_loc ?? d.image_loc ?? d.heatmap_loc ?? d.file_loc
+ ?? d.result_path ?? d.path ?? d.url ?? d.image_url ?? d.output_path
+ ?? d.result ?? null;
+
+ if (!loc) {
+ const st = (d.status || '').toUpperCase();
+ if (st === 'FAILED' || st === 'ERROR') {
+ stopPolling();
+ setErrorMsg('히트맵 생성 실패');
+ setErrorDetail(d.result_msg || d.message || d.detail || '서버 오류');
+ setStatus('error');
+ return;
+ }
+ // PENDING / STARTED → 계속 폴링
+ return;
+ }
+ }
+
+ if (loc) {
+ stopPolling();
+ setHeatmapSrc(toAbsoluteUrl(loc));
+ setStatus('done');
+ }
+ } catch (e) {
+ networkErrorCount++;
+ console.warn(`[heatmap polling] 에러 ${networkErrorCount}/${MAX_ERRORS}:`, e.message);
+ if (networkErrorCount >= MAX_ERRORS) {
+ stopPolling();
+ setErrorMsg('서버 연결 실패');
+ setErrorDetail(`${MAX_ERRORS}회 연속 연결 오류. 백엔드 서버 상태를 확인해주세요. (${e.message})`);
+ setStatus('error');
+ }
+ }
+ }, POLL_INTERVAL);
+ };
+
+ const isFake = (fake_score ?? 0) > 50;
+ const isProcessing = status === 'submitting' || status === 'polling';
+
+ if (!state) {
+ return (
+
+
전송된 프레임 데이터가 없습니다.
+
navigate(-1)} style={{ marginLeft: '15px', padding: '8px 16px', backgroundColor: '#1A2C50', color: '#39FF14', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>뒤로가기
+
+ );
+ }
+
+ return (
+
+
+ {/* 헤더 */}
+
+
+
+
+ {/* 프레임 메타 패널 */}
+
+
+
TARGET METADATA
+
{video_name || `STREAM_INSTANCE_${video_id}`}
+
+
+ FRAME INDEX
+ #{frame_index ?? 0}
+
+
+ TIMESTAMP
+ {timestamp || '—'}
+
+
+ FORGERY RISK
+
+ {Number(fake_score ?? 0).toFixed(1)}%
+
+
+
+ VERDICT
+
+ {isFake ? 'FAKE' : 'REAL'}
+
+
+
+
+
+
+ {/* 메인 2열 */}
+
+
+ {/* 좌: 히트맵 결과 뷰어 */}
+
+
+ {/* 뷰어 헤더 */}
+
+
HEATMAP + BBOX OVERLAY
+ {status === 'done' && heatmapSrc && (
+
+ ↓ SAVE
+
+ )}
+
+
+ {/* 뷰어 본문 */}
+
+
+ {/* idle */}
+ {status === 'idle' && (
+
+
BRANCH LEVEL을 선택하고
+
GENERATE를 실행하세요
+
+ )}
+
+ {/* 처리중 */}
+ {isProcessing && (
+
+
+
+
+
+ {status === 'submitting' ? '서버 접수 중...' : '히트맵 생성 중...'}
+
+
+
+
+ {status === 'submitting' ? 'SUBMITTING...' : 'GENERATING HEATMAP...'}
+
+
+ {activeBranch.toUpperCase()} BRANCH · LAYERCAM · heatmap_bbox
+
+ {taskId && (
+
TASK: {taskId}
+ )}
+
+
+ )}
+
+ {/* 결과 이미지 */}
+ {status === 'done' && heatmapSrc && (
+
+
{
+ e.target.style.display = 'none';
+ setErrorMsg('이미지 로드 실패');
+ setErrorDetail(`URL: ${heatmapSrc}`);
+ setStatus('error');
+ }}
+ />
+
+ )}
+
+ {/* 결과 없음 */}
+ {status === 'done' && !heatmapSrc && (
+
+
결과 이미지를 받지 못했습니다.
+
재시도
+
+ )}
+
+ {/* 오류 */}
+ {status === 'error' && (
+
+
⚠
+
{errorMsg || '분석 실패'}
+ {errorDetail && (
+
+ {errorDetail}
+
+ )}
+
+ 재시도
+
+
+ )}
+
+
+
+ {/* 우: 컨트롤 패널 */}
+
+
+ {/* 브랜치 선택 */}
+
+
BRANCH LEVEL
+
+ {[
+ { key: 'low', desc: '국소 위조 흔적 포착 (Subtle Artifacts)' },
+ { key: 'high', desc: '전역 의미 구조 포착 (Global Artifacts)' },
+ ].map(({ key, desc }) => (
+
handleBranchChange(key)}
+ disabled={isProcessing}
+ style={{
+ padding: '16px 20px',
+ backgroundColor: activeBranch === key ? 'rgba(57,255,20,0.06)' : 'transparent',
+ border: `1px solid ${activeBranch === key ? '#39FF14' : '#222'}`,
+ borderRadius: '10px',
+ cursor: isProcessing ? 'not-allowed' : 'pointer',
+ color: activeBranch === key ? '#39FF14' : '#555',
+ fontWeight: 'bold', fontSize: '13px', letterSpacing: '1px',
+ textAlign: 'left', transition: 'all 0.15s',
+ }}
+ >
+
+ {key.toUpperCase()} BRANCH
+ {activeBranch === key && ● ACTIVE }
+
+ {desc}
+
+ ))}
+
+
+
+ {/* 파라미터 요약 */}
+
+
REQUEST PARAMETERS
+ {[
+ ['model_type', model_type || 'fast'],
+ ...Object.entries(BRANCH_CONFIG[activeBranch]),
+ ].map(([k, v]) => (
+
+ {k}
+ {String(v)}
+
+ ))}
+
+
+ {/* 분석 시작 버튼 */}
+
+ {isProcessing ? '분석 중...' : 'GENERATE HEATMAP'}
+
+
+ {/* task_id 표시 (디버그용) */}
+ {taskId && (
+
+ )}
+
+
+
+
+
+
+ );
+};
+
+export default HeatmapPage;
\ No newline at end of file
diff --git a/client/src/pages/VideoAnalysisPage.js b/client/src/pages/VideoAnalysisPage.js
index 8d0b49e..9519d4e 100644
--- a/client/src/pages/VideoAnalysisPage.js
+++ b/client/src/pages/VideoAnalysisPage.js
@@ -1,7 +1,8 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
-import axios from 'axios';
+import axios from 'axios';
+const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:8000";
axios.defaults.withCredentials = true;
const VideoAnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
@@ -11,29 +12,35 @@ const VideoAnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
const [previewUrl, setPreviewUrl] = useState(null);
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [history, setHistory] = useState([]);
+
const [showOptions, setShowOptions] = useState(false);
const [isEditMode, setIsEditMode] = useState(false);
- const [selectedIds, setSelectedIds] = useState([]);
+ const [selectedIds, setSelectedIds] = useState([]);
- const [versionType, setVersionType] = useState('v1');
+ const [versionType, setVersionType] = useState('v2');
const [modelType, setModelType] = useState('fast');
const [domainType, setDomainType] = useState('western');
- const [result, setResult] = useState(null);
+
+ const [result, setResult] = useState(null);
const [statusMessage, setStatusMessage] = useState('');
const pollingTimer = useRef(null);
const isMounted = useRef(true);
+ const sideBarStyle = { width: '280px', backgroundColor: '#050505', borderRight: '1px solid #222', display: 'flex', flexDirection: 'column', padding: '25px' };
+ const centerZoneStyle = { flex: 1, padding: '40px', display: 'flex', flexDirection: 'column', gap: '20px', overflowY: 'auto' };
+ const rightPanelStyle = { width: '340px', backgroundColor: '#0D0D0D', borderLeft: '1px solid #222', padding: '30px', display: 'flex', flexDirection: 'column' };
+ const innerBoxStyle = { flex: 1, border: '2px dashed #333', borderRadius: '20px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', backgroundColor: '#0a0a0a', cursor: 'pointer', position: 'relative', transition: 'all 0.3s' };
+ const plusBtnStyle = { width: '60px', height: '60px', borderRadius: '50%', backgroundColor: '#1A2C50', display: 'flex', justifyContent: 'center', alignItems: 'center', fontSize: '30px', color: '#39FF14', marginBottom: '20px', boxShadow: '0 0 15px rgba(57, 255, 20, 0.2)' };
+
const fetchHistory = useCallback(async () => {
if (!sessionUser) return;
try {
- const response = await axios.get('/video/history');
+ const response = await axios.get('/video/history');
if (response.data.status === "success") {
- setHistory(response.data.context || []);
+ setHistory(response.data.context || []);
}
- } catch (e) {
- console.log("히스토리 로드 실패:", e);
- }
+ } catch (e) { console.log("비디오 히스토리 로드 실패"); }
}, [sessionUser]);
useEffect(() => {
@@ -41,185 +48,272 @@ const VideoAnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
const syncSession = async () => {
if (!sessionUser) {
try {
- const res = await axios.get('/auth/check');
- if (res.data?.user) setSessionUser(res.data.user);
- } catch (error) {
- console.log("세션 확인 실패");
- }
+ const response = await axios.get('/auth/check');
+ if (response.data && response.data.user) setSessionUser(response.data.user);
+ } catch (error) { console.log("세션 확인 실패"); }
}
};
syncSession();
fetchHistory();
+
return () => {
isMounted.current = false;
- if (pollingTimer.current) { clearInterval(pollingTimer.current); pollingTimer.current = null; }
+ if (pollingTimer.current) {
+ clearInterval(pollingTimer.current);
+ pollingTimer.current = null;
+ }
};
}, [sessionUser, setSessionUser, fetchHistory]);
- /*삭제 로직 (백엔드 연동 포함)*/
- const handleDeleteSelected = async () => {
- if (selectedIds.length === 0) return;
- if (!window.confirm(`${selectedIds.length}개의 기록을 삭제하시겠습니까?`)) return;
-
- const updatedHistory = history.filter(item => !selectedIds.includes(item.id));
- setHistory(updatedHistory);
- setSelectedIds([]);
- setIsEditMode(false);
- alert("화면에서 제거되었습니다.");
- };
-
- const toggleSelect = (id) => {
- setSelectedIds(prev =>
- prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]
- );
- };
-
const startPolling = useCallback((videoId) => {
if (!videoId) return;
if (pollingTimer.current) clearInterval(pollingTimer.current);
+
pollingTimer.current = setInterval(async () => {
if (!isMounted.current) return;
try {
const response = await axios.get(`/inference/video/${videoId}`);
const data = response.data;
- if (data.status === 'SUCCESS') {
- clearInterval(pollingTimer.current); pollingTimer.current = null;
- setResult(data); setIsAnalyzing(false); setStatusMessage(''); fetchHistory();
- } else if (data.status === 'WARNING' || data.status === 'FAILED') {
- clearInterval(pollingTimer.current); pollingTimer.current = null;
- setIsAnalyzing(false); setStatusMessage(''); alert(data.result_msg || data.message || "오류");
- } else { setStatusMessage(data.message || "AI 추론 중..."); }
- } catch (err) { setStatusMessage("서버 연결 확인 중..."); }
- }, 2500);
+ if (data.status === 'SUCCESS' || data.prob !== undefined || data.analysis !== undefined) {
+ clearInterval(pollingTimer.current);
+ pollingTimer.current = null;
+ // [수정] video_id 포함
+ setResult({ ...data, video_id: videoId });
+ setIsAnalyzing(false);
+ setStatusMessage('');
+ fetchHistory();
+ } else if (data.status === 'FAILED' || data.status === 'ERROR') {
+ clearInterval(pollingTimer.current);
+ pollingTimer.current = null;
+ setIsAnalyzing(false);
+ setStatusMessage('');
+ alert(data.result_msg || "비디오 분석 실패");
+ // [수정] WARNING 분기 추가 — 얼굴 미탐지 등, 상세보기 없이 메시지만 표시
+ } else if (data.status === 'WARNING') {
+ clearInterval(pollingTimer.current);
+ pollingTimer.current = null;
+ setResult({ ...data, status: 'WARNING' });
+ setIsAnalyzing(false);
+ setStatusMessage('');
+ fetchHistory();
+ } else {
+ setStatusMessage(data.message || "비디오 분석 진행 중...");
+ }
+ } catch (err) { setStatusMessage("결과 확인 중..."); }
+ }, 2000);
}, [fetchHistory]);
const handlePredict = async () => {
- if (modelType === 'pro' && !sessionUser) { alert("로그인이 필요합니다."); navigate('/login'); return; }
- if (!file) return alert("영상을 업로드해주세요.");
-
+ if (modelType === 'pro' && !sessionUser) {
+ alert("PRO 모델 분석은 로그인이 필요합니다.");
+ navigate('/login');
+ return;
+ }
+ if (!file) return alert("동영상을 먼저 업로드해주세요.");
+
const formData = new FormData();
formData.append('videofile', file);
formData.append('model_type', modelType);
- formData.append('version_type', versionType);
formData.append('domain_type', domainType === 'western' ? '서양인' : '동양인');
+ formData.append('version_type', versionType);
+
+ setIsAnalyzing(true);
+ setStatusMessage('서버 접수 중...');
+ setResult(null);
- setIsAnalyzing(true); setStatusMessage('서버 접수 중...'); setResult(null);
try {
const response = await axios.post('/inference/video', formData);
- const videoId = response.data?.video_id;
- if (videoId) startPolling(videoId); else { alert("ID 실패"); setIsAnalyzing(false); }
- } catch (err) { setIsAnalyzing(false); setStatusMessage(''); }
+ const videoId = response.data?.video_id || response.data;
+ if (videoId) startPolling(videoId);
+ else setIsAnalyzing(false);
+ } catch (err) {
+ setIsAnalyzing(false);
+ setStatusMessage('');
+ }
};
- // 설정 변경 핸들러
- const handleVersionChange = (v) => {
- setVersionType(v);
- if (v === 'v1') setDomainType('western');
+ const handleFileSelect = (selectedFile) => {
+ if (!selectedFile) return;
+ setFile(selectedFile);
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
+ setPreviewUrl(URL.createObjectURL(selectedFile));
+ setResult(null);
+ setStatusMessage('');
+ setShowOptions(false);
};
- const handleModelChange = (type) => {
- if (type === 'pro' && !sessionUser) {
- alert("PRO 모델 분석은 로그인이 필요합니다.");
- navigate('/login');
- return;
- }
- setModelType(type);
+ const toggleSelect = (id) => {
+ setSelectedIds(prev => prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]);
};
- const handleFileSelect = (selectedFile) => {
- if (!selectedFile) return;
- setFile(selectedFile); setPreviewUrl(URL.createObjectURL(selectedFile));
- setResult(null); setStatusMessage(''); setShowOptions(false);
+ const handleDeleteSelected = async () => {
+ if (selectedIds.length === 0) return;
+ if (!window.confirm(`${selectedIds.length}개의 기록을 완전히 삭제하시겠습니까?`)) return;
+
+ try {
+ const deletePromises = selectedIds.map(id =>
+ axios.delete(`/video/history/${id}`)
+ );
+ await Promise.all(deletePromises);
+ alert("선택한 비디오 기록이 삭제되었습니다.");
+ fetchHistory();
+ setSelectedIds([]);
+ setIsEditMode(false);
+ } catch (error) {
+ console.error(error);
+ alert("서버에서 기록을 삭제하는 중 오류가 발생했습니다.");
+ }
};
const handleLogoutClick = async () => {
if (!window.confirm("로그아웃 하시겠습니까?")) return;
- try { await axios.get('/auth/logout'); onLogout(); navigate('/main'); } catch (error) {}
+ try {
+ await axios.get('/auth/logout');
+ onLogout();
+ navigate('/main');
+ } catch (error) { alert("로그아웃 실패"); }
};
- const sideBarStyle = { width: '280px', backgroundColor: '#050505', borderRight: '1px solid #222', display: 'flex', flexDirection: 'column', padding: '25px' };
- const centerZoneStyle = { flex: 1, padding: '40px', display: 'flex', flexDirection: 'column', gap: '20px', overflowY: 'auto' };
- const rightPanelStyle = { width: '340px', backgroundColor: '#0D0D0D', borderLeft: '1px solid #222', padding: '30px', display: 'flex', flexDirection: 'column' };
- const innerBoxStyle = { flex: 1, border: '2px dashed #333', borderRadius: '20px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', backgroundColor: '#0a0a0a', cursor: 'pointer', position: 'relative', transition: 'all 0.3s' };
- const plusBtnStyle = { width: '60px', height: '60px', borderRadius: '50%', backgroundColor: '#1A2C50', display: 'flex', justifyContent: 'center', alignItems: 'center', fontSize: '30px', color: '#39FF14', marginBottom: '20px', boxShadow: '0 0 15px rgba(57, 255, 20, 0.2)' };
-
return (
+
- { setFile(null); setPreviewUrl(null); setResult(null); setStatusMessage(''); setShowOptions(false); if (pollingTimer.current) clearInterval(pollingTimer.current); }} style={{ backgroundColor: '#1A2C50', color: 'white', padding: '14px', borderRadius: '10px', border: 'none', marginBottom: '35px', cursor: 'pointer', fontWeight: 'bold' }}>+ 새 영상 프로젝트
+ { setFile(null); setPreviewUrl(null); setResult(null); setStatusMessage(''); setShowOptions(false); if (pollingTimer.current) clearInterval(pollingTimer.current); }} style={{ backgroundColor: '#1A2C50', color: 'white', padding: '14px', borderRadius: '10px', border: 'none', marginBottom: '35px', cursor: 'pointer', fontWeight: 'bold' }}>+ 새 영상 분석
-
내 작업 기록
+ 내 비디오 기록
{sessionUser && history.length > 0 && (
- { setIsEditMode(!isEditMode); setSelectedIds([]); }} style={{ background: 'none', border: 'none', color: '#39FF14', cursor: 'pointer', fontSize: '12px' }}>{isEditMode ? '취소' : '편집'}
+ { setIsEditMode(!isEditMode); setSelectedIds([]); }} style={{ background: 'none', border: 'none', color: '#39FF14', cursor: 'pointer', fontSize: '12px' }}>
+ {isEditMode ? '취소' : '편집'}
+
)}
{sessionUser ? (
history.map((item, index) => {
- const score = item.score ?? item.prob ?? item.result_prob ?? -1;
- const vType = item.version_type ? item.version_type.toUpperCase() : 'V1';
+ const currentId = item.video_id || item.id;
+ const p = item.prob ?? item.score ?? item.analysis?.prob ?? -1;
+ const isSelected = selectedIds.includes(currentId);
+ const vType = item.version_type ? item.version_type.toUpperCase() : 'V2';
const dType = item.domain_type || '서양인';
const mType = item.model_type ? item.model_type.toUpperCase() : 'FAST';
+
return (
-
- {isEditMode &&
toggleSelect(item.id)} style={{ accentColor: '#39FF14', width: '18px', height: '18px', cursor: 'pointer' }} />}
-
!isEditMode && navigate('/analysis-detail', { state: { ...item, prob: score, video_loc: item.video_loc } })} style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '15px', padding: '12px', backgroundColor: '#111', borderRadius: '12px', border: selectedIds.includes(item.id) ? '1px solid #39FF14' : '1px solid #222', cursor: isEditMode ? 'default' : 'pointer' }}>
-
🎥
-
{vType} | {dType} | {mType}
{item.created_at}
{item.label}
+
+ {isEditMode &&
toggleSelect(currentId)} style={{ accentColor: '#39FF14', width: '18px', height: '18px' }} />}
+
!isEditMode && navigate('/video-detail', {
+ state: { video_id: currentId }
+ })}
+ style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '15px', padding: '12px', backgroundColor: '#111', borderRadius: '12px', border: isSelected ? '1px solid #39FF14' : '1px solid #222', cursor: isEditMode ? 'default' : 'pointer' }}
+ >
+
+ 📹
+
+
+
{vType} | {dType} | {mType}
+
{item.created_at?.split('T')[0] || ''}
+
{item.label || (p > 0.5 ? 'FAKE' : 'REAL')}
+
);
})
- ) : (
로그인 후 이용 가능합니다.
)}
+ ) :
로그인 필요
}
{isEditMode && (
-
0 ? '#FF4B4B' : '#222', color: '#fff', border: 'none', borderRadius: '8px', cursor: selectedIds.length > 0 ? 'pointer' : 'not-allowed', fontWeight: 'bold', marginTop: '10px' }}>{selectedIds.length}개 삭제하기
+
0 ? '#FF4B4B' : '#222', color: '#fff', border: 'none', borderRadius: '8px', cursor: selectedIds.length > 0 ? 'pointer' : 'not-allowed', marginTop: '10px', fontWeight: 'bold' }}
+ >
+ 선택 삭제 ({selectedIds.length})
+
)}
navigate('/main')} style={{ width: '100%', padding: '12px', backgroundColor: 'transparent', color: '#fff', border: '1px solid #444', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold', marginBottom: '15px' }}>메인 화면
- {sessionUser && (
+ {sessionUser ? (
{sessionUser.name}님 접속 중
로그아웃
- )}
+ ) :
navigate('/login')} style={{ width: '100%', padding: '12px', backgroundColor: '#1A2C50', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold' }}>로그인 }
-
-
Deep Guard Video AI
-
● 비디오 모드 가동 중
-
+ Deep Guard AI
-
{ if(!previewUrl) setShowOptions(!showOptions); }} onDragOver={(e) => e.preventDefault()} onDrop={(e) => { e.preventDefault(); handleFileSelect(e.dataTransfer.files[0]); }}>
- {previewUrl ?
:
-
+
Video Upload
영상을 업로드하세요
- {showOptions &&
{ e.stopPropagation(); document.getElementById('vInput').click(); }} style={{ padding: '10px 18px', backgroundColor: '#222', color: '#39FF14', border: '1px solid #39FF14', borderRadius: '8px', cursor:'pointer', fontWeight:'bold' }}>내 PC 영상 { e.stopPropagation(); alert("준비 중"); }} style={{ padding: '10px 18px', backgroundColor: '#222', color: '#fff', border: '1px solid #444', borderRadius: '8px', cursor:'pointer', fontWeight:'bold' }}>Cloud Drive
}
-
- }
-
handleFileSelect(e.target.files[0])} />
+
{ if(!previewUrl) setShowOptions(!showOptions); }}>
+ {previewUrl ?
: (
+
+
+
+
Video Upload
+
동영상을 업로드하세요
+ {showOptions && (
+
+ { e.stopPropagation(); document.getElementById('vIn').click(); }} style={{ padding: '10px 18px', backgroundColor: '#222', color: '#39FF14', border: '1px solid #39FF14', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold' }}>내 PC 파일
+ { e.stopPropagation(); alert("준비 중입니다."); setShowOptions(false); }} style={{ padding: '10px 18px', backgroundColor: '#222', color: '#fff', border: '1px solid #444', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold' }}>Cloud Drive
+
+ )}
+
+ )}
+
handleFileSelect(e.target.files[0])} />
- {/* 결과 박스: 추론 중일 때 테두리 색상 변경 및 이모티콘 애니메이션 추가 */}
-
- {isAnalyzing ? (
-
-
🔍
-
{statusMessage}
-
-
- ) : result ? (
-
-
{result.label?.toLowerCase() === 'fake' ? 'FAKE' : 'REAL'}
-
navigate('/analysis-detail', { state: { ...result, prob: result.score, video_loc: result.video_loc } })} style={{ color: '#39FF14', background:'none', border:'none', textDecoration: 'underline', marginTop: '15px', cursor:'pointer' }}>상세 결과 보기
+
+
+ {isAnalyzing ? (
+
+
+
+
+
VIDEO CORE ENGINE
+
SCANNING FRAMES...
+
+
+
+ {statusMessage || "PROCESSING..."}
+ LIVE
+
+
+
- ) :
WAITING...
}
+ ) : result ? (
+ // [수정] WARNING 분기 — result_msg만 표시, 상세보기 없음
+ result.status === 'WARNING' ? (
+
+
+ ⚠ UNDETECTED
+
+
+ {result.result_msg || result.message || "얼굴을 탐지하지 못했습니다."}
+
+
+ ) : (
+
+
0.5 ? '#FF4B4B' : '#39FF14' }}>
+ {(result.label || ( (result.prob ?? result.analysis?.prob ?? 0) > 0.5 ? 'FAKE' : 'REAL' ))}
+
+
navigate('/video-detail', {
+ state: { video_id: result.video_id }
+ })}
+ style={{ color: '#39FF14', background: 'none', border: 'none', textDecoration: 'underline', marginTop: '15px', cursor: 'pointer', fontWeight: 'bold' }}
+ >
+ 상세 결과 보기
+
+
+ )
+ ) :
READY TO ANALYZE
}
@@ -227,32 +321,41 @@ const VideoAnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
분석 설정
-
버전 선택
+
버전 선택
- handleVersionChange('v1')} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', backgroundColor: versionType === 'v1' ? '#222' : 'transparent', color: versionType === 'v1' ? '#39FF14' : '#666', cursor: 'pointer' }}>V1
- handleVersionChange('v2')} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', backgroundColor: versionType === 'v2' ? '#222' : 'transparent', color: versionType === 'v2' ? '#39FF14' : '#666', cursor: 'pointer' }}>V2
+ setVersionType('v1')} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', backgroundColor: versionType === 'v1' ? '#222' : 'transparent', color: versionType === 'v1' ? '#39FF14' : '#666', cursor: 'pointer' }}>V1
+ setVersionType('v2')} style={{ flex: 1, padding: '10px', borderRadius: '8px', border: 'none', backgroundColor: versionType === 'v2' ? '#222' : 'transparent', color: versionType === 'v2' ? '#39FF14' : '#666', cursor: 'pointer' }}>V2
-
대상 도메인
+
대상 도메인
setDomainType('western')} style={{ flex: 1, padding: '12px', borderRadius: '8px', border: 'none', backgroundColor: domainType === 'western' ? '#222' : '#000', color: domainType === 'western' ? '#39FF14' : '#666', cursor: 'pointer' }}>서양인
- setDomainType('asian')} style={{ flex: 1, padding: '12px', borderRadius: '8px', border: 'none', backgroundColor: domainType === 'asian' ? '#222' : '#000', color: domainType === 'asian' ? '#39FF14' : '#666', cursor: versionType === 'v1' ? 'not-allowed' : 'pointer', opacity: versionType === 'v1' ? 0.3 : 1 }}>동양인
+ setDomainType('asian')} disabled={versionType === 'v1'} style={{ flex: 1, padding: '12px', borderRadius: '8px', border: 'none', backgroundColor: domainType === 'asian' ? '#222' : '#000', color: domainType === 'asian' ? '#39FF14' : '#666', cursor: versionType === 'v1' ? 'not-allowed' : 'pointer' }}>동양인
- {isAnalyzing ? "분석 중..." : "분석 시작 (PREDICT)"}
+
+ {isAnalyzing ? "분석 중..." : "분석 시작"}
+
+
+
);
};
diff --git a/client/src/pages/VideoTimelinePage.js b/client/src/pages/VideoTimelinePage.js
new file mode 100644
index 0000000..862d94f
--- /dev/null
+++ b/client/src/pages/VideoTimelinePage.js
@@ -0,0 +1,353 @@
+import React, { useEffect, useState, useRef, useCallback } from 'react';
+import { useLocation, useNavigate } from 'react-router-dom';
+import axios from 'axios';
+
+const apiUrl = process.env.REACT_APP_API_URL || "http://localhost:8000";
+
+const normalizeFrame = (f, i) => {
+ const raw = f.score ?? f.fake_score ?? 0;
+ const fake_score = raw <= 1 ? raw * 100 : raw;
+ const ts = f.frame_time != null
+ ? new Date(f.frame_time * 1000).toISOString().substr(14, 5)
+ : f.timestamp ?? `00:${String(i * 2).padStart(2, '0')}`;
+ return { ...f, fake_score, frame_index: f.frame_index ?? i, timestamp: ts };
+};
+
+const VideoTimelinePage = ({ sessionUser }) => {
+ const { state: data } = useLocation();
+ const navigate = useNavigate();
+ const [timelineData, setTimelineData] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+
+ const videoId = data?.video_id || data?.id;
+ const mediaLoc = data?.video_loc || '';
+ const mediaSrc = mediaLoc.startsWith('blob') ? mediaLoc : `${apiUrl}${mediaLoc}`;
+
+ // Canvas는 막대 div 위에 absolute로 얹히므로
+ // 막대 div 자체를 ref로 잡아서 크기/위치를 측정
+ const canvasRef = useRef(null);
+ const barsRef = useRef(null); // 막대들이 들어있는 flex div
+ const barRefs = useRef([]); // 각 막대 div ref 배열
+
+ useEffect(() => {
+ const fetchTimelineDetails = async () => {
+ if (!videoId) { setIsLoading(false); return; }
+ try {
+ const response = await axios.get(`/inference/video/${videoId}/detail`);
+ const d = response.data;
+ let frames = [];
+ if (d?.frames?.length) frames = d.frames;
+ else if (d?.timeline?.length) frames = d.timeline;
+ if (frames.length) {
+ setTimelineData(frames.map(normalizeFrame));
+ } else {
+ setTimelineData(Array.from({ length: 10 }, (_, i) => normalizeFrame({
+ frame_index: i, frame_time: i * 2,
+ score: (i >= 3 && i <= 6) ? 0.855 : 0.123,
+ }, i)));
+ }
+ } catch {
+ setTimelineData(Array.from({ length: 10 }, (_, i) => normalizeFrame({
+ frame_index: i, frame_time: i * 2,
+ score: (i >= 3 && i <= 6) ? 0.855 : 0.123,
+ }, i)));
+ } finally {
+ setIsLoading(false);
+ }
+ };
+ fetchTimelineDetails();
+ }, [videoId]);
+
+ // ── Canvas 렌더링 ──
+ // 핵심: 각 막대 div의 getBoundingClientRect()로 실제 화면 위치를 측정해
+ // 캔버스 좌표로 변환 → 완벽하게 막대 상단 중앙을 통과하는 꺾은선
+ const drawCanvas = useCallback(() => {
+ const canvas = canvasRef.current;
+ const barsEl = barsRef.current;
+ if (!canvas || !barsEl || timelineData.length < 2) return;
+ if (barRefs.current.length !== timelineData.length) return;
+ if (barRefs.current.some(r => !r)) return;
+
+ const barsRect = barsEl.getBoundingClientRect();
+ const W = barsRect.width;
+ const H = barsRect.height;
+ const dpr = window.devicePixelRatio || 1;
+
+ canvas.width = W * dpr;
+ canvas.height = H * dpr;
+ canvas.style.width = W + 'px';
+ canvas.style.height = H + 'px';
+
+ const ctx = canvas.getContext('2d');
+ ctx.scale(dpr, dpr);
+ ctx.clearRect(0, 0, W, H);
+
+ // 각 막대 상단 중앙 좌표 계산
+ // barRef는 막대 div (색깔 있는 직사각형)
+ const points = barRefs.current.map((barEl, i) => {
+ const barRect = barEl.getBoundingClientRect();
+ // canvas 기준 좌표 = barRect - barsRect (offset)
+ const x = barRect.left - barsRect.left + barRect.width / 2;
+ const y = barRect.top - barsRect.top; // 막대 상단
+ return { x, y, isFake: timelineData[i].fake_score > 50 };
+ });
+
+ // 50% 기준선 y좌표: fake_score=50일 때의 막대 높이 비율로 계산
+ // 막대 영역 높이 = barsEl에서 timestamp span 제외한 부분
+ // barsEl padding-top=30px → 막대 영역 시작 y=30, 막대 바닥 y=H-23(timestamp)
+ const BAR_TOP = 30; // padding-top
+ const BAR_BOTTOM = H - 23; // timestamp 텍스트 영역 제외
+ const BAR_H = BAR_BOTTOM - BAR_TOP;
+ const y50 = BAR_BOTTOM - (50 / 100) * BAR_H;
+
+ // 50% 기준선
+ ctx.save();
+ ctx.setLineDash([5, 4]);
+ ctx.strokeStyle = 'rgba(255, 75, 75, 0.3)';
+ ctx.lineWidth = 1;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, y50);
+ ctx.lineTo(points[points.length - 1].x, y50);
+ ctx.stroke();
+ ctx.restore();
+
+ // 꺾은선 글로우 (바깥 레이어)
+ ctx.save();
+ ctx.shadowColor = 'rgba(255,255,255,0.6)';
+ ctx.shadowBlur = 10;
+ ctx.beginPath();
+ ctx.moveTo(points[0].x, points[0].y);
+ for (let i = 1; i < points.length; i++) ctx.lineTo(points[i].x, points[i].y);
+ ctx.strokeStyle = 'rgba(255,255,255,0.85)';
+ ctx.lineWidth = 2;
+ ctx.lineJoin = 'round';
+ ctx.lineCap = 'round';
+ ctx.stroke();
+ ctx.restore();
+
+ // 데이터 포인트 dot
+ points.forEach((pt) => {
+ // 외곽 글로우 원
+ ctx.beginPath();
+ ctx.arc(pt.x, pt.y, 7, 0, Math.PI * 2);
+ ctx.fillStyle = pt.isFake ? 'rgba(255,75,75,0.15)' : 'rgba(57,255,20,0.12)';
+ ctx.fill();
+ // 내부 dot
+ ctx.beginPath();
+ ctx.arc(pt.x, pt.y, 4, 0, Math.PI * 2);
+ ctx.fillStyle = pt.isFake ? '#FF4B4B' : '#39FF14';
+ ctx.fill();
+ });
+ }, [timelineData]);
+
+ useEffect(() => {
+ if (isLoading || !timelineData.length) return;
+ // DOM 렌더 완료 후 실행 (두 번 대기로 안전하게)
+ const t1 = setTimeout(drawCanvas, 100);
+ const t2 = setTimeout(drawCanvas, 400);
+ const ro = new ResizeObserver(() => setTimeout(drawCanvas, 50));
+ if (barsRef.current) ro.observe(barsRef.current);
+ return () => { clearTimeout(t1); clearTimeout(t2); ro.disconnect(); };
+ }, [timelineData, isLoading, drawCanvas]);
+
+ if (!data) {
+ return (
+
+
전송된 비디오 분석 데이터가 없습니다.
+
navigate(-1)} style={{ marginLeft: '15px', padding: '8px 16px', backgroundColor: '#1A2C50', color: '#39FF14', border: 'none', borderRadius: '6px', cursor: 'pointer' }}>뒤로가기
+
+ );
+ }
+
+ return (
+
+
+ {/* 네비게이션 헤더 */}
+
+
+
+
+ {/* 상단 미디어 정보 패널 존 */}
+
+
+
+
+
+
TARGET METADATA
+
{data.video_name || `STREAM_INSTANCE_${videoId}`}
+
+
+ ANALYSIS MODEL
+ {data.model_type?.toUpperCase() || 'FAST'} ENGINE
+
+
+ CORE KERNEL VERSION
+ {data.version_type?.toUpperCase() || 'V1'} SYSTEM
+
+
+ TOTAL FORGERY RISK
+ {(Number(data.prob ?? data.score ?? 0) * 100).toFixed(1)}% RISK
+
+
+
+
+
+ {/* 하단: 타임라인 그래프 & 프레임 데이터 보드 */}
+
+
CHRONOLOGICAL FORGERY RISK MATRIX
+
+ {isLoading ? (
+
CALCULATING FRAME-LEVEL METRICS...
+ ) : (
+
+ {/* 차트 영역: 막대 + Canvas 꺾은선 */}
+
+
+ {/* 막대 그래프 */}
+
+ {timelineData.map((frame, i) => {
+ const isFakeUnit = frame.fake_score > 50;
+ return (
+
+ {/* 막대 div — ref 배열로 각각 잡음 */}
+
barRefs.current[i] = el}
+ style={{
+ width: '24%',
+ height: `${Math.max(frame.fake_score, 4)}%`,
+ backgroundColor: isFakeUnit ? '#FF4B4B' : '#39FF14',
+ borderRadius: '2px 2px 0 0',
+ transition: 'height 0.4s cubic-bezier(0.1, 1, 0.1, 1)',
+ boxShadow: isFakeUnit ? '0 0 12px rgba(255,75,75,0.25)' : '0 0 12px rgba(57,255,20,0.15)'
+ }}
+ />
+ {frame.timestamp}
+
+ );
+ })}
+
+
+ {/* Canvas: barsRef와 완전히 동일한 위치/크기 */}
+
+
+
+ {/* 구간별 히트맵 버튼 행 */}
+
+ {timelineData.map((frame, i) => {
+ const isFake = frame.fake_score > 50;
+ return (
+
+ navigate('/heatmap', {
+ state: {
+ video_id: videoId,
+ frame_index: frame.frame_index,
+ timestamp: frame.timestamp,
+ fake_score: frame.fake_score,
+ model_type: data.model_type || 'fast',
+ video_name: data.video_name,
+ }
+ })}
+ title={`${frame.timestamp} 히트맵`}
+ style={{
+ width: '100%', padding: '5px 0',
+ backgroundColor: 'transparent',
+ color: isFake ? '#FF4B4B' : '#39FF14',
+ border: `1px solid ${isFake ? 'rgba(255,75,75,0.3)' : 'rgba(57,255,20,0.25)'}`,
+ borderRadius: '4px', fontSize: '9px', fontWeight: '700',
+ letterSpacing: '0.3px', cursor: 'pointer', transition: 'all 0.15s',
+ }}
+ onMouseEnter={(e) => {
+ e.currentTarget.style.backgroundColor = isFake ? 'rgba(255,75,75,0.1)' : 'rgba(57,255,20,0.08)';
+ e.currentTarget.style.boxShadow = isFake ? '0 0 8px rgba(255,75,75,0.2)' : '0 0 8px rgba(57,255,20,0.15)';
+ }}
+ onMouseLeave={(e) => {
+ e.currentTarget.style.backgroundColor = 'transparent';
+ e.currentTarget.style.boxShadow = 'none';
+ }}
+ >
+ HEAT
+
+
+ );
+ })}
+
+
+ {/* 구간별 텍스트 그리드 */}
+
+ {timelineData.map((frame, idx) => {
+ const isFake = frame.fake_score > 50;
+ return (
+
+
TIMESTAMP {frame.timestamp}
+
+ 변조 확률: {Number(frame.fake_score).toFixed(1)}%
+
+ {isFake ? 'MANIPULATED' : 'VERIFIED'}
+
+ navigate('/heatmap', {
+ state: {
+ video_id: videoId,
+ frame_index: frame.frame_index,
+ timestamp: frame.timestamp,
+ fake_score: frame.fake_score,
+ model_type: data.model_type || 'fast',
+ video_name: data.video_name,
+ }
+ })}
+ style={{
+ padding: '3px 10px', backgroundColor: 'transparent', color: '#666',
+ border: '1px solid #2A2A2A', borderRadius: '4px', fontSize: '11px',
+ fontWeight: 'bold', cursor: 'pointer', transition: 'all 0.15s',
+ }}
+ onMouseEnter={(e) => { e.currentTarget.style.borderColor = '#39FF14'; e.currentTarget.style.color = '#39FF14'; }}
+ onMouseLeave={(e) => { e.currentTarget.style.borderColor = '#2A2A2A'; e.currentTarget.style.color = '#666'; }}
+ >
+ 히트맵
+
+
+
+ );
+ })}
+
+
+ )}
+
+
+
+ );
+};
+
+export default VideoTimelinePage;
\ No newline at end of file
diff --git a/client/src/pages/analysispage.js b/client/src/pages/analysispage.js
index 73be47b..b966c25 100644
--- a/client/src/pages/analysispage.js
+++ b/client/src/pages/analysispage.js
@@ -17,7 +17,7 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
const [isEditMode, setIsEditMode] = useState(false);
const [selectedIds, setSelectedIds] = useState([]);
- const [versionType, setVersionType] = useState('v1');
+ const [versionType, setVersionType] = useState('v2');
const [modelType, setModelType] = useState('fast');
const [domainType, setDomainType] = useState('western');
@@ -27,6 +27,12 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
const pollingTimer = useRef(null);
const isMounted = useRef(true);
+ const sideBarStyle = { width: '280px', backgroundColor: '#050505', borderRight: '1px solid #222', display: 'flex', flexDirection: 'column', padding: '25px' };
+ const centerZoneStyle = { flex: 1, padding: '40px', display: 'flex', flexDirection: 'column', gap: '20px', overflowY: 'auto' };
+ const rightPanelStyle = { width: '340px', backgroundColor: '#0D0D0D', borderLeft: '1px solid #222', padding: '30px', display: 'flex', flexDirection: 'column' };
+ const innerBoxStyle = { flex: 1, border: '2px dashed #333', borderRadius: '20px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', backgroundColor: '#0a0a0a', cursor: 'pointer', position: 'relative', transition: 'all 0.3s' };
+ const plusBtnStyle = { width: '60px', height: '60px', borderRadius: '50%', backgroundColor: '#1A2C50', display: 'flex', justifyContent: 'center', alignItems: 'center', fontSize: '30px', color: '#39FF14', marginBottom: '20px', boxShadow: '0 0 15px rgba(57, 255, 20, 0.2)' };
+
const fetchHistory = useCallback(async () => {
if (!sessionUser) return;
try {
@@ -68,11 +74,11 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
try {
const response = await axios.get(`/inference/image/${imageId}`);
const data = response.data;
-
if (data.status === 'SUCCESS' || data.prob !== undefined || data.analysis !== undefined) {
clearInterval(pollingTimer.current);
pollingTimer.current = null;
- setResult(data);
+ // [수정] image_id 포함
+ setResult({ ...data, image_id: imageId });
setIsAnalyzing(false);
setStatusMessage('');
fetchHistory();
@@ -82,10 +88,18 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
setIsAnalyzing(false);
setStatusMessage('');
alert(data.result_msg || "분석 실패");
+ // [수정] WARNING 분기 추가 — 얼굴 미탐지 등 부분 실패, 상세보기 없이 메시지만 표시
+ } else if (data.status === 'WARNING') {
+ clearInterval(pollingTimer.current);
+ pollingTimer.current = null;
+ setResult({ ...data, status: 'WARNING' });
+ setIsAnalyzing(false);
+ setStatusMessage('');
+ fetchHistory();
} else {
setStatusMessage(data.message || "이미지 분석 진행 중...");
}
- } catch (err) { setStatusMessage("결과 확인 중..."); }
+ } catch (err) { setResult(null); }
}, 2000);
}, [fetchHistory]);
@@ -110,15 +124,9 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
try {
const response = await axios.post('/inference/image', formData);
const imageId = response.data?.image_id || response.data;
-
- if (imageId) {
- startPolling(imageId);
- } else {
- alert("분석 ID 발급 실패");
- setIsAnalyzing(false);
- }
+ if (imageId) startPolling(imageId);
+ else setIsAnalyzing(false);
} catch (err) {
- alert("서버 통신 오류");
setIsAnalyzing(false);
setStatusMessage('');
}
@@ -135,18 +143,26 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
};
const toggleSelect = (id) => {
- setSelectedIds(prev =>
- prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]
- );
+ setSelectedIds(prev => prev.includes(id) ? prev.filter(item => item !== id) : [...prev, id]);
};
const handleDeleteSelected = async () => {
if (selectedIds.length === 0) return;
if (!window.confirm(`${selectedIds.length}개의 기록을 삭제하시겠습니까?`)) return;
- setHistory(history.filter(item => !selectedIds.includes(item.id)));
- setSelectedIds([]);
- setIsEditMode(false);
- alert("삭제되었습니다.");
+
+ try {
+ const deletePromises = selectedIds.map(id =>
+ axios.delete(`/image/history/${id}`)
+ );
+ await Promise.all(deletePromises);
+ alert("선택한 이미지 기록이 성공적으로 삭제되었습니다.");
+ fetchHistory();
+ setSelectedIds([]);
+ setIsEditMode(false);
+ } catch (error) {
+ console.error(error);
+ alert("서버에서 기록을 삭제하는 중 오류가 발생했습니다.");
+ }
};
const handleLogoutClick = async () => {
@@ -158,13 +174,9 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
} catch (error) { alert("로그아웃 실패"); }
};
- // 스타일 상수 (UI 일관성 유지)
- const innerBoxStyle = { flex: 1, border: '2px dashed #333', borderRadius: '20px', display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'center', backgroundColor: '#0a0a0a', cursor: 'pointer', position: 'relative', transition: 'all 0.3s' };
- const plusBtnStyle = { width: '60px', height: '60px', borderRadius: '50%', backgroundColor: '#1A2C50', display: 'flex', justifyContent: 'center', alignItems: 'center', fontSize: '30px', color: '#39FF14', marginBottom: '20px', boxShadow: '0 0 15px rgba(57, 255, 20, 0.2)' };
-
return (
-
+
{ setFile(null); setPreviewUrl(null); setResult(null); setStatusMessage(''); setShowOptions(false); if (pollingTimer.current) clearInterval(pollingTimer.current); }} style={{ backgroundColor: '#1A2C50', color: 'white', padding: '14px', borderRadius: '10px', border: 'none', marginBottom: '35px', cursor: 'pointer', fontWeight: 'bold' }}>+ 새 분석 시작
@@ -179,20 +191,40 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
{sessionUser ? (
history.map((item, index) => {
- const p = item.prob ?? item.score ?? -1;
- const isSelected = selectedIds.includes(item.id);
+ const p = item.prob ?? item.score ?? item.analysis?.prob ?? -1;
+ const isSelected = selectedIds.includes(item.image_id);
+ const vType = item.version_type ? item.version_type.toUpperCase() : 'V2';
+ const dType = item.domain_type || '서양인';
+ const mType = item.model_type ? item.model_type.toUpperCase() : 'FAST';
+
+ const targetBrightness = item.face_brightness ?? item.analysis?.face_brightness ?? item.brightness ?? 0;
+ const targetRatio = item.face_ratio ?? item.analysis?.face_ratio ?? item.ratio ?? 0;
+ const targetConf = item.face_conf ?? item.analysis?.face_conf ?? item.conf ?? item.face_confidence ?? 0;
+
+ let itemLabel = 'UNKNOWN';
+ if (item.label && item.label !== 'UNKNOWN') {
+ itemLabel = item.label.toUpperCase();
+ } else if (p !== -1) {
+ itemLabel = p > 0.5 ? 'FAKE' : 'REAL';
+ }
+
return (
-
- {isEditMode && (
-
toggleSelect(item.id)} style={{ accentColor: '#39FF14', width: '18px', height: '18px' }} />
- )}
-
!isEditMode && navigate('/analysis-detail', { state: { ...item, prob: p } })} style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '15px', padding: '12px', backgroundColor: '#111', borderRadius: '12px', border: isSelected ? '1px solid #39FF14' : '1px solid #222', cursor: isEditMode ? 'default' : 'pointer' }}>
+
+ {isEditMode &&
toggleSelect(item.image_id)} style={{ accentColor: '#39FF14', width: '18px', height: '18px' }} />}
+
!isEditMode && navigate('/analysis-detail', {
+ state: { image_id: item.image_id }
+ })}
+ style={{ flex: 1, display: 'flex', alignItems: 'center', gap: '15px', padding: '12px', backgroundColor: '#111', borderRadius: '12px', border: isSelected ? '1px solid #39FF14' : '1px solid #222', cursor: isEditMode ? 'default' : 'pointer' }}
+ >
{item.image_loc ?
: '🖼️'}
+
{vType} | {dType} | {mType}
{item.created_at?.split('T')[0]}
-
{item.label || (p > 0.5 ? 'FAKE' : 'REAL')}
+
{itemLabel}
@@ -201,9 +233,7 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
) :
로그인 필요
}
- {isEditMode && (
-
0 ? '#FF4B4B' : '#222', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold', marginTop: '10px' }}>선택 삭제
- )}
+ {isEditMode &&
0 ? '#FF4B4B' : '#222', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold', marginTop: '10px' }}>선택 삭제 ({selectedIds.length}) }
navigate('/main')} style={{ width: '100%', padding: '12px', backgroundColor: 'transparent', color: '#fff', border: '1px solid #444', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold', marginBottom: '15px' }}>메인 화면
@@ -212,59 +242,94 @@ const AnalysisPage = ({ sessionUser, onLogout, setSessionUser }) => {
{sessionUser.name}님 접속 중
로그아웃
- ) : (
-
navigate('/login')} style={{ width: '100%', padding: '12px', backgroundColor: '#1A2C50', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold' }}>로그인
- )}
+ ) :
navigate('/login')} style={{ width: '100%', padding: '12px', backgroundColor: '#1A2C50', color: 'white', border: 'none', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold' }}>로그인 }
-
+
Deep Guard AI
- {/* 업로드 섹션: 비디오 페이지와 동일하게 수정 */}
-
{ if(!previewUrl) setShowOptions(!showOptions); }}
- onDragOver={(e) => e.preventDefault()}
- onDrop={(e) => { e.preventDefault(); handleFileSelect(e.dataTransfer.files[0]); }}
- >
+
{ if(!previewUrl) setShowOptions(!showOptions); }}>
{previewUrl ?
: (
-
-
+
-
Image Upload
-
이미지를 업로드하세요
- {showOptions && (
-
- { e.stopPropagation(); document.getElementById('fIn').click(); }} style={{ padding: '10px 18px', backgroundColor: '#222', color: '#39FF14', border: '1px solid #39FF14', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold' }}>내 PC 파일
- { e.stopPropagation(); alert("준비 중입니다."); setShowOptions(false); }} style={{ padding: '10px 18px', backgroundColor: '#222', color: '#fff', border: '1px solid #444', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold' }}>Cloud Drive
-
- )}
-
+
+
+
+
Image Upload
+
이미지를 업로드하세요
+ {showOptions && (
+
+ { e.stopPropagation(); document.getElementById('fIn').click(); }} style={{ padding: '10px 18px', backgroundColor: '#222', color: '#39FF14', border: '1px solid #39FF14', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold' }}>내 PC 파일
+ { e.stopPropagation(); alert("준비 중입니다."); setShowOptions(false); }} style={{ padding: '10px 18px', backgroundColor: '#222', color: '#fff', border: '1px solid #444', borderRadius: '8px', cursor: 'pointer', fontWeight: 'bold' }}>Cloud Drive
+
+ )}
+
)}
handleFileSelect(e.target.files[0])} />
- {/* 결과 섹션: 추론 중일 때 디자인 강화 */}
-
+
{isAnalyzing ? (
-
-
🖼️
-
{statusMessage || "분석 중..."}
-
+
+
+
+
+
+
+
SYSTEM ENGINE
+
AI SCANNING...
+
+
+
+
+ {statusMessage || "ANALYZING DATA..."}
+ LIVE_CORE
+
+
+
+
+
) : result ? (
-
-
0.5 ? '#FF4B4B' : '#39FF14' }}>
- {(result.label || ( (result.prob ?? result.analysis?.prob ?? 0) > 0.5 ? 'FAKE' : 'REAL' ))}
-
-
navigate('/analysis-detail', { state: { ...result, ...result.analysis, image_loc: previewUrl } })} style={{ color: '#39FF14', background: 'none', border: 'none', textDecoration: 'underline', marginTop: '15px', cursor: 'pointer' }}>상세 보기
-
+ // [수정] WARNING 분기 — result_msg만 표시, 상세보기 없음
+ result.status === 'WARNING' ? (
+
+
+ ⚠ UNDETECTED
+
+
+ {result.result_msg || result.message || "얼굴을 탐지하지 못했습니다."}
+
+
+ ) : (
+
+
0.5 ? '#FF4B4B' : '#39FF14' }}>
+ {(result.label || ( (result.prob ?? result.analysis?.prob ?? 0) > 0.5 ? 'FAKE' : 'REAL' ))}
+
+
navigate('/analysis-detail', {
+ state: { image_id: result.image_id }
+ })}
+ style={{ color: '#39FF14', background: 'none', border: 'none', textDecoration: 'underline', marginTop: '15px', cursor: 'pointer' }}
+ >
+ 상세 결과 보기
+
+
+ )
) :
WAITING...
}
-
+
분석 설정
버전 선택
diff --git a/client/src/pages/loginpage.js b/client/src/pages/loginpage.js
index 3787e8f..6cd6541 100644
--- a/client/src/pages/loginpage.js
+++ b/client/src/pages/loginpage.js
@@ -3,7 +3,6 @@ import { useNavigate } from 'react-router-dom';
import axios from 'axios';
import logo from '../assets/logo.svg';
-// 백엔드 세션 쿠키 전역 설정
axios.defaults.withCredentials = true;
const LoginPage = ({ setSessionUser }) => {
@@ -16,15 +15,13 @@ const LoginPage = ({ setSessionUser }) => {
e.preventDefault();
setIsLoading(true);
try {
- // 로그인 요청
await axios.post('/auth/login', { email, password });
- // 세션 정보 가져오기 (home.py)
const homeRes = await axios.get('/home');
if (homeRes.data.session_user) {
setSessionUser(homeRes.data.session_user);
alert(`${homeRes.data.session_user.name}님 환영합니다!`);
- navigate('/analysis'); // 성공 시 분석 페이지로
+ navigate('/analysis');
}
} catch (error) {
const detail = error.response?.data?.detail || "로그인 정보를 확인해주세요.";
diff --git a/client/src/pages/mainpage.js b/client/src/pages/mainpage.js
index 28b1503..e9675f3 100644
--- a/client/src/pages/mainpage.js
+++ b/client/src/pages/mainpage.js
@@ -9,10 +9,8 @@ import bgCurve from '../assets/line.svg';
const MainPage = ({ sessionUser, onLogout }) => {
const navigate = useNavigate();
- // 이미지 분석 페이지로 이동
const handleBasicAnalysis = () => navigate('/analysis');
- // 동영상 분석 페이지로 이동
const handleVideoAnalysis = () => navigate('/video-analysis');
const handleProAnalysis = () => {
@@ -80,7 +78,6 @@ const MainPage = ({ sessionUser, onLogout }) => {
- {/* 이미지 분석 박스 */}
e.currentTarget.style.borderColor = '#39FF14'} onMouseOut={(e) => e.currentTarget.style.borderColor = '#222'}>
이미지 분석
@@ -91,7 +88,6 @@ const MainPage = ({ sessionUser, onLogout }) => {
- {/* 비디오 분석 박스 */}
e.currentTarget.style.borderColor = '#39FF14'} onMouseOut={(e) => e.currentTarget.style.borderColor = '#222'}>
비디오 분석