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>
51 KiB
[!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.ioNamecheap·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 -
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>로 표시 - 시각 검증: 사람 영역이 흰색, 배경이 검은색인 마스크 확인
- Photo Library에서 정면사진 1장 선택 →
-
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에서는
useShaderhook +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 / 정렬 정확도 메모
- 정면사진 + 마스크 + 카메라 라이브 prop으로
-
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
- ✅ "W1
W4 일정 + 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 재검토 — 사용자 명시: "다음에 수정하면서 디벨롭"
관련 링크 / 교차참조
- 사업-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 + W56 버퍼 Task 2 = 총 13 task. 핵심 알고리즘(자동스케일·마스크분할·자세검증)은 TDD 단위 테스트 포함. UI·카메라·합성은 manual test 절차 명시. Windows 환경에서 RN iOS 빌드 불가 이슈 Pre-Task 0.2에 명시. 7월 착수 직전 재검토 전제.