diff --git a/src/locales/en.json b/src/locales/en.json index edf6e91..b649a24 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -260,10 +260,11 @@ "selectedImages": "Selected Images", "imageAlt": "Image", "uploadBadge": "Uploaded", + "previewUnavailable": "No preview", "imageUpload": "Image Upload", "dragAndDrop": "Drag & drop or\nclick to upload", "videoRatio": "Video Ratio", - "minImages": "Min. 5 images", + "minImages": "Recommended 30+ images", "youtubeShorts": "YouTube Shorts", "youtubeVideo": "YouTube Video", "back": "Go Back", @@ -271,6 +272,9 @@ "uploadFailed": "Image upload failed.", "uploadErrorTitle": "Image Upload Error", "uploadErrorConfirm": "OK", + "duplicateSkippedTitle": "Duplicate Images Skipped", + "duplicateSkippedMessage": "Skipped {{count}} image(s) that were already added.", + "preparingPreviews": "Preparing previews ({{done}} / {{total}})", "uploading": "Uploading... (30 sec – 1 min)", "nextStep": "Next Step" }, diff --git a/src/locales/ko.json b/src/locales/ko.json index 16728d7..25270d1 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -259,17 +259,21 @@ "selectedImages": "선택된 이미지", "imageAlt": "이미지", "uploadBadge": "업로드", + "previewUnavailable": "미리보기 없음", "imageUpload": "이미지 업로드", "dragAndDrop": "이미지를 끌어다 놓거나\n클릭하여 업로드", "videoRatio": "영상 비율", - "minImages": "최소 5장", + "minImages": "권장 30장 이상", "youtubeShorts": "유튜브 쇼츠", "youtubeVideo": "유튜브 일반", "back": "뒤로가기", "loadMore": "더보기", "uploadFailed": "이미지 업로드에 실패했습니다.", - "uploadErrorTitle": "이미지 업로드 오류", + "uploadErrorTitle": "이미지 업로드 초과", "uploadErrorConfirm": "확인", + "duplicateSkippedTitle": "중복 이미지 제외", + "duplicateSkippedMessage": "이미 추가된 이미지 {{count}}장을 제외했습니다.", + "preparingPreviews": "미리보기 준비 중 ({{done}} / {{total}})", "uploading": "업로드 중 (30~60초 소요)", "nextStep": "다음 단계" }, diff --git a/src/pages/Dashboard/AssetManagementContent.tsx b/src/pages/Dashboard/AssetManagementContent.tsx index 1c74671..ce5f5aa 100755 --- a/src/pages/Dashboard/AssetManagementContent.tsx +++ b/src/pages/Dashboard/AssetManagementContent.tsx @@ -4,13 +4,18 @@ import { useTranslation } from 'react-i18next'; import { ImageItem, ImageUrlItem } from '../../types/api'; import { uploadImages } from '../../utils/api'; import { isImageInputFile } from '../../utils/imageCompression.ts'; +import { MAX_IMAGES_PER_UPLOAD_TASK } from '../../utils/imageUpload.ts'; +import { splitDuplicateFiles } from '../../utils/imageDedup.ts'; interface AssetManagementContentProps { onNext: (imageTaskId: string) => void; onBack?: () => void; imageList: ImageItem[]; onRemoveImage: (index: number) => void; - onAddImages: (files: File[]) => void; + onAddImages: ( + files: File[], + onProgress: (done: number, total: number) => void + ) => Promise; } type VideoRatio = 'vertical' | 'horizontal'; @@ -28,9 +33,11 @@ const AssetManagementContent: React.FC = ({ const fileInputRef = useRef(null); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); - const [uploadError, setUploadError] = useState(null); + // 업로드 실패와 중복 제외 안내가 같은 다이얼로그 마크업을 공유한다. + const [dialog, setDialog] = useState<{ title: string; message: string } | null>(null); const [videoRatio, setVideoRatio] = useState('vertical'); const [displayCount, setDisplayCount] = useState(IMAGES_PER_PAGE); + const [thumbnailProgress, setThumbnailProgress] = useState<{ done: number; total: number } | null>(null); useEffect(() => { const savedRatio = localStorage.getItem('castad_video_ratio') as VideoRatio; @@ -40,25 +47,25 @@ const AssetManagementContent: React.FC = ({ }, []); useEffect(() => { - if (!uploadError) return; + if (!dialog) return; const handleEscape = (event: KeyboardEvent) => { if (event.key === 'Escape') { event.preventDefault(); - setUploadError(null); + setDialog(null); } }; window.addEventListener('keydown', handleEscape); return () => window.removeEventListener('keydown', handleEscape); - }, [uploadError]); + }, [dialog]); const handleVideoRatioChange = (ratio: VideoRatio) => { setVideoRatio(ratio); localStorage.setItem('castad_video_ratio', ratio); }; - const getImageSrc = (item: ImageItem): string => { + const getImageSrc = (item: ImageItem): string | null => { return item.type === 'url' ? item.preview_url : item.preview; }; @@ -67,7 +74,7 @@ const AssetManagementContent: React.FC = ({ setIsUploading(true); setUploadProgress(0); - setUploadError(null); + setDialog(null); const interval = setInterval(() => { setUploadProgress(prev => { @@ -100,13 +107,37 @@ const AssetManagementContent: React.FC = ({ } catch (error) { clearInterval(interval); console.error('Image upload failed:', error); - setUploadError(error instanceof Error ? error.message : t('assetManagement.uploadFailed')); + setDialog({ + title: t('assetManagement.uploadErrorTitle'), + message: error instanceof Error ? error.message : t('assetManagement.uploadFailed'), + }); } finally { setIsUploading(false); setUploadProgress(0); } }; + const runAddImages = async (files: File[]) => { + const { newFiles, duplicateCount } = splitDuplicateFiles(imageList, files); + + if (newFiles.length > 0) { + setThumbnailProgress({ done: 0, total: newFiles.length }); + try { + await onAddImages(newFiles, (done, total) => setThumbnailProgress({ done, total })); + } finally { + setThumbnailProgress(null); + } + } + + // 썸네일 생성 오버레이에 가려지지 않도록 작업이 끝난 뒤에 안내한다. + if (duplicateCount > 0) { + setDialog({ + title: t('assetManagement.duplicateSkippedTitle'), + message: t('assetManagement.duplicateSkippedMessage', { count: duplicateCount }), + }); + } + }; + const handleDragOver = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); @@ -116,7 +147,7 @@ const AssetManagementContent: React.FC = ({ e.preventDefault(); e.stopPropagation(); const files = Array.from(e.dataTransfer.files).filter(isImageInputFile); - if (files.length > 0) onAddImages(files); + if (files.length > 0) void runAddImages(files); }; const handleFileSelect = () => { @@ -126,7 +157,7 @@ const AssetManagementContent: React.FC = ({ const handleFileChange = (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { - onAddImages(Array.from(files)); + void runAddImages(Array.from(files)); e.target.value = ''; } }; @@ -136,6 +167,25 @@ const AssetManagementContent: React.FC = ({ return (
+ {thumbnailProgress && ( +
+
+
+
+
+
+
+
+

+ {t('assetManagement.preparingPreviews', { + done: thumbnailProgress.done, + total: thumbnailProgress.total, + })} +

+
+
+ )} + {isUploading && (
@@ -159,10 +209,10 @@ const AssetManagementContent: React.FC = ({
)} - {uploadError && ( + {dialog && (
setUploadError(null)} + onClick={() => setDialog(null)} >
= ({ onClick={(event) => event.stopPropagation()} >

- {t('assetManagement.uploadErrorTitle')} + {dialog.title}

- {uploadError} + {dialog.message}

-
- ))} + {visibleImages.map((item, i) => { + const src = getImageSrc(item); + const fileName = item.type === 'file' ? item.file.name : ''; + return ( +
+ {src ? ( + {`${t('assetManagement.imageAlt')} + ) : ( +
+ + + + + + + {fileName || t('assetManagement.previewUnavailable')} + +
+ )} + {item.type === 'file' && ( +
{t('assetManagement.uploadBadge')}
+ )} + +
+ ); + })}
)}
diff --git a/src/pages/Dashboard/GenerationFlow.tsx b/src/pages/Dashboard/GenerationFlow.tsx index 0d5748d..f045029 100755 --- a/src/pages/Dashboard/GenerationFlow.tsx +++ b/src/pages/Dashboard/GenerationFlow.tsx @@ -20,6 +20,7 @@ import { useTutorial } from '../../components/Tutorial/useTutorial'; import { TUTORIAL_KEYS } from '../../components/Tutorial/tutorialSteps'; import TutorialOverlay, { TutorialRestartPopup } from '../../components/Tutorial/TutorialOverlay'; import WizardStepper from '../../components/WizardStepper'; +import { createPreviewThumbnail } from '../../utils/imageCompression.ts'; const WIZARD_STEP_KEY = 'castad_wizard_step'; const ACTIVE_ITEM_KEY = 'castad_active_item'; @@ -28,6 +29,15 @@ const IMAGE_TASK_ID_KEY = 'castad_image_task_id'; const ANALYSIS_DATA_KEY = 'castad_analysis_data'; import { saveSearchHistory } from '../../components/SearchHistory/useSearchHistory'; +/** 파일 이미지의 썸네일 objectURL 을 일괄 해제한다. preview 가 null 인 항목은 건너뛴다. */ +const revokeAllPreviews = (items: ImageItem[]) => { + items.forEach(item => { + if (item.type === 'file' && item.preview) { + URL.revokeObjectURL(item.preview); + } + }); +}; + // 다른 컴포넌트에서 사용하는 storage key들 (초기화용) const SONG_GENERATION_KEY = 'castad_song_generation'; const VIDEO_GENERATION_KEY = 'castad_video_generation'; @@ -175,6 +185,15 @@ const GenerationFlow: React.FC = ({ }; const [imageList, setImageList] = useState(getInitialImageList()); + const imageListRef = useRef(imageList); + imageListRef.current = imageList; + + // 의존성 배열에 imageList 를 넣으면 이미지를 추가할 때마다 cleanup 이 돌아 + // 방금 만든 썸네일이 즉시 revoke 된다. 반드시 빈 배열이어야 한다. + useEffect(() => { + return () => revokeAllPreviews(imageListRef.current); + }, []); + const prevAnalysisMIdRef = useRef(analysisData?.m_id); // analysisData 변경 시 m_id가 바뀐 경우(새로운 분석)에만 imageList 업데이트 @@ -183,6 +202,8 @@ const GenerationFlow: React.FC = ({ console.log('[GenerationFlow] analysisData updated, m_id:', analysisData?.m_id); if (analysisData?.image_list && analysisData.image_list.length > 0) { if (prevAnalysisMIdRef.current !== analysisData.m_id) { + // 목록을 통째로 교체하므로 기존 파일 썸네일을 먼저 해제한다. + revokeAllPreviews(imageListRef.current); setImageList(analysisData.image_list.map(item => ({ type: 'url' as const, url: item.original, preview_url: item.preview }))); } } @@ -192,21 +213,35 @@ const GenerationFlow: React.FC = ({ const handleRemoveImage = (index: number) => { setImageList(prev => { const item = prev[index]; - // 파일 이미지인 경우 메모리 해제 - if (item.type === 'file') { + // 파일 이미지인 경우 썸네일 메모리 해제 (생성 실패 시 preview 는 null) + if (item.type === 'file' && item.preview) { URL.revokeObjectURL(item.preview); } return prev.filter((_, i) => i !== index); }); }; - const handleAddImages = (files: File[]) => { - const newImages: ImageItem[] = files.map(file => ({ - type: 'file', - file, - preview: URL.createObjectURL(file), - })); - // 새로 업로드된 이미지를 배열 앞에 추가 (최신 이미지가 상단에 표시) + const handleAddImages = async ( + files: File[], + onProgress: (done: number, total: number) => void + ) => { + const total = files.length; + const newImages: ImageItem[] = []; + + onProgress(0, total); + + // 반드시 순차 처리한다. Promise.all 로 동시에 돌리면 원본 크기 비트맵이 + // 한꺼번에 메모리에 올라가, 이 작업이 없애려던 문제가 그대로 재현된다. + for (const file of files) { + const preview = await createPreviewThumbnail(file); + newImages.push({ type: 'file', file, preview }); + onProgress(newImages.length, total); + // 진행률이 실제로 화면에 그려지도록 매 장마다 렌더링 기회를 넘긴다. + await new Promise(resolve => setTimeout(resolve, 0)); + } + + // 장마다 setState 하면 그리드가 N 번 재조정된다. 전부 끝난 뒤 한 번만 반영한다. + // 새로 추가된 이미지를 배열 앞에 둔다 (최신 이미지가 상단에 표시) setImageList(prev => [...newImages, ...prev]); }; @@ -218,6 +253,7 @@ const GenerationFlow: React.FC = ({ setSongTaskId(null); setImageTaskId(null); setAnalysisData(null); + revokeAllPreviews(imageListRef.current); setImageList([]); onHome(); }; diff --git a/src/styles/studio-assets.css b/src/styles/studio-assets.css index e3381e4..24a8dea 100644 --- a/src/styles/studio-assets.css +++ b/src/styles/studio-assets.css @@ -1098,6 +1098,20 @@ letter-spacing: -0.006em; } +.asset-section-count { + font-size: 0.875rem; + font-weight: 600; + color: #9BCACC; + line-height: 1.19; + letter-spacing: -0.006em; + font-variant-numeric: tabular-nums; +} + +/* 업로드 상한(100장)을 넘긴 상태. 추가 자체는 막지 않고 시각적으로만 경고한다. */ +.asset-section-count-over { + color: #fca5a5; +} + /* Asset Section Title */ .asset-section-title { font-size: 1.5rem; @@ -1200,6 +1214,30 @@ object-fit: cover; } +.asset-image-placeholder { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 6px; + padding: 8px; + background-color: #001416; + color: #379599; + text-align: center; +} + +.asset-image-placeholder-name { + width: 100%; + font-size: 0.6875rem; + line-height: 1.2; + color: #9BCACC; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .asset-image-badge { position: absolute; top: 8px; diff --git a/src/types/api.ts b/src/types/api.ts index 3e90638..253befc 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -61,8 +61,8 @@ export interface UrlImage { // 업로드된 파일 이미지 export interface FileImage { type: 'file'; - file: File; - preview: string; // createObjectURL로 생성된 미리보기 URL + file: File; // 업로드용 원본. 썸네일로 대체하지 않는다. + preview: string | null; // 미리보기용 축소 썸네일 objectURL. 생성 실패 시 null. } export type ImageItem = UrlImage | FileImage; diff --git a/src/utils/imageCompression.ts b/src/utils/imageCompression.ts index c031f43..7722a9d 100644 --- a/src/utils/imageCompression.ts +++ b/src/utils/imageCompression.ts @@ -247,3 +247,59 @@ export async function compressImageForUpload(file: File): Promise { decoded.dispose(); } } + +export const MAX_PREVIEW_DIMENSION = 400; +export const PREVIEW_QUALITY = 0.7; + +/** + * 그리드 미리보기 전용 축소 썸네일을 만든다. + * + * 업로드에는 원본 File 을 그대로 쓰고, 여기서 만든 objectURL 은 에만 쓰인다. + * 핵심은 finally 의 dispose() 로, createImageBitmap 경로에서 원본 비트맵을 즉시 + * 해제해 메모리에 원본 크기 비트맵이 남지 않게 한다. + * + * 실패해도 예외를 던지지 않고 null 을 돌려준다. 호출부가 자리표시자를 그린다. + */ +export async function createPreviewThumbnail(file: File): Promise { + if (!isImageInputFile(file)) { + return null; + } + + let decoded: DecodedImage; + try { + decoded = await decodeImage(file); + } catch (error) { + console.warn(`미리보기 썸네일 디코딩 실패: ${file.name}`, error); + return null; + } + + try { + const { width, height } = calculateTargetDimensions( + decoded.width, + decoded.height, + MAX_PREVIEW_DIMENSION + ); + + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + + const context = canvas.getContext('2d'); + if (!context) { + return null; + } + + // JPEG 에는 알파 채널이 없다. 투명 픽셀이 검게 나오지 않도록 흰 배경을 깐다. + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + context.drawImage(decoded.source, 0, 0, width, height); + + const blob = await canvasToBlob(canvas, PREVIEW_QUALITY); + return URL.createObjectURL(blob); + } catch (error) { + console.warn(`미리보기 썸네일 생성 실패: ${file.name}`, error); + return null; + } finally { + decoded.dispose(); + } +} diff --git a/src/utils/imageDedup.ts b/src/utils/imageDedup.ts new file mode 100644 index 0000000..06c39e0 --- /dev/null +++ b/src/utils/imageDedup.ts @@ -0,0 +1,49 @@ +import type { ImageItem } from '../types/api'; + +/** + * 같은 파일을 가리키는지 판별하기 위한 키. + * + * 브라우저는 동일한 파일에 대해 name/size/lastModified 를 항상 동일하게 주므로, + * 내용 해시를 계산하지 않고도 비용 없이 중복을 걸러낼 수 있다. + */ +export function getFileIdentity(file: Pick): string { + return `${file.name}|${file.size}|${file.lastModified}`; +} + +export interface FileDedupResult { + /** 목록에 새로 넣어야 할 파일 */ + newFiles: File[]; + /** 중복이라 제외된 파일 수 */ + duplicateCount: number; +} + +/** + * 이미 목록에 있는 파일, 그리고 이번 배치 안에서 서로 겹치는 파일을 걸러낸다. + * + * url 타입 이미지(크롤링으로 가져온 원격 이미지)는 로컬 파일과 비교할 수단이 + * 없으므로 비교 대상에서 제외한다. + */ +export function splitDuplicateFiles(existing: ImageItem[], incoming: File[]): FileDedupResult { + const seen = new Set(); + + for (const item of existing) { + if (item.type === 'file') { + seen.add(getFileIdentity(item.file)); + } + } + + const newFiles: File[] = []; + let duplicateCount = 0; + + for (const file of incoming) { + const identity = getFileIdentity(file); + if (seen.has(identity)) { + duplicateCount += 1; + continue; + } + seen.add(identity); + newFiles.push(file); + } + + return { newFiles, duplicateCount }; +} diff --git a/src/utils/imageUpload.ts b/src/utils/imageUpload.ts index bf0b4a4..9d0e6d9 100644 --- a/src/utils/imageUpload.ts +++ b/src/utils/imageUpload.ts @@ -45,7 +45,7 @@ export async function uploadImagesSequentially( ): Promise { const totalImageCount = imageUrls.length + files.length; if (totalImageCount > MAX_IMAGES_PER_UPLOAD_TASK) { - throw new Error(`이미지는 한 번에 최대 ${MAX_IMAGES_PER_UPLOAD_TASK}장까지 업로드할 수 있습니다.`); + throw new Error(`이미지는 최대 ${MAX_IMAGES_PER_UPLOAD_TASK}장까지 업로드할 수 있습니다.`); } if (files.length === 0) {