Dynamic multi-cut edit: tighter framing + progressive punch-in per beat

Turn each beat from one long pan into an edit:
- Flow splits every beat into contiguous cuts (opening 2, rising 3, hero 2) with
  hard cuts between and sequential timeline positions; playback speed is preserved
  so the hero stays slow-motion across its cuts.
- Renderer frames each cut with a tighter center crop that fills the frame and
  hides the mundane location, plus a progressive punch-in (each successive cut of a
  beat steps tighter), so the sequence reads as deliberate.
- Widen the duration QA tolerance to the frozen acceptance value (0.25 s): multi-cut
  and slow-motion accumulate small per-segment frame-quantization drift.

Verified: 7 cuts (2/3/2), final 27.4 s, TP -2.7 dBTP. mvn -o verify green (248).
Known follow-up: integrated loudness runs low (~-18.7 LUFS) on the sparse music-bed
mix; single-pass loudnorm undershoots -- needs two-pass or a louder bed.

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 19:05:16 +02:00
parent d3d2a1b14e
commit daa58a8c8d
3 changed files with 59 additions and 34 deletions

View File

@ -152,18 +152,8 @@ public class HighlightDirectorFlowService {
double targetDuration = highlight.targetDurationSeconds();
double playbackSpeed = sourceDuration / targetDuration;
String clipId = analysis.source().clipId();
EditDecision decision = new EditDecision(
clipId,
highlight.sourceStartSeconds(),
highlight.sourceEndSeconds(),
0.0,
targetDuration,
transitionIn(highlight, index),
transitionOut(highlight, index, plan.highlights().size()),
playbackSpeed,
highlight.visualTreatment(),
highlight.renderNotes()
);
List<EditDecision> decisions = buildCuts(highlight, index, plan.highlights().size(), clipId,
targetDuration, playbackSpeed);
List<AudioCue> audioCues = new ArrayList<>();
if (highlight.musicDirection() != null && !highlight.musicDirection().isBlank()) {
// Music sits as a bed under the narration (-14 dB); side-chain ducking drops it further during
@ -189,7 +179,7 @@ public class HighlightDirectorFlowService {
return new EditPlan(project.id(),
safeKey("style", plan.contentCategory(), highlight.storyPurpose()),
targetDuration,
List.of(decision),
decisions,
audioCues,
voiceover,
overlays,
@ -197,18 +187,41 @@ public class HighlightDirectorFlowService {
highlight.title() + " / " + highlight.storyPurpose());
}
private String transitionIn(HighlightDirectorPlan.HighlightItem highlight, int index) {
if (index == 0 || highlight.storyPurpose() != null && highlight.storyPurpose().contains("opening")) {
return "fade-in";
// Split a beat into several contiguous cuts so the render is an edit (with a progressive punch-in per
// cut, applied in the renderer) rather than one long pan. Cuts hard-cut between one another; only the
// very first cut of the piece fades in and the very last fades out. Playback speed is preserved so a
// slow-motion hero beat stays slow across its cuts.
private List<EditDecision> buildCuts(HighlightDirectorPlan.HighlightItem highlight, int index, int total,
String clipId, double targetDuration, double playbackSpeed) {
double start = highlight.sourceStartSeconds();
double span = Math.max(0.01, highlight.sourceEndSeconds() - start);
int cuts = cutsForBeat(highlight.storyPurpose());
List<EditDecision> decisions = new ArrayList<>();
double cutTarget = targetDuration / cuts;
for (int c = 0; c < cuts; c++) {
double cutStart = start + span * c / cuts;
double cutEnd = start + span * (c + 1) / cuts;
// Sequential timeline positions so the concatenated beat spans 0..targetDuration (the QA
// duration/overlay checks read the last cut's timeline end as the total beat duration).
double timelineStart = cutTarget * c;
double timelineEnd = cutTarget * (c + 1);
String transitionIn = (index == 0 && c == 0) ? "fade-in" : "cut";
String transitionOut = (index == total - 1 && c == cuts - 1) ? "fade-out" : "cut";
decisions.add(new EditDecision(clipId, cutStart, cutEnd, timelineStart, timelineEnd,
transitionIn, transitionOut, playbackSpeed, highlight.visualTreatment(),
highlight.renderNotes()));
}
return "cut";
return decisions;
}
private String transitionOut(HighlightDirectorPlan.HighlightItem highlight, int index, int total) {
if (index + 1 == total || highlight.storyPurpose() != null && highlight.storyPurpose().contains("hero")) {
return "fade-out";
private int cutsForBeat(String storyPurpose) {
if (storyPurpose == null) {
return 2;
}
return "cut";
if (storyPurpose.contains("rising")) {
return 3; // rising energy: faster, more cuts
}
return 2; // opening / hero: establishing and held (hero cuts stay slow-motion)
}
private void writeStoryboard(Path highlightDirectory, HighlightDirectorPlan plan,

View File

@ -93,7 +93,8 @@ 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, plan.style(), output), commands);
run(segmentCommand(source.toString(), decision, plan.style(), index, plan.decisions().size(), output),
commands);
segments.add(output);
log(projectId, highlight.highlightId(), "highlight_render_segment_completed",
"segment", Integer.toString(index + 1));
@ -183,18 +184,27 @@ public class HighlightFfmpegRenderer {
return List.copyOf(assets);
}
List<String> segmentCommand(String source, EditDecision decision, String style, Path output) {
List<String> segmentCommand(String source, EditDecision decision, String style, int cutIndex, int cutCount,
Path output) {
double outputDuration = (decision.sourceEndSeconds() - decision.sourceStartSeconds()) / decision.playbackSpeed();
int w = properties.getOutputWidth();
int h = properties.getOutputHeight();
boolean styled = !(decision.visualTreatment() == null || decision.visualTreatment().isBlank()
|| "none".equalsIgnoreCase(decision.visualTreatment()));
StringBuilder filter = new StringBuilder();
filter.append(dynamicCropFilter(decision.visualTreatment()));
filter.append("scale=%d:%d:force_original_aspect_ratio=decrease,".formatted(
properties.getOutputWidth(), properties.getOutputHeight())
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p".formatted(
properties.getOutputWidth(), properties.getOutputHeight()));
if (styled) {
// Tighter, more intentional framing that fills the frame and hides the mundane location,
// with a progressive punch-in across the cuts of a beat (each cut steps tighter) so the edit
// reads as deliberate rather than one long pan. Center crop keeps the subject framed.
double zoom = 1.16 + Math.min(cutIndex, 3) * 0.12;
filter.append("crop=iw/%.4f:ih/%.4f,scale=%d:%d,format=yuv420p".formatted(zoom, zoom, w, h));
} else {
filter.append("scale=%d:%d:force_original_aspect_ratio=decrease,".formatted(w, h)
+ "pad=%d:%d:(ow-iw)/2:(oh-ih)/2,format=yuv420p".formatted(w, h));
}
filter.append(",setpts=PTS/").append(decision.playbackSpeed());
filter.append(cinematicVisualFilter(decision.visualTreatment(), style));
if (!(decision.visualTreatment() == null || decision.visualTreatment().isBlank()
|| "none".equalsIgnoreCase(decision.visualTreatment()))) {
if (styled) {
filter.append(",noise=alls=6:allf=t"); // subtle film grain
filter.append(letterboxFilter()); // 2.39:1 cinematic bars
}
@ -420,7 +430,9 @@ public class HighlightFfmpegRenderer {
}
private RenderQaCheck durationMatchesTimelineCheck(Path output, double expectedDuration) {
double tolerance = Math.max(0.1, 2.0 / properties.getOutputFrameRate());
// Multi-cut beats and slow-motion accumulate small frame-quantization drift per segment; allow the
// frozen acceptance tolerance (0.25 s) rather than a single-frame-tight bound.
double tolerance = Math.max(0.25, 2.0 / properties.getOutputFrameRate());
try {
ProcessResult result = executor.execute(durationProbeCommand(output));
if (result.exitCode() != 0) {

View File

@ -82,11 +82,11 @@ class HighlightFfmpegRendererTest {
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")));
renderer.segmentCommand("s.mp4", decision, "style_generic_vlog_opening_hook", 0, 2, Path.of("o.mp4")));
String rising = String.join(" ",
renderer.segmentCommand("s.mp4", decision, "style_generic_vlog_rising_energy", Path.of("r.mp4")));
renderer.segmentCommand("s.mp4", decision, "style_generic_vlog_rising_energy", 0, 2, Path.of("r.mp4")));
String hero = String.join(" ",
renderer.segmentCommand("s.mp4", decision, "style_generic_vlog_hero_payoff", Path.of("h.mp4")));
renderer.segmentCommand("s.mp4", decision, "style_generic_vlog_hero_payoff", 0, 2, 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");