• working version but not cinematic
This commit is contained in:
parent
13b7dc4cdf
commit
5d889b00dc
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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<Path> 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<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) {
|
||||
List<ClipAnalysis> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue