886 lines
41 KiB
Python
886 lines
41 KiB
Python
from typing import Union, List, Tuple, Optional, Dict
|
||
import numpy as np
|
||
import json, os
|
||
import math
|
||
from .TimeUtils import TimeDelta
|
||
from pydantic import BaseModel, ConfigDict, computed_field
|
||
from ffmpeg import FFmpeg
|
||
from ffmpeg.asyncio import FFmpeg as AsyncFFmpeg
|
||
import soundfile as sf
|
||
import pyloudnorm as pyln
|
||
import noisereduce as nr
|
||
from pedalboard import (
|
||
Pedalboard,
|
||
Compressor,
|
||
Limiter,
|
||
HighpassFilter,
|
||
LowpassFilter,
|
||
Gain,
|
||
Reverb,
|
||
Chorus,
|
||
Distortion,
|
||
)
|
||
from pedalboard.io import AudioFile
|
||
from loguru import logger
|
||
from .PathUtils import FileUtils
|
||
from BowongModalFunctions.models.ffmpeg_worker_model import FFMpegSliceSegment
|
||
|
||
|
||
class MediaStream(BaseModel):
|
||
duration: float
|
||
codec_name: str
|
||
|
||
model_config = ConfigDict(extra='allow')
|
||
|
||
|
||
class AudioStream(MediaStream):
|
||
stream_type: str = "audio"
|
||
sample_rate: str
|
||
# bit_depth : str
|
||
# sample_fmt: str
|
||
|
||
model_config = ConfigDict(extra='allow')
|
||
|
||
|
||
class VideoStream(MediaStream):
|
||
stream_type: str = "video"
|
||
width: int
|
||
height: int
|
||
bit_rate: int
|
||
avg_frame_rate: str
|
||
|
||
@computed_field
|
||
@property
|
||
def video_bitrate(self) -> str:
|
||
return str(int(self.bit_rate / 1000)) + 'k'
|
||
|
||
@computed_field
|
||
@property
|
||
def video_frame_rate(self) -> float:
|
||
numerator, denominator = map(int, self.avg_frame_rate.split('/'))
|
||
if denominator != 0:
|
||
return numerator / denominator
|
||
return 0
|
||
|
||
model_config = ConfigDict(extra='allow')
|
||
|
||
|
||
class ImageStream(MediaStream):
|
||
stream_type: str = "image"
|
||
width: int
|
||
height: int
|
||
pix_fmt: str
|
||
|
||
model_config = ConfigDict(extra='allow')
|
||
|
||
|
||
class VideoMetadata(BaseModel):
|
||
streams: List[Union[VideoStream, ImageStream, AudioStream]]
|
||
|
||
model_config = ConfigDict(extra='allow')
|
||
|
||
|
||
class VideoUtils:
|
||
"""
|
||
python-ffmpeg package docs : https://python-ffmpeg.readthedocs.io/en/stable/
|
||
"""
|
||
|
||
@staticmethod
|
||
def ffprobe_video_format(media_path: str) -> VideoStream:
|
||
ffprobe = FFmpeg(executable="ffprobe").input(
|
||
media_path, print_format="json", show_streams=None
|
||
)
|
||
video_metadata = VideoMetadata.model_validate_json(ffprobe.execute())
|
||
return video_metadata.streams[0]
|
||
|
||
@staticmethod
|
||
def ffprobe_media_metadata(media_path: str) -> VideoMetadata:
|
||
ffprobe = FFmpeg(executable="ffprobe").input(
|
||
media_path, print_format="json", show_streams=None
|
||
)
|
||
video_metadata = VideoMetadata.model_validate_json(ffprobe.execute())
|
||
return video_metadata
|
||
|
||
@staticmethod
|
||
def ffprobe_video_duration(media_path: str) -> TimeDelta:
|
||
ffprobe_cmd = VideoUtils.ffmpeg_init(use_ffprobe=True)
|
||
ffprobe_cmd.input(media_path, print_format="json", show_streams=None)
|
||
metadata_json = ffprobe_cmd.execute()
|
||
metadata = VideoMetadata.model_validate_json(metadata_json)
|
||
return TimeDelta(seconds=metadata.streams[0].duration)
|
||
|
||
@staticmethod
|
||
def ffprobe_audio_duration(media_path: str) -> TimeDelta:
|
||
ffprobe_cmd = VideoUtils.ffmpeg_init(use_ffprobe=True)
|
||
ffprobe_cmd.input(media_path, print_format="json", show_streams=None)
|
||
metadata_json = ffprobe_cmd.execute()
|
||
metadata = VideoMetadata.model_validate_json(metadata_json)
|
||
return TimeDelta(seconds=metadata.streams[-1].duration)
|
||
|
||
@staticmethod
|
||
async def ffprobe_video_format_async(media_path: str) -> VideoStream:
|
||
ffprobe = AsyncFFmpeg(executable="ffprobe").input(
|
||
media_path, print_format="json", show_streams=None
|
||
)
|
||
video_metadata = VideoMetadata.model_validate_json(await ffprobe.execute())
|
||
return video_metadata.streams[0]
|
||
|
||
@staticmethod
|
||
def ffprobe_video_size(media_path: str) -> Tuple[int, int]:
|
||
"""
|
||
:param media_path: local path to video
|
||
:return: video_width, video_height
|
||
"""
|
||
ffprobe = FFmpeg(executable="ffprobe").input(
|
||
media_path, print_format="json", show_streams=None
|
||
)
|
||
video_metadata = VideoMetadata.model_validate_json(ffprobe.execute())
|
||
return video_metadata.streams[0].width, video_metadata.streams[0].height
|
||
|
||
@staticmethod
|
||
async def ffprobe_video_size_async(media_path: str) -> Tuple[int, int]:
|
||
ffprobe = AsyncFFmpeg(executable="ffprobe").input(
|
||
media_path, print_format="json", show_streams=None
|
||
)
|
||
video_metadata = VideoMetadata.model_validate_json(await ffprobe.execute())
|
||
return video_metadata.streams[0].width, video_metadata.streams[0].height
|
||
|
||
@staticmethod
|
||
def noise_reduce(audio_path: str, noise_sample_path: Optional[str] = None,
|
||
output_path: Optional[str] = None) -> str:
|
||
samplerate = 44100
|
||
with AudioFile(audio_path).resampled_to(float(samplerate)) as f:
|
||
audio = f.read(f.frames)
|
||
|
||
if noise_sample_path:
|
||
with AudioFile(noise_sample_path).resampled_to(float(samplerate)) as f:
|
||
noise_sample = f.read(f.frames)
|
||
else:
|
||
# 获取前2秒作为噪声样本
|
||
noise_sample_length = min(int(2 * samplerate), audio.shape[0])
|
||
noise_sample = audio[:noise_sample_length]
|
||
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(audio_path, "nr")
|
||
|
||
reduced_noise = nr.reduce_noise(y=audio, y_noise=noise_sample, sr=samplerate,
|
||
stationary=True, prop_decrease=0.75, n_std_thresh_stationary=1.5,
|
||
n_fft=2048, win_length=1024, hop_length=512, n_jobs=1)
|
||
|
||
board = Pedalboard(
|
||
[
|
||
HighpassFilter(cutoff_frequency_hz=150),
|
||
LowpassFilter(cutoff_frequency_hz=8000),
|
||
Reverb(room_size=0.08, damping=0.7, wet_level=0.08,
|
||
dry_level=0.92, width=0.4),
|
||
Chorus(rate_hz=0.7, depth=0.12, centre_delay_ms=3.0, mix=0.10),
|
||
Distortion(drive_db=3.0),
|
||
Compressor(threshold_db=-30, ratio=1.8, attack_ms=20, release_ms=200),
|
||
Compressor(threshold_db=-24, ratio=2.2, attack_ms=15, release_ms=180),
|
||
Compressor(threshold_db=-18, ratio=1.5, attack_ms=10, release_ms=150),
|
||
Gain(gain_db=4),
|
||
Limiter(threshold_db=-6, release_ms=200),
|
||
]
|
||
)
|
||
# Convert to float32 if not already
|
||
reduced_noise = reduced_noise.astype(np.float32)
|
||
# Ensure audio is in the correct range (-1.0 to 1.0)
|
||
if np.abs(reduced_noise).max() > 1.0:
|
||
reduced_noise = reduced_noise / np.abs(reduced_noise).max()
|
||
|
||
processed_audio = board(reduced_noise, samplerate)
|
||
# 格式处理
|
||
if len(processed_audio.shape) == 1:
|
||
processed_audio = processed_audio.reshape(-1, 1)
|
||
elif len(processed_audio.shape) == 2:
|
||
if processed_audio.shape[0] < processed_audio.shape[1]:
|
||
processed_audio = processed_audio.T
|
||
if processed_audio.shape[1] > 2:
|
||
processed_audio = processed_audio[:, :2]
|
||
# 响度标准化
|
||
meter = pyln.Meter(samplerate)
|
||
min_samples = int(0.4 * samplerate)
|
||
|
||
if processed_audio.shape[0] < min_samples:
|
||
normalized_audio = processed_audio
|
||
else:
|
||
loudness = meter.integrated_loudness(processed_audio)
|
||
safety_factor = 0.7
|
||
processed_audio = processed_audio * safety_factor
|
||
normalized_audio = pyln.normalize.loudness(
|
||
processed_audio, loudness, -16.0
|
||
)
|
||
|
||
max_peak = np.max(np.abs(normalized_audio))
|
||
if max_peak > 0.85:
|
||
additional_safety_factor = 0.85 / max_peak
|
||
normalized_audio = normalized_audio * additional_safety_factor
|
||
|
||
sf.write(
|
||
output_path,
|
||
normalized_audio,
|
||
samplerate,
|
||
format="WAV",
|
||
subtype="PCM_16",
|
||
)
|
||
return output_path
|
||
|
||
@staticmethod
|
||
def async_ffmpeg_init(use_ffprobe: bool = False) -> AsyncFFmpeg:
|
||
if use_ffprobe:
|
||
ffmpeg_cmd = AsyncFFmpeg('ffprobe')
|
||
else:
|
||
ffmpeg_cmd = AsyncFFmpeg().option('y').option('hide_banner')
|
||
|
||
@ffmpeg_cmd.on("start")
|
||
def on_start(arguments: list[str]):
|
||
try:
|
||
filter_index = arguments.index("-filter_complex")
|
||
filter_content = arguments[filter_index + 1]
|
||
arguments[filter_index + 1] = f'"{filter_content}"'
|
||
args = " ".join(arguments)
|
||
logger.info(f"FFmpeg command:{args}")
|
||
arguments[filter_index + 1] = filter_content
|
||
except ValueError:
|
||
args = " ".join(arguments)
|
||
logger.info(f"FFmpeg command:{args}")
|
||
|
||
@ffmpeg_cmd.on("progress")
|
||
def on_progress(progress):
|
||
logger.info(f"处理进度: {progress}")
|
||
|
||
@ffmpeg_cmd.on("completed")
|
||
def on_completed():
|
||
logger.info(f"FFMpeg task completed.")
|
||
|
||
@ffmpeg_cmd.on("stderr")
|
||
def on_stderr(line: str):
|
||
if line.startswith('Error'):
|
||
logger.error(line)
|
||
raise RuntimeError(line)
|
||
else:
|
||
logger.warning(line)
|
||
|
||
return ffmpeg_cmd
|
||
|
||
@staticmethod
|
||
def ffmpeg_init(use_ffprobe: bool = False) -> FFmpeg:
|
||
if use_ffprobe:
|
||
ffmpeg_cmd = FFmpeg('ffprobe')
|
||
else:
|
||
ffmpeg_cmd = FFmpeg().option('y').option('hide_banner')
|
||
|
||
@ffmpeg_cmd.on("start")
|
||
def on_start(arguments: list[str]):
|
||
try:
|
||
filter_index = arguments.index("-filter_complex")
|
||
filter_content = arguments[filter_index + 1]
|
||
arguments[filter_index + 1] = f'"{filter_content}"'
|
||
args = " ".join(arguments)
|
||
logger.info(f"FFmpeg command:{args}")
|
||
arguments[filter_index + 1] = filter_content
|
||
except ValueError:
|
||
args = " ".join(arguments)
|
||
logger.info(f"FFmpeg command:{args}")
|
||
|
||
@ffmpeg_cmd.on("progress")
|
||
def on_progress(progress):
|
||
logger.info(f"处理进度: {progress}")
|
||
|
||
@ffmpeg_cmd.on("completed")
|
||
def on_completed():
|
||
logger.info(f"FFMpeg task completed.")
|
||
|
||
@ffmpeg_cmd.on("stderr")
|
||
def on_stderr(line: str):
|
||
if line.startswith('Error'):
|
||
logger.error(line)
|
||
raise RuntimeError(line)
|
||
else:
|
||
logger.warning(line)
|
||
|
||
return ffmpeg_cmd
|
||
|
||
@staticmethod
|
||
async def ffmpeg_slice_media(media_path: str, media_markers: List[FFMpegSliceSegment],
|
||
output_path: Optional[str] = None) -> List[Tuple[str, VideoMetadata]]:
|
||
"""
|
||
使用本地视频文件按时间段切割出分段视频
|
||
:param media_path: 本地视频路径
|
||
:param media_markers: 分段起始结束时间标记
|
||
:param output_path: 最终输出文件路径, 片段会根据指定路径附加_1.mp4, _2.mp4等片段编号
|
||
:return: 输出片段的本地路径
|
||
"""
|
||
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(media_path)
|
||
filter_complex: List[str] = []
|
||
temp_outputs: List[str] = []
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(media_path, "slice")
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
video_metadata = VideoUtils.ffprobe_video_format(media_path=media_path)
|
||
|
||
for index, marker in enumerate(media_markers):
|
||
filter_complex.extend(
|
||
[
|
||
f"[v:0]trim=start={marker.start.total_seconds()}:end={marker.end.total_seconds()},setpts=PTS-STARTPTS[cut{index}]",
|
||
f"[a:0]atrim=start={marker.start.total_seconds()}:end={marker.end.total_seconds()},asetpts=PTS-STARTPTS[acut{index}]",
|
||
]
|
||
)
|
||
ffmpeg_cmd.option('filter_complex', ';'.join(filter_complex))
|
||
|
||
for i, marker in enumerate(media_markers):
|
||
if marker.start.total_seconds() > video_metadata.duration or marker.start.total_seconds() < 0:
|
||
raise ValueError(
|
||
f"第{i}个切割点起始点{marker.start.total_seconds()}s超出视频时长[0-{video_metadata.duration}s]范围")
|
||
if marker.end.total_seconds() > video_metadata.duration or marker.end.total_seconds() < 0:
|
||
raise ValueError(
|
||
f"第{i}个切割点结束点{marker.end.total_seconds()}s超出视频时长[0-{video_metadata.duration}s]范围")
|
||
segment_output_path = FileUtils.file_path_extend(output_path, str(i))
|
||
ffmpeg_cmd.output(segment_output_path,
|
||
map=[f"[cut{i}]", f"[acut{i}]"],
|
||
reset_timestamps="1",
|
||
sc_threshold="0",
|
||
g="1",
|
||
force_key_frames="expr:gte(t,n_forced*1)",
|
||
vcodec="libx264",
|
||
acodec="aac",
|
||
crf=16,
|
||
r=30,
|
||
)
|
||
temp_outputs.append(segment_output_path)
|
||
|
||
await ffmpeg_cmd.execute()
|
||
outputs: List[Tuple[str, VideoMetadata]] = [(output, VideoUtils.ffprobe_media_metadata(output)) for output in
|
||
temp_outputs]
|
||
return outputs
|
||
|
||
@staticmethod
|
||
async def ffmpeg_slice_stream_media(media_path: str,
|
||
media_markers: List[FFMpegSliceSegment],
|
||
output_path: Optional[str] = None) -> List[Tuple[str, VideoMetadata]]:
|
||
"""
|
||
按时间分段切割HLS视频流
|
||
:param media_path: hls manifest URL
|
||
:param media_markers: 分段起始结束时间标记
|
||
:param output_path: 最终输出文件路径, 片段会根据指定路径附加_1.mp4, _2.mp4等片段编号
|
||
:return: 输出片段的本地路径, 输出片段时长
|
||
"""
|
||
import m3u8
|
||
playlist = m3u8.load(media_path)
|
||
stream_total_duration: float = sum(segment.duration for segment in playlist.segments)
|
||
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(media_path,
|
||
protocol_whitelist="file,http,https,tcp,tls",
|
||
reconnect="1", # 自动重连
|
||
reconnect_streamed="1",
|
||
reconnect_delay_max="5")
|
||
filter_complex: List[str] = []
|
||
|
||
temp_outputs: List[str] = []
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(media_path, "slice")
|
||
if not output_path.endswith(".mp4"):
|
||
output_path = output_path + ".mp4"
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
|
||
for index, marker in enumerate(media_markers):
|
||
if marker.start.total_seconds() > stream_total_duration or marker.start.total_seconds() < 0:
|
||
raise ValueError(
|
||
f"第{index}个切割点起始点{marker.start.total_seconds()}s超出视频时长[0-{stream_total_duration}s]范围")
|
||
if marker.end.total_seconds() > stream_total_duration or marker.end.total_seconds() < 0:
|
||
raise ValueError(
|
||
f"第{index}个切割点结束点{marker.end.total_seconds()}s超出视频时长[0-{stream_total_duration}s]范围")
|
||
|
||
filter_complex.extend(
|
||
[
|
||
f"[v:0]trim=start={marker.start.total_seconds()}:end={marker.end.total_seconds()},setpts=PTS-STARTPTS[cut{index}]",
|
||
f"[a:0]atrim=start={marker.start.total_seconds()}:end={marker.end.total_seconds()},asetpts=PTS-STARTPTS[acut{index}]",
|
||
]
|
||
)
|
||
ffmpeg_cmd.option('filter_complex', ';'.join(filter_complex))
|
||
for i, marker in enumerate(media_markers):
|
||
output_filepath = FileUtils.file_path_extend(output_path, str(i))
|
||
ffmpeg_cmd.output(output_filepath,
|
||
map=[f"[cut{i}]", f"[acut{i}]"],
|
||
reset_timestamps="1",
|
||
sc_threshold="0",
|
||
g="1",
|
||
force_key_frames="expr:gte(t,n_forced*1)",
|
||
vcodec="libx264",
|
||
acodec="aac",
|
||
crf=16,
|
||
r=30, )
|
||
temp_outputs.append(output_filepath)
|
||
await ffmpeg_cmd.execute()
|
||
outputs: List[Tuple[str, VideoMetadata]] = [(output, VideoUtils.ffprobe_media_metadata(output)) for output in
|
||
temp_outputs]
|
||
return outputs
|
||
|
||
@staticmethod
|
||
async def ffmpeg_convert_stream_media(media_stream_url: str,
|
||
output_path: Optional[str] = None) -> Tuple[str, VideoMetadata]:
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(media_stream_url, "convert")
|
||
if not output_path.endswith(".mp4"):
|
||
output_path = output_path + ".mp4"
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(media_stream_url,
|
||
protocol_whitelist="file,http,https,tcp,tls",
|
||
reconnect="1", # 自动重连
|
||
reconnect_streamed="1",
|
||
reconnect_delay_max="5")
|
||
ffmpeg_cmd.output(output_path,
|
||
reset_timestamps="1",
|
||
sc_threshold="0",
|
||
g="1",
|
||
force_key_frames="expr:gte(t,n_forced*1)",
|
||
vcodec="libx264",
|
||
acodec="aac",
|
||
crf=16,
|
||
r=30, )
|
||
await ffmpeg_cmd.execute()
|
||
output: Tuple[str, VideoMetadata] = (output_path, VideoUtils.ffprobe_media_metadata(output_path))
|
||
return output
|
||
|
||
@staticmethod
|
||
async def ffmpeg_concat_medias(media_paths: List[str],
|
||
target_width: int = 1080,
|
||
target_height: int = 1920,
|
||
output_path: Optional[str] = None) -> Tuple[str, VideoMetadata]:
|
||
"""
|
||
将待处理的视频合并为一个视频
|
||
:param media_paths: 待合并的多个视频文件路径
|
||
:param target_width: 输出的视频分辨率宽
|
||
:param target_height: 输出的视频分辨率高
|
||
:param output_path: 指定输出视频路径
|
||
:return: 最终合并结果路径,最终合并结果时长
|
||
"""
|
||
|
||
total_videos = len(media_paths)
|
||
if total_videos == 0:
|
||
raise ValueError("没有可以合并的视频源")
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(media_paths[0], "concat")
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
filter_complex = []
|
||
for input_path in media_paths:
|
||
ffmpeg_cmd.input(input_path)
|
||
# 2. 统一所有视频的格式、分辨率和帧率
|
||
for i in range(total_videos):
|
||
filter_complex.extend(
|
||
[
|
||
# 先缩放到统一分辨率,然后设置帧率和格式
|
||
f"[{i}:v]scale={target_width}:{target_height}:force_original_aspect_ratio=decrease,"
|
||
f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2,"
|
||
f"setsar=1:1," # 新增强制设置SAR
|
||
f"fps=30,format=yuv420p[v{i}]",
|
||
f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a{i}]",
|
||
]
|
||
)
|
||
# 3. 准备处理后的视频流和音频流的连接字符串
|
||
video_streams = "".join(f"[v{i}]" for i in range(total_videos))
|
||
audio_streams = "".join(f"[a{i}]" for i in range(total_videos))
|
||
|
||
# 4. 使用concat过滤器合并视频和音频
|
||
filter_complex.extend(
|
||
[
|
||
f"{video_streams}concat=n={total_videos}:v=1:a=0[vconcated]",
|
||
f"{audio_streams}concat=n={total_videos}:v=0:a=1[aconcated]",
|
||
]
|
||
)
|
||
|
||
ffmpeg_cmd.output(
|
||
output_path,
|
||
{
|
||
"filter_complex": ";".join(filter_complex),
|
||
"map": ["[vconcated]", "[aconcated]"],
|
||
"vcodec": "libx264",
|
||
"crf": 16,
|
||
"r": 30,
|
||
"acodec": "aac",
|
||
"ar": 44100,
|
||
"ac": 2,
|
||
"ab": "192k",
|
||
},
|
||
)
|
||
await ffmpeg_cmd.execute()
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, video_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_extract_audio_async(media_path: str, output_path: Optional[str] = None) -> Tuple[
|
||
str, VideoMetadata]:
|
||
"""
|
||
提取源视频的音频
|
||
:param media_path: 待处理的源视频
|
||
:param output_path: 指定输出的音频文件路径(可选)
|
||
:return: 最终输出音频文件路径,音频文件时长
|
||
"""
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_change_extension(output_path, 'wav')
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
ffprobe_cmd = VideoUtils.async_ffmpeg_init(use_ffprobe=True)
|
||
ffprobe_cmd.input(media_path,
|
||
v="quiet",
|
||
print_format="json",
|
||
select_streams="a",
|
||
show_entries="stream=codec_type")
|
||
audio_check_bytes = await ffprobe_cmd.execute()
|
||
audio_check = json.loads(audio_check_bytes)
|
||
logger.info(audio_check)
|
||
if len(audio_check['streams']) == 0:
|
||
raise RuntimeError(f"Media has no audio streams.")
|
||
# output_path = f"{output_path_prefix}/extract_audio/outputs/{fn_id}/output.wav"
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(media_path).output(output_path,
|
||
map="0:a",
|
||
acodec="pcm_s16le",
|
||
ar=44100,
|
||
ac=1)
|
||
await ffmpeg_cmd.execute()
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, video_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_mix_bgm(origin_audio_path: str, bgm_audio_path: str, video_volume: float = 1.4,
|
||
music_volume: float = 0.1, output_path: Optional[str] = None) -> Tuple[str, VideoMetadata]:
|
||
"""
|
||
给待处理视频混合BGM
|
||
:param origin_audio_path: 待处理的源视频
|
||
:param bgm_audio_path: 需要混合的BGM
|
||
:param video_volume: 最终输出视频的音量系数
|
||
:param music_volume: BGM在源视频音量内占比的音量系数
|
||
:param output_path: 指定最终输出的视频路径(可选)
|
||
:return: 最终输出视频文件路径,最终输出视频时长
|
||
"""
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(origin_audio_path, "bgm")
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
origin_audio_duration = VideoUtils.ffprobe_audio_duration(origin_audio_path)
|
||
bgm_duration = VideoUtils.ffprobe_audio_duration(bgm_audio_path)
|
||
loops_needed = math.ceil(origin_audio_duration.total_seconds() / bgm_duration.total_seconds())
|
||
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init(use_ffprobe=True)
|
||
ffmpeg_cmd.input(origin_audio_path)
|
||
ffmpeg_cmd.input(bgm_audio_path)
|
||
filter_complex = [
|
||
f"[0:a]volume={video_volume}[a1]",
|
||
f"[1:a]aloop=loop={loops_needed}:size={bgm_duration.total_seconds()},volume={music_volume}[a2]"
|
||
"[a1][a2]amix=inputs=2:duration=first[audio]"
|
||
]
|
||
ffmpeg_cmd.output(output_path,
|
||
options={"filter_complex": ";".join(filter_complex), },
|
||
map="[audio]",
|
||
acodec='libmp3lame', # 音频编码器
|
||
ar=48000, # 音频采样率
|
||
ab='192k', # 音频码率
|
||
ac=2, # 音频通道数
|
||
)
|
||
await ffmpeg_cmd.execute()
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, video_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_mix_bgm_with_noise_reduce(media_path: str,
|
||
bgm_audio_path: str,
|
||
video_volume: float = 1.4,
|
||
music_volume: float = 0.1,
|
||
noise_sample_path: Optional[str] = None,
|
||
temp_audio_path: Optional[str] = None,
|
||
output_path: Optional[str] = None) -> Tuple[str, VideoMetadata]:
|
||
"""
|
||
先对待处理的视频音轨降噪,再将降噪后的结果添加BGM,最终输出降噪过且混合BGM的视频;
|
||
由于最终视频画面和音轨是同步混合+合成视频,所以处理速度会比分步降噪, 加BGM快;
|
||
:param media_path: 待处理的原始视频路径
|
||
:param bgm_audio_path: 待处理的BGM音频路径
|
||
:param video_volume: 最终输出的视频音量系数
|
||
:param music_volume: 最终输出的BGM音量系数
|
||
:param noise_sample_path: 降噪使用的噪音样本,如不指定将使用源视频的前2秒作为样本(可选)
|
||
:param temp_audio_path: 指定暂存音频的路径(可选)
|
||
:param output_path: 指定输出视频的路径(可选)
|
||
:return: 最终输出视频的路径, 最终输出视频时长
|
||
"""
|
||
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(media_path, "bgm_nr")
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
if not temp_audio_path:
|
||
temp_audio_path = FileUtils.file_path_extend(media_path, "temp")
|
||
temp_audio_path = FileUtils.file_path_change_extension(temp_audio_path, "wav")
|
||
media_audio, metadata = await VideoUtils.ffmpeg_extract_audio_async(media_path=media_path,
|
||
output_path=temp_audio_path)
|
||
logger.info(f"media_audio = {media_audio}, metadata = {metadata}")
|
||
nr_audio_path = VideoUtils.noise_reduce(audio_path=media_audio, noise_sample_path=noise_sample_path)
|
||
logger.info(f"nr_audio_path = {nr_audio_path}")
|
||
video_metadata = VideoUtils.ffprobe_video_format(media_path)
|
||
origin_audio_duration = VideoUtils.ffprobe_audio_duration(nr_audio_path)
|
||
bgm_duration = VideoUtils.ffprobe_audio_duration(bgm_audio_path)
|
||
loops_needed = math.ceil(origin_audio_duration.total_seconds() / bgm_duration.total_seconds())
|
||
logger.info(
|
||
f"{bgm_duration.total_seconds()}s的BGM循环{loops_needed}次, 填充{origin_audio_duration.total_seconds()}s的视频长度")
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(media_path)
|
||
ffmpeg_cmd.input(nr_audio_path)
|
||
ffmpeg_cmd.input(bgm_audio_path)
|
||
filter_complex = [
|
||
f"[1:a]volume={video_volume}[a1]",
|
||
f"[2:a]aloop=loop={loops_needed}:size={bgm_duration.total_seconds()},volume={music_volume}[a2]",
|
||
"[a1][a2]amix=inputs=2:duration=first[audio]"
|
||
]
|
||
ffmpeg_cmd.output(output_path,
|
||
options={"filter_complex": ";".join(filter_complex), },
|
||
map=["0:v", "[audio]"],
|
||
crf=16,
|
||
vcodec='libx264',
|
||
b=video_metadata.video_bitrate, # 视频码率
|
||
r=video_metadata.video_frame_rate, # 帧率
|
||
acodec='libmp3lame',
|
||
ar=48000, ab='192k', ac=2,
|
||
)
|
||
await ffmpeg_cmd.execute()
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, video_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_overlay_gif(media_path: str, overlay_gif_path: str, output_path: Optional[str] = None) -> Tuple[
|
||
str, VideoMetadata]:
|
||
"""
|
||
将GIF特效叠加到视频上,如果视频较长则循环播放GIF
|
||
:param media_path: 输入视频路径
|
||
:param overlay_gif_path: GIF特效文件路径
|
||
:param output_path: 指定输出路径
|
||
:return: 输出视频路径, 最终输出视频时长
|
||
"""
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(media_path, "overlay")
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
video_metadata = VideoUtils.ffprobe_video_format(media_path)
|
||
filter_complex = [
|
||
# 确保GIF正确解码并循环
|
||
"[1:v]fps=30,format=rgba[gif]", # 强制设置30fps
|
||
# 叠加GIF到视频上,保持透明通道
|
||
"[0:v][gif]overlay=shortest=1:format=auto,setpts=PTS-STARTPTS[v]",
|
||
]
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(media_path)
|
||
ffmpeg_cmd.input(overlay_gif_path, stream_loop=-1) # 使用stream_loop让GIF循环直到视频结束
|
||
ffmpeg_cmd.output(output_path,
|
||
options={"filter_complex": ";".join(filter_complex), },
|
||
map=["[v]", "0:a"],
|
||
crf=16,
|
||
vcodec='libx264',
|
||
b=video_metadata.video_bitrate, # 视频码率
|
||
r=video_metadata.video_frame_rate, # 帧率
|
||
)
|
||
await ffmpeg_cmd.execute()
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, video_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_zoom_loop(media_path: str, duration: float = 6.0, zoom: float = 0.1,
|
||
output_path: Optional[str] = None) -> Tuple[str, VideoMetadata]:
|
||
"""
|
||
视频放大缩小循环特效
|
||
:param media_path: 待处理的视频文件路径
|
||
:param duration: 视频特效循环时间长度
|
||
:param zoom: 视频特效放大缩小系数
|
||
:param output_path: 指定输出视频地址(可选)
|
||
:return: 最终输出视频地址, 最终输出视频时长
|
||
"""
|
||
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(media_path, 'zoomed')
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
video_metadata = VideoUtils.ffprobe_video_format(media_path)
|
||
# abs(sin())表达式会导致实际的往复频率为2倍
|
||
duration = duration * 2
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(media_path).output(output_path,
|
||
options={
|
||
"vf": f"scale={4 * video_metadata.width}x{4 * video_metadata.height},fps=30,"
|
||
f"zoompan=z='1+{zoom}*abs(sin(2*PI*time/{duration}))':"
|
||
"x='trunc(iw/2*(1-1/zoom))':"
|
||
"y='trunc(ih/2*(1-1/zoom))':"
|
||
f"d=1:s={video_metadata.width}x{video_metadata.height}:fps=30"
|
||
},
|
||
vcodec="libx264",
|
||
acodec="copy",
|
||
crf=16,
|
||
b=video_metadata.video_bitrate, # 视频码率
|
||
r=video_metadata.video_frame_rate, # 帧率
|
||
)
|
||
await ffmpeg_cmd.execute()
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, video_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_corner_mirror(media_path: str, mirror_scale_down_size: int = 6,
|
||
mirror_from_right: bool = True, mirror_position: tuple[float, float] = (40, 40),
|
||
output_path: Optional[str] = None) -> Tuple[str, VideoMetadata]:
|
||
"""
|
||
对源视频添加镜像小窗特效
|
||
:param media_path: 待处理的源视频
|
||
:param mirror_scale_down_size: 源视频画面缩放系数
|
||
:param mirror_from_right: 小窗原点是否使用右下角
|
||
:param mirror_position: 小窗基于原点坐标轴的偏移量
|
||
:param output_path: 指定的输出视频路径(可选)
|
||
:return: 返回最终输出视频的路径, 最终输出视频时长
|
||
"""
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(media_path, 'mir')
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
|
||
mirror_x = (
|
||
f"main_w-overlay_w-{mirror_position[0]}"
|
||
if mirror_from_right
|
||
else str(mirror_position[0])
|
||
)
|
||
filter_complex = [
|
||
"[0:v]split[original][mirror]",
|
||
f"[mirror]hflip,scale=iw/{mirror_scale_down_size}:-1,format=rgba[flipped]",
|
||
"[flipped]split[fm1][fm2]",
|
||
f"[fm2]format=gray,geq=lum='255*(1-pow(min(1,2*sqrt(pow(X/W-0.5,2)+pow(Y/H-0.5,2))),1.5))':a='if(lt(pow(X/W-0.5,2)+pow(Y/H-0.5,2),0.15),(1-pow(2*sqrt(pow(X/W-0.5,2)+pow(Y/H-0.5,2)),1.5))*255,0)'[fm2Blur]",
|
||
"[fm1][fm2Blur]alphamerge[flipped_blured]",
|
||
f"[original][flipped_blured]overlay=x={mirror_x}:y=main_h-overlay_h-{mirror_position[1]}[video]",
|
||
]
|
||
video_metadata = VideoUtils.ffprobe_video_format(media_path)
|
||
ffmpeg_cmd.input(media_path).output(output_path,
|
||
options={"filter_complex": ";".join(filter_complex)},
|
||
map=["[video]", "0:a"],
|
||
vcodec="libx264",
|
||
crf=16,
|
||
b=video_metadata.video_bitrate, # 视频码率
|
||
r=video_metadata.video_frame_rate # 帧率
|
||
)
|
||
await ffmpeg_cmd.execute()
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, video_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_subtitle_apply(media_path: str, subtitle_path: str,
|
||
font_dir: str, output_path: Optional[str] = None) -> Tuple[str, VideoMetadata]:
|
||
"""
|
||
给视频画面叠加字幕,需要确保字幕文件为ass字幕,并且subtitle文件内设置的字体存在与font_dir文件夹内
|
||
:param media_path: 待处理的源视频
|
||
:param subtitle_path: ass字幕文件路径
|
||
:param font_dir: 字体文件目录路径
|
||
:param output_path: 指定输出文件路径(可选)
|
||
:return: 返回最终输出视频路径, 最终输出视频时长
|
||
"""
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(media_path, 'sub')
|
||
|
||
video_metadata = VideoUtils.ffprobe_video_format(media_path)
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(media_path)
|
||
ffmpeg_cmd.output(output_path,
|
||
vf=f"subtitles=filename={subtitle_path}:fontsdir={font_dir}",
|
||
vcodec="libx264",
|
||
crf=16,
|
||
b=video_metadata.video_bitrate, # 视频码率
|
||
r=video_metadata.video_frame_rate, # 帧率
|
||
acodec="copy",
|
||
)
|
||
await ffmpeg_cmd.execute()
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, video_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_fill_longest(video_path: str, audio_path: str, output_path: Optional[str] = None) -> Tuple[
|
||
str, VideoMetadata]:
|
||
"""
|
||
用视频循环对齐音频时长,如果短于音频时长则循环填满音频时长,如短于音频时长则裁剪结尾
|
||
:param video_path: 使用的视频文件路径
|
||
:param audio_path: 匹配的音频文件路径
|
||
:param output_path: 指定输出文件地址
|
||
:return: 最终输出的文件路径, 最终输出视频详细信息
|
||
"""
|
||
video_metadata = VideoUtils.ffprobe_video_format(video_path)
|
||
audio_duration = VideoUtils.ffprobe_audio_duration(audio_path)
|
||
loop_times = 0 if video_metadata.duration > audio_duration.total_seconds() else int(
|
||
math.ceil(audio_duration.total_seconds() / video_metadata.duration))
|
||
logger.info(
|
||
f"视频长度 = {video_metadata.duration}, 音频长度 = {audio_duration.total_seconds()}, 重复 = {loop_times}")
|
||
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(video_path, 'fill')
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(video_path, stream_loop=str(loop_times))
|
||
ffmpeg_cmd.input(audio_path)
|
||
|
||
ffmpeg_cmd.output(output_path,
|
||
map=["0:v", "1:a"],
|
||
vcodec="copy",
|
||
acodec="aac",
|
||
shortest=None,
|
||
)
|
||
await ffmpeg_cmd.execute()
|
||
video_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, video_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_extract_frame_image(video_path: str, frame_index: int, output_path: Optional[str] = None) -> Tuple[
|
||
str, VideoMetadata]:
|
||
"""
|
||
获取视频的第n帧输出为图片, 并返回图片相关的元数据
|
||
"""
|
||
if not output_path:
|
||
output_path = FileUtils.file_path_extend(video_path, 'cover')
|
||
output_path = FileUtils.file_path_change_extension(output_path, 'jpg')
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(video_path)
|
||
ffmpeg_cmd.output(output_path, vframes=frame_index)
|
||
await ffmpeg_cmd.execute()
|
||
image_metadata = VideoUtils.ffprobe_media_metadata(output_path)
|
||
return output_path, image_metadata
|
||
|
||
@staticmethod
|
||
async def ffmpeg_stream_record_as_hls(stream_url: str,
|
||
segments_output_dir: str,
|
||
playlist_output_dir: str,
|
||
playlist_output_method: Optional[str] = None,
|
||
playlist_output_headers: Optional[Dict[str, str]] = None,
|
||
manifest_segment_prefix: Optional[str] = None,
|
||
segment_duration: float = 5.0,
|
||
stream_content_timeout: int = 300,
|
||
output_file_pattern: str = "%10d.ts"):
|
||
os.makedirs(segments_output_dir, exist_ok=True)
|
||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||
ffmpeg_cmd.input(stream_url,
|
||
protocol_whitelist="file,http,https,tcp,tls", # 使用flv
|
||
reconnect="1", # 自动重连
|
||
reconnect_at_eof="1",
|
||
reconnect_streamed="1",
|
||
reconnect_delay_max="5")
|
||
ffmpeg_cmd.output(
|
||
f"{playlist_output_dir}/playlist.m3u8",
|
||
f="hls",
|
||
method=playlist_output_method,
|
||
headers="\\r\\n".join([f"{header_key}:{playlist_output_headers[header_key]}" for header_key in
|
||
playlist_output_headers.keys()]) if playlist_output_headers else None,
|
||
hls_time=segment_duration,
|
||
hls_segment_filename=f"{segments_output_dir}/{output_file_pattern}",
|
||
hls_segment_type="mpegts",
|
||
hls_flags="append_list+independent_segments+program_date_time",
|
||
hls_base_url=manifest_segment_prefix if manifest_segment_prefix else None,
|
||
hls_playlist_type="event",
|
||
hls_list_size=0,
|
||
hls_start_number_source="epoch_us",
|
||
timeout=stream_content_timeout,
|
||
c="copy",
|
||
)
|
||
await ffmpeg_cmd.execute()
|
||
logger.info(f'停止录制')
|