forked from jsl/video_editing_poc
Add highlight source scheduler
This commit is contained in:
parent
5ba108f740
commit
0b6e5afddf
|
|
@ -297,7 +297,8 @@ The plan should be strict JSON so a cheaper model or deterministic renderer can
|
|||
|
||||
- [x] Milestone 1: Define the cinematic highlight project domain model, folder contract, and persisted manifests.
|
||||
- [x] Milestone 1 progress: Added a dedicated highlight project domain, standard folder contract, manifest model, configurable `output/highlight-projects` root, filesystem store, and tests for directory creation, JSON round trips, and path safety.
|
||||
- [ ] Milestone 2: Add a local scheduler that accepts exactly one source video at a time from the highlight source folder.
|
||||
- [x] Milestone 2: Add a local scheduler that accepts exactly one source video at a time from the highlight source folder.
|
||||
- [x] Milestone 2 progress: Added a configurable highlight source scheduler and directory initializer for `input/highlights/source`, `working`, `processed`, and `rejected`; it claims one valid source video per scan, creates a highlight project and manifest, copies the source into the project folder, and leaves later videos for later scans.
|
||||
- [ ] Milestone 3: Implement source probing, proxy generation, frame extraction, waveform extraction, and contact sheet creation.
|
||||
- [ ] Milestone 4: Implement shot and scene segmentation using FFmpeg scene detection or PySceneDetect.
|
||||
- [ ] Milestone 5: Implement audio analysis for loudness, silence, peaks, speech/music/noise sections, and optional ASR transcript.
|
||||
|
|
|
|||
|
|
@ -544,6 +544,8 @@ public class VideoClippingProperties {
|
|||
|
||||
private final LocalDirector localDirector = new LocalDirector();
|
||||
|
||||
private final HighlightScheduler highlightScheduler = new HighlightScheduler();
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
|
@ -744,6 +746,10 @@ public class VideoClippingProperties {
|
|||
return localDirector;
|
||||
}
|
||||
|
||||
public HighlightScheduler getHighlightScheduler() {
|
||||
return highlightScheduler;
|
||||
}
|
||||
|
||||
public static class Assets {
|
||||
private String musicFolder = "./input/highlights/assets/music";
|
||||
|
||||
|
|
@ -908,6 +914,69 @@ public class VideoClippingProperties {
|
|||
this.approvalFileName = approvalFileName;
|
||||
}
|
||||
}
|
||||
|
||||
public static class HighlightScheduler {
|
||||
private boolean enabled = true;
|
||||
|
||||
private String sourceDirectory = "./input/highlights/source";
|
||||
|
||||
private String workingDirectory = "./input/highlights/working";
|
||||
|
||||
private String processedDirectory = "./input/highlights/processed";
|
||||
|
||||
private String rejectedDirectory = "./input/highlights/rejected";
|
||||
|
||||
@Min(1000)
|
||||
private long pollIntervalMs = 5000;
|
||||
|
||||
public boolean isEnabled() {
|
||||
return enabled;
|
||||
}
|
||||
|
||||
public void setEnabled(boolean enabled) {
|
||||
this.enabled = enabled;
|
||||
}
|
||||
|
||||
public String getSourceDirectory() {
|
||||
return sourceDirectory;
|
||||
}
|
||||
|
||||
public void setSourceDirectory(String sourceDirectory) {
|
||||
this.sourceDirectory = sourceDirectory;
|
||||
}
|
||||
|
||||
public String getWorkingDirectory() {
|
||||
return workingDirectory;
|
||||
}
|
||||
|
||||
public void setWorkingDirectory(String workingDirectory) {
|
||||
this.workingDirectory = workingDirectory;
|
||||
}
|
||||
|
||||
public String getProcessedDirectory() {
|
||||
return processedDirectory;
|
||||
}
|
||||
|
||||
public void setProcessedDirectory(String processedDirectory) {
|
||||
this.processedDirectory = processedDirectory;
|
||||
}
|
||||
|
||||
public String getRejectedDirectory() {
|
||||
return rejectedDirectory;
|
||||
}
|
||||
|
||||
public void setRejectedDirectory(String rejectedDirectory) {
|
||||
this.rejectedDirectory = rejectedDirectory;
|
||||
}
|
||||
|
||||
public long getPollIntervalMs() {
|
||||
return pollIntervalMs;
|
||||
}
|
||||
|
||||
public void setPollIntervalMs(long pollIntervalMs) {
|
||||
this.pollIntervalMs = pollIntervalMs;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public static class Quotas {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
import org.example.videoclips.config.VideoClippingProperties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
|
||||
@Component
|
||||
@ConditionalOnExpression("${video-clipping.editing.enabled:true} and "
|
||||
+ "${video-clipping.editing.highlight-scheduler.enabled:true}")
|
||||
public class HighlightProjectDirectoryInitializer {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HighlightProjectDirectoryInitializer.class);
|
||||
|
||||
private final List<Path> directories;
|
||||
|
||||
public HighlightProjectDirectoryInitializer(VideoClippingProperties properties) {
|
||||
VideoClippingProperties.Editing editing = properties.getEditing();
|
||||
VideoClippingProperties.Editing.HighlightScheduler scheduler = editing.getHighlightScheduler();
|
||||
this.directories = List.of(
|
||||
Path.of(scheduler.getSourceDirectory()),
|
||||
Path.of(scheduler.getWorkingDirectory()),
|
||||
Path.of(scheduler.getProcessedDirectory()),
|
||||
Path.of(scheduler.getRejectedDirectory()),
|
||||
Path.of(editing.getHighlightProjectDirectory())
|
||||
);
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void initialize() {
|
||||
for (Path directory : directories) {
|
||||
try {
|
||||
Files.createDirectories(directory);
|
||||
log.info("event=highlight_directory_ready directory={}", directory);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unable to create highlight directory: " + directory, ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
import org.example.videoclips.config.VideoClippingProperties;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.math.BigInteger;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.StandardCopyOption;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.Comparator;
|
||||
import java.util.Locale;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
import java.util.concurrent.atomic.AtomicLong;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
@Component
|
||||
@ConditionalOnExpression("${video-clipping.editing.enabled:true} and "
|
||||
+ "${video-clipping.editing.highlight-scheduler.enabled:true}")
|
||||
public class HighlightSourceScheduler {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(HighlightSourceScheduler.class);
|
||||
private static final Set<String> VIDEO_EXTENSIONS = Set.of(
|
||||
"mp4", "mov", "m4v", "mkv", "webm", "avi"
|
||||
);
|
||||
|
||||
private final VideoClippingProperties.Editing.HighlightScheduler properties;
|
||||
private final HighlightProjectStore store;
|
||||
private final Clock clock;
|
||||
private final AtomicBoolean scanning = new AtomicBoolean(false);
|
||||
private final AtomicLong scanSequence = new AtomicLong();
|
||||
|
||||
@Autowired
|
||||
public HighlightSourceScheduler(VideoClippingProperties properties, HighlightProjectStore store) {
|
||||
this(properties, store, Clock.systemUTC());
|
||||
}
|
||||
|
||||
HighlightSourceScheduler(VideoClippingProperties properties, HighlightProjectStore store, Clock clock) {
|
||||
this.properties = properties.getEditing().getHighlightScheduler();
|
||||
this.store = store;
|
||||
this.clock = clock;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void logStartup() {
|
||||
log.info("event=highlight_scheduler_started enabled=true fixed_delay_ms={} source_directory={}",
|
||||
properties.getPollIntervalMs(), properties.getSourceDirectory());
|
||||
}
|
||||
|
||||
@Scheduled(
|
||||
initialDelayString = "${video-clipping.editing.highlight-scheduler.poll-interval-ms:5000}",
|
||||
fixedDelayString = "${video-clipping.editing.highlight-scheduler.poll-interval-ms:5000}"
|
||||
)
|
||||
public void scan() {
|
||||
if (!scanning.compareAndSet(false, true)) {
|
||||
log.warn("event=highlight_scan_skipped reason=previous_scan_active");
|
||||
return;
|
||||
}
|
||||
|
||||
long scanId = scanSequence.incrementAndGet();
|
||||
long startedAt = System.nanoTime();
|
||||
try {
|
||||
findNextCandidate().ifPresentOrElse(
|
||||
candidate -> processCandidate(scanId, candidate),
|
||||
() -> log.debug("event=highlight_scan_idle scan_id={} source_directory={}",
|
||||
scanId, properties.getSourceDirectory())
|
||||
);
|
||||
} finally {
|
||||
scanning.set(false);
|
||||
log.debug("event=highlight_scan_completed scan_id={} elapsed_ms={}", scanId, elapsedMillis(startedAt));
|
||||
}
|
||||
}
|
||||
|
||||
Optional<Path> findNextCandidate() {
|
||||
Path sourceDirectory = Path.of(properties.getSourceDirectory());
|
||||
if (!Files.isDirectory(sourceDirectory)) {
|
||||
log.warn("event=highlight_scan_skipped reason=source_directory_missing directory={}", sourceDirectory);
|
||||
return Optional.empty();
|
||||
}
|
||||
|
||||
try (Stream<Path> files = Files.list(sourceDirectory)) {
|
||||
return files.filter(this::isCandidateFile)
|
||||
.sorted(candidateComparator())
|
||||
.findFirst();
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unable to scan highlight source directory: " + sourceDirectory, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void processCandidate(long scanId, Path candidate) {
|
||||
Path activeFile = candidate;
|
||||
log.info("event=highlight_candidate_selected scan_id={} file={} source_directory={}",
|
||||
scanId, candidate.getFileName(), candidate.getParent());
|
||||
try {
|
||||
activeFile = moveToDirectory(candidate, Path.of(properties.getWorkingDirectory()));
|
||||
log.info("event=highlight_candidate_claimed scan_id={} file={} working_directory={}",
|
||||
scanId, activeFile.getFileName(), activeFile.getParent());
|
||||
createProject(scanId, activeFile);
|
||||
} catch (RuntimeException ex) {
|
||||
handleFailure(scanId, activeFile, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private void createProject(long scanId, Path workingFile) {
|
||||
long startedAt = System.nanoTime();
|
||||
String projectId = projectId(workingFile);
|
||||
Instant now = clock.instant();
|
||||
HighlightProject project = new HighlightProject(
|
||||
projectId,
|
||||
displayName(workingFile),
|
||||
HighlightProjectStatus.CREATED,
|
||||
workingFile.getFileName().toString(),
|
||||
store.projectDirectory(projectId).toString(),
|
||||
now,
|
||||
now,
|
||||
null
|
||||
);
|
||||
HighlightProjectManifest manifest = new HighlightProjectManifest(
|
||||
projectId,
|
||||
workingFile.getFileName().toString(),
|
||||
HighlightFolderContract.standard(),
|
||||
now
|
||||
);
|
||||
Path projectDirectory = store.createProject(project, manifest);
|
||||
copySourceToProject(workingFile, store.sourceDirectory(projectId).resolve(workingFile.getFileName()));
|
||||
Path processedFile = moveToDirectory(workingFile, Path.of(properties.getProcessedDirectory()));
|
||||
log.info("event=highlight_project_created scan_id={} project_id={} source_file={} project_directory={} "
|
||||
+ "elapsed_ms={}",
|
||||
scanId, projectId, processedFile.getFileName(), projectDirectory, elapsedMillis(startedAt));
|
||||
}
|
||||
|
||||
private void handleFailure(long scanId, Path activeFile, RuntimeException failure) {
|
||||
log.error("event=highlight_processing_failed scan_id={} file={} error_type={} message={}",
|
||||
scanId, activeFile.getFileName(), failure.getClass().getSimpleName(), failure.getMessage());
|
||||
try {
|
||||
Path rejectedFile = moveToDirectory(activeFile, Path.of(properties.getRejectedDirectory()));
|
||||
log.warn("event=highlight_source_rejected scan_id={} file={} rejected_directory={}",
|
||||
scanId, rejectedFile.getFileName(), rejectedFile.getParent());
|
||||
} catch (RuntimeException rejectionFailure) {
|
||||
log.error("event=highlight_rejection_failed scan_id={} file={} error_type={}",
|
||||
scanId, activeFile.getFileName(), rejectionFailure.getClass().getSimpleName());
|
||||
}
|
||||
}
|
||||
|
||||
private void copySourceToProject(Path source, Path target) {
|
||||
try {
|
||||
Files.copy(source, target, StandardCopyOption.COPY_ATTRIBUTES);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unable to copy highlight source file into project: " + source, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private Path moveToDirectory(Path source, Path targetDirectory) {
|
||||
Path target = targetDirectory.resolve(source.getFileName());
|
||||
if (Files.exists(target)) {
|
||||
throw new IllegalStateException("Refusing to overwrite highlight source file: " + target);
|
||||
}
|
||||
try {
|
||||
Files.createDirectories(targetDirectory);
|
||||
return Files.move(source, target, StandardCopyOption.ATOMIC_MOVE);
|
||||
} catch (IOException ex) {
|
||||
throw new IllegalStateException("Unable to move highlight source file to: " + targetDirectory, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isCandidateFile(Path path) {
|
||||
if (!Files.isRegularFile(path)) {
|
||||
return false;
|
||||
}
|
||||
String fileName = path.getFileName().toString();
|
||||
return !fileName.startsWith(".")
|
||||
&& !fileName.endsWith(".tmp")
|
||||
&& !fileName.endsWith(".part")
|
||||
&& !fileName.endsWith(".download")
|
||||
&& !fileName.endsWith(".failed")
|
||||
&& VIDEO_EXTENSIONS.contains(extension(fileName));
|
||||
}
|
||||
|
||||
private String projectId(Path source) {
|
||||
String baseName = stripExtension(source.getFileName().toString())
|
||||
.toLowerCase(Locale.ROOT)
|
||||
.replaceAll("[^a-z0-9._-]+", "-")
|
||||
.replaceAll("^-+|-+$", "");
|
||||
if (baseName.isBlank()) {
|
||||
baseName = "highlight-project";
|
||||
}
|
||||
Path candidate = store.projectDirectory(baseName);
|
||||
int suffix = 1;
|
||||
while (Files.exists(candidate)) {
|
||||
candidate = store.projectDirectory(baseName + "-" + suffix);
|
||||
suffix++;
|
||||
}
|
||||
return candidate.getFileName().toString();
|
||||
}
|
||||
|
||||
private String displayName(Path source) {
|
||||
return stripExtension(source.getFileName().toString()).replace('-', ' ').replace('_', ' ').trim();
|
||||
}
|
||||
|
||||
private String stripExtension(String fileName) {
|
||||
int dot = fileName.lastIndexOf('.');
|
||||
return dot > 0 ? fileName.substring(0, dot) : fileName;
|
||||
}
|
||||
|
||||
private String extension(String fileName) {
|
||||
int dot = fileName.lastIndexOf('.');
|
||||
return dot > 0 && dot < fileName.length() - 1
|
||||
? fileName.substring(dot + 1).toLowerCase(Locale.ROOT)
|
||||
: "";
|
||||
}
|
||||
|
||||
private Comparator<Path> candidateComparator() {
|
||||
return Comparator.comparing(path -> naturalSortKey(path.getFileName().toString())
|
||||
+ "\0" + path.getFileName());
|
||||
}
|
||||
|
||||
private String naturalSortKey(String value) {
|
||||
StringBuilder key = new StringBuilder();
|
||||
StringBuilder number = new StringBuilder();
|
||||
for (int i = 0; i < value.length(); i++) {
|
||||
char current = value.charAt(i);
|
||||
if (Character.isDigit(current)) {
|
||||
number.append(current);
|
||||
} else {
|
||||
appendNumber(key, number);
|
||||
key.append(Character.toLowerCase(current));
|
||||
}
|
||||
}
|
||||
appendNumber(key, number);
|
||||
return key.toString();
|
||||
}
|
||||
|
||||
private void appendNumber(StringBuilder key, StringBuilder number) {
|
||||
if (number.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
key.append(String.format("%020d", number.length()));
|
||||
key.append(new BigInteger(number.toString()));
|
||||
number.setLength(0);
|
||||
}
|
||||
|
||||
private long elapsedMillis(long startedAt) {
|
||||
return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAt);
|
||||
}
|
||||
}
|
||||
|
|
@ -56,6 +56,13 @@ video-clipping:
|
|||
auto-render-when-plan-appears: ${VIDEO_EDITING_LOCAL_DIRECTOR_AUTO_RENDER:false}
|
||||
require-approval-before-render: ${VIDEO_EDITING_LOCAL_DIRECTOR_REQUIRE_APPROVAL:true}
|
||||
approval-file-name: ${VIDEO_EDITING_LOCAL_DIRECTOR_APPROVAL_FILE_NAME:approved.flag}
|
||||
highlight-scheduler:
|
||||
enabled: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_ENABLED:true}
|
||||
source-directory: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_SOURCE_DIRECTORY:./input/highlights/source}
|
||||
working-directory: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_WORKING_DIRECTORY:./input/highlights/working}
|
||||
processed-directory: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_PROCESSED_DIRECTORY:./input/highlights/processed}
|
||||
rejected-directory: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_REJECTED_DIRECTORY:./input/highlights/rejected}
|
||||
poll-interval-ms: ${VIDEO_EDITING_HIGHLIGHT_SCHEDULER_POLL_INTERVAL_MS:5000}
|
||||
|
||||
logging:
|
||||
level:
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ class VideoClippingPropertiesTest {
|
|||
assertThat(properties.getEditing().getLocalDirector().isAutoRenderWhenPlanAppears()).isFalse();
|
||||
assertThat(properties.getEditing().getLocalDirector().isRequireApprovalBeforeRender()).isTrue();
|
||||
assertThat(properties.getEditing().getLocalDirector().getApprovalFileName()).isEqualTo("approved.flag");
|
||||
assertThat(properties.getEditing().getHighlightScheduler().isEnabled()).isTrue();
|
||||
assertThat(properties.getEditing().getHighlightScheduler().getSourceDirectory())
|
||||
.isEqualTo("./input/highlights/source");
|
||||
assertThat(properties.getEditing().getHighlightScheduler().getPollIntervalMs()).isEqualTo(5000);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -85,7 +89,10 @@ class VideoClippingPropertiesTest {
|
|||
"video-clipping.editing.local-director.poll-interval-ms=9000",
|
||||
"video-clipping.editing.local-director.auto-render-when-plan-appears=true",
|
||||
"video-clipping.editing.local-director.require-approval-before-render=false",
|
||||
"video-clipping.editing.local-director.approval-file-name=go.flag"
|
||||
"video-clipping.editing.local-director.approval-file-name=go.flag",
|
||||
"video-clipping.editing.highlight-scheduler.enabled=false",
|
||||
"video-clipping.editing.highlight-scheduler.source-directory=/tmp/highlight-source",
|
||||
"video-clipping.editing.highlight-scheduler.poll-interval-ms=11000"
|
||||
)
|
||||
.run(context -> {
|
||||
VideoClippingProperties properties = context.getBean(VideoClippingProperties.class);
|
||||
|
|
@ -113,6 +120,10 @@ class VideoClippingPropertiesTest {
|
|||
assertThat(properties.getEditing().getLocalDirector().isAutoRenderWhenPlanAppears()).isTrue();
|
||||
assertThat(properties.getEditing().getLocalDirector().isRequireApprovalBeforeRender()).isFalse();
|
||||
assertThat(properties.getEditing().getLocalDirector().getApprovalFileName()).isEqualTo("go.flag");
|
||||
assertThat(properties.getEditing().getHighlightScheduler().isEnabled()).isFalse();
|
||||
assertThat(properties.getEditing().getHighlightScheduler().getSourceDirectory())
|
||||
.isEqualTo("/tmp/highlight-source");
|
||||
assertThat(properties.getEditing().getHighlightScheduler().getPollIntervalMs()).isEqualTo(11000);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,40 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
import org.example.videoclips.config.VideoClippingProperties;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HighlightProjectDirectoryInitializerTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
@Test
|
||||
void createsAllConfiguredHighlightDirectories() {
|
||||
VideoClippingProperties properties = properties();
|
||||
|
||||
new HighlightProjectDirectoryInitializer(properties).initialize();
|
||||
|
||||
assertThat(Files.isDirectory(tempDir.resolve("source"))).isTrue();
|
||||
assertThat(Files.isDirectory(tempDir.resolve("working"))).isTrue();
|
||||
assertThat(Files.isDirectory(tempDir.resolve("processed"))).isTrue();
|
||||
assertThat(Files.isDirectory(tempDir.resolve("rejected"))).isTrue();
|
||||
assertThat(Files.isDirectory(tempDir.resolve("highlight-projects"))).isTrue();
|
||||
}
|
||||
|
||||
private VideoClippingProperties properties() {
|
||||
VideoClippingProperties properties = new VideoClippingProperties();
|
||||
properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString());
|
||||
var scheduler = properties.getEditing().getHighlightScheduler();
|
||||
scheduler.setSourceDirectory(tempDir.resolve("source").toString());
|
||||
scheduler.setWorkingDirectory(tempDir.resolve("working").toString());
|
||||
scheduler.setProcessedDirectory(tempDir.resolve("processed").toString());
|
||||
scheduler.setRejectedDirectory(tempDir.resolve("rejected").toString());
|
||||
return properties;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,102 @@
|
|||
package org.example.videoclips.editing;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.example.videoclips.config.VideoClippingProperties;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.Optional;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class HighlightSourceSchedulerTest {
|
||||
|
||||
@TempDir
|
||||
Path tempDir;
|
||||
|
||||
private VideoClippingProperties properties;
|
||||
private FileSystemHighlightProjectStore store;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
properties = new VideoClippingProperties();
|
||||
properties.getEditing().setHighlightProjectDirectory(tempDir.resolve("highlight-projects").toString());
|
||||
var scheduler = properties.getEditing().getHighlightScheduler();
|
||||
scheduler.setSourceDirectory(tempDir.resolve("source").toString());
|
||||
scheduler.setWorkingDirectory(tempDir.resolve("working").toString());
|
||||
scheduler.setProcessedDirectory(tempDir.resolve("processed").toString());
|
||||
scheduler.setRejectedDirectory(tempDir.resolve("rejected").toString());
|
||||
new HighlightProjectDirectoryInitializer(properties).initialize();
|
||||
store = new FileSystemHighlightProjectStore(properties, new ObjectMapper().findAndRegisterModules());
|
||||
}
|
||||
|
||||
@Test
|
||||
void selectsFirstValidVideoFileAndIgnoresTemporaryOrUnsupportedFiles() throws Exception {
|
||||
Files.writeString(tempDir.resolve("source/10-drive.mp4"), "video");
|
||||
Files.writeString(tempDir.resolve("source/2-drive.mov"), "video");
|
||||
Files.writeString(tempDir.resolve("source/.hidden.mp4"), "video");
|
||||
Files.writeString(tempDir.resolve("source/upload.tmp"), "video");
|
||||
Files.writeString(tempDir.resolve("source/upload.part"), "video");
|
||||
Files.writeString(tempDir.resolve("source/upload.download"), "video");
|
||||
Files.writeString(tempDir.resolve("source/notes.txt"), "ignore");
|
||||
Files.createDirectory(tempDir.resolve("source/folder.mp4"));
|
||||
|
||||
Optional<Path> candidate = scheduler().findNextCandidate();
|
||||
|
||||
assertThat(candidate).get().extracting(path -> path.getFileName().toString()).isEqualTo("2-drive.mov");
|
||||
}
|
||||
|
||||
@Test
|
||||
void processesExactlyOneSourceVideoPerScan() throws Exception {
|
||||
Path first = Files.writeString(tempDir.resolve("source/1.mp4"), "first");
|
||||
Path second = Files.writeString(tempDir.resolve("source/2.mp4"), "second");
|
||||
|
||||
scheduler().scan();
|
||||
|
||||
assertThat(first).doesNotExist();
|
||||
assertThat(second).exists();
|
||||
assertThat(tempDir.resolve("processed/1.mp4")).exists();
|
||||
assertThat(tempDir.resolve("highlight-projects/1/project.json")).exists();
|
||||
assertThat(tempDir.resolve("highlight-projects/1/manifest.json")).exists();
|
||||
assertThat(tempDir.resolve("highlight-projects/1/source/1.mp4")).exists();
|
||||
assertThat(tempDir.resolve("highlight-projects/2")).doesNotExist();
|
||||
}
|
||||
|
||||
@Test
|
||||
void writesProjectAndManifestForClaimedSourceVideo() throws Exception {
|
||||
Files.writeString(tempDir.resolve("source/Porsche Drive.mp4"), "video");
|
||||
|
||||
scheduler().scan();
|
||||
|
||||
HighlightProject project = store.readJson("porsche-drive", "project.json", HighlightProject.class);
|
||||
HighlightProjectManifest manifest = store.readJson("porsche-drive", "manifest.json",
|
||||
HighlightProjectManifest.class);
|
||||
assertThat(project.status()).isEqualTo(HighlightProjectStatus.CREATED);
|
||||
assertThat(project.sourceVideoFileName()).isEqualTo("Porsche Drive.mp4");
|
||||
assertThat(project.projectDirectory()).isEqualTo(tempDir.resolve("highlight-projects/porsche-drive").toString());
|
||||
assertThat(project.createdAt()).isEqualTo(Instant.parse("2026-07-11T08:00:00Z"));
|
||||
assertThat(manifest.projectId()).isEqualTo("porsche-drive");
|
||||
assertThat(manifest.folderContract()).isEqualTo(HighlightFolderContract.standard());
|
||||
}
|
||||
|
||||
@Test
|
||||
void createsUniqueProjectIdWhenProjectAlreadyExists() throws Exception {
|
||||
Files.writeString(tempDir.resolve("source/porsche.mp4"), "video");
|
||||
Files.createDirectories(tempDir.resolve("highlight-projects/porsche"));
|
||||
|
||||
scheduler().scan();
|
||||
|
||||
assertThat(tempDir.resolve("highlight-projects/porsche-1/project.json")).exists();
|
||||
}
|
||||
|
||||
private HighlightSourceScheduler scheduler() {
|
||||
Clock clock = Clock.fixed(Instant.parse("2026-07-11T08:00:00Z"), ZoneOffset.UTC);
|
||||
return new HighlightSourceScheduler(properties, store, clock);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue