fix
This commit is contained in:
84
python_core/utils/PathUtils.py
Normal file
84
python_core/utils/PathUtils.py
Normal 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
|
||||
|
||||
163
python_core/utils/TimeUtils.py
Normal file
163
python_core/utils/TimeUtils.py
Normal 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())
|
||||
953
python_core/utils/VideoUtils.py
Normal file
953
python_core/utils/VideoUtils.py
Normal 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
|
||||
Reference in New Issue
Block a user