feat: 이미지 업로드를 압축 후 순차 전송 방식으로 전환
This commit is contained in:
parent
c4c46db0d9
commit
23938871b0
@ -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"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -268,6 +268,8 @@
|
|||||||
"back": "뒤로가기",
|
"back": "뒤로가기",
|
||||||
"loadMore": "더보기",
|
"loadMore": "더보기",
|
||||||
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
"uploadFailed": "이미지 업로드에 실패했습니다.",
|
||||||
|
"uploadErrorTitle": "이미지 업로드 오류",
|
||||||
|
"uploadErrorConfirm": "확인",
|
||||||
"uploading": "업로드 중 (30~60초 소요)",
|
"uploading": "업로드 중 (30~60초 소요)",
|
||||||
"nextStep": "다음 단계"
|
"nextStep": "다음 단계"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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로 전달
|
|
||||||
files.forEach((file) => {
|
|
||||||
formData.append('files', file);
|
|
||||||
});
|
|
||||||
|
|
||||||
|
async function postImageUpload(formData: FormData): Promise<ImageUploadResponse> {
|
||||||
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
249
src/utils/imageCompression.ts
Normal file
249
src/utils/imageCompression.ts
Normal 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
83
src/utils/imageUpload.ts
Normal 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;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user