Finished ffmpeg implementation.

This commit is contained in:
Andrew Lalis 2023-04-04 20:05:03 +02:00
parent d66fa71ae2
commit 63550c880d
6 changed files with 143 additions and 96 deletions

View File

@ -2,6 +2,10 @@ package nl.andrewlalis.gymboardcdn;
import nl.andrewlalis.gymboardcdn.files.FileStorageService; import nl.andrewlalis.gymboardcdn.files.FileStorageService;
import nl.andrewlalis.gymboardcdn.files.util.ULID; 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.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@ -11,6 +15,8 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import org.springframework.web.filter.CorsFilter; import org.springframework.web.filter.CorsFilter;
import java.util.Arrays; import java.util.Arrays;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
@Configuration @Configuration
@EnableScheduling @EnableScheduling
@ -42,4 +48,19 @@ public class Config {
public FileStorageService fileStorageService() { public FileStorageService fileStorageService() {
return new FileStorageService(ulid(), "cdn-files"); 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);
}
} }

View File

@ -5,6 +5,8 @@ import nl.andrewlalis.gymboardcdn.files.FileStorageService;
import nl.andrewlalis.gymboardcdn.files.util.ULID; import nl.andrewlalis.gymboardcdn.files.util.ULID;
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask; import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask;
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository; 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.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled; import org.springframework.scheduling.annotation.Scheduled;
@ -13,8 +15,6 @@ import org.springframework.stereotype.Service;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.time.Duration;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.concurrent.Executor; import java.util.concurrent.Executor;
@ -24,14 +24,22 @@ import java.util.concurrent.TimeUnit;
public class VideoProcessingService { public class VideoProcessingService {
private static final Logger log = LoggerFactory.getLogger(VideoProcessingService.class); private static final Logger log = LoggerFactory.getLogger(VideoProcessingService.class);
private final Executor taskExecutor; private final Executor videoProcessingExecutor;
private final VideoProcessingTaskRepository taskRepo; private final VideoProcessingTaskRepository taskRepo;
private final FileStorageService fileStorageService; private final FileStorageService fileStorageService;
private final VideoProcessor videoProcessor;
private final ThumbnailGenerator thumbnailGenerator;
public VideoProcessingService(Executor taskExecutor, VideoProcessingTaskRepository taskRepo, FileStorageService fileStorageService) { public VideoProcessingService(Executor videoProcessingExecutor,
this.taskExecutor = taskExecutor; VideoProcessingTaskRepository taskRepo,
FileStorageService fileStorageService,
VideoProcessor videoProcessor,
ThumbnailGenerator thumbnailGenerator) {
this.videoProcessingExecutor = videoProcessingExecutor;
this.taskRepo = taskRepo; this.taskRepo = taskRepo;
this.fileStorageService = fileStorageService; this.fileStorageService = fileStorageService;
this.videoProcessor = videoProcessor;
this.thumbnailGenerator = thumbnailGenerator;
} }
@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.SECONDS) @Scheduled(fixedDelay = 5, timeUnit = TimeUnit.SECONDS)
@ -40,7 +48,7 @@ public class VideoProcessingService {
for (var task : waitingTasks) { for (var task : waitingTasks) {
log.info("Queueing processing of task {}.", task.getId()); log.info("Queueing processing of task {}.", task.getId());
updateTask(task, VideoProcessingTask.Status.IN_PROGRESS); 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) { private void processVideo(VideoProcessingTask task) {
log.info("Started processing task {}.", task.getId()); log.info("Started processing task {}.", task.getId());
Path tempFilePath = fileStorageService.getStoragePathForFile(task.getUploadFileId()); Path uploadFile = fileStorageService.getStoragePathForFile(task.getUploadFileId());
if (Files.notExists(tempFilePath) || !Files.isReadable(tempFilePath)) { if (Files.notExists(uploadFile) || !Files.isReadable(uploadFile)) {
log.error("Temp file {} doesn't exist or isn't readable.", tempFilePath); log.error("Uploaded video file {} doesn't exist or isn't readable.", uploadFile);
updateTask(task, VideoProcessingTask.Status.FAILED); updateTask(task, VideoProcessingTask.Status.FAILED);
return; return;
} }
// Then begin running the actual FFMPEG processing. Path videoFile = uploadFile.resolveSibling(task.getUploadFileId() + "-vid-out");
Path tempDir = tempFilePath.getParent(); Path thumbnailFile = uploadFile.resolveSibling(task.getUploadFileId() + "-thm-out");
Path ffmpegOutputFile = tempDir.resolve(task.getUploadFileId() + "-video-out");
Path ffmpegThumbnailOutputFile = tempDir.resolve(task.getUploadFileId() + "-thumbnail-out");
try { try {
generateThumbnailWithFFMPEG(tempDir, tempFilePath, ffmpegThumbnailOutputFile); log.info("Processing video for uploaded video file {}.", uploadFile.getFileName());
processVideoWithFFMPEG(tempDir, tempFilePath, ffmpegOutputFile); videoProcessor.processVideo(uploadFile, videoFile);
log.info("Generating thumbnail for uploaded video file {}.", uploadFile.getFileName());
thumbnailGenerator.generateThumbnailImage(uploadFile, thumbnailFile);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
log.error(""" log.error("""
@ -90,8 +98,8 @@ public class VideoProcessingService {
Output file: {} Output file: {}
Exception message: {}""", Exception message: {}""",
task.getId(), task.getId(),
tempFilePath, uploadFile,
ffmpegOutputFile, videoFile,
e.getMessage() e.getMessage()
); );
updateTask(task, VideoProcessingTask.Status.FAILED); updateTask(task, VideoProcessingTask.Status.FAILED);
@ -100,8 +108,8 @@ public class VideoProcessingService {
// And finally, copy the output to the final location. // And finally, copy the output to the final location.
try ( try (
var videoIn = Files.newInputStream(ffmpegOutputFile); var videoIn = Files.newInputStream(videoFile);
var thumbnailIn = Files.newInputStream(ffmpegThumbnailOutputFile) var thumbnailIn = Files.newInputStream(thumbnailFile)
) { ) {
// Save the video to a final file location. // Save the video to a final file location.
var originalMetadata = fileStorageService.getMetadata(task.getUploadFileId()); var originalMetadata = fileStorageService.getMetadata(task.getUploadFileId());
@ -110,14 +118,14 @@ public class VideoProcessingService {
originalMetadata.mimeType(), originalMetadata.mimeType(),
true 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. // Save the thumbnail too.
FileMetadata thumbnailMetadata = new FileMetadata( FileMetadata thumbnailMetadata = new FileMetadata(
"thumbnail.jpeg", "thumbnail.jpeg",
"image/jpeg", "image/jpeg",
true true
); );
fileStorageService.save(thumbnailIn, thumbnailMetadata, Files.size(ffmpegThumbnailOutputFile)); fileStorageService.save(thumbnailIn, thumbnailMetadata, Files.size(thumbnailFile));
updateTask(task, VideoProcessingTask.Status.COMPLETED); updateTask(task, VideoProcessingTask.Status.COMPLETED);
log.info("Finished processing task {}.", task.getId()); log.info("Finished processing task {}.", task.getId());
@ -128,62 +136,15 @@ public class VideoProcessingService {
} finally { } finally {
try { try {
fileStorageService.delete(task.getUploadFileId()); fileStorageService.delete(task.getUploadFileId());
Files.deleteIfExists(ffmpegOutputFile); Files.deleteIfExists(videoFile);
Files.deleteIfExists(ffmpegThumbnailOutputFile); Files.deleteIfExists(thumbnailFile);
} catch (IOException e) { } 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(); 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) { private void updateTask(VideoProcessingTask task, VideoProcessingTask.Status status) {
task.setStatus(status); task.setStatus(status);
taskRepo.saveAndFlush(task); taskRepo.saveAndFlush(task);

View File

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

View File

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

View File

@ -1,6 +1,5 @@
package nl.andrewlalis.gymboardcdn.uploads.service.process; package nl.andrewlalis.gymboardcdn.uploads.service.process;
import nl.andrewlalis.gymboardcdn.uploads.service.CommandFailedException;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -10,38 +9,40 @@ import java.nio.file.Path;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; 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); private static final Logger log = LoggerFactory.getLogger(FfmpegVideoProcessor.class);
@Override @Override
public void processVideo(Path inputFilePath, Path outputFilePath) throws IOException { public void processVideo(Path inputFilePath, Path outputFilePath) throws IOException {
String inputFilename = inputFilePath.getFileName().toString().strip(); Instant start = Instant.now();
Path stdoutFile = inputFilePath.resolveSibling(inputFilename + "-ffmpeg-video-stdout.log"); long inputFileSize = Files.size(inputFilePath);
Path stderrFile = inputFilePath.resolveSibling(inputFilename + "-ffmpeg-video-stderr.log");
final String[] command = { 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", "ffmpeg",
"-i", inputFilePath.toAbsolutePath().toString(), "-i", inputFile.toString(),
"-vf", "scale=640x480:flags=lanczos", "-vf", "scale=640x480:flags=lanczos",
"-vcodec", "libx264", "-vcodec", "libx264",
"-crf", "28", "-crf", "28",
"-f", "mp4", "-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);
} }
} }

View File

@ -1,7 +1,8 @@
package nl.andrewlalis.gymboardcdn.uploads.service.process; package nl.andrewlalis.gymboardcdn.uploads.service.process;
import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;
public interface ThumbnailGenerator { public interface ThumbnailGenerator {
void generateThumbnailImage(Path videoInputFile, Path outputFilePath); void generateThumbnailImage(Path videoInputFile, Path outputFilePath) throws IOException;
} }