diff --git a/.runtime.env b/.runtime.env
index a31361e..0236cf0 100644
--- a/.runtime.env
+++ b/.runtime.env
@@ -1,5 +1,5 @@
-MODAL_ENVIRONMENT=test
-modal_app_name=bowong-ai-video
+MODAL_ENVIRONMENT=dev
+modal_app_name=bowong-ai-video-gemini
S3_mount_dir=/mntS3
S3_bucket_name=modal-media-cache
S3_region=ap-northeast-2
diff --git a/src/BowongModalFunctions/router/google.py b/src/BowongModalFunctions/router/google.py
index 7699cb7..70932cd 100644
--- a/src/BowongModalFunctions/router/google.py
+++ b/src/BowongModalFunctions/router/google.py
@@ -1,26 +1,26 @@
import json
import os
-from typing import Annotated, Optional, cast, List
+from typing import Annotated, Optional, cast
+import httpx
import modal
import sentry_sdk
+from fastapi import APIRouter, UploadFile, Header, HTTPException, Depends, File, Form
from fastapi.responses import Response
from loguru import logger
-import httpx
-from fastapi import APIRouter, UploadFile, Header, HTTPException, Depends
from pydantic import BaseModel, Field
from pydantic import computed_field
from starlette import status
from starlette.responses import JSONResponse
-from ..models.settings.cluster import WorkerConfig
from ..middleware.authorization import verify_token
-from ..models.responses.models import ModalTaskResponse, GeminiResultResponse, GoogleUploadResponse
+from ..models.ffmpeg_tasks.models import SentryTransactionInfo
from ..models.requests.models import GeminiRequest, MakeGridGeminiRequest, GeminiRequestFirstOnly, \
GeminiRequestSecondOnly
-from ..models.ffmpeg_tasks.models import SentryTransactionInfo
-from ..utils.ModalUtils import ModalUtils
+from ..models.responses.models import ModalTaskResponse, GeminiResultResponse, GoogleUploadResponse
+from ..models.settings.cluster import WorkerConfig
from ..utils.HTTPUtils import GoogleAuthUtils
+from ..utils.ModalUtils import ModalUtils
config = WorkerConfig()
@@ -176,23 +176,7 @@ async def get_access_token() -> GoogleAuthUtils.GoogleAuthResponse:
return access_token
-@router.post("/make_grid_gemini", summary="将输入图拼为网格上传到Gemini网盘")
-async def make_grid_gemini_upload(data: MakeGridGeminiRequest, headers: Annotated[BundleHeaders, Header()]):
- google_api_key = headers.x_google_api_key or os.environ.get("GOOGLE_API_KEY")
- if not google_api_key:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Google API Key")
- fn = modal.Function.from_name(config.modal_app_name, "make_image_grid_upload",
- environment_name=config.modal_environment)
- image_grid_uri = await fn.remote.aio(data.pic_info_list, data.image_size, data.text_height, data.font_size,
- data.padding, data.separator, google_api_key,
- SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
- x_baggage=sentry_sdk.get_baggage())
- if headers.x_trace_id is None else SentryTransactionInfo(
- x_trace_id=headers.x_trace_id, x_baggage=headers.x_baggage))
- return JSONResponse(content={"uri": image_grid_uri}, status_code=200)
-
-
-@router.post('/inference_gemini', summary="使用Gemini推理hls视频流指定时间段的打点情况")
+@router.post('/video/inference_gemini', summary="使用Gemini推理hls视频流指定时间段的打点情况")
async def inference_gemini(data: GeminiRequest, headers: Annotated[BundleHeaders, Header()], ) -> ModalTaskResponse:
google_api_key = headers.x_google_api_key or os.environ.get("GOOGLE_API_KEY")
if not google_api_key:
@@ -209,7 +193,7 @@ async def inference_gemini(data: GeminiRequest, headers: Annotated[BundleHeaders
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
-@router.post('/inference_gemini_first_only', summary="使用Gemini推理hls视频流指定时间段的打点情况-仅第一阶段")
+@router.post('/video/inference_gemini_first_only', summary="使用Gemini推理hls视频流指定时间段的打点情况-仅第一阶段")
async def inference_gemini_first_only(data: GeminiRequestFirstOnly,
headers: Annotated[BundleHeaders, Header()], ) -> ModalTaskResponse:
google_api_key = headers.x_google_api_key or os.environ.get("GOOGLE_API_KEY")
@@ -221,7 +205,7 @@ async def inference_gemini_first_only(data: GeminiRequestFirstOnly,
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
-@router.post('/inference_gemini_second_only', summary="使用Gemini推理hls视频流指定时间段的打点情况-仅第二阶段")
+@router.post('/video/inference_gemini_second_only', summary="使用Gemini推理hls视频流指定时间段的打点情况-仅第二阶段")
async def inference_gemini_second_only(data: GeminiRequestSecondOnly,
headers: Annotated[BundleHeaders, Header()], ) -> ModalTaskResponse:
google_api_key = headers.x_google_api_key or os.environ.get("GOOGLE_API_KEY")
@@ -234,7 +218,54 @@ async def inference_gemini_second_only(data: GeminiRequestSecondOnly,
return ModalTaskResponse(success=True, taskId=fn_call.object_id)
-@router.get("/inference_gemini/{task_id}", summary="查询Gemini推理hls视频流指定时间段的打点任务")
+@router.post("/image/make_grid_gemini", summary="将输入图拼为网格上传到Gemini网盘")
+async def make_grid_gemini_upload(data: MakeGridGeminiRequest, headers: Annotated[BundleHeaders, Header()]):
+ google_api_key = headers.x_google_api_key or os.environ.get("GOOGLE_API_KEY")
+ if not google_api_key:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Google API Key")
+ fn = modal.Function.from_name(config.modal_app_name, "make_image_grid_upload",
+ environment_name=config.modal_environment)
+ image_grid_uri = await fn.remote.aio(data.pic_info_list, data.image_size, data.text_height, data.font_size,
+ data.padding, data.separator, google_api_key,
+ SentryTransactionInfo(x_trace_id=sentry_sdk.get_traceparent(),
+ x_baggage=sentry_sdk.get_baggage())
+ if headers.x_trace_id is None else SentryTransactionInfo(
+ x_trace_id=headers.x_trace_id, x_baggage=headers.x_baggage))
+ return JSONResponse(content={"uri": image_grid_uri}, status_code=200)
+
+
+@router.post("/image/clothes_mark", summary="使用Gemini将图像中模特身上某一件衣服涂成指定颜色")
+async def clothes_mark(
+ headers: Annotated[BundleHeaders, Header()],
+ origin_image: Annotated[UploadFile, File(description="待处理图片")],
+ mark_clothes_type: Annotated[str, Form(description="替换衣服类型")],
+ mark_color: Annotated[str, Form(description="替换颜色")] = "绿色"
+) -> ModalTaskResponse:
+ google_api_key = headers.x_google_api_key or os.environ.get("GOOGLE_API_KEY")
+ if not google_api_key:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Google API Key")
+ fn = modal.Function.from_name(config.modal_app_name, "image_clothes_mark",
+ environment_name=config.modal_environment)
+ fn_call = fn.spawn(google_api_key, origin_image, mark_clothes_type, mark_color)
+ return ModalTaskResponse(success=True, taskId=fn_call.object_id)
+
+
+@router.post("/image/edit_custom", summary="使用Gemini进行图像编辑")
+async def clothes_mark(
+ headers: Annotated[BundleHeaders, Header()],
+ origin_image: Annotated[UploadFile, File(description="待处理图片")],
+ prompt: Annotated[str, Form(description="图像编辑提示词")]
+) -> ModalTaskResponse:
+ google_api_key = headers.x_google_api_key or os.environ.get("GOOGLE_API_KEY")
+ if not google_api_key:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing Google API Key")
+ fn = modal.Function.from_name(config.modal_app_name, "image_edit_custom",
+ environment_name=config.modal_environment)
+ fn_call = fn.spawn(google_api_key, origin_image, prompt)
+ return ModalTaskResponse(success=True, taskId=fn_call.object_id)
+
+
+@router.get("/{task_id}", summary="查询Gemini推理结果")
async def gemini_status(task_id: str, response: Response) -> GeminiResultResponse:
task_info = await ModalUtils.get_modal_task_status(task_id)
if task_info.transaction:
diff --git a/src/BowongModalFunctions/utils/HTTPUtils.py b/src/BowongModalFunctions/utils/HTTPUtils.py
index 3c150c4..421f012 100644
--- a/src/BowongModalFunctions/utils/HTTPUtils.py
+++ b/src/BowongModalFunctions/utils/HTTPUtils.py
@@ -155,14 +155,14 @@ class GoogleAuthUtils:
regions: List[str]
access_token: str
- @computed_field(description="通过Cloudflare gateway调用的url")
+ @computed_field(description="通过谷歌官方API调用的url")
@property
def gateway_url(self) -> str:
- return f"https://gateway.ai.cloudflare.com/v1/{self.cloudflare_project_id}/{self.cloudflare_gateway_id}/google-vertex-ai/v1/projects/{self.google_project_id}/locations/{random.choice(self.regions)}/publishers/google/models"
+ return f"https://aiplatform.googleapis.com/v1beta1/projects/{self.google_project_id}/locations/{random.choice(self.regions)}/publishers/google/models"
def generate_content(self, model_id: str, contents: Union[types.ContentListUnion, types.ContentListUnionDict],
config: Optional[types.GenerateContentConfig] = None,
- timeout: int = 30) -> tuple[dict[Any, Any], int] | tuple[GenerateContentResponse, int]:
+ timeout: int = 30) -> tuple[str, int] | tuple[GenerateContentResponse, int]:
parameter_model = GoogleAuthUtils.VertexAIRequestModel(contents=contents, generationConfig=config)
json_body = parameter_model.model_dump_json(indent=2, exclude_none=True, by_alias=True,
exclude={"generation_config": ["safety_settings", "system_instruction"]})
@@ -177,7 +177,7 @@ class GoogleAuthUtils:
# response.raise_for_status()
# logger.info(response.text)
if response.status_code != 200:
- return {}, response.status_code
+ return response.text, response.status_code
return types.GenerateContentResponse.model_validate_json(response.text), response.status_code
@staticmethod
diff --git a/src/cluster/image_apps/__init__.py b/src/cluster/image_apps/__init__.py
new file mode 100644
index 0000000..807d170
--- /dev/null
+++ b/src/cluster/image_apps/__init__.py
@@ -0,0 +1,17 @@
+import os
+import importlib
+import pkgutil
+
+# 获取当前包所在的目录
+package_dir = os.path.dirname(__file__)
+
+# 自动导入所有模块
+for (_, module_name, _) in pkgutil.iter_modules([package_dir]):
+ # 导入模块
+ module = importlib.import_module(f'.{module_name}', package=__name__)
+ # 将模块添加到当前包的命名空间
+ globals()[module_name] = module
+ # 可选:将模块名添加到 __all__
+ if '__all__' not in globals():
+ globals()['__all__'] = []
+ globals()['__all__'].append(module_name)
\ No newline at end of file
diff --git a/src/cluster/image_apps/image_edit.py b/src/cluster/image_apps/image_edit.py
new file mode 100644
index 0000000..8674c3a
--- /dev/null
+++ b/src/cluster/image_apps/image_edit.py
@@ -0,0 +1,241 @@
+import base64
+
+import modal
+
+from ..video import app, config, downloader_image
+
+with (downloader_image.imports()):
+ import json
+ from google.genai import types
+ from loguru import logger
+ from fastapi import UploadFile, HTTPException
+ import tempfile
+ import uuid
+ from BowongModalFunctions.utils.HTTPUtils import GoogleAuthUtils
+
+ gemini_region = [
+ "global"
+ ]
+
+ SUPPORTED_IMAGE_TYPES = {
+ "image/jpeg": ".jpg",
+ "image/png": ".png",
+ "image/webp": ".webp"
+ }
+
+
+ async def upload(file_path: str, content_type: str,
+ google_api_key: str) -> str:
+ """
+ 上传视频到gcs
+ """
+ with open(file_path, "rb") as image:
+ image = image.read()
+ content_length = len(image)
+ filename = ".".join([str(uuid.uuid4()), SUPPORTED_IMAGE_TYPES[content_type][1:]])
+ logger.info(
+ f"Uploading name = {filename}, size = {content_length}, type = {content_type} to google file")
+ upload_response = await GoogleAuthUtils.google_upload_file(file_stream=image,
+ content_type=content_type,
+ google_api_key=google_api_key,
+ bucket_name="dy-media-storage",
+ filename=f"images/{filename}", )
+
+ return upload_response.urn
+
+
+ @app.function(cpu=(0.5, 64), timeout=1800,
+ max_containers=config.video_downloader_concurrency,
+ volumes={
+ config.S3_mount_dir: modal.CloudBucketMount(
+ bucket_name=config.S3_bucket_name,
+ secret=modal.Secret.from_name("aws-s3-secret", environment_name=config.modal_environment),
+ ),
+ },
+ region="us",
+ cloud='gcp')
+ @modal.concurrent(max_inputs=1)
+ async def image_clothes_mark(google_api_key: str, origin_image: UploadFile, mark_clothes_type: str, mark_color: str):
+ content_type = origin_image.content_type
+ if content_type not in SUPPORTED_IMAGE_TYPES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"不支持的文件类型: {content_type}。支持的类型: {list(SUPPORTED_IMAGE_TYPES.keys())}"
+ )
+
+ try:
+ # 获取对应文件类型的扩展名
+ file_extension = SUPPORTED_IMAGE_TYPES[content_type]
+ # 创建临时文件,使用正确的扩展名
+ with tempfile.NamedTemporaryFile(
+ delete=False,
+ suffix=file_extension,
+ mode='wb'
+ ) as temp_file:
+ # 写入文件内容
+ contents = await origin_image.read()
+ temp_file.write(contents)
+ temp_file_path = temp_file.name
+ logger.success(f"上传图片已保存到{temp_file_path}")
+ except Exception as e:
+ logger.exception("保存图片失败")
+ raise e
+ origin_image_uri = await upload(temp_file_path, content_type, google_api_key)
+ client = GoogleAuthUtils.GoogleGenaiClient(
+ cloudflare_project_id="67720b647ff2b55cf37ba3ef9e677083",
+ cloudflare_gateway_id="bowong-dev",
+ google_project_id="gen-lang-client-0413414134",
+ regions=gemini_region, access_token=google_api_key,
+ )
+ try:
+ logger.info("🐬开始处理图片")
+ resp, resp_code = client.generate_content(model_id="gemini-2.0-flash-preview-image-generation",
+ contents=[types.Content(role='user',
+ parts=[
+ types.Part.from_uri(
+ file_uri=origin_image_uri,
+ mime_type=content_type),
+ types.Part.from_text(
+ text=""
+ ""
+ "请将图片中模特身上的**{0}**涂为**纯{1}**, 不考虑褶皱纹理透视,用于服装替换, 其他部分保持不变, 输出原尺寸的图片"
+ ""
+ "".format(
+ mark_clothes_type, mark_color
+ )
+ )
+ ]
+ )
+ ],
+ config=types.GenerateContentConfig.model_validate({
+ "temperature": 0.01,
+ "top_p": 0.7,
+ "response_modalities": ["IMAGE","TEXT"]
+ }),
+ timeout=900)
+ except Exception as e:
+ logger.exception(f"😭修改图片请求失败 {e}")
+ return None, None
+ if resp_code == 200:
+ reason = resp.candidates[0].finish_reason
+ logger.info("😊用量信息" + resp.usage_metadata.model_dump_json(indent=2))
+ if reason == "STOP" or reason == "FINISH_REASON_UNSPECIFIED":
+ for part in resp.candidates[0].content.parts:
+ if part.inline_data is not None:
+ result_image_raw: bytes = part.inline_data.data
+ result_image_mime: str = part.inline_data.mime_type
+ result_image_b64 = base64.b64encode(result_image_raw).decode('ascii')
+ result = {
+ "image_b64": f"data:{result_image_mime};base64,{result_image_b64}",
+ }
+ # 解析识别结果
+ logger.info(f"Result:\n {json.dumps(result, indent=2)}")
+ return result, None
+ else:
+ logger.error(f"😭Gemini推理失败, Reason {reason}")
+ return None, None
+ else:
+ logger.error(f"😭Gemini推理失败, 状态码{resp_code}")
+ if resp:
+ logger.error(f"😭Gemini推理失败, 返回内容{resp}")
+ if resp_code == 429:
+ logger.warning("🥵请求负载过高, 随机更换地区重试")
+ return None, None
+
+ @app.function(cpu=(0.5, 64), timeout=1800,
+ max_containers=config.video_downloader_concurrency,
+ volumes={
+ config.S3_mount_dir: modal.CloudBucketMount(
+ bucket_name=config.S3_bucket_name,
+ secret=modal.Secret.from_name("aws-s3-secret", environment_name=config.modal_environment),
+ ),
+ },
+ region="us",
+ cloud='gcp')
+ @modal.concurrent(max_inputs=1)
+ async def image_edit_custom(google_api_key: str, origin_image: UploadFile, prompt: str):
+ content_type = origin_image.content_type
+ if content_type not in SUPPORTED_IMAGE_TYPES:
+ raise HTTPException(
+ status_code=400,
+ detail=f"不支持的文件类型: {content_type}。支持的类型: {list(SUPPORTED_IMAGE_TYPES.keys())}"
+ )
+
+ try:
+ # 获取对应文件类型的扩展名
+ file_extension = SUPPORTED_IMAGE_TYPES[content_type]
+ # 创建临时文件,使用正确的扩展名
+ with tempfile.NamedTemporaryFile(
+ delete=False,
+ suffix=file_extension,
+ mode='wb'
+ ) as temp_file:
+ # 写入文件内容
+ contents = await origin_image.read()
+ temp_file.write(contents)
+ temp_file_path = temp_file.name
+ logger.success(f"上传图片已保存到{temp_file_path}")
+ except Exception as e:
+ logger.exception("保存图片失败")
+ raise e
+ origin_image_uri = await upload(temp_file_path, content_type, google_api_key)
+ client = GoogleAuthUtils.GoogleGenaiClient(
+ cloudflare_project_id="67720b647ff2b55cf37ba3ef9e677083",
+ cloudflare_gateway_id="bowong-dev",
+ google_project_id="gen-lang-client-0413414134",
+ regions=gemini_region, access_token=google_api_key,
+ )
+ try:
+ logger.info("🐬开始处理图片")
+ resp, resp_code = client.generate_content(model_id="gemini-2.0-flash-preview-image-generation",
+ contents=[types.Content(role='user',
+ parts=[
+ types.Part.from_uri(
+ file_uri=origin_image_uri,
+ mime_type=content_type),
+ types.Part.from_text(
+ text=""
+ ""
+ "{0}"
+ ""
+ "".format(
+ prompt
+ )
+ )
+ ]
+ )
+ ],
+ config=types.GenerateContentConfig.model_validate({
+ "temperature": 0.01,
+ "top_p": 0.7,
+ "response_modalities": ["IMAGE","TEXT"]
+ }),
+ timeout=900)
+ except Exception as e:
+ logger.exception(f"😭修改图片请求失败 {e}")
+ return None, None
+ if resp_code == 200:
+ reason = resp.candidates[0].finish_reason
+ logger.info("😊用量信息" + resp.usage_metadata.model_dump_json(indent=2))
+ if reason == "STOP" or reason == "FINISH_REASON_UNSPECIFIED":
+ for part in resp.candidates[0].content.parts:
+ if part.inline_data is not None:
+ result_image_raw: bytes = part.inline_data.data
+ result_image_mime: str = part.inline_data.mime_type
+ result_image_b64 = base64.b64encode(result_image_raw).decode('ascii')
+ result = {
+ "image_b64": f"data:{result_image_mime};base64,{result_image_b64}",
+ }
+ # 解析识别结果
+ logger.info(f"Result:\n {json.dumps(result, indent=2)}")
+ return result, None
+ else:
+ logger.error(f"😭Gemini推理失败, Reason {reason}")
+ return None, None
+ else:
+ logger.error(f"😭Gemini推理失败, 状态码{resp_code}")
+ if resp:
+ logger.error(f"😭Gemini推理失败, 返回内容{resp}")
+ if resp_code == 429:
+ logger.warning("🥵请求负载过高, 随机更换地区重试")
+ return None, None
diff --git a/src/cluster/video.py b/src/cluster/video.py
index 06b70c7..c35a651 100644
--- a/src/cluster/video.py
+++ b/src/cluster/video.py
@@ -106,6 +106,7 @@ with downloader_image.imports():
# 导入sub app function
from .video_apps import *
+ from .image_apps import *