From 5d889b00dce6aad17b2ee50875cd0b0064d66615 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Sat, 11 Jul 2026 00:32:46 +0200 Subject: [PATCH] =?UTF-8?q?=E2=80=A2=20working=20version=20but=20not=20cin?= =?UTF-8?q?ematic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cinematic-video-editing-service-plan.md | 2 + .../editing/FfmpegEditRenderer.java | 12 +- .../editing/LocalDirectorScheduler.java | 116 +++++++++++++++--- .../application-cinematic-editing-local.yml | 2 +- .../CinematicEditingIntegrationTest.java | 2 +- .../editing/FfmpegEditRendererTest.java | 15 +++ .../editing/LocalDirectorSchedulerTest.java | 25 +++- 7 files changed, 153 insertions(+), 21 deletions(-) diff --git a/docs/cinematic-video-editing-service-plan.md b/docs/cinematic-video-editing-service-plan.md index 124f489..c36d281 100644 --- a/docs/cinematic-video-editing-service-plan.md +++ b/docs/cinematic-video-editing-service-plan.md @@ -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. +For the standalone step-by-step operator guide, see `docs/cinematic-editing-runbook.md`. + ### Prerequisites Install: diff --git a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java index f05d6b3..64fc5e7 100644 --- a/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java +++ b/src/main/java/org/example/videoclips/editing/FfmpegEditRenderer.java @@ -138,7 +138,7 @@ public class FfmpegEditRenderer implements EditRenderer { } else { copy(timeline, output); } - double duration = plan.decisions().getLast().timelineEndSeconds(); + double duration = plan.decisions().get(plan.decisions().size() - 1).timelineEndSeconds(); RenderManifest manifest = new RenderManifest(projectId, plan.decisions().stream().map(EditDecision::clipId).toList(), output.toString(), duration, 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( properties.getOutputWidth(), properties.getOutputHeight())); filter.append(",setpts=PTS/").append(decision.playbackSpeed()); + filter.append(cinematicVisualFilter(decision.visualTreatment())); double fadeDuration = Math.min(0.5, outputDuration / 2); if ("fade-in".equals(decision.transitionIn()) || "crossfade".equals(decision.transitionIn())) { filter.append(",fade=t=in:st=0:d=").append(fadeDuration); @@ -178,6 +179,15 @@ public class FfmpegEditRenderer implements EditRenderer { 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) { if (speed < 0.5) { return "atempo=0.5,atempo=" + (speed / 0.5); diff --git a/src/main/java/org/example/videoclips/editing/LocalDirectorScheduler.java b/src/main/java/org/example/videoclips/editing/LocalDirectorScheduler.java index 45aa9e0..3fc4044 100644 --- a/src/main/java/org/example/videoclips/editing/LocalDirectorScheduler.java +++ b/src/main/java/org/example/videoclips/editing/LocalDirectorScheduler.java @@ -29,6 +29,7 @@ public class LocalDirectorScheduler { private final VideoClippingProperties.Editing editing; private final VideoClippingProperties.Editing.LocalDirector local; private final EditProjectService projectService; + private final EditClipDiscovery clipDiscovery; private final EditProjectAnalyzer analyzer; private final AiDirectorPromptGenerator promptGenerator; private final EditProjectStore store; @@ -37,6 +38,7 @@ public class LocalDirectorScheduler { public LocalDirectorScheduler( VideoClippingProperties properties, EditProjectService projectService, + EditClipDiscovery clipDiscovery, EditProjectAnalyzer analyzer, AiDirectorPromptGenerator promptGenerator, EditProjectStore store @@ -44,6 +46,7 @@ public class LocalDirectorScheduler { this.editing = properties.getEditing(); this.local = editing.getLocalDirector(); this.projectService = projectService; + this.clipDiscovery = clipDiscovery; this.analyzer = analyzer; this.promptGenerator = promptGenerator; this.store = store; @@ -59,7 +62,12 @@ public class LocalDirectorScheduler { return; } try { - findNextCandidate().ifPresent(this::process); + Optional candidate = findNextCandidate(); + if (candidate.isPresent()) { + process(candidate.get()); + } else { + processLooseClips(); + } } finally { scanning.set(false); } @@ -98,23 +106,9 @@ public class LocalDirectorScheduler { 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); + processWorkingProject(working, startedAt); } catch (RuntimeException ex) { + projectId = projectId(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)) { @@ -126,6 +120,64 @@ public class LocalDirectorScheduler { } } + private void processLooseClips() { + Path sourceDirectory = Path.of(local.getSourceDirectory()); + List 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) { List clips = analysis.clips().stream() .map(clip -> new ClipAnalysis( @@ -148,4 +200,34 @@ public class LocalDirectorScheduler { 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; + } + } } diff --git a/src/main/resources/application-cinematic-editing-local.yml b/src/main/resources/application-cinematic-editing-local.yml index 5bf4be4..21e45de 100644 --- a/src/main/resources/application-cinematic-editing-local.yml +++ b/src/main/resources/application-cinematic-editing-local.yml @@ -5,7 +5,7 @@ video-clipping: enabled: true local-director: enabled: true - auto-render-when-plan-appears: false + auto-render-when-plan-appears: true logging: level: diff --git a/src/test/java/org/example/videoclips/editing/CinematicEditingIntegrationTest.java b/src/test/java/org/example/videoclips/editing/CinematicEditingIntegrationTest.java index 0a38519..8634a46 100644 --- a/src/test/java/org/example/videoclips/editing/CinematicEditingIntegrationTest.java +++ b/src/test/java/org/example/videoclips/editing/CinematicEditingIntegrationTest.java @@ -37,7 +37,7 @@ class CinematicEditingIntegrationTest { StoryboardPromptGenerator storyboard = new StoryboardPromptGenerator(store); AiDirectorPromptGenerator director = new AiDirectorPromptGenerator(store, storyboard, properties); LocalDirectorScheduler scheduler = new LocalDirectorScheduler( - properties, projects, analyzer, director, store); + properties, projects, new EditClipDiscovery(), analyzer, director, store); scheduler.scan(); diff --git a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java index 0d400fa..aaa610b 100644 --- a/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/FfmpegEditRendererTest.java @@ -67,10 +67,25 @@ class FfmpegEditRendererTest { 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," + + "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"); 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 void addsDelayedSfxInputToFinalMix() { VideoClippingProperties properties = new VideoClippingProperties(); diff --git a/src/test/java/org/example/videoclips/editing/LocalDirectorSchedulerTest.java b/src/test/java/org/example/videoclips/editing/LocalDirectorSchedulerTest.java index c0dbc3a..8ac8df4 100644 --- a/src/test/java/org/example/videoclips/editing/LocalDirectorSchedulerTest.java +++ b/src/test/java/org/example/videoclips/editing/LocalDirectorSchedulerTest.java @@ -85,6 +85,29 @@ class LocalDirectorSchedulerTest { 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 void rejectsEmptyProjectFolder() throws Exception { Files.createDirectory(tempDir.resolve("source/empty-project")); @@ -118,6 +141,6 @@ class LocalDirectorSchedulerTest { proxies, clock); StoryboardPromptGenerator storyboard = new StoryboardPromptGenerator(store); AiDirectorPromptGenerator director = new AiDirectorPromptGenerator(store, storyboard, properties); - return new LocalDirectorScheduler(properties, projectService, analyzer, director, store); + return new LocalDirectorScheduler(properties, projectService, discovery, analyzer, director, store); } }