Files
DefVideo/agent-docs/feature-folder-load.md
b23042 819065a8f5 UI 수정
기획안 반영 및 보완
2026-06-19 14:40:47 +09:00

359 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 기능: 폴더 선택 기반 영상+데이터 동시 로딩
> 설계 문서 (① 설계 단계). 이 문서는 구현 지시서이며, 코드 변경은 ②구현 단계 에이전트가 수행한다.
> 모든 경로는 프로젝트 루트 `/home/hanmac/projects/gitea/b23042/DefVideo` 기준 상대경로로 표기한다.
---
## 목표 / 사용자 시나리오
영상과 지리정보(드론 GPS/자세, 측점, POI, 선로 중심선)를 **하나의 폴더 선택으로 동시에 로드**한다.
현재는 영상은 클라이언트 File API로, 지리정보는 서버가 고정 디렉토리(`samplevideo`) + 하드코딩 영상명(`'회덕'`)으로 따로 로드하므로 두 데이터가 연결되지 않는다.
### 사용자 시나리오
1. 사용자가 영상 플레이어에서 **"폴더 선택"** 컨트롤을 클릭한다.
2. OS 폴더 선택 다이얼로그에서 영상 + 데이터가 들어있는 폴더(예: `samplevideo/`)를 고른다.
3. 브라우저가 `webkitdirectory`로 폴더 내 모든 파일(하위 `building/` 포함)을 노출한다.
4. 클라이언트가:
- 폴더에서 영상 파일(`.mp4/.MP4/.webm`)을 찾아 `loadLocalFile`로 재생한다.
- `<base>.csv`(드론 프레임), `center.csv`(중심선), `building/<base>_POI_위경도값.csv`, `building/<base>_측점_위경도값.csv`를 **클라이언트에서 직접 파싱**해 Zustand 지리정보 스토어에 적재한다.
5. 우측 패널(지리정보/측점)과 영상 오버레이(측점/POI/중심선)가 **선택한 폴더의 데이터** 기준으로 즉시 표시된다.
6. **영상명에 의존하지 않는다**: `'회덕'` 하드코딩 제거. 동일한 폴더 구조면 어떤 영상이든 동작한다.
### 폴백
- 폴더가 아직 선택되지 않았으면 지리정보 스토어는 비어 있다. 각 소비 컴포넌트는 빈 상태(측점 0개, 검색 결과 없음, 오버레이 미표시)를 표시한다.
- (선택) 스토어가 비었을 때 기존 `/api/geo/*` 서버 API로 폴백할 수 있으나, **기본 동작은 클라이언트 스토어 우선**. 이 문서에서는 서버 폴백을 "옵션"으로 두고, 기본 구현은 스토어 단일 소스로 한다.
---
## 제약
- **브라우저 폴더 접근**: `<input type="file" webkitdirectory>` 사용. Chrome/Firefox/Edge(PC) 지원 — 프로젝트 대상 브라우저와 일치(CLAUDE.md). iOS Safari 비대상.
- 선택된 각 `File``file.webkitRelativePath`(예: `samplevideo/building/...csv`)로 폴더 내 상대경로를 노출한다.
- 첫 세그먼트는 사용자가 고른 루트 폴더명이므로, 매칭 시 **basename + 마지막 디렉토리 세그먼트**로 판단한다.
- **클라이언트 전용**: 서버 의존 없음. 파일 업로드 없이 File API로 직접 처리.
- **인코딩**: POI/측점 CSV와 `center.csv`**EUC-KR**(서버는 `iconv-lite`로 디코딩). 클라이언트에는 `iconv-lite`가 없으므로 브라우저 **`TextDecoder('euc-kr')`**(Encoding API legacy label, Chrome/FF/Edge 지원)로 디코딩한다. 드론 `<base>.csv`**UTF-8 + BOM**.
- **메모리**: 영상은 `URL.createObjectURL()`로 직접 재생, 사용 후 `revokeObjectURL`(기존 `useVideoPlayer.loadLocalFile`가 이미 처리).
- **좌표 투영**: 클라이언트는 이미 `proj4`(EPSG:5186) 기반 투영 유틸을 보유. 서버 `geoMatch.ts`는 단순 ENU(cos-lat 근사) 방식이라 투영식이 다름 — **데이터 로딩/파싱/검색 로직만 포팅**하고 투영은 기존 클라이언트 유틸(`geoProjection.ts`)을 재사용한다(아래 상세).
---
## 파일 명명 규칙 & CSV 스키마
`<base>` = 영상 파일명에서 확장자를 제거한 이름. 예: `하행)회덕-대전조차장.MP4` → base `하행)회덕-대전조차장`.
```
<folder>/
<base>.MP4 (또는 .mp4/.webm) → 영상 (loadLocalFile / createObjectURL)
<base>.csv → 드론 프레임 (UTF-8 BOM)
<base>.srt → 프레임 메타(현재 geo 시스템 미파싱 — 주의)
center.csv → 중심선 (224행, EUC-KR)
building/<base>_POI_위경도값.csv → POI 건물 (EUC-KR)
building/<base>_측점_위경도값.csv → 측점 (EUC-KR)
building/<base>_POI_XY값.csv → 미사용
building/<base>_측점_XY값.csv → 미사용
```
### 1) 드론 프레임 `<base>.csv` (UTF-8, BOM 있음)
헤더(BOM 제거 후): `frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len`
```
frame_cnt,latitude,longitude,altitude,yaw,pitch,roll,focal_len
0,36.402256,127.421515,84.244,114.7,-29.8,0.0,24.00
1,36.402256,127.421515,84.244,114.7,-29.8,0.0,24.00
```
- 컬럼은 **헤더 이름으로 인덱스 조회**(`header.indexOf('frame_cnt')` 등). 서버 `geoMatch.ts:290-302`와 동일.
- 매핑: `frame=frame_cnt(int)`, `lat=latitude`, `lon=longitude`, `altitude`, `yaw`, `pitch`, `roll`, `focalLen=focal_len`.
- `!isNaN(lat)` 행만 채택.
- 구분자 `,`. 따옴표 처리는 `parseCsvLine` 동일 규칙.
### 2) 중심선 `center.csv` (EUC-KR — 헤더 한글이 깨지므로 **위치 인덱스 직접 사용**)
헤더(깨진 상태): `id,lat,lon,표고(H),지오이드높이(N),타원체고(h),67.39,...,x,y`
```
id,lat,lon,<EUC-KR깨짐>,<깨짐>,<깨짐(h)>,67.39,41.57...,,x,y
0,36.4087069,127.4256135,44.5959549,25.449,70.0449549,2.6549549,...,238176.0008,423480.0717
```
- 서버 `geoMatch.ts:251-257` 규칙: **인덱스 1=lat, 2=lon, 5=타원체고(h)=z**. 헤더 이름 의존 금지(EUC-KR 깨짐 회피).
- `!isNaN(lat&&lon&&z)` 행만 채택. 헤더 1행 skip.
### 3) POI `building/<base>_POI_위경도값.csv` (EUC-KR)
헤더: `title,category_clean,address_road,address_jibun,lat,lon,z`
```
title,category_clean,address_road,address_jibun,lat,lon,z
한국철도공사대전철도역사,역사,대전 ...,대전 ...,36.371101,127.421776,14.93833585
대전역,역사,...,...,36.380398,127.422422,17.43774457
```
- 매핑(헤더 이름 조회, `geoMatch.ts:323-335`): `title`, `category = category_clean || '건물'`, `lat`, `lon`, `z = z || 0`, `type='poi'`.
- 파일 선택 우선순위(`geoMatch.ts:317-320`): 파일명에 `타원체고` 포함 버전이 있으면 그것 우선, 없으면 `타원체고` 미포함 버전. (현재 샘플엔 `타원체고` 파일 없음 → 기본 `_POI_위경도값.csv` 사용.)
- `category_clean` 값도 EUC-KR(터널/교량/역사/지장물). `TextDecoder('euc-kr')`로 정상 복원돼야 RoutePanel 카테고리 필터/이모지가 동작한다.
### 4) 측점 `building/<base>_측점_위경도값.csv` (EUC-KR)
헤더: `title,category_clean,address_road,address_jibun,lat,lon,z` (POI와 동일)
```
title,category_clean,address_road,address_jibun,lat,lon,z
157K900,,,,36.40296455,127.4218765,0.023576703
158K000,,,,36.40222619,127.4212379,-0.148290668
```
- 매핑(`geoMatch.ts:345-355`): `title`, `category='측점'`(강제), `lat`, `lon`, `z = z || 0`, `type='station'`.
- `title``157K900` 형태(km 정렬에 사용: 정규식 `/(\d+)[Kk](\d+)/`).
### 5) SRT `<base>.srt` (UTF-8) — 참고용, 현재 geo 미사용
```
1
00:00:00,000 --> 00:00:00,033
FrameCnt: 0 2026-03-05 17:18:28.405
[... focal_len: 24.00] [latitude: 36.402256] [longitude: 127.421515] [rel_alt: 16.853 abs_alt: 84.244] [gb_yaw: 114.7 gb_pitch: -29.8 gb_roll: 0.0] ...
```
- 서버 `geoMatch.ts:208-222 loadTerrainOffset`만 SRT를 읽어 `terrain offset = abs_alt - rel_alt`를 계산하나, **현재 코드 경로에서 이 offset은 어디에도 적용되지 않는다**(`_terrainOffset`은 set만 되고 사용처 없음). → 이번 포팅에서 SRT는 **파싱하지 않아도 무방**(현 동작 동일). 문서상 명시.
---
## 현재 구조 (As-Is)
### 영상 선택
- `client/src/components/player/VideoPlayer.tsx:194-205` — "파일 선택" 라벨 + `<input type="file" accept="video/*">`, `onChange`에서 `loadLocalFile(file)` 호출.
- 드래그앤드롭: `VideoPlayer.tsx:96-100`.
- `client/src/hooks/useVideoPlayer.ts:63-81 loadLocalFile``URL.createObjectURL(file)``player.src(...)``store.setSource({kind:'local', file, objectUrl})``emptied``revokeObjectURL`.
- 소스 타입: `client/src/types/player.ts:1-3 VideoSource`.
### 지리정보 (서버 단일 소스)
- 데이터 디렉토리: `server/src/routes/geo.ts:8-11``GEO_DATA_DIR = process.env.GEO_DATA_DIR || <root>/samplevideo`, `setGeoDataDir()` 호출.
- 서버 서비스 `server/src/services/geoMatch.ts` (싱글턴 캐시 `_frames/_pois/_centerline`):
- CSV 파서 `parseCsvLine`(162-173), `readCsvUtf8`(175-192, EUC-KR/UTF-8 BOM 자동 판별, `iconv-lite`).
- 투영: `geoToEnu`(78-87, cos-lat 근사 ENU), `projectEnu`(90-124), `project3D`(127-156).
- 로더: `loadFrames`(275-305), `loadPois`(307-361), `loadCenterline`(236-261).
-`loadFrames:285``f.includes('회덕')` **하드코딩**. (제거 대상)
- `loadCenterline:239``CENTER_CSV_PATH` 또는 `pythonsource/input/center.csv` 경로(샘플 폴더의 center.csv와 별개일 수 있음).
- 원점: `getWorldOrigin`(378-387) — 첫 측점(`stationOrder` 정렬) 사용, 없으면 frame[0].
- 검색 API: `findFramesForPoi`(399-468), `findPoisForFrame`(473-512), `getAllPois`(515-517), `getDroneFrames`(520-522), `getCenterlinePoints`(263-265).
- HTTP 라우트 `server/src/routes/geo.ts`:
- `GET /api/geo/pois`(14-21) → `getAllPois()``GeoPoint[]`
- `GET /api/geo/search?q=&margin=&maxDist=&yawOffset=`(27-45) → `{poi, frames: FrameMatch[]}`
- `GET /api/geo/frame/:frameNum?margin=&yawOffset=`(51-64) → `{droneFrame, pois: PoiInFrame[]}`
- `GET /api/geo/frames?step=`(70-79) → 샘플링된 `DroneFrame[]`
- `GET /api/geo/centerline`(85-92) → `CenterlinePoint[]`
### 클라이언트 투영 (이미 존재 — 재사용)
- `client/src/utils/geoProjection.ts`:
- `proj4` EPSG:5186 정의(70-72), `latLonToTM`(76-80), `geoToEnu`(86-92, TM 기반).
- `CameraParams` / `DEFAULT_CAMERA_PARAMS`(35-62), `paramsFromFrame`(65-67).
- `toCameraCoords`(175-184), `pixelFromCamera`(115-126), `projectPoint`(201-294).
- **오버레이(StationOverlay/RoutePanel)는 이 유틸로 자체 투영**한다. 서버 `project3D`와 다른 알고리즘. → 검색용 `findFramesForPoi`/`findPoisForFrame`만 서버에 의존.
### 클라이언트 소비 컴포넌트 (각 fetch 정리)
| 컴포넌트 | fetch | 기대 응답 형태 | 용도 |
|---|---|---|---|
| `GeoSearch.tsx:61` | `GET /api/geo/pois` | `GeoPoint[]` | 자동완성 목록 |
| `GeoSearch.tsx:82` | `GET /api/geo/search?q=&margin=1.0&maxDist=1500` | `{poi:GeoPoint, frames:FrameMatch[]}` | 건물→프레임 검색 |
| `GeoSearch.tsx:98` | `GET /api/geo/frame/{currentFrame}?margin=1.0` | `{pois:PoiInFrame[]}` | 프레임→건물 역조회 |
| `StationVerify.tsx:57` | `GET /api/geo/pois` | `GeoPoint[]` (station만 필터) | 측점 목록 |
| `StationVerify.tsx:69` | `GET /api/geo/frames?step=1` | `{frame,lat,lon}[]` | GPS 최근접 프레임 |
| `StationVerify.tsx:102` | `GET /api/geo/search?q=&margin=1.2&maxDist=2000` | `{poi,frames:FrameMatch[]}` | 검증 정보 |
| `StationOverlay.tsx:186` | `GET /api/geo/pois` | `GeoPoint[]` (station/poi 분리) | 라벨 사전계산 |
| `StationOverlay.tsx:197` | `GET /api/geo/centerline` | `CenterlinePoint[]` | 중심선 |
| `StationOverlay.tsx:206` | `GET /api/geo/frames?step=1` | `DroneFrameBasic[]` | 드론 프레임 |
| `RoutePanel.tsx:75` | `GET /api/geo/pois` | `GeoPoint[]` | 미니맵 측점/POI |
| `RoutePanel.tsx:87` | `GET /api/geo/frames?step=1` | `DroneFrameBasic[]` | 현재 km 계산 |
### 상태/배선
- `client/src/App.tsx` — 우측 탭 `rightTab: 'annotation'|'geo'|'station'`(19), GeoSearch(151-157)/StationVerify(158-163) 렌더. `currentFrame = round(currentTime*fps)`(35).
- `client/src/store/playerStore.ts` — 영상 재생 상태(source/currentTime/fps 등). 지리정보 스토어 없음.
- `playerRef.loadServerStream`은 좌측 VideoList에서, `loadLocalFile`은 VideoPlayer 내부 input/드롭에서 호출.
---
## 변경 설계 (To-Be)
### 개요
1. 폴더 선택 UI → 폴더 내 파일 목록(`File[]` + `webkitRelativePath`)을 단일 파서 진입점에 넘긴다.
2.**클라이언트 지리정보 스토어**(`geoStore`)가 파싱 결과(frames/pois/stations/centerline/origin)를 보관.
3. CSV 파싱 + 서버 `geoMatch` 검색 로직을 클라이언트 유틸로 포팅(투영은 기존 `geoProjection.ts` 재사용).
4. 4개 소비 컴포넌트의 `/api/geo/*` fetch → `geoStore` 셀렉터 + 포팅 함수 호출로 교체.
5. 영상 파일은 폴더에서 찾아 기존 `loadLocalFile`로 재생.
---
### New: 폴더 선택 UI
**파일**: `client/src/components/player/VideoPlayer.tsx` 수정.
- 기존 "파일 선택"(194-205) 옆에 **"폴더 선택"** 라벨+input 추가:
```tsx
<label className="cursor-pointer bg-emerald-600 hover:bg-emerald-700 ... rounded">
폴더 선택
<input type="file" className="hidden"
// @ts-expect-error - 비표준 속성
webkitdirectory="" directory="" multiple
onChange={(e) => { const files = e.target.files; if (files?.length) onSelectFolder(Array.from(files)); }} />
</label>
```
- `webkitdirectory` 타입 보강: `client/src/types/`에 전역 선언 추가하거나 `// @ts-expect-error`. (권장: `client/src/vite-env.d.ts` 또는 새 `client/src/types/dom.d.ts`에 `HTMLInputElement` 확장 선언.)
- `onSelectFolder(files: File[])` 핸들러(VideoPlayer 내부 또는 App에서 전달):
1. `loadFolderGeoData(files)` 호출 → 파싱 + `geoStore` 채움(영상 File도 함께 반환/식별).
2. 영상 File을 찾으면 `loadLocalFile(videoFile)`.
- 기존 "파일 선택"(단일 영상)은 **유지**(지리정보 없이 영상만).
---
### New: 클라이언트 지리정보 스토어 (Zustand)
**신규 파일**: `client/src/store/geoStore.ts`
```ts
import { create } from 'zustand';
// 타입은 client/src/types/geo.ts(신규)로 분리 권장
export interface DroneFrame { frame:number; lat:number; lon:number; altitude:number; yaw:number; pitch:number; roll:number; focalLen:number; }
export interface GeoPoint { title:string; category:string; lat:number; lon:number; z:number; type:'poi'|'station'; }
export interface CenterlinePoint { lat:number; lon:number; z:number; }
interface GeoStore {
loaded: boolean;
frames: DroneFrame[];
pois: GeoPoint[]; // type==='poi'
stations: GeoPoint[]; // type==='station' (stationOrder 정렬)
centerline: CenterlinePoint[];
origin: { lat:number; lon:number; alt:number } | null; // 첫 측점 = ENU 원점
baseName: string | null;
setGeoData: (d: Partial<GeoStore>) => void;
reset: () => void;
}
export const useGeoStore = create<GeoStore>((set) => ({ /* 초기값 + setGeoData/reset */ }));
```
- `pois`/`stations`는 `getAllPois()` 결과를 `type`으로 분리해 저장(소비처 편의). 또는 통합 `pois` 하나만 두고 셀렉터로 분리해도 됨 — **분리 저장 권장**(현 컴포넌트들이 매번 filter 함).
- `origin` = 첫 측점(`stationOrder` 최소). 서버 `getWorldOrigin` 동치.
---
### New: 클라이언트 CSV 파싱 + geoMatch 포팅
**신규 파일**: `client/src/utils/geoData.ts` (파싱 + 로딩 + 검색 포팅)
#### 인코딩/파싱 헬퍼 (서버 `readCsvUtf8`/`parseCsvLine` 포팅)
- `parseCsvLine(line)` — 서버 `geoMatch.ts:162-173` 그대로 포팅(순수 함수, 의존성 없음).
- `decodeBytes(buf: ArrayBuffer, encoding:'utf-8'|'euc-kr')` — `new TextDecoder(encoding).decode(buf)`. UTF-8 BOM 제거(`replace(/^/,'')`). EUC-KR은 `TextDecoder('euc-kr')`.
- 드론 csv: UTF-8(BOM). POI/측점/center: EUC-KR.
- 서버처럼 자동판별(`iconv`)이 아니라 **파일 종류별로 인코딩 지정**(폴더 구조 알고 있으므로 단순). center/POI/측점은 EUC-KR 고정.
- `readCsv(file: File, encoding)` → `Promise<string[][]>` — `await file.arrayBuffer()` → decode → `split(/\r?\n/).filter(Boolean).map(parseCsvLine)`.
#### 로더 (서버 loader 포팅, but 파일은 `File[]`에서 매칭)
- `findVideoFile(files)` — 확장자 `.mp4/.webm`(대소문자 무시), `building/` 미포함. → base 추출.
- `parseDroneFrames(file): Promise<DroneFrame[]>` — 서버 `loadFrames:289-302` 로직(헤더 이름 인덱스). **`회덕` 필터 제거**, base 일치 또는 `building/` 미포함 루트 csv로 식별.
- `parsePois(poiFile, stationFile): Promise<GeoPoint[]>` — 서버 `loadPois:321-357` 포팅. POI=`category_clean||'건물'`,`type:'poi'`; 측점=`category:'측점'`,`type:'station'`. 파일 선택은 `building/` 내 `webkitRelativePath`에서 `POI`+`위경도`, `측점`+`위경도` 매칭(`타원체고` 우선규칙 유지).
- `parseCenterline(file): Promise<CenterlinePoint[]>` — 서버 `loadCenterline:253-257` 포팅(인덱스 1/2/5, EUC-KR).
- `loadFolderGeoData(files: File[])` — 위 파서들을 호출해 `geoStore.setGeoData({...})` 채우고, 영상 File을 반환.
#### 검색 함수 포팅 (서버 `geoMatch.ts` → 클라이언트)
검색은 **서버의 단순 ENU `project3D`(geoMatch.ts) 알고리즘을 그대로 포팅**해 서버 응답과 동일 결과를 보장한다(오버레이용 `geoProjection.ts`와는 별개 — 검색 UI 호환 우선).
| 포팅 함수 | 서버 소스 라인 | 비고 |
|---|---|---|
| `toRad` | `geoMatch.ts:63` | |
| `geoToEnu` (cos-lat ENU) | `geoMatch.ts:78-87` | 검색 전용. 오버레이는 기존 proj4 ENU 사용 |
| `projectEnu` | `geoMatch.ts:90-124` | |
| `project3D` | `geoMatch.ts:127-156` | |
| `stationOrder` | `geoMatch.ts:371-375` | (이미 컴포넌트들에 중복 존재 — 공용화) |
| `getWorldOrigin` | `geoMatch.ts:378-387` | 스토어 `origin`으로 대체 가능 |
| `findFramesForPoi(query, margin, maxDist, yawOffset)` | `geoMatch.ts:399-468` | 스토어 frames/pois 입력으로 변경 |
| `findPoisForFrame(frameNum, margin, yawOffset)` | `geoMatch.ts:473-512` | 동일 |
- 입력을 싱글턴 캐시(`loadFrames()`) 대신 **`geoStore` 데이터를 인자/직접 read**로 변경. 반환 타입은 서버와 동일(`FrameMatch`/`PoiInFrame`) → 컴포넌트 인터페이스 무변경.
- `DEFAULT_FPS=30`, `GAP=30` 등 상수 동일 포팅.
---
### Changes: 소비 컴포넌트 (fetch → 스토어)
각 컴포넌트는 내부 `interface GeoPoint/FrameMatch` 중복 선언을 **`types/geo.ts`로 통합**(선택, 최소변경 시 유지 가능). 핵심은 fetch 제거:
- **`GeoSearch.tsx`**
- `:61` `fetch('/api/geo/pois')` → `useGeoStore` 의 `[...stations, ...pois]`.
- `:82` `/api/geo/search` → 포팅 `findFramesForPoi(q, 1.0, 1500, 0)` 직접 호출(동기). `loading`/`error`는 데이터 없을 때만 의미.
- `:98` `/api/geo/frame/{n}` → 포팅 `findPoisForFrame(currentFrame, 1.0)`.
- **`StationVerify.tsx`**
- `:57` pois → 스토어 `stations`.
- `:69` frames(`{frame,lat,lon}`) → 스토어 `frames`(필드 일부만 사용).
- `:102` search → 포팅 `findFramesForPoi(title, 1.2, 2000)`.
- **`StationOverlay.tsx`**
- `:186` pois → 스토어 `stations`/`pois`(이미 station/poi 분리 사용). `updateWorldOrigin`은 스토어 `origin`으로 대체.
- `:197` centerline → 스토어 `centerline`.
- `:206` frames → 스토어 `frames`.
- 투영은 기존 `geoProjection.ts` 그대로(변경 없음).
- **`RoutePanel.tsx`**
- `:75` pois → 스토어. `:87` frames → 스토어.
- **폴백/가드**: 스토어 `loaded===false`이면 빈 배열로 동작(현재 fetch 실패 시와 동일한 빈 상태). `useEffect`의 `[visible]` 의존성은 스토어 구독으로 대체(데이터 도착 시 자동 재렌더).
- (옵션) 스토어가 비어 있을 때 기존 `/api/geo/*`로 폴백하려면 각 컴포넌트에 `if (store.loaded) useStore else fetch` 분기. **기본 구현은 스토어 단일 소스 권장**(서버 의존 제거가 목표).
---
### 영상 재생 배선
- `loadFolderGeoData`가 반환한 영상 `File` → `playerRef.loadLocalFile(file)`(기존 경로 그대로).
- 영상이 폴더에 없으면 지리정보만 로드(오버레이는 동작하나 재생 화면 없음) — 에러 토스트/콘솔 경고.
- `App.tsx`: 폴더 선택 핸들러를 VideoPlayer로 내려보내거나 VideoPlayer 내부에서 `loadLocalFile`(이미 보유) + `loadFolderGeoData` 직접 호출. **VideoPlayer 내부 처리 권장**(이미 `loadLocalFile` 접근 가능).
---
## 구현 태스크 목록
- [ ] `client/src/types/geo.ts` 신규 — `DroneFrame`/`GeoPoint`/`CenterlinePoint`/`FrameMatch`/`PoiInFrame` 공용 타입.
- [ ] `client/src/store/geoStore.ts` 신규 — Zustand 스토어(frames/pois/stations/centerline/origin/baseName/loaded + setGeoData/reset).
- [ ] `client/src/utils/geoData.ts` 신규:
- [ ] `parseCsvLine` 포팅(server `geoMatch.ts:162-173`).
- [ ] `decodeBytes`/`readCsv`(TextDecoder utf-8 + euc-kr, BOM 제거).
- [ ] `findVideoFile(files)` (mp4/MP4/webm, building 제외, base 추출).
- [ ] `parseDroneFrames`(헤더 인덱스, **회덕 하드코딩 없음**).
- [ ] `parsePois`(POI: category_clean||건물 / 측점: '측점', 타원체고 우선규칙).
- [ ] `parseCenterline`(인덱스 1/2/5, EUC-KR).
- [ ] `loadFolderGeoData(files)` → setGeoData + return videoFile.
- [ ] `client/src/utils/geoSearch.ts` 신규(또는 geoData.ts 내) — `toRad/geoToEnu/projectEnu/project3D/stationOrder/findFramesForPoi/findPoisForFrame` 포팅(server `geoMatch.ts`). 입력은 스토어 데이터.
- [ ] `client/src/types/dom.d.ts` (또는 vite-env.d.ts) — `HTMLInputElement` `webkitdirectory`/`directory` 보강.
- [ ] `client/src/components/player/VideoPlayer.tsx` — "폴더 선택" UI 추가 + `onSelectFolder` → `loadFolderGeoData` + `loadLocalFile`.
- [ ] `client/src/components/geo/GeoSearch.tsx` — fetch 3곳 제거 → 스토어/포팅 함수.
- [ ] `client/src/components/geo/StationVerify.tsx` — fetch 3곳 제거 → 스토어/포팅 함수.
- [ ] `client/src/components/overlay/StationOverlay.tsx` — fetch 3곳 제거 → 스토어 구독, origin 스토어화.
- [ ] `client/src/components/overlay/RoutePanel.tsx` — fetch 2곳 제거 → 스토어.
- [ ] (정리) 컴포넌트 내 중복 `stationOrder`/`GeoPoint` 선언을 공용 타입/유틸로 통합.
- [ ] `npm run build -w client` 통과 확인(tsc + vite).
- [ ] 수동 검증: `samplevideo/` 폴더 선택 → 영상 재생 + 측점/POI/중심선 오버레이 + 측점 패널 목록 표시.
- [ ] `회덕` 등 영상명 하드코딩이 클라이언트 경로에 없음을 확인(`grep -r 회덕 client/src` → 0건; RoutePanel 주석의 '대전조차장/회덕'은 단순 주석이므로 무관).
---
## 완료 기준
- `npm run build -w client` (= `tsc && vite build`)가 **타입 에러 없이** 통과.
- "폴더 선택"으로 `samplevideo/`(또는 동일 구조 폴더) 선택 시:
- 폴더 내 영상이 재생된다(`createObjectURL`).
- 측점/POI/선로 중심선 오버레이가 표시된다(`StationOverlay`).
- 우측 "측점" 탭에 측점 목록이 채워지고, 클릭 시 해당 프레임으로 이동한다.
- "지리정보" 탭 검색/역조회가 클라이언트 데이터로 동작한다.
- RoutePanel 미니맵이 시점/종점/현재 km를 표시한다.
- 동작이 **영상명 비의존적**: `'회덕'` 등 하드코딩 없음. 다른 base명 폴더도 동일 구조면 동작.
- EUC-KR 한글(측점명/POI 카테고리)이 깨지지 않고 표시된다(`TextDecoder('euc-kr')`).
---
## 토큰 사용 기록
> 추정치(k = 1,000 tokens). 각 에이전트 자체 보고 입력/출력 추정 + 측정된 subagent 총 토큰(측정 합계) 기준.
| 단계 | 에이전트 | 입력(추정) | 출력(추정) | 측정 합계 |
|------|----------|-----------|-----------|----------|
| ①설계 | DESIGN/DOCUMENTATION | ~46k | ~9k | 98.7k |
| ②a 구현·기반 | DEV-FOUNDATION (types/store/utils + 폴더선택 UI) | ~60k | ~9k | 99.3k |
| ②b 구현·소비 | DEV-CONSUMER: GeoSearch | ~30k | ~2k | 52.0k |
| ②b 구현·소비 | DEV-CONSUMER: StationVerify | ~24k | ~2k | 48.1k |
| ②b 구현·소비 | DEV-CONSUMER: StationOverlay | ~32k | ~3k | 66.8k |
| ②b 구현·소비 | DEV-CONSUMER: RoutePanel | ~30k | ~3k | 55.8k |
| ③검증 | REVIEW/검수 | ~62k | ~3k | 105.1k |
| — | (기반 1차 시도, 529 과부하로 무산) | 0 | 0 | 0 |
| **합계** | **7개 에이전트** | **~284k** | **~31k** | **≈526k** |
> ②b 4개 소비 에이전트는 **병렬 실행**(서로 다른 파일)으로 벽시계 시간 단축. 판정: **ship** (빌드 통과, blocker 0, 발견 이슈 전부 nit).
---
## 후속 수정 (2026-06-17)
### 🔴 누락 컴포넌트: StationBar (측점 바) — 사용자 보고 "측점 패널 안 보임"
- **원인**: 초기 탐색이 geo 소비 컴포넌트로 GeoSearch/StationVerify/StationOverlay/RoutePanel 4개만 식별. 영상 하단의 측점 바 [client/src/stationbar/StationBar.tsx](../client/src/stationbar/StationBar.tsx)는 누락되어, 여전히 `/api/geo/pois`·`/api/geo/frames`를 직접 fetch. → 폴더 선택으로 채운 클라이언트 데이터를 못 읽고, 서버 미기동 시 측점이 표시되지 않음.
- **수정**: StationBar를 다른 컴포넌트와 동일하게 geoStore로 연결.
- `pois` = `[...stations, ...pois]` (store 파생), `frames` = `storeFrames`로 교체. 두 `fetch` useEffect 제거.
- precompute/세그먼트 effect 의존성 `framesVersion` → `storeFrames`. 데이터 비었을 때 `setReady(false)`로 idle 복귀.
- `npm run build -w client` 통과 확인. 클라이언트 전체에 live `/api/geo` fetch 0건.
- **교훈**: 다음 탐색 시 `grep -rn "fetch(.*\/api\/geo"`로 **소비처 전수 조사**를 먼저 수행해 누락 방지.
</content>
</invoke>