feat: 앨범 커버 이미지 3개 해상도로 저장

- DB 스키마 변경: cover_url → cover_original_url, cover_medium_url, cover_thumb_url
- 백엔드: 앨범 생성/수정 시 original/800/400 3개 크기로 저장
- 프론트엔드: 용도에 맞게 적절한 해상도 사용
  - 앨범 목록: medium
  - 상세 페이지: medium
  - 관리자 목록: thumb
This commit is contained in:
caadiq 2026-01-04 11:34:31 +09:00
parent 1c42d3333c
commit b262907780
7 changed files with 135 additions and 56 deletions

View file

@ -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);
}
}
}

View file

@ -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"
);
// 각 앨범에 트랙 정보 추가

View file

@ -121,7 +121,7 @@ function Album() {
style={{ viewTransitionName: `album-cover-${album.id}` }}
>
<img
src={album.cover_url}
src={album.cover_medium_url || album.cover_original_url}
alt={album.title}
loading="lazy"
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-500 will-change-transform"

View file

@ -249,7 +249,7 @@ function AlbumDetail() {
className="w-80 h-80 flex-shrink-0 rounded-2xl overflow-hidden shadow-lg"
>
<img
src={album.cover_url}
src={album.cover_medium_url || album.cover_original_url}
alt={album.title}
className="w-full h-full object-cover"
/>

View file

@ -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);

View file

@ -720,7 +720,7 @@ function AdminAlbumPhotos() {
>
<div className="flex items-center gap-4">
<img
src={album?.cover_url}
src={album?.cover_thumb_url || album?.cover_original_url}
alt={album?.title}
className="w-14 h-14 rounded-xl object-cover"
/>

View file

@ -260,7 +260,7 @@ function AdminAlbums() {
<td className="px-6 py-4">
<div className="flex items-center gap-4">
<img
src={album.cover_url}
src={album.cover_thumb_url || album.cover_original_url}
alt={album.title}
className="w-12 h-12 rounded-lg object-cover"
/>