R6: cross-dissolve transitions between montage beats
Beats now blend instead of hard-cutting (incl. the cut into the slow-mo payoff) via an xfade + acrossfade chain (xfadeTimelineCommand). The timeline compresses by (n-1)*xf, so shiftOverlayForCrossfade re-times overlays and the reported duration is reduced to keep overlays, loudness mastering, and QA aligned. Opt-in via editing.crossfade-seconds (0 = hard cuts default; localpoc 0.25), clamped to half the shortest beat. Offset math + overlay shift unit-tested; verified on the bowling cut (visible dissolve, STRIKE overlay stayed on the payoff). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MPuJXQyAeWpFcTtcnxo1UN
This commit is contained in:
parent
30c832e9d5
commit
f03d5472c5
|
|
@ -41,9 +41,15 @@ Legend: ✅ implemented · ⏳ planned (see `cinematic-highlight-poc-plan.md` P5
|
||||||
- **Evidence (bowling):** static opening shot measured lowest motion → strongest push (0.117); active
|
- **Evidence (bowling):** static opening shot measured lowest motion → strongest push (0.117); active
|
||||||
celebration → gentlest (0.084). Confirmed visually (opening pushes in ~11% over 2s).
|
celebration → gentlest (0.084). Confirmed visually (opening pushes in ~11% over 2s).
|
||||||
|
|
||||||
## R6 — Transitions: ease, don't jerk ⏳ (P5.11)
|
## R6 — Transitions: ease, don't jerk — crossfades ✅ / speed-ramp ⏳
|
||||||
- Ramp speed into a slow-motion beat (don't hard-switch playback speed); crossfade on beat boundaries where
|
- **Crossfades (DONE):** `xfadeTimelineCommand` dissolves montage beats (video xfade + audio acrossfade)
|
||||||
the cut isn't meant to be a hard cut.
|
instead of hard-cutting, incl. the cut into the slow-mo payoff. Timeline compresses by (n-1)*xf;
|
||||||
|
`shiftOverlayForCrossfade` re-times overlays and the reported duration is adjusted so overlays/loudness/QA
|
||||||
|
stay aligned. Opt-in via `editing.crossfade-seconds` (0 = hard cuts default; localpoc 0.25), clamped to
|
||||||
|
half the shortest beat. Verified on bowling (visible dissolve, overlay stayed on the payoff).
|
||||||
|
- **Speed-ramp into slow-mo (deferred):** easing the playback-speed change itself conflicts with the R5
|
||||||
|
per-shot push-in (sub-segment splitting would reset the zoom); needs a time-varying `setpts` or a push-in
|
||||||
|
that spans sub-segments. The crossfade already softens the *cut* into the payoff.
|
||||||
|
|
||||||
## R7 — Overlays: bold, animated, synced ⏳ (P5.12)
|
## R7 — Overlays: bold, animated, synced ⏳ (P5.12)
|
||||||
- Title/label text scaled to the frame, with an entrance (scale/fade) timed to the musical/edit accent — not
|
- Title/label text scaled to the frame, with an entrance (scale/fade) timed to the musical/edit accent — not
|
||||||
|
|
|
||||||
|
|
@ -523,6 +523,9 @@ public class VideoClippingProperties {
|
||||||
@Min(1)
|
@Min(1)
|
||||||
private int outputFrameRate = 30;
|
private int outputFrameRate = 30;
|
||||||
|
|
||||||
|
/** Cross-dissolve duration (seconds) between montage beats. 0 = hard cuts (default). */
|
||||||
|
private double crossfadeSeconds = 0.0;
|
||||||
|
|
||||||
@Min(1)
|
@Min(1)
|
||||||
private int audioSampleRate = 48000;
|
private int audioSampleRate = 48000;
|
||||||
|
|
||||||
|
|
@ -694,6 +697,14 @@ public class VideoClippingProperties {
|
||||||
this.outputFrameRate = outputFrameRate;
|
this.outputFrameRate = outputFrameRate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public double getCrossfadeSeconds() {
|
||||||
|
return crossfadeSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setCrossfadeSeconds(double crossfadeSeconds) {
|
||||||
|
this.crossfadeSeconds = crossfadeSeconds;
|
||||||
|
}
|
||||||
|
|
||||||
public int getAudioSampleRate() {
|
public int getAudioSampleRate() {
|
||||||
return audioSampleRate;
|
return audioSampleRate;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -104,19 +104,30 @@ public class HighlightFfmpegRenderer {
|
||||||
"segment", Integer.toString(index + 1));
|
"segment", Integer.toString(index + 1));
|
||||||
}
|
}
|
||||||
List<Path> renderedClips = publishRenderedClips(highlightDirectory, segments);
|
List<Path> renderedClips = publishRenderedClips(highlightDirectory, segments);
|
||||||
Path concatFile = work.resolve("concat.txt");
|
List<Double> segmentDurations = plan.decisions().stream()
|
||||||
writeConcatFile(concatFile, segments);
|
.map(d -> d.timelineEndSeconds() - d.timelineStartSeconds()).toList();
|
||||||
log(projectId, highlight.highlightId(), "highlight_render_concat_started",
|
double crossfade = crossfadeDuration(segmentDurations);
|
||||||
"segments", Integer.toString(segments.size()));
|
|
||||||
Path timeline = work.resolve("timeline-video.mp4");
|
Path timeline = work.resolve("timeline-video.mp4");
|
||||||
run(concatCommand(concatFile, timeline), commands);
|
log(projectId, highlight.highlightId(), "highlight_render_concat_started",
|
||||||
|
"segments", Integer.toString(segments.size()), "crossfade_seconds", Double.toString(crossfade));
|
||||||
|
if (crossfade > 0.0) {
|
||||||
|
run(xfadeTimelineCommand(segments, segmentDurations, crossfade, sourceAudioPresent, timeline), commands);
|
||||||
|
} else {
|
||||||
|
Path concatFile = work.resolve("concat.txt");
|
||||||
|
writeConcatFile(concatFile, segments);
|
||||||
|
run(concatCommand(concatFile, timeline), commands);
|
||||||
|
}
|
||||||
|
double crossfadeShrink = crossfade > 0.0 ? crossfade * (segments.size() - 1) : 0.0;
|
||||||
|
List<TextOverlay> overlays = crossfade > 0.0
|
||||||
|
? plan.overlays().stream().map(o -> shiftOverlayForCrossfade(o, segmentDurations, crossfade)).toList()
|
||||||
|
: plan.overlays();
|
||||||
Path postTimeline = timeline;
|
Path postTimeline = timeline;
|
||||||
if (!plan.overlays().isEmpty()) {
|
if (!overlays.isEmpty()) {
|
||||||
postTimeline = work.resolve("timeline-overlays.mp4");
|
postTimeline = work.resolve("timeline-overlays.mp4");
|
||||||
log(projectId, highlight.highlightId(), "highlight_render_effects_started",
|
log(projectId, highlight.highlightId(), "highlight_render_effects_started",
|
||||||
"overlays", Integer.toString(plan.overlays().size()),
|
"overlays", Integer.toString(overlays.size()),
|
||||||
"treatment", plan.decisions().isEmpty() ? "none" : plan.decisions().get(0).visualTreatment());
|
"treatment", plan.decisions().isEmpty() ? "none" : plan.decisions().get(0).visualTreatment());
|
||||||
run(overlayCommand(timeline, plan.overlays(), postTimeline), commands);
|
run(overlayCommand(timeline, overlays, postTimeline), commands);
|
||||||
}
|
}
|
||||||
|
|
||||||
Path output = highlightDirectory.resolve("final.mp4");
|
Path output = highlightDirectory.resolve("final.mp4");
|
||||||
|
|
@ -144,8 +155,8 @@ public class HighlightFfmpegRenderer {
|
||||||
}
|
}
|
||||||
masterLoudness(output, commands);
|
masterLoudness(output, commands);
|
||||||
run(previewCommand(output, preview), commands);
|
run(previewCommand(output, preview), commands);
|
||||||
double duration = plan.decisions().isEmpty() ? 0 : plan.decisions().get(plan.decisions().size() - 1)
|
double duration = (plan.decisions().isEmpty() ? 0 : plan.decisions().get(plan.decisions().size() - 1)
|
||||||
.timelineEndSeconds();
|
.timelineEndSeconds()) - crossfadeShrink;
|
||||||
RenderManifest manifest = new RenderManifest(projectId,
|
RenderManifest manifest = new RenderManifest(projectId,
|
||||||
plan.decisions().stream().map(EditDecision::clipId).toList(),
|
plan.decisions().stream().map(EditDecision::clipId).toList(),
|
||||||
renderedClips.stream().map(Path::toString).toList(), output.toString(), duration, List.copyOf(commands),
|
renderedClips.stream().map(Path::toString).toList(), output.toString(), duration, List.copyOf(commands),
|
||||||
|
|
@ -460,6 +471,81 @@ public class HighlightFfmpegRenderer {
|
||||||
"-i", concatFile.toString(), "-c", "copy", output.toString());
|
"-i", concatFile.toString(), "-c", "copy", output.toString());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cross-dissolve the segments into one timeline with an xfade (and acrossfade) chain, so beat boundaries
|
||||||
|
* blend instead of hard-cutting. Each transition overlaps by {@code xf} seconds; the total shrinks by
|
||||||
|
* (n-1)*xf. Package-visible for offset-math testing.
|
||||||
|
*/
|
||||||
|
List<String> xfadeTimelineCommand(List<Path> segments, List<Double> durations, double xf,
|
||||||
|
boolean withAudio, Path output) {
|
||||||
|
List<String> command = new ArrayList<>(List.of(properties.getFfmpegBinary(), "-hide_banner", "-y"));
|
||||||
|
for (Path segment : segments) {
|
||||||
|
command.add("-i");
|
||||||
|
command.add(segment.toString());
|
||||||
|
}
|
||||||
|
StringBuilder fc = new StringBuilder();
|
||||||
|
String vPrev = "[0:v]";
|
||||||
|
String aPrev = "[0:a]";
|
||||||
|
double accum = durations.get(0);
|
||||||
|
int last = segments.size() - 1;
|
||||||
|
for (int i = 1; i < segments.size(); i++) {
|
||||||
|
double offset = accum - xf;
|
||||||
|
String vOut = i == last ? "[vout]" : "[v" + i + "]";
|
||||||
|
fc.append(vPrev).append("[").append(i).append(":v]xfade=transition=fade:duration=")
|
||||||
|
.append(fixed(xf)).append(":offset=").append(fixed(offset)).append(vOut).append(";");
|
||||||
|
vPrev = vOut;
|
||||||
|
if (withAudio) {
|
||||||
|
String aOut = i == last ? "[aout]" : "[a" + i + "]";
|
||||||
|
fc.append(aPrev).append("[").append(i).append(":a]acrossfade=d=")
|
||||||
|
.append(fixed(xf)).append(aOut).append(";");
|
||||||
|
aPrev = aOut;
|
||||||
|
}
|
||||||
|
accum += durations.get(i) - xf;
|
||||||
|
}
|
||||||
|
command.addAll(List.of("-filter_complex", fc.substring(0, fc.length() - 1), "-map", "[vout]"));
|
||||||
|
if (withAudio) {
|
||||||
|
command.addAll(List.of("-map", "[aout]"));
|
||||||
|
}
|
||||||
|
command.addAll(List.of("-c:v", "libx264", "-preset", "veryfast", "-crf", "18"));
|
||||||
|
if (withAudio) {
|
||||||
|
command.addAll(List.of("-c:a", "aac", "-b:a", properties.getAudioBitrate(),
|
||||||
|
"-ar", Integer.toString(properties.getAudioSampleRate())));
|
||||||
|
}
|
||||||
|
command.add(output.toString());
|
||||||
|
return List.copyOf(command);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Shifts an overlay's timing onto the crossfade-compressed timeline (each earlier transition removes xf). */
|
||||||
|
static TextOverlay shiftOverlayForCrossfade(TextOverlay overlay, List<Double> durations, double xf) {
|
||||||
|
double boundary = 0;
|
||||||
|
int transitions = 0;
|
||||||
|
for (int i = 0; i < durations.size() - 1; i++) {
|
||||||
|
boundary += durations.get(i);
|
||||||
|
if (boundary <= overlay.timelineStartSeconds() + 0.001) {
|
||||||
|
transitions++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
double shift = transitions * xf;
|
||||||
|
return new TextOverlay(overlay.text(), Math.max(0.0, overlay.timelineStartSeconds() - shift),
|
||||||
|
Math.max(0.1, overlay.timelineEndSeconds() - shift), overlay.placement(),
|
||||||
|
overlay.animation(), overlay.reason());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Effective crossfade duration: the configured value clamped so it can't exceed half the shortest beat; 0 when disabled or fewer than two beats. */
|
||||||
|
double crossfadeDuration(List<Double> segmentDurations) {
|
||||||
|
double configured = properties.getCrossfadeSeconds();
|
||||||
|
if (configured <= 0.0 || segmentDurations.size() < 2) {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
double shortest = segmentDurations.stream().mapToDouble(Double::doubleValue).min().orElse(0.0);
|
||||||
|
double clamped = Math.min(configured, shortest * 0.5);
|
||||||
|
return clamped >= 0.05 ? clamped : 0.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String fixed(double value) {
|
||||||
|
return String.format(Locale.ROOT, "%.3f", value);
|
||||||
|
}
|
||||||
|
|
||||||
List<String> previewCommand(Path input, Path output) {
|
List<String> previewCommand(Path input, Path output) {
|
||||||
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-i", input.toString(),
|
return List.of(properties.getFfmpegBinary(), "-hide_banner", "-y", "-i", input.toString(),
|
||||||
"-vf", "scale=iw*0.5:ih*0.5", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
|
"-vf", "scale=iw*0.5:ih*0.5", "-c:v", "libx264", "-preset", "veryfast", "-crf", "26",
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ video-clipping:
|
||||||
# Isolated PoC output tree (base output/ is left untouched).
|
# Isolated PoC output tree (base output/ is left untouched).
|
||||||
highlight-project-directory: ./output/localpoc/highlight-projects
|
highlight-project-directory: ./output/localpoc/highlight-projects
|
||||||
|
|
||||||
|
# Soft cross-dissolves between montage beats (0 = hard cuts). Softens the abrupt cut into the slow-mo payoff.
|
||||||
|
crossfade-seconds: 0.25
|
||||||
|
|
||||||
assets:
|
assets:
|
||||||
# Empty/absent asset folders -> the pipeline generates assets with local models instead of copying.
|
# Empty/absent asset folders -> the pipeline generates assets with local models instead of copying.
|
||||||
music-folder: ./input/localpoc/assets/music
|
music-folder: ./input/localpoc/assets/music
|
||||||
|
|
|
||||||
|
|
@ -203,6 +203,28 @@ class HighlightFfmpegRendererTest {
|
||||||
assertThat(portrait).contains("scale=1080:1920").doesNotContain("crop=1080:"); // no letterbox
|
assertThat(portrait).contains("scale=1080:1920").doesNotContain("crop=1080:"); // no letterbox
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void crossfadeChainOverlapsSegmentsAndShiftsOverlaysOntoTheCompressedTimeline() {
|
||||||
|
HighlightFfmpegRenderer renderer = renderer();
|
||||||
|
List<Double> durations = List.of(2.0, 2.5, 3.0);
|
||||||
|
|
||||||
|
String cmd = String.join(" ", renderer.xfadeTimelineCommand(
|
||||||
|
List.of(Path.of("s0.mp4"), Path.of("s1.mp4"), Path.of("s2.mp4")),
|
||||||
|
durations, 0.25, true, Path.of("t.mp4")));
|
||||||
|
assertThat(cmd).contains("xfade=transition=fade:duration=0.250:offset=1.750") // dur0 - xf
|
||||||
|
.contains("offset=4.000") // dur0+dur1 - 2*xf
|
||||||
|
.contains("acrossfade=d=0.250").contains("[vout]").contains("[aout]");
|
||||||
|
|
||||||
|
// An overlay on the 3rd beat (starts 4.5s uncompressed) shifts back by 2 transitions * xf.
|
||||||
|
TextOverlay overlay = new TextOverlay("STRIKE", 4.5, 6.5, "lower_center_safe", "fade", "montage");
|
||||||
|
TextOverlay shifted = HighlightFfmpegRenderer.shiftOverlayForCrossfade(overlay, durations, 0.25);
|
||||||
|
assertThat(shifted.timelineStartSeconds()).isCloseTo(4.0, within(0.001));
|
||||||
|
assertThat(shifted.timelineEndSeconds()).isCloseTo(6.0, within(0.001));
|
||||||
|
|
||||||
|
// Disabled by default (crossfade-seconds defaults to 0).
|
||||||
|
assertThat(renderer.crossfadeDuration(durations)).isZero();
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void loudnessMasteringCorrectsTowardTargetOnlyWhenNeeded() {
|
void loudnessMasteringCorrectsTowardTargetOnlyWhenNeeded() {
|
||||||
// Too quiet -> positive boost; too loud -> negative cut; on-target and implausible -> no change.
|
// Too quiet -> positive boost; too loud -> negative cut; on-target and implausible -> no change.
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue