• working version but not cinematic

This commit is contained in:
JSLMPR 2026-07-11 00:32:46 +02:00
parent 13b7dc4cdf
commit 5d889b00dc
7 changed files with 153 additions and 21 deletions

View File

@ -1237,6 +1237,8 @@ Read `ai-director-prompt.md`, `analysis.json`, and the generated contact sheets
Use this runbook after the local AI director milestones are implemented. Use this runbook after the local AI director milestones are implemented.
For the standalone step-by-step operator guide, see `docs/cinematic-editing-runbook.md`.
### Prerequisites ### Prerequisites
Install: Install:

View File

@ -138,7 +138,7 @@ public class FfmpegEditRenderer implements EditRenderer {
} else { } else {
copy(timeline, output); copy(timeline, output);
} }
double duration = plan.decisions().getLast().timelineEndSeconds(); double duration = plan.decisions().get(plan.decisions().size() - 1).timelineEndSeconds();
RenderManifest manifest = new RenderManifest(projectId, RenderManifest manifest = new RenderManifest(projectId,
plan.decisions().stream().map(EditDecision::clipId).toList(), output.toString(), duration, plan.decisions().stream().map(EditDecision::clipId).toList(), output.toString(), duration,
List.copyOf(commands), Instant.now()); List.copyOf(commands), Instant.now());
@ -156,6 +156,7 @@ public class FfmpegEditRenderer implements EditRenderer {
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p".formatted( + "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p".formatted(
properties.getOutputWidth(), properties.getOutputHeight())); properties.getOutputWidth(), properties.getOutputHeight()));
filter.append(",setpts=PTS/").append(decision.playbackSpeed()); filter.append(",setpts=PTS/").append(decision.playbackSpeed());
filter.append(cinematicVisualFilter(decision.visualTreatment()));
double fadeDuration = Math.min(0.5, outputDuration / 2); double fadeDuration = Math.min(0.5, outputDuration / 2);
if ("fade-in".equals(decision.transitionIn()) || "crossfade".equals(decision.transitionIn())) { if ("fade-in".equals(decision.transitionIn()) || "crossfade".equals(decision.transitionIn())) {
filter.append(",fade=t=in:st=0:d=").append(fadeDuration); filter.append(",fade=t=in:st=0:d=").append(fadeDuration);
@ -178,6 +179,15 @@ public class FfmpegEditRenderer implements EditRenderer {
return List.copyOf(command); return List.copyOf(command);
} }
String cinematicVisualFilter(String visualTreatment) {
if (visualTreatment == null || visualTreatment.isBlank() || "none".equalsIgnoreCase(visualTreatment)) {
return "";
}
return ",eq=contrast=1.12:saturation=1.18:brightness=-0.015"
+ ",unsharp=5:5:0.55:3:3:0.25"
+ ",vignette=PI/7";
}
String audioTempoFilter(double speed) { String audioTempoFilter(double speed) {
if (speed < 0.5) { if (speed < 0.5) {
return "atempo=0.5,atempo=" + (speed / 0.5); return "atempo=0.5,atempo=" + (speed / 0.5);

View File

@ -29,6 +29,7 @@ public class LocalDirectorScheduler {
private final VideoClippingProperties.Editing editing; private final VideoClippingProperties.Editing editing;
private final VideoClippingProperties.Editing.LocalDirector local; private final VideoClippingProperties.Editing.LocalDirector local;
private final EditProjectService projectService; private final EditProjectService projectService;
private final EditClipDiscovery clipDiscovery;
private final EditProjectAnalyzer analyzer; private final EditProjectAnalyzer analyzer;
private final AiDirectorPromptGenerator promptGenerator; private final AiDirectorPromptGenerator promptGenerator;
private final EditProjectStore store; private final EditProjectStore store;
@ -37,6 +38,7 @@ public class LocalDirectorScheduler {
public LocalDirectorScheduler( public LocalDirectorScheduler(
VideoClippingProperties properties, VideoClippingProperties properties,
EditProjectService projectService, EditProjectService projectService,
EditClipDiscovery clipDiscovery,
EditProjectAnalyzer analyzer, EditProjectAnalyzer analyzer,
AiDirectorPromptGenerator promptGenerator, AiDirectorPromptGenerator promptGenerator,
EditProjectStore store EditProjectStore store
@ -44,6 +46,7 @@ public class LocalDirectorScheduler {
this.editing = properties.getEditing(); this.editing = properties.getEditing();
this.local = editing.getLocalDirector(); this.local = editing.getLocalDirector();
this.projectService = projectService; this.projectService = projectService;
this.clipDiscovery = clipDiscovery;
this.analyzer = analyzer; this.analyzer = analyzer;
this.promptGenerator = promptGenerator; this.promptGenerator = promptGenerator;
this.store = store; this.store = store;
@ -59,7 +62,12 @@ public class LocalDirectorScheduler {
return; return;
} }
try { try {
findNextCandidate().ifPresent(this::process); Optional<Path> candidate = findNextCandidate();
if (candidate.isPresent()) {
process(candidate.get());
} else {
processLooseClips();
}
} finally { } finally {
scanning.set(false); scanning.set(false);
} }
@ -98,23 +106,9 @@ public class LocalDirectorScheduler {
Path working = move(source, Path.of(local.getWorkingDirectory())); Path working = move(source, Path.of(local.getWorkingDirectory()));
active = working; active = working;
log.info("event=local_director_project_claimed folder={}", working.getFileName()); log.info("event=local_director_project_claimed folder={}", working.getFileName());
EditProjectResponse project = projectService.createProject(new CreateEditProjectRequest( processWorkingProject(working, startedAt);
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) { } catch (RuntimeException ex) {
projectId = projectId(ex);
log.error("event=local_director_project_failed folder={} project_id={} error_type={} message={}", log.error("event=local_director_project_failed folder={} project_id={} error_type={} message={}",
source.getFileName(), projectId, ex.getClass().getSimpleName(), ex.getMessage()); source.getFileName(), projectId, ex.getClass().getSimpleName(), ex.getMessage());
if (Files.exists(active)) { if (Files.exists(active)) {
@ -126,6 +120,64 @@ public class LocalDirectorScheduler {
} }
} }
private void processLooseClips() {
Path sourceDirectory = Path.of(local.getSourceDirectory());
List<Path> clips = clipDiscovery.discover(sourceDirectory);
if (clips.isEmpty()) {
return;
}
Path active = null;
String projectId = null;
long startedAt = System.nanoTime();
try {
Path working = uniqueDirectory(Path.of(local.getWorkingDirectory()), "source-clips");
Files.createDirectories(working);
active = working;
for (Path clip : clips) {
Files.move(clip, working.resolve(clip.getFileName()), StandardCopyOption.ATOMIC_MOVE);
}
log.info("event=local_director_loose_clips_claimed folder={} clip_count={}",
working.getFileName(), clips.size());
processWorkingProject(working, startedAt);
} catch (IOException ex) {
throw new IllegalStateException("Unable to claim loose local director clips", ex);
} catch (RuntimeException ex) {
projectId = projectId(ex);
log.error("event=local_director_loose_clips_failed folder={} project_id={} error_type={} message={}",
active == null ? null : active.getFileName(), projectId, ex.getClass().getSimpleName(),
ex.getMessage());
if (active != null && Files.exists(active)) {
active = move(active, Path.of(local.getRejectedDirectory()));
}
if (projectId != null) {
projectService.updateProject(projectId, EditProjectStatus.FAILED, active, ex.getMessage());
}
}
}
private void processWorkingProject(Path working, long startedAt) {
String projectId = null;
try {
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()));
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) {
throw new LocalDirectorProcessingException(projectId, ex);
}
}
private EditProjectAnalysis relocate(EditProjectAnalysis analysis, Path processedDirectory) { private EditProjectAnalysis relocate(EditProjectAnalysis analysis, Path processedDirectory) {
List<ClipAnalysis> clips = analysis.clips().stream() List<ClipAnalysis> clips = analysis.clips().stream()
.map(clip -> new ClipAnalysis( .map(clip -> new ClipAnalysis(
@ -148,4 +200,34 @@ public class LocalDirectorScheduler {
throw new IllegalStateException("Unable to move local director folder to: " + targetDirectory, ex); throw new IllegalStateException("Unable to move local director folder to: " + targetDirectory, ex);
} }
} }
private Path uniqueDirectory(Path parent, String baseName) {
Path candidate = parent.resolve(baseName);
int suffix = 1;
while (Files.exists(candidate)) {
candidate = parent.resolve(baseName + "-" + suffix);
suffix++;
}
return candidate;
}
private String projectId(RuntimeException ex) {
if (ex instanceof LocalDirectorProcessingException processingException) {
return processingException.projectId();
}
return null;
}
private static class LocalDirectorProcessingException extends RuntimeException {
private final String projectId;
LocalDirectorProcessingException(String projectId, RuntimeException cause) {
super(cause.getMessage(), cause);
this.projectId = projectId;
}
String projectId() {
return projectId;
}
}
} }

View File

@ -5,7 +5,7 @@ video-clipping:
enabled: true enabled: true
local-director: local-director:
enabled: true enabled: true
auto-render-when-plan-appears: false auto-render-when-plan-appears: true
logging: logging:
level: level:

View File

@ -37,7 +37,7 @@ class CinematicEditingIntegrationTest {
StoryboardPromptGenerator storyboard = new StoryboardPromptGenerator(store); StoryboardPromptGenerator storyboard = new StoryboardPromptGenerator(store);
AiDirectorPromptGenerator director = new AiDirectorPromptGenerator(store, storyboard, properties); AiDirectorPromptGenerator director = new AiDirectorPromptGenerator(store, storyboard, properties);
LocalDirectorScheduler scheduler = new LocalDirectorScheduler( LocalDirectorScheduler scheduler = new LocalDirectorScheduler(
properties, projects, analyzer, director, store); properties, projects, new EditClipDiscovery(), analyzer, director, store);
scheduler.scan(); scheduler.scan();

View File

@ -67,10 +67,25 @@ class FfmpegEditRendererTest {
assertThat(command).contains("scale=1920:1080:force_original_aspect_ratio=decrease," assertThat(command).contains("scale=1920:1080:force_original_aspect_ratio=decrease,"
+ "pad=1920:1080:(ow-iw)/2:(oh-ih)/2,format=yuv420p,setpts=PTS/2.0," + "pad=1920:1080:(ow-iw)/2:(oh-ih)/2,format=yuv420p,setpts=PTS/2.0,"
+ "eq=contrast=1.12:saturation=1.18:brightness=-0.015,"
+ "unsharp=5:5:0.55:3:3:0.25,vignette=PI/7,"
+ "fade=t=in:st=0:d=0.5,fade=t=out:st=1.5:d=0.5"); + "fade=t=in:st=0:d=0.5,fade=t=out:st=1.5:d=0.5");
assertThat(command).containsSubsequence("-af", "atempo=2.0"); assertThat(command).containsSubsequence("-af", "atempo=2.0");
} }
@Test
void skipsCinematicGradeWhenVisualTreatmentIsNone() {
VideoClippingProperties properties = new VideoClippingProperties();
FfmpegEditRenderer renderer = new FfmpegEditRenderer(properties, mock(EditProjectStore.class),
mock(EditProjectService.class), mock(EditPlanValidator.class), command -> null);
EditDecision decision = new EditDecision("clip-1", 0, 4, 0, 4,
"cut", "cut", 1, "none", "reason");
var command = renderer.segmentCommand("source.mp4", decision, Path.of("segment.mp4"));
assertThat(command).noneMatch(argument -> argument.contains("eq=contrast=1.12"));
}
@Test @Test
void addsDelayedSfxInputToFinalMix() { void addsDelayedSfxInputToFinalMix() {
VideoClippingProperties properties = new VideoClippingProperties(); VideoClippingProperties properties = new VideoClippingProperties();

View File

@ -85,6 +85,29 @@ class LocalDirectorSchedulerTest {
assertThat(firstClip).doesNotExist(); assertThat(firstClip).doesNotExist();
} }
@Test
void processesLooseVideoFilesAsOneSourceClipsProject() throws Exception {
Path firstClip = Files.writeString(tempDir.resolve("source/clip_00001.mp4"), "video");
Path secondClip = Files.writeString(tempDir.resolve("source/clip_00002.mp4"), "video");
Files.writeString(tempDir.resolve("source/notes.txt"), "ignore");
EditClipDiscovery discovery = new EditClipDiscovery();
scheduler(discovery).scan();
assertThat(Files.isDirectory(tempDir.resolve("processed/source-clips"))).isTrue();
assertThat(Files.exists(tempDir.resolve("projects/source-clips/analysis.json"))).isTrue();
assertThat(Files.exists(tempDir.resolve("projects/source-clips/ai-director-prompt.md"))).isTrue();
assertThat(firstClip).doesNotExist();
assertThat(secondClip).doesNotExist();
assertThat(Files.exists(tempDir.resolve("source/notes.txt"))).isTrue();
EditProject project = store.readJson("source-clips", "project.json", EditProject.class);
assertThat(project.status()).isEqualTo(EditProjectStatus.WAITING_FOR_DIRECTOR);
assertThat(project.inputDirectory()).isEqualTo(tempDir.resolve("processed/source-clips").toString());
EditProjectAnalysis analysis = store.readJson("source-clips", "analysis.json", EditProjectAnalysis.class);
assertThat(analysis.clips()).extracting(ClipAnalysis::clipId)
.containsExactly("clip_00001", "clip_00002");
}
@Test @Test
void rejectsEmptyProjectFolder() throws Exception { void rejectsEmptyProjectFolder() throws Exception {
Files.createDirectory(tempDir.resolve("source/empty-project")); Files.createDirectory(tempDir.resolve("source/empty-project"));
@ -118,6 +141,6 @@ class LocalDirectorSchedulerTest {
proxies, clock); proxies, clock);
StoryboardPromptGenerator storyboard = new StoryboardPromptGenerator(store); StoryboardPromptGenerator storyboard = new StoryboardPromptGenerator(store);
AiDirectorPromptGenerator director = new AiDirectorPromptGenerator(store, storyboard, properties); AiDirectorPromptGenerator director = new AiDirectorPromptGenerator(store, storyboard, properties);
return new LocalDirectorScheduler(properties, projectService, analyzer, director, store); return new LocalDirectorScheduler(properties, projectService, discovery, analyzer, director, store);
} }
} }