This commit is contained in:
root
2025-07-12 14:38:15 +08:00
parent 92db62869a
commit 493e347b03
14 changed files with 3267 additions and 114 deletions

View File

@@ -0,0 +1,84 @@
import os
from pathlib import Path
from typing import List
class FileUtils:
@staticmethod
def file_path_extend(media_path: str, extend: str) -> str:
"""
基于现有文件路径添加后缀, 例如 extend = "def" : 123/abc.txt -> 123/abc_def.txt
:param media_path: 现有文件路径
:param extend: 后缀名
:return: 处理过的新文件路径
"""
media_filename = os.path.basename(media_path)
media_dir = os.path.dirname(media_path) + '/'
filenames = media_filename.split('.')
filenames[0] = f"{filenames[0]}_{extend}"
extend_filename = '.'.join(filenames)
return os.path.join(media_dir, extend_filename)
@staticmethod
def replace_root_by_depth(media_path: str, root: str, depth: int = 1) -> str:
"""
使用根目录名替换路径起始位置, 例如:
prefix="pre",depth=0 : ./abc/def.txt -> pre\\.\\abc\\def.txt
prefix="pre",depth=1 : ./abc/def.txt -> pre\\abc\\def.txt
prefix="pre",depth=2 : ./abc/def.txt -> pre\\def.txt
prefix="pre",depth=3 : IndexError("Depth is out of range")
:param media_path: 现有文件路径
:param root: 替换用的根目录名
:param depth: root所占的路径深度层级
:return: 处理后的文件路径
:exception IndexError: 根目录深度超过实际路径深度
"""
media_dirs = media_path.split('/')
if depth >= len(media_dirs):
raise IndexError("Depth is out of range")
media_dir_prefix = os.path.join(root, *media_dirs[depth:])
return media_dir_prefix
@staticmethod
def file_path_change_extension(media_path: str, extension: str) -> str:
media_filename = os.path.basename(media_path)
media_dir = os.path.dirname(media_path) + "/"
filenames = media_filename.split(".")
filenames[-1] = extension
filename = ".".join(filenames)
return os.path.join(media_dir, filename)
@staticmethod
def get_folder_size(folder_path: str) -> int:
"""
Args:
folder_path:
Returns:
"""
total_size = 0
for path in Path(folder_path).rglob('*'):
if path.is_file():
total_size += path.stat().st_size
return total_size
@staticmethod
def get_file_size(file_path: str) -> int:
return os.path.getsize(file_path)
@staticmethod
def get_files_size(files: List[str]) -> int:
total_size = 0
for file in files:
total_size += os.path.getsize(file)
return total_size

View File

@@ -0,0 +1,163 @@
import re
from datetime import timedelta, datetime
def parse_time(time_str):
"""解析时间字符串为datetime对象"""
parsed_time = None
if re.match(r"^\d{2}:\d{2}:\d{2}\.\d{3}$", time_str):
# 先尝试完整格式 HH:MM:SS.fff
parsed_time = datetime.strptime(time_str, '%H:%M:%S.%f')
elif re.match(r"^\d{2}:\d{2}:\d{2}:\d{3}$", time_str):
# 如果失败,尝试 HH:MM:SS:fff 格式
parsed_time = datetime.strptime(time_str, '%H:%M:%S:%f')
elif re.match(r"^\d{2}:\d{2}\.\d{3}$", time_str):
# 如果失败,尝试 MM:SS.fff 格式
dt = datetime.strptime(time_str, '%M:%S.%f')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}\.\d{2}:\d{3}$", time_str):
# 如果失败,尝试 MM.SS:fff 格式
dt = datetime.strptime(time_str, '%M.%S:%f')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}\.\d{2}\.\d{3}$", time_str):
# 如果失败,尝试 MM.SS.fff 格式
dt = datetime.strptime(time_str, '%M.%S.%f')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}:\d{2}:\d{3}$", time_str):
# 如果失败,尝试 MM:SS:fff 格式
dt = datetime.strptime(time_str, '%M:%S:%f')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}:\d{2}$", time_str):
# 如果失败,尝试 MM:SS:fff 格式
dt = datetime.strptime(time_str, '%M:%S')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}\.\d{2}$", time_str):
# 如果失败,尝试 MM:SS:fff 格式
dt = datetime.strptime(time_str, '%M.%S')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
else:
raise RuntimeError(f"转换时间格式失败 {time_str}")
return parsed_time
def format_time(dt):
"""将datetime对象格式化为时间字符串"""
return dt.strftime('%H:%M:%S.%f')[:-3] # 保留3位小数
def parse_timeline_item(item):
"""解析时间线项目,提取开始和结束时间"""
time_range, _ = item.split(' (', 1)
start_time_str, end_time_str = time_range.split(' - ')
return parse_time(start_time_str), parse_time(end_time_str)
def format_timeline_item(start_time, end_time, activity):
"""格式化时间线项目"""
return f"{format_time(start_time)} - {format_time(end_time)} ({activity})"
def merge_timeline_items(items, merge_diff=5):
"""合并相邻的时间线项目,保留不同的活动描述"""
if not items:
return []
# 解析所有项目
parsed_items = []
for item in items:
start_time, end_time = parse_timeline_item(item)
activity = item.split(' (', 1)[1].rstrip(')')
parsed_items.append((start_time, end_time, activity))
# 按开始时间排序
parsed_items.sort(key=lambda x: x[0])
# 合并相邻的时间段
merged_items = [parsed_items[0]]
for current in parsed_items[1:]:
last = merged_items[-1]
# 合法时间段
if current[1] > current[0]:
# 如果当前项目的开始时间与上一个项目的结束时间相邻或重叠 活动描述相同,直接合并
if current[0] <= last[1] + timedelta(seconds=merge_diff) and last[2] == current[2]:
# 更新结束时间为两个结束时间的最大值
new_end_time = max(last[1], current[1])
merged_items[-1] = (last[0], new_end_time, last[2])
else:
# 不相邻,添加新项目
merged_items.append(current)
# 格式化回原始字符串格式
return [format_timeline_item(start, end, activity) for start, end, activity in merged_items]
def convert_time(time_str):
# 去除秒字段并转换为标准时间
parts = time_str.split(':')
if len(parts) == 3:
new_time_str = f"00:{parts[0]}:{parts[1]}.{parts[2].split('.')[1]}"
return new_time_str
return time_str
def merge_product_data(data, start_time_str, end_time_str, merge_diff=5):
"""合并相同产品的数据"""
start_time = parse_time(start_time_str)
end_time = parse_time(end_time_str)
duration = end_time - start_time
# 手动格式化时间差
total_seconds = duration.total_seconds()
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
microseconds = duration.microseconds
max_time_str = f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}.{microseconds // 1000:03d}"
product_dict = {}
# 按产品名称分组
for item in data:
product = item["product"]
if product not in product_dict:
product_dict[product] = {"product": product, "timeline": []}
product_dict[product]["timeline"].extend(item["timeline"])
# 合并每个产品的时间线
for product in product_dict:
timeline = product_dict[product]["timeline"]
new_timeline = []
for item in timeline:
start, end = parse_timeline_item(item)
# 比较起始时间与时间差
start_str = format_time(start)
if (start - datetime.strptime("00:00:00.000", '%H:%M:%S.%f')) > duration and not start_str.startswith("00"):
new_start_str = convert_time(start_str)
else:
new_start_str = start_str
if (parse_time(new_start_str) - datetime.strptime("00:00:00.000", '%H:%M:%S.%f')) > duration:
new_start_str = max_time_str
end_str = format_time(end)
if (end - datetime.strptime("00:00:00.000", '%H:%M:%S.%f')) > duration and not end_str.startswith("00"):
new_end_str = convert_time(end_str)
else:
new_end_str = end_str
if (parse_time(new_end_str) - datetime.strptime("00:00:00.000", '%H:%M:%S.%f')) > duration:
new_end_str = max_time_str
activity = item.split(' (', 1)[1].rstrip(')')
new_item = f"{new_start_str} - {new_end_str} ({activity})"
new_timeline.append(new_item)
product_dict[product]["timeline"] = merge_timeline_items(new_timeline, merge_diff=merge_diff)
# 返回合并后的列表
return list(product_dict.values())

View File

@@ -0,0 +1,953 @@
import asyncio
import shutil
import tempfile
from datetime import datetime, timedelta
import aiofiles
import aiohttp
from typing import List, Tuple, Optional, Any
import m3u8
import numpy as np
import json, os
import math
from aiohttp import ClientTimeout
from .TimeUtils import TimeDelta
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 ..models.ffmpeg_tasks.models import FFMpegSliceSegment, FFMPEGSliceOptions, VideoStream, VideoMetadata
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, show_format=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, show_format=None
)
metadata_bytes = ffprobe.execute()
metadata_json = json.loads(metadata_bytes)
logger.info(f"metadata = {json.dumps(metadata_json, ensure_ascii=False)}")
video_metadata = VideoMetadata.model_validate_json(metadata_bytes)
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, show_format=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, show_format=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, show_format=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, show_format=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, show_format=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, quiet: 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):
if not quiet:
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') and ".m3u8" not in line:
logger.error(line)
raise RuntimeError(line)
elif "Output file is empty" in line:
raise RuntimeError("输出是空文件")
else:
if not quiet:
if "Skip" not in line:
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],
options: FFMPEGSliceOptions, is_streams: bool = False,
output_path: Optional[str] = None) -> List[Tuple[str, VideoMetadata]]:
"""
使用本地视频文件按时间段切割出分段视频, 如果是直播流则按时间分段切割HLS视频流_预先多线程下载所有ts
:param media_path: 本地视频路径
:param media_markers: 分段起始结束时间标记
:param options: 输出切割质量选项
:param is_streams: 输入是否为直播流
:param output_path: 最终输出文件路径, 片段会根据指定路径附加_1.mp4 _2.mp4等片段编号
:return: 输出片段的本地路径
"""
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
if not is_streams:
ffmpeg_cmd.input(media_path)
seek_head = 0
else:
seek_head = media_markers[0].start.total_seconds()
seek_tail = media_markers[-1].end.total_seconds()
duration = seek_tail - seek_head
logger.info(f"Only using {seek_head}s --> {seek_tail}s = {duration}s")
local_m3u8_path, temp_dir, diff = await VideoUtils.convert_m3u8_to_local_source(media_path, head=seek_head,
tail=seek_tail)
logger.info(f"local_playlist: {local_m3u8_path}")
for segment in media_markers:
segment.start = segment.start - timedelta(seconds=seek_head) + diff
segment.end = segment.end - timedelta(seconds=seek_head) + diff
logger.info(f"Only using {seek_head}s --> {seek_tail}s = {duration}s")
ffmpeg_cmd.input(local_m3u8_path,
t=duration,
protocol_whitelist="file,http,https,tcp,tls")
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)
metadata = VideoUtils.ffprobe_media_metadata(media_path)
for index, marker in enumerate(media_markers):
start = marker.start.total_seconds()
end = marker.end.total_seconds()
# 处理指定的输出分辨率
if options.width and options.height:
filter_complex.extend(
[
f"[v:0]trim=start={start}:end={end},scale={options.width}:{options.height},setpts=PTS-STARTPTS[cut{index}]",
f"[a:0]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[acut{index}]",
]
)
else:
filter_complex.extend(
[
f"[v:0]trim=start={start}:end={end},setpts=PTS-STARTPTS[cut{index}]",
f"[a:0]atrim=start={start}:end={end},asetpts=PTS-STARTPTS[acut{index}]",
]
)
ffmpeg_cmd.option('filter_complex', ';'.join(filter_complex))
diff_tolerance = 0.001
for i, marker in enumerate(media_markers):
if marker.start.total_seconds() > metadata.format.duration or marker.start.total_seconds() < 0:
raise ValueError(
f"{i}个切割点起始点{marker.start.total_seconds()}s超出视频时长[0-{metadata.format.duration}s]范围")
if marker.end.total_seconds() > metadata.format.duration or marker.end.total_seconds() < 0:
if marker.end.total_seconds() > 0 and math.isclose(marker.end.total_seconds(), metadata.format.duration,
rel_tol=diff_tolerance):
marker.end = TimeDelta(seconds=metadata.format.duration)
logger.warning(
f"{i}个切割点结束点{marker.end.total_seconds()}s接近视频时长[0-{metadata.format.duration}s]范围")
else:
raise ValueError(
f"{i}个切割点结束点{marker.end.total_seconds()}s超出视频时长[0-{metadata.format.duration}s]范围")
segment_output_path = FileUtils.file_path_extend(output_path, str(i))
ffmpeg_options = {
"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": options.crf,
"r": options.fps
}
if options.limit_size:
ffmpeg_options["fs"] = options.limit_size
elif options.bit_rate:
ffmpeg_options["b:v"] = options.pretty_bit_rate
ffmpeg_cmd.output(segment_output_path, options=ffmpeg_options)
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 async_download_file(url: str, output_path: Optional[str] = None) -> str | None | Any:
t = 10
while t > 0:
try:
logger.info(f"Downloading {url} to {output_path}")
async with aiohttp.ClientSession(timeout=ClientTimeout(total=60)) as session:
async with session.get(url) as response:
if response.status != 200:
raise Exception(f"Failed to download {url}, status code: {response.status}")
if output_path:
async with aiofiles.open(output_path, 'wb') as f:
await f.write(await response.read())
return output_path
else:
return await response.text()
except:
t -= 1
logger.warning(f"Retrying downloading {url} to {output_path} Remain Times: {t}")
@staticmethod
async def convert_m3u8_to_local_source(media_stream_url: str,
head: float = 0,
tail: float = 86400, # 使用24H时长替代♾
temp_dir: str = None) -> tuple[str, str, TimeDelta]:
"""
转换m3u8为本地来源
"""
# 创建临时目录存储TS片段
if temp_dir:
os.makedirs(temp_dir, exist_ok=True)
else:
temp_dir = tempfile.mkdtemp()
from m3u8 import SegmentList, Segment
try:
# 1. 下载m3u8文件
playlist = m3u8.load(media_stream_url)
# duration = (tail - head) if head else None
origin_time: datetime = playlist.segments[0].current_program_date_time
logger.info(f"Start Timestamp: {origin_time}")
# 2. 解析TS片段URL
ts_urls: SegmentList[Segment] = SegmentList()
duration = 0
min_head = origin_time + timedelta(seconds=head)
max_head = origin_time + timedelta(seconds=tail)
logger.info(f"min: {min_head}, max: {max_head}")
for segment in playlist.segments:
if min_head - timedelta(seconds=segment.duration) <= segment.current_program_date_time <= max_head:
logger.info(f"duration: {segment.duration}, head: {segment.current_program_date_time}")
duration += segment.duration
ts_urls.append(segment)
if len(ts_urls) > 0:
delta = min_head - ts_urls[0].current_program_date_time
diff = TimeDelta.from_timedelta(delta)
else:
diff = TimeDelta(seconds=0)
logger.info(f"diff = {diff.total_seconds()}")
# 3. 并行下载TS片段
tasks = []
playlist.segments = ts_urls
duration_delta = TimeDelta(seconds=duration)
logger.info(f"count : {len(playlist.segments)}, duration = {duration_delta.toFormatStr()}")
playlist.is_endlist = True
for url in ts_urls:
tasks.append(VideoUtils.async_download_file(url.absolute_uri, f"{temp_dir}/{url.uri}"))
await asyncio.gather(*tasks)
# 4. 修改m3u8文件指向本地TS片段
local_m3u8_path = os.path.join(temp_dir, "local.m3u8")
playlist.dump(local_m3u8_path)
return local_m3u8_path, temp_dir, diff
except Exception as e:
logger.exception(e)
raise Exception(f"下载TS转换M3U8失败 {e}")
@staticmethod
def purge_temp_ts_dir(temp_dir: str) -> None:
# 6. 删除临时文件和目录
try:
shutil.rmtree(temp_dir)
except Exception as e:
logger.exception(e)
@staticmethod
async def ffmpeg_convert_stream_media(media_stream_url: str, options: FFMPEGSliceOptions,
output_path: Optional[str] = None) -> tuple[
str, VideoMetadata] | None:
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)
try:
local_m3u8_path, temp_dir, diff = await VideoUtils.convert_m3u8_to_local_source(
media_stream_url=media_stream_url)
# 使用ffmpeg合并TS片段
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
ffmpeg_cmd.input(local_m3u8_path,
protocol_whitelist="file,http,https,tcp,tls")
ffmpeg_options = {
"reset_timestamps": "1",
"sc_threshold": "0",
"g": "1",
"force_key_frames": "expr:gte(t,n_forced*1)",
"vcodec": "libx264",
"b:v": options.pretty_bit_rate,
"acodec": "aac",
"crf": options.crf,
"r": options.fps,
}
ffmpeg_cmd.output(output_path, options=ffmpeg_options)
await ffmpeg_cmd.execute()
VideoUtils.purge_temp_ts_dir(temp_dir)
except Exception as e:
logger.exception(f"合并TS失败 {e}")
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}]",
# 修改音频过滤器确保输出为AAC兼容格式
# f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a{i}]",
f"[{i}:a]aformat=sample_fmts=s16: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: Optional[str], embed_subtitle_path: Optional[str],
font_dir: Optional[str], output_path: Optional[str] = None) -> Tuple[
str, VideoMetadata]:
"""
给视频画面叠加字幕需要确保字幕文件为ass字幕并且subtitle文件内设置的字体存在与font_dir文件夹内
:param media_path: 待处理的源视频
:param subtitle_path: ass渲染字幕文件路径
:param embed_subtitle_path: ass/vtt/srt内嵌字幕文件路径
: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)
if embed_subtitle_path:
ffmpeg_cmd.input(embed_subtitle_path)
ffmpeg_options = {
"vcodec": "libx264",
"acodec": "copy",
"crf": 16,
"b": video_metadata.video_bitrate,
"r": video_metadata.video_frame_rate,
"map": ["0:v", "0:a"]
}
if subtitle_path and font_dir:
ffmpeg_options["vf"] = f"subtitles=filename={subtitle_path}:fontsdir={font_dir}"
if embed_subtitle_path:
ffmpeg_options['map'].append("1")
ffmpeg_options['c:s'] = "mov_text"
ffmpeg_options['metadata:s:s:0'] = "language=chi"
ffmpeg_cmd.output(output_path, options=ffmpeg_options)
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, seek_time: Optional[TimeDelta] = None,
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()
if seek_time:
ffmpeg_cmd.input(video_path, ss=seek_time.total_seconds())
else:
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,
first_segment_duration: float = 2.0,
segment_duration: float = 5.0,
stream_content_timeout: int = 300,
stream_monitor_timeout: int = 36000,
output_file_pattern: str = "%10d.ts"):
os.makedirs(segments_output_dir, exist_ok=True)
ffmpeg_cmd = VideoUtils.async_ffmpeg_init(quiet=True)
# ffmpeg_cmd.option("loglevel", "debug")
ffmpeg_cmd.option("t", stream_monitor_timeout)
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")
output_playlist = f"{playlist_output_dir}/playlist.m3u8"
ffmpeg_cmd.output(
output_playlist,
f="hls",
hls_init_time=first_segment_duration,
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+split_by_time+discont_start",
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'停止录制')
return output_playlist