Files
Socialflow/ffmpeg-service/tests/test_render.py
T
Claude d4c544e467 fix(voix): plus de fins de phrases coupées ni de bruits parasites
Fins de phrases coupées :
- Le fondu final ne commence plus avant la fin de la voix (il rognait la
  dernière phrase quand la vidéo finissait peu après) ; il se raccourcit au
  besoin. Même règle côté Python, aperçu et rendu Remotion
- SRT : découpe au milieu de la zone la plus calme de chaque pause, mesurée
  sur l'enveloppe sonore (tranches de 10 ms), avec un seuil relatif au
  niveau de la voix qui respecte les fins douces ; 300 ms gardées après le
  dernier son

Bruits parasites :
- Normalisation de la voix en deux passes, en mode linéaire : le mode
  dynamique relevait souffles et bruits entre les phrases (mesuré : -64 dB
  avant, -75 dB après dans les pauses)
- Réduction de bruit douce (afftdn), compresseur sans gain ajouté, fondus
  de 5 ms aux extrémités ; mix final moins « pompant » (LRA 20)
- Voix Gemini vérifiée : les bruits ajoutés avant le premier ou après le
  dernier mot sont retirés

Vérification de lecture comparée lettre à lettre : les petites erreurs de
Whisper ne déclenchent plus de régénérations inutiles.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
2026-09-28 09:21:18 +00:00

172 lines
5.5 KiB
Python

from pathlib import Path
from app.render import RenderPlan, build_command
def _graph(cmd):
return cmd[cmd.index("-filter_complex") + 1]
def test_voice_longer_than_video_extends_with_frozen_frame():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=5.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=10.0,
)
assert plan.total_duration == 12.8
cmd = build_command(plan)
assert "tpad=stop_mode=clone:stop_duration=7.800" in _graph(cmd)
assert cmd[cmd.index("-t") + 1] == "12.800"
def test_music_is_looped_and_ducked_under_voice():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=20.0,
output=Path("out.mp4"),
music=Path("m.mp3"),
voice=Path("v.wav"),
voice_duration=5.0,
)
cmd = build_command(plan)
assert cmd[cmd.index("m.mp3") - 3 : cmd.index("m.mp3")] == ["-stream_loop", "-1", "-i"]
graph = _graph(cmd)
assert "sidechaincompress" in graph
assert "amix=inputs=2:duration=longest" in graph
assert "loudnorm=I=-14" in graph
def test_no_brightness_hack_and_hdr_tonemapped():
plan = RenderPlan(video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), is_hdr=True)
graph = _graph(build_command(plan))
assert "eq=" not in graph
assert "tonemap=tonemap=hable" in graph
assert "flags=lanczos" in graph
def test_original_audio_kept_when_nothing_added():
plan = RenderPlan(
video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), keep_original_audio=True
)
cmd = build_command(plan)
assert "[0:a:0]aresample=48000,apad[bed]" in _graph(cmd)
assert cmd[cmd.index("-map", cmd.index("[vout]")) + 1] == "[aout]"
def test_original_audio_stays_under_the_voice_when_no_music_is_chosen():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=10.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=4.0,
keep_original_audio=True,
)
graph = _graph(build_command(plan))
assert "[0:a:0]aresample=48000,apad[bed]" in graph
assert "[bed][vkey]sidechaincompress" in graph
def test_chosen_music_replaces_the_original_audio():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=10.0,
output=Path("out.mp4"),
music=Path("m.mp3"),
keep_original_audio=True,
)
assert "0:a:0" not in _graph(build_command(plan))
def test_prepared_audio_mix_keeps_original_audio_under_voice():
from app.render import build_audio_mix_command
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=10.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=4.0,
keep_original_audio=True,
)
cmd = build_audio_mix_command(plan, Path("a.wav"))
# Entrées : voix (0) puis vidéo d'origine (1)
assert cmd[cmd.index("in.mp4") - 1] == "-i"
assert "[1:a:0]aresample=48000,apad[bed]" in " ".join(cmd)
def test_encoding_targets_social_networks():
cmd = build_command(RenderPlan(video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4")))
joined = " ".join(cmd)
assert "-crf 19" in joined and "-b:v" not in joined
assert "-ar 48000" in joined and "-b:a 192k" in joined
assert "-colorspace bt709" in joined
def test_big_logo_waits_for_the_end_of_the_voice():
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=6.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=4.8,
watermark=Path("logo.png"),
outro=Path("o.ass"),
)
assert plan.logo_start == 6.8
assert plan.total_duration == 9.3
assert "gte(t,6.800)" in _graph(build_command(plan))
def test_corner_logo_can_be_hidden_while_keeping_the_ending_logo():
base = dict(video=Path("in.mp4"), video_duration=6.0, output=Path("out.mp4"), watermark=Path("logo.png"))
hidden = _graph(build_command(RenderPlan(**base, outro=Path("o.ass"), show_watermark=False)))
assert "W-w-30:H-h-30" not in hidden
assert "overlay=(W-w)/2" in hidden
nothing = _graph(build_command(RenderPlan(**base, show_watermark=False)))
assert "overlay" not in nothing
def test_prepared_video_has_no_audio_and_final_duration():
from app.render import build_prepared_video_command
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=6.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=5.0,
outro_expected=True,
)
cmd = build_prepared_video_command(plan, Path("video.mp4"))
assert "-an" in cmd
assert cmd[cmd.index("-t") + 1] == "9.500" # 2 s + 5 s de voix + 2,5 s d'effet de fin
assert plan.logo_start == 7.0
def test_audio_mix_absent_without_any_sound():
from app.render import build_audio_mix_command
plan = RenderPlan(video=Path("in.mp4"), video_duration=6.0, output=Path("out.mp4"))
assert build_audio_mix_command(plan, Path("a.wav")) is None
plan.keep_original_audio = True
assert "loudnorm=I=-14" in " ".join(build_audio_mix_command(plan, Path("a.wav")))
def test_final_fade_never_starts_before_the_voice_ends():
# Vidéo courte sans logo : 2 s + 5 s de voix, la vidéo finit 0,8 s après.
plan = RenderPlan(
video=Path("in.mp4"),
video_duration=4.0,
output=Path("out.mp4"),
voice=Path("v.wav"),
voice_duration=5.0,
)
assert plan.total_duration == 7.8
assert plan.fade_start >= plan.speech_end
assert plan.fade_duration >= 0.3
assert f"afade=t=out:st={plan.fade_start:.3f}" in _graph(build_command(plan))