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 { 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
54
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
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