diff --git a/src/layout/RootLayout.tsx b/src/layout/RootLayout.tsx index ac06d0866..d7846e1a7 100644 --- a/src/layout/RootLayout.tsx +++ b/src/layout/RootLayout.tsx @@ -1,19 +1,8 @@ -import { useEffect } from 'react'; +import { Outlet } from 'react-router-dom'; -import { Outlet, useLocation } from 'react-router-dom'; - -import { ROUTES } from '@/routes/paths'; import { useScrollToTop } from '@/shared/hooks/useScrollToTop'; -import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; -import { preloadONNXModel } from '@pages/generate/hooks/useOnnxModel'; - -const GENERATE_WARMUP_PATHS = [ - ROUTES.GENERATE, - ROUTES.GENERATE_RESULT, - ROUTES.GENERATE_START, - ROUTES.IMAGE_SETUP, -]; +import { useGenerateWarmup } from '@pages/generate/hooks/useGenerateWarmup'; function RootLayout() { // 라우트/쿼리/해시/키 변화와 초기 마운트 시 스크롤 최상단으로 이동 @@ -26,19 +15,4 @@ function RootLayout() { ); } -function useGenerateWarmup() { - const location = useLocation(); - - useEffect(() => { - const pathname = location.pathname; - const shouldWarmup = GENERATE_WARMUP_PATHS.some( - (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) - ); - - if (!shouldWarmup) return; - - preloadONNXModel(OBJ365_MODEL_PATH).catch(() => undefined); - }, [location.pathname]); -} - export default RootLayout; diff --git a/src/pages/generate/apis/furniture.ts b/src/pages/generate/apis/furniture.ts index 0c11905e7..856004264 100644 --- a/src/pages/generate/apis/furniture.ts +++ b/src/pages/generate/apis/furniture.ts @@ -13,12 +13,17 @@ import type { // 생성 이미지 카테고리 조회 API 호출 export const getGeneratedImageCategories = async ( imageId: number, - detectedObjects: FurnitureCategoryCode[] + detectedObjects?: FurnitureCategoryCode[] ) => { + const query = + detectedObjects && detectedObjects.length > 0 + ? { detectedObjects } + : undefined; + return request({ method: HTTPMethod.GET, url: API_ENDPOINT.GENERATE.CURATION_CATEGORIES(imageId), - query: { detectedObjects }, + ...(query ? { query } : {}), }); }; diff --git a/src/pages/generate/constants/curationDetectionMode.ts b/src/pages/generate/constants/curationDetectionMode.ts new file mode 100644 index 000000000..0cba2d701 --- /dev/null +++ b/src/pages/generate/constants/curationDetectionMode.ts @@ -0,0 +1,22 @@ +export type CurationDetectionMode = 'server' | 'client'; + +export const CURATION_DETECTION_MODE = ( + import.meta.env.VITE_CURATION_DETECTION_MODE === 'client' + ? 'client' + : 'server' +) satisfies CurationDetectionMode; + +export const IS_CLIENT_DETECTION_ENABLED = CURATION_DETECTION_MODE === 'client'; + +export const getCategoryQueryDetectedObjects = (detectedObjects: T[]) => + IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined; + +export const isCategoryQueryEnabled = ( + imageId: number | null, + detectedObjectsCount: number +) => + imageId !== null && + (!IS_CLIENT_DETECTION_ENABLED || detectedObjectsCount > 0); + +export const shouldShowDetectionPending = (detectedObjectsCount: number) => + IS_CLIENT_DETECTION_ENABLED && detectedObjectsCount === 0; diff --git a/src/pages/generate/hooks/useFurnitureCuration.ts b/src/pages/generate/hooks/useFurnitureCuration.ts index fd5d2a8a6..66e5723fe 100644 --- a/src/pages/generate/hooks/useFurnitureCuration.ts +++ b/src/pages/generate/hooks/useFurnitureCuration.ts @@ -10,6 +10,10 @@ import { getGeneratedImageCategories, getGeneratedImageProducts, } from '@pages/generate/apis/furniture'; +import { + getCategoryQueryDetectedObjects, + isCategoryQueryEnabled, +} from '@pages/generate/constants/curationDetectionMode'; import { useCurationCacheStore } from '@pages/generate/stores/useCurationCacheStore'; import { useCurationStore, @@ -107,7 +111,9 @@ export const useGeneratedCategoriesQuery = ( ); const canUseGroupInitialData = groupId !== null && + imageId !== null && groupCategoriesEntry !== null && + groupCategoriesEntry.imageId === imageId && groupCategoriesEntry.detectionSignature === detectionSignature; const categoriesQueryKey: CategoriesQueryKey = [ @@ -135,8 +141,11 @@ export const useGeneratedCategoriesQuery = ( // queryKey에 이미지/감지값 전체를 직접 포함해 의존성 유지 queryKey: categoriesQueryKey, queryFn: () => - getGeneratedImageCategories(imageId!, normalizedDetectedObjects), - enabled: Boolean(imageId) && normalizedDetectedObjects.length > 0, + getGeneratedImageCategories( + imageId!, + getCategoryQueryDetectedObjects(normalizedDetectedObjects) + ), + enabled: isCategoryQueryEnabled(imageId, normalizedDetectedObjects.length), staleTime: 15 * 60 * 1000, gcTime: 30 * 60 * 1000, ...(initialCategoriesResponse @@ -146,11 +155,13 @@ export const useGeneratedCategoriesQuery = ( useEffect(() => { if (groupId === null) return; + if (imageId === null) return; if (!query.data) return; const existing = useCurationCacheStore.getState().groups[groupId]?.categories ?? null; if ( existing && + existing.imageId === imageId && existing.detectionSignature === detectionSignature && existing.response === query.data ) { @@ -158,12 +169,14 @@ export const useGeneratedCategoriesQuery = ( } saveGroupCategories({ groupId, + imageId, response: query.data, detectedObjects: normalizedDetectedObjects, detectionSignature, }); }, [ groupId, + imageId, query.data, detectionSignature, normalizedDetectedObjects, @@ -174,7 +187,7 @@ export const useGeneratedCategoriesQuery = ( // 카테고리 자동 선택 제거 // - 기본값은 선택 해제 상태 유지 // - 현재 선택이 더 이상 유효하지 않다면 null 로 초기화 - if (!imageId) return; + if (imageId === null) return; if (!query.data) { if (selectedCategoryId !== null) selectCategory(imageId, null); return; @@ -226,7 +239,10 @@ export const useGeneratedProductsQuery = ( ]; const initialProductsResponse = - groupId !== null && productCacheEntry + groupId !== null && + imageId !== null && + productCacheEntry && + productCacheEntry.imageId === imageId ? productCacheEntry.response : undefined; @@ -239,7 +255,7 @@ export const useGeneratedProductsQuery = ( // queryKey에 그룹/이미지/카테고리 식별자를 직접 배치 queryKey: productsQueryKey, queryFn: () => getGeneratedImageProducts(imageId!, categoryId!), - enabled: Boolean(imageId) && categoryId !== null, + enabled: imageId !== null && categoryId !== null, staleTime: 5 * 60 * 1000, gcTime: 30 * 60 * 1000, ...(initialProductsResponse @@ -248,19 +264,20 @@ export const useGeneratedProductsQuery = ( }); useEffect(() => { - if (groupId === null || categoryId === null) return; + if (groupId === null || imageId === null || categoryId === null) return; if (!query.data) return; const groupCache = useCurationCacheStore.getState().groups[groupId]; const existing = groupCache?.products[categoryId] ?? null; - if (existing?.response === query.data) { + if (existing?.imageId === imageId && existing.response === query.data) { return; } saveGroupProducts({ groupId, + imageId, categoryId, response: query.data, }); - }, [groupId, categoryId, query.data, saveGroupProducts]); + }, [groupId, imageId, categoryId, query.data, saveGroupProducts]); return query; }; diff --git a/src/pages/generate/hooks/useFurnitureHotspots.ts b/src/pages/generate/hooks/useFurnitureHotspots.ts index c9b97d22a..bcfdfe757 100644 --- a/src/pages/generate/hooks/useFurnitureHotspots.ts +++ b/src/pages/generate/hooks/useFurnitureHotspots.ts @@ -130,7 +130,7 @@ export function useFurnitureHotspots( runInference, isLoading, error: modelError, - } = useONNXModel(OBJ365_MODEL_PATH); + } = useONNXModel(OBJ365_MODEL_PATH, { enabled }); const prefetchedDetections = options?.prefetchedDetections ?? null; const onInferenceComplete = options?.onInferenceComplete; const inferenceCompleteRef = diff --git a/src/pages/generate/hooks/useGenerateWarmup.ts b/src/pages/generate/hooks/useGenerateWarmup.ts new file mode 100644 index 000000000..b398e23ba --- /dev/null +++ b/src/pages/generate/hooks/useGenerateWarmup.ts @@ -0,0 +1,35 @@ +import { useEffect } from 'react'; + +import { useLocation } from 'react-router-dom'; + +import { ROUTES } from '@/routes/paths'; + +import { IS_CLIENT_DETECTION_ENABLED } from '@pages/generate/constants/curationDetectionMode'; +import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; + +import { preloadONNXModel } from './useOnnxModel'; + +const GENERATE_WARMUP_PATHS = [ROUTES.GENERATE, ROUTES.IMAGE_SETUP]; + +const useGenerateWarmupClient = () => { + const location = useLocation(); + + useEffect(() => { + const pathname = location.pathname; + const shouldWarmup = GENERATE_WARMUP_PATHS.some( + (prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`) + ); + + if (!shouldWarmup) return; + + preloadONNXModel(OBJ365_MODEL_PATH).catch(() => undefined); + }, [location.pathname]); +}; + +const useGenerateWarmupServer = () => { + // 서버 모드 no-op 훅 +}; + +export const useGenerateWarmup = IS_CLIENT_DETECTION_ENABLED + ? useGenerateWarmupClient + : useGenerateWarmupServer; diff --git a/src/pages/generate/hooks/useOnnxModel.ts b/src/pages/generate/hooks/useOnnxModel.ts index 6e5b9c9d0..93414d37e 100644 --- a/src/pages/generate/hooks/useOnnxModel.ts +++ b/src/pages/generate/hooks/useOnnxModel.ts @@ -218,7 +218,12 @@ export const preloadONNXModel = async ( * - 640×640 렌더링 텐서를 입력으로 받아 감지 결과를 반환 * - 추론 결과는 후속 파이프라인(`useFurnitureHotspots`)에서 원본 좌표로 보정 */ -export function useONNXModel(modelPath: string) { +interface UseONNXModelOptions { + enabled?: boolean; +} + +export function useONNXModel(modelPath: string, options?: UseONNXModelOptions) { + const enabled = options?.enabled ?? true; const [session, setSession] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); @@ -226,6 +231,15 @@ export function useONNXModel(modelPath: string) { const ortRef = useRef(null); // onnxruntime-web 모듈 보관 useEffect(() => { + if (!enabled) { + setSession(null); + ortRef.current = null; + setIsLoading(false); + setError(null); + setProgress(0); + return; + } + if (typeof window === 'undefined') { setIsLoading(false); setError('브라우저 환경이 아닙니다'); @@ -260,7 +274,7 @@ export function useONNXModel(modelPath: string) { return () => { isMounted = false; }; - }, [modelPath]); + }, [enabled, modelPath]); const runInference = useCallback( async (imageElement: HTMLImageElement): Promise => { diff --git a/src/pages/generate/hooks/useStartPageModelPreload.ts b/src/pages/generate/hooks/useStartPageModelPreload.ts new file mode 100644 index 000000000..c3dfd4156 --- /dev/null +++ b/src/pages/generate/hooks/useStartPageModelPreload.ts @@ -0,0 +1,20 @@ +import { useEffect } from 'react'; + +import { IS_CLIENT_DETECTION_ENABLED } from '@pages/generate/constants/curationDetectionMode'; +import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; + +import { preloadONNXModel } from './useOnnxModel'; + +const useStartPageModelPreloadClient = () => { + useEffect(() => { + preloadONNXModel(OBJ365_MODEL_PATH).catch(() => undefined); + }, []); +}; + +const useStartPageModelPreloadServer = () => { + // 서버 모드 no-op 훅 +}; + +export const useStartPageModelPreload = IS_CLIENT_DETECTION_ENABLED + ? useStartPageModelPreloadClient + : useStartPageModelPreloadServer; diff --git a/src/pages/generate/pages/result/ResultPage.tsx b/src/pages/generate/pages/result/ResultPage.tsx index 25e2529f4..c15bea0cc 100644 --- a/src/pages/generate/pages/result/ResultPage.tsx +++ b/src/pages/generate/pages/result/ResultPage.tsx @@ -11,6 +11,7 @@ import type { import { createImageDetailPlaceholder } from '@/pages/mypage/utils/resultNavigation'; import Loading from '@components/loading/Loading'; +import { IS_CLIENT_DETECTION_ENABLED } from '@pages/generate/constants/curationDetectionMode'; import { useABTest } from '@pages/generate/hooks/useABTest'; import { useGetResultDataQuery } from '@pages/generate/hooks/useGenerate'; import { useCurationStore } from '@pages/generate/stores/useCurationStore'; @@ -215,6 +216,7 @@ const ResultPage = () => { { )} diff --git a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx index 5af0a4c46..584320963 100644 --- a/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx +++ b/src/pages/generate/pages/result/curationSheet/CurationSheet.tsx @@ -20,6 +20,7 @@ import { QUERY_KEY } from '@/shared/constants/queryKey'; import { useSavedItemsStore } from '@/store/useSavedItemsStore'; import { getGeneratedImageProducts } from '@pages/generate/apis/furniture'; +import { shouldShowDetectionPending } from '@pages/generate/constants/curationDetectionMode'; import CardProductItem from './CardProductItem'; import * as styles from './CurationSheet.css'; @@ -52,11 +53,7 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { const imageState = useActiveImageCurationState(); const selectedCategoryId = imageState?.selectedCategoryId ?? null; const selectCategory = useCurationStore((state) => state.selectCategory); - const detectedObjects = useMemo( - () => imageState?.detectedObjects ?? [], - [imageState?.detectedObjects] - ); - const hasDetectionCodes = detectedObjects.length > 0; + const detectedObjectsCount = imageState?.detectedObjects.length ?? 0; const navigate = useNavigate(); const { variant } = useABTest(); @@ -255,7 +252,7 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => { '상단 가구 필터에서 원하는 가구를 선택해 주세요' ); } - if (!hasDetectionCodes) { + if (shouldShowDetectionPending(detectedObjectsCount)) { return renderStatus('가구를 분석 중이에요', '잠시만 기다려 주세요'); } if (categoriesQuery.isLoading) { diff --git a/src/pages/generate/pages/start/StartPage.tsx b/src/pages/generate/pages/start/StartPage.tsx index f78b2cf9b..a94486633 100644 --- a/src/pages/generate/pages/start/StartPage.tsx +++ b/src/pages/generate/pages/start/StartPage.tsx @@ -1,5 +1,3 @@ -import { useEffect } from 'react'; - import { useNavigate } from 'react-router-dom'; import { useABTest } from '@/pages/generate/hooks/useABTest'; @@ -10,8 +8,7 @@ import TitleNavBar from '@/shared/components/navBar/TitleNavBar'; import { useUserStore } from '@/store/useUserStore'; import SignupImage from '@assets/icons/loginAfter.png'; -import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; -import { preloadONNXModel } from '@pages/generate/hooks/useOnnxModel'; +import { useStartPageModelPreload } from '@pages/generate/hooks/useStartPageModelPreload'; import * as styles from './StartPage.css.ts'; @@ -21,12 +18,7 @@ const StartPage = () => { const navigate = useNavigate(); const { variant } = useABTest(); - useEffect(() => { - // 이미지 생성 플로우 진입 시 모델 선로딩 - preloadONNXModel(OBJ365_MODEL_PATH).catch(() => { - // console.warn('[StartPage] preload model failed'); - }); - }, []); + useStartPageModelPreload(); const handleGoToImageSetup = () => { // 이미지 생성 시작 페이지 CTA 버튼 클릭 시 GA 이벤트 전송 diff --git a/src/pages/generate/stores/useCurationCacheStore.ts b/src/pages/generate/stores/useCurationCacheStore.ts index 62e00d3d4..ce76e77d0 100644 --- a/src/pages/generate/stores/useCurationCacheStore.ts +++ b/src/pages/generate/stores/useCurationCacheStore.ts @@ -8,6 +8,7 @@ import type { // 카테고리 응답과 감지 객체 집합을 묶어 저장 type CategoryCacheEntry = { + imageId: number; response: FurnitureCategoriesResponse; detectedObjects: FurnitureCategoryCode[]; detectionSignature: string; @@ -16,6 +17,7 @@ type CategoryCacheEntry = { // 카테고리별 추천 상품 응답 저장 type ProductCacheEntry = { + imageId: number; response: FurnitureProductsInfoResponse; updatedAt: number; }; @@ -34,12 +36,14 @@ interface CurationCacheStore { groups: Record; saveCategories: (params: { groupId: number; + imageId: number; response: FurnitureCategoriesResponse; detectedObjects: FurnitureCategoryCode[]; detectionSignature: string; }) => void; saveProducts: (params: { groupId: number; + imageId: number; categoryId: number; response: FurnitureProductsInfoResponse; }) => void; @@ -53,6 +57,7 @@ export const useCurationCacheStore = create()((set) => ({ groups: {}, saveCategories: ({ groupId, + imageId, response, detectedObjects, detectionSignature, @@ -66,6 +71,7 @@ export const useCurationCacheStore = create()((set) => ({ [groupId]: { ...prevGroup, categories: { + imageId, response, detectedObjects, detectionSignature, @@ -76,7 +82,7 @@ export const useCurationCacheStore = create()((set) => ({ }; }); }, - saveProducts: ({ groupId, categoryId, response }) => { + saveProducts: ({ groupId, imageId, categoryId, response }) => { if (!groupId || !categoryId) return; set((state) => { const prevGroup = state.groups[groupId] ?? createDefaultGroup(); @@ -88,6 +94,7 @@ export const useCurationCacheStore = create()((set) => ({ products: { ...prevGroup.products, [categoryId]: { + imageId, response, updatedAt: Date.now(), }, diff --git a/src/pages/mypage/hooks/detectionPrefetch.types.ts b/src/pages/mypage/hooks/detectionPrefetch.types.ts new file mode 100644 index 000000000..3fe389655 --- /dev/null +++ b/src/pages/mypage/hooks/detectionPrefetch.types.ts @@ -0,0 +1,10 @@ +export type PrefetchPriority = 'immediate' | 'background'; + +export interface DetectionPrefetchOptions { + priority?: PrefetchPriority; +} + +export interface PrefetchTask { + imageId: number; + imageUrl: string; +} diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.client.ts b/src/pages/mypage/hooks/useDetectionPrefetch.client.ts new file mode 100644 index 000000000..03cabd1f2 --- /dev/null +++ b/src/pages/mypage/hooks/useDetectionPrefetch.client.ts @@ -0,0 +1,333 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; +import { buildHotspotsPipeline } from '@pages/generate/hooks/furnitureHotspotPipeline'; +import { loadCorsImage } from '@pages/generate/hooks/useFurnitureHotspots'; +import { useONNXModel } from '@pages/generate/hooks/useOnnxModel'; +import { useDetectionCacheStore } from '@pages/generate/stores/useDetectionCacheStore'; +import { + filterAllowedDetectedObjects, + mapHotspotsToDetectedObjects, +} from '@pages/generate/utils/detectedObjectMapper'; + +import type { + DetectionPrefetchOptions, + PrefetchTask, +} from './detectionPrefetch.types'; +import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureCategoryMapping'; +import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots'; +import type { ProcessedDetections } from '@pages/generate/types/detection'; + +const PREFETCH_DELAY_MS = 120; +const MAX_CONCURRENCY = 1; +const IMAGE_LOAD_TIMEOUT_MS = 12_000; + +/** + * 외부 이미지 요소 로더 + * - crossOrigin 허용을 기본으로 시도 + * - 실패 시 에러를 상위로 전달 + */ +const loadImageElement = (url: string, signal?: AbortSignal) => + new Promise((resolve, reject) => { + const img = new Image(); + let settled = false; + img.crossOrigin = 'anonymous'; + img.decoding = 'async'; + const cleanup = () => { + clearTimeout(timeoutId); + img.onload = null; + img.onerror = null; + signal?.removeEventListener('abort', handleAbort); + }; + const finalizeReject = (reason: unknown) => { + if (settled) return; + settled = true; + cleanup(); + reject(reason); + }; + const handleAbort = () => { + img.src = ''; + finalizeReject(new DOMException('이미지 로드 취소', 'AbortError')); + }; + const timeoutId = window.setTimeout(() => { + img.src = ''; + finalizeReject( + new Error(`이미지 로드 타임아웃(${IMAGE_LOAD_TIMEOUT_MS}ms)`) + ); + }, IMAGE_LOAD_TIMEOUT_MS); + + if (signal?.aborted) { + handleAbort(); + return; + } + + signal?.addEventListener('abort', handleAbort, { once: true }); + img.onload = () => { + if (settled) return; + settled = true; + cleanup(); + resolve(img); + }; + img.onerror = (event) => { + finalizeReject( + event instanceof ErrorEvent + ? event.error + : new Error('이미지 로드 실패') + ); + }; + img.src = url; + }); + +const isAbortError = (value: unknown) => + value instanceof DOMException && value.name === 'AbortError'; + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +/** + * 감지(inference) 결과를 사전 계산해 캐시에 적재하는 훅 + * - 즉시(immediate) 요청과 백그라운드 큐를 분리해 성능 균형 유지 + */ +export const useDetectionPrefetchClient = () => { + const { runInference, isLoading, error } = useONNXModel(OBJ365_MODEL_PATH); + const setEntry = useDetectionCacheStore((state) => state.setEntry); + const modelStateRef = useRef({ isLoading, error }); + const isMountedRef = useRef(true); + const pendingRef = useRef>(new Set()); + const queueRef = useRef([]); + const drainingRef = useRef(false); + const activeCountRef = useRef(0); // 동시에 실행 중인 작업 수 + const waitersRef = useRef<(() => void)[]>([]); // 세마포어 대기열 + const inflightControllersRef = useRef>( + new Map() + ); + + // 세마포어 슬롯 확보 + const acquireSlot = useCallback(async () => { + if (activeCountRef.current < MAX_CONCURRENCY) { + activeCountRef.current += 1; + return; + } + await new Promise((resolve) => { + waitersRef.current.push(resolve); + }); + activeCountRef.current += 1; + }, []); + + // 세마포어 슬롯 반환 + const releaseSlot = useCallback(() => { + activeCountRef.current = Math.max(0, activeCountRef.current - 1); + const next = waitersRef.current.shift(); + if (next) { + next(); + } + }, []); + + // 공통 실행 래퍼: 동시 실행 상한 적용 + const runWithSemaphore = useCallback( + async (task: () => Promise) => { + await acquireSlot(); + try { + await task(); + } finally { + releaseSlot(); + } + }, + [acquireSlot, releaseSlot] + ); + + const storeDetections = useCallback( + ( + imageId: number, + imageUrl: string, + payload: ProcessedDetections, + extra?: { + hotspots?: FurnitureHotspot[]; + detectedObjects?: FurnitureCategoryCode[]; + } + ) => { + setEntry(imageId, { + imageUrl, + processedDetections: payload, + hotspots: extra?.hotspots ?? [], + detectedObjects: extra?.detectedObjects, + }); + }, + [setEntry] + ); + + const processAndStore = useCallback( + ( + imageId: number, + imageUrl: string, + targetImage: HTMLImageElement, + processed: ProcessedDetections + ) => { + const pipeline = buildHotspotsPipeline(targetImage, processed); + const rawDetectedObjects = mapHotspotsToDetectedObjects( + pipeline.hotspots + ); + const detectedObjects = filterAllowedDetectedObjects(rawDetectedObjects, { + stage: 'prefetch-detection', + imageId, + hotspotCount: pipeline.hotspots.length, + }); + + storeDetections(imageId, imageUrl, processed, { + hotspots: pipeline.hotspots, + detectedObjects, + }); + }, + [storeDetections] + ); + + const executePrefetch = useCallback( + async (imageId: number, imageUrl: string) => { + if (!imageId || !imageUrl) return; + if (pendingRef.current.has(imageId)) return; + const cached = useDetectionCacheStore.getState().images[imageId]; + if (cached) return; + if ( + modelStateRef.current.isLoading || + modelStateRef.current.error + ) { + return; + } + if (!isMountedRef.current) return; + + const controller = new AbortController(); + inflightControllersRef.current.set(imageId, controller); + pendingRef.current.add(imageId); + try { + let targetImage: HTMLImageElement | null = null; + try { + targetImage = await loadImageElement(imageUrl, controller.signal); + } catch { + if (controller.signal.aborted || !isMountedRef.current) return; + targetImage = await loadCorsImage(imageUrl, controller.signal); + } + if ( + !targetImage || + controller.signal.aborted || + !isMountedRef.current + ) { + return; + } + + try { + const result = await runInference(targetImage); + if (controller.signal.aborted || !isMountedRef.current) return; + processAndStore(imageId, imageUrl, targetImage, result); + return; + } catch (inferenceError) { + if (isAbortError(inferenceError)) return; + if ( + inferenceError instanceof DOMException && + inferenceError.name === 'SecurityError' + ) { + const corsImage = await loadCorsImage(imageUrl, controller.signal); + if ( + !corsImage || + controller.signal.aborted || + !isMountedRef.current + ) { + return; + } + const corsResult = await runInference(corsImage); + if (controller.signal.aborted || !isMountedRef.current) return; + processAndStore(imageId, imageUrl, corsImage, corsResult); + return; + } + console.warn('감지 프리페치 실패', inferenceError); + } + } catch (unexpectedError) { + if (isAbortError(unexpectedError)) return; + console.warn('감지 프리페치 예외', unexpectedError); + } finally { + inflightControllersRef.current.delete(imageId); + pendingRef.current.delete(imageId); + } + }, + [processAndStore, runInference] + ); + + // 백그라운드 큐를 순차로 소모해 모델 호출 폭주 방지 + const drainQueue = useCallback(async () => { + if (drainingRef.current) return; + drainingRef.current = true; + try { + while (queueRef.current.length > 0) { + if (modelStateRef.current.isLoading || modelStateRef.current.error) { + break; + } + const task = queueRef.current.shift(); + if (!task) continue; + await runWithSemaphore(async () => { + await executePrefetch(task.imageId, task.imageUrl); + await sleep(PREFETCH_DELAY_MS); // 감지 모델 연속 호출 완화 + }); + } + } finally { + drainingRef.current = false; + if ( + isMountedRef.current && + queueRef.current.length > 0 && + !modelStateRef.current.isLoading && + !modelStateRef.current.error + ) { + void drainQueue(); + } + } + }, [executePrefetch, runWithSemaphore]); + + const scheduleBackgroundPrefetch = useCallback( + (imageId: number, imageUrl: string) => { + if (!imageId || !imageUrl) return; + if (queueRef.current.some((task) => task.imageId === imageId)) return; + queueRef.current.push({ imageId, imageUrl }); + void drainQueue(); + }, + [drainQueue] + ); + + const prefetchDetection = useCallback( + (imageId: number, imageUrl: string, options?: DetectionPrefetchOptions) => { + const priority = options?.priority ?? 'background'; + if (priority === 'immediate') { + if (modelStateRef.current.isLoading || modelStateRef.current.error) { + scheduleBackgroundPrefetch(imageId, imageUrl); + return; + } + void runWithSemaphore(() => executePrefetch(imageId, imageUrl)); + return; + } + scheduleBackgroundPrefetch(imageId, imageUrl); + }, + [executePrefetch, runWithSemaphore, scheduleBackgroundPrefetch] + ); + + useEffect(() => { + modelStateRef.current = { isLoading, error }; + if (!isLoading && !error && queueRef.current.length > 0) { + void drainQueue(); + } + }, [isLoading, error, drainQueue]); + + useEffect(() => { + return () => { + isMountedRef.current = false; + queueRef.current = []; + pendingRef.current.clear(); + activeCountRef.current = 0; + inflightControllersRef.current.forEach((controller) => + controller.abort() + ); + inflightControllersRef.current.clear(); + waitersRef.current.splice(0).forEach((resolve) => resolve()); + }; + }, []); + + return { prefetchDetection }; +}; diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.server.ts b/src/pages/mypage/hooks/useDetectionPrefetch.server.ts new file mode 100644 index 000000000..54da42d25 --- /dev/null +++ b/src/pages/mypage/hooks/useDetectionPrefetch.server.ts @@ -0,0 +1,16 @@ +import { useCallback } from 'react'; + +import type { DetectionPrefetchOptions } from './detectionPrefetch.types'; + +export const useDetectionPrefetchServer = () => { + const prefetchDetection = useCallback( + ( + _imageId: number, + _imageUrl: string, + _options?: DetectionPrefetchOptions + ) => {}, + [] + ); + + return { prefetchDetection }; +}; diff --git a/src/pages/mypage/hooks/useDetectionPrefetch.ts b/src/pages/mypage/hooks/useDetectionPrefetch.ts index 1cd2b662f..61eacbb30 100644 --- a/src/pages/mypage/hooks/useDetectionPrefetch.ts +++ b/src/pages/mypage/hooks/useDetectionPrefetch.ts @@ -1,244 +1,8 @@ -import { useCallback, useRef } from 'react'; +import { IS_CLIENT_DETECTION_ENABLED } from '@pages/generate/constants/curationDetectionMode'; -import { OBJ365_MODEL_PATH } from '@pages/generate/constants/detection'; -import { buildHotspotsPipeline } from '@pages/generate/hooks/furnitureHotspotPipeline'; -import { loadCorsImage } from '@pages/generate/hooks/useFurnitureHotspots'; -import { useONNXModel } from '@pages/generate/hooks/useOnnxModel'; -import { useDetectionCacheStore } from '@pages/generate/stores/useDetectionCacheStore'; -import { - filterAllowedDetectedObjects, - mapHotspotsToDetectedObjects, -} from '@pages/generate/utils/detectedObjectMapper'; +import { useDetectionPrefetchClient } from './useDetectionPrefetch.client'; +import { useDetectionPrefetchServer } from './useDetectionPrefetch.server'; -import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureCategoryMapping'; -import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots'; -import type { ProcessedDetections } from '@pages/generate/types/detection'; - -const PREFETCH_DELAY_MS = 120; - -/** - * 외부 이미지 요소 로더 - * - crossOrigin 허용을 기본으로 시도 - * - 실패 시 에러를 상위로 전달 - */ -const loadImageElement = (url: string) => - new Promise((resolve, reject) => { - const img = new Image(); - img.crossOrigin = 'anonymous'; - img.decoding = 'async'; - img.onload = () => resolve(img); - img.onerror = (event) => - reject( - event instanceof ErrorEvent - ? event.error - : new Error('이미지 로드 실패') - ); - img.src = url; - }); - -const sleep = (ms: number) => - new Promise((resolve) => { - setTimeout(resolve, ms); - }); - -type PrefetchPriority = 'immediate' | 'background'; - -export interface DetectionPrefetchOptions { - priority?: PrefetchPriority; -} - -type PrefetchTask = { - imageId: number; - imageUrl: string; -}; - -const MAX_CONCURRENCY = 1; - -/** - * 감지(inference) 결과를 사전 계산해 캐시에 적재하는 훅 - * - 즉시(immediate) 요청과 백그라운드 큐를 분리해 성능 균형 유지 - */ -export const useDetectionPrefetch = () => { - const { runInference, isLoading, error } = useONNXModel(OBJ365_MODEL_PATH); - const setEntry = useDetectionCacheStore((state) => state.setEntry); - const pendingRef = useRef>(new Set()); - const queueRef = useRef([]); - const drainingRef = useRef(false); - const activeCountRef = useRef(0); // 동시에 실행 중인 작업 수 - const waitersRef = useRef<(() => void)[]>([]); // 세마포어 대기열 - - // 세마포어 슬롯 확보 - const acquireSlot = useCallback(async () => { - if (activeCountRef.current < MAX_CONCURRENCY) { - activeCountRef.current += 1; - return; - } - await new Promise((resolve) => { - waitersRef.current.push(resolve); - }); - activeCountRef.current += 1; - }, []); - - // 세마포어 슬롯 반환 - const releaseSlot = useCallback(() => { - activeCountRef.current = Math.max(0, activeCountRef.current - 1); - const next = waitersRef.current.shift(); - if (next) { - next(); - } - }, []); - - // 공통 실행 래퍼: 동시 실행 상한을 2개로 제한 - const runWithSemaphore = useCallback( - async (task: () => Promise) => { - await acquireSlot(); - try { - await task(); - } finally { - releaseSlot(); - } - }, - [acquireSlot, releaseSlot] - ); - - const storeDetections = useCallback( - ( - imageId: number, - imageUrl: string, - payload: ProcessedDetections, - extra?: { - hotspots?: FurnitureHotspot[]; - detectedObjects?: FurnitureCategoryCode[]; - } - ) => { - setEntry(imageId, { - imageUrl, - processedDetections: payload, - hotspots: extra?.hotspots ?? [], - detectedObjects: extra?.detectedObjects, - }); - }, - [setEntry] - ); - - const processAndStore = useCallback( - ( - imageId: number, - imageUrl: string, - targetImage: HTMLImageElement, - processed: ProcessedDetections - ) => { - const pipeline = buildHotspotsPipeline(targetImage, processed); - const rawDetectedObjects = mapHotspotsToDetectedObjects( - pipeline.hotspots - ); - const detectedObjects = filterAllowedDetectedObjects(rawDetectedObjects, { - stage: 'prefetch-detection', - imageId, - hotspotCount: pipeline.hotspots.length, - }); - - storeDetections(imageId, imageUrl, processed, { - hotspots: pipeline.hotspots, - detectedObjects, - }); - }, - [storeDetections] - ); - - const executePrefetch = useCallback( - async (imageId: number, imageUrl: string) => { - if (!imageId || !imageUrl) return; - if (pendingRef.current.has(imageId)) return; - const cached = useDetectionCacheStore.getState().images[imageId]; - if (cached) return; - if (isLoading || error) return; - - pendingRef.current.add(imageId); - try { - let targetImage: HTMLImageElement | null = null; - try { - targetImage = await loadImageElement(imageUrl); - } catch { - targetImage = await loadCorsImage(imageUrl); - } - if (!targetImage) return; - - try { - const result = await runInference(targetImage); - processAndStore(imageId, imageUrl, targetImage, result); - return; - } catch (inferenceError) { - if ( - inferenceError instanceof DOMException && - inferenceError.name === 'SecurityError' - ) { - const corsImage = await loadCorsImage(imageUrl); - if (!corsImage) return; - const corsResult = await runInference(corsImage); - processAndStore(imageId, imageUrl, corsImage, corsResult); - return; - } - console.warn('감지 프리페치 실패', inferenceError); - } - } catch (unexpectedError) { - console.warn('감지 프리페치 예외', unexpectedError); - } finally { - pendingRef.current.delete(imageId); - } - }, - [error, isLoading, processAndStore, runInference] - ); - - // 백그라운드 큐를 순차로 소모해 모델 호출 폭주 방지 - const drainQueue = useCallback(async () => { - if (drainingRef.current) return; - drainingRef.current = true; - try { - const jobs: Promise[] = []; - while (queueRef.current.length > 0) { - const task = queueRef.current.shift(); - if (!task) continue; - jobs.push( - runWithSemaphore(async () => { - await executePrefetch(task.imageId, task.imageUrl); - await sleep(PREFETCH_DELAY_MS); // 감지 모델 연속 호출 완화 - }) - ); - } - await Promise.all(jobs); - } finally { - drainingRef.current = false; - } - }, [executePrefetch, runWithSemaphore]); - - const scheduleBackgroundPrefetch = useCallback( - (imageId: number, imageUrl: string) => { - if (!imageId || !imageUrl) return; - if (queueRef.current.some((task) => task.imageId === imageId)) return; - queueRef.current.push({ imageId, imageUrl }); - void drainQueue(); - }, - [drainQueue] - ); - - const prefetchDetection = useCallback( - (imageId: number, imageUrl: string, options?: DetectionPrefetchOptions) => { - const priority = options?.priority ?? 'background'; - if (priority === 'immediate') { - void runWithSemaphore(() => executePrefetch(imageId, imageUrl)); - return; - } - scheduleBackgroundPrefetch(imageId, imageUrl); - }, - [executePrefetch, runWithSemaphore, scheduleBackgroundPrefetch] - ); - - /** - * 감지 프리패치 트리거 - * - 반환 객체를 통해 외부에서 우선순위를 선택적으로 제어 - */ - return { - prefetchDetection, - }; -}; +export const useDetectionPrefetch = IS_CLIENT_DETECTION_ENABLED + ? useDetectionPrefetchClient + : useDetectionPrefetchServer; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 523e3e90c..1aaa23827 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -6,6 +6,7 @@ interface ImportMetaEnv { readonly VITE_SENTRY_ENVIRONMENT?: string; readonly VITE_SENTRY_RELEASE?: string; readonly VITE_CURATION_OUTBOUND_UTM_QUERY?: string; + readonly VITE_CURATION_DETECTION_MODE?: 'server' | 'client'; } declare const __APP_VERSION__: string;