From 23938871b0d0f3b7eb6fd09786899c671b94f26b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=84=B1=EA=B2=BD?= Date: Tue, 11 Aug 2026 17:07:57 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EC=97=85=EB=A1=9C=EB=93=9C=EB=A5=BC=20=EC=95=95=EC=B6=95=20?= =?UTF-8?q?=ED=9B=84=20=EC=88=9C=EC=B0=A8=20=EC=A0=84=EC=86=A1=20=EB=B0=A9?= =?UTF-8?q?=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/locales/en.json | 2 + src/locales/ko.json | 2 + .../Dashboard/AssetManagementContent.tsx | 55 +++- src/styles/contents-social.css | 4 - src/styles/studio-assets.css | 66 +++++ src/utils/api.ts | 23 +- src/utils/imageCompression.ts | 249 ++++++++++++++++++ src/utils/imageUpload.ts | 83 ++++++ 8 files changed, 459 insertions(+), 25 deletions(-) create mode 100644 src/utils/imageCompression.ts create mode 100644 src/utils/imageUpload.ts diff --git a/src/locales/en.json b/src/locales/en.json index 3c4d0fc..edf6e91 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -269,6 +269,8 @@ "back": "Go Back", "loadMore": "Load more", "uploadFailed": "Image upload failed.", + "uploadErrorTitle": "Image Upload Error", + "uploadErrorConfirm": "OK", "uploading": "Uploading... (30 sec – 1 min)", "nextStep": "Next Step" }, diff --git a/src/locales/ko.json b/src/locales/ko.json index 8afb089..16728d7 100644 --- a/src/locales/ko.json +++ b/src/locales/ko.json @@ -268,6 +268,8 @@ "back": "뒤로가기", "loadMore": "더보기", "uploadFailed": "이미지 업로드에 실패했습니다.", + "uploadErrorTitle": "이미지 업로드 오류", + "uploadErrorConfirm": "확인", "uploading": "업로드 중 (30~60초 소요)", "nextStep": "다음 단계" }, diff --git a/src/pages/Dashboard/AssetManagementContent.tsx b/src/pages/Dashboard/AssetManagementContent.tsx index 50e57a4..1c74671 100755 --- a/src/pages/Dashboard/AssetManagementContent.tsx +++ b/src/pages/Dashboard/AssetManagementContent.tsx @@ -3,6 +3,7 @@ import React, { useRef, useState, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { ImageItem, ImageUrlItem } from '../../types/api'; import { uploadImages } from '../../utils/api'; +import { isImageInputFile } from '../../utils/imageCompression.ts'; interface AssetManagementContentProps { onNext: (imageTaskId: string) => void; @@ -38,6 +39,20 @@ const AssetManagementContent: React.FC = ({ } }, []); + useEffect(() => { + if (!uploadError) return; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + setUploadError(null); + } + }; + + window.addEventListener('keydown', handleEscape); + return () => window.removeEventListener('keydown', handleEscape); + }, [uploadError]); + const handleVideoRatioChange = (ratio: VideoRatio) => { setVideoRatio(ratio); localStorage.setItem('castad_video_ratio', ratio); @@ -100,9 +115,7 @@ const AssetManagementContent: React.FC = ({ const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); - const files = Array.from(e.dataTransfer.files).filter((file: File) => - file.type.startsWith('image/') - ); + const files = Array.from(e.dataTransfer.files).filter(isImageInputFile); if (files.length > 0) onAddImages(files); }; @@ -146,6 +159,37 @@ const AssetManagementContent: React.FC = ({ )} + {uploadError && ( +
setUploadError(null)} + > +
event.stopPropagation()} + > +

+ {t('assetManagement.uploadErrorTitle')} +

+

+ {uploadError} +

+ +
+
+ )} + {/* Fixed Header - 뒤로가기 버튼 */}
{onBack && ( @@ -268,9 +312,6 @@ const AssetManagementContent: React.FC = ({ {/* Fixed Footer - 다음 단계 버튼 */}
- {uploadError && ( -

{uploadError}

- )} -
- ))} + {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) {