diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java index 05c3ced..defdbc3 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/Config.java @@ -2,6 +2,10 @@ package nl.andrewlalis.gymboardcdn; import nl.andrewlalis.gymboardcdn.files.FileStorageService; import nl.andrewlalis.gymboardcdn.files.util.ULID; +import nl.andrewlalis.gymboardcdn.uploads.service.process.FfmpegThumbnailGenerator; +import nl.andrewlalis.gymboardcdn.uploads.service.process.FfmpegVideoProcessor; +import nl.andrewlalis.gymboardcdn.uploads.service.process.ThumbnailGenerator; +import nl.andrewlalis.gymboardcdn.uploads.service.process.VideoProcessor; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -11,6 +15,8 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource; import org.springframework.web.filter.CorsFilter; import java.util.Arrays; +import java.util.concurrent.Executor; +import java.util.concurrent.Executors; @Configuration @EnableScheduling @@ -42,4 +48,19 @@ public class Config { public FileStorageService fileStorageService() { return new FileStorageService(ulid(), "cdn-files"); } + + @Bean + public VideoProcessor videoProcessor() { + return new FfmpegVideoProcessor(); + } + + @Bean + public ThumbnailGenerator thumbnailGenerator() { + return new FfmpegThumbnailGenerator(); + } + + @Bean + public Executor videoProcessingExecutor() { + return Executors.newFixedThreadPool(1); + } } diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java index f3587bb..4a2b700 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/VideoProcessingService.java @@ -5,6 +5,8 @@ import nl.andrewlalis.gymboardcdn.files.FileStorageService; import nl.andrewlalis.gymboardcdn.files.util.ULID; import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask; import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository; +import nl.andrewlalis.gymboardcdn.uploads.service.process.ThumbnailGenerator; +import nl.andrewlalis.gymboardcdn.uploads.service.process.VideoProcessor; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.scheduling.annotation.Scheduled; @@ -13,8 +15,6 @@ import org.springframework.stereotype.Service; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; -import java.time.Duration; -import java.time.Instant; import java.time.LocalDateTime; import java.util.List; import java.util.concurrent.Executor; @@ -24,14 +24,22 @@ import java.util.concurrent.TimeUnit; public class VideoProcessingService { private static final Logger log = LoggerFactory.getLogger(VideoProcessingService.class); - private final Executor taskExecutor; + private final Executor videoProcessingExecutor; private final VideoProcessingTaskRepository taskRepo; private final FileStorageService fileStorageService; + private final VideoProcessor videoProcessor; + private final ThumbnailGenerator thumbnailGenerator; - public VideoProcessingService(Executor taskExecutor, VideoProcessingTaskRepository taskRepo, FileStorageService fileStorageService) { - this.taskExecutor = taskExecutor; + public VideoProcessingService(Executor videoProcessingExecutor, + VideoProcessingTaskRepository taskRepo, + FileStorageService fileStorageService, + VideoProcessor videoProcessor, + ThumbnailGenerator thumbnailGenerator) { + this.videoProcessingExecutor = videoProcessingExecutor; this.taskRepo = taskRepo; this.fileStorageService = fileStorageService; + this.videoProcessor = videoProcessor; + this.thumbnailGenerator = thumbnailGenerator; } @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.SECONDS) @@ -40,7 +48,7 @@ public class VideoProcessingService { for (var task : waitingTasks) { log.info("Queueing processing of task {}.", task.getId()); updateTask(task, VideoProcessingTask.Status.IN_PROGRESS); - taskExecutor.execute(() -> processVideo(task)); + videoProcessingExecutor.execute(() -> processVideo(task)); } } @@ -68,20 +76,20 @@ public class VideoProcessingService { private void processVideo(VideoProcessingTask task) { log.info("Started processing task {}.", task.getId()); - Path tempFilePath = fileStorageService.getStoragePathForFile(task.getUploadFileId()); - if (Files.notExists(tempFilePath) || !Files.isReadable(tempFilePath)) { - log.error("Temp file {} doesn't exist or isn't readable.", tempFilePath); + Path uploadFile = fileStorageService.getStoragePathForFile(task.getUploadFileId()); + if (Files.notExists(uploadFile) || !Files.isReadable(uploadFile)) { + log.error("Uploaded video file {} doesn't exist or isn't readable.", uploadFile); updateTask(task, VideoProcessingTask.Status.FAILED); return; } - // Then begin running the actual FFMPEG processing. - Path tempDir = tempFilePath.getParent(); - Path ffmpegOutputFile = tempDir.resolve(task.getUploadFileId() + "-video-out"); - Path ffmpegThumbnailOutputFile = tempDir.resolve(task.getUploadFileId() + "-thumbnail-out"); + Path videoFile = uploadFile.resolveSibling(task.getUploadFileId() + "-vid-out"); + Path thumbnailFile = uploadFile.resolveSibling(task.getUploadFileId() + "-thm-out"); try { - generateThumbnailWithFFMPEG(tempDir, tempFilePath, ffmpegThumbnailOutputFile); - processVideoWithFFMPEG(tempDir, tempFilePath, ffmpegOutputFile); + log.info("Processing video for uploaded video file {}.", uploadFile.getFileName()); + videoProcessor.processVideo(uploadFile, videoFile); + log.info("Generating thumbnail for uploaded video file {}.", uploadFile.getFileName()); + thumbnailGenerator.generateThumbnailImage(uploadFile, thumbnailFile); } catch (Exception e) { e.printStackTrace(); log.error(""" @@ -90,8 +98,8 @@ public class VideoProcessingService { Output file: {} Exception message: {}""", task.getId(), - tempFilePath, - ffmpegOutputFile, + uploadFile, + videoFile, e.getMessage() ); updateTask(task, VideoProcessingTask.Status.FAILED); @@ -100,8 +108,8 @@ public class VideoProcessingService { // And finally, copy the output to the final location. try ( - var videoIn = Files.newInputStream(ffmpegOutputFile); - var thumbnailIn = Files.newInputStream(ffmpegThumbnailOutputFile) + var videoIn = Files.newInputStream(videoFile); + var thumbnailIn = Files.newInputStream(thumbnailFile) ) { // Save the video to a final file location. var originalMetadata = fileStorageService.getMetadata(task.getUploadFileId()); @@ -110,14 +118,14 @@ public class VideoProcessingService { originalMetadata.mimeType(), true ); - fileStorageService.save(ULID.parseULID(task.getVideoFileId()), videoIn, metadata, Files.size(ffmpegOutputFile)); + fileStorageService.save(ULID.parseULID(task.getVideoFileId()), videoIn, metadata, Files.size(videoFile)); // Save the thumbnail too. FileMetadata thumbnailMetadata = new FileMetadata( "thumbnail.jpeg", "image/jpeg", true ); - fileStorageService.save(thumbnailIn, thumbnailMetadata, Files.size(ffmpegThumbnailOutputFile)); + fileStorageService.save(thumbnailIn, thumbnailMetadata, Files.size(thumbnailFile)); updateTask(task, VideoProcessingTask.Status.COMPLETED); log.info("Finished processing task {}.", task.getId()); @@ -128,62 +136,15 @@ public class VideoProcessingService { } finally { try { fileStorageService.delete(task.getUploadFileId()); - Files.deleteIfExists(ffmpegOutputFile); - Files.deleteIfExists(ffmpegThumbnailOutputFile); + Files.deleteIfExists(videoFile); + Files.deleteIfExists(thumbnailFile); } catch (IOException e) { - log.error("Couldn't delete temporary FFMPEG output file: {}", ffmpegOutputFile); + log.error("Couldn't delete temporary output files for uploaded video {}", uploadFile); e.printStackTrace(); } } } - /** - * Uses the `ffmpeg` system command to process a raw input video and produce - * a compressed, reduced-size output video that's ready for usage in the - * application. - * @param dir The working directory. - * @param inFile The input file to read from. - * @param outFile The output file to write to. MUST have a ".mp4" extension. - * @throws IOException If a filesystem error occurs. - * @throws CommandFailedException If the ffmpeg command fails. - * @throws InterruptedException If the ffmpeg command is interrupted. - */ - private void processVideoWithFFMPEG(Path dir, Path inFile, Path outFile) throws IOException, InterruptedException { - Path tmpStdout = Files.createTempFile(dir, "stdout-", ".log"); - Path tmpStderr = Files.createTempFile(dir, "stderr-", ".log"); - final String[] command = { - "ffmpeg", - "-i", inFile.getFileName().toString(), - "-vf", "scale=640x480:flags=lanczos", - "-vcodec", "libx264", - "-crf", "28", - "-f", "mp4", - outFile.getFileName().toString() - }; - - long startSize = Files.size(inFile); - Instant startTime = Instant.now(); - - Process ffmpegProcess = new ProcessBuilder() - .command(command) - .redirectOutput(tmpStdout.toFile()) - .redirectError(tmpStderr.toFile()) - .directory(dir.toFile()) - .start(); - int result = ffmpegProcess.waitFor(); - if (result != 0) throw new CommandFailedException(command, result, tmpStdout, tmpStderr); - - long endSize = Files.size(outFile); - Duration dur = Duration.between(startTime, Instant.now()); - double reductionFactor = startSize / (double) endSize; - String reductionFactorStr = String.format("%.3f%%", reductionFactor * 100); - log.info("Processed video from {} bytes to {} bytes in {} seconds, {} reduction.", startSize, endSize, dur.getSeconds(), reductionFactorStr); - - // Delete the logs if everything was successful. - Files.deleteIfExists(tmpStdout); - Files.deleteIfExists(tmpStderr); - } - private void updateTask(VideoProcessingTask task, VideoProcessingTask.Status status) { task.setStatus(status); taskRepo.saveAndFlush(task); diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegCommandExecutor.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegCommandExecutor.java new file mode 100644 index 0000000..f2e3cb4 --- /dev/null +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegCommandExecutor.java @@ -0,0 +1,42 @@ +package nl.andrewlalis.gymboardcdn.uploads.service.process; + +import nl.andrewlalis.gymboardcdn.uploads.service.CommandFailedException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; + +public abstract class FfmpegCommandExecutor { + private static final Logger log = LoggerFactory.getLogger(FfmpegCommandExecutor.class); + + protected abstract String[] buildCommand(Path inputFile, Path outputFile); + + public void run(String label, Path inputFile, Path outputFile) throws IOException { + String inputFilename = inputFile.getFileName().toString().strip(); + Path stdout = inputFile.resolveSibling(inputFilename + "-ffmpeg-" + label + "-out.log"); + Path stderr = inputFile.resolveSibling(inputFilename + "-ffmpeg-" + label + "-err.log"); + String[] command = buildCommand(inputFile, outputFile); + Process process = new ProcessBuilder(buildCommand(inputFile, outputFile)) + .redirectOutput(stdout.toFile()) + .redirectError(stderr.toFile()) + .start(); + try { + int result = process.waitFor(); + if (result != 0) { + throw new CommandFailedException(command, result, stdout, stderr); + } + } catch (InterruptedException e) { + throw new IOException("Interrupted while waiting for ffmpeg to finish.", e); + } + + // Try to clean up output files when the command exited successfully. + try { + Files.deleteIfExists(stdout); + Files.deleteIfExists(stderr); + } catch (IOException e) { + log.warn("Failed to delete output files after successful ffmpeg execution.", e); + } + } +} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegThumbnailGenerator.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegThumbnailGenerator.java new file mode 100644 index 0000000..298f596 --- /dev/null +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegThumbnailGenerator.java @@ -0,0 +1,21 @@ +package nl.andrewlalis.gymboardcdn.uploads.service.process; + +import java.io.IOException; +import java.nio.file.Path; + +public class FfmpegThumbnailGenerator extends FfmpegCommandExecutor implements ThumbnailGenerator { + @Override + public void generateThumbnailImage(Path videoInputFile, Path outputFilePath) throws IOException { + super.run("thm", videoInputFile, outputFilePath); + } + + @Override + protected String[] buildCommand(Path inputFile, Path outputFile) { + return new String[]{ + "ffmpeg", + "-i", inputFile.toString(), + "-vframes", "1", + outputFile.toString() + }; + } +} diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegVideoProcessor.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegVideoProcessor.java index e8d1337..e907094 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegVideoProcessor.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/FfmpegVideoProcessor.java @@ -1,6 +1,5 @@ package nl.andrewlalis.gymboardcdn.uploads.service.process; -import nl.andrewlalis.gymboardcdn.uploads.service.CommandFailedException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -10,38 +9,40 @@ import java.nio.file.Path; import java.time.Duration; import java.time.Instant; -public class FfmpegVideoProcessor implements VideoProcessor { +public class FfmpegVideoProcessor extends FfmpegCommandExecutor implements VideoProcessor { private static final Logger log = LoggerFactory.getLogger(FfmpegVideoProcessor.class); @Override public void processVideo(Path inputFilePath, Path outputFilePath) throws IOException { - String inputFilename = inputFilePath.getFileName().toString().strip(); - Path stdoutFile = inputFilePath.resolveSibling(inputFilename + "-ffmpeg-video-stdout.log"); - Path stderrFile = inputFilePath.resolveSibling(inputFilename + "-ffmpeg-video-stderr.log"); - final String[] command = { + Instant start = Instant.now(); + long inputFileSize = Files.size(inputFilePath); + + super.run("vid", inputFilePath, outputFilePath); + + Duration duration = Duration.between(start, Instant.now()); + long outputFileSize = Files.size(outputFilePath); + double reductionFactor = inputFileSize / (double) outputFileSize; + double durationSeconds = duration.toMillis() / 1000.0; + log.info( + "Processed video {} from {} to {} bytes, {} reduction in {} seconds.", + inputFilePath.getFileName().toString(), + inputFileSize, + outputFileSize, + String.format("%.3f%%", reductionFactor), + String.format("%.3f", durationSeconds) + ); + } + + @Override + protected String[] buildCommand(Path inputFile, Path outputFile) { + return new String[]{ "ffmpeg", - "-i", inputFilePath.toAbsolutePath().toString(), + "-i", inputFile.toString(), "-vf", "scale=640x480:flags=lanczos", "-vcodec", "libx264", "-crf", "28", "-f", "mp4", - outputFilePath.toAbsolutePath().toString() + outputFile.toString() }; - long startFileSize = Files.size(inputFilePath); - Instant startTime = Instant.now(); - Process process = new ProcessBuilder(command) - .redirectOutput(stdoutFile.toAbsolutePath().toFile()) - .redirectError(stderrFile.toAbsolutePath().toFile()) - .start(); - int result = process.waitFor(); - if (result != 0) { - throw new CommandFailedException(command, result, stdoutFile, stderrFile); - } - long endFileSize = Files.size(outputFilePath); - Duration duration = Duration.between(startTime, Instant.now()); - double reductionFactor = startFileSize / (double) endFileSize; - String reductionFactorStr = String.format("%.3f%%", reductionFactor * 100); - log.info("Processed video from {} bytes to {} bytes in {} seconds, {} reduction.", startSize, endSize, dur.getSeconds(), reductionFactorStr); - } } diff --git a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/ThumbnailGenerator.java b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/ThumbnailGenerator.java index 4059336..13ad4a3 100644 --- a/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/ThumbnailGenerator.java +++ b/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/uploads/service/process/ThumbnailGenerator.java @@ -1,7 +1,8 @@ package nl.andrewlalis.gymboardcdn.uploads.service.process; +import java.io.IOException; import java.nio.file.Path; public interface ThumbnailGenerator { - void generateThumbnailImage(Path videoInputFile, Path outputFilePath); + void generateThumbnailImage(Path videoInputFile, Path outputFilePath) throws IOException; }