测试极速成片

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
@@ -5,6 +5,26 @@ import requests
from .volcano import VolcanoArkProvider
# gpt-image / New API 网关只认这三档。业务层为了「真 16:9」会传 1536x864
# 不在这里收成合法横图的话,立绘能成、三视图 image_edit 直接 400。
_OPENAI_IMAGE_SIZES = {"1024x1024", "1024x1536", "1536x1024"}
def openai_image_size(size: str, default: str = "1024x1536") -> str:
"""把任意宽高收成 gpt-image 接受的三档:方 / 竖 / 横。"""
raw = (size or "").strip()
if raw in _OPENAI_IMAGE_SIZES:
return raw
try:
width, height = (int(part) for part in raw.lower().replace("×", "x").split("x", 1))
except (TypeError, ValueError):
return default
if width <= 0 or height <= 0:
return default
if width == height:
return "1024x1024"
return "1536x1024" if width > height else "1024x1536"
def _downscale_ref_for_edit(data: bytes, content_type: str, max_edge: int = 2048) -> tuple[bytes, str]:
"""图生图参考图过大时按需降采样,避免中转站(yunqi/gpt-image edits)拒收大图报 400
@@ -91,7 +111,7 @@ class OpenAICompatibleProvider(VolcanoArkProvider):
"""文生图(可选单图参考 base64)。多图参考请用 image_edit。返回体含 url 或 b64_json。"""
if not self.api_key:
raise ValueError("中转站 api_key 未配置")
body: dict[str, Any] = {"model": model, "prompt": prompt, "size": size, "n": 1}
body: dict[str, Any] = {"model": model, "prompt": prompt, "size": openai_image_size(size), "n": 1}
if image:
body["image"] = image
# 实测中转站生图延迟可达 75s+,超时给到 300s
@@ -135,7 +155,7 @@ class OpenAICompatibleProvider(VolcanoArkProvider):
files.append(("image[]", (f"ref{idx + 1}.{ext}", img_bytes, content_type)))
if not files:
raise ValueError("image_edit 至少需要一张参考图")
data = {"model": model, "prompt": prompt, "size": size, "n": "1"}
data = {"model": model, "prompt": prompt, "size": openai_image_size(size), "n": "1"}
response = requests.post(
self._endpoint_url(endpoint),
headers={"Authorization": f"Bearer {self.api_key}"}, # multipart 不要手设 Content-Type
+2 -1
View File
@@ -3,6 +3,7 @@ from typing import Any
import requests
from django.conf import settings
from .openai_compatible import openai_image_size
from .volcano import VolcanoArkProvider
@@ -32,7 +33,7 @@ class YunqiProvider(VolcanoArkProvider):
raise ValueError("YUNQI_API_KEY is not configured")
if image:
raise ValueError("gpt-image-2 via YunQi only supports text-to-image; reference image is not supported")
body: dict[str, Any] = {"model": model, "prompt": prompt, "size": size, "n": 1}
body: dict[str, Any] = {"model": model, "prompt": prompt, "size": openai_image_size(size), "n": 1}
# 实测该网关生图平均延迟 75s+,超时须显著高于火山的 180s
response = requests.post(
f"{self.base_url.rstrip('/')}/{endpoint.lstrip('/')}",
+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(
@@ -0,0 +1,34 @@
from io import BytesIO
from unittest.mock import Mock, patch
from django.test import SimpleTestCase
from apps.ai.providers.openai_compatible import OpenAICompatibleProvider, openai_image_size
class OpenAICompatibleImageSizeTests(SimpleTestCase):
def test_coerces_true_16_9_to_supported_landscape(self):
self.assertEqual(openai_image_size("1536x864"), "1536x1024")
self.assertEqual(openai_image_size("1024x1536"), "1024x1536")
self.assertEqual(openai_image_size("1536x1024"), "1536x1024")
self.assertEqual(openai_image_size("1024x1024"), "1024x1024")
self.assertEqual(openai_image_size("512x768"), "1024x1536")
@patch("apps.ai.providers.openai_compatible.requests.post")
@patch.object(OpenAICompatibleProvider, "media_to_bytes")
def test_image_edit_sends_gateway_supported_size(self, media_to_bytes, post):
media_to_bytes.return_value = (BytesIO(b"img"), "image/png")
response = Mock()
response.ok = True
response.json.return_value = {"data": [{"b64_json": "x"}]}
post.return_value = response
provider = OpenAICompatibleProvider(base_url="https://img.example/v1", api_key="secret")
provider.image_edit(
model="gpt-image-2",
prompt="三视图",
images=["http://example.test/portrait.png"],
size="1536x864",
)
self.assertEqual(post.call_args.kwargs["data"]["size"], "1536x1024")
@@ -150,6 +150,24 @@ class ProjectPersonTriviewRoutingTests(TestCase):
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
@patch("apps.ai.services.create_ai_task")
def test_submit_reads_candidate_assets_manager_to_keep_portrait_label(self, create_task):
"""基础资产落库后自动接力三视图时,关联集合必须可正常读取。"""
self.model(self.provider("project-tri-label", 20), "project-tri-label")
group = BaseAssetGroup.objects.create(
project=self.project,
kind=BaseAssetGroup.Kind.PERSON,
adopted_asset=self.portrait,
metadata={"label": "专业测评师"},
)
group.candidate_assets.add(self.portrait)
create_task.return_value = Mock(id="triview-task")
task = self.submit()
self.assertEqual(create_task.call_args.kwargs["request_payload"]["label"], "专业测评师")
self.assertEqual(task, create_task.return_value)
def test_retry_then_dynamic_candidate_success_charges_once(self):
primary = self.model(
self.provider("project-tri-fallback-primary", 100),
@@ -232,3 +250,37 @@ class ProjectPersonTriviewRoutingTests(TestCase):
self.assertEqual(call.kwargs["image"], ["http://example.test/portrait.png"])
self.assertEqual(call.kwargs["size"], "1536x864")
self.assertEqual(task.model_attempts.get().public_model_name, primary.display_name)
def test_worker_refreshes_stale_reference_url_from_portrait(self):
"""立绘成功后立刻出三视图:worker 必须用当前立绘 URL,不能沿用已过期的 payload 链接。"""
self.model(self.provider("project-tri-refresh", 20), "project-tri-refresh")
task = self.submit()
payload = dict(task.request_payload)
payload["reference_image"] = "http://expired.test/old-portrait.png"
task.request_payload = payload
task.save(update_fields=["request_payload", "updated_at"])
run_triview_task(task_id=str(task.id))
task.refresh_from_db()
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
primary = next(iter(self.provider_mocks.values()))
self.assertEqual(
primary.image_edit.call_args.kwargs["images"],
["http://example.test/portrait.png"],
)
def test_worker_fails_clearly_when_portrait_reference_is_missing(self):
self.model(self.provider("project-tri-missing-ref", 20), "project-tri-missing-ref")
self.portrait.files.all().delete()
task = self.submit()
payload = dict(task.request_payload)
payload["reference_image"] = ""
task.request_payload = payload
task.save(update_fields=["request_payload", "updated_at"])
run_triview_task(task_id=str(task.id))
task.refresh_from_db()
self.assertEqual(task.status, AITask.Status.FAILED)
self.assertIn("立绘参考图不可用", task.error_message)
+2 -2
View File
@@ -24,8 +24,8 @@ class ModelRoutingPolicyTests(SimpleTestCase):
self.assertEqual(policy.jitter_ratio, 0.20)
self.assertEqual(policy.text.retry_delays, (1.0, 3.0))
self.assertEqual(policy.text.request_timeout, 120.0)
self.assertEqual(policy.text.stream_timeout, 300.0)
self.assertEqual(policy.text.total_timeout, 480.0)
self.assertEqual(policy.text.stream_timeout, 1740.0)
self.assertEqual(policy.text.total_timeout, 1800.0)
self.assertEqual(policy.image.retry_delays, (3.0,))
self.assertEqual(policy.image.request_timeout, 300.0)
self.assertEqual(policy.image.total_timeout, 900.0)
@@ -185,6 +185,16 @@ class StoryboardRoutingTests(TestCase):
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
def test_wizard_aspect_ratio_drives_storyboard_size(self):
self.project.metadata = {"wizard": {"aspect_ratio": "16:9", "resolution": "480p"}}
self.project.save(update_fields=["metadata"])
primary = self.model(self.provider("storyboard-wide", 20), "storyboard-wide")
task = self.enqueue()
attempt = task.model_attempts.get()
self.assertEqual(attempt.request_summary["aspect_ratio"], "16:9")
call = self.provider_mocks[primary.id].image_edit.call_args
self.assertEqual(call.kwargs["size"], "1536x864")
def test_retry_then_dynamic_candidate_success_charges_once(self):
primary = self.model(
self.provider("storyboard-fallback-primary", 100),
+46 -5
View File
@@ -1,5 +1,6 @@
import json
from pathlib import Path
from types import SimpleNamespace
from django.conf import settings
from django.test import SimpleTestCase
@@ -1544,6 +1545,7 @@ class StandaloneCategoryTests(TestCase):
)
@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}})
class TriviewModelDecouplingTests(TestCase):
"""项目角色三视图只归项目,不自动创建或更新团队模特。"""
@@ -1606,9 +1608,9 @@ class TriviewModelDecouplingTests(TestCase):
model.refresh_from_db()
self.assertEqual(model.triview_asset_id, existing_tri.id)
@patch("apps.ai.services.generate_person_triview")
def test_person_base_asset_ignores_legacy_auto_triview_flag(self, kickoff_triview):
"""即使旧前端/旧任务传 auto_triview=True,角色立绘完成后也不能自动接力三视图"""
@patch("apps.ai.tasks.generate_base_asset_task.delay")
def test_person_base_asset_marks_auto_triview_for_worker(self, _enqueue_base_asset):
"""角色立绘任务需标记自动接力三视图,实际接力只在立绘成功落库后发生"""
from apps.ai.services import generate_base_asset
from apps.projects.models import BaseAssetGroup
@@ -1633,8 +1635,47 @@ class TriviewModelDecouplingTests(TestCase):
)
task.refresh_from_db()
self.assertFalse(task.request_payload.get("auto_triview"))
kickoff_triview.assert_not_called()
self.assertTrue(task.request_payload.get("auto_triview"))
@patch("apps.assets.review.submit_asset_for_review")
@patch("apps.ai.services.generate_person_triview")
@patch("apps.ai.services._store_generated_media")
@patch("apps.ai.services.execute_routed_image_request")
@patch("apps.ai.tasks.generate_base_asset_task.delay")
def test_completed_auto_person_generation_queues_triview(
self, _enqueue_base_asset, routed, store_media, kickoff_triview, _review
):
"""角色立绘成功落库后,worker 必须自动创建绑定该立绘的三视图任务。"""
from apps.ai.services import generate_base_asset, run_base_asset_task
from apps.projects.models import BaseAssetGroup
ModelConfig.objects.create(
provider=ModelProvider.objects.create(name="auto-tri-success-provider", display_name="Auto Tri Success Provider"),
name="auto-tri-success-img",
display_name="Auto Tri Success Img",
capability=ModelConfig.Capability.IMAGE,
endpoint="images/generations",
unit_price="1.0000",
is_default=True,
)
routed.return_value = SimpleNamespace(value=({"data": [{"url": "http://x/person.png"}]}, "http://x/person.png"))
store_media.return_value = self.portrait
task = generate_base_asset(
project=self.project,
user=self.user,
kind=BaseAssetGroup.Kind.PERSON,
prompt="项目角色立绘",
label="女主",
# 专业模式显式要求时才接力三视图;极速成片传 False 会跳过。
auto_triview=True,
)
with self.captureOnCommitCallbacks(execute=True):
run_base_asset_task(task_id=str(task.id))
kickoff_triview.assert_called_once_with(
project=self.project, user=self.user, portrait_asset=self.portrait
)
class ModelLibraryTriviewTaskTests(TestCase):