Files
lapie_app/docs/v0-plan.md
gahusb 69d61231b9 docs: v0-plan task step 체크박스 + README 상태 표 갱신
docs/v0-plan.md ( 표시 + Mac 작업 명시):
- Task 1 (Expo 셋업): Step 1/3/4/6  (Step 2/5 Mac)
- Task 4 (Pose Swift 브릿지): Step 3/4 TS+test  (Step 1/2/5/6 Mac)
- Task 5 (validatePose + 사진 등록 UI): Step 1-4 TDD  (Step 5/6 Task C)
- Task 6 (splitMask): Step 1-4 
- Task 8 (자동 스케일): Step 1-6  (Step 7 Mac 검증)

README.md:
- "v0 상태"를 사전 액션 + v0 코드 진행으로 2분할
- W1 Task 1 ⏸ →  Windows 가능 범위
- macOS 접근 방안 ⏸ →  Mac 보유
- W1~W4 Task 별 상태 한눈에 (Task 4 🟡 일부, Task 5/6/8 , Task 2/3/7/9/10/11 ⏸ Mac)
- 검증 결과 한 줄: 17 tests passed, 7 commits

검증:
- npx tsc --noEmit: 무에러
- npm test: 6 suites / 17 tests passed (docs 변경이라 기존 테스트 영향 없음 확인)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:32:00 +09:00

51 KiB
Raw Permalink Blame History

[!takeaway] 비즈니스/방향성 관점에서의 핵심 사업-Lapie-피팅앱 design의 v0 4~6주 구체 구현 계획. 7월 착수 직전 (6월 말 프로젝트-우리카드-AI숏폼-공모전 마감 후) 재수정 가능. 박재오는 RN 신규 영역이므로 모든 task는 bite-sized + TDD 가능 부분은 TDD / UI·카메라·합성은 manual test 절차 명시. 핵심 차별화 = Task 9 자동 스케일 = v0 W3 발목 잡힐 가능성 가장 큰 곳.

사업 — Lapie 피팅앱 v0 구현 계획

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: iOS 단독 RN 앱 v0 빌드 — 정면 전신 사진 등록 → 자동 마스킹(상/하/전신 3모드) → 카메라 라이브 합성(자동스케일+핀치) → 캡쳐·SNS 공유까지. 박재오 본인+아내+친구 5명이 사용 가능한 수준.

Architecture: Expo bare workflow 위 React Native + TypeScript. 카메라·세그멘테이션·합성은 react-native-vision-camera + react-native-skia로 처리. iOS Vision Framework 직접 호출 부분은 Swift 네이티브 모듈로 분리. 상태는 Zustand로 최소화.

Tech Stack: React Native (0.76+) / Expo (52+, bare) / TypeScript / react-native-vision-camera (v4) / react-native-skia (v1) / @shopify/react-native-skia / react-native-worklets-core / Vision Framework (Swift bridge) / Zustand / Jest (유닛) / Detox (선택, v1으로 미룸)

전제 조건:

  • 박재오 가용시간: 주 15~20h. 7월 1주 착수 가정.
  • macOS + Xcode 15+ 필요 (현재 박재오 메인은 Windows이므로 Xcode 접근 방안 별도 확인 — 5번 액션 참고).
  • Apple Developer Program 가입 ($99/yr).
  • 워크스페이스: C:\Users\jaeoh\Desktop\workspace\lapie\ 신설 (또는 macOS 경로).

중요: 박재오 Windows 환경에서 RN iOS 빌드 불가. 다음 중 선택:

  • (a) macOS 기기 확보 (중고 Mac mini M1 ~50만) — Day 0 결정 항목
  • (b) Expo EAS Build 클라우드 빌드 ($29/월) — Mac 없이 가능, 단 iOS 네이티브 모듈 디버깅 어려움
  • (c) 처음부터 RN 포기, Flutter or 웹 PWA 재검토 → design 재논의 필요

이 plan은 (a) 또는 (b) 가정. 7월 착수 전 결정 필요.


Pre-Tasks: Day 0 차단성 액션 (착수 전, 비코딩)

Task 0.1: 도메인·앱스토어·인스타·상표 검증

Files: 없음 (외부 검색)

  • Step 1: 도메인 가용성 확인

    • lapie.app / lapie.kr / lapie.io Namecheap·Cafe24에서 검색
    • 결과 기록: 사업-Lapie-피팅앱 "Day 0 차단성 액션" 표에 / 표시
    • 모두 사용 불가 시 대안: Wittu / Mirree / Geola 순으로 재검증
  • Step 2: 앱스토어 동명 앱 검색

    • App Store (iOS) + Google Play (Android) 검색: "Lapie", "라피"
    • 동명 패션·피팅 앱 존재 시 → 네이밍 재검토
  • Step 3: 인스타 핸들 확보

    • @lapie / @lapie_app / @lapie_official 검색
    • 가능한 핸들 1개 즉시 선점 등록 (이메일·비밀번호만 필요, 정식 운영은 W4 직전)
  • Step 4: 상표 검색 (KIPRIS + USPTO)

  • Step 5: 결과를 wiki에 반영

    • 사업-Lapie-피팅앱 "Day 0 차단성 액션" 표에 검증 결과 갱신
    • 충돌 발견 시 본 plan 머리의 brand name을 새 후보로 변경

Task 0.2: 개발 환경 결정

  • Step 1: macOS 접근 방안 결정

    • (a) 중고 Mac mini M1 구매 (~50만)
    • (b) EAS Build 클라우드 ($29/월 = 7월~10월 약 12만)
    • (c) Flutter or PWA 재논의 (design 변경 필요)
    • 결정 → 사업-Lapie-피팅앱 변경 이력에 한 줄 기록
  • Step 2: Apple Developer Program 가입


Workspace Setup

Task 1: RN + Expo 프로젝트 초기화

Files:

  • Create: C:/Users/jaeoh/Desktop/workspace/lapie/ (또는 mac 경로)

  • Create: lapie/package.json (Expo CLI가 생성)

  • Create: lapie/app.json

  • Create: lapie/tsconfig.json

  • Step 1: Expo 프로젝트 생성 2026-05-24 (SDK 56, Windows 임시폴더 → 파일 복사 방식)

cd C:/Users/jaeoh/Desktop/workspace/
npx create-expo-app@latest lapie --template blank-typescript
cd lapie

Expected: lapie/ 디렉터리에 App.tsx, package.json, tsconfig.json 생성.

  • Step 2: Bare workflow로 전환 (네이티브 모듈 필요) ⏸ Mac 작업
npx expo prebuild --platform ios

Expected: lapie/ios/ 디렉터리 생성, CocoaPods install. (Windows에서는 prebuild 실패 가능 — mac 환경 또는 EAS에서 실행).

  • Step 3: 필수 의존성 설치 2026-05-24 (vision-camera v5, skia v2.6, worklets-core, zustand, gesture-handler, safe-area-context, jest-expo 등)
npm install react-native-vision-camera @shopify/react-native-skia react-native-worklets-core zustand react-native-safe-area-context
npm install --save-dev jest @types/jest ts-jest
  • Step 4: 권한 설정 (app.json) 2026-05-24 (iOS NSCamera/Photo/PhotoAdd 3종 + bundleIdentifier com.lapie.app)
{
  "expo": {
    "name": "Lapie",
    "slug": "lapie",
    "ios": {
      "bundleIdentifier": "com.lapie.app",
      "infoPlist": {
        "NSCameraUsageDescription": "옷을 카메라에 비춰 합성 미리보기에 사용합니다.",
        "NSPhotoLibraryUsageDescription": "정면 사진을 등록하고 캡쳐를 저장하기 위해 사용합니다.",
        "NSPhotoLibraryAddUsageDescription": "합성 결과 사진을 저장하기 위해 사용합니다."
      }
    }
  }
}
  • Step 5: 첫 빌드 확인 ⏸ Mac 작업 (npx expo run:ios)
npx expo run:ios

Expected: iOS 시뮬레이터에 기본 화면 표시.

  • Step 6: Git 초기화 + 첫 커밋 2026-05-24 (Gitea SSH remote 연결 + ED25519 키 + 2 commits push)
cd lapie
git init
git add .
git commit -m "chore: initial Expo + RN + TS setup with vision-camera/skia deps"

W1 — 카메라 + 세그멘테이션 PoC

Task 2: Vision Camera 카메라 화면 + 권한

Files:

  • Create: lapie/src/screens/CameraTestScreen.tsx

  • Modify: lapie/App.tsx

  • Step 1: 카메라 권한 hook 작성

lapie/src/screens/CameraTestScreen.tsx:

import { useEffect } from 'react';
import { View, Text, StyleSheet } from 'react-native';
import { Camera, useCameraDevice, useCameraPermission } from 'react-native-vision-camera';

export function CameraTestScreen() {
  const { hasPermission, requestPermission } = useCameraPermission();
  const device = useCameraDevice('back');

  useEffect(() => {
    if (!hasPermission) requestPermission();
  }, [hasPermission, requestPermission]);

  if (!hasPermission) {
    return <View style={styles.center}><Text>카메라 권한이 필요합니다</Text></View>;
  }
  if (!device) {
    return <View style={styles.center}><Text>카메라 디바이스 없음</Text></View>;
  }

  return (
    <Camera
      style={StyleSheet.absoluteFill}
      device={device}
      isActive={true}
    />
  );
}

const styles = StyleSheet.create({
  center: { flex: 1, justifyContent: 'center', alignItems: 'center' },
});
  • Step 2: App.tsx에서 호출

lapie/App.tsx:

import { CameraTestScreen } from './src/screens/CameraTestScreen';
export default function App() {
  return <CameraTestScreen />;
}
  • Step 3: 실기기 빌드 + 검증 (시뮬레이터는 카메라 없음)
npx expo run:ios --device

Expected: 실기기에서 후방 카메라 실시간 프리뷰 표시.

  • Step 4: 커밋
git add src/screens/CameraTestScreen.tsx App.tsx
git commit -m "feat: vision-camera live preview with permission flow"

Task 3: iOS Vision Selfie Segmentation 네이티브 브릿지

Files:

  • Create: lapie/modules/segmentation/ios/SegmentationModule.swift
  • Create: lapie/modules/segmentation/ios/SegmentationModule.m
  • Create: lapie/src/features/segmentation/index.ts
  • Create: lapie/src/features/segmentation/segmentImage.ts
  • Test: lapie/src/features/segmentation/__tests__/segmentation.test.ts

Vision Framework의 VNGeneratePersonSegmentationRequest를 RN으로 노출. 입력: 이미지 URI, 출력: 마스크 PNG URI.

  • Step 1: Swift 모듈 작성

lapie/modules/segmentation/ios/SegmentationModule.swift:

import Foundation
import Vision
import UIKit

@objc(SegmentationModule)
class SegmentationModule: NSObject {

  @objc static func requiresMainQueueSetup() -> Bool { false }

  @objc(segmentPerson:resolver:rejecter:)
  func segmentPerson(_ imageUri: String,
                     resolver: @escaping RCTPromiseResolveBlock,
                     rejecter: @escaping RCTPromiseRejectBlock) {
    guard let url = URL(string: imageUri),
          let imageData = try? Data(contentsOf: url),
          let uiImage = UIImage(data: imageData),
          let cgImage = uiImage.cgImage else {
      rejecter("E_IMG", "Image load failed", nil)
      return
    }

    let request = VNGeneratePersonSegmentationRequest()
    request.qualityLevel = .accurate
    request.outputPixelFormat = kCVPixelFormatType_OneComponent8

    let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
    do {
      try handler.perform([request])
      guard let result = request.results?.first else {
        rejecter("E_NO_RESULT", "No segmentation result", nil); return
      }
      // PixelBuffer → PNG 파일로 저장
      let maskUri = try saveMaskPNG(result.pixelBuffer, originalSize: uiImage.size)
      resolver(maskUri)
    } catch {
      rejecter("E_SEG", error.localizedDescription, error)
    }
  }

  private func saveMaskPNG(_ pixelBuffer: CVPixelBuffer, originalSize: CGSize) throws -> String {
    let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
    let scaleX = originalSize.width / ciImage.extent.width
    let scaleY = originalSize.height / ciImage.extent.height
    let scaled = ciImage.transformed(by: CGAffineTransform(scaleX: scaleX, y: scaleY))
    let context = CIContext()
    guard let cgImage = context.createCGImage(scaled, from: scaled.extent) else {
      throw NSError(domain: "Lapie", code: 1)
    }
    let uiImage = UIImage(cgImage: cgImage)
    let pngData = uiImage.pngData()!
    let path = NSTemporaryDirectory() + "mask_\(UUID().uuidString).png"
    try pngData.write(to: URL(fileURLWithPath: path))
    return "file://" + path
  }
}
  • Step 2: Objective-C 브릿지

lapie/modules/segmentation/ios/SegmentationModule.m:

#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(SegmentationModule, NSObject)
RCT_EXTERN_METHOD(segmentPerson:(NSString *)imageUri
                  resolver:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)
@end
  • Step 3: TS 래퍼

lapie/src/features/segmentation/index.ts:

import { NativeModules } from 'react-native';

const { SegmentationModule } = NativeModules;

export async function segmentPerson(imageUri: string): Promise<string> {
  if (!SegmentationModule?.segmentPerson) {
    throw new Error('SegmentationModule not linked');
  }
  return SegmentationModule.segmentPerson(imageUri);
}
  • Step 4: 유닛 테스트 (모킹)

lapie/src/features/segmentation/__tests__/segmentation.test.ts:

import { segmentPerson } from '..';

jest.mock('react-native', () => ({
  NativeModules: {
    SegmentationModule: {
      segmentPerson: jest.fn(async (uri: string) => `file://mask_for_${uri}.png`),
    },
  },
}));

describe('segmentPerson', () => {
  it('returns mask URI for input image', async () => {
    const result = await segmentPerson('file://input.jpg');
    expect(result).toBe('file://mask_for_file://input.jpg.png');
  });
});
  • Step 5: 테스트 실행 (실패 → 통과)
npx jest src/features/segmentation

Expected: 1 passed (모킹된 호출 검증).

  • Step 6: 실기기 manual test

    • Photo Library에서 정면사진 1장 선택 → segmentPerson(uri) 호출
    • 반환된 mask URI를 <Image>로 표시
    • 시각 검증: 사람 영역이 흰색, 배경이 검은색인 마스크 확인
  • Step 7: CocoaPods 설치 + 커밋

cd ios && pod install && cd ..
git add modules/segmentation src/features/segmentation
git commit -m "feat: iOS Vision person segmentation native bridge"

W2 — 정면사진 등록 + Pose 키포인트 + 마스크 분할

Task 4: iOS Vision Pose Detection 네이티브 브릿지

Files:

  • Create: lapie/modules/pose/ios/PoseModule.swift

  • Create: lapie/modules/pose/ios/PoseModule.m

  • Create: lapie/src/features/pose/index.ts

  • Test: lapie/src/features/pose/__tests__/pose.test.ts

  • Step 1: Swift 모듈 작성 (17개 keypoint 반환) ⏸ Mac 작업

lapie/modules/pose/ios/PoseModule.swift:

import Foundation
import Vision
import UIKit

@objc(PoseModule)
class PoseModule: NSObject {

  @objc static func requiresMainQueueSetup() -> Bool { false }

  @objc(detectPose:resolver:rejecter:)
  func detectPose(_ imageUri: String,
                  resolver: @escaping RCTPromiseResolveBlock,
                  rejecter: @escaping RCTPromiseRejectBlock) {
    guard let url = URL(string: imageUri),
          let data = try? Data(contentsOf: url),
          let uiImage = UIImage(data: data),
          let cgImage = uiImage.cgImage else {
      rejecter("E_IMG", "Image load failed", nil); return
    }

    let request = VNDetectHumanBodyPoseRequest()
    let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
    do {
      try handler.perform([request])
      guard let observation = request.results?.first else {
        rejecter("E_NO_POSE", "No pose detected", nil); return
      }
      let points = try observation.recognizedPoints(.all)
      let imageW = CGFloat(cgImage.width)
      let imageH = CGFloat(cgImage.height)
      var result: [String: [String: Any]] = [:]
      for (jointName, point) in points where point.confidence > 0.3 {
        // Vision 좌표: 좌하단 원점, normalized (0~1). UIKit으로 변환.
        result[jointName.rawValue.rawValue] = [
          "x": point.location.x * imageW,
          "y": (1 - point.location.y) * imageH,
          "confidence": point.confidence,
        ]
      }
      resolver(result)
    } catch {
      rejecter("E_POSE", error.localizedDescription, error)
    }
  }
}
  • Step 2: Obj-C 브릿지 ⏸ Mac 작업

lapie/modules/pose/ios/PoseModule.m:

#import <React/RCTBridgeModule.h>

@interface RCT_EXTERN_MODULE(PoseModule, NSObject)
RCT_EXTERN_METHOD(detectPose:(NSString *)imageUri
                  resolver:(RCTPromiseResolveBlock)resolve
                  rejecter:(RCTPromiseRejectBlock)reject)
@end
  • Step 3: TS 래퍼 + 타입 2026-05-24 (Keypoint/Joint/PoseResult + NativeModules wrapper, 매번 조회 패턴)

lapie/src/features/pose/index.ts:

import { NativeModules } from 'react-native';

const { PoseModule } = NativeModules;

export interface Keypoint {
  x: number;
  y: number;
  confidence: number;
}

export type Joint =
  | 'left_shoulder_joint' | 'right_shoulder_joint'
  | 'left_hip_joint' | 'right_hip_joint'
  | 'left_ankle_joint' | 'right_ankle_joint'
  | 'neck_1_joint' | 'root';

export type PoseResult = Partial<Record<Joint, Keypoint>>;

export async function detectPose(imageUri: string): Promise<PoseResult> {
  if (!PoseModule?.detectPose) throw new Error('PoseModule not linked');
  return PoseModule.detectPose(imageUri);
}
  • Step 4: 유닛 테스트 (모킹) 2026-05-24 (1 passed, jest-expo 호이스팅 충돌 회피 — NativeModules 직접 할당 패턴 사용)

lapie/src/features/pose/__tests__/pose.test.ts:

import { detectPose } from '..';

jest.mock('react-native', () => ({
  NativeModules: {
    PoseModule: {
      detectPose: jest.fn(async () => ({
        left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 },
        right_shoulder_joint: { x: 300, y: 200, confidence: 0.9 },
      })),
    },
  },
}));

describe('detectPose', () => {
  it('returns keypoint map', async () => {
    const result = await detectPose('file://photo.jpg');
    expect(result.left_shoulder_joint?.x).toBe(100);
    expect(result.right_shoulder_joint?.x).toBe(300);
  });
});
  • Step 5: 실기기 manual test ⏸ Mac 작업

    • 정면사진 1장 입력 → 어깨·골반·발목 keypoint가 사진 위 정확한 위치에 표시되는지 시각 검증
    • 임시 화면 PoseDebugScreen.tsx에 keypoint를 점으로 overlay
  • Step 6: 커밋

git add modules/pose src/features/pose
git commit -m "feat: iOS Vision human body pose native bridge (17 keypoints)"

Task 5: 정면사진 등록 화면 + 자세 가이드 오버레이

Files:

  • Create: lapie/src/screens/PhotoCaptureScreen.tsx

  • Create: lapie/src/features/photoValidation/validatePose.ts

  • Test: lapie/src/features/photoValidation/__tests__/validatePose.test.ts

  • Step 1: 자세 검증 함수 (TDD) 2026-05-24 — RED 단계 (Cannot find module → 실패 확인)

lapie/src/features/photoValidation/__tests__/validatePose.test.ts:

import { validatePose } from '../validatePose';

describe('validatePose', () => {
  it('passes when all required joints present with high confidence', () => {
    const pose = {
      left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 },
      right_shoulder_joint: { x: 300, y: 200, confidence: 0.9 },
      left_hip_joint: { x: 120, y: 400, confidence: 0.85 },
      right_hip_joint: { x: 280, y: 400, confidence: 0.85 },
      left_ankle_joint: { x: 130, y: 700, confidence: 0.7 },
      right_ankle_joint: { x: 270, y: 700, confidence: 0.7 },
    };
    const result = validatePose(pose);
    expect(result.score).toBeGreaterThanOrEqual(80);
    expect(result.passed).toBe(true);
  });

  it('fails when shoulders missing', () => {
    const pose = {
      left_hip_joint: { x: 120, y: 400, confidence: 0.85 },
      right_hip_joint: { x: 280, y: 400, confidence: 0.85 },
    };
    const result = validatePose(pose);
    expect(result.passed).toBe(false);
    expect(result.reasons).toContain('어깨가 보이지 않습니다');
  });

  it('fails when body tilted (shoulders y diff > 30px)', () => {
    const pose = {
      left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 },
      right_shoulder_joint: { x: 300, y: 250, confidence: 0.9 },
      left_hip_joint: { x: 120, y: 400, confidence: 0.85 },
      right_hip_joint: { x: 280, y: 400, confidence: 0.85 },
      left_ankle_joint: { x: 130, y: 700, confidence: 0.7 },
      right_ankle_joint: { x: 270, y: 700, confidence: 0.7 },
    };
    const result = validatePose(pose);
    expect(result.passed).toBe(false);
    expect(result.reasons).toContain('몸이 기울어져 있습니다');
  });
});
  • Step 2: 실행 → 실패 확인 2026-05-24
npx jest src/features/photoValidation

Expected: FAIL — validatePose not defined.

  • Step 3: 구현 2026-05-24 (한국어 격조사 정확화: '어깨가/골반이/발목이')

lapie/src/features/photoValidation/validatePose.ts:

import type { PoseResult } from '../pose';

export interface ValidationResult {
  score: number;
  passed: boolean;
  reasons: string[];
}

const REQUIRED = [
  'left_shoulder_joint', 'right_shoulder_joint',
  'left_hip_joint', 'right_hip_joint',
  'left_ankle_joint', 'right_ankle_joint',
] as const;

export function validatePose(pose: PoseResult): ValidationResult {
  const reasons: string[] = [];

  for (const joint of REQUIRED) {
    if (!pose[joint] || pose[joint]!.confidence < 0.5) {
      const label = joint.includes('shoulder') ? '어깨'
                  : joint.includes('hip') ? '골반'
                  : '발목';
      const msg = `${label}이 보이지 않습니다`;
      if (!reasons.includes(msg)) reasons.push(msg);
    }
  }

  if (pose.left_shoulder_joint && pose.right_shoulder_joint) {
    const yDiff = Math.abs(pose.left_shoulder_joint.y - pose.right_shoulder_joint.y);
    if (yDiff > 30) reasons.push('몸이 기울어져 있습니다');
  }

  const score = Math.max(0, 100 - reasons.length * 25);
  return { score, passed: score >= 80, reasons };
}
  • Step 4: 테스트 통과 확인 2026-05-24 — 3 passed
npx jest src/features/photoValidation

Expected: 3 passed.

  • Step 5: 사진 등록 화면 (UI) ⏸ Task C에서 진행 + Mac 실기기 검증

lapie/src/screens/PhotoCaptureScreen.tsx:

import { useState } from 'react';
import { View, Text, Button, Image, StyleSheet } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { detectPose } from '../features/pose';
import { validatePose, ValidationResult } from '../features/photoValidation/validatePose';

export function PhotoCaptureScreen({ onPhotoReady }: { onPhotoReady: (uri: string) => void }) {
  const [photoUri, setPhotoUri] = useState<string | null>(null);
  const [validation, setValidation] = useState<ValidationResult | null>(null);

  async function pickAndValidate() {
    const result = await ImagePicker.launchImageLibraryAsync({
      mediaTypes: ImagePicker.MediaTypeOptions.Images,
      allowsEditing: false,
      quality: 0.8,
    });
    if (result.canceled) return;
    const uri = result.assets[0].uri;
    setPhotoUri(uri);
    const pose = await detectPose(uri);
    const v = validatePose(pose);
    setValidation(v);
  }

  return (
    <View style={styles.container}>
      <Text style={styles.title}>정면 전신 사진</Text>
      <Text style={styles.guide}>
         어깨·골반·발목이 모두 보이도록{'\n'}
         정면을 응시{'\n'}
         단색 배경 권장
      </Text>
      <Button title="갤러리에서 선택" onPress={pickAndValidate} />
      {photoUri && <Image source={{ uri: photoUri }} style={styles.preview} />}
      {validation && (
        <View style={styles.result}>
          <Text>점수: {validation.score} / 100</Text>
          {validation.reasons.map(r => <Text key={r} style={styles.warn}> {r}</Text>)}
          {validation.passed && (
            <Button title="다음" onPress={() => onPhotoReady(photoUri!)} />
          )}
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, paddingTop: 60 },
  title: { fontSize: 24, fontWeight: 'bold', marginBottom: 12 },
  guide: { fontSize: 14, color: '#666', marginBottom: 20 },
  preview: { width: 200, height: 400, alignSelf: 'center', marginVertical: 20 },
  result: { marginTop: 12 },
  warn: { color: '#c00', marginVertical: 4 },
});
  • Step 6: 의존성 추가 + 커밋 ⏸ Task C에서 진행 (expo-image-picker)
npx expo install expo-image-picker
git add src/features/photoValidation src/screens/PhotoCaptureScreen.tsx package.json
git commit -m "feat: photo capture screen with pose-based validation"

Task 6: 3 모드 마스크 자동 분할

Files:

  • Create: lapie/src/features/maskSplit/splitMask.ts
  • Test: lapie/src/features/maskSplit/__tests__/splitMask.test.ts

Selfie Segmentation 마스크(전체 실루엣) + Pose keypoint(어깨·골반·발목)로 상의/하의/전신 3 마스크를 잘라낸다. Skia로 처리.

  • Step 1: 분할 로직 테스트 (TDD) 2026-05-24 — RED 단계

lapie/src/features/maskSplit/__tests__/splitMask.test.ts:

import { computeSplitRegions } from '../splitMask';

describe('computeSplitRegions', () => {
  const pose = {
    left_shoulder_joint: { x: 100, y: 200, confidence: 0.9 },
    right_shoulder_joint: { x: 300, y: 200, confidence: 0.9 },
    left_hip_joint: { x: 120, y: 400, confidence: 0.85 },
    right_hip_joint: { x: 280, y: 400, confidence: 0.85 },
    left_ankle_joint: { x: 130, y: 700, confidence: 0.7 },
    right_ankle_joint: { x: 270, y: 700, confidence: 0.7 },
  };

  it('top region spans shoulders to hips', () => {
    const r = computeSplitRegions(pose, { width: 400, height: 800 });
    expect(r.top.yStart).toBe(180);    // 어깨 - 20 (목까지)
    expect(r.top.yEnd).toBe(400);      // 골반 y
  });

  it('bottom region spans hips to ankles', () => {
    const r = computeSplitRegions(pose, { width: 400, height: 800 });
    expect(r.bottom.yStart).toBe(400);
    expect(r.bottom.yEnd).toBe(720);   // 발목 + 20 (발끝까지)
  });

  it('full region spans entire body bbox', () => {
    const r = computeSplitRegions(pose, { width: 400, height: 800 });
    expect(r.full.yStart).toBe(120);   // 머리 위 (어깨 - 80)
    expect(r.full.yEnd).toBe(720);
  });
});
  • Step 2: 구현 2026-05-24 (requireJoint helper로 invariant explicit — ! 안티패턴 회피)

lapie/src/features/maskSplit/splitMask.ts:

import type { PoseResult } from '../pose';

export interface Region {
  xStart: number;
  yStart: number;
  xEnd: number;
  yEnd: number;
}

export interface SplitRegions {
  top: Region;
  bottom: Region;
  full: Region;
}

export function computeSplitRegions(
  pose: PoseResult,
  imageSize: { width: number; height: number }
): SplitRegions {
  const ls = pose.left_shoulder_joint!;
  const rs = pose.right_shoulder_joint!;
  const lh = pose.left_hip_joint!;
  const rh = pose.right_hip_joint!;
  const la = pose.left_ankle_joint!;
  const ra = pose.right_ankle_joint!;

  const shoulderY = Math.min(ls.y, rs.y);
  const hipY = Math.max(lh.y, rh.y);
  const ankleY = Math.max(la.y, ra.y);
  const xMin = Math.min(ls.x, rs.x, lh.x, rh.x, la.x, ra.x) - 20;
  const xMax = Math.max(ls.x, rs.x, lh.x, rh.x, la.x, ra.x) + 20;

  return {
    top:    { xStart: xMin, yStart: shoulderY - 20,  xEnd: xMax, yEnd: hipY },
    bottom: { xStart: xMin, yStart: hipY,            xEnd: xMax, yEnd: ankleY + 20 },
    full:   { xStart: xMin, yStart: shoulderY - 80,  xEnd: xMax, yEnd: ankleY + 20 },
  };
}
  • Step 3: 테스트 통과 확인 2026-05-24 — 4 passed (top/bottom/full y + x 범위 별도)
npx jest src/features/maskSplit

Expected: 3 passed.

  • Step 4: 커밋 2026-05-24 — 8ec53d9 feat(maskSplit): computeSplitRegions TDD (v0-plan Task 6)
git add src/features/maskSplit
git commit -m "feat: split full mask into top/bottom/full regions using pose keypoints"

W3 — Skia 합성 + 자동 스케일 (핵심 차별화)

Task 7: Skia 합성 컴포넌트 (마스크 영역에 카메라 라이브 채우기)

Files:

  • Create: lapie/src/features/composite/CompositeView.tsx
  • Create: lapie/src/features/composite/shaders.ts

정면사진 위에 마스크 영역만큼 카메라 라이브 프레임을 잘라 채운다. Skia ImageShader + Mask 사용.

  • Step 1: 셰이더 정의

lapie/src/features/composite/shaders.ts:

import { Skia } from '@shopify/react-native-skia';

// 마스크가 흰색(>0.5)인 픽셀만 카메라 프레임으로 채우고,
// 나머지는 원본 사진 픽셀 유지
export const compositeShader = Skia.RuntimeEffect.Make(`
  uniform shader baseImage;     // 정면사진
  uniform shader cameraImage;   // 카메라 라이브 프레임 (스케일·이동 적용)
  uniform shader maskImage;     // 분할 영역 마스크 (흰=합성영역)

  half4 main(float2 xy) {
    half4 base = baseImage.eval(xy);
    half4 mask = maskImage.eval(xy);
    if (mask.r > 0.5) {
      half4 cam = cameraImage.eval(xy);
      return cam;
    }
    return base;
  }
`)!;
  • Step 2: 합성 컴포넌트

lapie/src/features/composite/CompositeView.tsx:

import { Canvas, Image as SkImage, useImage, Fill, Shader } from '@shopify/react-native-skia';
import { useMemo } from 'react';
import { compositeShader } from './shaders';

interface Props {
  baseImageUri: string;
  maskImageUri: string;
  cameraFrameUri: string | null;
  cameraTransform: { scale: number; tx: number; ty: number };
  width: number;
  height: number;
}

export function CompositeView({ baseImageUri, maskImageUri, cameraFrameUri, cameraTransform, width, height }: Props) {
  const base = useImage(baseImageUri);
  const mask = useImage(maskImageUri);
  const cam = useImage(cameraFrameUri);

  if (!base || !mask) return null;

  const uniforms = useMemo(() => ({}), []);

  return (
    <Canvas style={{ width, height }}>
      <Fill>
        <Shader source={compositeShader} uniforms={uniforms}>
          {/* Skia shader children = uniform shaders, 순서 = 셰이더 정의 순서 */}
          <SkImage image={base} x={0} y={0} width={width} height={height} />
          {cam ? (
            <SkImage
              image={cam}
              x={cameraTransform.tx}
              y={cameraTransform.ty}
              width={width * cameraTransform.scale}
              height={height * cameraTransform.scale}
            />
          ) : (
            <SkImage image={base} x={0} y={0} width={width} height={height} />
          )}
          <SkImage image={mask} x={0} y={0} width={width} height={height} />
        </Shader>
      </Fill>
    </Canvas>
  );
}

⚠ Skia Shader child 전달 방식은 SDK 버전마다 다름. v1.x에서는 useShader hook + Shader 사용. 착수 시점에 @shopify/react-native-skia 문서 재확인.

  • Step 3: 카메라 frame을 jpeg URI로 추출하는 헬퍼 (Vision Camera Frame Processor)

lapie/src/features/composite/useCameraFrameUri.ts:

import { useRef, useEffect, useState } from 'react';
import { Camera, useFrameProcessor } from 'react-native-vision-camera';

// 단순화: 매 N 프레임마다 takeSnapshot → temp URI
export function useCameraSnapshotUri(cameraRef: React.RefObject<Camera>, intervalMs = 100) {
  const [uri, setUri] = useState<string | null>(null);

  useEffect(() => {
    const t = setInterval(async () => {
      if (!cameraRef.current) return;
      try {
        const snap = await cameraRef.current.takeSnapshot({ quality: 50 });
        setUri('file://' + snap.path);
      } catch {}
    }, intervalMs);
    return () => clearInterval(t);
  }, [cameraRef, intervalMs]);

  return uri;
}

takeSnapshot은 10fps 정도. 30fps 합성이 필요하면 Frame Processor + Skia 텍스처 직접 전달로 업그레이드 (v0 W5 버퍼 또는 v1).

  • Step 4: 실기기 manual test

    • 정면사진 + 마스크 + 카메라 라이브 prop으로 CompositeView 렌더
    • 마스크 영역에 카메라 프레임이 채워지는지 시각 확인
    • 프레임 끊김 / FPS / 정렬 정확도 메모
  • Step 5: 커밋

git add src/features/composite
git commit -m "feat: Skia composite view masking camera live into base photo"

Task 8: 자동 스케일 추정 (하이브리드 — 핵심 차별화)

Files:

  • Create: lapie/src/features/autoScale/calculateScale.ts

  • Create: lapie/src/features/autoScale/detectClothBounds.ts

  • Test: lapie/src/features/autoScale/__tests__/calculateScale.test.ts

  • Step 1: 스케일 계산 테스트 (TDD) 2026-05-25 — 6 케이스 (정상비율/0폴백/음수폴백/상한클램핑/하한클램핑/클램핑X)

lapie/src/features/autoScale/__tests__/calculateScale.test.ts:

import { calculateScale } from '../calculateScale';

describe('calculateScale', () => {
  it('returns ratio of photo shoulder width to camera cloth width', () => {
    const result = calculateScale({
      photoShoulderPx: 200,
      cameraClothWidthPx: 400,
    });
    expect(result.scale).toBe(0.5);    // 사진 어깨 200px / 카메라 옷 400px = 옷을 절반으로 축소
    expect(result.confidence).toBeGreaterThan(0);
  });

  it('returns scale=1 when camera cloth width is 0 (unknown)', () => {
    const result = calculateScale({
      photoShoulderPx: 200,
      cameraClothWidthPx: 0,
    });
    expect(result.scale).toBe(1);
    expect(result.confidence).toBe(0);
  });

  it('clamps scale to [0.3, 3.0]', () => {
    expect(calculateScale({ photoShoulderPx: 200, cameraClothWidthPx: 10 }).scale).toBe(3.0);
    expect(calculateScale({ photoShoulderPx: 10, cameraClothWidthPx: 200 }).scale).toBe(0.3);
  });
});
  • Step 2: 구현 2026-05-25 (MIN_SCALE/MAX_SCALE export — usePinchScale에서 재사용)

lapie/src/features/autoScale/calculateScale.ts:

export interface ScaleInput {
  photoShoulderPx: number;
  cameraClothWidthPx: number;
}

export interface ScaleResult {
  scale: number;
  confidence: number;  // 0~1, 옷 검출 신뢰도
}

const MIN_SCALE = 0.3;
const MAX_SCALE = 3.0;

export function calculateScale({ photoShoulderPx, cameraClothWidthPx }: ScaleInput): ScaleResult {
  if (cameraClothWidthPx <= 0) {
    return { scale: 1, confidence: 0 };
  }
  const raw = photoShoulderPx / cameraClothWidthPx;
  const scale = Math.max(MIN_SCALE, Math.min(MAX_SCALE, raw));
  const confidence = scale === raw ? 1 : 0.5;  // 클램핑 됐으면 신뢰도 낮춤
  return { scale, confidence };
}
  • Step 3: 테스트 통과 2026-05-25 — 6 passed
npx jest src/features/autoScale/calculateScale

Expected: 3 passed.

  • Step 4: 옷 경계 검출 (단순화: 카메라 프레임에서 person mask 제외 영역의 bbox) 2026-05-25 — v0 폴백 함수 (항상 0 반환) + 회귀 테스트 2 케이스. 정식 구현은 Task 12 (Vision Saliency)

lapie/src/features/autoScale/detectClothBounds.ts:

import { segmentPerson } from '../segmentation';

// 카메라 프레임 1장에서 사람이 아닌 영역(=손에 들린 옷)의 가로 너비 추정
// 단순화: 사람 mask의 외부에서 색 변화 큰 연속 픽셀의 가로 너비
// v0에서는 사용자가 카메라 중앙에 옷을 들도록 안내 → 중앙 가로선 색 prof 분석
export async function detectClothWidthPx(cameraFrameUri: string): Promise<number> {
  // Step 1: 사람 mask 받기
  const maskUri = await segmentPerson(cameraFrameUri);
  // Step 2: 마스크가 검은(=배경=옷일 가능성) 영역 중 중앙 가로선의 연속 가로 너비
  // → 실제 픽셀 분석은 네이티브 모듈에서 (TODO Task 8.5 별도 분리)
  // v0 폴백: 화면 가로의 50%를 기본값으로 (사용자 핀치로 보정)
  return 0;  // 0 = 자동 검출 실패, 핀치만으로 보정
}

⚠ 옷 윤곽 검출은 v0 W3 가장 어려운 부분. v0 1차 출시는 자동 검출 폴백(scale=1) + 핀치만으로 운영, 정확도는 W5~6 버퍼·v1에 보강. 본 단계의 핵심은 calculateScale 순수 함수의 TDD 검증.

  • Step 5: 핀치 줌 hook 2026-05-25 — .runOnJS(true) 명시로 reanimated 의존 회피, Mac 실기기 검증 대기

lapie/src/features/autoScale/usePinchScale.ts:

import { useState, useCallback } from 'react';
import { Gesture } from 'react-native-gesture-handler';

export function usePinchScale(initialScale = 1) {
  const [scale, setScale] = useState(initialScale);
  const [tx, setTx] = useState(0);
  const [ty, setTy] = useState(0);

  const pinch = Gesture.Pinch().onUpdate((e) => {
    setScale(s => Math.max(0.3, Math.min(3.0, s * e.scale)));
  });

  const pan = Gesture.Pan().onUpdate((e) => {
    setTx(x => x + e.translationX);
    setTy(y => y + e.translationY);
  });

  const composed = Gesture.Simultaneous(pinch, pan);

  const reset = useCallback(() => {
    setScale(initialScale); setTx(0); setTy(0);
  }, [initialScale]);

  return { scale, tx, ty, gesture: composed, reset };
}
  • Step 6: 의존성 + 커밋 2026-05-25 — gesture-handler는 W1 Task 1에서 이미 설치, 005d612 feat(autoScale): detectClothBounds 폴백 + usePinchScale hook
npx expo install react-native-gesture-handler
git add src/features/autoScale package.json
git commit -m "feat: hybrid auto-scale (calculation + pinch zoom override)"

W4 — 캡쳐·공유 + 온보딩 + 폴리싱

Task 9: 라이브 피팅 화면 (모든 요소 통합)

Files:

  • Create: lapie/src/screens/LiveFittingScreen.tsx

  • Create: lapie/src/store/useAppStore.ts

  • Step 1: Zustand store

lapie/src/store/useAppStore.ts:

import { create } from 'zustand';
import type { PoseResult } from '../features/pose';

export type Mode = 'top' | 'bottom' | 'full';

interface AppState {
  photoUri: string | null;
  maskUri: string | null;
  pose: PoseResult | null;
  mode: Mode;
  setPhoto: (uri: string) => void;
  setMask: (uri: string) => void;
  setPose: (p: PoseResult) => void;
  setMode: (m: Mode) => void;
}

export const useAppStore = create<AppState>((set) => ({
  photoUri: null,
  maskUri: null,
  pose: null,
  mode: 'top',
  setPhoto: (uri) => set({ photoUri: uri }),
  setMask: (uri) => set({ maskUri: uri }),
  setPose: (p) => set({ pose: p }),
  setMode: (m) => set({ mode: m }),
}));
  • Step 2: 라이브 피팅 화면 UI

lapie/src/screens/LiveFittingScreen.tsx:

import { useRef } from 'react';
import { View, Text, TouchableOpacity, StyleSheet, Dimensions } from 'react-native';
import { Camera, useCameraDevice } from 'react-native-vision-camera';
import { GestureDetector } from 'react-native-gesture-handler';
import { useAppStore, Mode } from '../store/useAppStore';
import { CompositeView } from '../features/composite/CompositeView';
import { useCameraSnapshotUri } from '../features/composite/useCameraFrameUri';
import { usePinchScale } from '../features/autoScale/usePinchScale';

const { width, height } = Dimensions.get('window');

export function LiveFittingScreen({ onCapture }: { onCapture: (uri: string) => void }) {
  const { photoUri, maskUri, mode, setMode } = useAppStore();
  const cameraRef = useRef<Camera>(null);
  const device = useCameraDevice('back');
  const cameraUri = useCameraSnapshotUri(cameraRef, 100);
  const { scale, tx, ty, gesture, reset } = usePinchScale(1);

  if (!photoUri || !maskUri || !device) return <Text>준비 ...</Text>;

  return (
    <View style={styles.container}>
      <Camera ref={cameraRef} device={device} isActive={true} photo={true} style={styles.hiddenCam} />
      <GestureDetector gesture={gesture}>
        <View style={{ width, height: height * 0.8 }}>
          <CompositeView
            baseImageUri={photoUri}
            maskImageUri={maskUri}  // mode 별 마스크는 Task 6 결과로 미리 생성
            cameraFrameUri={cameraUri}
            cameraTransform={{ scale, tx, ty }}
            width={width}
            height={height * 0.8}
          />
        </View>
      </GestureDetector>

      <View style={styles.controls}>
        {(['top', 'bottom', 'full'] as Mode[]).map(m => (
          <TouchableOpacity key={m} onPress={() => { setMode(m); reset(); }}
            style={[styles.modeBtn, mode === m && styles.modeBtnActive]}>
            <Text>{m === 'top' ? '상의' : m === 'bottom' ? '하의' : '전신'}</Text>
          </TouchableOpacity>
        ))}
        <TouchableOpacity style={styles.captureBtn}
          onPress={async () => {
            const snap = await cameraRef.current?.takePhoto();
            if (snap) onCapture('file://' + snap.path);
          }}>
          <Text style={{ color: '#fff' }}>캡쳐</Text>
        </TouchableOpacity>
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, backgroundColor: '#000' },
  hiddenCam: { width: 1, height: 1, opacity: 0 },
  controls: { flexDirection: 'row', justifyContent: 'space-around', padding: 20 },
  modeBtn: { padding: 12, backgroundColor: '#333', borderRadius: 8 },
  modeBtnActive: { backgroundColor: '#fff' },
  captureBtn: { padding: 12, backgroundColor: '#e74c3c', borderRadius: 24, paddingHorizontal: 24 },
});
  • Step 3: 모드 변경 시 마스크 재생성 hook

lapie/src/features/maskSplit/useModeMask.ts:

import { useEffect } from 'react';
import { useAppStore } from '../../store/useAppStore';
import { computeSplitRegions } from './splitMask';
import { generateRegionMask } from './generateRegionMask'; // 다음 Step에서 추가

export function useModeMask() {
  const { photoUri, pose, mode, setMask } = useAppStore();
  useEffect(() => {
    if (!photoUri || !pose) return;
    (async () => {
      const regions = computeSplitRegions(pose, { width: 1080, height: 1920 });
      const region = regions[mode];
      const regionMaskUri = await generateRegionMask(photoUri, region);
      setMask(regionMaskUri);
    })();
  }, [photoUri, pose, mode, setMask]);
}
  • Step 4: 영역 마스크 생성 (Skia로 사각형 마스크 PNG 생성)

lapie/src/features/maskSplit/generateRegionMask.ts:

import { Skia, ImageFormat } from '@shopify/react-native-skia';
import * as FileSystem from 'expo-file-system';
import type { Region } from './splitMask';
import { segmentPerson } from '../segmentation';

// person mask와 region을 AND → 해당 영역의 person 실루엣만 흰색
export async function generateRegionMask(photoUri: string, region: Region): Promise<string> {
  const personMaskUri = await segmentPerson(photoUri);
  const personMask = await Skia.Data.fromURI(personMaskUri).then(d => Skia.Image.MakeImageFromEncoded(d));
  if (!personMask) throw new Error('person mask load failed');

  const w = personMask.width();
  const h = personMask.height();
  const surface = Skia.Surface.MakeOffscreen(w, h)!;
  const canvas = surface.getCanvas();

  // 검은 배경
  canvas.drawColor(Skia.Color('black'));
  // region 안에서만 person mask 그리기 (clip)
  canvas.save();
  canvas.clipRect({ x: region.xStart, y: region.yStart, width: region.xEnd - region.xStart, height: region.yEnd - region.yStart }, 0, true);
  canvas.drawImage(personMask, 0, 0);
  canvas.restore();

  const snap = surface.makeImageSnapshot();
  const bytes = snap.encodeToBytes(ImageFormat.PNG);
  const path = FileSystem.cacheDirectory + `region_mask_${Date.now()}.png`;
  await FileSystem.writeAsStringAsync(path, Buffer.from(bytes).toString('base64'), { encoding: FileSystem.EncodingType.Base64 });
  return path;
}
  • Step 5: 의존성 추가 + 커밋
npx expo install expo-file-system
npm install buffer
git add src/screens/LiveFittingScreen.tsx src/store src/features/maskSplit
git commit -m "feat: live fitting screen with mode switching and capture"

Task 10: 캡쳐 결과 + 공유 화면

Files:

  • Create: lapie/src/screens/CaptureResultScreen.tsx

  • Step 1: 결과 + 공유 UI

lapie/src/screens/CaptureResultScreen.tsx:

import { View, Image, Button, StyleSheet, Share, Alert } from 'react-native';
import * as MediaLibrary from 'expo-media-library';

export function CaptureResultScreen({ imageUri, onRetry }: { imageUri: string; onRetry: () => void }) {
  async function saveToGallery() {
    const { status } = await MediaLibrary.requestPermissionsAsync();
    if (status !== 'granted') { Alert.alert('권한 필요'); return; }
    await MediaLibrary.saveToLibraryAsync(imageUri);
    Alert.alert('저장 완료');
  }
  async function shareImage() {
    await Share.share({ url: imageUri, message: 'Lapie로 입어봤어요!' });
  }
  return (
    <View style={styles.container}>
      <Image source={{ uri: imageUri }} style={styles.preview} />
      <View style={styles.buttons}>
        <Button title="다시 찍기" onPress={onRetry} />
        <Button title="갤러리 저장" onPress={saveToGallery} />
        <Button title="공유" onPress={shareImage} />
      </View>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, padding: 20, paddingTop: 60 },
  preview: { flex: 1, resizeMode: 'contain' },
  buttons: { flexDirection: 'row', justifyContent: 'space-around', paddingVertical: 20 },
});
  • Step 2: 의존성 추가 + 커밋
npx expo install expo-media-library
git add src/screens/CaptureResultScreen.tsx
git commit -m "feat: capture result screen with save and share"

Task 11: 네비게이션 + 온보딩 + App 통합

Files:

  • Create: lapie/src/navigation/AppNavigator.tsx

  • Create: lapie/src/screens/OnboardingScreen.tsx

  • Modify: lapie/App.tsx

  • Step 1: 온보딩 3장

lapie/src/screens/OnboardingScreen.tsx:

import { useState } from 'react';
import { View, Text, Button, StyleSheet, Image } from 'react-native';

const SLIDES = [
  { title: '내 사진 한 장으로', body: '전신 정면 사진을 등록하면 자동으로 옷 영역을 인식합니다.' },
  { title: '옷을 카메라에', body: '카메라 앞에 옷을 들면 사진 속 내가 그 옷을 입은 듯 보입니다.' },
  { title: '캡쳐 후 공유', body: '결과를 친구에게 보내고 의견을 들어보세요.' },
];

export function OnboardingScreen({ onDone }: { onDone: () => void }) {
  const [i, setI] = useState(0);
  const slide = SLIDES[i];
  return (
    <View style={styles.container}>
      <Text style={styles.title}>{slide.title}</Text>
      <Text style={styles.body}>{slide.body}</Text>
      <Button
        title={i < SLIDES.length - 1 ? '다음' : '시작'}
        onPress={() => i < SLIDES.length - 1 ? setI(i + 1) : onDone()}
      />
    </View>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1, justifyContent: 'center', padding: 40 },
  title: { fontSize: 28, fontWeight: 'bold', marginBottom: 12 },
  body: { fontSize: 16, color: '#555', marginBottom: 40 },
});
  • Step 2: 간단 네비게이션 (라이브러리 없이 state)

lapie/App.tsx:

import { useState } from 'react';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { OnboardingScreen } from './src/screens/OnboardingScreen';
import { PhotoCaptureScreen } from './src/screens/PhotoCaptureScreen';
import { LiveFittingScreen } from './src/screens/LiveFittingScreen';
import { CaptureResultScreen } from './src/screens/CaptureResultScreen';
import { useAppStore } from './src/store/useAppStore';
import { detectPose } from './src/features/pose';

type Screen = 'onboarding' | 'photo' | 'live' | 'result';

export default function App() {
  const [screen, setScreen] = useState<Screen>('onboarding');
  const [capturedUri, setCapturedUri] = useState<string>('');
  const setPhoto = useAppStore(s => s.setPhoto);
  const setPose = useAppStore(s => s.setPose);

  return (
    <GestureHandlerRootView style={{ flex: 1 }}>
      {screen === 'onboarding' && <OnboardingScreen onDone={() => setScreen('photo')} />}
      {screen === 'photo' && (
        <PhotoCaptureScreen onPhotoReady={async (uri) => {
          setPhoto(uri);
          const pose = await detectPose(uri);
          setPose(pose);
          setScreen('live');
        }} />
      )}
      {screen === 'live' && (
        <LiveFittingScreen onCapture={(uri) => { setCapturedUri(uri); setScreen('result'); }} />
      )}
      {screen === 'result' && (
        <CaptureResultScreen imageUri={capturedUri} onRetry={() => setScreen('live')} />
      )}
    </GestureHandlerRootView>
  );
}
  • Step 3: 전체 동작 manual test

    • 앱 실행 → 온보딩 3장 → 사진 등록 → 자세 검증 → 라이브 피팅 → 모드 변경 → 캡쳐 → 저장·공유
    • 박재오 본인 실사용 1회 완주
  • Step 4: 커밋

git add App.tsx src/screens/OnboardingScreen.tsx
git commit -m "feat: integrate all screens with simple state-based navigation"

W5~6 (버퍼) — 자동 스케일 보강 + 마케팅 콘텐츠

Task 12: 자동 스케일 정확도 보강

Files: lapie/src/features/autoScale/detectClothBounds.ts 보강

  • Step 1: 옷 윤곽 검출 네이티브 모듈 (iOS Vision Saliency)

    • VNGenerateAttentionBasedSaliencyImageRequest 또는 VNGenerateForegroundInstanceMaskRequest 사용
    • 카메라 프레임에서 손에 들린 옷의 saliency mask → bbox 추출
    • calculateScale에 cameraClothWidthPx 전달
  • Step 2: 실기기 정확도 측정

    • 옷 5종 × 거리 3종 (20cm / 50cm / 100cm) = 15케이스
    • 자동 스케일 후 핀치 보정량 측정 → 평균 보정 비율 < 20% 목표
    • 미달 시 보정 알고리즘 재설계
  • Step 3: 커밋

git add src/features/autoScale
git commit -m "feat: cloth bounds detection via Vision Saliency"

Task 13: 마케팅 콘텐츠 5편

Files: 코드 없음 (인스타 / 쇼츠)

  • Step 1: Lapie 인스타 계정 정식 운영 개시

    • Task 0.1 Step 3에서 선점한 핸들로 프로필 설정
    • 첫 게시물: "왜 Lapie를 만들었나" (박재오 D-3 Why 외면 분기 1줄)
  • Step 2: 사용 영상 5편 촬영·편집·업로드

    • 박재오 본인 + 아내 사용 영상 각 2편
    • 친구 1명 사용 영상 1편
    • 인스타 릴스 + 유튜브 쇼츠 동시 업로드
  • Step 3: 사업-hedgy75-인스타 채널에 빌더 일지 카드뉴스 1건

    • "1인 개발자가 4주 만에 만든 피팅 앱 — 박재오 빌드 일지" 컨셉
  • Step 4: 매직 넘버 추적 시작

    • 정체성-Why-탐색 PMF 매직넘버 #7 원칙 적용
    • 선행 지표: 주간 콘텐츠 발행 수 + 영상 조회수
    • 후행 지표: 다운로드 수 + DAU
  • Step 5: 1차 데이터 수집 (2주)

    • 2주 후 사업-Lapie-피팅앱 변경 이력에 KPI 1줄 기록
    • 반응 약하면 v1 보류 + Why 검증대 재평가

Self-Review (작성 후 점검)

Spec coverage 체크

  • "정면 전신 사진 등록 + 자동 마스킹" → Task 3 + 5 + 6
  • "상의/하의/전신 3모드" → Task 6 (computeSplitRegions) + Task 9 (모드 UI)
  • "카메라 실행 + 합성" → Task 2 + 7 + 9
  • "자동 스케일 (하이브리드)" → Task 8 + 12
  • "캡쳐 + SNS 공유" → Task 10
  • "자세 가이드 오버레이" → Task 5
  • "온보딩 3장" → Task 11
  • "W1W4 일정 + W56 버퍼" → 일정 헤더와 task 라벨에 매핑

Placeholder scan

  • ⚠ Task 8 Step 4 detectClothWidthPx는 v0 1차 출시에서는 return 0 폴백으로 명시. Task 12에서 정식 구현. 의도된 점진적 개선이므로 placeholder 아님.
  • ⚠ Task 7 Step 2 Skia Shader children 패턴은 SDK 버전 의존 → 착수 시 문서 재확인 명시. 명시적 책임 이관.
  • 나머지는 모든 코드 단계에 실제 코드 포함.

Type consistency

  • PoseResult (Task 4) → Task 5 (validatePose) → Task 6 (computeSplitRegions) 모두 동일 타입 사용.
  • Region (Task 6) → Task 9 (generateRegionMask) 동일.
  • ScaleResult / ScaleInput (Task 8) 일관.
  • Store Mode = 'top' | 'bottom' | 'full' (Task 9) → computeSplitRegions 반환 키와 일치.

박재오 환경 특이점 (이미 plan에 명시)

  • Windows 환경 → mac 접근 방안 Pre-Task 0.2에서 결정
  • git이 wiki에는 없지만 코드 워크스페이스(lapie/)는 git init
  • 7월 착수 직전 본 plan 재검토 — 사용자 명시: "다음에 수정하면서 디벨롭"

관련 링크 / 교차참조

출처

  • raw/2026-05-23-Lapie-피팅앱-브리핑-원본.md — 본 plan의 design 출발점
  • 본 plan 자체는 superpowers:writing-plans 스킬 결과 (2026-05-23)

변경 이력

  • 2026-05-23: plan 신설. Pre-Task 2 + W1W4 본 Task 9 + W56 버퍼 Task 2 = 총 13 task. 핵심 알고리즘(자동스케일·마스크분할·자세검증)은 TDD 단위 테스트 포함. UI·카메라·합성은 manual test 절차 명시. Windows 환경에서 RN iOS 빌드 불가 이슈 Pre-Task 0.2에 명시. 7월 착수 직전 재검토 전제.