feat: StationOverlay 렌더링 최적화 및 스무딩 적용 close #1
- 텍스트(측점/POI) 전 프레임 사전 계산 Map (requestIdleCallback 백그라운드) - 드론 데이터 이동 평균 스무딩 (smoothFrame ±N프레임) - 30fps→60fps 프레임 간 선형 보간 (performance.now() 기반) - EMA(지수이동평균) 표시 위치 스무딩 (α=0.01 기본값) - 글씨 2배 크기, bold, strokeText 테두리, 배경 박스 제거 - 카메라 파라미터 패널에 smooth/EMA α 슬라이더 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
83
client/src/components/AddAnnotationModal.tsx
Normal file
83
client/src/components/AddAnnotationModal.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import React, { useState } from 'react';
|
||||
import { secondsToTimecode } from '../utils/timecode';
|
||||
|
||||
interface Props {
|
||||
currentTime: number;
|
||||
onAdd: (type: 'subtitle' | 'memo', text: string, timeStart: number, timeEnd: number) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function AddAnnotationModal({ currentTime, onAdd, onClose }: Props) {
|
||||
const [type, setType] = useState<'subtitle' | 'memo'>('memo');
|
||||
const [text, setText] = useState('');
|
||||
const [duration, setDuration] = useState(3);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!text.trim()) return;
|
||||
onAdd(type, text.trim(), currentTime, currentTime + duration);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
|
||||
<div className="bg-gray-900 rounded-lg p-6 w-96 shadow-xl">
|
||||
<h2 className="text-lg font-semibold mb-4">주석 추가</h2>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">시작 시간</label>
|
||||
<div className="text-white font-mono">{secondsToTimecode(currentTime)}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">유형</label>
|
||||
<select
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as 'subtitle' | 'memo')}
|
||||
className="w-full bg-gray-800 text-white rounded px-3 py-2"
|
||||
>
|
||||
<option value="subtitle">자막</option>
|
||||
<option value="memo">메모</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">내용</label>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full bg-gray-800 text-white rounded px-3 py-2 resize-none"
|
||||
placeholder="주석 내용을 입력하세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-gray-400 mb-1">표시 시간 (초)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={duration}
|
||||
onChange={(e) => setDuration(Number(e.target.value))}
|
||||
min={1}
|
||||
max={300}
|
||||
className="w-full bg-gray-800 text-white rounded px-3 py-2"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 text-gray-400 hover:text-white"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded text-white"
|
||||
>
|
||||
추가
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
22
client/src/components/ErrorBoundary.tsx
Normal file
22
client/src/components/ErrorBoundary.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import React from 'react';
|
||||
|
||||
interface State { hasError: boolean; error?: Error }
|
||||
export default class ErrorBoundary extends React.Component<React.PropsWithChildren, State> {
|
||||
state: State = { hasError: false };
|
||||
static getDerivedStateFromError(error: Error): State { return { hasError: true, error }; }
|
||||
render() {
|
||||
if (this.state.hasError) return (
|
||||
<div className="flex items-center justify-center h-screen bg-gray-950 text-white">
|
||||
<div className="text-center p-8 max-w-lg">
|
||||
<div className="text-5xl mb-4">⚠️</div>
|
||||
<h2 className="text-xl font-bold mb-2">오류가 발생했습니다</h2>
|
||||
<p className="text-gray-400 text-sm mb-4">{this.state.error?.message}</p>
|
||||
<button onClick={() => this.setState({ hasError: false })} className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded">
|
||||
다시 시도
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
40
client/src/components/HelpOverlay.tsx
Normal file
40
client/src/components/HelpOverlay.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props { onClose: () => void }
|
||||
const shortcuts = [
|
||||
['Space', '재생 / 일시정지'],
|
||||
['← / →', '5초 뒤로 / 앞으로'],
|
||||
['J / L', '10초 뒤로 / 앞으로'],
|
||||
[', / .', '이전 / 다음 프레임 (일시정지 시)'],
|
||||
['[ / ]', '이전 / 다음 장면 (±30초)'],
|
||||
['0 ~ 9', '10% 단위 탐색'],
|
||||
['F', '전체화면 토글'],
|
||||
['M', '음소거 토글'],
|
||||
['+ / -', '재생 속도 증가 / 감소'],
|
||||
['Shift+S', '현재 프레임 캡처'],
|
||||
['Shift+M', '현재 시점에 메모 추가'],
|
||||
['?', '이 도움말 열기 / 닫기'],
|
||||
];
|
||||
export default function HelpOverlay({ onClose }: Props) {
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50" onClick={onClose}>
|
||||
<div className="bg-gray-900 rounded-lg p-6 w-96 shadow-xl" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h2 className="text-lg font-semibold">키보드 단축키</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-white text-xl">×</button>
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<tbody className="divide-y divide-gray-800">
|
||||
{shortcuts.map(([key, desc]) => (
|
||||
<tr key={key} className="py-1">
|
||||
<td className="py-1.5 pr-4 font-mono text-yellow-400 whitespace-nowrap">{key}</td>
|
||||
<td className="py-1.5 text-gray-300">{desc}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<p className="text-xs text-gray-500 mt-4 text-center">아무 곳이나 클릭하면 닫힙니다</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
293
client/src/components/geo/GeoSearch.tsx
Normal file
293
client/src/components/geo/GeoSearch.tsx
Normal file
@@ -0,0 +1,293 @@
|
||||
/**
|
||||
* 드론 지리정보 검색 패널
|
||||
* - 건물/측점명 → 해당 프레임 탐색
|
||||
* - 현재 프레임 → 보이는 건물/측점 목록
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
|
||||
interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
type: 'poi' | 'station';
|
||||
}
|
||||
|
||||
interface FrameMatch {
|
||||
frame: number;
|
||||
time: number;
|
||||
bearingDiff: number;
|
||||
elevationDiff: number;
|
||||
distance: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
groupSize?: number;
|
||||
groupStart?: number;
|
||||
groupEnd?: number;
|
||||
}
|
||||
|
||||
interface PoiInFrame {
|
||||
poi: GeoPoint;
|
||||
bearingDiff: number;
|
||||
elevationDiff: number;
|
||||
distance: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
currentFrame: number;
|
||||
fps: number;
|
||||
onSeekToFrame: (frame: number) => void;
|
||||
}
|
||||
|
||||
type Tab = 'search' | 'reverse';
|
||||
|
||||
export default function GeoSearch({ currentFrame, fps, onSeekToFrame }: Props) {
|
||||
const [tab, setTab] = useState<Tab>('search');
|
||||
const [query, setQuery] = useState('');
|
||||
const [suggestions, setSuggestions] = useState<GeoPoint[]>([]);
|
||||
const [allPois, setAllPois] = useState<GeoPoint[]>([]);
|
||||
const [searchResult, setSearchResult] = useState<{ poi: GeoPoint; frames: FrameMatch[] } | null>(null);
|
||||
const [reverseResult, setReverseResult] = useState<PoiInFrame[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
// POI 목록 로드 (자동완성용)
|
||||
useEffect(() => {
|
||||
fetch('/api/geo/pois')
|
||||
.then(r => r.json())
|
||||
.then(data => setAllPois(Array.isArray(data) ? data : []))
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
// 자동완성 필터링
|
||||
useEffect(() => {
|
||||
if (!query.trim()) { setSuggestions([]); return; }
|
||||
const q = query.toLowerCase();
|
||||
setSuggestions(allPois.filter(p => p.title.toLowerCase().includes(q)).slice(0, 10));
|
||||
}, [query, allPois]);
|
||||
|
||||
// 건물/측점명으로 프레임 검색
|
||||
const handleSearch = useCallback(async (q?: string) => {
|
||||
const searchQ = (q ?? query).trim();
|
||||
if (!searchQ) return;
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSuggestions([]);
|
||||
try {
|
||||
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(searchQ)}&margin=1.0&maxDist=1500`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error || '검색 실패'); setSearchResult(null); return; }
|
||||
setSearchResult(data);
|
||||
} catch {
|
||||
setError('서버 연결 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [query]);
|
||||
|
||||
// 현재 프레임 역조회
|
||||
const handleReverse = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await fetch(`/api/geo/frame/${currentFrame}?margin=1.0`);
|
||||
const data = await res.json();
|
||||
if (!res.ok) { setError(data.error || '조회 실패'); setReverseResult(null); return; }
|
||||
setReverseResult(data.pois ?? []);
|
||||
} catch {
|
||||
setError('서버 연결 실패');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [currentFrame]);
|
||||
|
||||
// 탭 전환 시 역조회 자동 실행
|
||||
useEffect(() => {
|
||||
if (tab === 'reverse') handleReverse();
|
||||
}, [tab, currentFrame]);
|
||||
|
||||
const formatDist = (m: number) =>
|
||||
m >= 1000 ? `${(m / 1000).toFixed(2)}km` : `${Math.round(m)}m`;
|
||||
|
||||
const formatAngle = (deg: number) =>
|
||||
`${deg >= 0 ? '+' : ''}${deg.toFixed(1)}°`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full text-sm">
|
||||
{/* 탭 */}
|
||||
<div className="flex border-b border-gray-700 flex-shrink-0">
|
||||
<button
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${tab === 'search' ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
onClick={() => setTab('search')}
|
||||
>
|
||||
건물 → 프레임
|
||||
</button>
|
||||
<button
|
||||
className={`flex-1 py-2 text-xs font-medium transition-colors ${tab === 'reverse' ? 'text-blue-400 border-b-2 border-blue-400' : 'text-gray-500 hover:text-gray-300'}`}
|
||||
onClick={() => setTab('reverse')}
|
||||
>
|
||||
프레임 → 건물
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 검색 탭 */}
|
||||
{tab === 'search' && (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="p-2 flex-shrink-0 relative">
|
||||
<div className="flex gap-1">
|
||||
<input
|
||||
className="flex-1 bg-gray-800 border border-gray-600 rounded px-2 py-1 text-xs text-white placeholder-gray-500 focus:outline-none focus:border-blue-500"
|
||||
placeholder="건물명 또는 측점번호..."
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && handleSearch()}
|
||||
/>
|
||||
<button
|
||||
onClick={() => handleSearch()}
|
||||
disabled={loading}
|
||||
className="px-2 py-1 bg-blue-600 hover:bg-blue-500 disabled:bg-gray-700 rounded text-xs transition-colors"
|
||||
>
|
||||
{loading ? '…' : '검색'}
|
||||
</button>
|
||||
</div>
|
||||
{/* 자동완성 */}
|
||||
{suggestions.length > 0 && (
|
||||
<div className="absolute left-2 right-2 mt-0.5 bg-gray-800 border border-gray-600 rounded shadow-lg z-50 max-h-48 overflow-y-auto">
|
||||
{suggestions.map((s, i) => (
|
||||
<button
|
||||
key={i}
|
||||
className="w-full text-left px-3 py-1.5 hover:bg-gray-700 text-xs text-white flex items-center gap-2"
|
||||
onClick={() => { setQuery(s.title); handleSearch(s.title); }}
|
||||
>
|
||||
<span className={`text-xs px-1 rounded ${s.type === 'station' ? 'bg-green-800 text-green-300' : 'bg-blue-900 text-blue-300'}`}>
|
||||
{s.type === 'station' ? '측점' : s.category || 'POI'}
|
||||
</span>
|
||||
{s.title}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && <div className="px-3 py-1 text-xs text-red-400">{error}</div>}
|
||||
|
||||
{searchResult && (
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* POI 정보 */}
|
||||
<div className="px-3 py-2 bg-gray-800/50 border-b border-gray-700 flex-shrink-0">
|
||||
<div className="text-xs font-semibold text-white">{searchResult.poi.title}</div>
|
||||
<div className="text-xs text-gray-400 mt-0.5">
|
||||
{searchResult.poi.category} · 표고 {searchResult.poi.z.toFixed(1)}m
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">
|
||||
{searchResult.poi.lat.toFixed(6)}, {searchResult.poi.lon.toFixed(6)}
|
||||
</div>
|
||||
<div className="text-xs text-blue-400 mt-1">
|
||||
{searchResult.frames.length}개 관측 구간
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchResult.frames.length === 0 && (
|
||||
<div className="text-xs text-gray-500 p-3 text-center">
|
||||
카메라 시야에 들어오는 프레임 없음<br />
|
||||
<button
|
||||
className="mt-1 text-blue-400 hover:text-blue-300"
|
||||
onClick={() => handleSearch()}
|
||||
>
|
||||
여백 늘려서 재검색
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1 p-1">
|
||||
{searchResult.frames.map((fm, i) => (
|
||||
<button
|
||||
key={fm.frame}
|
||||
className="w-full text-left px-2 py-2 rounded hover:bg-gray-700 transition-colors border border-gray-700/50"
|
||||
onClick={() => onSeekToFrame(fm.frame)}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-white font-mono font-bold">
|
||||
#{i + 1} Frame {fm.frame}
|
||||
</span>
|
||||
<span className="text-xs text-gray-300">{formatDist(fm.distance)}</span>
|
||||
</div>
|
||||
{(fm as any).groupSize > 1 && (
|
||||
<div className="text-xs text-yellow-600 mt-0.5">
|
||||
구간 {(fm as any).groupStart}~{(fm as any).groupEnd} ({(fm as any).groupSize}프레임)
|
||||
</div>
|
||||
)}
|
||||
<div className="flex gap-2 mt-0.5">
|
||||
<span className="text-xs text-gray-400">수평 {formatAngle(fm.bearingDiff)}</span>
|
||||
<span className="text-xs text-gray-400">수직 {formatAngle(fm.elevationDiff)}</span>
|
||||
<span className="text-xs text-gray-600">
|
||||
화면 ({(fm.pixelX * 100).toFixed(0)}%, {(fm.pixelY * 100).toFixed(0)}%)
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 역조회 탭 */}
|
||||
{tab === 'reverse' && (
|
||||
<div className="flex flex-col h-full min-h-0">
|
||||
<div className="px-3 py-2 bg-gray-800/50 border-b border-gray-700 flex-shrink-0 flex items-center justify-between">
|
||||
<div>
|
||||
<span className="text-xs text-gray-400">현재 프레임: </span>
|
||||
<span className="text-xs text-white font-mono">#{currentFrame}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleReverse}
|
||||
disabled={loading}
|
||||
className="text-xs text-blue-400 hover:text-blue-300 disabled:text-gray-600"
|
||||
>
|
||||
{loading ? '조회 중…' : '새로고침'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && <div className="px-3 py-1 text-xs text-red-400">{error}</div>}
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{reverseResult && reverseResult.length === 0 && (
|
||||
<div className="text-xs text-gray-500 p-3 text-center">
|
||||
현재 프레임 시야에 건물/측점 없음
|
||||
</div>
|
||||
)}
|
||||
{reverseResult && reverseResult.length > 0 && (
|
||||
<div className="space-y-0.5 p-1">
|
||||
{reverseResult.map((item, i) => (
|
||||
<div key={i} className="px-2 py-1.5 rounded bg-gray-800/30 hover:bg-gray-800/60">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-white">{item.poi.title}</span>
|
||||
<span className="text-xs text-gray-400">{formatDist(item.distance)}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-0.5 items-center">
|
||||
<span className={`text-xs px-1 rounded ${item.poi.type === 'station' ? 'bg-green-800 text-green-300' : 'bg-blue-900 text-blue-300'}`}>
|
||||
{item.poi.type === 'station' ? '측점' : item.poi.category}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500">
|
||||
수평 {formatAngle(item.bearingDiff)} / 수직 {formatAngle(item.elevationDiff)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">
|
||||
화면 위치 ({(item.pixelX * 100).toFixed(0)}%, {(item.pixelY * 100).toFixed(0)}%)
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
client/src/components/geo/StationVerify.tsx
Normal file
169
client/src/components/geo/StationVerify.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/**
|
||||
* 측점 검증 패널
|
||||
* - 측점 목록을 클릭하면 해당 측점이 가장 잘 보이는 프레임으로 이동
|
||||
* - 이동 결과(거리, 화면 위치)를 표시하여 계산 정확도 검증
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
|
||||
interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
type: 'poi' | 'station';
|
||||
}
|
||||
|
||||
interface FrameMatch {
|
||||
frame: number;
|
||||
time: number;
|
||||
bearingDiff: number;
|
||||
elevationDiff: number;
|
||||
distance: number;
|
||||
pixelX: number;
|
||||
pixelY: number;
|
||||
groupSize?: number;
|
||||
groupStart?: number;
|
||||
groupEnd?: number;
|
||||
}
|
||||
|
||||
interface StationResult {
|
||||
frames: FrameMatch[];
|
||||
poi: GeoPoint;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
fps: number;
|
||||
onSeekToFrame: (frame: number) => void;
|
||||
}
|
||||
|
||||
function stationOrder(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
if (!m) return 0;
|
||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||
}
|
||||
|
||||
export default function StationVerify({ fps, onSeekToFrame }: Props) {
|
||||
const [stations, setStations] = useState<GeoPoint[]>([]);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<StationResult | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [seekedFrame, setSeekedFrame] = useState<number | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/geo/pois')
|
||||
.then(r => r.json())
|
||||
.then((data: GeoPoint[]) => {
|
||||
const s = Array.isArray(data)
|
||||
? data.filter(p => p.type === 'station').sort((a, b) => stationOrder(a.title) - stationOrder(b.title))
|
||||
: [];
|
||||
setStations(s);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
const handleClick = async (station: GeoPoint) => {
|
||||
setSelected(station.title);
|
||||
setResult(null);
|
||||
setLoading(true);
|
||||
setSeekedFrame(null);
|
||||
try {
|
||||
const res = await fetch(`/api/geo/search?q=${encodeURIComponent(station.title)}&margin=1.2&maxDist=2000`);
|
||||
const data = await res.json();
|
||||
if (!res.ok || !data.frames?.length) {
|
||||
setResult({ frames: [], poi: data.poi ?? station });
|
||||
return;
|
||||
}
|
||||
setResult(data);
|
||||
// 가장 중심에 가까운 첫 번째 결과로 이동
|
||||
const best = data.frames[0];
|
||||
onSeekToFrame(best.frame);
|
||||
setSeekedFrame(best.frame);
|
||||
} catch {
|
||||
setResult(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const pixelQuality = (px: number, py: number) => {
|
||||
const dx = Math.abs(px - 0.5);
|
||||
const dy = Math.abs(py - 0.5);
|
||||
const dist = Math.sqrt(dx * dx + dy * dy);
|
||||
if (dist < 0.15) return 'text-green-400';
|
||||
if (dist < 0.35) return 'text-yellow-400';
|
||||
return 'text-orange-400';
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full text-sm">
|
||||
<div className="px-3 py-2 bg-gray-800/50 border-b border-gray-700 flex-shrink-0">
|
||||
<div className="text-xs text-gray-400">측점 클릭 → 최적 프레임 이동 + 검증</div>
|
||||
<div className="text-xs text-gray-600 mt-0.5">{stations.length}개 측점</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
{/* 선택된 측점 결과 */}
|
||||
{selected && (
|
||||
<div className="mx-2 my-2 p-2 bg-gray-800 rounded border border-gray-600 flex-shrink-0">
|
||||
<div className="text-xs font-bold text-white">{selected}</div>
|
||||
{loading && <div className="text-xs text-gray-400 mt-1">검색 중…</div>}
|
||||
{!loading && result && result.frames.length === 0 && (
|
||||
<div className="text-xs text-red-400 mt-1">카메라 시야에 들어오는 프레임 없음</div>
|
||||
)}
|
||||
{!loading && result && result.frames.length > 0 && (() => {
|
||||
const f = result.frames[0];
|
||||
return (
|
||||
<>
|
||||
<div className="text-xs text-gray-300 mt-1">
|
||||
F{f.frame} · {f.distance >= 1000 ? `${(f.distance/1000).toFixed(2)}km` : `${Math.round(f.distance)}m`}
|
||||
</div>
|
||||
<div className={`text-xs mt-0.5 font-mono ${pixelQuality(f.pixelX, f.pixelY)}`}>
|
||||
화면 ({(f.pixelX * 100).toFixed(0)}%, {(f.pixelY * 100).toFixed(0)}%)
|
||||
수평 {f.bearingDiff >= 0 ? '+' : ''}{f.bearingDiff.toFixed(1)}°
|
||||
</div>
|
||||
{result.frames.length > 1 && (
|
||||
<div className="flex flex-wrap gap-1 mt-1.5">
|
||||
{result.frames.slice(1).map((fm, i) => (
|
||||
<button
|
||||
key={fm.frame}
|
||||
className="text-[10px] px-1.5 py-0.5 bg-gray-700 hover:bg-gray-600 rounded text-gray-300"
|
||||
onClick={() => { onSeekToFrame(fm.frame); setSeekedFrame(fm.frame); }}
|
||||
>
|
||||
#{i + 2} F{fm.frame}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 측점 목록 */}
|
||||
<div className="space-y-px px-1 pb-2">
|
||||
{stations.map(st => (
|
||||
<button
|
||||
key={st.title}
|
||||
onClick={() => handleClick(st)}
|
||||
className={`w-full text-left px-2 py-2 rounded transition-colors flex items-center justify-between ${
|
||||
selected === st.title
|
||||
? 'bg-yellow-500/20 border border-yellow-500/50'
|
||||
: 'hover:bg-gray-800 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<span className={`text-xs font-mono font-bold ${selected === st.title ? 'text-yellow-400' : 'text-gray-200'}`}>
|
||||
{st.title}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600">
|
||||
{st.z.toFixed(0)}m
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
111
client/src/components/overlay/MemoOverlay.tsx
Normal file
111
client/src/components/overlay/MemoOverlay.tsx
Normal file
@@ -0,0 +1,111 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import interact from 'interactjs';
|
||||
import type { Annotation } from '@abcvideo/shared';
|
||||
|
||||
interface Props {
|
||||
annotations: Annotation[];
|
||||
currentTime: number;
|
||||
onUpdate: (id: string, pos: { x: number; y: number }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
containerRef: React.RefObject<HTMLElement | null>;
|
||||
}
|
||||
|
||||
function isVisible(a: Annotation, t: number): boolean {
|
||||
return a.type === 'memo' && t >= a.timeStart && t <= a.timeEnd;
|
||||
}
|
||||
|
||||
export default function MemoOverlay({
|
||||
annotations,
|
||||
currentTime,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: Props) {
|
||||
const visible = annotations.filter((a) => isVisible(a, currentTime));
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 pointer-events-none overflow-hidden">
|
||||
{visible.map((a) => (
|
||||
<MemoItem
|
||||
key={a.id}
|
||||
annotation={a}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoItem({
|
||||
annotation: a,
|
||||
onUpdate,
|
||||
onDelete,
|
||||
}: {
|
||||
annotation: Annotation;
|
||||
onUpdate: (id: string, pos: { x: number; y: number }) => void;
|
||||
onDelete: (id: string) => void;
|
||||
}) {
|
||||
const elRef = useRef<HTMLDivElement>(null);
|
||||
// Track cumulative pixel offset from initial position
|
||||
const offsetRef = useRef({ x: 0, y: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
const el = elRef.current;
|
||||
if (!el) return;
|
||||
|
||||
// Reset pixel offset when annotation changes position externally
|
||||
offsetRef.current = { x: 0, y: 0 };
|
||||
el.style.transform = 'translate(0px, 0px)';
|
||||
|
||||
const interactable = interact(el).draggable({
|
||||
listeners: {
|
||||
move(event) {
|
||||
const parent = el.parentElement;
|
||||
if (!parent) return;
|
||||
const pw = parent.offsetWidth;
|
||||
const ph = parent.offsetHeight;
|
||||
offsetRef.current.x += event.dx;
|
||||
offsetRef.current.y += event.dy;
|
||||
el.style.transform = `translate(${offsetRef.current.x}px, ${offsetRef.current.y}px)`;
|
||||
const newX = Math.max(0, Math.min(100, a.position.x + (offsetRef.current.x / pw) * 100));
|
||||
const newY = Math.max(0, Math.min(100, a.position.y + (offsetRef.current.y / ph) * 100));
|
||||
onUpdate(a.id, { x: newX, y: newY });
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
return () => interactable.unset();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [a.id]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={elRef}
|
||||
className="absolute pointer-events-auto cursor-move select-none"
|
||||
style={{
|
||||
left: `${a.position.x}%`,
|
||||
top: `${a.position.y}%`,
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="rounded px-2 py-1 text-sm max-w-xs shadow-lg"
|
||||
style={{
|
||||
backgroundColor: a.style.backgroundColor ?? 'rgba(0,0,0,0.75)',
|
||||
color: a.style.color ?? '#ffffff',
|
||||
fontSize: `${a.style.fontSize ?? 14}px`,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-1">
|
||||
<span className="flex-1">{a.text}</span>
|
||||
<button
|
||||
onClick={() => onDelete(a.id)}
|
||||
className="text-gray-400 hover:text-white text-xs leading-none ml-1 flex-shrink-0"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
618
client/src/components/overlay/StationOverlay.tsx
Normal file
618
client/src/components/overlay/StationOverlay.tsx
Normal file
@@ -0,0 +1,618 @@
|
||||
/**
|
||||
* 지리정보 오버레이
|
||||
* 렌더링 최적화:
|
||||
* - 텍스트(측점+POI): 데이터 로드 완료 시 전 프레임 Map 사전 계산 (requestIdleCallback)
|
||||
* params 변경 시 500ms debounce 후 재계산
|
||||
* - 중심선: 드론 프레임 변경 시 renderCacheRef 갱신 (per-frame, 나중에 최적화)
|
||||
* - RAF 루프: Map 조회 + 캐시 읽기만 (계산 없음 → 60fps)
|
||||
*/
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import {
|
||||
toCameraCoords,
|
||||
pixelFromCamera,
|
||||
type DroneFrameBasic,
|
||||
type CameraParams,
|
||||
type CameraCoords,
|
||||
DEFAULT_CAMERA_PARAMS,
|
||||
} from '../../utils/geoProjection';
|
||||
|
||||
interface GeoPoint {
|
||||
title: string;
|
||||
category: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
type: 'poi' | 'station';
|
||||
}
|
||||
|
||||
interface CenterlinePoint {
|
||||
lat: number;
|
||||
lon: number;
|
||||
z: number;
|
||||
}
|
||||
|
||||
const VIDEO_FPS = 30000 / 1001;
|
||||
|
||||
interface Props {
|
||||
currentFrame: number;
|
||||
currentTime: number;
|
||||
fps: number;
|
||||
visible: boolean;
|
||||
}
|
||||
|
||||
// 텍스트 사전 계산 캐시 (Map<frameNum, LabelCache>)
|
||||
interface LabelCache {
|
||||
stationLabels: { sx: number; sy: number; title: string }[];
|
||||
poiMarkers: { x: number; y: number; title: string }[];
|
||||
}
|
||||
|
||||
// 중심선 + 나침반 렌더 캐시 (per-frame, renderCacheRef)
|
||||
interface RenderCache {
|
||||
centerlineSegs: [number, number, number, number][];
|
||||
effectiveYaw: number;
|
||||
hFovRad: number;
|
||||
clCount: number;
|
||||
poiCount: number;
|
||||
}
|
||||
|
||||
function stationOrder(title: string): number {
|
||||
const m = title.match(/(\d+)[Kk](\d+)/);
|
||||
if (!m) return 0;
|
||||
return parseInt(m[1]) * 1000 + parseInt(m[2]);
|
||||
}
|
||||
|
||||
// ── ParamRow ─────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ParamRowProps {
|
||||
label: string; value: number; min: number; max: number; step: number;
|
||||
unit: string; decimals?: number; onChange: (v: number) => void;
|
||||
}
|
||||
|
||||
function ParamRow({ label, value, min, max, step, unit, decimals = 1, onChange }: ParamRowProps) {
|
||||
const fmt = useCallback((v: number) => v.toFixed(decimals), [decimals]);
|
||||
const [text, setText] = useState(() => fmt(value));
|
||||
const prevRef = useRef(value);
|
||||
useEffect(() => {
|
||||
if (prevRef.current !== value) { prevRef.current = value; setText(fmt(value)); }
|
||||
}, [value, fmt]);
|
||||
const commit = (s: string) => {
|
||||
const n = parseFloat(s);
|
||||
if (!isNaN(n)) {
|
||||
const c = Math.max(min, Math.min(max, n));
|
||||
onChange(c); setText(fmt(c)); prevRef.current = c;
|
||||
} else setText(fmt(value));
|
||||
};
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<span className="text-gray-400 w-12 shrink-0 text-right">{label}</span>
|
||||
<input type="range" min={min} max={max} step={step} value={value}
|
||||
onChange={e => { const v = parseFloat(e.target.value); onChange(v); prevRef.current = v; setText(fmt(v)); }}
|
||||
className="flex-1 h-1 accent-yellow-400 cursor-pointer" />
|
||||
<input type="number" min={min} max={max} step={step} value={text}
|
||||
onChange={e => setText(e.target.value)}
|
||||
onBlur={e => commit(e.target.value)}
|
||||
onKeyDown={e => { if (e.key === 'Enter') commit((e.target as HTMLInputElement).value); if (e.key === 'Escape') setText(fmt(value)); }}
|
||||
className="w-16 bg-black/60 border border-gray-700 rounded px-1 py-0.5 text-right font-mono text-yellow-300 text-[11px] [appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none" />
|
||||
<span className="text-gray-500 text-[10px] w-5 shrink-0">{unit}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 메인 컴포넌트 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export default function StationOverlay({ currentFrame, currentTime, fps, visible }: Props) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const canvasSizeRef = useRef({ w: 0, h: 0 });
|
||||
|
||||
// 데이터 ref
|
||||
const allDroneFramesRef = useRef<DroneFrameBasic[]>([]);
|
||||
const allCenterlinePointsRef = useRef<CenterlinePoint[]>([]);
|
||||
const allGeoStationsRef = useRef<GeoPoint[]>([]);
|
||||
const allPoisRef = useRef<GeoPoint[]>([]);
|
||||
|
||||
// 현재 상태 ref
|
||||
const currentDroneFrameRef = useRef<DroneFrameBasic | null>(null);
|
||||
const currentFrameNumRef = useRef<number>(0); // RAF에서 Map 조회용
|
||||
const currentTimeSecRef = useRef<number>(0); // 마지막으로 알려진 재생 시간
|
||||
const timeUpdateWallRef = useRef<number>(performance.now()); // currentTime 갱신된 시각
|
||||
const paramsRef = useRef<CameraParams>(DEFAULT_CAMERA_PARAMS);
|
||||
const visibleRef = useRef(visible);
|
||||
const worldOriginRef = useRef<{ lat: number; lon: number; alt: number } | undefined>(undefined);
|
||||
|
||||
// 텍스트 사전 계산 Map
|
||||
const labelMapRef = useRef<Map<number, LabelCache>>(new Map());
|
||||
const precomputeIdRef = useRef(0); // 진행 중 계산 취소용
|
||||
|
||||
// 중심선 + 나침반 렌더 캐시 (per-frame)
|
||||
const renderCacheRef = useRef<RenderCache | null>(null);
|
||||
|
||||
// UI state
|
||||
const [params, setParams] = useState<CameraParams>(DEFAULT_CAMERA_PARAMS);
|
||||
const [smoothHalf, setSmoothHalf] = useState(10);
|
||||
const smoothHalfRef = useRef(10);
|
||||
const [emaAlpha, setEmaAlpha] = useState(0.01);
|
||||
const emaAlphaRef = useRef(0.01);
|
||||
// 표시 위치 EMA 상태 (RAF 내부 유지)
|
||||
const displayedStRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
const displayedPoiRef = useRef<Map<string, { x: number; y: number }>>(new Map());
|
||||
const [showControls, setShowControls] = useState(false);
|
||||
const [droneFramesLoaded, setDroneFramesLoaded] = useState(false);
|
||||
const [geoDataLoaded, setGeoDataLoaded] = useState(false);
|
||||
const [clDataLoaded, setClDataLoaded] = useState(false);
|
||||
const [panelDroneFrame, setPanelDroneFrame] = useState<DroneFrameBasic | null>(null);
|
||||
|
||||
useEffect(() => { paramsRef.current = params; }, [params]);
|
||||
useEffect(() => { visibleRef.current = visible; }, [visible]);
|
||||
useEffect(() => { smoothHalfRef.current = smoothHalf; }, [smoothHalf]);
|
||||
useEffect(() => { emaAlphaRef.current = emaAlpha; }, [emaAlpha]);
|
||||
|
||||
const setParam = useCallback(<K extends keyof CameraParams>(key: K, val: CameraParams[K]) =>
|
||||
setParams(prev => ({ ...prev, [key]: val })), []);
|
||||
|
||||
const nearestCL = useCallback((lat: number, lon: number): CenterlinePoint | null => {
|
||||
const pts = allCenterlinePointsRef.current;
|
||||
if (!pts.length) return null;
|
||||
let best = pts[0], bestD = (best.lat - lat) ** 2 + (best.lon - lon) ** 2;
|
||||
for (const pt of pts) {
|
||||
const d = (pt.lat - lat) ** 2 + (pt.lon - lon) ** 2;
|
||||
if (d < bestD) { bestD = d; best = pt; }
|
||||
}
|
||||
return best;
|
||||
}, []);
|
||||
|
||||
function updateWorldOrigin() {
|
||||
const st = allGeoStationsRef.current;
|
||||
const cl = allCenterlinePointsRef.current;
|
||||
if (st[0]) worldOriginRef.current = { lat: st[0].lat, lon: st[0].lon, alt: st[0].z };
|
||||
else if (cl[0]) worldOriginRef.current = { lat: cl[0].lat, lon: cl[0].lon, alt: cl[0].z };
|
||||
}
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
fetch('/api/geo/pois').then(r => r.json()).then((data: GeoPoint[]) => {
|
||||
allGeoStationsRef.current = (data || []).filter(p => p.type === 'station')
|
||||
.sort((a, b) => stationOrder(a.title) - stationOrder(b.title));
|
||||
allPoisRef.current = (data || []).filter(p => p.type === 'poi');
|
||||
updateWorldOrigin();
|
||||
setGeoDataLoaded(true);
|
||||
}).catch(() => {});
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible) return;
|
||||
fetch('/api/geo/centerline').then(r => r.json()).then((data: CenterlinePoint[]) => {
|
||||
allCenterlinePointsRef.current = Array.isArray(data) ? data : [];
|
||||
updateWorldOrigin();
|
||||
setClDataLoaded(true);
|
||||
}).catch(() => {});
|
||||
}, [visible]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!visible || droneFramesLoaded) return;
|
||||
fetch('/api/geo/frames?step=1').then(r => r.json()).then((data: DroneFrameBasic[]) => {
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
allDroneFramesRef.current = data;
|
||||
setDroneFramesLoaded(true);
|
||||
}
|
||||
}).catch(() => {});
|
||||
}, [visible, droneFramesLoaded]);
|
||||
|
||||
// 드론 프레임 이동 평균 (GPS/자세 노이즈 제거)
|
||||
const smoothFrame = useCallback((frames: DroneFrameBasic[], i: number, halfWin: number): DroneFrameBasic => {
|
||||
const lo = Math.max(0, i - halfWin);
|
||||
const hi = Math.min(frames.length - 1, i + halfWin);
|
||||
const n = hi - lo + 1;
|
||||
let lat = 0, lon = 0, alt = 0, pitch = 0, roll = 0, sinYaw = 0, cosYaw = 0;
|
||||
for (let k = lo; k <= hi; k++) {
|
||||
const f = frames[k];
|
||||
lat += f.lat; lon += f.lon; alt += f.alt;
|
||||
pitch += f.pitch; roll += f.roll;
|
||||
const yr = f.yaw * Math.PI / 180;
|
||||
sinYaw += Math.sin(yr); cosYaw += Math.cos(yr);
|
||||
}
|
||||
return {
|
||||
...frames[i],
|
||||
lat: lat / n, lon: lon / n, alt: alt / n,
|
||||
pitch: pitch / n, roll: roll / n,
|
||||
yaw: Math.atan2(sinYaw / n, cosYaw / n) * 180 / Math.PI,
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 텍스트 사전 계산 — requestIdleCallback으로 백그라운드 실행
|
||||
const startLabelPrecompute = useCallback((currentParams: CameraParams, currentSmoothHalf: number) => {
|
||||
const id = ++precomputeIdRef.current;
|
||||
const newMap = new Map<number, LabelCache>();
|
||||
const frames = allDroneFramesRef.current;
|
||||
const allSt = allGeoStationsRef.current;
|
||||
const allPoi = allPoisRef.current;
|
||||
const worldOrigin = worldOriginRef.current;
|
||||
const CLIP_Z = 0.1;
|
||||
const SMOOTH_HALF = currentSmoothHalf;
|
||||
|
||||
if (!frames.length) return;
|
||||
|
||||
const t0 = performance.now();
|
||||
let idx = 0;
|
||||
const CHUNK = 200; // 한 번에 처리할 프레임 수
|
||||
|
||||
const step = () => {
|
||||
if (precomputeIdRef.current !== id) return; // 취소됨
|
||||
|
||||
const end = Math.min(idx + CHUNK, frames.length);
|
||||
while (idx < end) {
|
||||
const drone = smoothFrame(frames, idx++, SMOOTH_HALF);
|
||||
|
||||
const stationLabels: LabelCache['stationLabels'] = [];
|
||||
for (const st of allSt) {
|
||||
const snap = nearestCL(st.lat, st.lon);
|
||||
const cc = toCameraCoords(drone, snap?.lat ?? st.lat, snap?.lon ?? st.lon, snap?.z ?? st.z, currentParams, worldOrigin);
|
||||
if (cc.Zc < CLIP_Z) continue;
|
||||
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
|
||||
if (pxRaw < -0.05 || pxRaw > 1.05 || pyRaw < -0.05 || pyRaw > 1.05) continue;
|
||||
stationLabels.push({ sx: pxRaw, sy: pyRaw, title: st.title });
|
||||
}
|
||||
|
||||
const poiMarkers: LabelCache['poiMarkers'] = [];
|
||||
for (const poi of allPoi) {
|
||||
const poiZ = nearestCL(poi.lat, poi.lon)?.z ?? poi.z;
|
||||
const cc = toCameraCoords(drone, poi.lat, poi.lon, poiZ, currentParams, worldOrigin);
|
||||
if (cc.Zc < CLIP_Z) continue;
|
||||
const { pxRaw, pyRaw } = pixelFromCamera(cc, currentParams);
|
||||
if (pxRaw < -0.02 || pxRaw > 1.02 || pyRaw < -0.02 || pyRaw > 1.02) continue;
|
||||
poiMarkers.push({ x: pxRaw, y: pyRaw, title: poi.title });
|
||||
}
|
||||
|
||||
newMap.set(drone.frame, { stationLabels, poiMarkers });
|
||||
}
|
||||
|
||||
if (idx < frames.length) {
|
||||
requestIdleCallback(step, { timeout: 200 });
|
||||
} else {
|
||||
labelMapRef.current = newMap;
|
||||
console.log(
|
||||
`[labelMap] complete ${(performance.now() - t0).toFixed(0)}ms | ${frames.length} frames × ${allSt.length + allPoi.length} items`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
requestIdleCallback(step, { timeout: 200 });
|
||||
}, [nearestCL]);
|
||||
|
||||
// 모든 데이터 로드 완료 시 사전 계산 시작
|
||||
useEffect(() => {
|
||||
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
|
||||
startLabelPrecompute(paramsRef.current, smoothHalf);
|
||||
}, [droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute, smoothHalf]);
|
||||
|
||||
// params / smoothHalf 변경 시 사전 계산 재시작 (500ms debounce)
|
||||
useEffect(() => {
|
||||
if (!droneFramesLoaded || !geoDataLoaded || !clDataLoaded) return;
|
||||
const timer = setTimeout(() => startLabelPrecompute(params, smoothHalf), 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [params, smoothHalf, droneFramesLoaded, geoDataLoaded, clDataLoaded, startLabelPrecompute]);
|
||||
|
||||
// 현재 재생 시간 → 드론 프레임 ref 갱신
|
||||
useEffect(() => {
|
||||
if (!visible || !droneFramesLoaded) return;
|
||||
const frames = allDroneFramesRef.current;
|
||||
if (!frames.length) return;
|
||||
let best = frames[0], bestD = Math.abs((best.frame ?? 0) / VIDEO_FPS - currentTime);
|
||||
for (const f of frames) {
|
||||
const d = Math.abs(f.frame / VIDEO_FPS - currentTime);
|
||||
if (d < bestD) { bestD = d; best = f; }
|
||||
if (bestD < 1 / VIDEO_FPS / 2) break;
|
||||
}
|
||||
currentFrameNumRef.current = best.frame;
|
||||
currentTimeSecRef.current = currentTime;
|
||||
timeUpdateWallRef.current = performance.now();
|
||||
if (currentDroneFrameRef.current?.frame !== best.frame) {
|
||||
currentDroneFrameRef.current = best;
|
||||
setPanelDroneFrame(best);
|
||||
}
|
||||
}, [currentTime, visible, droneFramesLoaded]);
|
||||
|
||||
// 중심선 + 나침반 캐시 빌드 (per-frame, 텍스트 계산 없음)
|
||||
useEffect(() => {
|
||||
const drone = currentDroneFrameRef.current;
|
||||
if (!drone || !visible) { renderCacheRef.current = null; return; }
|
||||
|
||||
const t0 = performance.now();
|
||||
const params = paramsRef.current;
|
||||
const worldOrigin = worldOriginRef.current;
|
||||
const allCL = allCenterlinePointsRef.current;
|
||||
const CLIP_Z = 0.1;
|
||||
const SCREEN_M = 200;
|
||||
|
||||
// 선로 중심선
|
||||
const camPts: CameraCoords[] = allCL.map(pt =>
|
||||
toCameraCoords(drone, pt.lat, pt.lon, pt.z, params, worldOrigin)
|
||||
);
|
||||
const centerlineSegs: [number, number, number, number][] = [];
|
||||
for (let i = 0; i < camPts.length - 1; i++) {
|
||||
const c1 = camPts[i], c2 = camPts[i + 1];
|
||||
const z1 = c1.Zc, z2 = c2.Zc;
|
||||
if (z1 < CLIP_Z && z2 < CLIP_Z) continue;
|
||||
let px1: number, py1: number, px2: number, py2: number;
|
||||
if (z1 >= CLIP_Z && z2 >= CLIP_Z) {
|
||||
const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(c2, params);
|
||||
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
|
||||
} else {
|
||||
const t = (CLIP_Z - z1) / (z2 - z1);
|
||||
const cClip: CameraCoords = { Xc: c1.Xc + t*(c2.Xc-c1.Xc), Yc: c1.Yc + t*(c2.Yc-c1.Yc), Zc: CLIP_Z };
|
||||
if (z1 < CLIP_Z) {
|
||||
const p1 = pixelFromCamera(cClip, params), p2 = pixelFromCamera(c2, params);
|
||||
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
|
||||
} else {
|
||||
const p1 = pixelFromCamera(c1, params), p2 = pixelFromCamera(cClip, params);
|
||||
px1 = p1.pxRaw; py1 = p1.pyRaw; px2 = p2.pxRaw; py2 = p2.pyRaw;
|
||||
}
|
||||
}
|
||||
const oc = (px: number, py: number) =>
|
||||
(px < -0.1 ? 1 : 0) | (px > 1.1 ? 2 : 0) |
|
||||
(py < -0.1 ? 4 : 0) | (py > 1.1 ? 8 : 0);
|
||||
if (oc(px1, py1) & oc(px2, py2)) continue;
|
||||
centerlineSegs.push([px1, py1, px2, py2]);
|
||||
}
|
||||
|
||||
renderCacheRef.current = {
|
||||
centerlineSegs,
|
||||
effectiveYaw: drone.yaw + params.yawOffset,
|
||||
hFovRad: 2 * Math.atan((params.sensorW ?? 36) / (2 * params.focalLen)),
|
||||
clCount: allCL.length,
|
||||
poiCount: allPoisRef.current.length,
|
||||
};
|
||||
|
||||
const elapsed = performance.now() - t0;
|
||||
console.log(
|
||||
`[cache] ${elapsed.toFixed(1)}ms | CL segs=${centerlineSegs.length}/${allCL.length} | frame=${drone.frame}`
|
||||
);
|
||||
}, [panelDroneFrame, params, visible]);
|
||||
|
||||
// ResizeObserver
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const parent = canvas.parentElement;
|
||||
if (!parent) return;
|
||||
const ro = new ResizeObserver(entries => {
|
||||
for (const e of entries) {
|
||||
canvas.width = Math.round(e.contentRect.width);
|
||||
canvas.height = Math.round(e.contentRect.height);
|
||||
canvasSizeRef.current = { w: canvas.width, h: canvas.height };
|
||||
}
|
||||
});
|
||||
ro.observe(parent);
|
||||
canvas.width = parent.clientWidth;
|
||||
canvas.height = parent.clientHeight;
|
||||
canvasSizeRef.current = { w: canvas.width, h: canvas.height };
|
||||
return () => ro.disconnect();
|
||||
}, []);
|
||||
|
||||
// RAF 렌더 루프 — 계산 없이 캐시/Map 조회만
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
let rafId = 0;
|
||||
|
||||
const draw = () => {
|
||||
rafId = requestAnimationFrame(draw);
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
const { w: W, h: H } = canvasSizeRef.current;
|
||||
ctx.clearRect(0, 0, W, H);
|
||||
if (!visibleRef.current) return;
|
||||
|
||||
const cache = renderCacheRef.current;
|
||||
if (!cache) return;
|
||||
|
||||
// 선로 중심선
|
||||
if (cache.centerlineSegs.length > 0) {
|
||||
ctx.strokeStyle = 'rgba(255,50,50,0.85)';
|
||||
ctx.lineWidth = 3;
|
||||
ctx.setLineDash([]);
|
||||
ctx.beginPath();
|
||||
for (const [px1, py1, px2, py2] of cache.centerlineSegs) {
|
||||
ctx.moveTo(px1*W, py1*H);
|
||||
ctx.lineTo(px2*W, py2*H);
|
||||
}
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// 텍스트 — Map 조회 + 프레임 간 보간 (30fps 데이터 → 60fps 부드러운 표시)
|
||||
const frameNum = currentFrameNumRef.current;
|
||||
// performance.now()로 마지막 prop 업데이트 이후 경과 시간을 더해 현재 재생 위치 추정
|
||||
const elapsed = (performance.now() - timeUpdateWallRef.current) / 1000;
|
||||
const estTime = currentTimeSecRef.current + elapsed;
|
||||
const frac = Math.min(0.999, (estTime * VIDEO_FPS) - frameNum); // 0~0.999
|
||||
const mapSize = labelMapRef.current.size;
|
||||
const labelsA = labelMapRef.current.get(frameNum);
|
||||
const labelsB = labelMapRef.current.get(frameNum + 1);
|
||||
|
||||
// 두 프레임 사이 픽셀 좌표 보간
|
||||
const interpY = (a: number, b: number | undefined) => b !== undefined ? a + (b - a) * frac : a;
|
||||
|
||||
// 디버그 표시 (좌하단)
|
||||
ctx.font = '11px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'top';
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
||||
ctx.fillRect(6, H - 54, 300, 48);
|
||||
ctx.fillStyle = '#0f0';
|
||||
ctx.fillText(`frame: ${frameNum} frac: ${frac.toFixed(2)} mapSize: ${mapSize}`, 10, H - 50);
|
||||
const firstSt = labelsA?.stationLabels[0];
|
||||
const firstStB = labelsB?.stationLabels[0];
|
||||
const dispY = firstSt ? interpY(firstSt.sy, firstStB?.sy) * H : 0;
|
||||
ctx.fillText(`labels: ${labelsA?.stationLabels.length ?? '-'} firstY: ${firstSt ? dispY.toFixed(1) : '-'}px`, 10, H - 36);
|
||||
ctx.fillText(`smooth: ±${smoothHalfRef.current}fr interp: ${frac.toFixed(2)}`, 10, H - 22);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
|
||||
if (labelsA) {
|
||||
const α = emaAlphaRef.current;
|
||||
|
||||
// 측점 라벨 — 보간 후 EMA 적용
|
||||
ctx.font = 'bold 18px monospace';
|
||||
ctx.textAlign = 'left';
|
||||
ctx.textBaseline = 'middle';
|
||||
labelsA.stationLabels.forEach((stA, i) => {
|
||||
const stB = labelsB?.stationLabels[i];
|
||||
const targetX = interpY(stA.sx, stB?.sx);
|
||||
const targetY = interpY(stA.sy, stB?.sy);
|
||||
const prev = displayedStRef.current.get(stA.title);
|
||||
const dispX = prev ? prev.x + (targetX - prev.x) * α : targetX;
|
||||
const dispY = prev ? prev.y + (targetY - prev.y) * α : targetY;
|
||||
displayedStRef.current.set(stA.title, { x: dispX, y: dispY });
|
||||
const x = dispX*W, y = dispY*H;
|
||||
// 마커 선
|
||||
ctx.strokeStyle = 'rgba(255,100,100,0.95)'; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath(); ctx.moveTo(x, y-10); ctx.lineTo(x, y+10); ctx.stroke();
|
||||
// 텍스트 테두리
|
||||
const lx = Math.max(2, x + 8);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.strokeText(stA.title, lx, y);
|
||||
// 텍스트 본문
|
||||
ctx.fillStyle = 'rgba(255,200,200,1.0)';
|
||||
ctx.fillText(stA.title, lx, y);
|
||||
});
|
||||
|
||||
// POI 마커 — 보간 후 EMA 적용
|
||||
ctx.font = 'bold 20px sans-serif';
|
||||
labelsA.poiMarkers.forEach((poiA, i) => {
|
||||
const poiB = labelsB?.poiMarkers[i];
|
||||
const targetX = interpY(poiA.x, poiB?.x);
|
||||
const targetY = interpY(poiA.y, poiB?.y);
|
||||
const prev = displayedPoiRef.current.get(poiA.title);
|
||||
const dispX = prev ? prev.x + (targetX - prev.x) * α : targetX;
|
||||
const dispY = prev ? prev.y + (targetY - prev.y) * α : targetY;
|
||||
displayedPoiRef.current.set(poiA.title, { x: dispX, y: dispY });
|
||||
const px = dispX*W, py = dispY*H, r = 10;
|
||||
// 십자 마커
|
||||
ctx.strokeStyle = '#64c8ff'; ctx.lineWidth = 2.5;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(px-r, py); ctx.lineTo(px+r, py);
|
||||
ctx.moveTo(px, py-r); ctx.lineTo(px, py+r);
|
||||
ctx.stroke();
|
||||
// 텍스트 테두리
|
||||
const lx = Math.max(2, px + 14);
|
||||
ctx.strokeStyle = 'rgba(0,0,0,0.85)'; ctx.lineWidth = 4;
|
||||
ctx.lineJoin = 'round';
|
||||
ctx.textBaseline = 'middle';
|
||||
ctx.strokeText(poiA.title, lx, py);
|
||||
// 텍스트 본문
|
||||
ctx.fillStyle = '#64c8ff';
|
||||
ctx.fillText(poiA.title, lx, py);
|
||||
ctx.textBaseline = 'alphabetic';
|
||||
});
|
||||
}
|
||||
|
||||
// 나침반 HUD
|
||||
{
|
||||
const cx = W-54, cy = H-54-H*0.05, r = 38;
|
||||
ctx.beginPath(); ctx.arc(cx, cy, r, 0, Math.PI*2);
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.55)'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.25)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
ctx.font = 'bold 9px sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
|
||||
for (const [label, deg] of [['N',0],['E',90],['S',180],['W',270]] as const) {
|
||||
const rad = (deg-90)*Math.PI/180;
|
||||
ctx.fillStyle = label==='N' ? '#ff6060' : 'rgba(255,255,255,0.5)';
|
||||
ctx.fillText(label, cx+Math.cos(rad)*(r-9), cy+Math.sin(rad)*(r-9));
|
||||
}
|
||||
const yawRad = (cache.effectiveYaw-90)*Math.PI/180;
|
||||
const tx = cx+Math.cos(yawRad)*(r-14), ty = cy+Math.sin(yawRad)*(r-14);
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(tx, ty);
|
||||
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2.5; ctx.stroke();
|
||||
const ha = 0.42, hl = 9;
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(tx, ty); ctx.lineTo(tx-hl*Math.cos(yawRad-ha), ty-hl*Math.sin(yawRad-ha));
|
||||
ctx.moveTo(tx, ty); ctx.lineTo(tx-hl*Math.cos(yawRad+ha), ty-hl*Math.sin(yawRad+ha));
|
||||
ctx.strokeStyle = '#ffd700'; ctx.lineWidth = 2; ctx.stroke();
|
||||
ctx.beginPath(); ctx.moveTo(cx, cy);
|
||||
ctx.arc(cx, cy, r-2, yawRad-cache.hFovRad/2, yawRad+cache.hFovRad/2); ctx.closePath();
|
||||
ctx.fillStyle = 'rgba(255,215,0,0.12)'; ctx.fill();
|
||||
ctx.strokeStyle = 'rgba(255,215,0,0.35)'; ctx.lineWidth = 1; ctx.stroke();
|
||||
ctx.font = '9px monospace'; ctx.textBaseline = 'top'; ctx.fillStyle = '#ffd700';
|
||||
ctx.fillText(`${((cache.effectiveYaw+360)%360).toFixed(1)}°`, cx, cy+r+2);
|
||||
ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
// 범례
|
||||
if (cache.clCount > 0 || cache.poiCount > 0) {
|
||||
const lines = [
|
||||
...(cache.clCount > 0 ? [{ color: 'rgba(255,60,60,0.9)', text: `— 선로중심선 (${cache.clCount}점)` }] : []),
|
||||
...(cache.poiCount > 0 ? [{ color: '#64c8ff', text: `+ 지장물 ${cache.poiCount}개` }] : []),
|
||||
];
|
||||
ctx.fillStyle = 'rgba(0,0,0,0.6)';
|
||||
ctx.fillRect(6, 6, 160, lines.length*15+6);
|
||||
lines.forEach(({ color, text }, i) => {
|
||||
ctx.font = '10px sans-serif'; ctx.fillStyle = color;
|
||||
ctx.fillText(text, 12, 20+i*15);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
rafId = requestAnimationFrame(draw);
|
||||
return () => cancelAnimationFrame(rafId);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="absolute inset-0 pointer-events-none z-20"
|
||||
style={{ width: '100%', height: '100%', display: visible ? undefined : 'none' }}
|
||||
/>
|
||||
<div className="absolute top-2 right-2 z-30 flex flex-col items-end gap-1">
|
||||
<button onClick={() => setShowControls(v => !v)}
|
||||
className="text-xs bg-black/70 hover:bg-black/90 text-gray-200 px-2 py-1 rounded border border-gray-500 shadow">
|
||||
{showControls ? '▲ 카메라 파라미터' : '▼ 카메라 파라미터'}
|
||||
</button>
|
||||
{showControls && (
|
||||
<div className="bg-black/90 border border-gray-600 rounded p-3 text-white w-72 select-none max-h-[80vh] overflow-y-auto shadow-xl">
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-b border-gray-700 pb-2 mb-2">스무딩 <span className="text-gray-600">(재계산 500ms 후)</span></div>
|
||||
<div className="mb-3 space-y-2">
|
||||
<ParamRow label="smooth" value={smoothHalf} min={0} max={60} step={1} unit="fr" decimals={0} onChange={v => setSmoothHalf(Math.round(v))} />
|
||||
<div className="text-[10px] text-gray-600 text-right">±{smoothHalf}fr = ±{(smoothHalf * 1000 / (30000/1001)).toFixed(0)}ms</div>
|
||||
<ParamRow label="EMA α" value={emaAlpha} min={0.01} max={1.0} step={0.01} unit="" decimals={2} onChange={v => setEmaAlpha(v)} />
|
||||
<div className="text-[10px] text-gray-600 text-right">α={emaAlpha.toFixed(2)} → lag≈{(1000/60*(1/emaAlpha - 1)).toFixed(0)}ms</div>
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5">자세 보정 오프셋<span className="ml-1 text-gray-600">(SRT + offset)</span></div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<ParamRow label="Yaw ±" value={params.yawOffset} min={-180} max={180} step={0.1} unit="°" decimals={1} onChange={v => setParam('yawOffset', v)} />
|
||||
<ParamRow label="Pitch ±" value={params.pitch} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('pitch', v)} />
|
||||
<ParamRow label="Roll ±" value={params.roll} min={-45} max={45} step={0.1} unit="°" decimals={1} onChange={v => setParam('roll', v)} />
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">위치 보정 (드론 GPS 오프셋)</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<ParamRow label="off X" value={params.offX} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offX', v)} />
|
||||
<ParamRow label="off Y" value={params.offY} min={-500} max={500} step={0.1} unit="m" decimals={1} onChange={v => setParam('offY', v)} />
|
||||
<ParamRow label="off Z" value={params.offZ} min={-200} max={200} step={0.1} unit="m" decimals={1} onChange={v => setParam('offZ', v)} />
|
||||
</div>
|
||||
<div className="text-[10px] text-gray-500 uppercase tracking-wider mb-1.5 border-t border-gray-700 pt-2">내부표정 (초점·주점·센서)</div>
|
||||
<div className="space-y-2 mb-3">
|
||||
<ParamRow label="f" value={params.focalLen} min={10} max={100} step={0.1} unit="mm" decimals={1} onChange={v => setParam('focalLen', v)} />
|
||||
<ParamRow label="cx₀" value={params.cx0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cx0', v)} />
|
||||
<ParamRow label="cy₀" value={params.cy0} min={-0.5} max={0.5} step={0.005} unit="" decimals={3} onChange={v => setParam('cy0', v)} />
|
||||
<ParamRow label="sen W" value={params.sensorW} min={10} max={50} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorW', v)} />
|
||||
<ParamRow label="sen H" value={params.sensorH} min={6} max={36} step={0.05} unit="mm" decimals={2} onChange={v => setParam('sensorH', v)} />
|
||||
</div>
|
||||
{panelDroneFrame && (
|
||||
<div className="border-t border-gray-700 pt-2 mb-2 text-[10px] text-gray-400 font-mono space-y-0.5">
|
||||
<div>yaw: {((panelDroneFrame.yaw+params.yawOffset+360)%360).toFixed(1)}° pitch: {(panelDroneFrame.pitch+params.pitch).toFixed(1)}° roll: {(panelDroneFrame.roll+params.roll).toFixed(1)}°</div>
|
||||
<div>f: {params.focalLen.toFixed(1)}mm hFOV: {(2*Math.atan((params.sensorW??36)/(2*params.focalLen))*180/Math.PI).toFixed(1)}°</div>
|
||||
<div>offX: {params.offX.toFixed(1)}m offY: {params.offY.toFixed(1)}m offZ: {params.offZ.toFixed(1)}m</div>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-gray-700 pt-2 flex items-center justify-end">
|
||||
<button onClick={() => setParams({ ...DEFAULT_CAMERA_PARAMS })}
|
||||
className="text-[11px] text-gray-400 hover:text-white border border-gray-600 hover:border-gray-400 px-2 py-0.5 rounded transition-colors">
|
||||
초기화
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
15
client/src/components/player/FrameCaptureButton.tsx
Normal file
15
client/src/components/player/FrameCaptureButton.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import React from 'react';
|
||||
|
||||
interface Props { onCapture: () => void; }
|
||||
|
||||
export default function FrameCaptureButton({ onCapture }: Props) {
|
||||
return (
|
||||
<button
|
||||
onClick={onCapture}
|
||||
title="현재 프레임 캡처 (Shift+S)"
|
||||
className="bg-gray-700 hover:bg-gray-600 text-white text-sm px-3 py-1.5 rounded"
|
||||
>
|
||||
캡처
|
||||
</button>
|
||||
);
|
||||
}
|
||||
63
client/src/components/player/HlsConversionStatus.tsx
Normal file
63
client/src/components/player/HlsConversionStatus.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface Props {
|
||||
videoId: string;
|
||||
onConversionDone: () => void;
|
||||
}
|
||||
|
||||
export default function HlsConversionStatus({ videoId, onConversionDone }: Props) {
|
||||
const [status, setStatus] = useState<'idle' | 'converting' | 'done' | 'error'>('idle');
|
||||
const [percent, setPercent] = useState(0);
|
||||
|
||||
const startConversion = async () => {
|
||||
setStatus('converting');
|
||||
await fetch(`/api/hls/${videoId}/convert`, { method: 'POST' });
|
||||
|
||||
const es = new EventSource(`/api/hls/${videoId}/progress`);
|
||||
es.onmessage = (e) => {
|
||||
try {
|
||||
const data = JSON.parse(e.data);
|
||||
setPercent(Math.round(data.percent ?? 0));
|
||||
setStatus(data.status);
|
||||
if (data.status === 'done') {
|
||||
es.close();
|
||||
onConversionDone();
|
||||
} else if (data.status === 'error') {
|
||||
es.close();
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
setStatus('error');
|
||||
};
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
{status === 'idle' && (
|
||||
<button
|
||||
onClick={startConversion}
|
||||
className="bg-green-700 hover:bg-green-600 text-white px-3 py-1.5 rounded"
|
||||
>
|
||||
HLS 변환
|
||||
</button>
|
||||
)}
|
||||
{status === 'converting' && (
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="w-24 bg-gray-700 rounded-full h-2">
|
||||
<div
|
||||
className="bg-green-500 h-2 rounded-full transition-all"
|
||||
style={{ width: `${percent}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-gray-400">{percent}%</span>
|
||||
</div>
|
||||
)}
|
||||
{status === 'done' && <span className="text-green-400">HLS 준비됨</span>}
|
||||
{status === 'error' && <span className="text-red-400">변환 실패</span>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
201
client/src/components/player/VideoPlayer.tsx
Normal file
201
client/src/components/player/VideoPlayer.tsx
Normal file
@@ -0,0 +1,201 @@
|
||||
import React, { useRef, useImperativeHandle, forwardRef, useState } from 'react';
|
||||
import StationOverlay from '../overlay/StationOverlay';
|
||||
import 'video.js/dist/video-js.css';
|
||||
import { useVideoPlayer } from '../../hooks/useVideoPlayer';
|
||||
import { useFrameStep } from '../../hooks/useFrameStep';
|
||||
import { useKeyboard } from '../../hooks/useKeyboard';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
|
||||
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
|
||||
import { useCaptureStore } from '../../store/captureStore';
|
||||
import FrameCaptureButton from './FrameCaptureButton';
|
||||
import HlsConversionStatus from './HlsConversionStatus';
|
||||
|
||||
export interface VideoPlayerHandle {
|
||||
loadLocalFile: (file: File) => void;
|
||||
loadServerStream: (videoId: string, filename: string) => void;
|
||||
seekTo: (time: number) => void;
|
||||
getVideoElement: () => HTMLVideoElement | null;
|
||||
}
|
||||
|
||||
interface VideoPlayerProps {
|
||||
onAddMemo: (time: number) => void;
|
||||
onToggleHelp?: () => void;
|
||||
}
|
||||
|
||||
const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
|
||||
function VideoPlayer({ onAddMemo, onToggleHelp }, ref) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement } =
|
||||
useVideoPlayer(containerRef);
|
||||
|
||||
const { stepForward, stepBackward, fps } = useFrameStep(playerRef);
|
||||
const { currentTime, source } = usePlayerStore();
|
||||
|
||||
// Expose methods to parent via ref
|
||||
useImperativeHandle(ref, () => ({
|
||||
loadLocalFile,
|
||||
loadServerStream,
|
||||
seekTo: (time: number) => {
|
||||
playerRef.current?.currentTime(time);
|
||||
},
|
||||
getVideoElement,
|
||||
}));
|
||||
|
||||
const addCapture = useCaptureStore((s) => s.addCapture);
|
||||
|
||||
const handleCaptureFrame = () => {
|
||||
const video = getVideoElement();
|
||||
if (!video) return;
|
||||
const dataUrl = captureFrame(video);
|
||||
if (!dataUrl) return;
|
||||
const filename = `frame_${secondsToTimecode(currentTime).replace(/[:.]/g, '-')}.jpg`;
|
||||
downloadDataUrl(dataUrl, filename);
|
||||
addCapture({
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
dataUrl,
|
||||
time: currentTime,
|
||||
filename,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleAddMemo = () => onAddMemo(currentTime);
|
||||
const [showStations, setShowStations] = useState(true);
|
||||
|
||||
useKeyboard({
|
||||
playerRef,
|
||||
onStepForward: stepForward,
|
||||
onStepBackward: stepBackward,
|
||||
onCaptureFrame: handleCaptureFrame,
|
||||
onAddMemo: handleAddMemo,
|
||||
onToggleHelp,
|
||||
containerRef: wrapperRef,
|
||||
});
|
||||
|
||||
// Drag and drop local video file
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
const file = e.dataTransfer.files[0];
|
||||
if (file?.type.startsWith('video/')) loadLocalFile(file);
|
||||
};
|
||||
|
||||
// VIDEO_FPS: 영상 실제 fps (29.97 = 30000/1001). Python SRT FrameCnt 기준과 일치.
|
||||
// stableFps(VFC 감지)는 31fps 오감지가 있어 프레임 번호 표시에는 사용하지 않음.
|
||||
const VIDEO_FPS = 30000 / 1001;
|
||||
const frame = secondsToFrame(currentTime, VIDEO_FPS);
|
||||
const videoId = source?.kind === 'server' ? source.videoId : null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className="relative bg-black w-full"
|
||||
onDrop={handleDrop}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
>
|
||||
{/* Video.js container — data-vjs-player prevents extra wrapper per CLAUDE.md */}
|
||||
<div data-vjs-player ref={containerRef} className="w-full" />
|
||||
|
||||
{/* Empty state placeholder */}
|
||||
{!source && (
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-500 pointer-events-none select-none" style={{ minHeight: '240px' }}>
|
||||
<div className="text-5xl mb-3">▶</div>
|
||||
<p className="text-lg">동영상을 드래그하거나 아래에서 선택하세요</p>
|
||||
<p className="text-sm mt-1">로컬 파일 또는 서버 영상 재생 지원</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 측점 오버레이 */}
|
||||
<StationOverlay
|
||||
currentFrame={frame}
|
||||
currentTime={currentTime}
|
||||
fps={fps}
|
||||
visible={showStations}
|
||||
/>
|
||||
|
||||
{/* Timecode overlay — positioned over video */}
|
||||
{source && (
|
||||
<div className="absolute bottom-16 left-2 bg-black/70 text-white text-xs px-2 py-1 rounded font-mono pointer-events-none z-10">
|
||||
{secondsToTimecode(currentTime)} | F{frame} | {fps}fps
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bottom controls bar */}
|
||||
<div className="flex items-center gap-2 p-2 bg-gray-900 flex-wrap">
|
||||
<label className="cursor-pointer bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 rounded">
|
||||
파일 선택
|
||||
<input
|
||||
type="file"
|
||||
accept="video/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) loadLocalFile(file);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<FrameCaptureButton onCapture={handleCaptureFrame} />
|
||||
|
||||
<button
|
||||
onClick={() => setShowStations(v => !v)}
|
||||
className={`text-xs px-3 py-1.5 rounded border transition-colors ${
|
||||
showStations
|
||||
? 'bg-yellow-500/20 border-yellow-500 text-yellow-400'
|
||||
: 'bg-gray-800 border-gray-600 text-gray-400 hover:text-white'
|
||||
}`}
|
||||
title="측점 오버레이 토글"
|
||||
>
|
||||
측점선 {showStations ? 'ON' : 'OFF'}
|
||||
</button>
|
||||
|
||||
{/* 프레임 직접 이동 */}
|
||||
<form
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
const input = (e.currentTarget.elements.namedItem('frameInput') as HTMLInputElement);
|
||||
const frameNum = parseInt(input.value, 10);
|
||||
if (!isNaN(frameNum)) {
|
||||
playerRef.current?.currentTime(frameNum / VIDEO_FPS);
|
||||
}
|
||||
input.blur();
|
||||
}}
|
||||
className="flex items-center gap-1"
|
||||
>
|
||||
<span className="text-gray-500 text-xs">F</span>
|
||||
<input
|
||||
name="frameInput"
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="프레임"
|
||||
className="w-20 bg-black/60 border border-gray-600 rounded px-1.5 py-1 text-xs text-yellow-300 font-mono
|
||||
[appearance:textfield] [&::-webkit-outer-spin-button]:appearance-none [&::-webkit-inner-spin-button]:appearance-none"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="text-xs px-2 py-1 rounded border border-gray-600 bg-gray-800 text-gray-300 hover:text-white"
|
||||
>
|
||||
이동
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{videoId && (
|
||||
<HlsConversionStatus
|
||||
videoId={videoId}
|
||||
onConversionDone={() => switchToHls(videoId)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<span className="text-gray-500 text-xs ml-auto hidden sm:inline">
|
||||
Space 재생 | ←/→ 5초 | J/L 10초 | ,/. 프레임 | Shift+S 캡처
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export default VideoPlayer;
|
||||
86
client/src/components/sidebar/AnnotationPanel.tsx
Normal file
86
client/src/components/sidebar/AnnotationPanel.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
import React, { useState } from 'react';
|
||||
import type { Annotation } from '@abcvideo/shared';
|
||||
import { secondsToTimecode } from '../../utils/timecode';
|
||||
|
||||
interface Props {
|
||||
annotations: Annotation[];
|
||||
currentTime: number;
|
||||
onSeek: (time: number) => void;
|
||||
onDelete: (id: string) => void;
|
||||
onExport: (format: string) => void;
|
||||
}
|
||||
|
||||
export default function AnnotationPanel({
|
||||
annotations,
|
||||
currentTime,
|
||||
onSeek,
|
||||
onDelete,
|
||||
onExport,
|
||||
}: Props) {
|
||||
const [tab, setTab] = useState<'subtitle' | 'memo'>('subtitle');
|
||||
const filtered = annotations.filter((a) => a.type === tab);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-gray-700">
|
||||
{(['subtitle', 'memo'] as const).map((t) => (
|
||||
<button
|
||||
key={t}
|
||||
onClick={() => setTab(t)}
|
||||
className={`flex-1 py-2 text-sm ${
|
||||
tab === t ? 'text-white border-b-2 border-blue-500' : 'text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{t === 'subtitle' ? '자막' : '메모'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* List */}
|
||||
<div className="flex-1 overflow-y-auto divide-y divide-gray-800">
|
||||
{filtered.length === 0 && (
|
||||
<p className="text-gray-500 text-sm text-center py-6">
|
||||
{tab === 'subtitle' ? '자막이 없습니다' : '메모가 없습니다'}
|
||||
</p>
|
||||
)}
|
||||
{filtered.map((a) => {
|
||||
const active = currentTime >= a.timeStart && currentTime <= a.timeEnd;
|
||||
return (
|
||||
<div
|
||||
key={a.id}
|
||||
className={`px-3 py-2 cursor-pointer hover:bg-gray-800 ${
|
||||
active ? 'bg-gray-800 border-l-2 border-yellow-400' : ''
|
||||
}`}
|
||||
onClick={() => onSeek(a.timeStart)}
|
||||
>
|
||||
<div className="text-xs text-gray-400 font-mono">
|
||||
{secondsToTimecode(a.timeStart)} → {secondsToTimecode(a.timeEnd)}
|
||||
</div>
|
||||
<div className="text-sm text-white mt-0.5 truncate">{a.text}</div>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); onDelete(a.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 mt-1"
|
||||
>
|
||||
삭제
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Export buttons */}
|
||||
<div className="p-2 border-t border-gray-700 flex gap-1 flex-wrap">
|
||||
{['vtt', 'srt', 'json', 'csv'].map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => onExport(f)}
|
||||
className="text-xs bg-gray-700 hover:bg-gray-600 text-white px-2 py-1 rounded"
|
||||
>
|
||||
{f.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
client/src/components/sidebar/CaptureList.tsx
Normal file
62
client/src/components/sidebar/CaptureList.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import React from 'react';
|
||||
import { useCaptureStore } from '../../store/captureStore';
|
||||
import { secondsToTimecode } from '../../utils/timecode';
|
||||
|
||||
interface Props {
|
||||
onSeek: (time: number) => void;
|
||||
}
|
||||
|
||||
export default function CaptureList({ onSeek }: Props) {
|
||||
const { captures, removeCapture, clearCaptures } = useCaptureStore();
|
||||
|
||||
if (captures.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-xs p-4 text-center">
|
||||
캡처된 프레임이 없습니다<br />
|
||||
<span className="text-gray-600">Shift+S로 캡처</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between px-3 py-1">
|
||||
<span className="text-xs text-gray-400">{captures.length}개</span>
|
||||
<button
|
||||
onClick={clearCaptures}
|
||||
className="text-xs text-gray-500 hover:text-red-400 transition-colors"
|
||||
>
|
||||
전체 삭제
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto space-y-1 px-2 pb-2">
|
||||
{captures.map((cap) => (
|
||||
<div
|
||||
key={cap.id}
|
||||
className="group relative cursor-pointer rounded overflow-hidden border border-gray-700 hover:border-blue-500 transition-colors"
|
||||
onClick={() => onSeek(cap.time)}
|
||||
>
|
||||
<img
|
||||
src={cap.dataUrl}
|
||||
alt={cap.filename}
|
||||
className="w-full h-auto object-cover"
|
||||
draggable={false}
|
||||
/>
|
||||
<div className="absolute bottom-0 left-0 right-0 bg-black/70 px-2 py-0.5 flex items-center justify-between">
|
||||
<span className="text-xs text-white font-mono">
|
||||
{secondsToTimecode(cap.time)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); removeCapture(cap.id); }}
|
||||
className="text-xs text-gray-400 hover:text-red-400 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title="삭제"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
46
client/src/components/sidebar/VideoList.tsx
Normal file
46
client/src/components/sidebar/VideoList.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { usePlayerStore } from '../../store/playerStore';
|
||||
|
||||
interface VideoItem { videoId: string; filename: string; }
|
||||
|
||||
interface Props {
|
||||
onSelect: (videoId: string, filename: string) => void;
|
||||
}
|
||||
|
||||
export default function VideoList({ onSelect }: Props) {
|
||||
const [videos, setVideos] = useState<VideoItem[]>([]);
|
||||
const { source } = usePlayerStore();
|
||||
const activeId = source?.kind === 'server' ? source.videoId : null;
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/videos')
|
||||
.then((r) => r.json())
|
||||
.then(setVideos)
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
|
||||
if (videos.length === 0) {
|
||||
return (
|
||||
<div className="text-gray-500 text-sm p-4 text-center">
|
||||
서버에 영상이 없습니다
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y divide-gray-800">
|
||||
{videos.map((v) => (
|
||||
<button
|
||||
key={v.videoId}
|
||||
onClick={() => onSelect(v.videoId, v.filename)}
|
||||
className={`w-full text-left px-4 py-3 hover:bg-gray-800 transition-colors ${
|
||||
activeId === v.videoId ? 'bg-gray-800 border-l-2 border-blue-500' : ''
|
||||
}`}
|
||||
>
|
||||
<div className="text-sm text-white truncate">{v.filename}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">{v.videoId}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user