Files
GhiVideo_v4/client/src/components/player/VideoPlayer.tsx
T
b23042andClaude Fable 5 87b66d9ed4 데이터 파이프라인 — 전 구간 프레임·djmd dt 전파·세그먼트 시크·선형 곡선 평활
- geoData: fullFrames(전체 비행 프레임 — 전 구간 측점 검색용, datum·초점 보정 동일
  적용), DJI_VIRTUAL_FPS export, 선형 곡선 평활(Centripetal Catmull-Rom — 정점
  ~6m 세분, 원 정점 전부 통과로 측점 정합 유지, 60m+ 직선·양끝 제외)
- djmdTrack: refineFramesWithDjmd 가 교차상관 dt(초)도 반환
- geoStore: fullFrames·djmdDtSec 상태 — 검색의 CSV↔영상 시간 변환에 사용
- useVideoPlayer.loadLocalFile(startAtSec): 로드 후 지정 시각부터 시작
- VideoPlayer.handleSeekSegment: 측점 검색의 타 세그먼트 점프(전환+시크,
  재생 상태 유지), station_lookahead 등 RouteInfo 타입 문서화

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 16:27:03 +09:00

729 lines
35 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useRef, useImperativeHandle, forwardRef, useState, useEffect, useMemo } from 'react';
import StationOverlay from '../overlay/StationOverlay';
import RoutePanel from '../overlay/RoutePanel';
import RouteInfoOverlay from '../overlay/RouteInfoOverlay';
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 { useGeoStore } from '../../store/geoStore';
import { useSettingsStore, KNOWN_GRADES } from '../../store/settingsStore';
import { captureFrame, downloadDataUrl } from '../../utils/frameCapture';
import { generateByDemKmls } from '../../utils/demKml';
import { secondsToTimecode, secondsToFrame } from '../../utils/timecode';
import { useCaptureStore } from '../../store/captureStore';
import HlsConversionStatus from './HlsConversionStatus';
import { StationBar } from '../../stationbar/StationBar';
/** 드롭된 디렉토리/파일 엔트리에서 모든 파일을 재귀 수집 (폴더 드래그&드롭 지원). */
function collectDropEntry(entry: FileSystemEntry, out: File[]): Promise<void> {
return new Promise((resolve) => {
if (entry.isFile) {
(entry as FileSystemFileEntry).file((f) => { out.push(f); resolve(); }, () => resolve());
} else if (entry.isDirectory) {
const reader = (entry as FileSystemDirectoryEntry).createReader();
const readBatch = () => {
reader.readEntries(async (ents) => {
if (!ents.length) { resolve(); return; } // 모든 배치 소진
await Promise.all(ents.map((en) => collectDropEntry(en, out)));
readBatch();
}, () => resolve());
};
readBatch();
} else {
resolve();
}
});
}
export interface VideoPlayerHandle {
loadLocalFile: (file: File, autoPlay?: boolean, startAtSec?: number) => void;
loadServerStream: (videoId: string, filename: string) => void | Promise<void>;
seekTo: (time: number) => void;
getVideoElement: () => HTMLVideoElement | null;
}
/** 초 → "m:ss" (재생목록 길이 표시용). 미상이면 "--:--". */
function fmtDur(d: number | null | undefined): string {
if (d == null || !isFinite(d)) return '--:--';
return `${Math.floor(d / 60)}:${String(Math.floor(d % 60)).padStart(2, '0')}`;
}
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 } = useFrameStep(playerRef);
const { currentTime, duration, playing, source, playbackRate, videoReady, videoWidth, videoHeight } = usePlayerStore();
// 커서 매끄러운 이동:
// - 라이브 시간(smoothTimeRef): 매 프레임 '단조' 벽시계 보간. StationBar가 ref로 읽어
// 커서/진행바를 직접(transform) 갱신 → React 60fps 리렌더 없이 매끄럽게.
// - state(smoothTime): 배지 숫자·색 변경용으로만 throttle(≈10fps) 갱신.
const [smoothTime, setSmoothTime] = useState(0);
const smoothTimeRef = useRef(0);
const anchorRef = useRef({ media: 0, wall: 0 });
const lastSetRef = useRef(0);
useEffect(() => {
let raf = 0;
// 시크(클릭/드래그 이동) 시 커서를 즉시 그 위치로 재동기화.
// (단조 보간은 앵커보다 앞쪽으로의 뒤로가기 시크를 무시하므로 별도 처리 필요)
const onSeeked = (): void => {
const p = playerRef.current;
if (!p || p.isDisposed()) return;
const t = p.currentTime() ?? 0;
anchorRef.current = { media: t, wall: performance.now() };
smoothTimeRef.current = t;
lastSetRef.current = t;
setSmoothTime(t);
};
playerRef.current?.on('seeked', onSeeked);
const tick = (): void => {
const p = playerRef.current;
if (p && !p.isDisposed()) {
const dur = p.duration() ?? 0;
let t: number;
if (p.paused()) {
t = p.currentTime() ?? 0;
anchorRef.current = { media: t, wall: performance.now() };
} else {
const a = anchorRef.current;
const rate = p.playbackRate() ?? 1;
let est = a.media + ((performance.now() - a.wall) / 1000) * rate;
const real = p.currentTime() ?? 0;
// 단조: 작은 역행은 무시(흔들림 방지). 뒤처짐(real이 앞섬) 또는 시크(뒤로)만 재동기화.
if (real - est > 0.3 || real < a.media - 0.3) {
est = real;
anchorRef.current = { media: real, wall: performance.now() };
}
t = dur > 0 ? Math.min(est, dur) : est;
}
smoothTimeRef.current = t;
if (Math.abs(t - lastSetRef.current) >= 0.1) {
lastSetRef.current = t;
setSmoothTime(t);
}
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(raf);
playerRef.current?.off('seeked', onSeeked);
};
}, [playerRef, source]);
const loadFromFolder = useGeoStore((s) => s.loadFromFolder);
const geoLoaded = useGeoStore((s) => s.loaded);
// 드론 정보 — 하단바에 GPS/고도 항상 표시.
const storeFrames = useGeoStore((s) => s.frames);
// dev_mode (route.json) — 0 이면 '영상제어' 버튼과 제어 UI 전체를 숨김(뷰어 모드).
// routeInfo.dev_mode 우선, 최상위 dev_mode 도 허용. 없으면 1(표시) — 기존 동작 유지.
const storeRouteMeta = useGeoStore((s) => s.routeMeta);
const fileDevMode = (() => {
const ri = storeRouteMeta?.routeInfo as Record<string, unknown> | undefined;
const v = ri?.dev_mode ?? (storeRouteMeta as unknown as Record<string, unknown> | null)?.dev_mode;
return v === 0 || v === '0' ? 0 : 1;
})();
// F2 → 개발자 모드(1↔0) 전환 — route.json 값을 세션 한정으로 덮어씀(파일 미변경).
// 새 폴더 로드 시 초기화되어 다시 파일 값을 따른다. 텍스트 입력 중에는 무시.
const [devModeOverride, setDevModeOverride] = useState<number | null>(null);
useEffect(() => { setDevModeOverride(null); }, [storeRouteMeta]);
const devMode = devModeOverride ?? fileDevMode;
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.code !== 'F2') return;
const t = e.target as HTMLElement;
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return;
e.preventDefault();
setDevModeOverride((prev) => ((prev ?? fileDevMode) === 0 ? 1 : 0));
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [fileDevMode]);
// 분할 영상 재생목록 — 폴더 로드 시 이름 오름차순. 목록 클릭으로 세그먼트 직접 선택 재생.
const videoFiles = useGeoStore((s) => s.videoFiles);
const videoDurations = useGeoStore((s) => s.videoDurations);
const videoIndex = useGeoStore((s) => s.videoIndex);
// 영상목록 콤보(좌상단 타이틀 아래) 펼침 상태 — 기본 접힘.
const [showPlaylist, setShowPlaylist] = useState(false);
// 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어.
const gradeFilter = useSettingsStore((s) => s.gradeFilter);
const setGradeFilter = useSettingsStore((s) => s.setGradeFilter);
const poiOverlapExclude = useSettingsStore((s) => s.poiOverlapExclude);
const setPoiOverlapExclude = useSettingsStore((s) => s.setPoiOverlapExclude);
// 선형(중심선)·드론 궤적 영상 오버레이 토글 — StationOverlay 와 공유(설정 스토어).
const showCenterline = useSettingsStore((s) => s.showCenterline);
const setShowCenterline = useSettingsStore((s) => s.setShowCenterline);
// A → 선형(중심선+측점 라벨) 표시/숨김 토글. 텍스트 입력·수정키 조합(Ctrl+A 등)은 무시.
useEffect(() => {
const onKey = (e: KeyboardEvent): void => {
if (e.code !== 'KeyA' || e.ctrlKey || e.metaKey || e.altKey) return;
const t = e.target as HTMLElement;
if (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.isContentEditable) return;
e.preventDefault();
setShowCenterline(!showCenterline);
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [showCenterline, setShowCenterline]);
const showDronePath = useSettingsStore((s) => s.showDronePath);
const setShowDronePath = useSettingsStore((s) => s.setShowDronePath);
const showRoutePanel = useSettingsStore((s) => s.showRoutePanel);
const setShowRoutePanel = useSettingsStore((s) => s.setShowRoutePanel);
// 하단 도구 패널: UI에서 숨김(코드는 보존). 다시 표시하려면 true 로.
const SHOW_TOOLBAR = false;
// 폴더 선택: 지리정보 파싱 + 영상 재생
const handleSelectFolder = async (files: File[]) => {
if (!files.length) return;
try {
const videoFile = await loadFromFolder(files);
if (videoFile) loadLocalFile(videoFile);
else console.warn('[geo] 폴더에 영상 파일(mp4/webm)이 없습니다 — 지리정보만 로드');
// KMZ(POI·구조물 원본) 필수 — 측점/드론은 있는데 KMZ만 빠진 경우 = 데이터 누락.
const { kmzMissing, stations, frames } = useGeoStore.getState();
if (kmzMissing && (stations.length > 0 || frames.length > 0)) {
alert(
'KMZ(POI·구조물 원본)가 폴더에 없습니다.\n' +
'POI·구조물이 표시되지 않습니다. KMZ를 포함해 데이터를 재구축·전달하세요.\n' +
'(측점·드론 정보는 정상 로드되었습니다.)',
);
}
// 표고(z) 없는 KML → DEM(SRTM 30m)으로 채운 <이름>_byDem.kml 자동 생성·다운로드.
// 폴더에 _byDem 이 이미 있으면 건너뛰고, 로더는 _byDem 을 원본보다 우선 사용한다.
void generateByDemKmls(files)
.then((made) => {
if (made.length) {
alert(
`표고 없는 KML을 발견해 DEM 표고를 채운 파일을 생성했습니다:\n${made.join(', ')}\n\n` +
'다운로드된 파일을 영상 폴더에 넣어두면 다음 폴더 선택부터 자동으로 사용됩니다.',
);
}
})
.catch((e) => console.warn('[DEM] _byDem.kml 생성 실패(오프라인이면 정상):', e));
} catch (err) {
console.error('[geo] 폴더 로드 실패', err);
}
};
// 재생목록에서 세그먼트 선택 — 같은 항목이면 처음부터 재생, 다른 항목이면
// 드론 로그를 해당 구간으로 재정렬(loadSegment)한 뒤 전환 재생.
const handleSelectSegment = async (i: number): Promise<void> => {
const geo = useGeoStore.getState();
if (usePlayerStore.getState().source?.kind !== 'local') return;
if (i === geo.videoIndex) {
playerRef.current?.currentTime(0);
const p = playerRef.current?.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
} else {
const file = await geo.loadSegment(i);
if (!file) return;
loadLocalFile(file, true); // 목록 선택 — 로딩 완료 시 0초부터 자동 재생
}
};
// 측점 검색 전 구간 점프 — 다른 세그먼트의 통과 지점으로 전환+시크.
// 같은 세그먼트면 시크만, 다르면 loadSegment 후 해당 시각부터(재생 상태 유지).
const handleSeekSegment = async (i: number, timeSec: number): Promise<void> => {
const geo = useGeoStore.getState();
if (usePlayerStore.getState().source?.kind !== 'local') return;
if (i === geo.videoIndex) {
playerRef.current?.currentTime(timeSec);
return;
}
const wasPlaying = usePlayerStore.getState().playing;
const file = await geo.loadSegment(i);
if (!file) return;
loadLocalFile(file, wasPlaying, timeSec);
};
// 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생.
// 드론 로그(시간 기반)는 loadSegment 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다.
useEffect(() => {
const player = playerRef.current;
if (!player) return;
const onEnded = (): void => {
void (async () => {
const geo = useGeoStore.getState();
if (!geo.loaded || geo.videoFiles.length < 2) return;
// 폴더 기반 로컬 재생에서만 동작 (서버 스트림/단일 파일은 대상 아님)
if (usePlayerStore.getState().source?.kind !== 'local') return;
const next = geo.videoIndex + 1;
if (next >= geo.videoFiles.length) return;
const file = await geo.loadSegment(next);
if (!file) return;
loadLocalFile(file, true); // 연속재생 — 로딩 완료 시 0초부터 자동 재생
})();
};
player.on('ended', onEnded);
return () => { player.off('ended', onEnded); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [playerRef, loadLocalFile]);
// 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);
// 영상제어 토글: 카메라 파라미터·프레임상태·배속 표시 on/off
const [showVideoControls, setShowVideoControls] = useState(true);
// 좌하단 그룹을 재생바 위에 두기 위해 StationBar 높이를 동적 측정.
const barWrapRef = useRef<HTMLDivElement>(null);
const [barHeight, setBarHeight] = useState(0);
useEffect(() => {
const el = barWrapRef.current;
if (!el) return;
const update = (): void => setBarHeight(el.offsetHeight);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, [geoLoaded]);
// 노선 배너(429.5×65 @1920 비율) 높이만큼 카메라 파라미터를 아래로.
const [stageWidth, setStageWidth] = useState(0);
useEffect(() => {
const el = wrapperRef.current;
if (!el) return;
const update = (): void => setStageWidth(el.clientWidth);
update();
const ro = new ResizeObserver(update);
ro.observe(el);
return () => ro.disconnect();
}, []);
const paramTop = Math.round((stageWidth / 1920) * 65) + 10;
const [showUtilBar, setShowUtilBar] = useState(false);
const handleTogglePlay = (): void => {
const p = playerRef.current;
if (!p) return;
if (playing) p.pause();
else void p.play();
};
const handleStop = (): void => {
const p = playerRef.current;
if (!p) return;
p.pause();
p.currentTime(0);
};
const handleSeek = (t: number): void => {
playerRef.current?.currentTime(t);
};
useKeyboard({
playerRef,
onStepForward: stepForward,
onStepBackward: stepBackward,
onCaptureFrame: handleCaptureFrame,
onAddMemo: handleAddMemo,
onToggleHelp,
containerRef: wrapperRef,
});
// 드래그&드롭 — 폴더(영상+측점/POI) 또는 단일 영상 파일.
// 폴더는 dataTransfer.files 가 비어 있으므로 webkitGetAsEntry 로 디렉토리를 재귀 순회한다.
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
// await 후엔 dataTransfer 가 무효화될 수 있어 동기적으로 먼저 캡처.
const entries: FileSystemEntry[] = [];
const dt = e.dataTransfer;
if (dt.items) {
for (let i = 0; i < dt.items.length; i++) {
const en = dt.items[i].webkitGetAsEntry?.();
if (en) entries.push(en);
}
}
const flatFiles = Array.from(dt.files);
void (async () => {
if (entries.length) {
const out: File[] = [];
await Promise.all(entries.map((en) => collectDropEntry(en, out)));
if (out.length) { handleSelectFolder(out); return; }
}
if (flatFiles.length > 1) { handleSelectFolder(flatFiles); return; }
const file = flatFiles[0];
if (file?.type.startsWith('video/')) loadLocalFile(file);
})();
};
// 영상 실제 fps — 데이터(드론 CSV 마지막 프레임 번호) ÷ 영상 길이(초) 로 자동 산출 후
// 표준 fps(24/25/29.97/30/50/60 등) 중 가까운 값에 스냅. 영상마다 fps 가 달라도 자동 대응.
// (드론 CSV 엔 시간이 없고 frame_cnt 만 있어, 영상 length 와 결합해야 fps 를 알 수 있다.)
// 데이터/영상길이 미확보 시 29.97 폴백. VFC 자동감지는 31fps 오감지가 있어 사용 안 함.
const effectiveFps = useMemo(() => {
const FALLBACK = 30000 / 1001;
if (!storeFrames.length || !duration || duration <= 0) return FALLBACK;
let maxF = 0;
for (const f of storeFrames) if (f.frame > maxF) maxF = f.frame;
if (maxF <= 0) return FALLBACK;
const raw = maxF / duration; // 마지막 프레임 번호 ÷ 영상 길이(초)
const STD = [23.976, 24, 25, 29.97, 30, 50, 59.94, 60];
let best = STD[0], bd = Math.abs(raw - STD[0]);
for (const s of STD) { const d = Math.abs(raw - s); if (d < bd) { bd = d; best = s; } }
return bd <= best * 0.1 ? best : raw; // 표준값 ±10% 이내면 스냅, 아니면 원시값
}, [storeFrames, duration]);
const frame = secondsToFrame(currentTime, effectiveFps);
const videoId = source?.kind === 'server' ? source.videoId : null;
// 드론 정보(측점진단) — 현재 프레임에 가장 가까운 드론 프레임의 GPS/고도만 표시.
// 드론 GPS·고도 HUD 는 항상 표시 → 토글 없이 프레임만 있으면 계산.
const stationDiag = useMemo(() => {
if (!storeFrames.length) return null;
let best = storeFrames[0], bd = Math.abs(storeFrames[0].frame - frame);
for (const df of storeFrames) {
const d = Math.abs(df.frame - frame);
if (d < bd) { bd = d; best = df; }
}
return { f: best };
}, [storeFrames, frame]);
return (
<div
ref={wrapperRef}
className="relative bg-black w-full h-full"
onDrop={handleDrop}
onDragOver={(e) => e.preventDefault()}
>
{/* 영상 영역 — 컨테이너 전체를 채우는 relative 래퍼 (사이니지: 화면 가득) */}
<div className="relative w-full h-full">
{/* Video.js container — 영상이 영역을 꽉 채우는 베이스 레이어 (object-fit:cover) */}
{/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
<div
data-vjs-player
ref={containerRef}
className="absolute inset-0 w-full h-full"
style={{ cursor: source ? 'pointer' : 'default' }}
onClick={() => {
if (source) handleTogglePlay();
}}
/>
{/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */}
<RouteInfoOverlay />
{/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */}
{/* dev_mode=0(route.json) 이면 그룹 전체 숨김 — 뷰어 모드 */}
{source && devMode !== 0 && (
<div
className="absolute left-2 z-30 flex items-center gap-2 flex-wrap pointer-events-auto"
style={{ bottom: (barHeight || 130) + 8 }}
>
{/* 영상제어 토글 — 버튼만, 고정폭(가장 긴 'OFF' 기준이라 ON/OFF로 폭이 안 변함) */}
<button
type="button"
onClick={() => setShowVideoControls((v) => !v)}
className={`text-xs px-2 py-1 rounded border font-semibold text-center min-w-[96px] ${showVideoControls ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>영상제어 {showVideoControls ? 'ON' : 'OFF'}</button>
{showVideoControls && (
<>
{/* 배속 패널 (영상제어 우측) */}
<div className="flex items-center gap-1 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs">배속</span>
{[0.5, 1, 1.5, 2, 3, 4].map((r) => (
<button
key={r}
type="button"
onClick={() => playerRef.current?.playbackRate(r)}
className={`text-xs px-1.5 py-0.5 rounded ${
Math.abs(playbackRate - r) < 0.01
? 'bg-amber-400 text-black'
: 'bg-gray-700 text-gray-200 hover:bg-gray-600'
}`}
>
{r}x
</button>
))}
</div>
{/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */}
<span className="bg-black/70 text-gray-200 text-xs px-2 py-1 rounded font-mono whitespace-nowrap">
{secondsToTimecode(currentTime)} | F<span className="inline-block text-left" style={{ minWidth: '6ch' }}>{frame}</span> | {effectiveFps.toFixed(2)}fps
</span>
{geoLoaded && (
<>
{/* 좌측 패널 토글 — 선형 왼편, 동일 폭(min-w-[96px]) */}
<button
type="button"
onClick={() => setShowRoutePanel(!showRoutePanel)}
title="좌측 노선 패널 표시/숨김"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showRoutePanel ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>좌측 패널 {showRoutePanel ? 'ON' : 'OFF'}</button>
{/* 선형(중심선) 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
<button
type="button"
onClick={() => setShowCenterline(!showCenterline)}
title="선형(중심선) 표시/숨김"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showCenterline ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>선형 {showCenterline ? 'ON' : 'OFF'}</button>
{/* 드론 궤적 토글 — 겹침제외와 동일 폭(min-w-[96px]) */}
<button
type="button"
onClick={() => setShowDronePath(!showDronePath)}
title="드론 궤적 표시/숨김"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${showDronePath ? 'bg-amber-400 border-amber-300 text-black' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>드론궤적 {showDronePath ? 'ON' : 'OFF'}</button>
{/* 드론 위치정보 — 드론궤적 버튼 바로 우측에 붙임. GPS·고도 항상 표시. */}
{stationDiag && (
<div className="bg-black/70 px-2 py-1 rounded font-mono text-xs text-gray-200 whitespace-nowrap">
GPS {stationDiag.f.lat.toFixed(6)}, {stationDiag.f.lon.toFixed(6)}
<span className="text-gray-400"> · 절대고도 {stationDiag.f.altitude.toFixed(1)}m</span>
</div>
)}
{/* 겹침제외 토글 — UI 숨김(기능/상태는 보존, 기본 ON). 다시 보이려면 주석 해제. */}
{/* <button
type="button"
onClick={() => setPoiOverlapExclude(!poiOverlapExclude)}
title="ON=겹친 POI 숨김 / OFF=모든 POI 표시"
className={`text-xs px-2 py-1 rounded border text-center min-w-[96px] ${poiOverlapExclude ? 'bg-blue-600/80 border-blue-400 text-white' : 'bg-black/70 border-gray-600 text-gray-300'}`}
>겹침제외 {poiOverlapExclude ? 'ON' : 'OFF'}</button> */}
{/* 시설등급 패널 */}
<div className="flex items-center gap-2 bg-black/70 px-2 py-1 rounded">
<span className="text-gray-400 text-xs">시설등급</span>
{KNOWN_GRADES.map((g) => (
<label
key={g}
className="flex items-center gap-1 text-xs text-gray-200 cursor-pointer select-none"
>
<input
type="checkbox"
className="accent-amber-400"
checked={gradeFilter[g] ?? false}
onChange={(e) => setGradeFilter(g, e.target.checked)}
/>
{g}
</label>
))}
</div>
</>
)}
</>
)}
</div>
)}
{/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */}
<RoutePanel
currentTime={currentTime}
visible={showStations && showRoutePanel}
onSeek={(time) => playerRef.current?.currentTime(time)}
topPx={paramTop + 40}
bottomPx={(barHeight || 130) + 90}
/>
</div>
{/* 영상 목록 콤보 — 좌상단 타이틀(노선 배너) 아래. 버튼 클릭 시 목록이 아래로 펼쳐지고
항목 선택 시 해당 영상으로 즉시 전환(드론 로그도 세그먼트에 맞춰 자동 재정렬).
현재 재생 항목 재선택 시 처음부터 재생. */}
{source?.kind === 'local' && videoFiles.length > 0 && (
<div className="absolute left-2 z-40 w-80 pointer-events-auto" style={{ top: paramTop }}>
<button
type="button"
onClick={() => setShowPlaylist((v) => !v)}
title="폴더 영상 목록 펼치기/접기"
className={`w-full flex items-center gap-1.5 px-2 py-1 rounded border text-xs text-left ${
showPlaylist
? 'bg-amber-400 border-amber-300 text-black font-semibold'
: 'bg-black/70 border-gray-600 text-gray-200 hover:bg-black/90'
}`}
>
<span className="shrink-0"></span>
<span className="flex-1 truncate">{videoFiles[videoIndex]?.name ?? ''}</span>
<span className={`shrink-0 font-mono ${showPlaylist ? 'text-black/70' : 'text-gray-400'}`}>
{videoIndex + 1}/{videoFiles.length} {showPlaylist ? '▲' : '▼'}
</span>
</button>
{showPlaylist && (
<ul className="mt-1 max-h-56 overflow-y-auto bg-black/85 border border-gray-600 rounded-md">
{videoFiles.map((f, i) => (
<li key={`${i}-${f.name}`}>
<button
type="button"
onClick={() => { setShowPlaylist(false); void handleSelectSegment(i); }}
title={i === videoIndex ? `${f.name} (재선택 시 처음부터)` : f.name}
className={`w-full flex items-center gap-1.5 px-2 py-1 text-xs text-left ${
i === videoIndex
? 'bg-amber-400/90 text-black font-semibold'
: 'text-gray-200 hover:bg-gray-700/80'
}`}
>
<span className="w-4 shrink-0 text-center">{i === videoIndex ? '▶' : i + 1}</span>
<span className="flex-1 truncate">{f.name}</span>
<span className={`shrink-0 font-mono ${i === videoIndex ? 'text-black/70' : 'text-gray-400'}`}>
{fmtDur(videoDurations[i])}
</span>
</button>
</li>
))}
</ul>
)}
</div>
)}
{/* Empty state placeholder — 가운데 반투명 폴더 선택 */}
{!source && (
<div className="absolute inset-0 flex flex-col items-center justify-center text-gray-400 pointer-events-none select-none" style={{ minHeight: '240px' }}>
<div className="text-5xl mb-3 opacity-60">&#9654;</div>
<p className="text-lg">동영상 폴더를 드래그하거나 선택하세요</p>
<p className="text-sm mt-1 mb-6">영상 + 측점/POI 데이터가 함께 로드됩니다</p>
<label className="pointer-events-auto cursor-pointer bg-emerald-500/20 hover:bg-emerald-500/40 backdrop-blur-sm border border-emerald-300/40 text-white text-base font-medium px-7 py-3 rounded-xl shadow-lg transition-colors">
폴더 선택
<input
type="file"
className="hidden"
webkitdirectory=""
directory=""
multiple
onChange={(e) => {
const files = e.target.files;
if (files?.length) void handleSelectFolder(Array.from(files));
}}
/>
</label>
</div>
)}
{/* 측점 오버레이 */}
<StationOverlay
currentFrame={frame}
currentTime={currentTime}
timeRef={smoothTimeRef}
fps={effectiveFps}
visible={showStations}
videoReady={videoReady}
videoWidth={videoWidth}
videoHeight={videoHeight}
onTogglePlay={handleTogglePlay}
showPanel={devMode !== 0 && showVideoControls && !!source}
topPx={paramTop}
barHeight={barHeight}
/>
{/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */}
{geoLoaded && (
<div ref={barWrapRef} className="absolute bottom-0 left-0 right-0 z-20">
<StationBar
currentTime={smoothTime}
timeRef={smoothTimeRef}
duration={duration}
playing={playing}
onTogglePlay={handleTogglePlay}
onStop={handleStop}
onCapture={handleCaptureFrame}
onSeek={handleSeek}
onSeekSegment={(i, t) => { void handleSeekSegment(i, t); }}
showStations={showStations}
onToggleStations={() => setShowStations((v) => !v)}
/>
</div>
)}
{/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) — UI 숨김(코드 보존, SHOW_TOOLBAR 로 제어) */}
{SHOW_TOOLBAR && (
<div className="bg-gray-900 border-t border-gray-800">
<button
type="button"
onClick={() => setShowUtilBar((v) => !v)}
className="w-full flex items-center gap-1.5 px-2 py-1 text-xs text-gray-400 hover:text-white"
>
<span className={`inline-block transition-transform ${showUtilBar ? 'rotate-90' : ''}`}>&#9656;</span>
도구 {showUtilBar ? '접기' : '펼치기'}
</button>
{showUtilBar && (
<div className="flex items-center gap-2 px-2 pb-2 flex-wrap">
<label className="cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-sm px-3 py-1.5 rounded">
폴더 선택
<input
type="file"
className="hidden"
webkitdirectory=""
directory=""
multiple
onChange={(e) => {
const files = e.target.files;
if (files?.length) void handleSelectFolder(Array.from(files));
}}
/>
</label>
{/* 프레임 직접 이동 */}
<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 / effectiveFps);
}
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>
)}
</div>
);
}
);
export default VideoPlayer;