完成极速成片

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
@@ -45,15 +45,15 @@ class QuickCreateApiTests(TestCase):
category=Asset.Category.PRODUCT_IMAGE,
)
@patch("apps.projects.views.get_default_model", return_value=object())
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
@patch("apps.projects.views.get_default_model")
@patch("apps.projects.views.advance_quick_create_task.apply_async")
@patch("apps.projects.views.require_worker_task")
@patch("apps.projects.views._store_uploaded_asset")
def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model):
def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
store_asset.side_effect = self._uploaded_asset
video_model_id = uuid.uuid4()
get_model.side_effect = [
object(),
object(),
SimpleNamespace(
id=video_model_id,
@@ -95,18 +95,20 @@ class QuickCreateApiTests(TestCase):
self.assertEqual(enqueue.call_args.kwargs["args"], [str(job.id)])
self.assertEqual(enqueue.call_args.kwargs["queue"], "airshelf.quick")
require_worker_task.assert_called_once_with("apps.projects.tasks.advance_quick_create_task")
self.assertEqual(get_model.call_count, 3)
self.assertEqual(get_model.call_count, 2)
get_quick_model.assert_called_once()
self.assertEqual(len(response.data["product_images"]), 2)
self.assertEqual(
{item["asset_id"] for item in response.data["product_images"]},
{str(image.asset_id) for image in product.images.order_by("sort_order")},
)
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
@patch("apps.projects.views.get_default_model", return_value=object())
@patch("apps.projects.views.advance_quick_create_task.apply_async")
@patch("apps.projects.views.require_worker_task")
@patch("apps.projects.views._store_uploaded_asset")
def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model):
def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
source_asset = Asset.objects.create(
team=self.team,
created_by=self.user,
@@ -117,7 +119,7 @@ class QuickCreateApiTests(TestCase):
)
source = Product.objects.create(team=self.team, created_by=self.user, title="旧商品", cover_asset=source_asset)
ProductImage.objects.create(product=source, asset=source_asset, sort_order=0, is_primary=True)
get_model.side_effect = [object(), object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})]
get_model.side_effect = [object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})]
response = self.client.post(
"/api/projects/quick-create/",
@@ -138,11 +140,14 @@ class QuickCreateApiTests(TestCase):
self.assertEqual(product.images.first().asset_id, source_asset.id)
self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id))
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
@patch("apps.projects.views.get_default_model", return_value=object())
@patch("apps.projects.views.require_worker_task")
def test_submit_rejects_foreign_source_product_images(self, require_worker_task):
def test_submit_rejects_foreign_source_product_images(self, require_worker_task, get_model, get_quick_model):
other = User.objects.create_user(username="quick-image-other", password="pass")
other_team = Team.objects.create(name="Image Other Team", owner=other)
foreign = Product.objects.create(team=other_team, created_by=other, title="别人的图")
get_model.side_effect = [object(), SimpleNamespace(metadata={})]
response = self.client.post(
"/api/projects/quick-create/",
{
@@ -193,9 +198,10 @@ class QuickCreateApiTests(TestCase):
self.assertEqual(response.status_code, 400)
self.assertFalse(Product.objects.filter(title="错误文件").exists())
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
@patch("apps.projects.views.get_default_model", return_value=object())
@patch("apps.projects.views.require_worker_task")
def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model):
def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model, get_quick_model):
provider = ModelProvider.objects.create(name="quick-video-provider", display_name="Quick", status="active")
model = ModelConfig.objects.create(
provider=provider,
@@ -520,6 +526,51 @@ class QuickCreateCoordinatorTests(TestCase):
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
self.assertEqual(self.job.phase, QuickCreateJob.Phase.COMPLETE)
@patch("apps.projects.services.quick_create.generate_base_asset", side_effect=TimeoutError("Timeout reading from socket"))
def test_asset_start_timeout_retries_without_locking_the_job(self, _generate):
script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场")
self.job.status = QuickCreateJob.Status.RUNNING
self.job.phase = QuickCreateJob.Phase.ASSETS
self.job.save(update_fields=["status", "phase", "updated_at"])
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.project.refresh_from_db()
self.assertEqual(delay, 10)
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
self.assertFalse(self.job.metadata.get("assets_started"))
self.assertFalse(self.job.metadata.get("base_asset_task_ids"))
self.assertNotEqual(self.project.status, Project.Status.FAILED)
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
def test_recover_resumes_asset_timeout_after_script_exists(self, enqueue):
script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场")
self.job.status = QuickCreateJob.Status.FAILED
self.job.phase = QuickCreateJob.Phase.ASSETS
self.job.error_message = "脚本已保留,后续步骤遇到网络波动。点重试会从上次进度继续"
self.job.metadata = {"internal_error": "Timeout reading from socket", "transient_retries": 1}
self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"])
recover_quick_create(self.job)
self.job.refresh_from_db()
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
enqueue.assert_called_once()
def test_timeout_after_script_does_not_fail_project(self):
script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场")
self.job.status = QuickCreateJob.Status.RUNNING
self.job.phase = QuickCreateJob.Phase.ASSETS
self.job.save(update_fields=["status", "phase", "updated_at"])
fail_quick_create(self.job, "脚本已保留,后续步骤遇到网络波动。点重试会从上次进度继续", internal_error="Timeout reading from socket")
self.job.refresh_from_db()
self.project.refresh_from_db()
self.assertEqual(self.job.status, QuickCreateJob.Status.FAILED)
self.assertNotEqual(self.project.status, Project.Status.FAILED)
@patch("apps.projects.services.quick_create._reviews_ready", return_value=True)
def test_production_counts_ready_videos_without_adding_version_ids(self, _reviews):
self.project.video_segments.exclude(sort_order=0).delete()
@@ -623,3 +674,16 @@ class QuickCreateCoordinatorTests(TestCase):
self.assertEqual(call.kwargs["model_config_id"], "11111111-1111-4111-8111-111111111111")
self.job.refresh_from_db()
self.assertIn("30秒 16:9", self.job.message)
@patch("apps.projects.tasks.poll_video_segment_task.apply_async")
@patch("apps.projects.services.quick_create.submit_video_segment")
def test_start_videos_does_not_resubmit_when_called_twice(self, submit_video, _schedule_poll):
self.project.video_segments.exclude(sort_order__lt=2).delete()
self.assertEqual(self.project.video_segments.count(), 2)
_start_videos(self.job)
_start_videos(self.job)
self.assertEqual(submit_video.call_count, 2)
statuses = list(self.project.video_segments.order_by("sort_order").values_list("status", flat=True))
self.assertEqual(statuses, [VideoSegment.Status.QUEUED, VideoSegment.Status.QUEUED])
+18 -3
View File
@@ -499,7 +499,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
if total_duration not in {15, 30, 45, 60}:
return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST)
text_model = get_default_model(ModelConfig.Capability.TEXT)
# 极速成片和专业创作使用同一款豆包脚本模型;绝不因默认模型变化而退回 DeepSeek。
from .services.quick_create import get_quick_script_model
text_model = get_quick_script_model()
image_model = get_default_model(ModelConfig.Capability.IMAGE)
requested_video_model_id = str(request.data.get("video_model_config_id") or "").strip()
video_model = None
@@ -523,9 +526,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
missing = [
label
for model, label in ((text_model, "文本"), (image_model, "图像"), (video_model, "视频"))
for model, label in ((image_model, "图像"), (video_model, "视频"))
if model is None
]
if text_model is None:
return Response(
{"detail": "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
if missing:
return Response(
{"detail": f"当前缺少可用的{'、'.join(missing)}模型,请联系管理员配置"},
@@ -988,7 +996,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"""本项目在途的基础资产出图任务(立绘/商品图/三视图)。前端进基础资产趴时据此重建
「生成中」占位卡 loading —— 出图在 worker 跑,刷新页面后内存态 genBusy 丢了,用它从后端认领恢复。"""
project = self.get_object()
inflight = [AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING]
inflight = [
AITask.Status.CREATED,
AITask.Status.RESERVED,
AITask.Status.SUBMITTED,
AITask.Status.POLLING,
AITask.Status.POSTPROCESSING,
]
image_types = [AITask.Type.PRODUCT_IMAGE, AITask.Type.PERSON_IMAGE, AITask.Type.SCENE_IMAGE]
tasks = AITask.objects.filter(project=project, task_type__in=image_types, status__in=inflight)
pending = []
@@ -999,6 +1013,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
"kind": payload.get("kind") or "",
"label": payload.get("label") or "",
"is_triview": bool(payload.get("triview_of")),
"triview_of": payload.get("triview_of") or "",
"status": task.status,
})
return Response({"pending": pending})