完成极速成品和脚本优化

This commit is contained in:
Azmat@qq.com
2026-08-25 11:13:07 +08:00
parent 00fc454db7
commit e2ec2d14af
46 changed files with 4734 additions and 432 deletions
+63 -23
View File
@@ -52,6 +52,8 @@ VIDEO_STRUCTURES: dict[str, str] = {
"contrast": "前后对比",
"review": "测评验证",
"scene": "场景种草",
"promo": "促销抢购",
"knowledge": "知识分享",
}
DEFAULT_PRESENTATION_FORMAT = "oral"
DEFAULT_VIDEO_STRUCTURE = "pain"
@@ -62,7 +64,9 @@ FORBIDDEN_COMBOS: set[tuple[str, str]] = {("drama", "review")}
# 表现形式推荐的默认总时长:口播短平快,短剧要装下三幕,Vlog 要铺氛围。
FORMAT_DEFAULT_DURATION: dict[str, int] = {"oral": 30, "drama": 45, "vlog": 30}
# 各结构能压到的最短总时长(须落在 15 秒步进上),见 playbooks/combo-matrix.md。
STRUCTURE_MIN_DURATION: dict[str, int] = {"pain": 15, "contrast": 15, "review": 30, "scene": 30}
STRUCTURE_MIN_DURATION: dict[str, int] = {
"pain": 15, "contrast": 15, "review": 30, "scene": 30, "promo": 15, "knowledge": 30,
}
# 设定卡人物 key → 中文(与前端 WIZ_PERSONA_LABEL / 模板 coerce_persona 对齐)。
PERSONA_LABELS: dict[str, str] = {
@@ -83,11 +87,11 @@ PERSONA_BRIEFS: dict[str, str] = {
}
_PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()}
# 成片有同期画面与自然停顿,3.5 字/秒会让 15 秒口播在约 10 秒时说完
# 电商真人口播以 3.6–4.5 字/秒为可用区间:既留出换气,也不会让画面空转
NARRATION_CHARS_PER_SECOND = 4.5
NARRATION_CHARS_PER_SECOND_MIN = 3.6
NARRATION_CHARS_HARD_CAP = 68
# 电商口播需要同时承载观点、证据和转折;15 秒以 4.3–5.2 字/秒为可用区间
# 这比日常闲聊快,但仍给句间停顿和镜头切换留出空间
NARRATION_CHARS_PER_SECOND = 5.2
NARRATION_CHARS_PER_SECOND_MIN = 4.3
NARRATION_CHARS_HARD_CAP = 78
VISUAL_CHARS_MIN = 72
SHOT_BEATS_MIN = 3
BEAT_SPAN_RE = re.compile(
@@ -161,7 +165,7 @@ def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]:
def narration_limit(duration: int) -> int:
"""这一镜旁白的字数上限:秒数 × 4.5,且不超过硬上限 68。"""
"""这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。"""
return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND)))
@@ -342,14 +346,15 @@ _CREATIVE_DIRECTION = """
每个 segment 的 `visual` 采用下面的层级;这不是给用户看的散文,而是会原样交给故事板和视频模型的执行指令:
```
【本镜任务】这一段要让观众看懂的变化或悬念。
【声音】只写可发生的同期声 / 人声状态;没有配乐就明确写「无配乐,仅同期声」。
【声音】台词/旁白状态;音效;背景音乐;字幕。没有就写「无」;没有配乐写「无配乐,仅同期声」。
【画面内容】
0-3s:景别;构图;运镜;人物/商品动作;情绪或信息变化。
3-8s:景别;构图;运镜;手与商品的空间关系;可见细节。
8-12s:景别;构图;运镜;动作结果或卖点证据。
12-15s:景别;构图;运镜;反应/悬念/自然收束。
0-3s:景别;机位;运镜;人物/商品动作;情绪或信息变化。
3-8s:景别;机位;运镜;手与商品的空间关系;可见细节。
8-12s:景别;机位;运镜;动作结果或卖点证据。
12-15s:景别;机位;运镜;反应/悬念/自然收束。
```
每条秒级分镜都要明确写出 **景别、构图、运镜、动作、信息变化** 五项中的至少四项。
每条秒级分镜都要明确写出 **景别、机位、运镜、动作、信息变化** 五项中的至少四项。
输入如果是 `【镜头 01】` 导演分镜稿,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进对应栏,不要压成一句画面摘要。
同一角色、商品、场景的外观一律引用 entities 里的既定设定,不在每一镜随意换发型、服装、包装、光线或地点。
"""
@@ -492,7 +497,7 @@ def build_agent_messages(
beats_line = (
f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 必须按「导演说明书」写成多行:"
"先写【本镜任务】、【声音】、【画面内容】,再写秒级分镜;"
f"每条格式`0-3s:景别;构图;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
f"每条格式`0-3s:景别;机位;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
"每条写清手/商品/容器的空间关系和真实用法"
"(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;手从真实方向入画,禁止悬浮肢体)。"
"禁止一句空画面撑满 15 秒。允许另给 beats 数组,后端会折进 visual。\n"
@@ -519,6 +524,18 @@ def build_agent_messages(
"【场景种草骨架】按镜走完:场景建立(商品先不出现)→商品被使用着入场→场景因它更好/软CTA。"
"每镜写出具体时刻+地点+光/声音/物件。口播时每镜仍要说满字数,留白靠停顿不是靠少写字。\n"
)
elif structure == "promo":
structure_line = (
"【促销抢购合规】只可使用商品资料中明确给出的价格、优惠、库存、赠品和活动时间;"
"没有明确数据时,绝不写原价、最低价、限时、限量、领券或赠品。"
"结尾只给一个清楚的购买入口和操作动作,不制造虚假的紧迫感。\n"
)
elif structure == "knowledge":
structure_line = (
"【知识分享骨架】先提出一个与商品相关的选购/使用误区或判断方法,再用实物与步骤证明,"
"最后把商品作为符合该判断的具体实例。只能讲商品资料可支持的事实,"
"禁止编造专业身份、数据、标准或功效承诺。\n"
)
combo_line = (
f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写,"
f"不要串成别的结构的套话。\n"
@@ -562,15 +579,30 @@ def build_agent_messages(
)
else:
extra_block = f"\n\n【补充要求】{extra}\n" if extra else "\n"
user = (
"【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路"
"自动定镜/选 tone/造 entity/填结构骨架"
"不要另起一个空主题;钩子、痛点、卖点必须能对上这份商品,而不是品类套话。"
"每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
f"{head}"
f"{extra_block}\n"
"请按技能流程一次性产出 ScriptDraft"
)
if extra and _looks_like_shot_digest(extra):
user = (
"【任务】参考视频改写:用户给了一份逐镜拆解稿"
"照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,"
"把人物、商品、品牌、台词全部换成当前商品。"
"写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注"
"折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。"
"原稿写「无 / 不可见 / 听不清」的栏不要编造"
"钩子、痛点、卖点必须能对上这份商品。"
"每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
f"{head}"
f"{extra_block}\n"
"请按技能流程一次性产出 ScriptDraft。"
)
else:
user = (
"【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路"
"自动定镜/选 tone/造 entity/填结构骨架。"
"不要另起一个空主题;钩子、痛点、卖点必须能对上这份商品,而不是品类套话。"
"每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
f"{head}"
f"{extra_block}\n"
"请按技能流程一次性产出 ScriptDraft。"
)
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
@@ -607,6 +639,14 @@ def _balanced_object(text: str) -> str | None:
return None
def _looks_like_shot_digest(text: str) -> bool:
"""用户贴进来的是视频提炼分镜稿,不是一句补充要求。"""
blob = text or ""
has_shot = "【镜头" in blob or ("【第" in blob and "镜】" in blob)
has_picture = "画面:" in blob or "画面:" in blob
return has_shot and has_picture
def _looks_like_draft(blob: str) -> bool:
try:
d = json.loads(blob)
+32 -9
View File
@@ -3225,8 +3225,31 @@ def collect_video_review_blockers(project, only_segment: "VideoSegment | None" =
return blockers
def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> VideoSegmentVersion | None:
model_config = get_default_model(ModelConfig.Capability.VIDEO)
def submit_video_segment(
*,
video_segment: VideoSegment,
user,
prompt: str,
model_config_id=None,
aspect_ratio: str = "9:16",
resolution: str = "720p",
) -> VideoSegmentVersion | None:
model_config = None
if model_config_id:
model_config = (
ModelConfig.objects.select_related("provider")
.filter(
id=model_config_id,
capability=ModelConfig.Capability.VIDEO,
status=ModelConfig.Status.ACTIVE,
provider__status="active",
)
.first()
)
if model_config is None:
raise ValueError("selected video model is unavailable")
else:
model_config = get_default_model(ModelConfig.Capability.VIDEO)
if model_config is None:
raise ValueError("no active video model configured")
project = video_segment.project
@@ -3244,15 +3267,15 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
reference_images = [r["url"] for r in refs]
final_prompt = build_video_segment_prompt(project, video_segment, scene, refs, prompt)
# 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按 9:16/720p/目标时长预估,
# 视频段 token 计量计价(与自由创作同一成本表+同一毛利):按用户选定的比例/清晰度/目标时长预估,
# 预留=积分×buffer,终态按火山真实 usage.total_tokens 结算(poll_video_segment true-up)。
# 这里终结了「视频 ¥1/段、成本 ¥15」的倒贴定价。
from apps.billing.pricing import quote_video_estimate, video_reserve_amount
est_tokens, quote = quote_video_estimate(
model_config,
aspect_ratio="9:16",
resolution="720p",
aspect_ratio=aspect_ratio,
resolution=resolution,
duration=video_segment.target_duration_seconds,
references=[],
team=project.team,
@@ -3269,8 +3292,8 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
"endpoint": model_config.endpoint,
"prompt": final_prompt,
"duration": video_segment.target_duration_seconds,
"ratio": "9:16",
"resolution": "720p",
"ratio": aspect_ratio,
"resolution": resolution,
"estimated_tokens": est_tokens,
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律)
"price_multiplier": quote.meta.get("price_multiplier", "1"),
@@ -3288,8 +3311,8 @@ def submit_video_segment(*, video_segment: VideoSegment, user, prompt: str) -> V
primary_model=model_config,
prompt=final_prompt,
duration=video_segment.target_duration_seconds,
ratio="9:16",
resolution="720p",
ratio=aspect_ratio,
resolution=resolution,
reference_images=reference_images,
request_summary={"video_segment_id": str(video_segment.id)},
)
@@ -249,6 +249,29 @@ class PromptAssemblyTests(SimpleTestCase):
self.assertIn("禁止一句 20 字收工", user)
self.assertIn("秒级分镜", user)
def test_video_digest_prompt_maps_camera_and_sound(self):
digest = (
"片名:《雨夜归家》\n视频类型:剧情短片\n"
"【镜头 01】\n时间:00:00-00:04\n景别:城市远景\n运镜:缓慢向前推进\n"
"画面:深夜街道被雨水覆盖。\n台词/旁白:无\n音效:雨声\n"
)
messages = build_agent_messages(
project=self._project(),
mode="auto",
user_prompt=digest,
selling_point_ids=["1"],
base_draft=None,
aspect_ratio="9:16",
total_duration=15,
persona="urban",
)
user = messages[1]["content"]
self.assertIn("参考视频改写", user)
self.assertNotIn("全自动", user)
self.assertIn("景别/机位/运镜", user)
self.assertIn("音效/背景音乐", user)
self.assertIn("【镜头 01】", user)
def test_scene_oral_prompt_locks_skeleton_and_density(self):
messages = build_agent_messages(
project=self._project(),
@@ -284,15 +307,15 @@ class PromptAssemblyTests(SimpleTestCase):
class NarrationLimitTests(SimpleTestCase):
def test_limit_scales_with_shot_length(self):
self.assertEqual(narration_limit(4), 18)
self.assertEqual(narration_limit(8), 36)
self.assertEqual(narration_limit(15), 67)
self.assertEqual(narration_limit(4), 20)
self.assertEqual(narration_limit(8), 41)
self.assertEqual(narration_limit(15), 78)
def test_never_exceeds_hard_cap(self):
self.assertLessEqual(narration_limit(60), 68)
self.assertLessEqual(narration_limit(60), 78)
def test_fifteen_second_floor_is_about_fifty_four(self):
self.assertEqual(narration_floor(15), 54)
def test_fifteen_second_floor_is_about_sixty_four(self):
self.assertEqual(narration_floor(15), 64)
self.assertLess(narration_floor(15), narration_limit(15))
@@ -408,7 +431,7 @@ class ComboTests(SimpleTestCase):
def test_drama_cannot_pick_review(self):
# 演出来的实测没有可信度 —— 这是唯一的禁用组合
self.assertNotIn("review", allowed_structures("drama"))
self.assertEqual(len(allowed_structures("drama")), 3)
self.assertEqual(len(allowed_structures("drama")), 5)
def test_other_formats_allow_everything(self):
for fmt in ("oral", "vlog"):
+168 -7
View File
@@ -10,7 +10,7 @@ import subprocess
from unittest.mock import patch
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import SimpleTestCase, TestCase
from django.test import SimpleTestCase, TestCase, override_settings
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
@@ -36,6 +36,14 @@ from apps.ai.video_digest import (
)
def _shot_digest(shots=3, *, heading="lens"):
lines = ["片名:《示例》\n视频类型:口播带货\n时长:15秒\n整体风格:写实\n"]
for index in range(1, shots + 1):
mark = f"【镜头 {index:02d}" if heading == "lens" else f"【第 {index} 镜】"
lines.append(f"{mark}\n时间:00:00-00:04\n画面:一位女生拿着商品\n台词/旁白:无\n")
return "".join(lines)
class FramePlanTests(SimpleTestCase):
def test_frame_count_scales_with_duration_within_bounds(self):
for duration, expected in [(3, MIN_FRAMES), (10, MIN_FRAMES), (30, 15), (60, 30), (180, MAX_FRAMES)]:
@@ -98,12 +106,15 @@ class MessageBuildTests(SimpleTestCase):
def test_system_prompt_is_the_digest_skill(self):
messages = build_digest_messages(self.frames, 10)
self.assertEqual(messages[0]["role"], "system")
self.assertIn("解说词", messages[0]["content"])
self.assertIn("台词/旁白", messages[0]["content"])
self.assertIn("覆盖全片", messages[0]["content"])
self.assertIn("【镜头 01】", messages[0]["content"])
def test_user_prompt_asks_for_full_timeline(self):
content = build_digest_messages(self.frames, 60)[1]["content"]
self.assertIn("全片", content[0]["text"])
self.assertIn("景别", content[0]["text"])
self.assertIn("背景音乐", content[0]["text"])
def test_every_frame_is_inlined_with_its_timestamp(self):
content = build_digest_messages(self.frames, 10)[1]["content"]
@@ -121,17 +132,19 @@ class MessageBuildTests(SimpleTestCase):
self.assertIn("蓝牙耳机 · 数码3C", content[0]["text"])
def test_skill_loads_from_disk(self):
self.assertIn("解说词", load_digest_skill())
self.assertIn("台词/旁白", load_digest_skill())
self.assertIn("景别", load_digest_skill())
class NativeVideoInputTests(SimpleTestCase):
def test_short_clip_is_sent_as_whole_video(self):
video, frames, duration = digest_input_from_upload(_synth_clip(seconds=6))
video, frames, duration, extras = digest_input_from_upload(_synth_clip(seconds=6))
self.assertIsNotNone(video)
self.assertEqual(frames, [])
self.assertTrue(video.mime.startswith("video/"))
self.assertGreater(len(video.data), 100)
self.assertAlmostEqual(duration, 6, delta=1)
self.assertIn("cover_jpeg", extras)
def test_native_video_message_inlines_video_data_uri(self):
video = DigestVideo(mime="video/mp4", data=b"\x00\x00fake")
@@ -140,6 +153,8 @@ class NativeVideoInputTests(SimpleTestCase):
self.assertEqual(len(media), 1)
self.assertTrue(media[0]["image_url"]["url"].startswith("data:video/mp4;base64,"))
self.assertIn("口播", content[0]["text"])
self.assertIn("景别", content[0]["text"])
self.assertIn("背景音乐", content[0]["text"])
class DigestValidationTests(SimpleTestCase):
@@ -153,11 +168,15 @@ class DigestValidationTests(SimpleTestCase):
validate_digest_text("这是一条很好的带货视频,节奏明快,画面精美。" * 5)
def test_accepts_well_formed_digest(self):
good = _shot_digest()
self.assertEqual(validate_digest_text(f" {good} "), good.strip())
def test_accepts_legacy_shot_heading(self):
good = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3
self.assertEqual(validate_digest_text(f" {good} "), good.strip())
def test_rejects_too_few_shots_for_a_long_clip(self):
stub = "【整体结构】\n形式:口播\n" + "【第 1 镜】0-4 秒 · 钩子\n主体:一位女生\n" * 3
stub = _shot_digest(3)
with self.assertRaises(ValueError):
validate_digest_text(stub, duration=60, frame_count=30)
@@ -324,7 +343,142 @@ class VideoDigestApiTests(TestCase):
self.assertTrue(response.data["detail"])
self.assertEqual(response.data["error"]["operation"], "video_digest")
def _seed_model(self):
from decimal import Decimal
provider, _ = ModelProvider.objects.get_or_create(
name="remix-hist-relay",
defaults={"display_name": "Gemini 官转", "status": ModelProvider.Status.ACTIVE},
)
return ModelConfig.objects.create(
provider=provider,
name=DIGEST_VISION_MODEL_NAME,
display_name="Gemini 3.1 Pro 官转",
capability=ModelConfig.Capability.TEXT,
status=ModelConfig.Status.ACTIVE,
unit_price=Decimal("10"),
)
def _seed_digest_task(self, *, team=None, status=None, is_deleted=False, prompt="示例提示词", extra_key=""):
from apps.ai.models import AITask
model = getattr(self, "_hist_model", None) or self._seed_model()
self._hist_model = model
task = AITask.objects.create(
team=team or self.team,
created_by=self.user,
project=None,
task_type=AITask.Type.VIDEO_DIGEST,
status=status or AITask.Status.SUCCEEDED,
model_config=model,
idempotency_key=f"digest-hist-{team.id if team else self.team.id}-{extra_key or AITask.objects.count()}",
request_payload={
"feature": "video_remix",
"file_name": "参考视频.mp4",
"title": "轻醒咖啡场景种草视频",
"duration_seconds": 15,
"ratio": "9:16 竖屏",
"shot_count": 5,
"video_url": "https://cdn.example/coffee.mp4",
"cover_url": "https://cdn.example/coffee.jpg",
},
response_payload={"digest": prompt, "prompt": prompt},
is_deleted=is_deleted,
)
return task
def test_history_empty_by_default(self):
response = self.client.get("/api/ai/video-digest/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"], [])
self.assertEqual(response.data["total"], 0)
def test_history_lists_succeeded_team_digests(self):
from apps.ai.models import AITask
self._seed_digest_task(prompt="咖啡提示词", extra_key="ok")
self._seed_digest_task(status=AITask.Status.FAILED, extra_key="fail")
self._seed_digest_task(is_deleted=True, extra_key="del")
other_user = User.objects.create_user(username="remix-other", password="p")
other_team = Team.objects.create(name="Other", owner=other_user)
TeamMember.objects.create(team=other_team, user=other_user, role=TeamMember.Role.OWNER)
self._seed_digest_task(team=other_team, extra_key="other")
response = self.client.get("/api/ai/video-digest/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["total"], 1)
item = response.data["results"][0]
self.assertEqual(item["title"], "轻醒咖啡场景种草视频")
self.assertEqual(item["status"], "已完成")
self.assertEqual(item["duration_label"], "00:15")
self.assertEqual(item["ratio"], "9:16 竖屏")
self.assertEqual(item["shots"], 5)
self.assertEqual(item["file_name"], "参考视频.mp4")
self.assertEqual(item["prompt"], "咖啡提示词")
self.assertEqual(item["video_url"], "https://cdn.example/coffee.mp4")
self.assertEqual(item["cover_url"], "https://cdn.example/coffee.jpg")
self.assertRegex(item["created_date"], r"^\d{4}-\d{2}-\d{2}$")
@patch("apps.assets.storage.TosStorage")
def test_history_signs_video_url_from_object_key(self, storage_cls):
storage = storage_cls.return_value
storage.presigned_get_url.return_value = "https://signed.example/play.mp4"
storage.public_url.return_value = "https://cdn.example/cover.jpg"
task = self._seed_digest_task(extra_key="signed")
task.request_payload = {
**task.request_payload,
"video_url": "",
"video_key": "teams/1/video-digest/abc.mp4",
"cover_url": "",
"cover_key": "teams/1/video-digest/abc.jpg",
}
task.save(update_fields=["request_payload"])
response = self.client.get("/api/ai/video-digest/")
self.assertEqual(response.status_code, 200)
item = response.data["results"][0]
self.assertEqual(item["video_url"], "https://signed.example/play.mp4")
self.assertEqual(item["cover_url"], "https://cdn.example/cover.jpg")
storage.presigned_get_url.assert_called()
def test_history_without_saved_source_has_empty_video_url(self):
task = self._seed_digest_task(extra_key="nosource")
payload = dict(task.request_payload)
payload.pop("video_url", None)
payload.pop("video_key", None)
task.request_payload = payload
task.save(update_fields=["request_payload"])
response = self.client.get("/api/ai/video-digest/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["results"][0]["video_url"], "")
def test_save_prompt_updates_history(self):
task = self._seed_digest_task(prompt="旧提示词", extra_key="save")
response = self.client.patch(
f"/api/ai/video-digest/{task.id}/",
{"prompt": "新提示词"},
format="json",
)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["prompt"], "新提示词")
task.refresh_from_db()
self.assertEqual(task.response_payload["prompt"], "新提示词")
self.assertEqual(task.response_payload["digest"], "旧提示词")
def test_save_prompt_rejects_empty_and_missing(self):
task = self._seed_digest_task(extra_key="empty")
empty = self.client.patch(f"/api/ai/video-digest/{task.id}/", {"prompt": " "}, format="json")
self.assertEqual(empty.status_code, 400)
missing = self.client.patch(
"/api/ai/video-digest/00000000-0000-0000-0000-000000000099/",
{"prompt": "还在"},
format="json",
)
self.assertEqual(missing.status_code, 404)
@override_settings(CACHES={"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}})
class VideoDigestBillingTests(TestCase):
"""提炼提示词按功能价 30 积分结算,不跟 Gemini 文本单价。"""
@@ -362,7 +516,7 @@ class VideoDigestBillingTests(TestCase):
with (
patch(
"apps.ai.video_digest.digest_input_from_upload",
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0),
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0, {}),
),
patch(
"apps.ai.services.execute_routed_text_request",
@@ -380,6 +534,13 @@ class VideoDigestBillingTests(TestCase):
from decimal import Decimal
from apps.ai.models import AITask
from apps.billing.pricing import VIDEO_DIGEST_POINTS, quote_video_digest
self.assertEqual(VIDEO_DIGEST_POINTS, Decimal("30"))
self.assertEqual(quote_video_digest(model_config=self.model).points, Decimal("30"))
self.team.price_multiplier = Decimal("0.80")
self.assertEqual(quote_video_digest(team=self.team, model_config=self.model).points, Decimal("24"))
self.team.price_multiplier = Decimal("1")
result = self._run_digest()
self.account.refresh_from_db()
@@ -401,7 +562,7 @@ class VideoDigestBillingTests(TestCase):
with (
patch(
"apps.ai.video_digest.digest_input_from_upload",
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0),
return_value=(None, [VideoFrame(at_seconds=2, jpeg=b"\xff\xd8x")], 15.0, {}),
),
patch("apps.ai.services.execute_routed_text_request") as mocked,
):
+2
View File
@@ -3,6 +3,7 @@ from rest_framework.routers import DefaultRouter
from .views import (
AITaskViewSet,
VideoDigestDetailView,
VideoDigestView,
FreeVideoDetailView,
FreeVideoFavoriteView,
@@ -25,6 +26,7 @@ router.register("image-conversations", ImageConversationViewSet, basename="image
urlpatterns = [
path("generate-image/", GenerateImageView.as_view(), name="ai-generate-image"),
path("video-digest/", VideoDigestView.as_view(), name="ai-video-digest"),
path("video-digest/<uuid:task_id>/", VideoDigestDetailView.as_view(), name="ai-video-digest-detail"),
path("free-video/", FreeVideoView.as_view(), name="ai-free-video"),
path("free-video/trash/", FreeVideoTrashView.as_view(), name="ai-free-video-trash"),
path("free-video/upload/", FreeVideoUploadView.as_view(), name="ai-free-video-upload"),
+364 -21
View File
@@ -46,8 +46,8 @@ MIN_FRAMES = 8
MAX_FRAMES = 36
FRAME_WIDTH = 768
FRAME_QUALITY = 3
DIGEST_MAX_TOKENS = 8192
_SHOT_MARK = re.compile(r"【第\s*\d+\s*镜】")
DIGEST_MAX_TOKENS = 12288
_SHOT_MARK = re.compile(r"(?:镜头\s*\d+|\s*\d+\s*镜)")
_FFMPEG_TIMEOUT = 60
@@ -93,9 +93,10 @@ def load_digest_skill() -> str:
return main.read_text(encoding="utf-8")
# 兜底:skill 丢了也别整条链路挂掉,退化成一句话提示词(产出会明显变差,交接文档已注明须带 skills 目录)
return (
"你是分镜拆解 agent。输入是一条电商短视频的完整文件,含画面和口播"
"必须覆盖全片,从 0 写到片尾,有几镜写几镜,不要概括成几大段。"
"每镜写画面和解说词,解说词按听到的口播逐字写。输出中文纯文本。"
"你是分镜拆解 agent。输入是一条短视频的完整文件,含画面和音轨"
"必须覆盖全片,从 00:00 写到片尾,有几镜写几镜,不要概括成几大段。"
"每镜按【镜头 01】写出时间、时长、景别、机位、运镜、画面、人物动作、人物表情、"
"台词/旁白、音效、背景音乐、字幕、备注。没有就写无。输出中文纯文本。"
)
@@ -130,6 +131,73 @@ def probe_duration(path: str | Path) -> float:
return duration
def probe_video_size(path: str | Path) -> tuple[int, int]:
"""ffprobe 读画面宽高。读不到返回 0,0,不挡拆解。"""
try:
out = subprocess.run(
[
_binary("ffprobe"), "-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height",
"-of", "csv=p=0:s=x",
str(path),
],
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
).stdout.decode("utf-8", errors="replace").strip()
width_s, height_s = out.split("x", 1)
return max(0, int(width_s)), max(0, int(height_s))
except Exception: # noqa: BLE001
return 0, 0
def extract_cover_jpeg(path: str | Path, duration: float) -> bytes:
"""抽一帧作历史封面。失败返回空字节,不挡拆解。"""
try:
ffmpeg = _binary("ffmpeg")
except VideoDigestError:
return b""
at = max(0.0, min(float(duration) * 0.35, max(0.0, float(duration) - 0.15)))
try:
done = subprocess.run(
[
ffmpeg, "-v", "error", "-ss", f"{at:.2f}", "-i", str(path),
"-frames:v", "1", "-vf", "scale=640:-2",
"-q:v", "4", "-f", "image2", "-",
],
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
)
except Exception: # noqa: BLE001
return b""
return done.stdout or b""
def ratio_label(width: int, height: int) -> str:
if not width or not height:
return ""
ratio = width / height
if abs(ratio - 9 / 16) < 0.08:
return "9:16 竖屏"
if abs(ratio - 16 / 9) < 0.08:
return "16:9 横屏"
if abs(ratio - 1) < 0.08:
return "1:1"
return "横屏" if width > height else "竖屏"
def title_from_filename(name: str) -> str:
stem = Path(name or "").stem.strip()
return (stem or "参考视频")[:80]
def duration_clock(seconds: float) -> str:
total = max(0, int(round(float(seconds or 0))))
return f"{total // 60:02d}:{total % 60:02d}"
def shot_count(text: str) -> int:
return len(_SHOT_MARK.findall(text or ""))
def plan_frame_times(duration: float) -> list[int]:
"""均匀采样时间点。取每段的**中点**,避开首尾黑场与片尾卡片。"""
count = max(MIN_FRAMES, min(MAX_FRAMES, math.ceil(duration / SECONDS_PER_FRAME)))
@@ -223,6 +291,60 @@ def _compress_video(path: str) -> bytes | None:
Path(out.name).unlink(missing_ok=True)
def _is_browser_playable(path: str) -> bool:
"""Chrome 播不了微信常见的 HEVC。H.264 + AAC/无音轨才直接存。"""
try:
out = subprocess.run(
[
_binary("ffprobe"), "-v", "error",
"-show_entries", "stream=codec_name,codec_type",
"-of", "json",
str(path),
],
capture_output=True, timeout=_FFMPEG_TIMEOUT, check=True,
)
streams = json.loads(out.stdout).get("streams") or []
except Exception: # noqa: BLE001
return False
video_ok = False
audio_ok = True
for stream in streams:
kind = stream.get("codec_type")
codec = str(stream.get("codec_name") or "").lower()
if kind == "video":
video_ok = codec in {"h264", "vp8", "vp9", "av1"}
elif kind == "audio":
audio_ok = codec in {"aac", "mp3", "opus", "vorbis"}
return video_ok and audio_ok
def _prepare_browser_video(path: str, suffix: str) -> tuple[str, str, bool]:
"""转成浏览器能播的 H.264 AAC。失败退回原片。第三项表示调用方要删临时文件。"""
ext = suffix if str(suffix).startswith(".") else f".{suffix or 'mp4'}"
if _is_browser_playable(path):
return path, ext, False
ffmpeg = _binary("ffmpeg")
out = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)
out.close()
try:
done = subprocess.run(
[
ffmpeg, "-v", "error", "-y", "-i", path,
"-c:v", "libx264", "-preset", "veryfast", "-crf", "23",
"-c:a", "aac", "-b:a", "128k",
"-movflags", "+faststart", out.name,
],
capture_output=True, timeout=_FFMPEG_TIMEOUT * 3,
)
if done.returncode != 0 or not Path(out.name).is_file() or Path(out.name).stat().st_size <= 0:
Path(out.name).unlink(missing_ok=True)
return path, ext, False
return out.name, ".mp4", True
except Exception: # noqa: BLE001 — 转码失败仍存原片,总比历史卡不能播好
Path(out.name).unlink(missing_ok=True)
return path, ext, False
def _native_video(path: str, size: int, suffix: str) -> DigestVideo | None:
mime = _SUFFIX_MIME.get(suffix.lower(), "video/mp4")
if size <= INLINE_VIDEO_MAX_BYTES:
@@ -233,17 +355,37 @@ def _native_video(path: str, size: int, suffix: str) -> DigestVideo | None:
return None
def digest_input_from_upload(upload) -> tuple[DigestVideo | None, list[VideoFrame], float]:
"""优先整段视频(含音轨);塞不进请求才抽帧。"""
def digest_input_from_upload(
upload,
*,
keep_source: bool = False,
) -> tuple[DigestVideo | None, list[VideoFrame], float, dict]:
"""优先整段视频(含音轨);塞不进请求才抽帧。顺带抽出历史卡要用的封面/宽高。
keep_source=True 时不删临时文件,调用方上传原片后再删
"""
path = ""
keep = False
original_name = Path(getattr(upload, "name", "") or "参考视频.mp4").name or "参考视频.mp4"
try:
path, suffix, size, duration = _materialize_upload(upload)
width, height = probe_video_size(path)
cover_jpeg = extract_cover_jpeg(path, duration)
video = _native_video(path, size, suffix)
if video is not None:
return video, [], duration
return None, extract_frames(path, plan_frame_times(duration)), duration
frames = [] if video is not None else extract_frames(path, plan_frame_times(duration))
extras = {
"file_name": original_name,
"file_size": size,
"width": width,
"height": height,
"cover_jpeg": cover_jpeg,
"suffix": suffix,
"source_path": path if keep_source else "",
}
keep = keep_source
return video, frames, duration, extras
finally:
if path:
if path and not keep:
Path(path).unlink(missing_ok=True)
@@ -267,20 +409,28 @@ def build_digest_messages(
*,
product_hint: str = "",
video: DigestVideo | None = None,
aspect_ratio: str = "",
file_title: str = "",
) -> list[dict]:
"""system = 拆解 skill;user = 完整视频(优先)或抽帧。"""
frames = frames or []
measured = f"实测时长约 {round(duration)}"
if aspect_ratio:
measured += f",画面比例 {aspect_ratio}"
title_hint = f"文件名可参考片名:《{file_title}》。" if file_title else ""
if video is not None:
head = [
f"这是一条时长约 {round(duration)} 秒的电商带货短视频**完整文件**(含画面和口播音轨)。",
"请按技能还原全片分镜:从 0 写到片尾,有几镜写几镜。",
"解说词按听到的口播逐字写;画面上的花字一并写进画面",
f"这是一条{measured}短视频**完整文件**(含画面和口播音轨)。{title_hint}",
"请按技能还原全片导演分镜稿:从 00:00 写到片尾,有几镜写几镜。",
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注",
"台词/旁白按听到的口播逐字写;画面上的花字写进字幕。",
]
else:
head = [
f"这是一条时长约 {round(duration)} 秒的电商带货短视频,",
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。",
"请按技能还原**全片**分镜:从 0 写到片尾,有几镜写几镜。",
f"这是一条{measured}短视频,",
f"按时间顺序均匀抽了 {len(frames)} 帧。每帧图前面标了它在原片中的时间点。{title_hint}",
"请按技能还原**全片**导演分镜稿:从 00:00 写到片尾,有几镜写几镜。",
"每镜必须写齐时间、时长、景别、机位、运镜、画面、人物动作、人物表情、台词/旁白、音效、背景音乐、字幕、备注。",
]
if product_hint:
head.append(f"用户接下来想用这条片子的结构去拍自己的商品:{product_hint}")
@@ -429,6 +579,127 @@ def digest_team_video(*, team, user, upload, model_config_id=None) -> dict:
)
def _store_digest_cover(*, team, jpeg: bytes) -> tuple[str, str]:
"""封面传到 TOS。失败返回空,历史卡走占位底。"""
from io import BytesIO
from apps.assets.storage import TosStorage
if not jpeg:
return "", ""
key = f"teams/{team.id}/video-digest/{uuid.uuid4()}.jpg"
storage = TosStorage()
stored = storage.upload_fileobj(fileobj=BytesIO(jpeg), object_key=key, content_type="image/jpeg")
return stored.object_key, storage.public_url(object_key=stored.object_key)
def _store_digest_video(*, team, path: str, suffix: str) -> tuple[str, str]:
"""原片(优先转成 H.264)传到 TOS,历史封面点击才能播。失败返回空,不挡拆解。"""
from apps.assets.storage import TosStorage
if not path or not Path(path).is_file():
return "", ""
upload_path, ext, ephemeral = _prepare_browser_video(path, suffix)
try:
if ext.lower() not in ALLOWED_SUFFIXES:
ext = ".mp4"
mime = _SUFFIX_MIME.get(ext.lower(), "video/mp4")
key = f"teams/{team.id}/video-digest/{uuid.uuid4()}{ext.lower()}"
storage = TosStorage()
with Path(upload_path).open("rb") as fileobj:
stored = storage.upload_fileobj(fileobj=fileobj, object_key=key, content_type=mime)
return stored.object_key, storage.public_url(object_key=stored.object_key)
finally:
if ephemeral:
Path(upload_path).unlink(missing_ok=True)
def _media_url_from_payload(req: dict, url_key: str, object_key_name: str, *, signed: bool = False) -> str:
stored = str(req.get(url_key) or "").strip()
key = str(req.get(object_key_name) or "").strip()
if not key:
return stored
try:
from apps.assets.storage import TosStorage
storage = TosStorage()
if signed:
return storage.presigned_get_url(object_key=key, expires_in=6 * 3600)
return storage.public_url(object_key=key)
except Exception: # noqa: BLE001
return stored
def serialize_digest_history(task) -> dict:
from django.utils import timezone
from apps.ai.models import AITask
req = task.request_payload or {}
resp = task.response_payload or {}
prompt = str(resp.get("prompt") or resp.get("digest") or "").strip()
duration = float(req.get("duration_seconds") or 0)
file_name = str(req.get("file_name") or "") or "参考视频.mp4"
title = str(req.get("title") or "").strip() or title_from_filename(file_name)
shots = int(req.get("shot_count") or 0) or shot_count(prompt)
created = timezone.localtime(task.created_at) if task.created_at else timezone.now()
return {
"id": str(task.id),
"title": title,
"status": "已完成" if task.status == AITask.Status.SUCCEEDED else "失败",
"duration": int(round(duration)),
"duration_label": duration_clock(duration),
"ratio": str(req.get("ratio") or ""),
"shots": shots,
"file_name": file_name,
"cover_url": _media_url_from_payload(req, "cover_url", "cover_key"),
"video_url": _media_url_from_payload(req, "video_url", "video_key", signed=True),
"prompt": prompt,
"created_at": task.created_at.isoformat() if task.created_at else "",
"created_date": created.strftime("%Y-%m-%d"),
}
def list_team_digest_history(*, team, limit: int = 50) -> list[dict]:
from apps.ai.models import AITask
qs = (
AITask.objects.filter(
team=team,
task_type=AITask.Type.VIDEO_DIGEST,
project__isnull=True,
status=AITask.Status.SUCCEEDED,
is_deleted=False,
purged_at__isnull=True,
)
.order_by("-created_at")[: max(1, min(int(limit), 50))]
)
return [serialize_digest_history(task) for task in qs]
def save_digest_prompt(*, team, task_id, prompt: str) -> dict | None:
from apps.ai.models import AITask
cleaned = (prompt or "").strip()
if not cleaned:
raise VideoDigestError("提示词不能为空")
task = AITask.objects.filter(
id=task_id,
team=team,
task_type=AITask.Type.VIDEO_DIGEST,
project__isnull=True,
is_deleted=False,
purged_at__isnull=True,
).first()
if task is None:
return None
payload = dict(task.response_payload or {})
payload["prompt"] = cleaned[:32000]
task.response_payload = payload
task.save(update_fields=["response_payload", "updated_at"])
return serialize_digest_history(task)
def _digest_video(*, team, user, upload, project=None, product_hint="", model_config_id=None) -> dict:
"""上传视频 → 分镜稿。抽帧在建任务之前做,文件不合格不占积分。"""
from django.db import transaction
@@ -439,10 +710,20 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
from apps.billing.pricing import quote_video_digest
from apps.billing.services.ledger import charge_reserved_credit, reserve_credit
video, frames, duration = digest_input_from_upload(upload)
video, frames, duration, extras = digest_input_from_upload(
upload, keep_source=(project is None)
)
extras = extras or {}
source_path = str(extras.get("source_path") or "")
file_name = str(extras.get("file_name") or getattr(upload, "name", "") or "参考视频.mp4")
width = int(extras.get("width") or 0)
height = int(extras.get("height") or 0)
file_size = int(extras.get("file_size") or getattr(upload, "size", 0) or 0)
model_config = resolve_digest_model_config(preferred_id=model_config_id)
if model_config is None:
if source_path:
Path(source_path).unlink(missing_ok=True)
raise VideoDigestError("视频提炼需要 Gemini 3.1 Pro(会看图),当前没有启用,请联系管理员")
logger.info(
"video digest using %s:%s (%s) input=%s duration=%.1fs bytes=%s frames=%s",
@@ -456,7 +737,12 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
)
messages = build_digest_messages(
frames, duration, product_hint=product_hint, video=video
frames,
duration,
product_hint=product_hint,
video=video,
aspect_ratio=ratio_label(width, height),
file_title=title_from_filename(file_name),
)
request_payload = {
"model": model_config.name,
@@ -467,6 +753,12 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
"frame_count": len(frames),
"video_bytes": len(video.data) if video is not None else 0,
"frame_times": [f.at_seconds for f in frames],
"file_name": file_name,
"title": title_from_filename(file_name),
"file_size": file_size,
"width": width,
"height": height,
"ratio": ratio_label(width, height),
}
quote = quote_video_digest(team=team, model_config=model_config)
@@ -502,6 +794,8 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
task.status = AITask.Status.RESERVED
task.save(update_fields=["status", "updated_at"])
except ValueError as exc:
if source_path:
Path(source_path).unlink(missing_ok=True)
if "insufficient credit" in str(exc).lower():
raise VideoDigestError("团队余额不足,请充值后重试") from exc
raise VideoDigestError(str(exc)) from exc
@@ -532,15 +826,56 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
)
_text, _response, digest = routed.value
except Exception as exc: # noqa: BLE001
if source_path:
Path(source_path).unlink(missing_ok=True)
_fail_digest_task(task, reservation, str(exc))
raise
cover_key, cover_url, video_key, video_url = "", "", "", ""
if project is None:
try:
cover_key, cover_url = _store_digest_cover(team=team, jpeg=extras.get("cover_jpeg") or b"")
except Exception: # noqa: BLE001 — 封面失败不挡拆解结果
logger.warning("video digest cover upload failed", exc_info=True)
try:
video_key, video_url = _store_digest_video(
team=team,
path=source_path,
suffix=str(extras.get("suffix") or ".mp4"),
)
except Exception: # noqa: BLE001 — 原片失败仍可看提示词,封面不能播
logger.warning("video digest source upload failed", exc_info=True)
if source_path:
Path(source_path).unlink(missing_ok=True)
source_path = ""
shots = shot_count(digest)
request_payload = {
**(task.request_payload or {}),
**request_payload,
"shot_count": shots,
"cover_key": cover_key,
"cover_url": cover_url,
"video_key": video_key,
"video_url": video_url,
}
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = {"digest": digest[:32000]}
task.request_payload = request_payload
task.response_payload = {"digest": digest[:32000], "prompt": digest[:32000]}
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
task.save(
update_fields=[
"status",
"request_payload",
"response_payload",
"actual_cost",
"completed_at",
"updated_at",
]
)
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
return {
@@ -551,6 +886,14 @@ def _digest_video(*, team, user, upload, project=None, product_hint="", model_co
"duration": round(duration, 1),
"task_id": str(task.id),
"estimated_cost": str(task.estimated_cost),
"title": request_payload.get("title") or title_from_filename(file_name),
"file_name": file_name,
"ratio": request_payload.get("ratio") or "",
"shots": shots,
"cover_url": cover_url,
"video_url": video_url,
"width": width,
"height": height,
}
+32 -2
View File
@@ -7,7 +7,7 @@ from django.utils import timezone
from rest_framework import status
from rest_framework.decorators import action
from rest_framework.exceptions import ValidationError
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.parsers import FormParser, JSONParser, MultiPartParser
from rest_framework.response import Response
from rest_framework.views import APIView
from rest_framework.viewsets import ModelViewSet, ReadOnlyModelViewSet
@@ -700,9 +700,17 @@ class VideoDigestView(APIView):
"""视频提炼 · 上传参考视频提炼分镜稿(不绑项目)。
POST /api/ai/video-digest/ multipart file 中文分镜稿(30~60 ),固定 30 积分/,失败退还
GET /api/ai/video-digest/ 本团队已完成的提炼历史()
"""
parser_classes = [MultiPartParser, FormParser]
parser_classes = [MultiPartParser, FormParser, JSONParser]
def get(self, request):
from .video_digest import list_team_digest_history
team = get_current_team(request.user)
results = list_team_digest_history(team=team)
return Response({"results": results, "total": len(results)})
def post(self, request):
from .video_digest import VideoDigestError, digest_team_video
@@ -730,6 +738,28 @@ class VideoDigestView(APIView):
return Response({"name": getattr(upload, "name", "") or "参考视频", **result})
class VideoDigestDetailView(APIView):
"""PATCH /api/ai/video-digest/<id>/ 保存编辑后的提示词到这条历史。"""
parser_classes = [JSONParser, FormParser]
def patch(self, request, task_id):
from .video_digest import VideoDigestError, save_digest_prompt
team = get_current_team(request.user)
try:
item = save_digest_prompt(
team=team,
task_id=task_id,
prompt=str(request.data.get("prompt") or ""),
)
except VideoDigestError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_400_BAD_REQUEST)
if item is None:
return Response({"detail": "记录不存在"}, status=status.HTTP_404_NOT_FOUND)
return Response(item)
class FreeVideoView(APIView):
"""自由创作·视频生成(不绑项目,universal 全能参考 / keyframe 首尾帧)。
-17
View File
@@ -231,15 +231,6 @@ class PricingEngineTests(TestCase):
# 多单位(单图批量按张)
self.assertEqual(quote_flat(self.text_model, units=3).points, Decimal("30"))
def test_video_digest_is_fixed_thirty_not_model_unit_price(self):
from apps.billing.pricing import VIDEO_DIGEST_POINTS, quote_video_digest
self.assertEqual(VIDEO_DIGEST_POINTS, Decimal("30"))
quote = quote_video_digest(model_config=self.text_model)
self.assertEqual(quote.points, Decimal("30"))
self.assertEqual(quote.meta["rule"], "video_digest_flat")
self.assertEqual(quote.meta["unit_points"], "30")
def test_flat_fallback_when_unit_price_unset(self):
from apps.billing.pricing import FLAT_FALLBACK_POINTS, quote_flat
@@ -423,14 +414,6 @@ class TeamPriceMultiplierTests(TestCase):
# meta.rate 是汇率快照契约:create_ai_task 落 payload.points_per_yuan_snapshot,毛利报表用它防汇率漂移
self.assertEqual(Decimal(quote.meta["rate"]), Decimal("10"))
def test_video_digest_applies_multiplier_to_feature_price(self):
from apps.billing.pricing import quote_video_digest
# 30 × 0.8 = 24;不跟模型 unit_price=20
quote = quote_video_digest(team=self.team, model_config=self.image_model)
self.assertEqual(quote.points, Decimal("24"))
self.assertEqual(quote_video_digest().points, Decimal("30"))
def test_two_step_rounding(self):
from apps.billing.pricing import quote_video_from_cost
+54 -2
View File
@@ -27,13 +27,39 @@ class WorkerUnavailable(APIException):
_OK_TTL = 30.0
_FAIL_TTL = 5.0
_cache = {"ok": False, "expires": 0.0}
# True / False / None(inspect 没答上来,不当成「未部署」)
_registered_task_cache: dict[str, tuple[bool | None, float]] = {}
def _task_name_listed(task_name: str, task_names) -> bool:
"""Celery inspect.registered() 可能给全名、短名,或带 [rate] 后缀。"""
short = task_name.rsplit(".", 1)[-1]
for raw in task_names or []:
name = str(raw).split("[", 1)[0].strip()
if name in {task_name, short} or name.endswith("." + short):
return True
return False
def _inspect_task_registered(task_name: str) -> bool | None:
"""True 已注册 / False 明确没有 / None 问不到(超时或空应答)。"""
try:
from airshelf.celery import app as celery_app
registered = celery_app.control.inspect(timeout=2.5).registered()
except Exception: # noqa: BLE001 — inspect 失败不等于任务没部署
return None
if not registered:
return None
return any(_task_name_listed(task_name, names) for names in registered.values())
def _ping_workers() -> bool:
from airshelf.celery import app as celery_app
# limit=1:收到第一个 worker 回包立即返回,不傻等满 timeout
return bool(celery_app.control.ping(timeout=1.0, limit=1))
# limit=1:收到第一个 worker 回包立即返回。本机连火山 Redis 往返常超过 1s,
# 超时会被误判成 worker 未运行,生成入口整页锁死。
return bool(celery_app.control.ping(timeout=3.0, limit=1))
def celery_worker_available() -> bool:
@@ -55,3 +81,29 @@ def require_worker() -> None:
"""生成类提交入口的前置检查:无 worker 直接 503,不让任务出门。"""
if not celery_worker_available():
raise WorkerUnavailable()
def require_worker_task(task_name: str) -> None:
"""确认在线 Worker 已加载指定任务,避免新功能被旧进程静默丢弃。
Worker 在线不代表它已经加载了最新 tasks.pyCelery 收到未注册任务会直接丢弃消息
页面只能看到永久生成中提交极速成片等新编排前额外确认任务名已注册
inspect 超时/空应答不能当成未部署本机常见 ping inspect 1s 问不到,
会误报正在升级只有明确看到已注册列表且没有该任务时才拦截
"""
if getattr(settings, "CELERY_TASK_ALWAYS_EAGER", False):
return
require_worker()
now = time.monotonic()
cached = _registered_task_cache.get(task_name)
if cached and now < cached[1]:
available = cached[0]
else:
available = _inspect_task_registered(task_name)
ttl = _OK_TTL if available else _FAIL_TTL
_registered_task_cache[task_name] = (available, now + ttl)
if available is False:
exc = WorkerUnavailable()
exc.detail = "极速成片后台任务尚未加载,请重启 Celery worker 后再试。"
raise exc
@@ -0,0 +1,41 @@
from unittest.mock import Mock, patch
from django.test import SimpleTestCase, override_settings
from rest_framework.exceptions import APIException
from apps.common import celery_health
from apps.common.celery_health import require_worker_task
class RequireWorkerTaskTests(SimpleTestCase):
def setUp(self):
celery_health._registered_task_cache.clear()
celery_health._cache["expires"] = 0.0
def tearDown(self):
celery_health._registered_task_cache.clear()
celery_health._cache["expires"] = 0.0
def test_registered_name_accepts_short_name_and_rate_suffix(self):
listed = ["apps.projects.tasks.advance_quick_create_task[1/s]"]
self.assertTrue(
celery_health._task_name_listed("apps.projects.tasks.advance_quick_create_task", listed)
)
self.assertTrue(celery_health._task_name_listed("advance_quick_create_task", listed))
self.assertFalse(celery_health._task_name_listed("apps.projects.tasks.missing_task", listed))
@override_settings(CELERY_TASK_ALWAYS_EAGER=False)
@patch("apps.common.celery_health.celery_worker_available", return_value=True)
@patch("apps.common.celery_health._inspect_task_registered", return_value=None)
def test_inspect_unknown_does_not_block(self, _inspect, _available):
require_worker_task("apps.projects.tasks.advance_quick_create_task")
@override_settings(CELERY_TASK_ALWAYS_EAGER=False)
@patch("apps.common.celery_health.celery_worker_available", return_value=True)
@patch("apps.common.celery_health._inspect_task_registered", return_value=False)
def test_missing_task_blocks_with_restart_hint(self, _inspect, _available):
with self.assertRaises(APIException) as raised:
require_worker_task("apps.projects.tasks.advance_quick_create_task")
self.assertEqual(raised.exception.status_code, 503)
self.assertIn("重启", str(raised.exception.detail))
self.assertNotIn("正在升级", str(raised.exception.detail))
@@ -0,0 +1,92 @@
# Generated by Django 5.1.15 on 2026-08-24
import django.db.models.deletion
import uuid
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("accounts", "0009_team_price_multiplier"),
("projects", "0008_scripttemplate"),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="QuickCreateJob",
fields=[
("id", models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
("created_at", models.DateTimeField(auto_now_add=True)),
("updated_at", models.DateTimeField(auto_now=True)),
(
"status",
models.CharField(
choices=[
("queued", "Queued"),
("running", "Running"),
("succeeded", "Succeeded"),
("failed", "Failed"),
("cancelled", "Cancelled"),
],
default="queued",
max_length=24,
),
),
(
"phase",
models.CharField(
choices=[
("product", "Product"),
("script", "Script"),
("assets", "Assets"),
("production", "Storyboard and Video"),
("complete", "Complete"),
],
default="product",
max_length=24,
),
),
("progress", models.PositiveSmallIntegerField(default=0)),
("message", models.CharField(blank=True, max_length=255)),
("error_message", models.TextField(blank=True)),
("metadata", models.JSONField(blank=True, default=dict)),
("completed_at", models.DateTimeField(blank=True, null=True)),
(
"created_by",
models.ForeignKey(
blank=True,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="created_%(class)s_set",
to=settings.AUTH_USER_MODEL,
),
),
(
"project",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE,
related_name="quick_create_job",
to="projects.project",
),
),
(
"team",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="%(class)s_set",
to="accounts.team",
),
),
],
options={
"ordering": ["-created_at"],
"indexes": [
models.Index(fields=["team", "status", "-created_at"], name="projects_qu_team_id_a8a924_idx"),
models.Index(fields=["team", "phase"], name="projects_qu_team_id_98380e_idx"),
],
},
),
]
+38 -1
View File
@@ -70,6 +70,44 @@ class ProjectStage(TimeStampedModel):
ordering = ["created_at"]
class QuickCreateJob(TeamOwnedModel):
"""“极速成片”自动编排任务。
AI 子任务仍落在 AITask本表只保存跨商品脚本资产故事板视频和导出的编排状态
让页面刷新浏览器关闭后都能继续查询同一条流水线
"""
class Status(models.TextChoices):
QUEUED = "queued", "Queued"
RUNNING = "running", "Running"
SUCCEEDED = "succeeded", "Succeeded"
FAILED = "failed", "Failed"
CANCELLED = "cancelled", "Cancelled"
class Phase(models.TextChoices):
PRODUCT = "product", "Product"
SCRIPT = "script", "Script"
ASSETS = "assets", "Assets"
PRODUCTION = "production", "Storyboard and Video"
COMPLETE = "complete", "Complete"
project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="quick_create_job")
status = models.CharField(max_length=24, choices=Status.choices, default=Status.QUEUED)
phase = models.CharField(max_length=24, choices=Phase.choices, default=Phase.PRODUCT)
progress = models.PositiveSmallIntegerField(default=0)
message = models.CharField(max_length=255, blank=True)
error_message = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
completed_at = models.DateTimeField(null=True, blank=True)
class Meta:
ordering = ["-created_at"]
indexes = [
models.Index(fields=["team", "status", "-created_at"], name="projects_qu_team_id_a8a924_idx"),
models.Index(fields=["team", "phase"], name="projects_qu_team_id_98380e_idx"),
]
class ScriptVersion(TimeStampedModel):
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="script_versions")
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="script_versions")
@@ -334,4 +372,3 @@ class ExportJob(TimeStampedModel):
progress = models.PositiveIntegerField(default=0)
error_message = models.TextField(blank=True)
metadata = models.JSONField(default=dict, blank=True)
+132 -1
View File
@@ -10,6 +10,7 @@ from .models import (
ExportJob,
Project,
ProjectStage,
QuickCreateJob,
ScriptSegment,
ScriptTemplate,
ScriptVersion,
@@ -329,6 +330,132 @@ class ExportJobSerializer(serializers.ModelSerializer):
read_only_fields = fields
class QuickCreateJobSerializer(serializers.ModelSerializer):
"""极速成片轮询响应:只下发用户需要的四阶段进度和最终可播结果。"""
project_id = serializers.UUIDField(source="project.id", read_only=True)
product_id = serializers.UUIDField(source="project.product_id", read_only=True)
product_name = serializers.CharField(source="project.product.title", read_only=True)
title = serializers.CharField(source="project.name", read_only=True)
product_images = serializers.SerializerMethodField()
phase_index = serializers.SerializerMethodField()
settings = serializers.SerializerMethodField()
result = serializers.SerializerMethodField()
class Meta:
model = QuickCreateJob
fields = [
"id",
"project_id",
"product_id",
"product_name",
"title",
"product_images",
"status",
"phase",
"phase_index",
"settings",
"progress",
"message",
"error_message",
"result",
"created_at",
"updated_at",
"completed_at",
]
read_only_fields = fields
def get_product_images(self, obj) -> list[dict]:
product = getattr(obj.project, "product", None)
if product is None:
return []
return [
{
"asset_id": str(image.asset_id),
"url": _asset_preview_url(image.asset),
}
for image in product.images.all()
]
def get_phase_index(self, obj) -> int:
return {
QuickCreateJob.Phase.PRODUCT: 0,
QuickCreateJob.Phase.SCRIPT: 1,
QuickCreateJob.Phase.ASSETS: 2,
QuickCreateJob.Phase.PRODUCTION: 3,
QuickCreateJob.Phase.COMPLETE: 3,
}.get(obj.phase, 0)
def get_settings(self, obj) -> dict:
wizard = dict((obj.project.metadata or {}).get("wizard") or {})
return {
"aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"),
"resolution": str(wizard.get("resolution") or "720p").lower(),
"total_duration": int(wizard.get("total_duration") or 15),
"video_model_config_id": str(wizard.get("video_model_config_id") or ""),
"video_model_name": str(wizard.get("video_model_name") or ""),
"video_model_label": str(wizard.get("video_model_label") or ""),
}
def get_result(self, obj) -> dict | None:
if obj.status != QuickCreateJob.Status.SUCCEEDED:
return None
project = obj.project
settings = self.get_settings(obj)
video_url = _final_video_url(project)
segments = list(project.video_segments.all())
if not video_url and len(segments) == 1:
version = segments[0].adopted_version
if version is not None and is_playable_video(version.asset):
video_url = _asset_preview_url(version.asset)
first_shot = next(
(shot for shot in project.storyboard_shots.all() if shot.adopted_version_id),
None,
)
poster_url = _asset_preview_url(first_shot.adopted_version.asset) if first_shot else ""
script = next((version for version in project.script_versions.all() if version.is_adopted), None)
script_meta = (script.metadata or {}) if script is not None else {}
format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get(
script_meta.get("presentation_format"), "智能推荐"
)
structure_label = {
"pain": "痛点解决",
"contrast": "前后对比",
"review": "测评验证",
"scene": "场景种草",
}.get(script_meta.get("video_structure"), "智能推荐")
groups = list(project.base_asset_groups.all())
person = next((group for group in groups if group.kind == BaseAssetGroup.Kind.PERSON and group.adopted_asset_id), None)
scene = next((group for group in groups if group.kind == BaseAssetGroup.Kind.SCENE and group.adopted_asset_id), None)
duration = sum(segment.target_duration_seconds for segment in segments)
video_segments = []
for segment in sorted(segments, key=lambda item: item.sort_order):
version = segment.adopted_version
clip_url = _asset_preview_url(version.asset) if version is not None and is_playable_video(version.asset) else ""
video_segments.append(
{
"id": str(segment.id),
"sort_order": segment.sort_order,
"duration_seconds": segment.target_duration_seconds or 15,
"video_url": clip_url,
"poster_url": poster_url,
}
)
return {
"video_url": video_url,
"poster_url": poster_url,
"duration_seconds": duration or 15,
"aspect_ratio": settings["aspect_ratio"],
"resolution": settings["resolution"],
"video_model": settings["video_model_label"] or settings["video_model_name"],
"structure": structure_label,
"presentation": format_label,
"person": ((person.metadata or {}).get("label") or "自动匹配") if person else "自动匹配",
"scene": ((scene.metadata or {}).get("label") or "自动匹配") if scene else "自动匹配",
"video_segments": video_segments,
}
class ScriptSegmentSerializer(serializers.ModelSerializer):
class Meta:
model = ScriptSegment
@@ -359,13 +486,14 @@ class ProjectListSerializer(serializers.ModelSerializer):
video_segment_count = serializers.IntegerField(read_only=True, default=0)
# 合成成片地址:项目列表的播放按钮据此直接播成片(没合成过为空 → 退回进流水线)
final_video_url = serializers.SerializerMethodField()
quick_create = serializers.SerializerMethodField()
class Meta:
model = Project
fields = [
"id", "name", "product", "product_title", "cover_preview_url",
"status", "current_stage", "script_version_count", "video_segment_count",
"final_video_url",
"final_video_url", "quick_create",
"is_deleted", "purged_at", "created_at", "updated_at",
]
@@ -375,6 +503,9 @@ class ProjectListSerializer(serializers.ModelSerializer):
def get_final_video_url(self, obj) -> str:
return _final_video_url(obj)
def get_quick_create(self, obj) -> bool:
return bool((obj.metadata or {}).get("quick_create"))
class ProjectSerializer(serializers.ModelSerializer):
stages = ProjectStageSerializer(many=True, read_only=True)
+32 -15
View File
@@ -98,27 +98,28 @@ def _wrap_cjk(draw, text: str, font, max_width: int) -> list[str]:
return lines[:3] # 最多 3 行,够长截断
def _render_subtitle_png(text: str, style_key: str, path: Path) -> tuple[int, int]:
"""把一条字幕渲染成 1080 宽的透明 PNG(居中,带描边/底框),返回 (w,h)"""
def _render_subtitle_png(text: str, style_key: str, path: Path, *, canvas_w: int = 1080) -> tuple[int, int]:
"""把一条字幕渲染成与导出宽度一致的透明 PNG(居中,带描边/底框)。"""
from PIL import Image, ImageDraw
st = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
canvas_w = 1080
margin_x = 90
base = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
scale = max(0.4, canvas_w / 1080)
st = {**base, "size": max(24, round(base["size"] * scale)), "stroke_w": max(0, round(base["stroke_w"] * scale))}
margin_x = max(30, round(90 * scale))
font = _load_font(st["size"])
probe = ImageDraw.Draw(Image.new("RGBA", (10, 10)))
lines = _wrap_cjk(probe, (text or "").strip().replace("\n", " "), font, canvas_w - 2 * margin_x)
line_h = st["size"] + 16
pad = 22
line_h = st["size"] + max(6, round(16 * scale))
pad = max(8, round(22 * scale))
text_h = line_h * len(lines)
canvas_h = text_h + 2 * pad
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
draw = ImageDraw.Draw(img)
if st["box"]:
widest = max((draw.textlength(ln, font=font) for ln in lines), default=0)
box_w = int(widest) + 2 * pad + 24
box_w = int(widest) + 2 * pad + max(10, round(24 * scale))
x0 = (canvas_w - box_w) // 2
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=16, fill=st["box"])
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=max(6, round(16 * scale)), fill=st["box"])
y = pad
for ln in lines:
w = draw.textlength(ln, font=font)
@@ -186,7 +187,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
transition: str, sub_overlays: list[tuple[str, float, float]],
bgm_name: str | None, bgm_volume: float,
has_audio: list[bool] | None = None, fps: float = 30.0,
voice_overlays: list[tuple[str, float, float]] | None = None) -> list[str]:
voice_overlays: list[tuple[str, float, float]] | None = None,
output_width: int = 1080, output_height: int = 1920) -> list[str]:
has_audio = has_audio or [False] * n
voice_overlays = voice_overlays or []
fps_expr = f"{fps:.6g}"
@@ -194,8 +196,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
for i, s in enumerate(specs):
parts.append(
f"[{i}:v]trim=start={s['ts']:.3f}:end={s['te']:.3f},setpts=PTS-STARTPTS,"
"scale=1080:1920:force_original_aspect_ratio=decrease,"
f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
)
xname = XFADE_MAP.get(transition or "none")
if xname and n > 1:
@@ -213,8 +215,9 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
for j, (_png, start, end) in enumerate(sub_overlays):
idx = sub_base + j
out = "vout" if j == len(sub_overlays) - 1 else f"ov{j}"
bottom_offset = max(40, round(output_height * 150 / 1920))
parts.append(
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-{bottom_offset}:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
)
vlabel = out
@@ -331,6 +334,19 @@ def _split_subtitle_text(text: str) -> list[str]:
return out
def _timeline_output_size(timeline) -> tuple[int, int]:
"""读取时间线输出尺寸;旧项目或异常值安全回落到原来的 1080×1920。"""
try:
width_text, height_text = str(timeline.resolution or "").lower().split("x", 1)
width, height = int(width_text), int(height_text)
except (TypeError, ValueError):
return 1080, 1920
if not 320 <= width <= 4400 or not 320 <= height <= 4400:
return 1080, 1920
# H.264 yuv420p 要求偶数尺寸。
return width - width % 2, height - height % 2
def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]:
"""字幕条目(逐句):优先用 SubtitleTrack.content 里每条 cue 自带的 start_ms——
先定位到所属片段(输入时间轴=各片段时长累计),再重映射到输出时间轴(xfade 会压缩起点);
@@ -424,6 +440,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
style_key = str((subtitle_track.style or {}).get("key", "plain")) if subtitle_track else "plain"
specs = _clip_specs(clips)
output_width, output_height = _timeline_output_size(timeline)
xfade = 0.5 if XFADE_MAP.get(transition) and len(clips) > 1 else 0.0
starts, total = _output_starts(specs, xfade)
@@ -451,7 +468,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
sub_overlays: list[tuple[str, float, float]] = []
for i, (start, end, text) in enumerate(cues):
png = f"sub{i}.png"
_render_subtitle_png(text, style_key, tmp / png)
_render_subtitle_png(text, style_key, tmp / png, canvas_w=output_width)
sub_overlays.append((png, start, end))
# 旁白配音(TTS 资产):按 timeline.metadata.voiceover 映射下载,人声轨混在 BGM 之上;
@@ -484,6 +501,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
has_audio=has_audio, fps=output_fps, voice_overlays=voice_overlays,
output_width=output_width, output_height=output_height,
)
# 加超时:ffmpeg 卡死(资源不足/被 OOM 杀)时不能让任务永久停在 RUNNING、前端无限转圈;
# 超时即按失败收尾,前端能看到「导出失败」而非一直卡。15 分钟足够正常 60s 成片。
@@ -527,4 +545,3 @@ def run_export_job(export_job_id: str) -> ExportJob:
project.status = project.Status.COMPLETED
project.save(update_fields=["status", "updated_at"])
return export_job
+107 -1
View File
@@ -1,6 +1,8 @@
from dataclasses import dataclass
from apps.projects.models import ProjectStage
from django.utils import timezone
from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment
STAGE_ORDER = [
@@ -35,3 +37,107 @@ def can_enter_stage(current_stage: str, target_stage: str, allow_skip_storyboard
return StageTransition(current_stage, target_stage, False, "stage prerequisite is not satisfied")
def initialize_project_pipeline(project: Project, *, placeholder_segments: int = 4) -> None:
"""建立专业创作和极速成片共用的阶段、视频占位数据。幂等,可安全重试。"""
for stage_name in STAGE_ORDER:
ProjectStage.objects.get_or_create(project=project, stage=stage_name)
for index in range(placeholder_segments):
VideoSegment.objects.get_or_create(
project=project,
sort_order=index,
defaults={"target_duration_seconds": 15},
)
def sync_video_segments_to_script(project: Project, script: ScriptVersion) -> None:
"""把视频段数量和时长收口到采用脚本;已生成过的段绝不裁剪或改时长。"""
if not script.is_adopted:
return
script_segments = list(script.segments.order_by("sort_order"))
target = len(script_segments)
segments = list(project.video_segments.order_by("sort_order"))
while len(segments) > target:
tail = segments[-1]
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
tail.delete()
segments.pop()
else:
break
next_order = (segments[-1].sort_order + 1) if segments else 0
for _ in range(target - len(segments)):
index = len(segments)
seconds = script_segments[index].duration_seconds if index < target else 15
segments.append(
VideoSegment.objects.create(
project=project,
sort_order=next_order,
target_duration_seconds=seconds,
)
)
next_order += 1
stale: list[VideoSegment] = []
for index, video_segment in enumerate(segments):
if index >= target:
break
seconds = script_segments[index].duration_seconds
if not seconds or video_segment.target_duration_seconds == seconds:
continue
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
continue
video_segment.target_duration_seconds = seconds
stale.append(video_segment)
if stale:
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
def adopt_script_version(project: Project, script: ScriptVersion) -> None:
"""采用脚本并推进到资产阶段,供专业创作按钮和极速编排共同调用。"""
ScriptVersion.objects.filter(project=project).exclude(id=script.id).update(is_adopted=False)
if not script.is_adopted:
script.is_adopted = True
script.save(update_fields=["is_adopted", "updated_at"])
sync_video_segments_to_script(project, script)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
project.current_stage = ProjectStage.Stage.BASE_ASSETS
project.status = Project.Status.ASSETING
project.failure_reason = ""
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
def finish_storyboard_stage(project: Project) -> None:
"""故事板全部成功后推进到视频阶段,并再次校准片段数量。"""
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if adopted_script is not None:
sync_video_segments_to_script(project, adopted_script)
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
def finish_video_stage(project: Project) -> bool:
"""全部片段成功即完成专业创作的视频阶段。"""
segments = list(project.video_segments.values_list("status", "adopted_version_id"))
if not segments or not all(status == VideoSegment.Status.SUCCEEDED and adopted for status, adopted in segments):
return False
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.VIDEO)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.COMPLETED
project.failure_reason = ""
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
return True
@@ -0,0 +1,806 @@
"""“极速成片”自动编排。
这不是另一套生成实现脚本基础资产三视图故事板视频和合成全部调用专业创作现有服务
本模块只负责按状态推进并把子任务 ID 持久化到 QuickCreateJob方便刷新后恢复进度
"""
from __future__ import annotations
import json
import logging
import threading
from datetime import datetime, timedelta
from django.db import connections, transaction
from django.utils import timezone
from apps.ai.models import AITask, ModelConfig
from apps.ai.script_agent import stream_script_agent
from apps.ai.services import (
collect_video_review_blockers,
create_export_job,
generate_base_asset,
generate_person_triview,
get_default_model,
poll_storyboard,
submit_storyboard,
submit_video_segment,
)
from apps.assets import assets_client
from apps.assets.models import Asset
from apps.assets.review import poll_team_reviews, submit_asset_for_review
from apps.projects.models import (
BaseAssetGroup,
ExportJob,
Project,
ProjectStage,
QuickCreateJob,
Timeline,
TimelineClip,
VideoSegment,
)
from apps.projects.services.pipeline import (
STAGE_ORDER,
adopt_script_version,
finish_storyboard_stage,
finish_video_stage,
)
logger = logging.getLogger(__name__)
POLL_DELAY_SECONDS = 10
SCRIPT_POLL_SECONDS = 5
SCRIPT_TIMEOUT = timedelta(minutes=4)
SCRIPT_STOLEN_AFTER = timedelta(seconds=25)
STALE_AFTER = timedelta(seconds=12)
_ACTIVE_TASK_STATUSES = {
AITask.Status.CREATED,
AITask.Status.RESERVED,
AITask.Status.SUBMITTED,
AITask.Status.POLLING,
AITask.Status.POSTPROCESSING,
}
_FINISHED_STATUSES = {
QuickCreateJob.Status.SUCCEEDED,
QuickCreateJob.Status.FAILED,
QuickCreateJob.Status.CANCELLED,
}
def _is_finished(job: QuickCreateJob) -> bool:
return job.status in _FINISHED_STATUSES
def _quick_settings(project: Project) -> dict:
wizard = dict((project.metadata or {}).get("wizard") or {})
return {
"aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"),
"resolution": str(wizard.get("resolution") or "720p").lower(),
"total_duration": int(wizard.get("total_duration") or 15),
"video_model_config_id": str(wizard.get("video_model_config_id") or ""),
"video_model_name": str(wizard.get("video_model_name") or ""),
"video_model_label": str(wizard.get("video_model_label") or ""),
}
def _save_job(job: QuickCreateJob, **changes) -> None:
fields = []
for field, value in changes.items():
setattr(job, field, value)
fields.append(field)
if fields:
job.save(update_fields=[*fields, "updated_at"])
def _safe_error(exc: Exception) -> str:
raw = str(exc or "").strip()
lower = raw.lower()
if "insufficient credit" in lower or "额度不足" in raw:
return "可用积分不足,极速成片已暂停"
if "no active" in lower or "not configured" in lower or "没有可用" in raw:
return "当前缺少可用的生成模型,请联系管理员配置"
if "review" in lower or "审核" in raw:
return "生成素材未通过审核,请进入专业模式调整后重试"
return "极速成片暂未完成,请稍后重试或进入专业模式查看"
def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str = "") -> None:
job.refresh_from_db(fields=["status", "metadata"])
if job.status in {QuickCreateJob.Status.SUCCEEDED, QuickCreateJob.Status.CANCELLED}:
return
public_message = (message or "极速成片暂未完成,请稍后重试").strip()[:500]
metadata = dict(job.metadata or {})
if internal_error:
metadata["internal_error"] = internal_error[:2000]
_save_job(
job,
status=QuickCreateJob.Status.FAILED,
error_message=public_message,
message=public_message,
metadata=metadata,
)
project = job.project
if project.status != Project.Status.COMPLETED:
project.status = Project.Status.FAILED
project.failure_reason = public_message
project.save(update_fields=["status", "failure_reason", "updated_at"])
if project.current_stage in STAGE_ORDER:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage)
stage.status = ProjectStage.Status.FAILED
stage.error_message = public_message
stage.save(update_fields=["status", "error_message", "updated_at"])
def cancel_quick_create(job: QuickCreateJob) -> QuickCreateJob:
"""用户主动停掉进行中的极速成片;已完成的任务保持原样。"""
if job.status == QuickCreateJob.Status.SUCCEEDED:
return job
if job.status in {QuickCreateJob.Status.FAILED, QuickCreateJob.Status.CANCELLED}:
return job
public_message = "已取消本次生成"
metadata = dict(job.metadata or {})
metadata["cancelled"] = True
_save_job(
job,
status=QuickCreateJob.Status.CANCELLED,
error_message=public_message,
message=public_message,
metadata=metadata,
completed_at=timezone.now(),
)
project = job.project
project.status = Project.Status.FAILED
project.failure_reason = public_message
project.save(update_fields=["status", "failure_reason", "updated_at"])
if project.current_stage in STAGE_ORDER:
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage)
if stage.status not in {ProjectStage.Status.SUCCEEDED, ProjectStage.Status.FAILED}:
stage.status = ProjectStage.Status.FAILED
stage.error_message = public_message
stage.save(update_fields=["status", "error_message", "updated_at"])
return job
def _consume_script_agent(job: QuickCreateJob) -> None:
project = job.project
settings = _quick_settings(project)
user = job.created_by or project.created_by
if user is None:
raise ValueError("极速成片任务缺少创建人")
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise ValueError("no active text model configured")
error_detail = ""
stream = stream_script_agent(
project=project,
user=user,
model_config=model_config,
mode="auto",
user_prompt=(
"请根据商品名称与商品参考图信息,自动推荐最适合的"
f"{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。"
),
aspect_ratio=settings["aspect_ratio"],
total_duration=settings["total_duration"],
presentation_format="oral",
video_structure="pain",
entry_source="ai",
persona="reviewer",
)
for frame in stream:
job.refresh_from_db(fields=["status"])
if _is_finished(job):
return
if not frame.startswith("data:"):
continue
try:
event = json.loads(frame[5:].strip())
except (TypeError, ValueError):
continue
if event.get("type") == "error":
error_detail = str(event.get("detail") or "脚本生成失败")
if error_detail:
raise ValueError(error_detail)
script = project.script_versions.order_by("-created_at").first()
if script is None or not script.segments.exists():
raise ValueError("脚本生成后没有可用分镜")
adopt_script_version(project, script)
def consume_quick_script(job_id: str) -> None:
"""Celery 专用:跑完专业创作同一套脚本 agent,再把编排推去资产阶段。"""
from apps.projects.tasks import advance_quick_create_task
job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id)
if _is_finished(job):
return
adopted = job.project.script_versions.filter(is_adopted=True).first()
if adopted is not None and adopted.segments.exists():
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
return
try:
_consume_script_agent(job)
except Exception as exc: # noqa: BLE001 — 脚本失败必须落终态
logger.exception("quick create script failed for job %s", job.id)
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
return
job.refresh_from_db(fields=["status"])
if _is_finished(job):
return
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
def _adopted_script(project: Project):
script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if script is not None and script.segments.exists():
return script
return None
def _parse_iso(value) -> datetime | None:
if not value:
return None
try:
parsed = datetime.fromisoformat(str(value))
except (TypeError, ValueError):
return None
if timezone.is_naive(parsed):
parsed = timezone.make_aware(parsed)
return parsed
def _advance_product(job: QuickCreateJob) -> int:
"""商品已在提交接口建好;这里只补卖点并切到脚本阶段,让页面先看到「识别商品」。"""
from apps.products.models import ProductSellingPoint
product = job.project.product
if product is not None and not product.selling_points.exists():
ProductSellingPoint.objects.create(
product=product,
title=product.title,
detail="由极速成片根据商品名称自动填写",
sort_order=0,
)
_save_job(
job,
status=QuickCreateJob.Status.RUNNING,
phase=QuickCreateJob.Phase.SCRIPT,
progress=18,
message="已识别商品与卖点,正在推荐脚本方向",
)
return 1
def _advance_script(job: QuickCreateJob) -> int | None:
if _adopted_script(job.project) is not None:
_save_job(job, phase=QuickCreateJob.Phase.ASSETS, progress=48, message="脚本已生成,正在匹配模特与场景")
return 1
metadata = dict(job.metadata or {})
if not metadata.get("script_started"):
from apps.projects.tasks import run_quick_script_task
metadata["script_started"] = True
metadata["script_started_at"] = timezone.now().isoformat()
_save_job(
job,
metadata=metadata,
progress=28,
message="正在根据商品名称与图片生成带货脚本",
)
run_quick_script_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
return SCRIPT_POLL_SECONDS
failed = (
AITask.objects.filter(
project=job.project,
task_type=AITask.Type.SCRIPT_GENERATION,
status=AITask.Status.FAILED,
)
.order_by("-created_at")
.first()
)
if failed is not None:
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
return None
started_at = _parse_iso(metadata.get("script_started_at"))
if started_at and timezone.now() - started_at > SCRIPT_TIMEOUT:
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
return None
elapsed = int((timezone.now() - started_at).total_seconds()) if started_at else 0
_save_job(job, progress=min(44, 28 + elapsed // 8), message="正在生成分镜脚本…")
return SCRIPT_POLL_SECONDS
def _ensure_fallback_entities(project: Project) -> list[dict]:
"""短脚本偶尔只提到商品;极速模式仍补齐一名模特和一个空镜场景,保证自动链路可走完。"""
metadata = dict(project.metadata or {})
entities = [dict(item) for item in (metadata.get("script_entities") or []) if isinstance(item, dict)]
types = {str(item.get("type") or "") for item in entities}
added_ids: list[str] = []
if "character" not in types:
entities.append(
{
"id": "quick_character_1",
"type": "character",
"name": "推荐模特",
"visual_prompt": f"专业电商测评模特,亲和自然,适合展示{project.product.title}",
"ref_index": 1,
}
)
added_ids.append("quick_character_1")
if "scene" not in types:
settings = _quick_settings(project)
entities.append(
{
"id": "quick_scene_1",
"type": "scene",
"name": "推荐场景",
"visual_prompt": (
f"明亮干净的电商产品展示空间,适合突出{project.product.title}"
f"{settings['aspect_ratio']}构图"
),
"ref_index": 2,
}
)
added_ids.append("quick_scene_1")
if added_ids:
metadata["script_entities"] = entities
metadata["cast"] = [item["name"] for item in entities if item.get("type") == "character"]
metadata["scenes"] = [item["name"] for item in entities if item.get("type") == "scene"]
metadata["cast_prompts"] = {
item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "character"
}
metadata["scene_prompts"] = {
item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "scene"
}
project.metadata = metadata
project.save(update_fields=["metadata", "updated_at"])
script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if script is not None:
for segment in script.segments.all():
refs = list(segment.entity_refs or [])
segment.entity_refs = [*refs, *(item for item in added_ids if item not in refs)]
segment.save(update_fields=["entity_refs", "updated_at"])
return entities
def _start_base_assets(job: QuickCreateJob) -> None:
project = job.project
user = job.created_by or project.created_by
if user is None:
raise ValueError("极速成片任务缺少创建人")
with transaction.atomic():
job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id)
metadata = dict(job.metadata or {})
if metadata.get("base_asset_task_ids") or metadata.get("assets_started"):
return
metadata["assets_started"] = True
job.metadata = metadata
job.save(update_fields=["metadata", "updated_at"])
entities = _ensure_fallback_entities(job.project)
specs = [
(BaseAssetGroup.Kind.PRODUCT, job.project.product.title, job.project.product.title),
*[
(BaseAssetGroup.Kind.PERSON, str(item.get("visual_prompt") or item.get("name") or "电商模特"), str(item.get("name") or "推荐模特"))
for item in entities
if item.get("type") == "character"
],
*[
(BaseAssetGroup.Kind.SCENE, str(item.get("visual_prompt") or item.get("name") or "电商场景"), str(item.get("name") or "推荐场景"))
for item in entities
if item.get("type") == "scene"
],
]
task_ids = []
for kind, prompt, label in specs:
task = generate_base_asset(project=job.project, user=user, kind=kind, prompt=prompt, label=label)
task_ids.append(str(task.id))
metadata = dict(job.metadata or {})
metadata["base_asset_task_ids"] = task_ids
_save_job(job, metadata=metadata, message="正在生成商品、模特与场景资产", progress=52)
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
def _task_failure(tasks) -> AITask | None:
return next(
(
task
for task in tasks
if task.status == AITask.Status.FAILED
or task.status not in _ACTIVE_TASK_STATUSES | {AITask.Status.SUCCEEDED}
),
None,
)
def _task_public_error(task: AITask) -> str:
from apps.ai.generation_errors import public_error_for_task
public_error = public_error_for_task(task)
return public_error.fallback_message if public_error else "生成任务失败,请稍后重试"
def _advance_assets(job: QuickCreateJob) -> int | None:
metadata = dict(job.metadata or {})
base_ids = metadata.get("base_asset_task_ids") or []
if not base_ids:
_start_base_assets(job)
return POLL_DELAY_SECONDS
tasks = list(AITask.objects.filter(project=job.project, id__in=base_ids))
failed = _task_failure(tasks)
if failed is not None:
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
return None
if len(tasks) < len(base_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in tasks):
done = sum(task.status == AITask.Status.SUCCEEDED for task in tasks)
_save_job(job, progress=min(64, 52 + done * 4), message="正在匹配并生成模特与场景")
return POLL_DELAY_SECONDS
triview_ids = metadata.get("triview_task_ids")
if triview_ids is None:
person_groups = list(
job.project.base_asset_groups.filter(
kind=BaseAssetGroup.Kind.PERSON,
task_id__in=base_ids,
adopted_asset__isnull=False,
).select_related("adopted_asset")
)
triview_ids = [
str(generate_person_triview(project=job.project, user=job.created_by or job.project.created_by, portrait_asset=group.adopted_asset).id)
for group in person_groups
]
metadata["triview_task_ids"] = triview_ids
_save_job(job, metadata=metadata, progress=65, message="正在为推荐模特生成三视图")
return POLL_DELAY_SECONDS
triview_tasks = list(AITask.objects.filter(project=job.project, id__in=triview_ids))
failed = _task_failure(triview_tasks)
if failed is not None:
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
return None
if len(triview_tasks) < len(triview_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in triview_tasks):
_save_job(job, progress=68, message="正在为推荐模特生成三视图")
return POLL_DELAY_SECONDS
for group in job.project.base_asset_groups.filter(adopted_asset__isnull=False):
if group.kind != BaseAssetGroup.Kind.PRODUCT:
group_meta = dict(group.metadata or {})
group_meta["adopt"] = "adopted"
group.metadata = group_meta
group.save(update_fields=["metadata", "updated_at"])
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.SUCCEEDED
stage.completed_at = timezone.now()
stage.error_message = ""
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
job.project.current_stage = ProjectStage.Stage.STORYBOARD
job.project.status = Project.Status.STORYBOARDING
job.project.save(update_fields=["current_stage", "status", "updated_at"])
_save_job(
job,
phase=QuickCreateJob.Phase.PRODUCTION,
progress=72,
message="正在生成故事板与镜头画面",
)
return 1
def _reviews_ready(job: QuickCreateJob) -> bool | None:
"""True=可出视频,False=继续等,None=审核失败且任务已终止。"""
if not assets_client.is_enabled():
return True
poll_team_reviews(job.team)
blockers = collect_video_review_blockers(job.project)
if not blockers:
return True
if any(item.get("review_status") == "failed" for item in blockers):
fail_quick_create(job, "生成素材未通过审核,请进入专业模式调整后重试")
return None
metadata = dict(job.metadata or {})
wait_started = metadata.get("review_wait_started")
if not wait_started:
metadata["review_wait_started"] = timezone.now().isoformat()
_save_job(job, metadata=metadata)
else:
try:
started_at = datetime.fromisoformat(str(wait_started))
if timezone.is_naive(started_at):
started_at = timezone.make_aware(started_at)
if timezone.now() - started_at > timedelta(minutes=20):
fail_quick_create(job, "素材质量检查等待超时,请进入专业模式查看")
return None
except (TypeError, ValueError):
metadata["review_wait_started"] = timezone.now().isoformat()
_save_job(job, metadata=metadata)
for item in blockers:
if not item.get("review_status") and item.get("asset_id"):
asset = Asset.objects.filter(team=job.team, id=item["asset_id"]).first()
if asset is not None:
submit_asset_for_review(asset)
_save_job(job, progress=82, message="故事板已完成,正在进行视频素材质量检查")
return False
def _start_videos(job: QuickCreateJob) -> None:
from apps.projects.tasks import poll_video_segment_task
settings = _quick_settings(job.project)
for segment in job.project.video_segments.order_by("sort_order"):
if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}:
continue
submit_video_segment(
video_segment=segment,
user=job.created_by or job.project.created_by,
prompt="极速成片自动生成,严格遵循本镜故事板与脚本。",
model_config_id=settings["video_model_config_id"] or None,
aspect_ratio=settings["aspect_ratio"],
resolution=settings["resolution"],
)
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
metadata = dict(job.metadata or {})
metadata["video_started"] = True
_save_job(
job,
metadata=metadata,
progress=86,
message=f"正在生成{settings['total_duration']}{settings['aspect_ratio']} 视频",
)
def _start_export(job: QuickCreateJob) -> None:
from apps.projects.tasks import run_export_job_task
from apps.ai.video_pricing import get_resolution
project = job.project
settings = _quick_settings(project)
total_duration = sum(project.video_segments.values_list("target_duration_seconds", flat=True)) or 15
width, height = get_resolution(settings["aspect_ratio"], settings["resolution"])
with transaction.atomic():
timeline, _ = Timeline.objects.get_or_create(
project=project,
defaults={
"name": f"{project.name} Timeline",
"duration_seconds": total_duration,
"aspect_ratio": settings["aspect_ratio"],
"resolution": f"{width}x{height}",
},
)
timeline.duration_seconds = total_duration
timeline.aspect_ratio = settings["aspect_ratio"]
timeline.resolution = f"{width}x{height}"
timeline.save(update_fields=["duration_seconds", "aspect_ratio", "resolution", "updated_at"])
timeline.clips.all().delete()
start_ms = 0
for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"):
TimelineClip.objects.create(
timeline=timeline,
asset=segment.adopted_version.asset,
sort_order=segment.sort_order,
start_ms=start_ms,
duration_ms=segment.target_duration_seconds * 1000,
)
start_ms += segment.target_duration_seconds * 1000
export_job = create_export_job(timeline=timeline, user=job.created_by or project.created_by)
run_export_job_task.delay(str(export_job.id))
metadata = dict(job.metadata or {})
metadata["export_job_id"] = str(export_job.id)
_save_job(job, metadata=metadata, progress=96, message="各镜头已生成,正在合成为完整视频")
def _videos_ready(job: QuickCreateJob) -> bool:
segments = list(job.project.video_segments.order_by("sort_order"))
if not segments:
return False
return all(
segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id
for segment in segments
)
def _can_complete(job: QuickCreateJob) -> bool:
if not _videos_ready(job):
return False
segments = list(job.project.video_segments.all())
if len(segments) <= 1:
return True
export_job_id = (job.metadata or {}).get("export_job_id")
if not export_job_id:
return False
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
return export_job is not None and export_job.status == ExportJob.Status.SUCCEEDED
def _complete(job: QuickCreateJob) -> None:
_save_job(
job,
status=QuickCreateJob.Status.SUCCEEDED,
phase=QuickCreateJob.Phase.COMPLETE,
progress=100,
message="视频已生成",
error_message="",
completed_at=timezone.now(),
)
def _advance_production(job: QuickCreateJob) -> int | None:
metadata = dict(job.metadata or {})
if not metadata.get("storyboard_started"):
submit_storyboard(project=job.project, user=job.created_by or job.project.created_by, prompt="")
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.RUNNING
stage.save(update_fields=["status", "updated_at"])
metadata["storyboard_started"] = True
_save_job(job, metadata=metadata, progress=74, message="正在生成故事板与镜头画面")
if job.project.current_stage != ProjectStage.Stage.VIDEO:
result = poll_storyboard(project=job.project, user=job.created_by or job.project.created_by)
if result.get("status") == "failed":
fail_quick_create(job, str(result.get("error") or "故事板生成失败"))
return None
if result.get("status") != "succeeded":
total = max(1, int(result.get("total") or 1))
done = int(result.get("done") or 0)
_save_job(job, progress=min(80, 74 + round(done / total * 6)), message=f"正在生成故事板({done}/{total}")
return POLL_DELAY_SECONDS
finish_storyboard_stage(job.project)
review_state = _reviews_ready(job)
if review_state is None:
return None
if not review_state:
return POLL_DELAY_SECONDS
if not (job.metadata or {}).get("video_started"):
_start_videos(job)
return POLL_DELAY_SECONDS
segments = list(job.project.video_segments.order_by("sort_order"))
failed = next((segment for segment in segments if segment.status == VideoSegment.Status.FAILED), None)
if failed is not None:
fail_quick_create(job, failed.error_message or f"{failed.sort_order + 1}段视频生成失败")
return None
completed = sum(
1
for segment in segments
if segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id
)
if completed < len(segments):
_save_job(job, progress=min(94, 86 + completed * 3), message=f"正在生成视频({completed}/{len(segments)}")
return POLL_DELAY_SECONDS
finish_video_stage(job.project)
if len(segments) == 1:
_complete(job)
return None
export_job_id = (job.metadata or {}).get("export_job_id")
if not export_job_id:
_start_export(job)
return POLL_DELAY_SECONDS
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
if export_job is None:
raise ValueError("视频合成任务不存在")
if export_job.status == ExportJob.Status.FAILED:
fail_quick_create(job, "视频片段已生成,但自动合成失败,请进入专业模式查看", internal_error=export_job.error_message)
return None
if export_job.status != ExportJob.Status.SUCCEEDED:
_save_job(job, progress=max(96, min(99, int(export_job.progress or 0))), message="正在合成为完整视频")
return POLL_DELAY_SECONDS
_complete(job)
return None
_local_script_jobs: set[str] = set()
def _script_task_started(job: QuickCreateJob) -> bool:
return job.project.ai_tasks.filter(task_type=AITask.Type.SCRIPT_GENERATION).exists()
def _claim_next_advance(job_id: str, delay_seconds: int) -> bool:
"""同一任务同一时刻只允许一枚后续推进,避免轮询把队列打爆。"""
with transaction.atomic():
job = QuickCreateJob.objects.select_for_update().get(id=job_id)
metadata = dict(job.metadata or {})
eta = _parse_iso(metadata.get("next_advance_at"))
if eta and eta > timezone.now():
return False
metadata["next_advance_at"] = (timezone.now() + timedelta(seconds=max(1, delay_seconds))).isoformat()
job.metadata = metadata
job.save(update_fields=["metadata", "updated_at"])
return True
def _run_quick_script_in_thread(job_id: str) -> None:
"""共享 Redis 上的旧 worker 会把未注册任务直接丢掉;本机线程兜底,避免页面永久转圈。"""
if job_id in _local_script_jobs:
return
_local_script_jobs.add(job_id)
def _worker() -> None:
try:
consume_quick_script(job_id)
except Exception as exc: # noqa: BLE001 — 线程失败必须落终态
job = QuickCreateJob.objects.select_related("project").filter(id=job_id).first()
if job is not None and not _is_finished(job):
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
finally:
_local_script_jobs.discard(job_id)
connections.close_all()
threading.Thread(target=_worker, daemon=True, name=f"quick-script-{job_id[:8]}").start()
def recover_quick_create(job: QuickCreateJob) -> None:
"""前端轮询时把卡住的编排拉起来:超时落失败,被旧 worker 丢掉的脚本改走本机线程。"""
job.refresh_from_db()
if job.status == QuickCreateJob.Status.FAILED and _can_complete(job):
_complete(job)
return
if _is_finished(job):
return
job_id = str(job.id)
metadata = dict(job.metadata or {})
if job.phase == QuickCreateJob.Phase.SCRIPT and metadata.get("script_started"):
started_at = _parse_iso(metadata.get("script_started_at"))
adopted = _adopted_script(job.project)
local_running = job_id in _local_script_jobs
timed_out = bool(started_at and timezone.now() - started_at > SCRIPT_TIMEOUT)
stolen = bool(
started_at
and timezone.now() - started_at > SCRIPT_STOLEN_AFTER
and adopted is None
and not local_running
)
if stolen:
metadata["script_local"] = True
metadata["script_started_at"] = timezone.now().isoformat()
_save_job(job, metadata=metadata, message="正在继续生成分镜脚本…")
_run_quick_script_in_thread(job_id)
return
if timed_out and adopted is None and not local_running:
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
return
if timezone.now() - job.updated_at > STALE_AFTER and _claim_next_advance(str(job.id), SCRIPT_POLL_SECONDS):
from apps.projects.tasks import advance_quick_create_task
try:
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
except Exception: # noqa: BLE001 — 队列不可用时就地推进一步,避免永久 loading
advance_quick_create(str(job.id))
def advance_quick_create(job_id: str) -> int | None:
"""推进一个状态并返回下次轮询秒数;返回 None 表示终态。
一次只做一段识别商品 脚本(独立任务) 资产 故事板/视频/合成
脚本不再在编排任务里同步吃完整条 SSE避免页面一直停在推荐脚本方向
"""
job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id)
if _is_finished(job):
return None
try:
if job.status == QuickCreateJob.Status.QUEUED:
_save_job(job, status=QuickCreateJob.Status.RUNNING, progress=8, message="正在识别商品图片与卖点")
if job.phase == QuickCreateJob.Phase.PRODUCT:
return _advance_product(job)
if job.phase == QuickCreateJob.Phase.SCRIPT:
return _advance_script(job)
if job.phase == QuickCreateJob.Phase.ASSETS:
return _advance_assets(job)
if job.phase == QuickCreateJob.Phase.PRODUCTION:
return _advance_production(job)
return None
except Exception as exc: # noqa: BLE001 — 编排失败必须落可恢复终态,不能留下永久 loading
logger.exception("quick create job %s failed", job.id)
if _can_complete(job):
_complete(job)
return None
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
return None
+53
View File
@@ -42,3 +42,56 @@ def run_export_job_task(self, export_job_id: str) -> str:
export_job.save(update_fields=["status", "error_message", "updated_at"])
raise
return export_job_id
QUICK_CREATE_QUEUE = "airshelf.quick"
@app.task(bind=True, max_retries=0, soft_time_limit=240, time_limit=270, queue=QUICK_CREATE_QUEUE)
def run_quick_script_task(self, quick_job_id: str) -> str:
"""脚本生成单独跑,避免把整条极速成片编排堵在一次 SSE 消费里。"""
from celery.exceptions import SoftTimeLimitExceeded
from apps.projects.models import QuickCreateJob
from apps.projects.services.quick_create import consume_quick_script, fail_quick_create
try:
consume_quick_script(quick_job_id)
except SoftTimeLimitExceeded:
job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first()
if job is not None and job.status not in {
QuickCreateJob.Status.SUCCEEDED,
QuickCreateJob.Status.FAILED,
QuickCreateJob.Status.CANCELLED,
}:
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
raise
return quick_job_id
@app.task(bind=True, max_retries=0, queue=QUICK_CREATE_QUEUE)
def advance_quick_create_task(self, quick_job_id: str) -> str:
"""一次只推进一个可重入状态,等待型阶段通过重新入队轮询,不占 worker 睡眠。"""
from apps.projects.services.quick_create import advance_quick_create
next_delay = advance_quick_create(quick_job_id)
if next_delay is not None:
from apps.projects.services.quick_create import _claim_next_advance
if not _claim_next_advance(quick_job_id, int(next_delay)):
return quick_job_id
try:
advance_quick_create_task.apply_async(
args=[quick_job_id],
countdown=max(1, int(next_delay)),
queue=QUICK_CREATE_QUEUE,
)
except Exception as exc: # noqa: BLE001 — 重排失败不能留下永久“生成中”
from apps.projects.models import QuickCreateJob
from apps.projects.services.quick_create import fail_quick_create
job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first()
if job is not None:
fail_quick_create(job, "生成队列暂时中断,请稍后重试", internal_error=str(exc))
raise
return quick_job_id
@@ -0,0 +1,490 @@
from datetime import timedelta
from unittest.mock import patch
from types import SimpleNamespace
import uuid
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.utils import timezone
from rest_framework.test import APIClient
from apps.accounts.models import Team, TeamMember, User
from apps.ai.models import ModelConfig, ModelProvider
from apps.assets.models import Asset, AssetFile
from apps.products.models import Product, ProductImage
from apps.projects.models import Project, ProjectStage, QuickCreateJob, ScriptSegment, ScriptVersion, VideoSegment, VideoSegmentVersion
from apps.projects.serializers import ProjectListSerializer, QuickCreateJobSerializer
from apps.projects.services.pipeline import initialize_project_pipeline
from apps.projects.services.quick_create import (
_start_videos,
advance_quick_create,
cancel_quick_create,
recover_quick_create,
)
class QuickCreateApiTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="quick-owner", password="pass")
self.team = Team.objects.create(name="Quick Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.client = APIClient()
self.client.force_authenticate(self.user)
def _uploaded_asset(self, **kwargs):
upload = kwargs["upload"]
return Asset.objects.create(
team=self.team,
created_by=self.user,
name=upload.name,
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PRODUCT_IMAGE,
)
@patch("apps.projects.views.get_default_model", return_value=object())
@patch("apps.projects.views.advance_quick_create_task.apply_async")
@patch("apps.projects.views.require_worker_task")
@patch("apps.projects.views._store_uploaded_asset")
def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model):
store_asset.side_effect = self._uploaded_asset
video_model_id = uuid.uuid4()
get_model.side_effect = [
object(),
object(),
SimpleNamespace(
id=video_model_id,
name="doubao-seedance-2-0-fast-260128",
display_name="Seedance 2.0 Fast",
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
),
]
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "轻醒咖啡",
"images": [
SimpleUploadedFile("front.png", b"png-one", content_type="image/png"),
SimpleUploadedFile("detail.webp", b"webp-two", content_type="image/webp"),
],
"aspect_ratio": "9:16",
"resolution": "720p",
"total_duration": "30",
},
format="multipart",
)
self.assertEqual(response.status_code, 202)
product = Product.objects.get(title="轻醒咖啡")
self.assertEqual(product.images.count(), 2)
self.assertEqual(product.cover_asset_id, product.images.order_by("sort_order").first().asset_id)
project = Project.objects.get(product=product)
self.assertTrue(project.metadata["quick_create"])
self.assertEqual(project.metadata["wizard"]["total_duration"], 30)
self.assertEqual(project.metadata["wizard"]["resolution"], "720p")
self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id))
self.assertEqual(project.stages.count(), 5)
self.assertEqual(project.video_segments.count(), 2)
self.assertTrue(product.selling_points.exists())
job = QuickCreateJob.objects.get(project=project)
self.assertEqual(str(job.id), response.data["id"])
enqueue.assert_called_once()
self.assertEqual(enqueue.call_args.kwargs["args"], [str(job.id)])
self.assertEqual(enqueue.call_args.kwargs["queue"], "airshelf.quick")
require_worker_task.assert_called_once_with("apps.projects.tasks.advance_quick_create_task")
self.assertEqual(get_model.call_count, 3)
self.assertEqual(len(response.data["product_images"]), 2)
self.assertEqual(
{item["asset_id"] for item in response.data["product_images"]},
{str(image.asset_id) for image in product.images.order_by("sort_order")},
)
@patch("apps.projects.views.get_default_model", return_value=object())
@patch("apps.projects.views.advance_quick_create_task.apply_async")
@patch("apps.projects.views.require_worker_task")
@patch("apps.projects.views._store_uploaded_asset")
def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model):
source_asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="已有主图.png",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PRODUCT_IMAGE,
)
source = Product.objects.create(team=self.team, created_by=self.user, title="旧商品", cover_asset=source_asset)
ProductImage.objects.create(product=source, asset=source_asset, sort_order=0, is_primary=True)
get_model.side_effect = [object(), object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})]
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "复用商品图",
"source_product_id": str(source.id),
"aspect_ratio": "9:16",
"resolution": "720p",
"total_duration": "15",
},
format="multipart",
)
self.assertEqual(response.status_code, 202)
store_asset.assert_not_called()
product = Product.objects.get(title="复用商品图")
self.assertEqual(product.images.count(), 1)
self.assertEqual(product.images.first().asset_id, source_asset.id)
self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id))
@patch("apps.projects.views.require_worker_task")
def test_submit_rejects_foreign_source_product_images(self, require_worker_task):
other = User.objects.create_user(username="quick-image-other", password="pass")
other_team = Team.objects.create(name="Image Other Team", owner=other)
foreign = Product.objects.create(team=other_team, created_by=other, title="别人的图")
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "偷图",
"source_product_id": str(foreign.id),
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertFalse(Product.objects.filter(title="偷图").exists())
def test_status_returns_saved_product_images(self):
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="主图.png",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PRODUCT_IMAGE,
)
AssetFile.objects.create(
asset=asset,
object_key="quick/product.png",
bucket="test",
content_type="image/png",
preview_url="https://cdn.example/product.png",
is_primary=True,
)
product = Product.objects.create(team=self.team, created_by=self.user, title="带图商品", cover_asset=asset)
ProductImage.objects.create(product=product, asset=asset, sort_order=0, is_primary=True)
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="带图项目")
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
response = self.client.get(f"/api/projects/quick-create-status/{job.id}/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["product_images"], [{"asset_id": str(asset.id), "url": "https://cdn.example/product.png"}])
@patch("apps.projects.views.require_worker_task")
def test_submit_rejects_non_image(self, require_worker_task):
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "错误文件",
"images": [SimpleUploadedFile("note.txt", b"text", content_type="text/plain")],
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertFalse(Product.objects.filter(title="错误文件").exists())
@patch("apps.projects.views.get_default_model", return_value=object())
@patch("apps.projects.views.require_worker_task")
def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model):
provider = ModelProvider.objects.create(name="quick-video-provider", display_name="Quick", status="active")
model = ModelConfig.objects.create(
provider=provider,
name="quick-fast",
display_name="Quick Fast",
capability=ModelConfig.Capability.VIDEO,
status=ModelConfig.Status.ACTIVE,
metadata={"capabilities": {"resolutions": ["480p", "720p"], "durations": [15]}},
)
response = self.client.post(
"/api/projects/quick-create/",
{
"name": "不兼容清晰度",
"images": [SimpleUploadedFile("front.png", b"png", content_type="image/png")],
"video_model_config_id": str(model.id),
"resolution": "1080p",
},
format="multipart",
)
self.assertEqual(response.status_code, 400)
self.assertIn("不支持 1080P", response.data["detail"])
self.assertFalse(Product.objects.filter(title="不兼容清晰度").exists())
def test_status_is_team_scoped(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品")
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目")
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
other = User.objects.create_user(username="quick-other", password="pass")
other_team = Team.objects.create(name="Other Team", owner=other)
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
other_client = APIClient()
other_client.force_authenticate(other)
self.assertEqual(other_client.get(f"/api/projects/quick-create-status/{job.id}/").status_code, 404)
def test_cancel_stops_running_job(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="待取消商品")
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="待取消项目")
job = QuickCreateJob.objects.create(
team=self.team,
created_by=self.user,
project=project,
status=QuickCreateJob.Status.RUNNING,
phase=QuickCreateJob.Phase.SCRIPT,
message="正在根据商品名称与图片生成带货脚本",
)
response = self.client.post(f"/api/projects/quick-create-cancel/{job.id}/")
self.assertEqual(response.status_code, 200)
job.refresh_from_db()
self.assertEqual(job.status, QuickCreateJob.Status.CANCELLED)
self.assertEqual(response.data["status"], "cancelled")
self.assertIn("取消", response.data["error_message"])
def test_cancel_is_team_scoped(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品")
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目")
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
other = User.objects.create_user(username="quick-cancel-other", password="pass")
other_team = Team.objects.create(name="Cancel Other Team", owner=other)
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
other_client = APIClient()
other_client.force_authenticate(other)
self.assertEqual(other_client.post(f"/api/projects/quick-create-cancel/{job.id}/").status_code, 404)
def test_history_lists_succeeded_jobs_for_team(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品")
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 极速成片")
QuickCreateJob.objects.create(
team=self.team,
created_by=self.user,
project=project,
status=QuickCreateJob.Status.SUCCEEDED,
phase=QuickCreateJob.Phase.COMPLETE,
)
other = User.objects.create_user(username="quick-history-other", password="pass")
other_team = Team.objects.create(name="History Other Team", owner=other)
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
other_product = Product.objects.create(team=other_team, created_by=other, title="别人的商品")
other_project = Project.objects.create(team=other_team, created_by=other, product=other_product, name="别人的项目")
QuickCreateJob.objects.create(
team=other_team,
created_by=other,
project=other_project,
status=QuickCreateJob.Status.SUCCEEDED,
phase=QuickCreateJob.Phase.COMPLETE,
)
response = self.client.get("/api/projects/quick-create-history/")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.data["count"], 1)
self.assertEqual(response.data["results"][0]["product_name"], "历史商品")
self.assertEqual(response.data["results"][0]["title"], "历史商品 · 极速成片")
def test_list_serializer_flags_quick_create_projects(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="列表商品")
quick = Project.objects.create(
team=self.team,
created_by=self.user,
product=product,
name="列表商品 · 极速成片",
metadata={"quick_create": True},
)
normal = Project.objects.create(
team=self.team,
created_by=self.user,
product=product,
name="列表商品 · 专业创作",
)
self.assertTrue(ProjectListSerializer(quick).data["quick_create"])
self.assertFalse(ProjectListSerializer(normal).data["quick_create"])
class QuickCreateCoordinatorTests(TestCase):
def setUp(self):
self.user = User.objects.create_user(username="quick-worker", password="pass")
self.team = Team.objects.create(name="Worker Team", owner=self.user)
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
self.product = Product.objects.create(team=self.team, created_by=self.user, title="测试精华")
self.project = Project.objects.create(
team=self.team,
created_by=self.user,
product=self.product,
name="测试精华 · 极速成片",
status=Project.Status.SCRIPTING,
current_stage=ProjectStage.Stage.SCRIPT,
)
initialize_project_pipeline(self.project)
self.job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=self.project)
@patch("apps.projects.tasks.run_quick_script_task.apply_async")
def test_first_advance_recognizes_product_then_starts_script(self, start_script):
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertEqual(delay, 1)
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
self.assertEqual(self.job.phase, QuickCreateJob.Phase.SCRIPT)
start_script.assert_not_called()
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertEqual(delay, 5)
self.assertTrue(self.job.metadata.get("script_started"))
start_script.assert_called_once()
self.assertEqual(start_script.call_args.kwargs["args"], [str(self.job.id)])
self.assertEqual(start_script.call_args.kwargs["queue"], "airshelf.quick")
def test_adopted_script_moves_to_assets_without_rerunning(self):
script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场")
self.job.status = QuickCreateJob.Status.RUNNING
self.job.phase = QuickCreateJob.Phase.SCRIPT
self.job.save(update_fields=["status", "phase", "updated_at"])
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertEqual(delay, 1)
self.assertEqual(self.job.phase, QuickCreateJob.Phase.ASSETS)
def test_cancelled_job_does_not_keep_advancing(self):
cancel_quick_create(self.job)
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertIsNone(delay)
self.assertEqual(self.job.status, QuickCreateJob.Status.CANCELLED)
@patch("apps.projects.services.quick_create._run_quick_script_in_thread")
def test_recover_reruns_script_locally_when_queue_drops_it(self, run_local):
self.job.status = QuickCreateJob.Status.RUNNING
self.job.phase = QuickCreateJob.Phase.SCRIPT
self.job.metadata = {
"script_started": True,
"script_started_at": (timezone.now() - timedelta(minutes=5)).isoformat(),
}
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
recover_quick_create(self.job)
self.job.refresh_from_db()
self.assertTrue(self.job.metadata.get("script_local"))
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
run_local.assert_called_once_with(str(self.job.id))
def test_recover_marks_success_when_video_already_finished(self):
self.project.video_segments.exclude(sort_order=0).delete()
segment = self.project.video_segments.get(sort_order=0)
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="已完成视频",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.VIDEO_CLIP,
)
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.save(update_fields=["adopted_version", "status", "updated_at"])
self.job.status = QuickCreateJob.Status.FAILED
self.job.phase = QuickCreateJob.Phase.PRODUCTION
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
self.job.save(update_fields=["status", "phase", "error_message", "updated_at"])
recover_quick_create(self.job)
self.job.refresh_from_db()
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
self.assertEqual(self.job.phase, QuickCreateJob.Phase.COMPLETE)
@patch("apps.projects.services.quick_create._reviews_ready", return_value=True)
def test_production_counts_ready_videos_without_adding_version_ids(self, _reviews):
self.project.video_segments.exclude(sort_order=0).delete()
segment = self.project.video_segments.get(sort_order=0)
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="计数视频",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.VIDEO_CLIP,
)
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.save(update_fields=["adopted_version", "status", "updated_at"])
self.job.status = QuickCreateJob.Status.RUNNING
self.job.phase = QuickCreateJob.Phase.PRODUCTION
self.job.metadata = {"storyboard_started": True, "video_started": True}
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
self.project.current_stage = ProjectStage.Stage.VIDEO
self.project.save(update_fields=["current_stage", "updated_at"])
delay = advance_quick_create(str(self.job.id))
self.job.refresh_from_db()
self.assertIsNone(delay)
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
def test_success_serializer_returns_direct_single_segment_video(self):
self.project.video_segments.exclude(sort_order=0).delete()
segment = self.project.video_segments.get(sort_order=0)
asset = Asset.objects.create(
team=self.team,
created_by=self.user,
name="极速成片",
asset_type=Asset.Type.VIDEO,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.VIDEO_CLIP,
)
AssetFile.objects.create(
asset=asset,
object_key="quick/final.mp4",
bucket="test",
content_type="video/mp4",
preview_url="https://cdn.example/quick.mp4",
is_primary=True,
)
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
segment.adopted_version = version
segment.status = VideoSegment.Status.SUCCEEDED
segment.save(update_fields=["adopted_version", "status", "updated_at"])
self.job.status = QuickCreateJob.Status.SUCCEEDED
self.job.phase = QuickCreateJob.Phase.COMPLETE
self.job.progress = 100
self.job.save(update_fields=["status", "phase", "progress", "updated_at"])
data = QuickCreateJobSerializer(self.job).data
self.assertEqual(data["phase_index"], 3)
self.assertEqual(data["result"]["video_url"], "https://cdn.example/quick.mp4")
self.assertEqual(data["result"]["duration_seconds"], 15)
@patch("apps.projects.tasks.poll_video_segment_task.apply_async")
@patch("apps.projects.services.quick_create.submit_video_segment")
def test_video_start_uses_quick_create_core_parameters(self, submit_video, schedule_poll):
self.project.metadata = {
"wizard": {
"aspect_ratio": "16:9",
"resolution": "1080p",
"total_duration": 30,
"video_model_config_id": "11111111-1111-4111-8111-111111111111",
}
}
self.project.save(update_fields=["metadata", "updated_at"])
self.project.video_segments.exclude(sort_order__lt=2).delete()
_start_videos(self.job)
self.assertEqual(submit_video.call_count, 2)
for call in submit_video.call_args_list:
self.assertEqual(call.kwargs["aspect_ratio"], "16:9")
self.assertEqual(call.kwargs["resolution"], "1080p")
self.assertEqual(call.kwargs["model_config_id"], "11111111-1111-4111-8111-111111111111")
self.job.refresh_from_db()
self.assertIn("30秒 16:9", self.job.message)
+20
View File
@@ -834,6 +834,26 @@ class ProjectApiTests(TestCase):
self.assertIn("[avoice][vo0][vo1]amix=inputs=3", graph)
self.assertIn("[anarr][abgm]amix=inputs=2", graph)
def test_export_command_uses_selected_output_size(self):
"""极速成片选横屏/清晰度后,多段合成必须沿用该尺寸,不能回退成固定竖屏。"""
from apps.projects.services.export import _build_export_command
cmd = _build_export_command(
n=1,
specs=[{"ts": 0.0, "te": 15.0, "dur": 15.0}],
starts=[0.0],
total=15.0,
transition="none",
sub_overlays=[],
bgm_name=None,
bgm_volume=1.0,
output_width=1920,
output_height=1080,
)
graph = cmd[cmd.index("-filter_complex") + 1]
self.assertIn("scale=1920:1080", graph)
self.assertIn("pad=1920:1080", graph)
def test_save_timeline_updates_voiceover_offsets(self):
"""拖动字幕块后保存:按 asset 回写句内起点 offset_ms,未拖动的句不受影响。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VOFF")
+281 -75
View File
@@ -44,9 +44,10 @@ from apps.assets.models import Asset, AssetFile
from apps.assets.serializers import AssetFileSerializer
from apps.assets.storage import TosStorage
from apps.common.api import TeamScopedViewSetMixin
from apps.common.celery_health import require_worker
from apps.common.celery_health import require_worker, require_worker_task
from apps.ai.generation_errors import classify_generation_error, public_error_for_task
from apps.ai.video_digest import VideoDigestError, digest_project_video
from apps.products.models import Product, ProductImage, ProductSellingPoint
from .models import (
BaseAssetGroup,
@@ -54,6 +55,7 @@ from .models import (
ExportJob,
Project,
ProjectStage,
QuickCreateJob,
ScriptSegment,
ScriptTemplate,
ScriptVersion,
@@ -72,6 +74,7 @@ from .serializers import (
ExportJobSerializer,
ProjectListSerializer,
ProjectSerializer,
QuickCreateJobSerializer,
ScriptTemplateSerializer,
ScriptVersionSerializer,
StoryboardVersionSerializer,
@@ -79,10 +82,15 @@ from .serializers import (
is_playable_video,
)
from .services.export import run_export_job_in_thread
from .services.pipeline import STAGE_ORDER
from .services.pipeline import (
adopt_script_version,
finish_storyboard_stage,
initialize_project_pipeline,
sync_video_segments_to_script,
)
from .services.script_import import ScriptFileError, extract_script_text
from .services.templates import build_template_fields, coerce_persona, coerce_template_combo, render_outline_text
from .tasks import poll_video_segment_task
from .tasks import advance_quick_create_task, poll_video_segment_task
logger = logging.getLogger(__name__)
@@ -101,7 +109,12 @@ class ServerSentEventRenderer(BaseRenderer):
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
fallback_suffix = {
Asset.Type.IMAGE: ".png",
Asset.Type.VIDEO: ".mp4",
Asset.Type.AUDIO: ".mp3",
}.get(asset_type, ".bin")
suffix = Path(upload.name).suffix.lower() or fallback_suffix
asset_id = uuid.uuid4()
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
stored = TosStorage().upload_fileobj(
@@ -435,17 +448,271 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))}
return Response({"total": base.count(), "by_status": by_status})
@action(
detail=False,
methods=["post"],
url_path="quick-create",
parser_classes=[MultiPartParser, FormParser],
)
def quick_create(self, request):
"""商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。"""
require_worker_task("apps.projects.tasks.advance_quick_create_task")
name = str(request.data.get("name") or "").strip()
uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]")
source_product_id = str(request.data.get("source_product_id") or "").strip()
requested_asset_ids = [
str(value).strip()
for value in (request.data.getlist("image_asset_ids") or request.data.getlist("image_asset_ids[]") or [])
if str(value).strip()
]
if not name:
return Response({"detail": "请填写商品名称"}, status=status.HTTP_400_BAD_REQUEST)
if len(name) > 255:
return Response({"detail": "商品名称不能超过255个字符"}, status=status.HTTP_400_BAD_REQUEST)
if not uploads and not source_product_id:
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
if len(uploads) > 9:
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
allowed_types = {"image/jpeg", "image/png", "image/webp"}
for upload in uploads:
if (upload.content_type or "").lower() not in allowed_types:
return Response({"detail": "仅支持 JPG、PNG 或 WebP 商品图片"}, status=status.HTTP_400_BAD_REQUEST)
if upload.size > 20 * 1024 * 1024:
return Response({"detail": "单张商品图片不能超过20MB"}, status=status.HTTP_400_BAD_REQUEST)
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
resolution = str(request.data.get("resolution") or "720p").lower()
try:
total_duration = int(request.data.get("total_duration") or 15)
except (TypeError, ValueError):
total_duration = 0
if aspect_ratio not in {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}:
return Response({"detail": "请选择有效的视频比例"}, status=status.HTTP_400_BAD_REQUEST)
if resolution not in {"480p", "720p", "1080p", "4k"}:
return Response({"detail": "请选择有效的视频分辨率"}, status=status.HTTP_400_BAD_REQUEST)
if total_duration not in {15, 30, 45, 60}:
return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST)
text_model = get_default_model(ModelConfig.Capability.TEXT)
image_model = get_default_model(ModelConfig.Capability.IMAGE)
requested_video_model_id = str(request.data.get("video_model_config_id") or "").strip()
video_model = None
if requested_video_model_id:
try:
video_model_uuid = uuid.UUID(requested_video_model_id)
except (TypeError, ValueError):
return Response({"detail": "请选择有效的视频模型"}, status=status.HTTP_400_BAD_REQUEST)
video_model = (
ModelConfig.objects.select_related("provider")
.filter(
id=video_model_uuid,
capability=ModelConfig.Capability.VIDEO,
status=ModelConfig.Status.ACTIVE,
provider__status="active",
)
.first()
)
else:
video_model = get_default_model(ModelConfig.Capability.VIDEO)
missing = [
label
for model, label in ((text_model, "文本"), (image_model, "图像"), (video_model, "视频"))
if model is None
]
if missing:
return Response(
{"detail": f"当前缺少可用的{''.join(missing)}模型,请联系管理员配置"},
status=status.HTTP_503_SERVICE_UNAVAILABLE,
)
capabilities = dict((video_model.metadata or {}).get("capabilities") or {})
supported_resolutions = set(capabilities.get("resolutions") or (video_model.metadata or {}).get("resolutions") or [])
supported_ratios = set(capabilities.get("aspect_ratios") or [])
supported_durations = set(capabilities.get("durations") or (video_model.metadata or {}).get("durations") or [])
if supported_resolutions and resolution not in supported_resolutions:
return Response(
{"detail": f"{video_model.display_name} 不支持 {resolution.upper()},请更换清晰度或模型"},
status=status.HTTP_400_BAD_REQUEST,
)
if supported_ratios and aspect_ratio not in supported_ratios:
return Response(
{"detail": f"{video_model.display_name} 不支持 {aspect_ratio} 比例"},
status=status.HTTP_400_BAD_REQUEST,
)
# 极速成片把总时长拆成多个15秒镜头,视频模型只需支持单镜15秒。
if supported_durations and 15 not in supported_durations:
return Response(
{"detail": f"{video_model.display_name} 不支持15秒单镜生成"},
status=status.HTTP_400_BAD_REQUEST,
)
team = self.get_team()
reused_assets = []
if source_product_id:
try:
source_uuid = uuid.UUID(source_product_id)
except (TypeError, ValueError):
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
source = (
Product.objects.filter(id=source_uuid, team=team)
.prefetch_related("images__asset")
.first()
)
if source is None:
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
source_images = list(source.images.all())
by_asset_id = {str(image.asset_id): image.asset for image in source_images}
if requested_asset_ids:
if any(asset_id not in by_asset_id for asset_id in requested_asset_ids):
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
reused_assets = [by_asset_id[asset_id] for asset_id in requested_asset_ids]
else:
reused_assets = [image.asset for image in source_images]
if not reused_assets and not uploads:
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
if len(reused_assets) + len(uploads) > 9:
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
assets = list(reused_assets)
assets.extend(
_store_uploaded_asset(
team=team,
user=request.user,
upload=upload,
asset_type=Asset.Type.IMAGE,
category=Asset.Category.PRODUCT_IMAGE,
name=upload.name or f"{name}-商品图{index + 1}",
)
for index, upload in enumerate(uploads)
)
if not assets:
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
product = Product.objects.create(
team=team,
created_by=request.user,
title=name,
business_type=Product.BusinessType.ECOMMERCE,
cover_asset=assets[0],
)
ProductImage.objects.bulk_create(
[
ProductImage(
product=product,
asset=asset,
sort_order=index,
is_primary=index == 0,
)
for index, asset in enumerate(assets)
]
)
project = Project.objects.create(
team=team,
created_by=request.user,
product=product,
name=f"{name} · 极速成片",
status=Project.Status.SCRIPTING,
current_stage=ProjectStage.Stage.SCRIPT,
metadata={
"quick_create": True,
"wizard": {
"aspect_ratio": aspect_ratio,
"resolution": resolution,
"total_duration": total_duration,
"video_model_config_id": str(video_model.id),
"video_model_name": video_model.name,
"video_model_label": video_model.display_name,
"presentation_format": "oral",
"video_structure": "pain",
"persona": "reviewer",
},
},
)
initialize_project_pipeline(project, placeholder_segments=max(1, total_duration // 15))
if not product.selling_points.exists():
ProductSellingPoint.objects.create(
product=product,
title=name,
detail="由极速成片根据商品名称自动填写",
sort_order=0,
)
job = QuickCreateJob.objects.create(
team=team,
created_by=request.user,
project=project,
status=QuickCreateJob.Status.QUEUED,
phase=QuickCreateJob.Phase.PRODUCT,
progress=0,
message="等待开始极速成片",
)
try:
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
except Exception as exc: # noqa: BLE001 — broker 极小窗口失败也必须给任务落终态
from .services.quick_create import fail_quick_create
logger.exception("quick create enqueue failed for job %s", job.id)
fail_quick_create(job, "生成队列暂时不可用,请稍后重试", internal_error=str(exc))
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_503_SERVICE_UNAVAILABLE)
job = self._quick_job_queryset().get(id=job.id)
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_202_ACCEPTED)
def _quick_job_queryset(self):
return (
QuickCreateJob.objects.select_related("project__product", "project__timeline")
.prefetch_related(
"project__product__images__asset__files",
"project__script_versions",
"project__base_asset_groups",
"project__storyboard_shots__adopted_version__asset__files",
"project__video_segments__adopted_version__asset__files",
"project__timeline__export_jobs__output_asset__files",
)
.filter(team=self.get_team())
)
@action(detail=False, methods=["get"], url_path=r"quick-create-status/(?P<job_id>[^/.]+)")
def quick_create_status(self, request, job_id=None):
job = self._quick_job_queryset().filter(id=job_id).first()
if job is None:
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
from .services.quick_create import recover_quick_create
try:
recover_quick_create(job)
except Exception: # noqa: BLE001 — 恢复失败不能把进度接口打成 500
logger.exception("quick create recover failed for job %s", job.id)
job = self._quick_job_queryset().get(id=job.id)
return Response(QuickCreateJobSerializer(job).data)
@action(detail=False, methods=["post"], url_path=r"quick-create-cancel/(?P<job_id>[^/.]+)")
def quick_create_cancel(self, request, job_id=None):
job = self._quick_job_queryset().filter(id=job_id).first()
if job is None:
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
if job.status == QuickCreateJob.Status.SUCCEEDED:
return Response({"detail": "已完成的任务不能取消"}, status=status.HTTP_400_BAD_REQUEST)
from .services.quick_create import cancel_quick_create
cancel_quick_create(job)
job = self._quick_job_queryset().get(id=job.id)
return Response(QuickCreateJobSerializer(job).data)
@action(detail=False, methods=["get"], url_path="quick-create-history")
def quick_create_history(self, request):
jobs = self._quick_job_queryset().filter(status=QuickCreateJob.Status.SUCCEEDED).order_by("-created_at")
return Response({
"count": jobs.count(),
"results": QuickCreateJobSerializer(jobs[:30], many=True).data,
})
@transaction.atomic
def perform_create(self, serializer):
project = serializer.save(team=self.get_team(), created_by=self.request.user)
self._apply_wizard_template(project)
for stage in STAGE_ORDER:
ProjectStage.objects.create(project=project, stage=stage)
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
for index in range(4):
VideoSegment.objects.create(
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
)
# 先铺 4 段占位;真实段数与每段时长在采用脚本时收口。
initialize_project_pipeline(project, placeholder_segments=4)
def _apply_wizard_template(self, project) -> None:
"""新建向导选了套路模板 → 后端按模板真值回填 metadata.wizard,并计一次使用。
@@ -583,19 +850,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = self.get_object()
script_id = request.data.get("script_version_id")
script = ScriptVersion.objects.select_for_update().get(project=project, id=script_id)
ScriptVersion.objects.filter(project=project).update(is_adopted=False)
script.is_adopted = True
script.save(update_fields=["is_adopted", "updated_at"])
# 采用脚本时把视频片段数对齐到这版分镜数:用户常在「采用前」就增删分镜,
# 那些编辑因 _sync 的 is_adopted 闸而未同步到 VideoSegment(项目创建时固定铺了 4 段),
# 不在此收口的话视频步骤会一直停在 4 段,与故事板/分镜数对不上。已生成的段绝不动。
self._sync_video_segments_to_script(project, script)
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
project.current_stage = ProjectStage.Stage.BASE_ASSETS
project.status = Project.Status.ASSETING
project.save(update_fields=["current_stage", "status", "updated_at"])
adopt_script_version(project, script)
return Response(ScriptVersionSerializer(script).data)
@action(detail=True, methods=["post"], url_path="extract-entities")
@@ -900,47 +1155,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
时长同步:脚本镜主流程固定 15 ,而出片计价时间线读的都是
VideoSegment.target_duration_seconds不同步的话脚本写了 15 出片仍按旧默认跑
已出片的段不改时长改了会跟已渲染的成片对不上"""
if not script.is_adopted:
return
script_segments = list(script.segments.order_by("sort_order"))
target = len(script_segments)
segments = list(project.video_segments.order_by("sort_order"))
while len(segments) > target:
tail = segments[-1]
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
tail.delete()
segments.pop()
else:
break
next_order = (segments[-1].sort_order + 1) if segments else 0
for _ in range(target - len(segments)):
index = len(segments)
seconds = (
script_segments[index].duration_seconds
if index < target
else SEGMENT_DURATION_MAX
)
segments.append(
VideoSegment.objects.create(
project=project, sort_order=next_order, target_duration_seconds=seconds
)
)
next_order += 1
# 已存在的段:只对「还没出过片」的回填脚本时长,已渲染的保持原样
stale: list[VideoSegment] = []
for index, video_segment in enumerate(segments):
if index >= target:
break
seconds = script_segments[index].duration_seconds
if not seconds or video_segment.target_duration_seconds == seconds:
continue
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
continue
video_segment.target_duration_seconds = seconds
stale.append(video_segment)
if stale:
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
sync_video_segments_to_script(project, script)
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
@@ -1133,16 +1348,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
project = self.get_object()
result = poll_storyboard(project=project, user=request.user)
if result.get("status") == "succeeded":
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
stage.status = ProjectStage.Status.SUCCEEDED
stage.save(update_fields=["status", "updated_at"])
# 进入视频阶段前再收口一次视频片段数,确保与采用版分镜数一致(已生成的段不动)
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
if adopted_script is not None:
self._sync_video_segments_to_script(project, adopted_script)
project.current_stage = ProjectStage.Stage.VIDEO
project.status = Project.Status.VIDEOING
project.save(update_fields=["current_stage", "status", "updated_at"])
finish_storyboard_stage(project)
http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED
return Response(result, status=http_status)