添加webhook回调处理装饰器

This commit is contained in:
shuohigh@gmail.com
2025-05-16 11:21:02 +08:00
parent 9f25ec0ced
commit 49e9bfe0a1
11 changed files with 150 additions and 70 deletions

View File

@@ -3,7 +3,7 @@
## 部署
```bash
modal deploy -e dev ./src/cluster/app.py
modal deploy -m cluster.app
```
# Pip

View File

@@ -1,7 +1,7 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="modal deploy" type="PythonConfigurationType" factoryName="Python">
<module name="modalDeploy" />
<option name="ENV_FILES" value="$PROJECT_DIR$/.env" />
<option name="ENV_FILES" value="$PROJECT_DIR$/.runtime.env" />
<option name="INTERPRETER_OPTIONS" value="" />
<option name="PARENT_ENVS" value="true" />
<envs>

View File

@@ -30,6 +30,7 @@ dependencies = [
"scalar-fastapi>=1.0.3",
"modal>=0.76.3",
"python-dotenv>=1.1.0",
"python-multipart>=0.0.20",
]
classifiers = [
"Programming Language :: Python :: 3",

View File

@@ -6,7 +6,7 @@ from typing import List, Annotated, Tuple, Any, Optional
from modal import current_function_call_id
from modal.call_graph import InputStatus
from loguru import logger
from fastapi import FastAPI, Response, Depends, Header
from fastapi import FastAPI, Response, Depends, Header, UploadFile, File
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.responses import JSONResponse, RedirectResponse
from fastapi.exceptions import HTTPException
@@ -167,9 +167,9 @@ def batch_remove_cloudflare_kv(keys: List[str]):
async def get_modal_task_status(task_id: str) -> Tuple[
TaskStatus, Optional[int], Optional[str], Optional[Any], Optional[SentryTransactionInfo]]:
"""
:param task_id:
:return: (TaskStaus, errorCode, errorReason, results, sentryTransactionInfo)
使用modal任务id查看任务运行状态和结果
:param task_id: modal 任务id
:return: (TaskStaus 任务运行状态, errorCode 错误代码, errorReason 错误原因, results 任务结果, sentryTransactionInfo Sentry跟踪信息)
"""
try:
fn_task = modal.FunctionCall.from_id(task_id)
@@ -180,7 +180,6 @@ async def get_modal_task_status(task_id: str) -> Tuple[
task = root.children[0]
if not task.function_call_id == task_id:
return TaskStatus.expired, ErrorCode.NOT_FOUND.value, "NOT_FOUND", None, None
logger.info(task)
logger.info(f"Task function call status: {task.status}")
match task.status:
@@ -388,7 +387,7 @@ async def slice_media(request: FFMPEGSliceRequest,
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(request.media, request.markers, sentry_trace)
fn_call = fn.spawn(media=request.media, markers=request.markers, sentry_trace=sentry_trace, webhook=request.webhook)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@@ -425,7 +424,7 @@ async def concat_media(body: FFMPEGConcatRequest,
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(medias, sentry_trace)
fn_call = fn.spawn(medias, sentry_trace, webhook=body.webhook)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@@ -464,7 +463,7 @@ async def extract_audio(body: FFMPEGExtractAudioRequest,
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, sentry_trace)
fn_call = fn.spawn(media, sentry_trace, webhook=body.webhook)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@@ -499,7 +498,7 @@ async def corner_mirror(body: FFMPEGCornerMirrorRequest,
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=media, sentry_trace=sentry_trace)
fn_call = fn.spawn(media=media, sentry_trace=sentry_trace, webhook=body.webhook)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@@ -532,7 +531,7 @@ async def overlay_gif(body: FFMPEGOverlayGifRequest,
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.media, gif=body.gif, sentry_trace=sentry_trace)
fn_call = fn.spawn(media=body.media, gif=body.gif, sentry_trace=sentry_trace, webhook=body.webhook)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@@ -562,7 +561,8 @@ async def zoom_loop(body: FFMPEGZoomLoopRequest,
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.media, duration=body.duration, zoom=body.zoom, sentry_trace=sentry_trace)
fn_call = fn.spawn(media=body.media, duration=body.duration, zoom=body.zoom, sentry_trace=sentry_trace,
webhook=body.webhook)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@@ -593,7 +593,8 @@ async def subtitle_apply(body: FFMPEGSubtitleOverlayRequest,
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.media, subtitle=body.subtitle, sentry_trace=sentry_trace)
fn_call = fn.spawn(media=body.media, subtitle=body.subtitle, fonts=body.fonts, sentry_trace=sentry_trace,
webhook=body.webhook)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@@ -625,11 +626,11 @@ async def bgm_nosie_reduce(body: FFMPEGMixBgmWithNoiseReduceRequest,
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.media, bgm=body.bgm, video_volume=body.video_volume, music_volume=body.music_volume,
noise_sample=body.noise_sample, sentry_trace=sentry_trace)
noise_sample=body.noise_sample, sentry_trace=sentry_trace, webhook=body.webhook)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@web_app.get("/ffmpeg/subtitle-apply/{task_id}", tags=["查询任务"],
@web_app.get("/ffmpeg/bgm-nosie-reduce/{task_id}", tags=["查询任务"],
summary="查询混合BGM并降噪任务状态", description="查询混合BGM并降噪任务状态",
responses={
status.HTTP_200_OK: {

View File

@@ -6,6 +6,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class WorkerConfig(BaseSettings):
video_downloader_concurrency: int = Field(default=10, description="处理缓存任务的并行数")
ffmpeg_worker_concurrency: int = Field(default=10, description="处理视频合成任务的并行数")
local_output_mount: str = Field(default="/mnt/outputs", description="本地结果挂载路径前缀")
S3_region: str = Field(default='ap-northeast-2', description="S3挂载桶的地域")
S3_bucket_name: str = Field(default='modal-media-cache', description="集群挂载的S3存储桶")

View File

@@ -7,10 +7,12 @@ from urllib.parse import urlparse
from pydantic import (BaseModel, Field, field_validator, ValidationError,
field_serializer, SerializationInfo, computed_field)
from pydantic.json_schema import JsonSchemaValue
from ..config import WorkerConfig
# todo: 临时解决方案,需要想个办法获取本地挂载点配置
s3_region = 'ap-northeast-2'
s3_bucket_name = 'modal-media-cache'
config = WorkerConfig()
s3_region = config.S3_region
s3_bucket_name = config.S3_bucket_name
class MediaProtocol(str, Enum):
@@ -53,6 +55,8 @@ class MediaSource(BaseModel):
urn=media_url)
elif media_url.startswith('s3://'): # s3://{endpoint}/{bucket}/{url}
paths = media_url[5:].split('/')
if len(paths) < 3:
raise ValidationError("URN-s3 格式错误")
return MediaSource(path='/'.join(paths[2:]),
protocol=MediaProtocol.s3,
endpoint=paths[0],
@@ -60,6 +64,8 @@ class MediaSource(BaseModel):
urn=media_url)
elif media_url.startswith('vod://'): # vod://{endpoint}/{subAppId}/{fileId}
paths = media_url[6:].split('/')
if len(paths) < 3:
raise ValidationError("URN-vod 格式错误")
# 兼容有文件类型后缀和没有文件类型后缀的格式
url = paths[2] if '.' in os.path.basename(paths[2]) else paths[2] + ".mp4"
return MediaSource(path=url,
@@ -103,7 +109,7 @@ class MediaSource(BaseModel):
@computed_field(description="s3挂载路径下的缓存相对路径")
@property
def cache_filepath(self, local_mount: bool = False) -> str:
def cache_filepath(self) -> str:
match self.protocol:
case MediaProtocol.s3:
# 本地挂载缓存

View File

@@ -52,7 +52,7 @@ class WebhookNotify(BaseModel):
endpoint: HttpUrl = Field(description="Webhook回调端点", examples=["https://webhook.example.com"])
method: WebhookMethodEnum = Field(description="Webhook回调请求方法", examples=["get", "post"])
authentication: Optional[str] = Field(description="Webhook authentication回调请求降权token如不需要鉴权则不填",
examples=["Bearer 123456"])
examples=["Bearer 123456"], default=None)
class BaseFFMPEGTaskRequest(BaseModel):
@@ -64,6 +64,7 @@ class BaseFFMPEGTaskStatusResponse(BaseModel):
status: TaskStatus = Field(description="任务运行状态")
error: Optional[str] = Field(description="任务错误原因", default=None)
code: Optional[int] = Field(description="任务错误原因代码", default=None)
results: Optional[List[str]] = Field(description="任务运行结果", default=None)
model_config = ConfigDict(extra='ignore')
@@ -217,6 +218,7 @@ class FFMPEGOverlayGifTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
class FFMPEGSubtitleOverlayRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
subtitle: MediaSource = Field(description="需要叠加的字幕文件")
fonts: List[MediaSource] = Field(description="字幕文件内使用到的字体文件")
@field_validator('media', mode='before')
@classmethod
@@ -241,6 +243,21 @@ class FFMPEGSubtitleOverlayRequest(BaseFFMPEGTaskRequest):
else:
raise TypeError(v)
@field_validator('fonts', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]) -> List[MediaSource]:
if not v:
return v
result = []
for item in v:
if isinstance(item, str):
result.append(MediaSource.from_str(item))
elif isinstance(item, MediaSource):
result.append(item)
else:
raise pydantic.ValidationError("fonts元素类型错误: 必须是字符串")
return result
class FFMPEGSubtitleTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")

View File

@@ -1,6 +1,13 @@
from typing import Optional, Tuple, Any
import asyncio
from typing import Optional, Tuple, Any, Coroutine
import httpx
import psutil
import sentry_sdk
from loguru import logger
import functools
from BowongModalFunctions.models.web_model import WebhookNotify, WebhookMethodEnum, BaseFFMPEGTaskStatusResponse, \
TaskStatus, ErrorCode
class SentryUtils:
@@ -16,6 +23,7 @@ class SentryUtils:
def sentry_tracker(sentry_trace_id: Optional[str], sentry_baggage: Optional[str],
op: Optional[str], name: Optional[str], fn_id: str):
def decorator(func):
@functools.wraps(func)
def wrapper(*args, **kwargs):
if sentry_trace_id and sentry_baggage:
transaction = sentry_sdk.continue_trace(environ_or_headers={"sentry-trace": sentry_trace_id,
@@ -38,3 +46,39 @@ class SentryUtils:
return decorator
@staticmethod
def webhook_handler(webhook: WebhookNotify, func_id: str):
def decorator(func):
@functools.wraps(func)
async def async_wrapper(*args, **kwargs):
try:
result = await func(*args, **kwargs)
status = TaskStatus.success
error = None
code = ErrorCode.SUCCESS.value
except Exception as e:
logger.exception(e)
result = None
status = TaskStatus.failed
error = e.message if hasattr(e, 'message') else str(e)
code = ErrorCode.SYSTEM_ERROR.value
logger.info(f"webhook = {webhook}")
if webhook:
with httpx.Client() as client:
match webhook.method:
case WebhookMethodEnum.POST:
response = client.post(url=webhook.endpoint.__str__(),
json=BaseFFMPEGTaskStatusResponse(
taskId=func_id, status=status, error=error,
code=code,
results=[result] if isinstance(result, str) else result,
).model_dump())
logger.info(f"webhook response = {response}")
case WebhookMethodEnum.GET:
response = client.post(url=webhook.endpoint.__str__())
response.raise_for_status()
return result
return async_wrapper
return decorator

View File

@@ -16,8 +16,8 @@ app = modal.App(
include_source=False)
with ffmpeg_worker_image.imports():
import shutil, psutil, os, backoff, json, sentry_sdk
from typing import List, Optional, Tuple, Dict, Any
import shutil, psutil, os, backoff, sentry_sdk
from typing import List, Optional, Tuple, Any
from loguru import logger
from modal import current_function_call_id
from ffmpeg.asyncio import FFmpeg
@@ -26,13 +26,13 @@ with ffmpeg_worker_image.imports():
from BowongModalFunctions.utils.VideoUtils import VideoUtils
from BowongModalFunctions.models.ffmpeg_worker_model import FFMpegSliceSegment
from BowongModalFunctions.models.media_model import MediaSources, MediaSource, MediaProtocol
from BowongModalFunctions.models.web_model import SentryTransactionInfo
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify
from BowongModalFunctions.config import WorkerConfig
config = WorkerConfig()
s3_mount = config.S3_mount_dir
output_path_prefix = "/mnt/outputs"
output_path_prefix = config.local_output_mount
sentry_sdk.init(
dsn="https://75cca970bfcc3d45d24361e7c0f1833c@sentry.bowongai.com/4",
@@ -111,7 +111,7 @@ with ffmpeg_worker_image.imports():
# region='ap-northeast',
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
s3_mount: modal.CloudBucketMount(
bucket_name=config.S3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.modal_environment),
@@ -119,7 +119,8 @@ with ffmpeg_worker_image.imports():
}, )
@modal.concurrent(max_inputs=1)
async def ffmpeg_concat_medias(medias: MediaSources,
sentry_trace: Optional[SentryTransactionInfo] = None) -> Tuple[
sentry_trace: Optional[SentryTransactionInfo] = None,
webhook: Optional[WebhookNotify] = None) -> Tuple[
str, Optional[SentryTransactionInfo]]:
fn_id = current_function_call_id()
@@ -127,19 +128,20 @@ with ffmpeg_worker_image.imports():
@SentryUtils.sentry_tracker(name="视频合并任务", op="ffmpeg.concat", 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_sources: MediaSources, output_filepath: str) -> str:
input_videos = [f"{s3_mount}/{media_source.cache_filepath}" for media_source in media_sources.inputs]
local_output_path = await VideoUtils.ffmpeg_concat_medias(media_paths=input_videos,
output_path=output_filepath)
return local_output_path
s3_outputs = local_copy_to_s3([local_output_path])
return s3_outputs[0]
output_path = f"{output_path_prefix}/concat/outputs/{fn_id}/output.mp4"
local_output = await ffmpeg_process(media_sources=medias, output_filepath=output_path)
s3_outputs = local_copy_to_s3([local_output])
output_path = f"{output_path_prefix}/{config.modal_environment}/concat/outputs/{fn_id}/output.mp4"
s3_output = await ffmpeg_process(media_sources=medias, output_filepath=output_path)
if not sentry_trace:
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
x_baggage=sentry_sdk.get_baggage())
return s3_outputs[0], sentry_trace
return s3_output, sentry_trace
@app.function(
@@ -147,7 +149,7 @@ with ffmpeg_worker_image.imports():
cloud="aws",
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
s3_mount: modal.CloudBucketMount(
bucket_name=config.S3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.modal_environment),
@@ -157,14 +159,15 @@ with ffmpeg_worker_image.imports():
@modal.concurrent(max_inputs=1)
async def ffmpeg_slice_media(media: MediaSource,
markers: List[FFMpegSliceSegment],
webhook: Optional[str], # todo : handle webhook callback
sentry_trace: Optional[SentryTransactionInfo] = None) -> Tuple[
sentry_trace: Optional[SentryTransactionInfo] = None,
webhook: Optional[WebhookNotify] = None) -> Tuple[
List[str], Optional[SentryTransactionInfo]]:
fn_id = current_function_call_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_baggage=sentry_trace.x_baggage if sentry_trace else None)
@SentryUtils.webhook_handler(webhook=webhook, func_id=fn_id)
async def ffmpeg_slice_process(media_source: MediaSource, media_markers: List[FFMpegSliceSegment],
fn_id: str) -> List[str]:
cache_filepath = f"{s3_mount}/{media_source.cache_filepath}"
@@ -268,7 +271,7 @@ with ffmpeg_worker_image.imports():
@app.function(timeout=600, cloud="aws",
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
s3_mount: modal.CloudBucketMount(
bucket_name=config.S3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.modal_environment),
@@ -277,8 +280,8 @@ with ffmpeg_worker_image.imports():
)
@modal.concurrent(max_inputs=1)
async def ffmpeg_extract_audio(media_source: MediaSource,
webhook: Optional[str], # todo handle webhook callback
sentry_trace: Optional[SentryTransactionInfo] = None) -> Tuple[
sentry_trace: Optional[SentryTransactionInfo] = None,
webhook: Optional[WebhookNotify] = None) -> Tuple[
Optional[str], Optional[SentryTransactionInfo]]:
fn_id = current_function_call_id()
@@ -286,9 +289,10 @@ with ffmpeg_worker_image.imports():
@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_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, fn_id: str) -> str:
cache_filepath = f"{s3_mount}/{media.cache_filepath}"
output_path = f"{output_path_prefix}/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)
s3_outputs = local_copy_to_s3([output_path])
return s3_outputs[0]
@@ -304,7 +308,7 @@ with ffmpeg_worker_image.imports():
@app.function(timeout=600, cloud="aws",
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
s3_mount: modal.CloudBucketMount(
bucket_name=config.S3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.modal_environment),
@@ -315,17 +319,19 @@ with ffmpeg_worker_image.imports():
mirror_scale_down_size: int = 6,
mirror_from_right: bool = True,
mirror_position: tuple[float, float] = (40, 40),
sentry_trace: Optional[SentryTransactionInfo] = None):
sentry_trace: Optional[SentryTransactionInfo] = None,
webhook: Optional[WebhookNotify] = None):
fn_id = current_function_call_id()
@SentryUtils.sentry_tracker(name="视频镜像去重", op="ffmpeg.mirror", 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, func_id: str, mirror_scale_down_size: int = 6,
mirror_from_right: bool = True,
mirror_position: tuple[float, float] = (40, 40)) -> str:
media_filepath = f"{s3_mount}/{media.cache_filepath}"
output_path = f"{output_path_prefix}/corner_mirror/outputs/{func_id}/output.mp4"
output_path = f"{output_path_prefix}/{config.modal_environment}/corner_mirror/outputs/{func_id}/output.mp4"
local_output_filepath = await VideoUtils.ffmpeg_corner_mirror(media_path=media_filepath,
output_path=output_path,
mirror_from_right=mirror_from_right,
@@ -346,7 +352,7 @@ with ffmpeg_worker_image.imports():
@app.function(timeout=600, cloud="aws",
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
s3_mount: modal.CloudBucketMount(
bucket_name=config.S3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.modal_environment),
@@ -354,16 +360,18 @@ with ffmpeg_worker_image.imports():
}, )
@modal.concurrent(max_inputs=1)
async def ffmpeg_overlay_gif(media: MediaSource, gif: MediaSource,
sentry_trace: Optional[SentryTransactionInfo] = None):
sentry_trace: Optional[SentryTransactionInfo] = None,
webhook: Optional[WebhookNotify] = None):
fn_id = current_function_call_id()
@SentryUtils.sentry_tracker(name="视频叠加GIF特效", op="ffmpeg.overlay", 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, func_id: str, gif: MediaSource) -> str:
media_filepath = f"{s3_mount}/{media.cache_filepath}"
gif_filepath = f"{s3_mount}/{gif.cache_filepath}"
output_path = f"{output_path_prefix}/overlay/outputs/{func_id}/output.mp4"
output_path = f"{output_path_prefix}/{config.modal_environment}/overlay/outputs/{func_id}/output.mp4"
local_output_filepath = await VideoUtils.ffmpeg_overlay_gif(media_path=media_filepath,
output_path=output_path,
overlay_gif_path=gif_filepath)
@@ -380,7 +388,7 @@ with ffmpeg_worker_image.imports():
@app.function(timeout=600, cloud="aws",
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
s3_mount: modal.CloudBucketMount(
bucket_name=config.S3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.modal_environment),
@@ -388,15 +396,17 @@ with ffmpeg_worker_image.imports():
}, )
@modal.concurrent(max_inputs=1)
async def ffmpeg_zoom_loop(media: MediaSource, duration: int = 6, zoom: float = 0.1,
sentry_trace: Optional[SentryTransactionInfo] = None):
sentry_trace: Optional[SentryTransactionInfo] = None,
webhook: Optional[WebhookNotify] = None):
fn_id = current_function_call_id()
@SentryUtils.sentry_tracker(name="视频放大缩小去重", op="ffmpeg.zoom.loop", 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, func_id: str, duration: int = 6, zoom: float = 0.1) -> str:
media_filepath = f"{s3_mount}/{media.cache_filepath}"
output_path = f"{output_path_prefix}/zoom_loop/outputs/{func_id}/output.mp4"
output_path = f"{output_path_prefix}/{config.modal_environment}/zoom_loop/outputs/{func_id}/output.mp4"
local_output_filepath = await VideoUtils.ffmpeg_zoom_loop(media_path=media_filepath,
output_path=output_path,
duration=duration,
@@ -414,7 +424,7 @@ with ffmpeg_worker_image.imports():
@app.function(timeout=600, cloud="aws",
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
s3_mount: modal.CloudBucketMount(
bucket_name=config.S3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.modal_environment),
@@ -423,18 +433,20 @@ with ffmpeg_worker_image.imports():
@modal.concurrent(max_inputs=1)
async def ffmpeg_bgm_nosie_reduce(media: MediaSource, bgm: MediaSource, noise_sample: Optional[MediaSource] = None,
video_volume: float = 1.4, music_volume: float = 0.1,
sentry_trace: Optional[SentryTransactionInfo] = None):
sentry_trace: Optional[SentryTransactionInfo] = None,
webhook: Optional[WebhookNotify] = None):
fn_id = current_function_call_id()
@SentryUtils.sentry_tracker(name="视频混合BGM降噪", op="ffmpeg.bgm.nosie.reduce", 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(video: MediaSource, bgm: MediaSource, func_id: str,
noise_sample: Optional[MediaSource] = None) -> str:
local_input_filepath = f"{s3_mount}/{video.cache_filepath}"
bgm_filepath = f"{s3_mount}/{bgm.cache_filepath}"
noise_sample_path = f"{s3_mount}/{noise_sample.cache_filepath}" if noise_sample else None
output_path = f"{output_path_prefix}/bgm_nosie_reduce/outputs/{func_id}/output.mp4"
output_path = f"{output_path_prefix}/{config.modal_environment}/bgm_nosie_reduce/outputs/{func_id}/output.mp4"
local_output_filepath = await VideoUtils.ffmpeg_mix_bgm_with_noise_reduce(media_path=local_input_filepath,
bgm_audio_path=bgm_filepath,
video_volume=video_volume,
@@ -457,7 +469,7 @@ with ffmpeg_worker_image.imports():
@app.function(timeout=600, cloud="aws",
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
s3_mount: modal.CloudBucketMount(
bucket_name=config.S3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.modal_environment),
@@ -465,26 +477,31 @@ with ffmpeg_worker_image.imports():
}, )
@modal.concurrent(max_inputs=1)
async def ffmpeg_subtitle_apply(media: MediaSource, subtitle: MediaSource,
sentry_trace: Optional[SentryTransactionInfo] = None):
fonts: List[MediaSource], sentry_trace: Optional[SentryTransactionInfo] = None,
webhook: Optional[WebhookNotify] = None):
fn_id = current_function_call_id()
@SentryUtils.sentry_tracker(name="视频叠加字幕", op="ffmpeg.subtitle.apply", 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)
async def ffmpeg_process(video: MediaSource, subtitle: MediaSource, func_id: str, font_dir: str) -> str:
@SentryUtils.webhook_handler(webhook=webhook, func_id=fn_id)
async def ffmpeg_process(video: MediaSource, subtitle: MediaSource,
fonts: List[MediaSource], func_id: str) -> str:
media_path = f"{s3_mount}/{video.cache_filepath}"
subtitle_path = f"{s3_mount}/{subtitle.cache_filepath}"
output_path = f"{output_path_prefix}/subtitle_apply/{func_id}/output.mp4"
output_path = f"{output_path_prefix}/{config.modal_environment}/subtitle_apply/{func_id}/output.mp4"
font_dir = f"{output_path_prefix}/{config.modal_environment}/subtitle_apply/{func_id}/fonts"
os.makedirs(font_dir, exist_ok=True)
local_fonts = [f"{s3_mount}/{font.cache_filepath}" for font in fonts]
for font in local_fonts:
font_filename = os.path.basename(font)
shutil.copy(font, f"{font_dir}/{font_filename}")
local_output = await VideoUtils.ffmpeg_subtitle_apply(media_path=media_path, subtitle_path=subtitle_path,
font_dir=font_dir, output_path=output_path)
s3_outputs = local_copy_to_s3([local_output])
return s3_outputs[0]
# todo : check if subtitle use fonts exist in fonts dir
font_dir = f"{s3_mount}/fonts"
result = await ffmpeg_process(video=media, subtitle=subtitle, func_id=fn_id, font_dir=font_dir)
result = await ffmpeg_process(video=media, subtitle=subtitle, fonts=fonts, func_id=fn_id)
if not sentry_trace:
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
x_baggage=sentry_sdk.get_baggage())

View File

@@ -38,15 +38,6 @@ with downloader_image.imports():
from BowongModalFunctions.models.web_model import SentryTransactionInfo
config = WorkerConfig()
# config = WorkerConfig(
# modal_app_name="bowong-ai-video-test",
# video_downloader_concurrency=10,
# ffmpeg_worker_concurrency=10,
# modal_kv_name="media-cache",
# S3_region='ap-northeast-2',
# S3_bucket_name="modal-media-cache",
# modal_environment="dev"
# )
sentry_sdk.init(dsn="https://85632fdcd62f699c2f88af6ca489e9ec@sentry.bowongai.com/3",
send_default_pii=True,

2
uv.lock generated
View File

@@ -186,6 +186,7 @@ dependencies = [
{ name = "pyloudnorm" },
{ name = "python-dotenv" },
{ name = "python-ffmpeg" },
{ name = "python-multipart" },
{ name = "scalar-fastapi" },
{ name = "sentry-sdk", extra = ["loguru"] },
{ name = "soundfile" },
@@ -217,6 +218,7 @@ requires-dist = [
{ name = "pyloudnorm", specifier = ">=0.1.1" },
{ name = "python-dotenv", specifier = ">=1.1.0" },
{ name = "python-ffmpeg", specifier = ">=2.0.12" },
{ name = "python-multipart", specifier = ">=0.0.20" },
{ name = "scalar-fastapi", specifier = ">=1.0.3" },
{ name = "sentry-sdk", extras = ["loguru"], specifier = ">=2.26.1" },
{ name = "soundfile", specifier = ">=0.13.1" },