测试极速成片
This commit is contained in:
@@ -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:9;gpt-image 不认 1536x864,OpenAICompatibleProvider 会收成 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(
|
||||
|
||||
Reference in New Issue
Block a user