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.
|
10. [x] Add `analysis.json` writing and reading.
|
||||||
11. [x] Add storyboard prompt generation from `analysis.json`.
|
11. [x] Add storyboard prompt generation from `analysis.json`.
|
||||||
12. [x] Add local AI director prompt generation with thumbnail/contact-sheet instructions.
|
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.
|
14. [ ] Add project `inbox/` plan pickup.
|
||||||
15. [ ] Add strict JSON edit plan schema and validation.
|
15. [ ] Add strict JSON edit plan schema and validation.
|
||||||
16. [ ] Add `PUT /v1/edit-projects/{projectId}/plan`.
|
16. [ ] Add `PUT /v1/edit-projects/{projectId}/plan`.
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package org.example.videoclips.editing;
|
package org.example.videoclips.editing;
|
||||||
|
|
||||||
import org.example.videoclips.config.VideoClippingProperties;
|
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 org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
|
@ -9,8 +9,8 @@ import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
|
|
||||||
@Component
|
@Component
|
||||||
@ConditionalOnProperty(name = "video-clipping.editing.local-director.enabled", havingValue = "true",
|
@ConditionalOnExpression("${video-clipping.editing.enabled:true} and "
|
||||||
matchIfMissing = true)
|
+ "${video-clipping.editing.local-director.enabled:true}")
|
||||||
public class AiDirectorPromptGenerator {
|
public class AiDirectorPromptGenerator {
|
||||||
|
|
||||||
private static final String README_FILE_NAME = "director-readme.md";
|
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));
|
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) {
|
private String nextProjectId(String baseProjectId) {
|
||||||
String candidate = baseProjectId;
|
String candidate = baseProjectId;
|
||||||
int suffix = 1;
|
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