feat(reels): montage Remotion unifié, aperçu en direct, tests et CI

Montage (étape 3) :
- Nouvelle composition ReelVideo : sous-titres animés mot à mot (3 styles :
  Impact, Surligné, Épuré), logo, effet de fin et fondu, en React
- Le service Python prépare l'image (recadrage, HDR, stabilisation, dernière
  image figée) et la piste son finale (/prepare-reel) ; Remotion compose
- Reels d'images sur les mêmes composants, avec le vrai minutage de la voix
- Police Montserrat embarquée (plus de dépendance à Google Fonts)
- REEL_RENDERER=ffmpeg conserve le rendu FFmpeg, plus rapide, en secours
- Script de pré-bundle réparé (échouait en silence : require en ESM)

Interface (étape 4) :
- Aperçu en direct avec @remotion/player, identique au rendu final ; la voix
  testée cale les sous-titres, sinon minutage estimé
- Choix du style de sous-titres sur les 4 pages Reel
- Vraie progression : étape réelle du rendu, échecs visibles 24 h avec leur
  cause ; fin de la barre simulée et du faux « publié avec succès »

Outillage (étape 5) :
- Tests vitest (minutage identique à Python, validation, sécurité) et
  pytest ; CI GitHub Actions (tsc, tests, build, ruff)
- Captures d'écran, out.mp4 et scripts de test retirés de la racine

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
Claude committed 2026-09-24 14:37:42 +00:00
1 parent 3c6a66bf64
commit 9b66a6dc5a
64 files changed
+2488 -782

No files matched your search

+121 -52
View File
@@ -74,6 +74,9 @@ class ReelRequest(BaseModel):
draw_text: bool = True
stabilize: bool = False
enable_ending_effect: bool = True
# Préparation Remotion : le logo est composé par Remotion, mais sa présence
# allonge la vidéo pour laisser place à l'effet de fin.
has_logo: bool = False
# Champs d'anciennes versions, acceptés et ignorés
music_id: str | None = None
word_duration: float | None = None
@@ -115,13 +118,23 @@ async def preview_tts(request: TtsRequest):
@app.post("/process-reel", dependencies=[Depends(require_key)])
async def process_reel(request: ReelRequest):
"""Produit le Reel ; le MP4 se récupère ensuite via GET /files/{job_id}/output.mp4."""
"""Rendu complet par FFmpeg ; le MP4 se récupère via GET /files/{job_id}/output.mp4."""
return await _guarded(_process, request)
@app.post("/prepare-reel", dependencies=[Depends(require_key)])
async def prepare_reel(request: ReelRequest):
"""Prépare un rendu Remotion : image recadrée à la durée finale, piste son
finale et minutage des mots. Rien n'est incrusté dans l'image."""
return await _guarded(_prepare, request)
async def _guarded(handler, request: ReelRequest) -> dict:
job_id, workdir = jobs.new_job()
stats: dict[str, float] = {}
started = time.monotonic()
try:
async with render_slot:
return await _process(request, job_id, workdir, stats, started)
return await handler(request, job_id, workdir, started)
except HTTPException:
jobs.remove_job(job_id)
raise
@@ -140,7 +153,8 @@ async def get_file(job_id: str, name: str):
path = jobs.job_file(job_id, name)
if not path:
raise HTTPException(status_code=404, detail="Fichier introuvable ou expiré")
return FileResponse(path, media_type="video/mp4" if name.endswith(".mp4") else None)
media_type = {"mp4": "video/mp4", "wav": "audio/wav"}.get(name.rsplit(".", 1)[-1])
return FileResponse(path, media_type=media_type)
@app.delete("/jobs/{job_id}", dependencies=[Depends(require_key)])
@@ -185,8 +199,23 @@ async def _download(url: str, target: Path, what: str, required: bool) -> bool:
return False
async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict, started: float) -> dict:
step = time.monotonic()
class Stopwatch:
def __init__(self, started: float):
self.started = started
self.last = time.monotonic()
self.stats: dict[str, float] = {}
def lap(self, name: str) -> None:
now = time.monotonic()
self.stats[name] = round(now - self.last, 2)
self.last = now
def summary(self) -> dict[str, float]:
return {**self.stats, "total": round(time.monotonic() - self.started, 2)}
async def _gather(request: ReelRequest, workdir: Path, clock: Stopwatch, *, fetch_logo: bool):
"""Téléchargements, analyse, voix et stabilisation : communs aux deux modes."""
video = workdir / "input.mp4"
if request.video_base64:
video.write_bytes(base64.b64decode(request.video_base64))
@@ -200,21 +229,21 @@ async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict
request.music_url, music, "la musique", required=False
)
watermark = workdir / "watermark.png"
has_watermark = bool(request.watermark_url) and await _download(
request.watermark_url, watermark, "le logo", required=False
has_watermark = (
fetch_logo
and bool(request.watermark_url)
and await _download(request.watermark_url, watermark, "le logo", required=False)
)
info = await proc.probe(video)
if info.duration <= 0:
raise HTTPException(status_code=400, detail="Vidéo illisible (durée nulle)")
stats["download"] = time.monotonic() - step
clock.lap("download")
# --- Voix ---
step = time.monotonic()
track = None
spoken_text = clean_text(request.text)
if request.tts_enabled and spoken_text:
track = await _synthesize(spoken_text, request.text, request, workdir)
stats["tts"] = time.monotonic() - step
clock.lap("tts")
plan = render.RenderPlan(
video=video,
@@ -226,30 +255,11 @@ async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict
voice=track.path if track else None,
voice_duration=track.duration if track else 0.0,
watermark=watermark if has_watermark else None,
outro_expected=not fetch_logo and request.has_logo,
ending_effect=request.enable_ending_effect,
keep_original_audio=info.has_audio,
)
# --- Sous-titres ---
step = time.monotonic()
font_size = max(48, round(request.font_size * 1.4))
display = clean_text(request.text)
if request.draw_text and display:
captions = workdir / "captions.ass"
if track:
subtitles.write_captions(track.words, captions, offset=plan.voice_delay, font_size=font_size)
else:
words = await _caption_words_without_voice(display, video, info, plan)
subtitles.write_captions(words, captions, offset=0.0, font_size=font_size)
plan.captions = captions
if request.store_name and request.enable_ending_effect and has_watermark:
outro = workdir / "outro.ass"
subtitles.write_outro(request.store_name, outro, plan.logo_start, plan.total_duration)
plan.outro = outro
stats["subtitles"] = time.monotonic() - step
# --- Stabilisation (1re passe) ---
step = time.monotonic()
if request.stabilize:
transforms = workdir / "transforms.trf"
try:
@@ -257,36 +267,95 @@ async def _process(request: ReelRequest, job_id: str, workdir: Path, stats: dict
plan.stabilize_transforms = transforms
except proc.CommandError as error:
log.warning("Stabilisation ignorée : %s", error)
stats["stabilize"] = time.monotonic() - step
clock.lap("stabilize")
return plan, track, info
# --- Encodage ---
step = time.monotonic()
await proc.run(render.build_command(plan), timeout=1200)
stats["encode"] = time.monotonic() - step
stats["total"] = time.monotonic() - started
duration = (await proc.probe(plan.output)).duration
log.info(
"Rendu %s terminé : %.1f s de vidéo, étapes %s",
job_id,
duration,
{k: round(v, 1) for k, v in stats.items()},
)
async def _caption_words(request: ReelRequest, plan: render.RenderPlan, track, info) -> list[align.Word]:
"""Mots à afficher, en secondes depuis le début de la vidéo."""
display = clean_text(request.text)
if not request.draw_text or not display:
return []
if track:
return [align.Word(w.text, w.start + plan.voice_delay, w.end + plan.voice_delay) for w in track.words]
return await _caption_words_without_voice(display, plan.video, info, plan)
# Seul le résultat est conservé jusqu'au téléchargement
def _keep_only(workdir: Path, keep: set[Path]) -> None:
"""Seuls les fichiers à télécharger restent jusqu'à la récupération."""
for entry in workdir.iterdir():
if entry != plan.output:
if entry not in keep:
entry.unlink(missing_ok=True)
def _voice_info(track) -> dict:
return {
"tts_engine": track.engine if track else None,
"tts_voice": track.voice if track else None,
"warnings": track.warnings if track else [],
}
async def _process(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
clock = Stopwatch(started)
plan, track, info = await _gather(request, workdir, clock, fetch_logo=True)
words = await _caption_words(request, plan, track, info)
if words:
captions = workdir / "captions.ass"
font_size = max(48, round(request.font_size * 1.4))
subtitles.write_captions(words, captions, offset=0.0, font_size=font_size)
plan.captions = captions
if request.store_name and plan.has_outro and plan.watermark:
outro = workdir / "outro.ass"
subtitles.write_outro(request.store_name, outro, plan.logo_start, plan.total_duration)
plan.outro = outro
clock.lap("subtitles")
await proc.run(render.build_command(plan), timeout=1200)
clock.lap("encode")
duration = (await proc.probe(plan.output)).duration
log.info("Rendu %s terminé : %.1f s de vidéo, étapes %s", job_id, duration, clock.summary())
_keep_only(workdir, {plan.output})
return {
"success": True,
"job_id": job_id,
"output_path": f"/files/{job_id}/output.mp4",
"duration": duration,
"tts_engine": track.engine if track else None,
"tts_voice": track.voice if track else None,
"warnings": track.warnings if track else [],
"processing_stats": stats,
**_voice_info(track),
"processing_stats": clock.summary(),
}
async def _prepare(request: ReelRequest, job_id: str, workdir: Path, started: float) -> dict:
clock = Stopwatch(started)
plan, track, info = await _gather(request, workdir, clock, fetch_logo=False)
words = await _caption_words(request, plan, track, info)
clock.lap("subtitles")
video_out = workdir / "video.mp4"
audio_out = workdir / "audio.wav"
await proc.run(render.build_prepared_video_command(plan, video_out), timeout=1200)
clock.lap("video")
mix = render.build_audio_mix_command(plan, audio_out)
if mix:
await proc.run(mix, timeout=600)
clock.lap("audio")
log.info("Préparation %s terminée : %.1f s, étapes %s", job_id, plan.total_duration, clock.summary())
_keep_only(workdir, {video_out, audio_out})
return {
"success": True,
"job_id": job_id,
"video_path": f"/files/{job_id}/video.mp4",
"audio_path": f"/files/{job_id}/audio.wav" if mix else None,
"total_duration": plan.total_duration,
"video_duration": info.duration,
"logo_start": plan.logo_start if plan.has_outro else None,
"words": [w.to_dict() for w in words],
**_voice_info(track),
"processing_stats": clock.summary(),
}
@@ -300,5 +369,5 @@ async def _caption_words_without_voice(display: str, video: Path, info, plan: re
return align.align_words(display, spoken, info.duration)
except Exception as error: # noqa: BLE001 — on retombe sur la répartition
log.warning("Transcription de la vidéo impossible : %s", error)
end = plan.logo_start if plan.ending_effect and plan.watermark else plan.total_duration
end = plan.logo_start if plan.has_outro else plan.total_duration
return align.align_words(display, [], max(1.0, end - 0.5))
+153 -114
View File
@@ -1,4 +1,11 @@
"""Construction de la commande FFmpeg d'un Reel (fonction pure, testable)."""
"""Construction des commandes FFmpeg d'un Reel (fonctions pures, testables).
Deux usages :
- rendu complet par FFmpeg (sous-titres ASS incrustés) : `build_command` ;
- préparation pour Remotion, qui compose ensuite les sous-titres animés, le
logo et l'effet de fin : `build_prepared_video_command` (image seule, déjà
recadrée et allongée) et `build_audio_mix_command` (piste son finale).
"""
from dataclasses import dataclass
from pathlib import Path
@@ -19,6 +26,8 @@ TONEMAP = (
"tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p"
)
COLOR_TAGS = ["-color_primaries", "bt709", "-color_trc", "bt709", "-colorspace", "bt709"]
@dataclass
class RenderPlan:
@@ -35,9 +44,15 @@ class RenderPlan:
captions: Path | None = None
watermark: Path | None = None
outro: Path | None = None # nom du magasin (effet de fin)
# Effet de fin prévu sans que le logo passe par FFmpeg (rendu Remotion)
outro_expected: bool = False
ending_effect: bool = True
keep_original_audio: bool = False
@property
def has_outro(self) -> bool:
return self.ending_effect and (self.watermark is not None or self.outro_expected)
@property
def speech_end(self) -> float:
return self.voice_delay + self.voice_duration if self.voice else 0.0
@@ -48,42 +63,26 @@ class RenderPlan:
duration = self.video_duration
if self.voice:
duration = max(duration, self.speech_end + VOICE_TAIL)
if self.ending_effect and self.watermark:
if self.has_outro:
duration = max(duration, self.speech_end + OUTRO_MIN)
return round(duration, 3)
@property
def logo_start(self) -> float:
"""Le grand logo n'arrive qu'une fois la voix terminée."""
return max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end)
return round(max(0.0, self.total_duration - LOGO_SECONDS, self.speech_end), 3)
@property
def fade_start(self) -> float:
return max(0.0, self.total_duration - FADE_SECONDS)
@property
def freeze_duration(self) -> float:
return max(0.0, self.total_duration - self.video_duration)
def build_command(plan: RenderPlan) -> list[str]:
total = plan.total_duration
logo_start = plan.logo_start
fade_start = max(0.0, total - FADE_SECONDS)
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video)]
index = 1
music_idx = voice_idx = wm_idx = None
if plan.music:
# Musique bouclée : une piste plus courte que la vidéo ne coupe plus le son
cmd += ["-stream_loop", "-1", "-i", str(plan.music)]
music_idx, index = index, index + 1
if plan.voice:
cmd += ["-i", str(plan.voice)]
voice_idx, index = index, index + 1
if plan.watermark:
cmd += ["-i", str(plan.watermark)]
wm_idx, index = index, index + 1
graph: list[str] = []
# --- Vidéo ---
def video_filters(plan: RenderPlan) -> list[str]:
"""Stabilisation, HDR, recadrage 9:16, cadence fixe, dernière image figée."""
chain = []
if plan.stabilize_transforms:
chain.append(
@@ -98,35 +97,15 @@ def build_command(plan: RenderPlan) -> list[str]:
)
if plan.freeze_duration > 0:
chain.append(f"tpad=stop_mode=clone:stop_duration={plan.freeze_duration:.3f}")
if plan.captions:
chain.append(f"subtitles='{filter_path(plan.captions)}'")
graph.append(f"[0:v]{','.join(chain)}[vbase]")
return chain
current = "vbase"
if wm_idx is not None:
corner = "W-w-30:H-h-30"
if plan.outro and plan.ending_effect:
graph.append(f"[{wm_idx}:v]scale=200:-1,split=2[wm_small][wm_big0]")
graph.append("[wm_big0]scale=-1:300[wm_big]")
graph.append(f"[{current}][wm_small]overlay={corner}:enable='lt(t,{logo_start:.3f})'[vwm1]")
graph.append(f"[vwm1][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]")
current = "vwm2"
else:
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
graph.append(f"[{current}][wm_small]overlay={corner}[vwm1]")
current = "vwm1"
if plan.outro and plan.ending_effect:
graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]")
current = "vout0"
tail = []
if plan.ending_effect:
tail.append(f"fade=t=out:st={fade_start:.3f}:d={FADE_SECONDS}")
tail.append("format=yuv420p")
graph.append(f"[{current}]{','.join(tail)}[vout]")
# --- Audio ---
mix = None
def audio_graph(
plan: RenderPlan, music_idx: int | None, voice_idx: int | None
) -> tuple[list[str], str | None]:
"""Graphe audio : voix décalée, musique bouclée et baissée sous la voix,
niveau final ~ -14 LUFS. Renvoie les filtres et l'étiquette de sortie."""
graph: list[str] = []
if voice_idx is not None:
delay_ms = int(plan.voice_delay * 1000)
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
@@ -143,75 +122,135 @@ def build_command(plan: RenderPlan) -> list[str]:
mix = "voice"
elif music_idx is not None:
mix = "music"
else:
return graph, None
audio_map: list[str] = []
if mix:
# Niveau final recommandé par les réseaux sociaux (~ -14 LUFS)
audio_tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
if plan.ending_effect:
audio_tail.append(f"afade=t=out:st={fade_start:.3f}:d={FADE_SECONDS}")
graph.append(f"[{mix}]{','.join(audio_tail)}[aout]")
audio_map = ["-map", "[aout]"]
tail = ["loudnorm=I=-14:TP=-1.5:LRA=11", "aresample=48000"]
if plan.ending_effect:
tail.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
graph.append(f"[{mix}]{','.join(tail)}[aout]")
return graph, "aout"
def _audio_inputs(plan: RenderPlan, first_index: int) -> tuple[list[str], int | None, int | None, int]:
args: list[str] = []
index = first_index
music_idx = voice_idx = None
if plan.music:
# Musique bouclée : une piste plus courte que la vidéo ne coupe plus le son
args += ["-stream_loop", "-1", "-i", str(plan.music)]
music_idx, index = index, index + 1
if plan.voice:
args += ["-i", str(plan.voice)]
voice_idx, index = index, index + 1
return args, music_idx, voice_idx, index
def _video_encoding(crf: int, preset: str) -> list[str]:
return [
"-c:v", "libx264", "-preset", preset, "-crf", str(crf),
"-maxrate", "10M", "-bufsize", "20M",
"-profile:v", "high", "-level", "4.2",
"-g", str(config.FPS * 2), "-keyint_min", str(config.FPS),
"-pix_fmt", "yuv420p", *COLOR_TAGS,
] # fmt: skip
def build_command(plan: RenderPlan) -> list[str]:
"""Rendu complet par FFmpeg, sous-titres ASS incrustés."""
total = plan.total_duration
cmd = ["ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video)]
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 1)
cmd += audio_args
wm_idx = None
if plan.watermark:
cmd += ["-i", str(plan.watermark)]
wm_idx = index
chain = video_filters(plan)
if plan.captions:
chain.append(f"subtitles='{filter_path(plan.captions)}'")
graph = [f"[0:v]{','.join(chain)}[vbase]"]
current = "vbase"
if wm_idx is not None:
corner = "W-w-30:H-h-30"
if plan.outro and plan.ending_effect:
logo_start = plan.logo_start
graph.append(f"[{wm_idx}:v]scale=200:-1,split=2[wm_small][wm_big0]")
graph.append("[wm_big0]scale=-1:300[wm_big]")
graph.append(f"[{current}][wm_small]overlay={corner}:enable='lt(t,{logo_start:.3f})'[vwm1]")
graph.append(f"[vwm1][wm_big]overlay=(W-w)/2:(H-h)/2-100:enable='gte(t,{logo_start:.3f})'[vwm2]")
current = "vwm2"
else:
graph.append(f"[{wm_idx}:v]scale=200:-1[wm_small]")
graph.append(f"[{current}][wm_small]overlay={corner}[vwm1]")
current = "vwm1"
if plan.outro and plan.ending_effect:
graph.append(f"[{current}]subtitles='{filter_path(plan.outro)}'[vout0]")
current = "vout0"
tail = []
if plan.ending_effect:
tail.append(f"fade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
tail.append("format=yuv420p")
graph.append(f"[{current}]{','.join(tail)}[vout]")
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx)
graph += audio_filters
if audio_out:
audio_map = ["-map", f"[{audio_out}]"]
elif plan.keep_original_audio:
audio_map = ["-map", "0:a:0"]
else:
audio_map = []
cmd += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
cmd += [
"-t",
f"{total:.3f}",
"-c:v",
"libx264",
"-preset",
"medium",
"-crf",
"19",
"-maxrate",
"10M",
"-bufsize",
"20M",
"-profile:v",
"high",
"-level",
"4.2",
"-g",
str(config.FPS * 2),
"-keyint_min",
str(config.FPS),
"-pix_fmt",
"yuv420p",
"-color_primaries",
"bt709",
"-color_trc",
"bt709",
"-colorspace",
"bt709",
"-c:a",
"aac",
"-b:a",
"192k",
"-ar",
"48000",
"-ac",
"2",
"-movflags",
"+faststart",
str(plan.output),
]
cmd += ["-t", f"{total:.3f}", *_video_encoding(19, "medium")]
cmd += ["-c:a", "aac", "-b:a", "192k", "-ar", "48000", "-ac", "2", "-movflags", "+faststart"]
cmd.append(str(plan.output))
return cmd
def build_prepared_video_command(plan: RenderPlan, output: Path) -> list[str]:
"""Image seule, prête pour Remotion : recadrée, en cadence fixe, à la durée finale.
Qualité élevée (CRF 16) : elle sera réencodée une fois par Remotion."""
chain = [*video_filters(plan), "format=yuv420p"]
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
"-vf", ",".join(chain), "-an", "-t", f"{plan.total_duration:.3f}",
*_video_encoding(16, "veryfast"), "-movflags", "+faststart", str(output),
] # fmt: skip
def build_audio_mix_command(plan: RenderPlan, output: Path) -> list[str] | None:
"""Piste son finale (WAV 48 kHz stéréo), ou None s'il n'y a aucun son à produire."""
total = f"{plan.total_duration:.3f}"
if not plan.music and not plan.voice:
if not plan.keep_original_audio:
return None
# Son d'origine seul : même niveau final que les autres Reels
chain = ["aresample=48000", "loudnorm=I=-14:TP=-1.5:LRA=11", "apad"]
if plan.ending_effect:
chain.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(plan.video),
"-map", "0:a:0", "-af", ",".join(chain), "-t", total,
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
] # fmt: skip
audio_args, music_idx, voice_idx, _ = _audio_inputs(plan, 0)
graph, audio_out = audio_graph(plan, music_idx, voice_idx)
return [
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *audio_args,
"-filter_complex", ";".join(graph), "-map", f"[{audio_out}]", "-t", total,
"-ar", "48000", "-ac", "2", "-c:a", "pcm_s16le", str(output),
] # fmt: skip
def stabilize_detect_command(video: Path, transforms: Path) -> list[str]:
return [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
str(video),
"-vf",
f"vidstabdetect=stepsize=32:shakiness=8:accuracy=15:result={filter_path(transforms)}",
"-f",
"null",
"-",
]
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", "-i", str(video),
"-vf", f"vidstabdetect=stepsize=32:shakiness=8:accuracy=15:result={filter_path(transforms)}",
"-f", "null", "-",
] # fmt: skip
+26
View File
@@ -75,3 +75,29 @@ def test_big_logo_waits_for_the_end_of_the_voice():
assert plan.logo_start == 6.8
assert plan.total_duration == 9.3
assert "gte(t,6.800)" in _graph(build_command(plan))
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")))