From 0b6e5afddf9f46d0314f00feccdfb3de3141c0d4 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Sat, 11 Jul 2026 09:50:11 +0200 Subject: [PATCH] Add highlight source scheduler --- ...uction-cinematic-highlight-editing-plan.md | 3 +- .../config/VideoClippingProperties.java | 69 +++++ .../HighlightProjectDirectoryInitializer.java | 48 ++++ .../editing/HighlightSourceScheduler.java | 257 ++++++++++++++++++ src/main/resources/application.yml | 7 + .../config/VideoClippingPropertiesTest.java | 13 +- ...hlightProjectDirectoryInitializerTest.java | 40 +++ .../editing/HighlightSourceSchedulerTest.java | 102 +++++++ 8 files changed, 537 insertions(+), 2 deletions(-) create mode 100644 src/main/java/org/example/videoclips/editing/HighlightProjectDirectoryInitializer.java create mode 100644 src/main/java/org/example/videoclips/editing/HighlightSourceScheduler.java create mode 100644 src/test/java/org/example/videoclips/editing/HighlightProjectDirectoryInitializerTest.java create mode 100644 src/test/java/org/example/videoclips/editing/HighlightSourceSchedulerTest.java diff --git a/docs/production-cinematic-highlight-editing-plan.md b/docs/production-cinematic-highlight-editing-plan.md index 09cda29..a7fcd86 100644 --- a/docs/production-cinematic-highlight-editing-plan.md +++ b/docs/production-cinematic-highlight-editing-plan.md @@ -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. diff --git a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java index 849aa51..8dc7396 100644 --- a/src/main/java/org/example/videoclips/config/VideoClippingProperties.java +++ b/src/main/java/org/example/videoclips/config/VideoClippingProperties.java @@ -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 { diff --git a/src/main/java/org/example/videoclips/editing/HighlightProjectDirectoryInitializer.java b/src/main/java/org/example/videoclips/editing/HighlightProjectDirectoryInitializer.java new file mode 100644 index 0000000..5012a9f --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/HighlightProjectDirectoryInitializer.java @@ -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 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); + } + } + } +} diff --git a/src/main/java/org/example/videoclips/editing/HighlightSourceScheduler.java b/src/main/java/org/example/videoclips/editing/HighlightSourceScheduler.java new file mode 100644 index 0000000..8346e11 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/HighlightSourceScheduler.java @@ -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 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 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 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 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); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fc47897..784c66d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -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: diff --git a/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java b/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java index f783e4d..362a41c 100644 --- a/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java +++ b/src/test/java/org/example/videoclips/config/VideoClippingPropertiesTest.java @@ -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); }); } diff --git a/src/test/java/org/example/videoclips/editing/HighlightProjectDirectoryInitializerTest.java b/src/test/java/org/example/videoclips/editing/HighlightProjectDirectoryInitializerTest.java new file mode 100644 index 0000000..88a7eb5 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/HighlightProjectDirectoryInitializerTest.java @@ -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; + } +} diff --git a/src/test/java/org/example/videoclips/editing/HighlightSourceSchedulerTest.java b/src/test/java/org/example/videoclips/editing/HighlightSourceSchedulerTest.java new file mode 100644 index 0000000..c516497 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/HighlightSourceSchedulerTest.java @@ -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 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); + } +}