新增video-loop-fill接口
* FIX : 修复ffmpeg_concat_medias没有添加inputs的bug * FIX: ffmpeg_slice_media和ffmpeg_slice_stream_media新增marker时间范围检查,如切割点不在视频时长范围内会报错 * 新增video-loop-fill接口 --------- Merge request URL: https://g-ldyi2063.coding.net/p/dev/d/modalDeploy/git/merge/4740 Co-authored-by: shuohigh@gmail.com
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "BowongModalFunctions"
|
name = "BowongModalFunctions"
|
||||||
dynamic = ["version"]
|
version = "0.1.4"
|
||||||
authors = [
|
authors = [
|
||||||
{ name = "Yudi Xiao", email = "xyd@bowong.ai" },
|
{ name = "Yudi Xiao", email = "xyd@bowong.ai" },
|
||||||
]
|
]
|
||||||
@@ -31,6 +31,7 @@ dependencies = [
|
|||||||
"modal>=0.76.3",
|
"modal>=0.76.3",
|
||||||
"python-dotenv>=1.1.0",
|
"python-dotenv>=1.1.0",
|
||||||
"python-multipart>=0.0.20",
|
"python-multipart>=0.0.20",
|
||||||
|
"m3u8>=6.0.0",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"Programming Language :: Python :: 3",
|
"Programming Language :: Python :: 3",
|
||||||
|
|||||||
@@ -309,6 +309,15 @@ class FFMPEGMixBgmWithNoiseReduceStatusResponse(BaseFFMPEGTaskStatusResponse):
|
|||||||
result: Optional[str] = Field(default=None, description="生成结果的URN")
|
result: Optional[str] = Field(default=None, description="生成结果的URN")
|
||||||
|
|
||||||
|
|
||||||
|
class FFMPEGVideoLoopFillAudioRequest(BaseFFMPEGTaskRequest):
|
||||||
|
video: MediaSource = Field(description="用来填充的视频素材")
|
||||||
|
audio: MediaSource = Field(description="被填充的音频素材")
|
||||||
|
|
||||||
|
|
||||||
|
class FFMPEGVideoLoopFillAudioResponse(BaseFFMPEGTaskStatusResponse):
|
||||||
|
result: Optional[str] = Field(default=None, description="生成结果的URN")
|
||||||
|
|
||||||
|
|
||||||
class ComfyTaskStatusResponse(BaseModel):
|
class ComfyTaskStatusResponse(BaseModel):
|
||||||
taskId: str = Field(description="任务Id")
|
taskId: str = Field(description="任务Id")
|
||||||
status: TaskStatus = Field(description="任务运行状态")
|
status: TaskStatus = Field(description="任务运行状态")
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from ..models.web_model import (FFMPEGSliceRequest, SentryTransactionHeader,
|
|||||||
FFMPEGZoomLoopRequest,
|
FFMPEGZoomLoopRequest,
|
||||||
FFMPEGSubtitleOverlayRequest,
|
FFMPEGSubtitleOverlayRequest,
|
||||||
FFMPEGMixBgmWithNoiseReduceRequest,
|
FFMPEGMixBgmWithNoiseReduceRequest,
|
||||||
|
FFMPEGVideoLoopFillAudioRequest,
|
||||||
FFMPEGSliceTaskStatusResponse,
|
FFMPEGSliceTaskStatusResponse,
|
||||||
FFMPEGConcatTaskStatusResponse,
|
FFMPEGConcatTaskStatusResponse,
|
||||||
FFMPEGExtractAudioTaskStatusResponse,
|
FFMPEGExtractAudioTaskStatusResponse,
|
||||||
@@ -24,6 +25,7 @@ from ..models.web_model import (FFMPEGSliceRequest, SentryTransactionHeader,
|
|||||||
FFMPEGOverlayGifTaskStatusResponse,
|
FFMPEGOverlayGifTaskStatusResponse,
|
||||||
FFMPEGSubtitleTaskStatusResponse,
|
FFMPEGSubtitleTaskStatusResponse,
|
||||||
FFMPEGZoomLoopTaskStatusResponse,
|
FFMPEGZoomLoopTaskStatusResponse,
|
||||||
|
FFMPEGVideoLoopFillAudioResponse,
|
||||||
FFMPEGMixBgmWithNoiseReduceStatusResponse)
|
FFMPEGMixBgmWithNoiseReduceStatusResponse)
|
||||||
|
|
||||||
config = WorkerConfig()
|
config = WorkerConfig()
|
||||||
@@ -317,3 +319,34 @@ async def bgm_nosie_reduce_status(task_id: str, response: Response) -> FFMPEGMix
|
|||||||
response.headers["x-baggage"] = transaction.x_baggage
|
response.headers["x-baggage"] = transaction.x_baggage
|
||||||
return FFMPEGMixBgmWithNoiseReduceStatusResponse(taskId=task_id, status=task_status, code=code,
|
return FFMPEGMixBgmWithNoiseReduceStatusResponse(taskId=task_id, status=task_status, code=code,
|
||||||
error=reason, result=result)
|
error=reason, result=result)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post('/video-loop-fill', tags=["发起任务"], summary="发起视频匹配音频长度任务",
|
||||||
|
description="发起视频匹配音频长度任务", dependencies=[Depends(verify_token)])
|
||||||
|
async def video_loop_fill_audio(body: FFMPEGVideoLoopFillAudioRequest,
|
||||||
|
headers: Annotated[SentryTransactionHeader, Header()]) -> ModalTaskResponse:
|
||||||
|
fn = modal.Function.from_name(config.modal_app_name, "ffmpeg_loop_fill",
|
||||||
|
environment_name=config.modal_environment)
|
||||||
|
sentry_trace = None
|
||||||
|
if headers.x_trace_id and headers.x_baggage:
|
||||||
|
sentry_trace = SentryTransactionInfo(x_trace_id=headers.x_trace_id, x_baggage=headers.x_baggage)
|
||||||
|
fn_call = fn.spawn(media=body.video, audio=body.audio, sentry_trace=sentry_trace, webhook=body.webhook)
|
||||||
|
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/video-loop-fill/{task_id}", tags=["查询任务"],
|
||||||
|
summary="查询视频匹配音频长度任务状态", description="查询视频匹配音频长度任务状态",
|
||||||
|
responses={
|
||||||
|
status.HTTP_200_OK: {
|
||||||
|
"description": "",
|
||||||
|
"headers": sentry_header_schema
|
||||||
|
},
|
||||||
|
},
|
||||||
|
dependencies=[Depends(verify_token)])
|
||||||
|
async def video_loop_fill_audio_status(task_id: str, response: Response) -> FFMPEGVideoLoopFillAudioResponse:
|
||||||
|
task_status, code, reason, result, transaction = await ModalUtils.get_modal_task_status(task_id)
|
||||||
|
if transaction:
|
||||||
|
response.headers["x-trace-id"] = transaction.x_trace_id
|
||||||
|
response.headers["x-baggage"] = transaction.x_baggage
|
||||||
|
return FFMPEGVideoLoopFillAudioResponse(taskId=task_id, status=task_status, code=code,
|
||||||
|
error=reason, result=result)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ from pedalboard import (
|
|||||||
from pedalboard.io import AudioFile
|
from pedalboard.io import AudioFile
|
||||||
from loguru import logger
|
from loguru import logger
|
||||||
from .PathUtils import FileUtils
|
from .PathUtils import FileUtils
|
||||||
|
from BowongModalFunctions.models.ffmpeg_worker_model import FFMpegSliceSegment
|
||||||
|
|
||||||
|
|
||||||
class MediaStream(BaseModel):
|
class MediaStream(BaseModel):
|
||||||
@@ -69,6 +70,9 @@ class VideoMetadata(BaseModel):
|
|||||||
|
|
||||||
|
|
||||||
class VideoUtils:
|
class VideoUtils:
|
||||||
|
"""
|
||||||
|
python-ffmpeg package docs : https://python-ffmpeg.readthedocs.io/en/stable/
|
||||||
|
"""
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def ffprobe_video_format(media_path: str) -> VideoStream:
|
def ffprobe_video_format(media_path: str) -> VideoStream:
|
||||||
@@ -270,6 +274,120 @@ class VideoUtils:
|
|||||||
|
|
||||||
return ffmpeg_cmd
|
return ffmpeg_cmd
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def ffmpeg_slice_media(media_path: str, media_markers: List[FFMpegSliceSegment],
|
||||||
|
output_path: Optional[str] = None) -> List[str]:
|
||||||
|
"""
|
||||||
|
使用本地视频文件按时间段切割出分段视频
|
||||||
|
: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] = []
|
||||||
|
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))
|
||||||
|
outputs.append(segment_output_path)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
await ffmpeg_cmd.execute()
|
||||||
|
return outputs
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def ffmpeg_slice_stream_media(media_path: str,
|
||||||
|
media_markers: List[FFMpegSliceSegment],
|
||||||
|
output_path: Optional[str] = None) -> List[str]:
|
||||||
|
"""
|
||||||
|
按时间分段切割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] = []
|
||||||
|
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)
|
||||||
|
|
||||||
|
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))
|
||||||
|
outputs.append(output_filepath)
|
||||||
|
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, )
|
||||||
|
|
||||||
|
await ffmpeg_cmd.execute()
|
||||||
|
return outputs
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def ffmpeg_concat_medias(media_paths: List[str],
|
async def ffmpeg_concat_medias(media_paths: List[str],
|
||||||
target_width: int = 1080,
|
target_width: int = 1080,
|
||||||
@@ -292,7 +410,8 @@ class VideoUtils:
|
|||||||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||||||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
||||||
filter_complex = []
|
filter_complex = []
|
||||||
|
for input_path in media_paths:
|
||||||
|
ffmpeg_cmd.input(input_path)
|
||||||
# 2. 统一所有视频的格式、分辨率和帧率
|
# 2. 统一所有视频的格式、分辨率和帧率
|
||||||
for i in range(total_videos):
|
for i in range(total_videos):
|
||||||
filter_complex.extend(
|
filter_complex.extend(
|
||||||
@@ -335,7 +454,7 @@ class VideoUtils:
|
|||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def ffmpeg_extract_audio_async(media_path: str, output_path: Optional[str] = None):
|
async def ffmpeg_extract_audio_async(media_path: str, output_path: Optional[str] = None) -> str:
|
||||||
"""
|
"""
|
||||||
提取源视频的音频
|
提取源视频的音频
|
||||||
:param media_path: 待处理的源视频
|
:param media_path: 待处理的源视频
|
||||||
@@ -365,6 +484,7 @@ class VideoUtils:
|
|||||||
ar=44100,
|
ar=44100,
|
||||||
ac=1)
|
ac=1)
|
||||||
await ffmpeg_cmd.execute()
|
await ffmpeg_cmd.execute()
|
||||||
|
return output_path
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def ffmpeg_mix_bgm(origin_audio_path: str, bgm_audio_path: str, video_volume: float = 1.4,
|
async def ffmpeg_mix_bgm(origin_audio_path: str, bgm_audio_path: str, video_volume: float = 1.4,
|
||||||
@@ -594,3 +714,36 @@ class VideoUtils:
|
|||||||
)
|
)
|
||||||
await ffmpeg_cmd.execute()
|
await ffmpeg_cmd.execute()
|
||||||
return output_path
|
return output_path
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def ffmpeg_fill_longest(video_path: str, audio_path: str, output_path: Optional[str] = None) -> str:
|
||||||
|
"""
|
||||||
|
用视频循环对齐音频时长,如果短于音频时长则循环填满音频时长,如短于音频时长则裁剪结尾
|
||||||
|
: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()
|
||||||
|
return output_path
|
||||||
|
|||||||
@@ -162,99 +162,37 @@ with ffmpeg_worker_image.imports():
|
|||||||
async def ffmpeg_slice_process(media_source: MediaSource, media_markers: List[FFMpegSliceSegment],
|
async def ffmpeg_slice_process(media_source: MediaSource, media_markers: List[FFMpegSliceSegment],
|
||||||
fn_id: str) -> List[str]:
|
fn_id: str) -> List[str]:
|
||||||
cache_filepath = f"{s3_mount}/{media_source.cache_filepath}"
|
cache_filepath = f"{s3_mount}/{media_source.cache_filepath}"
|
||||||
logger.info(cache_filepath)
|
logger.info(f"从{media_source.urn}切割")
|
||||||
outputs: List[str] = []
|
for marker in media_markers:
|
||||||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
logger.info(f"{marker.start.toFormatStr()} --> {marker.end.toFormatStr()}")
|
||||||
ffmpeg_cmd.input(cache_filepath)
|
segments = await VideoUtils.ffmpeg_slice_media(media_path=cache_filepath,
|
||||||
filter_complex: List[str] = []
|
media_markers=media_markers,
|
||||||
for index, marker in enumerate(media_markers):
|
output_path=f"{output_path_prefix}/{config.modal_environment}/slice/outputs/{fn_id}/output.mp4")
|
||||||
filter_complex.extend(
|
s3_outputs = local_copy_to_s3(segments)
|
||||||
[
|
|
||||||
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):
|
|
||||||
filename = FileUtils.file_path_extend(os.path.basename(cache_filepath), str(i))
|
|
||||||
output_filepath = f"{output_path_prefix}/slice/outputs/{fn_id}/{filename}"
|
|
||||||
output_filepath = output_filepath.replace('//', '/')
|
|
||||||
workdir = os.path.dirname(output_filepath)
|
|
||||||
os.makedirs(workdir, exist_ok=True)
|
|
||||||
outputs.append(output_filepath)
|
|
||||||
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, )
|
|
||||||
|
|
||||||
await ffmpeg_cmd.execute()
|
|
||||||
|
|
||||||
s3_outputs = local_copy_to_s3(outputs)
|
|
||||||
|
|
||||||
return s3_outputs
|
return s3_outputs
|
||||||
|
|
||||||
@SentryUtils.sentry_tracker(name="视频切割任务", op="ffmpeg.slice", fn_id=fn_id,
|
@SentryUtils.sentry_tracker(name="视频切割任务", op="ffmpeg.slice", fn_id=fn_id,
|
||||||
sentry_trace_id=sentry_trace.x_trace_id if sentry_trace else None,
|
sentry_trace_id=sentry_trace.x_trace_id if sentry_trace else None,
|
||||||
sentry_baggage=sentry_trace.x_baggage if sentry_trace else None)
|
sentry_baggage=sentry_trace.x_baggage if sentry_trace else None)
|
||||||
async def ffmpeg_hls_slice_process(media_source: MediaSource, media_markers: List[FFMpegSliceSegment],
|
async def ffmpeg_hls_slice_process(media_source: MediaSource,
|
||||||
|
media_markers: List[FFMpegSliceSegment],
|
||||||
fn_id: str) -> List[str]:
|
fn_id: str) -> List[str]:
|
||||||
hls_m3u8_url = media_source.path
|
hls_m3u8_url = media_source.path
|
||||||
logger.info(hls_m3u8_url)
|
segments = await VideoUtils.ffmpeg_slice_stream_media(media_path=hls_m3u8_url,
|
||||||
outputs: List[str] = []
|
media_markers=media_markers,
|
||||||
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
|
output_path=f"{output_path_prefix}/{config.modal_environment}/slice/outputs/{fn_id}/output.mp4")
|
||||||
ffmpeg_cmd.input(hls_m3u8_url,
|
s3_outputs = local_copy_to_s3(segments)
|
||||||
protocol_whitelist="file,http,https,tcp,tls",
|
|
||||||
reconnect="1", # 自动重连
|
|
||||||
reconnect_streamed="1",
|
|
||||||
reconnect_delay_max="5")
|
|
||||||
filter_complex: List[str] = []
|
|
||||||
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):
|
|
||||||
paths = hls_m3u8_url.split(".")[:-1]
|
|
||||||
paths.append('mp4')
|
|
||||||
output_filepath = '.'.join(paths)
|
|
||||||
filename = FileUtils.file_path_extend(os.path.basename(output_filepath), str(i))
|
|
||||||
output_filepath = f"{output_path_prefix}/slice/outputs/{fn_id}/{filename}"
|
|
||||||
output_filepath = output_filepath.replace('//', '/')
|
|
||||||
workdir = os.path.dirname(output_filepath)
|
|
||||||
os.makedirs(workdir, exist_ok=True)
|
|
||||||
outputs.append(output_filepath)
|
|
||||||
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, )
|
|
||||||
|
|
||||||
await ffmpeg_cmd.execute()
|
|
||||||
|
|
||||||
s3_outputs = local_copy_to_s3(outputs)
|
|
||||||
|
|
||||||
return s3_outputs
|
return s3_outputs
|
||||||
|
|
||||||
match media.protocol:
|
match media.protocol:
|
||||||
case MediaProtocol.hls:
|
case MediaProtocol.hls:
|
||||||
outputs = await ffmpeg_hls_slice_process(media_source=media, media_markers=markers, fn_id=fn_id)
|
outputs = await ffmpeg_hls_slice_process(media_source=media,
|
||||||
|
media_markers=markers,
|
||||||
|
fn_id=fn_id)
|
||||||
case _:
|
case _:
|
||||||
outputs = await ffmpeg_slice_process(media_source=media, media_markers=markers, fn_id=fn_id)
|
outputs = await ffmpeg_slice_process(media_source=media,
|
||||||
|
media_markers=markers,
|
||||||
|
fn_id=fn_id)
|
||||||
|
|
||||||
return outputs, sentry_trace
|
return outputs, sentry_trace
|
||||||
|
|
||||||
@@ -277,14 +215,14 @@ with ffmpeg_worker_image.imports():
|
|||||||
|
|
||||||
fn_id = current_function_call_id()
|
fn_id = current_function_call_id()
|
||||||
|
|
||||||
@SentryUtils.sentry_tracker(name="视频切割任务", op="ffmpeg.extract.audio", fn_id=fn_id,
|
@SentryUtils.sentry_tracker(name="音频提取任务", op="ffmpeg.extract.audio", fn_id=fn_id,
|
||||||
sentry_trace_id=sentry_trace.x_trace_id if sentry_trace else None,
|
sentry_trace_id=sentry_trace.x_trace_id if sentry_trace else None,
|
||||||
sentry_baggage=sentry_trace.x_baggage if sentry_trace else None)
|
sentry_baggage=sentry_trace.x_baggage if sentry_trace else None)
|
||||||
@SentryUtils.webhook_handler(webhook=webhook, func_id=fn_id)
|
@SentryUtils.webhook_handler(webhook=webhook, func_id=fn_id)
|
||||||
async def ffmpeg_process(media: MediaSource, fn_id: str) -> str:
|
async def ffmpeg_process(media: MediaSource, fn_id: str) -> str:
|
||||||
cache_filepath = f"{s3_mount}/{media.cache_filepath}"
|
cache_filepath = f"{s3_mount}/{media.cache_filepath}"
|
||||||
output_path = f"{output_path_prefix}/{config.modal_environment}/extract_audio/outputs/{fn_id}/output.wav"
|
output_path = f"{output_path_prefix}/{config.modal_environment}/extract_audio/outputs/{fn_id}/output.wav"
|
||||||
await VideoUtils.ffmpeg_extract_audio_async(cache_filepath, output_path)
|
output_path = await VideoUtils.ffmpeg_extract_audio_async(cache_filepath, output_path)
|
||||||
s3_outputs = local_copy_to_s3([output_path])
|
s3_outputs = local_copy_to_s3([output_path])
|
||||||
return s3_outputs[0]
|
return s3_outputs[0]
|
||||||
|
|
||||||
@@ -497,3 +435,41 @@ with ffmpeg_worker_image.imports():
|
|||||||
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
|
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
|
||||||
x_baggage=sentry_sdk.get_baggage())
|
x_baggage=sentry_sdk.get_baggage())
|
||||||
return result, sentry_trace
|
return result, sentry_trace
|
||||||
|
|
||||||
|
|
||||||
|
@app.function(timeout=600, cloud="aws",
|
||||||
|
max_containers=config.ffmpeg_worker_concurrency,
|
||||||
|
volumes={
|
||||||
|
s3_mount: modal.CloudBucketMount(
|
||||||
|
bucket_name=config.S3_bucket_name,
|
||||||
|
secret=modal.Secret.from_name("aws-s3-secret",
|
||||||
|
environment_name=config.modal_environment),
|
||||||
|
),
|
||||||
|
}, )
|
||||||
|
@modal.concurrent(max_inputs=1)
|
||||||
|
async def ffmpeg_loop_fill(media: MediaSource, audio: MediaSource,
|
||||||
|
sentry_trace: Optional[SentryTransactionInfo] = None,
|
||||||
|
webhook: Optional[WebhookNotify] = None):
|
||||||
|
fn_id = current_function_call_id()
|
||||||
|
|
||||||
|
@SentryUtils.sentry_tracker(name="视频匹配音频长度", op="ffmpeg.loop.fill", fn_id=fn_id,
|
||||||
|
sentry_trace_id=sentry_trace.x_trace_id if sentry_trace else None,
|
||||||
|
sentry_baggage=sentry_trace.x_baggage if sentry_trace else None)
|
||||||
|
@SentryUtils.webhook_handler(webhook=webhook, func_id=fn_id)
|
||||||
|
async def ffmpeg_process(media: MediaSource, audio: MediaSource, func_id: str) -> str:
|
||||||
|
video_path = f"{s3_mount}/{media.cache_filepath}"
|
||||||
|
audio_path = f"{s3_mount}/{audio.cache_filepath}"
|
||||||
|
|
||||||
|
local_output = await VideoUtils.ffmpeg_fill_longest(video_path=video_path,
|
||||||
|
audio_path=audio_path,
|
||||||
|
output_path=f"{output_path_prefix}/{config.modal_environment}/loop_fill/{func_id}/output.mp4")
|
||||||
|
s3_outputs = local_copy_to_s3([local_output])
|
||||||
|
return s3_outputs[0]
|
||||||
|
|
||||||
|
output = await ffmpeg_process(media=media, audio=audio, func_id=fn_id)
|
||||||
|
|
||||||
|
if not sentry_trace:
|
||||||
|
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
|
||||||
|
x_baggage=sentry_sdk.get_baggage())
|
||||||
|
|
||||||
|
return output, sentry_trace
|
||||||
|
|||||||
12
uv.lock
generated
12
uv.lock
generated
@@ -168,6 +168,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bowongmodalfunctions"
|
name = "bowongmodalfunctions"
|
||||||
|
version = "0.1.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "backoff" },
|
{ name = "backoff" },
|
||||||
@@ -177,6 +178,7 @@ dependencies = [
|
|||||||
{ name = "fastapi", extra = ["standard"] },
|
{ name = "fastapi", extra = ["standard"] },
|
||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "loguru" },
|
{ name = "loguru" },
|
||||||
|
{ name = "m3u8" },
|
||||||
{ name = "modal" },
|
{ name = "modal" },
|
||||||
{ name = "noisereduce" },
|
{ name = "noisereduce" },
|
||||||
{ name = "pedalboard" },
|
{ name = "pedalboard" },
|
||||||
@@ -212,6 +214,7 @@ requires-dist = [
|
|||||||
{ name = "gunicorn", marker = "extra == 'cli'", specifier = ">=23.0.0" },
|
{ name = "gunicorn", marker = "extra == 'cli'", specifier = ">=23.0.0" },
|
||||||
{ name = "httpx", specifier = ">=0.28.1" },
|
{ name = "httpx", specifier = ">=0.28.1" },
|
||||||
{ name = "loguru", specifier = ">=0.7.3" },
|
{ name = "loguru", specifier = ">=0.7.3" },
|
||||||
|
{ name = "m3u8", specifier = ">=6.0.0" },
|
||||||
{ name = "modal", specifier = ">=0.76.3" },
|
{ name = "modal", specifier = ">=0.76.3" },
|
||||||
{ name = "noisereduce", specifier = ">=3.0.3" },
|
{ name = "noisereduce", specifier = ">=3.0.3" },
|
||||||
{ name = "pedalboard", specifier = "==0.9.2" },
|
{ name = "pedalboard", specifier = "==0.9.2" },
|
||||||
@@ -988,6 +991,15 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
|
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "m3u8"
|
||||||
|
version = "6.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/9b/a5/73697aaa99bb32b610adc1f11d46a0c0c370351292e9b271755084a145e6/m3u8-6.0.0.tar.gz", hash = "sha256:7ade990a1667d7a653bcaf9413b16c3eb5cd618982ff46aaff57fe6d9fa9c0fd", size = 42720 }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f8/31/50f3c38b38ff28635ff9c4a4afefddccc5f1b57457b539bdbdf75ce18669/m3u8-6.0.0-py3-none-any.whl", hash = "sha256:566d0748739c552dad10f8c87150078de6a0ec25071fa48e6968e96fc6dcba5d", size = 24133 },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "3.0.0"
|
version = "3.0.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user