forked from jsl/video_editing_poc
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:
parent
1a1566adda
commit
a0b6023b9c
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Reference in New Issue