完成极速成片
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user