Files
GhiVideo_v4/client/src/hooks/useVideoPlayer.ts
T
b23042andClaude Fable 5 c64c57b58e djmd 프레임 동기·오버레이 지면고정·선형-측점 정합·UI 단축키
- djmd(영상 내장 텔레메트리) 프레임 동기 위치/고도: MP4 stbl 선별 파싱(djmdTrack.ts),
  CSV 시각정렬 오차(평균 3.2m) 제거 — 세그먼트별 백그라운드 정밀화(frameSynced 플래그)
- 자세 시간축 재정렬: djmd 위치 교차상관으로 CSV↔영상 오프셋 자동 추정(실측 -0.33s,
  잔차 0.24m) 후 yaw/pitch/roll 재샘플(yaw 최단각) — 옆 지물 미끄러짐 해소
- 포즈 평활 자동 제한: 프레임 동기 데이터는 ±5프레임 상한(기존 ±60=±1s) —
  회전 시 오버레이 전체가 드론에 딸려오는 지연 제거
- 선형-측점 정합: 측점 위치를 선형 정점으로 삽입(3D 이격≤0.012m) + 정점 z 를 측점
  체이니지 보간으로 재정렬, 측점 라벨 화면 EMA 제거(선과 동일한 즉시 투영) —
  선형이 측점 POI 를 픽셀 단위로 통과
- 드론높이 모드 높이 규칙 통일: 선형도 '드론고도-이격거리' 평면 적용(라벨과 동일)
- 카메라 offZ 잔재 교정: 표고 체계 정비 이전 드래그 보정값(-18.3m)이 이중 보정이 되어
  원거리 POI 가 접근할수록 밀리던 원인 — 경로 1-1 camera.json 0 으로 교정(.bak 백업)
- 로드 UX: 폴더 로드 시 정지 상태로 대기(처음부터), 데이터 교체 시 UI 초기 상태 복원
- 라벨: 측점 팝업 표고(타원체고) 행 추가, 구조물-POI 이중 라벨 제거(어음천교),
  시간동기 보정 슬라이더(timeOffsetSec)
- 측점 표기 0+000 형식 전환(생성 6곳·파서 4곳 신구 호환) + 스테이션바 양끝 100m 내
  시설물 없으면 시작/끝 스테이션 마크
- 단축키: F2 개발자 모드(dev_mode 1↔0, 세션 한정), A 선형 표시 토글
- 인프라: 54000 포트 재부팅 자동화(portproxy 직접 IP + 로그온 갱신, pm2 resurrect)
- docs/history: 작업 이력 23건

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-14 17:59:43 +09:00

205 lines
8.3 KiB
TypeScript

import { useEffect, useRef, useCallback } from 'react';
import videojs from 'video.js';
import type Player from 'video.js/dist/types/player';
import Hls from 'hls.js';
import { usePlayerStore } from '../store/playerStore';
const HLS_CONFIG = {
maxBufferLength: 30,
maxMaxBufferLength: 600,
maxBufferSize: 60 * 1024 * 1024,
backBufferLength: 30,
enableWorker: true,
};
// PC 브라우저(Chrome/Firefox/Edge)가 HTML5 video로 직접 디코딩 못 하는 코덱.
// 이런 영상은 원본 대신 (트랜스코딩된) HLS로 재생해야 한다.
const UNSUPPORTED_CODECS = new Set(['hevc', 'h265', 'hvc1', 'hev1']);
export function useVideoPlayer(containerRef: React.RefObject<HTMLDivElement | null>) {
const playerRef = useRef<Player | null>(null);
const hlsRef = useRef<Hls | null>(null);
// 현재 로컬 재생 중인 blob URL. 소스 교체/해제 시점을 결정적으로 관리(이벤트 기반 해제는 교체 시
// 새 URL까지 조기 revoke 되는 race 가 있어 ref 로 직접 추적한다).
const objectUrlRef = useRef<string | null>(null);
const store = usePlayerStore();
useEffect(() => {
if (!containerRef.current || playerRef.current) return;
const videoEl = document.createElement('video-js');
// fill: 컨테이너를 꽉 채움(사이니지처럼 화면 가득). object-fit:cover 로 비율 유지 크롭.
videoEl.classList.add('vjs-big-play-centered', 'vjs-fill');
containerRef.current.appendChild(videoEl);
const player = videojs(videoEl, {
// 하단 시간 스크러버는 측점 기반 StationBar 로 대체하므로 Video.js 기본 컨트롤바 숨김
controls: false,
fill: true,
responsive: true,
playbackRates: [0.25, 0.5, 0.75, 1, 1.25, 1.5, 2, 4],
html5: { vhs: { overrideNative: true } },
});
player.on('play', () => store.setPlaying(true));
player.on('pause', () => store.setPlaying(false));
// 영상 첫 프레임 표시 가능 시점 — 오버레이(라벨/선)를 영상보다 먼저 그리지 않도록 게이트.
player.on('loadstart', () => store.setVideoReady(false));
player.on('loadeddata', () => store.setVideoReady(true));
player.on('playing', () => store.setVideoReady(true));
// 영상 원본 해상도 — object-fit:cover 크롭 영역 계산용(오버레이 정렬). 메타 로드 시 확정.
const reportSize = () => store.setVideoSize(player.videoWidth() ?? 0, player.videoHeight() ?? 0);
player.on('loadedmetadata', reportSize);
player.on('loadeddata', reportSize);
player.on('timeupdate', () => store.setCurrentTime(player.currentTime() ?? 0));
player.on('durationchange', () => store.setDuration(player.duration() ?? 0));
player.on('volumechange', () => {
store.setVolume(player.volume() ?? 1);
store.setMuted(player.muted() ?? false);
});
player.on('ratechange', () => store.setPlaybackRate(player.playbackRate() ?? 1));
playerRef.current = player;
return () => {
hlsRef.current?.destroy();
hlsRef.current = null;
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
if (playerRef.current && !playerRef.current.isDisposed()) {
playerRef.current.dispose();
playerRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadLocalFile = useCallback((file: File, autoPlay = false) => {
const player = playerRef.current;
if (!player) return;
// Clean up previous hls
hlsRef.current?.destroy();
hlsRef.current = null;
// 재생 중 폴더 교체 지원: 새 src 설정 후 이전 blob URL 을 해제한다.
// (player.src() 가 새 소스를 채택한 뒤이므로 prevUrl 은 더 이상 참조되지 않아 안전)
const prevUrl = objectUrlRef.current;
const objectUrl = URL.createObjectURL(file);
objectUrlRef.current = objectUrl;
player.src({ src: objectUrl, type: file.type || 'video/mp4' });
// 로드 정책: 로딩 완료(loadedmetadata) 시점에
// - autoPlay=false(폴더 로드): '정지' 명령 실행 — pause + 0초. 애매한 일시정지 상태 방지,
// 재생 버튼/스페이스 등 사용자 조작으로만 시작
// - autoPlay=true(분할영상 연속재생·재생목록 선택): 0초부터 즉시 재생
player.one('loadedmetadata', () => {
if (player.isDisposed() || objectUrlRef.current !== objectUrl) return;
player.currentTime(0);
if (autoPlay) {
const p = player.play();
if (p && typeof p.catch === 'function') p.catch(() => {});
} else {
player.pause();
}
});
store.setSource({ kind: 'local', file, objectUrl });
store.setHlsReady(false);
// 데이터 교체 시 UI 상태 즉시 초기화 — 이전 소스의 재생중 표시(일시정지 토글)·시간이
// 새 소스 메타데이터 로드 전까지 남아 보이는 문제 방지. (src 교체는 pause 이벤트를
// 보장하지 않아 store.playing 이 true 로 남을 수 있음)
store.setPlaying(false);
store.setCurrentTime(0);
store.setDuration(0);
if (prevUrl) URL.revokeObjectURL(prevUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const loadServerStream = useCallback(async (videoId: string, filename: string) => {
const player = playerRef.current;
if (!player) return;
hlsRef.current?.destroy();
hlsRef.current = null;
// 로컬 → 서버 전환 시 이전 blob URL 해제.
if (objectUrlRef.current) {
URL.revokeObjectURL(objectUrlRef.current);
objectUrlRef.current = null;
}
store.setSource({ kind: 'server', videoId, filename });
store.setHlsReady(false);
// 데이터 교체 시 UI 상태 즉시 초기화 (loadLocalFile 과 동일 정책)
store.setPlaying(false);
store.setCurrentTime(0);
store.setDuration(0);
const playRaw = () => player.src({ src: `/api/stream/${videoId}`, type: 'video/mp4' });
// 코덱 확인 — 브라우저가 직접 못 푸는 코덱(HEVC 등)이면 HLS로 자동 재생
let needsHls = false;
try {
const meta = await fetch(`/api/meta/${videoId}`).then((r) => r.json());
needsHls = UNSUPPORTED_CODECS.has(String(meta?.codec ?? '').toLowerCase());
} catch {
// meta 조회 실패 시 일단 원본으로 시도
}
// await 사이에 사용자가 다른 영상을 선택했으면 중단
const stillCurrent = () => {
const s = usePlayerStore.getState().source;
return s?.kind === 'server' && s.videoId === videoId;
};
if (needsHls) {
if (!stillCurrent()) return;
const hlsId = videoId.replace(/\.[^.]+$/, '');
const ready = await fetch(`/api/hls/${hlsId}/index.m3u8`, { method: 'HEAD' })
.then((r) => r.ok)
.catch(() => false);
if (!stillCurrent()) return;
if (ready) {
switchToHls(videoId, 0);
return;
}
// HLS 미생성 상태: 원본을 시도(에러 표시)하고, 사용자가 'HLS 변환' 버튼으로 생성하도록 유도
}
// 지원 코덱이거나 HLS가 아직 없으면 원본 즉시 재생 (Range Request)
playRaw();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const switchToHls = useCallback((videoId: string, seekTo?: number) => {
const player = playerRef.current;
if (!player) return;
const hlsId = videoId.replace(/\.[^.]+$/, '');
const hlsUrl = `/api/hls/${hlsId}/index.m3u8`;
const savedTime = seekTo ?? player.currentTime() ?? 0;
if (Hls.isSupported()) {
const hls = new Hls(HLS_CONFIG);
hls.loadSource(hlsUrl);
const videoEl = player.tech(true)?.el() as HTMLVideoElement;
hls.attachMedia(videoEl);
hls.on(Hls.Events.MANIFEST_PARSED, () => {
player.currentTime(savedTime);
hlsRef.current = hls;
store.setHlsReady(true);
});
} else {
player.src({ src: hlsUrl, type: 'application/x-mpegURL' });
player.currentTime(savedTime);
store.setHlsReady(true);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const getVideoElement = useCallback((): HTMLVideoElement | null => {
return (playerRef.current?.tech(true)?.el() as HTMLVideoElement | null) ?? null;
}, []);
return { playerRef, loadLocalFile, loadServerStream, switchToHls, getVideoElement };
}