完成极速成品和脚本优化
This commit is contained in:
@@ -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"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user