diff --git a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java index f84069f..ec1538e 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java +++ b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java @@ -14,6 +14,7 @@ import java.time.Instant; import java.util.ArrayList; import java.util.Comparator; import java.util.List; +import java.util.Locale; import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; @@ -92,7 +93,7 @@ public class HighlightFfmpegRenderer { "segment", Integer.toString(index + 1), "source_start", Double.toString(decision.sourceStartSeconds()), "source_end", Double.toString(decision.sourceEndSeconds())); - run(segmentCommand(source.toString(), decision, output), commands); + run(segmentCommand(source.toString(), decision, plan.style(), output), commands); segments.add(output); log(projectId, highlight.highlightId(), "highlight_render_segment_completed", "segment", Integer.toString(index + 1)); @@ -182,7 +183,7 @@ public class HighlightFfmpegRenderer { return List.copyOf(assets); } - List segmentCommand(String source, EditDecision decision, Path output) { + List segmentCommand(String source, EditDecision decision, String style, Path output) { double outputDuration = (decision.sourceEndSeconds() - decision.sourceStartSeconds()) / decision.playbackSpeed(); StringBuilder filter = new StringBuilder(); filter.append(dynamicCropFilter(decision.visualTreatment())); @@ -191,7 +192,7 @@ public class HighlightFfmpegRenderer { + "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())); + filter.append(cinematicVisualFilter(decision.visualTreatment(), style)); 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); @@ -518,12 +519,31 @@ public class HighlightFfmpegRenderer { return AssetLicensePolicy.read(asset).orElse(AssetLicensePolicy.UNTRACKED); } - private String cinematicVisualFilter(String visualTreatment) { + private String cinematicVisualFilter(String visualTreatment, String style) { if (visualTreatment == null || visualTreatment.isBlank() || "none".equalsIgnoreCase(visualTreatment)) { return ""; } - // Deliberate filmic grade: S-curve contrast, teal shadows / warm highlights (cinematic - // "teal-orange"), gentle saturation and gamma lift, light sharpen, and a soft vignette. + // Beat-specific filmic grade. All variants share the teal-shadow / warm-highlight ("teal-orange") + // language but shift contrast, warmth and vignette to match the story beat encoded in the plan + // style key (safeKey: style__). Unknown/rising -> balanced base grade. + String beat = style == null ? "" : style.toLowerCase(Locale.ROOT); + if (beat.contains("opening")) { + // Opening hook: calmer, cooler, softer contrast to invite the viewer in. + return ",curves=preset=linear_contrast" + + ",colorbalance=rs=-0.05:bs=0.08:rm=0.01:bm=-0.01:rh=0.03:bh=-0.03" + + ",eq=contrast=1.08:saturation=1.08:gamma=0.98:brightness=-0.008" + + ",unsharp=5:5:0.3:3:3:0.15" + + ",vignette=PI/7"; + } + if (beat.contains("hero")) { + // Hero payoff: richest grade, stronger S-curve, warmer highlights, deeper vignette. + return ",curves=preset=strong_contrast" + + ",colorbalance=rs=-0.03:bs=0.05:rm=0.01:bm=-0.01:rh=0.09:bh=-0.07" + + ",eq=contrast=1.16:saturation=1.16:gamma=0.96:brightness=-0.015" + + ",unsharp=5:5:0.5:3:3:0.25" + + ",vignette=PI/5.5"; + } + // Rising energy / default: balanced base grade. return ",curves=preset=medium_contrast" + ",colorbalance=rs=-0.04:bs=0.06:rm=0.01:bm=-0.01:rh=0.06:bh=-0.05" + ",eq=contrast=1.12:saturation=1.12:gamma=0.97:brightness=-0.012" diff --git a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java index 8c861b9..1fea838 100644 --- a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java @@ -76,6 +76,24 @@ class HighlightFfmpegRendererTest { .contains("[voice_line_0][voice_line_1]amix=inputs=2:duration=longest")); } + @Test + void appliesBeatSpecificGradePerStoryPurpose() { + HighlightFfmpegRenderer renderer = renderer(); + EditDecision decision = new EditDecision("clip", 0, 9, 0, 9, "cut", "cut", 1.0, "premium grade", "notes"); + + String opening = String.join(" ", + renderer.segmentCommand("s.mp4", decision, "style_generic_vlog_opening_hook", Path.of("o.mp4"))); + String rising = String.join(" ", + renderer.segmentCommand("s.mp4", decision, "style_generic_vlog_rising_energy", Path.of("r.mp4"))); + String hero = String.join(" ", + renderer.segmentCommand("s.mp4", decision, "style_generic_vlog_hero_payoff", Path.of("h.mp4"))); + + assertThat(opening).contains("curves=preset=linear_contrast").contains("vignette=PI/7"); + assertThat(rising).contains("curves=preset=medium_contrast").contains("eq=contrast=1.12"); + assertThat(hero).contains("curves=preset=strong_contrast").contains("eq=contrast=1.16"); + assertThat(opening).isNotEqualTo(hero); + } + @Test void stylesOverlaysWithSoftShadowAndAlphaFade() { HighlightFfmpegRenderer renderer = renderer();