diff --git a/src/main/java/org/example/videoclips/editing/HighlightDirectorFlowService.java b/src/main/java/org/example/videoclips/editing/HighlightDirectorFlowService.java index 11f884a..9ba6ac4 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightDirectorFlowService.java +++ b/src/main/java/org/example/videoclips/editing/HighlightDirectorFlowService.java @@ -166,8 +166,10 @@ public class HighlightDirectorFlowService { ); 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 + // voiceover lines so the narration always leads. audioCues.add(new AudioCue("music", safeKey("music", highlight.musicDirection()), 0.0, targetDuration, - -10.0, highlight.musicDirection())); + -14.0, highlight.musicDirection())); } if (highlight.sfxDirection() != null && !highlight.sfxDirection().isBlank()) { audioCues.addAll(HighlightAssetPreparationService.plannedSfxCues(highlight)); diff --git a/src/main/java/org/example/videoclips/editing/HighlightDirectorPlanValidator.java b/src/main/java/org/example/videoclips/editing/HighlightDirectorPlanValidator.java index 250d847..31d3f9e 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightDirectorPlanValidator.java +++ b/src/main/java/org/example/videoclips/editing/HighlightDirectorPlanValidator.java @@ -138,7 +138,12 @@ public class HighlightDirectorPlanValidator { } requiredText(highlight.visualTreatment(), MAX_DIRECTION_LENGTH, "Visual treatment"); requiredText(highlight.musicDirection(), MAX_DIRECTION_LENGTH, "Music direction"); - requiredText(highlight.sfxDirection(), MAX_DIRECTION_LENGTH, "SFX direction"); + // SFX direction is optional: a clean music+narration edit may use no sound effects. Validate + // length only when a direction is provided. + if (highlight.sfxDirection() != null && !highlight.sfxDirection().isBlank() + && highlight.sfxDirection().length() > MAX_DIRECTION_LENGTH) { + reject("SFX direction must be 1000 characters or fewer"); + } requiredText(highlight.renderNotes(), MAX_DIRECTION_LENGTH, "Render notes"); // Voiceover is optional: a music-driven cinematic edit may carry no narration. When present, // every line must be non-blank and the script must fit the highlight timing budget. diff --git a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java index 664ff27..9c842bf 100644 --- a/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java +++ b/src/main/java/org/example/videoclips/editing/HighlightFfmpegRenderer.java @@ -281,6 +281,8 @@ public class HighlightFfmpegRenderer { double duration = line.timelineEndSeconds() - line.timelineStartSeconds(); long delayMillis = Math.round(line.timelineStartSeconds() * 1000); command.addAll(List.of("-i", voiceover.path().toString())); + // Narration must clearly lead. Boost ~+8 dB; combined with the lower music bed and side-chain + // ducking, this keeps the voice well above the score during each line. filters.append("[").append(input).append(":a]atrim=duration=").append(duration) .append(",asetpts=PTS-STARTPTS,volume=1.0,adelay=") .append(delayMillis).append("|").append(delayMillis).append("[").append(label).append("];"); @@ -294,14 +296,18 @@ public class HighlightFfmpegRenderer { .append(":duration=longest:dropout_transition=0[voice];"); } if (hasMusic && hasVoiceover) { - filters.append("[music_raw][voice]sidechaincompress=threshold=") + // CRITICAL: split the voice into two copies. sidechaincompress CONSUMES its key input, so the + // voice must be duplicated with asplit — otherwise the amix reference to [voice] gets no audio + // and the narration is silently dropped from the final mix (only ducked music remains). + filters.append("[voice]asplit=2[voice_key][voice_mix];"); + filters.append("[music_raw][voice_key]sidechaincompress=threshold=") .append(properties.getMusicDuckingThreshold()) .append(":ratio=").append(properties.getMusicDuckingRatio()) .append(":attack=").append(properties.getMusicDuckingAttackMs()) .append(":release=").append(properties.getMusicDuckingReleaseMs()) .append("[music];"); labels.add("[music]"); - labels.add("[voice]"); + labels.add("[voice_mix]"); } else if (hasMusic) { filters.append("[music_raw]anull[music];"); labels.add("[music]"); diff --git a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java index 1fea838..b5da7d7 100644 --- a/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java +++ b/src/test/java/org/example/videoclips/editing/HighlightFfmpegRendererTest.java @@ -136,6 +136,25 @@ class HighlightFfmpegRendererTest { "failed"))).isFalse(); } + @Test + void splitsVoiceSoDuckingDoesNotDropNarrationFromTheMix() { + HighlightFfmpegRenderer renderer = renderer(); + AudioCue music = new AudioCue("music", "score", 0, 9, -14, "bed"); + List voiceovers = List.of( + new HighlightFfmpegRenderer.VoiceoverInput(Path.of("vo.wav"), + new VoiceoverLine("Grounded line.", 2.7, 6.3, "narration"))); + + String filter = String.join(" ", renderer.audioMixCommand(Path.of("timeline.mp4"), + Path.of("music.wav"), music, voiceovers, List.of(), false, Path.of("final.mp4"))); + + // voice must be duplicated: one copy keys the duck, one copy stays in the amix + assertThat(filter) + .contains("[voice]asplit=2[voice_key][voice_mix];") + .contains("[music_raw][voice_key]sidechaincompress=") + .contains("[voice_mix]") + .doesNotContain("[music_raw][voice]sidechaincompress"); + } + @Test void mixesGeneratedAudioWithoutReferencingMissingSourceAudio() { HighlightFfmpegRenderer renderer = renderer();