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:
64
App.tsx
64
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<Screen>('onboarding');
|
||||
const [capturedUri, setCapturedUri] = useState<string>('');
|
||||
const setPhoto = useAppStore((s) => s.setPhoto);
|
||||
const setPose = useAppStore((s) => s.setPose);
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
<Text>Open up App.tsx to start working on your app!</Text>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<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
54
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
27
src/features/composite/CompositeView.tsx
Normal file
27
src/features/composite/CompositeView.tsx
Normal 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 />;
|
||||
}
|
||||
26
src/features/composite/shaders.ts
Normal file
26
src/features/composite/shaders.ts
Normal 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);
|
||||
22
src/features/composite/useCameraFrameUri.ts
Normal file
22
src/features/composite/useCameraFrameUri.ts
Normal 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;
|
||||
}
|
||||
22
src/features/maskSplit/generateRegionMask.ts
Normal file
22
src/features/maskSplit/generateRegionMask.ts
Normal 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)');
|
||||
}
|
||||
39
src/features/maskSplit/useModeMask.ts
Normal file
39
src/features/maskSplit/useModeMask.ts
Normal 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]);
|
||||
}
|
||||
14
src/features/segmentation/index.ts
Normal file
14
src/features/segmentation/index.ts
Normal 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);
|
||||
}
|
||||
41
src/screens/CaptureResultScreen.tsx
Normal file
41
src/screens/CaptureResultScreen.tsx
Normal 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 },
|
||||
});
|
||||
109
src/screens/LiveFittingScreen.tsx
Normal file
109
src/screens/LiveFittingScreen.tsx
Normal 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' },
|
||||
});
|
||||
54
src/screens/OnboardingScreen.tsx
Normal file
54
src/screens/OnboardingScreen.tsx
Normal 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' },
|
||||
});
|
||||
82
src/screens/PhotoCaptureScreen.tsx
Normal file
82
src/screens/PhotoCaptureScreen.tsx
Normal 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 },
|
||||
});
|
||||
28
src/store/useAppStore.ts
Normal file
28
src/store/useAppStore.ts
Normal 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' }),
|
||||
}));
|
||||
Reference in New Issue
Block a user