完成极速成片

This commit is contained in:
Azmat@qq.com
2026-08-25 18:46:20 +08:00
parent df6784b90c
commit 2f70d3e8a0
15 changed files with 495 additions and 146 deletions
@@ -20,10 +20,10 @@ from apps.ai.services import (
create_export_job,
generate_base_asset,
generate_person_triview,
get_default_model,
poll_storyboard,
submit_storyboard,
submit_video_segment,
video_segment_has_inflight_task,
)
from apps.assets import assets_client
from apps.assets.models import Asset
@@ -43,11 +43,13 @@ from apps.projects.services.pipeline import (
adopt_script_version,
finish_storyboard_stage,
finish_video_stage,
sync_video_segments_to_script,
)
logger = logging.getLogger(__name__)
POLL_DELAY_SECONDS = 10
QUICK_SCRIPT_MODEL_NAME = "doubao-seed-2-1-pro-260628"
SCRIPT_POLL_SECONDS = 5
SCRIPT_TIMEOUT = timedelta(minutes=4)
SCRIPT_STOLEN_AFTER = timedelta(seconds=25)
@@ -70,6 +72,24 @@ def _is_finished(job: QuickCreateJob) -> bool:
return job.status in _FINISHED_STATUSES
def get_quick_script_model() -> ModelConfig | None:
"""极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。
不回退到默认文本模型,避免默认配置切到 DeepSeek 后两条创作链路的成片质量不一致。
"""
return (
ModelConfig.objects.select_related("provider")
.filter(
name=QUICK_SCRIPT_MODEL_NAME,
capability=ModelConfig.Capability.TEXT,
status=ModelConfig.Status.ACTIVE,
provider__status="active",
)
.order_by("created_at")
.first()
)
def _quick_settings(project: Project) -> dict:
wizard = dict((project.metadata or {}).get("wizard") or {})
return {
@@ -97,16 +117,22 @@ TRANSIENT_RETRY_LIMIT = 8
def _safe_error(exc: Exception) -> str:
raw = str(exc or "").strip()
lower = raw.lower()
if QUICK_SCRIPT_MODEL_NAME in lower:
return "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"
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 "生成素材未通过审核,请进入专业模式调整后重试"
if _is_retryable_exc(exc):
return "脚本已保留,后续步骤遇到网络波动。点重试会从上次进度继续"
return "极速成片暂未完成,请稍后重试或进入专业模式查看"
def _is_retryable_exc(exc: Exception) -> bool:
if isinstance(exc, TimeoutError):
return True
text = str(exc or "").lower()
return any(
token in text
@@ -123,6 +149,10 @@ def _is_retryable_exc(exc: Exception) -> bool:
)
def _transient_internal(job: QuickCreateJob) -> bool:
return _is_retryable_exc(Exception(str((job.metadata or {}).get("internal_error") or "")))
def _videos_have_started(job: QuickCreateJob) -> bool:
if (job.metadata or {}).get("video_started"):
return True
@@ -131,9 +161,12 @@ def _videos_have_started(job: QuickCreateJob) -> bool:
def _should_mark_project_failed(job: QuickCreateJob) -> bool:
"""编排在「还没申请生成视频」时超时,不能把整个项目打成失败。"""
if job.phase != QuickCreateJob.Phase.PRODUCTION:
return True
return _videos_have_started(job)
if job.phase == QuickCreateJob.Phase.PRODUCTION:
return _videos_have_started(job)
# 脚本已经落库时,资产/脚本阶段的网络抖动不要把整个项目打成失败。
if job.phase in {QuickCreateJob.Phase.SCRIPT, QuickCreateJob.Phase.ASSETS} and _adopted_script(job.project) is not None:
return False
return True
def restore_false_failed_quick_creates(team) -> None:
@@ -241,9 +274,9 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
user = job.created_by or project.created_by
if user is None:
raise ValueError("极速成片任务缺少创建人")
model_config = get_default_model(ModelConfig.Capability.TEXT)
model_config = get_quick_script_model()
if model_config is None:
raise ValueError("no active text model configured")
raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured")
error_detail = ""
stream = stream_script_agent(
@@ -452,11 +485,8 @@ def _start_base_assets(job: QuickCreateJob) -> None:
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"):
if metadata.get("base_asset_task_ids"):
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),
@@ -477,6 +507,7 @@ def _start_base_assets(job: QuickCreateJob) -> None:
task_ids.append(str(task.id))
metadata = dict(job.metadata or {})
metadata["base_asset_task_ids"] = task_ids
metadata["assets_started"] = True
_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
@@ -622,14 +653,33 @@ def _reviews_ready(job: QuickCreateJob) -> bool | None:
def _start_videos(job: QuickCreateJob) -> None:
from apps.projects.tasks import poll_video_segment_task
adopted = _adopted_script(job.project)
if adopted is not None:
sync_video_segments_to_script(job.project, adopted)
settings = _quick_settings(job.project)
segments = list(job.project.video_segments.order_by("sort_order"))
claimed_ids: list = []
with transaction.atomic():
segments = list(
VideoSegment.objects.select_for_update()
.filter(project=job.project)
.order_by("sort_order")
)
for segment in segments:
if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.SUCCEEDED}:
continue
if video_segment_has_inflight_task(segment):
continue
if segment.status == VideoSegment.Status.QUEUED and timezone.now() - segment.updated_at < timedelta(minutes=2):
continue
segment.status = VideoSegment.Status.QUEUED
segment.save(update_fields=["status", "updated_at"])
claimed_ids.append(segment.id)
submitted = 0
last_error: Exception | None = None
for segment in segments:
if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}:
submitted += 1
continue
for segment_id in claimed_ids:
segment = VideoSegment.objects.get(id=segment_id)
try:
submit_video_segment(
video_segment=segment,
@@ -644,13 +694,22 @@ def _start_videos(job: QuickCreateJob) -> None:
except Exception as exc: # noqa: BLE001 — 单镜提交失败下一轮再试,不把整单打断
last_error = exc
logger.warning("quick create job %s failed to start video %s: %s", job.id, segment.sort_order, exc)
if segment.status == VideoSegment.Status.QUEUED and not video_segment_has_inflight_task(segment):
segment.status = VideoSegment.Status.NOT_STARTED
segment.save(update_fields=["status", "updated_at"])
if not _is_retryable_exc(exc):
raise
break
metadata = dict(job.metadata or {})
if last_error is not None:
metadata["internal_error"] = str(last_error)[:2000]
if submitted >= len(segments) and segments:
segments = list(job.project.video_segments.order_by("sort_order"))
started = all(
segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}
or video_segment_has_inflight_task(segment)
for segment in segments
)
if started and segments:
metadata["video_started"] = True
_save_job(
job,
@@ -757,6 +816,7 @@ def _advance_production(job: QuickCreateJob) -> int | None:
if not review_state:
return POLL_DELAY_SECONDS
job.refresh_from_db(fields=["metadata"])
if not (job.metadata or {}).get("video_started"):
_start_videos(job)
return POLL_DELAY_SECONDS
@@ -897,6 +957,17 @@ def recover_quick_create(job: QuickCreateJob) -> None:
return
if job.status == QuickCreateJob.Status.FAILED:
_restore_project_after_orchestrator_timeout(job)
job.refresh_from_db()
retries = int((job.metadata or {}).get("transient_retries") or 0)
if (
job.status == QuickCreateJob.Status.FAILED
and job.phase in {QuickCreateJob.Phase.SCRIPT, QuickCreateJob.Phase.ASSETS}
and _adopted_script(job.project) is not None
and _transient_internal(job)
and retries < TRANSIENT_RETRY_LIMIT
):
_save_job(job, status=QuickCreateJob.Status.RUNNING, error_message="", message="网络波动,正在继续生成…")
_enqueue_advance(job)
return
if _is_finished(job):
return