Add highlight source scheduler

This commit is contained in:
JSLMPR 2026-07-11 09:50:11 +02:00
parent 5ba108f740
commit 0b6e5afddf
8 changed files with 537 additions and 2 deletions

View File

@ -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.

View File

@ -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 {

View File

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

View File

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

View File

@ -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:

View File

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

View File

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

View File

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