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'停止录制')