mirror of
https://github.com/R0m1k3/Socialflow.git
synced 2026-10-22 22:48:09 +02:00
fix(reels): garder le son d'origine de la vidéo quand aucune musique n'est choisie
Sans musique (bouton « Passer »), la bande son de la vidéo était supprimée dès que la voix était activée. Elle sert désormais de fond sonore : conservée telle quelle, baissée automatiquement sous la voix, puis normalisée avec le reste. Une musique choisie la remplace toujours. Même comportement dans l'aperçu, le rendu Remotion et le rendu FFmpeg de secours. Le bouton indique « Passer (garder le son de la vidéo) ». Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018Ze4bs7tpF1KGWUk6ZZSZ4
This commit is contained in:
4 files changed
+91
-38
No files matched your search
@@ -694,7 +694,7 @@ export default function NewReel() {
|
|||||||
setSelectedTrack(null);
|
setSelectedTrack(null);
|
||||||
setCurrentStep('text');
|
setCurrentStep('text');
|
||||||
}}>
|
}}>
|
||||||
Passer
|
Passer (garder le son de la vidéo)
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={() => setCurrentStep('text')}>
|
<Button onClick={() => setCurrentStep('text')}>
|
||||||
Continuer
|
Continuer
|
||||||
@@ -1052,7 +1052,7 @@ export default function NewReel() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Musique</span>
|
<span className="text-muted-foreground">Musique</span>
|
||||||
<span>{selectedTrack ? selectedTrack.title : 'Aucune'}</span>
|
<span>{selectedTrack ? selectedTrack.title : 'Son de la vidéo'}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-between">
|
||||||
<span className="text-muted-foreground">Texte</span>
|
<span className="text-muted-foreground">Texte</span>
|
||||||
|
|||||||
@@ -36,9 +36,16 @@ export const ReelVideo: React.FC<ReelVideoProps> = (props) => {
|
|||||||
|
|
||||||
const videoFrames = Math.max(1, Math.floor(videoDuration * fps));
|
const videoFrames = Math.max(1, Math.floor(videoDuration * fps));
|
||||||
const totalFrames = Math.max(1, Math.round(totalDuration * fps));
|
const totalFrames = Math.max(1, Math.round(totalDuration * fps));
|
||||||
const hasAddedAudio = Boolean(mixedAudioUrl || voiceUrl || musicUrl);
|
// Le son d'origine est remplacé par la musique choisie ; sans musique, il reste
|
||||||
|
// en fond (baissé sous la voix). Au rendu final, il est déjà dans la piste mixée.
|
||||||
|
const keepVideoSound = !mixedAudioUrl && !musicUrl;
|
||||||
const video = (
|
const video = (
|
||||||
<OffthreadVideo src={videoUrl} muted={hasAddedAudio} style={{ width: "100%", height: "100%", objectFit: "cover" }} />
|
<OffthreadVideo
|
||||||
|
src={videoUrl}
|
||||||
|
muted={!keepVideoSound}
|
||||||
|
volume={keepVideoSound && voiceUrl ? (frame) => previewMusicVolume(frame / fps, words, 1, fadeStart) : 1}
|
||||||
|
style={{ width: "100%", height: "100%", objectFit: "cover" }}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,7 +85,7 @@ export const ReelVideo: React.FC<ReelVideoProps> = (props) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Aperçu : la musique baisse pendant la parole et s'éteint avec le fondu final. */
|
/** Aperçu : le fond sonore (musique ou son d'origine) baisse pendant la parole et s'éteint avec le fondu final. */
|
||||||
function previewMusicVolume(time: number, words: TimedWord[], base: number, fadeStart: number | null): number {
|
function previewMusicVolume(time: number, words: TimedWord[], base: number, fadeStart: number | null): number {
|
||||||
const speaking = words.some((w) => time >= w.start - 0.2 && time <= w.end + 0.3);
|
const speaking = words.some((w) => time >= w.start - 0.2 && time <= w.end + 0.3);
|
||||||
const fade =
|
const fade =
|
||||||
|
|||||||
@@ -102,28 +102,41 @@ def video_filters(plan: RenderPlan) -> list[str]:
|
|||||||
return chain
|
return chain
|
||||||
|
|
||||||
|
|
||||||
|
def uses_original_audio(plan: RenderPlan) -> bool:
|
||||||
|
"""Sans musique choisie, la bande son d'origine de la vidéo sert de fond sonore."""
|
||||||
|
return plan.music is None and plan.keep_original_audio
|
||||||
|
|
||||||
|
|
||||||
def audio_graph(
|
def audio_graph(
|
||||||
plan: RenderPlan, music_idx: int | None, voice_idx: int | None
|
plan: RenderPlan, music_idx: int | None, voice_idx: int | None, original_idx: int | None = None
|
||||||
) -> tuple[list[str], str | None]:
|
) -> tuple[list[str], str | None]:
|
||||||
"""Graphe audio : voix décalée, musique bouclée et baissée sous la voix,
|
"""Graphe audio : voix décalée sur un fond sonore (musique bouclée, ou à
|
||||||
niveau final ~ -14 LUFS. Renvoie les filtres et l'étiquette de sortie."""
|
défaut bande son d'origine) baissé sous la voix, niveau final ~ -14 LUFS.
|
||||||
|
Renvoie les filtres et l'étiquette de sortie."""
|
||||||
graph: list[str] = []
|
graph: list[str] = []
|
||||||
if voice_idx is not None:
|
if voice_idx is not None:
|
||||||
delay_ms = int(plan.voice_delay * 1000)
|
delay_ms = int(plan.voice_delay * 1000)
|
||||||
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
|
graph.append(f"[{voice_idx}:a]aresample=48000,adelay={delay_ms}:all=1,apad[voice]")
|
||||||
if music_idx is not None:
|
|
||||||
graph.append(f"[{music_idx}:a]aresample=48000,volume={plan.music_volume:.3f}[music]")
|
|
||||||
|
|
||||||
if voice_idx is not None and music_idx is not None:
|
bed = None
|
||||||
# La musique baisse automatiquement quand la voix parle (ducking)
|
if music_idx is not None:
|
||||||
|
graph.append(f"[{music_idx}:a]aresample=48000,volume={plan.music_volume:.3f}[bed]")
|
||||||
|
bed = "bed"
|
||||||
|
elif original_idx is not None:
|
||||||
|
# Son d'origine conservé à son niveau ; il se tait après la fin de la vidéo
|
||||||
|
graph.append(f"[{original_idx}:a:0]aresample=48000,apad[bed]")
|
||||||
|
bed = "bed"
|
||||||
|
|
||||||
|
if voice_idx is not None and bed:
|
||||||
|
# Le fond sonore baisse automatiquement quand la voix parle (ducking)
|
||||||
graph.append("[voice]asplit=2[vmix][vkey]")
|
graph.append("[voice]asplit=2[vmix][vkey]")
|
||||||
graph.append("[music][vkey]sidechaincompress=threshold=0.02:ratio=8:attack=20:release=400[ducked]")
|
graph.append(f"[{bed}][vkey]sidechaincompress=threshold=0.02:ratio=8:attack=20:release=400[ducked]")
|
||||||
graph.append("[ducked][vmix]amix=inputs=2:duration=longest:normalize=0[mix]")
|
graph.append("[ducked][vmix]amix=inputs=2:duration=longest:normalize=0[mix]")
|
||||||
mix = "mix"
|
mix = "mix"
|
||||||
elif voice_idx is not None:
|
elif voice_idx is not None:
|
||||||
mix = "voice"
|
mix = "voice"
|
||||||
elif music_idx is not None:
|
elif bed:
|
||||||
mix = "music"
|
mix = bed
|
||||||
else:
|
else:
|
||||||
return graph, None
|
return graph, None
|
||||||
|
|
||||||
@@ -206,14 +219,11 @@ def build_command(plan: RenderPlan) -> list[str]:
|
|||||||
tail.append("format=yuv420p")
|
tail.append("format=yuv420p")
|
||||||
graph.append(f"[{current}]{','.join(tail)}[vout]")
|
graph.append(f"[{current}]{','.join(tail)}[vout]")
|
||||||
|
|
||||||
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx)
|
# La vidéo (entrée 0) fournit la bande son d'origine si aucune musique n'est choisie
|
||||||
|
original_idx = 0 if uses_original_audio(plan) else None
|
||||||
|
audio_filters, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
|
||||||
graph += audio_filters
|
graph += audio_filters
|
||||||
if audio_out:
|
audio_map = ["-map", f"[{audio_out}]"] if audio_out else []
|
||||||
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 += ["-filter_complex", ";".join(graph), "-map", "[vout]", *audio_map]
|
||||||
cmd += ["-t", f"{total:.3f}", *_video_encoding(19, "medium")]
|
cmd += ["-t", f"{total:.3f}", *_video_encoding(19, "medium")]
|
||||||
@@ -236,21 +246,14 @@ def build_prepared_video_command(plan: RenderPlan, output: Path) -> list[str]:
|
|||||||
def build_audio_mix_command(plan: RenderPlan, output: Path) -> list[str] | None:
|
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."""
|
"""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}"
|
total = f"{plan.total_duration:.3f}"
|
||||||
if not plan.music and not plan.voice:
|
audio_args, music_idx, voice_idx, index = _audio_inputs(plan, 0)
|
||||||
if not plan.keep_original_audio:
|
original_idx = None
|
||||||
return None
|
if uses_original_audio(plan):
|
||||||
# Son d'origine seul : même niveau final que les autres Reels
|
audio_args += ["-i", str(plan.video)]
|
||||||
chain = ["aresample=48000", "loudnorm=I=-14:TP=-1.5:LRA=11", "apad"]
|
original_idx = index
|
||||||
if plan.ending_effect:
|
graph, audio_out = audio_graph(plan, music_idx, voice_idx, original_idx)
|
||||||
chain.append(f"afade=t=out:st={plan.fade_start:.3f}:d={FADE_SECONDS}")
|
if not audio_out:
|
||||||
return [
|
return None
|
||||||
"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 [
|
return [
|
||||||
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *audio_args,
|
"ffmpeg", "-y", "-hide_banner", "-loglevel", "error", *audio_args,
|
||||||
"-filter_complex", ";".join(graph), "-map", f"[{audio_out}]", "-t", total,
|
"-filter_complex", ";".join(graph), "-map", f"[{audio_out}]", "-t", total,
|
||||||
|
|||||||
@@ -51,7 +51,50 @@ def test_original_audio_kept_when_nothing_added():
|
|||||||
video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), keep_original_audio=True
|
video=Path("in.mp4"), video_duration=5.0, output=Path("out.mp4"), keep_original_audio=True
|
||||||
)
|
)
|
||||||
cmd = build_command(plan)
|
cmd = build_command(plan)
|
||||||
assert cmd[cmd.index("-map", cmd.index("[vout]")) + 1] == "0:a:0"
|
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():
|
def test_encoding_targets_social_networks():
|
||||||
|
|||||||
Reference in new issue
Block a user