feat: 이미지 업로드를 압축 후 순차 전송 방식으로 전환

This commit is contained in:
김성경 2026-08-11 17:07:57 +09:00
parent c4c46db0d9
commit 23938871b0
8 changed files with 459 additions and 25 deletions

View File

@ -269,6 +269,8 @@
"back": "Go Back", "back": "Go Back",
"loadMore": "Load more", "loadMore": "Load more",
"uploadFailed": "Image upload failed.", "uploadFailed": "Image upload failed.",
"uploadErrorTitle": "Image Upload Error",
"uploadErrorConfirm": "OK",
"uploading": "Uploading... (30 sec 1 min)", "uploading": "Uploading... (30 sec 1 min)",
"nextStep": "Next Step" "nextStep": "Next Step"
}, },

View File

@ -268,6 +268,8 @@
"back": "뒤로가기", "back": "뒤로가기",
"loadMore": "더보기", "loadMore": "더보기",
"uploadFailed": "이미지 업로드에 실패했습니다.", "uploadFailed": "이미지 업로드에 실패했습니다.",
"uploadErrorTitle": "이미지 업로드 오류",
"uploadErrorConfirm": "확인",
"uploading": "업로드 중 (30~60초 소요)", "uploading": "업로드 중 (30~60초 소요)",
"nextStep": "다음 단계" "nextStep": "다음 단계"
}, },

View File

@ -3,6 +3,7 @@ import React, { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { ImageItem, ImageUrlItem } from '../../types/api'; import { ImageItem, ImageUrlItem } from '../../types/api';
import { uploadImages } from '../../utils/api'; import { uploadImages } from '../../utils/api';
import { isImageInputFile } from '../../utils/imageCompression.ts';
interface AssetManagementContentProps { interface AssetManagementContentProps {
onNext: (imageTaskId: string) => void; onNext: (imageTaskId: string) => void;
@ -38,6 +39,20 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
} }
}, []); }, []);
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) => { const handleVideoRatioChange = (ratio: VideoRatio) => {
setVideoRatio(ratio); setVideoRatio(ratio);
localStorage.setItem('castad_video_ratio', ratio); localStorage.setItem('castad_video_ratio', ratio);
@ -100,9 +115,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
const handleDrop = (e: React.DragEvent) => { const handleDrop = (e: React.DragEvent) => {
e.preventDefault(); e.preventDefault();
e.stopPropagation(); e.stopPropagation();
const files = Array.from(e.dataTransfer.files).filter((file: File) => const files = Array.from(e.dataTransfer.files).filter(isImageInputFile);
file.type.startsWith('image/')
);
if (files.length > 0) onAddImages(files); if (files.length > 0) onAddImages(files);
}; };
@ -146,6 +159,37 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
</div> </div>
)} )}
{uploadError && (
<div
className="asset-upload-error-overlay"
onClick={() => setUploadError(null)}
>
<div
className="asset-upload-error-dialog"
role="alertdialog"
aria-modal="true"
aria-labelledby="asset-upload-error-title"
aria-describedby="asset-upload-error-message"
onClick={(event) => event.stopPropagation()}
>
<h2 id="asset-upload-error-title" className="asset-upload-error-title">
{t('assetManagement.uploadErrorTitle')}
</h2>
<p id="asset-upload-error-message" className="asset-upload-error-message">
{uploadError}
</p>
<button
type="button"
className="asset-upload-error-confirm"
onClick={() => setUploadError(null)}
autoFocus
>
{t('assetManagement.uploadErrorConfirm')}
</button>
</div>
</div>
)}
{/* Fixed Header - 뒤로가기 버튼 */} {/* Fixed Header - 뒤로가기 버튼 */}
<div className="asset-sticky-header"> <div className="asset-sticky-header">
{onBack && ( {onBack && (
@ -268,9 +312,6 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
{/* Fixed Footer - 다음 단계 버튼 */} {/* Fixed Footer - 다음 단계 버튼 */}
<div className="asset-sticky-footer"> <div className="asset-sticky-footer">
{uploadError && (
<p className="text-red-500 text-sm mb-2">{uploadError}</p>
)}
<button <button
onClick={handleNextWithUpload} onClick={handleNextWithUpload}
disabled={imageList.length === 0 || isUploading} disabled={imageList.length === 0 || isUploading}
@ -283,7 +324,7 @@ const AssetManagementContent: React.FC<AssetManagementContentProps> = ({
<input <input
ref={fileInputRef} ref={fileInputRef}
type="file" type="file"
accept="image/*" accept="image/*,.heic,.heif"
multiple multiple
onChange={handleFileChange} onChange={handleFileChange}
className="hidden" className="hidden"

View File

@ -689,10 +689,6 @@
flex-direction: column; flex-direction: column;
} }
.video-detail-header {
/* margin-bottom: 24px; */
}
.video-detail-back-btn { .video-detail-back-btn {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@ -832,6 +832,72 @@
gap: 0; gap: 0;
} }
/* Upload Error Dialog */
.asset-upload-error-overlay {
position: fixed;
inset: 0;
z-index: 1100;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background: rgba(0, 17, 18, 0.82);
backdrop-filter: blur(4px);
}
.asset-upload-error-dialog {
width: min(100%, 420px);
padding: 1.5rem;
background: #01393B;
border: 1px solid #379599;
border-radius: 20px;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
}
.asset-upload-error-title {
margin: 0;
color: #94FBE0;
font-size: 1.25rem;
font-weight: 700;
line-height: 1.3;
}
.asset-upload-error-message {
margin: 0.75rem 0 1.5rem;
color: #E5F1F2;
font-size: 0.9375rem;
line-height: 1.6;
overflow-wrap: anywhere;
white-space: pre-wrap;
}
.asset-upload-error-confirm {
width: 100%;
min-height: 44px;
padding: 0.75rem 1rem;
color: #002224;
background: #94FBE0;
border: 0;
border-radius: 999px;
font-size: 0.9375rem;
font-weight: 700;
cursor: pointer;
transition: background-color 0.2s, transform 0.2s;
}
.asset-upload-error-confirm:hover {
background: #B8FFE9;
}
.asset-upload-error-confirm:active {
transform: translateY(1px);
}
.asset-upload-error-confirm:focus-visible {
outline: 3px solid #CFABFB;
outline-offset: 3px;
}
/* Fixed Header - 뒤로가기 */ /* Fixed Header - 뒤로가기 */
.asset-sticky-header { .asset-sticky-header {

View File

@ -35,6 +35,7 @@ import {
CommentItem, CommentItem,
LikeToggleResponse, LikeToggleResponse,
} from '../types/api'; } from '../types/api';
import { uploadImagesSequentially } from './imageUpload.ts';
export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44'; export const API_URL = import.meta.env.VITE_API_URL || 'http://40.82.133.44';
console.log('[API] API_URL:', API_URL); console.log('[API] API_URL:', API_URL);
@ -655,18 +656,10 @@ export async function uploadImages(
imageUrls: ImageUrlItem[], imageUrls: ImageUrlItem[],
files: File[] files: File[]
): Promise<ImageUploadResponse> { ): Promise<ImageUploadResponse> {
const formData = new FormData(); return uploadImagesSequentially(imageUrls, files, postImageUpload);
// URL 이미지들을 images_json으로 전달
if (imageUrls.length > 0) {
formData.append('images_json', JSON.stringify(imageUrls));
} }
// 파일들을 files로 전달 async function postImageUpload(formData: FormData): Promise<ImageUploadResponse> {
files.forEach((file) => {
formData.append('files', file);
});
const controller = new AbortController(); const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT); const timeoutId = setTimeout(() => controller.abort(), IMAGE_UPLOAD_TIMEOUT);
@ -677,19 +670,21 @@ export async function uploadImages(
signal: controller.signal, signal: controller.signal,
}); });
clearTimeout(timeoutId);
if (!response.ok) { if (!response.ok) {
if (response.status === 413) {
throw new Error('이미지 파일이 너무 커서 업로드할 수 없습니다. 더 작은 이미지를 선택해주세요.');
}
throw new Error(`HTTP error! status: ${response.status}`); throw new Error(`HTTP error! status: ${response.status}`);
} }
return response.json(); return response.json();
} catch (error) { } catch (error) {
clearTimeout(timeoutId);
if (error instanceof Error && error.name === 'AbortError') { if (error instanceof Error && error.name === 'AbortError') {
throw new Error('이미지 업로드 시간이 초과되었습니다. 다시 시도해주세요.'); throw new Error('이미지 업로드 시간이 초과되었습니다. 다시 시도해주세요.');
} }
throw error; throw error;
} finally {
clearTimeout(timeoutId);
} }
} }

View File

@ -0,0 +1,249 @@
export const MAX_UPLOAD_IMAGE_BYTES = 2 * 1024 * 1024;
export const MAX_UPLOAD_IMAGE_DIMENSION = 2048;
export const MAX_FALLBACK_UPLOAD_IMAGE_BYTES = 15 * 1024 * 1024;
const OUTPUT_MIME_TYPE = 'image/jpeg';
const OUTPUT_EXTENSION = 'jpg';
const BACKEND_IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif']);
const BACKEND_IMAGE_MIME_TYPES = new Set([
'image/jpeg',
'image/png',
'image/webp',
'image/heic',
'image/heif',
'image/heic-sequence',
'image/heif-sequence',
]);
interface ImageDimensions {
width: number;
height: number;
}
interface DecodedImage extends ImageDimensions {
source: CanvasImageSource;
dispose: () => void;
}
interface CompressionAttempt {
scale: number;
quality: number;
}
const COMPRESSION_ATTEMPTS: CompressionAttempt[] = [
{ scale: 1, quality: 0.86 },
{ scale: 1, quality: 0.74 },
{ scale: 1, quality: 0.62 },
{ scale: 1, quality: 0.5 },
{ scale: 0.85, quality: 0.7 },
{ scale: 0.7, quality: 0.65 },
{ scale: 0.55, quality: 0.6 },
{ scale: 0.4, quality: 0.55 },
];
export function calculateTargetDimensions(
width: number,
height: number,
maxDimension = MAX_UPLOAD_IMAGE_DIMENSION
): ImageDimensions {
if (
!Number.isFinite(width) ||
!Number.isFinite(height) ||
!Number.isFinite(maxDimension) ||
width <= 0 ||
height <= 0 ||
maxDimension <= 0
) {
throw new Error('Image dimensions must be positive finite numbers.');
}
const scale = Math.min(1, maxDimension / Math.max(width, height));
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
};
}
export function shouldCompressImage(
fileSize: number,
dimensions: ImageDimensions,
maxBytes = MAX_UPLOAD_IMAGE_BYTES,
maxDimension = MAX_UPLOAD_IMAGE_DIMENSION
): boolean {
return (
fileSize > maxBytes ||
dimensions.width > maxDimension ||
dimensions.height > maxDimension
);
}
export function buildCompressedFileName(fileName: string): string {
const extensionIndex = fileName.lastIndexOf('.');
const baseName = extensionIndex > 0 ? fileName.slice(0, extensionIndex) : fileName;
return `${baseName || 'image'}.${OUTPUT_EXTENSION}`;
}
function getFileExtension(fileName: string): string {
const extensionIndex = fileName.lastIndexOf('.');
return extensionIndex >= 0 ? fileName.slice(extensionIndex + 1).toLowerCase() : '';
}
export function isImageInputFile(file: Pick<File, 'name' | 'type'>): boolean {
return file.type.startsWith('image/') || BACKEND_IMAGE_EXTENSIONS.has(getFileExtension(file.name));
}
export function canUploadOriginalImage(file: Pick<File, 'name' | 'type'>): boolean {
const hasAllowedExtension = BACKEND_IMAGE_EXTENSIONS.has(getFileExtension(file.name));
const hasAllowedMimeType = file.type === '' || BACKEND_IMAGE_MIME_TYPES.has(file.type.toLowerCase());
return hasAllowedExtension && hasAllowedMimeType;
}
export function canUploadOriginalAfterCompressionFailure(
fileSize: number,
maxBytes = MAX_FALLBACK_UPLOAD_IMAGE_BYTES
): boolean {
return fileSize <= maxBytes;
}
function useOriginalOrThrow(file: File, cause: unknown): File {
if (!canUploadOriginalImage(file)) {
throw new Error(
`${file.name} 파일을 지원하는 이미지 형식으로 변환할 수 없습니다. ` +
'JPEG, PNG 또는 WebP로 변환한 뒤 다시 시도해주세요.',
{ cause }
);
}
if (!canUploadOriginalAfterCompressionFailure(file.size)) {
throw new Error(
`${file.name} 파일은 15 MB보다 크고 브라우저에서 크기를 줄일 수 없습니다. ` +
'JPEG나 PNG로 변환하거나 더 작은 이미지를 선택해주세요.',
{ cause }
);
}
console.warn(`Image compression was unavailable for ${file.name}; uploading the original.`, cause);
return file;
}
function decodeWithImageElement(file: File): Promise<DecodedImage> {
return new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const image = new Image();
image.onload = () => {
resolve({
source: image,
width: image.naturalWidth,
height: image.naturalHeight,
dispose: () => URL.revokeObjectURL(objectUrl),
});
};
image.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error(`Unable to decode image: ${file.name}`));
};
image.src = objectUrl;
});
}
async function decodeImage(file: File): Promise<DecodedImage> {
if (typeof createImageBitmap === 'function') {
const bitmap = await createImageBitmap(file);
return {
source: bitmap,
width: bitmap.width,
height: bitmap.height,
dispose: () => bitmap.close(),
};
}
return decodeWithImageElement(file);
}
function canvasToBlob(canvas: HTMLCanvasElement, quality: number): Promise<Blob> {
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('The browser could not compress the image.'));
}
},
OUTPUT_MIME_TYPE,
quality
);
});
}
export async function compressImageForUpload(file: File): Promise<File> {
if (!isImageInputFile(file)) {
throw new Error(`지원하지 않는 이미지 파일입니다: ${file.name}`);
}
let decoded: DecodedImage;
try {
decoded = await decodeImage(file);
} catch (error) {
return useOriginalOrThrow(file, error);
}
try {
if (
canUploadOriginalImage(file) &&
!shouldCompressImage(file.size, {
width: decoded.width,
height: decoded.height,
})
) {
return file;
}
const baseDimensions = calculateTargetDimensions(decoded.width, decoded.height);
let smallestBlob: Blob | null = null;
try {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) {
throw new Error('The browser does not support image compression.');
}
for (const attempt of COMPRESSION_ATTEMPTS) {
canvas.width = Math.max(1, Math.round(baseDimensions.width * attempt.scale));
canvas.height = Math.max(1, Math.round(baseDimensions.height * attempt.scale));
// JPEG has no alpha channel. A white background avoids transparent pixels
// becoming black when PNG/WebP images are converted.
context.fillStyle = '#ffffff';
context.fillRect(0, 0, canvas.width, canvas.height);
context.drawImage(decoded.source, 0, 0, canvas.width, canvas.height);
const blob = await canvasToBlob(canvas, attempt.quality);
if (!smallestBlob || blob.size < smallestBlob.size) {
smallestBlob = blob;
}
if (blob.size <= MAX_UPLOAD_IMAGE_BYTES) {
return new File([blob], buildCompressedFileName(file.name), {
type: blob.type || OUTPUT_MIME_TYPE,
lastModified: file.lastModified,
});
}
}
} catch (error) {
return useOriginalOrThrow(file, error);
}
const measuredSize = smallestBlob?.size ?? file.size;
throw new Error(
`${file.name} 파일을 ${Math.round(MAX_UPLOAD_IMAGE_BYTES / (1024 * 1024))} MB 이하로 ` +
`압축할 수 없습니다(압축 결과: ${Math.ceil(measuredSize / (1024 * 1024))} MB).`
);
} finally {
decoded.dispose();
}
}

83
src/utils/imageUpload.ts Normal file
View File

@ -0,0 +1,83 @@
import type { ImageUploadResponse, ImageUrlItem } from '../types/api';
import { compressImageForUpload } from './imageCompression.ts';
export const MAX_IMAGES_PER_UPLOAD_TASK = 100;
export type ImageUploadRequest = (formData: FormData) => Promise<ImageUploadResponse>;
export type ImageCompressor = (file: File) => Promise<File>;
interface ImageUploadFormOptions {
imageUrls?: ImageUrlItem[];
file?: File;
taskId?: string;
finalize?: boolean;
}
export function createImageUploadFormData({
imageUrls = [],
file,
taskId,
finalize,
}: ImageUploadFormOptions): FormData {
const formData = new FormData();
if (imageUrls.length > 0) {
formData.append('images_json', JSON.stringify(imageUrls));
}
if (taskId) {
formData.append('task_id', taskId);
}
if (finalize !== undefined) {
formData.append('finalize', String(finalize));
}
if (file) {
formData.append('files', file);
}
return formData;
}
export async function uploadImagesSequentially(
imageUrls: ImageUrlItem[],
files: File[],
sendRequest: ImageUploadRequest,
compressFile: ImageCompressor = compressImageForUpload
): Promise<ImageUploadResponse> {
const totalImageCount = imageUrls.length + files.length;
if (totalImageCount > MAX_IMAGES_PER_UPLOAD_TASK) {
throw new Error(`이미지는 한 번에 최대 ${MAX_IMAGES_PER_UPLOAD_TASK}장까지 업로드할 수 있습니다.`);
}
if (files.length === 0) {
return sendRequest(createImageUploadFormData({ imageUrls }));
}
let taskId: string | undefined;
let finalResponse: ImageUploadResponse | undefined;
for (let index = 0; index < files.length; index += 1) {
// Compress and upload one source at a time. This prevents both decoded
// image buffers and multipart request bodies from accumulating in memory.
const compressedFile = await compressFile(files[index]);
const isFirstRequest = index === 0;
const isLastRequest = index === files.length - 1;
const formData = createImageUploadFormData({
imageUrls: isFirstRequest ? imageUrls : [],
file: compressedFile,
taskId,
finalize: isLastRequest,
});
finalResponse = await sendRequest(formData);
if (!finalResponse.task_id) {
throw new Error('이미지 업로드 응답에 작업 ID가 없습니다.');
}
if (taskId && finalResponse.task_id !== taskId) {
throw new Error('이미지 업로드 작업 ID가 요청 사이에 변경되었습니다. 다시 시도해주세요.');
}
taskId = finalResponse.task_id;
}
// files.length > 0 guarantees that the loop produced a response.
return finalResponse as ImageUploadResponse;
}