From 1081a3a85e1cfd4b28953ee6c8af01785a4b5114 Mon Sep 17 00:00:00 2001 From: gahusb Date: Mon, 25 May 2026 16:38:44 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui):=20UI=20=ED=99=94=EB=A9=B4=20=EA=B3=A8?= =?UTF-8?q?=EA=B2=A9=20+=20store=20+=20composite/maskSplit=20hook=20(v0-pl?= =?UTF-8?q?an=20Task=209/10/11)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 새 의존성: - expo-image-picker ~56.0.13 (PhotoCapture) - expo-media-library ~56.0.6 (CaptureResult 저장) - expo-file-system ~56.0.7 (generateRegionMask 파일 저장 — Mac 구현 시) 새 파일: - src/store/useAppStore.ts: zustand store (photoUri/maskUri/pose/mode + reset) - src/features/segmentation/index.ts: SegmentationModule TS wrapper (Mac Swift 빌드 대기) - src/features/composite/shaders.ts: Skia RuntimeEffect 셰이더 소스 (마스크 흰색 → 카메라, 그 외 → 베이스) - src/features/composite/CompositeView.tsx: 합성 컴포넌트 골격 (Skia v2 API Mac 검증 대기) - src/features/composite/useCameraFrameUri.ts: 카메라 프레임 URI hook 골격 (vision-camera v5 API Mac 검증 대기) - src/features/maskSplit/generateRegionMask.ts: region 마스크 생성 골격 (Skia v2 API Mac 검증 대기) - src/features/maskSplit/useModeMask.ts: mode 전환 시 마스크 자동 재생성 hook + cleanup race 처리 - src/screens/OnboardingScreen.tsx: 3장 슬라이드 (정상 동작) - src/screens/PhotoCaptureScreen.tsx: 갤러리 선택 + 자세 검증 (PoseModule Mac 빌드 후 동작) - src/screens/CaptureResultScreen.tsx: 결과 + 저장 + 공유 (expo-media-library) - src/screens/LiveFittingScreen.tsx: 모드 전환 + 핀치/팬 + Composite 골격 (Camera는 v5 API Mac 검증 대기) - App.tsx: 4화면 state-based navigation (onboarding → photo → live → result) + GestureHandlerRootView 감싸기 핵심 결정: - vision-camera v5는 v4와 완전 다른 Nitro 기반 (takeSnapshot 미존재 / usePhotoOutput/usePreviewOutput hook 패턴). v0-plan v4 코드 그대로 옮길 수 없어 Camera 부분 골격 + Mac TODO 명시. - Skia v2 child shader API도 v1과 다를 가능성 → CompositeView 골격 + Mac TODO. - 모드 전환/핀치 줌/네비게이션/store/검증 로직은 골격 단계에서 정상 동작. 검증 (Windows 가능 범위): - npx tsc --noEmit: 무에러 - npm test: 6 suites / 17 tests passed (UI 골격 추가가 기존 테스트 영향 없음 확인) Mac에서 채울 부분 (정리): - vision-camera v5 Camera + photoOutput + previewOutput 통합 - Skia v2 RuntimeEffect child shader 패턴 (ImageShader / useShader) - expo-file-system v56 writeAsStringAsync(base64) 동작 확인 - generateRegionMask 본 구현 (Surface.MakeOffscreen + clipRect + encode PNG) - 실기기 manual test 5종 (각 화면 전환 + 핀치 줌 + 캡쳐 + 저장 + 공유) Co-Authored-By: Claude Opus 4.7 (1M context) --- App.tsx | 64 ++++++++--- package-lock.json | 54 +++++++-- package.json | 3 + src/features/composite/.gitkeep | 0 src/features/composite/CompositeView.tsx | 27 +++++ src/features/composite/shaders.ts | 26 +++++ src/features/composite/useCameraFrameUri.ts | 22 ++++ src/features/maskSplit/generateRegionMask.ts | 22 ++++ src/features/maskSplit/useModeMask.ts | 39 +++++++ src/features/segmentation/.gitkeep | 0 src/features/segmentation/index.ts | 14 +++ src/screens/.gitkeep | 0 src/screens/CaptureResultScreen.tsx | 41 +++++++ src/screens/LiveFittingScreen.tsx | 109 +++++++++++++++++++ src/screens/OnboardingScreen.tsx | 54 +++++++++ src/screens/PhotoCaptureScreen.tsx | 82 ++++++++++++++ src/store/.gitkeep | 0 src/store/useAppStore.ts | 28 +++++ 18 files changed, 561 insertions(+), 24 deletions(-) delete mode 100644 src/features/composite/.gitkeep create mode 100644 src/features/composite/CompositeView.tsx create mode 100644 src/features/composite/shaders.ts create mode 100644 src/features/composite/useCameraFrameUri.ts create mode 100644 src/features/maskSplit/generateRegionMask.ts create mode 100644 src/features/maskSplit/useModeMask.ts delete mode 100644 src/features/segmentation/.gitkeep create mode 100644 src/features/segmentation/index.ts delete mode 100644 src/screens/.gitkeep create mode 100644 src/screens/CaptureResultScreen.tsx create mode 100644 src/screens/LiveFittingScreen.tsx create mode 100644 src/screens/OnboardingScreen.tsx create mode 100644 src/screens/PhotoCaptureScreen.tsx delete mode 100644 src/store/.gitkeep create mode 100644 src/store/useAppStore.ts diff --git a/App.tsx b/App.tsx index 0329d0c..b874a2f 100644 --- a/App.tsx +++ b/App.tsx @@ -1,20 +1,56 @@ +import type { ReactElement } from 'react'; +import { useState } from 'react'; import { StatusBar } from 'expo-status-bar'; -import { StyleSheet, Text, View } from 'react-native'; +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(): ReactElement { + const [screen, setScreen] = useState('onboarding'); + const [capturedUri, setCapturedUri] = useState(''); + const setPhoto = useAppStore((s) => s.setPhoto); + const setPose = useAppStore((s) => s.setPose); -export default function App() { return ( - - Open up App.tsx to start working on your app! + - + {screen === 'onboarding' && ( + setScreen('photo')} /> + )} + {screen === 'photo' && ( + { + setPhoto(uri); + try { + const pose = await detectPose(uri); + setPose(pose); + } catch { + // Mac에서 PoseModule 빌드 전까지는 pose 없이 진행 (LiveFitting에서 mask 생성 실패) + } + setScreen('live'); + }} + /> + )} + {screen === 'live' && ( + { + setCapturedUri(uri); + setScreen('result'); + }} + /> + )} + {screen === 'result' && ( + setScreen('live')} + /> + )} + ); } - -const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#fff', - alignItems: 'center', - justifyContent: 'center', - }, -}); diff --git a/package-lock.json b/package-lock.json index 6cfe216..a7be389 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,9 @@ "dependencies": { "@shopify/react-native-skia": "2.6.2", "expo": "~56.0.4", + "expo-file-system": "~56.0.7", + "expo-image-picker": "~56.0.13", + "expo-media-library": "~56.0.6", "expo-status-bar": "~56.0.4", "react": "19.2.3", "react-native": "0.85.3", @@ -4529,6 +4532,47 @@ } } }, + "node_modules/expo-file-system": { + "version": "56.0.7", + "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz", + "integrity": "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, + "node_modules/expo-image-loader": { + "version": "56.0.3", + "resolved": "https://registry.npmjs.org/expo-image-loader/-/expo-image-loader-56.0.3.tgz", + "integrity": "sha512-JgUo4fUeU1ZC+z8iBFj8v7yoGQnZrLbOVPyNE+DWVrld55F2F6R1ck+rmdm/8TNWLz1LhNQfD7c3XYP1ZikxXA==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-image-picker": { + "version": "56.0.13", + "resolved": "https://registry.npmjs.org/expo-image-picker/-/expo-image-picker-56.0.13.tgz", + "integrity": "sha512-eLO6t3jTRE2tOmCGR6tQIYAdvxj66KanyRmIOH/aISx5Zb4AG5B5VHuOXx9+1T5PtNAoXwsHML0G5+vB4OgI3w==", + "license": "MIT", + "dependencies": { + "expo-image-loader": "~56.0.3" + }, + "peerDependencies": { + "expo": "*" + } + }, + "node_modules/expo-media-library": { + "version": "56.0.6", + "resolved": "https://registry.npmjs.org/expo-media-library/-/expo-media-library-56.0.6.tgz", + "integrity": "sha512-UsyVcxP7Op9ErFFLW1xImjoKFgKi7XSw8hrCfzf2yIG+OgVb9dsQth0mVRPgfRxdELagsUslXc1QXTiW8dpbaQ==", + "license": "MIT", + "peerDependencies": { + "expo": "*", + "react-native": "*" + } + }, "node_modules/expo-modules-autolinking": { "version": "56.0.12", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.12.tgz", @@ -4900,16 +4944,6 @@ "react-native": "*" } }, - "node_modules/expo/node_modules/expo-file-system": { - "version": "56.0.7", - "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-56.0.7.tgz", - "integrity": "sha512-dcKzo8ShPloM7jgfnMcJStgQebhP8owVjCkNI/aX6NMFV1CYB8bxKGMdnzJ3mXk5nfaiW+F/lSKr2UIJ02WAUA==", - "license": "MIT", - "peerDependencies": { - "expo": "*", - "react-native": "*" - } - }, "node_modules/expo/node_modules/expo-font": { "version": "56.0.5", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.5.tgz", diff --git a/package.json b/package.json index a23371c..aa51aeb 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,9 @@ "dependencies": { "@shopify/react-native-skia": "2.6.2", "expo": "~56.0.4", + "expo-file-system": "~56.0.7", + "expo-image-picker": "~56.0.13", + "expo-media-library": "~56.0.6", "expo-status-bar": "~56.0.4", "react": "19.2.3", "react-native": "0.85.3", diff --git a/src/features/composite/.gitkeep b/src/features/composite/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/composite/CompositeView.tsx b/src/features/composite/CompositeView.tsx new file mode 100644 index 0000000..f71b306 --- /dev/null +++ b/src/features/composite/CompositeView.tsx @@ -0,0 +1,27 @@ +import type { ReactElement } from 'react'; +import { View } from 'react-native'; + +export interface CompositeViewProps { + baseImageUri: string; + maskImageUri: string; + cameraFrameUri: string | null; + cameraTransform: { scale: number; tx: number; ty: number }; + width: number; + height: number; +} + +/** + * Skia 합성 컴포넌트 — Mac 실기기 검증 필요. + * + * 본 commit은 골격(skeleton). 실제 합성 구현은 Mac에서 Skia v2 API 확정 후 작성. + * + * Mac에서 채울 구현 (v0-plan Task 7 Step 2 참조): + * 1. useImage(baseImageUri / maskImageUri / cameraFrameUri) + * 2. + 위에 RuntimeEffect 적용 + * 3. child shader 전달 패턴 — v2에서는 + * 또는 useShader hook + Skia.ImageShader.Make 패턴 (Skia v2 문서 재확인) + * 4. cameraTransform.scale / tx / ty를 ImageShader의 rect 또는 fit 옵션에 적용 + */ +export function CompositeView(_props: CompositeViewProps): ReactElement { + return ; +} diff --git a/src/features/composite/shaders.ts b/src/features/composite/shaders.ts new file mode 100644 index 0000000..e363462 --- /dev/null +++ b/src/features/composite/shaders.ts @@ -0,0 +1,26 @@ +import { Skia } from '@shopify/react-native-skia'; + +/** + * 합성 셰이더: 마스크가 흰색(>0.5)인 픽셀은 카메라 라이브 프레임으로, + * 나머지는 베이스 사진으로 채운다. + * + * Mac 실기기 검증 시: + * - Skia v2의 RuntimeEffect.Make API + child shader 전달 패턴 재확인 + * (plan은 v1 기준, v2에서 useShader hook으로 변경됐을 수 있음 → 문서 재확인) + */ +export const compositeShaderSource = ` +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) { + return cameraImage.eval(xy); + } + return base; +} +`; + +export const compositeRuntimeEffect = Skia.RuntimeEffect.Make(compositeShaderSource); diff --git a/src/features/composite/useCameraFrameUri.ts b/src/features/composite/useCameraFrameUri.ts new file mode 100644 index 0000000..32295d6 --- /dev/null +++ b/src/features/composite/useCameraFrameUri.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +/** + * 카메라 라이브 프레임을 주기적으로 jpeg 파일 URI로 추출. + * + * Mac 실기기에서 vision-camera v5 API로 본격 구현 필요. + * + * vision-camera v5는 v4와 완전 다른 Nitro 기반: + * - takeSnapshot 미존재 + * - usePhotoOutput / usePreviewOutput hook 패턴 + * - photoOutput.capturePhoto({ flashMode }, {}) 형태 + * + * Mac에서 작성할 구현 (v0-plan Task 7 Step 3 재설계): + * 1. usePhotoOutput으로 photoOutput 인스턴스 획득 + * 2. setInterval(intervalMs)마다 capturePhoto() → file:// URI → setUri + * 3. cleanup에서 interval clear + photo.dispose() + * 4. 30fps 필요하면 useFrameRenderer + Skia 직접 통합 (v0 W5 버퍼) + */ +export function useCameraSnapshotUri(_intervalMs: number = 100): string | null { + const [uri] = useState(null); + return uri; +} diff --git a/src/features/maskSplit/generateRegionMask.ts b/src/features/maskSplit/generateRegionMask.ts new file mode 100644 index 0000000..a0a0de9 --- /dev/null +++ b/src/features/maskSplit/generateRegionMask.ts @@ -0,0 +1,22 @@ +import type { Region } from './splitMask'; + +/** + * person mask(전체 실루엣) + region(상/하/전신 bbox) → 해당 영역의 person 실루엣만 흰색인 PNG 마스크 생성. + * + * Mac 실기기 검증 필요: + * - Skia v2의 Surface.MakeOffscreen / drawImage / clipRect API 확인 + * - expo-file-system v56 writeAsStringAsync(base64) 동작 + * - LiveFittingScreen 모드 전환 시 latency + * + * 본 commit은 골격. 실제 구현 (v0-plan Task 9 Step 4 참조): + * 1. segmentPerson(photoUri)로 전체 person mask 받음 + * 2. Skia.Surface.MakeOffscreen(w, h) + canvas.drawColor(black) + clipRect(region) + drawImage(personMask) + * 3. surface.makeImageSnapshot().encodeToBytes(PNG) → expo-file-system으로 저장 + * 4. file:// URI 반환 + */ +export async function generateRegionMask( + _photoUri: string, + _region: Region, +): Promise { + throw new Error('generateRegionMask: Mac 실기기 구현 대기 (v0-plan Task 9 Step 4)'); +} diff --git a/src/features/maskSplit/useModeMask.ts b/src/features/maskSplit/useModeMask.ts new file mode 100644 index 0000000..2553ef7 --- /dev/null +++ b/src/features/maskSplit/useModeMask.ts @@ -0,0 +1,39 @@ +import { useEffect } from 'react'; +import { useAppStore } from '../../store/useAppStore'; +import { computeSplitRegions } from './splitMask'; +import { generateRegionMask } from './generateRegionMask'; + +/** + * photoUri + pose + mode가 바뀔 때마다 해당 모드의 region mask를 비동기로 생성하여 store에 반영. + * + * Mac 실기기 검증 필요: + * - generateRegionMask 실제 구현 후 동작 확인 + * - mode 빠른 전환 시 race condition (마지막 mode만 반영되도록 cleanup 보장) + */ +export function useModeMask(): void { + const photoUri = useAppStore((s) => s.photoUri); + const pose = useAppStore((s) => s.pose); + const mode = useAppStore((s) => s.mode); + const setMask = useAppStore((s) => s.setMask); + + useEffect(() => { + if (!photoUri || !pose) return; + let cancelled = false; + + (async () => { + try { + // photoUri의 실제 이미지 크기를 알 수 없으므로 1080×1920 가정 (v0) + const regions = computeSplitRegions(pose, { width: 1080, height: 1920 }); + const region = regions[mode]; + const regionMaskUri = await generateRegionMask(photoUri, region); + if (!cancelled) setMask(regionMaskUri); + } catch { + // generateRegionMask 미구현 또는 실패 — Mac 검증 시점에 처리 + } + })(); + + return () => { + cancelled = true; + }; + }, [photoUri, pose, mode, setMask]); +} diff --git a/src/features/segmentation/.gitkeep b/src/features/segmentation/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/features/segmentation/index.ts b/src/features/segmentation/index.ts new file mode 100644 index 0000000..a1c7e4a --- /dev/null +++ b/src/features/segmentation/index.ts @@ -0,0 +1,14 @@ +import { NativeModules } from 'react-native'; + +interface SegmentationNativeModule { + segmentPerson: (imageUri: string) => Promise; +} + +export async function segmentPerson(imageUri: string): Promise { + const SegmentationModule = (NativeModules as Record) + .SegmentationModule; + if (!SegmentationModule?.segmentPerson) { + throw new Error('SegmentationModule not linked'); + } + return SegmentationModule.segmentPerson(imageUri); +} diff --git a/src/screens/.gitkeep b/src/screens/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/screens/CaptureResultScreen.tsx b/src/screens/CaptureResultScreen.tsx new file mode 100644 index 0000000..411542e --- /dev/null +++ b/src/screens/CaptureResultScreen.tsx @@ -0,0 +1,41 @@ +import type { ReactElement } from 'react'; +import { Alert, Button, Image, Share, StyleSheet, View } from 'react-native'; +import * as MediaLibrary from 'expo-media-library'; + +interface Props { + imageUri: string; + onRetry: () => void; +} + +export function CaptureResultScreen({ imageUri, onRetry }: Props): ReactElement { + async function saveToGallery(): Promise { + const { status } = await MediaLibrary.requestPermissionsAsync(); + if (status !== 'granted') { + Alert.alert('권한 필요', '갤러리 저장 권한이 필요합니다.'); + return; + } + await MediaLibrary.saveToLibraryAsync(imageUri); + Alert.alert('저장 완료'); + } + + async function shareImage(): Promise { + await Share.share({ url: imageUri, message: 'Lapie로 입어봤어요!' }); + } + + return ( + + + +