Gymboard/gymboard-cdn/src/main/java/nl/andrewlalis/gymboardcdn/service/VideoProcessingService.java

174 lines
6.5 KiB
Java

package nl.andrewlalis.gymboardcdn.service;
import nl.andrewlalis.gymboardcdn.model.StoredFile;
import nl.andrewlalis.gymboardcdn.model.StoredFileRepository;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.scheduling.annotation.Scheduled;
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;
import java.util.concurrent.TimeUnit;
@Service
public class VideoProcessingService {
private static final Logger log = LoggerFactory.getLogger(VideoProcessingService.class);
private final Executor taskExecutor;
private final VideoProcessingTaskRepository taskRepo;
private final StoredFileRepository storedFileRepository;
private final FileService fileService;
public VideoProcessingService(Executor taskExecutor, VideoProcessingTaskRepository taskRepo, StoredFileRepository storedFileRepository, FileService fileService) {
this.taskExecutor = taskExecutor;
this.taskRepo = taskRepo;
this.storedFileRepository = storedFileRepository;
this.fileService = fileService;
}
@Scheduled(fixedDelay = 5, timeUnit = TimeUnit.SECONDS)
public void startWaitingTasks() {
List<VideoProcessingTask> waitingTasks = taskRepo.findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status.WAITING);
for (var task : waitingTasks) {
log.info("Queueing processing of video {}.", task.getVideoIdentifier());
updateTask(task, VideoProcessingTask.Status.IN_PROGRESS);
taskExecutor.execute(() -> processVideo(task));
}
}
@Scheduled(fixedRate = 1, timeUnit = TimeUnit.HOURS)
public void removeOldTasks() {
LocalDateTime cutoff = LocalDateTime.now().minusHours(12);
List<VideoProcessingTask> oldTasks = taskRepo.findAllByCreatedAtBefore(cutoff);
for (var task : oldTasks) {
if (task.getStatus() == VideoProcessingTask.Status.COMPLETED) {
log.info("Deleting completed task for video {}.", task.getVideoIdentifier());
taskRepo.delete(task);
} else if (task.getStatus() == VideoProcessingTask.Status.FAILED) {
log.info("Deleting failed task for video {}.", task.getVideoIdentifier());
taskRepo.delete(task);
} else if (task.getStatus() == VideoProcessingTask.Status.IN_PROGRESS) {
log.info("Task for video {} was in progress for too long; deleting.", task.getVideoIdentifier());
taskRepo.delete(task);
} else if (task.getStatus() == VideoProcessingTask.Status.WAITING) {
log.info("Task for video {} was waiting for too long; deleting.", task.getVideoIdentifier());
taskRepo.delete(task);
}
}
}
private void processVideo(VideoProcessingTask task) {
log.info("Started processing video {}.", task.getVideoIdentifier());
Path tempFile = Path.of(task.getTempFilePath());
if (Files.notExists(tempFile) || !Files.isReadable(tempFile)) {
log.error("Temp file {} doesn't exist or isn't readable.", tempFile);
updateTask(task, VideoProcessingTask.Status.FAILED);
return;
}
// Then begin running the actual FFMPEG processing.
Path tempDir = tempFile.getParent();
Path ffmpegOutputFile = tempDir.resolve(task.getVideoIdentifier());
try {
processVideoWithFFMPEG(tempDir, tempFile, ffmpegOutputFile);
} catch (Exception e) {
e.printStackTrace();
log.error("""
Video processing failed for video {}:
Input file: {}
Output file: {}
Exception message: {}""",
task.getVideoIdentifier(),
tempFile,
ffmpegOutputFile,
e.getMessage()
);
updateTask(task, VideoProcessingTask.Status.FAILED);
return;
}
// And finally, copy the output to the final location.
try {
StoredFile storedFile = new StoredFile(
task.getVideoIdentifier(),
task.getFilename(),
"video/mp4",
Files.size(ffmpegOutputFile),
task.getCreatedAt()
);
Path finalFilePath = fileService.getStoragePathForFile(storedFile);
Files.move(ffmpegOutputFile, finalFilePath);
Files.deleteIfExists(tempFile);
Files.deleteIfExists(ffmpegOutputFile);
storedFileRepository.saveAndFlush(storedFile);
updateTask(task, VideoProcessingTask.Status.COMPLETED);
log.info("Finished processing video {}.", task.getVideoIdentifier());
} catch (IOException e) {
log.error("Failed to copy processed video to final storage location.", e);
updateTask(task, VideoProcessingTask.Status.FAILED);
}
}
/**
* 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);
}
}