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:
minsung
2026-04-01 15:11:39 +09:00
commit 2aae3d1c0d
89 changed files with 15739 additions and 0 deletions

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>abcvideo — 대용량 동영상 플레이어</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

36
client/package.json Normal file
View File

@@ -0,0 +1,36 @@
{
"name": "@abcvideo/client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview",
"lint": "eslint src --ext .ts,.tsx"
},
"dependencies": {
"@abcvideo/shared": "*",
"@types/proj4": "^2.5.6",
"@videojs/http-streaming": "^3.17.4",
"hls.js": "^1.6.15",
"interactjs": "^1.10.27",
"proj4": "^2.20.4",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"subsrt-ts": "^2.1.2",
"video.js": "^8.23.7",
"zustand": "^4.5.2"
},
"devDependencies": {
"@types/react": "^18.2.73",
"@types/react-dom": "^18.2.23",
"@types/video.js": "^7.3.58",
"@vitejs/plugin-react": "^4.2.1",
"autoprefixer": "^10.4.19",
"postcss": "^8.4.38",
"tailwindcss": "^3.4.1",
"typescript": "^5.4.5",
"vite": "^5.2.6"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

180
client/src/App.tsx Normal file
View File

@@ -0,0 +1,180 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import VideoPlayer, { type VideoPlayerHandle } from './components/player/VideoPlayer';
import VideoList from './components/sidebar/VideoList';
import CaptureList from './components/sidebar/CaptureList';
import AnnotationPanel from './components/sidebar/AnnotationPanel';
import MemoOverlay from './components/overlay/MemoOverlay';
import AddAnnotationModal from './components/AddAnnotationModal';
import HelpOverlay from './components/HelpOverlay';
import GeoSearch from './components/geo/GeoSearch';
import StationVerify from './components/geo/StationVerify';
import { usePlayerStore } from './store/playerStore';
import { useAnnotations } from './hooks/useAnnotations';
export default function App() {
const [showModal, setShowModal] = useState(false);
const [modalTime, setModalTime] = useState(0);
const [showHelp, setShowHelp] = useState(false);
const [memoTime, setMemoTime] = useState(0);
const [rightTab, setRightTab] = useState<'annotation' | 'geo' | 'station'>('annotation');
const playerRef = useRef<VideoPlayerHandle>(null);
const rafRef = useRef<number>(0);
// RAF loop for MemoOverlay — avoids timeupdate (~4fps) imprecision
useEffect(() => {
const tick = () => {
const video = playerRef.current?.getVideoElement();
if (video) setMemoTime(video.currentTime);
rafRef.current = requestAnimationFrame(tick);
};
rafRef.current = requestAnimationFrame(tick);
return () => cancelAnimationFrame(rafRef.current);
}, []);
const { source, currentTime, fps } = usePlayerStore();
const currentFrame = Math.round(currentTime * (fps || 30));
const videoId =
source?.kind === 'server'
? source.videoId
: source?.kind === 'local'
? source.file.name
: null;
const { annotations, create, update, remove } = useAnnotations(videoId);
const handleAddMemo = useCallback((time: number) => {
setModalTime(time);
setShowModal(true);
}, []);
const handleAnnotationCreate = async (
type: 'subtitle' | 'memo',
text: string,
timeStart: number,
timeEnd: number
) => {
await create({
type,
text,
timeStart,
timeEnd,
position: { x: 10, y: 10 },
size: { width: 30, height: 10 },
style: { fontSize: 14, color: '#ffffff', backgroundColor: 'rgba(0,0,0,0.75)' },
});
};
const handleExport = (format: string) => {
if (!videoId) return;
window.open(`/api/annotations/${videoId}/export?format=${format}`, '_blank');
};
const handleSeek = (time: number) => {
playerRef.current?.seekTo(time);
};
return (
<div className="flex h-screen bg-gray-950 text-white overflow-hidden">
{/* Left sidebar — top: video list, bottom: captures */}
<div className="w-56 flex-shrink-0 bg-gray-900 border-r border-gray-800 flex flex-col">
<div className="px-4 py-3 border-b border-gray-800">
<h1 className="text-sm font-bold text-white">abcvideo</h1>
<p className="text-xs text-gray-500"> </p>
<p className="text-xs text-gray-600 mt-0.5">v1.0.0 · {new Date(__BUILD_TIME__).toLocaleString('ko-KR', { month:'2-digit', day:'2-digit', hour:'2-digit', minute:'2-digit' })}</p>
</div>
{/* 상단: 서버 영상 목록 */}
<div className="flex-1 overflow-y-auto border-b border-gray-700 min-h-0">
<div className="px-4 py-2 text-xs text-gray-500 uppercase tracking-wide"> </div>
<VideoList
onSelect={(id, name) => playerRef.current?.loadServerStream(id, name)}
/>
</div>
{/* 하단: 캡처 프레임 미리보기 */}
<div className="flex-1 overflow-y-auto min-h-0">
<div className="px-4 py-2 text-xs text-gray-500 uppercase tracking-wide"> </div>
<CaptureList onSeek={handleSeek} />
</div>
</div>
{/* Main — player + memo overlay */}
<div className="flex-1 flex flex-col min-w-0 relative">
<VideoPlayer
ref={playerRef}
onAddMemo={handleAddMemo}
onToggleHelp={() => setShowHelp(h => !h)}
/>
{/* Memo overlay — positioned absolute over the player area */}
<MemoOverlay
annotations={annotations}
currentTime={memoTime}
onUpdate={(id, pos) => update(id, { position: pos })}
onDelete={remove}
containerRef={{ current: null }}
/>
</div>
{/* Right sidebar — annotation + geo */}
<div className="w-64 flex-shrink-0 bg-gray-900 border-l border-gray-800 flex flex-col">
{/* 탭 헤더 */}
<div className="flex border-b border-gray-800 flex-shrink-0">
<button
className={`flex-1 py-2.5 text-xs font-semibold transition-colors ${rightTab === 'annotation' ? 'text-white border-b-2 border-blue-500' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setRightTab('annotation')}
>
</button>
<button
className={`flex-1 py-2.5 text-xs font-semibold transition-colors ${rightTab === 'geo' ? 'text-white border-b-2 border-blue-500' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setRightTab('geo')}
>
</button>
<button
className={`flex-1 py-2.5 text-xs font-semibold transition-colors ${rightTab === 'station' ? 'text-white border-b-2 border-yellow-500' : 'text-gray-500 hover:text-gray-300'}`}
onClick={() => setRightTab('station')}
>
</button>
</div>
<div className="flex-1 min-h-0 overflow-hidden">
{rightTab === 'annotation' && (
<AnnotationPanel
annotations={annotations}
currentTime={currentTime}
onSeek={handleSeek}
onDelete={remove}
onExport={handleExport}
/>
)}
{rightTab === 'geo' && (
<GeoSearch
currentFrame={currentFrame}
fps={fps}
onSeekToFrame={(frame) => handleSeek(frame / fps)}
/>
)}
{rightTab === 'station' && (
<StationVerify
fps={fps}
onSeekToFrame={(frame) => handleSeek(frame / (fps || 30))}
/>
)}
</div>
</div>
{/* Add annotation modal */}
{showModal && (
<AddAnnotationModal
currentTime={modalTime}
onAdd={handleAnnotationCreate}
onClose={() => setShowModal(false)}
/>
)}
{/* Help overlay */}
{showHelp && <HelpOverlay onClose={() => setShowHelp(false)} />}
</div>
);
}

View 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>
);
}

View 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;
}
}

View 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>
);
}

View 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>
);
}

View 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)}%)
&nbsp; {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>
);
}

View 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>
);
}

View 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>
</>
);
}

View 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>
);
}

View 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>
);
}

View 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">&#9654;</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;

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -0,0 +1,61 @@
import { useEffect, useCallback } from 'react';
import { useAnnotationStore } from '../store/annotationStore';
import type { Annotation, CreateAnnotationInput, UpdateAnnotationInput } from '@abcvideo/shared';
export function useAnnotations(videoId: string | null) {
const { annotations, setAnnotations, addAnnotation, updateAnnotation, removeAnnotation } =
useAnnotationStore();
useEffect(() => {
if (!videoId) { setAnnotations([]); return; }
fetch(`/api/annotations/${videoId}`)
.then((r) => r.json())
.then((data: Annotation[]) => setAnnotations(data))
.catch(console.error);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoId]);
const create = useCallback(
async (input: Omit<CreateAnnotationInput, 'videoId'>) => {
if (!videoId) return;
const res = await fetch(`/api/annotations/${videoId}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...input, videoId }),
});
const annotation: Annotation = await res.json();
addAnnotation(annotation);
return annotation;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[videoId]
);
const update = useCallback(
async (id: string, input: UpdateAnnotationInput) => {
if (!videoId) return;
const res = await fetch(`/api/annotations/${videoId}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(input),
});
const annotation: Annotation = await res.json();
updateAnnotation(id, annotation);
return annotation;
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[videoId]
);
const remove = useCallback(
async (id: string) => {
if (!videoId) return;
await fetch(`/api/annotations/${videoId}/${id}`, { method: 'DELETE' });
removeAnnotation(id);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[videoId]
);
return { annotations, create, update, remove };
}

View File

@@ -0,0 +1,73 @@
import { useEffect, useRef, useState } from 'react';
import type Player from 'video.js/dist/types/player';
import { usePlayerStore } from '../store/playerStore';
export function useFrameStep(playerRef: React.RefObject<Player | null>) {
const detectedFps = useRef(30);
// 안정적인 fps: 재생 중 연속 2회 이상 동일 값이 나와야 확정
const [stableFps, setStableFps] = useState(30);
const lastMeasured = useRef(30);
const stableCount = useRef(0);
const store = usePlayerStore();
useEffect(() => {
const video = playerRef.current?.tech(true)?.el() as HTMLVideoElement | null;
if (!video || !('requestVideoFrameCallback' in video)) return;
let frameCount = 0;
let startTime: number | null = null;
let handle = 0;
const measure = (_now: number, meta: { mediaTime: number }) => {
if (startTime === null) {
// 첫 호출은 시작점만 기록하고 카운트하지 않음.
// 카운트에 포함하면 elapsed ≈ 1.001s에 frameCount=31이 되어 31fps로 오감지됨.
startTime = meta.mediaTime;
handle = (video as any).requestVideoFrameCallback(measure);
return;
}
frameCount++;
const elapsed = meta.mediaTime - startTime;
if (elapsed >= 1.0) {
const fps = Math.round(frameCount / elapsed);
if (fps >= 24 && fps <= 120) {
// 같은 값이 연속 2회 이상 나와야 안정된 fps로 확정
if (fps === lastMeasured.current) {
stableCount.current++;
if (stableCount.current >= 2) {
detectedFps.current = fps;
setStableFps(fps);
store.setFps(fps);
}
} else {
lastMeasured.current = fps;
stableCount.current = 1;
}
}
frameCount = 0;
startTime = null;
}
handle = (video as any).requestVideoFrameCallback(measure);
};
handle = (video as any).requestVideoFrameCallback(measure);
return () => { (video as any).cancelVideoFrameCallback(handle); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerRef.current]);
const stepForward = () => {
const player = playerRef.current;
if (!player || player.paused() === false) return;
const step = 1 / detectedFps.current;
player.currentTime((player.currentTime() ?? 0) + step);
};
const stepBackward = () => {
const player = playerRef.current;
if (!player || player.paused() === false) return;
const step = 1 / detectedFps.current;
player.currentTime(Math.max(0, (player.currentTime() ?? 0) - step));
};
return { stepForward, stepBackward, fps: stableFps };
}

View File

@@ -0,0 +1,113 @@
import { useEffect, useCallback } from 'react';
import type Player from 'video.js/dist/types/player';
interface KeyboardOptions {
playerRef: React.RefObject<Player | null>;
onStepForward: () => void;
onStepBackward: () => void;
onCaptureFrame: () => void;
onAddMemo: () => void;
onToggleHelp?: () => void;
containerRef: React.RefObject<HTMLDivElement | null>;
}
export function useKeyboard({
playerRef,
onStepForward,
onStepBackward,
onCaptureFrame,
onAddMemo,
onToggleHelp,
containerRef,
}: KeyboardOptions) {
const handleKey = useCallback(
(e: KeyboardEvent) => {
const target = e.target as HTMLElement;
// Don't fire when typing in an input
if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) return;
const player = playerRef.current;
if (!player) return;
const ct = player.currentTime() ?? 0;
const dur = player.duration() ?? 0;
switch (e.code) {
case 'Space':
e.preventDefault();
player.paused() ? player.play() : player.pause();
break;
case 'ArrowLeft':
e.preventDefault();
player.currentTime(Math.max(0, ct - 5));
break;
case 'ArrowRight':
e.preventDefault();
player.currentTime(Math.min(dur, ct + 5));
break;
case 'KeyJ':
player.currentTime(Math.max(0, ct - 10));
break;
case 'KeyL':
player.currentTime(Math.min(dur, ct + 10));
break;
case 'Comma':
e.preventDefault();
onStepBackward();
break;
case 'Period':
e.preventDefault();
onStepForward();
break;
case 'BracketLeft':
player.currentTime(Math.max(0, ct - 30));
break;
case 'BracketRight':
player.currentTime(Math.min(dur, ct + 30));
break;
case 'KeyF':
if (containerRef.current) {
if (!document.fullscreenElement) {
containerRef.current.requestFullscreen();
} else {
document.exitFullscreen();
}
}
break;
case 'KeyM':
if (e.shiftKey) {
e.preventDefault();
onAddMemo();
} else {
player.muted(!player.muted());
}
break;
case 'Equal':
case 'NumpadAdd':
player.playbackRate(Math.min(4, (player.playbackRate() ?? 1) + 0.25));
break;
case 'Minus':
case 'NumpadSubtract':
player.playbackRate(Math.max(0.25, (player.playbackRate() ?? 1) - 0.25));
break;
case 'KeyS':
if (e.shiftKey) { e.preventDefault(); onCaptureFrame(); }
break;
case 'Slash':
if (e.shiftKey) { e.preventDefault(); onToggleHelp?.(); }
break;
default:
if (e.code >= 'Digit0' && e.code <= 'Digit9') {
const pct = parseInt(e.code.replace('Digit', ''), 10) / 10;
player.currentTime(dur * pct);
}
}
},
[playerRef, onStepForward, onStepBackward, onCaptureFrame, onAddMemo, onToggleHelp, containerRef]
);
useEffect(() => {
window.addEventListener('keydown', handleKey);
return () => window.removeEventListener('keydown', handleKey);
}, [handleKey]);
}

View File

@@ -0,0 +1,124 @@
import { useEffect, useRef, useCallback } from 'react';
import videojs from 'video.js';
import type Player from 'video.js/dist/types/player';
import Hls from 'hls.js';
import { usePlayerStore } from '../store/playerStore';
const HLS_CONFIG = {
maxBufferLength: 30,
maxMaxBufferLength: 600,
maxBufferSize: 60 * 1024 * 1024,
backBufferLength: 30,
enableWorker: true,
};
export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | null>) {
const playerRef = useRef<Player | null>(null);
const hlsRef = useRef<Hls | null>(null);
const store = usePlayerStore();
useEffect(() => {
if (!containerRef.current || playerRef.current) return;
const videoEl = document.createElement('video-js');
videoEl.classList.add('vjs-big-play-centered', 'vjs-fluid');
containerRef.current.appendChild(videoEl);
const player = videojs(videoEl, {
controls: true,
fluid: true,
responsive: true,
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4],
html5: { vhs: { overrideNative: true } },
});
player.on('play', () => store.setPlaying(true));
player.on('pause', () => store.setPlaying(false));
player.on('timeupdate', () => store.setCurrentTime(player.currentTime() ?? 0));
player.on('durationchange', () => store.setDuration(player.duration() ?? 0));
player.on('volumechange', () => {
store.setVolume(player.volume() ?? 1);
store.setMuted(player.muted() ?? false);
});
player.on('ratechange', () => store.setPlaybackRate(player.playbackRate() ?? 1));
playerRef.current = player;
return () => {
hlsRef.current?.destroy();
hlsRef.current = null;
if (playerRef.current && !playerRef.current.isDisposed()) {
playerRef.current.dispose();
playerRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadLocalFile = useCallback((file: File) => {
const player = playerRef.current;
if (!player) return;
// Clean up previous hls
hlsRef.current?.destroy();
hlsRef.current = null;
const objectUrl = URL.createObjectURL(file);
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
store.setSource({ kind: 'local', file, objectUrl });
store.setHlsReady(false);
// Revoke objectUrl when video element is reset
player.one('emptied', () => {
URL.revokeObjectURL(objectUrl);
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadServerStream = useCallback((videoId: string, filename: string) => {
const player = playerRef.current;
if (!player) return;
hlsRef.current?.destroy();
hlsRef.current = null;
// Immediate playback via Range Request
const streamUrl = `/api/stream/${videoId}`;
player.src({ src: streamUrl, type: 'video/mp4' });
store.setSource({ kind: 'server', videoId, filename });
store.setHlsReady(false);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const switchToHls = useCallback((videoId: string) => {
const player = playerRef.current;
if (!player) return;
const hlsId = videoId.replace(/\.[^.]+$/, '');
const hlsUrl = `/api/hls/${hlsId}/index.m3u8`;
const savedTime = player.currentTime() ?? 0;
if (Hls.isSupported()) {
const hls = new Hls(HLS_CONFIG);
hls.loadSource(hlsUrl);
const videoEl = player.tech(true)?.el() as HTMLVideoElement;
hls.attachMedia(videoEl);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
player.currentTime(savedTime);
hlsRef.current = hls;
store.setHlsReady(true);
});
} else {
player.src({ src: hlsUrl, type: 'application/x-mpegURL' });
player.currentTime(savedTime);
store.setHlsReady(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getVideoElement = useCallback((): HTMLVideoElement | null => {
return (playerRef.current?.tech(true)?.el() as HTMLVideoElement | null) ?? null;
}, []);
return { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement };
}

14
client/src/index.css Normal file
View File

@@ -0,0 +1,14 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
background: #030712;
color: #f9fafb;
}

13
client/src/main.tsx Normal file
View File

@@ -0,0 +1,13 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import ErrorBoundary from './components/ErrorBoundary';
import './index.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</React.StrictMode>
);

View File

@@ -0,0 +1,25 @@
import { create } from 'zustand';
import type { Annotation } from '@abcvideo/shared';
interface AnnotationStore {
annotations: Annotation[];
setAnnotations: (a: Annotation[]) => void;
addAnnotation: (a: Annotation) => void;
updateAnnotation: (id: string, a: Partial<Annotation>) => void;
removeAnnotation: (id: string) => void;
}
export const useAnnotationStore = create<AnnotationStore>((set) => ({
annotations: [],
setAnnotations: (annotations) => set({ annotations }),
addAnnotation: (annotation) =>
set((s) => ({
annotations: [...s.annotations, annotation].sort((a, b) => a.timeStart - b.timeStart),
})),
updateAnnotation: (id, update) =>
set((s) => ({
annotations: s.annotations.map((a) => (a.id === id ? { ...a, ...update } : a)),
})),
removeAnnotation: (id) =>
set((s) => ({ annotations: s.annotations.filter((a) => a.id !== id) })),
}));

View File

@@ -0,0 +1,23 @@
import { create } from 'zustand';
export interface CaptureItem {
id: string;
dataUrl: string;
time: number;
filename: string;
createdAt: number;
}
interface CaptureStore {
captures: CaptureItem[];
addCapture: (item: CaptureItem) => void;
removeCapture: (id: string) => void;
clearCaptures: () => void;
}
export const useCaptureStore = create<CaptureStore>((set) => ({
captures: [],
addCapture: (item) => set((s) => ({ captures: [item, ...s.captures] })),
removeCapture: (id) => set((s) => ({ captures: s.captures.filter((c) => c.id !== id) })),
clearCaptures: () => set({ captures: [] }),
}));

View File

@@ -0,0 +1,44 @@
import { create } from 'zustand';
import type { VideoSource } from '../types/player';
interface PlayerStore {
source: VideoSource | null;
playing: boolean;
currentTime: number;
duration: number;
fps: number;
volume: number;
muted: boolean;
playbackRate: number;
hlsReady: boolean;
setSource: (source: VideoSource | null) => void;
setPlaying: (playing: boolean) => void;
setCurrentTime: (t: number) => void;
setDuration: (d: number) => void;
setFps: (fps: number) => void;
setVolume: (v: number) => void;
setMuted: (m: boolean) => void;
setPlaybackRate: (r: number) => void;
setHlsReady: (ready: boolean) => void;
}
export const usePlayerStore = create<PlayerStore>((set) => ({
source: null,
playing: false,
currentTime: 0,
duration: 0,
fps: 30,
volume: 1,
muted: false,
playbackRate: 1,
hlsReady: false,
setSource: (source) => set({ source, hlsReady: false }),
setPlaying: (playing) => set({ playing }),
setCurrentTime: (currentTime) => set({ currentTime }),
setDuration: (duration) => set({ duration }),
setFps: (fps) => set({ fps }),
setVolume: (volume) => set({ volume }),
setMuted: (muted) => set({ muted }),
setPlaybackRate: (playbackRate) => set({ playbackRate }),
setHlsReady: (hlsReady) => set({ hlsReady }),
}));

View File

@@ -0,0 +1,16 @@
export type VideoSource =
| { kind: 'local'; file: File; objectUrl: string }
| { kind: 'server'; videoId: string; filename: string };
export interface PlayerState {
source: VideoSource | null;
playing: boolean;
currentTime: number;
duration: number;
fps: number;
volume: number;
muted: boolean;
playbackRate: number;
fullscreen: boolean;
hlsReady: boolean;
}

View File

@@ -0,0 +1,21 @@
// Reusable canvas for frame capture — single canvas instance per CLAUDE.md
let captureCanvas: HTMLCanvasElement | null = null;
export function captureFrame(video: HTMLVideoElement): string | null {
if (!captureCanvas) {
captureCanvas = document.createElement('canvas');
}
captureCanvas.width = video.videoWidth;
captureCanvas.height = video.videoHeight;
const ctx = captureCanvas.getContext('2d');
if (!ctx) return null;
ctx.drawImage(video, 0, 0);
return captureCanvas.toDataURL('image/jpeg', 0.95);
}
export function downloadDataUrl(dataUrl: string, filename: string): void {
const a = document.createElement('a');
a.href = dataUrl;
a.download = filename;
a.click();
}

View File

@@ -0,0 +1,294 @@
/**
* 클라이언트 사이드 3D 좌표 변환 투영
*
* Python advanced_tuner_v2.py 와 동일한 알고리즘:
* R_b2w = Rz(-yaw) * Rx(pitch) * Ry(roll)
* R_align = [[1,0,0],[0,0,-1],[0,1,0]]
* R_w2c = R_align @ R_b2w.T
*
* 좌표계: EPSG:5186 TM [East(m), North(m), Up(m)]
* Python swap_xy=ON 과 동일: easting=X, northing=Y
* sensorH 기본값 20.25mm = 36 × (9/16), 16:9 동영상 기준
*/
import proj4 from 'proj4';
export interface DroneFrameBasic {
frame: number;
lat: number;
lon: number;
altitude: number;
yaw: number;
pitch: number;
roll: number;
focalLen: number;
}
/**
* 카메라 파라미터 — Python advanced_tuner_v2.py 기본값 기준
*
* yaw / pitch / roll 은 모두 SRT 프레임값에 더하는 오프셋 (기본 0).
* Python: pitch = radians(meta['pitch'] + spn_pitch.value()) ← spn_pitch 기본 0
* focalLen / sensorW / sensorH 는 Python spn_focal(24) / spn_sensor(36) 기본값.
* offX/offY/offZ: 드론 위치 보정 — Python off_x/y/z 동치.
*/
export interface CameraParams {
yawOffset: number; // yaw 오프셋 (degrees, per-frame SRT yaw에 더함)
pitch: number; // pitch 오프셋 (degrees, per-frame SRT pitch에 더함, 기본 0)
roll: number; // roll 오프셋 (degrees, per-frame SRT roll에 더함, 기본 0)
focalLen: number; // 초점거리 (35mm 환산 mm, 기본 24)
cx0: number; // 주점 X 오프셋 (정규화)
cy0: number; // 주점 Y 오프셋 (정규화)
offX: number; // 드론 위치 East 보정 (m, 기본 0)
offY: number; // 드론 위치 North 보정 (m, 기본 0)
offZ: number; // 드론 위치 Up 보정 (m, 기본 0)
sensorW: number; // 센서 폭 (mm, 기본 36)
sensorH: number; // 센서 높이 (mm, 기본 20.25 = 36×9/16, 16:9 영상)
}
/** Python advanced_tuner_v2.py 기본값 */
export const DEFAULT_CAMERA_PARAMS: CameraParams = {
yawOffset: 0,
pitch: 0, // offset, Python spn_pitch 기본값 0
roll: 0, // offset, Python spn_roll 기본값 0
focalLen: 24, // Python spn_focal 기본값 24
cx0: 0,
cy0: 0,
offX: 0,
offY: 0,
offZ: 0,
sensorW: 36, // Python spn_sensor 기본값 36
sensorH: 20.25,
};
/** 항상 DEFAULT_CAMERA_PARAMS 반환 (Python 방식: SRT 값은 per-frame으로 자동 적용됨) */
export function paramsFromFrame(_frame: DroneFrameBasic): CameraParams {
return { ...DEFAULT_CAMERA_PARAMS };
}
// EPSG:5186 Korean TM 정의 (Python pyproj와 동일)
proj4.defs('EPSG:5186',
'+proj=tmerc +lat_0=38 +lon_0=127 +k=1 +x_0=200000 +y_0=600000 +ellps=GRS80 +units=m +no_defs'
);
const _toTM = proj4('EPSG:4326', 'EPSG:5186');
/** 위경도 → EPSG:5186 TM [easting(m), northing(m)] */
function latLonToTM(lat: number, lon: number): [number, number] {
// proj4: forward(lon, lat) → [easting, northing]
const [e, n] = _toTM.forward([lon, lat]);
return [e, n];
}
function toRad(d: number) { return d * Math.PI / 180; }
/** 위경도+표고 → 월드 [East, North, Up] (m).
* Python swap_xy=ON 방식: EPSG:5186 TM easting/northing + altitude */
function geoToEnu(
lat: number, lon: number, alt: number,
_refLat: number, _refLon: number, refAlt: number,
): [number, number, number] {
const [e, n] = latLonToTM(lat, lon);
return [e, n, alt - refAlt];
}
export interface ProjectResult {
px: number; // 0~1, 0=왼쪽 (클램프됨)
py: number; // 0~1, 0=위 (클램프됨)
pxRaw: number; // 클램프 없는 원본
pyRaw: number;
dist: number; // 수평 거리 (m)
h: number; // 수평각 (degrees)
v: number; // 수직각 (degrees)
inFov: boolean;
}
type Vec3 = [number, number, number];
/** 카메라 좌표 (Zc 부호 체크 없음 — 근거리 클리핑은 호출자가 처리) */
export interface CameraCoords {
Xc: number;
Yc: number;
Zc: number;
}
/** 카메라 좌표 → 정규화 픽셀 (Zc > 0 보장 후 호출) */
export function pixelFromCamera(
cc: CameraCoords,
params: CameraParams,
): { pxRaw: number; pyRaw: number } {
const f = params.focalLen;
const sW = params.sensorW ?? 36;
const sH = params.sensorH ?? 20.25;
return {
pxRaw: (0.5 + params.cx0) + (cc.Xc / cc.Zc) * (f / sW),
pyRaw: (0.5 + params.cy0) + (cc.Yc / cc.Zc) * (f / sH),
};
}
// ── 공통 내부 계산 ────────────────────────────────────────────────────────────
function buildRelEnu(
camera: DroneFrameBasic,
targetLat: number, targetLon: number, targetAlt: number,
params: CameraParams,
ref?: { lat: number; lon: number; alt: number },
): { relEnu: Vec3; dist: number } {
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
const stEnu = geoToEnu(targetLat, targetLon, targetAlt, refPt.lat, refPt.lon, refPt.alt);
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
const drEnuAdj: Vec3 = [
drEnu[0] + (params.offX ?? 0),
drEnu[1] + (params.offY ?? 0),
drEnu[2] + (params.offZ ?? 0),
];
const relEnu: Vec3 = [stEnu[0] - drEnuAdj[0], stEnu[1] - drEnuAdj[1], stEnu[2] - drEnuAdj[2]];
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2);
return { relEnu, dist };
}
function buildRotation(camera: DroneFrameBasic, params: CameraParams): [Vec3, Vec3, Vec3] {
const yaw = toRad(camera.yaw + params.yawOffset);
const pitch = toRad(camera.pitch + params.pitch);
const roll = toRad(camera.roll + params.roll);
const cy = Math.cos(yaw), sy = Math.sin(yaw);
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const cr = Math.cos(roll), sr = Math.sin(roll);
return [
[ cy*cr + sy*sp*sr, sy*cp, cy*sr - sy*sp*cr],
[-sy*cr + cy*sp*sr, cy*cp, -sy*sr - cy*sp*cr],
[ -cp*sr, sp, cp*cr ],
];
}
function applyRw2c(b2w: [Vec3, Vec3, Vec3], rel: Vec3): CameraCoords {
return {
Xc: b2w[0][0]*rel[0] + b2w[1][0]*rel[1] + b2w[2][0]*rel[2],
Yc: -(b2w[0][2]*rel[0] + b2w[1][2]*rel[1] + b2w[2][2]*rel[2]),
Zc: b2w[0][1]*rel[0] + b2w[1][1]*rel[1] + b2w[2][1]*rel[2],
};
}
/**
* 카메라 좌표만 반환 (Zc 체크 없음).
* 선로 중심선 근거리 클리핑(Python 방식)에 사용.
*/
export function toCameraCoords(
camera: DroneFrameBasic,
targetLat: number, targetLon: number, targetAlt: number,
params: CameraParams,
ref?: { lat: number; lon: number; alt: number },
): CameraCoords {
const { relEnu } = buildRelEnu(camera, targetLat, targetLon, targetAlt, params, ref);
const b2w = buildRotation(camera, params);
return applyRw2c(b2w, relEnu);
}
/**
* Python advanced_tuner_v2.py 와 동일한 투영 공식
*
* 회전 행렬:
* R_b2w = Rz(-yaw) × Rx(pitch) × Ry(roll)
* R_align = [[1,0,0],[0,0,-1],[0,1,0]] (body→camera 축 변환)
* R_w2c = R_align × R_b2w.T
*
* 투영:
* pts_cam = R_w2c × rel_enu
* u_norm = 0.5 + (Xc/Zc) × (f/sensorW)
* v_norm = 0.5 + (Yc/Zc) × (f/sensorH)
*
* 드론 위치 오프셋 (off_x/y/z): 카메라 위치를 ENU 공간에서 보정
*/
export function projectPoint(
camera: DroneFrameBasic,
targetLat: number,
targetLon: number,
targetAlt: number,
params: CameraParams,
ref?: { lat: number; lon: number; alt: number },
): ProjectResult | null {
const refPt = ref ?? { lat: camera.lat, lon: camera.lon, alt: camera.altitude };
// 1. 월드 ENU (m)
const stEnu = geoToEnu(targetLat, targetLon, targetAlt, refPt.lat, refPt.lon, refPt.alt);
const drEnu = geoToEnu(camera.lat, camera.lon, camera.altitude, refPt.lat, refPt.lon, refPt.alt);
// 드론 위치 보정 적용 (Python: drone_pos = [dx+off_x, dy+off_y, alt+off_z])
const drEnuAdj: Vec3 = [
drEnu[0] + (params.offX ?? 0),
drEnu[1] + (params.offY ?? 0),
drEnu[2] + (params.offZ ?? 0),
];
const relEnu: Vec3 = [
stEnu[0] - drEnuAdj[0],
stEnu[1] - drEnuAdj[1],
stEnu[2] - drEnuAdj[2],
];
const dist = Math.sqrt(relEnu[0] ** 2 + relEnu[1] ** 2);
// 2. 회전 행렬 (Python 방식: Rz(-yaw)*Rx(pitch)*Ry(roll), 모두 라디안)
// Python: yaw=radians(meta['yaw']+off_yaw), pitch=radians(meta['pitch']+off_pitch), ...
const yaw = toRad(camera.yaw + params.yawOffset);
const pitch = toRad(camera.pitch + params.pitch); // SRT per-frame + offset
const roll = toRad(camera.roll + params.roll); // SRT per-frame + offset
const cy = Math.cos(yaw), sy = Math.sin(yaw);
const cp = Math.cos(pitch), sp = Math.sin(pitch);
const cr = Math.cos(roll), sr = Math.sin(roll);
// Rz(-yaw): rotation around Z by -yaw
// [[cy, sy, 0], [-sy, cy, 0], [0, 0, 1]]
// Rx(pitch): rotation around X by pitch
// [[1, 0, 0], [0, cp, -sp], [0, sp, cp]]
// Ry(roll): rotation around Y by roll
// [[cr, 0, sr], [0, 1, 0], [-sr, 0, cr]]
//
// R_b2w = Rz(-yaw) * Rx(pitch) * Ry(roll)
// Computed element by element:
const b2w: [Vec3, Vec3, Vec3] = [
[
cy*cr + sy*sp*sr, sy*cp, cy*sr - sy*sp*cr,
],
[
-sy*cr + cy*sp*sr, cy*cp, -sy*sr - cy*sp*cr,
],
[
-cp*sr, sp, cp*cr,
],
];
// R_w2c = R_align @ R_b2w.T (R_align = [[1,0,0],[0,0,-1],[0,1,0]])
//
// R_w2c rows are derived from columns of R_b2w:
// R_w2c row 0 = col 0 of R_b2w (R_align row 0 = [1,0,0])
// R_w2c row 1 = -(col 2 of R_b2w) (R_align row 1 = [0,0,-1])
// R_w2c row 2 = col 1 of R_b2w (R_align row 2 = [0,1,0])
//
// p_cam = R_w2c @ relEnu → access b2w columns (swap first index across rows)
const Xc = b2w[0][0]*relEnu[0] + b2w[1][0]*relEnu[1] + b2w[2][0]*relEnu[2]; // col 0
const Yc = -(b2w[0][2]*relEnu[0] + b2w[1][2]*relEnu[1] + b2w[2][2]*relEnu[2]); // -col 2
const Zc = b2w[0][1]*relEnu[0] + b2w[1][1]*relEnu[1] + b2w[2][1]*relEnu[2]; // col 1
if (Zc <= 0) return null;
// 3. 핀홀 투영 (Python: u=f_px*(Xc/Zc)+w/2, v=f_px*(Yc/Zc)+h/2)
const f = params.focalLen;
const sW = params.sensorW ?? 36;
const sH = params.sensorH ?? 20.25;
const pxRaw = (0.5 + params.cx0) + (Xc / Zc) * (f / sW);
const pyRaw = (0.5 + params.cy0) + (Yc / Zc) * (f / sH);
return {
px: Math.max(0, Math.min(1, pxRaw)),
py: Math.max(0, Math.min(1, pyRaw)),
pxRaw,
pyRaw,
dist,
h: Math.atan2(Xc, Zc) * (180 / Math.PI),
v: Math.atan2(-Yc, Zc) * (180 / Math.PI),
inFov: pxRaw >= 0 && pxRaw <= 1 && pyRaw >= 0 && pyRaw <= 1,
};
}

View File

@@ -0,0 +1,28 @@
export function secondsToTimecode(seconds: number, separator = '.'): string {
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = Math.floor(seconds % 60);
const ms = Math.round((seconds % 1) * 1000);
return [
h.toString().padStart(2, '0'),
m.toString().padStart(2, '0'),
s.toString().padStart(2, '0'),
].join(':') + separator + ms.toString().padStart(3, '0');
}
export function timecodeToSeconds(tc: string): number {
const parts = tc.replace(',', '.').split(':');
if (parts.length === 3) {
const [h, m, s] = parts;
return parseInt(h) * 3600 + parseInt(m) * 60 + parseFloat(s);
}
return parseFloat(tc);
}
export function frameToSeconds(frame: number, fps: number): number {
return frame / fps;
}
export function secondsToFrame(seconds: number, fps: number): number {
return Math.floor(seconds * fps);
}

3
client/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare const __BUILD_TIME__: string;

View File

@@ -0,0 +1,8 @@
/** @type {import('tailwindcss').Config} */
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
};

24
client/tsconfig.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"paths": {
"@abcvideo/shared": ["../shared/src/index.ts"]
}
},
"include": ["src"],
"references": [{ "path": "./tsconfig.node.json" }]
}

10
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}

38
client/vite.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import path from 'path';
export default defineConfig({
plugins: [react()],
define: {
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
},
build: {
rollupOptions: {
output: {
// 큰 라이브러리를 별도 청크로 분리 (변수 충돌 방지 + 로딩 최적화)
manualChunks: {
'vendor-react': ['react', 'react-dom'],
'vendor-videojs': ['video.js'],
'vendor-hls': ['hls.js'],
},
},
},
},
resolve: {
alias: {
'@abcvideo/shared': path.resolve(__dirname, '../shared/src/index.ts'),
},
},
server: {
port: 5173,
host: '0.0.0.0',
allowedHosts: true,
proxy: {
'/api': {
target: 'http://localhost:3030',
changeOrigin: true,
},
},
},
});