More uploads refactoring.

This commit is contained in:
Andrew Lalis 2023-04-04 17:06:38 +02:00
parent c00697b3d1
commit d66fa71ae2
23 changed files with 277 additions and 157 deletions

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboardcdn; package nl.andrewlalis.gymboardcdn;
import nl.andrewlalis.gymboardcdn.service.FileStorageService; import nl.andrewlalis.gymboardcdn.files.FileStorageService;
import nl.andrewlalis.gymboardcdn.util.ULID; import nl.andrewlalis.gymboardcdn.files.util.ULID;
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;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.api; package nl.andrewlalis.gymboardcdn;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;

View File

@ -1,5 +0,0 @@
package nl.andrewlalis.gymboardcdn.api;
public record FileUploadResponse(
String id
) {}

View File

@ -1,8 +1,6 @@
package nl.andrewlalis.gymboardcdn.api; package nl.andrewlalis.gymboardcdn.files;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboardcdn.model.FullFileMetadata;
import nl.andrewlalis.gymboardcdn.service.FileStorageService;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;

View File

@ -0,0 +1,7 @@
package nl.andrewlalis.gymboardcdn.files;
public record FileMetadata (
String filename,
String mimeType,
boolean accessible
) {}

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.api; package nl.andrewlalis.gymboardcdn.files;
public record FileMetadataResponse( public record FileMetadataResponse(
String filename, String filename,

View File

@ -1,10 +1,8 @@
package nl.andrewlalis.gymboardcdn.service; package nl.andrewlalis.gymboardcdn.files;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboardcdn.model.FileMetadata; import nl.andrewlalis.gymboardcdn.files.util.ULID;
import nl.andrewlalis.gymboardcdn.model.FullFileMetadata;
import nl.andrewlalis.gymboardcdn.util.ULID;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
@ -46,6 +44,10 @@ public class FileStorageService {
this.baseStorageDir = baseStorageDir; this.baseStorageDir = baseStorageDir;
} }
public String generateFileId() {
return ulid.nextULID();
}
/** /**
* Saves a new file to the storage. * Saves a new file to the storage.
* @param in The input stream to the file contents. * @param in The input stream to the file contents.
@ -106,8 +108,8 @@ public class FileStorageService {
FileMetadata metadata = readMetadata(in); FileMetadata metadata = readMetadata(in);
LocalDateTime date = dateFromULID(ULID.parseULID(rawId)); LocalDateTime date = dateFromULID(ULID.parseULID(rawId));
return new FullFileMetadata( return new FullFileMetadata(
metadata.filename, metadata.filename(),
metadata.mimeType, metadata.mimeType(),
Files.size(filePath) - HEADER_SIZE, Files.size(filePath) - HEADER_SIZE,
date.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) date.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME)
); );
@ -131,7 +133,7 @@ public class FileStorageService {
try (var in = Files.newInputStream(filePath)) { try (var in = Files.newInputStream(filePath)) {
FileMetadata metadata = readMetadata(in); FileMetadata metadata = readMetadata(in);
response.setContentType(metadata.mimeType); response.setContentType(metadata.mimeType());
response.setContentLengthLong(Files.size(filePath) - HEADER_SIZE); response.setContentLengthLong(Files.size(filePath) - HEADER_SIZE);
response.addHeader("Cache-Control", "max-age=604800, immutable"); response.addHeader("Cache-Control", "max-age=604800, immutable");
var out = response.getOutputStream(); var out = response.getOutputStream();

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.model; package nl.andrewlalis.gymboardcdn.files;
public record FullFileMetadata( public record FullFileMetadata(
String filename, String filename,

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.util; package nl.andrewlalis.gymboardcdn.files.util;
/* /*
* sulky-modules - several general-purpose modules. * sulky-modules - several general-purpose modules.

View File

@ -1,6 +0,0 @@
package nl.andrewlalis.gymboardcdn.model;
public class FileMetadata {
public String filename;
public String mimeType;
}

View File

@ -1,62 +0,0 @@
package nl.andrewlalis.gymboardcdn.model;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
/**
* An entity to keep track of a task for processing a raw video into a better
* format for Gymboard to serve.
*/
@Entity
@Table(name = "task_video_processing")
public class VideoProcessingTask {
public enum Status {
WAITING,
IN_PROGRESS,
COMPLETED,
FAILED
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreationTimestamp
private LocalDateTime createdAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Status status;
@Column(nullable = false, updatable = false, length = 26)
private String rawUploadFileId;
public VideoProcessingTask() {}
public VideoProcessingTask(Status status, String rawUploadFileId) {
this.status = status;
this.rawUploadFileId = rawUploadFileId;
}
public Long getId() {
return id;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
public String getRawUploadFileId() {
return rawUploadFileId;
}
}

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboardcdn.api; package nl.andrewlalis.gymboardcdn.uploads.api;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import nl.andrewlalis.gymboardcdn.service.UploadService; import nl.andrewlalis.gymboardcdn.uploads.service.UploadService;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
@ -16,12 +16,17 @@ public class UploadController {
} }
@PostMapping(path = "/uploads/video", consumes = {"video/mp4"}) @PostMapping(path = "/uploads/video", consumes = {"video/mp4"})
public FileUploadResponse uploadVideo(HttpServletRequest request) { public VideoUploadResponse uploadVideo(HttpServletRequest request) {
return uploadService.processableVideoUpload(request); return uploadService.processableVideoUpload(request);
} }
@GetMapping(path = "/uploads/video/{id}/status") @PostMapping(path = "/uploads/video/{taskId}/start")
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable String id) { public void startVideoProcessing(@PathVariable long taskId) {
return uploadService.getVideoProcessingStatus(id); uploadService.startVideoProcessing(taskId);
}
@GetMapping(path = "/uploads/video/{taskId}/status")
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(@PathVariable long taskId) {
return uploadService.getVideoProcessingStatus(taskId);
} }
} }

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.api; package nl.andrewlalis.gymboardcdn.uploads.api;
public record VideoProcessingTaskStatusResponse( public record VideoProcessingTaskStatusResponse(
String status String status

View File

@ -0,0 +1,5 @@
package nl.andrewlalis.gymboardcdn.uploads.api;
public record VideoUploadResponse(
long taskId
) {}

View File

@ -0,0 +1,87 @@
package nl.andrewlalis.gymboardcdn.uploads.model;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
/**
* An entity to keep track of a task for processing a raw video into a better
* format for Gymboard to serve. Generally, tasks are processed like so:
* <ol>
* <li>A video is uploaded, and a new task is created with the NOT_STARTED status.</li>
* <li>Once the Gymboard API verifies the associated submission, it'll
* request to start the task, bringing it to the WAITING status.</li>
* <li>When a task executor picks up the waiting task, its status changes to IN_PROGRESS.</li>
* <li>If the video is processed successfully, then the task is COMPLETED, otherwise FAILED.</li>
* </ol>
*/
@Entity
@Table(name = "task_video_processing")
public class VideoProcessingTask {
public enum Status {
NOT_STARTED,
WAITING,
IN_PROGRESS,
COMPLETED,
FAILED
}
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@CreationTimestamp
private LocalDateTime createdAt;
@Enumerated(EnumType.STRING)
@Column(nullable = false)
private Status status;
/**
* The file id for the original, raw user-uploaded video file that needs to
* be processed.
*/
@Column(nullable = false, updatable = false, length = 26)
private String uploadFileId;
/**
* The file id for the final processed video file. This doesn't exist yet,
* but we generate the video id right away, just in case there's a need to
* preemptively link to it.
*/
@Column(nullable = false, updatable = false, length = 26)
private String videoFileId;
public VideoProcessingTask() {}
public VideoProcessingTask(Status status, String uploadFileId, String videoFileId) {
this.status = status;
this.uploadFileId = uploadFileId;
this.videoFileId = videoFileId;
}
public Long getId() {
return id;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public Status getStatus() {
return status;
}
public void setStatus(Status status) {
this.status = status;
}
public String getUploadFileId() {
return uploadFileId;
}
public String getVideoFileId() {
return videoFileId;
}
}

View File

@ -1,16 +1,13 @@
package nl.andrewlalis.gymboardcdn.model; package nl.andrewlalis.gymboardcdn.uploads.model;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Optional;
@Repository @Repository
public interface VideoProcessingTaskRepository extends JpaRepository<VideoProcessingTask, Long> { public interface VideoProcessingTaskRepository extends JpaRepository<VideoProcessingTask, Long> {
Optional<VideoProcessingTask> findByVideoIdentifier(String identifier);
List<VideoProcessingTask> findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status status); List<VideoProcessingTask> findAllByStatusOrderByCreatedAtDesc(VideoProcessingTask.Status status);
List<VideoProcessingTask> findAllByCreatedAtBefore(LocalDateTime cutoff); List<VideoProcessingTask> findAllByCreatedAtBefore(LocalDateTime cutoff);

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboardcdn.service; package nl.andrewlalis.gymboardcdn.uploads.service;
import java.io.IOException; import java.io.IOException;
import java.nio.file.Path; import java.nio.file.Path;

View File

@ -1,11 +1,12 @@
package nl.andrewlalis.gymboardcdn.service; package nl.andrewlalis.gymboardcdn.uploads.service;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import nl.andrewlalis.gymboardcdn.api.FileUploadResponse; import nl.andrewlalis.gymboardcdn.uploads.api.VideoUploadResponse;
import nl.andrewlalis.gymboardcdn.api.VideoProcessingTaskStatusResponse; import nl.andrewlalis.gymboardcdn.uploads.api.VideoProcessingTaskStatusResponse;
import nl.andrewlalis.gymboardcdn.model.FileMetadata; import nl.andrewlalis.gymboardcdn.files.FileMetadata;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask; import nl.andrewlalis.gymboardcdn.files.FileStorageService;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository; import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTask;
import nl.andrewlalis.gymboardcdn.uploads.model.VideoProcessingTaskRepository;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
@ -33,11 +34,12 @@ public class UploadService {
* Handles uploading of a processable video file that will be processed * Handles uploading of a processable video file that will be processed
* before being stored in the system. * before being stored in the system.
* @param request The request from which we can read the file. * @param request The request from which we can read the file.
* @return A response that contains an identifier that can be used to check * @return A response containing the id of the video processing task, to be
* the status of the video processing, and eventually fetch the video. * given to the Gymboard API so that it can further manage processing after
* a submission is completed.
*/ */
@Transactional @Transactional
public FileUploadResponse processableVideoUpload(HttpServletRequest request) { public VideoUploadResponse processableVideoUpload(HttpServletRequest request) {
String contentLengthStr = request.getHeader("Content-Length"); String contentLengthStr = request.getHeader("Content-Length");
if (contentLengthStr == null || !contentLengthStr.matches("\\d+")) { if (contentLengthStr == null || !contentLengthStr.matches("\\d+")) {
throw new ResponseStatusException(HttpStatus.LENGTH_REQUIRED); throw new ResponseStatusException(HttpStatus.LENGTH_REQUIRED);
@ -46,34 +48,51 @@ public class UploadService {
if (contentLength > MAX_UPLOAD_SIZE_BYTES) { if (contentLength > MAX_UPLOAD_SIZE_BYTES) {
throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE); throw new ResponseStatusException(HttpStatus.PAYLOAD_TOO_LARGE);
} }
FileMetadata metadata = new FileMetadata(); String filename = request.getHeader("X-Gymboard-Filename");
metadata.mimeType = request.getContentType(); if (filename == null) filename = "unnamed.mp4";
metadata.filename = request.getHeader("X-Gymboard-Filename"); FileMetadata metadata = new FileMetadata(
if (metadata.filename == null) metadata.filename = "unnamed.mp4"; filename,
String fileId; request.getContentType(),
false
);
String uploadFileId;
try { try {
fileId = fileStorageService.save(request.getInputStream(), metadata, contentLength); uploadFileId = fileStorageService.save(request.getInputStream(), metadata, contentLength);
} catch (IOException e) { } catch (IOException e) {
log.error("Failed to save video upload to temp file.", e); log.error("Failed to save video upload to temp file.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR); throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
} }
videoTaskRepository.save(new VideoProcessingTask( var task = videoTaskRepository.save(new VideoProcessingTask(
VideoProcessingTask.Status.WAITING, VideoProcessingTask.Status.NOT_STARTED,
fileId, uploadFileId,
"bleh" fileStorageService.generateFileId()
)); ));
return new FileUploadResponse("bleh"); return new VideoUploadResponse(task.getId());
} }
/** /**
* Gets the status of a video processing task. * Gets the status of a video processing task.
* @param id The video identifier. * @param id The task id.
* @return The status of the video processing task. * @return The status of the video processing task.
*/ */
@Transactional(readOnly = true) @Transactional(readOnly = true)
public VideoProcessingTaskStatusResponse getVideoProcessingStatus(String id) { public VideoProcessingTaskStatusResponse getVideoProcessingStatus(long id) {
VideoProcessingTask task = videoTaskRepository.findByVideoIdentifier(id) VideoProcessingTask task = videoTaskRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
return new VideoProcessingTaskStatusResponse(task.getStatus().name()); return new VideoProcessingTaskStatusResponse(task.getStatus().name());
} }
/**
* Marks this task as waiting to be picked up for processing. The Gymboard
* API should send a message itself to start processing of an uploaded video
* once it validates a submission.
* @param taskId The task id.
*/
@Transactional
public void startVideoProcessing(long taskId) {
VideoProcessingTask task = videoTaskRepository.findById(taskId)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
task.setStatus(VideoProcessingTask.Status.WAITING);
videoTaskRepository.save(task);
}
} }

View File

@ -1,7 +1,10 @@
package nl.andrewlalis.gymboardcdn.service; package nl.andrewlalis.gymboardcdn.uploads.service;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask; import nl.andrewlalis.gymboardcdn.files.FileMetadata;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository; 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 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;
@ -65,7 +68,7 @@ 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.getRawUploadFileId()); Path tempFilePath = fileStorageService.getStoragePathForFile(task.getUploadFileId());
if (Files.notExists(tempFilePath) || !Files.isReadable(tempFilePath)) { if (Files.notExists(tempFilePath) || !Files.isReadable(tempFilePath)) {
log.error("Temp file {} doesn't exist or isn't readable.", tempFilePath); log.error("Temp file {} doesn't exist or isn't readable.", tempFilePath);
updateTask(task, VideoProcessingTask.Status.FAILED); updateTask(task, VideoProcessingTask.Status.FAILED);
@ -74,19 +77,20 @@ public class VideoProcessingService {
// Then begin running the actual FFMPEG processing. // Then begin running the actual FFMPEG processing.
Path tempDir = tempFilePath.getParent(); Path tempDir = tempFilePath.getParent();
Files.createTempFile() Path ffmpegOutputFile = tempDir.resolve(task.getUploadFileId() + "-video-out");
Path ffmpegOutputFile = tempDir.resolve(task.getVideoIdentifier()); Path ffmpegThumbnailOutputFile = tempDir.resolve(task.getUploadFileId() + "-thumbnail-out");
try { try {
processVideoWithFFMPEG(tempDir, tempFile, ffmpegOutputFile); generateThumbnailWithFFMPEG(tempDir, tempFilePath, ffmpegThumbnailOutputFile);
processVideoWithFFMPEG(tempDir, tempFilePath, ffmpegOutputFile);
} catch (Exception e) { } catch (Exception e) {
e.printStackTrace(); e.printStackTrace();
log.error(""" log.error("""
Video processing failed for video {}: Video processing failed for task {}:
Input file: {} Input file: {}
Output file: {} Output file: {}
Exception message: {}""", Exception message: {}""",
task.getVideoIdentifier(), task.getId(),
tempFile, tempFilePath,
ffmpegOutputFile, ffmpegOutputFile,
e.getMessage() e.getMessage()
); );
@ -95,24 +99,41 @@ public class VideoProcessingService {
} }
// And finally, copy the output to the final location. // And finally, copy the output to the final location.
try { try (
StoredFile storedFile = new StoredFile( var videoIn = Files.newInputStream(ffmpegOutputFile);
task.getVideoIdentifier(), var thumbnailIn = Files.newInputStream(ffmpegThumbnailOutputFile)
task.getFilename(), ) {
"video/mp4", // Save the video to a final file location.
Files.size(ffmpegOutputFile), var originalMetadata = fileStorageService.getMetadata(task.getUploadFileId());
task.getCreatedAt() FileMetadata metadata = new FileMetadata(
originalMetadata.filename(),
originalMetadata.mimeType(),
true
); );
Path finalFilePath = fileService.getStoragePathForFile(storedFile); fileStorageService.save(ULID.parseULID(task.getVideoFileId()), videoIn, metadata, Files.size(ffmpegOutputFile));
Files.move(ffmpegOutputFile, finalFilePath); // Save the thumbnail too.
Files.deleteIfExists(tempFile); FileMetadata thumbnailMetadata = new FileMetadata(
Files.deleteIfExists(ffmpegOutputFile); "thumbnail.jpeg",
storedFileRepository.saveAndFlush(storedFile); "image/jpeg",
true
);
fileStorageService.save(thumbnailIn, thumbnailMetadata, Files.size(ffmpegThumbnailOutputFile));
updateTask(task, VideoProcessingTask.Status.COMPLETED); updateTask(task, VideoProcessingTask.Status.COMPLETED);
log.info("Finished processing video {}.", task.getVideoIdentifier()); log.info("Finished processing task {}.", task.getId());
// TODO: Send HTTP POST to API, with video id and thumbnail id.
} catch (IOException e) { } catch (IOException e) {
log.error("Failed to copy processed video to final storage location.", e); log.error("Failed to copy processed video to final storage location.", e);
updateTask(task, VideoProcessingTask.Status.FAILED); updateTask(task, VideoProcessingTask.Status.FAILED);
} finally {
try {
fileStorageService.delete(task.getUploadFileId());
Files.deleteIfExists(ffmpegOutputFile);
Files.deleteIfExists(ffmpegThumbnailOutputFile);
} catch (IOException e) {
log.error("Couldn't delete temporary FFMPEG output file: {}", ffmpegOutputFile);
e.printStackTrace();
}
} }
} }

View File

@ -0,0 +1,47 @@
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;
import java.time.Duration;
import java.time.Instant;
public class FfmpegVideoProcessor 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 = {
"ffmpeg",
"-i", inputFilePath.toAbsolutePath().toString(),
"-vf", "scale=640x480:flags=lanczos",
"-vcodec", "libx264",
"-crf", "28",
"-f", "mp4",
outputFilePath.toAbsolutePath().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

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

View File

@ -0,0 +1,8 @@
package nl.andrewlalis.gymboardcdn.uploads.service.process;
import java.io.IOException;
import java.nio.file.Path;
public interface VideoProcessor {
void processVideo(Path inputFilePath, Path outputFilePath) throws IOException;
}

View File

@ -1,21 +1,11 @@
package nl.andrewlalis.gymboardcdn.service; package nl.andrewlalis.gymboardcdn.service;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import nl.andrewlalis.gymboardcdn.api.FileUploadResponse;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTask;
import nl.andrewlalis.gymboardcdn.model.VideoProcessingTaskRepository;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import java.io.IOException; import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.AdditionalAnswers.returnsFirstArg;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.*;
public class UploadServiceTest { public class UploadServiceTest {
/** /**