diff --git a/backend/routes/admin.js b/backend/routes/admin.js index 526e2a2..3159013 100644 --- a/backend/routes/admin.js +++ b/backend/routes/admin.js @@ -171,40 +171,75 @@ router.post( .json({ error: "필수 필드를 모두 입력해주세요." }); } - let coverUrl = null; + let coverOriginalUrl = null; + let coverMediumUrl = null; + let coverThumbUrl = null; - // 커버 이미지 업로드 + // 커버 이미지 업로드 (3개 해상도) if (req.file) { - // WebP 변환 (원본 크기, 무손실) - const processedImage = await sharp(req.file.buffer) - .webp({ lossless: true }) - .toBuffer(); + // 3가지 크기로 변환 (병렬) + const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([ + sharp(req.file.buffer).webp({ lossless: true }).toBuffer(), + sharp(req.file.buffer) + .resize(800, null, { withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer(), + sharp(req.file.buffer) + .resize(400, null, { withoutEnlargement: true }) + .webp({ quality: 80 }) + .toBuffer(), + ]); - const coverKey = `album/${folder_name}/cover.webp`; + const basePath = `album/${folder_name}/cover`; - await s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: coverKey, - Body: processedImage, - ContentType: "image/webp", - }) - ); + // S3 업로드 (병렬) + await Promise.all([ + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/original/cover.webp`, + Body: originalBuffer, + ContentType: "image/webp", + }) + ), + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/medium_800/cover.webp`, + Body: mediumBuffer, + ContentType: "image/webp", + }) + ), + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/thumb_400/cover.webp`, + Body: thumbBuffer, + ContentType: "image/webp", + }) + ), + ]); - coverUrl = `${process.env.RUSTFS_ENDPOINT}/${BUCKET}/${coverKey}`; + const publicUrl = + process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; + coverOriginalUrl = `${publicUrl}/${BUCKET}/${basePath}/original/cover.webp`; + coverMediumUrl = `${publicUrl}/${BUCKET}/${basePath}/medium_800/cover.webp`; + coverThumbUrl = `${publicUrl}/${BUCKET}/${basePath}/thumb_400/cover.webp`; } // 앨범 삽입 const [albumResult] = await connection.query( - `INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, cover_url, description) - VALUES (?, ?, ?, ?, ?, ?, ?)`, + `INSERT INTO albums (title, album_type, album_type_short, release_date, folder_name, cover_original_url, cover_medium_url, cover_thumb_url, description) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ title, album_type, album_type_short || null, release_date, folder_name, - coverUrl, + coverOriginalUrl, + coverMediumUrl, + coverThumbUrl, description || null, ] ); @@ -278,31 +313,65 @@ router.put( return res.status(404).json({ error: "앨범을 찾을 수 없습니다." }); } - let coverUrl = existingAlbums[0].cover_url; + let coverOriginalUrl = existingAlbums[0].cover_original_url; + let coverMediumUrl = existingAlbums[0].cover_medium_url; + let coverThumbUrl = existingAlbums[0].cover_thumb_url; - // WebP 변환 (원본 크기, 무손실) + // 커버 이미지 업로드 (3개 해상도) if (req.file) { - const processedImage = await sharp(req.file.buffer) - .webp({ lossless: true }) - .toBuffer(); + // 3가지 크기로 변환 (병렬) + const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([ + sharp(req.file.buffer).webp({ lossless: true }).toBuffer(), + sharp(req.file.buffer) + .resize(800, null, { withoutEnlargement: true }) + .webp({ quality: 85 }) + .toBuffer(), + sharp(req.file.buffer) + .resize(400, null, { withoutEnlargement: true }) + .webp({ quality: 80 }) + .toBuffer(), + ]); - const coverKey = `album/${folder_name}/cover.webp`; + const basePath = `album/${folder_name}/cover`; - await s3Client.send( - new PutObjectCommand({ - Bucket: BUCKET, - Key: coverKey, - Body: processedImage, - ContentType: "image/webp", - }) - ); + // S3 업로드 (병렬) + await Promise.all([ + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/original/cover.webp`, + Body: originalBuffer, + ContentType: "image/webp", + }) + ), + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/medium_800/cover.webp`, + Body: mediumBuffer, + ContentType: "image/webp", + }) + ), + s3Client.send( + new PutObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/thumb_400/cover.webp`, + Body: thumbBuffer, + ContentType: "image/webp", + }) + ), + ]); - coverUrl = `${process.env.RUSTFS_ENDPOINT}/${BUCKET}/${coverKey}`; + const publicUrl = + process.env.RUSTFS_PUBLIC_URL || process.env.RUSTFS_ENDPOINT; + coverOriginalUrl = `${publicUrl}/${BUCKET}/${basePath}/original/cover.webp`; + coverMediumUrl = `${publicUrl}/${BUCKET}/${basePath}/medium_800/cover.webp`; + coverThumbUrl = `${publicUrl}/${BUCKET}/${basePath}/thumb_400/cover.webp`; } // 앨범 업데이트 await connection.query( - `UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, folder_name = ?, cover_url = ?, description = ? + `UPDATE albums SET title = ?, album_type = ?, album_type_short = ?, release_date = ?, folder_name = ?, cover_original_url = ?, cover_medium_url = ?, cover_thumb_url = ?, description = ? WHERE id = ?`, [ title, @@ -310,7 +379,9 @@ router.put( album_type_short || null, release_date, folder_name, - coverUrl, + coverOriginalUrl, + coverMediumUrl, + coverThumbUrl, description || null, albumId, ] @@ -375,17 +446,21 @@ router.delete("/albums/:id", authenticateToken, async (req, res) => { const album = existingAlbums[0]; - // RustFS에서 커버 이미지 삭제 - if (album.cover_url && album.folder_name) { - try { - await s3Client.send( - new DeleteObjectCommand({ - Bucket: BUCKET, - Key: `album/${album.folder_name}/cover.webp`, - }) - ); - } catch (s3Error) { - console.error("S3 삭제 오류:", s3Error); + // RustFS에서 커버 이미지 삭제 (3가지 크기) + if (album.cover_original_url && album.folder_name) { + const basePath = `album/${album.folder_name}/cover`; + const sizes = ["original", "medium_800", "thumb_400"]; + for (const size of sizes) { + try { + await s3Client.send( + new DeleteObjectCommand({ + Bucket: BUCKET, + Key: `${basePath}/${size}/cover.webp`, + }) + ); + } catch (s3Error) { + console.error(`S3 커버 삭제 오류 (${size}):`, s3Error); + } } } diff --git a/backend/routes/albums.js b/backend/routes/albums.js index 669de07..a58440a 100644 --- a/backend/routes/albums.js +++ b/backend/routes/albums.js @@ -62,7 +62,7 @@ async function getAlbumDetails(album) { router.get("/", async (req, res) => { try { const [albums] = await pool.query( - "SELECT id, title, album_type, album_type_short, release_date, cover_url FROM albums ORDER BY release_date DESC" + "SELECT id, title, album_type, album_type_short, release_date, cover_original_url, cover_medium_url, cover_thumb_url FROM albums ORDER BY release_date DESC" ); // 각 앨범에 트랙 정보 추가 diff --git a/frontend/src/pages/pc/Album.jsx b/frontend/src/pages/pc/Album.jsx index 88eb6f6..fbc367c 100644 --- a/frontend/src/pages/pc/Album.jsx +++ b/frontend/src/pages/pc/Album.jsx @@ -121,7 +121,7 @@ function Album() { style={{ viewTransitionName: `album-cover-${album.id}` }} > {album.title} {album.title} diff --git a/frontend/src/pages/pc/admin/AdminAlbumForm.jsx b/frontend/src/pages/pc/admin/AdminAlbumForm.jsx index abaddcf..727b133 100644 --- a/frontend/src/pages/pc/admin/AdminAlbumForm.jsx +++ b/frontend/src/pages/pc/admin/AdminAlbumForm.jsx @@ -371,7 +371,9 @@ function AdminAlbumForm() { album_type: '', album_type_short: '', release_date: '', - cover_url: '', + cover_original_url: '', + cover_medium_url: '', + cover_thumb_url: '', folder_name: '', description: '', }); @@ -399,12 +401,14 @@ function AdminAlbumForm() { album_type: data.album_type || '', album_type_short: data.album_type_short || '', release_date: data.release_date ? data.release_date.split('T')[0] : '', - cover_url: data.cover_url || '', + cover_original_url: data.cover_original_url || '', + cover_medium_url: data.cover_medium_url || '', + cover_thumb_url: data.cover_thumb_url || '', folder_name: data.folder_name || '', description: data.description || '', }); - if (data.cover_url) { - setCoverPreview(data.cover_url); + if (data.cover_medium_url || data.cover_original_url) { + setCoverPreview(data.cover_medium_url || data.cover_original_url); } setTracks(data.tracks || []); setLoading(false); diff --git a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx index a6f9978..de98d7c 100644 --- a/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx +++ b/frontend/src/pages/pc/admin/AdminAlbumPhotos.jsx @@ -720,7 +720,7 @@ function AdminAlbumPhotos() { >
{album?.title} diff --git a/frontend/src/pages/pc/admin/AdminAlbums.jsx b/frontend/src/pages/pc/admin/AdminAlbums.jsx index 899f7e5..e984d3c 100644 --- a/frontend/src/pages/pc/admin/AdminAlbums.jsx +++ b/frontend/src/pages/pc/admin/AdminAlbums.jsx @@ -260,7 +260,7 @@ function AdminAlbums() {
{album.title}