测试极速成片

This commit is contained in:
Azmat@qq.com
2026-08-26 15:18:18 +08:00
parent 245525ec53
commit 0ee498d807
30 changed files with 1963 additions and 367 deletions
+115 -15
View File
@@ -1411,6 +1411,7 @@ def _ratio_to_image_size(ratio: str) -> str:
"9:16": "1024x1536", # 近似竖图(网关无精确 9:16)
"4:3": "1536x1024",
"16:9": "1536x864", # 真 16:9(原来误用 1536x1024 = 3:2)
"21:9": "1536x1024", # 超宽近似横图(网关无精确 21:9)
}
normalized = (ratio or "").strip()
if normalized in known:
@@ -1429,6 +1430,56 @@ def _ratio_to_image_size(ratio: str) -> str:
return "1024x1024"
def project_output_spec(project) -> dict:
"""专业创作 / 极速成片共用的成片规格:画幅、分辨率、视频模型。缺省 9:16 + 720p。"""
wizard = dict((project.metadata or {}).get("wizard") or {})
return {
"aspect_ratio": str(wizard.get("aspect_ratio") or "9:16").strip() or "9:16",
"resolution": str(wizard.get("resolution") or "720p").strip().lower() or "720p",
"video_model_config_id": str(wizard.get("video_model_config_id") or "").strip() or None,
}
def _storyboard_canvas_phrase(ratio: str) -> str:
r = (ratio or "9:16").strip() or "9:16"
if r in {"9:16", "3:4"}:
return f"电商竖屏 {r}"
if r == "1:1":
return f"电商方形 {r}"
return f"电商横屏 {r}"
def _apply_storyboard_output_ratio(text: str, project) -> str:
phrase = _storyboard_canvas_phrase(project_output_spec(project)["aspect_ratio"])
return (text or "").replace("电商竖屏 9:16", phrase)
def _sync_timeline_output_spec(project, *, aspect_ratio: str, resolution: str) -> None:
from apps.ai.video_pricing import get_resolution
try:
width, height = get_resolution(aspect_ratio, resolution)
except Exception: # noqa: BLE001 — 规格不合法时不挡提交,时间线保持原值
return
pixels = f"{width}x{height}"
timeline, created = Timeline.objects.get_or_create(
project=project,
defaults={
"name": f"{project.name} Timeline",
"duration_seconds": 60,
"aspect_ratio": aspect_ratio,
"resolution": pixels,
},
)
if created:
return
if timeline.aspect_ratio == aspect_ratio and timeline.resolution == pixels:
return
timeline.aspect_ratio = aspect_ratio
timeline.resolution = pixels
timeline.save(update_fields=["aspect_ratio", "resolution", "updated_at"])
def _ratio_to_volcano_size(ratio: str) -> str:
"""前端比例 → 火山 Seedream 尺寸(~2K 面积,各边夹在 [1024,4096] 且取 16 的倍数)。
预设比例直接给好尺寸;自定义 W:H 按 2K 面积换算;解析不到回落 '2K'"""
@@ -2060,9 +2111,9 @@ def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "
"model": model_config.name, "endpoint": model_config.endpoint, "prompt": gen_prompt,
"kind": kind, "label": label or "", "group_id": str(group_id) if group_id else "",
"use_edit": use_edit, "reference_image": ref_url, "model_routing_v1": True,
# 角色立绘不再自动接力三视图;三视图只通过角色详情里的显式按钮生成
# 保留字段为 False,兼容旧前端/旧任务读取,但不允许再触发自动链路。
"auto_triview": False,
# 角色从这里生成时,立绘落库后由 worker 自动接力生成绑定它的三视图
# 非角色一律忽略该参数,避免商品/场景误入人物三视图链路。
"auto_triview": bool(auto_triview and kind == BaseAssetGroup.Kind.PERSON),
}
task = create_ai_task(
project=project,
@@ -2189,6 +2240,21 @@ def run_base_asset_task(*, task_id: str) -> None:
from apps.assets.review import submit_asset_for_review
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
# 三视图是专业创作可选的增强资产。只有调用方明确要求时才接力,
# 极速成片只需人物立绘即可进入故事板,不能为三视图额外等待或失败。
if kind == BaseAssetGroup.Kind.PERSON and payload.get("auto_triview"):
def _kickoff_person_triview(portrait=asset):
try:
generate_person_triview(
project=project, user=user, portrait_asset=portrait
)
except Exception:
logger.exception(
"auto person triview kickoff failed for portrait %s",
getattr(portrait, "id", ""),
)
transaction.on_commit(_kickoff_person_triview)
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
task.status = AITask.Status.FAILED
task.error_message = str(exc)
@@ -2201,6 +2267,16 @@ def run_base_asset_task(*, task_id: str) -> None:
)
def _triview_reference_url(*, asset_id: str, fallback: str = "") -> str:
"""三视图 worker 现取立绘可访问 URL。提交时写进 payload 的 TOS 签名链接会过期,
立绘落库瞬间也可能还没签出 URL;执行时再取一次,避免「立绘成了、三视图没参考图」。"""
if not asset_id:
return str(fallback or "")
portrait = Asset.objects.filter(id=asset_id, is_deleted=False).first()
live = _asset_preview_url(portrait)
return live or str(fallback or "")
def generate_person_triview(*, project, user, portrait_asset) -> AITask:
"""流程步骤4 · 据「某一版立绘资产」生成它配套的三视图(**异步**:image_edit 慢,交给 worker)。
Web 请求只建 RESERVED 任务 + 预留额度后秒回;worker 内跑 image_edit 并把三视图归组(run_triview_task)。
@@ -2214,7 +2290,7 @@ def generate_person_triview(*, project, user, portrait_asset) -> AITask:
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
raise ValueError("no active image model configured")
ref_url = _asset_preview_url(portrait_asset)
ref_url = _triview_reference_url(asset_id=asset_key)
# 人物三视图提示词:正文可在 admin「提示词」页改(无占位符)
tri_prompt = render_prompt("person_triview", THREE_VIEW_PROMPT)
portrait_label = ""
@@ -2222,7 +2298,11 @@ def generate_person_triview(*, project, user, portrait_asset) -> AITask:
meta = group.metadata or {}
if meta.get("triview_of"):
continue
candidates = [str(value) for value in (group.candidate_assets or [])]
# candidate_assets 是 Django 的 ManyRelatedManager,不能直接遍历;
# 资产生成完成后这里会立即触发人物三视图,直接遍历会抛
# “ManyRelatedManager object is not iterable”,进而让极速成片
# 在所有基础资产已成功时被错误终止。
candidates = [str(value) for value in group.candidate_assets.all()]
if str(group.adopted_asset_id or "") == asset_key or asset_key in candidates:
portrait_label = str(meta.get("label") or "")
break
@@ -2343,7 +2423,10 @@ def run_model_triview_task(*, task_id: str) -> None:
payload = task.request_payload or {}
model_id = str(payload.get("model_id") or "")
portrait_asset_id = str(payload.get("portrait_asset_id") or "")
ref_url = str(payload.get("reference_image") or "")
ref_url = _triview_reference_url(
asset_id=portrait_asset_id,
fallback=str(payload.get("reference_image") or ""),
)
prompt = str(payload.get("prompt") or "")
use_model_routing = bool(payload.get("model_routing_v1"))
provider = None if use_model_routing else get_image_provider(task.model_config)
@@ -2465,13 +2548,19 @@ def run_triview_task(*, task_id: str) -> None:
user = task.created_by
payload = task.request_payload or {}
asset_key = str(payload.get("triview_of") or "")
ref_url = str(payload.get("reference_image") or "")
ref_url = _triview_reference_url(
asset_id=asset_key,
fallback=str(payload.get("reference_image") or ""),
)
prompt = str(payload.get("prompt") or THREE_VIEW_PROMPT)
model_config = task.model_config
use_model_routing = bool(payload.get("model_routing_v1"))
provider = None if use_model_routing else get_image_provider(model_config)
reservation = task.credit_reservation
try:
if not asset_key or not ref_url:
raise ValueError("立绘参考图不可用,无法生成三视图")
# 构图意图仍是 16:9gpt-image 不认 1536x864OpenAICompatibleProvider 会收成 1536x1024。
tri_size = prompt_ratio_size("person_triview", "1536x864")
def _make():
@@ -2603,7 +2692,8 @@ def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> s
脚本=(_segment_script_text(segment) or f"{segment.sort_order + 1}"),
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
)
return "\n".join(line for line in rendered.split("\n") if line.strip())
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
return _apply_storyboard_output_ratio(cleaned, project)
def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str:
@@ -2813,7 +2903,8 @@ def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra
脚本=(_segment_script_text(segment) or f"{segment.sort_order + 1}"),
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
)
return "\n".join(line for line in rendered.split("\n") if line.strip())
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
return _apply_storyboard_output_ratio(cleaned, project)
def _is_transient_error(exc: Exception) -> bool:
@@ -2968,6 +3059,9 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
model_config = task.model_config
reservation = task.credit_reservation
extra_prompt = (project.metadata or {}).get("storyboard_prompt", "") or ""
spec = project_output_spec(project)
frame_ratio = spec["aspect_ratio"]
frame_size = _ratio_to_image_size(frame_ratio)
task.status = AITask.Status.SUBMITTED
task.save(update_fields=["status", "updated_at"])
try:
@@ -2990,9 +3084,9 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
primary_model=model_config,
prompt=frame_prompt,
reference_images=ref_urls,
aspect_ratio="9:16",
edit_size="1024x1536",
direct_size="1024x1536",
aspect_ratio=frame_ratio,
edit_size=frame_size,
direct_size=frame_size,
request_summary={
"storyboard_shot": str(shot.id),
"storyboard_sort_order": shot.sort_order,
@@ -3006,7 +3100,7 @@ def _storyboard_shot_worker(task_id, shot_id, user_id) -> None:
model=model_config.name,
prompt=frame_prompt,
images=ref_urls,
size="1024x1536",
size=frame_size,
)
)
else:
@@ -3270,9 +3364,14 @@ def submit_video_segment(
user,
prompt: str,
model_config_id=None,
aspect_ratio: str = "9:16",
resolution: str = "720p",
aspect_ratio: str | None = None,
resolution: str | None = None,
) -> VideoSegmentVersion | None:
spec = project_output_spec(video_segment.project)
aspect_ratio = str(aspect_ratio or spec["aspect_ratio"] or "9:16")
resolution = str(resolution or spec["resolution"] or "720p").lower()
if not model_config_id:
model_config_id = spec["video_model_config_id"]
model_config = None
if model_config_id:
model_config = (
@@ -3392,6 +3491,7 @@ def submit_video_segment(
)
video_segment.status = VideoSegment.Status.RUNNING
video_segment.save(update_fields=["status", "updated_at"])
_sync_timeline_output_spec(project, aspect_ratio=aspect_ratio, resolution=resolution)
return None
except Exception as exc:
public_error = classify_generation_error(