From 9c0a2e88e369c20ecaeeb47400fd7eb0bf8d0dfe Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Fri, 10 Jul 2026 15:43:04 +0200 Subject: [PATCH] Add local director project scheduler --- docs/cinematic-video-editing-service-plan.md | 2 +- .../editing/AiDirectorPromptGenerator.java | 6 +- .../EditProjectDirectoryInitializer.java | 48 ++++++ .../editing/EditProjectService.java | 16 ++ .../editing/LocalDirectorScheduler.java | 151 ++++++++++++++++++ .../EditProjectDirectoryInitializerTest.java | 40 +++++ .../editing/LocalDirectorSchedulerTest.java | 123 ++++++++++++++ 7 files changed, 382 insertions(+), 4 deletions(-) create mode 100644 src/main/java/org/example/videoclips/editing/EditProjectDirectoryInitializer.java create mode 100644 src/main/java/org/example/videoclips/editing/LocalDirectorScheduler.java create mode 100644 src/test/java/org/example/videoclips/editing/EditProjectDirectoryInitializerTest.java create mode 100644 src/test/java/org/example/videoclips/editing/LocalDirectorSchedulerTest.java diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index a5640ee..5c15dc6 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -793,7 +793,7 @@ It writes the script only and requires an imported audio file. 10. [x] Add `analysis.json` writing and reading. 11. [x] Add storyboard prompt generation from `analysis.json`. 12. [x] Add local AI director prompt generation with thumbnail/contact-sheet instructions. -13. [ ] Add local director source-folder scheduler. +13. [x] Add local director source-folder scheduler. 14. [ ] Add project `inbox/` plan pickup. 15. [ ] Add strict JSON edit plan schema and validation. 16. [ ] Add `PUT /v1/edit-projects/{projectId}/plan`. diff --git a/src/main/java/org/example/videoclips/editing/AiDirectorPromptGenerator.java b/src/main/java/org/example/videoclips/editing/AiDirectorPromptGenerator.java index 93e7abd..a4cefe6 100644 --- a/src/main/java/org/example/videoclips/editing/AiDirectorPromptGenerator.java +++ b/src/main/java/org/example/videoclips/editing/AiDirectorPromptGenerator.java @@ -1,7 +1,7 @@ package org.example.videoclips.editing; import org.example.videoclips.config.VideoClippingProperties; -import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; import org.springframework.stereotype.Component; import java.io.IOException; @@ -9,8 +9,8 @@ import java.nio.file.Files; import java.nio.file.Path; @Component -@ConditionalOnProperty(name = "video-clipping.editing.local-director.enabled", havingValue = "true", - matchIfMissing = true) +@ConditionalOnExpression("${video-clipping.editing.enabled:true} and " + + "${video-clipping.editing.local-director.enabled:true}") public class AiDirectorPromptGenerator { private static final String README_FILE_NAME = "director-readme.md"; diff --git a/src/main/java/org/example/videoclips/editing/EditProjectDirectoryInitializer.java b/src/main/java/org/example/videoclips/editing/EditProjectDirectoryInitializer.java new file mode 100644 index 0000000..6c2e34c --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/EditProjectDirectoryInitializer.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.local-director.enabled:true}") +public class EditProjectDirectoryInitializer { + + private static final Logger log = LoggerFactory.getLogger(EditProjectDirectoryInitializer.class); + + private final List directories; + + public EditProjectDirectoryInitializer(VideoClippingProperties properties) { + VideoClippingProperties.Editing editing = properties.getEditing(); + VideoClippingProperties.Editing.LocalDirector local = editing.getLocalDirector(); + this.directories = List.of( + Path.of(local.getSourceDirectory()), + Path.of(local.getWorkingDirectory()), + Path.of(local.getProcessedDirectory()), + Path.of(local.getRejectedDirectory()), + Path.of(editing.getProjectDirectory()) + ); + } + + @EventListener(ApplicationReadyEvent.class) + public void initialize() { + for (Path directory : directories) { + try { + Files.createDirectories(directory); + log.info("event=local_director_directory_ready directory={}", directory); + } catch (IOException ex) { + throw new IllegalStateException("Unable to create local director directory: " + directory, ex); + } + } + } +} diff --git a/src/main/java/org/example/videoclips/editing/EditProjectService.java b/src/main/java/org/example/videoclips/editing/EditProjectService.java index 67d098b..59e8003 100644 --- a/src/main/java/org/example/videoclips/editing/EditProjectService.java +++ b/src/main/java/org/example/videoclips/editing/EditProjectService.java @@ -66,6 +66,22 @@ public class EditProjectService { return response(store.readJson(projectId, "project.json", EditProject.class)); } + public EditProjectResponse updateProject( + String projectId, + EditProjectStatus status, + Path inputDirectory, + String failureReason + ) { + EditProject current = store.readJson(projectId, "project.json", EditProject.class); + EditProject updated = new EditProject( + current.id(), current.name(), status, inputDirectory.toString(), current.outputDirectory(), + current.targetDurationSeconds(), current.style(), current.voiceoverEnabled(), current.musicEnabled(), + current.soundEffectsEnabled(), current.createdAt(), Instant.now(clock), failureReason + ); + store.writeJson(projectId, "project.json", updated); + return response(updated); + } + private String nextProjectId(String baseProjectId) { String candidate = baseProjectId; int suffix = 1; diff --git a/src/main/java/org/example/videoclips/editing/LocalDirectorScheduler.java b/src/main/java/org/example/videoclips/editing/LocalDirectorScheduler.java new file mode 100644 index 0000000..45aa9e0 --- /dev/null +++ b/src/main/java/org/example/videoclips/editing/LocalDirectorScheduler.java @@ -0,0 +1,151 @@ +package org.example.videoclips.editing; + +import org.example.videoclips.api.dto.CreateEditProjectRequest; +import org.example.videoclips.api.dto.EditProjectResponse; +import org.example.videoclips.config.VideoClippingProperties; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.stream.Stream; + +@Component +@ConditionalOnExpression("${video-clipping.editing.enabled:true} and " + + "${video-clipping.editing.local-director.enabled:true}") +public class LocalDirectorScheduler { + + private static final Logger log = LoggerFactory.getLogger(LocalDirectorScheduler.class); + + private final VideoClippingProperties.Editing editing; + private final VideoClippingProperties.Editing.LocalDirector local; + private final EditProjectService projectService; + private final EditProjectAnalyzer analyzer; + private final AiDirectorPromptGenerator promptGenerator; + private final EditProjectStore store; + private final AtomicBoolean scanning = new AtomicBoolean(); + + public LocalDirectorScheduler( + VideoClippingProperties properties, + EditProjectService projectService, + EditProjectAnalyzer analyzer, + AiDirectorPromptGenerator promptGenerator, + EditProjectStore store + ) { + this.editing = properties.getEditing(); + this.local = editing.getLocalDirector(); + this.projectService = projectService; + this.analyzer = analyzer; + this.promptGenerator = promptGenerator; + this.store = store; + } + + @Scheduled( + initialDelayString = "${video-clipping.editing.local-director.poll-interval-ms:5000}", + fixedDelayString = "${video-clipping.editing.local-director.poll-interval-ms:5000}" + ) + public void scan() { + if (!scanning.compareAndSet(false, true)) { + log.warn("event=local_director_scan_skipped reason=previous_scan_active"); + return; + } + try { + findNextCandidate().ifPresent(this::process); + } finally { + scanning.set(false); + } + } + + Optional findNextCandidate() { + Path sourceDirectory = Path.of(local.getSourceDirectory()); + if (!Files.isDirectory(sourceDirectory)) { + log.warn("event=local_director_scan_skipped reason=source_directory_missing directory={}", sourceDirectory); + return Optional.empty(); + } + try (Stream entries = Files.list(sourceDirectory)) { + return entries.filter(Files::isDirectory) + .filter(this::isCandidate) + .sorted(Comparator.comparing(path -> path.getFileName().toString(), String.CASE_INSENSITIVE_ORDER)) + .findFirst(); + } catch (IOException ex) { + throw new IllegalStateException("Unable to scan local director source directory: " + sourceDirectory, ex); + } + } + + private boolean isCandidate(Path path) { + String name = path.getFileName().toString(); + return !name.startsWith(".") + && !name.endsWith(".tmp") + && !name.endsWith(".part") + && !name.endsWith(".download") + && !name.endsWith(".processing"); + } + + private void process(Path source) { + Path active = source; + String projectId = null; + long startedAt = System.nanoTime(); + try { + Path working = move(source, Path.of(local.getWorkingDirectory())); + active = working; + log.info("event=local_director_project_claimed folder={}", working.getFileName()); + EditProjectResponse project = projectService.createProject(new CreateEditProjectRequest( + working.getFileName().toString(), working.toString(), editing.getTargetDurationSeconds(), + "cinematic-porsche-promo", true, true, true)); + projectId = project.projectId(); + projectService.updateProject(projectId, EditProjectStatus.ANALYZING, working, null); + EditProjectAnalysis analysis = analyzer.analyze(projectId, working); + + Path processed = move(working, Path.of(local.getProcessedDirectory())); + active = processed; + EditProjectAnalysis relocated = relocate(analysis, processed); + store.writeJson(projectId, "analysis.json", relocated); + projectService.updateProject(projectId, EditProjectStatus.ANALYZED, processed, null); + promptGenerator.generate(projectId); + projectService.updateProject(projectId, EditProjectStatus.WAITING_FOR_DIRECTOR, processed, null); + log.info("event=local_director_project_ready project_id={} source_folder={} prompt={} elapsed_ms={}", + projectId, processed, local.getDirectorPromptFileName(), (System.nanoTime() - startedAt) / 1_000_000); + } catch (RuntimeException ex) { + log.error("event=local_director_project_failed folder={} project_id={} error_type={} message={}", + source.getFileName(), projectId, ex.getClass().getSimpleName(), ex.getMessage()); + if (Files.exists(active)) { + active = move(active, Path.of(local.getRejectedDirectory())); + } + if (projectId != null) { + projectService.updateProject(projectId, EditProjectStatus.FAILED, active, ex.getMessage()); + } + } + } + + private EditProjectAnalysis relocate(EditProjectAnalysis analysis, Path processedDirectory) { + List clips = analysis.clips().stream() + .map(clip -> new ClipAnalysis( + clip.clipId(), processedDirectory.resolve(Path.of(clip.sourcePath()).getFileName()).toString(), + clip.durationSeconds(), clip.videoCodec(), clip.audioCodec(), clip.width(), clip.height(), + clip.frameRate(), clip.thumbnails(), clip.contactSheet(), clip.proxyPath(), clip.motionScore(), + clip.brightnessScore(), clip.sharpnessScore())) + .toList(); + return new EditProjectAnalysis(analysis.projectId(), clips, analysis.errors(), analysis.createdAt()); + } + + private Path move(Path source, Path targetDirectory) { + Path target = targetDirectory.resolve(source.getFileName()); + if (Files.exists(target)) { + throw new IllegalStateException("Refusing to overwrite local director folder: " + target); + } + try { + return Files.move(source, target, StandardCopyOption.ATOMIC_MOVE); + } catch (IOException ex) { + throw new IllegalStateException("Unable to move local director folder to: " + targetDirectory, ex); + } + } +} diff --git a/src/test/java/org/example/videoclips/editing/EditProjectDirectoryInitializerTest.java b/src/test/java/org/example/videoclips/editing/EditProjectDirectoryInitializerTest.java new file mode 100644 index 0000000..6b85b43 --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/EditProjectDirectoryInitializerTest.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 EditProjectDirectoryInitializerTest { + + @TempDir + Path tempDir; + + @Test + void createsAllConfiguredLocalDirectorDirectories() { + VideoClippingProperties properties = properties(); + + new EditProjectDirectoryInitializer(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("projects"))).isTrue(); + } + + private VideoClippingProperties properties() { + VideoClippingProperties properties = new VideoClippingProperties(); + properties.getEditing().setProjectDirectory(tempDir.resolve("projects").toString()); + var local = properties.getEditing().getLocalDirector(); + local.setSourceDirectory(tempDir.resolve("source").toString()); + local.setWorkingDirectory(tempDir.resolve("working").toString()); + local.setProcessedDirectory(tempDir.resolve("processed").toString()); + local.setRejectedDirectory(tempDir.resolve("rejected").toString()); + return properties; + } +} diff --git a/src/test/java/org/example/videoclips/editing/LocalDirectorSchedulerTest.java b/src/test/java/org/example/videoclips/editing/LocalDirectorSchedulerTest.java new file mode 100644 index 0000000..c0dbc3a --- /dev/null +++ b/src/test/java/org/example/videoclips/editing/LocalDirectorSchedulerTest.java @@ -0,0 +1,123 @@ +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.List; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LocalDirectorSchedulerTest { + + @TempDir + Path tempDir; + + private VideoClippingProperties properties; + private FileSystemEditProjectStore store; + + @BeforeEach + void setUp() { + properties = new VideoClippingProperties(); + properties.getEditing().setProjectDirectory(tempDir.resolve("projects").toString()); + var local = properties.getEditing().getLocalDirector(); + local.setSourceDirectory(tempDir.resolve("source").toString()); + local.setWorkingDirectory(tempDir.resolve("working").toString()); + local.setProcessedDirectory(tempDir.resolve("processed").toString()); + local.setRejectedDirectory(tempDir.resolve("rejected").toString()); + new EditProjectDirectoryInitializer(properties).initialize(); + store = new FileSystemEditProjectStore(properties, new ObjectMapper().findAndRegisterModules()); + } + + @Test + void selectsFoldersDeterministicallyAndIgnoresTemporaryFolders() throws Exception { + Files.createDirectory(tempDir.resolve("source/B-project")); + Files.createDirectory(tempDir.resolve("source/a-project")); + Files.createDirectory(tempDir.resolve("source/.hidden")); + Files.createDirectory(tempDir.resolve("source/upload.tmp")); + Files.createDirectory(tempDir.resolve("source/upload.part")); + Files.createDirectory(tempDir.resolve("source/upload.download")); + Files.createDirectory(tempDir.resolve("source/upload.processing")); + + Optional candidate = scheduler(mock(EditClipDiscovery.class)).findNextCandidate(); + + assertThat(candidate).get().extracting(path -> path.getFileName().toString()).isEqualTo("a-project"); + } + + @Test + void processesOneFolderAndWritesReadyDirectorProject() throws Exception { + Path first = Files.createDirectory(tempDir.resolve("source/01-porsche")); + Path firstClip = Files.writeString(first.resolve("clip_00001.mp4"), "video"); + Path second = Files.createDirectory(tempDir.resolve("source/02-porsche")); + Files.writeString(second.resolve("clip_00002.mp4"), "video"); + EditClipDiscovery discovery = mock(EditClipDiscovery.class); + when(discovery.discover(any())).thenAnswer(invocation -> { + Path directory = invocation.getArgument(0); + try (var files = Files.list(directory)) { + return files.filter(Files::isRegularFile).toList(); + } + }); + + scheduler(discovery).scan(); + + assertThat(Files.isDirectory(tempDir.resolve("processed/01-porsche"))).isTrue(); + assertThat(Files.isDirectory(tempDir.resolve("source/02-porsche"))).isTrue(); + assertThat(Files.exists(tempDir.resolve("projects/01-porsche/analysis.json"))).isTrue(); + assertThat(Files.exists(tempDir.resolve("projects/01-porsche/ai-director-prompt.md"))).isTrue(); + assertThat(Files.exists(tempDir.resolve("projects/01-porsche/director-readme.md"))).isTrue(); + EditProject project = store.readJson("01-porsche", "project.json", EditProject.class); + assertThat(project.status()).isEqualTo(EditProjectStatus.WAITING_FOR_DIRECTOR); + assertThat(project.inputDirectory()).isEqualTo(tempDir.resolve("processed/01-porsche").toString()); + EditProjectAnalysis analysis = store.readJson("01-porsche", "analysis.json", EditProjectAnalysis.class); + assertThat(analysis.clips().getFirst().sourcePath()) + .isEqualTo(tempDir.resolve("processed/01-porsche/clip_00001.mp4").toString()); + assertThat(firstClip).doesNotExist(); + } + + @Test + void rejectsEmptyProjectFolder() throws Exception { + Files.createDirectory(tempDir.resolve("source/empty-project")); + EditClipDiscovery discovery = mock(EditClipDiscovery.class); + when(discovery.discover(any())).thenReturn(List.of()); + + scheduler(discovery).scan(); + + assertThat(Files.isDirectory(tempDir.resolve("rejected/empty-project"))).isTrue(); + EditProject project = store.readJson("empty-project", "project.json", EditProject.class); + assertThat(project.status()).isEqualTo(EditProjectStatus.FAILED); + assertThat(project.inputDirectory()).isEqualTo(tempDir.resolve("rejected/empty-project").toString()); + } + + private LocalDirectorScheduler scheduler(EditClipDiscovery discovery) { + FfmpegClipInspector inspector = mock(FfmpegClipInspector.class); + when(inspector.inspect(any())).thenAnswer(invocation -> { + Path clip = invocation.getArgument(0); + return new ClipAnalysis(clip.getFileName().toString().replace(".mp4", ""), clip.toString(), 8.0, + "h264", "aac", 1920, 1080, 30.0, List.of(), null, null, 0.8, 0.7, 0.9); + }); + ThumbnailExtractor thumbnails = mock(ThumbnailExtractor.class); + when(thumbnails.extract(any(), any())).thenReturn(List.of("thumb.jpg")); + ContactSheetGenerator contactSheets = mock(ContactSheetGenerator.class); + when(contactSheets.generate(any(), any())).thenReturn("contact.jpg"); + ProxyGenerator proxies = mock(ProxyGenerator.class); + when(proxies.generate(any(), any())).thenReturn(Optional.empty()); + Clock clock = Clock.fixed(Instant.parse("2026-07-10T10:00:00Z"), ZoneOffset.UTC); + EditProjectService projectService = new EditProjectService(store, clock); + EditProjectAnalyzer analyzer = new EditProjectAnalyzer(store, discovery, inspector, thumbnails, contactSheets, + proxies, clock); + StoryboardPromptGenerator storyboard = new StoryboardPromptGenerator(store); + AiDirectorPromptGenerator director = new AiDirectorPromptGenerator(store, storyboard, properties); + return new LocalDirectorScheduler(properties, projectService, analyzer, director, store); + } +}