제주 드론 도로영상 지원: DJI 로그 어댑터·분할영상 연속재생·카메라 자동감지·오버레이 개선

- 제주 DJI 시간기반 비행로그(CSV) 파싱 어댑터: 세그먼트 창 정렬, 가상 fps 환산
- 분할 영상(이름 오름차순) 연속재생 + 좌상단 영상목록 콤보(선택 재생), 세그먼트별 드론 로그 재정렬
- KML 다중 파일 병합 파싱: STA 체이니지 측점(71개) + 지장물 신포맷(타입/이름/텍스트박스_색상)
- 시설물 라벨: 지정색 배경 박스 + 흰 글자, 팝업 중앙 정렬, 호버 구간 깜빡임 수정
- 카메라 자동감지(djmd: ZenmuseP1, focal 29.9mm) + <영상명>.camera.json PC 저장/폴더 자동 적용
- Yaw 자동추정(GPS 진행방위 기반) + 라벨 드래그 Yaw 역산 모드
- 스테이션바: GPS 이동거리 진행도(호버 시 정지), 노선밖 거리 표기, 원거리 구조물 마크 제외
- 서버: /api/camera 라우트, ecosystem 포트 54000·제주 데이터 경로
- docs/history: 작업 이력 21건

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-09 20:58:49 +09:00
co-authored by Claude Fable 5
parent d38b842e8d
commit 84f4921826
32 changed files with 1833 additions and 110 deletions
@@ -43,6 +43,12 @@ export interface VideoPlayerHandle {
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;
@@ -119,6 +125,12 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
const geoLoaded = useGeoStore((s) => s.loaded);
// 드론 정보 — 하단바에 GPS/고도 항상 표시.
const storeFrames = useGeoStore((s) => s.frames);
// 분할 영상 재생목록 — 폴더 로드 시 이름 오름차순. 목록 클릭으로 세그먼트 직접 선택 재생.
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);
@@ -155,6 +167,48 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
}
};
// 재생목록에서 세그먼트 선택 — 같은 항목이면 처음부터 재생, 다른 항목이면
// 드론 로그를 해당 구간으로 재정렬(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);
} else {
const file = await geo.loadSegment(i);
if (!file) return;
loadLocalFile(file);
}
const p = playerRef.current?.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
};
// 분할 영상 연속재생 — 영상이 끝나면 같은 폴더의 다음 영상(이름 오름차순)을 자동 재생.
// 드론 로그(시간 기반)는 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);
// ended 직후는 paused 상태라 loadLocalFile 의 이어재생 로직이 안 걸림 → 직접 재생.
const p = player.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
})();
};
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,
@@ -425,6 +479,54 @@ const VideoPlayer = forwardRef<VideoPlayerHandle, VideoPlayerProps>(
/>
</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' }}>