Cinematic pass: 2.39 letterbox, film grain, optional voiceover

Push the highlight output toward a genuinely cinematic look after a human review
found it functional-but-not-cinematic:

- HighlightFfmpegRenderer: add a 2.39:1 letterbox and subtle film grain to each
  graded segment; raise overlay placement above the letterbox bar. (A time-based
  crop-zoom push-in was prototyped and removed: FFmpeg crop cannot use the `t`
  variable for width/height; a zoompan push-in is a possible follow-up.)
- HighlightDirectorPlanValidator: make voiceover OPTIONAL so a music-driven edit
  can carry no narration (lines still validated when present).

Combined with a no-voiceover, driving-orchestral, slow-motion-hero director plan,
this yields a letterboxed, richly graded, music-led cinematic cut. mvn -o verify
green (247 tests, 0 failures).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Bg76sLc43Wc3j5ZcLkboYR
This commit is contained in:
JSLMPR 2026-07-22 16:13:19 +02:00
parent 3221b9827b
commit 842f2a7404
2 changed files with 28 additions and 13 deletions

View File

@ -140,9 +140,9 @@ public class HighlightDirectorPlanValidator {
requiredText(highlight.musicDirection(), MAX_DIRECTION_LENGTH, "Music direction");
requiredText(highlight.sfxDirection(), MAX_DIRECTION_LENGTH, "SFX direction");
requiredText(highlight.renderNotes(), MAX_DIRECTION_LENGTH, "Render notes");
if (highlight.voiceover() == null || highlight.voiceover().isEmpty()) {
reject("At least one grounded voiceover line is required");
}
// Voiceover is optional: a music-driven cinematic edit may carry no narration. When present,
// every line must be non-blank and the script must fit the highlight timing budget.
if (highlight.voiceover() != null && !highlight.voiceover().isEmpty()) {
for (String line : highlight.voiceover()) {
requiredText(line, MAX_VOICEOVER_LINE_LENGTH, "Voiceover line");
}
@ -152,6 +152,7 @@ public class HighlightDirectorPlanValidator {
if (voiceoverDuration > highlight.targetDurationSeconds() + EPSILON) {
reject("Voiceover script exceeds the highlight timing budget");
}
}
if (highlight.overlays() == null) {
reject("Highlight overlays list is required");
}

View File

@ -193,6 +193,11 @@ public class HighlightFfmpegRenderer {
properties.getOutputWidth(), properties.getOutputHeight()));
filter.append(",setpts=PTS/").append(decision.playbackSpeed());
filter.append(cinematicVisualFilter(decision.visualTreatment(), style));
if (!(decision.visualTreatment() == null || decision.visualTreatment().isBlank()
|| "none".equalsIgnoreCase(decision.visualTreatment()))) {
filter.append(",noise=alls=6:allf=t"); // subtle film grain
filter.append(letterboxFilter()); // 2.39:1 cinematic bars
}
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);
@ -567,6 +572,15 @@ public class HighlightFfmpegRenderer {
return "crop=w='iw*0.96':h='ih*0.96':x='(iw-out_w)/2':y='(ih-out_h)/2',";
}
// 2.39:1 cinematic letterbox: crop the center band, then pad back to frame with black bars.
private String letterboxFilter() {
int w = properties.getOutputWidth();
int h = properties.getOutputHeight();
int barHeight = (int) Math.round((h - w / 2.39) / 2.0);
int bandHeight = h - 2 * barHeight;
return ",crop=%d:%d,pad=%d:%d:0:%d:color=black".formatted(w, bandHeight, w, h, barHeight);
}
private String audioTempoFilter(double speed) {
if (speed < 0.5) {
return "atempo=0.5,atempo=" + (speed / 0.5);
@ -598,9 +612,9 @@ public class HighlightFfmpegRenderer {
private String overlayY(String placement) {
return switch (placement) {
case "upper_left_safe", "upper_right_safe" -> "h*0.08";
case "upper_left_safe", "upper_right_safe" -> "h*0.14";
case "center_safe" -> "(h-text_h)/2";
default -> "h-text_h-h*0.10";
default -> "h-text_h-h*0.18"; // sit above the 2.39:1 letterbox bar
};
}