更新2个ffmpeg worker方法和web调用接口
This commit is contained in:
@@ -5,11 +5,15 @@ description = "管理Modal worker的工程"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
"backoff>=2.2.1",
|
||||
"boto3>=1.37.37",
|
||||
"cos-python-sdk-v5>=1.9.36",
|
||||
"crcmod>=1.7",
|
||||
"fastapi[standard]>=0.115.12",
|
||||
"httpx>=0.28.1",
|
||||
"loguru>=0.7.3",
|
||||
"modal>=0.74.57",
|
||||
"psutil>=7.0.0",
|
||||
"pydantic>=2.11.3",
|
||||
"pydantic-settings>=2.9.1",
|
||||
"python-ffmpeg>=2.0.12",
|
||||
|
||||
@@ -2,12 +2,13 @@ import modal
|
||||
from config import config
|
||||
from video_downloader.worker import worker_app
|
||||
from web.worker import fastapi_app
|
||||
from ffmpeg_worker.worker import ffmpeg_worker_app
|
||||
from ffmpeg_worker.worker import app as ffmpeg_app
|
||||
|
||||
app = modal.App('video-downloader',
|
||||
app = modal.App(config.app_name,
|
||||
include_source=False,
|
||||
secrets=[modal.Secret.from_name("cf-kv-secret",
|
||||
environment_name=config.environment)])
|
||||
|
||||
app.include(fastapi_app)
|
||||
app.include(worker_app)
|
||||
app.include(ffmpeg_worker_app)
|
||||
app.include(ffmpeg_app)
|
||||
|
||||
@@ -4,10 +4,12 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class WorkerConfig(BaseSettings):
|
||||
app_name: str = Field(default='video-downloader', 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存储桶")
|
||||
cdn_endpoint: Optional[str] = Field(default="https://d2nj71io21vkj2.cloudfront.net",
|
||||
description="集群挂载S3存储桶的对应AWS Cloudfront CDN")
|
||||
@@ -20,6 +22,7 @@ config = WorkerConfig(
|
||||
video_downloader_concurrency=10,
|
||||
ffmpeg_worker_concurrency=10,
|
||||
modal_kv_name="media-cache",
|
||||
s3_region='ap-northeast-2',
|
||||
s3_bucket_name="modal-media-cache",
|
||||
environment="dev"
|
||||
)
|
||||
)
|
||||
|
||||
@@ -10,3 +10,11 @@ class FileUtils:
|
||||
filenames[0] = f"{filenames[0]}_{extend}"
|
||||
extend_filename = '.'.join(filenames)
|
||||
return os.path.join(media_dir, extend_filename)
|
||||
|
||||
@staticmethod
|
||||
def file_path_replace_root_prefix(media_path: str, prefix: str, depth: int = 1) -> str:
|
||||
media_dirs = media_path.split('/')
|
||||
if depth >= len(media_dirs):
|
||||
raise IndexError("Depth is out of range")
|
||||
media_dir_prefix = prefix + '/'.join(media_dirs[depth:])
|
||||
return media_dir_prefix
|
||||
|
||||
@@ -1,25 +1,33 @@
|
||||
import os.path
|
||||
|
||||
import modal
|
||||
from src.cluster.config import config
|
||||
|
||||
ffmpeg_worker_image = (
|
||||
modal.Image.debian_slim(python_version="3.11")
|
||||
.apt_install('ffmpeg')
|
||||
.pip_install('sentry-sdk', 'loguru', 'pydantic', 'pydantic_settings', 'httpx', 'python-ffmpeg')
|
||||
.pip_install('sentry-sdk', 'loguru', 'pydantic', 'pydantic_settings', 'httpx', 'python-ffmpeg', 'psutil', 'backoff')
|
||||
.add_local_python_source("src.cluster.config", copy=True)
|
||||
.add_local_python_source("src.cluster.ffmpeg_worker.model", copy=True)
|
||||
.add_local_python_source("src.cluster.video_downloader.model", copy=True)
|
||||
)
|
||||
|
||||
ffmpeg_worker_app = modal.App("ffmpeg_worker_app", image=ffmpeg_worker_image, )
|
||||
ffmpeg_worker_app.set_description("FFMPEG worker app")
|
||||
app = modal.App(image=ffmpeg_worker_image, )
|
||||
|
||||
with ffmpeg_worker_image.imports():
|
||||
from typing import List
|
||||
from src.cluster.config import config
|
||||
from typing import List, Optional, Tuple, Dict, Any
|
||||
from loguru import logger
|
||||
import sentry_sdk
|
||||
from src.cluster.video_downloader.model import MediaSource, MediaCache
|
||||
from src.cluster.ffmpeg_worker.model import FFMpegSliceSegment, TimeDelta
|
||||
from ffmpeg.asyncio import FFmpeg
|
||||
from src.cluster.ffmpeg_worker.Utils.PathUtils import FileUtils
|
||||
from src.cluster.video_downloader.model import (MediaSources,
|
||||
MediaSource,
|
||||
MediaCache,
|
||||
MediaProtocol,
|
||||
MediaCacheStatus)
|
||||
from src.cluster.web.model import SentryTransactionInfo
|
||||
from src.cluster.ffmpeg_worker.model import FFMpegSliceSegment
|
||||
import shutil, psutil, os, backoff, json, sentry_sdk
|
||||
from psutil import svmem
|
||||
from psutil._common import scpufreq
|
||||
from modal import current_function_call_id
|
||||
|
||||
sentry_sdk.init(
|
||||
dsn="https://75cca970bfcc3d45d24361e7c0f1833c@sentry.bowongai.com/4",
|
||||
@@ -33,62 +41,54 @@ with ffmpeg_worker_image.imports():
|
||||
modal_kv = modal.Dict.from_name(config.modal_kv_name,
|
||||
environment_name=config.environment,
|
||||
create_if_missing=True)
|
||||
|
||||
s3_mount = "/mntS3"
|
||||
output_path_prefix = "/mnt/outputs"
|
||||
|
||||
@sentry_sdk.trace
|
||||
def get_cache_filepath(media: MediaSource) -> MediaCache:
|
||||
cache_data_json = modal_kv.get(media.cache_key)
|
||||
if cache_data_json:
|
||||
"""
|
||||
获取缓存路径
|
||||
:param media: 媒体文件
|
||||
:return:
|
||||
"""
|
||||
# 本地挂载缓存
|
||||
if media.protocol == MediaProtocol.s3 and media.endpoint == config.s3_region and media.bucket == config.bucket:
|
||||
cache_data = MediaCache(status=MediaCacheStatus.ready, cache_filepath=f"{s3_mount}/{media.get_cdn_url()}")
|
||||
else:
|
||||
cache_data_json = modal_kv.get(media.cache_key)
|
||||
if not cache_data_json:
|
||||
raise FileNotFoundError(f"{media.cache_key} cache not found")
|
||||
cache_data = MediaCache.model_validate_json(cache_data_json)
|
||||
return cache_data
|
||||
raise FileNotFoundError(f"{media.cache_key} cache not found")
|
||||
return cache_data
|
||||
|
||||
|
||||
@ffmpeg_worker_app.function(
|
||||
cpu=6, timeout=1800, memory=(2048, 6144),
|
||||
# cloud="aws",
|
||||
# region='ap-northeast',
|
||||
max_containers=config.ffmpeg_worker_concurrency,
|
||||
volumes={
|
||||
"/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)
|
||||
def ffmpeg_concat_medias():
|
||||
return {}
|
||||
@sentry_sdk.trace
|
||||
def capture_hardware_info() -> Tuple[scpufreq, int, svmem]:
|
||||
cpu_freq = psutil.cpu_freq()
|
||||
cpu_count = psutil.cpu_count()
|
||||
mem = psutil.virtual_memory()
|
||||
|
||||
return (cpu_freq, cpu_count, mem)
|
||||
|
||||
|
||||
@ffmpeg_worker_app.function(
|
||||
cpu=12, timeout=900, memory=(2048, 4096),
|
||||
cloud="aws",
|
||||
max_containers=config.ffmpeg_worker_concurrency,
|
||||
volumes={
|
||||
"/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_slice_media(media: MediaSource, markers: List[FFMpegSliceSegment], ):
|
||||
from src.cluster.ffmpeg_worker.Utils.PathUtils import FileUtils
|
||||
from ffmpeg.asyncio import FFmpeg
|
||||
|
||||
cache = get_cache_filepath(media)
|
||||
@sentry_sdk.trace
|
||||
async def ffmpeg_slice_process(media_source: MediaSource, media_markers: List[FFMpegSliceSegment], fn_id: str) -> \
|
||||
List[str]:
|
||||
cache = get_cache_filepath(media_source)
|
||||
logger.info(cache.cache_filepath)
|
||||
outputs: List[str] = []
|
||||
ffmpeg_cmd = FFmpeg().option('y').option('hide_banner').input(cache.cache_filepath)
|
||||
filter_complex: List[str] = []
|
||||
for index, marker in enumerate(markers):
|
||||
for index, marker in enumerate(media_markers):
|
||||
segment_out_index = f"cut{index}"
|
||||
filter_complex.append(
|
||||
f"[0]trim=start={marker.start.total_seconds()}:end={marker.end.total_seconds()},setpts=PTS-STARTPTS[{segment_out_index}]")
|
||||
ffmpeg_cmd.option('filter_complex', ';'.join(filter_complex))
|
||||
for i, marker in enumerate(markers):
|
||||
output_filepath = FileUtils.file_path_extend(cache.cache_filepath, str(i))
|
||||
# f"{marker.start.toFormatStr()}_{marker.end.toFormatStr()}")
|
||||
|
||||
for i, marker in enumerate(media_markers):
|
||||
filename = FileUtils.file_path_extend(os.path.basename(cache.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)
|
||||
@@ -97,7 +97,7 @@ with ffmpeg_worker_image.imports():
|
||||
reset_timestamps="1",
|
||||
sc_threshold="0",
|
||||
g="1",
|
||||
force_key_frames="expr:gte(t, n_forced * 1)",
|
||||
force_key_frames="expr:gte(t,n_forced*1)",
|
||||
vcodec="libx264",
|
||||
crf=16,
|
||||
r=30, )
|
||||
@@ -124,9 +124,236 @@ with ffmpeg_worker_image.imports():
|
||||
logger.info(f"FFMpeg task completed.")
|
||||
|
||||
@ffmpeg_cmd.on("stderr")
|
||||
def on_stderr(line):
|
||||
logger.warning(line)
|
||||
def on_stderr(line: str):
|
||||
if line.startswith('Error'):
|
||||
logger.error(line)
|
||||
raise RuntimeError(line)
|
||||
else:
|
||||
logger.warning(line)
|
||||
|
||||
await ffmpeg_cmd.execute()
|
||||
|
||||
return outputs
|
||||
s3_outputs = local_copy_to_s3(outputs)
|
||||
|
||||
return s3_outputs
|
||||
|
||||
|
||||
@backoff.on_exception(backoff.constant, exception=Exception, max_tries=5, max_time=60, raise_on_giveup=True)
|
||||
def local_copy_to_s3(local_outputs: List[str]) -> List[str]:
|
||||
s3_outputs = []
|
||||
for output in local_outputs:
|
||||
out_s3 = output.replace(output_path_prefix, s3_mount)
|
||||
out_s3_dir = os.path.dirname(out_s3)
|
||||
logger.info(out_s3_dir)
|
||||
os.makedirs(out_s3_dir, exist_ok=True)
|
||||
shutil.copy(output, out_s3)
|
||||
s3_outputs.append(
|
||||
out_s3.replace(s3_mount + '/', f"s3://{config.s3_region}/{config.s3_bucket_name}/"))
|
||||
return s3_outputs
|
||||
|
||||
@app.function(
|
||||
name="concat_medias",
|
||||
cpu=12, timeout=1800, memory=(2048, 6144),
|
||||
cloud="aws",
|
||||
# region='ap-northeast',
|
||||
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.environment),
|
||||
),
|
||||
}, )
|
||||
@modal.concurrent(max_inputs=1)
|
||||
async def ffmpeg_concat_medias(medias: MediaSources,
|
||||
sentry_trace: Optional[SentryTransactionInfo] = None) -> Tuple[
|
||||
str, Optional[SentryTransactionInfo]]:
|
||||
|
||||
@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
|
||||
|
||||
@sentry_sdk.trace
|
||||
async def ffmpeg_process(media_sources: MediaSources, output_filepath: str) -> None:
|
||||
ffmpeg_cmd = FFmpeg().option('y').option('hide_banner')
|
||||
|
||||
input_videos = []
|
||||
for media_source in media_sources.inputs:
|
||||
cache = get_cache_filepath(media_source)
|
||||
logger.info(cache.cache_filepath)
|
||||
ffmpeg_cmd.input(cache.cache_filepath)
|
||||
input_videos.append(cache.cache_filepath)
|
||||
|
||||
video_info = print_media_streams_info(media_paths=input_videos)
|
||||
filter_complex = []
|
||||
|
||||
for i in range(len(input_videos)):
|
||||
info = video_info[i]
|
||||
streams = info.get("streams", [])
|
||||
video_stream = streams[0]
|
||||
# audio_stream = streams[1]
|
||||
video_duration = video_stream.get("duration")
|
||||
# audio_duration = audio_stream.get("duration")
|
||||
'''
|
||||
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"fps=30,format=yuv420p[v{i}]",
|
||||
f"[{i}:a]aformat=sample_fmts=fltp:sample_rates=44100:channel_layouts=stereo[a{i}]",
|
||||
]
|
||||
)
|
||||
'''
|
||||
filter_complex.extend(
|
||||
[
|
||||
f"[{i}:v]trim=0:{video_duration},setpts=PTS-STARTPTS[v{i}]",
|
||||
f"[{i}:a]atrim=0:{video_duration},asetpts=PTS-STARTPTS[a{i}]",
|
||||
]
|
||||
)
|
||||
|
||||
# 3. 准备处理后的视频流和音频流的连接字符串
|
||||
video_streams = "".join(f"[v{i}]" for i in range(len(input_videos)))
|
||||
audio_streams = "".join(f"[a{i}]" for i in range(len(input_videos)))
|
||||
|
||||
# 4. 使用concat过滤器合并视频和音频
|
||||
filter_complex.extend(
|
||||
[
|
||||
f"{video_streams}concat=n={len(input_videos)}:v=1:a=0[vconcated]",
|
||||
f"{audio_streams}concat=n={len(input_videos)}:v=0:a=1[aconcated]",
|
||||
]
|
||||
)
|
||||
|
||||
@ffmpeg_cmd.on("start")
|
||||
def on_start(arguments: list[str]):
|
||||
try:
|
||||
filter_index = arguments.index("-filter_complex")
|
||||
filter_content = arguments[filter_index + 1]
|
||||
arguments[filter_index + 1] = f'"{filter_content}"'
|
||||
args = " ".join(arguments)
|
||||
logger.info(f"FFmpeg command:{args}")
|
||||
arguments[filter_index + 1] = filter_content
|
||||
except ValueError:
|
||||
args = " ".join(arguments)
|
||||
logger.info(f"FFmpeg command:{args}")
|
||||
|
||||
@ffmpeg_cmd.on("progress")
|
||||
def on_progress(progress):
|
||||
logger.info(f"处理进度: {progress}")
|
||||
|
||||
@ffmpeg_cmd.on("completed")
|
||||
def on_completed():
|
||||
logger.info(f"FFMpeg task completed.")
|
||||
|
||||
@ffmpeg_cmd.on("stderr")
|
||||
def on_stderr(line: str):
|
||||
if line.startswith('Error'):
|
||||
logger.error(line)
|
||||
raise RuntimeError(line)
|
||||
else:
|
||||
logger.warning(line)
|
||||
|
||||
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()
|
||||
|
||||
fn_id = current_function_call_id()
|
||||
cpu_freq, cpu_count, mem = capture_hardware_info()
|
||||
total_mb = mem.total >> 20
|
||||
available_mb = mem.available >> 20
|
||||
logger.info((f"[{fn_id}] Current CPUs {cpu_count} * {cpu_freq.current / 1000:.2f} GHz"))
|
||||
logger.info(f"Current Memories {available_mb} Mb / {total_mb} Mb | {mem.percent}% used")
|
||||
|
||||
if sentry_trace:
|
||||
transaction = sentry_sdk.continue_trace(environ_or_headers={
|
||||
"sentry-trace": sentry_trace.x_trace_id,
|
||||
"baggage": sentry_trace.x_baggage,
|
||||
})
|
||||
else:
|
||||
transaction = sentry_sdk.start_transaction(op='modal.function', name="Modal Function直接调用")
|
||||
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
|
||||
x_baggage=sentry_sdk.get_baggage())
|
||||
|
||||
with transaction:
|
||||
with transaction.start_child(op='ffmpeg.concat', name='视频合并任务') as span:
|
||||
span.set_data('fn.id', fn_id)
|
||||
span.set_data('cpu.count', cpu_count)
|
||||
span.set_data('cpu.frequency', f"{cpu_freq.current / 1000:.2f} GHz")
|
||||
span.set_data('memory.available', f"{total_mb} Mb")
|
||||
output_path = f"{output_path_prefix}/concat/outputs/{fn_id}/output.mp4"
|
||||
await ffmpeg_process(medias, output_filepath=output_path)
|
||||
s3_outputs = local_copy_to_s3([output_path])
|
||||
|
||||
return s3_outputs[0], sentry_trace
|
||||
|
||||
|
||||
@app.function(
|
||||
name="slice_media",
|
||||
cpu=12, timeout=900, memory=(2048, 4096),
|
||||
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.environment),
|
||||
),
|
||||
},
|
||||
)
|
||||
@modal.concurrent(max_inputs=1)
|
||||
async def ffmpeg_slice_media(media: MediaSource,
|
||||
markers: List[FFMpegSliceSegment],
|
||||
sentry_trace: Optional[SentryTransactionInfo] = None) -> Tuple[
|
||||
List[str], Optional[SentryTransactionInfo]]:
|
||||
|
||||
fn_id = current_function_call_id()
|
||||
cpu_freq, cpu_count, mem = capture_hardware_info()
|
||||
total_mb = mem.total >> 20
|
||||
available_mb = mem.available >> 20
|
||||
logger.info((f"[{fn_id}] Current CPUs {cpu_count} * {cpu_freq.current / 1000:.2f} GHz"))
|
||||
logger.info(f"Current Memories {available_mb} Mb / {total_mb} Mb | {mem.percent}% used")
|
||||
|
||||
if sentry_trace:
|
||||
transaction = sentry_sdk.continue_trace(environ_or_headers={"sentry-trace": sentry_trace.x_trace_id,
|
||||
"baggage": sentry_trace.x_baggage, })
|
||||
else:
|
||||
transaction = sentry_sdk.start_transaction(op='modal.function', name="Modal Function直接调用")
|
||||
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
|
||||
x_baggage=sentry_sdk.get_baggage())
|
||||
with transaction:
|
||||
with transaction.start_child(op='ffmpeg.slice', name='视频切割任务') as span:
|
||||
span.set_data('fn.id', fn_id)
|
||||
span.set_data('cpu.count', cpu_count)
|
||||
span.set_data('cpu.frequency', f"{cpu_freq.current / 1000:.2f} GHz")
|
||||
span.set_data('memory.available', f"{total_mb} Mb")
|
||||
outputs = await ffmpeg_slice_process(media_source=media, media_markers=markers, fn_id=fn_id)
|
||||
|
||||
return outputs, sentry_trace
|
||||
|
||||
@@ -59,6 +59,13 @@ class MediaSource(BaseModel):
|
||||
]
|
||||
}
|
||||
|
||||
def get_cdn_url(self) -> str:
|
||||
if self.protocol == MediaProtocol.vod:
|
||||
return f"{self.protocol.value}/{self.endpoint}/{self.bucket}/{self.url}.mp4"
|
||||
elif self.protocol == MediaProtocol.s3:
|
||||
return f"{self.url}"
|
||||
return f"{self.protocol.value}/{self.endpoint}/{self.bucket}/{self.url}"
|
||||
|
||||
|
||||
class MediaSources(BaseModel):
|
||||
inputs: List[MediaSource] = Field(examples=[
|
||||
@@ -101,3 +108,7 @@ class MediaCache(BaseModel):
|
||||
|
||||
class CacheResult(BaseModel):
|
||||
caches: Dict[str, MediaCache] = Field(description="Cache ID")
|
||||
|
||||
|
||||
class DownloadResult(BaseModel):
|
||||
urls: List[str] = Field(description="下载链接")
|
||||
|
||||
@@ -1,26 +1,23 @@
|
||||
import modal
|
||||
from src.cluster.config import config
|
||||
|
||||
downloader_image = (
|
||||
modal.Image
|
||||
.debian_slim(python_version="3.11")
|
||||
.pip_install('httpx', 'cos-python-sdk-v5', 'loguru', 'pydantic', 'pydantic_settings', 'sentry-sdk[loguru]',
|
||||
'tqdm', 'crcmod', 'tencentcloud-sdk-python-common', 'tencentcloud-sdk-python-vod')
|
||||
.add_local_python_source("src.cluster.config", copy=True)
|
||||
.add_local_python_source("src.cluster.video_downloader.model", copy=True)
|
||||
.add_local_python_source("src.cluster.web.model", copy=True)
|
||||
.add_local_python_source("src.cluster.config", copy=True)
|
||||
)
|
||||
|
||||
worker_app = modal.App("video-downloader-worker", image=downloader_image, secrets=[
|
||||
modal.Secret.from_name("cf-kv-secret", environment_name=config.environment),
|
||||
])
|
||||
|
||||
with downloader_image.imports():
|
||||
import os, httpx, crcmod
|
||||
from tqdm import tqdm
|
||||
from typing import Tuple, Dict
|
||||
from loguru import logger
|
||||
from modal import current_function_call_id
|
||||
from src.cluster.config import config
|
||||
from src.cluster.video_downloader.model import MediaSource, MediaCacheStatus, MediaCache, MediaProtocol
|
||||
from src.cluster.web.model import SentryTransactionInfo
|
||||
from datetime import datetime, UTC, timedelta
|
||||
@@ -28,6 +25,11 @@ with downloader_image.imports():
|
||||
from tencentcloud.vod.v20180717.vod_client import VodClient
|
||||
from tencentcloud.vod.v20180717 import models as vod_request_models
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.loguru import LoguruIntegration
|
||||
|
||||
worker_app = modal.App(image=downloader_image, secrets=[
|
||||
modal.Secret.from_name("cf-kv-secret", environment_name=config.environment),
|
||||
])
|
||||
|
||||
sentry_sdk.init(dsn="https://85632fdcd62f699c2f88af6ca489e9ec@sentry.bowongai.com/3",
|
||||
send_default_pii=True,
|
||||
@@ -35,6 +37,7 @@ with downloader_image.imports():
|
||||
profiles_sample_rate=1.0,
|
||||
add_full_stack=True,
|
||||
shutdown_timeout=2,
|
||||
integrations=[LoguruIntegration()],
|
||||
environment=config.environment,
|
||||
)
|
||||
|
||||
@@ -217,8 +220,8 @@ with downloader_image.imports():
|
||||
create_if_missing=True)
|
||||
fn_id = current_function_call_id()
|
||||
|
||||
with sentry_sdk.continue_trace(environ_or_headers={"sentry-trace": sentry_trace.trace_id,
|
||||
"baggage": sentry_trace.baggage, }) as transaction:
|
||||
with sentry_sdk.continue_trace(environ_or_headers={"sentry-trace": sentry_trace.x_trace_id,
|
||||
"baggage": sentry_trace.x_baggage, }) as transaction:
|
||||
transaction.set_context("runtime_environment", {
|
||||
"MODAL_CLOUD_PROVIDER": os.environ.get('MODAL_CLOUD_PROVIDER', 'unknown'),
|
||||
"MODAL_ENVIRONMENT": os.environ.get('MODAL_ENVIRONMENT', 'unknown'),
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
from enum import Enum
|
||||
from typing import List, Union, Any, Optional
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, ConfigDict
|
||||
from src.cluster.ffmpeg_worker.model import FFMpegSliceSegment
|
||||
from src.cluster.video_downloader.model import MediaSource
|
||||
from src.cluster.video_downloader.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):
|
||||
trace_id: str = Field(description="Sentry Transaction ID")
|
||||
baggage: str = Field(description="Sentry Transaction baggage")
|
||||
x_trace_id: str = Field(description="Sentry Transaction ID")
|
||||
x_baggage: str = Field(description="Sentry Transaction baggage")
|
||||
|
||||
|
||||
class FFMPEGSliceRequest(BaseModel):
|
||||
@@ -27,12 +30,12 @@ class FFMPEGSliceRequest(BaseModel):
|
||||
raise TypeError(v)
|
||||
|
||||
|
||||
class FFMPEGSliceResponse(BaseModel):
|
||||
success: bool = Field(description="任务创建成功与否")
|
||||
class FFMPEGSliceTaskStatusRequest(BaseModel):
|
||||
taskId: str = Field(description="任务Id")
|
||||
|
||||
|
||||
class FFMPEGSliceTaskStatusRequest(BaseModel):
|
||||
class ModalTaskResponse(BaseModel):
|
||||
success: bool = Field(description="任务接受成功")
|
||||
taskId: str = Field(description="任务Id")
|
||||
|
||||
|
||||
@@ -43,7 +46,16 @@ class TaskStatus(str, Enum):
|
||||
expired = "expired"
|
||||
|
||||
|
||||
class FFMPEGSliceTaskStatusResponse(BaseModel):
|
||||
class BaseFFMPEGTaskStatusResponse(BaseModel):
|
||||
taskId: str = Field(description="任务Id")
|
||||
status: TaskStatus = Field(description="任务运行状态")
|
||||
error: Optional[str] = Field(description="任务错误原因", default=None)
|
||||
|
||||
model_config = ConfigDict(extra='ignore')
|
||||
|
||||
|
||||
class FFMPEGSliceTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
|
||||
result: Optional[List[str]] = Field(default=None, description="任务运行结果")
|
||||
|
||||
class FFMPEGConcatTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
|
||||
result: Optional[str] = Field(default=None, description="任务运行结果")
|
||||
@@ -1,5 +1,4 @@
|
||||
import modal
|
||||
from src.cluster.config import config
|
||||
|
||||
fastapi_image = (
|
||||
modal.Image
|
||||
@@ -11,9 +10,31 @@ fastapi_image = (
|
||||
.add_local_python_source("src.cluster.config", copy=True)
|
||||
)
|
||||
|
||||
fastapi_app = modal.App("video-downloader-web", image=fastapi_image)
|
||||
|
||||
with fastapi_image.imports():
|
||||
import os
|
||||
import httpx
|
||||
from typing import Dict, 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.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.responses import JSONResponse, RedirectResponse
|
||||
from fastapi.exceptions import HTTPException
|
||||
from starlette import status
|
||||
from scalar_fastapi import get_scalar_api_reference
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.loguru import LoguruIntegration
|
||||
from sentry_sdk.integrations.loguru import LoggingLevels
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from src.cluster.config import config
|
||||
from src.cluster.video_downloader.model import MediaSource, MediaSources, MediaCacheStatus, MediaCache, \
|
||||
CacheResult, DownloadResult
|
||||
from src.cluster.web.model import SentryTransactionInfo, SentryTransactionHeader, FFMPEGSliceRequest, \
|
||||
FFMPEGSliceTaskStatusResponse, FFMPEGConcatTaskStatusResponse, TaskStatus, ModalTaskResponse
|
||||
|
||||
fastapi_app = modal.App(image=fastapi_image)
|
||||
|
||||
@fastapi_app.function(scaledown_window=60,
|
||||
secrets=[
|
||||
modal.Secret.from_name("cf-kv-secret", environment_name=config.environment),
|
||||
@@ -21,26 +42,6 @@ with fastapi_image.imports():
|
||||
@modal.concurrent(max_inputs=100)
|
||||
@modal.asgi_app()
|
||||
def fastapi_webapp():
|
||||
import os
|
||||
import httpx
|
||||
from typing import Dict, List
|
||||
from modal import current_function_call_id
|
||||
from loguru import logger
|
||||
from fastapi import FastAPI, Request, Depends
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.encoders import jsonable_encoder
|
||||
from starlette import status
|
||||
from scalar_fastapi import get_scalar_api_reference
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.loguru import LoguruIntegration
|
||||
from sentry_sdk.integrations.loguru import LoggingLevels
|
||||
from sentry_sdk.integrations.fastapi import FastApiIntegration
|
||||
from src.cluster.video_downloader.model import MediaSources, MediaCacheStatus, MediaCache, CacheResult
|
||||
from src.cluster.web.model import SentryTransactionInfo, FFMPEGSliceRequest, FFMPEGSliceResponse, \
|
||||
FFMPEGSliceTaskStatusRequest, FFMPEGSliceTaskStatusResponse, TaskStatus
|
||||
|
||||
bearer_scheme = HTTPBearer()
|
||||
|
||||
web_app = FastAPI(title="Modal worker API",
|
||||
@@ -82,6 +83,21 @@ with fastapi_image.imports():
|
||||
cf_kv_api_token = os.environ.get("CF_KV_API_TOKEN")
|
||||
cf_kv_namespace_id = os.environ.get("CF_KV_NAMESPACE_ID")
|
||||
|
||||
sentry_header_schema = {
|
||||
"x-trace-id": {
|
||||
"description": "Sentry Transaction ID",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
}
|
||||
},
|
||||
"x-baggage": {
|
||||
"description": "Sentry Transaction baggage",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@sentry_sdk.trace
|
||||
def batch_update_cloudflare_kv(caches: Dict[str, MediaCache]):
|
||||
with httpx.Client() as client:
|
||||
@@ -129,11 +145,53 @@ with fastapi_image.imports():
|
||||
logger.error(f"An unexpected error occurred: {str(e)}")
|
||||
raise e
|
||||
|
||||
@sentry_sdk.trace
|
||||
async def get_modal_task_status(task_id: str) -> Tuple[
|
||||
TaskStatus, Optional[str], Optional[Any], Optional[SentryTransactionInfo]]:
|
||||
"""
|
||||
|
||||
:param task_id:
|
||||
:return: (TaskStaus, errorReason, results, sentryTransactionInfo)
|
||||
"""
|
||||
|
||||
try:
|
||||
fn_task = modal.FunctionCall.from_id(task_id)
|
||||
call_graph = fn_task.get_call_graph()
|
||||
task = call_graph[0]
|
||||
match task.status:
|
||||
case InputStatus.PENDING:
|
||||
return TaskStatus.running, None, None, None
|
||||
case InputStatus.SUCCESS:
|
||||
try:
|
||||
results, sentry_trace = fn_task.get(timeout=2)
|
||||
return TaskStatus.success, None, results, sentry_trace
|
||||
except modal.exception.OutputExpiredError:
|
||||
return TaskStatus.expired, None, None, None
|
||||
except TimeoutError:
|
||||
return TaskStatus.running, None, None, None
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return TaskStatus.failed, None, None, None
|
||||
case _:
|
||||
error = 'FAILURE'
|
||||
match task.status:
|
||||
case InputStatus.INIT_FAILURE:
|
||||
error = 'INIT_FAILURE'
|
||||
case InputStatus.TERMINATED:
|
||||
error = 'TERMINATED'
|
||||
case InputStatus.TIMEOUT:
|
||||
error = 'TIMEOUT'
|
||||
return TaskStatus.failed, error, None, None
|
||||
except Exception as e:
|
||||
logger.exception(e)
|
||||
raise RuntimeError("Modal task status check failed")
|
||||
|
||||
@web_app.get("/scalar", include_in_schema=False)
|
||||
async def scalar():
|
||||
return get_scalar_api_reference(openapi_url=web_app.openapi_schema, title="Modal worker web endpoint")
|
||||
|
||||
@web_app.post("/cache",
|
||||
tags=["缓存"],
|
||||
summary="缓存视频文件",
|
||||
description="异步缓存视频文件到S3存储桶和Modal Dict(KV)",
|
||||
dependencies=[Depends(verify_token)])
|
||||
@@ -142,8 +200,8 @@ with fastapi_image.imports():
|
||||
fn_id = current_function_call_id()
|
||||
caches: Dict[str, MediaCache] = {}
|
||||
parent = sentry_sdk.get_current_span()
|
||||
sentry_trace = SentryTransactionInfo(trace_id=sentry_sdk.get_traceparent(),
|
||||
baggage=sentry_sdk.get_baggage())
|
||||
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
|
||||
x_baggage=sentry_sdk.get_baggage())
|
||||
for media in medias.inputs:
|
||||
with parent.start_child(name="同步视频缓存", op="cache.get") as cache_span:
|
||||
cache_span.set_data("runner_id", fn_id)
|
||||
@@ -156,7 +214,7 @@ with fastapi_image.imports():
|
||||
# start new download task
|
||||
with cache_span.start_child(name="视频缓存任务入队",
|
||||
op="queue.publish") as queue_publish_span:
|
||||
fn = modal.Function.from_name('video-downloader', 'cache_submit')
|
||||
fn = modal.Function.from_name(config.app_name, 'cache_submit')
|
||||
fn_task = fn.spawn(media, sentry_trace)
|
||||
queue_publish_span.set_data("cache.key", media.cache_key)
|
||||
queue_publish_span.set_data("messaging.message.id", fn_task.object_id)
|
||||
@@ -178,7 +236,7 @@ with fastapi_image.imports():
|
||||
# start new download task
|
||||
with cache_span.start_child(name="视频缓存任务入队",
|
||||
op="queue.publish") as queue_publish_span:
|
||||
fn = modal.Function.from_name('video-downloader', 'cache_submit')
|
||||
fn = modal.Function.from_name(config.app_name, 'cache_submit')
|
||||
fn_task = fn.spawn(media, sentry_trace)
|
||||
queue_publish_span.set_data("cache.key", media.cache_key)
|
||||
queue_publish_span.set_data("messaging.message.id", fn_task.object_id)
|
||||
@@ -197,7 +255,31 @@ with fastapi_image.imports():
|
||||
return CacheResult(caches=caches)
|
||||
# return JSONResponse(content={"caches": jsonable_encoder(caches)})
|
||||
|
||||
@web_app.post("/cache/download",
|
||||
tags=["缓存"],
|
||||
summary="批量获取下载地址",
|
||||
description="获取已缓存的视频下载地址",
|
||||
dependencies=[Depends(verify_token)])
|
||||
@sentry_sdk.trace
|
||||
async def download_caches(medias: MediaSources) -> DownloadResult:
|
||||
cdn_endpoint = config.cdn_endpoint
|
||||
urls = []
|
||||
for media in medias.inputs:
|
||||
urls.append(f"{cdn_endpoint}/{media.get_cdn_url()}")
|
||||
return DownloadResult(urls=urls)
|
||||
|
||||
@web_app.get("/cache/download",
|
||||
tags=["缓存"],
|
||||
summary="下载已缓存的视频",
|
||||
description="通过CDN下载已缓存的视频文件")
|
||||
@sentry_sdk.trace
|
||||
async def download_cache(media: str) -> RedirectResponse:
|
||||
cdn_endpoint = config.cdn_endpoint
|
||||
media = MediaSource.from_str(media)
|
||||
return RedirectResponse(url=f"{cdn_endpoint}/{media.get_cdn_url()}", status_code=status.HTTP_302_FOUND)
|
||||
|
||||
@web_app.delete("/cache/kv",
|
||||
tags=["缓存"],
|
||||
summary="清除KV记录",
|
||||
description="清除当前环境下KV缓存过的所有数据(S3存储桶内的文件会保留)",
|
||||
dependencies=[Depends(verify_token)])
|
||||
@@ -210,6 +292,7 @@ with fastapi_image.imports():
|
||||
return JSONResponse(content={"success": True})
|
||||
|
||||
@web_app.post("/cache/kv",
|
||||
tags=["缓存"],
|
||||
summary="删除对应的KV记录",
|
||||
description="删除请求中对应的视频缓存记录",
|
||||
dependencies=[Depends(verify_token)])
|
||||
@@ -224,6 +307,7 @@ with fastapi_image.imports():
|
||||
return JSONResponse(content={"success": False, "error": str(e)})
|
||||
|
||||
@web_app.post("/cache/media",
|
||||
tags=["缓存"],
|
||||
summary="清除指定的所有缓存",
|
||||
description="清除指定的所有缓存(包括KV记录和S3存储文件)",
|
||||
dependencies=[Depends(verify_token)])
|
||||
@@ -233,7 +317,7 @@ with fastapi_image.imports():
|
||||
try:
|
||||
cache_data_json = media_cache_kv.pop(media.cache_key)
|
||||
cache_data = MediaCache.model_validate_json(cache_data_json)
|
||||
fn = modal.Function.from_name("video-downloader", "cache_delete",
|
||||
fn = modal.Function.from_name(config.app_name, "cache_delete",
|
||||
environment_name=config.environment)
|
||||
deleted_cache = await fn.remote.aio(cache_data)
|
||||
caches[media.cache_key] = deleted_cache
|
||||
@@ -245,37 +329,70 @@ with fastapi_image.imports():
|
||||
batch_remove_cloudflare_kv(keys)
|
||||
return JSONResponse(content={"success": True, "keys": keys})
|
||||
|
||||
@web_app.post("/merge",
|
||||
summary="发起合成任务",
|
||||
description="开发中",
|
||||
@web_app.post("/ffmpeg/slice",
|
||||
tags=["发起任务"],
|
||||
summary="发起切割任务",
|
||||
description="依据打点信息切出多个片段",
|
||||
dependencies=[Depends(verify_token)])
|
||||
async def merge_media(request: Request):
|
||||
body_json = await request.json()
|
||||
return JSONResponse(content={"success": False, "message": "Not Implemented"})
|
||||
async def slice_media(request: FFMPEGSliceRequest,
|
||||
headers: Annotated[SentryTransactionHeader, Header()]) -> ModalTaskResponse:
|
||||
|
||||
@web_app.post("/ffmpeg/slice", summary="发起切割任务", description="依据打点信息切出多个片段",
|
||||
dependencies=[Depends(verify_token)])
|
||||
async def slice_media(request: FFMPEGSliceRequest):
|
||||
fn = modal.Function.from_name("video-downloader", "ffmpeg_slice_media", environment_name=config.environment)
|
||||
fn_call = fn.spawn(request.media, request.markers)
|
||||
return FFMPEGSliceResponse(success=True, taskId=fn_call.object_id)
|
||||
fn = modal.Function.from_name(config.app_name, "ffmpeg_slice_media", 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(request.media, request.markers, sentry_trace)
|
||||
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
|
||||
|
||||
@web_app.get("/ffmpeg/slice/{task_id}", summary="查询切割任务状态/结果", description="根据任务Id查询运行状态",
|
||||
@web_app.get("/ffmpeg/slice/{task_id}",
|
||||
tags=["查询任务"],
|
||||
summary="查询切割任务状态/结果",
|
||||
description="根据任务Id查询运行状态",
|
||||
responses={
|
||||
status.HTTP_200_OK: {
|
||||
"description": "",
|
||||
"headers": sentry_header_schema
|
||||
},
|
||||
},
|
||||
dependencies=[Depends(verify_token)])
|
||||
async def slice_media(task_id: str):
|
||||
try:
|
||||
fn_task = modal.FunctionCall.from_id(task_id)
|
||||
try:
|
||||
results: List[str] = fn_task.get(timeout=2)
|
||||
return FFMPEGSliceTaskStatusResponse(taskId=task_id, status=TaskStatus.success, result=results)
|
||||
except modal.exception.OutputExpiredError:
|
||||
return FFMPEGSliceTaskStatusResponse(taskId=task_id, status=TaskStatus.expired)
|
||||
except TimeoutError:
|
||||
return FFMPEGSliceTaskStatusResponse(taskId=task_id, status=TaskStatus.running)
|
||||
except Exception as e:
|
||||
logger.error(e)
|
||||
return FFMPEGSliceTaskStatusResponse(taskId=task_id, status=TaskStatus.failed)
|
||||
except Exception as e:
|
||||
return JSONResponse(content={"success": False, "message": "任务Id不存在"}, status_code=400)
|
||||
async def slice_media(task_id: str, response: Response) -> FFMPEGSliceTaskStatusResponse:
|
||||
task_status, reason, results, 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 FFMPEGSliceTaskStatusResponse(taskId=task_id, status=TaskStatus.failed, error=reason)
|
||||
|
||||
@web_app.post("/ffmpeg/concat",
|
||||
tags=["发起任务"],
|
||||
summary="发起合并任务",
|
||||
description="依据AI分析的结果发起合并任务",
|
||||
dependencies=[Depends(verify_token)])
|
||||
async def concat_media(medias: MediaSources,
|
||||
headers: Annotated[SentryTransactionHeader, Header()]) -> ModalTaskResponse:
|
||||
fn = modal.Function.from_name(config.app_name, "ffmpeg_concat_medias",
|
||||
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(medias, sentry_trace)
|
||||
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
|
||||
|
||||
@web_app.get("/ffmpeg/concat/{task_id}",
|
||||
tags=["查询任务"],
|
||||
summary="获取合并任务结果",
|
||||
description="获取合并任务的处理状态和结果",
|
||||
responses={
|
||||
status.HTTP_200_OK: {
|
||||
"description": "",
|
||||
"headers": sentry_header_schema
|
||||
},
|
||||
},
|
||||
dependencies=[Depends(verify_token)])
|
||||
async def concat_media_status(task_id: str, response: Response) -> FFMPEGConcatTaskStatusResponse:
|
||||
task_status, reason, results, 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 FFMPEGConcatTaskStatusResponse(taskId=task_id, status=task_status, error=reason, result=results)
|
||||
|
||||
return web_app
|
||||
|
||||
Reference in New Issue
Block a user