import { S3Client, PutObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; import sharp from 'sharp'; import config from '../config/index.js'; // S3 클라이언트 생성 const s3Client = new S3Client({ endpoint: config.s3.endpoint, region: 'us-east-1', credentials: { accessKeyId: config.s3.accessKey, secretAccessKey: config.s3.secretKey, }, forcePathStyle: true, }); const BUCKET = config.s3.bucket; const PUBLIC_URL = config.s3.publicUrl; // 이미지 처리 설정 const { medium, thumb } = config.image; /** * 이미지를 3가지 해상도로 변환 */ async function processImage(buffer) { const [originalBuffer, mediumBuffer, thumbBuffer] = await Promise.all([ sharp(buffer).webp({ lossless: true }).toBuffer(), sharp(buffer) .resize(medium.width, null, { withoutEnlargement: true }) .webp({ quality: medium.quality }) .toBuffer(), sharp(buffer) .resize(thumb.width, null, { withoutEnlargement: true }) .webp({ quality: thumb.quality }) .toBuffer(), ]); return { originalBuffer, mediumBuffer, thumbBuffer }; } /** * S3에 이미지 업로드 */ async function uploadToS3(key, buffer, contentType = 'image/webp') { await s3Client.send(new PutObjectCommand({ Bucket: BUCKET, Key: key, Body: buffer, ContentType: contentType, })); return `${PUBLIC_URL}/${BUCKET}/${key}`; } /** * S3에서 이미지 삭제 */ async function deleteFromS3(key) { try { await s3Client.send(new DeleteObjectCommand({ Bucket: BUCKET, Key: key, })); } catch (err) { console.error(`S3 삭제 오류 (${key}):`, err.message); } } /** * 멤버 프로필 이미지 업로드 * @param {string} name - 멤버 이름 * @param {Buffer} buffer - 이미지 버퍼 * @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>} */ export async function uploadMemberImage(name, buffer) { const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer); const basePath = `member/${name}`; const filename = `${name}.webp`; // 병렬 업로드 const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([ uploadToS3(`${basePath}/original/${filename}`, originalBuffer), uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer), uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer), ]); return { originalUrl, mediumUrl, thumbUrl }; } /** * 멤버 프로필 이미지 삭제 * @param {string} name - 멤버 이름 */ export async function deleteMemberImage(name) { const basePath = `member/${name}`; const filename = `${name}.webp`; const sizes = ['original', 'medium_800', 'thumb_400']; await Promise.all( sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`)) ); } /** * 앨범 커버 이미지 업로드 * @param {string} folderName - 앨범 폴더명 * @param {Buffer} buffer - 이미지 버퍼 * @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string}>} */ export async function uploadAlbumCover(folderName, buffer) { const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer); const basePath = `album/${folderName}/cover`; const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([ uploadToS3(`${basePath}/original/cover.webp`, originalBuffer), uploadToS3(`${basePath}/medium_800/cover.webp`, mediumBuffer), uploadToS3(`${basePath}/thumb_400/cover.webp`, thumbBuffer), ]); return { originalUrl, mediumUrl, thumbUrl }; } /** * 앨범 커버 이미지 삭제 * @param {string} folderName - 앨범 폴더명 */ export async function deleteAlbumCover(folderName) { const basePath = `album/${folderName}/cover`; const sizes = ['original', 'medium_800', 'thumb_400']; await Promise.all( sizes.map(size => deleteFromS3(`${basePath}/${size}/cover.webp`)) ); } /** * 앨범 사진 업로드 (컨셉포토 또는 티저) * @param {string} folderName - 앨범 폴더명 * @param {string} subFolder - 'photo' 또는 'teaser' * @param {string} filename - 파일명 (예: '01.webp') * @param {Buffer} buffer - 이미지 버퍼 * @returns {Promise<{originalUrl: string, mediumUrl: string, thumbUrl: string, metadata: object}>} */ export async function uploadAlbumPhoto(folderName, subFolder, filename, buffer) { const { originalBuffer, mediumBuffer, thumbBuffer } = await processImage(buffer); const metadata = await sharp(originalBuffer).metadata(); const basePath = `album/${folderName}/${subFolder}`; const [originalUrl, mediumUrl, thumbUrl] = await Promise.all([ uploadToS3(`${basePath}/original/${filename}`, originalBuffer), uploadToS3(`${basePath}/medium_800/${filename}`, mediumBuffer), uploadToS3(`${basePath}/thumb_400/${filename}`, thumbBuffer), ]); return { originalUrl, mediumUrl, thumbUrl, metadata: { width: metadata.width, height: metadata.height, size: originalBuffer.length, }, }; } /** * 앨범 사진 삭제 * @param {string} folderName - 앨범 폴더명 * @param {string} subFolder - 'photo' 또는 'teaser' * @param {string} filename - 파일명 */ export async function deleteAlbumPhoto(folderName, subFolder, filename) { const basePath = `album/${folderName}/${subFolder}`; const sizes = ['original', 'medium_800', 'thumb_400']; await Promise.all( sizes.map(size => deleteFromS3(`${basePath}/${size}/${filename}`)) ); } /** * 앨범 비디오 업로드 (티저 전용) * @param {string} folderName - 앨범 폴더명 * @param {string} filename - 파일명 (예: '01.mp4') * @param {Buffer} buffer - 비디오 버퍼 * @returns {Promise} - 비디오 URL */ export async function uploadAlbumVideo(folderName, filename, buffer) { const key = `album/${folderName}/teaser/video/${filename}`; return await uploadToS3(key, buffer, 'video/mp4'); } /** * 앨범 비디오 삭제 * @param {string} folderName - 앨범 폴더명 * @param {string} filename - 파일명 */ export async function deleteAlbumVideo(folderName, filename) { await deleteFromS3(`album/${folderName}/teaser/video/${filename}`); }