basyx commited on
Commit
cd165e0
·
verified ·
1 Parent(s): 3696757

Add dedicated voiceover mixing operation

Browse files
Files changed (1) hide show
  1. app/operations/add_voiceover.py +118 -0
app/operations/add_voiceover.py ADDED
@@ -0,0 +1,118 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import Any
5
+
6
+ from app.core.exceptions import InputError
7
+ from app.models.media import InputMedia, OperationResult
8
+ from app.operations.common import execute, output_path, require_inputs
9
+ from app.operations.mix_audio import _audio_bitrate, _video_duration
10
+ from app.services.ffmpeg_service import FFmpegService
11
+ from app.services.validator import bounded_number
12
+
13
+
14
+ def _bool_param(params: dict[str, Any], name: str, default: bool) -> bool:
15
+ value = params.get(name, default)
16
+ if isinstance(value, bool):
17
+ return value
18
+ if isinstance(value, (int, float)) and value in (0, 1):
19
+ return bool(value)
20
+ if isinstance(value, str):
21
+ normalized = value.strip().lower()
22
+ if normalized in {"true", "1", "yes", "on"}:
23
+ return True
24
+ if normalized in {"false", "0", "no", "off"}:
25
+ return False
26
+ raise InputError(f"{name} must be a boolean")
27
+
28
+
29
+ async def add_voiceover(
30
+ ffmpeg: FFmpegService,
31
+ inputs: list[InputMedia] | tuple[InputMedia, ...],
32
+ params: dict[str, Any],
33
+ output_dir: Path,
34
+ ) -> OperationResult:
35
+ require_inputs(inputs, 2)
36
+ if len(inputs) != 2:
37
+ raise InputError("add-voiceover accepts exactly one video and one voiceover audio input")
38
+
39
+ duration = _video_duration(inputs)
40
+ start = bounded_number(params, "start", 0, 0, 86_400)
41
+ volume = bounded_number(params, "volume", 1, 0, 10)
42
+ duck_existing = _bool_param(params, "duck_existing", False)
43
+ duck_volume = bounded_number(params, "duck_volume", 0.35, 0, 1)
44
+ fade_in = bounded_number(params, "fade_in", 0, 0, 60)
45
+ fade_out = bounded_number(params, "fade_out", 0, 0, 60)
46
+ bitrate = _audio_bitrate(params)
47
+
48
+ if fade_in + fade_out > duration:
49
+ raise InputError("fade_in plus fade_out cannot exceed the video duration")
50
+
51
+ duration_value = f"{duration:.6f}"
52
+ delay_ms = round(start * 1000)
53
+
54
+ filters: list[str] = [
55
+ f"[0:a]apad=whole_dur={duration_value},atrim=duration={duration_value}[base]"
56
+ ]
57
+
58
+ voice_filters = [
59
+ f"[1:a]volume={volume:.6g}",
60
+ f"adelay={delay_ms}:all=1",
61
+ ]
62
+ if fade_in > 0:
63
+ voice_filters.append(f"afade=t=in:st={start:.6f}:d={fade_in:.6f}")
64
+ if fade_out > 0:
65
+ fade_start = max(start, duration - fade_out)
66
+ voice_filters.append(f"afade=t=out:st={fade_start:.6f}:d={fade_out:.6f}")
67
+ voice_filters.extend([
68
+ f"apad=whole_dur={duration_value}",
69
+ f"atrim=duration={duration_value}[voiceover]",
70
+ ])
71
+ filters.append(",".join(voice_filters))
72
+
73
+ base_label = "[base]"
74
+ if duck_existing:
75
+ # Duck the original mix while the voiceover is active using the
76
+ # voiceover signal as a sidechain control signal.
77
+ filters.append(
78
+ f"[base][voiceover]sidechaincompress=threshold=0.03:ratio=8:attack=20:release=300:"
79
+ f"makeup=1:mix=1[ducked]"
80
+ )
81
+ base_label = "[ducked]"
82
+
83
+ filters.append(
84
+ f"{base_label}[voiceover]amix=inputs=2:duration=first:dropout_transition=0:normalize=0[mixed]"
85
+ )
86
+
87
+ output = output_path(output_dir, "voiceover", "mp4")
88
+ args: list[str | Path] = []
89
+ for media in inputs:
90
+ args.extend(["-i", media.temp_path])
91
+ args.extend([
92
+ "-filter_complex", ";".join(filters),
93
+ "-map", "0:v:0",
94
+ "-map", "[mixed]",
95
+ "-c:v", "copy",
96
+ "-c:a", "aac",
97
+ "-b:a", bitrate,
98
+ "-movflags", "+faststart",
99
+ output,
100
+ ])
101
+
102
+ return await execute(
103
+ ffmpeg,
104
+ args,
105
+ output,
106
+ "video.add_voiceover",
107
+ {
108
+ "start": start,
109
+ "volume": volume,
110
+ "fade_in": fade_in,
111
+ "fade_out": fade_out,
112
+ "duck_existing": duck_existing,
113
+ "duck_volume": duck_volume,
114
+ "preserve_video": True,
115
+ "mix_mode": "voiceover",
116
+ "duration_seconds": duration,
117
+ },
118
+ )