新增Nakama登录接口,同时支持使用Nakama JWT调用API

* 新增Nakama登录接口,同时支持使用Nakama JWT调用API
* 将tikhub_api和api分离
* 拆分models为按功能划分,以便管理依赖

---------

Merge request URL: https://g-ldyi2063.coding.net/p/dev/d/modalDeploy/git/merge/4865?initial=true
Co-authored-by: shuohigh@gmail.com
This commit is contained in:
2025-06-24 18:02:29 +08:00
committed by Coding
parent 2a0c3364d0
commit b0f954c55d
52 changed files with 969 additions and 928 deletions

View File

@@ -7,8 +7,7 @@ from fastapi.middleware.cors import CORSMiddleware
from .utils.KVCache import MediaSourceKVCache
from .router import ffmpeg, cache, comfyui, google, task
from Douyin_TikTok_Download_API.app.api import router as tikhub
from .config import WorkerConfig
from .models.settings.cluster import WorkerConfig
config = WorkerConfig()
@@ -23,14 +22,9 @@ web_app = FastAPI(title="Modal worker API",
])
web_app_tikhub = FastAPI(title="Modal Tikhub API",
version="1.0.0",
summary="Modal Tikhub API, 包含抖音Web API",
servers=[
{
'url': f'https://bowongai-{config.modal_environment}--{config.modal_app_name}-fastapi-webapp-tikhub.modal.run',
'description': '当前Modal Tikhub API接口endpoint'
}
])
version="1.0.0",
summary="Modal Tikhub API, 包含抖音Web API",
)
sentry_sdk.init(dsn="https://dab7b7ae652216282c89f029a76bb10a@sentry.bowongai.com/2",
send_default_pii=True,
@@ -73,14 +67,6 @@ web_app.add_middleware(
allow_headers=["*"],
)
web_app_tikhub.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@web_app.middleware("http")
@web_app.middleware("https")
@@ -94,10 +80,6 @@ async def alias_middleware(request: Request, call_next):
async def scalar():
return get_scalar_api_reference(openapi_url='/openapi.json', title="Modal worker web endpoint")
@web_app_tikhub.get("/scalar", include_in_schema=False)
async def scalar():
return get_scalar_api_reference(openapi_url='/openapi.json', title="Modal worker web endpoint")
web_app.include_router(ffmpeg.router)
web_app.include_router(cache.router)
@@ -106,6 +88,3 @@ web_app.include_router(cache.router)
web_app.include_router(google.router)
web_app.include_router(task.router)
# Tikhub API
web_app_tikhub.include_router(tikhub.router)

View File

@@ -1,7 +1,14 @@
import httpx
from fastapi import Depends, HTTPException
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import EmailStr
from starlette import status
from ..models.responses.models import NakamaJWTResponse
from ..models.settings.cluster import WorkerConfig
config = WorkerConfig()
bearer_scheme = HTTPBearer(scheme_name="bearer", description="固定API Token的鉴权方式", auto_error=True)
@@ -18,6 +25,17 @@ async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(beare
def is_valid_token(token: str) -> bool:
# 这里实现具体的token验证逻辑
# 例如验证JWT token检查数据库中的token
return token == "bowong7777"
#
if token == config.api_server_token:
return True
else:
response = httpx.get(url=f"{config.nakama_client_endpoint}/v2/account",
headers={"Authorization": f"Bearer {token}"})
return response.is_success
def nakama_login(email: EmailStr, password: str) -> NakamaJWTResponse:
response = httpx.post(url=f"{config.nakama_client_endpoint}/v2/account/authenticate/email",
json={"email": email.__str__(), "password": password, })
response.raise_for_status()
return NakamaJWTResponse.model_validate_json(response.text)

View File

@@ -1,41 +1,22 @@
import json
import os
import re
from datetime import datetime
from enum import Enum
from functools import cached_property
from typing import List, Union, Optional, Any, Dict
from typing import Optional, Union, Any, List
from urllib.parse import urlparse
from pydantic import (BaseModel, Field, field_validator, ValidationError,
field_serializer, SerializationInfo, computed_field, FileUrl, Base64Str, Base64Bytes, ConfigDict)
from pydantic import BaseModel, Field, field_validator, model_validator, ValidationError, computed_field, \
field_serializer, Base64Bytes, RootModel
from pydantic.json_schema import JsonSchemaValue
from ..config import WorkerConfig
from ..utils.TimeUtils import TimeDelta
from ..utils.VideoUtils import VideoMetadata
from pydantic_core.core_schema import SerializationInfo
from ..enums.models import MediaProtocol, MediaCacheStatus, CacheOperationType
from ..settings.cluster import WorkerConfig
from ..ffmpeg_tasks.models import VideoMetadata
config = WorkerConfig()
s3_region = config.S3_region
s3_bucket_name = config.S3_bucket_name
s3_mount_point = config.S3_mount_dir
class MediaProtocol(str, Enum):
http = "http"
s3 = "s3"
vod = "vod"
cos = "cos"
hls = "hls"
gs = "gs"
class MediaCacheStatus(str, Enum):
downloading = "downloading"
failed = "failed"
ready = "ready"
deleted = "deleted"
missing = "missing"
unknown = "unknown"
class MediaSource(BaseModel):
path: str = Field(description="媒体源的路径")
@@ -63,7 +44,7 @@ class MediaSource(BaseModel):
elif media_url.startswith('s3://'):
pattern = r"^s3://[a-z]{2}-[a-z]+-\d/.*$"
if not re.match(pattern, media_url):
media_url = media_url.replace("s3://", f"s3://{s3_region}/")
media_url = media_url.replace("s3://", f"s3://{config.S3_region}/")
# s3://{endpoint}/{bucket}/{url}
paths = media_url[5:].split('/')
@@ -139,7 +120,7 @@ class MediaSource(BaseModel):
match self.protocol:
case MediaProtocol.s3:
# 本地挂载缓存
if self.protocol == MediaProtocol.s3 and self.endpoint == s3_region and self.bucket == s3_bucket_name:
if self.protocol == MediaProtocol.s3 and self.endpoint == config.S3_region and self.bucket == config.S3_bucket_name:
return f"{self.path}"
else:
return f"{self.protocol.value}/{self.endpoint}/{self.bucket}/{self.path}"
@@ -169,7 +150,7 @@ class MediaSource(BaseModel):
@computed_field(description="本地挂载地址")
@property
def local_mount_path(self) -> str:
return f"{s3_mount_point}/{self.cache_filepath}"
return f"{config.S3_mount_dir}/{self.cache_filepath}"
@field_serializer('expired_at')
def serialize_datetime(self, value: Optional[datetime], info: SerializationInfo) -> Optional[str]:
@@ -200,59 +181,56 @@ class MediaSource(BaseModel):
return f"{self.protocol.value}://{self.endpoint}/{self.bucket}/{self.path}"
class MediaSources(BaseModel):
inputs: List[MediaSource] = Field(examples=[
[
"vod://ap-shanghai/1500034234/1397757910405340824.mp4",
"vod://ap-shanghai/1500034234/1397757910403699452.mp4",
"s3://ap-northeast-2/modal-media-cache/concat/outputs/fc-01JTPV5FCNA74CKX3N3214XJPD/output.mp4"
]
], description="支持多种协议['vod://', 's3://'], 计划支持['cos://', http://]")
class CacheTask(BaseModel):
type: CacheOperationType = Field(description="操作类型")
source: MediaSource = Field(description="源媒体URN")
target: Optional[MediaSource] = Field(description="目标媒体URN")
@field_validator('inputs', mode='before')
@field_validator('source', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]) -> List[MediaSource]:
if not v:
raise ValidationError([
{
'loc': ('inputs',),
'msg': "inputs为空",
'type': 'value_error',
'input': v
}
], MediaSources)
result = []
for item in v:
if isinstance(item, str):
result.append(MediaSource.from_str(item))
elif isinstance(item, MediaSource):
result.append(item)
def parse_source(cls, v: Union[str, MediaSource]) -> MediaSource:
if isinstance(v, str):
media_source = MediaSource.from_str(v)
if media_source.protocol == MediaProtocol.s3:
return media_source
else:
raise ValidationError([
{
'loc': ('inputs',),
'msg': "inputs元素类型错误: 必须是字符串",
'type': 'value_error',
'input': v
}
], MediaSources)
return result
raise ValidationError('media只支持s3格式的urn')
elif isinstance(v, MediaSource):
return v
else:
raise ValidationError("media格式读取失败")
model_config = {
"arbitrary_types_allowed": True
}
@field_validator('target', mode='before')
@classmethod
def parse_target(cls, v: Union[str, MediaSource]) -> Optional[MediaSource]:
if v is None:
return None
if isinstance(v, str):
media_source = MediaSource.from_str(v)
if media_source.protocol == MediaProtocol.s3:
return media_source
else:
raise ValidationError('media只支持s3格式的urn')
elif isinstance(v, MediaSource):
return v
else:
raise ValidationError("media格式读取失败")
@model_validator(mode="after")
def parse_model(self):
match self.type:
case CacheOperationType.copy:
if self.target is None:
raise ValidationError("使用copy行为时必填target URN")
case CacheOperationType.move:
if self.target is None:
raise ValidationError("使用move行为时必填target URN")
case _:
return self
class CacheResult(BaseModel):
caches: Dict[str, MediaSource] = Field(description="Cache ID")
class DownloadResult(BaseModel):
urls: List[str] = Field(description="下载链接")
class UploadResultResponse(BaseModel):
media: MediaSource = Field(description="上传完成的媒体资源")
class CacheTaskResult(CacheTask):
success: bool = Field(default=False, description="执行成功")
class Base64File(BaseModel):
@@ -260,49 +238,103 @@ class Base64File(BaseModel):
filename: str = Field(description="文件名, 包含文件类型后缀")
content_type: str = Field(description="文件数据类型")
# @computed_field(description="Base64解码后的二进制内容")
# @property
# def content(self) -> Base64Bytes:
# return base64.b64decode(self.raw_content)
@computed_field(description="文件字节大小")
@property
def size(self) -> int:
# b64_str = self.raw_content.strip().replace('\n', '').replace('\r', '')
# padding = b64_str.count('=', -2) # 只统计末尾的 '='
# size = len(b64_str)
return len(self.raw_content)
class UploadBase64Request(BaseModel):
file: Base64File = Field(description="上传的文件")
prefix: Optional[str] = Field(description="文件存在的前缀目录", default=None)
class LiveProduct(BaseModel):
title: str = Field(default="", description="商品标题")
leaf_category: str = Field(default="", description="商品分类")
shop_id: int = Field(default=0, description="店铺ID")
product_id: str = Field(default="", description="商品ID")
cover: str = Field(default="", description="商品封面图链接")
detail_url: str = Field(default="", description="商品详情链接")
class UploadPresignRequest(BaseModel):
key: str = Field(description="上传文件的key", examples=['123/456/abc.mp4'])
content_type: str = Field(description="上传对象的文件类型", examples=['video/mp4'])
class LiveProductCaches(BaseModel):
room_id: str = Field(default="", description="直播间room_id/Room room_id")
author_id: str = Field(default="", description="作者id/Author id")
update_time: str = Field(default=0, description="商品列表更新时间")
count: int = Field(default=0, description="缓存直播间商品数量")
product_list: List[LiveProduct] = Field(default=None, description="缓存直播间商品列表")
class UploadPresignResponse(BaseModel):
url: str = Field(description="就近加速的PUT上传地址")
urn: str = Field(description="上传成功后获得的对应资源URN")
expired_at: datetime = Field(description="上传地址签名过期时间戳")
class VisualFeatures(BaseModel):
"""
代表产品的视觉特征
"""
color: str = Field(
description="商品详细颜色配色等色彩特征"
)
pattern: str = Field(
description="商品详细图案纹理布料等材质特征, 材料可以根据产品名称和图像确定"
)
style: str = Field(
default=None,
description="商品详细款式风格设计等有辨识度的款式特征"
)
class UploadMultipartPresignRequest(UploadPresignRequest):
parts_count: int = Field(description="分片数量")
class ProductInfo(BaseModel):
"""
表示从图像中提取单个产品的信息
"""
image_order: int = Field(
description="图像出现的顺序"
)
image_name: str = Field(
description="图像上显示的原始文本"
)
matched_product: str = Field(
description="匹配的标准产品名称(必须与产品列表中的标准产品名称保持一致),或者如果没有匹配,则空"
)
match_confidence: int = Field(
ge=0,
le=100,
description="产品匹配的置信度范围从0到100"
)
visual_features: VisualFeatures = Field(
description="产品的详细视觉特征"
)
class UploadMultipartPresignResponse(BaseModel):
urls: List[str] = Field(description="就近加速的PUT分片上传地址")
list_url: str = Field(description="用于确认分片上传状态的请求地址")
complete_url: str = Field(description="用于确认完成分片上传的请求地址")
urn: str = Field(description="上传成功后获得的对应资源URN")
expired_at: datetime = Field(description="上传地址签名过期时间戳")
class GeminiFirstStageResponseModel(RootModel):
"""
从图像中提取的产品信息列表
"""
root: List[ProductInfo]
class MediaCopyRequest(BaseModel):
class MediaCopyTask(BaseModel):
source: MediaSource = Field(description="源媒体")
destination: MediaSource = Field(description="")
class ProductTimeline(BaseModel):
"""
表示单个商品及其在视频时间线中的出现信息
"""
product: str = Field(description="标准商品名称")
timeline: List[str] = Field(
description="该商品在视频中出现的时间段列表。每个字符串表示一个时间段,格式为 '开始时间 - 结束时间 (描述)'"
)
class GeminiFirstStagePromptVariables(BaseModel):
product_list: List[str] = Field(description="商品名列表")
@computed_field(description="xml格式排列的商品列表")
@property
def product_list_xml(self) -> str:
xml_items = [f" <product>{product}</product>" for product in self.product_list]
xml_string = "\n".join(xml_items)
return f"<products>\n{xml_string}\n </products>"
class GeminiSecondStagePromptVariables(BaseModel):
product_json_list: List[dict] = Field(description="识别出的商品JSON列表")
@computed_field(description="xml格式排列的识别出的商品JSON列表")
@property
def product_json_list_xml(self) -> str:
xml_items = [f" <product>{json.dumps(product, ensure_ascii=False)}</product>" for product in
self.product_json_list]
xml_string = "\n".join(xml_items)
return f"<products>\n{xml_string}\n </products>"

View File

@@ -0,0 +1,47 @@
from enum import Enum
class MediaProtocol(str, Enum):
http = "http"
s3 = "s3"
vod = "vod"
cos = "cos"
hls = "hls"
gs = "gs"
class MediaCacheStatus(str, Enum):
downloading = "downloading"
failed = "failed"
ready = "ready"
deleted = "deleted"
missing = "missing"
unknown = "unknown"
class TaskStatus(str, Enum):
running = "running"
failed = "failed"
success = "success"
expired = "expired"
class CacheOperationType(str, Enum):
copy = "copy"
move = "move"
delete = "delete"
class ErrorCode(int, Enum):
SUCCESS = 0
PARAM_ERROR = 10001
NOT_FOUND = 10002
UNAUTHORIZED = 10003
FORBIDDEN = 10004
BUSINESS_ERROR = 10005
SYSTEM_ERROR = 99999
class WebhookMethodEnum(str, Enum):
GET = "get"
POST = "post"

View File

@@ -0,0 +1,310 @@
import json
from typing import Union, Any, Optional, List, Dict
from loguru import logger
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator, ConfigDict, HttpUrl
from pydantic.json_schema import JsonSchemaValue
from BowongModalFunctions.models.enums.models import WebhookMethodEnum
from BowongModalFunctions.models.settings.cluster import WorkerConfig
from BowongModalFunctions.utils.TimeUtils import TimeDelta
config = WorkerConfig()
class FFMpegSliceSegment(BaseModel):
start: TimeDelta = Field(
description="视频切割的开始时间点秒数, 可为浮点小数(精确到小数点后3位,毫秒级)或者为标准格式的时间戳")
end: TimeDelta = Field(
description="视频切割的结束时间点秒数, 可为浮点小数(精确到小数点后3位,毫秒级)或者标准格式的时间戳")
@computed_field
@property
def duration(self) -> TimeDelta:
return self.end - self.start
@field_validator('start', mode='before')
@classmethod
def parse_start(cls, v: Union[float, str, TimeDelta]):
if isinstance(v, float):
if v < 0.0:
raise ValueError("开始时间点不能小于0")
return TimeDelta(seconds=v)
elif isinstance(v, int):
if v < 0:
raise ValueError("开始时间点不能小于0")
return TimeDelta(seconds=v)
elif isinstance(v, str):
timedelta = TimeDelta.from_format_string(v)
if timedelta.total_seconds() < 0.0:
raise ValueError("开始时间点不能小于0")
return timedelta
elif isinstance(v, TimeDelta):
if v.total_seconds() < 0.0:
raise ValueError("开始时间点不能小于0")
return v
else:
raise TypeError(v)
@field_validator('end', mode='before')
@classmethod
def parse_end(cls, v: Union[float, TimeDelta]):
if isinstance(v, float):
if v < 0.0:
raise ValueError("结束时间点不能小于0")
return TimeDelta(seconds=v)
elif isinstance(v, int):
if v < 0:
raise ValueError("结束时间点不能小于0")
return TimeDelta(seconds=v)
elif isinstance(v, str):
timedelta = TimeDelta.from_format_string(v)
if timedelta.total_seconds() < 0.0:
raise ValueError("结束时间点不能小于0")
return timedelta
elif isinstance(v, TimeDelta):
if v.total_seconds() < 0.0:
raise ValueError("结束时间点不能小于0")
return v
else:
raise TypeError(v)
@model_validator(mode='after')
def validate_end_after_start(self) -> 'FFMpegSliceSegment':
if self.end <= self.start:
raise ValueError("end time must be greater than start time")
return self
@classmethod
def __get_pydantic_json_schema__(cls, core_schema: Any, handler: Any) -> JsonSchemaValue:
# Override the schema to represent it as a string
return {
"type": "object",
"properties": {
"start": {
"type": "number",
"examples": [5, 10.5, '00:00:10.500'],
},
"end": {
"type": "number",
"examples": [8, 12.5, '00:00:12.500'],
}
},
"required": [
"start",
"end"
]
}
model_config = {
"arbitrary_types_allowed": True
}
class FFMPEGSliceOptions(BaseModel):
limit_size: Optional[int] = Field(default=None, description="不超过指定文件(字节)大小, 默认为空不限制输出大小")
bit_rate: Optional[int] = Field(default=None, description="指定输出视频的比特率, 不能与limit_size同时设置")
crf: int = Field(default=16, description="输出视频的质量")
fps: int = Field(default=30, description="输出视频的FPS")
width: int = Field(default=None, description="输出视频的宽(像素)")
height: int = Field(default=None, description="输出视频的高(像素)")
@computed_field(description="解析为字符表达式的文件大小")
@property
def pretty_limit_size(self) -> Optional[str]:
if self.limit_size is not None:
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if self.limit_size < 1024.0:
return f"{self.limit_size:.2f}{unit}"
self.limit_size /= 1024.0
return f"{self.limit_size:.2f}PB"
else:
return None
@computed_field(description="解析为字符表达式的比特率")
@property
def pretty_bit_rate(self) -> Optional[str]:
if self.bit_rate is not None:
return f"{self.bit_rate}k"
class MediaStream(BaseModel):
duration: float = Field(0, description="时长")
codec_name: str
tags: Optional[Any] = Field(None)
model_config = ConfigDict(extra='allow')
class HLSMediaVideoStream(BaseModel):
stream_type: str = "video"
codec_name: str
codec_type: str
width: int
height: int
avg_frame_rate: str
tags: Optional[Any] = Field(None)
duration: Optional[float] = Field(None)
@computed_field
@property
def video_frame_rate(self) -> float:
numerator, denominator = map(int, self.avg_frame_rate.split('/'))
if denominator != 0:
return numerator / denominator
return 0
class HLSMediaAudioStream(BaseModel):
stream_type: str = "audio"
sample_rate: str
channels: int
channel_layout: str
start_time: str
tags: Optional[Any] = Field(None)
class AudioStream(MediaStream):
stream_type: str = Field("audio")
codec_type: str
sample_rate: str
channels: int
tags: Optional[Any] = Field(None)
model_config = ConfigDict(extra='allow')
class VideoStream(MediaStream):
stream_type: str = "video"
width: int
height: int
bit_rate: int
avg_frame_rate: str
@computed_field
@property
def video_bitrate(self) -> str:
return str(int(self.bit_rate / 1000)) + 'k'
@computed_field
@property
def video_frame_rate(self) -> float:
numerator, denominator = map(int, self.avg_frame_rate.split('/'))
if denominator != 0:
return numerator / denominator
return 0
model_config = ConfigDict(extra='allow')
class ImageStream(MediaStream):
stream_type: str = "image"
width: int
height: int
model_config = ConfigDict(extra='allow')
class SubtitleStreamTags(BaseModel):
language: str = Field(description="内嵌字幕的语言")
model_config = ConfigDict(extra='allow')
class SubtitleStream(MediaStream):
stream_type: str = "subtitle"
tags: SubtitleStreamTags
model_config = ConfigDict(extra='allow')
class VideoFormat(BaseModel):
filename: str
format_name: str
start_time: float = Field(0, description="起始时间")
size: int
bit_rate: Optional[int] = Field(None, description="文件比特率")
duration: float = Field(3600 * 12, description="文件时长")
class VideoMetadata(BaseModel):
streams: List[
Union[ImageStream, AudioStream, VideoStream, HLSMediaAudioStream, HLSMediaVideoStream, SubtitleStream]] = Field(
description="媒体包含的数据轨道")
format: Optional[VideoFormat] = Field(None)
@field_validator('streams', mode='before')
def parse_streams(cls, value):
streams = []
if isinstance(value, List):
for stream in value:
if isinstance(stream, Dict):
logger.info(f"Parsing stream : {json.dumps(stream, ensure_ascii=False)}")
if stream.get("codec_type") == 'audio':
if stream.get("duration") is None:
logger.info("Parsing audio stream")
hls_audio = HLSMediaAudioStream.model_validate(stream)
streams.append(hls_audio)
else:
logger.info("Parsing hls audio stream")
audio = AudioStream.model_validate(stream)
streams.append(audio)
elif stream.get("codec_type") == 'video':
if stream.get("codec_name") in ("gif", "png", "mjpg", "mjpeg", "webp"):
logger.info("Parsing image stream")
image = ImageStream.model_validate(stream)
streams.append(image)
else:
if stream.get("duration") is None:
logger.info("Parsing hls video stream")
hls_video = HLSMediaVideoStream.model_validate(stream)
streams.append(hls_video)
else:
logger.info("Parsing video stream")
video = VideoStream.model_validate(stream)
streams.append(video)
elif stream.get("codec_type") == 'subtitle':
logger.info("Parsing subtitle stream")
subtitle_stream = SubtitleStream.model_validate(stream)
streams.append(subtitle_stream)
return streams
else:
raise TypeError
model_config = ConfigDict(extra='allow')
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 WebhookNotify(BaseModel):
endpoint: HttpUrl = Field(description="Webhook回调端点", examples=["https://webhook.example.com?query=123"])
method: WebhookMethodEnum = Field(
description="Webhook回调请求方法, 使用POST方法时body与查询ffmpeg任务所获得的json body格式一致")
headers: Optional[Dict[str, str]] = Field(description="Webhook回调附带的Headers", default=None)
model_config = ConfigDict(json_schema_extra={
"description": "Webhook返回值与查询ffmpeg任务所获得的json格式一致"
})
class FFMPEGResult(BaseModel):
urn: str = Field(description="FFMPEG任务结果urn")
content_length: int = Field(description="媒体资源文件字节大小(Byte)")
metadata: VideoMetadata = Field(description="媒体元数据")
@computed_field(description="可通过CDN访问的资源链接")
@property
def url(self) -> str:
prefix = f"s3://{config.S3_region}/{config.S3_bucket_name}"
if not self.urn.startswith(prefix):
raise ValueError("无法转换非s3前缀协议和非当前挂载点的s3协议")
return self.urn.replace(prefix, f"{config.S3_cdn_endpoint}")

View File

@@ -1,93 +0,0 @@
from typing import Union, Any
from pydantic import BaseModel, Field, computed_field, field_validator, model_validator
from pydantic.json_schema import JsonSchemaValue
from ..utils.TimeUtils import TimeDelta
class FFMpegSliceSegment(BaseModel):
start: TimeDelta = Field(
description="视频切割的开始时间点秒数, 可为浮点小数(精确到小数点后3位,毫秒级)或者为标准格式的时间戳")
end: TimeDelta = Field(
description="视频切割的结束时间点秒数, 可为浮点小数(精确到小数点后3位,毫秒级)或者标准格式的时间戳")
@computed_field
@property
def duration(self) -> TimeDelta:
return self.end - self.start
@field_validator('start', mode='before')
@classmethod
def parse_start(cls, v: Union[float, str, TimeDelta]):
if isinstance(v, float):
if v < 0.0:
raise ValueError("开始时间点不能小于0")
return TimeDelta(seconds=v)
elif isinstance(v, int):
if v < 0:
raise ValueError("开始时间点不能小于0")
return TimeDelta(seconds=v)
elif isinstance(v, str):
timedelta = TimeDelta.from_format_string(v)
if timedelta.total_seconds() < 0.0:
raise ValueError("开始时间点不能小于0")
return timedelta
elif isinstance(v, TimeDelta):
if v.total_seconds() < 0.0:
raise ValueError("开始时间点不能小于0")
return v
else:
raise TypeError(v)
@field_validator('end', mode='before')
@classmethod
def parse_end(cls, v: Union[float, TimeDelta]):
if isinstance(v, float):
if v < 0.0:
raise ValueError("结束时间点不能小于0")
return TimeDelta(seconds=v)
elif isinstance(v, int):
if v < 0:
raise ValueError("结束时间点不能小于0")
return TimeDelta(seconds=v)
elif isinstance(v, str):
timedelta = TimeDelta.from_format_string(v)
if timedelta.total_seconds() < 0.0:
raise ValueError("结束时间点不能小于0")
return timedelta
elif isinstance(v, TimeDelta):
if v.total_seconds() < 0.0:
raise ValueError("结束时间点不能小于0")
return v
else:
raise TypeError(v)
@model_validator(mode='after')
def validate_end_after_start(self) -> 'FFMpegSliceSegment':
if self.end <= self.start:
raise ValueError("end time must be greater than start time")
return self
@classmethod
def __get_pydantic_json_schema__(cls, core_schema: Any, handler: Any) -> JsonSchemaValue:
# Override the schema to represent it as a string
return {
"type": "object",
"properties": {
"start": {
"type": "number",
"examples": [5, 10.5, '00:00:10.500'],
},
"end": {
"type": "number",
"examples": [8, 12.5, '00:00:12.500'],
}
},
"required": [
"start",
"end"
]
}
model_config = {
"arbitrary_types_allowed": True
}

View File

@@ -1,160 +1,14 @@
import json
import uuid
from datetime import datetime
from enum import Enum
from typing import List, Union, Optional, Dict, Any
from typing import List, Union, Optional, Dict
import pydantic
from pydantic import BaseModel, Field, field_validator, ConfigDict, HttpUrl, computed_field, RootModel, root_validator, \
model_validator
from pydantic import BaseModel, Field, ConfigDict, field_validator, ValidationError, model_validator, computed_field, \
EmailStr
from .ffmpeg_worker_model import FFMpegSliceSegment
from .media_model import MediaSource, MediaSources, MediaProtocol
from ..config import WorkerConfig
from ..utils.TimeUtils import TimeDelta
from ..utils.VideoUtils import VideoMetadata, FFMPEGSliceOptions
config = WorkerConfig()
class TaskStatus(str, Enum):
running = "running"
failed = "failed"
success = "success"
expired = "expired"
class CacheOperationType(str, Enum):
copy = "copy"
move = "move"
delete = "delete"
class ErrorCode(int, Enum):
SUCCESS = 0
PARAM_ERROR = 10001
NOT_FOUND = 10002
UNAUTHORIZED = 10003
FORBIDDEN = 10004
BUSINESS_ERROR = 10005
SYSTEM_ERROR = 99999
class WebhookMethodEnum(str, Enum):
GET = "get"
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 CacheDeleteTaskResponse(BaseModel):
success: bool = Field(description="运行成功")
keys: List[str] = Field(description="成功从KV和S3删除掉的URN")
non_kv_keys: List[str] = Field(alias="nonKVKeys", serialization_alias="nonKVKeys",
description="成功从S3删除的URN, 不存在于KV中")
not_found_keys: List[str] = Field(alias="notFoundKeys", serialization_alias="notFoundKeys",
description="不存在的URN")
class RecordingTaskResponse(BaseModel):
success: bool = Field(description="任务接受成功")
taskId: str = Field(description="任务Id")
manifest: str = Field(description="播放地址")
manifest_urn: str = Field(description="播放列表URN")
class WebhookNotify(BaseModel):
endpoint: HttpUrl = Field(description="Webhook回调端点", examples=["https://webhook.example.com?query=123"])
method: WebhookMethodEnum = Field(
description="Webhook回调请求方法, 使用POST方法时body与查询ffmpeg任务所获得的json body格式一致")
headers: Optional[Dict[str, str]] = Field(description="Webhook回调附带的Headers", default=None)
model_config = ConfigDict(json_schema_extra={
"description": "Webhook返回值与查询ffmpeg任务所获得的json格式一致"
})
class FFMPEGResult(BaseModel):
urn: str = Field(description="FFMPEG任务结果urn")
content_length: int = Field(description="媒体资源文件字节大小(Byte)")
metadata: VideoMetadata = Field(description="媒体元数据")
@computed_field(description="可通过CDN访问的资源链接")
@property
def url(self) -> str:
prefix = f"s3://{config.S3_region}/{config.S3_bucket_name}"
if not self.urn.startswith(prefix):
raise ValueError("无法转换非s3前缀协议和非当前挂载点的s3协议")
return self.urn.replace(prefix, f"{config.S3_cdn_endpoint}")
class CacheTask(BaseModel):
type: CacheOperationType = Field(description="操作类型")
source: MediaSource = Field(description="源媒体URN")
target: Optional[MediaSource] = Field(description="目标媒体URN")
@field_validator('source', mode='before')
@classmethod
def parse_source(cls, v: Union[str, MediaSource]) -> MediaSource:
if isinstance(v, str):
media_source = MediaSource.from_str(v)
if media_source.protocol == MediaProtocol.s3:
return media_source
else:
raise pydantic.ValidationError('media只支持s3格式的urn')
elif isinstance(v, MediaSource):
return v
else:
raise pydantic.ValidationError("media格式读取失败")
@field_validator('target', mode='before')
@classmethod
def parse_target(cls, v: Union[str, MediaSource]) -> Optional[MediaSource]:
if v is None:
return None
if isinstance(v, str):
media_source = MediaSource.from_str(v)
if media_source.protocol == MediaProtocol.s3:
return media_source
else:
raise pydantic.ValidationError('media只支持s3格式的urn')
elif isinstance(v, MediaSource):
return v
else:
raise pydantic.ValidationError("media格式读取失败")
@model_validator(mode="after")
def parse_model(self):
match self.type:
case CacheOperationType.copy:
if self.target is None:
raise pydantic.ValidationError("使用copy行为时必填target URN")
case CacheOperationType.move:
if self.target is None:
raise pydantic.ValidationError("使用move行为时必填target URN")
case _:
return self
class CacheTaskResult(CacheTask):
success: bool = Field(default=False, description="执行成功")
from ..cache_tasks.models import CacheTask, MediaSource, Base64File
from ..enums.models import MediaProtocol
from ..ffmpeg_tasks.models import WebhookNotify, FFMpegSliceSegment, FFMPEGSliceOptions
from ...utils.TimeUtils import TimeDelta
class ClusterCacheBatchRequest(BaseModel):
@@ -163,48 +17,77 @@ class ClusterCacheBatchRequest(BaseModel):
model_config = ConfigDict()
class ClusterCacheBatchResponse(BaseModel):
results: List[CacheTaskResult] = Field(description="批量操作任务结果")
class MediaSourcesRequest(BaseModel):
inputs: List[MediaSource] = Field(examples=[
[
"vod://ap-shanghai/1500034234/1397757910405340824.mp4",
"vod://ap-shanghai/1500034234/1397757910403699452.mp4",
"s3://ap-northeast-2/modal-media-cache/concat/outputs/fc-01JTPV5FCNA74CKX3N3214XJPD/output.mp4"
]
], description="支持多种协议['vod://', 's3://'], 计划支持['cos://', http://]")
@field_validator('inputs', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]) -> List[MediaSource]:
if not v:
raise ValidationError([
{
'loc': ('inputs',),
'msg': "inputs为空",
'type': 'value_error',
'input': v
}
], MediaSourcesRequest)
result = []
for item in v:
if isinstance(item, str):
result.append(MediaSource.from_str(item))
elif isinstance(item, MediaSource):
result.append(item)
else:
raise ValidationError([
{
'loc': ('inputs',),
'msg': "inputs元素类型错误: 必须是字符串",
'type': 'value_error',
'input': v
}
], MediaSourcesRequest)
return result
model_config = {
"arbitrary_types_allowed": True
}
class UploadBase64Request(BaseModel):
file: Base64File = Field(description="上传的文件")
prefix: Optional[str] = Field(description="文件存在的前缀目录", default=None)
class UploadPresignRequest(BaseModel):
key: str = Field(description="上传文件的key", examples=['123/456/abc.mp4'])
content_type: str = Field(description="上传对象的文件类型", examples=['video/mp4'])
class UploadMultipartPresignRequest(UploadPresignRequest):
parts_count: int = Field(description="分片数量")
class MediaCopyRequest(BaseModel):
class MediaCopyTask(BaseModel):
source: MediaSource = Field(description="源媒体")
destination: MediaSource = Field(description="")
class FFMPEGSliceTaskStatusRequest(BaseModel):
taskId: str = Field(description="任务Id")
class BaseFFMPEGTaskRequest(BaseModel):
webhook: Optional[WebhookNotify] = Field(description="Task webhook", default=None)
class BaseFFMPEGTaskStatusResponse(BaseModel):
taskId: str = Field(description="任务Id")
status: TaskStatus = Field(description="任务运行状态")
error: Optional[str] = Field(description="任务错误原因", default=None)
code: Optional[int] = Field(description="任务错误原因代码", default=None)
task_type: str = Field(description="任务类型", default="unknown")
results: Optional[List[Union[FFMPEGResult, Any]]] = Field(description="任务运行结果", default=None)
model_config = ConfigDict(extra='ignore')
class FFMPEGConvertStreamRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="待转换的媒体源")
options: FFMPEGSliceOptions = Field(default_factory=FFMPEGSliceOptions, description="输出质量选项")
@field_validator('media', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
media_source = MediaSource.from_str(v)
if media_source.protocol == MediaProtocol.hls:
return media_source
else:
raise pydantic.ValidationError('media只支持hls格式的urn')
elif isinstance(v, MediaSource):
return v
else:
raise pydantic.ValidationError("media格式读取失败")
class FFMPEGConvertStreamResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="任务运行结果")
class FFMPEGSliceRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="待切割的媒体源")
markers: List[FFMpegSliceSegment] = Field(description="按照时间顺序排序过的切割标记数组")
@@ -221,16 +104,8 @@ class FFMPEGSliceRequest(BaseFFMPEGTaskRequest):
raise TypeError(v)
class FFMPEGSliceTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[List[str]] = Field(default=None, description="任务运行结果")
class FFMPEGConcatRequest(BaseFFMPEGTaskRequest):
medias: MediaSources = Field(description="待合并的媒体源")
class FFMPEGConcatTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="任务运行结果")
medias: MediaSourcesRequest = Field(description="待合并的媒体源")
class FFMPEGExtractAudioRequest(BaseFFMPEGTaskRequest):
@@ -247,10 +122,6 @@ class FFMPEGExtractAudioRequest(BaseFFMPEGTaskRequest):
raise TypeError(v)
class FFMPEGExtractAudioTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="任务运行结果")
class FFMPEGCornerMirrorRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
@@ -265,10 +136,6 @@ class FFMPEGCornerMirrorRequest(BaseFFMPEGTaskRequest):
raise TypeError(v)
class FFMPEGCornerMirrorTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="任务运行结果")
class FFMPEGBgmRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
bgm_media: MediaSource = Field(description="添加的BGM媒体源", alias="bgmMedia")
@@ -296,10 +163,6 @@ class FFMPEGBgmRequest(BaseFFMPEGTaskRequest):
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)
@@ -316,10 +179,6 @@ class FFMPEGZoomLoopRequest(BaseFFMPEGTaskRequest):
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")
@@ -348,10 +207,6 @@ class FFMPEGOverlayGifRequest(BaseFFMPEGTaskRequest):
raise TypeError(v)
class FFMPEGOverlayGifTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGSubtitleOverlayRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
subtitle: Optional[MediaSource] = Field(default=None, description="需要叠加的字幕文件")
@@ -422,10 +277,6 @@ class FFMPEGSubtitleOverlayRequest(BaseFFMPEGTaskRequest):
return self
class FFMPEGSubtitleTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGMixBgmWithNoiseReduceRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="需要处理的媒体源")
bgm: MediaSource = Field(description="需要添加的BGM媒体源")
@@ -467,19 +318,11 @@ class FFMPEGMixBgmWithNoiseReduceRequest(BaseFFMPEGTaskRequest):
raise TypeError(v)
class FFMPEGMixBgmWithNoiseReduceStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGVideoLoopFillAudioRequest(BaseFFMPEGTaskRequest):
video: MediaSource = Field(description="用来填充的视频素材")
audio: MediaSource = Field(description="被填充的音频素材")
class FFMPEGVideoLoopFillAudioResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGExtractFrameRequest(BaseFFMPEGTaskRequest):
video: MediaSource = Field(description="提取帧画面的来源")
seek_time: Optional[Union[str, int, float]] = Field(default=None, description="先跳转到视频对应时间再取首帧",
@@ -515,23 +358,6 @@ class FFMPEGExtractFrameRequest(BaseFFMPEGTaskRequest):
}
class FFMPEGExtractFrameStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class ModalTaskCancelResponse(BaseModel):
success: bool = Field(description="成功取消任务")
error: Optional[str] = Field(default=None, description="失败原因")
class ComfyTaskStatusResponse(BaseModel):
taskId: str = Field(description="任务Id")
status: TaskStatus = Field(description="任务运行状态")
error: Optional[str] = Field(description="任务错误原因", default=None)
code: Optional[int] = Field(description="任务错误原因代码", default=None)
result: Optional[str] = Field(description="任务运行结果", default=None)
class ComfyTaskRequest(BaseFFMPEGTaskRequest):
video_path: MediaSource = Field(
default=None, description="视频源")
@@ -590,40 +416,12 @@ class GeminiRequest(BaseFFMPEGTaskRequest):
raise pydantic.ValidationError("media格式读取失败")
class GeminiResultResponse(BaseFFMPEGTaskStatusResponse):
result: str = Field(default="", description="推理出的json")
class LiveProduct(BaseModel):
title: str = Field(default="", description="商品标题")
leaf_category: str = Field(default="", description="商品分类")
shop_id: int = Field(default=0, description="店铺ID")
product_id: str = Field(default="", description="商品ID")
cover: str = Field(default="", description="商品封面图链接")
detail_url: str = Field(default="", description="商品详情链接")
class LiveProductCaches(BaseModel):
room_id: str = Field(default="", description="直播间room_id/Room room_id")
author_id: str = Field(default="", description="作者id/Author id")
update_time: str = Field(default=0, description="商品列表更新时间")
count: int = Field(default=0, description="缓存直播间商品数量")
product_list: List[LiveProduct] = Field(default=None, description="缓存直播间商品列表")
class MonitorLiveRoomProductRequest(BaseModel):
cookie: str = Field(default="YOUR_COOKIE", description="用户网页版抖音Cookie/Your web version of Douyin Cookie")
room_id: str = Field(default="", description="直播间room_id/Room room_id")
author_id: str = Field(default="", description="作者id/Author id")
class LiveRoomProductCachesResponse(BaseModel):
status: int = Field(default=None,
description="缓存状态/0-正常返回 1-直播已结束 2-IP风控 3-请求Tikhub API错误 4-内部错误")
message: str = Field(default="", description="错误信息")
cache_json: str = Field(default="", description="缓存内容/Json文本")
class MakeGridGeminiRequest(BaseFFMPEGTaskRequest):
pic_info_list: List[Dict[str, str]] = Field(default=[],
description="包含图片信息的字典列表,每个字典包含 \"title\"\"cover\"")
@@ -634,115 +432,25 @@ class MakeGridGeminiRequest(BaseFFMPEGTaskRequest):
separator: int = Field(default=12, description="分割线宽度/像素")
class GoogleUploadResponse(BaseModel):
kind: str
id: str
self_link: HttpUrl = Field(alias='selfLink')
media_link: HttpUrl = Field(alias='mediaLink')
name: str
bucket: str
generation: str
metageneration: str
content_type: str = Field(alias='contentType')
storage_class: str = Field(alias='storageClass')
size: int
md5_hash: str = Field(alias='md5Hash')
crc32c: str
etag: str
time_created: datetime = Field(alias='timeCreated')
updated: datetime
time_storage_class_updated: datetime = Field(alias='timeStorageClassUpdated')
time_finalized: datetime = Field(alias='timeFinalized')
class FFMPEGConvertStreamRequest(BaseFFMPEGTaskRequest):
media: MediaSource = Field(description="待转换的媒体源")
options: FFMPEGSliceOptions = Field(default_factory=FFMPEGSliceOptions, description="输出质量选项")
@computed_field(description="适用于Google内部服务的URN")
@property
def urn(self) -> str:
return ("gs://" + self.id).replace(f"/{self.generation}", "")
model_config = ConfigDict(populate_by_name=True)
@field_validator('media', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
media_source = MediaSource.from_str(v)
if media_source.protocol == MediaProtocol.hls:
return media_source
else:
raise pydantic.ValidationError('media只支持hls格式的urn')
elif isinstance(v, MediaSource):
return v
else:
raise pydantic.ValidationError("media格式读取失败")
class VisualFeatures(BaseModel):
"""
代表产品的视觉特征
"""
color: str = Field(
description="商品详细颜色配色等色彩特征"
)
pattern: str = Field(
description="商品详细图案纹理布料等材质特征, 材料可以根据产品名称和图像确定"
)
style: str = Field(
default=None,
description="商品详细款式风格设计等有辨识度的款式特征"
)
class ProductInfo(BaseModel):
"""
表示从图像中提取单个产品的信息
"""
image_order: int = Field(
description="图像出现的顺序"
)
image_name: str = Field(
description="图像上显示的原始文本"
)
matched_product: str = Field(
description="匹配的标准产品名称(必须与产品列表中的标准产品名称保持一致),或者如果没有匹配,则空"
)
match_confidence: int = Field(
ge=0,
le=100,
description="产品匹配的置信度范围从0到100"
)
visual_features: VisualFeatures = Field(
description="产品的详细视觉特征"
)
class GeminiFirstStageResponseModel(RootModel):
"""
从图像中提取的产品信息列表
"""
root: List[ProductInfo]
class ProductTimeline(BaseModel):
"""
表示单个商品及其在视频时间线中的出现信息
"""
product: str = Field(description="标准商品名称")
timeline: List[str] = Field(
description="该商品在视频中出现的时间段列表。每个字符串表示一个时间段,格式为 '开始时间 - 结束时间 (描述)'"
)
class GeminiSecondStageResponseModel(RootModel):
"""
表示一个包含多个商品及其时间线信息的列表
"""
root: List[ProductTimeline]
class GeminiFirstStagePromptVariables(BaseModel):
product_list: List[str] = Field(description="商品名列表")
@computed_field(description="xml格式排列的商品列表")
@property
def product_list_xml(self) -> str:
xml_items = [f" <product>{product}</product>" for product in self.product_list]
xml_string = "\n".join(xml_items)
return f"<products>\n{xml_string}\n </products>"
class GeminiSecondStagePromptVariables(BaseModel):
product_json_list: List[dict] = Field(description="识别出的商品JSON列表")
@computed_field(description="xml格式排列的识别出的商品JSON列表")
@property
def product_json_list_xml(self) -> str:
xml_items = [f" <product>{json.dumps(product, ensure_ascii=False)}</product>" for product in
self.product_json_list]
xml_string = "\n".join(xml_items)
return f"<products>\n{xml_string}\n </products>"
class NakamaLogin(BaseModel):
email: EmailStr = Field(description="Bowong Echo(Nakama)的登录账号")
password: str = Field(description="Bowong Echo(Nakama)的登录密码")

View File

@@ -0,0 +1,182 @@
from datetime import datetime
from typing import List, Dict, Optional, Union, Any
from pydantic import BaseModel, Field, ConfigDict, HttpUrl, computed_field, RootModel
from ..cache_tasks.models import MediaSource, CacheTaskResult, ProductTimeline
from ..enums.models import TaskStatus
from ..ffmpeg_tasks.models import FFMPEGResult
class DownloadResult(BaseModel):
urls: List[str] = Field(description="下载链接")
class CacheResultResponse(BaseModel):
caches: Dict[str, MediaSource] = Field(description="Cache ID")
class UploadResultResponse(BaseModel):
media: MediaSource = Field(description="上传完成的媒体资源")
class UploadPresignResponse(BaseModel):
url: str = Field(description="就近加速的PUT上传地址")
urn: str = Field(description="上传成功后获得的对应资源URN")
expired_at: datetime = Field(description="上传地址签名过期时间戳")
class UploadMultipartPresignResponse(BaseModel):
urls: List[str] = Field(description="就近加速的PUT分片上传地址")
list_url: str = Field(description="用于确认分片上传状态的请求地址")
complete_url: str = Field(description="用于确认完成分片上传的请求地址")
urn: str = Field(description="上传成功后获得的对应资源URN")
expired_at: datetime = Field(description="上传地址签名过期时间戳")
class ModalTaskResponse(BaseModel):
success: bool = Field(description="任务接受成功")
taskId: str = Field(description="任务Id")
class CacheDeleteTaskResponse(BaseModel):
success: bool = Field(description="运行成功")
keys: List[str] = Field(description="成功从KV和S3删除掉的URN")
non_kv_keys: List[str] = Field(alias="nonKVKeys", serialization_alias="nonKVKeys",
description="成功从S3删除的URN, 不存在于KV中")
not_found_keys: List[str] = Field(alias="notFoundKeys", serialization_alias="notFoundKeys",
description="不存在的URN")
class RecordingTaskResponse(BaseModel):
success: bool = Field(description="任务接受成功")
taskId: str = Field(description="任务Id")
manifest: str = Field(description="播放地址")
manifest_urn: str = Field(description="播放列表URN")
class ClusterCacheBatchResponse(BaseModel):
results: List[CacheTaskResult] = Field(description="批量操作任务结果")
class BaseFFMPEGTaskStatusResponse(BaseModel):
taskId: str = Field(description="任务Id")
status: TaskStatus = Field(description="任务运行状态")
error: Optional[str] = Field(description="任务错误原因", default=None)
code: Optional[int] = Field(description="任务错误原因代码", default=None)
task_type: str = Field(description="任务类型", default="unknown")
results: Optional[List[Union[FFMPEGResult, Any]]] = Field(description="任务运行结果", default=None)
model_config = ConfigDict(extra='ignore')
class FFMPEGConvertStreamResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="任务运行结果")
class FFMPEGSliceTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[List[str]] = Field(default=None, description="任务运行结果")
class FFMPEGConcatTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="任务运行结果")
class FFMPEGExtractAudioTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="任务运行结果")
class FFMPEGCornerMirrorTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="任务运行结果")
class FFMPEGBgmTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGZoomLoopTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGOverlayGifTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGSubtitleTaskStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGMixBgmWithNoiseReduceStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGVideoLoopFillAudioResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class FFMPEGExtractFrameStatusResponse(BaseFFMPEGTaskStatusResponse):
result: Optional[str] = Field(default=None, description="生成结果的URN")
class ModalTaskCancelResponse(BaseModel):
success: bool = Field(description="成功取消任务")
error: Optional[str] = Field(default=None, description="失败原因")
class ComfyTaskStatusResponse(BaseModel):
taskId: str = Field(description="任务Id")
status: TaskStatus = Field(description="任务运行状态")
error: Optional[str] = Field(description="任务错误原因", default=None)
code: Optional[int] = Field(description="任务错误原因代码", default=None)
result: Optional[str] = Field(description="任务运行结果", default=None)
class GeminiResultResponse(BaseFFMPEGTaskStatusResponse):
result: str = Field(default="", description="推理出的json")
class LiveRoomProductCachesResponse(BaseModel):
status: int = Field(default=None,
description="缓存状态/0-正常返回 1-直播已结束 2-IP风控 3-请求Tikhub API错误 4-内部错误")
message: str = Field(default="", description="错误信息")
cache_json: str = Field(default="", description="缓存内容/Json文本")
class GoogleUploadResponse(BaseModel):
kind: str
id: str
self_link: HttpUrl = Field(alias='selfLink')
media_link: HttpUrl = Field(alias='mediaLink')
name: str
bucket: str
generation: str
metageneration: str
content_type: str = Field(alias='contentType')
storage_class: str = Field(alias='storageClass')
size: int
md5_hash: str = Field(alias='md5Hash')
crc32c: str
etag: str
time_created: datetime = Field(alias='timeCreated')
updated: datetime
time_storage_class_updated: datetime = Field(alias='timeStorageClassUpdated')
time_finalized: datetime = Field(alias='timeFinalized')
@computed_field(description="适用于Google内部服务的URN")
@property
def urn(self) -> str:
return ("gs://" + self.id).replace(f"/{self.generation}", "")
model_config = ConfigDict(populate_by_name=True)
class GeminiSecondStageResponseModel(RootModel):
"""
表示一个包含多个商品及其时间线信息的列表。
"""
root: List[ProductTimeline]
class NakamaJWTResponse(BaseModel):
token: str = Field(description="Nakama JWT Token")
refresh_token: str = Field(description="Nakama JWT Refresh Token")

View File

@@ -1,4 +1,5 @@
from typing import Optional, Any
from pydantic import Field, HttpUrl
from pydantic_settings import BaseSettings, SettingsConfigDict
@@ -20,7 +21,6 @@ class WorkerConfig(BaseSettings):
modal_product_kv_name: str = Field(default='live-product-cache', description="Modal抖音直播间商品缓存KV库")
modal_environment: str = Field(default="dev", description="Modal worker运行环境")
modal_app_name: str = Field(default='bowong-ai-video', description="Modal App集群名称")
modal_is_local: bool = Field(default=False, description="本地开发环境")
comfyui_s3_input: Optional[str] = Field(default="comfyui-input", description="ComfyUI input S3文件夹名")
comfyui_s3_output: Optional[str] = Field(default="comfyui-output", description="ComfyUI output S3文件夹名")
@@ -29,8 +29,10 @@ class WorkerConfig(BaseSettings):
description="Langfuse public key")
langfuse_secret_key: str = Field(default="sk-lf-dd20cb0b-ef2e-49f6-80f0-b2d9cff1bb11",
description="Langfuse secret key")
nakama_client_endpoint: str = Field(default="http://43.143.58.201:7350", description="Nakama client endpoint")
api_version: str = Field(default="0.1.7", description="API接口版本")
api_server_token: str = Field(default="bowong7777", description="固定的API调用Bearer Token")
modal_config: Any = SettingsConfigDict(json_schema_extra={
"description": "可通过本地环境变量加载对应Field, 不区分大小写, Modal创建App的Image时可通过dotenv加载指定.env文件写入到Docker Image的系统变量",

View File

@@ -13,22 +13,17 @@ from fastapi.responses import JSONResponse, RedirectResponse
from starlette import status
import boto3
from botocore.config import Config
from typing_inspection.typing_objects import target
from ..config import WorkerConfig
from ..models.settings.cluster import WorkerConfig
from ..middleware.authorization import verify_token
from ..models.media_model import (MediaSources,
CacheResult,
MediaSource,
MediaCacheStatus,
DownloadResult,
UploadResultResponse,
UploadBase64Request, UploadPresignRequest, UploadPresignResponse,
UploadMultipartPresignRequest, UploadMultipartPresignResponse, MediaProtocol
)
from ..models.web_model import SentryTransactionInfo, MonitorLiveRoomProductRequest, ModalTaskResponse, \
LiveRoomProductCachesResponse, CacheDeleteTaskResponse, ClusterCacheBatchRequest, CacheOperationType, \
ClusterCacheBatchResponse, CacheTaskResult
from ..models.responses.models import DownloadResult, CacheResultResponse, UploadResultResponse, UploadPresignResponse, \
UploadMultipartPresignResponse, CacheDeleteTaskResponse, ClusterCacheBatchResponse, LiveRoomProductCachesResponse
from ..models.enums.models import MediaCacheStatus, CacheOperationType
from ..models.ffmpeg_tasks.models import SentryTransactionInfo
from ..models.requests.models import ClusterCacheBatchRequest, MediaSourcesRequest, UploadBase64Request, \
UploadPresignRequest, \
UploadMultipartPresignRequest, MonitorLiveRoomProductRequest
from ..models.cache_tasks.models import CacheTaskResult, MediaSource
from ..utils.KVCache import MediaSourceKVCache, LiveProductKVCache
from ..utils.SentryUtils import SentryUtils
@@ -57,9 +52,9 @@ modal_kv_product_cache = LiveProductKVCache(kv_name=config.modal_product_kv_name
summary="缓存视频文件",
description="异步缓存视频文件到S3存储桶和Modal Dict(KV)",
dependencies=[Depends(verify_token)])
async def cache(medias: MediaSources) -> CacheResult:
async def cache(medias: MediaSourcesRequest) -> CacheResultResponse:
fn_id = current_function_call_id()
caches: MediaSources
caches: MediaSourcesRequest
sentry_trace = SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
x_baggage=sentry_sdk.get_baggage())
@@ -123,14 +118,14 @@ async def cache(medias: MediaSources) -> CacheResult:
cache_task_result_list.append(result)
modal_kv_cache.batch_update_cloudflare_kv(cache_task_result_dict)
return CacheResult(caches={media.urn: media for media in cache_task_result_list})
return CacheResultResponse(caches={media.urn: media for media in cache_task_result_list})
@router.delete("/",
summary="清除指定的所有缓存",
description="清除指定的所有缓存(包括KV记录和S3存储文件)",
dependencies=[Depends(verify_token)])
async def purge_media_kv_file(medias: MediaSources) -> CacheDeleteTaskResponse:
async def purge_media_kv_file(medias: MediaSourcesRequest) -> CacheDeleteTaskResponse:
fn_id = current_function_call_id()
fn = modal.Function.from_name(config.modal_app_name, "cache_delete", environment_name=config.modal_environment)
@@ -182,7 +177,7 @@ async def purge_media_kv_file(medias: MediaSources) -> CacheDeleteTaskResponse:
description="获取已缓存的视频下载地址",
dependencies=[Depends(verify_token)])
@sentry_sdk.trace
async def download_caches(medias: MediaSources) -> DownloadResult:
async def download_caches(medias: MediaSourcesRequest) -> DownloadResult:
cdn_endpoint = config.S3_cdn_endpoint
urls = []
for media in medias.inputs:
@@ -218,7 +213,7 @@ async def purge_kv_all():
summary="删除对应的KV记录",
description="删除请求中对应的视频缓存记录",
dependencies=[Depends(verify_token)])
async def purge_kv(medias: MediaSources):
async def purge_kv(medias: MediaSourcesRequest):
try:
for media in medias.inputs:
modal_kv_cache.pop(media.urn)

View File

@@ -5,13 +5,14 @@ from fastapi import APIRouter, Depends, Header
from fastapi.responses import Response
from starlette import status
from ..config import WorkerConfig
from ..models.settings.cluster import WorkerConfig
from ..middleware.authorization import verify_token
from ..models.media_model import MediaSource
from ..models.cache_tasks.models import MediaSource
from ..utils.ModalUtils import ModalUtils
from ..models.web_model import (SentryTransactionHeader,
ModalTaskResponse, SentryTransactionInfo,
ComfyTaskRequest, ComfyTaskStatusResponse, TaskStatus)
from ..models.responses.models import ModalTaskResponse, ComfyTaskStatusResponse
from ..models.requests.models import ComfyTaskRequest
from ..models.ffmpeg_tasks.models import SentryTransactionHeader, SentryTransactionInfo
from ..models.enums.models import TaskStatus
config = WorkerConfig()

View File

@@ -6,24 +6,15 @@ from fastapi.responses import Response
from loguru import logger
from starlette import status
from ..config import WorkerConfig
from ..models.settings.cluster import WorkerConfig
from ..middleware.authorization import verify_token
from ..utils.ModalUtils import ModalUtils
from ..models.web_model import (FFMPEGSliceRequest, SentryTransactionHeader,
ModalTaskResponse, SentryTransactionInfo,
FFMPEGConcatRequest,
FFMPEGExtractAudioRequest,
FFMPEGCornerMirrorRequest,
FFMPEGOverlayGifRequest,
FFMPEGZoomLoopRequest,
FFMPEGSubtitleOverlayRequest,
FFMPEGMixBgmWithNoiseReduceRequest,
FFMPEGVideoLoopFillAudioRequest,
FFMPEGConvertStreamRequest,
FFMPEGExtractFrameRequest,
BaseFFMPEGTaskStatusResponse,
FFMPEGStreamRecordRequest,
RecordingTaskResponse)
from ..models.responses.models import ModalTaskResponse, RecordingTaskResponse, BaseFFMPEGTaskStatusResponse
from ..models.requests.models import FFMPEGSliceRequest, FFMPEGConcatRequest, FFMPEGExtractAudioRequest, \
FFMPEGCornerMirrorRequest, FFMPEGZoomLoopRequest, FFMPEGOverlayGifRequest, FFMPEGSubtitleOverlayRequest, \
FFMPEGMixBgmWithNoiseReduceRequest, FFMPEGVideoLoopFillAudioRequest, FFMPEGExtractFrameRequest, \
FFMPEGStreamRecordRequest, FFMPEGConvertStreamRequest
from ..models.ffmpeg_tasks.models import SentryTransactionHeader, SentryTransactionInfo
config = WorkerConfig()

View File

@@ -13,12 +13,13 @@ from pydantic import computed_field
from starlette import status
from starlette.responses import JSONResponse
from BowongModalFunctions.config import WorkerConfig
from BowongModalFunctions.middleware.authorization import verify_token
from BowongModalFunctions.models.web_model import SentryTransactionInfo, GeminiResultResponse, GeminiRequest, \
ModalTaskResponse, MakeGridGeminiRequest, GoogleUploadResponse
from BowongModalFunctions.utils.ModalUtils import ModalUtils
from BowongModalFunctions.utils.HTTPUtils import GoogleAuthUtils
from ..models.settings.cluster import WorkerConfig
from ..middleware.authorization import verify_token
from ..models.responses.models import ModalTaskResponse, GeminiResultResponse, GoogleUploadResponse
from ..models.requests.models import GeminiRequest, MakeGridGeminiRequest
from ..models.ffmpeg_tasks.models import SentryTransactionInfo
from ..utils.ModalUtils import ModalUtils
from ..utils.HTTPUtils import GoogleAuthUtils
config = WorkerConfig()
@@ -191,10 +192,7 @@ async def make_grid_gemini_upload(data: MakeGridGeminiRequest, headers: Annotate
@router.post('/inference_gemini', summary="使用Gemini推理hls视频流指定时间段的打点情况")
async def inference_gemini(
data: GeminiRequest,
headers: Annotated[BundleHeaders, Header()],
) -> ModalTaskResponse:
async def inference_gemini(data: GeminiRequest, headers: Annotated[BundleHeaders, Header()], ) -> ModalTaskResponse:
google_api_key = headers.x_google_api_key or os.environ.get("GOOGLE_API_KEY")
if not google_api_key:
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Google API Key")

View File

@@ -1,14 +1,18 @@
import modal
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException
from loguru import logger
from starlette import status
from ..middleware.authorization import verify_token
from ..models.web_model import ModalTaskCancelResponse
from ..middleware.authorization import verify_token, nakama_login
from ..models.requests.models import NakamaLogin
from ..models.responses.models import ModalTaskCancelResponse, NakamaJWTResponse
router = APIRouter(prefix="/task", tags=["tasks"], dependencies=[Depends(verify_token)])
router = APIRouter(prefix="/task", tags=["tasks"])
@router.get("/cancel/{task_id}", summary="终止任务", description="终止任务, 无论是正在排队还是真正运行")
@router.get("/cancel/{task_id}", summary="终止任务",
description="终止任务, 无论是正在排队还是真正运行",
dependencies=[Depends(verify_token)])
async def task_cancel(task_id: str) -> ModalTaskCancelResponse:
try:
fn_call = modal.FunctionCall.from_id(task_id)
@@ -18,3 +22,12 @@ async def task_cancel(task_id: str) -> ModalTaskCancelResponse:
logger.exception(e)
return ModalTaskCancelResponse(success=False,
error=e.message if hasattr(e, 'message') else str(e))
@router.post("/oauth", summary="登录以获取Nakama JWT")
async def nakama_oauth(body: NakamaLogin) -> NakamaJWTResponse:
try:
return nakama_login(body.email, body.password)
except Exception as e:
logger.exception(e)
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号无法登录")

View File

@@ -12,7 +12,7 @@ from loguru import logger
import aiofiles
from pathlib import Path
from pydantic import BaseModel, computed_field, Field, field_validator
from BowongModalFunctions.models.web_model import GoogleUploadResponse
from ..models.responses.models import GoogleUploadResponse
class HTTPDownloadUtils:
@@ -192,7 +192,8 @@ class GoogleAuthUtils:
return GoogleAuthUtils.GoogleAuthResponse.model_validate_json(response.text)
@staticmethod
async def google_upload_file(file_stream: Union[BinaryIO ,bytes], content_type: str, google_api_key: str, bucket_name: str,
async def google_upload_file(file_stream: Union[BinaryIO, bytes], content_type: str, google_api_key: str,
bucket_name: str,
filename: str) -> GoogleUploadResponse:
safe_filename = quote(filename)

View File

@@ -4,8 +4,7 @@ import httpx
import modal
from loguru import logger
from .VideoUtils import VideoUtils
from ..models.media_model import MediaSource
from ..models.web_model import LiveProductCaches
from ..models.cache_tasks.models import MediaSource, LiveProductCaches
class KVCache:

View File

@@ -1,11 +1,12 @@
from typing import Tuple, Optional, Any, List
from typing import Optional, Any, List
import modal
from loguru import logger
from modal.call_graph import InputStatus
from modal.call_graph import InputStatus, InputInfo
from pydantic import BaseModel, Field
from BowongModalFunctions.models.web_model import TaskStatus, SentryTransactionInfo, ErrorCode
from ..models.ffmpeg_tasks.models import SentryTransactionInfo
from ..models.enums.models import TaskStatus, ErrorCode
class ModalTaskInfo(BaseModel):
@@ -25,16 +26,16 @@ class ModalUtils:
:param task_id: modal 任务id
:return: (TaskStaus 任务运行状态, function_name 任务名字, errorCode 错误代码, errorReason 错误原因, results 任务结果, sentryTransactionInfo Sentry跟踪信息)
"""
task = None
task: Optional[InputInfo] = None
try:
fn_task = modal.FunctionCall.from_id(task_id)
call_graph = fn_task.get_call_graph()
root = call_graph[0]
if len(root.children) == 0:
return ModalTaskInfo(function_name="unknown", error_code=ErrorCode.NOT_FOUND,
status=TaskStatus.expired, error_reason="NOT_FOUND")
task = root.children[0]
# task: Optional[InputInfo] = root.children[0]
task: Optional[InputInfo] = next(iter(root.children), None)
if not task.function_call_id == task_id:
return ModalTaskInfo(function_name=task.function_name, status=TaskStatus.expired,
error_code=ErrorCode.NOT_FOUND, error_reason="NOT_FOUND")

View File

@@ -8,8 +8,9 @@ import sentry_sdk
from loguru import logger
import functools
from BowongModalFunctions.models.web_model import WebhookNotify, WebhookMethodEnum, BaseFFMPEGTaskStatusResponse, \
TaskStatus, ErrorCode, FFMPEGResult
from ..models.responses.models import BaseFFMPEGTaskStatusResponse
from ..models.ffmpeg_tasks.models import WebhookNotify, FFMPEGResult
from ..models.enums.models import TaskStatus, ErrorCode, WebhookMethodEnum
class SentryUtils:

View File

@@ -1,8 +1,6 @@
import re
from datetime import timedelta, datetime
from loguru import logger
class TimeDelta(timedelta):
@classmethod
@@ -23,38 +21,38 @@ def parse_time(time_str):
"""解析时间字符串为datetime对象"""
parsed_time = None
if re.match(r"^\d{2}:\d{2}:\d{2}\.\d{3}$",time_str):
if re.match(r"^\d{2}:\d{2}:\d{2}\.\d{3}$", time_str):
# 先尝试完整格式 HH:MM:SS.fff
parsed_time = datetime.strptime(time_str, '%H:%M:%S.%f')
elif re.match(r"^\d{2}:\d{2}:\d{2}:\d{3}$",time_str):
elif re.match(r"^\d{2}:\d{2}:\d{2}:\d{3}$", time_str):
# 如果失败,尝试 HH:MM:SS:fff 格式
parsed_time = datetime.strptime(time_str, '%H:%M:%S:%f')
elif re.match(r"^\d{2}:\d{2}\.\d{3}$",time_str):
elif re.match(r"^\d{2}:\d{2}\.\d{3}$", time_str):
# 如果失败,尝试 MM:SS.fff 格式
dt = datetime.strptime(time_str, '%M:%S.%f')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}\.\d{2}:\d{3}$",time_str):
elif re.match(r"^\d{2}\.\d{2}:\d{3}$", time_str):
# 如果失败,尝试 MM.SS:fff 格式
dt = datetime.strptime(time_str, '%M.%S:%f')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}\.\d{2}\.\d{3}$",time_str):
elif re.match(r"^\d{2}\.\d{2}\.\d{3}$", time_str):
# 如果失败,尝试 MM.SS.fff 格式
dt = datetime.strptime(time_str, '%M.%S.%f')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}:\d{2}:\d{3}$",time_str):
elif re.match(r"^\d{2}:\d{2}:\d{3}$", time_str):
# 如果失败,尝试 MM:SS:fff 格式
dt = datetime.strptime(time_str, '%M:%S:%f')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}:\d{2}$",time_str):
elif re.match(r"^\d{2}:\d{2}$", time_str):
# 如果失败,尝试 MM:SS:fff 格式
dt = datetime.strptime(time_str, '%M:%S')
# 将小时设为0只保留分钟和秒
parsed_time = datetime.combine(dt.date(), dt.time().replace(hour=0))
elif re.match(r"^\d{2}\.\d{2}$",time_str):
elif re.match(r"^\d{2}\.\d{2}$", time_str):
# 如果失败,尝试 MM:SS:fff 格式
dt = datetime.strptime(time_str, '%M.%S')
# 将小时设为0只保留分钟和秒
@@ -136,7 +134,7 @@ def merge_product_data(data, start_time_str, end_time_str, merge_diff=5):
hours, remainder = divmod(total_seconds, 3600)
minutes, seconds = divmod(remainder, 60)
microseconds = duration.microseconds
max_time_str = f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}.{microseconds//1000:03d}"
max_time_str = f"{int(hours):02d}:{int(minutes):02d}:{int(seconds):02d}.{microseconds // 1000:03d}"
product_dict = {}
@@ -155,7 +153,7 @@ def merge_product_data(data, start_time_str, end_time_str, merge_diff=5):
start, end = parse_timeline_item(item)
# 比较起始时间与时间差
start_str = format_time(start)
if (start - datetime.strptime("00:00:00.000",'%H:%M:%S.%f')) > duration and not start_str.startswith("00"):
if (start - datetime.strptime("00:00:00.000", '%H:%M:%S.%f')) > duration and not start_str.startswith("00"):
new_start_str = convert_time(start_str)
else:
new_start_str = start_str
@@ -163,7 +161,7 @@ def merge_product_data(data, start_time_str, end_time_str, merge_diff=5):
new_start_str = max_time_str
end_str = format_time(end)
if (end - datetime.strptime("00:00:00.000",'%H:%M:%S.%f')) > duration and not end_str.startswith("00"):
if (end - datetime.strptime("00:00:00.000", '%H:%M:%S.%f')) > duration and not end_str.startswith("00"):
new_end_str = convert_time(end_str)
else:
new_end_str = end_str

View File

@@ -5,7 +5,7 @@ from datetime import datetime, timedelta
import aiofiles
import aiohttp
from typing import Union, List, Tuple, Optional, Dict, Any
from typing import List, Tuple, Optional, Any
import m3u8
import numpy as np
@@ -14,7 +14,6 @@ import math
from aiohttp import ClientTimeout
from .TimeUtils import TimeDelta
from pydantic import BaseModel, ConfigDict, computed_field, Field, field_validator
from ffmpeg import FFmpeg
from ffmpeg.asyncio import FFmpeg as AsyncFFmpeg
import soundfile as sf
@@ -35,181 +34,7 @@ from pedalboard.io import AudioFile
from loguru import logger
from .PathUtils import FileUtils
from ..models.ffmpeg_worker_model import FFMpegSliceSegment
class FFMPEGSliceOptions(BaseModel):
limit_size: Optional[int] = Field(default=None, description="不超过指定文件(字节)大小, 默认为空不限制输出大小")
bit_rate: Optional[int] = Field(default=None, description="指定输出视频的比特率, 不能与limit_size同时设置")
crf: int = Field(default=16, description="输出视频的质量")
fps: int = Field(default=30, description="输出视频的FPS")
width: int = Field(default=None, description="输出视频的宽(像素)")
height: int = Field(default=None, description="输出视频的高(像素)")
@computed_field(description="解析为字符表达式的文件大小")
@property
def pretty_limit_size(self) -> Optional[str]:
if self.limit_size is not None:
for unit in ['B', 'KB', 'MB', 'GB', 'TB']:
if self.limit_size < 1024.0:
return f"{self.limit_size:.2f}{unit}"
self.limit_size /= 1024.0
return f"{self.limit_size:.2f}PB"
else:
return None
@computed_field(description="解析为字符表达式的比特率")
@property
def pretty_bit_rate(self) -> Optional[str]:
if self.bit_rate is not None:
return f"{self.bit_rate}k"
class MediaStream(BaseModel):
duration: float = Field(0, description="时长")
codec_name: str
tags: Optional[Any] = Field(None)
model_config = ConfigDict(extra='allow')
class HLSMediaVideoStream(BaseModel):
stream_type: str = "video"
codec_name: str
codec_type: str
width: int
height: int
avg_frame_rate: str
tags: Optional[Any] = Field(None)
duration: Optional[float] = Field(None)
@computed_field
@property
def video_frame_rate(self) -> float:
numerator, denominator = map(int, self.avg_frame_rate.split('/'))
if denominator != 0:
return numerator / denominator
return 0
class HLSMediaAudioStream(BaseModel):
stream_type: str = "audio"
sample_rate: str
channels: int
channel_layout: str
start_time: str
tags: Optional[Any] = Field(None)
class AudioStream(MediaStream):
stream_type: str = Field("audio")
codec_type: str
sample_rate: str
channels: int
tags: Optional[Any] = Field(None)
model_config = ConfigDict(extra='allow')
class VideoStream(MediaStream):
stream_type: str = "video"
width: int
height: int
bit_rate: int
avg_frame_rate: str
@computed_field
@property
def video_bitrate(self) -> str:
return str(int(self.bit_rate / 1000)) + 'k'
@computed_field
@property
def video_frame_rate(self) -> float:
numerator, denominator = map(int, self.avg_frame_rate.split('/'))
if denominator != 0:
return numerator / denominator
return 0
model_config = ConfigDict(extra='allow')
class ImageStream(MediaStream):
stream_type: str = "image"
width: int
height: int
model_config = ConfigDict(extra='allow')
class SubtitleStreamTags(BaseModel):
language: str = Field(description="内嵌字幕的语言")
model_config = ConfigDict(extra='allow')
class SubtitleStream(MediaStream):
stream_type: str = "subtitle"
tags: SubtitleStreamTags
model_config = ConfigDict(extra='allow')
class VideoFormat(BaseModel):
filename: str
format_name: str
start_time: float = Field(0, description="起始时间")
size: int
bit_rate: Optional[int] = Field(None, description="文件比特率")
duration: float = Field(3600 * 12, description="文件时长")
class VideoMetadata(BaseModel):
streams: List[
Union[ImageStream, AudioStream, VideoStream, HLSMediaAudioStream, HLSMediaVideoStream, SubtitleStream]] = Field(
description="媒体包含的数据轨道")
format: Optional[VideoFormat] = Field(None)
@field_validator('streams', mode='before')
def parse_streams(cls, value):
streams = []
if isinstance(value, List):
for stream in value:
if isinstance(stream, Dict):
logger.info(f"Parsing stream : {json.dumps(stream, ensure_ascii=False)}")
if stream.get("codec_type") == 'audio':
if stream.get("duration") is None:
logger.info("Parsing audio stream")
hls_audio = HLSMediaAudioStream.model_validate(stream)
streams.append(hls_audio)
else:
logger.info("Parsing hls audio stream")
audio = AudioStream.model_validate(stream)
streams.append(audio)
elif stream.get("codec_type") == 'video':
if stream.get("codec_name") in ("gif", "png", "mjpg", "mjpeg", "webp"):
logger.info("Parsing image stream")
image = ImageStream.model_validate(stream)
streams.append(image)
else:
if stream.get("duration") is None:
logger.info("Parsing hls video stream")
hls_video = HLSMediaVideoStream.model_validate(stream)
streams.append(hls_video)
else:
logger.info("Parsing video stream")
video = VideoStream.model_validate(stream)
streams.append(video)
elif stream.get("codec_type") == 'subtitle':
logger.info("Parsing subtitle stream")
subtitle_stream = SubtitleStream.model_validate(stream)
streams.append(subtitle_stream)
return streams
else:
raise TypeError
model_config = ConfigDict(extra='allow')
from ..models.ffmpeg_tasks.models import FFMpegSliceSegment, FFMPEGSliceOptions, VideoStream, VideoMetadata
class VideoUtils:

View File

@@ -0,0 +1,22 @@
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from scalar_fastapi import get_scalar_api_reference
from Douyin_TikTok_Download_API.app.api import router
web_app_tikhub = FastAPI(title="Modal Tikhub API",
version="1.0.0",
summary="Modal Tikhub API, 包含抖音Web API",
)
web_app_tikhub.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
@web_app_tikhub.get("/scalar", include_in_schema=False)
async def scalar():
return get_scalar_api_reference(openapi_url='/openapi.json', title="Modal worker web endpoint")
web_app_tikhub.include_router(router.router)

View File

@@ -1,5 +1,5 @@
import modal
from BowongModalFunctions.config import WorkerConfig
from BowongModalFunctions.models.settings.cluster import WorkerConfig
from .video import app as media_app
from .web import app as web_app
from .ffmpeg_app import app as ffmpeg_app

View File

@@ -75,8 +75,8 @@ with comfyui_image.imports():
from typing import Tuple, Any, Dict, Optional
import sentry_sdk
from modal import current_function_call_id
from BowongModalFunctions.config import WorkerConfig
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify
from BowongModalFunctions.models.settings.cluster import WorkerConfig
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from sentry_sdk.integrations.loguru import LoguruIntegration

View File

@@ -56,10 +56,10 @@ with comfyui_latentsync_1_5_image.imports():
import sentry_sdk
import psutil
from loguru import logger
from BowongModalFunctions.config import WorkerConfig
from BowongModalFunctions.models.settings.cluster import WorkerConfig
from modal import current_function_call_id
from sentry_sdk.integrations.loguru import LoguruIntegration
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify
from BowongModalFunctions.utils.SentryUtils import SentryUtils
config = WorkerConfig()

View File

@@ -19,7 +19,7 @@ with ffmpeg_worker_image.imports():
import shutil, backoff, sentry_sdk
from typing import List
from loguru import logger
from BowongModalFunctions.config import WorkerConfig
from BowongModalFunctions.models.settings.cluster import WorkerConfig
config = WorkerConfig()

View File

@@ -3,8 +3,8 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSource
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils

View File

@@ -3,8 +3,8 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSources
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.requests.models import MediaSourcesRequest
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils
@@ -26,7 +26,7 @@ with ffmpeg_worker_image.imports():
),
}, )
@modal.concurrent(max_inputs=1)
async def ffmpeg_concat_medias(medias: MediaSources,
async def ffmpeg_concat_medias(medias: MediaSourcesRequest,
sentry_trace: Optional[SentryTransactionInfo] = None,
webhook: Optional[WebhookNotify] = None) -> Tuple[
FFMPEGResult, Optional[SentryTransactionInfo]]:
@@ -36,7 +36,7 @@ with ffmpeg_worker_image.imports():
sentry_trace_id=sentry_trace.x_trace_id if sentry_trace else None,
sentry_baggage=sentry_trace.x_baggage if sentry_trace else None)
@SentryUtils.webhook_handler(webhook=webhook, func_id=fn_id)
async def ffmpeg_process(media_sources: MediaSources, output_filepath: str) -> FFMPEGResult:
async def ffmpeg_process(media_sources: MediaSourcesRequest, output_filepath: str) -> FFMPEGResult:
input_videos = [f"{s3_mount}/{media_source.cache_filepath}" for media_source in media_sources.inputs]
local_output_path, metadata = await VideoUtils.ffmpeg_concat_medias(media_paths=input_videos,
output_path=output_filepath)

View File

@@ -3,11 +3,13 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSource, MediaProtocol
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.enums.models import MediaProtocol
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils, FFMPEGSliceOptions
from BowongModalFunctions.utils.VideoUtils import VideoUtils
from BowongModalFunctions.models.ffmpeg_tasks.models import FFMPEGSliceOptions, SentryTransactionInfo, WebhookNotify, \
FFMPEGResult
import sentry_sdk
from typing import Optional, Tuple
from modal import current_function_call_id

View File

@@ -3,8 +3,8 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSource
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils

View File

@@ -3,8 +3,8 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSource
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils

View File

@@ -4,8 +4,9 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSource, MediaProtocol
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.enums.models import MediaProtocol
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils

View File

@@ -3,8 +3,8 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSource
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils

View File

@@ -3,8 +3,8 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSource
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils

View File

@@ -3,12 +3,14 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.ffmpeg_worker_model import FFMpegSliceSegment
from BowongModalFunctions.models.media_model import MediaSources, MediaProtocol, MediaSource
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.ffmpeg_tasks.models import FFMpegSliceSegment, FFMPEGSliceOptions, \
SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.requests.models import MediaSourcesRequest
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.enums.models import MediaProtocol
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils, FFMPEGSliceOptions
from BowongModalFunctions.utils.VideoUtils import VideoUtils
import sentry_sdk
from loguru import logger
from typing import Optional, Tuple, List

View File

@@ -6,8 +6,9 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult, \
WebhookMethodEnum, BaseFFMPEGTaskStatusResponse, TaskStatus
from BowongModalFunctions.models.responses.models import BaseFFMPEGTaskStatusResponse
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.enums.models import TaskStatus, WebhookMethodEnum
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils
import sentry_sdk

View File

@@ -3,8 +3,8 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSource
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils

View File

@@ -3,8 +3,8 @@ import modal
from ..ffmpeg_app import ffmpeg_worker_image, app, config, s3_mount, local_copy_to_s3, output_path_prefix
with ffmpeg_worker_image.imports():
from BowongModalFunctions.models.media_model import MediaSource
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo, WebhookNotify, FFMPEGResult
from BowongModalFunctions.utils.PathUtils import FileUtils
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils

View File

@@ -28,9 +28,9 @@ with downloader_image.imports():
from typing import List
from loguru import logger
from BowongModalFunctions.config import WorkerConfig
from BowongModalFunctions.models.settings.cluster import WorkerConfig
from BowongModalFunctions.utils.KVCache import MediaSourceKVCache, LiveProductKVCache
from BowongModalFunctions.models.media_model import MediaSource
from BowongModalFunctions.models.cache_tasks.models import MediaSource
config = WorkerConfig()

View File

@@ -4,7 +4,8 @@ from ..video import downloader_image, app, config
with downloader_image.imports():
import os
from BowongModalFunctions.models.media_model import MediaSource, MediaCacheStatus
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.enums.models import MediaCacheStatus
@app.function(cpu=1, timeout=300,

View File

@@ -15,8 +15,9 @@ with downloader_image.imports():
from tencentcloud.vod.v20180717.vod_client import VodClient
from tencentcloud.vod.v20180717 import models as vod_request_models
from BowongModalFunctions.models.media_model import MediaSource, MediaCacheStatus, MediaProtocol
from BowongModalFunctions.models.web_model import SentryTransactionInfo
from BowongModalFunctions.models.cache_tasks.models import MediaSource
from BowongModalFunctions.models.enums.models import MediaProtocol, MediaCacheStatus
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo
@app.function(cpu=1, timeout=1800,

View File

@@ -18,12 +18,13 @@ with downloader_image.imports():
from fastapi import HTTPException
from starlette import status
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.models.media_model import MediaSource
from BowongModalFunctions.models.web_model import SentryTransactionInfo, WebhookNotify, GeminiFirstStagePromptVariables, \
GeminiSecondStagePromptVariables, GeminiFirstStageResponseModel, GeminiSecondStageResponseModel
from BowongModalFunctions.models.ffmpeg_worker_model import FFMpegSliceSegment
from BowongModalFunctions.models.cache_tasks.models import MediaSource, GeminiFirstStageResponseModel, \
GeminiFirstStagePromptVariables, GeminiSecondStagePromptVariables
from BowongModalFunctions.models.responses.models import GeminiSecondStageResponseModel
from BowongModalFunctions.models.ffmpeg_tasks.models import FFMpegSliceSegment, FFMPEGSliceOptions, \
SentryTransactionInfo, WebhookNotify
from BowongModalFunctions.utils.TimeUtils import TimeDelta, merge_product_data
from BowongModalFunctions.utils.VideoUtils import FFMPEGSliceOptions, VideoUtils
from BowongModalFunctions.utils.VideoUtils import VideoUtils
@app.function(cpu=(0.5, 64), timeout=1800,

View File

@@ -18,7 +18,8 @@ with downloader_image.imports():
from modal import current_function_call_id
from BowongModalFunctions.utils.SentryUtils import SentryUtils
from BowongModalFunctions.models.web_model import SentryTransactionInfo
from BowongModalFunctions.models.ffmpeg_tasks.models import SentryTransactionInfo
@app.function(cpu=(0.5, 16),
max_containers=config.video_downloader_concurrency,

View File

@@ -8,7 +8,8 @@ with downloader_image.imports():
from datetime import datetime, timedelta, timezone
from httpx import Timeout
from BowongModalFunctions.models.web_model import LiveProduct, LiveProductCaches
from BowongModalFunctions.models.cache_tasks.models import LiveProduct, LiveProductCaches
@app.function(max_containers=config.video_downloader_concurrency, timeout=130)
@modal.concurrent(max_inputs=50)

View File

@@ -22,8 +22,9 @@ app = modal.App(
include_source=False)
with fastapi_image.imports():
from BowongModalFunctions.api import web_app, web_app_tikhub
from BowongModalFunctions.config import WorkerConfig
from BowongModalFunctions.api import web_app
from BowongModalFunctions.models.settings.cluster import WorkerConfig
from Douyin_TikTok_Download_API.tikhub_api import web_app_tikhub
config = WorkerConfig()
@@ -44,11 +45,10 @@ with fastapi_image.imports():
def fastapi_webapp():
return web_app
# 短保持时间保证IP不停更换
@app.function(scaledown_window=2,
secrets=[
modal.Secret.from_name("cf-kv-secret"),
],
secrets=[modal.Secret.from_name("cf-kv-secret"), ],
cloud="aws",
volumes={
config.S3_mount_dir: modal.CloudBucketMount(

View File

@@ -5,8 +5,8 @@ from typing import List, Optional
import httpx
from loguru import logger
from tqdm import tqdm
from BowongModalFunctions.models.ffmpeg_worker_model import FFMpegSliceSegment
from BowongModalFunctions.utils.VideoUtils import VideoUtils, FFMPEGSliceOptions
from BowongModalFunctions.models.ffmpeg_tasks.models import FFMpegSliceSegment, FFMPEGSliceOptions
from BowongModalFunctions.utils.VideoUtils import VideoUtils
class FFMPEGTestCase(unittest.IsolatedAsyncioTestCase):

View File

@@ -3,7 +3,7 @@ import unittest
import httpx
from loguru import logger
from BowongModalFunctions.models.web_model import FFMPEGSubtitleOverlayRequest
from BowongModalFunctions.models.requests.models import FFMPEGSubtitleOverlayRequest
class PydanticModelTestCase(unittest.TestCase):