Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
78e63e8
docs: OBJ365 기준으로 가구 매핑 주석 명확화
maehwasoo Dec 10, 2025
2a902f1
fix: 의자 벤치 스툴의 소파 매핑 비활성화
maehwasoo Dec 10, 2025
5bfc64b
refactor: 캐비닛 2차 분류 타입과 매핑 명확화
maehwasoo Dec 10, 2025
fcc3e36
fix: Couch 매핑에 1인 소파 코드 추가
maehwasoo Dec 10, 2025
77c5271
feat: 생성 경로 워밍업 및 ONNX 모델 영속 캐시 추가
maehwasoo Dec 10, 2025
ef95e7b
chore: 2차 분류 라벨 디버그 로그 추가
maehwasoo Dec 10, 2025
11e409a
fix: 찜 스낵바 중복 노출 방지 및 시트 닫힘 시 리셋
maehwasoo Dec 10, 2025
77ac112
fix: 찜 스낵바 중복 노출을 쿨타임으로 방지
maehwasoo Dec 10, 2025
d130a72
feat: 감지 캐시 도입하여 핫스팟 추론 재사용
maehwasoo Dec 11, 2025
7cf96ba
fix: 마이페이지 이미지 캐시 15분 및 포커스 재조회 중지
maehwasoo Dec 11, 2025
c04b9ec
chore: npm 캐시 디렉터리 무시 추가
maehwasoo Dec 11, 2025
b751c09
fix: 이미지 로드 상태 저장하여 스켈레톤 깜빡임 방지
maehwasoo Dec 11, 2025
49ff528
fix: 마이페이지 프로필 전달로 결과 크레딧 동기화
maehwasoo Dec 11, 2025
d412917
fix: 마이페이지 히스토리로 결과 페이지 표시하도록 수정
maehwasoo Dec 11, 2025
3091e9f
fix: 감지 캐시에 핫스팟과 카테고리 저장
maehwasoo Dec 11, 2025
acfd0ee
fix: 결과 감지 캐시 적용 및 마이페이지 뒤로가기 개선
maehwasoo Dec 11, 2025
9ed3101
fix: 결과 캐시 구조 정비
maehwasoo Dec 11, 2025
1b10c5b
fix: 바텀시트 hidden 상태 추가 및 동작 개선
maehwasoo Dec 11, 2025
ce86f39
fix: 카테고리 필터 로딩에 스켈레톤 적용
maehwasoo Dec 11, 2025
5cae060
fix: 새로 만들기 시 바텀시트 복원 막고 replace로 이동
maehwasoo Dec 11, 2025
3d499ec
fix: 렌더 메트릭 비교와 콜백 안정화로 중복 실행 방지
maehwasoo Dec 11, 2025
9131734
refactor: 카테고리 프리패치 병렬화로 초기 응답 개선
maehwasoo Dec 11, 2025
3551605
fix: 마이페이지 결과 매핑 및 로딩 흐름 수정
maehwasoo Dec 11, 2025
d603052
fix: 생성 결과 캐시 유지 시간 연장
maehwasoo Dec 11, 2025
8c38cd3
fix: 감지 캐시 영속 타입 정정 및 마이페이지 쿼리 옵션 보강
maehwasoo Dec 11, 2025
2967960
fix: 슬라이드 개수 로딩 시 스켈레톤 표시
maehwasoo Dec 11, 2025
ab8a123
feat: 마이페이지 감지 프리페치 추가로 첫 렌더 가속
maehwasoo Dec 11, 2025
6d93311
fix: 그룹 기반 쿼리키로 큐레이션 바텀시트 안정화
maehwasoo Dec 11, 2025
1b88c95
feat: 감지 프리페치 개선 및 큐와 우선순위 도입
maehwasoo Dec 11, 2025
3f69166
fix: 쿼리 키 안정화 및 타입 정비로 캐시 오류 수정
maehwasoo Dec 11, 2025
7cc3a08
fix: 캐시와 초기 데이터도 저장하도록 변경하고 gcTime 적용
maehwasoo Dec 11, 2025
0604fba
fix: 캐시 저장을 onSuccess로 이전해 중복 갱신 방지
maehwasoo Dec 11, 2025
6e97b94
fix: 캐시 오염과 불필요한 재요청 방지
maehwasoo Dec 11, 2025
4a78f5a
fix: 검출시그니처로 초기데이터와 캐시 동기화
maehwasoo Dec 11, 2025
1cb72be
fix: 이미지 생성 완료 시 마이페이지 목록 갱신
maehwasoo Dec 11, 2025
161ac83
fix: 그룹/카테고리 null 판별로 쿼리 캐시 오류 수정
maehwasoo Dec 11, 2025
10004ff
fix: 더보기 슬라이드에서 바텀시트 숨김 및 복원
maehwasoo Dec 11, 2025
ec60ce1
fix: 카테고리와 상품 쿼리 무효화 로직 수정
maehwasoo Dec 11, 2025
ab0fe38
docs: 큐레이션과 결과 및 마이페이지 JSDoc 추가
maehwasoo Dec 11, 2025
d781917
fix: nullish 비교로 그룹 초기화 누락 방지
maehwasoo Dec 11, 2025
69c698d
fix: 감지 프리페치 동시 실행 제한
maehwasoo Dec 12, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -56,4 +56,5 @@ node_modules/
AGENTS.md

.pnpm-store/
.serena/
.serena/
.npm-cache/
31 changes: 30 additions & 1 deletion src/layout/RootLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,44 @@
import { Outlet } from 'react-router-dom';
import { useEffect } from 'react';

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,
];

function RootLayout() {
// 라우트/쿼리/해시/키 변화와 초기 마운트 시 스크롤 최상단으로 이동
useScrollToTop();
useGenerateWarmup();
return (
<div>
<Outlet />
</div>
);
}

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]);
}
Comment on lines +29 to +42
Copy link

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

커스텀 훅 파일 분리 고려

코딩 가이드라인에 따르면 커스텀 훅 파일명은 use*.ts 형식이어야 해요. useGenerateWarmup 훅을 별도 파일(src/shared/hooks/useGenerateWarmup.ts)로 분리하면 재사용성과 테스트 용이성이 향상될 수 있어요. 현재 구현도 동작에는 문제없지만, 프로젝트 컨벤션 일관성을 위해 분리를 권장해요.

-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]);
-}
+// src/shared/hooks/useGenerateWarmup.ts 로 분리 권장

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In src/layout/RootLayout.tsx lines 29-42 the custom hook useGenerateWarmup
should be moved to its own file to follow the project convention for hooks
(use*.ts). Create src/shared/hooks/useGenerateWarmup.ts exporting the hook
(export default or named export), copy the implementation including the
useLocation, useEffect, GENERATE_WARMUP_PATHS and preloadONNXModel usage, keep
the dependency array as [location.pathname], add any needed imports/exports and
TypeScript types, then update RootLayout.tsx to import the hook from the new
path and remove the in-file definition.


export default RootLayout;
9 changes: 7 additions & 2 deletions src/pages/generate/GeneratePage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
} from 'react-router-dom';

import TitleNavBar from '@/shared/components/navBar/TitleNavBar';
import { getCanHistoryGoBack } from '@/shared/utils/history';

const GeneratePage = () => {
const location = useLocation();
Expand All @@ -25,8 +26,12 @@ const GeneratePage = () => {
const handleBackClick = () => {
if (location.pathname === '/generate/result') {
if (isFromMypage) {
// 마이페이지에서 온 경우 마이페이지로 이동
navigate('/mypage');
// 마이페이지에서 온 경우 스택이 남아 있으면 실제 이전 화면으로 이동
if (getCanHistoryGoBack()) {
navigate(-1);
} else {
navigate('/mypage', { replace: true });
}
} else {
// 일반 생성 플로우에서는 랜딩페이지로 이동
navigate('/');
Expand Down
62 changes: 31 additions & 31 deletions src/pages/generate/constants/furnitureCategoryMapping.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import {
OBJ365_FURNITURE_INDEX_SET,
} from '../utils/obj365Furniture';

import type { FurnitureCategory } from '../utils/furnitureCategories';
import type { CabinetRefinementCategory } from '../utils/cabinetRefinementCategories';

// 허용 FurnitureCategoryCode 목록
export const FURNITURE_CATEGORY_CODES = [
Expand Down Expand Up @@ -48,19 +48,19 @@ const uniqueCodes = (codes: FurnitureCategoryCode[]) => {
// 모델 최종 라벨을 12개 코드로 단일화하는 매핑(정리본 기반, 불필요 단어 제거)
const defineLabelMap = (): Record<string, FurnitureCategoryCode[]> => {
const entries: Array<[string, FurnitureCategoryCode[]]> = [
['Cabinet/shelf', ['DISPLAY_CABINET']], // 12번 기본값, 리파인 실패 시 사용
['Chair', ['SINGLE_SOFA']], // 2
['Desk', ['OFFICE_DESK']], // 9
['Storage box', ['DRAWER']], // 20
['Bench', ['SINGLE_SOFA']], // 24
['Monitor/TV', ['MOVABLE_TV']], // 37
['Stool', ['SINGLE_SOFA']], // 47
['Couch', ['TWO_SEATER_SOFA']], // 50
['Bed', ['SINGLE']], // 75
['Mirror', ['MIRROR']], // 79
['Dining Table', ['DINING_TABLE']], // 98
['Coffee Table', ['SITTING_TABLE']], // 167
['Side Table', ['SITTING_TABLE']], // 168
['Cabinet/shelf', ['DISPLAY_CABINET']], // 12번 OBJ365 'Cabinet/shelf' 기본값 리파인 실패 시 사용
// ['Chair', ['SINGLE_SOFA']], // 2번 OBJ365 'Chair' 비활성화
['Desk', ['OFFICE_DESK']], // 9번 OBJ365 'Desk'
['Storage box', ['DRAWER']], // 20번 OBJ365 'Storage box'
// ['Bench', ['SINGLE_SOFA']], // 24번 OBJ365 'Bench' 비활성화
['Monitor/TV', ['MOVABLE_TV']], // 37번 OBJ365 'Monitor/TV'
// ['Stool', ['SINGLE_SOFA']], // 47번 OBJ365 'Stool' 비활성화
['Couch', ['TWO_SEATER_SOFA', 'SINGLE_SOFA']], // 50번 OBJ365 'Couch' → 2인/1인 겸용
['Bed', ['SINGLE']], // 75번 OBJ365 'Bed'
['Mirror', ['MIRROR']], // 79번 OBJ365 'Mirror'
['Dining Table', ['DINING_TABLE']], // 98번 OBJ365 'Dining Table'
['Coffee Table', ['SITTING_TABLE']], // 167번 OBJ365 'Coffee Table'
['Side Table', ['SITTING_TABLE']], // 168번 OBJ365 'Side Table'
// ['Nightstand', ['DRAWER']], // 121 (미지원 가구로 비활성화)
];
return entries.reduce<Record<string, FurnitureCategoryCode[]>>(
Expand All @@ -77,25 +77,25 @@ const defineLabelMap = (): Record<string, FurnitureCategoryCode[]> => {
const FINAL_LABEL_MAP = defineLabelMap();

const OBJ365_TO_CODE: Record<number, FurnitureCategoryCode[]> = {
2: ['SINGLE_SOFA'],
9: ['OFFICE_DESK'],
12: ['DISPLAY_CABINET'],
20: ['DRAWER'],
24: ['SINGLE_SOFA'],
37: ['MOVABLE_TV'],
47: ['SINGLE_SOFA'],
50: ['TWO_SEATER_SOFA'],
75: ['SINGLE'],
79: ['MIRROR'],
98: ['DINING_TABLE'],
167: ['SITTING_TABLE'],
168: ['SITTING_TABLE'],
// 2: ['SINGLE_SOFA'], // OBJ365 'Chair' 비활성화
9: ['OFFICE_DESK'], // OBJ365 'Desk'
12: ['DISPLAY_CABINET'], // OBJ365 'Cabinet/shelf'
20: ['DRAWER'], // OBJ365 'Storage box'
// 24: ['SINGLE_SOFA'], // OBJ365 'Bench' 비활성화
37: ['MOVABLE_TV'], // OBJ365 'Monitor/TV'
// 47: ['SINGLE_SOFA'], // OBJ365 'Stool' 비활성화
50: ['TWO_SEATER_SOFA', 'SINGLE_SOFA'], // OBJ365 'Couch' → 2인/1인 겸용
75: ['SINGLE'], // OBJ365 'Bed'
79: ['MIRROR'], // OBJ365 'Mirror'
98: ['DINING_TABLE'], // OBJ365 'Dining Table'
167: ['SITTING_TABLE'], // OBJ365 'Coffee Table'
168: ['SITTING_TABLE'], // OBJ365 'Side Table'
// 121: ['DRAWER'], // Nightstand 미지원으로 비활성화
} as const;

// cabinet 2차 분류 결과 → 12개 코드 매핑
const CABINET_CATEGORY_TO_CODE: Partial<
Record<FurnitureCategory, FurnitureCategoryCode>
Record<CabinetRefinementCategory, FurnitureCategoryCode>
> = {
lowerCabinet: 'DISPLAY_CABINET',
// upperCabinet: 'WHITE_BOOKSHELF', // 상부장(upperCabinet)은 2차 cabinet 분류에서 추론 비활성 처리
Expand Down Expand Up @@ -123,7 +123,7 @@ const getCodeFromObj365Label = (
};

const getCodeFromRefinedLabel = (
refined: FurnitureCategory | undefined
refined: CabinetRefinementCategory | undefined
): FurnitureCategoryCode | null => {
if (!refined) return null;
return CABINET_CATEGORY_TO_CODE[refined] ?? null;
Expand All @@ -132,7 +132,7 @@ const getCodeFromRefinedLabel = (
export const resolveFurnitureCodes = (input: {
finalLabel?: string | null;
obj365Label?: number | null;
refinedLabel?: FurnitureCategory;
refinedLabel?: CabinetRefinementCategory;
refinedConfidence?: number;
}): FurnitureCategoryCode[] => {
const code = resolveFurnitureCode(input);
Expand All @@ -142,7 +142,7 @@ export const resolveFurnitureCodes = (input: {
export const resolveFurnitureCode = (input: {
finalLabel?: string | null;
obj365Label?: number | null;
refinedLabel?: FurnitureCategory;
refinedLabel?: CabinetRefinementCategory;
refinedConfidence?: number;
}): FurnitureCategoryCode | null => {
// Cabinet/shelf 인데 2차 리파인 결과가 없으면 감지 실패로 간주
Expand Down
4 changes: 2 additions & 2 deletions src/pages/generate/hooks/furnitureHotspotState.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import type { Dispatch } from 'react';

import type { Detection as FurnitureDetection } from '@pages/generate/types/detection';
import type { FurnitureCategory } from '@pages/generate/utils/refineFurnitureDetections';
import type { CabinetRefinementCategory } from '@pages/generate/utils/refineFurnitureDetections';

// 가구 핫스팟 상태 타입과 reducer 정의
export type FurnitureHotspot = FurnitureDetection & {
id: number;
cx: number;
cy: number;
refinedLabel?: FurnitureCategory;
refinedLabel?: CabinetRefinementCategory;
refinedLabelEn?: string;
finalLabel: string | null;
confidence?: number;
Expand Down
92 changes: 92 additions & 0 deletions src/pages/generate/hooks/useDetectionCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useCallback, useEffect, useMemo } from 'react';

import {
useDetectionCacheStore,
type DetectionCacheEntry,
} from '@pages/generate/stores/useDetectionCacheStore';

import type { FurnitureCategoryCode } from '@pages/generate/constants/furnitureCategoryMapping';
import type { FurnitureHotspot } from '@pages/generate/hooks/useFurnitureHotspots';
import type { ProcessedDetections } from '@pages/generate/types/detection';

export const DETECTION_CACHE_TTL = 30 * 60 * 1000; // 30분

type SavePayload = {
processedDetections: ProcessedDetections;
hotspots: FurnitureHotspot[];
detectedObjects?: FurnitureCategoryCode[];
};

type UseDetectionCacheOptions = {
initialEntry?: DetectionCacheEntry | null;
};

export function useDetectionCache(
imageId: number | null,
imageUrl: string,
options?: UseDetectionCacheOptions
) {
const initialEntry = options?.initialEntry ?? null;
const storeEntry = useDetectionCacheStore((state) =>
imageId ? (state.images[imageId] ?? null) : null
);
const setEntry = useDetectionCacheStore((state) => state.setEntry);
const removeEntry = useDetectionCacheStore((state) => state.removeEntry);

const isExpired = useMemo(() => {
if (!storeEntry) return false;
if (storeEntry.imageUrl !== imageUrl) return true;
return Date.now() - storeEntry.updatedAt > DETECTION_CACHE_TTL;
}, [storeEntry, imageUrl]);

useEffect(() => {
if (!imageId || !initialEntry) return;
const candidate = initialEntry;
if (candidate.imageUrl !== imageUrl) return;
if (!storeEntry || candidate.updatedAt > storeEntry.updatedAt) {
setEntry(imageId, candidate);
}
}, [imageId, imageUrl, initialEntry, setEntry, storeEntry]);

useEffect(() => {
if (!imageId || !isExpired) return;
removeEntry(imageId);
}, [imageId, isExpired, removeEntry]);

const effectiveEntry = isExpired ? null : storeEntry;

const prefetchedDetections = effectiveEntry?.processedDetections ?? null;

const saveEntry = useCallback(
({ processedDetections, hotspots, detectedObjects }: SavePayload) => {
if (!imageId) return;
setEntry(imageId, {
imageUrl,
processedDetections,
hotspots,
detectedObjects,
});
},
[imageId, imageUrl, setEntry]
);

const clearEntry = useCallback(() => {
if (!imageId) return;
removeEntry(imageId);
}, [imageId, removeEntry]);

return {
entry: effectiveEntry,
prefetchedDetections,
saveEntry,
clearEntry,
} as const;
}

export const primeDetectionCacheEntry = (
imageId: number,
payload: DetectionCacheEntry
) => {
if (!imageId) return;
useDetectionCacheStore.getState().setEntry(imageId, payload);
};
Loading