mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-11 17:26:45 +02:00
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:
64 files changed
+2488
-782
No files matched your search
+121
-52
@@ -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
@@ -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
|
||||
@@ -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")))
|
||||
Reference in new issue
Block a user