新增API

- /ffmpeg/subtitle-apply
- /ffmpeg/bgm-nosie-reduce
- /ffmpeg/zoom-loop
- /ffmpeg/overlay-gif
This commit is contained in:
shuohigh@gmail.com
2025-05-15 15:20:25 +08:00
parent 0f2b9ddb16
commit 0fca52a271
16 changed files with 904 additions and 271 deletions

3
.gitignore vendored
View File

@@ -1 +1,2 @@
*/**/.pypirc .pypirc
.env

View File

@@ -14,7 +14,7 @@
<option name="ADD_SOURCE_ROOTS" value="true" /> <option name="ADD_SOURCE_ROOTS" value="true" />
<EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" /> <EXTENSION ID="PythonCoverageRunConfigurationExtension" runner="coverage.py" />
<option name="SCRIPT_NAME" value="modal" /> <option name="SCRIPT_NAME" value="modal" />
<option name="PARAMETERS" value="deploy ./src/cluster/app.py" /> <option name="PARAMETERS" value="deploy -m cluster.app" />
<option name="SHOW_COMMAND_LINE" value="false" /> <option name="SHOW_COMMAND_LINE" value="false" />
<option name="EMULATE_TERMINAL" value="false" /> <option name="EMULATE_TERMINAL" value="false" />
<option name="MODULE_MODE" value="true" /> <option name="MODULE_MODE" value="true" />

View File

@@ -28,5 +28,5 @@ py -m twine upload --config-file=.pypirc --repository=coding-pypi dist/*
## 安装 ## 安装
```bash ```bash
uv add bowong-modal-functions --index-url=https://<用户名>:<密钥>@g-ldyi2063-pypi.pkg.coding.net/dev/packages/simple uv pip install bowong-modal-functions --index-url=https://<用户名>:<密钥>@g-ldyi2063-pypi.pkg.coding.net/dev/packages/simple
``` ```

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "BowongModalFunctions" name = "BowongModalFunctions"
version = "0.1.1-pre" dynamic = ["version"]
authors = [ authors = [
{ name = "Yudi Xiao", email = "xyd@bowong.ai" }, { name = "Yudi Xiao", email = "xyd@bowong.ai" },
] ]
@@ -27,7 +27,6 @@ dependencies = [
"cos-python-sdk-v5>=1.9.36", "cos-python-sdk-v5>=1.9.36",
"boto3>=1.37.37", "boto3>=1.37.37",
"psutil>=7.0.0", "psutil>=7.0.0",
"twine>=6.1.0",
"scalar-fastapi>=1.0.3", "scalar-fastapi>=1.0.3",
"modal>=0.76.3", "modal>=0.76.3",
] ]
@@ -39,10 +38,16 @@ classifiers = [
license = "MIT" license = "MIT"
license-files = ["LICEN[CS]E*"] license-files = ["LICEN[CS]E*"]
[project.optional-dependencies]
cli = ["twine>=6.1.0"]
[build-system] [build-system]
requires = ["hatchling >= 1.26"] requires = ["hatchling >= 1.26"]
build-backend = "hatchling.build" build-backend = "hatchling.build"
[tool.hatch.version]
path = "src/BowongModalFunctions/__init__.py"
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/BowongModalFunctions"] packages = ["src/BowongModalFunctions"]
sources = ["src"] sources = ["src"]

View File

@@ -1 +1 @@
__version__ = "0.1.1-pre" __version__ = "0.1.1-pre3"

View File

@@ -30,10 +30,19 @@ from .models.web_model import (TaskStatus, ErrorCode,
FFMPEGConcatRequest, FFMPEGConcatRequest,
FFMPEGExtractAudioRequest, FFMPEGExtractAudioRequest,
FFMPEGCornerMirrorRequest, FFMPEGCornerMirrorRequest,
FFMPEGOverlayGifRequest,
FFMPEGZoomLoopRequest,
FFMPEGSubtitleOverlayRequest,
FFMPEGMixBgmWithNoiseReduceRequest,
FFMPEGSliceTaskStatusResponse, FFMPEGSliceTaskStatusResponse,
FFMPEGConcatTaskStatusResponse, FFMPEGConcatTaskStatusResponse,
FFMPEGExtractAudioTaskStatusResponse, FFMPEGExtractAudioTaskStatusResponse,
FFMPEGCornerMirrorTaskStatusResponse) FFMPEGCornerMirrorTaskStatusResponse,
FFMPEGOverlayGifTaskStatusResponse,
FFMPEGSubtitleTaskStatusResponse,
FFMPEGZoomLoopTaskStatusResponse,
FFMPEGMixBgmWithNoiseReduceStatusResponse
)
from .config import WorkerConfig from .config import WorkerConfig
bearer_scheme = HTTPBearer() bearer_scheme = HTTPBearer()
@@ -499,7 +508,7 @@ async def corner_mirror(body: FFMPEGCornerMirrorRequest,
@web_app.get("/ffmpeg/corner-mirror/{task_id}", @web_app.get("/ffmpeg/corner-mirror/{task_id}",
tags=["查询任务"], tags=["查询任务"],
summary="查询镜像小窗去重任务状态", summary="查询镜像小窗去重任务状态",
description="<UNK>", description="查询镜像小窗去重任务状态",
responses={ responses={
status.HTTP_200_OK: { status.HTTP_200_OK: {
"description": "", "description": "",
@@ -514,3 +523,124 @@ async def corner_mirror_status(task_id: str, response: Response) -> FFMPEGCorner
response.headers["x-baggage"] = transaction.x_baggage response.headers["x-baggage"] = transaction.x_baggage
return FFMPEGCornerMirrorTaskStatusResponse(taskId=task_id, status=task_status, code=code, return FFMPEGCornerMirrorTaskStatusResponse(taskId=task_id, status=task_status, code=code,
error=reason, result=result) error=reason, result=result)
@web_app.post("/ffmpeg/overlay-gif", tags=["发起任务"], summary="发起叠加gif特效任务",
description="发起叠加gif特效任务", dependencies=[Depends(verify_token)])
async def overlay_gif(body: FFMPEGOverlayGifRequest,
headers: Annotated[SentryTransactionHeader, Header()]) -> ModalTaskResponse:
fn = modal.Function.from_name(config.app_name, "ffmpeg_overlay_gif", environment_name=config.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.media, gif=body.gif, sentry_trace=sentry_trace)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@web_app.get("/ffmpeg/overlay-gif/{task_id}", tags=["查询任务"],
summary="查询叠加gif特效任务状态", description="查询叠加gif特效任务状态",
responses={
status.HTTP_200_OK: {
"description": "",
"headers": sentry_header_schema
},
},
dependencies=[Depends(verify_token)])
async def overlay_gif_status(task_id: str, response: Response) -> FFMPEGOverlayGifTaskStatusResponse:
task_status, code, reason, result, transaction = await 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 FFMPEGOverlayGifTaskStatusResponse(taskId=task_id, status=task_status, code=code,
error=reason, result=result)
@web_app.post("/ffmpeg/zoom-loop", tags=["发起任务"], summary="发起放大缩小循环去重任务",
description="发起放大缩小循环去重任务", dependencies=[Depends(verify_token)])
async def zoom_loop(body: FFMPEGZoomLoopRequest,
headers: Annotated[SentryTransactionHeader, Header()]) -> ModalTaskResponse:
fn = modal.Function.from_name(config.app_name, "ffmpeg_zoom_loop", environment_name=config.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.media, duration=body.duration, zoom=body.zoom, sentry_trace=sentry_trace)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@web_app.get("/ffmpeg/zoom-loop/{task_id}", tags=["查询任务"],
summary="查询放大缩小循环去重任务状态", description="查询放大缩小循环去重任务状态",
responses={
status.HTTP_200_OK: {
"description": "",
"headers": sentry_header_schema
},
},
dependencies=[Depends(verify_token)])
async def zoom_loop_status(task_id: str, response: Response) -> FFMPEGZoomLoopTaskStatusResponse:
task_status, code, reason, result, transaction = await 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 FFMPEGZoomLoopTaskStatusResponse(taskId=task_id, status=task_status, code=code,
error=reason, result=result)
@web_app.post("/ffmpeg/subtitle-apply", tags=["发起任务"], summary="发起渲染字幕任务",
description="发起渲染字幕任务", dependencies=[Depends(verify_token)])
async def subtitle_apply(body: FFMPEGSubtitleOverlayRequest,
headers: Annotated[SentryTransactionHeader, Header()]) -> ModalTaskResponse:
fn = modal.Function.from_name(config.app_name, "ffmpeg_subtitle_apply", environment_name=config.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.media, subtitle=body.subtitle, sentry_trace=sentry_trace)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@web_app.get("/ffmpeg/subtitle-apply/{task_id}", tags=["查询任务"],
summary="查询渲染字幕任务状态", description="查询渲染字幕任务状态",
responses={
status.HTTP_200_OK: {
"description": "",
"headers": sentry_header_schema
},
},
dependencies=[Depends(verify_token)])
async def subtitle_apply_status(task_id: str, response: Response) -> FFMPEGSubtitleTaskStatusResponse:
task_status, code, reason, result, transaction = await 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 FFMPEGSubtitleTaskStatusResponse(taskId=task_id, status=task_status, code=code,
error=reason, result=result)
@web_app.post("/ffmpeg/bgm-nosie-reduce", tags=["发起任务"], summary="发起混合BGM并降噪任务",
description="发起混合BGM并降噪任务", dependencies=[Depends(verify_token)])
async def bgm_nosie_reduce(body: FFMPEGMixBgmWithNoiseReduceRequest,
headers: Annotated[SentryTransactionHeader, Header()]) -> ModalTaskResponse:
fn = modal.Function.from_name(config.app_name, "ffmpeg_bgm_nosie_reduce", environment_name=config.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.media, bgm=body.bgm, video_volume=body.video_volume, music_volume=body.music_volume,
noise_sample=body.noise_sample, sentry_trace=sentry_trace)
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
@web_app.get("/ffmpeg/subtitle-apply/{task_id}", tags=["查询任务"],
summary="查询混合BGM并降噪任务状态", description="查询混合BGM并降噪任务状态",
responses={
status.HTTP_200_OK: {
"description": "",
"headers": sentry_header_schema
},
},
dependencies=[Depends(verify_token)])
async def bgm_nosie_reduce_status(task_id: str, response: Response) -> FFMPEGMixBgmWithNoiseReduceStatusResponse:
task_status, code, reason, result, transaction = await 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 FFMPEGMixBgmWithNoiseReduceStatusResponse(taskId=task_id, status=task_status, code=code,
error=reason, result=result)

View File

@@ -1,6 +1,7 @@
import os import os
from datetime import datetime from datetime import datetime
from enum import Enum from enum import Enum
from functools import cached_property
from typing import List, Union, Optional, Any, Dict from typing import List, Union, Optional, Any, Dict
from urllib.parse import urlparse from urllib.parse import urlparse
from pydantic import (BaseModel, Field, field_validator, ValidationError, from pydantic import (BaseModel, Field, field_validator, ValidationError,
@@ -116,6 +117,11 @@ class MediaSource(BaseModel):
case _: case _:
return f"{self.protocol.value}/{self.endpoint}/{self.bucket}/{self.path}" return f"{self.protocol.value}/{self.endpoint}/{self.bucket}/{self.path}"
@computed_field(description="文件后缀名")
@cached_property
def file_extension(self) -> Optional[str]:
return os.path.basename(self.urn).split('.')[-1]
@field_serializer('expired_at') @field_serializer('expired_at')
def serialize_datetime(self, value: Optional[datetime], info: SerializationInfo) -> Optional[str]: def serialize_datetime(self, value: Optional[datetime], info: SerializationInfo) -> Optional[str]:
if value: if value:

View File

@@ -1,29 +1,12 @@
from enum import Enum from enum import Enum
from typing import List, Union, Optional from typing import List, Union, Optional
import pydantic
from pydantic import BaseModel, Field, field_validator, ConfigDict, HttpUrl from pydantic import BaseModel, Field, field_validator, ConfigDict, HttpUrl
from .ffmpeg_worker_model import FFMpegSliceSegment from .ffmpeg_worker_model import FFMpegSliceSegment
from .media_model import MediaSource, MediaSources from .media_model import MediaSource, MediaSources
class SentryTransactionHeader(BaseModel):
x_trace_id: Optional[str] = Field(description="Sentry Transaction ID", default=None)
x_baggage: Optional[str] = Field(description="Sentry Transaction baggage", default=None)
class SentryTransactionInfo(BaseModel):
x_trace_id: str = Field(description="Sentry Transaction ID")
x_baggage: str = Field(description="Sentry Transaction baggage")
class FFMPEGSliceTaskStatusRequest(BaseModel):
taskId: str = Field(description="任务Id")
class ModalTaskResponse(BaseModel):
success: bool = Field(description="任务接受成功")
taskId: str = Field(description="任务Id")
class TaskStatus(str, Enum): class TaskStatus(str, Enum):
running = "running" running = "running"
failed = "failed" failed = "failed"
@@ -46,6 +29,25 @@ class WebhookMethodEnum(str, Enum):
POST = "post" POST = "post"
class SentryTransactionHeader(BaseModel):
x_trace_id: Optional[str] = Field(description="Sentry Transaction ID", default=None)
x_baggage: Optional[str] = Field(description="Sentry Transaction baggage", default=None)
class SentryTransactionInfo(BaseModel):
x_trace_id: str = Field(description="Sentry Transaction ID")
x_baggage: str = Field(description="Sentry Transaction baggage")
class FFMPEGSliceTaskStatusRequest(BaseModel):
taskId: str = Field(description="任务Id")
class ModalTaskResponse(BaseModel):
success: bool = Field(description="任务接受成功")
taskId: str = Field(description="任务Id")
class WebhookNotify(BaseModel): class WebhookNotify(BaseModel):
endpoint: HttpUrl = Field(description="Webhook回调端点", examples=["https://webhook.example.com"]) endpoint: HttpUrl = Field(description="Webhook回调端点", examples=["https://webhook.example.com"])
method: WebhookMethodEnum = Field(description="Webhook回调请求方法", examples=["get", "post"]) method: WebhookMethodEnum = Field(description="Webhook回调请求方法", examples=["get", "post"])
@@ -112,7 +114,7 @@ class FFMPEGExtractAudioTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
class FFMPEGCornerMirrorRequest(BaseFFMPEGTaskRequest): class FFMPEGCornerMirrorRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="") media: MediaSource = Field(description="需要处理的媒体源")
@field_validator('media', mode='before') @field_validator('media', mode='before')
@classmethod @classmethod
@@ -127,3 +129,163 @@ class FFMPEGCornerMirrorRequest(BaseFFMPEGTaskRequest):
class FFMPEGCornerMirrorTaskStatusResponse(BaseFFMPEGTaskStatusResponse): class FFMPEGCornerMirrorTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="任务运行结果") result: Optional[str] = Field(default=None, description="任务运行结果")
class FFMPEGBgmRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
bgm_media: MediaSource = Field(description="添加的BGM媒体源", alias="bgmMedia")
@field_validator('media', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
return MediaSource.from_str(v)
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
@field_validator('bgm_media', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
media = MediaSource.from_str(v)
if not media.file_extension in ['wav', 'mp3']:
raise pydantic.ValidationError("必须使用符合规范的音频格式, 如wav或者mp3")
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
class FFMPEGBgmTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGZoomLoopRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
duration: float = Field(description="放大缩小一个循环的持续时间秒数", default=6.0)
zoom: float = Field(description="放大缩小系数", default=0.1)
@field_validator('media', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
return MediaSource.from_str(v)
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
class FFMPEGZoomLoopTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGOverlayGifRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
gif: MediaSource = Field(description="叠加的特效gif")
@field_validator('media', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
return MediaSource.from_str(v)
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
@field_validator('gif', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
media = MediaSource.from_str(v)
if not media.file_extension == 'gif':
raise pydantic.ValidationError("必须使用.gif文件")
return media
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
class FFMPEGOverlayGifTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGSubtitleOverlayRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
subtitle: MediaSource = Field(description="需要叠加的字幕文件")
@field_validator('media', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
return MediaSource.from_str(v)
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
@field_validator('subtitle', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
media = MediaSource.from_str(v)
if not media.file_extension in ['ass']:
raise pydantic.ValidationError("必须使用标准字幕文件, 如.ass")
return media
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
class FFMPEGSubtitleTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGMixBgmWithNoiseReduceRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
bgm: MediaSource = Field(description="需要添加的BGM媒体源")
video_volume: float = Field(description="最终输出的视频音量系数", default=1.4)
music_volume: float = Field(description="最终输出的BGM音量系数", default=0.1)
noise_sample: Optional[MediaSource] = Field(description="常考噪音样本如不指定将使用源视频的前2秒作为样本",
default=None)
@field_validator('media', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
return MediaSource.from_str(v)
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
@field_validator('bgm', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
return MediaSource.from_str(v)
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
@field_validator('noise_sample', mode='before')
@classmethod
def parse_inputs(cls, v: Union[None, str, MediaSource]):
if not v:
return None
if isinstance(v, str):
return MediaSource.from_str(v)
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
class FFMPEGMixBgmWithNoiseReduceStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")

View File

@@ -18,3 +18,12 @@ class FileUtils:
raise IndexError("Depth is out of range") raise IndexError("Depth is out of range")
media_dir_prefix = prefix + '/'.join(media_dirs[depth:]) media_dir_prefix = prefix + '/'.join(media_dirs[depth:])
return media_dir_prefix 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)

View File

@@ -1,6 +1,9 @@
from typing import Union, List, Tuple, Optional from typing import Union, List, Tuple, Optional
import numpy as np import numpy as np
import json, os
import math
from BowongModalFunctions.models.media_model import MediaSource
from .TimeUtils import TimeDelta
from pydantic import BaseModel, ConfigDict, computed_field from pydantic import BaseModel, ConfigDict, computed_field
from ffmpeg import FFmpeg from ffmpeg import FFmpeg
from ffmpeg.asyncio import FFmpeg as AsyncFFmpeg from ffmpeg.asyncio import FFmpeg as AsyncFFmpeg
@@ -20,8 +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 src.cluster.ffmpeg_worker.Utils.PathUtils import FileUtils
class MediaStream(BaseModel): class MediaStream(BaseModel):
@@ -77,6 +79,14 @@ class VideoUtils:
video_metadata = VideoMetadata.model_validate_json(ffprobe.execute()) video_metadata = VideoMetadata.model_validate_json(ffprobe.execute())
return video_metadata.streams[0] return video_metadata.streams[0]
@staticmethod
def ffprobe_audio_duration(media_path: str) -> TimeDelta:
ffprobe_cmd = VideoUtils.ffmpeg_init(use_ffprobe=True)
ffprobe_cmd.input(media_path, print_format="json", show_streams=None)
metadata_json = ffprobe_cmd.execute()
metadata = VideoMetadata.model_validate_json(metadata_json)
return TimeDelta(seconds=metadata.streams[-1].duration)
@staticmethod @staticmethod
async def ffprobe_video_format_async(media_path: str) -> VideoStream: async def ffprobe_video_format_async(media_path: str) -> VideoStream:
ffprobe = AsyncFFmpeg(executable="ffprobe").input( ffprobe = AsyncFFmpeg(executable="ffprobe").input(
@@ -106,7 +116,8 @@ class VideoUtils:
return video_metadata.streams[0].width, video_metadata.streams[0].height return video_metadata.streams[0].width, video_metadata.streams[0].height
@staticmethod @staticmethod
def noise_reduce(media_path: str, noise_sample_path: Optional[str] = None) -> str: def noise_reduce(media_path: str, noise_sample_path: Optional[str] = None,
output_path: Optional[str] = None) -> str:
samplerate = 44100 samplerate = 44100
with AudioFile(media_path).resampled_to(float(samplerate)) as f: with AudioFile(media_path).resampled_to(float(samplerate)) as f:
audio = f.read(f.frames) audio = f.read(f.frames)
@@ -119,6 +130,9 @@ class VideoUtils:
noise_sample_length = min(int(2 * samplerate), audio.shape[0]) noise_sample_length = min(int(2 * samplerate), audio.shape[0])
noise_sample = audio[:noise_sample_length] noise_sample = audio[:noise_sample_length]
if not output_path:
output_path = FileUtils.file_path_extend(media_path, "nr")
reduced_noise = nr.reduce_noise(y=audio, y_noise=noise_sample, sr=samplerate, 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, stationary=True, prop_decrease=0.75, n_std_thresh_stationary=1.5,
n_fft=2048, win_length=1024, hop_length=512, n_jobs=1) n_fft=2048, win_length=1024, hop_length=512, n_jobs=1)
@@ -171,18 +185,18 @@ class VideoUtils:
if max_peak > 0.85: if max_peak > 0.85:
additional_safety_factor = 0.85 / max_peak additional_safety_factor = 0.85 / max_peak
normalized_audio = normalized_audio * additional_safety_factor normalized_audio = normalized_audio * additional_safety_factor
processed_audio_path = FileUtils.file_path_extend(media_path, "nr")
sf.write( sf.write(
processed_audio_path, output_path,
normalized_audio, normalized_audio,
samplerate, samplerate,
format="WAV", format="WAV",
subtype="PCM_16", subtype="PCM_16",
) )
return processed_audio_path return output_path
@staticmethod @staticmethod
def ffmpeg_init_async(use_ffprobe: bool = False) -> AsyncFFmpeg: def async_ffmpeg_init(use_ffprobe: bool = False) -> AsyncFFmpeg:
if use_ffprobe: if use_ffprobe:
ffmpeg_cmd = AsyncFFmpeg('ffprobe') ffmpeg_cmd = AsyncFFmpeg('ffprobe')
else: else:
@@ -258,10 +272,81 @@ class VideoUtils:
return ffmpeg_cmd return ffmpeg_cmd
@staticmethod @staticmethod
async def ffmpeg_extract_audio_async(media_path: str, output_path: str): async def ffmpeg_concat_medias(media_paths: List[str],
import json, os target_width: int = 1080,
target_height: int = 1920,
output_path: Optional[str] = None) -> str:
"""
将待处理的视频合并为一个视频
:param media_paths: 待合并的多个视频文件路径
:param target_width: 输出的视频分辨率宽
:param target_height: 输出的视频分辨率高
:param output_path: 指定输出视频路径
:return:
"""
ffprobe_cmd = VideoUtils.ffmpeg_init_async(use_ffprobe=True) 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 = []
# 2. 统一所有视频的格式、分辨率和帧率
for i in range(total_videos):
filter_complex.extend(
[
# 先缩放到统一分辨率,然后设置帧率和格式
f"[{i}:v]scale={target_width}:{target_height}:force_original_aspect_ratio=decrease,"
f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2,"
f"setsar=1:1," # 新增强制设置SAR
f"fps=30,format=yuv420p[v{i}]",
f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a{i}]",
]
)
# 3. 准备处理后的视频流和音频流的连接字符串
video_streams = "".join(f"[v{i}]" for i in range(total_videos))
audio_streams = "".join(f"[a{i}]" for i in range(total_videos))
# 4. 使用concat过滤器合并视频和音频
filter_complex.extend(
[
f"{video_streams}concat=n={total_videos}:v=1:a=0[vconcated]",
f"{audio_streams}concat=n={total_videos}:v=0:a=1[aconcated]",
]
)
ffmpeg_cmd.output(
output_path,
{
"filter_complex": ";".join(filter_complex),
"map": ["[vconcated]", "[aconcated]"],
"vcodec": "libx264",
"crf": 16,
"r": 30,
"acodec": "aac",
"ar": 44100,
"ac": 2,
"ab": "192k",
},
)
await ffmpeg_cmd.execute()
return output_path
@staticmethod
async def ffmpeg_extract_audio_async(media_path: str, output_path: Optional[str] = None):
"""
提取源视频的音频
: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, ffprobe_cmd.input(media_path,
v="quiet", v="quiet",
print_format="json", print_format="json",
@@ -274,10 +359,239 @@ class VideoUtils:
raise RuntimeError(f"Media has no audio streams.") raise RuntimeError(f"Media has no audio streams.")
# output_path = f"{output_path_prefix}/extract_audio/outputs/{fn_id}/output.wav" # output_path = f"{output_path_prefix}/extract_audio/outputs/{fn_id}/output.wav"
os.makedirs(os.path.dirname(output_path), exist_ok=True) os.makedirs(os.path.dirname(output_path), exist_ok=True)
ffmpeg_cmd = VideoUtils.ffmpeg_init_async() ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
ffmpeg_cmd.input(media_path).output(output_path, ffmpeg_cmd.input(media_path).output(output_path,
map="0:a", map="0:a",
acodec="pcm_s16le", acodec="pcm_s16le",
ar=44100, ar=44100,
ac=1) ac=1)
await ffmpeg_cmd.execute() await ffmpeg_cmd.execute()
@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) -> str:
"""
给待处理视频混合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()
return output_path
@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,
output_path: Optional[str] = None) -> str:
"""
先对待处理的视频音轨降噪再将降噪后的结果添加BGM最终输出降噪过且混合BGM的视频
由于最终视频画面和音轨是同步混合+合成视频,所以处理速度会比分步降噪, 加BGM快
:param media_path: 待处理的原始视频路径
:param bgm_audio_path: 待处理的BGM音频路径
:param video_volume: 最终输出的视频音量系数
:param music_volume: 最终输出的BGM音量系数
:param noise_sample_path: 降噪使用的噪音样本如不指定将使用源视频的前2秒作为样本可选
: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)
nr_audio_path = VideoUtils.noise_reduce(media_path=media_path, noise_sample_path=noise_sample_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())
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()
return output_path
@staticmethod
async def ffmpeg_overlay_gif(media_path: str, overlay_gif_path: str, output_path: Optional[str] = None) -> str:
"""
将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)
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()
return output_path
@staticmethod
async def ffmpeg_zoom_loop(media_path: str, duration: float = 6.0, zoom: float = 0.1,
output_path: Optional[str] = None) -> str:
"""
视频放大缩小循环特效
: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()
return output_path
@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) -> str:
"""
对源视频添加镜像小窗特效
: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()
return output_path
@staticmethod
async def ffmpeg_subtitle_apply(media_path: str, subtitle_path: str,
font_dir: str, output_path: Optional[str] = None) -> str:
"""
给视频画面叠加字幕需要确保字幕文件为ass字幕并且subtitle文件内设置的字体存在与font_dir文件夹内
:param media_path: 待处理的源视频
:param subtitle_path: ass字幕文件路径
:param font_dir: 字体文件目录路径
:param output_path: 指定输出文件路径(可选)
:return: 返回最终输出视频路径
"""
if not output_path:
output_path = FileUtils.file_path_extend(media_path, 'sub')
video_metadata = VideoUtils.ffprobe_video_format(media_path)
ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
ffmpeg_cmd.input(media_path)
ffmpeg_cmd.output(output_path,
vf=f"subtitles=filename={subtitle_path}:fontsdir={font_dir}",
vcodec="libx264",
crf=16,
b=video_metadata.video_bitrate, # 视频码率
r=video_metadata.video_frame_rate, # 帧率
acodec="copy",
)
await ffmpeg_cmd.execute()
return output_path

View File

@@ -1,8 +1,8 @@
import modal import modal
from config import WorkerConfig from BowongModalFunctions.config import WorkerConfig
from video import media_app from .video import app as media_app
from web import fastapi_app from .web import app as web_app
from ffmpeg_app import ffmpeg_app from .ffmpeg_app import app as ffmpeg_app
config = WorkerConfig( config = WorkerConfig(
app_name="bowong-ai-video-test", app_name="bowong-ai-video-test",
@@ -19,6 +19,6 @@ app = modal.App('bowong-modal-ai-video-preview',
secrets=[modal.Secret.from_name("cf-kv-secret", secrets=[modal.Secret.from_name("cf-kv-secret",
environment_name=config.environment)]) environment_name=config.environment)])
app.include(fastapi_app)
app.include(media_app) app.include(media_app)
app.include(ffmpeg_app) app.include(ffmpeg_app)
app.include(web_app)

View File

@@ -1,19 +0,0 @@
from typing import Optional, Any
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class WorkerConfig(BaseSettings):
app_name: str = Field(default='bowong-ai-video', description="Modal App集群名称")
video_downloader_concurrency: Optional[int] = Field(default=10, description="处理缓存任务的并行数")
ffmpeg_worker_concurrency: Optional[int] = Field(default=10, description="处理视频合成任务的并行数")
modal_kv_name: Optional[str] = Field(default='media-cache', description="Modal视频缓存KV库")
s3_region: Optional[str] = Field(default='ap-northeast-2', description="S3挂载桶的地域")
s3_bucket_name: Optional[str] = Field(default='modal-media-cache', description="集群挂载的S3存储桶")
s3_mount_dir: str = Field(default='/mntS3', description="集群S3存储桶挂载在本地的根目录")
cdn_endpoint: Optional[str] = Field(default="https://d2nj71io21vkj2.cloudfront.net",
description="集群挂载S3存储桶的对应AWS Cloudfront CDN")
environment: Optional[str] = Field(default="dev", description="Modal worker运行环境")
modal_config: Any = SettingsConfigDict()

View File

@@ -1,7 +0,0 @@
import modal.cli.run
from .config import config
if __name__ == '__main__':
# modal.cli.run.deploy(app_ref='src.cluster.app', env=config.environment, use_module_mode=True)
# todo : 通过python命令部署不成功换https://modal.com/docs/guide/continuous-deployment推荐的方式通过命令行部署
modal.cli.run.deploy(app_ref='src\\cluster\\app.py', env=config.environment, use_module_mode=False)

View File

@@ -3,13 +3,15 @@ import modal
ffmpeg_worker_image = ( ffmpeg_worker_image = (
modal.Image.debian_slim(python_version="3.11") modal.Image.debian_slim(python_version="3.11")
.apt_install('ffmpeg') .apt_install('ffmpeg')
.pip_install( .pip_install_from_pyproject("pyproject.toml")
'BowongModalFunctions==0.1.1-pre', .add_local_python_source('cluster')
extra_index_url="https://packages-1747117713372:a8865382010036f1e8ad5ca8d0cdab520b78a8ff@g-ldyi2063-pypi.pkg.coding.net/dev/packages/simple", .add_local_python_source('BowongModalFunctions')
)
) )
ffmpeg_app = modal.App(image=ffmpeg_worker_image, include_source=False) app = modal.App(
name="ffmpeg_app",
image=ffmpeg_worker_image,
include_source=False)
with ffmpeg_worker_image.imports(): with ffmpeg_worker_image.imports():
import shutil, psutil, os, backoff, json, sentry_sdk import shutil, psutil, os, backoff, json, sentry_sdk
@@ -109,7 +111,7 @@ with ffmpeg_worker_image.imports():
return s3_outputs return s3_outputs
@ffmpeg_app.function( @app.function(
timeout=1800, timeout=1800,
cloud="aws", cloud="aws",
# region='ap-northeast', # region='ap-northeast',
@@ -128,94 +130,25 @@ with ffmpeg_worker_image.imports():
fn_id = current_function_call_id() fn_id = current_function_call_id()
@sentry_sdk.trace
async def print_media_streams_info(media_paths: List[str]) -> List[Dict[str, Any]]:
"""
打印多个媒体文件的视频和音频流详细信息
:param media_paths: 媒体文件路径列表
"""
res = []
for path in media_paths:
try:
# 使用ffprobe探测媒体文件信息
probe_info_byte = await FFmpeg(executable="ffprobe").input(path, print_format="json",
show_streams=None).execute()
probe_info = json.loads(probe_info_byte)
res.append(probe_info)
except Exception as e:
logger.exception(e)
continue
return res
@SentryUtils.sentry_tracker(name="视频合并任务", op="ffmpeg.concat", fn_id=fn_id, @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_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_process(media_sources: MediaSources, output_filepath: str) -> str: async def ffmpeg_process(media_sources: MediaSources, output_filepath: str) -> str:
ffmpeg_cmd = VideoUtils.ffmpeg_init_async() input_videos = [f"{s3_mount}/{media_source.cache_filepath}" for media_source in media_sources.inputs]
input_videos = [] local_output_path = await VideoUtils.ffmpeg_concat_medias(media_paths=input_videos,
for media_source in media_sources.inputs: output_path=output_filepath)
cache_filepath = f"{s3_mount}/{media_source.cache_filepath}" return local_output_path
logger.info(cache_filepath)
ffmpeg_cmd.input(cache_filepath)
input_videos.append(cache_filepath)
target_width = 1080
target_height = 1920
# video_info = await print_media_streams_info(media_paths=input_videos)
filter_complex = []
total_videos = len(input_videos)
# 2. 统一所有视频的格式、分辨率和帧率
for i in range(total_videos):
filter_complex.extend(
[
# 先缩放到统一分辨率,然后设置帧率和格式
f"[{i}:v]scale={target_width}:{target_height}:force_original_aspect_ratio=decrease,"
f"pad={target_width}:{target_height}:(ow-iw)/2:(oh-ih)/2,"
f"setsar=1:1," # 新增强制设置SAR
f"fps=30,format=yuv420p[v{i}]",
f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a{i}]",
]
)
# 3. 准备处理后的视频流和音频流的连接字符串
video_streams = "".join(f"[v{i}]" for i in range(total_videos))
audio_streams = "".join(f"[a{i}]" for i in range(total_videos))
# 4. 使用concat过滤器合并视频和音频
filter_complex.extend(
[
f"{video_streams}concat=n={total_videos}:v=1:a=0[vconcated]",
f"{audio_streams}concat=n={total_videos}:v=0:a=1[aconcated]",
]
)
ffmpeg_cmd.output(
output_filepath,
{
"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()
s3_outputs = local_copy_to_s3([output_filepath])
return s3_outputs[0]
output_path = f"{output_path_prefix}/concat/outputs/{fn_id}/output.mp4" output_path = f"{output_path_prefix}/concat/outputs/{fn_id}/output.mp4"
os.makedirs(os.path.dirname(output_path), exist_ok=True) local_output = await ffmpeg_process(media_sources=medias, output_filepath=output_path)
s3_output = await ffmpeg_process(medias, output_filepath=output_path) s3_outputs = local_copy_to_s3([local_output])
return s3_output, sentry_trace 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
@ffmpeg_app.function( @app.function(
timeout=900, timeout=900,
cloud="aws", cloud="aws",
max_containers=config.ffmpeg_worker_concurrency, max_containers=config.ffmpeg_worker_concurrency,
@@ -243,7 +176,7 @@ with ffmpeg_worker_image.imports():
cache_filepath = f"{s3_mount}/{media_source.cache_filepath}" cache_filepath = f"{s3_mount}/{media_source.cache_filepath}"
logger.info(cache_filepath) logger.info(cache_filepath)
outputs: List[str] = [] outputs: List[str] = []
ffmpeg_cmd = VideoUtils.ffmpeg_init_async() ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
ffmpeg_cmd.input(cache_filepath) ffmpeg_cmd.input(cache_filepath)
filter_complex: List[str] = [] filter_complex: List[str] = []
for index, marker in enumerate(media_markers): for index, marker in enumerate(media_markers):
@@ -287,7 +220,7 @@ with ffmpeg_worker_image.imports():
hls_m3u8_url = media_source.path hls_m3u8_url = media_source.path
logger.info(hls_m3u8_url) logger.info(hls_m3u8_url)
outputs: List[str] = [] outputs: List[str] = []
ffmpeg_cmd = VideoUtils.ffmpeg_init_async() ffmpeg_cmd = VideoUtils.async_ffmpeg_init()
ffmpeg_cmd.input(hls_m3u8_url, ffmpeg_cmd.input(hls_m3u8_url,
protocol_whitelist="file,http,https,tcp,tls", protocol_whitelist="file,http,https,tcp,tls",
reconnect="1", # 自动重连 reconnect="1", # 自动重连
@@ -338,9 +271,7 @@ with ffmpeg_worker_image.imports():
return outputs, sentry_trace return outputs, sentry_trace
@ffmpeg_app.function( @app.function(timeout=600, cloud="aws",
timeout=600,
cloud="aws",
max_containers=config.ffmpeg_worker_concurrency, max_containers=config.ffmpeg_worker_concurrency,
volumes={ volumes={
f"/mntS3": modal.CloudBucketMount( f"/mntS3": modal.CloudBucketMount(
@@ -376,13 +307,7 @@ with ffmpeg_worker_image.imports():
return output, sentry_trace return output, sentry_trace
async def ffmpeg_overlay_gif(media: MediaSource, gif: MediaSource): @app.function(timeout=600, cloud="aws",
raise NotImplementedError
@ffmpeg_app.function(
timeout=600,
cloud="aws",
max_containers=config.ffmpeg_worker_concurrency, max_containers=config.ffmpeg_worker_concurrency,
volumes={ volumes={
f"/mntS3": modal.CloudBucketMount( f"/mntS3": modal.CloudBucketMount(
@@ -406,34 +331,14 @@ with ffmpeg_worker_image.imports():
mirror_from_right: bool = True, mirror_from_right: bool = True,
mirror_position: tuple[float, float] = (40, 40)) -> str: mirror_position: tuple[float, float] = (40, 40)) -> str:
media_filepath = f"{s3_mount}/{media.cache_filepath}" media_filepath = f"{s3_mount}/{media.cache_filepath}"
video_metadata = VideoUtils.ffprobe_video_format(media_filepath)
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]",
]
output_path = f"{output_path_prefix}/corner_mirror/outputs/{func_id}/output.mp4" output_path = f"{output_path_prefix}/corner_mirror/outputs/{func_id}/output.mp4"
os.makedirs(os.path.dirname(output_path), exist_ok=True) local_output_filepath = await VideoUtils.ffmpeg_corner_mirror(media_path=media_filepath,
ffmpeg_cmd = VideoUtils.ffmpeg_init_async() output_path=output_path,
ffmpeg_cmd.input(media_filepath).output(output_path, mirror_from_right=mirror_from_right,
options={"filter_complex": ";".join(filter_complex)}, mirror_position=mirror_position,
map=["[video]", "0:a"], mirror_scale_down_size=mirror_scale_down_size)
vcodec="libx264",
crf=16, s3_outputs = local_copy_to_s3([local_output_filepath])
b=video_metadata.video_bitrate, # 视频码率
r=video_metadata.video_frame_rate # 帧率
)
await ffmpeg_cmd.execute()
s3_outputs = local_copy_to_s3([output_path])
return s3_outputs[0] return s3_outputs[0]
result = await ffmpeg_process(media=media, mirror_scale_down_size=mirror_scale_down_size, func_id=fn_id, result = await ffmpeg_process(media=media, mirror_scale_down_size=mirror_scale_down_size, func_id=fn_id,
@@ -444,23 +349,149 @@ with ffmpeg_worker_image.imports():
return result, sentry_trace return result, sentry_trace
async def ffmpeg_zoom_loop(media: MediaSource, duration: int = 6, zoom: float = 0.1): @app.function(timeout=600, cloud="aws",
raise NotImplementedError max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
async def ffmpeg_bgm_nosie_reduce(media: MediaSource, bgm: MediaSource, noise_sample: Optional[MediaSource] = None): bucket_name=config.s3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.environment),
),
}, )
@modal.concurrent(max_inputs=1)
async def ffmpeg_overlay_gif(media: MediaSource, gif: MediaSource,
sentry_trace: Optional[SentryTransactionInfo] = None):
fn_id = current_function_call_id() fn_id = current_function_call_id()
async def ffmpeg_process(video: MediaSource, bgm: MediaSource, noise_sample: Optional[MediaSource] = None): @SentryUtils.sentry_tracker(name="视频叠加GIF特效", op="ffmpeg.overlay", fn_id=fn_id,
local_input_filepath = f"{s3_mount}/{media.cache_filepath}" sentry_trace_id=sentry_trace.x_trace_id if sentry_trace else None,
audio_filepath = FileUtils.file_path_extend(local_input_filepath, "audio").replace('mp4', 'wav') sentry_baggage=sentry_trace.x_baggage if sentry_trace else None)
output_audio_filepath = f"{s3_mount}/{audio_filepath}" async def ffmpeg_process(media: MediaSource, func_id: str, gif: MediaSource) -> str:
await VideoUtils.ffmpeg_extract_audio_async(local_input_filepath, output_audio_filepath) media_filepath = f"{s3_mount}/{media.cache_filepath}"
# VideoUtils.noise_reduce() gif_filepath = f"{s3_mount}/{gif.cache_filepath}"
raise NotImplementedError output_path = f"{output_path_prefix}/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)
s3_outputs = local_copy_to_s3([local_output_filepath])
return s3_outputs[0]
raise NotImplementedError result = await ffmpeg_process(media=media, func_id=fn_id, gif=gif)
if not sentry_trace:
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
x_baggage=sentry_sdk.get_baggage())
return result, sentry_trace
async def ffmpeg_subtitle_apply(media: MediaSource, subtitle: MediaSource): @app.function(timeout=600, cloud="aws",
raise NotImplementedError max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
bucket_name=config.s3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.environment),
),
}, )
@modal.concurrent(max_inputs=1)
async def ffmpeg_zoom_loop(media: MediaSource, duration: int = 6, zoom: float = 0.1,
sentry_trace: Optional[SentryTransactionInfo] = 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)
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"
local_output_filepath = await VideoUtils.ffmpeg_zoom_loop(media_path=media_filepath,
output_path=output_path,
duration=duration,
zoom=zoom)
s3_outputs = local_copy_to_s3([local_output_filepath])
return s3_outputs[0]
result = await ffmpeg_process(media=media, duration=duration, zoom=zoom, 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 result, sentry_trace
@app.function(timeout=600, cloud="aws",
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
bucket_name=config.s3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.environment),
),
}, )
@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):
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)
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"
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,
music_volume=music_volume,
noise_sample_path=noise_sample_path,
output_path=output_path)
s3_outputs = local_copy_to_s3([local_output_filepath])
return s3_outputs
result = await ffmpeg_process(video=media, bgm=bgm, video_volume=video_volume,
music_volume=music_volume, noise_sample=noise_sample)
if not sentry_trace:
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
x_baggage=sentry_sdk.get_baggage())
return result, sentry_trace
@app.function(timeout=600, cloud="aws",
max_containers=config.ffmpeg_worker_concurrency,
volumes={
f"/mntS3": modal.CloudBucketMount(
bucket_name=config.s3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret",
environment_name=config.environment),
),
}, )
@modal.concurrent(max_inputs=1)
async def ffmpeg_subtitle_apply(media: MediaSource, subtitle: MediaSource,
sentry_trace: Optional[SentryTransactionInfo] = 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:
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"
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)
if not sentry_trace:
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
x_baggage=sentry_sdk.get_baggage())
return result, sentry_trace

View File

@@ -3,13 +3,14 @@ import modal
downloader_image = ( downloader_image = (
modal.Image modal.Image
.debian_slim(python_version="3.11") .debian_slim(python_version="3.11")
.pip_install( .pip_install_from_pyproject("pyproject.toml")
'BowongModalFunctions==0.1.1-pre', .add_local_python_source('cluster')
extra_index_url="https://packages-1747117713372:a8865382010036f1e8ad5ca8d0cdab520b78a8ff@g-ldyi2063-pypi.pkg.coding.net/dev/packages/simple", .add_local_python_source('BowongModalFunctions')
)
) )
media_app = modal.App(image=downloader_image, app = modal.App(
name="media_app",
image=downloader_image,
include_source=False, include_source=False,
secrets=[ secrets=[
modal.Secret.from_name("cf-kv-secret", environment_name='dev'), modal.Secret.from_name("cf-kv-secret", environment_name='dev'),
@@ -110,7 +111,7 @@ with downloader_image.imports():
raise e raise e
@media_app.function(cpu=1, timeout=1800, @app.function(cpu=1, timeout=1800,
cloud="aws", cloud="aws",
# region='ap-northeast', # region='ap-northeast',
max_containers=config.video_downloader_concurrency, max_containers=config.video_downloader_concurrency,
@@ -305,7 +306,7 @@ with downloader_image.imports():
return media return media
@media_app.function(cpu=1, timeout=300, @app.function(cpu=1, timeout=300,
max_containers=config.video_downloader_concurrency, max_containers=config.video_downloader_concurrency,
volumes={ volumes={
"/mntS3": modal.CloudBucketMount( "/mntS3": modal.CloudBucketMount(

View File

@@ -3,21 +3,21 @@ import modal
fastapi_image = ( fastapi_image = (
modal.Image modal.Image
.debian_slim(python_version="3.11") .debian_slim(python_version="3.11")
# .pip_install("fastapi[standard]", "sentry-sdk[fastapi]", .pip_install_from_pyproject("pyproject.toml")
# 'loguru', 'pydantic', 'pydantic_settings', 'scalar-fastapi', 'psutil') .add_local_python_source('cluster')
.pip_install( .add_local_python_source('BowongModalFunctions')
'BowongModalFunctions==0.1.1-pre',
extra_index_url="https://packages-1747117713372:a8865382010036f1e8ad5ca8d0cdab520b78a8ff@g-ldyi2063-pypi.pkg.coding.net/dev/packages/simple",
)
) )
fastapi_app = modal.App(image=fastapi_image, include_source=False) app = modal.App(
name="web_app",
image=fastapi_image,
include_source=False)
with fastapi_image.imports(): with fastapi_image.imports():
from BowongModalFunctions.api import web_app from BowongModalFunctions.api import web_app
@fastapi_app.function(scaledown_window=60, @app.function(scaledown_window=60,
secrets=[ secrets=[
modal.Secret.from_name("cf-kv-secret", environment_name='dev'), modal.Secret.from_name("cf-kv-secret", environment_name='dev'),
]) ])