사진·카메라

NP3 Lab

니콘 Z 카메라 사용자를 위해 SD카드의 플렉시블 컬러 레시피(.NP3)를 읽고·넣고·만드는 Mac·안드로이드·웹 앱.

분야사진·카메라플랫폼macOS · Android · Web기술TypeScript · Preact · Preact Signals · Vite · Electron · Capacitor · WebGL2 · Firebase · JSZip · Vitest
Mac 앱: SD카드를 꽂으면 카드 속 레시피를 미리보기와 함께 보여 줍니다.
Mac 앱: SD카드를 꽂으면 카드 속 레시피를 미리보기와 함께 보여 줍니다.
레시피 탭: 크리에이터·톤으로 걸러 카드에 담습니다.
레시피 탭: 크리에이터·톤으로 걸러 카드에 담습니다.
레시피 상세: 원본/적용 비교 슬라이더와 컬러 설정값.
레시피 상세: 원본/적용 비교 슬라이더와 컬러 설정값.
안드로이드판 에디터: 컬러 블렌더와 컬러 그레이딩.
안드로이드판 에디터: 컬러 블렌더와 컬러 그레이딩.

왜 만들었나

Nikon Imaging Cloud는 레시피를 9개까지만 보낼 수 있고 Z8·Z9는 클라우드를 못 써서, 카드에 NIKON/CUSTOMPC 폴더를 만들고 파일을 손으로 복사해야 했습니다. 카드를 꽂으면 앱이 열려 레시피를 바로 넣고 빼도록 했습니다.

주요 기능

  • 카드 자동 인식: 마운트된 FAT/exFAT 볼륨 중 니콘 카드만 골라 CUSTOMPC 속 레시피를 미리보기와 함께 표시
  • PICCON01~99 번호 자동 배정으로 여러 레시피를 한 번에 넣기, 빼기는 휴지통 이동으로 되돌리기 가능
  • WebGL2 셰이더로 원본/적용 비교 미리보기, 컬러 블렌더 8색·컬러 그레이딩·톤 커브 에디터
  • 구형 기종용 .NP3 → .NCP 변환, 커뮤니티 레시피 다운로드와 텍스트(Reddit) 수치 가져오기
  • 사진 EXIF에 기록된 Picture Control 이름으로 JPEG·NEF를 레시피별로 자동 정리하고 촬영 정보 프레임 저장

숫자로 보면

카드당 형식별 최대 99개 파일(PICCON01~99) 자동 번호
사진 프레임 레이아웃 14종 · 배경 3종 · 비율 4종
Vitest 테스트 파일 18개

어떻게 동작하나

  1. 카드 감지

    /Volumes를 fs.watch로 지켜보고 mount 출력에서 FAT·exFAT 볼륨만 골라 4초 타임아웃으로 검사합니다.

    electron/main.mjs
  2. 니콘 카드 판별

    루트의 NIKON 폴더나 DCIM 하위 폴더 이름(100NIKON 등)으로 다른 브랜드 카드를 걸러냅니다.

    electron/cardRules.mjs
  3. 바이트 파싱

    헤더의 "NCP\0" 매직과 버전(0310/02xx/01xx)으로 형식을 가르고 이름 필드와 파라미터를 읽어 정규화합니다.

    src/np3/recipe.ts
  4. 미리보기 렌더링

    정규화된 값을 셰이더 uniform으로, 257점 톤 커브를 R32F LUT 텍스처로 올려 샘플 사진에 적용합니다.

    src/render/renderer.ts
  5. 파일 이름 계획

    카드에 이미 있는 이름을 대소문자 구분 없이 피해 형식별로 PICCON01~99 번호를 매기고 넘치는 레시피는 따로 돌려줍니다.

    src/pack/naming.ts
  6. 카드에 쓰기

    IPC로 넘어온 이름·바이트를 isSafeNp3Name·isNp3Bytes로 검증한 뒤 CUSTOMPC에만 기록합니다.

    electron/main.mjs

핵심 코드

export function parseNcp(b: Uint8Array): NcpParams | null {
  if (!isNcp(b)) return null;
  const mono = b[0x24] === 0x06 && b[0x25] === 0x4d;
  const step = (v: number, min: number, max: number) => (v === 0xff || v === 0x01 ? 0 : clamp(v - 0x80, min, max));
  const view = new DataView(b.buffer, b.byteOffset, b.byteLength);
  let curve = Array.from({ length: RAW_LENGTH }, (_, i) => Math.min(RAW_MAX, view.getUint16(CURVE_AT + 2 * i)));
  // Made with the contrast and brightness sliders instead of a curve: draw those as one.
  if (b[0x29] !== 0x01) {
    const contrast = step(b[0x29], -3, 3) * STEP;
    const brightness = step(b[0x2a], -1, 1);
    curve = curveFrom((x) => toneAt(x, { contrast }) + brightness * 0.06 * Math.sin(Math.PI * x));
  }
  const points: CurvePoint[] = [];
  for (let i = 0; i < Math.min(b[0x40], 20); i++) points.push({ x: b[0x41 + 2 * i], y: b[0x42 + 2 * i] });
  // …

구형 Picture Control(.NCP) 638바이트 구조를 오프셋 단위로 직접 해석해 톤 커브와 슬라이더 값을 복원하는 부분입니다.

어려웠던 점

Flexible Color와 구형 .NCP는 표현할 수 있는 값이 달라, NCP 바이트 레이아웃을 직접 분석하고 대비·밝기 슬라이더를 미리보기와 같은 톤 함수로 커브에 옮겨야 했습니다. 카드에서 읽은 파일은 재인코딩 손실이 없도록 원본 바이트 그대로 쓰고 이름 필드만 교체합니다.