GhiVideo 소스 복제 — v4 작업 시작 기준
기존 GhiVideo 저장소 HEAD의 트래킹 소스 362개 파일을 복제. (node_modules·storage·빌드 산출물·대용량 미디어는 .gitignore 규칙대로 제외) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,563 @@
|
||||
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 { 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) => void;
|
||||
loadServerStream: (videoId: string, filename: string) => void | Promise<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 } = 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);
|
||||
// 시설등급(시설종별) 표시 필터 — 재생바/패널이 구독하는 설정 스토어.
|
||||
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);
|
||||
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' +
|
||||
'(측점·드론 정보는 정상 로드되었습니다.)',
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('[geo] 폴더 로드 실패', err);
|
||||
}
|
||||
};
|
||||
|
||||
// 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 />
|
||||
{/* 좌하단 그룹(재생바 위) — 한 줄: 영상제어 → 배속 → 프레임정보 → 겹침제외 → 시설등급 */}
|
||||
{source && (
|
||||
<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>
|
||||
|
||||
{/* 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">▶</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={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}
|
||||
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' : ''}`}>▸</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;
|
||||
Reference in New Issue
Block a user