feat(ui): UI 화면 골격 + store + composite/maskSplit hook (v0-plan Task 9/10/11)

새 의존성:
- 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) <noreply@anthropic.com>
This commit is contained in:
2026-05-25 16:38:44 +09:00
parent 69d61231b9
commit 1081a3a85e
18 changed files with 561 additions and 24 deletions

64
App.tsx
View File

@@ -1,20 +1,56 @@
import type { ReactElement } from 'react';
import { useState } from 'react';
import { StatusBar } from 'expo-status-bar'; 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<Screen>('onboarding');
const [capturedUri, setCapturedUri] = useState<string>('');
const setPhoto = useAppStore((s) => s.setPhoto);
const setPose = useAppStore((s) => s.setPose);
export default function App() {
return ( return (
<View style={styles.container}> <GestureHandlerRootView style={{ flex: 1 }}>
<Text>Open up App.tsx to start working on your app!</Text>
<StatusBar style="auto" /> <StatusBar style="auto" />
</View> {screen === 'onboarding' && (
<OnboardingScreen onDone={() => setScreen('photo')} />
)}
{screen === 'photo' && (
<PhotoCaptureScreen
onPhotoReady={async (uri) => {
setPhoto(uri);
try {
const pose = await detectPose(uri);
setPose(pose);
} catch {
// Mac에서 PoseModule 빌드 전까지는 pose 없이 진행 (LiveFitting에서 mask 생성 실패)
}
setScreen('live');
}}
/>
)}
{screen === 'live' && (
<LiveFittingScreen
onCapture={(uri) => {
setCapturedUri(uri);
setScreen('result');
}}
/>
)}
{screen === 'result' && (
<CaptureResultScreen
imageUri={capturedUri}
onRetry={() => setScreen('live')}
/>
)}
</GestureHandlerRootView>
); );
} }
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: '#fff',
alignItems: 'center',
justifyContent: 'center',
},
});

54
package-lock.json generated
View File

@@ -10,6 +10,9 @@
"dependencies": { "dependencies": {
"@shopify/react-native-skia": "2.6.2", "@shopify/react-native-skia": "2.6.2",
"expo": "~56.0.4", "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", "expo-status-bar": "~56.0.4",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.85.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": { "node_modules/expo-modules-autolinking": {
"version": "56.0.12", "version": "56.0.12",
"resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.12.tgz", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-56.0.12.tgz",
@@ -4900,16 +4944,6 @@
"react-native": "*" "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": { "node_modules/expo/node_modules/expo-font": {
"version": "56.0.5", "version": "56.0.5",
"resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.5.tgz", "resolved": "https://registry.npmjs.org/expo-font/-/expo-font-56.0.5.tgz",

View File

@@ -14,6 +14,9 @@
"dependencies": { "dependencies": {
"@shopify/react-native-skia": "2.6.2", "@shopify/react-native-skia": "2.6.2",
"expo": "~56.0.4", "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", "expo-status-bar": "~56.0.4",
"react": "19.2.3", "react": "19.2.3",
"react-native": "0.85.3", "react-native": "0.85.3",

View File

@@ -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. <Canvas> + <Fill> 위에 RuntimeEffect 적용
* 3. child shader 전달 패턴 — v2에서는 <ImageShader image={...} rect={rect(...)}>
* 또는 useShader hook + Skia.ImageShader.Make 패턴 (Skia v2 문서 재확인)
* 4. cameraTransform.scale / tx / ty를 ImageShader의 rect 또는 fit 옵션에 적용
*/
export function CompositeView(_props: CompositeViewProps): ReactElement {
return <View />;
}

View File

@@ -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);

View File

@@ -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<string | null>(null);
return uri;
}

View File

@@ -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<string> {
throw new Error('generateRegionMask: Mac 실기기 구현 대기 (v0-plan Task 9 Step 4)');
}

View File

@@ -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]);
}

View File

@@ -0,0 +1,14 @@
import { NativeModules } from 'react-native';
interface SegmentationNativeModule {
segmentPerson: (imageUri: string) => Promise<string>;
}
export async function segmentPerson(imageUri: string): Promise<string> {
const SegmentationModule = (NativeModules as Record<string, SegmentationNativeModule | undefined>)
.SegmentationModule;
if (!SegmentationModule?.segmentPerson) {
throw new Error('SegmentationModule not linked');
}
return SegmentationModule.segmentPerson(imageUri);
}

View File

View File

@@ -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<void> {
const { status } = await MediaLibrary.requestPermissionsAsync();
if (status !== 'granted') {
Alert.alert('권한 필요', '갤러리 저장 권한이 필요합니다.');
return;
}
await MediaLibrary.saveToLibraryAsync(imageUri);
Alert.alert('저장 완료');
}
async function shareImage(): Promise<void> {
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 },
});

View File

@@ -0,0 +1,109 @@
import type { ReactElement } from 'react';
import { Dimensions, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
import { GestureDetector } from 'react-native-gesture-handler';
import { useAppStore } from '../store/useAppStore';
import type { Mode } from '../store/useAppStore';
import { CompositeView } from '../features/composite/CompositeView';
import { useCameraSnapshotUri } from '../features/composite/useCameraFrameUri';
import { usePinchScale } from '../features/autoScale/usePinchScale';
import { useModeMask } from '../features/maskSplit/useModeMask';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const COMPOSITE_HEIGHT = SCREEN_HEIGHT * 0.8;
const MODES: readonly { key: Mode; label: string }[] = [
{ key: 'top', label: '상의' },
{ key: 'bottom', label: '하의' },
{ key: 'full', label: '전신' },
];
interface Props {
onCapture: (uri: string) => void;
}
/**
* 라이브 피팅 화면 — UI 골격.
*
* Mac 실기기에서 채울 부분 (v0-plan Task 9 Step 2 + v5 API 재설계):
* - vision-camera v5 Camera 컴포넌트 + usePhotoOutput + usePreviewOutput
* - useRef<CameraRef> + photoOutput.capturePhoto() 패턴
* - Frame Processor를 통한 카메라 프레임 → Skia 합성
*
* 현재 골격 동작:
* - mode 전환 / pinch+pan gesture / Composite view 영역 / 캡쳐 버튼 UI는 작동
* - 실제 카메라 합성과 photo 캡쳐는 Mac 검증 후 활성화
*/
export function LiveFittingScreen({ onCapture }: Props): ReactElement {
const photoUri = useAppStore((s) => s.photoUri);
const maskUri = useAppStore((s) => s.maskUri);
const mode = useAppStore((s) => s.mode);
const setMode = useAppStore((s) => s.setMode);
const cameraUri = useCameraSnapshotUri(100);
const { scale, tx, ty, gesture, reset } = usePinchScale(1);
useModeMask();
if (!photoUri) {
return (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}> </Text>
</View>
);
}
function capture(): void {
// Mac TODO: photoOutput.capturePhoto() → file:// URI → onCapture
onCapture(photoUri ?? '');
}
return (
<View style={styles.container}>
<GestureDetector gesture={gesture}>
<View style={{ width: SCREEN_WIDTH, height: COMPOSITE_HEIGHT }}>
{maskUri && (
<CompositeView
baseImageUri={photoUri}
maskImageUri={maskUri}
cameraFrameUri={cameraUri}
cameraTransform={{ scale, tx, ty }}
width={SCREEN_WIDTH}
height={COMPOSITE_HEIGHT}
/>
)}
</View>
</GestureDetector>
<View style={styles.controls}>
{MODES.map(({ key, label }) => (
<TouchableOpacity
key={key}
onPress={() => {
setMode(key);
reset();
}}
style={[styles.modeBtn, mode === key && styles.modeBtnActive]}
>
<Text style={mode === key ? styles.modeTextActive : styles.modeText}>{label}</Text>
</TouchableOpacity>
))}
<TouchableOpacity style={styles.captureBtn} onPress={capture}>
<Text style={styles.captureText}></Text>
</TouchableOpacity>
</View>
</View>
);
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#000' },
loadingContainer: { flex: 1, justifyContent: 'center', alignItems: 'center', backgroundColor: '#000' },
loadingText: { color: '#fff' },
controls: { flexDirection: 'row', justifyContent: 'space-around', alignItems: 'center', padding: 20 },
modeBtn: { paddingVertical: 12, paddingHorizontal: 16, backgroundColor: '#333', borderRadius: 8 },
modeBtnActive: { backgroundColor: '#fff' },
modeText: { color: '#fff' },
modeTextActive: { color: '#000', fontWeight: 'bold' },
captureBtn: { paddingVertical: 12, paddingHorizontal: 24, backgroundColor: '#e74c3c', borderRadius: 24 },
captureText: { color: '#fff', fontWeight: 'bold' },
});

View File

@@ -0,0 +1,54 @@
import type { ReactElement } from 'react';
import { useState } from 'react';
import { Button, StyleSheet, Text, View } from 'react-native';
interface Slide {
title: string;
body: string;
}
const SLIDES: readonly Slide[] = [
{
title: '내 사진 한 장으로',
body: '전신 정면 사진을 등록하면 자동으로 옷 영역을 인식합니다.',
},
{
title: '옷을 카메라에',
body: '카메라 앞에 옷을 들면 사진 속 내가 그 옷을 입은 듯 보입니다.',
},
{
title: '캡쳐 후 공유',
body: '결과를 친구에게 보내고 의견을 들어보세요.',
},
];
interface Props {
onDone: () => void;
}
export function OnboardingScreen({ onDone }: Props): ReactElement {
const [index, setIndex] = useState(0);
const slide = SLIDES[index];
if (!slide) return <View />;
const isLast = index >= SLIDES.length - 1;
return (
<View style={styles.container}>
<Text style={styles.title}>{slide.title}</Text>
<Text style={styles.body}>{slide.body}</Text>
<Text style={styles.progress}>{index + 1} / {SLIDES.length}</Text>
<Button
title={isLast ? '시작' : '다음'}
onPress={() => (isLast ? onDone() : setIndex(index + 1))}
/>
</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, lineHeight: 24 },
progress: { fontSize: 12, color: '#888', marginBottom: 24, textAlign: 'center' },
});

View File

@@ -0,0 +1,82 @@
import type { ReactElement } from 'react';
import { useState } from 'react';
import { Button, Image, StyleSheet, Text, View } from 'react-native';
import * as ImagePicker from 'expo-image-picker';
import { detectPose } from '../features/pose';
import { validatePose } from '../features/photoValidation/validatePose';
import type { ValidationResult } from '../features/photoValidation/validatePose';
interface Props {
onPhotoReady: (uri: string) => void;
}
export function PhotoCaptureScreen({ onPhotoReady }: Props): ReactElement {
const [photoUri, setPhotoUri] = useState<string | null>(null);
const [validation, setValidation] = useState<ValidationResult | null>(null);
const [loading, setLoading] = useState(false);
async function pickAndValidate(): Promise<void> {
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.8,
});
if (result.canceled) return;
const asset = result.assets[0];
if (!asset) return;
setPhotoUri(asset.uri);
setLoading(true);
try {
const pose = await detectPose(asset.uri);
const v = validatePose(pose);
setValidation(v);
} catch (e) {
setValidation({
score: 0,
passed: false,
reasons: ['자세 검출 실패 (Mac 실기기 + PoseModule 필요)'],
});
} finally {
setLoading(false);
}
}
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} />}
{loading && <Text style={styles.loading}> </Text>}
{validation && (
<View style={styles.result}>
<Text>: {validation.score} / 100</Text>
{validation.reasons.map((r) => (
<Text key={r} style={styles.warn}> {r}</Text>
))}
{validation.passed && photoUri && (
<View style={styles.nextButton}>
<Button title="다음" onPress={() => onPhotoReady(photoUri)} />
</View>
)}
</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, lineHeight: 22 },
preview: { width: 200, height: 400, alignSelf: 'center', marginVertical: 20 },
loading: { textAlign: 'center', color: '#888', marginTop: 12 },
result: { marginTop: 12 },
warn: { color: '#c00', marginVertical: 4 },
nextButton: { marginTop: 12 },
});

View File

28
src/store/useAppStore.ts Normal file
View File

@@ -0,0 +1,28 @@
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;
reset: () => 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 }),
reset: () => set({ photoUri: null, maskUri: null, pose: null, mode: 'top' }),
}));