diff --git a/.github/workflows/jib-build.yaml b/.github/workflows/jib-build.yaml index 70e2704..e8eddb0 100644 --- a/.github/workflows/jib-build.yaml +++ b/.github/workflows/jib-build.yaml @@ -58,6 +58,7 @@ jobs: IMAGE_NAME: ${{ inputs.image-name }} IMAGE_TAG: ${{ inputs.image-tag }} SERVER_PORT: ${{ inputs.server-port }} + RECAP_SRC_URL: ${{ secrets.RECAP_SRC_URL }} run: | cd ${{ inputs.module-path }} && \ chmod +x gradlew && ./gradlew jib diff --git a/photo-service/Dockerfile b/photo-service/Dockerfile new file mode 100644 index 0000000..76d4e5e --- /dev/null +++ b/photo-service/Dockerfile @@ -0,0 +1,11 @@ +FROM amazoncorretto:17-alpine3.17-jdk + +ARG RECAP_SRC_URL +RUN apt-get update && \ + apt-get install -y ffmpeg && \ + apt-get clean + +RUN mkdir -p /usr/bin/recap/tmp && \ + wget -O /tmp/src.tar.gz $RECAP_SRC_URL && \ + tar -xvzf /tmp/src.tar.gz -C /usr/bin/recap && \ + rm /tmp/src.tar.gz diff --git a/photo-service/build.gradle.kts b/photo-service/build.gradle.kts index db7418d..30722bc 100644 --- a/photo-service/build.gradle.kts +++ b/photo-service/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { implementation("io.opentelemetry:opentelemetry-exporter-zipkin:1.40.0") implementation("io.micrometer:micrometer-registry-prometheus:1.13.2") implementation("com.slack.api:slack-api-client:1.40.3") + implementation("net.bramp.ffmpeg:ffmpeg:0.8.0") } tasks.withType { @@ -51,14 +52,18 @@ jib { val imageName: String? = System.getenv("IMAGE_NAME") val imageTag: String? = System.getenv("IMAGE_TAG") val serverPort: String = System.getenv("SERVER_PORT") ?: "8080" + val recapSrcUrl: String? = System.getenv("RECAP_SRC_URL") from { - image = "amazoncorretto:17-alpine3.17-jdk" + image = "custom-base-image" } to { image = imageName tags = setOf(imageTag, "latest") } container { + environment = mapOf( + "RECAP_SRC_URL" to recapSrcUrl + ) jvmFlags = listOf( "-Dspring.profiles.active=$activeProfile", diff --git a/photo-service/src/main/java/kr/mafoo/photo/api/RecapApi.java b/photo-service/src/main/java/kr/mafoo/photo/api/RecapApi.java new file mode 100644 index 0000000..a418448 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/api/RecapApi.java @@ -0,0 +1,36 @@ +package kr.mafoo.photo.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import kr.mafoo.photo.annotation.RequestMemberId; +import kr.mafoo.photo.controller.dto.request.RecapCreateRequest; +import kr.mafoo.photo.controller.dto.response.RecapResponse; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import reactor.core.publisher.Mono; + +@Validated +@Tag(name = "리캡 관련 API", description = "리캡 생성 API") +@RequestMapping("/v1/recaps") +public interface RecapApi { + @Operation(summary = "리캡 생성", description = "앨범의 리캡을 생성합니다.") + @PostMapping + Mono createRecap( + @RequestMemberId + String memberId, + + @Valid + @RequestBody + RecapCreateRequest request, + + @Parameter(description = "정렬 종류", example = "ASC | DESC") + @RequestParam(required = false) + String sort, + + @RequestHeader("Authorization") + String authorizationToken + ); + +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/config/FFmpegConfig.java b/photo-service/src/main/java/kr/mafoo/photo/config/FFmpegConfig.java new file mode 100644 index 0000000..99c3274 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/config/FFmpegConfig.java @@ -0,0 +1,24 @@ +package kr.mafoo.photo.config; + +import lombok.extern.slf4j.Slf4j; +import net.bramp.ffmpeg.FFmpeg; +import net.bramp.ffmpeg.FFmpegExecutor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; + +@Slf4j +@Configuration +public class FFmpegConfig { + + @Value("${ffmpeg.path}") + private String ffmpegPath; + + @Bean + public FFmpegExecutor ffMpegExecutor() throws IOException { + FFmpeg ffmpeg = new FFmpeg(ffmpegPath); + return new FFmpegExecutor(ffmpeg); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/controller/RecapController.java b/photo-service/src/main/java/kr/mafoo/photo/controller/RecapController.java new file mode 100644 index 0000000..aec3718 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/controller/RecapController.java @@ -0,0 +1,28 @@ +package kr.mafoo.photo.controller; + +import kr.mafoo.photo.api.RecapApi; +import kr.mafoo.photo.controller.dto.request.RecapCreateRequest; +import kr.mafoo.photo.controller.dto.response.RecapResponse; +import kr.mafoo.photo.service.RecapService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.RestController; +import reactor.core.publisher.Mono; + +@RequiredArgsConstructor +@RestController +public class RecapController implements RecapApi { + + private final RecapService recapService; + + @Override + public Mono createRecap( + String memberId, + RecapCreateRequest request, + String sort, + String authorizationToken + ) { + return recapService.createRecap(request.albumId(), memberId, sort, authorizationToken) + .map(RecapResponse::fromString); + } + +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/controller/dto/request/RecapCreateRequest.java b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/request/RecapCreateRequest.java new file mode 100644 index 0000000..bfd179e --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/request/RecapCreateRequest.java @@ -0,0 +1,12 @@ +package kr.mafoo.photo.controller.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import kr.mafoo.photo.annotation.ULID; + +@Schema(description = "리캡 생성 요청") +public record RecapCreateRequest( + @ULID + @Schema(description = "앨범 ID", example = "test_album_id") + String albumId +) { +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/RecapResponse.java b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/RecapResponse.java new file mode 100644 index 0000000..97d7917 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/controller/dto/response/RecapResponse.java @@ -0,0 +1,19 @@ +package kr.mafoo.photo.controller.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "리캡 응답") +public record RecapResponse( + + @Schema(description = "리캡 URL", example = "recap_url") + String recapUrl + +) { + public static RecapResponse fromString( + String recapUrl + ) { + return new RecapResponse( + recapUrl + ); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/Graphics2dService.java b/photo-service/src/main/java/kr/mafoo/photo/service/Graphics2dService.java new file mode 100644 index 0000000..bf651f4 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/Graphics2dService.java @@ -0,0 +1,167 @@ +package kr.mafoo.photo.service; + +import kr.mafoo.photo.service.properties.RecapProperties; +import lombok.RequiredArgsConstructor; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; + +import javax.imageio.ImageIO; +import java.awt.*; +import java.awt.image.BufferedImage; +import java.io.File; +import java.io.IOException; +import java.util.HashMap; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class Graphics2dService { + + private final RecapProperties recapProperties; + + public Mono generateAlbumChipForRecap(String recapId, String albumName, String albumType) { + + return Mono.fromCallable(() -> createAlbumChipImage(recapId, albumName, albumType)) + .flatMap(buffer -> ServerResponse.ok() + .contentType(MediaType.IMAGE_PNG) + .bodyValue(buffer)) + .onErrorResume(IOException.class, e -> ServerResponse.status(500) + .bodyValue("Error generating album chip image: " + e.getMessage())).then(); + } + + private final Map iconCache = new HashMap<>(); + + private FontMetrics cachedMetrics; + + private String createAlbumChipImage(String recapId, String albumName, String albumType) throws IOException { + int paddingLeftRight = 32; + int paddingTopBottom = 22; + int iconTextSpacing = 8; + int borderThickness = 2; + Font font = new Font(recapProperties.getPretendardFontPath(), Font.BOLD, 36); + + FontMetrics metrics = getCachedFontMetrics(font); + BufferedImage icon = getCachedIcon(albumType); + + int chipHeight = 90; + int chipWidth = calculateChipWidth( + icon.getWidth(), + metrics.stringWidth(albumName), + paddingLeftRight, + iconTextSpacing + ); + + // TODO: 이해가 쉬운 형태로 정리 필요 + int[] coordinates = calculateCoordinates( + chipHeight, + metrics.getHeight(), + paddingTopBottom, + paddingLeftRight, + icon.getHeight(), + icon.getWidth(), + iconTextSpacing, + metrics.getAscent() + ); + + BufferedImage image = new BufferedImage(chipWidth, chipHeight, BufferedImage.TYPE_INT_ARGB); + Graphics2D g2d = setupGraphics(image, font); + + drawRoundedRectangle( + g2d, + new Color(255, 255, 255, 180), + chipWidth, + coordinates[0], + metrics.getHeight(), + paddingTopBottom, + borderThickness, + chipHeight + ); + + drawIcon( + g2d, + icon, + paddingLeftRight, + coordinates[1] + ); + + drawText( + g2d, + albumName, + new Color(33, 37, 41), + coordinates[2], + coordinates[3] + ); + + g2d.dispose(); + + return saveImage(generateAlbumChipImagePath(recapId), image); + } + + private int calculateChipWidth(int iconWidth, int textWidth, int paddingLeftRight, int iconTextSpacing) { + return iconWidth + iconTextSpacing + textWidth + paddingLeftRight * 2; + } + + // TODO: 이해가 쉬운 형태로 정리 필요 + private int[] calculateCoordinates(int imageHeight, int textHeight, int paddingTopBottom, int paddingLeftRight, int iconHeight, int iconWidth, int iconTextSpacing, int ascent) { + int y = (imageHeight - textHeight - paddingTopBottom * 2) / 2; + int iconY = y + (textHeight + paddingTopBottom * 2 - iconHeight) / 2; + int textX = paddingLeftRight + iconWidth + iconTextSpacing; + int textBaselineY = y + paddingTopBottom + ascent; + return new int[]{y, iconY, textX, textBaselineY}; + } + + private String generateAlbumChipImagePath(String recapId) { + return recapProperties.getChipFilePath(recapId); + } + + private FontMetrics getCachedFontMetrics(Font font) { + if (cachedMetrics == null) { + BufferedImage tempImage = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB); + Graphics2D tempGraphics = tempImage.createGraphics(); + tempGraphics.setFont(font); + cachedMetrics = tempGraphics.getFontMetrics(font); + tempGraphics.dispose(); + } + return cachedMetrics; + } + + private BufferedImage getCachedIcon(String iconType) throws IOException { + if (!iconCache.containsKey(iconType)) { + BufferedImage icon = ImageIO.read(new File(recapProperties.getIconPath(iconType))); + iconCache.put(iconType, icon); + } + return iconCache.get(iconType); + } + + private Graphics2D setupGraphics(BufferedImage image, Font font) { + Graphics2D g2d = image.createGraphics(); + g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); + g2d.setFont(font); + return g2d; + } + + private void drawRoundedRectangle(Graphics2D g2d, Color color, int rectangleWidth, int y, int textHeight, int paddingTopBottom, int borderThickness, int imageHeight) { + g2d.setStroke(new BasicStroke(borderThickness)); + g2d.setColor(color); + g2d.drawRoundRect(borderThickness / 2, y + borderThickness / 2, rectangleWidth - borderThickness, textHeight + paddingTopBottom * 2 - borderThickness, imageHeight, imageHeight); + g2d.fillRoundRect(borderThickness / 2, y + borderThickness / 2, rectangleWidth - borderThickness, textHeight + paddingTopBottom * 2 - borderThickness, imageHeight, imageHeight); + } + + private void drawIcon(Graphics2D g2d, BufferedImage icon, int x, int y) { + g2d.drawImage(icon, x, y, null); + } + + private void drawText(Graphics2D g2d, String text, Color color, int x, int y) { + g2d.setColor(color); + g2d.drawString(text, x, y); + } + + private String saveImage(String imagePath, BufferedImage image) throws IOException { + ImageIO.write(image, "png", new File(imagePath)); + return imagePath; + } + +} + diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/LocalFileService.java b/photo-service/src/main/java/kr/mafoo/photo/service/LocalFileService.java new file mode 100644 index 0000000..8b99c1f --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/LocalFileService.java @@ -0,0 +1,40 @@ +package kr.mafoo.photo.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; + +import java.io.File; + +@RequiredArgsConstructor +@Service +public class LocalFileService { + + public Mono deleteSimilarNameFileForPath(String path, String name) { + return Mono.fromCallable(() -> new File(path).listFiles()) + .flatMapMany(files -> { + if (files == null) { + throw new RuntimeException("Failed to retrieve file list from the specified path: " + path); + } + return Flux.fromArray(files); + }) + .filter(file -> file.getName().contains(name) && file.isFile()) + .flatMap(file -> Mono.fromCallable(() -> { + boolean deleted = file.delete(); + if (!deleted) { + throw new RuntimeException("Failed to delete file: " + file.getAbsolutePath()); + } + return deleted; + }) + .subscribeOn(Schedulers.boundedElastic()) + .onErrorResume(e -> Mono.empty()) + .then() + ) + .then() + .onErrorResume(e -> { + throw new RuntimeException("Error occurred while processing files in the directory: " + path, e); + }); + } +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/MemberService.java b/photo-service/src/main/java/kr/mafoo/photo/service/MemberService.java new file mode 100644 index 0000000..260710a --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/MemberService.java @@ -0,0 +1,27 @@ +package kr.mafoo.photo.service; + +import kr.mafoo.photo.service.dto.MemberDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; +import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.ClientResponse; + +@RequiredArgsConstructor +@Service +public class MemberService { + + private final WebClient client; + + public Mono getMemberInfo(String authorizationToken) { + return client + .get() + .uri("https://gateway.mafoo.kr/user/v1/me") + .header("Authorization", "Bearer " + authorizationToken) + .retrieve() + .onStatus(status -> status.is4xxClientError() || status.is5xxServerError(), + ClientResponse::createException) + .bodyToMono(MemberDto.class); + } +} + diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/ObjectStorageService.java b/photo-service/src/main/java/kr/mafoo/photo/service/ObjectStorageService.java index c16232f..8a77dc3 100644 --- a/photo-service/src/main/java/kr/mafoo/photo/service/ObjectStorageService.java +++ b/photo-service/src/main/java/kr/mafoo/photo/service/ObjectStorageService.java @@ -8,7 +8,9 @@ import com.amazonaws.services.s3.model.PutObjectRequest; import kr.mafoo.photo.exception.PreSignedUrlBannedFileType; import kr.mafoo.photo.exception.PreSignedUrlExceedMaximum; +import kr.mafoo.photo.service.properties.RecapProperties; import lombok.extern.slf4j.Slf4j; +import org.apache.commons.io.FileUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -17,12 +19,16 @@ import reactor.core.publisher.Mono; import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; import java.io.InputStream; import java.net.URL; import java.util.Date; import java.util.List; import java.util.UUID; +import java.util.stream.Collectors; +import java.util.stream.IntStream; @Slf4j @Service @@ -40,6 +46,8 @@ public class ObjectStorageService { @Value("${cloud.aws.s3.presigned-url-expiration}") private long presignedUrlExpiration; + private final RecapProperties recapProperties; + public Mono uploadFile(byte[] fileByte) { String keyName = "qr/" + UUID.randomUUID() + ".jpeg"; @@ -60,6 +68,31 @@ public Mono uploadFile(byte[] fileByte) { }); } + public Mono uploadFileFromPath(String filePath) { + return Mono.fromCallable(() -> { + File file = new File(filePath); + String keyName = "recap/" + file.getName(); + + if (!file.exists() || !file.isFile()) { + throw new IllegalArgumentException("Invalid file path: " + filePath); + } + + ObjectMetadata objectMetadata = new ObjectMetadata(); + objectMetadata.setContentLength(file.length()); + objectMetadata.setContentType("application/octet-stream"); + + try (InputStream inputStream = FileUtils.openInputStream(file)) { + amazonS3Client.putObject( + new PutObjectRequest(bucketName, keyName, inputStream, objectMetadata) + .withCannedAcl(CannedAccessControlList.PublicRead)); + + return generateFileLink(keyName); + } catch (IOException e) { + throw new RuntimeException("Failed to upload file to object storage: " + filePath, e); + } + }); + } + public Mono createPreSignedUrls(String[] fileNames, String memberId) { if (fileNames.length > 30) { return Mono.error(new PreSignedUrlExceedMaximum()); @@ -114,4 +147,27 @@ private Mono extractFileType(String fileName) { private String generateFileLink(String keyName) { return endpoint + "/" + bucketName + "/" + keyName; } + + public Mono> downloadFilesForRecap(List fileUrls, String recapId) { + return Mono.defer(() -> { + try { + List downloadedPaths = IntStream.range(0, fileUrls.size()) + .mapToObj(i -> { + try { + String downloadedPath = recapProperties.getDownloadFilePath(recapId, i+1); + FileUtils.copyURLToFile(new URL(fileUrls.get(i)), new File(downloadedPath)); + + return downloadedPath; + } catch (IOException e) { + throw new RuntimeException("Failed to download image for recap: " + fileUrls.get(i), e); + } + }) + .collect(Collectors.toList()); + + return Mono.just(downloadedPaths); + } catch (Exception e) { + return Mono.error(e); + } + }); + } } diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/RecapService.java b/photo-service/src/main/java/kr/mafoo/photo/service/RecapService.java new file mode 100644 index 0000000..8ea586f --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/RecapService.java @@ -0,0 +1,156 @@ +package kr.mafoo.photo.service; + +import kr.mafoo.photo.domain.PhotoEntity; +import kr.mafoo.photo.service.properties.RecapProperties; +import kr.mafoo.photo.util.IdGenerator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import reactor.core.publisher.Mono; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import net.bramp.ffmpeg.builder.FFmpegBuilder; +import net.bramp.ffmpeg.FFmpegExecutor; + +@Slf4j +@RequiredArgsConstructor +@Service +public class RecapService { + + @Value("${recap.max-size}") + private int recapImageMaxSize; + + @Value("${recap.path.tmp}") + private String tmpPath; + + private final AlbumService albumService; + private final PhotoService photoService; + private final MemberService memberService; + + private final ObjectStorageService objectStorageService; + private final Graphics2dService graphics2dService; + private final FFmpegExecutor ffmpegExecutor; + private final LocalFileService localFileService; + + private final RecapProperties recapProperties; + + public Mono createRecap(String albumId, String requestMemberId, String sort, String token) { + + String recapId = IdGenerator.generate(); + + return albumService.findByAlbumId(albumId, requestMemberId) + .flatMap(albumEntity -> { + String albumType = String.valueOf(albumEntity.getType()); + + return graphics2dService.generateAlbumChipForRecap(recapId, albumEntity.getName(), albumType) + .then(memberService.getMemberInfo(token)) + .flatMap(memberInfo -> generateRecapFrame(recapId, memberInfo.name(), albumType)) + .then(photoService.findAllByAlbumId(albumId, requestMemberId, sort) + .collectList() + .flatMap(photoEntities -> { + List photoUrls = photoEntities.stream() + .limit(recapImageMaxSize) + .map(PhotoEntity::getPhotoUrl) + .toList(); + + return objectStorageService.downloadFilesForRecap(photoUrls, recapId); + }) + ) + .flatMap(downloadedPath -> generateRecapPhotos(downloadedPath, recapId)) + .then(Mono.defer(() -> generateRecapVideo(recapId))) + .flatMap(objectStorageService::uploadFileFromPath); + }) + .flatMap(recapUploadedPath -> + localFileService.deleteSimilarNameFileForPath(tmpPath, recapId) + .thenReturn(recapUploadedPath) + ); + } + + private Mono generateRecapFrame(String recapId, String memberName, String albumType) { + return Mono.fromCallable(() -> { + try { + String recapFramePath = recapProperties.getFrameFilePath(recapId); + String recapCreatedDate = DateTimeFormatter.ofPattern("yyyy.MM.dd").format(LocalDate.now()); + + FFmpegBuilder builder = new FFmpegBuilder() + .addExtraArgs( + "-filter_complex", + String.format( + "[1]scale=w=-1:h=176[chip]; " + + "[0][chip]overlay=188:H-h-120[bg_w_chip]; " + + "[bg_w_chip]drawtext=fontfile=%s:text='@%s님의 RECAP':fontcolor=white@0.7:fontsize=72:x=(w-tw)/2:y=208[bg_w_title]; " + + "[bg_w_title]drawtext=fontfile=%s:text='%s':fontcolor=white:fontsize=72:x=w-tw-188:y=h-th-180;", + recapProperties.getAggroBFontPath(), memberName, + recapProperties.getAggroMFontPath(), recapCreatedDate + ) + ) + .addInput(recapProperties.getBackgroundPath(albumType)) + .addInput(recapProperties.getChipFilePath(recapId)) + .addOutput(recapFramePath) + .done(); + + ffmpegExecutor.createJob(builder).run(); + + return recapFramePath; + } catch (Exception e) { + log.error("Failed to generate recap frame", e); + throw new RuntimeException("Failed to generate recap_frame", e); + } + }); + } + + private Mono generateRecapPhotos(List downloadedPath, String recapId) { + + return Mono.fromRunnable(() -> { + + FFmpegBuilder builder = new FFmpegBuilder() + .addInput(recapProperties.getFrameFilePath(recapId)); + + for (String path : downloadedPath) { + builder.addInput(path); + } + + StringBuilder filterComplex = new StringBuilder(); + + for (int inputIndex = 1; inputIndex <= downloadedPath.size(); inputIndex++) { + filterComplex.append(String.format("[%d]scale='min(1200,iw)':'min(1776,ih)':force_original_aspect_ratio=decrease[photo_scaled_%d]; ", inputIndex, inputIndex)); + filterComplex.append(String.format("[recap_bg][photo_scaled_%d]overlay=(W-w)/2:(H-h)/2+80[final%d];", inputIndex, inputIndex)); + } + + builder.addExtraArgs("-filter_complex", filterComplex.toString()); + + for (int outputIndex = 1; outputIndex <= downloadedPath.size(); outputIndex++) { + builder.addOutput(recapProperties.getPhotoFilePath(recapId, outputIndex)) + .addExtraArgs("-map", String.format("[final%d]", outputIndex)); + } + + ffmpegExecutor.createJob(builder).run(); + }).then(); + } + + private Mono generateRecapVideo(String recapId) { + return Mono.fromCallable(() -> { + try { + String recapVideoPath = recapProperties.getVideoFilePath(recapId); + + FFmpegBuilder builder = new FFmpegBuilder() + .addExtraArgs("-r", "2") + .addInput(recapProperties.getPhotoFilePath(recapId)) + .addOutput(recapVideoPath) + .done(); + + ffmpegExecutor.createJob(builder).run(); + + return recapVideoPath; + } catch (Exception e) { + log.error("Failed to generate recap video", e); + throw new RuntimeException("Failed to generate recap video", e); + } + }); + } + +} \ No newline at end of file diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/dto/MemberDto.java b/photo-service/src/main/java/kr/mafoo/photo/service/dto/MemberDto.java new file mode 100644 index 0000000..116f5dd --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/dto/MemberDto.java @@ -0,0 +1,8 @@ +package kr.mafoo.photo.service.dto; + +public record MemberDto( + String memberId, + String name, + String profileImageUrl +) { +} diff --git a/photo-service/src/main/java/kr/mafoo/photo/service/properties/RecapProperties.java b/photo-service/src/main/java/kr/mafoo/photo/service/properties/RecapProperties.java new file mode 100644 index 0000000..1dbc1d6 --- /dev/null +++ b/photo-service/src/main/java/kr/mafoo/photo/service/properties/RecapProperties.java @@ -0,0 +1,61 @@ +package kr.mafoo.photo.service.properties; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Component +@ConfigurationProperties(prefix = "recap") +public class RecapProperties { + + @Value("${recap.path.tmp}") + private String tmpPath; + + @Value("${recap.path.src}") + private String srcPath; + + public String getDownloadFilePath(String identifier, int index) { + return String.format("%s%s_download_%02d.png", tmpPath, identifier, index); + } + + public String getPhotoFilePath(String identifier) { + return String.format("%s%s_photo_%%02d.png", tmpPath, identifier); + } + + public String getPhotoFilePath(String identifier, int index) { + return String.format("%s%s_photo_%02d.png", tmpPath, identifier, index); + } + + public String getVideoFilePath(String identifier) { + return String.format("%s%s_video.mp4", tmpPath, identifier); + } + + public String getChipFilePath(String identifier) { + return String.format("%s%s_chip.png", tmpPath, identifier); + } + + public String getFrameFilePath(String identifier) { + return String.format("%s%s_frame.png", tmpPath, identifier); + } + + public String getBackgroundPath(String identifier) { + return String.format("%sbackground/%s.png", srcPath, identifier); + } + + public String getIconPath(String identifier) { + return String.format("%sicon/%s.png", srcPath, identifier); + } + + public String getAggroMFontPath() { + return String.format("%sfont/SB_AggroOTF_M.otf", srcPath); + } + + public String getAggroBFontPath() { + return String.format("%sfont/SB_AggroOTF_B.otf", srcPath); + } + + public String getPretendardFontPath() { + return String.format("%sfont/Pretendard-SemiBold.otf", srcPath); + } +} + diff --git a/photo-service/src/main/resources/application.yaml b/photo-service/src/main/resources/application.yaml index 38a863c..b6656bf 100644 --- a/photo-service/src/main/resources/application.yaml +++ b/photo-service/src/main/resources/application.yaml @@ -48,4 +48,13 @@ slack: token: ${SLACK_TOKEN} channel: error: ${SLACK_ERROR_CHANNEL} - qr: ${SLACK_QR_ERROR_CHANNEL} \ No newline at end of file + qr: ${SLACK_QR_ERROR_CHANNEL} + +ffmpeg: + path: /usr/bin/ffmpeg + +recap: + max-size: 15 + path: + tmp: /usr/bin/recap/tmp/ + src: /usr/bin/recap/src/