* Merge branch 'main' into feature/modal-cluster-gemini * ADD 增加大模型输出错误时间值的处理 * Merge branch 'main' into feature/modal-cluster-gemini * FIX 修复推理问题 * Merge branch 'main' into feature/modal-cluster-gemini * FIX 修复拼接网格图的异步失效问题 * FIX 修复拼接网格图的异步失效问题 * Merge branch 'main' into feature/modal-cluster-gemini * Merge branch 'main' into feature/modal-cluster-gemini * FIX 调高合成网格图容器配置 * Merge branch 'main' into feature/modal-cluster-gemini * FIX 修复带图片模态推理接口 * PERF 优化Gemini Prompt词 * Merge remote-tracking branch 'origin/main' into feature/modal-cluster-gemini * ADD 增加网格图片创建接口 ADD gemini推理打点增加商品封面模态 PERF gemini打点结果相邻时间线同商品合并 * Merge branch 'main' into feature/modal-cluster-gemini * FIX 429重试机制 * FIX 修复Gemini推理函数入参 * Merge branch 'main' into feature/modal-cluster-gemini * PERF gemini修改参数类型 ADD 增加批量删除Gemini文件接口 * PERF gemini修改prompt * Merge branch 'feature/modal-cluster' into feature/modal-cluster-gemini * PERF gemini修改prompt * 录制HLS时 结果复制到S3挂载点增加一个fallback处理 * Merge branch 'feature/modal-cluster' into feature/modal-cluster-gemini * PERF gemini修改prompt * PERF gemini增加图像模态 * PERF gemini增加图像模态 * fix : TikHub的Logger换为Loguru * fix : 修复直播切分视频接口 * Merge branch 'feature/modal-cluster' into feature/modal-cluster-gemini * PERF tikhub接口增加随机延迟 * Merge remote-tracking branch 'origin/feature/modal-cluster' into feature/modal-cluster * 合并分支 * fix : hls录制缓存读写问题 * FIX 修改冲突 * ADD 增加获取直播间指定商品数量接口 * Merge branch 'feature/modal-cluster' into feature/modal-cluster-gemini * PERF 修改tikhub到aws * fix : 增加s3分片上传接口 * PERF Gemini推理直接从参数列表获取商品列表 ADD Tikhub增加获取置顶|讲解中商品接口 * PERF Gemini推理直接从参数获取商品列表 * Merge branch 'feature/modal-cluster' into feature/modal-cluster-gemini * fix 处理jpg图片的codec_name应为mjpg * Merge branch 'feature/modal-cluster' into feature/modal-cluster-gemini * PERF 优化返回值 * fix 获取媒体metadata的类型报错,modal client升级到1.0.2 * Merge branch 'feature/modal-cluster' into feature/modal-cluster-gemini * PERF 风控后爬虫直接退出避免cookie失效 PERF 分离tikhub api, 使用更低的保持时间以更换IP FIX 修复Dict键不存在报错的bug * fix 一些webhook相关的bug,添加了更详细的接口说明 * 更新直播录制为可跳转时间的hls流接口,用于替代掉腾讯VOD+火山云拉流转推,片段保证每片开头为关键帧,时间精度准确到毫秒级 * ADD 增加直播间商品增量缓存触发器 * Merge branch 'feature/modal-cluster' into feature/modal-cluster-gemini * PERF 调整gemini prompt * - KVCache类改为可拓展,基于环境变量设置KV space - test 环境配置与CF测试环境对齐 * FIX 修复Gemini推理视频切条的问题 PERF 优化视频切条函数性能 * ADD 基本完成gemini视频推理API * ADD 添加内联的tikhub * 对齐预发环境 --------- Merge request URL: https://g-ldyi2063.coding.net/p/dev/d/modalDeploy/git/merge/4802 Co-authored-by: 康宇佳,shuohigh@gmail.com
218 lines
13 KiB
Python
218 lines
13 KiB
Python
import modal
|
||
|
||
from ..video import downloader_image, app, config, batch_update_cloudflare_kv, modal_kv_cache
|
||
|
||
with downloader_image.imports():
|
||
import os, httpx, crcmod
|
||
import sentry_sdk
|
||
from tqdm import tqdm
|
||
from typing import Tuple
|
||
from loguru import logger
|
||
from datetime import datetime, UTC, timedelta
|
||
from modal import current_function_call_id
|
||
|
||
from tencentcloud.common.credential import Credential
|
||
from tencentcloud.vod.v20180717.vod_client import VodClient
|
||
from tencentcloud.vod.v20180717 import models as vod_request_models
|
||
|
||
from BowongModalFunctions.models.media_model import MediaSource, MediaCacheStatus, MediaProtocol
|
||
from BowongModalFunctions.models.web_model import SentryTransactionInfo
|
||
|
||
|
||
@app.function(cpu=1, timeout=1800,
|
||
cloud="aws",
|
||
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.modal_environment),
|
||
),
|
||
},
|
||
secrets=[modal.Secret.from_name("tencent-cloud-secret", environment_name=config.modal_environment)])
|
||
@modal.concurrent(max_inputs=10)
|
||
async def cache_submit(media: MediaSource, sentry_trace: SentryTransactionInfo) -> MediaSource:
|
||
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)
|
||
# 兼容fileId带文件类型的格式和不带文件类型的格式
|
||
request.FileIds = [media.path.split('.')[0] if '.' in media.path else media.path]
|
||
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"{config.S3_mount_dir}/{media.protocol.value}/{media.endpoint}/{media.bucket}"
|
||
cache_file = media.path if '.' in media.path else f"{media.path}.{file_extension}"
|
||
return cache_dir, cache_file, media_info.MediaUrl
|
||
else:
|
||
raise FileNotFoundError(
|
||
f"FileId : {media.path} 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, protocol: MediaProtocol = MediaProtocol.vod,
|
||
on_progress_callback: callable(float) = None) -> None:
|
||
# 配置日志
|
||
logger.info(f"Starting download from {url}")
|
||
try:
|
||
# 使用 httpx 发送 HEAD 请求获取文件大小
|
||
# 设置请求头,支持断点续传
|
||
headers = {'Range': 'bytes=0-'}
|
||
with httpx.Client() as client:
|
||
match protocol:
|
||
case MediaProtocol.vod:
|
||
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")
|
||
|
||
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
|
||
os.makedirs(os.path.dirname(output_path), exist_ok=True)
|
||
|
||
logger.info(f"Downloading {url}...")
|
||
# 发起流式请求
|
||
with client.stream('GET', url, headers=headers) as response:
|
||
response.raise_for_status()
|
||
file_size = int(response.headers.get('content-length', 0))
|
||
# 设置进度条
|
||
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_kv_cache
|
||
fn_id = current_function_call_id()
|
||
|
||
with sentry_sdk.continue_trace(environ_or_headers={"sentry-trace": sentry_trace.x_trace_id,
|
||
"baggage": sentry_trace.x_baggage, }) as transaction:
|
||
transaction.set_context("runtime_environment", {
|
||
"MODAL_CLOUD_PROVIDER": os.environ.get('MODAL_CLOUD_PROVIDER', 'unknown'),
|
||
"MODAL_ENVIRONMENT": config.modal_environment,
|
||
"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.urn)
|
||
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.urn)
|
||
volume_cache_path = None
|
||
match media.protocol:
|
||
case MediaProtocol.vod:
|
||
try:
|
||
volume_cache_path = vod_download(media)
|
||
process_span.set_status("success")
|
||
except Exception as e:
|
||
logger.exception(e)
|
||
media.status = MediaCacheStatus.failed
|
||
modal_kv.set_cache(media)
|
||
batch_update_cloudflare_kv([media])
|
||
process_span.set_status("failed")
|
||
raise e
|
||
case MediaProtocol.http:
|
||
try:
|
||
cache_filepath = f"{config.S3_mount_dir}/{media.protocol.value}/{media.cache_filepath}"
|
||
os.makedirs(os.path.dirname(cache_filepath), exist_ok=True)
|
||
download_large_file(url=media.__str__(), output_path=cache_filepath)
|
||
except Exception as e:
|
||
logger.exception(e)
|
||
media.status = MediaCacheStatus.failed
|
||
modal_kv.set_cache(media)
|
||
batch_update_cloudflare_kv([media])
|
||
process_span.set_status("failed")
|
||
raise e
|
||
case MediaProtocol.s3:
|
||
# 本地挂载缓存
|
||
if media.protocol == MediaProtocol.s3 and media.endpoint == config.S3_region and media.bucket == config.S3_bucket_name:
|
||
volume_cache_path = f"{config.S3_mount_dir}/{media.cache_filepath}"
|
||
else:
|
||
logger.error("protocol not yet supported")
|
||
case _:
|
||
process_span.set_status("failed")
|
||
raise NotImplementedError("protocol not yet supported")
|
||
media.downloader_id = fn_id
|
||
media.status = MediaCacheStatus.ready if volume_cache_path else MediaCacheStatus.failed
|
||
media.progress = 1 if volume_cache_path else 0
|
||
media.expired_at = datetime.now(UTC) + timedelta(days=7) if volume_cache_path else None
|
||
modal_kv.set_cache(media)
|
||
batch_update_cloudflare_kv([media])
|
||
return media
|