Skip to content
30 changes: 2 additions & 28 deletions src/layout/RootLayout.tsx
Original file line number Diff line number Diff line change
@@ -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() {
// 라우트/쿼리/해시/키 변화와 초기 마운트 시 스크롤 최상단으로 이동
Expand All @@ -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;
9 changes: 7 additions & 2 deletions src/pages/generate/apis/furniture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<FurnitureCategoriesResponse>({
method: HTTPMethod.GET,
url: API_ENDPOINT.GENERATE.CURATION_CATEGORIES(imageId),
query: { detectedObjects },
...(query ? { query } : {}),
});
};

Expand Down
21 changes: 21 additions & 0 deletions src/pages/generate/constants/curationDetectionMode.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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 = <T>(detectedObjects: T[]) =>
IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

getCategoryQueryDetectedObjects의 반환 타입을 명시적으로 표기하면 API 계약이 더 명확해져요.

현재 추론된 타입은 T[] | undefined인데, 호출처에서 의도를 바로 파악하기 어려울 수 있어요. 선택적 제안이에요.

💡 타입 명시 제안
-export const getCategoryQueryDetectedObjects = <T>(detectedObjects: T[]) =>
-  IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined;
+export const getCategoryQueryDetectedObjects = <T>(
+  detectedObjects: T[]
+): T[] | undefined =>
+  IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
export const getCategoryQueryDetectedObjects = <T>(detectedObjects: T[]) =>
IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined;
export const getCategoryQueryDetectedObjects = <T>(
detectedObjects: T[]
): T[] | undefined =>
IS_CLIENT_DETECTION_ENABLED ? detectedObjects : undefined;
🤖 Prompt for AI Agents
In `@src/pages/generate/constants/curationDetectionMode.ts` around lines 10 - 11,
The function getCategoryQueryDetectedObjects currently relies on inferred return
type; explicitly annotate its signature to return T[] | undefined to make the
API contract clear (update the exported function declaration signature to
include the return type) and ensure any callers/readers can immediately see that
it may return an array of T or 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;
33 changes: 25 additions & 8 deletions src/pages/generate/hooks/useFurnitureCuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -107,7 +111,9 @@ export const useGeneratedCategoriesQuery = (
);
const canUseGroupInitialData =
groupId !== null &&
imageId !== null &&
groupCategoriesEntry !== null &&
groupCategoriesEntry.imageId === imageId &&
groupCategoriesEntry.detectionSignature === detectionSignature;

const categoriesQueryKey: CategoriesQueryKey = [
Expand Down Expand Up @@ -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
Expand All @@ -146,24 +155,28 @@ 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
) {
return;
}
saveGroupCategories({
groupId,
imageId,
response: query.data,
detectedObjects: normalizedDetectedObjects,
detectionSignature,
});
}, [
groupId,
imageId,
query.data,
detectionSignature,
normalizedDetectedObjects,
Expand All @@ -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;
Expand Down Expand Up @@ -226,7 +239,10 @@ export const useGeneratedProductsQuery = (
];

const initialProductsResponse =
groupId !== null && productCacheEntry
groupId !== null &&
imageId !== null &&
productCacheEntry &&
productCacheEntry.imageId === imageId
? productCacheEntry.response
: undefined;

Expand All @@ -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
Expand All @@ -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;
};
Expand Down
2 changes: 1 addition & 1 deletion src/pages/generate/hooks/useFurnitureHotspots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down
38 changes: 38 additions & 0 deletions src/pages/generate/hooks/useGenerateWarmup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
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]);
};
Comment on lines +14 to +27
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

useGenerateWarmupClient 훅 구조가 적절해요.

경로 매칭 로직(pathname === prefix || pathname.startsWith(...))이 정확하고, useEffect 의존성 배열도 올바르게 구성되어 있어요.

한 가지 제안: Line 25에서 .catch(() => undefined)로 에러를 완전히 무시하고 있는데, 디버깅 시 warmup 실패 원인 파악이 어려울 수 있어요. 최소한 console.warn 수준의 로깅을 추가하면 운영 시 유용해요.

🔧 제안
-    preloadONNXModel(OBJ365_MODEL_PATH).catch(() => undefined);
+    preloadONNXModel(OBJ365_MODEL_PATH).catch((e) => {
+      console.warn('[useGenerateWarmup] model preload failed:', e);
+    });
🤖 Prompt for AI Agents
In `@src/pages/generate/hooks/useGenerateWarmup.ts` around lines 14 - 27, The
catch in useGenerateWarmupClient silently swallows errors from
preloadONNXModel(OBJ365_MODEL_PATH); change the error handler to log a warning
so failures are visible: inside the useEffect where
preloadONNXModel(OBJ365_MODEL_PATH).catch(...) is called, replace the empty
catch with a handler that logs the error (e.g., console.warn or
processLogger.warn) including context like "warmup preload failed" and the error
object, keeping the rest of the hook (useEffect, dependency on location.pathname
and matching logic) unchanged.


const useGenerateWarmupServer = () => {
// 서버 모드 no-op 훅
};
Comment on lines +29 to +31
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

No-op 훅의 의도를 명확히 해두면 좋겠어요.

현재 주석이 한 줄이지만, 향후 서버 모드에서 다른 warmup 로직(예: 서버 캐시 프리히트)을 추가할 가능성이 있다면, JSDoc으로 확장 포인트를 남겨두는 것도 고려해 보세요.

🤖 Prompt for AI Agents
In `@src/pages/generate/hooks/useGenerateWarmup.ts` around lines 29 - 31, Add a
clear JSDoc above the useGenerateWarmupServer function to state it is
intentionally a no-op hook in server mode and to document it as an extension
point for future server-side warmup logic (e.g., cache preheat, background
tasks); keep the current function signature (useGenerateWarmupServer) and
behavior (no-op / returns undefined or a noop cleanup) so callers remain
unchanged, and mention any expected future responsibilities and where to add
them.


export const useGenerateWarmup = IS_CLIENT_DETECTION_ENABLED
? useGenerateWarmupClient
: useGenerateWarmupServer;
18 changes: 16 additions & 2 deletions src/pages/generate/hooks/useOnnxModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,14 +218,28 @@ 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<InferenceSession | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [progress, setProgress] = useState(0);
const ortRef = useRef<OnnxModule | null>(null); // onnxruntime-web 모듈 보관

useEffect(() => {
if (!enabled) {
setSession(null);
ortRef.current = null;
setIsLoading(false);
setError(null);
setProgress(0);
return;
}
Comment on lines +234 to +241
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial

enabled=false 시 이미 로드 중인 모델 fetch를 취소하지 않는 점을 확인해 주세요.

enabledtruefalse로 전환될 때, 이미 진행 중인 ensureModelLoad Promise는 isMounted = false로 결과만 무시되지만 네트워크 요청 자체는 완료될 때까지 계속 돼요. 모델이 수십 MB인 점을 고려하면, AbortController를 도입해 불필요한 다운로드를 중단하는 것이 바람직해요.

현재 구조에서 ensureModelLoad가 Promise 캐시를 공유하므로 즉각적인 문제는 아니지만, 모바일 환경에서 대역폭 낭비가 될 수 있어요.

🤖 Prompt for AI Agents
In `@src/pages/generate/hooks/useOnnxModel.ts` around lines 234 - 241, When
enabled flips from true to false we should abort any in-flight model download
instead of just ignoring its result: add an AbortController ref (e.g.,
abortControllerRef) to useOnnxModel, have ensureModelLoad accept a signal (or
read abortControllerRef.current.signal) and pass that signal into the
fetch/stream used to download the model, store the controller when starting a
load, and on the enabled=false branch call abortControllerRef.current?.abort(),
clear abortControllerRef.current and any cached ensureModelLoad Promise, then
proceed with setSession(null)/ortRef.current=null/etc.; update ensureModelLoad
and any helper that performs fetch to handle AbortError cleanly.


if (typeof window === 'undefined') {
setIsLoading(false);
setError('브라우저 환경이 아닙니다');
Expand Down Expand Up @@ -260,7 +274,7 @@ export function useONNXModel(modelPath: string) {
return () => {
isMounted = false;
};
}, [modelPath]);
}, [enabled, modelPath]);

const runInference = useCallback(
async (imageElement: HTMLImageElement): Promise<ProcessedDetections> => {
Expand Down
20 changes: 20 additions & 0 deletions src/pages/generate/hooks/useStartPageModelPreload.ts
Original file line number Diff line number Diff line change
@@ -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;
3 changes: 3 additions & 0 deletions src/pages/generate/pages/result/ResultPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -215,6 +216,7 @@ const ResultPage = () => {
<GeneratedImgA
result={result}
onCurrentImgIdChange={setCurrentImgId}
shouldInferHotspots={IS_CLIENT_DETECTION_ENABLED}
userProfile={forwardedUserProfile}
detectionCache={forwardedDetectionMap ?? undefined}
isSlideCountLoading={isSlideCountLoading}
Expand All @@ -223,6 +225,7 @@ const ResultPage = () => {
<GeneratedImgB
result={result}
onCurrentImgIdChange={setCurrentImgId}
shouldInferHotspots={IS_CLIENT_DETECTION_ENABLED}
detectionCache={forwardedDetectionMap ?? undefined}
/>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -255,7 +252,7 @@ export const CurationSheet = ({ groupId = null }: CurationSheetProps) => {
'상단 가구 필터에서 원하는 가구를 선택해 주세요'
);
}
if (!hasDetectionCodes) {
if (shouldShowDetectionPending(detectedObjectsCount)) {
return renderStatus('가구를 분석 중이에요', '잠시만 기다려 주세요');
}
if (categoriesQuery.isLoading) {
Expand Down
12 changes: 2 additions & 10 deletions src/pages/generate/pages/start/StartPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { useEffect } from 'react';

import { useNavigate } from 'react-router-dom';

import { useABTest } from '@/pages/generate/hooks/useABTest';
Expand All @@ -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';

Expand All @@ -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 이벤트 전송
Expand Down
Loading