完成极速成品和脚本优化

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
@@ -0,0 +1,92 @@
# Generated by Django 5.1.15 on 2026-08-24
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0009_team_price_multiplier"),
("projects", "0008_scripttemplate"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="QuickCreateJob",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
models.CharField(
choices=[
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
("cancelled", "Cancelled"),
],
default="queued",
max_length=24,
),
),
(
"phase",
models.CharField(
choices=[
("product", "Product"),
("script", "Script"),
("assets", "Assets"),
("production", "Storyboard and Video"),
("complete", "Complete"),
],
default="product",
max_length=24,
),
),
("progress", models.PositiveSmallIntegerField(default=0)),
("message", models.CharField(blank=True, max_length=255)),
("error_message", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("completed_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"project",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="quick_create_job",
to="projects.project",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["team", "status", "-created_at"], name="projects_qu_team_id_a8a924_idx"),
models.Index(fields=["team", "phase"], name="projects_qu_team_id_98380e_idx"),
],
},
),
]
+38 -1
View File
@@ -70,6 +70,44 @@ class ProjectStage(TimeStampedModel):
ordering = ["created_at"]
class QuickCreateJob(TeamOwnedModel):
"""“极速成片”自动编排任务。
AI 子任务仍落在 AITask;本表只保存跨商品、脚本、资产、故事板、视频和导出的编排状态,
让页面刷新、浏览器关闭后都能继续查询同一条流水线。
"""
class Status(models.TextChoices):
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
CANCELLED = "cancelled", "Cancelled"
class Phase(models.TextChoices):
PRODUCT = "product", "Product"
SCRIPT = "script", "Script"
ASSETS = "assets", "Assets"
PRODUCTION = "production", "Storyboard and Video"
COMPLETE = "complete", "Complete"
project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="quick_create_job")
status = models.CharField(max_length=24, choices=Status.choices, default=Status.QUEUED)
phase = models.CharField(max_length=24, choices=Phase.choices, default=Phase.PRODUCT)
progress = models.PositiveSmallIntegerField(default=0)
message = models.CharField(max_length=255, blank=True)
error_message = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["team", "status", "-created_at"], name="projects_qu_team_id_a8a924_idx"),
models.Index(fields=["team", "phase"], name="projects_qu_team_id_98380e_idx"),
]
class ScriptVersion(TimeStampedModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="script_versions")
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="script_versions")
@@ -334,4 +372,3 @@ class ExportJob(TimeStampedModel):
progress = models.PositiveIntegerField(default=0)
error_message = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
+132 -1
View File
@@ -10,6 +10,7 @@ from .models import (
ExportJob,
Project,
ProjectStage,
QuickCreateJob,
ScriptSegment,
ScriptTemplate,
ScriptVersion,
@@ -329,6 +330,132 @@ class ExportJobSerializer(serializers.ModelSerializer):
read_only_fields = fields
class QuickCreateJobSerializer(serializers.ModelSerializer):
"""极速成片轮询响应:只下发用户需要的四阶段进度和最终可播结果。"""
project_id = serializers.UUIDField(source="project.id", read_only=True)
product_id = serializers.UUIDField(source="project.product_id", read_only=True)
product_name = serializers.CharField(source="project.product.title", read_only=True)
title = serializers.CharField(source="project.name", read_only=True)
product_images = serializers.SerializerMethodField()
phase_index = serializers.SerializerMethodField()
settings = serializers.SerializerMethodField()
result = serializers.SerializerMethodField()
class Meta:
model = QuickCreateJob
fields = [
"id",
"project_id",
"product_id",
"product_name",
"title",
"product_images",
"status",
"phase",
"phase_index",
"settings",
"progress",
"message",
"error_message",
"result",
"created_at",
"updated_at",
"completed_at",
]
read_only_fields = fields
def get_product_images(self, obj) -> list[dict]:
product = getattr(obj.project, "product", None)
if product is None:
return []
return [
{
"asset_id": str(image.asset_id),
"url": _asset_preview_url(image.asset),
}
for image in product.images.all()
]
def get_phase_index(self, obj) -> int:
return {
QuickCreateJob.Phase.PRODUCT: 0,
QuickCreateJob.Phase.SCRIPT: 1,
QuickCreateJob.Phase.ASSETS: 2,
QuickCreateJob.Phase.PRODUCTION: 3,
QuickCreateJob.Phase.COMPLETE: 3,
}.get(obj.phase, 0)
def get_settings(self, obj) -> dict:
wizard = dict((obj.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 get_result(self, obj) -> dict | None:
if obj.status != QuickCreateJob.Status.SUCCEEDED:
return None
project = obj.project
settings = self.get_settings(obj)
video_url = _final_video_url(project)
segments = list(project.video_segments.all())
if not video_url and len(segments) == 1:
version = segments[0].adopted_version
if version is not None and is_playable_video(version.asset):
video_url = _asset_preview_url(version.asset)
first_shot = next(
(shot for shot in project.storyboard_shots.all() if shot.adopted_version_id),
None,
)
poster_url = _asset_preview_url(first_shot.adopted_version.asset) if first_shot else ""
script = next((version for version in project.script_versions.all() if version.is_adopted), None)
script_meta = (script.metadata or {}) if script is not None else {}
format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get(
script_meta.get("presentation_format"), "智能推荐"
)
structure_label = {
"pain": "痛点解决",
"contrast": "前后对比",
"review": "测评验证",
"scene": "场景种草",
}.get(script_meta.get("video_structure"), "智能推荐")
groups = list(project.base_asset_groups.all())
person = next((group for group in groups if group.kind == BaseAssetGroup.Kind.PERSON and group.adopted_asset_id), None)
scene = next((group for group in groups if group.kind == BaseAssetGroup.Kind.SCENE and group.adopted_asset_id), None)
duration = sum(segment.target_duration_seconds for segment in segments)
video_segments = []
for segment in sorted(segments, key=lambda item: item.sort_order):
version = segment.adopted_version
clip_url = _asset_preview_url(version.asset) if version is not None and is_playable_video(version.asset) else ""
video_segments.append(
{
"id": str(segment.id),
"sort_order": segment.sort_order,
"duration_seconds": segment.target_duration_seconds or 15,
"video_url": clip_url,
"poster_url": poster_url,
}
)
return {
"video_url": video_url,
"poster_url": poster_url,
"duration_seconds": duration or 15,
"aspect_ratio": settings["aspect_ratio"],
"resolution": settings["resolution"],
"video_model": settings["video_model_label"] or settings["video_model_name"],
"structure": structure_label,
"presentation": format_label,
"person": ((person.metadata or {}).get("label") or "自动匹配") if person else "自动匹配",
"scene": ((scene.metadata or {}).get("label") or "自动匹配") if scene else "自动匹配",
"video_segments": video_segments,
}
class ScriptSegmentSerializer(serializers.ModelSerializer):
class Meta:
model = ScriptSegment
@@ -359,13 +486,14 @@ class ProjectListSerializer(serializers.ModelSerializer):
video_segment_count = serializers.IntegerField(read_only=True, default=0)
# 合成成片地址:项目列表的播放按钮据此直接播成片(没合成过为空 → 退回进流水线)
final_video_url = serializers.SerializerMethodField()
quick_create = serializers.SerializerMethodField()
class Meta:
model = Project
fields = [
"id", "name", "product", "product_title", "cover_preview_url",
"status", "current_stage", "script_version_count", "video_segment_count",
"final_video_url",
"final_video_url", "quick_create",
"is_deleted", "purged_at", "created_at", "updated_at",
]
@@ -375,6 +503,9 @@ class ProjectListSerializer(serializers.ModelSerializer):
def get_final_video_url(self, obj) -> str:
return _final_video_url(obj)
def get_quick_create(self, obj) -> bool:
return bool((obj.metadata or {}).get("quick_create"))
class ProjectSerializer(serializers.ModelSerializer):
stages = ProjectStageSerializer(many=True, read_only=True)
+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
+53
View File
@@ -42,3 +42,56 @@ def run_export_job_task(self, export_job_id: str) -> str:
export_job.save(update_fields=["status", "error_message", "updated_at"])
raise
return export_job_id
QUICK_CREATE_QUEUE = "airshelf.quick"
@app.task(bind=True, max_retries=0, soft_time_limit=240, time_limit=270, queue=QUICK_CREATE_QUEUE)
def run_quick_script_task(self, quick_job_id: str) -> str:
"""脚本生成单独跑,避免把整条极速成片编排堵在一次 SSE 消费里。"""
from celery.exceptions import SoftTimeLimitExceeded
from apps.projects.models import QuickCreateJob
from apps.projects.services.quick_create import consume_quick_script, fail_quick_create
try:
consume_quick_script(quick_job_id)
except SoftTimeLimitExceeded:
job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first()
if job is not None and job.status not in {
QuickCreateJob.Status.SUCCEEDED,
QuickCreateJob.Status.FAILED,
QuickCreateJob.Status.CANCELLED,
}:
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
raise
return quick_job_id
@app.task(bind=True, max_retries=0, queue=QUICK_CREATE_QUEUE)
def advance_quick_create_task(self, quick_job_id: str) -> str:
"""一次只推进一个可重入状态,等待型阶段通过重新入队轮询,不占 worker 睡眠。"""
from apps.projects.services.quick_create import advance_quick_create
next_delay = advance_quick_create(quick_job_id)
if next_delay is not None:
from apps.projects.services.quick_create import _claim_next_advance
if not _claim_next_advance(quick_job_id, int(next_delay)):
return quick_job_id
try:
advance_quick_create_task.apply_async(
args=[quick_job_id],
countdown=max(1, int(next_delay)),
queue=QUICK_CREATE_QUEUE,
)
except Exception as exc: # noqa: BLE001 — 重排失败不能留下永久“生成中”
from apps.projects.models import QuickCreateJob
from apps.projects.services.quick_create import fail_quick_create
job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first()
if job is not None:
fail_quick_create(job, "生成队列暂时中断,请稍后重试", internal_error=str(exc))
raise
return quick_job_id
@@ -0,0 +1,490 @@
from datetime import timedelta
from unittest.mock import patch
from types import SimpleNamespace
import uuid
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import ModelConfig, ModelProvider
from apps.assets.models import Asset, AssetFile
from apps.products.models import Product, ProductImage
from apps.projects.models import Project, ProjectStage, QuickCreateJob, ScriptSegment, ScriptVersion, VideoSegment, VideoSegmentVersion
from apps.projects.serializers import ProjectListSerializer, QuickCreateJobSerializer
from apps.projects.services.pipeline import initialize_project_pipeline
from apps.projects.services.quick_create import (
_start_videos,
advance_quick_create,
cancel_quick_create,
recover_quick_create,
)
class QuickCreateApiTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="quick-owner", password="pass")
self.team = Team.objects.create(name="Quick Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.client = APIClient()
self.client.force_authenticate(self.user)
def _uploaded_asset(self, **kwargs):
upload = kwargs["upload"]
return Asset.objects.create(
team=self.team,
created_by=self.user,
name=upload.name,
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PRODUCT_IMAGE,
)
@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_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model):
store_asset.side_effect = self._uploaded_asset
video_model_id = uuid.uuid4()
get_model.side_effect = [
object(),
object(),
SimpleNamespace(
id=video_model_id,
name="doubao-seedance-2-0-fast-260128",
display_name="Seedance 2.0 Fast",
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
),
]
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "轻醒咖啡",
"images": [
SimpleUploadedFile("front.png", b"png-one", content_type="image/png"),
SimpleUploadedFile("detail.webp", b"webp-two", content_type="image/webp"),
],
"aspect_ratio": "9:16",
"resolution": "720p",
"total_duration": "30",
},
format="multipart",
)
self.assertEqual(response.status_code, 202)
product = Product.objects.get(title="轻醒咖啡")
self.assertEqual(product.images.count(), 2)
self.assertEqual(product.cover_asset_id, product.images.order_by("sort_order").first().asset_id)
project = Project.objects.get(product=product)
self.assertTrue(project.metadata["quick_create"])
self.assertEqual(project.metadata["wizard"]["total_duration"], 30)
self.assertEqual(project.metadata["wizard"]["resolution"], "720p")
self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id))
self.assertEqual(project.stages.count(), 5)
self.assertEqual(project.video_segments.count(), 2)
self.assertTrue(product.selling_points.exists())
job = QuickCreateJob.objects.get(project=project)
self.assertEqual(str(job.id), response.data["id"])
enqueue.assert_called_once()
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(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.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):
source_asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="已有主图.png",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PRODUCT_IMAGE,
)
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={})]
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "复用商品图",
"source_product_id": str(source.id),
"aspect_ratio": "9:16",
"resolution": "720p",
"total_duration": "15",
},
format="multipart",
)
self.assertEqual(response.status_code, 202)
store_asset.assert_not_called()
product = Product.objects.get(title="复用商品图")
self.assertEqual(product.images.count(), 1)
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.views.require_worker_task")
def test_submit_rejects_foreign_source_product_images(self, require_worker_task):
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="别人的图")
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "偷图",
"source_product_id": str(foreign.id),
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertFalse(Product.objects.filter(title="偷图").exists())
def test_status_returns_saved_product_images(self):
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="主图.png",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PRODUCT_IMAGE,
)
AssetFile.objects.create(
asset=asset,
object_key="quick/product.png",
bucket="test",
content_type="image/png",
preview_url="https://cdn.example/product.png",
is_primary=True,
)
product = Product.objects.create(team=self.team, created_by=self.user, title="带图商品", cover_asset=asset)
ProductImage.objects.create(product=product, asset=asset, sort_order=0, is_primary=True)
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="带图项目")
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
response = self.client.get(f"/api/projects/quick-create-status/{job.id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["product_images"], [{"asset_id": str(asset.id), "url": "https://cdn.example/product.png"}])
@patch("apps.projects.views.require_worker_task")
def test_submit_rejects_non_image(self, require_worker_task):
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "错误文件",
"images": [SimpleUploadedFile("note.txt", b"text", content_type="text/plain")],
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertFalse(Product.objects.filter(title="错误文件").exists())
@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):
provider = ModelProvider.objects.create(name="quick-video-provider", display_name="Quick", status="active")
model = ModelConfig.objects.create(
provider=provider,
name="quick-fast",
display_name="Quick Fast",
capability=ModelConfig.Capability.VIDEO,
status=ModelConfig.Status.ACTIVE,
metadata={"capabilities": {"resolutions": ["480p", "720p"], "durations": [15]}},
)
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "不兼容清晰度",
"images": [SimpleUploadedFile("front.png", b"png", content_type="image/png")],
"video_model_config_id": str(model.id),
"resolution": "1080p",
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertIn("不支持 1080P", response.data["detail"])
self.assertFalse(Product.objects.filter(title="不兼容清晰度").exists())
def test_status_is_team_scoped(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品")
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目")
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
other = User.objects.create_user(username="quick-other", password="pass")
other_team = Team.objects.create(name="Other Team", owner=other)
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
other_client = APIClient()
other_client.force_authenticate(other)
self.assertEqual(other_client.get(f"/api/projects/quick-create-status/{job.id}/").status_code, 404)
def test_cancel_stops_running_job(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="待取消商品")
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="待取消项目")
job = QuickCreateJob.objects.create(
team=self.team,
created_by=self.user,
project=project,
status=QuickCreateJob.Status.RUNNING,
phase=QuickCreateJob.Phase.SCRIPT,
message="正在根据商品名称与图片生成带货脚本",
)
response = self.client.post(f"/api/projects/quick-create-cancel/{job.id}/")
self.assertEqual(response.status_code, 200)
job.refresh_from_db()
self.assertEqual(job.status, QuickCreateJob.Status.CANCELLED)
self.assertEqual(response.data["status"], "cancelled")
self.assertIn("取消", response.data["error_message"])
def test_cancel_is_team_scoped(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品")
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目")
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
other = User.objects.create_user(username="quick-cancel-other", password="pass")
other_team = Team.objects.create(name="Cancel Other Team", owner=other)
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
other_client = APIClient()
other_client.force_authenticate(other)
self.assertEqual(other_client.post(f"/api/projects/quick-create-cancel/{job.id}/").status_code, 404)
def test_history_lists_succeeded_jobs_for_team(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品")
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 极速成片")
QuickCreateJob.objects.create(
team=self.team,
created_by=self.user,
project=project,
status=QuickCreateJob.Status.SUCCEEDED,
phase=QuickCreateJob.Phase.COMPLETE,
)
other = User.objects.create_user(username="quick-history-other", password="pass")
other_team = Team.objects.create(name="History Other Team", owner=other)
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
other_product = Product.objects.create(team=other_team, created_by=other, title="别人的商品")
other_project = Project.objects.create(team=other_team, created_by=other, product=other_product, name="别人的项目")
QuickCreateJob.objects.create(
team=other_team,
created_by=other,
project=other_project,
status=QuickCreateJob.Status.SUCCEEDED,
phase=QuickCreateJob.Phase.COMPLETE,
)
response = self.client.get("/api/projects/quick-create-history/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["product_name"], "历史商品")
self.assertEqual(response.data["results"][0]["title"], "历史商品 · 极速成片")
def test_list_serializer_flags_quick_create_projects(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="列表商品")
quick = Project.objects.create(
team=self.team,
created_by=self.user,
product=product,
name="列表商品 · 极速成片",
metadata={"quick_create": True},
)
normal = Project.objects.create(
team=self.team,
created_by=self.user,
product=product,
name="列表商品 · 专业创作",
)
self.assertTrue(ProjectListSerializer(quick).data["quick_create"])
self.assertFalse(ProjectListSerializer(normal).data["quick_create"])
class QuickCreateCoordinatorTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="quick-worker", password="pass")
self.team = Team.objects.create(name="Worker Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="测试精华")
self.project = Project.objects.create(
team=self.team,
created_by=self.user,
product=self.product,
name="测试精华 · 极速成片",
status=Project.Status.SCRIPTING,
current_stage=ProjectStage.Stage.SCRIPT,
)
initialize_project_pipeline(self.project)
self.job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=self.project)
@patch("apps.projects.tasks.run_quick_script_task.apply_async")
def test_first_advance_recognizes_product_then_starts_script(self, start_script):
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertEqual(delay, 1)
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
self.assertEqual(self.job.phase, QuickCreateJob.Phase.SCRIPT)
start_script.assert_not_called()
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertEqual(delay, 5)
self.assertTrue(self.job.metadata.get("script_started"))
start_script.assert_called_once()
self.assertEqual(start_script.call_args.kwargs["args"], [str(self.job.id)])
self.assertEqual(start_script.call_args.kwargs["queue"], "airshelf.quick")
def test_adopted_script_moves_to_assets_without_rerunning(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.SCRIPT
self.job.save(update_fields=["status", "phase", "updated_at"])
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertEqual(delay, 1)
self.assertEqual(self.job.phase, QuickCreateJob.Phase.ASSETS)
def test_cancelled_job_does_not_keep_advancing(self):
cancel_quick_create(self.job)
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertIsNone(delay)
self.assertEqual(self.job.status, QuickCreateJob.Status.CANCELLED)
@patch("apps.projects.services.quick_create._run_quick_script_in_thread")
def test_recover_reruns_script_locally_when_queue_drops_it(self, run_local):
self.job.status = QuickCreateJob.Status.RUNNING
self.job.phase = QuickCreateJob.Phase.SCRIPT
self.job.metadata = {
"script_started": True,
"script_started_at": (timezone.now() - timedelta(minutes=5)).isoformat(),
}
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
recover_quick_create(self.job)
self.job.refresh_from_db()
self.assertTrue(self.job.metadata.get("script_local"))
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
run_local.assert_called_once_with(str(self.job.id))
def test_recover_marks_success_when_video_already_finished(self):
self.project.video_segments.exclude(sort_order=0).delete()
segment = self.project.video_segments.get(sort_order=0)
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="已完成视频",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.VIDEO_CLIP,
)
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.save(update_fields=["adopted_version", "status", "updated_at"])
self.job.status = QuickCreateJob.Status.FAILED
self.job.phase = QuickCreateJob.Phase.PRODUCTION
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
self.job.save(update_fields=["status", "phase", "error_message", "updated_at"])
recover_quick_create(self.job)
self.job.refresh_from_db()
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
self.assertEqual(self.job.phase, QuickCreateJob.Phase.COMPLETE)
@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()
segment = self.project.video_segments.get(sort_order=0)
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="计数视频",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.VIDEO_CLIP,
)
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.save(update_fields=["adopted_version", "status", "updated_at"])
self.job.status = QuickCreateJob.Status.RUNNING
self.job.phase = QuickCreateJob.Phase.PRODUCTION
self.job.metadata = {"storyboard_started": True, "video_started": True}
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
self.project.current_stage = ProjectStage.Stage.VIDEO
self.project.save(update_fields=["current_stage", "updated_at"])
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertIsNone(delay)
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
def test_success_serializer_returns_direct_single_segment_video(self):
self.project.video_segments.exclude(sort_order=0).delete()
segment = self.project.video_segments.get(sort_order=0)
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="极速成片",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.VIDEO_CLIP,
)
AssetFile.objects.create(
asset=asset,
object_key="quick/final.mp4",
bucket="test",
content_type="video/mp4",
preview_url="https://cdn.example/quick.mp4",
is_primary=True,
)
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.save(update_fields=["adopted_version", "status", "updated_at"])
self.job.status = QuickCreateJob.Status.SUCCEEDED
self.job.phase = QuickCreateJob.Phase.COMPLETE
self.job.progress = 100
self.job.save(update_fields=["status", "phase", "progress", "updated_at"])
data = QuickCreateJobSerializer(self.job).data
self.assertEqual(data["phase_index"], 3)
self.assertEqual(data["result"]["video_url"], "https://cdn.example/quick.mp4")
self.assertEqual(data["result"]["duration_seconds"], 15)
@patch("apps.projects.tasks.poll_video_segment_task.apply_async")
@patch("apps.projects.services.quick_create.submit_video_segment")
def test_video_start_uses_quick_create_core_parameters(self, submit_video, schedule_poll):
self.project.metadata = {
"wizard": {
"aspect_ratio": "16:9",
"resolution": "1080p",
"total_duration": 30,
"video_model_config_id": "11111111-1111-4111-8111-111111111111",
}
}
self.project.save(update_fields=["metadata", "updated_at"])
self.project.video_segments.exclude(sort_order__lt=2).delete()
_start_videos(self.job)
self.assertEqual(submit_video.call_count, 2)
for call in submit_video.call_args_list:
self.assertEqual(call.kwargs["aspect_ratio"], "16:9")
self.assertEqual(call.kwargs["resolution"], "1080p")
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)
+20
View File
@@ -834,6 +834,26 @@ class ProjectApiTests(TestCase):
self.assertIn("[avoice][vo0][vo1]amix=inputs=3", graph)
self.assertIn("[anarr][abgm]amix=inputs=2", graph)
def test_export_command_uses_selected_output_size(self):
"""极速成片选横屏/清晰度后,多段合成必须沿用该尺寸,不能回退成固定竖屏。"""
from apps.projects.services.export import _build_export_command
cmd = _build_export_command(
n=1,
specs=[{"ts": 0.0, "te": 15.0, "dur": 15.0}],
starts=[0.0],
total=15.0,
transition="none",
sub_overlays=[],
bgm_name=None,
bgm_volume=1.0,
output_width=1920,
output_height=1080,
)
graph = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("scale=1920:1080", graph)
self.assertIn("pad=1920:1080", graph)
def test_save_timeline_updates_voiceover_offsets(self):
"""拖动字幕块后保存:按 asset 回写句内起点 offset_ms,未拖动的句不受影响。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VOFF")
+281 -75
View File
@@ -44,9 +44,10 @@ from apps.assets.models import Asset, AssetFile
from apps.assets.serializers import AssetFileSerializer
from apps.assets.storage import TosStorage
from apps.common.api import TeamScopedViewSetMixin
from apps.common.celery_health import require_worker
from apps.common.celery_health import require_worker, require_worker_task
from apps.ai.generation_errors import classify_generation_error, public_error_for_task
from apps.ai.video_digest import VideoDigestError, digest_project_video
from apps.products.models import Product, ProductImage, ProductSellingPoint
from .models import (
BaseAssetGroup,
@@ -54,6 +55,7 @@ from .models import (
ExportJob,
Project,
ProjectStage,
QuickCreateJob,
ScriptSegment,
ScriptTemplate,
ScriptVersion,
@@ -72,6 +74,7 @@ from .serializers import (
ExportJobSerializer,
ProjectListSerializer,
ProjectSerializer,
QuickCreateJobSerializer,
ScriptTemplateSerializer,
ScriptVersionSerializer,
StoryboardVersionSerializer,
@@ -79,10 +82,15 @@ from .serializers import (
is_playable_video,
)
from .services.export import run_export_job_in_thread
from .services.pipeline import STAGE_ORDER
from .services.pipeline import (
adopt_script_version,
finish_storyboard_stage,
initialize_project_pipeline,
sync_video_segments_to_script,
)
from .services.script_import import ScriptFileError, extract_script_text
from .services.templates import build_template_fields, coerce_persona, coerce_template_combo, render_outline_text
from .tasks import poll_video_segment_task
from .tasks import advance_quick_create_task, poll_video_segment_task
logger = logging.getLogger(__name__)
@@ -101,7 +109,12 @@ class ServerSentEventRenderer(BaseRenderer):
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
fallback_suffix = {
Asset.Type.IMAGE: ".png",
Asset.Type.VIDEO: ".mp4",
Asset.Type.AUDIO: ".mp3",
}.get(asset_type, ".bin")
suffix = Path(upload.name).suffix.lower() or fallback_suffix
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
@@ -435,17 +448,271 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))}
return Response({"total": base.count(), "by_status": by_status})
@action(
detail=False,
methods=["post"],
url_path="quick-create",
parser_classes=[MultiPartParser, FormParser],
)
def quick_create(self, request):
"""商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。"""
require_worker_task("apps.projects.tasks.advance_quick_create_task")
name = str(request.data.get("name") or "").strip()
uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]")
source_product_id = str(request.data.get("source_product_id") or "").strip()
requested_asset_ids = [
str(value).strip()
for value in (request.data.getlist("image_asset_ids") or request.data.getlist("image_asset_ids[]") or [])
if str(value).strip()
]
if not name:
return Response({"detail": "请填写商品名称"}, status=status.HTTP_400_BAD_REQUEST)
if len(name) > 255:
return Response({"detail": "商品名称不能超过255个字符"}, status=status.HTTP_400_BAD_REQUEST)
if not uploads and not source_product_id:
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
if len(uploads) > 9:
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
allowed_types = {"image/jpeg", "image/png", "image/webp"}
for upload in uploads:
if (upload.content_type or "").lower() not in allowed_types:
return Response({"detail": "仅支持 JPG、PNG 或 WebP 商品图片"}, status=status.HTTP_400_BAD_REQUEST)
if upload.size > 20 * 1024 * 1024:
return Response({"detail": "单张商品图片不能超过20MB"}, status=status.HTTP_400_BAD_REQUEST)
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
resolution = str(request.data.get("resolution") or "720p").lower()
try:
total_duration = int(request.data.get("total_duration") or 15)
except (TypeError, ValueError):
total_duration = 0
if aspect_ratio not in {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}:
return Response({"detail": "请选择有效的视频比例"}, status=status.HTTP_400_BAD_REQUEST)
if resolution not in {"480p", "720p", "1080p", "4k"}:
return Response({"detail": "请选择有效的视频分辨率"}, status=status.HTTP_400_BAD_REQUEST)
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)
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
if requested_video_model_id:
try:
video_model_uuid = uuid.UUID(requested_video_model_id)
except (TypeError, ValueError):
return Response({"detail": "请选择有效的视频模型"}, status=status.HTTP_400_BAD_REQUEST)
video_model = (
ModelConfig.objects.select_related("provider")
.filter(
id=video_model_uuid,
capability=ModelConfig.Capability.VIDEO,
status=ModelConfig.Status.ACTIVE,
provider__status="active",
)
.first()
)
else:
video_model = get_default_model(ModelConfig.Capability.VIDEO)
missing = [
label
for model, label in ((text_model, "文本"), (image_model, "图像"), (video_model, "视频"))
if model is None
]
if missing:
return Response(
{"detail": f"当前缺少可用的{''.join(missing)}模型,请联系管理员配置"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
capabilities = dict((video_model.metadata or {}).get("capabilities") or {})
supported_resolutions = set(capabilities.get("resolutions") or (video_model.metadata or {}).get("resolutions") or [])
supported_ratios = set(capabilities.get("aspect_ratios") or [])
supported_durations = set(capabilities.get("durations") or (video_model.metadata or {}).get("durations") or [])
if supported_resolutions and resolution not in supported_resolutions:
return Response(
{"detail": f"{video_model.display_name} 不支持 {resolution.upper()},请更换清晰度或模型"},
status=status.HTTP_400_BAD_REQUEST,
)
if supported_ratios and aspect_ratio not in supported_ratios:
return Response(
{"detail": f"{video_model.display_name} 不支持 {aspect_ratio} 比例"},
status=status.HTTP_400_BAD_REQUEST,
)
# 极速成片把总时长拆成多个15秒镜头,视频模型只需支持单镜15秒。
if supported_durations and 15 not in supported_durations:
return Response(
{"detail": f"{video_model.display_name} 不支持15秒单镜生成"},
status=status.HTTP_400_BAD_REQUEST,
)
team = self.get_team()
reused_assets = []
if source_product_id:
try:
source_uuid = uuid.UUID(source_product_id)
except (TypeError, ValueError):
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
source = (
Product.objects.filter(id=source_uuid, team=team)
.prefetch_related("images__asset")
.first()
)
if source is None:
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
source_images = list(source.images.all())
by_asset_id = {str(image.asset_id): image.asset for image in source_images}
if requested_asset_ids:
if any(asset_id not in by_asset_id for asset_id in requested_asset_ids):
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
reused_assets = [by_asset_id[asset_id] for asset_id in requested_asset_ids]
else:
reused_assets = [image.asset for image in source_images]
if not reused_assets and not uploads:
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
if len(reused_assets) + len(uploads) > 9:
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
assets = list(reused_assets)
assets.extend(
_store_uploaded_asset(
team=team,
user=request.user,
upload=upload,
asset_type=Asset.Type.IMAGE,
category=Asset.Category.PRODUCT_IMAGE,
name=upload.name or f"{name}-商品图{index + 1}",
)
for index, upload in enumerate(uploads)
)
if not assets:
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
product = Product.objects.create(
team=team,
created_by=request.user,
title=name,
business_type=Product.BusinessType.ECOMMERCE,
cover_asset=assets[0],
)
ProductImage.objects.bulk_create(
[
ProductImage(
product=product,
asset=asset,
sort_order=index,
is_primary=index == 0,
)
for index, asset in enumerate(assets)
]
)
project = Project.objects.create(
team=team,
created_by=request.user,
product=product,
name=f"{name} · 极速成片",
status=Project.Status.SCRIPTING,
current_stage=ProjectStage.Stage.SCRIPT,
metadata={
"quick_create": True,
"wizard": {
"aspect_ratio": aspect_ratio,
"resolution": resolution,
"total_duration": total_duration,
"video_model_config_id": str(video_model.id),
"video_model_name": video_model.name,
"video_model_label": video_model.display_name,
"presentation_format": "oral",
"video_structure": "pain",
"persona": "reviewer",
},
},
)
initialize_project_pipeline(project, placeholder_segments=max(1, total_duration // 15))
if not product.selling_points.exists():
ProductSellingPoint.objects.create(
product=product,
title=name,
detail="由极速成片根据商品名称自动填写",
sort_order=0,
)
job = QuickCreateJob.objects.create(
team=team,
created_by=request.user,
project=project,
status=QuickCreateJob.Status.QUEUED,
phase=QuickCreateJob.Phase.PRODUCT,
progress=0,
message="等待开始极速成片",
)
try:
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
except Exception as exc: # noqa: BLE001 — broker 极小窗口失败也必须给任务落终态
from .services.quick_create import fail_quick_create
logger.exception("quick create enqueue failed for job %s", job.id)
fail_quick_create(job, "生成队列暂时不可用,请稍后重试", internal_error=str(exc))
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_503_SERVICE_UNAVAILABLE)
job = self._quick_job_queryset().get(id=job.id)
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_202_ACCEPTED)
def _quick_job_queryset(self):
return (
QuickCreateJob.objects.select_related("project__product", "project__timeline")
.prefetch_related(
"project__product__images__asset__files",
"project__script_versions",
"project__base_asset_groups",
"project__storyboard_shots__adopted_version__asset__files",
"project__video_segments__adopted_version__asset__files",
"project__timeline__export_jobs__output_asset__files",
)
.filter(team=self.get_team())
)
@action(detail=False, methods=["get"], url_path=r"quick-create-status/(?P<job_id>[^/.]+)")
def quick_create_status(self, request, job_id=None):
job = self._quick_job_queryset().filter(id=job_id).first()
if job is None:
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
from .services.quick_create import recover_quick_create
try:
recover_quick_create(job)
except Exception: # noqa: BLE001 — 恢复失败不能把进度接口打成 500
logger.exception("quick create recover failed for job %s", job.id)
job = self._quick_job_queryset().get(id=job.id)
return Response(QuickCreateJobSerializer(job).data)
@action(detail=False, methods=["post"], url_path=r"quick-create-cancel/(?P<job_id>[^/.]+)")
def quick_create_cancel(self, request, job_id=None):
job = self._quick_job_queryset().filter(id=job_id).first()
if job is None:
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
if job.status == QuickCreateJob.Status.SUCCEEDED:
return Response({"detail": "已完成的任务不能取消"}, status=status.HTTP_400_BAD_REQUEST)
from .services.quick_create import cancel_quick_create
cancel_quick_create(job)
job = self._quick_job_queryset().get(id=job.id)
return Response(QuickCreateJobSerializer(job).data)
@action(detail=False, methods=["get"], url_path="quick-create-history")
def quick_create_history(self, request):
jobs = self._quick_job_queryset().filter(status=QuickCreateJob.Status.SUCCEEDED).order_by("-created_at")
return Response({
"count": jobs.count(),
"results": QuickCreateJobSerializer(jobs[:30], many=True).data,
})
@transaction.atomic
def perform_create(self, serializer):
project = serializer.save(team=self.get_team(), created_by=self.request.user)
self._apply_wizard_template(project)
for stage in STAGE_ORDER:
ProjectStage.objects.create(project=project, stage=stage)
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
for index in range(4):
VideoSegment.objects.create(
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
)
# 先铺 4 段占位;真实段数与每段时长在采用脚本时收口。
initialize_project_pipeline(project, placeholder_segments=4)
def _apply_wizard_template(self, project) -> None:
"""新建向导选了套路模板 → 后端按模板真值回填 metadata.wizard,并计一次使用。
@@ -583,19 +850,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = self.get_object()
script_id = request.data.get("script_version_id")
script = ScriptVersion.objects.select_for_update().get(project=project, id=script_id)
ScriptVersion.objects.filter(project=project).update(is_adopted=False)
script.is_adopted = True
script.save(update_fields=["is_adopted", "updated_at"])
# 采用脚本时把视频片段数对齐到这版分镜数:用户常在「采用前」就增删分镜,
# 那些编辑因 _sync 的 is_adopted 闸而未同步到 VideoSegment(项目创建时固定铺了 4 段),
# 不在此收口的话视频步骤会一直停在 4 段,与故事板/分镜数对不上。已生成的段绝不动。
self._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.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.BASE_ASSETS
project.status = Project.Status.ASSETING
project.save(update_fields=["current_stage", "status", "updated_at"])
adopt_script_version(project, script)
return Response(ScriptVersionSerializer(script).data)
@action(detail=True, methods=["post"], url_path="extract-entities")
@@ -900,47 +1155,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
时长同步:脚本镜主流程固定 15 秒,而出片、计价、时间线读的都是
VideoSegment.target_duration_seconds。不同步的话脚本写了 15 秒、出片仍按旧默认跑。
已出片的段不改时长——改了会跟已渲染的成片对不上。"""
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 SEGMENT_DURATION_MAX
)
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"])
sync_video_segments_to_script(project, script)
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
@@ -1133,16 +1348,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = self.get_object()
result = poll_storyboard(project=project, user=request.user)
if result.get("status") == "succeeded":
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
# 进入视频阶段前再收口一次视频片段数,确保与采用版分镜数一致(已生成的段不动)
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if adopted_script is not None:
self._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"])
finish_storyboard_stage(project)
http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED
return Response(result, status=http_status)