Add local director project scheduler
This commit is contained in:
parent
f8d0bc874e
commit
9c0a2e88e3
|
|
@ -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`.
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue