From daa58a8c8dfd07053398c5335f4e6f75cf9ce3db Mon Sep 17 00:00:00 2001 From: JSLMPR Date: Wed, 22 Jul 2026 19:05:16 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Bg76sLc43Wc3j5ZcLkboYR --- .../editing/HighlightDirectorFlowService.java | 55 ++++++++++++------- .../editing/HighlightFfmpegRenderer.java | 32 +++++++---- .../editing/HighlightFfmpegRendererTest.java | 6 +- 3 files changed, 59 insertions(+), 34 deletions(-) diff --git a/src/main/java/org/example/videoclips/editing/HighlightDirectorFlowService.java b/src/main/java/org/example/videoclips/editing/HighlightDirectorFlowService.java index 9ba6ac4..c8fb8bc 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightDirectorFlowService.java +++ b/src/main/java/org/example/videoclips/editing/HighlightDirectorFlowService.java @@ -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 decisions = buildCuts(highlight, index, plan.highlights().size(), clipId, + targetDuration, playbackSpeed); List 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 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 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, diff --git a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java index 5f3cbad..8ed4427 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java +++ b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java @@ -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 segmentCommand(String source, EditDecision decision, String style, Path output) { + List 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) { diff --git a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java index b5da7d7..c64d07f 100644 --- a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java @@ -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");