/** * DJI 영상 카메라 메타데이터 자동 감지 * * DJI 드론 MP4 에는 'djmd' 데이터 트랙(프레임별 protobuf 텔레메트리)이 mdat 에 * 비디오 프레임과 인터리브되어 있다. 파일 앞부분(8MB)에서 첫 djmd 샘플을 찾아 추출: * - 카메라 모델 문자열 (필드 1.1.2, 예: "DJI ZenmuseP1") * - 렌즈 초점거리 (필드 8.6, 0.1mm 단위 35mm 환산) * - 해상도/fps (필드 1.7/1.6/1.8) * * 실측 검증(2026-07-09, 제주 데이터): DJI_20260629121835_0002.MP4 → * model "DJI ZenmuseP1", 8.6=299(=29.9mm — DL 24mm 렌즈 × 4K 비디오 크롭 ≈1.25), * 3840×2160, 59.94fps. (djmd 필드 6.4/6.5 는 위경도 라디안, 6.6 은 타원체고 mm) */ import type { DetectedCameraInfo } from '../types/geo'; const HEAD_BYTES = 8 * 1024 * 1024; function readVarint(b: Uint8Array, i: number): [number, number] | null { let v = 0, s = 0; while (i < b.length && (b[i] & 0x80)) { v += (b[i] & 0x7f) * 2 ** s; s += 7; i++; if (s > 42) return null; } if (i >= b.length) return null; v += (b[i] & 0x7f) * 2 ** s; return [v, i + 1]; } /** [start,end) 구간을 protobuf 필드로 파싱해 경로("8.6")→값 수집. 깨진 구간은 조용히 중단. */ function parseFields( b: Uint8Array, dv: DataView, start: number, end: number, prefix: string, out: Map, ): void { let i = start; while (i < end) { const kv = readVarint(b, i); if (!kv) return; const [key, ni] = kv; i = ni; const tag = key >> 3, wire = key & 7; if (tag === 0 || tag > 60) return; const path = prefix + tag; if (wire === 0) { const r = readVarint(b, i); if (!r) return; out.set(path, r[0]); i = r[1]; } else if (wire === 5) { if (i + 4 > end) return; out.set(path, dv.getFloat32(i, true)); i += 4; } else if (wire === 1) { if (i + 8 > end) return; out.set(path, dv.getFloat64(i, true)); i += 8; } else if (wire === 2) { const r = readVarint(b, i); if (!r) return; const [len, p] = r; if (p + len > end) return; // 하위 메시지 시도 — 문자열/바이너리면 내부에서 알아서 중단됨(경로 prefix 로 충돌 없음) parseFields(b, dv, p, p + len, path + '.', out); i = p + len; } else return; } } /** 영상 File 앞부분에서 DJI djmd 카메라 정보를 감지한다. DJI 영상이 아니면 null. */ export async function detectCameraInfo(file: File): Promise { try { const buf = new Uint8Array(await file.slice(0, HEAD_BYTES).arrayBuffer()); const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength); // 모델 문자열: 0x12 "DJI ..." (필드 1.1.2) let m = -1; for (let i = 2; i < buf.length - 4; i++) { if (buf[i] === 0x44 && buf[i + 1] === 0x4a && buf[i + 2] === 0x49 && buf[i + 3] === 0x20 && buf[i - 2] === 0x12 && buf[i - 1] >= 4 && buf[i - 1] <= 40) { m = i; break; } } if (m < 0) return null; const model = new TextDecoder().decode(buf.slice(m, m + buf[m - 1])); // 샘플 시작(최상위 필드1, 0x0a 0x0a ...)을 모델 문자열에서 역방향 탐색 let sampleStart = -1; for (let back = 4; back <= 16; back++) { const i = m - 2 - back; if (i < 0) break; if (buf[i] !== 0x0a) continue; const r = readVarint(buf, i + 1); if (!r) continue; const [len, p] = r; if (buf[p] === 0x0a && p < m && p + len > m && p + len <= buf.length) { sampleStart = i; break; } } if (sampleStart < 0) return { model }; const out = new Map(); parseFields(buf, dv, sampleStart, Math.min(buf.length, sampleStart + 512), '', out); const info: DetectedCameraInfo = { model }; const focal10 = out.get('8.6'); if (focal10 !== undefined && focal10 > 80 && focal10 < 3000) info.focalLen35 = focal10 / 10; // 8~300mm 만 신뢰 const w = out.get('1.7'), h = out.get('1.6'), fps = out.get('1.8'); if (w && w > 100) info.width = w; if (h && h > 100) info.height = h; if (typeof fps === 'number' && fps > 1 && fps < 1000) info.fps = Math.round(fps * 100) / 100; // 6.6 = 드론 타원체고(mm) — 영상 시작 시점. CSV 정표고와의 차 = 지역 지오이드고(datum 변환용). const altMm = out.get('6.6'); if (typeof altMm === 'number' && altMm > 0 && altMm < 10_000_000) { info.ellipsoidalAltM = Math.round(altMm) / 1000; } return info; } catch { return null; } }