完成极速成品和脚本优化
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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 首尾帧)。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.py。Celery 收到未注册任务会直接丢弃消息,
|
||||
页面只能看到永久「生成中」。提交极速成片等新编排前,额外确认任务名已注册。
|
||||
|
||||
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"),
|
||||
],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -70,6 +70,44 @@ class ProjectStage(TimeStampedModel):
|
||||
ordering = ["created_at"]
|
||||
|
||||
|
||||
class QuickCreateJob(TeamOwnedModel):
|
||||
"""“极速成片”自动编排任务。
|
||||
|
||||
AI 子任务仍落在 AITask;本表只保存跨商品、脚本、资产、故事板、视频和导出的编排状态,
|
||||
让页面刷新、浏览器关闭后都能继续查询同一条流水线。
|
||||
"""
|
||||
|
||||
class Status(models.TextChoices):
|
||||
QUEUED = "queued", "Queued"
|
||||
RUNNING = "running", "Running"
|
||||
SUCCEEDED = "succeeded", "Succeeded"
|
||||
FAILED = "failed", "Failed"
|
||||
CANCELLED = "cancelled", "Cancelled"
|
||||
|
||||
class Phase(models.TextChoices):
|
||||
PRODUCT = "product", "Product"
|
||||
SCRIPT = "script", "Script"
|
||||
ASSETS = "assets", "Assets"
|
||||
PRODUCTION = "production", "Storyboard and Video"
|
||||
COMPLETE = "complete", "Complete"
|
||||
|
||||
project = models.OneToOneField(Project, on_delete=models.CASCADE, related_name="quick_create_job")
|
||||
status = models.CharField(max_length=24, choices=Status.choices, default=Status.QUEUED)
|
||||
phase = models.CharField(max_length=24, choices=Phase.choices, default=Phase.PRODUCT)
|
||||
progress = models.PositiveSmallIntegerField(default=0)
|
||||
message = models.CharField(max_length=255, blank=True)
|
||||
error_message = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
completed_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-created_at"]
|
||||
indexes = [
|
||||
models.Index(fields=["team", "status", "-created_at"], name="projects_qu_team_id_a8a924_idx"),
|
||||
models.Index(fields=["team", "phase"], name="projects_qu_team_id_98380e_idx"),
|
||||
]
|
||||
|
||||
|
||||
class ScriptVersion(TimeStampedModel):
|
||||
project = models.ForeignKey(Project, on_delete=models.CASCADE, related_name="script_versions")
|
||||
task = models.ForeignKey("ai.AITask", on_delete=models.SET_NULL, null=True, blank=True, related_name="script_versions")
|
||||
@@ -334,4 +372,3 @@ class ExportJob(TimeStampedModel):
|
||||
progress = models.PositiveIntegerField(default=0)
|
||||
error_message = models.TextField(blank=True)
|
||||
metadata = models.JSONField(default=dict, blank=True)
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ from .models import (
|
||||
ExportJob,
|
||||
Project,
|
||||
ProjectStage,
|
||||
QuickCreateJob,
|
||||
ScriptSegment,
|
||||
ScriptTemplate,
|
||||
ScriptVersion,
|
||||
@@ -329,6 +330,132 @@ class ExportJobSerializer(serializers.ModelSerializer):
|
||||
read_only_fields = fields
|
||||
|
||||
|
||||
class QuickCreateJobSerializer(serializers.ModelSerializer):
|
||||
"""极速成片轮询响应:只下发用户需要的四阶段进度和最终可播结果。"""
|
||||
|
||||
project_id = serializers.UUIDField(source="project.id", read_only=True)
|
||||
product_id = serializers.UUIDField(source="project.product_id", read_only=True)
|
||||
product_name = serializers.CharField(source="project.product.title", read_only=True)
|
||||
title = serializers.CharField(source="project.name", read_only=True)
|
||||
product_images = serializers.SerializerMethodField()
|
||||
phase_index = serializers.SerializerMethodField()
|
||||
settings = serializers.SerializerMethodField()
|
||||
result = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = QuickCreateJob
|
||||
fields = [
|
||||
"id",
|
||||
"project_id",
|
||||
"product_id",
|
||||
"product_name",
|
||||
"title",
|
||||
"product_images",
|
||||
"status",
|
||||
"phase",
|
||||
"phase_index",
|
||||
"settings",
|
||||
"progress",
|
||||
"message",
|
||||
"error_message",
|
||||
"result",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"completed_at",
|
||||
]
|
||||
read_only_fields = fields
|
||||
|
||||
def get_product_images(self, obj) -> list[dict]:
|
||||
product = getattr(obj.project, "product", None)
|
||||
if product is None:
|
||||
return []
|
||||
return [
|
||||
{
|
||||
"asset_id": str(image.asset_id),
|
||||
"url": _asset_preview_url(image.asset),
|
||||
}
|
||||
for image in product.images.all()
|
||||
]
|
||||
|
||||
def get_phase_index(self, obj) -> int:
|
||||
return {
|
||||
QuickCreateJob.Phase.PRODUCT: 0,
|
||||
QuickCreateJob.Phase.SCRIPT: 1,
|
||||
QuickCreateJob.Phase.ASSETS: 2,
|
||||
QuickCreateJob.Phase.PRODUCTION: 3,
|
||||
QuickCreateJob.Phase.COMPLETE: 3,
|
||||
}.get(obj.phase, 0)
|
||||
|
||||
def get_settings(self, obj) -> dict:
|
||||
wizard = dict((obj.project.metadata or {}).get("wizard") or {})
|
||||
return {
|
||||
"aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"),
|
||||
"resolution": str(wizard.get("resolution") or "720p").lower(),
|
||||
"total_duration": int(wizard.get("total_duration") or 15),
|
||||
"video_model_config_id": str(wizard.get("video_model_config_id") or ""),
|
||||
"video_model_name": str(wizard.get("video_model_name") or ""),
|
||||
"video_model_label": str(wizard.get("video_model_label") or ""),
|
||||
}
|
||||
|
||||
def get_result(self, obj) -> dict | None:
|
||||
if obj.status != QuickCreateJob.Status.SUCCEEDED:
|
||||
return None
|
||||
project = obj.project
|
||||
settings = self.get_settings(obj)
|
||||
video_url = _final_video_url(project)
|
||||
segments = list(project.video_segments.all())
|
||||
if not video_url and len(segments) == 1:
|
||||
version = segments[0].adopted_version
|
||||
if version is not None and is_playable_video(version.asset):
|
||||
video_url = _asset_preview_url(version.asset)
|
||||
first_shot = next(
|
||||
(shot for shot in project.storyboard_shots.all() if shot.adopted_version_id),
|
||||
None,
|
||||
)
|
||||
poster_url = _asset_preview_url(first_shot.adopted_version.asset) if first_shot else ""
|
||||
script = next((version for version in project.script_versions.all() if version.is_adopted), None)
|
||||
script_meta = (script.metadata or {}) if script is not None else {}
|
||||
format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get(
|
||||
script_meta.get("presentation_format"), "智能推荐"
|
||||
)
|
||||
structure_label = {
|
||||
"pain": "痛点解决",
|
||||
"contrast": "前后对比",
|
||||
"review": "测评验证",
|
||||
"scene": "场景种草",
|
||||
}.get(script_meta.get("video_structure"), "智能推荐")
|
||||
groups = list(project.base_asset_groups.all())
|
||||
person = next((group for group in groups if group.kind == BaseAssetGroup.Kind.PERSON and group.adopted_asset_id), None)
|
||||
scene = next((group for group in groups if group.kind == BaseAssetGroup.Kind.SCENE and group.adopted_asset_id), None)
|
||||
duration = sum(segment.target_duration_seconds for segment in segments)
|
||||
video_segments = []
|
||||
for segment in sorted(segments, key=lambda item: item.sort_order):
|
||||
version = segment.adopted_version
|
||||
clip_url = _asset_preview_url(version.asset) if version is not None and is_playable_video(version.asset) else ""
|
||||
video_segments.append(
|
||||
{
|
||||
"id": str(segment.id),
|
||||
"sort_order": segment.sort_order,
|
||||
"duration_seconds": segment.target_duration_seconds or 15,
|
||||
"video_url": clip_url,
|
||||
"poster_url": poster_url,
|
||||
}
|
||||
)
|
||||
return {
|
||||
"video_url": video_url,
|
||||
"poster_url": poster_url,
|
||||
"duration_seconds": duration or 15,
|
||||
"aspect_ratio": settings["aspect_ratio"],
|
||||
"resolution": settings["resolution"],
|
||||
"video_model": settings["video_model_label"] or settings["video_model_name"],
|
||||
"structure": structure_label,
|
||||
"presentation": format_label,
|
||||
"person": ((person.metadata or {}).get("label") or "自动匹配") if person else "自动匹配",
|
||||
"scene": ((scene.metadata or {}).get("label") or "自动匹配") if scene else "自动匹配",
|
||||
"video_segments": video_segments,
|
||||
}
|
||||
|
||||
|
||||
class ScriptSegmentSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ScriptSegment
|
||||
@@ -359,13 +486,14 @@ class ProjectListSerializer(serializers.ModelSerializer):
|
||||
video_segment_count = serializers.IntegerField(read_only=True, default=0)
|
||||
# 合成成片地址:项目列表的播放按钮据此直接播成片(没合成过为空 → 退回进流水线)
|
||||
final_video_url = serializers.SerializerMethodField()
|
||||
quick_create = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Project
|
||||
fields = [
|
||||
"id", "name", "product", "product_title", "cover_preview_url",
|
||||
"status", "current_stage", "script_version_count", "video_segment_count",
|
||||
"final_video_url",
|
||||
"final_video_url", "quick_create",
|
||||
"is_deleted", "purged_at", "created_at", "updated_at",
|
||||
]
|
||||
|
||||
@@ -375,6 +503,9 @@ class ProjectListSerializer(serializers.ModelSerializer):
|
||||
def get_final_video_url(self, obj) -> str:
|
||||
return _final_video_url(obj)
|
||||
|
||||
def get_quick_create(self, obj) -> bool:
|
||||
return bool((obj.metadata or {}).get("quick_create"))
|
||||
|
||||
|
||||
class ProjectSerializer(serializers.ModelSerializer):
|
||||
stages = ProjectStageSerializer(many=True, read_only=True)
|
||||
|
||||
@@ -98,27 +98,28 @@ def _wrap_cjk(draw, text: str, font, max_width: int) -> list[str]:
|
||||
return lines[:3] # 最多 3 行,够长截断
|
||||
|
||||
|
||||
def _render_subtitle_png(text: str, style_key: str, path: Path) -> tuple[int, int]:
|
||||
"""把一条字幕渲染成 1080 宽的透明 PNG(居中,带描边/底框),返回 (w,h)。"""
|
||||
def _render_subtitle_png(text: str, style_key: str, path: Path, *, canvas_w: int = 1080) -> tuple[int, int]:
|
||||
"""把一条字幕渲染成与导出宽度一致的透明 PNG(居中,带描边/底框)。"""
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
st = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
|
||||
canvas_w = 1080
|
||||
margin_x = 90
|
||||
base = SUBTITLE_STYLES.get(style_key) or SUBTITLE_STYLES["plain"]
|
||||
scale = max(0.4, canvas_w / 1080)
|
||||
st = {**base, "size": max(24, round(base["size"] * scale)), "stroke_w": max(0, round(base["stroke_w"] * scale))}
|
||||
margin_x = max(30, round(90 * scale))
|
||||
font = _load_font(st["size"])
|
||||
probe = ImageDraw.Draw(Image.new("RGBA", (10, 10)))
|
||||
lines = _wrap_cjk(probe, (text or "").strip().replace("\n", " "), font, canvas_w - 2 * margin_x)
|
||||
line_h = st["size"] + 16
|
||||
pad = 22
|
||||
line_h = st["size"] + max(6, round(16 * scale))
|
||||
pad = max(8, round(22 * scale))
|
||||
text_h = line_h * len(lines)
|
||||
canvas_h = text_h + 2 * pad
|
||||
img = Image.new("RGBA", (canvas_w, canvas_h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(img)
|
||||
if st["box"]:
|
||||
widest = max((draw.textlength(ln, font=font) for ln in lines), default=0)
|
||||
box_w = int(widest) + 2 * pad + 24
|
||||
box_w = int(widest) + 2 * pad + max(10, round(24 * scale))
|
||||
x0 = (canvas_w - box_w) // 2
|
||||
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=16, fill=st["box"])
|
||||
draw.rounded_rectangle([x0, 0, x0 + box_w, canvas_h], radius=max(6, round(16 * scale)), fill=st["box"])
|
||||
y = pad
|
||||
for ln in lines:
|
||||
w = draw.textlength(ln, font=font)
|
||||
@@ -186,7 +187,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
transition: str, sub_overlays: list[tuple[str, float, float]],
|
||||
bgm_name: str | None, bgm_volume: float,
|
||||
has_audio: list[bool] | None = None, fps: float = 30.0,
|
||||
voice_overlays: list[tuple[str, float, float]] | None = None) -> list[str]:
|
||||
voice_overlays: list[tuple[str, float, float]] | None = None,
|
||||
output_width: int = 1080, output_height: int = 1920) -> list[str]:
|
||||
has_audio = has_audio or [False] * n
|
||||
voice_overlays = voice_overlays or []
|
||||
fps_expr = f"{fps:.6g}"
|
||||
@@ -194,8 +196,8 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
for i, s in enumerate(specs):
|
||||
parts.append(
|
||||
f"[{i}:v]trim=start={s['ts']:.3f}:end={s['te']:.3f},setpts=PTS-STARTPTS,"
|
||||
"scale=1080:1920:force_original_aspect_ratio=decrease,"
|
||||
f"pad=1080:1920:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
|
||||
f"scale={output_width}:{output_height}:force_original_aspect_ratio=decrease,"
|
||||
f"pad={output_width}:{output_height}:(ow-iw)/2:(oh-ih)/2,setsar=1,fps={fps_expr},format=yuv420p[v" + str(i) + "]"
|
||||
)
|
||||
xname = XFADE_MAP.get(transition or "none")
|
||||
if xname and n > 1:
|
||||
@@ -213,8 +215,9 @@ def _build_export_command(*, n: int, specs: list[dict], starts: list[float], tot
|
||||
for j, (_png, start, end) in enumerate(sub_overlays):
|
||||
idx = sub_base + j
|
||||
out = "vout" if j == len(sub_overlays) - 1 else f"ov{j}"
|
||||
bottom_offset = max(40, round(output_height * 150 / 1920))
|
||||
parts.append(
|
||||
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-150:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
|
||||
f"[{vlabel}][{idx}:v]overlay=x=(W-w)/2:y=H-h-{bottom_offset}:enable='between(t,{start:.3f},{end:.3f})'[{out}]"
|
||||
)
|
||||
vlabel = out
|
||||
|
||||
@@ -331,6 +334,19 @@ def _split_subtitle_text(text: str) -> list[str]:
|
||||
return out
|
||||
|
||||
|
||||
def _timeline_output_size(timeline) -> tuple[int, int]:
|
||||
"""读取时间线输出尺寸;旧项目或异常值安全回落到原来的 1080×1920。"""
|
||||
try:
|
||||
width_text, height_text = str(timeline.resolution or "").lower().split("x", 1)
|
||||
width, height = int(width_text), int(height_text)
|
||||
except (TypeError, ValueError):
|
||||
return 1080, 1920
|
||||
if not 320 <= width <= 4400 or not 320 <= height <= 4400:
|
||||
return 1080, 1920
|
||||
# H.264 yuv420p 要求偶数尺寸。
|
||||
return width - width % 2, height - height % 2
|
||||
|
||||
|
||||
def _subtitle_cues(timeline, project, specs, starts, total) -> list[tuple[float, float, str]]:
|
||||
"""字幕条目(逐句):优先用 SubtitleTrack.content 里每条 cue 自带的 start_ms——
|
||||
先定位到所属片段(输入时间轴=各片段时长累计),再重映射到输出时间轴(xfade 会压缩起点);
|
||||
@@ -424,6 +440,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
style_key = str((subtitle_track.style or {}).get("key", "plain")) if subtitle_track else "plain"
|
||||
|
||||
specs = _clip_specs(clips)
|
||||
output_width, output_height = _timeline_output_size(timeline)
|
||||
xfade = 0.5 if XFADE_MAP.get(transition) and len(clips) > 1 else 0.0
|
||||
starts, total = _output_starts(specs, xfade)
|
||||
|
||||
@@ -451,7 +468,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
sub_overlays: list[tuple[str, float, float]] = []
|
||||
for i, (start, end, text) in enumerate(cues):
|
||||
png = f"sub{i}.png"
|
||||
_render_subtitle_png(text, style_key, tmp / png)
|
||||
_render_subtitle_png(text, style_key, tmp / png, canvas_w=output_width)
|
||||
sub_overlays.append((png, start, end))
|
||||
|
||||
# 旁白配音(TTS 资产):按 timeline.metadata.voiceover 映射下载,人声轨混在 BGM 之上;
|
||||
@@ -484,6 +501,7 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
n=len(clips), specs=specs, starts=starts, total=total, transition=transition,
|
||||
sub_overlays=sub_overlays, bgm_name=bgm_name, bgm_volume=(bgm_track.volume / 100.0) if bgm_track else 1.0,
|
||||
has_audio=has_audio, fps=output_fps, voice_overlays=voice_overlays,
|
||||
output_width=output_width, output_height=output_height,
|
||||
)
|
||||
# 加超时:ffmpeg 卡死(资源不足/被 OOM 杀)时不能让任务永久停在 RUNNING、前端无限转圈;
|
||||
# 超时即按失败收尾,前端能看到「导出失败」而非一直卡。15 分钟足够正常 60s 成片。
|
||||
@@ -527,4 +545,3 @@ def run_export_job(export_job_id: str) -> ExportJob:
|
||||
project.status = project.Status.COMPLETED
|
||||
project.save(update_fields=["status", "updated_at"])
|
||||
return export_job
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
from apps.projects.models import ProjectStage
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.projects.models import Project, ProjectStage, ScriptVersion, VideoSegment
|
||||
|
||||
|
||||
STAGE_ORDER = [
|
||||
@@ -35,3 +37,107 @@ def can_enter_stage(current_stage: str, target_stage: str, allow_skip_storyboard
|
||||
|
||||
return StageTransition(current_stage, target_stage, False, "stage prerequisite is not satisfied")
|
||||
|
||||
|
||||
def initialize_project_pipeline(project: Project, *, placeholder_segments: int = 4) -> None:
|
||||
"""建立专业创作和极速成片共用的阶段、视频占位数据。幂等,可安全重试。"""
|
||||
for stage_name in STAGE_ORDER:
|
||||
ProjectStage.objects.get_or_create(project=project, stage=stage_name)
|
||||
for index in range(placeholder_segments):
|
||||
VideoSegment.objects.get_or_create(
|
||||
project=project,
|
||||
sort_order=index,
|
||||
defaults={"target_duration_seconds": 15},
|
||||
)
|
||||
|
||||
|
||||
def sync_video_segments_to_script(project: Project, script: ScriptVersion) -> None:
|
||||
"""把视频段数量和时长收口到采用脚本;已生成过的段绝不裁剪或改时长。"""
|
||||
if not script.is_adopted:
|
||||
return
|
||||
script_segments = list(script.segments.order_by("sort_order"))
|
||||
target = len(script_segments)
|
||||
segments = list(project.video_segments.order_by("sort_order"))
|
||||
while len(segments) > target:
|
||||
tail = segments[-1]
|
||||
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
|
||||
tail.delete()
|
||||
segments.pop()
|
||||
else:
|
||||
break
|
||||
|
||||
next_order = (segments[-1].sort_order + 1) if segments else 0
|
||||
for _ in range(target - len(segments)):
|
||||
index = len(segments)
|
||||
seconds = script_segments[index].duration_seconds if index < target else 15
|
||||
segments.append(
|
||||
VideoSegment.objects.create(
|
||||
project=project,
|
||||
sort_order=next_order,
|
||||
target_duration_seconds=seconds,
|
||||
)
|
||||
)
|
||||
next_order += 1
|
||||
|
||||
stale: list[VideoSegment] = []
|
||||
for index, video_segment in enumerate(segments):
|
||||
if index >= target:
|
||||
break
|
||||
seconds = script_segments[index].duration_seconds
|
||||
if not seconds or video_segment.target_duration_seconds == seconds:
|
||||
continue
|
||||
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
|
||||
continue
|
||||
video_segment.target_duration_seconds = seconds
|
||||
stale.append(video_segment)
|
||||
if stale:
|
||||
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
|
||||
|
||||
|
||||
def adopt_script_version(project: Project, script: ScriptVersion) -> None:
|
||||
"""采用脚本并推进到资产阶段,供专业创作按钮和极速编排共同调用。"""
|
||||
ScriptVersion.objects.filter(project=project).exclude(id=script.id).update(is_adopted=False)
|
||||
if not script.is_adopted:
|
||||
script.is_adopted = True
|
||||
script.save(update_fields=["is_adopted", "updated_at"])
|
||||
sync_video_segments_to_script(project, script)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.completed_at = timezone.now()
|
||||
stage.error_message = ""
|
||||
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
|
||||
project.current_stage = ProjectStage.Stage.BASE_ASSETS
|
||||
project.status = Project.Status.ASSETING
|
||||
project.failure_reason = ""
|
||||
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
|
||||
|
||||
|
||||
def finish_storyboard_stage(project: Project) -> None:
|
||||
"""故事板全部成功后推进到视频阶段,并再次校准片段数量。"""
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.completed_at = timezone.now()
|
||||
stage.error_message = ""
|
||||
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
|
||||
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||
if adopted_script is not None:
|
||||
sync_video_segments_to_script(project, adopted_script)
|
||||
project.current_stage = ProjectStage.Stage.VIDEO
|
||||
project.status = Project.Status.VIDEOING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
|
||||
|
||||
def finish_video_stage(project: Project) -> bool:
|
||||
"""全部片段成功即完成专业创作的视频阶段。"""
|
||||
segments = list(project.video_segments.values_list("status", "adopted_version_id"))
|
||||
if not segments or not all(status == VideoSegment.Status.SUCCEEDED and adopted for status, adopted in segments):
|
||||
return False
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.VIDEO)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.completed_at = timezone.now()
|
||||
stage.error_message = ""
|
||||
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
|
||||
project.current_stage = ProjectStage.Stage.VIDEO
|
||||
project.status = Project.Status.COMPLETED
|
||||
project.failure_reason = ""
|
||||
project.save(update_fields=["current_stage", "status", "failure_reason", "updated_at"])
|
||||
return True
|
||||
|
||||
@@ -0,0 +1,806 @@
|
||||
"""“极速成片”自动编排。
|
||||
|
||||
这不是另一套生成实现:脚本、基础资产、三视图、故事板、视频和合成全部调用专业创作现有服务。
|
||||
本模块只负责按状态推进,并把子任务 ID 持久化到 QuickCreateJob,方便刷新后恢复进度。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from django.db import connections, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.script_agent import stream_script_agent
|
||||
from apps.ai.services import (
|
||||
collect_video_review_blockers,
|
||||
create_export_job,
|
||||
generate_base_asset,
|
||||
generate_person_triview,
|
||||
get_default_model,
|
||||
poll_storyboard,
|
||||
submit_storyboard,
|
||||
submit_video_segment,
|
||||
)
|
||||
from apps.assets import assets_client
|
||||
from apps.assets.models import Asset
|
||||
from apps.assets.review import poll_team_reviews, submit_asset_for_review
|
||||
from apps.projects.models import (
|
||||
BaseAssetGroup,
|
||||
ExportJob,
|
||||
Project,
|
||||
ProjectStage,
|
||||
QuickCreateJob,
|
||||
Timeline,
|
||||
TimelineClip,
|
||||
VideoSegment,
|
||||
)
|
||||
from apps.projects.services.pipeline import (
|
||||
STAGE_ORDER,
|
||||
adopt_script_version,
|
||||
finish_storyboard_stage,
|
||||
finish_video_stage,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_DELAY_SECONDS = 10
|
||||
SCRIPT_POLL_SECONDS = 5
|
||||
SCRIPT_TIMEOUT = timedelta(minutes=4)
|
||||
SCRIPT_STOLEN_AFTER = timedelta(seconds=25)
|
||||
STALE_AFTER = timedelta(seconds=12)
|
||||
_ACTIVE_TASK_STATUSES = {
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
}
|
||||
_FINISHED_STATUSES = {
|
||||
QuickCreateJob.Status.SUCCEEDED,
|
||||
QuickCreateJob.Status.FAILED,
|
||||
QuickCreateJob.Status.CANCELLED,
|
||||
}
|
||||
|
||||
|
||||
def _is_finished(job: QuickCreateJob) -> bool:
|
||||
return job.status in _FINISHED_STATUSES
|
||||
|
||||
|
||||
def _quick_settings(project: Project) -> dict:
|
||||
wizard = dict((project.metadata or {}).get("wizard") or {})
|
||||
return {
|
||||
"aspect_ratio": str(wizard.get("aspect_ratio") or "9:16"),
|
||||
"resolution": str(wizard.get("resolution") or "720p").lower(),
|
||||
"total_duration": int(wizard.get("total_duration") or 15),
|
||||
"video_model_config_id": str(wizard.get("video_model_config_id") or ""),
|
||||
"video_model_name": str(wizard.get("video_model_name") or ""),
|
||||
"video_model_label": str(wizard.get("video_model_label") or ""),
|
||||
}
|
||||
|
||||
|
||||
def _save_job(job: QuickCreateJob, **changes) -> None:
|
||||
fields = []
|
||||
for field, value in changes.items():
|
||||
setattr(job, field, value)
|
||||
fields.append(field)
|
||||
if fields:
|
||||
job.save(update_fields=[*fields, "updated_at"])
|
||||
|
||||
|
||||
def _safe_error(exc: Exception) -> str:
|
||||
raw = str(exc or "").strip()
|
||||
lower = raw.lower()
|
||||
if "insufficient credit" in lower or "额度不足" in raw:
|
||||
return "可用积分不足,极速成片已暂停"
|
||||
if "no active" in lower or "not configured" in lower or "没有可用" in raw:
|
||||
return "当前缺少可用的生成模型,请联系管理员配置"
|
||||
if "review" in lower or "审核" in raw:
|
||||
return "生成素材未通过审核,请进入专业模式调整后重试"
|
||||
return "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
|
||||
|
||||
def fail_quick_create(job: QuickCreateJob, message: str, *, internal_error: str = "") -> None:
|
||||
job.refresh_from_db(fields=["status", "metadata"])
|
||||
if job.status in {QuickCreateJob.Status.SUCCEEDED, QuickCreateJob.Status.CANCELLED}:
|
||||
return
|
||||
public_message = (message or "极速成片暂未完成,请稍后重试").strip()[:500]
|
||||
metadata = dict(job.metadata or {})
|
||||
if internal_error:
|
||||
metadata["internal_error"] = internal_error[:2000]
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.FAILED,
|
||||
error_message=public_message,
|
||||
message=public_message,
|
||||
metadata=metadata,
|
||||
)
|
||||
project = job.project
|
||||
if project.status != Project.Status.COMPLETED:
|
||||
project.status = Project.Status.FAILED
|
||||
project.failure_reason = public_message
|
||||
project.save(update_fields=["status", "failure_reason", "updated_at"])
|
||||
if project.current_stage in STAGE_ORDER:
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage)
|
||||
stage.status = ProjectStage.Status.FAILED
|
||||
stage.error_message = public_message
|
||||
stage.save(update_fields=["status", "error_message", "updated_at"])
|
||||
|
||||
|
||||
def cancel_quick_create(job: QuickCreateJob) -> QuickCreateJob:
|
||||
"""用户主动停掉进行中的极速成片;已完成的任务保持原样。"""
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return job
|
||||
if job.status in {QuickCreateJob.Status.FAILED, QuickCreateJob.Status.CANCELLED}:
|
||||
return job
|
||||
public_message = "已取消本次生成"
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["cancelled"] = True
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.CANCELLED,
|
||||
error_message=public_message,
|
||||
message=public_message,
|
||||
metadata=metadata,
|
||||
completed_at=timezone.now(),
|
||||
)
|
||||
project = job.project
|
||||
project.status = Project.Status.FAILED
|
||||
project.failure_reason = public_message
|
||||
project.save(update_fields=["status", "failure_reason", "updated_at"])
|
||||
if project.current_stage in STAGE_ORDER:
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=project.current_stage)
|
||||
if stage.status not in {ProjectStage.Status.SUCCEEDED, ProjectStage.Status.FAILED}:
|
||||
stage.status = ProjectStage.Status.FAILED
|
||||
stage.error_message = public_message
|
||||
stage.save(update_fields=["status", "error_message", "updated_at"])
|
||||
return job
|
||||
|
||||
|
||||
def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
project = job.project
|
||||
settings = _quick_settings(project)
|
||||
user = job.created_by or project.created_by
|
||||
if user is None:
|
||||
raise ValueError("极速成片任务缺少创建人")
|
||||
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
||||
if model_config is None:
|
||||
raise ValueError("no active text model configured")
|
||||
|
||||
error_detail = ""
|
||||
stream = stream_script_agent(
|
||||
project=project,
|
||||
user=user,
|
||||
model_config=model_config,
|
||||
mode="auto",
|
||||
user_prompt=(
|
||||
"请根据商品名称与商品参考图信息,自动推荐最适合的"
|
||||
f"{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。"
|
||||
),
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
total_duration=settings["total_duration"],
|
||||
presentation_format="oral",
|
||||
video_structure="pain",
|
||||
entry_source="ai",
|
||||
persona="reviewer",
|
||||
)
|
||||
for frame in stream:
|
||||
job.refresh_from_db(fields=["status"])
|
||||
if _is_finished(job):
|
||||
return
|
||||
if not frame.startswith("data:"):
|
||||
continue
|
||||
try:
|
||||
event = json.loads(frame[5:].strip())
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
if event.get("type") == "error":
|
||||
error_detail = str(event.get("detail") or "脚本生成失败")
|
||||
if error_detail:
|
||||
raise ValueError(error_detail)
|
||||
|
||||
script = project.script_versions.order_by("-created_at").first()
|
||||
if script is None or not script.segments.exists():
|
||||
raise ValueError("脚本生成后没有可用分镜")
|
||||
adopt_script_version(project, script)
|
||||
|
||||
|
||||
def consume_quick_script(job_id: str) -> None:
|
||||
"""Celery 专用:跑完专业创作同一套脚本 agent,再把编排推去资产阶段。"""
|
||||
from apps.projects.tasks import advance_quick_create_task
|
||||
|
||||
job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id)
|
||||
if _is_finished(job):
|
||||
return
|
||||
adopted = job.project.script_versions.filter(is_adopted=True).first()
|
||||
if adopted is not None and adopted.segments.exists():
|
||||
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
return
|
||||
try:
|
||||
_consume_script_agent(job)
|
||||
except Exception as exc: # noqa: BLE001 — 脚本失败必须落终态
|
||||
logger.exception("quick create script failed for job %s", job.id)
|
||||
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
|
||||
return
|
||||
job.refresh_from_db(fields=["status"])
|
||||
if _is_finished(job):
|
||||
return
|
||||
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
|
||||
|
||||
def _adopted_script(project: Project):
|
||||
script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||
if script is not None and script.segments.exists():
|
||||
return script
|
||||
return None
|
||||
|
||||
|
||||
def _parse_iso(value) -> datetime | None:
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(str(value))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if timezone.is_naive(parsed):
|
||||
parsed = timezone.make_aware(parsed)
|
||||
return parsed
|
||||
|
||||
|
||||
def _advance_product(job: QuickCreateJob) -> int:
|
||||
"""商品已在提交接口建好;这里只补卖点并切到脚本阶段,让页面先看到「识别商品」。"""
|
||||
from apps.products.models import ProductSellingPoint
|
||||
|
||||
product = job.project.product
|
||||
if product is not None and not product.selling_points.exists():
|
||||
ProductSellingPoint.objects.create(
|
||||
product=product,
|
||||
title=product.title,
|
||||
detail="由极速成片根据商品名称自动填写",
|
||||
sort_order=0,
|
||||
)
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.RUNNING,
|
||||
phase=QuickCreateJob.Phase.SCRIPT,
|
||||
progress=18,
|
||||
message="已识别商品与卖点,正在推荐脚本方向",
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def _advance_script(job: QuickCreateJob) -> int | None:
|
||||
if _adopted_script(job.project) is not None:
|
||||
_save_job(job, phase=QuickCreateJob.Phase.ASSETS, progress=48, message="脚本已生成,正在匹配模特与场景")
|
||||
return 1
|
||||
|
||||
metadata = dict(job.metadata or {})
|
||||
if not metadata.get("script_started"):
|
||||
from apps.projects.tasks import run_quick_script_task
|
||||
|
||||
metadata["script_started"] = True
|
||||
metadata["script_started_at"] = timezone.now().isoformat()
|
||||
_save_job(
|
||||
job,
|
||||
metadata=metadata,
|
||||
progress=28,
|
||||
message="正在根据商品名称与图片生成带货脚本",
|
||||
)
|
||||
run_quick_script_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
return SCRIPT_POLL_SECONDS
|
||||
|
||||
failed = (
|
||||
AITask.objects.filter(
|
||||
project=job.project,
|
||||
task_type=AITask.Type.SCRIPT_GENERATION,
|
||||
status=AITask.Status.FAILED,
|
||||
)
|
||||
.order_by("-created_at")
|
||||
.first()
|
||||
)
|
||||
if failed is not None:
|
||||
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
|
||||
return None
|
||||
|
||||
started_at = _parse_iso(metadata.get("script_started_at"))
|
||||
if started_at and timezone.now() - started_at > SCRIPT_TIMEOUT:
|
||||
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
|
||||
return None
|
||||
|
||||
elapsed = int((timezone.now() - started_at).total_seconds()) if started_at else 0
|
||||
_save_job(job, progress=min(44, 28 + elapsed // 8), message="正在生成分镜脚本…")
|
||||
return SCRIPT_POLL_SECONDS
|
||||
|
||||
|
||||
def _ensure_fallback_entities(project: Project) -> list[dict]:
|
||||
"""短脚本偶尔只提到商品;极速模式仍补齐一名模特和一个空镜场景,保证自动链路可走完。"""
|
||||
metadata = dict(project.metadata or {})
|
||||
entities = [dict(item) for item in (metadata.get("script_entities") or []) if isinstance(item, dict)]
|
||||
types = {str(item.get("type") or "") for item in entities}
|
||||
added_ids: list[str] = []
|
||||
if "character" not in types:
|
||||
entities.append(
|
||||
{
|
||||
"id": "quick_character_1",
|
||||
"type": "character",
|
||||
"name": "推荐模特",
|
||||
"visual_prompt": f"专业电商测评模特,亲和自然,适合展示{project.product.title}",
|
||||
"ref_index": 1,
|
||||
}
|
||||
)
|
||||
added_ids.append("quick_character_1")
|
||||
if "scene" not in types:
|
||||
settings = _quick_settings(project)
|
||||
entities.append(
|
||||
{
|
||||
"id": "quick_scene_1",
|
||||
"type": "scene",
|
||||
"name": "推荐场景",
|
||||
"visual_prompt": (
|
||||
f"明亮干净的电商产品展示空间,适合突出{project.product.title},"
|
||||
f"{settings['aspect_ratio']}构图"
|
||||
),
|
||||
"ref_index": 2,
|
||||
}
|
||||
)
|
||||
added_ids.append("quick_scene_1")
|
||||
if added_ids:
|
||||
metadata["script_entities"] = entities
|
||||
metadata["cast"] = [item["name"] for item in entities if item.get("type") == "character"]
|
||||
metadata["scenes"] = [item["name"] for item in entities if item.get("type") == "scene"]
|
||||
metadata["cast_prompts"] = {
|
||||
item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "character"
|
||||
}
|
||||
metadata["scene_prompts"] = {
|
||||
item["name"]: item.get("visual_prompt", "") for item in entities if item.get("type") == "scene"
|
||||
}
|
||||
project.metadata = metadata
|
||||
project.save(update_fields=["metadata", "updated_at"])
|
||||
script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||
if script is not None:
|
||||
for segment in script.segments.all():
|
||||
refs = list(segment.entity_refs or [])
|
||||
segment.entity_refs = [*refs, *(item for item in added_ids if item not in refs)]
|
||||
segment.save(update_fields=["entity_refs", "updated_at"])
|
||||
return entities
|
||||
|
||||
|
||||
def _start_base_assets(job: QuickCreateJob) -> None:
|
||||
project = job.project
|
||||
user = job.created_by or project.created_by
|
||||
if user is None:
|
||||
raise ValueError("极速成片任务缺少创建人")
|
||||
with transaction.atomic():
|
||||
job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id)
|
||||
metadata = dict(job.metadata or {})
|
||||
if metadata.get("base_asset_task_ids") or metadata.get("assets_started"):
|
||||
return
|
||||
metadata["assets_started"] = True
|
||||
job.metadata = metadata
|
||||
job.save(update_fields=["metadata", "updated_at"])
|
||||
entities = _ensure_fallback_entities(job.project)
|
||||
specs = [
|
||||
(BaseAssetGroup.Kind.PRODUCT, job.project.product.title, job.project.product.title),
|
||||
*[
|
||||
(BaseAssetGroup.Kind.PERSON, str(item.get("visual_prompt") or item.get("name") or "电商模特"), str(item.get("name") or "推荐模特"))
|
||||
for item in entities
|
||||
if item.get("type") == "character"
|
||||
],
|
||||
*[
|
||||
(BaseAssetGroup.Kind.SCENE, str(item.get("visual_prompt") or item.get("name") or "电商场景"), str(item.get("name") or "推荐场景"))
|
||||
for item in entities
|
||||
if item.get("type") == "scene"
|
||||
],
|
||||
]
|
||||
task_ids = []
|
||||
for kind, prompt, label in specs:
|
||||
task = generate_base_asset(project=job.project, user=user, kind=kind, prompt=prompt, label=label)
|
||||
task_ids.append(str(task.id))
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["base_asset_task_ids"] = task_ids
|
||||
_save_job(job, metadata=metadata, message="正在生成商品、模特与场景资产", progress=52)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
|
||||
|
||||
def _task_failure(tasks) -> AITask | None:
|
||||
return next(
|
||||
(
|
||||
task
|
||||
for task in tasks
|
||||
if task.status == AITask.Status.FAILED
|
||||
or task.status not in _ACTIVE_TASK_STATUSES | {AITask.Status.SUCCEEDED}
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
|
||||
def _task_public_error(task: AITask) -> str:
|
||||
from apps.ai.generation_errors import public_error_for_task
|
||||
|
||||
public_error = public_error_for_task(task)
|
||||
return public_error.fallback_message if public_error else "生成任务失败,请稍后重试"
|
||||
|
||||
|
||||
def _advance_assets(job: QuickCreateJob) -> int | None:
|
||||
metadata = dict(job.metadata or {})
|
||||
base_ids = metadata.get("base_asset_task_ids") or []
|
||||
if not base_ids:
|
||||
_start_base_assets(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
tasks = list(AITask.objects.filter(project=job.project, id__in=base_ids))
|
||||
failed = _task_failure(tasks)
|
||||
if failed is not None:
|
||||
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
|
||||
return None
|
||||
if len(tasks) < len(base_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in tasks):
|
||||
done = sum(task.status == AITask.Status.SUCCEEDED for task in tasks)
|
||||
_save_job(job, progress=min(64, 52 + done * 4), message="正在匹配并生成模特与场景")
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
triview_ids = metadata.get("triview_task_ids")
|
||||
if triview_ids is None:
|
||||
person_groups = list(
|
||||
job.project.base_asset_groups.filter(
|
||||
kind=BaseAssetGroup.Kind.PERSON,
|
||||
task_id__in=base_ids,
|
||||
adopted_asset__isnull=False,
|
||||
).select_related("adopted_asset")
|
||||
)
|
||||
triview_ids = [
|
||||
str(generate_person_triview(project=job.project, user=job.created_by or job.project.created_by, portrait_asset=group.adopted_asset).id)
|
||||
for group in person_groups
|
||||
]
|
||||
metadata["triview_task_ids"] = triview_ids
|
||||
_save_job(job, metadata=metadata, progress=65, message="正在为推荐模特生成三视图")
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
triview_tasks = list(AITask.objects.filter(project=job.project, id__in=triview_ids))
|
||||
failed = _task_failure(triview_tasks)
|
||||
if failed is not None:
|
||||
fail_quick_create(job, _task_public_error(failed), internal_error=failed.error_message)
|
||||
return None
|
||||
if len(triview_tasks) < len(triview_ids) or any(task.status in _ACTIVE_TASK_STATUSES for task in triview_tasks):
|
||||
_save_job(job, progress=68, message="正在为推荐模特生成三视图")
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
for group in job.project.base_asset_groups.filter(adopted_asset__isnull=False):
|
||||
if group.kind != BaseAssetGroup.Kind.PRODUCT:
|
||||
group_meta = dict(group.metadata or {})
|
||||
group_meta["adopt"] = "adopted"
|
||||
group.metadata = group_meta
|
||||
group.save(update_fields=["metadata", "updated_at"])
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.completed_at = timezone.now()
|
||||
stage.error_message = ""
|
||||
stage.save(update_fields=["status", "completed_at", "error_message", "updated_at"])
|
||||
job.project.current_stage = ProjectStage.Stage.STORYBOARD
|
||||
job.project.status = Project.Status.STORYBOARDING
|
||||
job.project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
_save_job(
|
||||
job,
|
||||
phase=QuickCreateJob.Phase.PRODUCTION,
|
||||
progress=72,
|
||||
message="正在生成故事板与镜头画面",
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
def _reviews_ready(job: QuickCreateJob) -> bool | None:
|
||||
"""True=可出视频,False=继续等,None=审核失败且任务已终止。"""
|
||||
if not assets_client.is_enabled():
|
||||
return True
|
||||
poll_team_reviews(job.team)
|
||||
blockers = collect_video_review_blockers(job.project)
|
||||
if not blockers:
|
||||
return True
|
||||
if any(item.get("review_status") == "failed" for item in blockers):
|
||||
fail_quick_create(job, "生成素材未通过审核,请进入专业模式调整后重试")
|
||||
return None
|
||||
metadata = dict(job.metadata or {})
|
||||
wait_started = metadata.get("review_wait_started")
|
||||
if not wait_started:
|
||||
metadata["review_wait_started"] = timezone.now().isoformat()
|
||||
_save_job(job, metadata=metadata)
|
||||
else:
|
||||
try:
|
||||
started_at = datetime.fromisoformat(str(wait_started))
|
||||
if timezone.is_naive(started_at):
|
||||
started_at = timezone.make_aware(started_at)
|
||||
if timezone.now() - started_at > timedelta(minutes=20):
|
||||
fail_quick_create(job, "素材质量检查等待超时,请进入专业模式查看")
|
||||
return None
|
||||
except (TypeError, ValueError):
|
||||
metadata["review_wait_started"] = timezone.now().isoformat()
|
||||
_save_job(job, metadata=metadata)
|
||||
for item in blockers:
|
||||
if not item.get("review_status") and item.get("asset_id"):
|
||||
asset = Asset.objects.filter(team=job.team, id=item["asset_id"]).first()
|
||||
if asset is not None:
|
||||
submit_asset_for_review(asset)
|
||||
_save_job(job, progress=82, message="故事板已完成,正在进行视频素材质量检查")
|
||||
return False
|
||||
|
||||
|
||||
def _start_videos(job: QuickCreateJob) -> None:
|
||||
from apps.projects.tasks import poll_video_segment_task
|
||||
|
||||
settings = _quick_settings(job.project)
|
||||
for segment in job.project.video_segments.order_by("sort_order"):
|
||||
if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}:
|
||||
continue
|
||||
submit_video_segment(
|
||||
video_segment=segment,
|
||||
user=job.created_by or job.project.created_by,
|
||||
prompt="极速成片自动生成,严格遵循本镜故事板与脚本。",
|
||||
model_config_id=settings["video_model_config_id"] or None,
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
resolution=settings["resolution"],
|
||||
)
|
||||
poll_video_segment_task.apply_async(args=[str(segment.id)], countdown=30)
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["video_started"] = True
|
||||
_save_job(
|
||||
job,
|
||||
metadata=metadata,
|
||||
progress=86,
|
||||
message=f"正在生成{settings['total_duration']}秒 {settings['aspect_ratio']} 视频",
|
||||
)
|
||||
|
||||
|
||||
def _start_export(job: QuickCreateJob) -> None:
|
||||
from apps.projects.tasks import run_export_job_task
|
||||
from apps.ai.video_pricing import get_resolution
|
||||
|
||||
project = job.project
|
||||
settings = _quick_settings(project)
|
||||
total_duration = sum(project.video_segments.values_list("target_duration_seconds", flat=True)) or 15
|
||||
width, height = get_resolution(settings["aspect_ratio"], settings["resolution"])
|
||||
with transaction.atomic():
|
||||
timeline, _ = Timeline.objects.get_or_create(
|
||||
project=project,
|
||||
defaults={
|
||||
"name": f"{project.name} Timeline",
|
||||
"duration_seconds": total_duration,
|
||||
"aspect_ratio": settings["aspect_ratio"],
|
||||
"resolution": f"{width}x{height}",
|
||||
},
|
||||
)
|
||||
timeline.duration_seconds = total_duration
|
||||
timeline.aspect_ratio = settings["aspect_ratio"]
|
||||
timeline.resolution = f"{width}x{height}"
|
||||
timeline.save(update_fields=["duration_seconds", "aspect_ratio", "resolution", "updated_at"])
|
||||
timeline.clips.all().delete()
|
||||
start_ms = 0
|
||||
for segment in project.video_segments.select_related("adopted_version__asset").order_by("sort_order"):
|
||||
TimelineClip.objects.create(
|
||||
timeline=timeline,
|
||||
asset=segment.adopted_version.asset,
|
||||
sort_order=segment.sort_order,
|
||||
start_ms=start_ms,
|
||||
duration_ms=segment.target_duration_seconds * 1000,
|
||||
)
|
||||
start_ms += segment.target_duration_seconds * 1000
|
||||
export_job = create_export_job(timeline=timeline, user=job.created_by or project.created_by)
|
||||
run_export_job_task.delay(str(export_job.id))
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["export_job_id"] = str(export_job.id)
|
||||
_save_job(job, metadata=metadata, progress=96, message="各镜头已生成,正在合成为完整视频")
|
||||
|
||||
|
||||
def _videos_ready(job: QuickCreateJob) -> bool:
|
||||
segments = list(job.project.video_segments.order_by("sort_order"))
|
||||
if not segments:
|
||||
return False
|
||||
return all(
|
||||
segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id
|
||||
for segment in segments
|
||||
)
|
||||
|
||||
|
||||
def _can_complete(job: QuickCreateJob) -> bool:
|
||||
if not _videos_ready(job):
|
||||
return False
|
||||
segments = list(job.project.video_segments.all())
|
||||
if len(segments) <= 1:
|
||||
return True
|
||||
export_job_id = (job.metadata or {}).get("export_job_id")
|
||||
if not export_job_id:
|
||||
return False
|
||||
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
|
||||
return export_job is not None and export_job.status == ExportJob.Status.SUCCEEDED
|
||||
|
||||
|
||||
def _complete(job: QuickCreateJob) -> None:
|
||||
_save_job(
|
||||
job,
|
||||
status=QuickCreateJob.Status.SUCCEEDED,
|
||||
phase=QuickCreateJob.Phase.COMPLETE,
|
||||
progress=100,
|
||||
message="视频已生成",
|
||||
error_message="",
|
||||
completed_at=timezone.now(),
|
||||
)
|
||||
|
||||
|
||||
def _advance_production(job: QuickCreateJob) -> int | None:
|
||||
metadata = dict(job.metadata or {})
|
||||
if not metadata.get("storyboard_started"):
|
||||
submit_storyboard(project=job.project, user=job.created_by or job.project.created_by, prompt="")
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
metadata["storyboard_started"] = True
|
||||
_save_job(job, metadata=metadata, progress=74, message="正在生成故事板与镜头画面")
|
||||
|
||||
if job.project.current_stage != ProjectStage.Stage.VIDEO:
|
||||
result = poll_storyboard(project=job.project, user=job.created_by or job.project.created_by)
|
||||
if result.get("status") == "failed":
|
||||
fail_quick_create(job, str(result.get("error") or "故事板生成失败"))
|
||||
return None
|
||||
if result.get("status") != "succeeded":
|
||||
total = max(1, int(result.get("total") or 1))
|
||||
done = int(result.get("done") or 0)
|
||||
_save_job(job, progress=min(80, 74 + round(done / total * 6)), message=f"正在生成故事板({done}/{total})")
|
||||
return POLL_DELAY_SECONDS
|
||||
finish_storyboard_stage(job.project)
|
||||
|
||||
review_state = _reviews_ready(job)
|
||||
if review_state is None:
|
||||
return None
|
||||
if not review_state:
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
if not (job.metadata or {}).get("video_started"):
|
||||
_start_videos(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
segments = list(job.project.video_segments.order_by("sort_order"))
|
||||
failed = next((segment for segment in segments if segment.status == VideoSegment.Status.FAILED), None)
|
||||
if failed is not None:
|
||||
fail_quick_create(job, failed.error_message or f"第{failed.sort_order + 1}段视频生成失败")
|
||||
return None
|
||||
completed = sum(
|
||||
1
|
||||
for segment in segments
|
||||
if segment.status == VideoSegment.Status.SUCCEEDED and segment.adopted_version_id
|
||||
)
|
||||
if completed < len(segments):
|
||||
_save_job(job, progress=min(94, 86 + completed * 3), message=f"正在生成视频({completed}/{len(segments)})")
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
finish_video_stage(job.project)
|
||||
if len(segments) == 1:
|
||||
_complete(job)
|
||||
return None
|
||||
|
||||
export_job_id = (job.metadata or {}).get("export_job_id")
|
||||
if not export_job_id:
|
||||
_start_export(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
export_job = ExportJob.objects.filter(id=export_job_id, timeline__project=job.project).first()
|
||||
if export_job is None:
|
||||
raise ValueError("视频合成任务不存在")
|
||||
if export_job.status == ExportJob.Status.FAILED:
|
||||
fail_quick_create(job, "视频片段已生成,但自动合成失败,请进入专业模式查看", internal_error=export_job.error_message)
|
||||
return None
|
||||
if export_job.status != ExportJob.Status.SUCCEEDED:
|
||||
_save_job(job, progress=max(96, min(99, int(export_job.progress or 0))), message="正在合成为完整视频")
|
||||
return POLL_DELAY_SECONDS
|
||||
_complete(job)
|
||||
return None
|
||||
|
||||
|
||||
_local_script_jobs: set[str] = set()
|
||||
|
||||
|
||||
def _script_task_started(job: QuickCreateJob) -> bool:
|
||||
return job.project.ai_tasks.filter(task_type=AITask.Type.SCRIPT_GENERATION).exists()
|
||||
|
||||
|
||||
def _claim_next_advance(job_id: str, delay_seconds: int) -> bool:
|
||||
"""同一任务同一时刻只允许一枚后续推进,避免轮询把队列打爆。"""
|
||||
with transaction.atomic():
|
||||
job = QuickCreateJob.objects.select_for_update().get(id=job_id)
|
||||
metadata = dict(job.metadata or {})
|
||||
eta = _parse_iso(metadata.get("next_advance_at"))
|
||||
if eta and eta > timezone.now():
|
||||
return False
|
||||
metadata["next_advance_at"] = (timezone.now() + timedelta(seconds=max(1, delay_seconds))).isoformat()
|
||||
job.metadata = metadata
|
||||
job.save(update_fields=["metadata", "updated_at"])
|
||||
return True
|
||||
|
||||
|
||||
def _run_quick_script_in_thread(job_id: str) -> None:
|
||||
"""共享 Redis 上的旧 worker 会把未注册任务直接丢掉;本机线程兜底,避免页面永久转圈。"""
|
||||
if job_id in _local_script_jobs:
|
||||
return
|
||||
_local_script_jobs.add(job_id)
|
||||
|
||||
def _worker() -> None:
|
||||
try:
|
||||
consume_quick_script(job_id)
|
||||
except Exception as exc: # noqa: BLE001 — 线程失败必须落终态
|
||||
job = QuickCreateJob.objects.select_related("project").filter(id=job_id).first()
|
||||
if job is not None and not _is_finished(job):
|
||||
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
|
||||
finally:
|
||||
_local_script_jobs.discard(job_id)
|
||||
connections.close_all()
|
||||
|
||||
threading.Thread(target=_worker, daemon=True, name=f"quick-script-{job_id[:8]}").start()
|
||||
|
||||
|
||||
def recover_quick_create(job: QuickCreateJob) -> None:
|
||||
"""前端轮询时把卡住的编排拉起来:超时落失败,被旧 worker 丢掉的脚本改走本机线程。"""
|
||||
job.refresh_from_db()
|
||||
if job.status == QuickCreateJob.Status.FAILED and _can_complete(job):
|
||||
_complete(job)
|
||||
return
|
||||
if _is_finished(job):
|
||||
return
|
||||
job_id = str(job.id)
|
||||
metadata = dict(job.metadata or {})
|
||||
if job.phase == QuickCreateJob.Phase.SCRIPT and metadata.get("script_started"):
|
||||
started_at = _parse_iso(metadata.get("script_started_at"))
|
||||
adopted = _adopted_script(job.project)
|
||||
local_running = job_id in _local_script_jobs
|
||||
timed_out = bool(started_at and timezone.now() - started_at > SCRIPT_TIMEOUT)
|
||||
stolen = bool(
|
||||
started_at
|
||||
and timezone.now() - started_at > SCRIPT_STOLEN_AFTER
|
||||
and adopted is None
|
||||
and not local_running
|
||||
)
|
||||
if stolen:
|
||||
metadata["script_local"] = True
|
||||
metadata["script_started_at"] = timezone.now().isoformat()
|
||||
_save_job(job, metadata=metadata, message="正在继续生成分镜脚本…")
|
||||
_run_quick_script_in_thread(job_id)
|
||||
return
|
||||
if timed_out and adopted is None and not local_running:
|
||||
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
|
||||
return
|
||||
if timezone.now() - job.updated_at > STALE_AFTER and _claim_next_advance(str(job.id), SCRIPT_POLL_SECONDS):
|
||||
from apps.projects.tasks import advance_quick_create_task
|
||||
|
||||
try:
|
||||
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
except Exception: # noqa: BLE001 — 队列不可用时就地推进一步,避免永久 loading
|
||||
advance_quick_create(str(job.id))
|
||||
|
||||
|
||||
def advance_quick_create(job_id: str) -> int | None:
|
||||
"""推进一个状态并返回下次轮询秒数;返回 None 表示终态。
|
||||
|
||||
一次只做一段:识别商品 → 脚本(独立任务) → 资产 → 故事板/视频/合成。
|
||||
脚本不再在编排任务里同步吃完整条 SSE,避免页面一直停在「推荐脚本方向」。
|
||||
"""
|
||||
job = QuickCreateJob.objects.select_related("project__product", "created_by", "team").get(id=job_id)
|
||||
if _is_finished(job):
|
||||
return None
|
||||
try:
|
||||
if job.status == QuickCreateJob.Status.QUEUED:
|
||||
_save_job(job, status=QuickCreateJob.Status.RUNNING, progress=8, message="正在识别商品图片与卖点")
|
||||
if job.phase == QuickCreateJob.Phase.PRODUCT:
|
||||
return _advance_product(job)
|
||||
if job.phase == QuickCreateJob.Phase.SCRIPT:
|
||||
return _advance_script(job)
|
||||
if job.phase == QuickCreateJob.Phase.ASSETS:
|
||||
return _advance_assets(job)
|
||||
if job.phase == QuickCreateJob.Phase.PRODUCTION:
|
||||
return _advance_production(job)
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001 — 编排失败必须落可恢复终态,不能留下永久 loading
|
||||
logger.exception("quick create job %s failed", job.id)
|
||||
if _can_complete(job):
|
||||
_complete(job)
|
||||
return None
|
||||
fail_quick_create(job, _safe_error(exc), internal_error=str(exc))
|
||||
return None
|
||||
@@ -42,3 +42,56 @@ def run_export_job_task(self, export_job_id: str) -> str:
|
||||
export_job.save(update_fields=["status", "error_message", "updated_at"])
|
||||
raise
|
||||
return export_job_id
|
||||
|
||||
|
||||
QUICK_CREATE_QUEUE = "airshelf.quick"
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0, soft_time_limit=240, time_limit=270, queue=QUICK_CREATE_QUEUE)
|
||||
def run_quick_script_task(self, quick_job_id: str) -> str:
|
||||
"""脚本生成单独跑,避免把整条极速成片编排堵在一次 SSE 消费里。"""
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
|
||||
from apps.projects.models import QuickCreateJob
|
||||
from apps.projects.services.quick_create import consume_quick_script, fail_quick_create
|
||||
|
||||
try:
|
||||
consume_quick_script(quick_job_id)
|
||||
except SoftTimeLimitExceeded:
|
||||
job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first()
|
||||
if job is not None and job.status not in {
|
||||
QuickCreateJob.Status.SUCCEEDED,
|
||||
QuickCreateJob.Status.FAILED,
|
||||
QuickCreateJob.Status.CANCELLED,
|
||||
}:
|
||||
fail_quick_create(job, "脚本生成超时,请稍后重试或进入专业模式查看")
|
||||
raise
|
||||
return quick_job_id
|
||||
|
||||
|
||||
@app.task(bind=True, max_retries=0, queue=QUICK_CREATE_QUEUE)
|
||||
def advance_quick_create_task(self, quick_job_id: str) -> str:
|
||||
"""一次只推进一个可重入状态,等待型阶段通过重新入队轮询,不占 worker 睡眠。"""
|
||||
from apps.projects.services.quick_create import advance_quick_create
|
||||
|
||||
next_delay = advance_quick_create(quick_job_id)
|
||||
if next_delay is not None:
|
||||
from apps.projects.services.quick_create import _claim_next_advance
|
||||
|
||||
if not _claim_next_advance(quick_job_id, int(next_delay)):
|
||||
return quick_job_id
|
||||
try:
|
||||
advance_quick_create_task.apply_async(
|
||||
args=[quick_job_id],
|
||||
countdown=max(1, int(next_delay)),
|
||||
queue=QUICK_CREATE_QUEUE,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — 重排失败不能留下永久“生成中”
|
||||
from apps.projects.models import QuickCreateJob
|
||||
from apps.projects.services.quick_create import fail_quick_create
|
||||
|
||||
job = QuickCreateJob.objects.select_related("project").filter(id=quick_job_id).first()
|
||||
if job is not None:
|
||||
fail_quick_create(job, "生成队列暂时中断,请稍后重试", internal_error=str(exc))
|
||||
raise
|
||||
return quick_job_id
|
||||
|
||||
@@ -0,0 +1,490 @@
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
from types import SimpleNamespace
|
||||
import uuid
|
||||
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase
|
||||
from django.utils import timezone
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import ModelConfig, ModelProvider
|
||||
from apps.assets.models import Asset, AssetFile
|
||||
from apps.products.models import Product, ProductImage
|
||||
from apps.projects.models import Project, ProjectStage, QuickCreateJob, ScriptSegment, ScriptVersion, VideoSegment, VideoSegmentVersion
|
||||
from apps.projects.serializers import ProjectListSerializer, QuickCreateJobSerializer
|
||||
from apps.projects.services.pipeline import initialize_project_pipeline
|
||||
from apps.projects.services.quick_create import (
|
||||
_start_videos,
|
||||
advance_quick_create,
|
||||
cancel_quick_create,
|
||||
recover_quick_create,
|
||||
)
|
||||
|
||||
|
||||
class QuickCreateApiTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="quick-owner", password="pass")
|
||||
self.team = Team.objects.create(name="Quick Team", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.client = APIClient()
|
||||
self.client.force_authenticate(self.user)
|
||||
|
||||
def _uploaded_asset(self, **kwargs):
|
||||
upload = kwargs["upload"]
|
||||
return Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name=upload.name,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.views.advance_quick_create_task.apply_async")
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model):
|
||||
store_asset.side_effect = self._uploaded_asset
|
||||
video_model_id = uuid.uuid4()
|
||||
get_model.side_effect = [
|
||||
object(),
|
||||
object(),
|
||||
SimpleNamespace(
|
||||
id=video_model_id,
|
||||
name="doubao-seedance-2-0-fast-260128",
|
||||
display_name="Seedance 2.0 Fast",
|
||||
metadata={"capabilities": {"resolutions": ["480p", "720p"], "aspect_ratios": ["9:16"], "durations": [15]}},
|
||||
),
|
||||
]
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "轻醒咖啡",
|
||||
"images": [
|
||||
SimpleUploadedFile("front.png", b"png-one", content_type="image/png"),
|
||||
SimpleUploadedFile("detail.webp", b"webp-two", content_type="image/webp"),
|
||||
],
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"total_duration": "30",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
product = Product.objects.get(title="轻醒咖啡")
|
||||
self.assertEqual(product.images.count(), 2)
|
||||
self.assertEqual(product.cover_asset_id, product.images.order_by("sort_order").first().asset_id)
|
||||
project = Project.objects.get(product=product)
|
||||
self.assertTrue(project.metadata["quick_create"])
|
||||
self.assertEqual(project.metadata["wizard"]["total_duration"], 30)
|
||||
self.assertEqual(project.metadata["wizard"]["resolution"], "720p")
|
||||
self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id))
|
||||
self.assertEqual(project.stages.count(), 5)
|
||||
self.assertEqual(project.video_segments.count(), 2)
|
||||
self.assertTrue(product.selling_points.exists())
|
||||
job = QuickCreateJob.objects.get(project=project)
|
||||
self.assertEqual(str(job.id), response.data["id"])
|
||||
enqueue.assert_called_once()
|
||||
self.assertEqual(enqueue.call_args.kwargs["args"], [str(job.id)])
|
||||
self.assertEqual(enqueue.call_args.kwargs["queue"], "airshelf.quick")
|
||||
require_worker_task.assert_called_once_with("apps.projects.tasks.advance_quick_create_task")
|
||||
self.assertEqual(get_model.call_count, 3)
|
||||
self.assertEqual(len(response.data["product_images"]), 2)
|
||||
self.assertEqual(
|
||||
{item["asset_id"] for item in response.data["product_images"]},
|
||||
{str(image.asset_id) for image in product.images.order_by("sort_order")},
|
||||
)
|
||||
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.views.advance_quick_create_task.apply_async")
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model):
|
||||
source_asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="已有主图.png",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
source = Product.objects.create(team=self.team, created_by=self.user, title="旧商品", cover_asset=source_asset)
|
||||
ProductImage.objects.create(product=source, asset=source_asset, sort_order=0, is_primary=True)
|
||||
get_model.side_effect = [object(), object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})]
|
||||
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "复用商品图",
|
||||
"source_product_id": str(source.id),
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"total_duration": "15",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 202)
|
||||
store_asset.assert_not_called()
|
||||
product = Product.objects.get(title="复用商品图")
|
||||
self.assertEqual(product.images.count(), 1)
|
||||
self.assertEqual(product.images.first().asset_id, source_asset.id)
|
||||
self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id))
|
||||
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
def test_submit_rejects_foreign_source_product_images(self, require_worker_task):
|
||||
other = User.objects.create_user(username="quick-image-other", password="pass")
|
||||
other_team = Team.objects.create(name="Image Other Team", owner=other)
|
||||
foreign = Product.objects.create(team=other_team, created_by=other, title="别人的图")
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "偷图",
|
||||
"source_product_id": str(foreign.id),
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(Product.objects.filter(title="偷图").exists())
|
||||
|
||||
def test_status_returns_saved_product_images(self):
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="主图.png",
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
source=Asset.Source.UPLOAD,
|
||||
category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key="quick/product.png",
|
||||
bucket="test",
|
||||
content_type="image/png",
|
||||
preview_url="https://cdn.example/product.png",
|
||||
is_primary=True,
|
||||
)
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="带图商品", cover_asset=asset)
|
||||
ProductImage.objects.create(product=product, asset=asset, sort_order=0, is_primary=True)
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="带图项目")
|
||||
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
|
||||
|
||||
response = self.client.get(f"/api/projects/quick-create-status/{job.id}/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["product_images"], [{"asset_id": str(asset.id), "url": "https://cdn.example/product.png"}])
|
||||
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
def test_submit_rejects_non_image(self, require_worker_task):
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "错误文件",
|
||||
"images": [SimpleUploadedFile("note.txt", b"text", content_type="text/plain")],
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(Product.objects.filter(title="错误文件").exists())
|
||||
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model):
|
||||
provider = ModelProvider.objects.create(name="quick-video-provider", display_name="Quick", status="active")
|
||||
model = ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
name="quick-fast",
|
||||
display_name="Quick Fast",
|
||||
capability=ModelConfig.Capability.VIDEO,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
metadata={"capabilities": {"resolutions": ["480p", "720p"], "durations": [15]}},
|
||||
)
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "不兼容清晰度",
|
||||
"images": [SimpleUploadedFile("front.png", b"png", content_type="image/png")],
|
||||
"video_model_config_id": str(model.id),
|
||||
"resolution": "1080p",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("不支持 1080P", response.data["detail"])
|
||||
self.assertFalse(Product.objects.filter(title="不兼容清晰度").exists())
|
||||
|
||||
def test_status_is_team_scoped(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目")
|
||||
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
|
||||
|
||||
other = User.objects.create_user(username="quick-other", password="pass")
|
||||
other_team = Team.objects.create(name="Other Team", owner=other)
|
||||
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
|
||||
other_client = APIClient()
|
||||
other_client.force_authenticate(other)
|
||||
|
||||
self.assertEqual(other_client.get(f"/api/projects/quick-create-status/{job.id}/").status_code, 404)
|
||||
|
||||
def test_cancel_stops_running_job(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="待取消商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="待取消项目")
|
||||
job = QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=project,
|
||||
status=QuickCreateJob.Status.RUNNING,
|
||||
phase=QuickCreateJob.Phase.SCRIPT,
|
||||
message="正在根据商品名称与图片生成带货脚本",
|
||||
)
|
||||
|
||||
response = self.client.post(f"/api/projects/quick-create-cancel/{job.id}/")
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
job.refresh_from_db()
|
||||
self.assertEqual(job.status, QuickCreateJob.Status.CANCELLED)
|
||||
self.assertEqual(response.data["status"], "cancelled")
|
||||
self.assertIn("取消", response.data["error_message"])
|
||||
|
||||
def test_cancel_is_team_scoped(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="本团队商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="本团队项目")
|
||||
job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=project)
|
||||
|
||||
other = User.objects.create_user(username="quick-cancel-other", password="pass")
|
||||
other_team = Team.objects.create(name="Cancel Other Team", owner=other)
|
||||
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
|
||||
other_client = APIClient()
|
||||
other_client.force_authenticate(other)
|
||||
|
||||
self.assertEqual(other_client.post(f"/api/projects/quick-create-cancel/{job.id}/").status_code, 404)
|
||||
|
||||
def test_history_lists_succeeded_jobs_for_team(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="历史商品")
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=product, name="历史商品 · 极速成片")
|
||||
QuickCreateJob.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
project=project,
|
||||
status=QuickCreateJob.Status.SUCCEEDED,
|
||||
phase=QuickCreateJob.Phase.COMPLETE,
|
||||
)
|
||||
other = User.objects.create_user(username="quick-history-other", password="pass")
|
||||
other_team = Team.objects.create(name="History Other Team", owner=other)
|
||||
TeamMember.objects.create(team=other_team, user=other, role=TeamMember.Role.OWNER)
|
||||
other_product = Product.objects.create(team=other_team, created_by=other, title="别人的商品")
|
||||
other_project = Project.objects.create(team=other_team, created_by=other, product=other_product, name="别人的项目")
|
||||
QuickCreateJob.objects.create(
|
||||
team=other_team,
|
||||
created_by=other,
|
||||
project=other_project,
|
||||
status=QuickCreateJob.Status.SUCCEEDED,
|
||||
phase=QuickCreateJob.Phase.COMPLETE,
|
||||
)
|
||||
|
||||
response = self.client.get("/api/projects/quick-create-history/")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.data["count"], 1)
|
||||
self.assertEqual(response.data["results"][0]["product_name"], "历史商品")
|
||||
self.assertEqual(response.data["results"][0]["title"], "历史商品 · 极速成片")
|
||||
|
||||
def test_list_serializer_flags_quick_create_projects(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="列表商品")
|
||||
quick = Project.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="列表商品 · 极速成片",
|
||||
metadata={"quick_create": True},
|
||||
)
|
||||
normal = Project.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=product,
|
||||
name="列表商品 · 专业创作",
|
||||
)
|
||||
self.assertTrue(ProjectListSerializer(quick).data["quick_create"])
|
||||
self.assertFalse(ProjectListSerializer(normal).data["quick_create"])
|
||||
|
||||
|
||||
class QuickCreateCoordinatorTests(TestCase):
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(username="quick-worker", password="pass")
|
||||
self.team = Team.objects.create(name="Worker Team", owner=self.user)
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role=TeamMember.Role.OWNER)
|
||||
self.product = Product.objects.create(team=self.team, created_by=self.user, title="测试精华")
|
||||
self.project = Project.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
product=self.product,
|
||||
name="测试精华 · 极速成片",
|
||||
status=Project.Status.SCRIPTING,
|
||||
current_stage=ProjectStage.Stage.SCRIPT,
|
||||
)
|
||||
initialize_project_pipeline(self.project)
|
||||
self.job = QuickCreateJob.objects.create(team=self.team, created_by=self.user, project=self.project)
|
||||
|
||||
@patch("apps.projects.tasks.run_quick_script_task.apply_async")
|
||||
def test_first_advance_recognizes_product_then_starts_script(self, start_script):
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(delay, 1)
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
self.assertEqual(self.job.phase, QuickCreateJob.Phase.SCRIPT)
|
||||
start_script.assert_not_called()
|
||||
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(delay, 5)
|
||||
self.assertTrue(self.job.metadata.get("script_started"))
|
||||
start_script.assert_called_once()
|
||||
self.assertEqual(start_script.call_args.kwargs["args"], [str(self.job.id)])
|
||||
self.assertEqual(start_script.call_args.kwargs["queue"], "airshelf.quick")
|
||||
|
||||
def test_adopted_script_moves_to_assets_without_rerunning(self):
|
||||
script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场")
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.SCRIPT
|
||||
self.job.save(update_fields=["status", "phase", "updated_at"])
|
||||
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(delay, 1)
|
||||
self.assertEqual(self.job.phase, QuickCreateJob.Phase.ASSETS)
|
||||
|
||||
def test_cancelled_job_does_not_keep_advancing(self):
|
||||
cancel_quick_create(self.job)
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertIsNone(delay)
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.CANCELLED)
|
||||
|
||||
@patch("apps.projects.services.quick_create._run_quick_script_in_thread")
|
||||
def test_recover_reruns_script_locally_when_queue_drops_it(self, run_local):
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.SCRIPT
|
||||
self.job.metadata = {
|
||||
"script_started": True,
|
||||
"script_started_at": (timezone.now() - timedelta(minutes=5)).isoformat(),
|
||||
}
|
||||
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
|
||||
|
||||
recover_quick_create(self.job)
|
||||
self.job.refresh_from_db()
|
||||
self.assertTrue(self.job.metadata.get("script_local"))
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
run_local.assert_called_once_with(str(self.job.id))
|
||||
|
||||
def test_recover_marks_success_when_video_already_finished(self):
|
||||
self.project.video_segments.exclude(sort_order=0).delete()
|
||||
segment = self.project.video_segments.get(sort_order=0)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="已完成视频",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
|
||||
segment.adopted_version = version
|
||||
segment.status = VideoSegment.Status.SUCCEEDED
|
||||
segment.save(update_fields=["adopted_version", "status", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.error_message = "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "updated_at"])
|
||||
|
||||
recover_quick_create(self.job)
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
|
||||
self.assertEqual(self.job.phase, QuickCreateJob.Phase.COMPLETE)
|
||||
|
||||
@patch("apps.projects.services.quick_create._reviews_ready", return_value=True)
|
||||
def test_production_counts_ready_videos_without_adding_version_ids(self, _reviews):
|
||||
self.project.video_segments.exclude(sort_order=0).delete()
|
||||
segment = self.project.video_segments.get(sort_order=0)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="计数视频",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
|
||||
segment.adopted_version = version
|
||||
segment.status = VideoSegment.Status.SUCCEEDED
|
||||
segment.save(update_fields=["adopted_version", "status", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.PRODUCTION
|
||||
self.job.metadata = {"storyboard_started": True, "video_started": True}
|
||||
self.job.save(update_fields=["status", "phase", "metadata", "updated_at"])
|
||||
self.project.current_stage = ProjectStage.Stage.VIDEO
|
||||
self.project.save(update_fields=["current_stage", "updated_at"])
|
||||
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.assertIsNone(delay)
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
|
||||
|
||||
def test_success_serializer_returns_direct_single_segment_video(self):
|
||||
self.project.video_segments.exclude(sort_order=0).delete()
|
||||
segment = self.project.video_segments.get(sort_order=0)
|
||||
asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
name="极速成片",
|
||||
asset_type=Asset.Type.VIDEO,
|
||||
source=Asset.Source.AI_GENERATED,
|
||||
category=Asset.Category.VIDEO_CLIP,
|
||||
)
|
||||
AssetFile.objects.create(
|
||||
asset=asset,
|
||||
object_key="quick/final.mp4",
|
||||
bucket="test",
|
||||
content_type="video/mp4",
|
||||
preview_url="https://cdn.example/quick.mp4",
|
||||
is_primary=True,
|
||||
)
|
||||
version = VideoSegmentVersion.objects.create(video_segment=segment, asset=asset, is_adopted=True)
|
||||
segment.adopted_version = version
|
||||
segment.status = VideoSegment.Status.SUCCEEDED
|
||||
segment.save(update_fields=["adopted_version", "status", "updated_at"])
|
||||
self.job.status = QuickCreateJob.Status.SUCCEEDED
|
||||
self.job.phase = QuickCreateJob.Phase.COMPLETE
|
||||
self.job.progress = 100
|
||||
self.job.save(update_fields=["status", "phase", "progress", "updated_at"])
|
||||
|
||||
data = QuickCreateJobSerializer(self.job).data
|
||||
self.assertEqual(data["phase_index"], 3)
|
||||
self.assertEqual(data["result"]["video_url"], "https://cdn.example/quick.mp4")
|
||||
self.assertEqual(data["result"]["duration_seconds"], 15)
|
||||
|
||||
@patch("apps.projects.tasks.poll_video_segment_task.apply_async")
|
||||
@patch("apps.projects.services.quick_create.submit_video_segment")
|
||||
def test_video_start_uses_quick_create_core_parameters(self, submit_video, schedule_poll):
|
||||
self.project.metadata = {
|
||||
"wizard": {
|
||||
"aspect_ratio": "16:9",
|
||||
"resolution": "1080p",
|
||||
"total_duration": 30,
|
||||
"video_model_config_id": "11111111-1111-4111-8111-111111111111",
|
||||
}
|
||||
}
|
||||
self.project.save(update_fields=["metadata", "updated_at"])
|
||||
self.project.video_segments.exclude(sort_order__lt=2).delete()
|
||||
|
||||
_start_videos(self.job)
|
||||
|
||||
self.assertEqual(submit_video.call_count, 2)
|
||||
for call in submit_video.call_args_list:
|
||||
self.assertEqual(call.kwargs["aspect_ratio"], "16:9")
|
||||
self.assertEqual(call.kwargs["resolution"], "1080p")
|
||||
self.assertEqual(call.kwargs["model_config_id"], "11111111-1111-4111-8111-111111111111")
|
||||
self.job.refresh_from_db()
|
||||
self.assertIn("30秒 16:9", self.job.message)
|
||||
@@ -834,6 +834,26 @@ class ProjectApiTests(TestCase):
|
||||
self.assertIn("[avoice][vo0][vo1]amix=inputs=3", graph)
|
||||
self.assertIn("[anarr][abgm]amix=inputs=2", graph)
|
||||
|
||||
def test_export_command_uses_selected_output_size(self):
|
||||
"""极速成片选横屏/清晰度后,多段合成必须沿用该尺寸,不能回退成固定竖屏。"""
|
||||
from apps.projects.services.export import _build_export_command
|
||||
|
||||
cmd = _build_export_command(
|
||||
n=1,
|
||||
specs=[{"ts": 0.0, "te": 15.0, "dur": 15.0}],
|
||||
starts=[0.0],
|
||||
total=15.0,
|
||||
transition="none",
|
||||
sub_overlays=[],
|
||||
bgm_name=None,
|
||||
bgm_volume=1.0,
|
||||
output_width=1920,
|
||||
output_height=1080,
|
||||
)
|
||||
graph = cmd[cmd.index("-filter_complex") + 1]
|
||||
self.assertIn("scale=1920:1080", graph)
|
||||
self.assertIn("pad=1920:1080", graph)
|
||||
|
||||
def test_save_timeline_updates_voiceover_offsets(self):
|
||||
"""拖动字幕块后保存:按 asset 回写句内起点 offset_ms,未拖动的句不受影响。"""
|
||||
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="VOFF")
|
||||
|
||||
@@ -44,9 +44,10 @@ from apps.assets.models import Asset, AssetFile
|
||||
from apps.assets.serializers import AssetFileSerializer
|
||||
from apps.assets.storage import TosStorage
|
||||
from apps.common.api import TeamScopedViewSetMixin
|
||||
from apps.common.celery_health import require_worker
|
||||
from apps.common.celery_health import require_worker, require_worker_task
|
||||
from apps.ai.generation_errors import classify_generation_error, public_error_for_task
|
||||
from apps.ai.video_digest import VideoDigestError, digest_project_video
|
||||
from apps.products.models import Product, ProductImage, ProductSellingPoint
|
||||
|
||||
from .models import (
|
||||
BaseAssetGroup,
|
||||
@@ -54,6 +55,7 @@ from .models import (
|
||||
ExportJob,
|
||||
Project,
|
||||
ProjectStage,
|
||||
QuickCreateJob,
|
||||
ScriptSegment,
|
||||
ScriptTemplate,
|
||||
ScriptVersion,
|
||||
@@ -72,6 +74,7 @@ from .serializers import (
|
||||
ExportJobSerializer,
|
||||
ProjectListSerializer,
|
||||
ProjectSerializer,
|
||||
QuickCreateJobSerializer,
|
||||
ScriptTemplateSerializer,
|
||||
ScriptVersionSerializer,
|
||||
StoryboardVersionSerializer,
|
||||
@@ -79,10 +82,15 @@ from .serializers import (
|
||||
is_playable_video,
|
||||
)
|
||||
from .services.export import run_export_job_in_thread
|
||||
from .services.pipeline import STAGE_ORDER
|
||||
from .services.pipeline import (
|
||||
adopt_script_version,
|
||||
finish_storyboard_stage,
|
||||
initialize_project_pipeline,
|
||||
sync_video_segments_to_script,
|
||||
)
|
||||
from .services.script_import import ScriptFileError, extract_script_text
|
||||
from .services.templates import build_template_fields, coerce_persona, coerce_template_combo, render_outline_text
|
||||
from .tasks import poll_video_segment_task
|
||||
from .tasks import advance_quick_create_task, poll_video_segment_task
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -101,7 +109,12 @@ class ServerSentEventRenderer(BaseRenderer):
|
||||
|
||||
def _store_uploaded_asset(*, team, user, upload, asset_type: str, category: str, name: str) -> Asset:
|
||||
"""把上传的文件落到 TOS,建 Asset+AssetFile(主文件)。供上传视频段 / 上传 BGM 复用。"""
|
||||
suffix = Path(upload.name).suffix.lower() or (".mp4" if asset_type == Asset.Type.VIDEO else ".mp3")
|
||||
fallback_suffix = {
|
||||
Asset.Type.IMAGE: ".png",
|
||||
Asset.Type.VIDEO: ".mp4",
|
||||
Asset.Type.AUDIO: ".mp3",
|
||||
}.get(asset_type, ".bin")
|
||||
suffix = Path(upload.name).suffix.lower() or fallback_suffix
|
||||
asset_id = uuid.uuid4()
|
||||
object_key = f"teams/{team.id}/uploads/{asset_id}{suffix}"
|
||||
stored = TosStorage().upload_fileobj(
|
||||
@@ -435,17 +448,271 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
by_status = {row["status"]: row["n"] for row in base.values("status").annotate(n=Count("id"))}
|
||||
return Response({"total": base.count(), "by_status": by_status})
|
||||
|
||||
@action(
|
||||
detail=False,
|
||||
methods=["post"],
|
||||
url_path="quick-create",
|
||||
parser_classes=[MultiPartParser, FormParser],
|
||||
)
|
||||
def quick_create(self, request):
|
||||
"""商品名称 + 1–9 张图 → 新建商品与项目,并启动完整自动生产流水线。"""
|
||||
require_worker_task("apps.projects.tasks.advance_quick_create_task")
|
||||
name = str(request.data.get("name") or "").strip()
|
||||
uploads = request.FILES.getlist("images") or request.FILES.getlist("images[]")
|
||||
source_product_id = str(request.data.get("source_product_id") or "").strip()
|
||||
requested_asset_ids = [
|
||||
str(value).strip()
|
||||
for value in (request.data.getlist("image_asset_ids") or request.data.getlist("image_asset_ids[]") or [])
|
||||
if str(value).strip()
|
||||
]
|
||||
if not name:
|
||||
return Response({"detail": "请填写商品名称"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(name) > 255:
|
||||
return Response({"detail": "商品名称不能超过255个字符"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if not uploads and not source_product_id:
|
||||
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(uploads) > 9:
|
||||
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
allowed_types = {"image/jpeg", "image/png", "image/webp"}
|
||||
for upload in uploads:
|
||||
if (upload.content_type or "").lower() not in allowed_types:
|
||||
return Response({"detail": "仅支持 JPG、PNG 或 WebP 商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if upload.size > 20 * 1024 * 1024:
|
||||
return Response({"detail": "单张商品图片不能超过20MB"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
aspect_ratio = str(request.data.get("aspect_ratio") or "9:16")
|
||||
resolution = str(request.data.get("resolution") or "720p").lower()
|
||||
try:
|
||||
total_duration = int(request.data.get("total_duration") or 15)
|
||||
except (TypeError, ValueError):
|
||||
total_duration = 0
|
||||
if aspect_ratio not in {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}:
|
||||
return Response({"detail": "请选择有效的视频比例"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if resolution not in {"480p", "720p", "1080p", "4k"}:
|
||||
return Response({"detail": "请选择有效的视频分辨率"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if total_duration not in {15, 30, 45, 60}:
|
||||
return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
text_model = get_default_model(ModelConfig.Capability.TEXT)
|
||||
image_model = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
requested_video_model_id = str(request.data.get("video_model_config_id") or "").strip()
|
||||
video_model = None
|
||||
if requested_video_model_id:
|
||||
try:
|
||||
video_model_uuid = uuid.UUID(requested_video_model_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response({"detail": "请选择有效的视频模型"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
video_model = (
|
||||
ModelConfig.objects.select_related("provider")
|
||||
.filter(
|
||||
id=video_model_uuid,
|
||||
capability=ModelConfig.Capability.VIDEO,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
provider__status="active",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
else:
|
||||
video_model = get_default_model(ModelConfig.Capability.VIDEO)
|
||||
|
||||
missing = [
|
||||
label
|
||||
for model, label in ((text_model, "文本"), (image_model, "图像"), (video_model, "视频"))
|
||||
if model is None
|
||||
]
|
||||
if missing:
|
||||
return Response(
|
||||
{"detail": f"当前缺少可用的{'、'.join(missing)}模型,请联系管理员配置"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
capabilities = dict((video_model.metadata or {}).get("capabilities") or {})
|
||||
supported_resolutions = set(capabilities.get("resolutions") or (video_model.metadata or {}).get("resolutions") or [])
|
||||
supported_ratios = set(capabilities.get("aspect_ratios") or [])
|
||||
supported_durations = set(capabilities.get("durations") or (video_model.metadata or {}).get("durations") or [])
|
||||
if supported_resolutions and resolution not in supported_resolutions:
|
||||
return Response(
|
||||
{"detail": f"{video_model.display_name} 不支持 {resolution.upper()},请更换清晰度或模型"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
if supported_ratios and aspect_ratio not in supported_ratios:
|
||||
return Response(
|
||||
{"detail": f"{video_model.display_name} 不支持 {aspect_ratio} 比例"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
# 极速成片把总时长拆成多个15秒镜头,视频模型只需支持单镜15秒。
|
||||
if supported_durations and 15 not in supported_durations:
|
||||
return Response(
|
||||
{"detail": f"{video_model.display_name} 不支持15秒单镜生成"},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
team = self.get_team()
|
||||
reused_assets = []
|
||||
if source_product_id:
|
||||
try:
|
||||
source_uuid = uuid.UUID(source_product_id)
|
||||
except (TypeError, ValueError):
|
||||
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
source = (
|
||||
Product.objects.filter(id=source_uuid, team=team)
|
||||
.prefetch_related("images__asset")
|
||||
.first()
|
||||
)
|
||||
if source is None:
|
||||
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
source_images = list(source.images.all())
|
||||
by_asset_id = {str(image.asset_id): image.asset for image in source_images}
|
||||
if requested_asset_ids:
|
||||
if any(asset_id not in by_asset_id for asset_id in requested_asset_ids):
|
||||
return Response({"detail": "找不到可复用的商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
reused_assets = [by_asset_id[asset_id] for asset_id in requested_asset_ids]
|
||||
else:
|
||||
reused_assets = [image.asset for image in source_images]
|
||||
if not reused_assets and not uploads:
|
||||
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(reused_assets) + len(uploads) > 9:
|
||||
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with transaction.atomic():
|
||||
assets = list(reused_assets)
|
||||
assets.extend(
|
||||
_store_uploaded_asset(
|
||||
team=team,
|
||||
user=request.user,
|
||||
upload=upload,
|
||||
asset_type=Asset.Type.IMAGE,
|
||||
category=Asset.Category.PRODUCT_IMAGE,
|
||||
name=upload.name or f"{name}-商品图{index + 1}",
|
||||
)
|
||||
for index, upload in enumerate(uploads)
|
||||
)
|
||||
if not assets:
|
||||
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
product = Product.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
title=name,
|
||||
business_type=Product.BusinessType.ECOMMERCE,
|
||||
cover_asset=assets[0],
|
||||
)
|
||||
ProductImage.objects.bulk_create(
|
||||
[
|
||||
ProductImage(
|
||||
product=product,
|
||||
asset=asset,
|
||||
sort_order=index,
|
||||
is_primary=index == 0,
|
||||
)
|
||||
for index, asset in enumerate(assets)
|
||||
]
|
||||
)
|
||||
project = Project.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
product=product,
|
||||
name=f"{name} · 极速成片",
|
||||
status=Project.Status.SCRIPTING,
|
||||
current_stage=ProjectStage.Stage.SCRIPT,
|
||||
metadata={
|
||||
"quick_create": True,
|
||||
"wizard": {
|
||||
"aspect_ratio": aspect_ratio,
|
||||
"resolution": resolution,
|
||||
"total_duration": total_duration,
|
||||
"video_model_config_id": str(video_model.id),
|
||||
"video_model_name": video_model.name,
|
||||
"video_model_label": video_model.display_name,
|
||||
"presentation_format": "oral",
|
||||
"video_structure": "pain",
|
||||
"persona": "reviewer",
|
||||
},
|
||||
},
|
||||
)
|
||||
initialize_project_pipeline(project, placeholder_segments=max(1, total_duration // 15))
|
||||
if not product.selling_points.exists():
|
||||
ProductSellingPoint.objects.create(
|
||||
product=product,
|
||||
title=name,
|
||||
detail="由极速成片根据商品名称自动填写",
|
||||
sort_order=0,
|
||||
)
|
||||
job = QuickCreateJob.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
project=project,
|
||||
status=QuickCreateJob.Status.QUEUED,
|
||||
phase=QuickCreateJob.Phase.PRODUCT,
|
||||
progress=0,
|
||||
message="等待开始极速成片",
|
||||
)
|
||||
|
||||
try:
|
||||
advance_quick_create_task.apply_async(args=[str(job.id)], queue="airshelf.quick")
|
||||
except Exception as exc: # noqa: BLE001 — broker 极小窗口失败也必须给任务落终态
|
||||
from .services.quick_create import fail_quick_create
|
||||
|
||||
logger.exception("quick create enqueue failed for job %s", job.id)
|
||||
fail_quick_create(job, "生成队列暂时不可用,请稍后重试", internal_error=str(exc))
|
||||
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
job = self._quick_job_queryset().get(id=job.id)
|
||||
return Response(QuickCreateJobSerializer(job).data, status=status.HTTP_202_ACCEPTED)
|
||||
|
||||
def _quick_job_queryset(self):
|
||||
return (
|
||||
QuickCreateJob.objects.select_related("project__product", "project__timeline")
|
||||
.prefetch_related(
|
||||
"project__product__images__asset__files",
|
||||
"project__script_versions",
|
||||
"project__base_asset_groups",
|
||||
"project__storyboard_shots__adopted_version__asset__files",
|
||||
"project__video_segments__adopted_version__asset__files",
|
||||
"project__timeline__export_jobs__output_asset__files",
|
||||
)
|
||||
.filter(team=self.get_team())
|
||||
)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path=r"quick-create-status/(?P<job_id>[^/.]+)")
|
||||
def quick_create_status(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
from .services.quick_create import recover_quick_create
|
||||
|
||||
try:
|
||||
recover_quick_create(job)
|
||||
except Exception: # noqa: BLE001 — 恢复失败不能把进度接口打成 500
|
||||
logger.exception("quick create recover failed for job %s", job.id)
|
||||
job = self._quick_job_queryset().get(id=job.id)
|
||||
return Response(QuickCreateJobSerializer(job).data)
|
||||
|
||||
@action(detail=False, methods=["post"], url_path=r"quick-create-cancel/(?P<job_id>[^/.]+)")
|
||||
def quick_create_cancel(self, request, job_id=None):
|
||||
job = self._quick_job_queryset().filter(id=job_id).first()
|
||||
if job is None:
|
||||
return Response({"detail": "极速成片任务不存在"}, status=status.HTTP_404_NOT_FOUND)
|
||||
if job.status == QuickCreateJob.Status.SUCCEEDED:
|
||||
return Response({"detail": "已完成的任务不能取消"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
from .services.quick_create import cancel_quick_create
|
||||
|
||||
cancel_quick_create(job)
|
||||
job = self._quick_job_queryset().get(id=job.id)
|
||||
return Response(QuickCreateJobSerializer(job).data)
|
||||
|
||||
@action(detail=False, methods=["get"], url_path="quick-create-history")
|
||||
def quick_create_history(self, request):
|
||||
jobs = self._quick_job_queryset().filter(status=QuickCreateJob.Status.SUCCEEDED).order_by("-created_at")
|
||||
return Response({
|
||||
"count": jobs.count(),
|
||||
"results": QuickCreateJobSerializer(jobs[:30], many=True).data,
|
||||
})
|
||||
|
||||
@transaction.atomic
|
||||
def perform_create(self, serializer):
|
||||
project = serializer.save(team=self.get_team(), created_by=self.request.user)
|
||||
self._apply_wizard_template(project)
|
||||
for stage in STAGE_ORDER:
|
||||
ProjectStage.objects.create(project=project, stage=stage)
|
||||
# 先铺 4 段占位;真实段数与每段时长在采用脚本时由 _sync_video_segments_to_script 收口
|
||||
for index in range(4):
|
||||
VideoSegment.objects.create(
|
||||
project=project, sort_order=index, target_duration_seconds=SEGMENT_DURATION_MAX
|
||||
)
|
||||
# 先铺 4 段占位;真实段数与每段时长在采用脚本时收口。
|
||||
initialize_project_pipeline(project, placeholder_segments=4)
|
||||
|
||||
def _apply_wizard_template(self, project) -> None:
|
||||
"""新建向导选了套路模板 → 后端按模板真值回填 metadata.wizard,并计一次使用。
|
||||
@@ -583,19 +850,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project = self.get_object()
|
||||
script_id = request.data.get("script_version_id")
|
||||
script = ScriptVersion.objects.select_for_update().get(project=project, id=script_id)
|
||||
ScriptVersion.objects.filter(project=project).update(is_adopted=False)
|
||||
script.is_adopted = True
|
||||
script.save(update_fields=["is_adopted", "updated_at"])
|
||||
# 采用脚本时把视频片段数对齐到这版分镜数:用户常在「采用前」就增删分镜,
|
||||
# 那些编辑因 _sync 的 is_adopted 闸而未同步到 VideoSegment(项目创建时固定铺了 4 段),
|
||||
# 不在此收口的话视频步骤会一直停在 4 段,与故事板/分镜数对不上。已生成的段绝不动。
|
||||
self._sync_video_segments_to_script(project, script)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
project.current_stage = ProjectStage.Stage.BASE_ASSETS
|
||||
project.status = Project.Status.ASSETING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
adopt_script_version(project, script)
|
||||
return Response(ScriptVersionSerializer(script).data)
|
||||
|
||||
@action(detail=True, methods=["post"], url_path="extract-entities")
|
||||
@@ -900,47 +1155,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
时长同步:脚本镜主流程固定 15 秒,而出片、计价、时间线读的都是
|
||||
VideoSegment.target_duration_seconds。不同步的话脚本写了 15 秒、出片仍按旧默认跑。
|
||||
已出片的段不改时长——改了会跟已渲染的成片对不上。"""
|
||||
if not script.is_adopted:
|
||||
return
|
||||
script_segments = list(script.segments.order_by("sort_order"))
|
||||
target = len(script_segments)
|
||||
segments = list(project.video_segments.order_by("sort_order"))
|
||||
while len(segments) > target:
|
||||
tail = segments[-1]
|
||||
if tail.status == VideoSegment.Status.NOT_STARTED and not tail.versions.exists():
|
||||
tail.delete()
|
||||
segments.pop()
|
||||
else:
|
||||
break
|
||||
next_order = (segments[-1].sort_order + 1) if segments else 0
|
||||
for _ in range(target - len(segments)):
|
||||
index = len(segments)
|
||||
seconds = (
|
||||
script_segments[index].duration_seconds
|
||||
if index < target
|
||||
else SEGMENT_DURATION_MAX
|
||||
)
|
||||
segments.append(
|
||||
VideoSegment.objects.create(
|
||||
project=project, sort_order=next_order, target_duration_seconds=seconds
|
||||
)
|
||||
)
|
||||
next_order += 1
|
||||
|
||||
# 已存在的段:只对「还没出过片」的回填脚本时长,已渲染的保持原样
|
||||
stale: list[VideoSegment] = []
|
||||
for index, video_segment in enumerate(segments):
|
||||
if index >= target:
|
||||
break
|
||||
seconds = script_segments[index].duration_seconds
|
||||
if not seconds or video_segment.target_duration_seconds == seconds:
|
||||
continue
|
||||
if video_segment.status == VideoSegment.Status.SUCCEEDED or video_segment.versions.exists():
|
||||
continue
|
||||
video_segment.target_duration_seconds = seconds
|
||||
stale.append(video_segment)
|
||||
if stale:
|
||||
VideoSegment.objects.bulk_update(stale, ["target_duration_seconds"])
|
||||
sync_video_segments_to_script(project, script)
|
||||
|
||||
def _sync_storyboard_shots_to_script(self, project: Project, script: ScriptVersion) -> None:
|
||||
"""采用版分镜数变化时,同步 StoryboardShot 数量(与视频段同策略,按位置对齐):
|
||||
@@ -1133,16 +1348,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
project = self.get_object()
|
||||
result = poll_storyboard(project=project, user=request.user)
|
||||
if result.get("status") == "succeeded":
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.STORYBOARD)
|
||||
stage.status = ProjectStage.Status.SUCCEEDED
|
||||
stage.save(update_fields=["status", "updated_at"])
|
||||
# 进入视频阶段前再收口一次视频片段数,确保与采用版分镜数一致(已生成的段不动)
|
||||
adopted_script = project.script_versions.filter(is_adopted=True).order_by("-created_at").first()
|
||||
if adopted_script is not None:
|
||||
self._sync_video_segments_to_script(project, adopted_script)
|
||||
project.current_stage = ProjectStage.Stage.VIDEO
|
||||
project.status = Project.Status.VIDEOING
|
||||
project.save(update_fields=["current_stage", "status", "updated_at"])
|
||||
finish_storyboard_stage(project)
|
||||
http_status = status.HTTP_200_OK if result.get("status") == "succeeded" else status.HTTP_202_ACCEPTED
|
||||
return Response(result, status=http_status)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user