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

1469 lines
51 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> [!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)**
- KIPRIS (http://www.kipris.or.kr) "Lapie" 검색 — 의류·앱 류
- USPTO (https://tmsearch.uspto.gov) "Lapie" 검색
- 선출원 발견 시 → 네이밍 재검토
- [ ] **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 가입**
- https://developer.apple.com/programs/ $99/yr
- 7월 착수 직전 가입 (그 전에 가입하면 1년 카운트가 일찍 시작됨)
---
## 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`
- [x] **Step 1: Expo 프로젝트 생성** ✅ 2026-05-24 (SDK 56, Windows 임시폴더 → 파일 복사 방식)
```bash
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 작업
```bash
npx expo prebuild --platform ios
```
Expected: `lapie/ios/` 디렉터리 생성, CocoaPods install. (Windows에서는 prebuild 실패 가능 — mac 환경 또는 EAS에서 실행).
- [x] **Step 3: 필수 의존성 설치** ✅ 2026-05-24 (vision-camera v5, skia v2.6, worklets-core, zustand, gesture-handler, safe-area-context, jest-expo 등)
```bash
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
```
- [x] **Step 4: 권한 설정 (`app.json`)** ✅ 2026-05-24 (iOS NSCamera/Photo/PhotoAdd 3종 + bundleIdentifier com.lapie.app)
```json
{
"expo": {
"name": "Lapie",
"slug": "lapie",
"ios": {
"bundleIdentifier": "com.lapie.app",
"infoPlist": {
"NSCameraUsageDescription": "옷을 카메라에 비춰 합성 미리보기에 사용합니다.",
"NSPhotoLibraryUsageDescription": "정면 사진을 등록하고 캡쳐를 저장하기 위해 사용합니다.",
"NSPhotoLibraryAddUsageDescription": "합성 결과 사진을 저장하기 위해 사용합니다."
}
}
}
}
```
- [ ] **Step 5: 첫 빌드 확인** ⏸ Mac 작업 (`npx expo run:ios`)
```bash
npx expo run:ios
```
Expected: iOS 시뮬레이터에 기본 화면 표시.
- [x] **Step 6: Git 초기화 + 첫 커밋** ✅ 2026-05-24 (Gitea SSH remote 연결 + ED25519 키 + 2 commits push)
```bash
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`:
```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`:
```tsx
import { CameraTestScreen } from './src/screens/CameraTestScreen';
export default function App() {
return <CameraTestScreen />;
}
```
- [ ] **Step 3: 실기기 빌드 + 검증** (시뮬레이터는 카메라 없음)
```bash
npx expo run:ios --device
```
Expected: 실기기에서 후방 카메라 실시간 프리뷰 표시.
- [ ] **Step 4: 커밋**
```bash
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`:
```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`:
```objc
#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`:
```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`:
```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: 테스트 실행 (실패 → 통과)**
```bash
npx jest src/features/segmentation
```
Expected: 1 passed (모킹된 호출 검증).
- [ ] **Step 6: 실기기 manual test**
- Photo Library에서 정면사진 1장 선택 → `segmentPerson(uri)` 호출
- 반환된 mask URI를 `<Image>`로 표시
- 시각 검증: 사람 영역이 흰색, 배경이 검은색인 마스크 확인
- [ ] **Step 7: CocoaPods 설치 + 커밋**
```bash
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`:
```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`:
```objc
#import <React/RCTBridgeModule.h>
@interface RCT_EXTERN_MODULE(PoseModule, NSObject)
RCT_EXTERN_METHOD(detectPose:(NSString *)imageUri
resolver:(RCTPromiseResolveBlock)resolve
rejecter:(RCTPromiseRejectBlock)reject)
@end
```
- [x] **Step 3: TS 래퍼 + 타입** ✅ 2026-05-24 (Keypoint/Joint/PoseResult + NativeModules wrapper, 매번 조회 패턴)
`lapie/src/features/pose/index.ts`:
```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);
}
```
- [x] **Step 4: 유닛 테스트 (모킹)** ✅ 2026-05-24 (1 passed, jest-expo 호이스팅 충돌 회피 — NativeModules 직접 할당 패턴 사용)
`lapie/src/features/pose/__tests__/pose.test.ts`:
```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: 커밋**
```bash
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`
- [x] **Step 1: 자세 검증 함수 (TDD)** ✅ 2026-05-24 — RED 단계 (Cannot find module → 실패 확인)
`lapie/src/features/photoValidation/__tests__/validatePose.test.ts`:
```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('몸이 기울어져 있습니다');
});
});
```
- [x] **Step 2: 실행 → 실패 확인** ✅ 2026-05-24
```bash
npx jest src/features/photoValidation
```
Expected: FAIL — validatePose not defined.
- [x] **Step 3: 구현** ✅ 2026-05-24 (한국어 격조사 정확화: '어깨가/골반이/발목이')
`lapie/src/features/photoValidation/validatePose.ts`:
```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 };
}
```
- [x] **Step 4: 테스트 통과 확인** ✅ 2026-05-24 — 3 passed
```bash
npx jest src/features/photoValidation
```
Expected: 3 passed.
- [ ] **Step 5: 사진 등록 화면 (UI)** ⏸ Task C에서 진행 + Mac 실기기 검증
`lapie/src/screens/PhotoCaptureScreen.tsx`:
```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)
```bash
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로 처리.
- [x] **Step 1: 분할 로직 테스트 (TDD)** ✅ 2026-05-24 — RED 단계
`lapie/src/features/maskSplit/__tests__/splitMask.test.ts`:
```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);
});
});
```
- [x] **Step 2: 구현** ✅ 2026-05-24 (requireJoint helper로 invariant explicit — `!` 안티패턴 회피)
`lapie/src/features/maskSplit/splitMask.ts`:
```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 },
};
}
```
- [x] **Step 3: 테스트 통과 확인** ✅ 2026-05-24 — 4 passed (top/bottom/full y + x 범위 별도)
```bash
npx jest src/features/maskSplit
```
Expected: 3 passed.
- [x] **Step 4: 커밋** ✅ 2026-05-24 — `8ec53d9 feat(maskSplit): computeSplitRegions TDD (v0-plan Task 6)`
```bash
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`:
```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`:
```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`:
```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: 커밋**
```bash
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`
- [x] **Step 1: 스케일 계산 테스트 (TDD)** ✅ 2026-05-25 — 6 케이스 (정상비율/0폴백/음수폴백/상한클램핑/하한클램핑/클램핑X)
`lapie/src/features/autoScale/__tests__/calculateScale.test.ts`:
```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);
});
});
```
- [x] **Step 2: 구현** ✅ 2026-05-25 (MIN_SCALE/MAX_SCALE export — usePinchScale에서 재사용)
`lapie/src/features/autoScale/calculateScale.ts`:
```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 };
}
```
- [x] **Step 3: 테스트 통과** ✅ 2026-05-25 — 6 passed
```bash
npx jest src/features/autoScale/calculateScale
```
Expected: 3 passed.
- [x] **Step 4: 옷 경계 검출 (단순화: 카메라 프레임에서 person mask 제외 영역의 bbox)** ✅ 2026-05-25 — v0 폴백 함수 (항상 0 반환) + 회귀 테스트 2 케이스. 정식 구현은 Task 12 (Vision Saliency)
`lapie/src/features/autoScale/detectClothBounds.ts`:
```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 검증.
- [x] **Step 5: 핀치 줌 hook** ✅ 2026-05-25 — `.runOnJS(true)` 명시로 reanimated 의존 회피, Mac 실기기 검증 대기
`lapie/src/features/autoScale/usePinchScale.ts`:
```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 };
}
```
- [x] **Step 6: 의존성 + 커밋** ✅ 2026-05-25 — gesture-handler는 W1 Task 1에서 이미 설치, `005d612 feat(autoScale): detectClothBounds 폴백 + usePinchScale hook`
```bash
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`:
```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`:
```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`:
```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`:
```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: 의존성 추가 + 커밋**
```bash
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`:
```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: 의존성 추가 + 커밋**
```bash
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`:
```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`:
```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: 커밋**
```bash
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: 커밋**
```bash
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
- ✅ "W1~W4 일정 + W5~6 버퍼" → 일정 헤더와 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 재검토 — 사용자 명시: "다음에 수정하면서 디벨롭"
---
## 관련 링크 / 교차참조
- [[사업-Lapie-피팅앱]] — design 본 문서 (양방향)
- [[정체성-Why-탐색]] — Why 검증대, PMF 매직넘버 원칙 (Task 13)
- [[사업-hedgy75-인스타]] — Task 13 마케팅 시너지
- [[자산-인프라-NAS-GPU]] — v1 클라우드 저장 (이 plan 외)
- [[기술-스택-역량]] — RN + iOS Swift 신규 학습 영역
## 출처
- `raw/2026-05-23-Lapie-피팅앱-브리핑-원본.md` — 본 plan의 design 출발점
- 본 plan 자체는 superpowers:writing-plans 스킬 결과 (2026-05-23)
## 변경 이력
- 2026-05-23: plan 신설. Pre-Task 2 + W1~W4 본 Task 9 + W5~6 버퍼 Task 2 = 총 13 task. 핵심 알고리즘(자동스케일·마스크분할·자세검증)은 TDD 단위 테스트 포함. UI·카메라·합성은 manual test 절차 명시. Windows 환경에서 RN iOS 빌드 불가 이슈 Pre-Task 0.2에 명시. 7월 착수 직전 재검토 전제.