제주 드론 도로영상 지원: 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:
@@ -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' }}>
|
||||
|
||||
Reference in New Issue
Block a user