完成极速成品和脚本优化

This commit is contained in:
Azmat@qq.com
2026-08-25 11:13:07 +08:00
parent 00fc454db7
commit e2ec2d14af
46 changed files with 4734 additions and 432 deletions
+32 -15
View File
@@ -98,27 +98,28 @@ def _wrap_cjk(draw, text: str, font, max_width: int) -> list[str]:
return lines[:3] # 最多 3 行,够长截断
def _render_subtitle_png(text: str, style_key: str, path: Path) -> tuple[int, int]:
"""把一条字幕渲染成 1080 宽的透明 PNG(居中,带描边/底框),返回 (w,h)。"""
def _render_subtitle_png(text: str, style_key: str, path: Path, *, canvas_w: int = 1080) -> tuple[int, int]:
"""把一条字幕渲染成与导出宽度一致的透明 PNG(居中,带描边/底框)。"""
from PIL import Image, ImageDraw
st = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
canvas_w = 1080
margin_x = 90
base = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
scale = max(0.4, canvas_w / 1080)
st = {**base, "size": max(24, round(base["size"] * scale)), "stroke_w": max(0, round(base["stroke_w"] * scale))}
margin_x = max(30, round(90 * scale))
font = _load_font(st["size"])
probe = ImageDraw.Draw(Image.new("RGBA", (10, 10)))
lines = _wrap_cjk(probe, (text or "").strip().replace("\n", " "), font, canvas_w - 2 * margin_x)
line_h = st["size"] + 16
pad = 22
line_h = st["size"] + max(6, round(16 * scale))
pad = max(8, round(22 * scale))
text_h = line_h * len(lines)
canvas_h = text_h + 2 * pad
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
if st["box"]:
widest = max((draw.textlength(ln, font=font) for ln in lines), default=0)
box_w = int(widest) + 2 * pad + 24
box_w = int(widest) + 2 * pad + max(10, round(24 * scale))
x0 = (canvas_w - box_w) // 2
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=16, fill=st["box"])
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=max(6, round(16 * scale)), fill=st["box"])
y = pad
for ln in lines:
w = draw.textlength(ln, font=font)
@@ -186,7 +187,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
transition: str, sub_overlays: list[tuple[str, float, float]],
bgm_name: str | None, bgm_volume: float,
has_audio: list[bool] | None = None, fps: float = 30.0,
voice_overlays: list[tuple[str, float, float]] | None = None) -> list[str]:
voice_overlays: list[tuple[str, float, float]] | None = None,
output_width: int = 1080, output_height: int = 1920) -> list[str]:
has_audio = has_audio or [False] * n
voice_overlays = voice_overlays or []
fps_expr = f"{fps:.6g}"
@@ -194,8 +196,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
for i, s in enumerate(specs):
parts.append(
f"[{i}:v]trim=start={s['ts']:.3f}:end={s['te']:.3f},setpts=PTS-STARTPTS,"
"scale=1080:1920:force_original_aspect_ratio=decrease,"
f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
)
xname = XFADE_MAP.get(transition or "none")
if xname and n > 1:
@@ -213,8 +215,9 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
for j, (_png, start, end) in enumerate(sub_overlays):
idx = sub_base + j
out = "vout" if j == len(sub_overlays) - 1 else f"ov{j}"
bottom_offset = max(40, round(output_height * 150 / 1920))
parts.append(
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-{bottom_offset}:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
)
vlabel = out
@@ -331,6 +334,19 @@ def _split_subtitle_text(text: str) -> list[str]:
return out
def _timeline_output_size(timeline) -> tuple[int, int]:
"""读取时间线输出尺寸;旧项目或异常值安全回落到原来的 1080×1920。"""
try:
width_text, height_text = str(timeline.resolution or "").lower().split("x", 1)
width, height = int(width_text), int(height_text)
except (TypeError, ValueError):
return 1080, 1920
if not 320 <= width <= 4400 or not 320 <= height <= 4400:
return 1080, 1920
# H.264 yuv420p 要求偶数尺寸。
return width - width % 2, height - height % 2
def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]:
"""字幕条目(逐句):优先用 SubtitleTrack.content 里每条 cue 自带的 start_ms——
先定位到所属片段(输入时间轴=各片段时长累计),再重映射到输出时间轴(xfade 会压缩起点);
@@ -424,6 +440,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
style_key = str((subtitle_track.style or {}).get("key", "plain")) if subtitle_track else "plain"
specs = _clip_specs(clips)
output_width, output_height = _timeline_output_size(timeline)
xfade = 0.5 if XFADE_MAP.get(transition) and len(clips) > 1 else 0.0
starts, total = _output_starts(specs, xfade)
@@ -451,7 +468,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
sub_overlays: list[tuple[str, float, float]] = []
for i, (start, end, text) in enumerate(cues):
png = f"sub{i}.png"
_render_subtitle_png(text, style_key, tmp / png)
_render_subtitle_png(text, style_key, tmp / png, canvas_w=output_width)
sub_overlays.append((png, start, end))
# 旁白配音(TTS 资产):按 timeline.metadata.voiceover 映射下载,人声轨混在 BGM 之上;
@@ -484,6 +501,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
has_audio=has_audio, fps=output_fps, voice_overlays=voice_overlays,
output_width=output_width, output_height=output_height,
)
# 加超时:ffmpeg 卡死(资源不足/被 OOM 杀)时不能让任务永久停在 RUNNING、前端无限转圈;
# 超时即按失败收尾,前端能看到「导出失败」而非一直卡。15 分钟足够正常 60s 成片。
@@ -527,4 +545,3 @@ def run_export_job(export_job_id: str) -> ExportJob:
project.status = project.Status.COMPLETED
project.save(update_fields=["status", "updated_at"])
return export_job
+107 -1
View File
@@ -1,6 +1,8 @@
from dataclasses import dataclass
from apps.projects.models import ProjectStage
from django.utils import timezone
from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment
STAGE_ORDER = [
@@ -35,3 +37,107 @@ def can_enter_stage(current_stage: str, target_stage: str, allow_skip_storyboard
return StageTransition(current_stage, target_stage, False, "stage prerequisite is not satisfied")
def initialize_project_pipeline(project: Project, *, placeholder_segments: int = 4) -> None:
"""建立专业创作和极速成片共用的阶段、视频占位数据。幂等,可安全重试。"""
for stage_name in STAGE_ORDER:
ProjectStage.objects.get_or_create(project=project, stage=stage_name)
for index in range(placeholder_segments):
VideoSegment.objects.get_or_create(
project=project,
sort_order=index,
defaults={"target_duration_seconds": 15},
)
def sync_video_segments_to_script(project: Project, script: ScriptVersion) -> None:
"""把视频段数量和时长收口到采用脚本;已生成过的段绝不裁剪或改时长。"""
if not script.is_adopted:
return
script_segments = list(script.segments.order_by("sort_order"))
target = len(script_segments)
segments = list(project.video_segments.order_by("sort_order"))
while len(segments) > target:
tail = segments[-1]
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
tail.delete()
segments.pop()
else:
break
next_order = (segments[-1].sort_order + 1) if segments else 0
for _ in range(target - len(segments)):
index = len(segments)
seconds = script_segments[index].duration_seconds if index < target else 15
segments.append(
VideoSegment.objects.create(
project=project,
sort_order=next_order,
target_duration_seconds=seconds,
)
)
next_order += 1
stale: list[VideoSegment] = []
for index, video_segment in enumerate(segments):
if index >= target:
break
seconds = script_segments[index].duration_seconds
if not seconds or video_segment.target_duration_seconds == seconds:
continue
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
continue
video_segment.target_duration_seconds = seconds
stale.append(video_segment)
if stale:
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
def adopt_script_version(project: Project, script: ScriptVersion) -> None:
"""采用脚本并推进到资产阶段,供专业创作按钮和极速编排共同调用。"""
ScriptVersion.objects.filter(project=project).exclude(id=script.id).update(is_adopted=False)
if not script.is_adopted:
script.is_adopted = True
script.save(update_fields=["is_adopted", "updated_at"])
sync_video_segments_to_script(project, script)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
project.current_stage = ProjectStage.Stage.BASE_ASSETS
project.status = Project.Status.ASSETING
project.failure_reason = ""
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
def finish_storyboard_stage(project: Project) -> None:
"""故事板全部成功后推进到视频阶段,并再次校准片段数量。"""
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if adopted_script is not None:
sync_video_segments_to_script(project, adopted_script)
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
def finish_video_stage(project: Project) -> bool:
"""全部片段成功即完成专业创作的视频阶段。"""
segments = list(project.video_segments.values_list("status", "adopted_version_id"))
if not segments or not all(status == VideoSegment.Status.SUCCEEDED and adopted for status, adopted in segments):
return False
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.VIDEO)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.COMPLETED
project.failure_reason = ""
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
return True
@@ -0,0 +1,806 @@
"""“极速成片”自动编排。
这不是另一套生成实现:脚本、基础资产、三视图、故事板、视频和合成全部调用专业创作现有服务。
本模块只负责按状态推进,并把子任务 ID 持久化到 QuickCreateJob,方便刷新后恢复进度。
"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timedelta
from django.db import connections, transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.script_agent import stream_script_agent
from apps.ai.services import (
collect_video_review_blockers,
create_export_job,
generate_base_asset,
generate_person_triview,
get_default_model,
poll_storyboard,
submit_storyboard,
submit_video_segment,
)
from apps.assets import assets_client
from apps.assets.models import Asset
from apps.assets.review import poll_team_reviews, submit_asset_for_review
from apps.projects.models import (
BaseAssetGroup,
ExportJob,
Project,
ProjectStage,
QuickCreateJob,
Timeline,
TimelineClip,
VideoSegment,
)
from apps.projects.services.pipeline import (
STAGE_ORDER,
adopt_script_version,
finish_storyboard_stage,
finish_video_stage,
)
logger = logging.getLogger(__name__)
POLL_DELAY_SECONDS = 10
SCRIPT_POLL_SECONDS = 5
SCRIPT_TIMEOUT = timedelta(minutes=4)
SCRIPT_STOLEN_AFTER = timedelta(seconds=25)
STALE_AFTER = timedelta(seconds=12)
_ACTIVE_TASK_STATUSES = {
AITask.Status.CREATED,
AITask.Status.RESERVED,
AITask.Status.SUBMITTED,
AITask.Status.POLLING,
AITask.Status.POSTPROCESSING,
}
_FINISHED_STATUSES = {
QuickCreateJob.Status.SUCCEEDED,
QuickCreateJob.Status.FAILED,
QuickCreateJob.Status.CANCELLED,
}
def _is_finished(job: QuickCreateJob) -> bool:
return job.status in _FINISHED_STATUSES
def _quick_settings(project: Project) -> dict:
wizard = dict((project.metadata or {}).get("wizard") or {})
return {
"aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"),
"resolution": str(wizard.get("resolution") or "720p").lower(),
"total_duration": int(wizard.get("total_duration") or 15),
"video_model_config_id": str(wizard.get("video_model_config_id") or ""),
"video_model_name": str(wizard.get("video_model_name") or ""),
"video_model_label": str(wizard.get("video_model_label") or ""),
}
def _save_job(job: QuickCreateJob, **changes) -> None:
fields = []
for field, value in changes.items():
setattr(job, field, value)
fields.append(field)
if fields:
job.save(update_fields=[*fields, "updated_at"])
def _safe_error(exc: Exception) -> str:
raw = str(exc or "").strip()
lower = raw.lower()
if "insufficient credit" in lower or "额度不足" in raw:
return "可用积分不足,极速成片已暂停"
if "no active" in lower or "not configured" in lower or "没有可用" in raw:
return "当前缺少可用的生成模型,请联系管理员配置"
if "review" in lower or "审核" in raw:
return "生成素材未通过审核,请进入专业模式调整后重试"
return "极速成片暂未完成,请稍后重试或进入专业模式查看"
def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str = "") -> None:
job.refresh_from_db(fields=["status", "metadata"])
if job.status in {QuickCreateJob.Status.SUCCEEDED, QuickCreateJob.Status.CANCELLED}:
return
public_message = (message or "极速成片暂未完成,请稍后重试").strip()[:500]
metadata = dict(job.metadata or {})
if internal_error:
metadata["internal_error"] = internal_error[:2000]
_save_job(
job,
status=QuickCreateJob.Status.FAILED,
error_message=public_message,
message=public_message,
metadata=metadata,
)
project = job.project
if project.status != Project.Status.COMPLETED:
project.status = Project.Status.FAILED
project.failure_reason = public_message
project.save(update_fields=["status", "failure_reason", "updated_at"])
if project.current_stage in STAGE_ORDER:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage)
stage.status = ProjectStage.Status.FAILED
stage.error_message = public_message
stage.save(update_fields=["status", "error_message", "updated_at"])
def cancel_quick_create(job: QuickCreateJob) -> QuickCreateJob:
"""用户主动停掉进行中的极速成片;已完成的任务保持原样。"""
if job.status == QuickCreateJob.Status.SUCCEEDED:
return job
if job.status in {QuickCreateJob.Status.FAILED, QuickCreateJob.Status.CANCELLED}:
return job
public_message = "已取消本次生成"
metadata = dict(job.metadata or {})
metadata["cancelled"] = True
_save_job(
job,
status=QuickCreateJob.Status.CANCELLED,
error_message=public_message,
message=public_message,
metadata=metadata,
completed_at=timezone.now(),
)
project = job.project
project.status = Project.Status.FAILED
project.failure_reason = public_message
project.save(update_fields=["status", "failure_reason", "updated_at"])
if project.current_stage in STAGE_ORDER:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage)
if stage.status not in {ProjectStage.Status.SUCCEEDED, ProjectStage.Status.FAILED}:
stage.status = ProjectStage.Status.FAILED
stage.error_message = public_message
stage.save(update_fields=["status", "error_message", "updated_at"])
return job
def _consume_script_agent(job: QuickCreateJob) -> None:
project = job.project
settings = _quick_settings(project)
user = job.created_by or project.created_by
if user is None:
raise ValueError("极速成片任务缺少创建人")
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise ValueError("no active text model configured")
error_detail = ""
stream = stream_script_agent(
project=project,
user=user,
model_config=model_config,
mode="auto",
user_prompt=(
"请根据商品名称与商品参考图信息,自动推荐最适合的"
f"{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。"
),
aspect_ratio=settings["aspect_ratio"],
total_duration=settings["total_duration"],
presentation_format="oral",
video_structure="pain",
entry_source="ai",
persona="reviewer",
)
for frame in stream:
job.refresh_from_db(fields=["status"])
if _is_finished(job):
return
if not frame.startswith("data:"):
continue
try:
event = json.loads(frame[5:].strip())
except (TypeError, ValueError):
continue
if event.get("type") == "error":
error_detail = str(event.get("detail") or "脚本生成失败")
if error_detail:
raise ValueError(error_detail)
script = project.script_versions.order_by("-created_at").first()
if script is None or not script.segments.exists():
raise ValueError("脚本生成后没有可用分镜")
adopt_script_version(project, script)
def consume_quick_script(job_id: str) -> None:
"""Celery 专用:跑完专业创作同一套脚本 agent,再把编排推去资产阶段。"""
from apps.projects.tasks import advance_quick_create_task
job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id)
if _is_finished(job):
return
adopted = job.project.script_versions.filter(is_adopted=True).first()
if adopted is not None and adopted.segments.exists():
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
return
try:
_consume_script_agent(job)
except Exception as exc: # noqa: BLE001 — 脚本失败必须落终态
logger.exception("quick create script failed for job %s", job.id)
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
return
job.refresh_from_db(fields=["status"])
if _is_finished(job):
return
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
def _adopted_script(project: Project):
script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if script is not None and script.segments.exists():
return script
return None
def _parse_iso(value) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value))
except (TypeError, ValueError):
return None
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed)
return parsed
def _advance_product(job: QuickCreateJob) -> int:
"""商品已在提交接口建好;这里只补卖点并切到脚本阶段,让页面先看到「识别商品」。"""
from apps.products.models import ProductSellingPoint
product = job.project.product
if product is not None and not product.selling_points.exists():
ProductSellingPoint.objects.create(
product=product,
title=product.title,
detail="由极速成片根据商品名称自动填写",
sort_order=0,
)
_save_job(
job,
status=QuickCreateJob.Status.RUNNING,
phase=QuickCreateJob.Phase.SCRIPT,
progress=18,
message="已识别商品与卖点,正在推荐脚本方向",
)
return 1
def _advance_script(job: QuickCreateJob) -> int | None:
if _adopted_script(job.project) is not None:
_save_job(job, phase=QuickCreateJob.Phase.ASSETS, progress=48, message="脚本已生成,正在匹配模特与场景")
return 1
metadata = dict(job.metadata or {})
if not metadata.get("script_started"):
from apps.projects.tasks import run_quick_script_task
metadata["script_started"] = True
metadata["script_started_at"] = timezone.now().isoformat()
_save_job(
job,
metadata=metadata,
progress=28,
message="正在根据商品名称与图片生成带货脚本",
)
run_quick_script_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
return SCRIPT_POLL_SECONDS
failed = (
AITask.objects.filter(
project=job.project,
task_type=AITask.Type.SCRIPT_GENERATION,
status=AITask.Status.FAILED,
)
.order_by("-created_at")
.first()
)
if failed is not None:
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
return None
started_at = _parse_iso(metadata.get("script_started_at"))
if started_at and timezone.now() - started_at > SCRIPT_TIMEOUT:
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
return None
elapsed = int((timezone.now() - started_at).total_seconds()) if started_at else 0
_save_job(job, progress=min(44, 28 + elapsed // 8), message="正在生成分镜脚本…")
return SCRIPT_POLL_SECONDS
def _ensure_fallback_entities(project: Project) -> list[dict]:
"""短脚本偶尔只提到商品;极速模式仍补齐一名模特和一个空镜场景,保证自动链路可走完。"""
metadata = dict(project.metadata or {})
entities = [dict(item) for item in (metadata.get("script_entities") or []) if isinstance(item, dict)]
types = {str(item.get("type") or "") for item in entities}
added_ids: list[str] = []
if "character" not in types:
entities.append(
{
"id": "quick_character_1",
"type": "character",
"name": "推荐模特",
"visual_prompt": f"专业电商测评模特,亲和自然,适合展示{project.product.title}",
"ref_index": 1,
}
)
added_ids.append("quick_character_1")
if "scene" not in types:
settings = _quick_settings(project)
entities.append(
{
"id": "quick_scene_1",
"type": "scene",
"name": "推荐场景",
"visual_prompt": (
f"明亮干净的电商产品展示空间,适合突出{project.product.title},"
f"{settings['aspect_ratio']}构图"
),
"ref_index": 2,
}
)
added_ids.append("quick_scene_1")
if added_ids:
metadata["script_entities"] = entities
metadata["cast"] = [item["name"] for item in entities if item.get("type") == "character"]
metadata["scenes"] = [item["name"] for item in entities if item.get("type") == "scene"]
metadata["cast_prompts"] = {
item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "character"
}
metadata["scene_prompts"] = {
item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "scene"
}
project.metadata = metadata
project.save(update_fields=["metadata", "updated_at"])
script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if script is not None:
for segment in script.segments.all():
refs = list(segment.entity_refs or [])
segment.entity_refs = [*refs, *(item for item in added_ids if item not in refs)]
segment.save(update_fields=["entity_refs", "updated_at"])
return entities
def _start_base_assets(job: QuickCreateJob) -> None:
project = job.project
user = job.created_by or project.created_by
if user is None:
raise ValueError("极速成片任务缺少创建人")
with transaction.atomic():
job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id)
metadata = dict(job.metadata or {})
if metadata.get("base_asset_task_ids") or metadata.get("assets_started"):
return
metadata["assets_started"] = True
job.metadata = metadata
job.save(update_fields=["metadata", "updated_at"])
entities = _ensure_fallback_entities(job.project)
specs = [
(BaseAssetGroup.Kind.PRODUCT, job.project.product.title, job.project.product.title),
*[
(BaseAssetGroup.Kind.PERSON, str(item.get("visual_prompt") or item.get("name") or "电商模特"), str(item.get("name") or "推荐模特"))
for item in entities
if item.get("type") == "character"
],
*[
(BaseAssetGroup.Kind.SCENE, str(item.get("visual_prompt") or item.get("name") or "电商场景"), str(item.get("name") or "推荐场景"))
for item in entities
if item.get("type") == "scene"
],
]
task_ids = []
for kind, prompt, label in specs:
task = generate_base_asset(project=job.project, user=user, kind=kind, prompt=prompt, label=label)
task_ids.append(str(task.id))
metadata = dict(job.metadata or {})
metadata["base_asset_task_ids"] = task_ids
_save_job(job, metadata=metadata, message="正在生成商品、模特与场景资产", progress=52)
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
def _task_failure(tasks) -> AITask | None:
return next(
(
task
for task in tasks
if task.status == AITask.Status.FAILED
or task.status not in _ACTIVE_TASK_STATUSES | {AITask.Status.SUCCEEDED}
),
None,
)
def _task_public_error(task: AITask) -> str:
from apps.ai.generation_errors import public_error_for_task
public_error = public_error_for_task(task)
return public_error.fallback_message if public_error else "生成任务失败,请稍后重试"
def _advance_assets(job: QuickCreateJob) -> int | None:
metadata = dict(job.metadata or {})
base_ids = metadata.get("base_asset_task_ids") or []
if not base_ids:
_start_base_assets(job)
return POLL_DELAY_SECONDS
tasks = list(AITask.objects.filter(project=job.project, id__in=base_ids))
failed = _task_failure(tasks)
if failed is not None:
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
return None
if len(tasks) < len(base_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in tasks):
done = sum(task.status == AITask.Status.SUCCEEDED for task in tasks)
_save_job(job, progress=min(64, 52 + done * 4), message="正在匹配并生成模特与场景")
return POLL_DELAY_SECONDS
triview_ids = metadata.get("triview_task_ids")
if triview_ids is None:
person_groups = list(
job.project.base_asset_groups.filter(
kind=BaseAssetGroup.Kind.PERSON,
task_id__in=base_ids,
adopted_asset__isnull=False,
).select_related("adopted_asset")
)
triview_ids = [
str(generate_person_triview(project=job.project, user=job.created_by or job.project.created_by, portrait_asset=group.adopted_asset).id)
for group in person_groups
]
metadata["triview_task_ids"] = triview_ids
_save_job(job, metadata=metadata, progress=65, message="正在为推荐模特生成三视图")
return POLL_DELAY_SECONDS
triview_tasks = list(AITask.objects.filter(project=job.project, id__in=triview_ids))
failed = _task_failure(triview_tasks)
if failed is not None:
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
return None
if len(triview_tasks) < len(triview_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in triview_tasks):
_save_job(job, progress=68, message="正在为推荐模特生成三视图")
return POLL_DELAY_SECONDS
for group in job.project.base_asset_groups.filter(adopted_asset__isnull=False):
if group.kind != BaseAssetGroup.Kind.PRODUCT:
group_meta = dict(group.metadata or {})
group_meta["adopt"] = "adopted"
group.metadata = group_meta
group.save(update_fields=["metadata", "updated_at"])
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
job.project.current_stage = ProjectStage.Stage.STORYBOARD
job.project.status = Project.Status.STORYBOARDING
job.project.save(update_fields=["current_stage", "status", "updated_at"])
_save_job(
job,
phase=QuickCreateJob.Phase.PRODUCTION,
progress=72,
message="正在生成故事板与镜头画面",
)
return 1
def _reviews_ready(job: QuickCreateJob) -> bool | None:
"""True=可出视频,False=继续等,None=审核失败且任务已终止。"""
if not assets_client.is_enabled():
return True
poll_team_reviews(job.team)
blockers = collect_video_review_blockers(job.project)
if not blockers:
return True
if any(item.get("review_status") == "failed" for item in blockers):
fail_quick_create(job, "生成素材未通过审核,请进入专业模式调整后重试")
return None
metadata = dict(job.metadata or {})
wait_started = metadata.get("review_wait_started")
if not wait_started:
metadata["review_wait_started"] = timezone.now().isoformat()
_save_job(job, metadata=metadata)
else:
try:
started_at = datetime.fromisoformat(str(wait_started))
if timezone.is_naive(started_at):
started_at = timezone.make_aware(started_at)
if timezone.now() - started_at > timedelta(minutes=20):
fail_quick_create(job, "素材质量检查等待超时,请进入专业模式查看")
return None
except (TypeError, ValueError):
metadata["review_wait_started"] = timezone.now().isoformat()
_save_job(job, metadata=metadata)
for item in blockers:
if not item.get("review_status") and item.get("asset_id"):
asset = Asset.objects.filter(team=job.team, id=item["asset_id"]).first()
if asset is not None:
submit_asset_for_review(asset)
_save_job(job, progress=82, message="故事板已完成,正在进行视频素材质量检查")
return False
def _start_videos(job: QuickCreateJob) -> None:
from apps.projects.tasks import poll_video_segment_task
settings = _quick_settings(job.project)
for segment in job.project.video_segments.order_by("sort_order"):
if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}:
continue
submit_video_segment(
video_segment=segment,
user=job.created_by or job.project.created_by,
prompt="极速成片自动生成,严格遵循本镜故事板与脚本。",
model_config_id=settings["video_model_config_id"] or None,
aspect_ratio=settings["aspect_ratio"],
resolution=settings["resolution"],
)
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
metadata = dict(job.metadata or {})
metadata["video_started"] = True
_save_job(
job,
metadata=metadata,
progress=86,
message=f"正在生成{settings['total_duration']}秒 {settings['aspect_ratio']} 视频",
)
def _start_export(job: QuickCreateJob) -> None:
from apps.projects.tasks import run_export_job_task
from apps.ai.video_pricing import get_resolution
project = job.project
settings = _quick_settings(project)
total_duration = sum(project.video_segments.values_list("target_duration_seconds", flat=True)) or 15
width, height = get_resolution(settings["aspect_ratio"], settings["resolution"])
with transaction.atomic():
timeline, _ = Timeline.objects.get_or_create(
project=project,
defaults={
"name": f"{project.name} Timeline",
"duration_seconds": total_duration,
"aspect_ratio": settings["aspect_ratio"],
"resolution": f"{width}x{height}",
},
)
timeline.duration_seconds = total_duration
timeline.aspect_ratio = settings["aspect_ratio"]
timeline.resolution = f"{width}x{height}"
timeline.save(update_fields=["duration_seconds", "aspect_ratio", "resolution", "updated_at"])
timeline.clips.all().delete()
start_ms = 0
for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"):
TimelineClip.objects.create(
timeline=timeline,
asset=segment.adopted_version.asset,
sort_order=segment.sort_order,
start_ms=start_ms,
duration_ms=segment.target_duration_seconds * 1000,
)
start_ms += segment.target_duration_seconds * 1000
export_job = create_export_job(timeline=timeline, user=job.created_by or project.created_by)
run_export_job_task.delay(str(export_job.id))
metadata = dict(job.metadata or {})
metadata["export_job_id"] = str(export_job.id)
_save_job(job, metadata=metadata, progress=96, message="各镜头已生成,正在合成为完整视频")
def _videos_ready(job: QuickCreateJob) -> bool:
segments = list(job.project.video_segments.order_by("sort_order"))
if not segments:
return False
return all(
segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id
for segment in segments
)
def _can_complete(job: QuickCreateJob) -> bool:
if not _videos_ready(job):
return False
segments = list(job.project.video_segments.all())
if len(segments) <= 1:
return True
export_job_id = (job.metadata or {}).get("export_job_id")
if not export_job_id:
return False
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
return export_job is not None and export_job.status == ExportJob.Status.SUCCEEDED
def _complete(job: QuickCreateJob) -> None:
_save_job(
job,
status=QuickCreateJob.Status.SUCCEEDED,
phase=QuickCreateJob.Phase.COMPLETE,
progress=100,
message="视频已生成",
error_message="",
completed_at=timezone.now(),
)
def _advance_production(job: QuickCreateJob) -> int | None:
metadata = dict(job.metadata or {})
if not metadata.get("storyboard_started"):
submit_storyboard(project=job.project, user=job.created_by or job.project.created_by, prompt="")
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
metadata["storyboard_started"] = True
_save_job(job, metadata=metadata, progress=74, message="正在生成故事板与镜头画面")
if job.project.current_stage != ProjectStage.Stage.VIDEO:
result = poll_storyboard(project=job.project, user=job.created_by or job.project.created_by)
if result.get("status") == "failed":
fail_quick_create(job, str(result.get("error") or "故事板生成失败"))
return None
if result.get("status") != "succeeded":
total = max(1, int(result.get("total") or 1))
done = int(result.get("done") or 0)
_save_job(job, progress=min(80, 74 + round(done / total * 6)), message=f"正在生成故事板({done}/{total})")
return POLL_DELAY_SECONDS
finish_storyboard_stage(job.project)
review_state = _reviews_ready(job)
if review_state is None:
return None
if not review_state:
return POLL_DELAY_SECONDS
if not (job.metadata or {}).get("video_started"):
_start_videos(job)
return POLL_DELAY_SECONDS
segments = list(job.project.video_segments.order_by("sort_order"))
failed = next((segment for segment in segments if segment.status == VideoSegment.Status.FAILED), None)
if failed is not None:
fail_quick_create(job, failed.error_message or f"第{failed.sort_order + 1}段视频生成失败")
return None
completed = sum(
1
for segment in segments
if segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id
)
if completed < len(segments):
_save_job(job, progress=min(94, 86 + completed * 3), message=f"正在生成视频({completed}/{len(segments)})")
return POLL_DELAY_SECONDS
finish_video_stage(job.project)
if len(segments) == 1:
_complete(job)
return None
export_job_id = (job.metadata or {}).get("export_job_id")
if not export_job_id:
_start_export(job)
return POLL_DELAY_SECONDS
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
if export_job is None:
raise ValueError("视频合成任务不存在")
if export_job.status == ExportJob.Status.FAILED:
fail_quick_create(job, "视频片段已生成,但自动合成失败,请进入专业模式查看", internal_error=export_job.error_message)
return None
if export_job.status != ExportJob.Status.SUCCEEDED:
_save_job(job, progress=max(96, min(99, int(export_job.progress or 0))), message="正在合成为完整视频")
return POLL_DELAY_SECONDS
_complete(job)
return None
_local_script_jobs: set[str] = set()
def _script_task_started(job: QuickCreateJob) -> bool:
return job.project.ai_tasks.filter(task_type=AITask.Type.SCRIPT_GENERATION).exists()
def _claim_next_advance(job_id: str, delay_seconds: int) -> bool:
"""同一任务同一时刻只允许一枚后续推进,避免轮询把队列打爆。"""
with transaction.atomic():
job = QuickCreateJob.objects.select_for_update().get(id=job_id)
metadata = dict(job.metadata or {})
eta = _parse_iso(metadata.get("next_advance_at"))
if eta and eta > timezone.now():
return False
metadata["next_advance_at"] = (timezone.now() + timedelta(seconds=max(1, delay_seconds))).isoformat()
job.metadata = metadata
job.save(update_fields=["metadata", "updated_at"])
return True
def _run_quick_script_in_thread(job_id: str) -> None:
"""共享 Redis 上的旧 worker 会把未注册任务直接丢掉;本机线程兜底,避免页面永久转圈。"""
if job_id in _local_script_jobs:
return
_local_script_jobs.add(job_id)
def _worker() -> None:
try:
consume_quick_script(job_id)
except Exception as exc: # noqa: BLE001 — 线程失败必须落终态
job = QuickCreateJob.objects.select_related("project").filter(id=job_id).first()
if job is not None and not _is_finished(job):
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
finally:
_local_script_jobs.discard(job_id)
connections.close_all()
threading.Thread(target=_worker, daemon=True, name=f"quick-script-{job_id[:8]}").start()
def recover_quick_create(job: QuickCreateJob) -> None:
"""前端轮询时把卡住的编排拉起来:超时落失败,被旧 worker 丢掉的脚本改走本机线程。"""
job.refresh_from_db()
if job.status == QuickCreateJob.Status.FAILED and _can_complete(job):
_complete(job)
return
if _is_finished(job):
return
job_id = str(job.id)
metadata = dict(job.metadata or {})
if job.phase == QuickCreateJob.Phase.SCRIPT and metadata.get("script_started"):
started_at = _parse_iso(metadata.get("script_started_at"))
adopted = _adopted_script(job.project)
local_running = job_id in _local_script_jobs
timed_out = bool(started_at and timezone.now() - started_at > SCRIPT_TIMEOUT)
stolen = bool(
started_at
and timezone.now() - started_at > SCRIPT_STOLEN_AFTER
and adopted is None
and not local_running
)
if stolen:
metadata["script_local"] = True
metadata["script_started_at"] = timezone.now().isoformat()
_save_job(job, metadata=metadata, message="正在继续生成分镜脚本…")
_run_quick_script_in_thread(job_id)
return
if timed_out and adopted is None and not local_running:
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
return
if timezone.now() - job.updated_at > STALE_AFTER and _claim_next_advance(str(job.id), SCRIPT_POLL_SECONDS):
from apps.projects.tasks import advance_quick_create_task
try:
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
except Exception: # noqa: BLE001 — 队列不可用时就地推进一步,避免永久 loading
advance_quick_create(str(job.id))
def advance_quick_create(job_id: str) -> int | None:
"""推进一个状态并返回下次轮询秒数;返回 None 表示终态。
一次只做一段:识别商品 → 脚本(独立任务) → 资产 → 故事板/视频/合成。
脚本不再在编排任务里同步吃完整条 SSE,避免页面一直停在「推荐脚本方向」。
"""
job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id)
if _is_finished(job):
return None
try:
if job.status == QuickCreateJob.Status.QUEUED:
_save_job(job, status=QuickCreateJob.Status.RUNNING, progress=8, message="正在识别商品图片与卖点")
if job.phase == QuickCreateJob.Phase.PRODUCT:
return _advance_product(job)
if job.phase == QuickCreateJob.Phase.SCRIPT:
return _advance_script(job)
if job.phase == QuickCreateJob.Phase.ASSETS:
return _advance_assets(job)
if job.phase == QuickCreateJob.Phase.PRODUCTION:
return _advance_production(job)
return None
except Exception as exc: # noqa: BLE001 — 编排失败必须落可恢复终态,不能留下永久 loading
logger.exception("quick create job %s failed", job.id)
if _can_complete(job):
_complete(job)
return None
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
return None