From 842f2a740419767ed50120e655e70ab8adce9027 Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Wed, 22 Jul 2026 16:13:19 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Bg76sLc43Wc3j5ZcLkboYR --- .../HighlightDirectorPlanValidator.java | 23 ++++++++++--------- .../editing/HighlightFfmpegRenderer.java | 18 +++++++++++++-- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/src/main/java/org/example/videoclips/editing/HighlightDirectorPlanValidator.java b/src/main/java/org/example/videoclips/editing/HighlightDirectorPlanValidator.java index 2921e9e..250d847 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightDirectorPlanValidator.java +++ b/src/main/java/org/example/videoclips/editing/HighlightDirectorPlanValidator.java @@ -140,17 +140,18 @@ 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"); - } - for (String line : highlight.voiceover()) { - requiredText(line, MAX_VOICEOVER_LINE_LENGTH, "Voiceover line"); - } - double voiceoverDuration = highlight.voiceover().stream() - .mapToDouble(HighlightAssetPreparationService::estimatedVoiceoverDuration) - .sum(); - if (voiceoverDuration > highlight.targetDurationSeconds() + EPSILON) { - reject("Voiceover script exceeds the highlight timing budget"); + // 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"); + } + double voiceoverDuration = highlight.voiceover().stream() + .mapToDouble(HighlightAssetPreparationService::estimatedVoiceoverDuration) + .sum(); + if (voiceoverDuration > highlight.targetDurationSeconds() + EPSILON) { + reject("Voiceover script exceeds the highlight timing budget"); + } } if (highlight.overlays() == null) { reject("Highlight overlays list is required"); diff --git a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java index ec1538e..664ff27 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java +++ b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java @@ -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 }; }