Beat-specific cinematic grading for highlights

The highlight renderer now selects a filmic grade per story beat, read from the
EditPlan style key (safeKey: style_<category>_<storyPurpose>), so no change to the
shared EditDecision or its serialization is needed:

- opening_hook: calmer, cooler, softer contrast (linear_contrast, gentle vignette)
- rising_energy / default: balanced base grade (medium_contrast)
- hero_payoff: richest, warmer, stronger S-curve and deeper vignette (strong_contrast)

Threads plan.style() into segmentCommand -> cinematicVisualFilter. Multi-clip
FfmpegEditRenderer untouched. Test added; 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 10:19:52 +02:00
parent 1a1566adda
commit a0b6023b9c
2 changed files with 44 additions and 6 deletions

View File

@ -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<String> segmentCommand(String source, EditDecision decision, Path output) {
List<String> 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_<category>_<storyPurpose>). 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"

View File

@ -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();