Add local director project scheduler

This commit is contained in:
JSLMPR 2026-07-10 15:43:04 +02:00
parent f8d0bc874e
commit 9c0a2e88e3
7 changed files with 382 additions and 4 deletions

View File

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

View File

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

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.local-director.enabled:true}")
public class EditProjectDirectoryInitializer {
private static final Logger log = LoggerFactory.getLogger(EditProjectDirectoryInitializer.class);
private final List<Path> 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);
}
}
}
}

View File

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

View File

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

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

View File

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