Modal worker统一python代码框架

This commit is contained in:
shuohigh@gmail.com
2025-04-29 10:31:25 +08:00
parent 93bfb3840e
commit 8e5cb27f32
40 changed files with 1627 additions and 2236 deletions

13
.env Normal file
View File

@@ -0,0 +1,13 @@
R2_API_TOKEN=_AmmE60blmX2wLIu6cuBWDhOuNpHOE4xQH6n5fYH
R2_ACCESS_KEY_ID=79cde91614e6e43eeb6ee9d63836b64e
R2_SECRET_ACCESS_KEY=7a712d6026e9f96fbde2b2efc2411f3dc5e4f9bed6e7d190e0f437a7aa77f9a0
VOD_SECRET_ID=AKIDsrihIyjZOBsjimt8TsN8yvv1AMh5dB44
VOD_SECRET_KEY=CPZcxdk6W39Jd4cGY95wvupoyMd0YFqW
AWS_ACCESS_KEY_ID=AKIAYRH5NGRSWHN2L4M6
AWS_SECRET_ACCESS_KEY=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
CF_KV_API_TOKEN=hAiXy3jB-Lt5q-XiIV588lyvyH1c8FJaG3oieqeT
CF_KV_NAMESPACE_ID=f24d396e0daa418e89a1d7074b435c24
CF_ACCOUNT_ID=67720b647ff2b55cf37ba3ef9e677083

12
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="MySQL" uuid="0c96f6ba-5b9d-4b2d-ac3f-629fdfb28b12">
<driver-ref>mysql.8</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>com.mysql.cj.jdbc.Driver</jdbc-driver>
<jdbc-url>jdbc:mysql://root:MrYRE^u9aU$n@sh-cynosdbmysql-grp-hofmpgvu.sql.tencentcdb.com:23314/local_merge</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

2
.idea/misc.xml generated
View File

@@ -3,5 +3,5 @@
<component name="Black">
<option name="sdkName" value="Python 3.9 (pythonProject)" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="ComfyUI-3.10" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="Python 3.11 (modalDeploy)" project-jdk-type="Python SDK" />
</project>

2
.idea/modalDeploy.iml generated
View File

@@ -4,7 +4,7 @@
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="ComfyUI-3.10" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="Python 3.11 (modalDeploy)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.11

View File

@@ -1,270 +0,0 @@
import json
import os
import shutil
import socket
import subprocess
import time
import traceback
import uuid
from concurrent.futures.thread import ThreadPoolExecutor
from typing import Any, Optional, Union
import httpx
import loguru
import requests
import starlette.datastructures
import uvicorn
from fastapi import UploadFile, HTTPException, Depends, FastAPI
from fastapi.routing import APIRoute
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from starlette import status
class HeyGem:
def __init__(self):
def check_port_in_use(port, host='127.0.0.1'):
s = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(1)
s.connect((host, int(port)))
return True
except socket.error:
return False
finally:
if s:
s.close()
uid = str(uuid.uuid4())
cmd = "/bin/bash -c \"echo client_id: %s && source /root/.bashrc && nohup python /root/heygem.py >> /root/logs/%s.log 2>&1 &\"" % (uid, uid)
subprocess.run(cmd, shell=True, check=True)
timeout = 30
while timeout > 0:
check = check_port_in_use(8383)
if check:
loguru.logger.success("HeyGem Server started on port 8383")
break
else:
timeout -= 1
time.sleep(2)
if timeout == 0:
raise TimeoutError("HeyGem Server Start timed out")
self.result = {}
self.executor = ThreadPoolExecutor(max_workers=1)
self.server = FastAPI()
self.server.routes.append(APIRoute(path='/', endpoint=self.api, methods=['POST']))
self.server.routes.append(APIRoute(path='/{code}', endpoint=self.get_result, methods=['GET']))
def infer(self, code, video_path, audio_path) -> str:
def task_submit(uid, video, audio, heygem_url):
"""Submit a task to the API"""
task_submit_api = f'{heygem_url}/easy/submit'
result_json = {
'status': False, 'data': {}, 'msg': ''
}
try:
data = {
"code": uid,
"video_url": video,
"audio_url": audio,
"chaofen": 1,
"watermark_switch": 0,
"pn": 1
}
loguru.logger.info(f'data={json.dumps(data, ensure_ascii=False)}')
with httpx.Client() as client:
resp = client.post(task_submit_api, json=data)
resp_dict = resp.json()
loguru.logger.info(f'submit data: {json.dumps(resp_dict, ensure_ascii=False)}')
if resp_dict['code'] != 10000:
result_json['status'] = False
result_json['msg'] = result_json['msg']
else:
result_json['status'] = True
result_json['data'] = uid
result_json['msg'] = '任务提交成功'
return result_json
except Exception as e:
loguru.logger.info(f'submit task fail case by:{str(e)}')
raise RuntimeError(str(e))
def query_task_progress(heygem_url, heygem_temp_path, task_id: str, interval: int = 10, timeout: int = 60 * 35):
"""Query task progress and wait for completion"""
result_json = {'status': False, 'data': {}, 'msg': ''}
def query_result(t_id: str):
tmp_dict = {'status': True, 'data': dict(), 'msg': ''}
try:
query_task_url = f'{heygem_url}/easy/query'
params = {
'code': t_id
}
with httpx.Client() as client:
resp = client.get(query_task_url, params=params)
resp_dict = resp.json()
loguru.logger.info(f'query task data: {json.dumps(resp_dict, ensure_ascii=False)}')
status_code = resp_dict['code']
if status_code in (9999, 10002, 10003, 10001):
tmp_dict['status'] = False
tmp_dict['msg'] = resp_dict['msg']
elif status_code == 10000:
status_code = resp_dict['data'].get('status', 1)
if status_code == 3:
tmp_dict['status'] = False
tmp_dict['msg'] = resp_dict['data']['msg']
else:
process = resp_dict['data'].get('progress', 20)
if status_code == 2:
process = 100
else:
process = process
result = resp_dict['data'].get('result', '')
tmp_dict['data'] = {'progress': process,
'path': result,
}
else:
pass
except Exception as e:
loguru.logger.info(f'query task fail case by:{str(e)}')
raise RuntimeError(str(e))
return tmp_dict
end = time.time() + timeout
while time.time() < end:
tmp_result = query_result(task_id)
if not tmp_result['status'] or tmp_result['data'].__eq__({}):
result_json['status'] = False
result_json['msg'] = tmp_result['msg']
break
else:
process = tmp_result['data']['progress']
loguru.logger.info(f'query task progress :{process}')
if tmp_result['data']['progress'] < 100:
time.sleep(interval)
loguru.logger.info(f'wait next interval:{interval}')
else:
p = tmp_result['data']['path']
p = p.replace('/', '').replace('\\', '')
result_json['data'] = "%s/%s" % (heygem_temp_path, p)
result_json['status'] = True
break
return result_json
try:
heygem_url = "http://127.0.0.1:8383"
heygem_temp_path = "/code/data/temp"
submit_result = task_submit(code, video_path, audio_path, heygem_url)
if not submit_result['status']:
raise Exception(f"Task submission failed: {submit_result['msg']}")
task_id = submit_result['data']
loguru.logger.info(f'Submitted task: {task_id}')
# Query task progress
progress_result = query_task_progress(heygem_url, heygem_temp_path, task_id, interval=5)
if not progress_result['status']:
raise RuntimeError(f"Task processing failed: {progress_result['msg']}")
# Return the file for download
file_path = progress_result['data']
if not os.path.exists(file_path):
raise FileNotFoundError(f"Output file not found at {file_path}")
return file_path
except Exception as e:
loguru.logger.error(f"Error processing request || {str(e)}")
raise Exception(str(e))
finally:
try:
os.remove(video_path)
os.remove(audio_path)
except:
pass
def download_file(self, url, path):
with requests.get(url, stream=True) as r:
with open(path, 'wb') as f:
shutil.copyfileobj(r.raw, f)
async def get_result(self, code:str, token: HTTPAuthorizationCredentials = Depends(HTTPBearer())):
if token.credentials != os.environ["AUTH_TOKEN"]:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
if code in self.result:
return self.result[code]
else:
return {"status": "waiting", "msg":""}
async def api(self, video_file: Optional[Union[UploadFile, str]], audio_file: Optional[Union[UploadFile, str]], token: HTTPAuthorizationCredentials = Depends(HTTPBearer())):
if token.credentials != os.environ["AUTH_TOKEN"]:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
code = str(uuid.uuid4())
video_path=None
audio_path=None
try:
file_id = str(uuid.uuid4())
if isinstance(video_file, UploadFile) or isinstance(video_file, starlette.datastructures.UploadFile):
video_path = "/root/%s.%s" % (file_id, video_file.filename.split(".")[-1])
with open(video_path, "wb") as f:
data = await video_file.read()
f.write(data)
elif isinstance(video_file, str) and video_file.startswith("http"):
video_path = "/root/%s.%s" % (file_id, video_file.split("?")[0].split("/")[-1].split(".")[-1])
self.download_file(video_file, video_path)
else:
self.result[code] = {"status": "fail", "msg": "video file save failed: Not a valid input"}
if isinstance(audio_file, UploadFile) or isinstance(audio_file, starlette.datastructures.UploadFile):
audio_path = "/root/%s.%s" % (file_id, audio_file.filename.split(".")[-1])
with open(audio_path, "wb") as f:
data = await audio_file.read()
f.write(data)
elif isinstance(audio_file, str) and audio_file.startswith("http"):
audio_path = "/root/%s.%s" % (file_id, audio_file.split("?")[0].split("/")[-1].split(".")[-1])
self.download_file(audio_file, audio_path)
else:
self.result[code] = {"status": "fail", "msg": "audio file save failed: Not a valid input"}
except Exception as e:
self.result[code] = {"status": "fail", "msg": f"video or audio file save failed: {str(e)}"}
# Submit
if video_path and audio_path:
self.executor.submit(self._api, video_path=video_path, audio_path=audio_path, code=code)
return {"status": "success", "code": code}
else:
return self.result[code]
def _api(self, video_path: str, audio_path: str, code):
loguru.logger.info(f"Enter Inference Module")
try:
file_path = self.infer(code, video_path, audio_path)
try:
loguru.logger.info("Try move file to S3 manually")
# S3 Fallback
import boto3
import yaml
with open("/root/config.yaml", encoding="utf-8",
mode="r+") as config:
yaml_config = yaml.load(config, Loader=yaml.FullLoader)
awss3 = boto3.resource('s3', aws_access_key_id=yaml_config["aws_key_id"],
aws_secret_access_key=yaml_config["aws_access_key"])
awss3.meta.client.upload_file(file_path, "bw-heygem-output", file_path.split(os.path.sep)[-1])
except Exception as e:
self.result[code] = {"status": "fail", "msg": "Failed to move file to S3 manually: " + str(e)}
self.result[code] = {"status": "success", "msg":f"{file_path.split(os.path.sep)[-1]}"}
except Exception as e:
traceback.print_exc()
self.result[code] = {"status": "fail", "msg": "Inference module failed: "+str(e)}
if __name__ == "__main__":
heygem = HeyGem()
uvicorn.run(app=heygem.server, host="0.0.0.0", port=6006)

View File

@@ -1,146 +0,0 @@
import copy
import os
import random
import time
from concurrent.futures.thread import ThreadPoolExecutor
from typing import List
import loguru
from AutoDL.autodl_scheduling.util.audodl_sdk import instance_operate, get_autodl_machines, payg
RETRY_LIMIT = 20 # 创建实例重试次数
class Instance:
def __init__(self, uuid:str, active:bool=False, last_active_time:float=-1., domain:str=""):
self.uuid:str=uuid
self.active:bool=active
self.last_active_time:float=last_active_time
self.domain:str=domain
def __str__(self):
return "uuid:%s active:%s last_active_time:%s domain:%s" % (self.uuid, self.active, self.last_active_time, self.domain)
class InstancePool:
def __init__(self, min_instance=0, max_instance=100, scaledown_window=120, buffer_instance=0, timeout=1200):
self.min_instance = min_instance
self.max_instance = max_instance
self.scaledown_window = scaledown_window
self.buffer_instance = buffer_instance
self.timeout = timeout
self.instances:List[Instance] = []
self.executor = ThreadPoolExecutor(max_workers=os.cpu_count()*2)
self.threads = []
self.intro_threads = []
def scale_instance(self, target_instance, disable_shrink=True):
if target_instance + self.buffer_instance < self.min_instance:
return self._scale(self.min_instance)
if target_instance + self.buffer_instance > self.max_instance:
return self._scale(self.max_instance)
if target_instance + self.buffer_instance == len(self.instances):
return True
return self._scale(target_instance + self.buffer_instance, disable_shrink)
def remove_instance(self, instance:Instance):
if instance_operate(instance.uuid, "power_off"):
if instance_operate(instance.uuid, "release"):
for i in self.instances:
if i.uuid == instance.uuid:
self.instances.remove(i)
else:
loguru.logger.error("Instance {} failed to release".format(instance.uuid))
else:
loguru.logger.error("Instance {} failed to power off".format(instance.uuid))
def _add_instance(self):
lim = RETRY_LIMIT
while lim > 0:
machines = get_autodl_machines()
if len(machines) > 0:
m = random.choice(machines)
result = payg(m["region_name"], m["machine_id"])
if result:
self.instances.append(
Instance(uuid=result[0], active=False, last_active_time=time.time(), domain="https://"+result[1]))
break
else:
time.sleep(1)
lim -= 1
if lim <= 0:
loguru.logger.error("Fail to Scale[Add] Instance")
def introspection(self):
# 停止超时实例(运行超时和无任务超时)
timeout = self.timeout//6
while len(self.intro_threads) > 0 and timeout > 0:
time.sleep(1)
timeout -= 1
if timeout == 0:
loguru.logger.error("Fail to Introspect Instances: Timeout")
return
try:
before=len(self.instances)
instance_copy = copy.deepcopy(self.instances)
flag = []
for instance in instance_copy:
if instance.active:
if (time.time() - instance.last_active_time) > self.timeout:
flag.append(instance.uuid)
self.intro_threads.append(self.executor.submit(self.remove_instance, instance=instance))
else:
if (time.time() - instance.last_active_time) > self.scaledown_window:
flag.append(instance.uuid)
self.intro_threads.append(self.executor.submit(self.remove_instance, instance=instance))
while len(self.intro_threads) > 0:
for t in self.intro_threads:
t.result(timeout=self.timeout//2)
self.intro_threads.remove(t)
after = len(self.instances)
for instance in self.instances:
if instance.uuid in flag:
raise Exception("Instance[%s] Remove Failed" % instance.uuid)
if len(flag) > 0:
loguru.logger.info("Instance Num Before Introspecting %d After Introspecting %d" % (before, after))
except Exception as e:
loguru.logger.error("Fail to Introspect Instances: %s" % e)
def _scale(self, target_instance:int, disable_shrink=True):
try:
self.introspection()
# 调整实例数量
instance_copy = copy.deepcopy(self.instances)
dest = target_instance - len(instance_copy)
if (disable_shrink and dest < 0) or dest == 0:
return True
loguru.logger.info("Instance Num Before Scaling %d ; Target %d" % (len(self.instances), target_instance))
if dest < 0:
dest = abs(dest)
for instance in instance_copy:
if not instance.active and dest > 0:
self.threads.append(self.executor.submit(self.remove_instance, instance=instance))
dest -= 1
elif dest > 0:
for i in range(dest):
self.threads.append(self.executor.submit(self._add_instance))
while len(self.threads) > 0:
for t in self.threads:
t.result(timeout=self.timeout//2)
self.threads.remove(t)
loguru.logger.info("Instance Num After Scaling %d ; Target %d" % (len(self.instances), target_instance))
if len(self.instances) == target_instance:
return True
else:
return False
except Exception as e:
loguru.logger.error("Fail to Scale: %s" % e)
return False
if __name__ == "__main__":
ip = InstancePool()
print(ip.scale_instance(5))
time.sleep(5)
print(ip.scale_instance(0))

View File

@@ -1,36 +0,0 @@
from typing import Union
class ResultMap:
def __init__(self, max_size:int=1000):
self.map = dict()
self.max_size = max_size
def get(self, uid:str) -> Union[dict,None]:
if uid in self.map:
if self.map[uid]:
r = self.map[uid]
# self.remove(uid)
return r
else:
return None
else:
raise KeyError(uid)
def set(self, uid:str, result:dict=None) -> None:
if uid in self.map:
raise RuntimeError(f"Key {uid} already exists")
else:
if len(self.map) + 1 > self.max_size:
raise RuntimeError(f"ResultMap exceeds max size {self.max_size}")
self.map[uid] = result
def update(self, uid:str, result:dict) -> None:
if uid in self.map:
self.map[uid] = result
else:
raise KeyError(uid)
def remove(self, uid:str) -> None:
if uid in self.map:
del self.map[uid]

View File

@@ -1,82 +0,0 @@
import time
import traceback
from typing import Union
import loguru
import requests
from fastapi import UploadFile
SERVER_TIME_OUT = 60 * 35
class RunningPool:
def __init__(self):
self.tasks = {}
self._headers = {
"Authorization": "Bearer 7468B79CA2CF53436C06B29A6F16451C"
}
def run(self, instance_id, uid, base_url:str, video_file:Union[UploadFile,str], audio_file:Union[UploadFile,str]) -> Union[bool, None]:
try:
code = self._req(base_url, video_file, audio_file)
if not code:
raise Exception("[Code] is None")
except Exception as e:
loguru.logger.error("%s Task Submit Failed: %s" % (uid, e))
return None
self.tasks[uid] = {"instance_id": instance_id, "base_url":base_url, "code": code, "status": False, "start_time": time.time()}
return True
def result(self, uid:str) -> Union[dict, None]:
if uid in self.tasks:
try:
resp = requests.get(self.tasks[uid]["base_url"] + "/" + self.tasks[uid]["code"], headers=self._headers, timeout=10)
if resp.status_code != 200:
raise Exception("%s Get Result Failed: %s" % (uid, resp.status_code))
else:
if resp.json()["status"] == "waiting":
if self.tasks[uid]["start_time"] + SERVER_TIME_OUT + 10 < time.time():
self.tasks[uid]["status"] = True
return {"status": "fail", "msg": "Instance Timeout", "instance_id": self.tasks[uid]["instance_id"]}
return None
else:
self.tasks[uid]["status"] = True
return {"status": resp.json()['status'], "msg": resp.json()['msg'],
"instance_id": self.tasks[uid]["instance_id"]}
except Exception as e:
loguru.logger.error("%s Get Result Failed: %s" % (uid, e))
return None
else:
raise KeyError(uid)
def get_running_size(self):
return len([i for i in self.tasks.values() if not i["status"]])
def _req(self, base_url, video_file, audio_file):
data = {
"video_file": video_file,
"audio_file": audio_file
}
resp = requests.post(base_url, data, headers=self._headers, allow_redirects=True, stream=True, timeout=60)
loguru.logger.info("Submit Response: %s" % resp.text)
if resp.status_code == 200:
if resp.json()["status"] == "success":
code = resp.json()["code"]
return code
else:
return None
else:
return None
if __name__ == "__main__":
rp = RunningPool()
id = rp.run("https://u336391-b31a-07132bc4.westx.seetacloud.com:8443",
"https://sucai-1324682537.cos.ap-shanghai.myqcloud.com/tiktok/video/1111.mp4",
"https://sucai-1324682537.cos.ap-shanghai.myqcloud.com/tiktok/video/XBR037ruAZsA.mp3")
while True:
r = rp.result(id)
if r:
loguru.logger.success(r)
break
else:
loguru.logger.info("waiting...")
time.sleep(5)

View File

@@ -1,21 +0,0 @@
from queue import Queue
class WaitingQueue:
def __init__(self):
self.queue = Queue(maxsize=500)
def enqueue(self,uid, video_path,audio_path):
data = {
"uid": uid,
"video_path": video_path,
"audio_path": audio_path
}
self.queue.put(data,timeout=10)
def dequeue(self):
data = self.queue.get(timeout=10)
return data["uid"], data["video_path"], data["audio_path"]
def get_size(self):
return self.queue.qsize()

View File

@@ -1,125 +0,0 @@
import time
import traceback
import uuid
from concurrent.futures.thread import ThreadPoolExecutor
import loguru
import uvicorn
from fastapi import FastAPI, Depends, HTTPException
from fastapi.routing import APIRoute
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from starlette import status
from AutoDL.autodl_scheduling.entity.instance_pool import InstancePool, Instance
from AutoDL.autodl_scheduling.entity.result_map import ResultMap
from AutoDL.autodl_scheduling.entity.running_pool import RunningPool
from AutoDL.autodl_scheduling.entity.waiting_queue import WaitingQueue
class Server:
def __init__(self):
self.app = FastAPI()
self.waiting_queue = WaitingQueue()
self.running_pool = RunningPool()
#账号限制max_instance不能超过30
self.instance_pool = InstancePool(max_instance=2)
self.result_map = ResultMap()
self.executor = ThreadPoolExecutor(max_workers=2)
self.worker_1 = self.executor.submit(self.scaling_worker)
self.worker_2 = self.executor.submit(self.introspect_instance)
self.app.routes.append(APIRoute("/", endpoint=self.submit, methods=['POST']))
self.app.routes.append(APIRoute("/{uid}", endpoint=self.get_result, methods=['GET']))
self.app.routes.append(APIRoute("/", endpoint=self.get_all_result, methods=['GET']))
# async def submit(self, video_url, audio_url, token: HTTPAuthorizationCredentials = Depends(HTTPBearer())):
async def submit(self, video_url, audio_url):
# if token.credentials != "7468B79CA2CF53436C06B29A6F16451C":
# raise HTTPException(
# status_code=status.HTTP_401_UNAUTHORIZED,
# detail="Incorrect bearer token",
# headers={"WWW-Authenticate": "Bearer"},
# )
uid = str(uuid.uuid4())
try:
self.waiting_queue.enqueue(uid, video_url, audio_url)
return {"uid": uid}
except:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
async def get_result(self, uid):
try:
return self.result_map.get(uid)
except:
l = list(self.waiting_queue.queue.queue)
for i in l:
if i["uid"] == uid:
return {"status": "queuing", "msg":""}
return {"status": "not found", "msg":""}
async def get_all_result(self):
try:
return self.result_map.map
except:
raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR)
def introspect_instance(self):
loguru.logger.info("Introspecting worker started || Scaledown Window: %ds" % self.instance_pool.scaledown_window)
while True:
try:
self.instance_pool.introspection()
time.sleep(1)
except:
traceback.print_exc()
def scaling_worker(self):
loguru.logger.info("Scaling worker started")
while True:
try:
# 提交任务
self.instance_pool.scale_instance(self.waiting_queue.get_size()+self.running_pool.get_running_size(), disable_shrink=True)
for instance in self.instance_pool.instances:
if not instance.active and self.waiting_queue.get_size() > 0:
# 从等待队列取出任务
uid, video_path, audio_path = self.waiting_queue.dequeue()
loguru.logger.info("Task[%s] Submitting to Instance[%s]" % (uid, instance.uuid))
# 提交任务到运行池
if self.running_pool.run(instance.uuid, uid, instance.domain, video_path, audio_path):
# 更新实例池状态
instance.active = True
instance.last_active_time = time.time()
loguru.logger.info("Task[%s] Submitted" % uid)
self.result_map.set(uid, {"status": "running", "msg": "Task Submitted, Waiting for result"})
else:
loguru.logger.error("Submit Task[%s] Failed" % uid)
self.result_map.set(uid, {"status":"fail", "msg":"Submit Task Failed"})
#查询结果放到结果缓存里
for uid, task in list(self.running_pool.tasks.items()):
result = self.running_pool.result(uid)
# 任务运行完成或失败
if result:
self.result_map.update(uid, result)
self.running_pool.tasks.pop(uid)
if result["status"] == "fail":
#删除失败任务的实例
self.instance_pool.remove_instance(Instance(uuid=task["instance_id"]))
loguru.logger.info("Instance[%s] Removed Due to Task[%s] Failure" % (task["instance_id"], uid))
else:
#更新成功任务实例的状态
for instance in self.instance_pool.instances:
if instance.uuid == task["instance_id"]:
loguru.logger.info("Instance[%s] Task[%s] Finished" % (instance.uuid, uid))
instance.active = False
instance.last_active_time = time.time()
time.sleep(0.5)
except:
traceback.print_exc()
if __name__=="__main__":
server = Server()
uvicorn.run(server.app, host="127.0.0.1", port=8888)

View File

@@ -1,305 +0,0 @@
import time
from typing import Literal, Union, Any
import logger
import loguru
import paramiko
import requests
token = "eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1aWQiOjMzNjM5MSwidXVpZCI6ImU2MDU0ZGI4LTlhN2UtNDllNC1hNDQ1LWI4N2M1NGViMjU4ZCIsImlzX2FkbWluIjpmYWxzZSwiYmFja3N0YWdlX3JvbGUiOiIiLCJpc19zdXBlcl9hZG1pbiI6ZmFsc2UsInN1Yl9uYW1lIjoid3F5QGI4N2M1NGViMjU4ZCIsInRlbmFudCI6IiIsInVwayI6IiJ9.4MV4P1feiUmrrzFbtTQpNQjvYyezPdaLxRJ79y0VyRAxR0aS5NQJGJPxa-6wuqsgzY-E1rvf5S8FCY92ZnViFQ"
req_instance_page_size = 1500
chk_instance_page_size = 1500
instances = {}
LIM = 200 # 等待状态时间LIM*5s
START_LIM = 20 #Heygem脚本启动等待超时时间-10
DEBUG = False
gpu_list = ["V100-SXM2-32GB",
"vGPU-32GB",
"RTX 4090",
"RTX 4090D",
"RTX 3090",
"RTX 3080",
"RTX 3080x2",
"RTX 3080 Ti",
"RTX 3060",
"RTX A4000",
"RTX 2080 Ti",
"RTX 2080 Ti x2",
"GTX 1080 Ti"]
area_list = ["nm-B1",
"nm-B2",
"west-B",
"west-C",
"west-X",
"bj-B1",
"beijing-A",
"beijing-B",
"beijing-D",
"beijing-E"]
def ssh_try(host,port,pwd,uid):
# 建立连接
trans = paramiko.Transport((host, int(port)))
trans.connect(username="root", password=pwd)
# 将sshclient的对象的transport指定为以上的trans
ssh = paramiko.SSHClient()
ssh._transport = trans
ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy())
ssh_stdin, ssh_stdout, ssh_stderr = ssh.exec_command("bash -ic \"source /root/.bashrc && sleep 10 && ps -ef|grep heygem|grep -v grep && lsof -i:6006|wc -l\"", get_pty=True)
out = ssh_stdout.read().decode()
start_lim = START_LIM
if len(out.split("\n")[-2]) > 2:
trans.close()
raise RuntimeError(out.split("\n")[-2])
loguru.logger.info(f"[{uid}]Waiting for HeyGem server ready...")
while int(out.split("\n")[-2]) <= 1 and start_lim > 0:
ssh_stdin, ssh_stdout, ssh_stderr = ssh.exec_command("bash -ic \"sleep 2 && lsof -i:6006|wc -l\"")
out = ssh_stdout.read().decode()
start_lim -= 1
if start_lim <= 0:
loguru.logger.error("HeyGem Server Start Timeout, Please check!")
else:
loguru.logger.success("HeyGem Server Start Success")
# 关闭连接
trans.close()
def get_autodl_machines() -> Union[list, None]:
machines = list()
headers = {
"Authorization": token,
"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
"Host": "www.autodl.com"
}
index = 1
payload = {
"charge_type":"payg",
"region_sign":"",
"gpu_type_name": gpu_list,
"machine_tag_name":[],
"gpu_idle_num":1,
"mount_net_disk":False,
"instance_disk_size_order":"",
"date_range":"",
"date_from":"",
"date_to":"",
"page_index":index,
"page_size":req_instance_page_size,
"pay_price_order":"",
"gpu_idle_type":"",
"default_order":False,
"region_sign_list": area_list,
"cpu_arch":["x86"],
"chip_corp":["nvidia"],
"machine_id":""
}
if DEBUG:
loguru.logger.info("Req Machine index {}".format(index))
rsp = requests.post("https://www.autodl.com/api/v1/sub_user/user/machine/list", json=payload, headers=headers)
if rsp.status_code == 200:
machine_list = rsp.json()
if DEBUG:
loguru.logger.info("Machine Result Total {}".format(machine_list["data"]["result_total"]))
while index < machine_list["data"]["max_page"]:
index += 1
if DEBUG:
loguru.logger.info("Req Machine index {}/{}".format(index, machine_list["data"]["max_page"]))
payload["page_index"] = index
rsp = requests.post("https://www.autodl.com/api/v1/sub_user/user/machine/list", json=payload, headers=headers)
if rsp.status_code == 200:
machine_list["data"]["list"].extend(rsp.json()["data"]["list"])
else:
loguru.logger.error("Get Machines Req Error")
return None
else:
loguru.logger.error("Get Machines Req Error")
return None
i = 0
for machine in machine_list["data"]["list"]:
if machine["health_status"] == 0 \
and machine["gpu_order_num"] > 0 \
and float(machine["highest_cuda_version"])>12. \
and machine["payg"] == True \
and machine["rent_mode"] == "" \
and not machine["user_visible_limit"]:
i += 1
machines.append({
"machine_id": machine["machine_id"],
"region_name": machine["region_name"],
"machine_alias": machine["machine_alias"],
"gpu_name": machine["gpu_name"],
"gpu_order_num": machine["gpu_order_num"],
"gpu_number": machine["gpu_number"],
"region_sign": machine["region_sign"],
})
return sorted(machines, key=lambda machine: machine["gpu_order_num"], reverse=True)
def payg(region_name:str, machine_id:str) -> tuple[Any, Any] | None:
region_image = {
"西北": ["hub.kce.ksyun.com/autodl-image/miniconda:cuda11.8-cudnn8-devel-ubuntu20.04-py38","image-0d3ab01d64"],
"内蒙": ["hub.kce.ksyun.com/autodl-image/miniconda:cuda11.8-cudnn8-devel-ubuntu20.04-py38","image-bfff5e82ae"],
"北京": ["hub.kce.ksyun.com/autodl-image/miniconda:cuda11.8-cudnn8-devel-ubuntu20.04-py38","image-537b5da0c5"]
}
headers = {
"Authorization": token,
"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
"Host": "www.autodl.com"
}
payload = {
"instance_info":{
"machine_id":machine_id,
"charge_type":"payg",
"req_gpu_amount":1,
"image":region_image[region_name[:2]][0],
"private_image_uuid":region_image[region_name[:2]][1],
"reproduction_uuid":"",
"instance_name":"",
"expand_data_disk":0,
"reproduction_id":0
},
"price_info":{
"coupon_id_list":[],
"machine_id":machine_id,
"charge_type":"payg",
"duration":1,
"num":1,
"expand_data_disk":0
}
}
loguru.logger.info("Try Create Payg Container on Machine {}/{}".format(region_name, machine_id))
rsp = requests.post("https://www.autodl.com/api/v1/sub_user/order/instance/create/payg", json=payload, headers=headers)
if rsp.status_code == 200:
j = rsp.json()
if j["code"] == "Success":
lim = LIM
while lim>0:
time.sleep(3)
status, host, port, pwd, domain = check_status(j['data'])
if status == "running":
ssh_try(host, port, pwd, j['data'])
break
elif status == "shutdown_by_starting_error":
loguru.logger.error("Create Payg Instance Error: Start Error, Try Remove Instance[%s]" % j['data'])
instance_operate(j['data'], "release")
return None
else:
lim = lim-1
if lim > 0:
loguru.logger.success("Create Payg Instance Success: %s" % j['data'])
return j['data'],domain
else:
logger.logger.error("Create Payg Instance Error: Wait for Created Timeout, Please Check!!! instance_uuid(%s)" % j['data'])
return None
else:
loguru.logger.error("Create Payg Instance Error: %s" % j['msg'])
return None
else:
loguru.logger.error("Create Payg Instance Error: Status Code[%s]" % rsp.status_code)
return None
def instance_operate(instance_uuid:str, operation: Literal["power_off","power_on","release"]) -> bool:
dest_dict={
"power_off":"shutdown",
"power_on":"running",
}
headers = {
"Authorization": token,
"User-Agent":"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
"Host": "www.autodl.com"
}
payload = {"instance_uuid":instance_uuid}
rsp = requests.post("https://www.autodl.com/api/v1/sub_user/instance/%s" % operation, json=payload, headers=headers)
if rsp.status_code == 200:
j = rsp.json()
if j["code"] == "Success":
lim = LIM
if operation in dest_dict.keys():
while lim>0:
time.sleep(5)
status = check_status(instance_uuid)
if status and status[0] == dest_dict[operation]:
break
else:
lim = lim-1
if lim > 0:
loguru.logger.success("Operate[%s] Instance[%s] Success" % (operation, instance_uuid))
return True
else:
loguru.logger.error("Operate[%s] Instance[%s] Error: Timeout, Please Check!!!" % (operation, instance_uuid))
return False
else:
loguru.logger.error("Operate[%s] Instance[%s] Error: %s" % (operation, instance_uuid, j['msg']))
return False
else:
loguru.logger.error("Operate[%s] Instance[%s] Error: Status Code[%s]" % (operation, instance_uuid, rsp.status_code))
return False
def check_status(instance_uuid:str) -> tuple[Any, Any, Any, Any, Any] | None:
headers = {
"Authorization": token,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/135.0.0.0 Safari/537.36",
"Host": "www.autodl.com"
}
index = 1
payload = {
"date_from":"",
"date_to":"",
"page_index": index,
"page_size":chk_instance_page_size,
"status":[],
"charge_type":[]
}
if DEBUG:
loguru.logger.info("Req Instance index {}".format(index))
lim = 3
while lim > 0:
rsp = None
try:
rsp = requests.post("https://www.autodl.com/api/v1/sub_user/instance", json=payload, headers=headers)
except:
lim -= 1
if rsp:
break
if lim <= 0:
loguru.logger.error("Get Instance Req Error")
return None
if rsp.status_code == 200:
instance_list = rsp.json()
if DEBUG:
loguru.logger.info("Instance Result Total {}".format(instance_list["data"]["result_total"]))
while index < instance_list["data"]["max_page"]:
if DEBUG:
loguru.logger.info("Req Instance index {}/{}".format(index, instance_list["data"]["max_page"]))
payload["page_index"] = index
rsp = requests.post("https://www.autodl.com/api/v1/sub_user/instance", json=payload, headers=headers)
if rsp.status_code == 200:
instance_list["data"]["list"].extend(rsp.json()["data"]["list"])
else:
loguru.logger.error("Get Instance Req Error")
return None
for l in instance_list["data"]["list"]:
if l["uuid"] == instance_uuid:
if DEBUG:
loguru.logger.info("Instance {} Status {}".format(instance_uuid, l["status"]))
return l["status"], l["proxy_host"], l["ssh_port"], l["root_password"], l["tensorboard_domain"]
loguru.logger.warning("Instance {} Not Found".format(instance_uuid))
return None
else:
loguru.logger.error("Get Instance Req Error")
return None
if __name__=="__main__":
machines = get_autodl_machines()
for m in machines:
instance_uuid, domain = payg(m["region_name"], m["machine_id"])
loguru.logger.success("instance_id:%s server_domain: https://%s" % (instance_uuid, domain))
# if instance_uuid:
# instance_operate(instance_uuid, "power_off")
# instance_operate(instance_uuid, "release")
break

View File

@@ -1,12 +0,0 @@
import site
import subprocess
import sys
pkg_path = site.getsitepackages()[0]
sys.path.append(f"{pkg_path}/heygem/code")
p = subprocess.Popen(f"cd {pkg_path}/heygem/code && "
"python3.8 app_local.py", shell=True)
p.communicate()

3
README.md Normal file
View File

@@ -0,0 +1,3 @@
```bash
modal deploy -e dev ./src/cluster/app.py
```

View File

@@ -1,80 +0,0 @@
import json
import subprocess
import uuid
from pathlib import Path
from typing import Dict
import modal
image = ( # build up a Modal Image to run ComfyUI, step by step
modal.Image.debian_slim( # start from basic Linux with Python
python_version="3.10"
)
.apt_install("git") # install git to clone ComfyUI
.pip_install("fastapi[standard]==0.115.4") # install web dependencies
.pip_install("comfy-cli==1.3.5") # install comfy-cli
.pip_install("cos-python-sdk-v5")
.pip_install("sqlalchemy")
.pip_install("ultralytics")
.pip_install("tencentcloud-sdk-python")
.pip_install("pymysql")
.pip_install("Pillow")
.pip_install("ffmpy")
.pip_install("opencv-python")
.run_commands( # use comfy-cli to install ComfyUI and its dependencies
"comfy --skip-prompt install --nvidia --version 0.3.10"
).add_local_file("snapshot.json","/root/snapshot.json", copy=True)
)
image = (
image.run_commands("comfy node install https://github.com/M1kep/ComfyLiterals")
.run_commands("comfy node install https://github.com/evanspearman/ComfyMath")
.run_commands("comfy node install https://github.com/Kosinkadink/ComfyUI-AnimateDiff-Evolved")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-Bowong.git")
.run_commands("comfy node install https://github.com/crystian/ComfyUI-Crystools")
.run_commands("comfy node install https://github.com/pythongosssss/ComfyUI-Custom-Scripts")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-CustomNode.git")
.run_commands("comfy node install https://github.com/BennyKok/comfyui-deploy")
.run_commands("comfy node install https://github.com/yolain/ComfyUI-Easy-Use")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-LatentSync-Node.git")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-VideoHelperSuite.git")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/CosyVoice-ComfyUI.git")
.run_commands("comfy node install https://github.com/jags111/efficiency-nodes-comfyui")
.run_commands("comfy node install https://github.com/WASasquatch/was-node-suite-comfyui")
.run_commands("comfy node install https://github.com/rgthree/rgthree-comfy")
.run_commands("comfy node install https://github.com/cubiq/ComfyUI_essentials")
.run_commands("comfy node install https://github.com/melMass/comfy_mtb")
.run_commands(
"mkdir -p /root/comfy/ComfyUI/models/ComfyUI-CustomNode/model && rm -rf /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/model && ln -s /root/comfy/ComfyUI/models/ComfyUI-CustomNode/model /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/model"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/ComfyUI-LatentSync-Node/checkpoints && rm -rf /root/comfy/ComfyUI/custom_nodes/ComfyUI-LatentSync-Node/checkpoints && ln -s /root/comfy/ComfyUI/models/ComfyUI-LatentSync-Node/checkpoints /root/comfy/ComfyUI/custom_nodes/ComfyUI-LatentSync-Node/checkpoints"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M-SFT && mkdir -p /root/comfy/ComfyUI/custom_nodes/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M-SFT && rm -rf /root/comfy/ComfyUI/custom_nodes/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M-SFT && ln -s /root/comfy/ComfyUI/models/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M-SFT /root/comfy/ComfyUI/custom_nodes/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M-SFT"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/.cache/torch/hub/checkpoints && mkdir -p /root/.cache/torch/hub/checkpoints && rm -rf /root/.cache/torch/hub/checkpoints && ln -s /root/comfy/ComfyUI/models/.cache/torch/hub/checkpoints /root/.cache/torch/hub/checkpoints"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/stabilityai && ln -s /root/comfy/ComfyUI/models/stabilityai /root/comfy/ComfyUI/stabilityai"
).run_commands(
"rm -rf /root/comfy/ComfyUI/models"
).run_commands(
"apt update && apt install -y ffmpeg && ffmpeg -version"
).pip_install("av")
# Add .run_commands(...) calls for any other custom nodes you want to download
)
image = image.add_local_file(
"highlight_v0.113_api.json", "/root/workflow_api.json"
)
app = modal.App(name="highlight-comfyui", image=image)
vol = modal.Volume.from_name("comfyui-model", create_if_missing=True)
@app.function(
allow_concurrent_inputs=10, # required for UI startup process which runs several API calls concurrently
concurrency_limit=1, # limit interactive session to 1 container
gpu="T4", # good starter GPU for inference
volumes={"/root/comfy/ComfyUI/models": vol}, # mounts our cached models
)
@modal.web_server(8000, startup_timeout=60)
def ui():
subprocess.Popen("comfy launch -- --listen 0.0.0.0 --port 8000", shell=True)

View File

@@ -1,12 +0,0 @@
import site
import subprocess
import sys
pkg_path = site.getsitepackages()[0]
sys.path.append(f"{pkg_path}/heygem/code")
p = subprocess.Popen(f"cd {pkg_path}/heygem/code && "
"python3.8 app_local.py", shell=True)
p.communicate()

23
pyproject.toml Normal file
View File

@@ -0,0 +1,23 @@
[project]
name = "modaldeploy"
version = "0.2.0"
description = "管理Modal worker的工程"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"boto3>=1.37.37",
"cos-python-sdk-v5>=1.9.36",
"crcmod>=1.7",
"httpx>=0.28.1",
"loguru>=0.7.3",
"pydantic>=2.11.3",
"pydantic-settings>=2.9.1",
"python-ffmpeg>=2.0.12",
"requests>=2.32.3",
"scalar-fastapi>=1.0.3",
"sentry-sdk[loguru]>=2.26.1",
"tencentcloud-sdk-python-common>=3.0.1363",
"tencentcloud-sdk-python-vod>=3.0.1363",
"tqdm>=4.67.1",
"webvtt-py>=0.5.1",
]

View File

@@ -1,298 +0,0 @@
# HeyGem模板
import json
import os
import shutil
import socket
import subprocess
import time
import traceback
import uuid
from typing import Any, Optional, Union
import httpx
import loguru
import modal
import requests
from fastapi import Depends, HTTPException, status, UploadFile
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
image = (
modal.Image.debian_slim( # start from basic Linux with Python
python_version="3.10"
).apt_install("git")
.apt_install("gcc")
.pip_install("loguru")
.pip_install("fastapi[standard]")
.run_commands(
"apt update && apt install -y ffmpeg && ffmpeg -version"
)
# 添加Python3.8 HeyGem
.run_commands("apt update && apt install -y curl build-essential libssl-dev zlib1g-dev libncurses5-dev libncursesw5-dev libreadline-dev libsqlite3-dev libgdbm-dev libdb5.3-dev libbz2-dev libexpat1-dev lzma liblzma-dev tk-dev libffi-dev")
.run_commands("curl -O https://www.python.org/ftp/python/3.8.12/Python-3.8.12.tar.xz&&tar -xf Python-3.8.12.tar.xz")
.run_commands("cd Python-3.8.12 && ./configure --enable-optimizations && make -j 10 && make altinstall")
.add_local_file("heygem-1.0-py3-none-any.whl","/root/heygem-1.0-py3-none-any.whl", copy=True)
.shell(["/bin/bash", "-c"])
.run_commands("python3.8 -m pip install /root/heygem-1.0-py3-none-any.whl")
.env({"LD_LIBRARY_PATH":"/usr/local/lib/python3.8/site-packages/nvidia/cuda_nvrtc/lib"})
.run_commands("ln -s /usr/local/lib/python3.8/site-packages/nvidia/cuda_nvrtc/lib/libnvrtc.so.11.2 /usr/local/lib/python3.8/site-packages/nvidia/cuda_nvrtc/lib/libnvrtc.so")
.run_commands("python3.8 -m pip install https://github.com/pydata/numexpr/releases/download/v2.8.6/numexpr-2.8.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl")
.add_local_file("heygem.py", "/root/heygem.py", copy=True)
.add_local_file("config.yaml", "/root/config.yaml", copy=True)
.workdir("/root").pip_install("boto3").pip_install("PyYAML").pip_install("requests")
)
app = modal.App(name="heygem", image=image)
secret = modal.Secret.from_name("aws-s3-secret")
auth_scheme = HTTPBearer()
bucket_output = "bw-heygem-output"
@app.cls(
allow_concurrent_inputs=1, # required for UI startup process which runs several API calls concurrently
max_containers=25, # limit interactive session to 1 container
gpu="L40S", # good starter GPU for inference
cpu=(2,32),
memory=(6144, 32768),
timeout=2160,
scaledown_window=240,
secrets=[secret, modal.Secret.from_name("web_auth_token")],
volumes={
"/code/data/final": modal.CloudBucketMount(
bucket_name=bucket_output,
# bucket_endpoint_url="https://s3.%s.amazonaws.com" % os.environ["AWS_REGION"],
secret=secret,
key_prefix="/"
),
"/root/logs": modal.CloudBucketMount(
bucket_name=bucket_output,
# bucket_endpoint_url="https://s3.%s.amazonaws.com" % os.environ["AWS_REGION"],
secret=secret,
key_prefix="/logs/"
)
}, # mounts our cached models
)
class HeyGem:
@modal.enter()
def start(self):
def check_port_in_use(port, host='127.0.0.1'):
s = None
try:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.settimeout(1)
s.connect((host, int(port)))
return True
except socket.error:
return False
finally:
if s:
s.close()
uid = str(uuid.uuid4())
cmd = "echo client_id: %s && nohup python3.8 /root/heygem.py >> /root/logs/%s.log 2>&1 &" % (uid, uid)
subprocess.run(cmd, shell=True, check=True)
timeout = 30
while timeout > 0:
check = check_port_in_use(8383)
if check:
loguru.logger.success("HeyGem Server started on port 8383")
break
else:
timeout -= 1
time.sleep(2)
if timeout == 0:
raise TimeoutError("HeyGem Server Start timed out")
@modal.method()
def infer(self, code, video_path, audio_path) -> str:
def task_submit(uid, video, audio, heygem_url):
"""Submit a task to the API"""
task_submit_api = f'{heygem_url}/easy/submit'
result_json = {
'status': False, 'data': {}, 'msg': ''
}
try:
data = {
"code": uid,
"video_url": video,
"audio_url": audio,
"chaofen": 1,
"watermark_switch": 0,
"pn": 1
}
loguru.logger.info(f'data={json.dumps(data, ensure_ascii=False)}')
with httpx.Client() as client:
resp = client.post(task_submit_api, json=data)
resp_dict = resp.json()
loguru.logger.info(f'submit data: {json.dumps(resp_dict, ensure_ascii=False)}')
if resp_dict['code'] != 10000:
result_json['status'] = False
result_json['msg'] = result_json['msg']
else:
result_json['status'] = True
result_json['data'] = uid
result_json['msg'] = '任务提交成功'
return result_json
except Exception as e:
loguru.logger.info(f'submit task fail case by:{str(e)}')
raise RuntimeError(str(e))
def query_task_progress(heygem_url, heygem_temp_path, task_id: str, interval: int = 10, timeout: int = 60 * 35):
"""Query task progress and wait for completion"""
result_json = {'status': False, 'data': {}, 'msg': ''}
def query_result(t_id: str):
tmp_dict = {'status': True, 'data': dict(), 'msg': ''}
try:
query_task_url = f'{heygem_url}/easy/query'
params = {
'code': t_id
}
with httpx.Client() as client:
resp = client.get(query_task_url, params=params)
resp_dict = resp.json()
loguru.logger.info(f'query task data: {json.dumps(resp_dict, ensure_ascii=False)}')
status_code = resp_dict['code']
if status_code in (9999, 10002, 10003, 10001):
tmp_dict['status'] = False
tmp_dict['msg'] = resp_dict['msg']
elif status_code == 10000:
status_code = resp_dict['data'].get('status', 1)
if status_code == 3:
tmp_dict['status'] = False
tmp_dict['msg'] = resp_dict['data']['msg']
else:
process = resp_dict['data'].get('progress', 20)
if status_code == 2:
process = 100
else:
process = process
result = resp_dict['data'].get('result', '')
tmp_dict['data'] = {'progress': process,
'path': result,
}
else:
pass
except Exception as e:
loguru.logger.info(f'query task fail case by:{str(e)}')
raise RuntimeError(str(e))
return tmp_dict
end = time.time() + timeout
while time.time() < end:
tmp_result = query_result(task_id)
if not tmp_result['status'] or tmp_result['data'].__eq__({}):
result_json['status'] = False
result_json['msg'] = tmp_result['msg']
break
else:
process = tmp_result['data']['progress']
loguru.logger.info(f'query task progress :{process}')
if tmp_result['data']['progress'] < 100:
time.sleep(interval)
loguru.logger.info(f'wait next interval:{interval}')
else:
p = tmp_result['data']['path']
p = p.replace('/', '').replace('\\', '')
result_json['data'] = "%s/%s" % (heygem_temp_path, p)
result_json['status'] = True
break
return result_json
try:
heygem_url = "http://127.0.0.1:8383"
heygem_temp_path = "/code/data/temp"
submit_result = task_submit(code, video_path, audio_path, heygem_url)
if not submit_result['status']:
raise Exception(f"Task submission failed: {submit_result['msg']}")
task_id = submit_result['data']
loguru.logger.info(f'Submitted task: {task_id}')
# Query task progress
progress_result = query_task_progress(heygem_url, heygem_temp_path, task_id, interval=5)
if not progress_result['status']:
raise RuntimeError(f"Task processing failed: {progress_result['msg']}")
# Return the file for download
file_path = progress_result['data']
if not os.path.exists(file_path):
raise FileNotFoundError(f"Output file not found at {file_path}")
return file_path
except Exception as e:
loguru.logger.error(f"Error processing request || {str(e)}")
raise Exception(str(e))
finally:
try:
os.remove(video_path)
os.remove(audio_path)
except:
pass
def download_file(self, url, path):
with requests.get(url, stream=True) as r:
with open(path, 'wb') as f:
shutil.copyfileobj(r.raw, f)
@modal.fastapi_endpoint(method="POST")
async def api(self, video_file: Optional[Union[UploadFile, str]], audio_file: Optional[Union[UploadFile, str]], token: HTTPAuthorizationCredentials = Depends(auth_scheme)):
if token.credentials != os.environ["AUTH_TOKEN"]:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
code = str(uuid.uuid4())
try:
if isinstance(video_file, UploadFile):
video_path = "/root/%s" % video_file.filename
with open(video_path, "wb") as f:
data = await video_file.read()
f.write(data)
elif isinstance(video_file, str) and video_file.startswith("http"):
video_path = "/root/%s" % video_file.split("?")[0].split("/")[-1]
self.download_file(video_file, video_path)
else:
return {"status": "fail", "msg": "video file save failed: Not a valid input"}
if isinstance(audio_file, UploadFile):
audio_path = "/root/%s" % audio_file.filename
with open(audio_path, "wb") as f:
data = await audio_file.read()
f.write(data)
elif isinstance(audio_file, str) and audio_file.startswith("http"):
audio_path = "/root/%s" % audio_file.split("?")[0].split("/")[-1]
self.download_file(audio_file, audio_path)
else:
return {"status": "fail", "msg": "audio file save failed: Not a valid input"}
except Exception as e:
return {"status": "fail", "msg": f"video or audio file save failed: {str(e)}"}
loguru.logger.info(f"Enter Inference Module")
try:
file_path = self.infer.local(code, video_path, audio_path)
try:
shutil.copy(file_path, os.path.join("/code/data/final",file_path.split(os.path.sep)[-1]))
except Exception as e:
loguru.logger.info(f"Moving {file_path} to S3 Failed: {str(e)}")
try:
print("Try move file to S3 manually")
# S3 Fallback
import boto3
import yaml
with open("/root/config.yaml", encoding="utf-8",
mode="r+") as config:
yaml_config = yaml.load(config, Loader=yaml.FullLoader)
awss3 = boto3.resource('s3', aws_access_key_id=yaml_config["aws_key_id"],
aws_secret_access_key=yaml_config["aws_access_key"])
awss3.meta.client.upload_file(file_path, bucket_output, file_path.split(os.path.sep)[-1])
except:
return {"status": "fail", "msg": "Failed to move file to S3 manually: " + str(e)}
# resp = FileResponse(path=file_path, filename=Path(file_path).name, status_code=200, headers={
# "Content-Type": "application/octet-stream",
# "Content-Disposition": f"attachment; filename={Path(file_path).name}",
# })
return {"status": "success", "msg":f"{file_path.split(os.path.sep)[-1]}"}
except Exception as e:
traceback.print_exc()
return {"status": "fail", "msg": "Inference module failed: "+str(e)}

View File

@@ -1,258 +0,0 @@
# ComfyUI模板--Base 停止维护!!
import json
import os
import shutil
import subprocess
import uuid
from typing import Dict
import loguru
import modal
image = ( # build up a Modal Image to run ComfyUI, step by step
modal.Image.debian_slim( # start from basic Linux with Python
python_version="3.10"
)
.apt_install("git") # install git to clone ComfyUI
.apt_install("gcc")
.apt_install("libportaudio2")
.pip_install("fastapi[standard]==0.115.4") # install web dependencies
.pip_install("comfy-cli==1.3.5") # install comfy-cli
.pip_install("cos-python-sdk-v5")
.pip_install("sqlalchemy")
.pip_install("ultralytics")
.pip_install("tencentcloud-sdk-python")
.pip_install("pymysql")
.pip_install("Pillow")
.pip_install("ffmpy")
.pip_install("opencv-python")
.pip_install("av")
.pip_install("imageio")
.pip_install("loguru")
.pip_install("conformer==0.3.2",extra_options="--no-dependencies")
.pip_install("einops>0.6.1",extra_options="--no-dependencies")
.pip_install("openai-whisper")
.run_commands( # use comfy-cli to install ComfyUI and its dependencies
"comfy --skip-prompt install --nvidia --version 0.3.10"
)
)
image = (
image.run_commands("comfy node install https://github.com/M1kep/ComfyLiterals")
.run_commands("comfy node install https://github.com/evanspearman/ComfyMath")
.run_commands("comfy node install https://github.com/Kosinkadink/ComfyUI-AnimateDiff-Evolved")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-Bowong.git")
.run_commands("comfy node install https://github.com/crystian/ComfyUI-Crystools")
.run_commands("comfy node install https://github.com/pythongosssss/ComfyUI-Custom-Scripts")
.run_commands("comfy node install https://github.com/BennyKok/comfyui-deploy")
.run_commands("comfy node install https://github.com/yolain/ComfyUI-Easy-Use")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-VideoHelperSuite.git")
.run_commands("comfy node install https://github.com/jags111/efficiency-nodes-comfyui")
.run_commands("comfy node install https://github.com/WASasquatch/was-node-suite-comfyui")
.run_commands("comfy node install https://github.com/rgthree/rgthree-comfy")
.run_commands("comfy node install https://github.com/cubiq/ComfyUI_essentials")
.run_commands("comfy node install https://github.com/melMass/comfy_mtb")
.run_commands("echo 3 && comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI_SparkTTS.git")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-CustomNode.git")
.run_commands("echo 04111 && comfy node install https://e.coding.net/g-ldyi2063/dev/cosyvoice_comfyui.git")
.run_commands("echo 4 && comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-LatentSync-Node.git")
.run_commands(
"mkdir -p /root/comfy/ComfyUI/models/ComfyUI-CustomNode/model && rm -rf /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/model && ln -s /root/comfy/ComfyUI/models/ComfyUI-CustomNode/model /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/model"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/ComfyUI-LatentSync-Node/checkpoints && rm -rf /root/comfy/ComfyUI/custom_nodes/ComfyUI-LatentSync-Node/checkpoints && ln -s /root/comfy/ComfyUI/models/ComfyUI-LatentSync-Node/checkpoints /root/comfy/ComfyUI/custom_nodes/ComfyUI-LatentSync-Node/checkpoints"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/.cache/torch/hub/checkpoints && mkdir -p /root/.cache/torch/hub/checkpoints && rm -rf /root/.cache/torch/hub/checkpoints && ln -s /root/comfy/ComfyUI/models/.cache/torch/hub/checkpoints /root/.cache/torch/hub/checkpoints"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/stabilityai && ln -s /root/comfy/ComfyUI/models/stabilityai /root/comfy/ComfyUI/stabilityai"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M && mkdir -p /root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/CosyVoice-300M && rm -rf /root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/CosyVoice-300M && ln -s /root/comfy/ComfyUI/models/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M /root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/CosyVoice-300M"
).run_commands(
"rm -rf /root/comfy/ComfyUI/models"
).run_commands(
"apt update && apt install -y ffmpeg && ffmpeg -version"
).add_local_file("config.yaml", "/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml", copy=True
).add_local_file("config.py", "/root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/tools/config.py", copy=True
).workdir("/root/comfy")
# Add .run_commands(...) calls for any other custom nodes you want to download
)
app = modal.App(name="highlight-comfyui-s3", image=image)
vol = modal.Volume.from_name("comfyui-model", create_if_missing=True)
bucket_dict = modal.Dict.from_name("aws_s3_bucket", create_if_missing=False)
bucket_input = str(bucket_dict.get("INPUT"))
bucket_output = str(bucket_dict.get("OUTPUT"))
secret = modal.Secret.from_name("aws-s3-secret")
# completed workflows write output images to this directory
output_dir = "/root/comfy/ComfyUI/output"
@app.cls(
allow_concurrent_inputs=1, # allow 10 concurrent API calls
max_containers=200,
min_containers=0,
buffer_containers=0,
scaledown_window=120,
# 5 minute container keep alive after it processes an input; increasing this value is a great way to reduce ComfyUI cold start times
timeout=900,
gpu=["L4", "T4"],
cpu=(2,16),
# memory=(32768, 32768), # (内存预留量, 内存使用上限)
memory=(20480,40960),
enable_memory_snapshot=False,
secrets=[secret],
volumes={
"/root/comfy/ComfyUI/models": vol,
"/root/comfy/ComfyUI/input_s3": modal.CloudBucketMount(
bucket_name=bucket_input,
# bucket_endpoint_url="https://s3.%s.amazonaws.com" % os.environ["AWS_REGION"],
secret=secret,
key_prefix="/"
),
"/root/comfy/ComfyUI/output_s3": modal.CloudBucketMount(
bucket_name=bucket_output,
# bucket_endpoint_url="https://s3.%s.amazonaws.com" % os.environ["AWS_REGION"],
secret=secret,
key_prefix="/"
),
},
)
class ComfyUI:
@modal.enter()
def launch_comfy_background(self):
# starts the ComfyUI server in the background exactly once when the first input is received
self.session_id = str(uuid.uuid4())
cmd = "echo client_uuid: {}&&rm -rf /root/comfy/ComfyUI/user&&mkdir -p /root/comfy/ComfyUI/output_s3/logs/{}&&ln -s /root/comfy/ComfyUI/output_s3/logs/{} /root/comfy/ComfyUI/user && comfy launch --background".format(self.session_id,self.session_id, self.session_id)
subprocess.run(cmd, shell=True, check=True)
@modal.method()
def infer(self, workflow_json: str = ""):
self.poll_server_health()
self.prompt_uuid = str(uuid.uuid4())
# runs the comfy run --workflow command as a subprocess
workflow = json.loads(workflow_json)
print("Workflow JSON:")
print(json.dumps(workflow, indent=4, ensure_ascii=False))
for node in workflow.values():
if node.get("class_type") == "ComfyUIDeployExternalText":
if node.get("inputs").get("input_id") == "file_path":
file_to_move = node.get("inputs").get("default_value")
if file_to_move:
os.makedirs(os.path.dirname(file_to_move.replace("input_s3", "input")), exist_ok=True)
try:
shutil.copy(file_to_move, file_to_move.replace("input_s3", "input"))
except:
try:
print("Try download file to S3 manually")
# S3 Fallback
import boto3
import yaml
with open("/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml",
encoding="utf-8", mode="r+") as config:
yaml_config = yaml.load(config, Loader=yaml.FullLoader)
awss3 = boto3.resource('s3', aws_access_key_id=yaml_config["aws_key_id"],
aws_secret_access_key=yaml_config["aws_access_key"])
awss3.meta.client.download_file(bucket_input, file_to_move.split("input_s3/")[1],
file_to_move.replace("input_s3", "input"))
except:
raise Exception("Failed to download file from S3 manually")
node["inputs"]["default_value"] = node["inputs"]["default_value"].replace("input_s3", "input")
with open(f"/root/{self.prompt_uuid}.json", "w", encoding="utf-8") as f:
f.write(json.dumps(workflow, ensure_ascii=False))
cmd = f"comfy run --workflow /root/{self.prompt_uuid}.json --wait --timeout 890 --verbose"
subprocess.run(cmd, shell=True, check=True, timeout=895)
# looks up the name of the output image file based on the workflow
file_prefix = [
node.get("inputs")
for node in workflow.values()
if node.get("class_type") == "VHS_VideoCombine"
][0]["filename_prefix"]
# returns the image as bytes
file_list = os.listdir(output_dir)
# 获取按照文件时间创建排序的列表,默认是按时间升序
new_file_list = sorted(file_list, key=lambda file: os.path.getctime(os.path.join(output_dir, file)),
reverse=True)
# print("file_list", new_file_list)
for f in new_file_list:
if f.startswith(file_prefix):
os.makedirs(os.path.dirname(os.path.join(output_dir.replace("output", "output_s3"), f)), exist_ok=True)
try:
shutil.copy(os.path.join(output_dir, f), os.path.join(output_dir.replace("output", "output_s3"), f))
except:
try:
print("Try move file to S3 manually")
# S3 Fallback
import boto3
import yaml
with open("/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml", encoding="utf-8",
mode="r+") as config:
yaml_config = yaml.load(config, Loader=yaml.FullLoader)
awss3 = boto3.resource('s3', aws_access_key_id=yaml_config["aws_key_id"],
aws_secret_access_key=yaml_config["aws_access_key"])
awss3.meta.client.upload_file(os.path.join(output_dir, f), bucket_output, f)
except:
raise Exception("Failed to move file to S3 manually")
return f
@modal.fastapi_endpoint(method="POST")
def api(self, item: Dict):
try:
# save this updated workflow to a new file
new_workflow_file = item["prompt"]
# run inference on the currently running container
fname = self.infer.local(new_workflow_file)
if fname is None:
raise RuntimeError("Output File not found")
j = {"file_name": fname}
loguru.logger.success(j)
return j
except Exception as e:
raise RuntimeError(str(e))
finally:
print("Purge Garbage By Restart Comfy")
cmd = "comfy stop"
try:
subprocess.run(cmd, shell=True, check=True)
except:
pass
cmd = "comfy launch --background"
try:
subprocess.run(cmd, shell=True, check=True)
except:
pass
def poll_server_health(self):
import socket
import urllib
try:
# dummy request to check if the server is healthy
req = urllib.request.Request("http://127.0.0.1:8188/system_stats")
urllib.request.urlopen(req, timeout=5)
print("ComfyUI server is healthy")
except (socket.timeout, urllib.error.URLError) as e:
# if no response in 5 seconds, stop the container; Modal will schedule queued inputs on a new container
print(f"Server health check failed: {str(e)} restarting ComfyUI...")
try:
cmd = "comfy stop"
try:
subprocess.run(cmd, shell=True, check=True)
except:
pass
cmd = "comfy launch --background"
try:
subprocess.run(cmd, shell=True, check=True)
except:
raise Exception("Failed to launch ComfyUI")
except:
modal.experimental.stop_fetching_inputs()
raise Exception("ComfyUI server is not healthy, restart failed, stopping container")
# @modal.web_endpoint(method="POST", label="tk")
# def tk_api(self, item: Dict):
# pass
#

View File

@@ -1,261 +0,0 @@
# ComfyUI模板--Base Auth
import json
import os
import shutil
import subprocess
import uuid
from typing import Dict
import loguru
import modal
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
image = ( # build up a Modal Image to run ComfyUI, step by step
modal.Image.debian_slim( # start from basic Linux with Python
python_version="3.10"
)
.apt_install("git") # install git to clone ComfyUI
.apt_install("gcc")
.apt_install("libportaudio2")
.pip_install("fastapi[standard]==0.115.4") # install web dependencies
.pip_install("comfy-cli==1.3.5") # install comfy-cli
.pip_install("cos-python-sdk-v5")
.pip_install("sqlalchemy")
.pip_install("ultralytics")
.pip_install("tencentcloud-sdk-python")
.pip_install("pymysql")
.pip_install("Pillow")
.pip_install("ffmpy")
.pip_install("opencv-python")
.pip_install("av")
.pip_install("imageio")
.pip_install("loguru")
.pip_install("conformer==0.3.2",extra_options="--no-dependencies")
.pip_install("einops>0.6.1",extra_options="--no-dependencies")
.pip_install("openai-whisper")
.run_commands( # use comfy-cli to install ComfyUI and its dependencies
"comfy --skip-prompt install --nvidia --version 0.3.10"
)
)
image = (
image.run_commands("comfy node install https://github.com/M1kep/ComfyLiterals")
.run_commands("comfy node install https://github.com/evanspearman/ComfyMath")
.run_commands("comfy node install https://github.com/Kosinkadink/ComfyUI-AnimateDiff-Evolved")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-Bowong.git")
.run_commands("comfy node install https://github.com/crystian/ComfyUI-Crystools")
.run_commands("comfy node install https://github.com/pythongosssss/ComfyUI-Custom-Scripts")
.run_commands("comfy node install https://github.com/BennyKok/comfyui-deploy")
.run_commands("comfy node install https://github.com/yolain/ComfyUI-Easy-Use")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-VideoHelperSuite.git")
.run_commands("comfy node install https://github.com/jags111/efficiency-nodes-comfyui")
.run_commands("comfy node install https://github.com/WASasquatch/was-node-suite-comfyui")
.run_commands("comfy node install https://github.com/rgthree/rgthree-comfy")
.run_commands("comfy node install https://github.com/cubiq/ComfyUI_essentials")
.run_commands("comfy node install https://github.com/melMass/comfy_mtb")
.run_commands("echo 3 && comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI_SparkTTS.git")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-CustomNode.git")
.run_commands("echo 04111 && comfy node install https://e.coding.net/g-ldyi2063/dev/cosyvoice_comfyui.git")
.run_commands("echo 4 && comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-LatentSync-Node.git")
.run_commands(
"mkdir -p /root/comfy/ComfyUI/models/ComfyUI-CustomNode/model && rm -rf /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/model && ln -s /root/comfy/ComfyUI/models/ComfyUI-CustomNode/model /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/model"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/ComfyUI-LatentSync-Node/checkpoints && rm -rf /root/comfy/ComfyUI/custom_nodes/ComfyUI-LatentSync-Node/checkpoints && ln -s /root/comfy/ComfyUI/models/ComfyUI-LatentSync-Node/checkpoints /root/comfy/ComfyUI/custom_nodes/ComfyUI-LatentSync-Node/checkpoints"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/.cache/torch/hub/checkpoints && mkdir -p /root/.cache/torch/hub/checkpoints && rm -rf /root/.cache/torch/hub/checkpoints && ln -s /root/comfy/ComfyUI/models/.cache/torch/hub/checkpoints /root/.cache/torch/hub/checkpoints"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/stabilityai && ln -s /root/comfy/ComfyUI/models/stabilityai /root/comfy/ComfyUI/stabilityai"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M && mkdir -p /root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/CosyVoice-300M && rm -rf /root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/CosyVoice-300M && ln -s /root/comfy/ComfyUI/models/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M /root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/CosyVoice-300M"
).run_commands(
"rm -rf /root/comfy/ComfyUI/models"
).run_commands(
"apt update && apt install -y ffmpeg && ffmpeg -version"
).add_local_file("config.yaml", "/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml", copy=True
).add_local_file("config.py", "/root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/tools/config.py", copy=True
).workdir("/root/comfy")
# Add .run_commands(...) calls for any other custom nodes you want to download
)
app = modal.App(name="highlight-comfyui-s3", image=image)
vol = modal.Volume.from_name("comfyui-model", create_if_missing=True)
bucket_dict = modal.Dict.from_name("aws_s3_bucket", create_if_missing=False)
bucket_input = str(bucket_dict.get("INPUT"))
bucket_output = str(bucket_dict.get("OUTPUT"))
secret = modal.Secret.from_name("aws-s3-secret")
# completed workflows write output images to this directory
output_dir = "/root/comfy/ComfyUI/output"
auth_scheme = HTTPBearer()
@app.cls(
allow_concurrent_inputs=1, # allow 10 concurrent API calls
max_containers=200,
min_containers=0,
buffer_containers=0,
scaledown_window=120,
# 5 minute container keep alive after it processes an input; increasing this value is a great way to reduce ComfyUI cold start times
timeout=900,
gpu=["L4", "T4"],
cpu=(2,16),
# memory=(32768, 32768), # (内存预留量, 内存使用上限)
memory=(32768,131072),
enable_memory_snapshot=False,
secrets=[secret, modal.Secret.from_name("web_auth_token")],
volumes={
"/root/comfy/ComfyUI/models": vol,
"/root/comfy/ComfyUI/input_s3": modal.CloudBucketMount(
bucket_name=bucket_input,
# bucket_endpoint_url="https://s3.%s.amazonaws.com" % os.environ["AWS_REGION"],
secret=secret,
key_prefix="/"
),
"/root/comfy/ComfyUI/output_s3": modal.CloudBucketMount(
bucket_name=bucket_output,
# bucket_endpoint_url="https://s3.%s.amazonaws.com" % os.environ["AWS_REGION"],
secret=secret,
key_prefix="/"
),
},
)
class ComfyUI:
@modal.enter()
def launch_comfy_background(self):
# starts the ComfyUI server in the background exactly once when the first input is received
self.session_id = str(uuid.uuid4())
cmd = "echo client_uuid: {}&&rm -rf /root/comfy/ComfyUI/user&&mkdir -p /root/comfy/ComfyUI/output_s3/logs/{}&&ln -s /root/comfy/ComfyUI/output_s3/logs/{} /root/comfy/ComfyUI/user && comfy launch --background".format(self.session_id,self.session_id, self.session_id)
subprocess.run(cmd, shell=True, check=True)
@modal.method()
def infer(self, workflow_json: str = ""):
self.poll_server_health()
self.prompt_uuid = str(uuid.uuid4())
# runs the comfy run --workflow command as a subprocess
workflow = json.loads(workflow_json)
print("Workflow JSON:")
print(json.dumps(workflow, indent=4, ensure_ascii=False))
for node in workflow.values():
if node.get("class_type") == "ComfyUIDeployExternalText":
if node.get("inputs").get("input_id") == "file_path":
file_to_move = node.get("inputs").get("default_value")
if file_to_move:
os.makedirs(os.path.dirname(file_to_move.replace("input_s3", "input")), exist_ok=True)
try:
shutil.copy(file_to_move, file_to_move.replace("input_s3", "input"))
except:
try:
print("Try download file to S3 manually")
# S3 Fallback
import boto3
import yaml
with open("/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml",encoding="utf-8",mode="r+") as config:
yaml_config = yaml.load(config, Loader=yaml.FullLoader)
awss3 = boto3.resource('s3', aws_access_key_id=yaml_config["aws_key_id"],
aws_secret_access_key=yaml_config["aws_access_key"])
awss3.meta.client.download_file(bucket_input, file_to_move.split("input_s3/")[1], file_to_move.replace("input_s3", "input"))
except:
raise Exception("Failed to download file from S3 manually")
node["inputs"]["default_value"] = node["inputs"]["default_value"].replace("input_s3", "input")
with open(f"/root/{self.prompt_uuid}.json", "w", encoding="utf-8") as fi:
fi.write(json.dumps(workflow, ensure_ascii=False))
cmd = f"comfy run --workflow /root/{self.prompt_uuid}.json --wait --timeout 890 --verbose"
subprocess.run(cmd, shell=True, check=True, timeout=895)
# looks up the name of the output image file based on the workflow
file_prefix = [
node.get("inputs")
for node in workflow.values()
if node.get("class_type") == "VHS_VideoCombine"
][0]["filename_prefix"]
# returns the image as bytes
file_list = os.listdir(output_dir)
# 获取按照文件时间创建排序的列表,默认是按时间升序
new_file_list = sorted(file_list, key=lambda file: os.path.getctime(os.path.join(output_dir, file)),
reverse=True)
# print("file_list", new_file_list)
for f in new_file_list:
if f.startswith(file_prefix):
os.makedirs(os.path.dirname(os.path.join(output_dir.replace("output", "output_s3"), f)), exist_ok=True)
try:
shutil.copy(os.path.join(output_dir, f), os.path.join(output_dir.replace("output", "output_s3"), f))
except:
try:
print("Try move file to S3 manually")
# S3 Fallback
import boto3
import yaml
with open("/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml", encoding="utf-8",mode="r+") as config:
yaml_config = yaml.load(config, Loader=yaml.FullLoader)
awss3 = boto3.resource('s3', aws_access_key_id=yaml_config["aws_key_id"],
aws_secret_access_key=yaml_config["aws_access_key"])
awss3.meta.client.upload_file(os.path.join(output_dir, f), bucket_output, f)
except:
raise Exception("Failed to move file to S3 manually")
return f
@modal.fastapi_endpoint(method="POST")
def api(self, item: Dict, token: HTTPAuthorizationCredentials = Depends(auth_scheme)):
if token.credentials != os.environ["AUTH_TOKEN"]:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# save this updated workflow to a new file
new_workflow_file = item["prompt"]
# run inference on the currently running container
fname = self.infer.local(new_workflow_file)
if fname is None:
raise RuntimeError("Output File not found")
j = {"status":"success", "file_name": fname}
loguru.logger.success(j)
return j
except Exception as e:
j = {"status":"fail", "msg": str(e)}
loguru.logger.error(j)
return j
finally:
print("Purge Garbage By Restart Comfy")
cmd = "comfy stop"
try:
subprocess.run(cmd, shell=True, check=True)
except:
pass
cmd = "comfy launch --background"
try:
subprocess.run(cmd, shell=True, check=True)
except:
pass
def poll_server_health(self):
import socket
import urllib
try:
# dummy request to check if the server is healthy
req = urllib.request.Request("http://127.0.0.1:8188/system_stats")
urllib.request.urlopen(req, timeout=5)
print("ComfyUI server is healthy")
except (socket.timeout, urllib.error.URLError) as e:
# if no response in 5 seconds, stop the container; Modal will schedule queued inputs on a new container
print(f"Server health check failed: {str(e)} restarting ComfyUI...")
try:
cmd = "comfy stop"
try:
subprocess.run(cmd, shell=True, check=True)
except:
pass
cmd = "comfy launch --background"
try:
subprocess.run(cmd, shell=True, check=True)
except:
raise Exception("Failed to launch ComfyUI")
except:
modal.experimental.stop_fetching_inputs()
raise Exception("ComfyUI server is not healthy, restart failed, stopping container")

View File

@@ -1,320 +0,0 @@
# ComfyUI模板--Base Auth Heygem
import json
import os
import shutil
import subprocess
import uuid
from typing import Dict
import loguru
import modal
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
image = ( # build up a Modal Image to run ComfyUI, step by step
modal.Image.debian_slim( # start from basic Linux with Python
python_version="3.10"
)
.apt_install("git") # install git to clone ComfyUI
.apt_install("gcc")
.apt_install("libportaudio2")
.pip_install("fastapi[standard]==0.115.4") # install web dependencies
.pip_install("comfy-cli==1.3.5") # install comfy-cli
.pip_install("cos-python-sdk-v5")
.pip_install("sqlalchemy")
.pip_install("ultralytics")
.pip_install("tencentcloud-sdk-python")
.pip_install("pymysql")
.pip_install("Pillow")
.pip_install("ffmpy")
.pip_install("opencv-python")
.pip_install("av")
.pip_install("imageio")
.pip_install("loguru")
.pip_install("conformer==0.3.2",extra_options="--no-dependencies")
.pip_install("einops>0.6.1",extra_options="--no-dependencies")
.pip_install("openai-whisper")
.run_commands( # use comfy-cli to install ComfyUI and its dependencies
"comfy --skip-prompt install --nvidia --version 0.3.10"
)
)
image = (
image.run_commands("comfy node install https://github.com/M1kep/ComfyLiterals")
.run_commands("comfy node install https://github.com/evanspearman/ComfyMath")
.run_commands("comfy node install https://github.com/Kosinkadink/ComfyUI-AnimateDiff-Evolved")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-Bowong.git")
.run_commands("comfy node install https://github.com/crystian/ComfyUI-Crystools")
.run_commands("comfy node install https://github.com/pythongosssss/ComfyUI-Custom-Scripts")
.run_commands("comfy node install https://github.com/BennyKok/comfyui-deploy")
.run_commands("comfy node install https://github.com/yolain/ComfyUI-Easy-Use")
.run_commands("comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-VideoHelperSuite.git")
.run_commands("comfy node install https://github.com/jags111/efficiency-nodes-comfyui")
.run_commands("comfy node install https://github.com/WASasquatch/was-node-suite-comfyui")
.run_commands("comfy node install https://github.com/rgthree/rgthree-comfy")
.run_commands("comfy node install https://github.com/cubiq/ComfyUI_essentials")
.run_commands("comfy node install https://github.com/melMass/comfy_mtb")
.run_commands("echo 3 && comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI_SparkTTS.git")
.run_commands("echo 4 && comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-CustomNode.git")
.run_commands("echo 04111 && comfy node install https://e.coding.net/g-ldyi2063/dev/cosyvoice_comfyui.git")
.run_commands("echo 4 && comfy node install https://e.coding.net/g-ldyi2063/dev/ComfyUI-LatentSync-Node.git")
.run_commands(
"mkdir -p /root/comfy/ComfyUI/models/ComfyUI-CustomNode/model && rm -rf /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/model && ln -s /root/comfy/ComfyUI/models/ComfyUI-CustomNode/model /root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/model"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/ComfyUI-LatentSync-Node/checkpoints && rm -rf /root/comfy/ComfyUI/custom_nodes/ComfyUI-LatentSync-Node/checkpoints && ln -s /root/comfy/ComfyUI/models/ComfyUI-LatentSync-Node/checkpoints /root/comfy/ComfyUI/custom_nodes/ComfyUI-LatentSync-Node/checkpoints"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/.cache/torch/hub/checkpoints && mkdir -p /root/.cache/torch/hub/checkpoints && rm -rf /root/.cache/torch/hub/checkpoints && ln -s /root/comfy/ComfyUI/models/.cache/torch/hub/checkpoints /root/.cache/torch/hub/checkpoints"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/stabilityai && ln -s /root/comfy/ComfyUI/models/stabilityai /root/comfy/ComfyUI/stabilityai"
).run_commands(
"mkdir -p /root/comfy/ComfyUI/models/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M && mkdir -p /root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/CosyVoice-300M && rm -rf /root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/CosyVoice-300M && ln -s /root/comfy/ComfyUI/models/CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M /root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/CosyVoice-300M"
).run_commands(
"rm -rf /root/comfy/ComfyUI/models"
).run_commands(
"apt update && apt install -y ffmpeg && ffmpeg -version"
).add_local_file("config.yaml", "/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml", copy=True
).add_local_file("config.py", "/root/comfy/ComfyUI/custom_nodes/cosyvoice_comfyui/pretrained_models/tools/config.py", copy=True
) # 添加Python3.8 HeyGem
.run_commands("apt update && apt install -y curl build-essential libssl-dev zlib1g-dev libncurses5-dev libncursesw5-dev libreadline-dev libsqlite3-dev libgdbm-dev libdb5.3-dev libbz2-dev libexpat1-dev lzma liblzma-dev tk-dev libffi-dev")
.run_commands("curl -O https://www.python.org/ftp/python/3.8.12/Python-3.8.12.tar.xz&&tar -xf Python-3.8.12.tar.xz")
.run_commands("cd Python-3.8.12 && ./configure --enable-optimizations && make -j 10 && make altinstall")
.add_local_file("heygem-1.0-py3-none-any.whl","/root/comfy/heygem-1.0-py3-none-any.whl", copy=True)
.shell(["/bin/bash", "-c"])
.run_commands("python3.8 -m pip install /root/comfy/heygem-1.0-py3-none-any.whl")
.env({"LD_LIBRARY_PATH":"/usr/local/lib/python3.8/site-packages/nvidia/cuda_nvrtc/lib"})
.run_commands("ln -s /usr/local/lib/python3.8/site-packages/nvidia/cuda_nvrtc/lib/libnvrtc.so.11.2 /usr/local/lib/python3.8/site-packages/nvidia/cuda_nvrtc/lib/libnvrtc.so")
.run_commands("python3.8 -m pip install https://github.com/pydata/numexpr/releases/download/v2.8.6/numexpr-2.8.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl")
.add_local_file("heygem.py", "/root/comfy/heygem.py", copy=True)
.workdir("/root/comfy")
# Add .run_commands(...) calls for any other custom nodes you want to download
)
app = modal.App(name="highlight-comfyui-s3", image=image)
vol = modal.Volume.from_name("comfyui-model", create_if_missing=True)
bucket_dict = modal.Dict.from_name("aws_s3_bucket", create_if_missing=False)
bucket_input = str(bucket_dict.get("INPUT"))
bucket_output = str(bucket_dict.get("OUTPUT"))
secret = modal.Secret.from_name("aws-s3-secret")
# completed workflows write output images to this directory
output_dir = "/root/comfy/ComfyUI/output"
auth_scheme = HTTPBearer()
@app.cls(
allow_concurrent_inputs=1, # allow 10 concurrent API calls
max_containers=200,
min_containers=0,
buffer_containers=0,
scaledown_window=120,
# 5 minute container keep alive after it processes an input; increasing this value is a great way to reduce ComfyUI cold start times
timeout=1200,
gpu=["L4", "T4"],
cpu=(2,16),
# memory=(32768, 32768), # (内存预留量, 内存使用上限)
memory=(20480,81920),
enable_memory_snapshot=False,
secrets=[secret, modal.Secret.from_name("web_auth_token")],
volumes={
"/root/comfy/ComfyUI/models": vol,
"/root/comfy/ComfyUI/input_s3": modal.CloudBucketMount(
bucket_name=bucket_input,
# bucket_endpoint_url="https://s3.%s.amazonaws.com" % os.environ["AWS_REGION"],
secret=secret,
key_prefix="/"
),
"/root/comfy/ComfyUI/output_s3": modal.CloudBucketMount(
bucket_name=bucket_output,
# bucket_endpoint_url="https://s3.%s.amazonaws.com" % os.environ["AWS_REGION"],
secret=secret,
key_prefix="/"
),
},
)
class ComfyUI:
@modal.enter()
def launch_comfy_background(self):
# starts the ComfyUI server in the background exactly once when the first input is received
self.session_id = str(uuid.uuid4())
cmd = ("echo client_uuid: {}"
" && rm -rf /root/comfy/ComfyUI/user"
" && mkdir -p /root/comfy/ComfyUI/output_s3/logs/{}"
" && ln -s /root/comfy/ComfyUI/output_s3/logs/{} /root/comfy/ComfyUI/user"
" && nohup python3.8 /root/comfy/heygem.py >> /root/comfy/ComfyUI/output_s3/logs/{}/heygem.log 2>&1 &"
).format(self.session_id,self.session_id, self.session_id, self.session_id)
subprocess.run(cmd, shell=True, check=True)
cmd = "comfy launch --background"
subprocess.run(cmd, shell=True, check=True)
@modal.method()
def infer(self, workflow_json: str = ""):
self.poll_server_health()
self.prompt_uuid = str(uuid.uuid4())
# runs the comfy run --workflow command as a subprocess
workflow = json.loads(workflow_json)
print("Workflow JSON:")
print(json.dumps(workflow, indent=4, ensure_ascii=False))
for node in workflow.values():
if node.get("class_type") == "ComfyUIDeployExternalText":
if node.get("inputs").get("input_id") == "file_path":
file_to_move = node.get("inputs").get("default_value")
if file_to_move:
os.makedirs(os.path.dirname(file_to_move.replace("input_s3", "input")), exist_ok=True)
try:
shutil.copy(file_to_move, file_to_move.replace("input_s3", "input"))
except:
try:
print("Try download file to S3 manually")
# S3 Fallback
import boto3
import yaml
with open("/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml",encoding="utf-8",mode="r+") as config:
yaml_config = yaml.load(config, Loader=yaml.FullLoader)
awss3 = boto3.resource('s3', aws_access_key_id=yaml_config["aws_key_id"],
aws_secret_access_key=yaml_config["aws_access_key"])
awss3.meta.client.download_file(bucket_input, file_to_move.split("input_s3/")[1], file_to_move.replace("input_s3", "input"))
except:
raise Exception("Failed to download file from S3 manually")
node["inputs"]["default_value"] = node["inputs"]["default_value"].replace("input_s3", "input")
with open(f"/root/{self.prompt_uuid}.json", "w", encoding="utf-8") as fi:
fi.write(json.dumps(workflow, ensure_ascii=False))
cmd = f"comfy run --workflow /root/{self.prompt_uuid}.json --wait --timeout 1190 --verbose"
subprocess.run(cmd, shell=True, check=True, timeout=1195)
# looks up the name of the output image file based on the workflow
if "VHS_VideoCombine" in workflow_json:
file_prefix = [
node.get("inputs")
for node in workflow.values()
if node.get("class_type") == "VHS_VideoCombine"
][0]["filename_prefix"]
# returns the image as bytes
file_list = os.listdir(output_dir)
# 获取按照文件时间创建排序的列表,默认是按时间升序
new_file_list = sorted(file_list, key=lambda file: os.path.getctime(os.path.join(output_dir, file)),
reverse=True)
# print("file_list", new_file_list)
for f in new_file_list:
if f.startswith(file_prefix):
os.makedirs(os.path.dirname(os.path.join(output_dir.replace("output", "output_s3"), f)), exist_ok=True)
try:
shutil.copy(os.path.join(output_dir, f), os.path.join(output_dir.replace("output", "output_s3"), f))
except:
try:
print("Try move file to S3 manually")
# S3 Fallback
import boto3
import yaml
with open("/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml", encoding="utf-8",mode="r+") as config:
yaml_config = yaml.load(config, Loader=yaml.FullLoader)
awss3 = boto3.resource('s3', aws_access_key_id=yaml_config["aws_key_id"],
aws_secret_access_key=yaml_config["aws_access_key"])
awss3.meta.client.upload_file(os.path.join(output_dir, f), bucket_output, f)
except:
raise Exception("Failed to move file to S3 manually")
return f
elif "HeyGemF2F" in workflow_json or "HeyGemF2FFromFile" in workflow_json:
heygem_dir = [
node.get("inputs")
for node in workflow.values()
if node.get("class_type") == "HeyGemF2F" or node.get("class_type") == "HeyGemF2FFromFile"
][0]["heygem_temp_path"]
# returns the image as bytes
file_list = os.listdir(heygem_dir)
# 获取按照文件时间创建排序的列表,默认是按时间升序
new_file_list = sorted(file_list, key=lambda file: os.path.getctime(os.path.join(heygem_dir, file)),
reverse=True)
if len(new_file_list) > 0:
try:
shutil.copy(os.path.join(heygem_dir, new_file_list[0]), os.path.join(output_dir.replace("output", "output_s3"), new_file_list[0]))
except:
try:
print("Try move file to S3 manually")
# S3 Fallback
import boto3
import yaml
with open("/root/comfy/ComfyUI/custom_nodes/ComfyUI-CustomNode/config.yaml", encoding="utf-8",
mode="r+") as config:
yaml_config = yaml.load(config, Loader=yaml.FullLoader)
awss3 = boto3.resource('s3', aws_access_key_id=yaml_config["aws_key_id"],
aws_secret_access_key=yaml_config["aws_access_key"])
awss3.meta.client.upload_file(os.path.join(heygem_dir, new_file_list[0]), bucket_output, new_file_list[0])
except:
raise Exception("Failed to move HeyGem file to S3 manually")
return new_file_list[0]
else:
raise FileNotFoundError("HeyGem generation file not found")
else:
raise Exception("Workflow does not contain VHS_VideoCombine/HeygemF2F node, cannot find output file")
@modal.fastapi_endpoint(method="POST")
def api(self, item: Dict, token: HTTPAuthorizationCredentials = Depends(auth_scheme)):
if token.credentials != os.environ["AUTH_TOKEN"]:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Incorrect bearer token",
headers={"WWW-Authenticate": "Bearer"},
)
try:
# save this updated workflow to a new file
new_workflow_file = item["prompt"]
# run inference on the currently running container
fname = self.infer.local(new_workflow_file)
if fname is None:
raise RuntimeError("Output File not found")
j = {"status": "success", "file_name": fname}
loguru.logger.success(j)
return j
except Exception as e:
j = {"status":"fail", "msg": str(e)}
loguru.logger.error(j)
return j
finally:
print("Purge Garbage By Restart Comfy")
cmd = "comfy stop"
try:
subprocess.run(cmd, shell=True, check=True)
except:
pass
cmd = "comfy launch --background"
try:
subprocess.run(cmd, shell=True, check=True)
except:
pass
def poll_server_health(self):
import socket
import urllib
try:
# dummy request to check if the server is healthy
req = urllib.request.Request("http://127.0.0.1:8188/system_stats")
urllib.request.urlopen(req, timeout=5)
print("ComfyUI server is healthy")
except (socket.timeout, urllib.error.URLError) as e:
# if no response in 5 seconds, stop the container; Modal will schedule queued inputs on a new container
print(f"Server health check failed: {str(e)} restarting ComfyUI...")
try:
cmd = "comfy stop"
try:
subprocess.run(cmd, shell=True, check=True)
except:
pass
cmd = "comfy launch --background"
try:
subprocess.run(cmd, shell=True, check=True)
except:
raise Exception("Failed to launch ComfyUI")
except:
modal.experimental.stop_fetching_inputs()
raise Exception("ComfyUI server is not healthy, restart failed, stopping container")
# @modal.web_endpoint(method="POST", label="tk")
# def tk_api(self, item: Dict):
# pass
#

0
src/__init__.py Normal file
View File

0
src/cluster/__init__.py Normal file
View File

13
src/cluster/app.py Normal file
View File

@@ -0,0 +1,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
app = modal.App('video-downloader',
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)

25
src/cluster/config.py Normal file
View File

@@ -0,0 +1,25 @@
from typing import Optional, Any
from pydantic import Field
from pydantic_settings import BaseSettings, SettingsConfigDict
class WorkerConfig(BaseSettings):
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_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")
environment: Optional[str] = Field(default="dev", description="Modal worker运行环境")
modal_config: Any = SettingsConfigDict()
config = WorkerConfig(
video_downloader_concurrency=10,
ffmpeg_worker_concurrency=10,
modal_kv_name="media-cache",
s3_bucket_name="modal-media-cache",
environment="dev"
)

View File

@@ -0,0 +1,12 @@
import os
class FileUtils:
@staticmethod
def file_path_extend(media_path: str, extend: str) -> str:
media_filename = os.path.basename(media_path)
media_dir = os.path.dirname(media_path) + '/'
filenames = media_filename.split('.')
filenames[0] = f"{filenames[0]}_{extend}"
extend_filename = '.'.join(filenames)
return os.path.join(media_dir, extend_filename)

View File

@@ -0,0 +1,16 @@
from datetime import timedelta, datetime
class TimeDelta(timedelta):
@classmethod
def from_timedelta(cls, delta: timedelta) -> "TimeDelta":
return cls(days=delta.days, seconds=delta.seconds, microseconds=delta.microseconds)
@classmethod
def from_format_string(cls, format_string: str) -> "TimeDelta":
formated_time = datetime.strptime(format_string, "%H:%M:%S.%f")
return cls(hours=formated_time.hour, minutes=formated_time.minute, seconds=formated_time.second,
microseconds=formated_time.microsecond)
def toFormatStr(self) -> str:
return (datetime(year=2000, month=1, day=1) + self).strftime("%H:%M:%S.%f")[:-3]

View File

View File

@@ -0,0 +1,65 @@
from typing import Union, Any
from pydantic import BaseModel, Field, computed_field, field_validator
from pydantic.json_schema import JsonSchemaValue
from src.cluster.ffmpeg_worker.Utils.TimeUtils import TimeDelta
class FFMpegSliceSegment(BaseModel):
start: TimeDelta = Field(description="FFMPEG segment start time from 0")
end: TimeDelta = Field(description="FFMPEG segment end time from 0")
@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, TimeDelta]):
if isinstance(v, float):
return TimeDelta(seconds=v)
elif isinstance(v, int):
return TimeDelta(seconds=v)
elif isinstance(v, TimeDelta):
return v
else:
raise TypeError(v)
@field_validator('end', mode='before')
@classmethod
def parse_end(cls, v: Union[float, TimeDelta]):
if isinstance(v, float):
return TimeDelta(seconds=v)
elif isinstance(v, int):
return TimeDelta(seconds=v)
elif isinstance(v, TimeDelta):
return v
else:
raise TypeError(v)
@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]
},
"end": {
"type": "number",
"examples": [8, 12.5]
}
},
"required": [
"start",
"end"
]
}
model_config = {
"arbitrary_types_allowed": True
}

View File

@@ -0,0 +1,132 @@
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')
.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")
with ffmpeg_worker_image.imports():
from typing import List
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
sentry_sdk.init(
dsn="https://75cca970bfcc3d45d24361e7c0f1833c@sentry.bowongai.com/4",
send_default_pii=True,
add_full_stack=True,
shutdown_timeout=2,
traces_sample_rate=1.0,
environment=config.environment,
)
modal_kv = modal.Dict.from_name(config.modal_kv_name,
environment_name=config.environment,
create_if_missing=True)
@sentry_sdk.trace
def get_cache_filepath(media: MediaSource) -> MediaCache:
cache_data_json = modal_kv.get(media.cache_key)
if cache_data_json:
cache_data = MediaCache.model_validate_json(cache_data_json)
return cache_data
raise FileNotFoundError(f"{media.cache_key} cache not found")
@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 {}
@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)
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):
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()}")
workdir = os.path.dirname(output_filepath)
os.makedirs(workdir, exist_ok=True)
outputs.append(output_filepath)
ffmpeg_cmd.output(output_filepath,
map=f"[cut{i}]",
reset_timestamps="1",
sc_threshold="0",
g="1",
force_key_frames="expr:gte(t, n_forced * 1)",
vcodec="libx264",
crf=16,
r=30, )
@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):
logger.warning(line)
await ffmpeg_cmd.execute()
return outputs

View File

View File

@@ -0,0 +1,103 @@
from datetime import datetime
from enum import Enum
from typing import List, Union, Optional, Any, Dict
from pydantic import BaseModel, Field, field_validator, ValidationError, field_serializer, SerializationInfo
from pydantic.json_schema import JsonSchemaValue
class MediaProtocol(str, Enum):
http = "http"
s3 = "s3"
vod = "vod"
cos = "cos"
class MediaCacheStatus(str, Enum):
downloading = "downloading"
failed = "failed"
ready = "ready"
deleted = "deleted"
missing = "missing"
class MediaSource(BaseModel):
url: str = Field()
protocol: MediaProtocol = Field()
endpoint: Optional[str] = Field()
bucket: Optional[str] = Field()
cache_key: Optional[str] = Field()
@classmethod
def from_str(cls, mediaUrl: str) -> 'MediaSource':
if mediaUrl.startswith('http://') or mediaUrl.startswith('https://'):
return MediaSource(url=mediaUrl, protocol=MediaProtocol.http, cache_key=mediaUrl)
elif mediaUrl.startswith('s3://'): # s3://{endpoint}/{bucket}/{url}
paths = mediaUrl[5:].split('/')
return MediaSource(url='/'.join(paths[2:]), protocol=MediaProtocol.s3, endpoint=paths[0], bucket=paths[1],
cache_key=mediaUrl)
elif mediaUrl.startswith('vod://'): # vod://{endpoint}/{subAppId}/{fileId}
paths = mediaUrl[6:].split('/')
return MediaSource(url=paths[2], protocol=MediaProtocol.vod, bucket=paths[1], endpoint=paths[0],
cache_key=mediaUrl)
elif mediaUrl.startswith('cos://'): # cos://{endpoint}/{bucket}/{url}
paths = mediaUrl[6:].split('/')
return MediaSource(url='/'.join(paths[2:]), protocol=MediaProtocol.cos, endpoint=paths[0], bucket=paths[1],
cache_key=mediaUrl)
else:
raise ValidationError("mediaUrl必须以http[s]、s3或vod协议开头")
@classmethod
def __get_pydantic_json_schema__(cls, core_schema: Any, handler: Any) -> JsonSchemaValue:
# Override the schema to represent it as a string
return {
"type": "string",
"examples": [
"vod://ap-shanghai/subAppId/fileId",
"http://example.com/video.mp4",
"s3://endpoint/bucket/path/to/file",
"cos://endpoint/bucket/path/to/file"
]
}
class MediaSources(BaseModel):
inputs: List[MediaSource] = Field(examples=[
["vod://ap-shanghai/1500034234/1397757910405340824", "vod://ap-shanghai/1500034234/1397757910403699452"]])
@field_validator('inputs', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]) -> List[MediaSource]:
if not v:
return v
result = []
for item in v:
if isinstance(item, str):
result.append(MediaSource.from_str(item))
elif isinstance(item, MediaSource):
result.append(item)
else:
raise ValidationError("inputs元素类型错误: 必须是字符串")
return result
model_config = {
"arbitrary_types_allowed": True
}
class MediaCache(BaseModel):
status: MediaCacheStatus = Field(description="缓存被处理的状态")
progress: float = Field(description="缓存下载进度0-1", default=0)
expired_at: Optional[datetime] = Field(description="缓存过期时间点", default=None)
downloader_id: Optional[str] = Field(description="正在处理下载的Downloader ID", default=None)
cache_filepath: Optional[str] = Field(description="缓存的文件地址", default=None)
@field_serializer('expired_at')
def serialize_datetime(self, value: Optional[datetime], info: SerializationInfo) -> Optional[str]:
if value:
return value.isoformat()
else:
return None
class CacheResult(BaseModel):
caches: Dict[str, MediaCache] = Field(description="Cache ID")

View File

@@ -0,0 +1,301 @@
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.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.video_downloader.model import MediaSource, MediaCacheStatus, MediaCache, MediaProtocol
from src.cluster.web.model import SentryTransactionInfo
from datetime import datetime, UTC, timedelta
from tencentcloud.common.credential import Credential
from tencentcloud.vod.v20180717.vod_client import VodClient
from tencentcloud.vod.v20180717 import models as vod_request_models
import sentry_sdk
sentry_sdk.init(dsn="https://85632fdcd62f699c2f88af6ca489e9ec@sentry.bowongai.com/3",
send_default_pii=True,
traces_sample_rate=1.0,
profiles_sample_rate=1.0,
add_full_stack=True,
shutdown_timeout=2,
environment=config.environment,
)
cf_account_id = os.environ.get("CF_ACCOUNT_ID")
cf_kv_api_token = os.environ.get("CF_KV_API_TOKEN")
cf_kv_namespace_id = os.environ.get("CF_KV_NAMESPACE_ID")
@sentry_sdk.trace
def batch_update_cloudflare_kv(caches: Dict[str, MediaCache]):
with httpx.Client() as client:
try:
response = client.put(
f"https://api.cloudflare.com/client/v4/accounts/{cf_account_id}/storage/kv/namespaces/{cf_kv_namespace_id}/bulk",
headers={"Authorization": f"Bearer {cf_kv_api_token}"},
json=[
{
"based64": False,
"key": mediaKey,
"value": cache_data.model_dump_json(),
}
for (mediaKey, cache_data) in caches.items()
]
)
response.raise_for_status()
except httpx.RequestError as e:
logger.error(f"An error occurred while put kv to cloudflare")
raise e
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error occurred while get kv from cloudflare {str(e)}")
raise e
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
raise e
@sentry_sdk.trace
def batch_remove_cloudflare_kv(caches: Dict[str, MediaCache]):
with httpx.Client() as client:
try:
response = client.post(
f"https://api.cloudflare.com/client/v4/accounts/{cf_account_id}/storage/kv/namespaces/{cf_kv_namespace_id}/bulk/delete",
headers={"Authorization": f"Bearer {cf_kv_api_token}"},
json=[mediaKey for (mediaKey, cache_data) in caches.items()]
)
response.raise_for_status()
except httpx.RequestError as e:
logger.error(f"An error occurred while put kv to cloudflare")
raise e
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error occurred while get kv from cloudflare {str(e)}")
raise e
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
raise e
@worker_app.function(cpu=1, timeout=1800,
cloud="aws",
# region='ap-northeast',
max_containers=config.video_downloader_concurrency,
volumes={
"/mntS3": modal.CloudBucketMount(
bucket_name=config.s3_bucket_name,
secret=modal.Secret.from_name("aws-s3-secret", environment_name=config.environment),
),
},
secrets=[modal.Secret.from_name("tencent-cloud-secret", environment_name=config.environment)])
@modal.concurrent(max_inputs=10)
async def cache_submit(media: MediaSource, sentry_trace: SentryTransactionInfo) -> MediaCache:
def vod_init():
tencent_secret_id = os.environ["VOD_SECRET_ID"]
tencent_secret_key = os.environ["VOD_SECRET_KEY"]
cred = Credential(secret_id=tencent_secret_id, secret_key=tencent_secret_key)
return VodClient(credential=cred, region='ap-shanghai')
def vod_info(media: MediaSource) -> Tuple[str, str, str]:
logger.info(f"Downloading media {media}")
request = vod_request_models.DescribeMediaInfosRequest()
request.SubAppId = int(media.bucket)
request.FileIds = [media.url]
response = vod_client.DescribeMediaInfos(request)
if len(response.MediaInfoSet) > 0:
media_info = response.MediaInfoSet[0].BasicInfo
logger.info(f"VOD info = {media_info}")
file_extension = media_info.Type
cache_dir = f"/mntS3/{media.protocol.value}/{media.endpoint}/{media.bucket}"
cache_file = f"{media.url}.{file_extension}"
return (cache_dir, cache_file, media_info.MediaUrl)
else:
raise FileNotFoundError(
f"FileId : {media.url} not found in SubAppId: {media.bucket} at {media.endpoint}")
def vod_download(media: MediaSource, on_progress_update: callable(float) = None) -> str:
cache_dir, cache_file, url = vod_info(media)
local_cache_filepath = os.path.join(cache_dir, cache_file)
download_large_file(url=url, output_path=local_cache_filepath,
on_progress_callback=on_progress_update)
return local_cache_filepath
def download_large_file(url: str, output_path: str, on_progress_callback: callable(float) = None) -> None:
# 配置日志
logger.info(f"Starting download from {url}")
try:
# 使用 httpx 发送 HEAD 请求获取文件大小
with httpx.Client() as client:
head_response = client.head(url)
file_size = int(head_response.headers.get('content-length', 0))
remote_crc64 = int(head_response.headers.get('X-Cos-Hash-Crc64ecma', 0))
logger.info(f"File size: {file_size / (1024 * 1024 * 1024):.2f} GB")
# 设置请求头,支持断点续传
headers = {'Range': 'bytes=0-'}
if os.path.exists(output_path):
local_file_size = os.path.getsize(output_path)
logger.info(f"File size match check {local_file_size} = {file_size}")
if local_file_size == file_size:
logger.info(f"Check file CRC64...")
# CRC64使用ECMA-182标准校验 ref: https://cloud.tencent.com/document/product/436/40334#python-sdk
c64 = crcmod.mkCrcFun(0x142F0E1EBA9EA3693, initCrc=0, xorOut=0xffffffffffffffff, rev=True)
with open(output_path, "rb") as local_file:
local_crc64 = c64(local_file.read())
logger.info(f"File crc64 check {local_crc64} = {remote_crc64}")
if local_crc64 == remote_crc64:
logger.success("File size verification passed!")
return
# 发起流式请求
with client.stream('GET', url, headers=headers) as response:
response.raise_for_status()
# 设置进度条
progress_bar = tqdm(
total=file_size,
unit='iB',
unit_scale=True,
desc='Downloading'
)
# 以二进制写模式打开文件
with open(output_path, 'wb') as file:
# 分块下载每次读取1MB
chunk_size = 1024 * 1024 # 1MB
downloaded_size = 0
for chunk in response.iter_bytes(chunk_size=chunk_size):
if chunk:
file.write(chunk)
downloaded_size += len(chunk)
if on_progress_callback:
on_progress_callback(downloaded_size / file_size)
progress_bar.update(len(chunk))
# 每下载100MB记录一次日志
if downloaded_size % (100 * 1024 * 1024) == 0:
logger.info(f"Downloaded: {downloaded_size / (1024 * 1024 * 1024):.2f} GB")
progress_bar.close()
# 验证下载是否完成
if os.path.exists(output_path):
final_size = os.path.getsize(output_path)
logger.info(f"Download completed successfully!")
logger.info(f"Final file size: {final_size / (1024 * 1024 * 1024):.2f} GB")
logger.info(f"File saved to: {os.path.abspath(output_path)}")
# 验证文件大小是否匹配
if final_size == file_size:
logger.info("File size verification passed!")
else:
logger.warning(f"File size mismatch! Expected: {file_size}, Got: {final_size}")
except httpx.RequestError as e:
logger.error(f"An error occurred while requesting {url}: {str(e)}")
raise e
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error occurred: {str(e)}")
raise e
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
raise e
finally:
if 'progress_bar' in locals():
progress_bar.close()
vod_client = vod_init()
modal_kv = modal.Dict.from_name(config.modal_kv_name, environment_name=config.environment,
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:
transaction.set_context("runtime_environment", {
"MODAL_CLOUD_PROVIDER": os.environ.get('MODAL_CLOUD_PROVIDER', 'unknown'),
"MODAL_ENVIRONMENT": os.environ.get('MODAL_ENVIRONMENT', 'unknown'),
"MODAL_IMAGE_ID": os.environ.get('MODAL_IMAGE_ID', 'unknown'),
"MODAL_IS_REMOTE": os.environ.get('MODAL_IS_REMOTE', 'unknown'),
"MODAL_REGION": os.environ.get('MODAL_REGION', 'unknown'),
"MODAL_TASK_ID": os.environ.get('MODAL_TASK_ID', 'unknown'),
"MODAL_IDENTITY_TOKEN": os.environ.get('MODAL_IDENTITY_TOKEN', 'unknown'),
})
with transaction.start_child(name="收到缓存视频任务", op="queue.receive") as receive_span:
receive_span.set_data("messaging.message.id", fn_id)
receive_span.set_data("messaging.destination.name", "video-downloader.cache_submit")
receive_span.set_data("messaging.message.retry.count", 0)
receive_span.set_data("cache.key", media.cache_key)
with receive_span.start_child(name="处理缓存视频任务", op="queue.process") as process_span:
process_span.set_data("messaging.message.id", fn_id)
process_span.set_data("messaging.destination.name", "video-downloader.cache_submit")
process_span.set_data("messaging.message.retry.count", 0)
process_span.set_data("cache.key", media.cache_key)
volume_cache_path = None
match media.protocol:
case MediaProtocol.vod:
media_cache_downloading = MediaCache(status=MediaCacheStatus.downloading,
downloader_id=fn_id)
def on_progress_callback(progress: float):
media_cache_downloading.progress = progress
caches_data = {media.cache_key: media_cache_downloading}
modal_kv.put(media.cache_key, media_cache_downloading.model_dump_json())
batch_update_cloudflare_kv(caches_data)
try:
volume_cache_path = vod_download(media)
process_span.set_status("success")
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
media_cache_downloading.status = MediaCacheStatus.failed
caches = {f"{media.cache_key}": media_cache_downloading}
modal_kv.put(media.cache_key, media_cache_downloading)
batch_update_cloudflare_kv(caches)
process_span.set_status("failed")
case _:
process_span.set_status("failed")
raise NotImplementedError("protocol not yet supported")
media_cache_ready = MediaCache(
downloader_id=fn_id,
cache_filepath=volume_cache_path,
status=MediaCacheStatus.ready if volume_cache_path else MediaCacheStatus.failed,
progress=1 if volume_cache_path else 0,
expired_at=datetime.now(UTC) + timedelta(days=7) if volume_cache_path else None, )
modal_kv.put(media.cache_key, media_cache_ready.model_dump_json())
batch_update_cloudflare_kv({media.cache_key: media_cache_ready})
return media_cache_ready
@worker_app.function(cpu=1, timeout=300,
max_containers=config.video_downloader_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=10)
async def cache_delete(cache: MediaCache) -> MediaCache:
sentry_sdk.add_breadcrumb({
"MODAL_CLOUD_PROVIDER": os.environ.get('MODAL_CLOUD_PROVIDER', 'unknown'),
"MODAL_ENVIRONMENT": os.environ.get('MODAL_ENVIRONMENT', 'unknown'),
"MODAL_IMAGE_ID": os.environ.get('MODAL_IMAGE_ID', 'unknown'),
"MODAL_IS_REMOTE": os.environ.get('MODAL_IS_REMOTE', 'unknown'),
"MODAL_REGION": os.environ.get('MODAL_REGION', 'unknown'),
"MODAL_TASK_ID": os.environ.get('MODAL_TASK_ID', 'unknown'),
"MODAL_IDENTITY_TOKEN": os.environ.get('MODAL_IDENTITY_TOKEN', 'unknown'),
})
if os.path.exists(cache.cache_filepath):
os.remove(cache.cache_filepath)
cache.status = MediaCacheStatus.deleted
else:
cache.status = MediaCacheStatus.missing
return cache

View File

49
src/cluster/web/model.py Normal file
View File

@@ -0,0 +1,49 @@
from enum import Enum
from typing import List, Union, Any, Optional
from pydantic import BaseModel, Field, field_validator
from src.cluster.ffmpeg_worker.model import FFMpegSliceSegment
from src.cluster.video_downloader.model import MediaSource
class SentryTransactionInfo(BaseModel):
trace_id: str = Field(description="Sentry Transaction ID")
baggage: str = Field(description="Sentry Transaction baggage")
class FFMPEGSliceRequest(BaseModel):
media: MediaSource = Field(description="FFMPEG Slice Media Source")
markers: List[FFMpegSliceSegment] = Field(description="FFMPEG Slice Marker List")
@field_validator('media', mode='before')
@classmethod
def parse_inputs(cls, v: Union[str, MediaSource]):
if isinstance(v, str):
return MediaSource.from_str(v)
elif isinstance(v, MediaSource):
return v
else:
raise TypeError(v)
class FFMPEGSliceResponse(BaseModel):
success: bool = Field(description="任务创建成功与否")
taskId: str = Field(description="任务Id")
class FFMPEGSliceTaskStatusRequest(BaseModel):
taskId: str = Field(description="任务Id")
class TaskStatus(str, Enum):
running = "running"
failed = "failed"
success = "success"
expired = "expired"
class FFMPEGSliceTaskStatusResponse(BaseModel):
taskId: str = Field(description="任务Id")
status: TaskStatus = Field(description="任务运行状态")
result: Optional[List[str]] = Field(default=None, description="任务运行结果")

281
src/cluster/web/worker.py Normal file
View File

@@ -0,0 +1,281 @@
import modal
from src.cluster.config import config
fastapi_image = (
modal.Image
.debian_slim(python_version="3.11")
.pip_install("fastapi[standard]", "sentry-sdk[fastapi]",
'loguru', 'pydantic', 'pydantic_settings', 'scalar-fastapi')
.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)
)
fastapi_app = modal.App("video-downloader-web", image=fastapi_image)
with fastapi_image.imports():
@fastapi_app.function(scaledown_window=60,
secrets=[
modal.Secret.from_name("cf-kv-secret", environment_name=config.environment),
])
@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",
summary="Modal Worker的API, 包括缓存视频, 发起生产任务等",
servers=[
{'url': 'https://bowongai-dev--video-downloader-fastapi-webapp.modal.run',
'description': 'modal dev环境测试服务'}])
async def verify_token(credentials: HTTPAuthorizationCredentials = Depends(bearer_scheme)):
token = credentials.credentials
# 在这里实现你的token验证逻辑
if not is_valid_token(token):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid authentication credentials",
headers={"WWW-Authenticate": "Bearer"},
)
return token
def is_valid_token(token: str) -> bool:
# 这里实现具体的token验证逻辑
# 例如验证JWT token检查数据库中的token等
return token == "bowong7777"
sentry_sdk.init(dsn="https://dab7b7ae652216282c89f029a76bb10a@sentry.bowongai.com/2",
send_default_pii=True,
traces_sample_rate=1.0,
profiles_sample_rate=1.0,
add_full_stack=True,
environment=config.environment,
integrations=[
LoguruIntegration(level=LoggingLevels.INFO.value, event_level=LoggingLevels.ERROR.value),
FastApiIntegration()
]
)
media_cache_kv = modal.Dict.from_name('media-cache', environment_name=config.environment,
create_if_missing=True)
cf_account_id = os.environ.get("CF_ACCOUNT_ID")
cf_kv_api_token = os.environ.get("CF_KV_API_TOKEN")
cf_kv_namespace_id = os.environ.get("CF_KV_NAMESPACE_ID")
@sentry_sdk.trace
def batch_update_cloudflare_kv(caches: Dict[str, MediaCache]):
with httpx.Client() as client:
try:
response = client.put(
f"https://api.cloudflare.com/client/v4/accounts/{cf_account_id}/storage/kv/namespaces/{cf_kv_namespace_id}/bulk",
headers={"Authorization": f"Bearer {cf_kv_api_token}"},
json=[
{
"based64": False,
"key": mediaKey,
"value": cache_data.model_dump_json(),
}
for (mediaKey, cache_data) in caches.items()
]
)
response.raise_for_status()
except httpx.RequestError as e:
logger.error(f"An error occurred while put kv to cloudflare")
raise e
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error occurred while get kv from cloudflare {str(e)}")
raise e
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
raise e
@sentry_sdk.trace
def batch_remove_cloudflare_kv(keys: List[str]):
with httpx.Client() as client:
try:
response = client.post(
f"https://api.cloudflare.com/client/v4/accounts/{cf_account_id}/storage/kv/namespaces/{cf_kv_namespace_id}/bulk/delete",
headers={"Authorization": f"Bearer {cf_kv_api_token}"},
json=keys
)
response.raise_for_status()
except httpx.RequestError as e:
logger.error(f"An error occurred while put kv to cloudflare")
raise e
except httpx.HTTPStatusError as e:
logger.error(f"HTTP error occurred while get kv from cloudflare {str(e)}")
raise e
except Exception as e:
logger.error(f"An unexpected error occurred: {str(e)}")
raise e
@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",
summary="缓存视频文件",
description="异步缓存视频文件到S3存储桶和Modal Dict(KV)",
dependencies=[Depends(verify_token)])
@sentry_sdk.trace
async def cache(medias: MediaSources) -> CacheResult:
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())
for media in medias.inputs:
with parent.start_child(name="同步视频缓存", op="cache.get") as cache_span:
cache_span.set_data("runner_id", fn_id)
cache_span.set_data("cache.key", [media.cache_key])
video_cache_status_json = media_cache_kv.get(media.cache_key)
video_cache: MediaCache
cache_hit: bool = False
if not video_cache_status_json:
# 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_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)
queue_publish_span.set_data("messaging.destination.name",
"video-downloader.cache_submit")
queue_publish_span.set_data("messaging.message.body.size", 0)
video_cache = MediaCache(status=MediaCacheStatus.downloading,
downloader_id=fn_task.object_id)
video_cache_status_json = video_cache.model_dump_json()
media_cache_kv.put(media.cache_key, video_cache_status_json)
video_cache = MediaCache.model_validate_json(video_cache_status_json)
match video_cache.status:
case MediaCacheStatus.ready:
cache_hit = True
case MediaCacheStatus.downloading: # 下载任务已经在进行
cache_hit = True
case _:
# 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_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)
queue_publish_span.set_data("messaging.destination.name",
"video-downloader.cache_submit")
queue_publish_span.set_data("messaging.message.body.size", 0)
video_cache = MediaCache(status=MediaCacheStatus.downloading,
downloader_id=fn_task.object_id)
video_cache_status_json = video_cache.model_dump_json()
media_cache_kv.put(media.cache_key, video_cache_status_json)
caches[media.cache_key] = video_cache
logger.info(f"Media cache hit ? {cache_hit}")
cache_span.set_data("cache.hit", cache_hit)
batch_update_cloudflare_kv(caches)
return CacheResult(caches=caches)
# return JSONResponse(content={"caches": jsonable_encoder(caches)})
@web_app.delete("/cache/kv",
summary="清除KV记录",
description="清除当前环境下KV缓存过的所有数据(S3存储桶内的文件会保留)",
dependencies=[Depends(verify_token)])
async def purge_kv_all():
parent = sentry_sdk.get_current_span()
span = parent.start_child(name="清除缓存KV", op="purge.flush")
media_cache_kv.clear()
span.set_data("cache.success", True)
span.finish()
return JSONResponse(content={"success": True})
@web_app.post("/cache/kv",
summary="删除对应的KV记录",
description="删除请求中对应的视频缓存记录",
dependencies=[Depends(verify_token)])
async def purge_kv(medias: MediaSources):
try:
for media in medias.inputs:
media_cache_kv.pop(media.cache_key)
keys = [media.cache_key for media in medias.inputs]
batch_remove_cloudflare_kv(keys)
return JSONResponse(content={"success": True, "keys": keys})
except Exception as e:
return JSONResponse(content={"success": False, "error": str(e)})
@web_app.post("/cache/media",
summary="清除指定的所有缓存",
description="清除指定的所有缓存(包括KV记录和S3存储文件)",
dependencies=[Depends(verify_token)])
async def purge_media(medias: MediaSources):
caches: Dict[str, MediaCache] = {}
for media in medias.inputs:
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",
environment_name=config.environment)
deleted_cache = await fn.remote.aio(cache_data)
caches[media.cache_key] = deleted_cache
except KeyError:
logger.warning("cache key not found")
caches[media.cache_key] = MediaCache(status=MediaCacheStatus.missing)
continue
keys = [key for key in caches.keys()]
batch_remove_cloudflare_kv(keys)
return JSONResponse(content={"success": True, "keys": keys})
@web_app.post("/merge",
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"})
@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)
@web_app.get("/ffmpeg/slice/{task_id}", summary="查询切割任务状态/结果", description="根据任务Id查询运行状态",
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)
return web_app

5
src/deploy.py Normal file
View File

@@ -0,0 +1,5 @@
import modal.cli.run
from cluster.config import config
if __name__ == '__main__':
modal.cli.run.deploy(app_ref='.\\src\\cluster\\app.py', env=config.environment, use_module_mode=False)

View File

@@ -1,8 +0,0 @@
import modal
vol = modal.Volume.from_name("comfyui-model")
with vol.batch_upload() as batch:
# batch.put_directory("D:\ComfyUI-aki-v1.6\ComfyUI\custom_nodes\ComfyUI-LatentSync-Node\checkpoints", "ComfyUI-LatentSync-Node/checkpoints")
# batch.put_directory("D:\ComfyUI-aki-v1.6\ComfyUI\custom_nodes\ComfyUI-CustomNode\model", "ComfyUI-CustomNode/model")
batch.put_directory(r"D:\ComfyUI-aki-v1.6\ComfyUI\custom_nodes\CosyVoice-ComfyUI\pretrained_models\CosyVoice-300M-SFT","CosyVoice-ComfyUI/pretrained_models/CosyVoice-300M-SFT")

571
uv.lock generated Normal file
View File

@@ -0,0 +1,571 @@
version = 1
revision = 1
requires-python = ">=3.11"
[[package]]
name = "annotated-types"
version = "0.7.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643 },
]
[[package]]
name = "anyio"
version = "4.9.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "idna" },
{ name = "sniffio" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/7d/4c1bd541d4dffa1b52bd83fb8527089e097a106fc90b467a7313b105f840/anyio-4.9.0.tar.gz", hash = "sha256:673c0c244e15788651a4ff38710fea9675823028a6f08a5eda409e0c9840a028", size = 190949 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916 },
]
[[package]]
name = "boto3"
version = "1.37.37"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
{ name = "jmespath" },
{ name = "s3transfer" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/8c/2ca661db6c9e591d9dc46149b43a91385283c852436ccba62e199643e196/boto3-1.37.37.tar.gz", hash = "sha256:752d31105a45e3e01c8c68471db14ae439990b75a35e72b591ca528e2575b28f", size = 111666 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e4/5f/032d93e74949222ffbfbc3270f29a3ee423fe648de8a31c49cce0cbb0a09/boto3-1.37.37-py3-none-any.whl", hash = "sha256:d125cb11e22817f7a2581bade4bf7b75247b401888890239ceb5d3e902ccaf38", size = 139917 },
]
[[package]]
name = "botocore"
version = "1.37.37"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jmespath" },
{ name = "python-dateutil" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/96/d0/70969515e3ae8ff0fcccf22827d5d131bc7b8729331127415cf8f2861d63/botocore-1.37.37.tar.gz", hash = "sha256:3eadde6fed95c4cb469cc39d1c3558528b7fa76d23e7e16d4bddc77250431a64", size = 13828530 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/fe/17/602915b29cb695e1e66f65e33b1026f1534e49975d99ea4e32e58d963542/botocore-1.37.37-py3-none-any.whl", hash = "sha256:eb730ff978f47c02f0c8ed07bccdc0db6d8fa098ed32ac31bee1da0e9be480d1", size = 13495584 },
]
[[package]]
name = "certifi"
version = "2025.1.31"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/1c/ab/c9f1e32b7b1bf505bf26f0ef697775960db7932abeb7b516de930ba2705f/certifi-2025.1.31.tar.gz", hash = "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651", size = 167577 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/38/fc/bce832fd4fd99766c04d1ee0eead6b0ec6486fb100ae5e74c1d91292b982/certifi-2025.1.31-py3-none-any.whl", hash = "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe", size = 166393 },
]
[[package]]
name = "charset-normalizer"
version = "3.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/16/b0/572805e227f01586461c80e0fd25d65a2115599cc9dad142fee4b747c357/charset_normalizer-3.4.1.tar.gz", hash = "sha256:44251f18cd68a75b56585dd00dae26183e102cd5e0f9f1466e6df5da2ed64ea3", size = 123188 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/72/80/41ef5d5a7935d2d3a773e3eaebf0a9350542f2cab4eac59a7a4741fbbbbe/charset_normalizer-3.4.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:8bfa33f4f2672964266e940dd22a195989ba31669bd84629f05fab3ef4e2d125", size = 194995 },
{ url = "https://files.pythonhosted.org/packages/7a/28/0b9fefa7b8b080ec492110af6d88aa3dea91c464b17d53474b6e9ba5d2c5/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:28bf57629c75e810b6ae989f03c0828d64d6b26a5e205535585f96093e405ed1", size = 139471 },
{ url = "https://files.pythonhosted.org/packages/71/64/d24ab1a997efb06402e3fc07317e94da358e2585165930d9d59ad45fcae2/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f08ff5e948271dc7e18a35641d2f11a4cd8dfd5634f55228b691e62b37125eb3", size = 149831 },
{ url = "https://files.pythonhosted.org/packages/37/ed/be39e5258e198655240db5e19e0b11379163ad7070962d6b0c87ed2c4d39/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:234ac59ea147c59ee4da87a0c0f098e9c8d169f4dc2a159ef720f1a61bbe27cd", size = 142335 },
{ url = "https://files.pythonhosted.org/packages/88/83/489e9504711fa05d8dde1574996408026bdbdbd938f23be67deebb5eca92/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fd4ec41f914fa74ad1b8304bbc634b3de73d2a0889bd32076342a573e0779e00", size = 143862 },
{ url = "https://files.pythonhosted.org/packages/c6/c7/32da20821cf387b759ad24627a9aca289d2822de929b8a41b6241767b461/charset_normalizer-3.4.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eea6ee1db730b3483adf394ea72f808b6e18cf3cb6454b4d86e04fa8c4327a12", size = 145673 },
{ url = "https://files.pythonhosted.org/packages/68/85/f4288e96039abdd5aeb5c546fa20a37b50da71b5cf01e75e87f16cd43304/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c96836c97b1238e9c9e3fe90844c947d5afbf4f4c92762679acfe19927d81d77", size = 140211 },
{ url = "https://files.pythonhosted.org/packages/28/a3/a42e70d03cbdabc18997baf4f0227c73591a08041c149e710045c281f97b/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:4d86f7aff21ee58f26dcf5ae81a9addbd914115cdebcbb2217e4f0ed8982e146", size = 148039 },
{ url = "https://files.pythonhosted.org/packages/85/e4/65699e8ab3014ecbe6f5c71d1a55d810fb716bbfd74f6283d5c2aa87febf/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:09b5e6733cbd160dcc09589227187e242a30a49ca5cefa5a7edd3f9d19ed53fd", size = 151939 },
{ url = "https://files.pythonhosted.org/packages/b1/82/8e9fe624cc5374193de6860aba3ea8070f584c8565ee77c168ec13274bd2/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:5777ee0881f9499ed0f71cc82cf873d9a0ca8af166dfa0af8ec4e675b7df48e6", size = 149075 },
{ url = "https://files.pythonhosted.org/packages/3d/7b/82865ba54c765560c8433f65e8acb9217cb839a9e32b42af4aa8e945870f/charset_normalizer-3.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:237bdbe6159cff53b4f24f397d43c6336c6b0b42affbe857970cefbb620911c8", size = 144340 },
{ url = "https://files.pythonhosted.org/packages/b5/b6/9674a4b7d4d99a0d2df9b215da766ee682718f88055751e1e5e753c82db0/charset_normalizer-3.4.1-cp311-cp311-win32.whl", hash = "sha256:8417cb1f36cc0bc7eaba8ccb0e04d55f0ee52df06df3ad55259b9a323555fc8b", size = 95205 },
{ url = "https://files.pythonhosted.org/packages/1e/ab/45b180e175de4402dcf7547e4fb617283bae54ce35c27930a6f35b6bef15/charset_normalizer-3.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:d7f50a1f8c450f3925cb367d011448c39239bb3eb4117c36a6d354794de4ce76", size = 102441 },
{ url = "https://files.pythonhosted.org/packages/0a/9a/dd1e1cdceb841925b7798369a09279bd1cf183cef0f9ddf15a3a6502ee45/charset_normalizer-3.4.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:73d94b58ec7fecbc7366247d3b0b10a21681004153238750bb67bd9012414545", size = 196105 },
{ url = "https://files.pythonhosted.org/packages/d3/8c/90bfabf8c4809ecb648f39794cf2a84ff2e7d2a6cf159fe68d9a26160467/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dad3e487649f498dd991eeb901125411559b22e8d7ab25d3aeb1af367df5efd7", size = 140404 },
{ url = "https://files.pythonhosted.org/packages/ad/8f/e410d57c721945ea3b4f1a04b74f70ce8fa800d393d72899f0a40526401f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c30197aa96e8eed02200a83fba2657b4c3acd0f0aa4bdc9f6c1af8e8962e0757", size = 150423 },
{ url = "https://files.pythonhosted.org/packages/f0/b8/e6825e25deb691ff98cf5c9072ee0605dc2acfca98af70c2d1b1bc75190d/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2369eea1ee4a7610a860d88f268eb39b95cb588acd7235e02fd5a5601773d4fa", size = 143184 },
{ url = "https://files.pythonhosted.org/packages/3e/a2/513f6cbe752421f16d969e32f3583762bfd583848b763913ddab8d9bfd4f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc2722592d8998c870fa4e290c2eec2c1569b87fe58618e67d38b4665dfa680d", size = 145268 },
{ url = "https://files.pythonhosted.org/packages/74/94/8a5277664f27c3c438546f3eb53b33f5b19568eb7424736bdc440a88a31f/charset_normalizer-3.4.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ffc9202a29ab3920fa812879e95a9e78b2465fd10be7fcbd042899695d75e616", size = 147601 },
{ url = "https://files.pythonhosted.org/packages/7c/5f/6d352c51ee763623a98e31194823518e09bfa48be2a7e8383cf691bbb3d0/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:804a4d582ba6e5b747c625bf1255e6b1507465494a40a2130978bda7b932c90b", size = 141098 },
{ url = "https://files.pythonhosted.org/packages/78/d4/f5704cb629ba5ab16d1d3d741396aec6dc3ca2b67757c45b0599bb010478/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:0f55e69f030f7163dffe9fd0752b32f070566451afe180f99dbeeb81f511ad8d", size = 149520 },
{ url = "https://files.pythonhosted.org/packages/c5/96/64120b1d02b81785f222b976c0fb79a35875457fa9bb40827678e54d1bc8/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c4c3e6da02df6fa1410a7680bd3f63d4f710232d3139089536310d027950696a", size = 152852 },
{ url = "https://files.pythonhosted.org/packages/84/c9/98e3732278a99f47d487fd3468bc60b882920cef29d1fa6ca460a1fdf4e6/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:5df196eb874dae23dcfb968c83d4f8fdccb333330fe1fc278ac5ceeb101003a9", size = 150488 },
{ url = "https://files.pythonhosted.org/packages/13/0e/9c8d4cb99c98c1007cc11eda969ebfe837bbbd0acdb4736d228ccaabcd22/charset_normalizer-3.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e358e64305fe12299a08e08978f51fc21fac060dcfcddd95453eabe5b93ed0e1", size = 146192 },
{ url = "https://files.pythonhosted.org/packages/b2/21/2b6b5b860781a0b49427309cb8670785aa543fb2178de875b87b9cc97746/charset_normalizer-3.4.1-cp312-cp312-win32.whl", hash = "sha256:9b23ca7ef998bc739bf6ffc077c2116917eabcc901f88da1b9856b210ef63f35", size = 95550 },
{ url = "https://files.pythonhosted.org/packages/21/5b/1b390b03b1d16c7e382b561c5329f83cc06623916aab983e8ab9239c7d5c/charset_normalizer-3.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:6ff8a4a60c227ad87030d76e99cd1698345d4491638dfa6673027c48b3cd395f", size = 102785 },
{ url = "https://files.pythonhosted.org/packages/38/94/ce8e6f63d18049672c76d07d119304e1e2d7c6098f0841b51c666e9f44a0/charset_normalizer-3.4.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:aabfa34badd18f1da5ec1bc2715cadc8dca465868a4e73a0173466b688f29dda", size = 195698 },
{ url = "https://files.pythonhosted.org/packages/24/2e/dfdd9770664aae179a96561cc6952ff08f9a8cd09a908f259a9dfa063568/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22e14b5d70560b8dd51ec22863f370d1e595ac3d024cb8ad7d308b4cd95f8313", size = 140162 },
{ url = "https://files.pythonhosted.org/packages/24/4e/f646b9093cff8fc86f2d60af2de4dc17c759de9d554f130b140ea4738ca6/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8436c508b408b82d87dc5f62496973a1805cd46727c34440b0d29d8a2f50a6c9", size = 150263 },
{ url = "https://files.pythonhosted.org/packages/5e/67/2937f8d548c3ef6e2f9aab0f6e21001056f692d43282b165e7c56023e6dd/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d074908e1aecee37a7635990b2c6d504cd4766c7bc9fc86d63f9c09af3fa11b", size = 142966 },
{ url = "https://files.pythonhosted.org/packages/52/ed/b7f4f07de100bdb95c1756d3a4d17b90c1a3c53715c1a476f8738058e0fa/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:955f8851919303c92343d2f66165294848d57e9bba6cf6e3625485a70a038d11", size = 144992 },
{ url = "https://files.pythonhosted.org/packages/96/2c/d49710a6dbcd3776265f4c923bb73ebe83933dfbaa841c5da850fe0fd20b/charset_normalizer-3.4.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:44ecbf16649486d4aebafeaa7ec4c9fed8b88101f4dd612dcaf65d5e815f837f", size = 147162 },
{ url = "https://files.pythonhosted.org/packages/b4/41/35ff1f9a6bd380303dea55e44c4933b4cc3c4850988927d4082ada230273/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0924e81d3d5e70f8126529951dac65c1010cdf117bb75eb02dd12339b57749dd", size = 140972 },
{ url = "https://files.pythonhosted.org/packages/fb/43/c6a0b685fe6910d08ba971f62cd9c3e862a85770395ba5d9cad4fede33ab/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2967f74ad52c3b98de4c3b32e1a44e32975e008a9cd2a8cc8966d6a5218c5cb2", size = 149095 },
{ url = "https://files.pythonhosted.org/packages/4c/ff/a9a504662452e2d2878512115638966e75633519ec11f25fca3d2049a94a/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c75cb2a3e389853835e84a2d8fb2b81a10645b503eca9bcb98df6b5a43eb8886", size = 152668 },
{ url = "https://files.pythonhosted.org/packages/6c/71/189996b6d9a4b932564701628af5cee6716733e9165af1d5e1b285c530ed/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:09b26ae6b1abf0d27570633b2b078a2a20419c99d66fb2823173d73f188ce601", size = 150073 },
{ url = "https://files.pythonhosted.org/packages/e4/93/946a86ce20790e11312c87c75ba68d5f6ad2208cfb52b2d6a2c32840d922/charset_normalizer-3.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:fa88b843d6e211393a37219e6a1c1df99d35e8fd90446f1118f4216e307e48cd", size = 145732 },
{ url = "https://files.pythonhosted.org/packages/cd/e5/131d2fb1b0dddafc37be4f3a2fa79aa4c037368be9423061dccadfd90091/charset_normalizer-3.4.1-cp313-cp313-win32.whl", hash = "sha256:eb8178fe3dba6450a3e024e95ac49ed3400e506fd4e9e5c32d30adda88cbd407", size = 95391 },
{ url = "https://files.pythonhosted.org/packages/27/f2/4f9a69cc7712b9b5ad8fdb87039fd89abba997ad5cbe690d1835d40405b0/charset_normalizer-3.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:b1ac5992a838106edb89654e0aebfc24f5848ae2547d22c2c3f66454daa11971", size = 102702 },
{ url = "https://files.pythonhosted.org/packages/0e/f6/65ecc6878a89bb1c23a086ea335ad4bf21a588990c3f535a227b9eea9108/charset_normalizer-3.4.1-py3-none-any.whl", hash = "sha256:d98b1668f06378c6dbefec3b92299716b931cd4e6061f3c875a71ced1780ab85", size = 49767 },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 },
]
[[package]]
name = "cos-python-sdk-v5"
version = "1.9.36"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "crcmod" },
{ name = "pycryptodome" },
{ name = "requests" },
{ name = "six" },
{ name = "xmltodict" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/29/9ff39438a3f749f291ddc41bc34a3932184665efe4f3f96c69f5743b9d97/cos_python_sdk_v5-1.9.36.tar.gz", hash = "sha256:789070f54be11b211a5ba9c0b00c88ec959abd678297dd0e5ffcd6d4d15eb472", size = 96589 }
[[package]]
name = "crcmod"
version = "1.7"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6b/b0/e595ce2a2527e169c3bcd6c33d2473c1918e0b7f6826a043ca1245dd4e5b/crcmod-1.7.tar.gz", hash = "sha256:dc7051a0db5f2bd48665a990d3ec1cc305a466a77358ca4492826f41f283601e", size = 89670 }
[[package]]
name = "h11"
version = "0.14.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f5/38/3af3d3633a34a3316095b39c8e8fb4853a28a536e55d347bd8d8e9a14b03/h11-0.14.0.tar.gz", hash = "sha256:8f19fbbe99e72420ff35c00b27a34cb9937e902a8b810e2c88300c6f0a3b699d", size = 100418 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/04/ff642e65ad6b90db43e668d70ffb6736436c7ce41fcc549f4e9472234127/h11-0.14.0-py3-none-any.whl", hash = "sha256:e3fe4ac4b851c468cc8363d500db52c2ead036020723024a109d37346efaa761", size = 58259 },
]
[[package]]
name = "httpcore"
version = "1.0.8"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "h11" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9f/45/ad3e1b4d448f22c0cff4f5692f5ed0666658578e358b8d58a19846048059/httpcore-1.0.8.tar.gz", hash = "sha256:86e94505ed24ea06514883fd44d2bc02d90e77e7979c8eb71b90f41d364a1bad", size = 85385 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/18/8d/f052b1e336bb2c1fc7ed1aaed898aa570c0b61a09707b108979d9fc6e308/httpcore-1.0.8-py3-none-any.whl", hash = "sha256:5254cf149bcb5f75e9d1b2b9f729ea4a4b883d1ad7379fc632b727cec23674be", size = 78732 },
]
[[package]]
name = "httpx"
version = "0.28.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "certifi" },
{ name = "httpcore" },
{ name = "idna" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 },
]
[[package]]
name = "idna"
version = "3.10"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 },
]
[[package]]
name = "jmespath"
version = "1.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/00/2a/e867e8531cf3e36b41201936b7fa7ba7b5702dbef42922193f05c8976cd6/jmespath-1.0.1.tar.gz", hash = "sha256:90261b206d6defd58fdd5e85f478bf633a2901798906be2ad389150c5c60edbe", size = 25843 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/b4/b9b800c45527aadd64d5b442f9b932b00648617eb5d63d2c7a6587b7cafc/jmespath-1.0.1-py3-none-any.whl", hash = "sha256:02e2e4cc71b5bcab88332eebf907519190dd9e6e82107fa7f83b1003a6252980", size = 20256 },
]
[[package]]
name = "loguru"
version = "0.7.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
{ name = "win32-setctime", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/05/a1dae3dffd1116099471c643b8924f5aa6524411dc6c63fdae648c4f1aca/loguru-0.7.3.tar.gz", hash = "sha256:19480589e77d47b8d85b2c827ad95d49bf31b0dcde16593892eb51dd18706eb6", size = 63559 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/29/0348de65b8cc732daa3e33e67806420b2ae89bdce2b04af740289c5c6c8c/loguru-0.7.3-py3-none-any.whl", hash = "sha256:31a33c10c8e1e10422bfd431aeb5d351c7cf7fa671e3c4df004162264b28220c", size = 61595 },
]
[[package]]
name = "modaldeploy"
version = "0.2.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
{ name = "cos-python-sdk-v5" },
{ name = "crcmod" },
{ name = "httpx" },
{ name = "loguru" },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "python-ffmpeg" },
{ name = "requests" },
{ name = "scalar-fastapi" },
{ name = "sentry-sdk", extra = ["loguru"] },
{ name = "tencentcloud-sdk-python-common" },
{ name = "tencentcloud-sdk-python-vod" },
{ name = "tqdm" },
{ name = "webvtt-py" },
]
[package.metadata]
requires-dist = [
{ name = "boto3", specifier = ">=1.37.37" },
{ name = "cos-python-sdk-v5", specifier = ">=1.9.36" },
{ name = "crcmod", specifier = ">=1.7" },
{ name = "httpx", specifier = ">=0.28.1" },
{ name = "loguru", specifier = ">=0.7.3" },
{ name = "pydantic", specifier = ">=2.11.3" },
{ name = "pydantic-settings", specifier = ">=2.9.1" },
{ name = "python-ffmpeg", specifier = ">=2.0.12" },
{ name = "requests", specifier = ">=2.32.3" },
{ name = "scalar-fastapi", specifier = ">=1.0.3" },
{ name = "sentry-sdk", extras = ["loguru"], specifier = ">=2.26.1" },
{ name = "tencentcloud-sdk-python-common", specifier = ">=3.0.1363" },
{ name = "tencentcloud-sdk-python-vod", specifier = ">=3.0.1363" },
{ name = "tqdm", specifier = ">=4.67.1" },
{ name = "webvtt-py", specifier = ">=0.5.1" },
]
[[package]]
name = "pycryptodome"
version = "3.22.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/44/e6/099310419df5ada522ff34ffc2f1a48a11b37fc6a76f51a6854c182dbd3e/pycryptodome-3.22.0.tar.gz", hash = "sha256:fd7ab568b3ad7b77c908d7c3f7e167ec5a8f035c64ff74f10d47a4edd043d723", size = 4917300 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1f/65/a05831c3e4bcd1bf6c2a034e399f74b3d6f30bb4e37e36b9c310c09dc8c0/pycryptodome-3.22.0-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:009e1c80eea42401a5bd5983c4bab8d516aef22e014a4705622e24e6d9d703c6", size = 2490637 },
{ url = "https://files.pythonhosted.org/packages/5c/76/ff3c2e7a60d17c080c4c6120ebaf60f38717cd387e77f84da4dcf7f64ff0/pycryptodome-3.22.0-cp37-abi3-macosx_10_9_x86_64.whl", hash = "sha256:3b76fa80daeff9519d7e9f6d9e40708f2fce36b9295a847f00624a08293f4f00", size = 1635372 },
{ url = "https://files.pythonhosted.org/packages/cc/7f/cc5d6da0dbc36acd978d80a72b228e33aadaec9c4f91c93221166d8bdc05/pycryptodome-3.22.0-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a31fa5914b255ab62aac9265654292ce0404f6b66540a065f538466474baedbc", size = 2177456 },
{ url = "https://files.pythonhosted.org/packages/92/65/35f5063e68790602d892ad36e35ac723147232a9084d1999630045c34593/pycryptodome-3.22.0-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a0092fd476701eeeb04df5cc509d8b739fa381583cda6a46ff0a60639b7cd70d", size = 2263744 },
{ url = "https://files.pythonhosted.org/packages/cc/67/46acdd35b1081c3dbc72dc466b1b95b80d2f64cad3520f994a9b6c5c7d00/pycryptodome-3.22.0-cp37-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:18d5b0ddc7cf69231736d778bd3ae2b3efb681ae33b64b0c92fb4626bb48bb89", size = 2303356 },
{ url = "https://files.pythonhosted.org/packages/3d/f9/a4f8a83384626098e3f55664519bec113002b9ef751887086ae63a53135a/pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f6cf6aa36fcf463e622d2165a5ad9963b2762bebae2f632d719dfb8544903cf5", size = 2176714 },
{ url = "https://files.pythonhosted.org/packages/88/65/e5f8c3a885f70a6e05c84844cd5542120576f4369158946e8cfc623a464d/pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_i686.whl", hash = "sha256:aec7b40a7ea5af7c40f8837adf20a137d5e11a6eb202cde7e588a48fb2d871a8", size = 2337329 },
{ url = "https://files.pythonhosted.org/packages/b8/2a/25e0be2b509c28375c7f75c7e8d8d060773f2cce4856a1654276e3202339/pycryptodome-3.22.0-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:d21c1eda2f42211f18a25db4eaf8056c94a8563cd39da3683f89fe0d881fb772", size = 2262255 },
{ url = "https://files.pythonhosted.org/packages/41/58/60917bc4bbd91712e53ce04daf237a74a0ad731383a01288130672994328/pycryptodome-3.22.0-cp37-abi3-win32.whl", hash = "sha256:f02baa9f5e35934c6e8dcec91fcde96612bdefef6e442813b8ea34e82c84bbfb", size = 1763403 },
{ url = "https://files.pythonhosted.org/packages/55/f4/244c621afcf7867e23f63cfd7a9630f14cfe946c9be7e566af6c3915bcde/pycryptodome-3.22.0-cp37-abi3-win_amd64.whl", hash = "sha256:d086aed307e96d40c23c42418cbbca22ecc0ab4a8a0e24f87932eeab26c08627", size = 1794568 },
]
[[package]]
name = "pydantic"
version = "2.11.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "annotated-types" },
{ name = "pydantic-core" },
{ name = "typing-extensions" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/10/2e/ca897f093ee6c5f3b0bee123ee4465c50e75431c3d5b6a3b44a47134e891/pydantic-2.11.3.tar.gz", hash = "sha256:7471657138c16adad9322fe3070c0116dd6c3ad8d649300e3cbdfe91f4db4ec3", size = 785513 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b0/1d/407b29780a289868ed696d1616f4aad49d6388e5a77f567dcd2629dcd7b8/pydantic-2.11.3-py3-none-any.whl", hash = "sha256:a082753436a07f9ba1289c6ffa01cd93db3548776088aa917cc43b63f68fa60f", size = 443591 },
]
[[package]]
name = "pydantic-core"
version = "2.33.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/17/19/ed6a078a5287aea7922de6841ef4c06157931622c89c2a47940837b5eecd/pydantic_core-2.33.1.tar.gz", hash = "sha256:bcc9c6fdb0ced789245b02b7d6603e17d1563064ddcfc36f046b61c0c05dd9df", size = 434395 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/7f/c6298830cb780c46b4f46bb24298d01019ffa4d21769f39b908cd14bbd50/pydantic_core-2.33.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6e966fc3caaf9f1d96b349b0341c70c8d6573bf1bac7261f7b0ba88f96c56c24", size = 2044224 },
{ url = "https://files.pythonhosted.org/packages/a8/65/6ab3a536776cad5343f625245bd38165d6663256ad43f3a200e5936afd6c/pydantic_core-2.33.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:bfd0adeee563d59c598ceabddf2c92eec77abcb3f4a391b19aa7366170bd9e30", size = 1858845 },
{ url = "https://files.pythonhosted.org/packages/e9/15/9a22fd26ba5ee8c669d4b8c9c244238e940cd5d818649603ca81d1c69861/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:91815221101ad3c6b507804178a7bb5cb7b2ead9ecd600041669c8d805ebd595", size = 1910029 },
{ url = "https://files.pythonhosted.org/packages/d5/33/8cb1a62818974045086f55f604044bf35b9342900318f9a2a029a1bec460/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9fea9c1869bb4742d174a57b4700c6dadea951df8b06de40c2fedb4f02931c2e", size = 1997784 },
{ url = "https://files.pythonhosted.org/packages/c0/ca/49958e4df7715c71773e1ea5be1c74544923d10319173264e6db122543f9/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d20eb4861329bb2484c021b9d9a977566ab16d84000a57e28061151c62b349a", size = 2141075 },
{ url = "https://files.pythonhosted.org/packages/7b/a6/0b3a167a9773c79ba834b959b4e18c3ae9216b8319bd8422792abc8a41b1/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0fb935c5591573ae3201640579f30128ccc10739b45663f93c06796854405505", size = 2745849 },
{ url = "https://files.pythonhosted.org/packages/0b/60/516484135173aa9e5861d7a0663dce82e4746d2e7f803627d8c25dfa5578/pydantic_core-2.33.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c964fd24e6166420d18fb53996d8c9fd6eac9bf5ae3ec3d03015be4414ce497f", size = 2005794 },
{ url = "https://files.pythonhosted.org/packages/86/70/05b1eb77459ad47de00cf78ee003016da0cedf8b9170260488d7c21e9181/pydantic_core-2.33.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:681d65e9011f7392db5aa002b7423cc442d6a673c635668c227c6c8d0e5a4f77", size = 2123237 },
{ url = "https://files.pythonhosted.org/packages/c7/57/12667a1409c04ae7dc95d3b43158948eb0368e9c790be8b095cb60611459/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e100c52f7355a48413e2999bfb4e139d2977a904495441b374f3d4fb4a170961", size = 2086351 },
{ url = "https://files.pythonhosted.org/packages/57/61/cc6d1d1c1664b58fdd6ecc64c84366c34ec9b606aeb66cafab6f4088974c/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:048831bd363490be79acdd3232f74a0e9951b11b2b4cc058aeb72b22fdc3abe1", size = 2258914 },
{ url = "https://files.pythonhosted.org/packages/d1/0a/edb137176a1f5419b2ddee8bde6a0a548cfa3c74f657f63e56232df8de88/pydantic_core-2.33.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:bdc84017d28459c00db6f918a7272a5190bec3090058334e43a76afb279eac7c", size = 2257385 },
{ url = "https://files.pythonhosted.org/packages/26/3c/48ca982d50e4b0e1d9954919c887bdc1c2b462801bf408613ccc641b3daa/pydantic_core-2.33.1-cp311-cp311-win32.whl", hash = "sha256:32cd11c5914d1179df70406427097c7dcde19fddf1418c787540f4b730289896", size = 1923765 },
{ url = "https://files.pythonhosted.org/packages/33/cd/7ab70b99e5e21559f5de38a0928ea84e6f23fdef2b0d16a6feaf942b003c/pydantic_core-2.33.1-cp311-cp311-win_amd64.whl", hash = "sha256:2ea62419ba8c397e7da28a9170a16219d310d2cf4970dbc65c32faf20d828c83", size = 1950688 },
{ url = "https://files.pythonhosted.org/packages/4b/ae/db1fc237b82e2cacd379f63e3335748ab88b5adde98bf7544a1b1bd10a84/pydantic_core-2.33.1-cp311-cp311-win_arm64.whl", hash = "sha256:fc903512177361e868bc1f5b80ac8c8a6e05fcdd574a5fb5ffeac5a9982b9e89", size = 1908185 },
{ url = "https://files.pythonhosted.org/packages/c8/ce/3cb22b07c29938f97ff5f5bb27521f95e2ebec399b882392deb68d6c440e/pydantic_core-2.33.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:1293d7febb995e9d3ec3ea09caf1a26214eec45b0f29f6074abb004723fc1de8", size = 2026640 },
{ url = "https://files.pythonhosted.org/packages/19/78/f381d643b12378fee782a72126ec5d793081ef03791c28a0fd542a5bee64/pydantic_core-2.33.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:99b56acd433386c8f20be5c4000786d1e7ca0523c8eefc995d14d79c7a081498", size = 1852649 },
{ url = "https://files.pythonhosted.org/packages/9d/2b/98a37b80b15aac9eb2c6cfc6dbd35e5058a352891c5cce3a8472d77665a6/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:35a5ec3fa8c2fe6c53e1b2ccc2454398f95d5393ab398478f53e1afbbeb4d939", size = 1892472 },
{ url = "https://files.pythonhosted.org/packages/4e/d4/3c59514e0f55a161004792b9ff3039da52448f43f5834f905abef9db6e4a/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b172f7b9d2f3abc0efd12e3386f7e48b576ef309544ac3a63e5e9cdd2e24585d", size = 1977509 },
{ url = "https://files.pythonhosted.org/packages/a9/b6/c2c7946ef70576f79a25db59a576bce088bdc5952d1b93c9789b091df716/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9097b9f17f91eea659b9ec58148c0747ec354a42f7389b9d50701610d86f812e", size = 2128702 },
{ url = "https://files.pythonhosted.org/packages/88/fe/65a880f81e3f2a974312b61f82a03d85528f89a010ce21ad92f109d94deb/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cc77ec5b7e2118b152b0d886c7514a4653bcb58c6b1d760134a9fab915f777b3", size = 2679428 },
{ url = "https://files.pythonhosted.org/packages/6f/ff/4459e4146afd0462fb483bb98aa2436d69c484737feaceba1341615fb0ac/pydantic_core-2.33.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d5e3d15245b08fa4a84cefc6c9222e6f37c98111c8679fbd94aa145f9a0ae23d", size = 2008753 },
{ url = "https://files.pythonhosted.org/packages/7c/76/1c42e384e8d78452ededac8b583fe2550c84abfef83a0552e0e7478ccbc3/pydantic_core-2.33.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ef99779001d7ac2e2461d8ab55d3373fe7315caefdbecd8ced75304ae5a6fc6b", size = 2114849 },
{ url = "https://files.pythonhosted.org/packages/00/72/7d0cf05095c15f7ffe0eb78914b166d591c0eed72f294da68378da205101/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:fc6bf8869e193855e8d91d91f6bf59699a5cdfaa47a404e278e776dd7f168b39", size = 2069541 },
{ url = "https://files.pythonhosted.org/packages/b3/69/94a514066bb7d8be499aa764926937409d2389c09be0b5107a970286ef81/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:b1caa0bc2741b043db7823843e1bde8aaa58a55a58fda06083b0569f8b45693a", size = 2239225 },
{ url = "https://files.pythonhosted.org/packages/84/b0/e390071eadb44b41f4f54c3cef64d8bf5f9612c92686c9299eaa09e267e2/pydantic_core-2.33.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:ec259f62538e8bf364903a7d0d0239447059f9434b284f5536e8402b7dd198db", size = 2248373 },
{ url = "https://files.pythonhosted.org/packages/d6/b2/288b3579ffc07e92af66e2f1a11be3b056fe1214aab314748461f21a31c3/pydantic_core-2.33.1-cp312-cp312-win32.whl", hash = "sha256:e14f369c98a7c15772b9da98987f58e2b509a93235582838bd0d1d8c08b68fda", size = 1907034 },
{ url = "https://files.pythonhosted.org/packages/02/28/58442ad1c22b5b6742b992ba9518420235adced665513868f99a1c2638a5/pydantic_core-2.33.1-cp312-cp312-win_amd64.whl", hash = "sha256:1c607801d85e2e123357b3893f82c97a42856192997b95b4d8325deb1cd0c5f4", size = 1956848 },
{ url = "https://files.pythonhosted.org/packages/a1/eb/f54809b51c7e2a1d9f439f158b8dd94359321abcc98767e16fc48ae5a77e/pydantic_core-2.33.1-cp312-cp312-win_arm64.whl", hash = "sha256:8d13f0276806ee722e70a1c93da19748594f19ac4299c7e41237fc791d1861ea", size = 1903986 },
{ url = "https://files.pythonhosted.org/packages/7a/24/eed3466a4308d79155f1cdd5c7432c80ddcc4530ba8623b79d5ced021641/pydantic_core-2.33.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:70af6a21237b53d1fe7b9325b20e65cbf2f0a848cf77bed492b029139701e66a", size = 2033551 },
{ url = "https://files.pythonhosted.org/packages/ab/14/df54b1a0bc9b6ded9b758b73139d2c11b4e8eb43e8ab9c5847c0a2913ada/pydantic_core-2.33.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:282b3fe1bbbe5ae35224a0dbd05aed9ccabccd241e8e6b60370484234b456266", size = 1852785 },
{ url = "https://files.pythonhosted.org/packages/fa/96/e275f15ff3d34bb04b0125d9bc8848bf69f25d784d92a63676112451bfb9/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4b315e596282bbb5822d0c7ee9d255595bd7506d1cb20c2911a4da0b970187d3", size = 1897758 },
{ url = "https://files.pythonhosted.org/packages/b7/d8/96bc536e975b69e3a924b507d2a19aedbf50b24e08c80fb00e35f9baaed8/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1dfae24cf9921875ca0ca6a8ecb4bb2f13c855794ed0d468d6abbec6e6dcd44a", size = 1986109 },
{ url = "https://files.pythonhosted.org/packages/90/72/ab58e43ce7e900b88cb571ed057b2fcd0e95b708a2e0bed475b10130393e/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dd8ecfde08d8bfadaea669e83c63939af76f4cf5538a72597016edfa3fad516", size = 2129159 },
{ url = "https://files.pythonhosted.org/packages/dc/3f/52d85781406886c6870ac995ec0ba7ccc028b530b0798c9080531b409fdb/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2f593494876eae852dc98c43c6f260f45abdbfeec9e4324e31a481d948214764", size = 2680222 },
{ url = "https://files.pythonhosted.org/packages/f4/56/6e2ef42f363a0eec0fd92f74a91e0ac48cd2e49b695aac1509ad81eee86a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:948b73114f47fd7016088e5186d13faf5e1b2fe83f5e320e371f035557fd264d", size = 2006980 },
{ url = "https://files.pythonhosted.org/packages/4c/c0/604536c4379cc78359f9ee0aa319f4aedf6b652ec2854953f5a14fc38c5a/pydantic_core-2.33.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e11f3864eb516af21b01e25fac915a82e9ddad3bb0fb9e95a246067398b435a4", size = 2120840 },
{ url = "https://files.pythonhosted.org/packages/1f/46/9eb764814f508f0edfb291a0f75d10854d78113fa13900ce13729aaec3ae/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:549150be302428b56fdad0c23c2741dcdb5572413776826c965619a25d9c6bde", size = 2072518 },
{ url = "https://files.pythonhosted.org/packages/42/e3/fb6b2a732b82d1666fa6bf53e3627867ea3131c5f39f98ce92141e3e3dc1/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:495bc156026efafd9ef2d82372bd38afce78ddd82bf28ef5276c469e57c0c83e", size = 2248025 },
{ url = "https://files.pythonhosted.org/packages/5c/9d/fbe8fe9d1aa4dac88723f10a921bc7418bd3378a567cb5e21193a3c48b43/pydantic_core-2.33.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:ec79de2a8680b1a67a07490bddf9636d5c2fab609ba8c57597e855fa5fa4dacd", size = 2254991 },
{ url = "https://files.pythonhosted.org/packages/aa/99/07e2237b8a66438d9b26482332cda99a9acccb58d284af7bc7c946a42fd3/pydantic_core-2.33.1-cp313-cp313-win32.whl", hash = "sha256:ee12a7be1742f81b8a65b36c6921022301d466b82d80315d215c4c691724986f", size = 1915262 },
{ url = "https://files.pythonhosted.org/packages/8a/f4/e457a7849beeed1e5defbcf5051c6f7b3c91a0624dd31543a64fc9adcf52/pydantic_core-2.33.1-cp313-cp313-win_amd64.whl", hash = "sha256:ede9b407e39949d2afc46385ce6bd6e11588660c26f80576c11c958e6647bc40", size = 1956626 },
{ url = "https://files.pythonhosted.org/packages/20/d0/e8d567a7cff7b04e017ae164d98011f1e1894269fe8e90ea187a3cbfb562/pydantic_core-2.33.1-cp313-cp313-win_arm64.whl", hash = "sha256:aa687a23d4b7871a00e03ca96a09cad0f28f443690d300500603bd0adba4b523", size = 1909590 },
{ url = "https://files.pythonhosted.org/packages/ef/fd/24ea4302d7a527d672c5be06e17df16aabfb4e9fdc6e0b345c21580f3d2a/pydantic_core-2.33.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:401d7b76e1000d0dd5538e6381d28febdcacb097c8d340dde7d7fc6e13e9f95d", size = 1812963 },
{ url = "https://files.pythonhosted.org/packages/5f/95/4fbc2ecdeb5c1c53f1175a32d870250194eb2fdf6291b795ab08c8646d5d/pydantic_core-2.33.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7aeb055a42d734c0255c9e489ac67e75397d59c6fbe60d155851e9782f276a9c", size = 1986896 },
{ url = "https://files.pythonhosted.org/packages/71/ae/fe31e7f4a62431222d8f65a3bd02e3fa7e6026d154a00818e6d30520ea77/pydantic_core-2.33.1-cp313-cp313t-win_amd64.whl", hash = "sha256:338ea9b73e6e109f15ab439e62cb3b78aa752c7fd9536794112e14bee02c8d18", size = 1931810 },
{ url = "https://files.pythonhosted.org/packages/0b/76/1794e440c1801ed35415238d2c728f26cd12695df9057154ad768b7b991c/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3a371dc00282c4b84246509a5ddc808e61b9864aa1eae9ecc92bb1268b82db4a", size = 2042858 },
{ url = "https://files.pythonhosted.org/packages/73/b4/9cd7b081fb0b1b4f8150507cd59d27b275c3e22ad60b35cb19ea0977d9b9/pydantic_core-2.33.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f59295ecc75a1788af8ba92f2e8c6eeaa5a94c22fc4d151e8d9638814f85c8fc", size = 1873745 },
{ url = "https://files.pythonhosted.org/packages/e1/d7/9ddb7575d4321e40d0363903c2576c8c0c3280ebea137777e5ab58d723e3/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:08530b8ac922003033f399128505f513e30ca770527cc8bbacf75a84fcc2c74b", size = 1904188 },
{ url = "https://files.pythonhosted.org/packages/d1/a8/3194ccfe461bb08da19377ebec8cb4f13c9bd82e13baebc53c5c7c39a029/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bae370459da6a5466978c0eacf90690cb57ec9d533f8e63e564ef3822bfa04fe", size = 2083479 },
{ url = "https://files.pythonhosted.org/packages/42/c7/84cb569555d7179ca0b3f838cef08f66f7089b54432f5b8599aac6e9533e/pydantic_core-2.33.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e3de2777e3b9f4d603112f78006f4ae0acb936e95f06da6cb1a45fbad6bdb4b5", size = 2118415 },
{ url = "https://files.pythonhosted.org/packages/3b/67/72abb8c73e0837716afbb58a59cc9e3ae43d1aa8677f3b4bc72c16142716/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:3a64e81e8cba118e108d7126362ea30e021291b7805d47e4896e52c791be2761", size = 2079623 },
{ url = "https://files.pythonhosted.org/packages/0b/cd/c59707e35a47ba4cbbf153c3f7c56420c58653b5801b055dc52cccc8e2dc/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:52928d8c1b6bda03cc6d811e8923dffc87a2d3c8b3bfd2ce16471c7147a24850", size = 2250175 },
{ url = "https://files.pythonhosted.org/packages/84/32/e4325a6676b0bed32d5b084566ec86ed7fd1e9bcbfc49c578b1755bde920/pydantic_core-2.33.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:1b30d92c9412beb5ac6b10a3eb7ef92ccb14e3f2a8d7732e2d739f58b3aa7544", size = 2254674 },
{ url = "https://files.pythonhosted.org/packages/12/6f/5596dc418f2e292ffc661d21931ab34591952e2843e7168ea5a52591f6ff/pydantic_core-2.33.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:f995719707e0e29f0f41a8aa3bcea6e761a36c9136104d3189eafb83f5cec5e5", size = 2080951 },
]
[[package]]
name = "pydantic-settings"
version = "2.9.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "typing-inspection" },
]
sdist = { url = "https://files.pythonhosted.org/packages/67/1d/42628a2c33e93f8e9acbde0d5d735fa0850f3e6a2f8cb1eb6c40b9a732ac/pydantic_settings-2.9.1.tar.gz", hash = "sha256:c509bf79d27563add44e8446233359004ed85066cd096d8b510f715e6ef5d268", size = 163234 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b6/5f/d6d641b490fd3ec2c4c13b4244d68deea3a1b970a97be64f34fb5504ff72/pydantic_settings-2.9.1-py3-none-any.whl", hash = "sha256:59b4f431b1defb26fe620c71a7d3968a710d719f5f4cdbbdb7926edeb770f6ef", size = 44356 },
]
[[package]]
name = "pyee"
version = "13.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/95/03/1fd98d5841cd7964a27d729ccf2199602fe05eb7a405c1462eb7277945ed/pyee-13.0.0.tar.gz", hash = "sha256:b391e3c5a434d1f5118a25615001dbc8f669cf410ab67d04c4d4e07c55481c37", size = 31250 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9b/4d/b9add7c84060d4c1906abe9a7e5359f2a60f7a9a4f67268b2766673427d8/pyee-13.0.0-py3-none-any.whl", hash = "sha256:48195a3cddb3b1515ce0695ed76036b5ccc2ef3a9f963ff9f77aec0139845498", size = 15730 },
]
[[package]]
name = "python-dateutil"
version = "2.9.0.post0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "six" },
]
sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 },
]
[[package]]
name = "python-dotenv"
version = "1.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/88/2c/7bb1416c5620485aa793f2de31d3df393d3686aa8a8506d11e10e13c5baf/python_dotenv-1.1.0.tar.gz", hash = "sha256:41f90bc6f5f177fb41f53e87666db362025010eb28f60a01c9143bfa33a2b2d5", size = 39920 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1e/18/98a99ad95133c6a6e2005fe89faedf294a748bd5dc803008059409ac9b1e/python_dotenv-1.1.0-py3-none-any.whl", hash = "sha256:d7c01d9e2293916c18baf562d95698754b0dbbb5e74d457c45d4f6561fb9d55d", size = 20256 },
]
[[package]]
name = "python-ffmpeg"
version = "2.0.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyee" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dd/4d/7ecffb341d646e016be76e36f5a42cb32f409c9ca21a57b68f067fad3fc7/python_ffmpeg-2.0.12.tar.gz", hash = "sha256:19ac80af5a064a2f53c245af1a909b2d7648ea045500d96d3bcd507b88d43dc7", size = 14126292 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7f/6d/02e817aec661defe148cb9eb0c4eca2444846305f625c2243fb9f92a9045/python_ffmpeg-2.0.12-py3-none-any.whl", hash = "sha256:d86697da8dfb39335183e336d31baf42fb217468adf5ac97fd743898240faae3", size = 14411 },
]
[[package]]
name = "requests"
version = "2.32.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "charset-normalizer" },
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928 },
]
[[package]]
name = "s3transfer"
version = "0.11.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "botocore" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c4/2b/5c9562795c2eb2b5f63536961754760c25bf0f34af93d36aa28dea2fb303/s3transfer-0.11.5.tar.gz", hash = "sha256:8c8aad92784779ab8688a61aefff3e28e9ebdce43142808eaa3f0b0f402f68b7", size = 149107 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/45/39/13402e323666d17850eca87e4cd6ecfcf9fd7809cac9efdcce10272fc29d/s3transfer-0.11.5-py3-none-any.whl", hash = "sha256:757af0f2ac150d3c75bc4177a32355c3862a98d20447b69a0161812992fe0bd4", size = 84782 },
]
[[package]]
name = "scalar-fastapi"
version = "1.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/2c/04/08ae9e86f24cd9b43bc5b2068fc5d1dcd3e7efa6db860290734d7d742512/scalar_fastapi-1.0.3.tar.gz", hash = "sha256:9e9cb8398e298cd435a0171eebe1675b8899eb21e47c238db0d48783143f0ffb", size = 4107 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6d/9e/ca0ffc3fc4788c6e1367b96a70f2d6ee33f91d580664a8142ba7ee292ef2/scalar_fastapi-1.0.3-py3-none-any.whl", hash = "sha256:4a47a140795097ad034518ce0e32940f2c54f0f4bc60e4c3289ca30a7e6f954d", size = 4579 },
]
[[package]]
name = "sentry-sdk"
version = "2.26.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/85/26/099631caa51abffb1fd9e08c2138bc6681d3f288a5936c2fc4e054729611/sentry_sdk-2.26.1.tar.gz", hash = "sha256:759e019c41551a21519a95e6cef6d91fb4af1054761923dadaee2e6eca9c02c7", size = 323099 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/23/32/0a30b4fafdb3d26d133f99bb566aaa6000004ee7f2c4b72aafea9237ab7e/sentry_sdk-2.26.1-py2.py3-none-any.whl", hash = "sha256:e99390e3f217d13ddcbaeaed08789f1ca614d663b345b9da42e35ad6b60d696a", size = 340558 },
]
[package.optional-dependencies]
loguru = [
{ name = "loguru" },
]
[[package]]
name = "six"
version = "1.17.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 },
]
[[package]]
name = "sniffio"
version = "1.3.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 },
]
[[package]]
name = "tencentcloud-sdk-python-common"
version = "3.0.1363"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "requests" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c2/27/05ad068ea5422b0ab11a08a2c2c7ed3e35c36ef1130aa01dfd53beca7ad8/tencentcloud-sdk-python-common-3.0.1363.tar.gz", hash = "sha256:c93df1475ea52ae16fb66c8b8eb5111145587e37dd81117def345b8545f7bc5f", size = 16906 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8c/67/f146b46fcc5e5fb5e609bd1053cbcdc60cf4327ab15c405f8ef15ba5afc3/tencentcloud_sdk_python_common-3.0.1363-py2.py3-none-any.whl", hash = "sha256:36e9c409b6fb0d8ca1323f3db0e97a8b97d1676eebf5f49e3b81cadec1732d46", size = 24710 },
]
[[package]]
name = "tencentcloud-sdk-python-vod"
version = "3.0.1363"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tencentcloud-sdk-python-common" },
]
sdist = { url = "https://files.pythonhosted.org/packages/50/60/4f80b7746da039d8d0bcaf52112c3ed22cef59b5fa20e7e5ca2d62f47d26/tencentcloud-sdk-python-vod-3.0.1363.tar.gz", hash = "sha256:f21af928e6bbc19b340a1f17ebe1f4dc0b80909bfe9735cf7fd58b6f13615f56", size = 291862 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/98/56239e5c4154de2ebaa1b44e26e48395506fe2cfad90e19659ef4bab1721/tencentcloud_sdk_python_vod-3.0.1363-py2.py3-none-any.whl", hash = "sha256:ab8a8c62ae1c332fc1b427c1354980d3c8afcfd63186a6d1afe3216b51dcfe1f", size = 298441 },
]
[[package]]
name = "tqdm"
version = "4.67.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a8/4b/29b4ef32e036bb34e4ab51796dd745cdba7ed47ad142a9f4a1eb8e0c744d/tqdm-4.67.1.tar.gz", hash = "sha256:f8aef9c52c08c13a65f30ea34f4e5aac3fd1a34959879d7e59e63027286627f2", size = 169737 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/30/dc54f88dd4a2b5dc8a0279bdd7270e735851848b762aeb1c1184ed1f6b14/tqdm-4.67.1-py3-none-any.whl", hash = "sha256:26445eca388f82e72884e0d580d5464cd801a3ea01e63e5601bdff9ba6a48de2", size = 78540 },
]
[[package]]
name = "typing-extensions"
version = "4.13.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f6/37/23083fcd6e35492953e8d2aaaa68b860eb422b34627b13f2ce3eb6106061/typing_extensions-4.13.2.tar.gz", hash = "sha256:e6c81219bd689f51865d9e372991c540bda33a0379d5573cddb9a3a23f7caaef", size = 106967 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/54/b1ae86c0973cc6f0210b53d508ca3641fb6d0c56823f288d108bc7ab3cc8/typing_extensions-4.13.2-py3-none-any.whl", hash = "sha256:a439e7c04b49fec3e5d3e2beaa21755cadbbdc391694e28ccdd36ca4a1408f8c", size = 45806 },
]
[[package]]
name = "typing-inspection"
version = "0.4.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/82/5c/e6082df02e215b846b4b8c0b887a64d7d08ffaba30605502639d44c06b82/typing_inspection-0.4.0.tar.gz", hash = "sha256:9765c87de36671694a67904bf2c96e395be9c6439bb6c87b5142569dcdd65122", size = 76222 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/31/08/aa4fdfb71f7de5176385bd9e90852eaf6b5d622735020ad600f2bab54385/typing_inspection-0.4.0-py3-none-any.whl", hash = "sha256:50e72559fcd2a6367a19f7a7e610e6afcb9fac940c650290eed893d61386832f", size = 14125 },
]
[[package]]
name = "urllib3"
version = "2.4.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/8a/78/16493d9c386d8e60e442a35feac5e00f0913c0f4b7c217c11e8ec2ff53e0/urllib3-2.4.0.tar.gz", hash = "sha256:414bc6535b787febd7567804cc015fee39daab8ad86268f1310a9250697de466", size = 390672 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6b/11/cc635220681e93a0183390e26485430ca2c7b5f9d33b15c74c2861cb8091/urllib3-2.4.0-py3-none-any.whl", hash = "sha256:4e16665048960a0900c702d4a66415956a584919c03361cac9f1df5c5dd7e813", size = 128680 },
]
[[package]]
name = "webvtt-py"
version = "0.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/5e/f6/7c9c964681fb148e0293e6860108d378e09ccab2218f9063fd3eb87f840a/webvtt-py-0.5.1.tar.gz", hash = "sha256:2040dd325277ddadc1e0c6cc66cbc4a1d9b6b49b24c57a0c3364374c3e8a3dc1", size = 55128 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/ed/aad7e0f5a462d679f7b4d2e0d8502c3096740c883b5bbed5103146480937/webvtt_py-0.5.1-py3-none-any.whl", hash = "sha256:9d517d286cfe7fc7825e9d4e2079647ce32f5678eb58e39ef544ffbb932610b7", size = 19802 },
]
[[package]]
name = "win32-setctime"
version = "1.2.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b3/8f/705086c9d734d3b663af0e9bb3d4de6578d08f46b1b101c2442fd9aecaa2/win32_setctime-1.2.0.tar.gz", hash = "sha256:ae1fdf948f5640aae05c511ade119313fb6a30d7eabe25fef9764dca5873c4c0", size = 4867 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/e1/07/c6fe3ad3e685340704d314d765b7912993bcb8dc198f0e7a89382d37974b/win32_setctime-1.2.0-py3-none-any.whl", hash = "sha256:95d644c4e708aba81dc3704a116d8cbc974d70b3bdb8be1d150e36be6e9d1390", size = 4083 },
]
[[package]]
name = "xmltodict"
version = "0.14.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/50/05/51dcca9a9bf5e1bce52582683ce50980bcadbc4fa5143b9f2b19ab99958f/xmltodict-0.14.2.tar.gz", hash = "sha256:201e7c28bb210e374999d1dde6382923ab0ed1a8a5faeece48ab525b7810a553", size = 51942 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d6/45/fc303eb433e8a2a271739c98e953728422fa61a3c1f36077a49e395c972e/xmltodict-0.14.2-py2.py3-none-any.whl", hash = "sha256:20cc7d723ed729276e808f26fb6b3599f786cbc37e06c65e192ba77c40f20aac", size = 9981 },
]