Files
video-gen/video-gen-api/app/services/api_v3/generation_service.py
T
root 0c511f3451 1、增加调用 AI 视频生成能力和虚拟素材库管理的对外api
2、增加后台apikkey管理
3、增加apikey单独的模型定价
4、增加apikey调用情况
5、完善所有数据的注释增加
2026-08-06 13:13:28 +08:00

495 lines
18 KiB
Python

import asyncio
import json
import logging
import time
from datetime import datetime, timezone
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.api.api_key import ApiKey
from app.schemas.api_v3.image import ApiImageGenerateRequest, ApiImageGenerateResponse, ApiImageGenerateDataItem
from app.schemas.api_v3.video import ApiVideoCreateRequest, ApiVideoCreateResponse
from app.services.api_v3 import task_service, engine_service, upscale_service
from app.services.api_v3.quota_service import can_start_video_task
from app.services.api_v3.pricing_service import calc_api_video_price, calc_api_image_price, PricingNotConfiguredError
from app.services.api_v3.logging_service import log_model_request
logger = logging.getLogger("videogen")
async def submit_video_generation(
db: AsyncSession,
key: ApiKey,
callable_models: list[dict],
req: ApiVideoCreateRequest,
) -> ApiVideoCreateResponse:
"""提交视频生成任务(异步)。
流程:
1. 检查并发视频任务数
2. 解析引擎
3. 构建超分快照
4. 创建任务记录
5. 入队 Celery 任务
6. 返回 task_id
"""
# 1. 检查是否可以立即启动(并发限制)
can_start = await can_start_video_task(key, db)
# 2. 解析引擎
engine_id, engine = await engine_service.resolve_engine_by_model_name(
db, req.model, callable_models, "video"
)
engine_snapshot = engine_service.build_engine_snapshot(engine)
# 3. 构建超分快照
provider_resolution, upscale_enabled, upscale_snapshot_json = await upscale_service.build_api_upscale_snapshot(
db, key.id, req.resolution or "480p", req.ratio
)
# 如果超分要求不同的生成分辨率,使用超分的
final_provider_resolution = provider_resolution or req.resolution or "480p"
def _get_max_supported_duration(engine) -> int | None:
"""从引擎 supported_durations 获取最大时长。"""
try:
durations = json.loads(engine.supported_durations) if engine.supported_durations else []
return max(durations) if durations else engine.max_duration
except (json.JSONDecodeError, TypeError, ValueError):
return engine.max_duration
# 3.5 验证传入的媒体文件是否符合引擎配置要求
from app.services.api_v3.file_service import process_media_url
from app.services.video_upscale.media_service import probe_video
from fastapi import HTTPException, status # noqa: F401
input_image_count = 0
input_video_count = 0
input_audio_count = 0
input_video_duration = 0.0
local_media_refs = [] # 存储本地路径
# 统计各类媒体数量
for p in req.content:
ptype = p.type
if ptype == "image_url":
input_image_count += 1
elif ptype == "video_url":
input_video_count += 1
elif ptype == "audio_url":
input_audio_count += 1
# 视频引擎校验(本函数仅处理视频生成)
# 校验图片数量限制
if input_image_count > (engine.max_image_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_image_count} 张参考图片,当前传入 {input_image_count} 张",
)
# 校验视频数量限制
if input_video_count > (engine.max_video_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_video_count} 个参考视频,当前传入 {input_video_count} 个",
)
# 校验音频数量限制
if input_audio_count > (engine.max_audio_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_audio_count} 个参考音频,当前传入 {input_audio_count} 个",
)
for p in req.content:
ptype = p.type
if ptype == "text":
local_media_refs.append(p.model_dump(exclude_none=True))
continue
original_url = ""
if ptype == "image_url" and p.image_url:
original_url = p.image_url.get("url", "")
elif ptype == "video_url" and p.video_url:
original_url = p.video_url.get("url", "")
elif ptype == "audio_url" and p.audio_url:
original_url = p.audio_url.get("url", "")
# 下载文件到本地
try:
local_path = await process_media_url(original_url, ptype.replace("_url", ""))
except Exception as exc:
logger.warning("Failed to download media %s: %s", original_url[:80], exc)
local_path = original_url
# 如果是视频/音频,探测实际时长并校验
if ptype in ("video_url", "audio_url") and local_path:
try:
media_info = await probe_video(local_path)
if media_info and media_info.duration_seconds:
duration = media_info.duration_seconds
# 校验最低时长(2秒)
if duration < 2.0:
raise HTTPException(
status_code=400,
detail=f"上传的{ptype.replace('_url', '')}时长不能低于2秒,当前时长: {duration:.1f}秒",
)
# 校验最高时长(根据引擎 supported_durations 最大值)
max_duration = _get_max_supported_duration(engine)
if max_duration and duration > max_duration:
raise HTTPException(
status_code=400,
detail=f"上传的{ptype.replace('_url', '')}时长不能超过{max_duration}秒,当前时长: {duration:.1f}秒",
)
if ptype == "video_url":
input_video_duration += duration
except HTTPException:
raise
except Exception as exc:
logger.warning("Failed to probe media duration: %s", exc)
local_media_refs.append({
"type": ptype,
ptype: {"url": local_path},
"role": p.role,
})
# 3.6 计算价格(基于实际探测的视频时长)
try:
estimated_price = await calc_api_video_price(
db,
duration=req.duration or 5,
resolution=req.resolution or "480p",
engine_id=engine_id,
input_video_duration=input_video_duration,
input_image_count=input_image_count,
)
except PricingNotConfiguredError as exc:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# 预检配额
if key.quota_limit is not None and key.quota_used + estimated_price > key.quota_limit:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配额不足 (需要 {estimated_price:.2f} 元, 剩余 {key.quota_limit - key.quota_used:.2f} 元)",
)
# 预扣配额
if estimated_price > 0:
key.quota_used = round((key.quota_used or 0.0) + estimated_price, 2)
# 4. 创建任务记录(幂等性已在路由层检查)
content_dicts = [p.model_dump(exclude_none=True) for p in req.content]
task = await task_service.create_video_task(
db=db,
api_key_id=key.id,
model_name=req.model,
engine_id=engine_id,
engine_snapshot=engine_snapshot,
content=content_dicts,
ratio=req.ratio,
duration=req.duration,
resolution=req.resolution,
provider_generation_resolution=final_provider_resolution,
upscale_enabled=upscale_enabled,
upscale_snapshot_json=upscale_snapshot_json,
idempotency_key=req.idempotency_key,
local_media_refs=local_media_refs,
)
task.credits_cost = estimated_price # 记录预扣金额
# 根据并发限制决定立即执行还是排队
if can_start:
# 立即执行
task.status = "pending"
task.pipeline_stage = "queued"
else:
# 排队等待
task.status = "queued"
task.pipeline_stage = "waiting_concurrency"
await db.commit()
# 提交时即记录使用日志(配额已预扣)
try:
from app.services.api_v3.usage_log_service import record_usage
quota_before = key.quota_used - estimated_price # 扣减前的余额
quota_after = key.quota_used # 扣减后的余额
price_detail = {
"base_price": getattr(locals(), "base_price", 0),
"per_second_price": getattr(locals(), "per_second_price", 0),
"duration": req.duration,
"resolution": req.resolution,
"ratio": req.ratio,
"total": estimated_price,
}
await record_usage(
db=db,
api_key_id=key.id,
request_type="video_create",
model_name=req.model,
gen_type="video",
status="success",
task_id=task.id,
credits_cost=estimated_price,
price_action="deduct",
resolution=req.resolution,
duration=req.duration,
quota_before=quota_before,
quota_after=quota_after,
price_detail_json=json.dumps(price_detail, ensure_ascii=False),
)
except Exception as log_exc:
logger.error("Failed to record usage on submit: %s", log_exc)
# 记录模型调用日志
log_model_request(
engine_id=engine_id,
model_name=req.model,
task_id=task.id,
params={
"ratio": req.ratio,
"duration": req.duration,
"resolution": req.resolution,
"generate_audio": req.generate_audio,
"watermark": req.watermark,
"content_count": len(req.content),
"queued": not can_start,
},
)
# 5. 只有立即执行的才入队 Celery
if can_start:
from app.tasks.api_generation_tasks import api_create_generation_task
api_create_generation_task.apply_async(
args=[task.id],
queue="gen_api_create",
)
status_str = "queued" if can_start else "pending_queue"
logger.info("API video task created: task_id=%s model=%s key=%s price=%.2f status=%s", task.id, req.model, key.id, estimated_price, status_str)
return ApiVideoCreateResponse(id=task.id)
async def generate_image_sync(
db: AsyncSession,
key: ApiKey,
callable_models: list[dict],
req: "ApiImageGenerateRequest",
start_time: float,
) -> ApiImageGenerateResponse:
"""同步生成图片。
流程:
1. 解析引擎
2. 创建任务记录
3. 调用 Volcano Ark SDK(同步)
4. 下载图片
5. 更新任务状态
6. 记录使用日志
7. 返回结果
"""
from app.services.api_v3.usage_log_service import record_usage
# 1. 解析引擎
engine_id, engine = await engine_service.resolve_engine_by_model_name(
db, req.model, callable_models, "image"
)
engine_snapshot = engine_service.build_engine_snapshot(engine)
# 2. 创建任务记录
task = await task_service.create_image_task(
db=db,
api_key_id=key.id,
model_name=req.model,
engine_id=engine_id,
engine_snapshot=engine_snapshot,
prompt=req.prompt,
size=req.size,
)
# 2.5 验证传入的媒体文件是否符合引擎配置要求
# 校验参考图片数量限制
input_image_count = len(req.image) if req.image else 0
if input_image_count > (engine.max_reference_image_count or 0):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持 {engine.max_reference_image_count} 张参考图片,当前传入 {input_image_count} 张",
)
# 校验组图数量限制
generation_count = req.generation_count or 1
if generation_count > (engine.multi_image_max_images or 1):
raise HTTPException(
status_code=400,
detail=f"该引擎最多支持生成 {engine.multi_image_max_images} 张图片,当前请求 {generation_count} 张",
)
try:
estimated_price = await calc_api_image_price(
db,
image_size=req.size or "2K",
engine_id=engine_id,
input_image_count=input_image_count,
)
except PricingNotConfiguredError as exc:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(exc),
)
# 预检配额
if key.quota_limit is not None and key.quota_used + estimated_price > key.quota_limit:
from fastapi import HTTPException, status
raise HTTPException(
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
detail=f"配额不足 (需要 {estimated_price:.2f} 元, 剩余 {key.quota_limit - key.quota_used:.2f} 元)",
)
# 预扣配额
if estimated_price > 0:
key.quota_used = round((key.quota_used or 0.0) + estimated_price, 2)
task.credits_cost = estimated_price
await db.commit()
# 提交时即记录使用日志(配额已预扣)
try:
quota_before = key.quota_used - estimated_price
quota_after = key.quota_used
price_detail = {
"base_price": getattr(locals(), "base_price", 0),
"size": req.size,
"generation_count": req.generation_count or 1,
"total": estimated_price,
}
await record_usage(
db=db,
api_key_id=key.id,
request_type="image_generate",
model_name=req.model,
gen_type="image",
status="success",
task_id=task.id,
credits_cost=estimated_price,
price_action="deduct",
resolution=req.size,
quota_before=quota_before,
quota_after=quota_after,
price_detail_json=json.dumps(price_detail, ensure_ascii=False),
)
except Exception as log_exc:
logger.error("Failed to record usage on image submit: %s", log_exc)
try:
# 3. 调用 Volcano Ark SDK(同步函数,在线程中执行)
from app.services.image_gen import submit_image_task, download_image
from app.config import settings
import os
# 构建 media_references,下载图片到本地
from app.services.api_v3.file_service import process_media_url
image_refs = []
if req.image:
for url in req.image:
try:
local_path = await process_media_url(url, "image")
except Exception as exc:
logger.warning("Failed to download image %s: %s", url[:80], exc)
local_path = url
image_refs.append({"type": "image", "url": local_path})
# 临时设置 media_references
task.media_references = json.dumps(image_refs, ensure_ascii=False) if image_refs else None
task.image_size = req.size or "2K"
await db.flush()
# 在线程中执行同步 SDK 调用
result = await asyncio.to_thread(
submit_image_task,
db,
engine,
task,
True, # include_media_references
req.generation_count or 1,
)
# 4. 下载图片
items = result.get("items", [])
downloaded_items: list[ApiImageGenerateDataItem] = []
for item in items:
url = item.get("remote_result_url")
if url:
# 下载到本地
date_dir = datetime.now().strftime("%Y%m%d")
dest_dir = f"./storage/generate/api/images/{date_dir}"
os.makedirs(dest_dir, exist_ok=True)
dest_path = os.path.join(dest_dir, f"{task.id}_{item.get('generation_index', 1)}.png")
try:
await download_image(url, dest_path)
except Exception as dl_err:
logger.warning("Image download failed: %s", dl_err)
downloaded_items.append(ApiImageGenerateDataItem(
url=dest_path, # 使用本地路径
size=item.get("size"),
output_format=item.get("output_format"),
))
elif item.get("error_message"):
downloaded_items.append(ApiImageGenerateDataItem(
url=None,
))
# 5. 使用预扣金额(不再重复扣减)
task.credits_cost = estimated_price
# 6. 更新任务状态
task.status = "completed"
task.pipeline_stage = "done"
task.generated_at = datetime.now(timezone.utc)
if downloaded_items and downloaded_items[0].url:
task.image_url = downloaded_items[0].url
await db.commit()
# 提交时已记录使用日志,成功时无需重复记录
duration_ms = int((time.perf_counter() - start_time) * 1000)
return ApiImageGenerateResponse(
created=result.get("created", int(time.time())),
data=downloaded_items,
model=result.get("model", req.model),
)
except Exception as exc:
# 失败:退回预扣配额
if estimated_price > 0:
key.quota_used = round(max(0, (key.quota_used or 0.0) - estimated_price), 2)
task.status = "failed"
task.error_message = str(exc)
task.credits_cost = 0 # 实际消耗为0(已退回)
await db.commit()
duration_ms = int((time.perf_counter() - start_time) * 1000)
quota_after_refund = key.quota_used # 退回后的余额
await record_usage(
db=db,
api_key_id=key.id,
request_type="image_generate",
model_name=req.model,
gen_type="image",
status="failed",
task_id=task.id,
credits_cost=estimated_price,
refund_amount=estimated_price,
request_duration_ms=duration_ms,
error_message=str(exc),
error_code="generation_failed",
price_action="refund",
resolution=req.size,
generation_count=req.generation_count or 1,
quota_before=quota_after_refund,
quota_after=quota_after_refund + estimated_price,
)
await db.commit()
raise