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 { 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) => void; loadServerStream: (videoId: string, filename: string) => void | Promise; 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( function VideoPlayer({ onAddMemo, onToggleHelp }, ref) { const containerRef = useRef(null); const wrapperRef = useRef(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 | undefined; const v = ri?.dev_mode ?? (storeRouteMeta as unknown as Record | null)?.dev_mode; return v === 0 || v === '0' ? 0 : 1; })(); // F2 → 개발자 모드(1↔0) 전환 — route.json 값을 세션 한정으로 덮어씀(파일 미변경). // 새 폴더 로드 시 초기화되어 다시 파일 값을 따른다. 텍스트 입력 중에는 무시. const [devModeOverride, setDevModeOverride] = useState(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 => { 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 가 누적 오프셋으로 재정렬해 궤적 정합을 유지한다. 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(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 (
e.preventDefault()} > {/* 영상 영역 — 컨테이너 전체를 채우는 relative 래퍼 (사이니지: 화면 가득) */}
{/* Video.js container — 영상이 영역을 꽉 채우는 베이스 레이어 (object-fit:cover) */} {/* 영상 클릭 = 재생/일시정지 토글 (컨트롤바 숨김 상태) */}
{ if (source) handleTogglePlay(); }} /> {/* 노선 정보 배너 — 영상 좌상단 (route.json routeInfo) */} {/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */} {/* dev_mode=0(route.json) 이면 그룹 전체 숨김 — 뷰어 모드 */} {source && devMode !== 0 && (
{/* 영상제어 토글 — 버튼만, 고정폭(가장 긴 'OFF' 기준이라 ON/OFF로 폭이 안 변함) */} {showVideoControls && ( <> {/* 배속 패널 (영상제어 우측) */}
배속 {[0.5, 1, 1.5, 2, 3, 4].map((r) => ( ))}
{/* 영상 프레임 표시 패널 — 프레임 번호를 고정폭(6ch)으로 묶어 자릿수가 늘어도 폭 불변 */} {secondsToTimecode(currentTime)} | F{frame} | {effectiveFps.toFixed(2)}fps {geoLoaded && ( <> {/* 좌측 패널 토글 — 선형 왼편, 동일 폭(min-w-[96px]) */} {/* 선형(중심선) 토글 — 겹침제외와 동일 폭(min-w-[96px]) */} {/* 드론 궤적 토글 — 겹침제외와 동일 폭(min-w-[96px]) */} {/* 드론 위치정보 — 드론궤적 버튼 바로 우측에 붙임. GPS·고도 항상 표시. */} {stationDiag && (
GPS {stationDiag.f.lat.toFixed(6)}, {stationDiag.f.lon.toFixed(6)} · 절대고도 {stationDiag.f.altitude.toFixed(1)}m
)} {/* 겹침제외 토글 — UI 숨김(기능/상태는 보존, 기본 ON). 다시 보이려면 주석 해제. */} {/* */} {/* 시설등급 패널 */}
시설등급 {KNOWN_GRADES.map((g) => ( ))}
)} )}
)} {/* 루트 패널 미니맵 — 위(배너+카메라파라미터)·아래(배속/토글/재생바) 침범 방지 */} playerRef.current?.currentTime(time)} topPx={paramTop + 40} bottomPx={(barHeight || 130) + 90} />
{/* 영상 목록 콤보 — 좌상단 타이틀(노선 배너) 아래. 버튼 클릭 시 목록이 아래로 펼쳐지고 항목 선택 시 해당 영상으로 즉시 전환(드론 로그도 세그먼트에 맞춰 자동 재정렬). 현재 재생 항목 재선택 시 처음부터 재생. */} {source?.kind === 'local' && videoFiles.length > 0 && (
{showPlaylist && (
    {videoFiles.map((f, i) => (
  • ))}
)}
)} {/* Empty state placeholder — 가운데 반투명 폴더 선택 */} {!source && (

동영상 폴더를 드래그하거나 선택하세요

영상 + 측점/POI 데이터가 함께 로드됩니다

)} {/* 측점 오버레이 */} {/* 측점 기반 재생 바 — 영상 하단에 오버레이로 앵커 (폴더 로드 후) */} {geoLoaded && (
setShowStations((v) => !v)} />
)} {/* abcVideo 전용 유틸 행 (파일/프레임이동/HLS) — UI 숨김(코드 보존, SHOW_TOOLBAR 로 제어) */} {SHOW_TOOLBAR && (
{showUtilBar && (
{/* 프레임 직접 이동 */}
{ 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" > F
{videoId && ( switchToHls(videoId)} /> )} Space 재생 | ←/→ 5초 | J/L 10초 | ,/. 프레임 | Shift+S 캡처
)}
)}
); } ); export default VideoPlayer;