完成极速成片
This commit is contained in:
@@ -81,7 +81,7 @@ PERSONA_BRIEFS: dict[str, str] = {
|
||||
"urban": "25–32岁都市白领,工位或下班回家,说话像跟同事吐槽,不要主播腔",
|
||||
"bestie": "闺蜜分享口吻,带点兴奋,爱用「你懂的」「我跟你讲」",
|
||||
"ceo": "利落、判断句、少形容词,像拍板不是带货",
|
||||
"reviewer": "先讲怎么试的再给结论,允许提一个小缺点才可信",
|
||||
"reviewer": "先讲怎么试的再给结论;可信度来自过程、适用边界或意外发现,不编造小缺点",
|
||||
"mom": "带娃/家务间隙,讲省事,孩子或家人能沾边",
|
||||
"genz": "宿舍或通勤,短句,像给朋友发语音条",
|
||||
}
|
||||
@@ -103,10 +103,18 @@ _SHOT_SIZE_MARKERS = (
|
||||
"手持", "跟拍", "俯拍", "仰拍", "推近", "拉远", "摇",
|
||||
)
|
||||
_MINOR_CHARACTER_RE = re.compile(
|
||||
r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)"
|
||||
r"|(?:[0-9零一二三四五六七八九十]{1,3}\s*岁)",
|
||||
r"(?:婴儿|宝宝|宝贝|幼儿|儿童|小孩|小朋友|未成年|男童|女童|baby|toddler|infant)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_NUMERIC_AGE_RE = re.compile(r"(?<!\d)(\d{1,2})\s*岁")
|
||||
_CHINESE_MINOR_AGE_RE = re.compile(r"(?:[零一二三四五六七八九]岁|十岁|十[一二三四五六七]岁)")
|
||||
_CANNED_DEFECT_RE = re.compile(r"唯一(?:的)?(?:小)?(?:缺点|不足|遗憾)")
|
||||
_NATURAL_TURN_PHRASES = (
|
||||
"我会额外留意的使用细节",
|
||||
"换个场景更能看出差别的地方",
|
||||
"下单前可以先确认的一点",
|
||||
"实测时我更在意的细节",
|
||||
)
|
||||
|
||||
|
||||
_FORMAT_KEY_BY_LABEL = {label: key for key, label in PRESENTATION_FORMATS.items()}
|
||||
@@ -231,7 +239,10 @@ def format_visual_beats(beats: list[tuple[int, int, str]]) -> str:
|
||||
|
||||
def _is_minor_character(name: str, visual_prompt: str) -> bool:
|
||||
"""角色基础资产不得是未成年人,避免真人生图审核拦截与儿童肖像风险。"""
|
||||
return bool(_MINOR_CHARACTER_RE.search(f"{name or ''} {visual_prompt or ''}"))
|
||||
text = f"{name or ''} {visual_prompt or ''}"
|
||||
if _MINOR_CHARACTER_RE.search(text) or _CHINESE_MINOR_AGE_RE.search(text):
|
||||
return True
|
||||
return any(int(age) < 18 for age in _NUMERIC_AGE_RE.findall(text))
|
||||
|
||||
|
||||
def _product_only_visual(duration: int) -> str:
|
||||
@@ -253,6 +264,14 @@ def _product_only_visual(duration: int) -> str:
|
||||
return "【本镜任务】用商品本身传达关键信息,不出现人物。\n【声音】旁白继续,画面不出现未成年人。\n【画面内容】\n" + format_visual_beats(beats)
|
||||
|
||||
|
||||
def _replace_canned_defect_phrase(text: str) -> str:
|
||||
"""不改模型给出的事实,只替换会让测评显得千篇一律的“唯一缺点”话术。"""
|
||||
if not text or not _CANNED_DEFECT_RE.search(text):
|
||||
return text
|
||||
variant = _NATURAL_TURN_PHRASES[sum(map(ord, text)) % len(_NATURAL_TURN_PHRASES)]
|
||||
return _CANNED_DEFECT_RE.sub(variant, text)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------- #
|
||||
# skill 加载(缓存)
|
||||
# --------------------------------------------------------------------------- #
|
||||
@@ -613,6 +632,11 @@ def build_agent_messages(
|
||||
f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写,"
|
||||
f"不要串成别的结构的套话。\n"
|
||||
)
|
||||
authenticity_line = (
|
||||
"【真实感转折】禁止使用「唯一缺点/唯一不足/唯一的小遗憾」这类模板句,也不要为了显得真实而编造缺点。"
|
||||
"每版从以下角度自然选一个推进:测试过程里的意外发现、适用人群的边界、不同使用场景的反差、"
|
||||
"一个可观察的细节、或使用习惯建议;必须由商品资料或画面可观察事实支持,不能每镜重复同一种。\n"
|
||||
)
|
||||
head = (
|
||||
f"【画幅】{aspect_ratio}\n"
|
||||
f"【表现形式】{PRESENTATION_FORMATS[fmt]}(套路见 playbooks/format-{fmt}.md,已加载)\n"
|
||||
@@ -623,6 +647,7 @@ def build_agent_messages(
|
||||
f"{beats_line}"
|
||||
f"{structure_line}"
|
||||
f"{combo_line}"
|
||||
f"{authenticity_line}"
|
||||
f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}"
|
||||
)
|
||||
if mode == "revise" and base_draft and target_index is not None:
|
||||
@@ -1196,7 +1221,7 @@ def normalize_draft(
|
||||
if not line:
|
||||
continue
|
||||
sp = d.get("speaker")
|
||||
dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line})
|
||||
dialogue.append({"speaker": sp if sp in valid_ids else None, "line": _replace_canned_defect_phrase(line)})
|
||||
# 旁白:结构化对白/lines 优先,其次整句字符串 dialogue,再退到通用字段解析(narration/voiceover/caption/字幕…)
|
||||
narration = ""
|
||||
if isinstance(raw_dialogue, str) and raw_dialogue.strip():
|
||||
@@ -1205,8 +1230,9 @@ def normalize_draft(
|
||||
narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音
|
||||
if not narration:
|
||||
narration = _pick_field(seg, _NARRATION_EXACT, _NARRATION_FUZZY)
|
||||
narration = _replace_canned_defect_phrase(narration)
|
||||
# 画面:优先收成秒级分镜;beats 数组会折进 visual,下游故事板/视频直接读这一段。
|
||||
visual = compose_segment_visual(seg)
|
||||
visual = _replace_canned_defect_phrase(compose_segment_visual(seg))
|
||||
norm_segments.append(
|
||||
{
|
||||
"index": i,
|
||||
@@ -1255,7 +1281,7 @@ def normalize_draft(
|
||||
elif index < len(segments) and isinstance(segments[index], dict):
|
||||
composed = compose_segment_visual(segments[index], seconds)
|
||||
if composed:
|
||||
norm["visual"] = composed
|
||||
norm["visual"] = _replace_canned_defect_phrase(composed)
|
||||
|
||||
draft["segments"] = norm_segments
|
||||
draft["segment_count"] = len(norm_segments)
|
||||
|
||||
@@ -2217,10 +2217,20 @@ def generate_person_triview(*, project, user, portrait_asset) -> AITask:
|
||||
ref_url = _asset_preview_url(portrait_asset)
|
||||
# 人物三视图提示词:正文可在 admin「提示词」页改(无占位符)
|
||||
tri_prompt = render_prompt("person_triview", THREE_VIEW_PROMPT)
|
||||
portrait_label = ""
|
||||
for group in project.base_asset_groups.filter(kind=BaseAssetGroup.Kind.PERSON):
|
||||
meta = group.metadata or {}
|
||||
if meta.get("triview_of"):
|
||||
continue
|
||||
candidates = [str(value) for value in (group.candidate_assets or [])]
|
||||
if str(group.adopted_asset_id or "") == asset_key or asset_key in candidates:
|
||||
portrait_label = str(meta.get("label") or "")
|
||||
break
|
||||
payload = {
|
||||
"model": model_config.name,
|
||||
"prompt": tri_prompt,
|
||||
"kind": "person",
|
||||
"label": portrait_label or str(portrait_asset.name or ""),
|
||||
"triview_of": asset_key,
|
||||
"reference_image": ref_url,
|
||||
"model_routing_v1": True,
|
||||
@@ -3234,6 +3244,26 @@ def collect_video_review_blockers(project, only_segment: "VideoSegment | None" =
|
||||
return blockers
|
||||
|
||||
|
||||
_VIDEO_INFLIGHT_STATUSES = {
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
}
|
||||
|
||||
|
||||
def video_segment_has_inflight_task(video_segment: VideoSegment) -> bool:
|
||||
"""同一镜头是否已有在途视频任务。防止极速成片并发推进把同一段提交两次。"""
|
||||
segment_id = str(video_segment.id)
|
||||
tasks = AITask.objects.filter(
|
||||
project_id=video_segment.project_id,
|
||||
task_type=AITask.Type.VIDEO_SEGMENT,
|
||||
status__in=_VIDEO_INFLIGHT_STATUSES,
|
||||
).only("request_payload")
|
||||
return any(str((task.request_payload or {}).get("video_segment_id") or "") == segment_id for task in tasks)
|
||||
|
||||
|
||||
def submit_video_segment(
|
||||
*,
|
||||
video_segment: VideoSegment,
|
||||
@@ -3261,7 +3291,17 @@ def submit_video_segment(
|
||||
model_config = get_default_model(ModelConfig.Capability.VIDEO)
|
||||
if model_config is None:
|
||||
raise ValueError("no active video model configured")
|
||||
project = video_segment.project
|
||||
|
||||
with transaction.atomic():
|
||||
video_segment = VideoSegment.objects.select_for_update().select_related("project").get(pk=video_segment.pk)
|
||||
project = video_segment.project
|
||||
if video_segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.SUCCEEDED}:
|
||||
return None
|
||||
if video_segment_has_inflight_task(video_segment):
|
||||
return None
|
||||
if video_segment.status != VideoSegment.Status.QUEUED:
|
||||
video_segment.status = VideoSegment.Status.QUEUED
|
||||
video_segment.save(update_fields=["status", "updated_at"])
|
||||
|
||||
# 衔接:按 sort_order 把视频段绑到对应脚本镜,并织出跟住该镜的提示词。
|
||||
scene = None
|
||||
@@ -3289,31 +3329,38 @@ def submit_video_segment(
|
||||
references=[],
|
||||
team=project.team,
|
||||
)
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
task_type=AITask.Type.VIDEO_SEGMENT,
|
||||
model_config=model_config,
|
||||
quote=quote,
|
||||
reserve_amount=video_reserve_amount(quote.points),
|
||||
request_payload={
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"prompt": final_prompt,
|
||||
"duration": video_segment.target_duration_seconds,
|
||||
"ratio": aspect_ratio,
|
||||
"resolution": resolution,
|
||||
"estimated_tokens": est_tokens,
|
||||
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律)
|
||||
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
||||
"video_segment_id": str(video_segment.id),
|
||||
"reference_images": reference_images,
|
||||
"model_routing_v1": True,
|
||||
},
|
||||
)
|
||||
# 提交尝试的实际平台成本由 AIModelAttempt 累加;成片后再用实际模型的 usage true-up 覆盖。
|
||||
task.base_cost = Decimal("0")
|
||||
task.save(update_fields=["base_cost", "updated_at"])
|
||||
with transaction.atomic():
|
||||
video_segment = VideoSegment.objects.select_for_update().select_related("project").get(pk=video_segment.pk)
|
||||
project = video_segment.project
|
||||
if video_segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.SUCCEEDED}:
|
||||
return None
|
||||
if video_segment_has_inflight_task(video_segment):
|
||||
return None
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
task_type=AITask.Type.VIDEO_SEGMENT,
|
||||
model_config=model_config,
|
||||
quote=quote,
|
||||
reserve_amount=video_reserve_amount(quote.points),
|
||||
request_payload={
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"prompt": final_prompt,
|
||||
"duration": video_segment.target_duration_seconds,
|
||||
"ratio": aspect_ratio,
|
||||
"resolution": resolution,
|
||||
"estimated_tokens": est_tokens,
|
||||
# 团队价格系数快照:按实结算用它,中途改价不影响在途任务(jimeng 同款纪律)
|
||||
"price_multiplier": quote.meta.get("price_multiplier", "1"),
|
||||
"video_segment_id": str(video_segment.id),
|
||||
"reference_images": reference_images,
|
||||
"model_routing_v1": True,
|
||||
},
|
||||
)
|
||||
# 提交尝试的实际平台成本由 AIModelAttempt 累加;成片后再用实际模型的 usage true-up 覆盖。
|
||||
task.base_cost = Decimal("0")
|
||||
task.save(update_fields=["base_cost", "updated_at"])
|
||||
try:
|
||||
routed = execute_routed_video_submit(
|
||||
task=task,
|
||||
|
||||
@@ -159,6 +159,36 @@ class NormalizeDurationTests(SimpleTestCase):
|
||||
self.assertIn("不出现人物", draft["segments"][0]["visual"])
|
||||
self.assertNotIn("宝宝坐", draft["segments"][0]["visual"])
|
||||
|
||||
def test_adult_student_is_not_mistaken_for_a_minor(self):
|
||||
import json
|
||||
|
||||
raw = {
|
||||
"entities": [
|
||||
{"id": "student", "type": "character", "name": "20岁大学生", "visual_prompt": "20岁成年学生,宿舍使用场景"},
|
||||
{"id": "scene", "type": "scene", "name": "宿舍", "visual_prompt": "宿舍桌面"},
|
||||
],
|
||||
"segments": [{"role": "钩子", "visual": "大学生在宿舍展示商品。", "entity_refs": ["student", "scene"]}],
|
||||
}
|
||||
draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15)
|
||||
self.assertIn("student", [entity["id"] for entity in draft["entities"]])
|
||||
self.assertIn("student", draft["segments"][0]["entity_refs"])
|
||||
self.assertNotIn("不出现人物", draft["segments"][0]["visual"])
|
||||
|
||||
def test_canned_only_defect_phrase_is_replaced_without_dropping_content(self):
|
||||
import json
|
||||
|
||||
raw = {
|
||||
"segments": [{
|
||||
"role": "卖点", "narration": "唯一缺点是瓶口需要慢一点倒,但质地很舒服。",
|
||||
"visual": "近景展示唯一小缺点是瓶口开口较窄。",
|
||||
}],
|
||||
}
|
||||
draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15)
|
||||
joined = draft["segments"][0]["narration"] + draft["segments"][0]["visual"]
|
||||
self.assertNotIn("唯一缺点", joined)
|
||||
self.assertNotIn("唯一小缺点", joined)
|
||||
self.assertIn("瓶口", joined)
|
||||
|
||||
|
||||
class ProductFactTests(SimpleTestCase):
|
||||
def test_missing_selling_point_is_rejected(self):
|
||||
@@ -268,6 +298,8 @@ class PromptAssemblyTests(SimpleTestCase):
|
||||
self.assertIn("跟同事吐槽", user)
|
||||
self.assertIn("禁止一句 20 字收工", user)
|
||||
self.assertIn("秒级分镜", user)
|
||||
self.assertIn("禁止使用「唯一缺点", user)
|
||||
self.assertIn("测试过程里的意外发现", user)
|
||||
|
||||
def test_video_digest_prompt_maps_camera_and_sound(self):
|
||||
digest = (
|
||||
|
||||
@@ -141,6 +141,20 @@ class VideoSegmentRoutingTests(TestCase):
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 0)
|
||||
|
||||
def test_second_submit_does_not_create_another_task(self):
|
||||
primary = self.model(self.provider("video-primary-once", 20), "video-primary-once", outbound=True, default=True)
|
||||
provider = self._provider_for(primary)
|
||||
provider.create_video_task.return_value = {"id": "remote-once", "status": "queued"}
|
||||
|
||||
first = self._submit()
|
||||
second = submit_video_segment(video_segment=self.segment, user=self.user, prompt="test")
|
||||
|
||||
self.assertIsNone(second)
|
||||
self.assertEqual(AITask.objects.filter(task_type=AITask.Type.VIDEO_SEGMENT, team=self.team).count(), 1)
|
||||
self.assertEqual(provider.create_video_task.call_count, 1)
|
||||
first.refresh_from_db()
|
||||
self.assertEqual(first.status, AITask.Status.SUBMITTED)
|
||||
|
||||
@patch("apps.ai.services._store_generated_media")
|
||||
def test_retry_then_dynamic_fallback_polls_actual_model_and_charges_once(self, store_media):
|
||||
primary = self.model(self.provider("video-primary-fail", 20), "video-primary-fail", outbound=True, price=46, default=True)
|
||||
|
||||
@@ -20,10 +20,10 @@ from apps.ai.services import (
|
||||
create_export_job,
|
||||
generate_base_asset,
|
||||
generate_person_triview,
|
||||
get_default_model,
|
||||
poll_storyboard,
|
||||
submit_storyboard,
|
||||
submit_video_segment,
|
||||
video_segment_has_inflight_task,
|
||||
)
|
||||
from apps.assets import assets_client
|
||||
from apps.assets.models import Asset
|
||||
@@ -43,11 +43,13 @@ from apps.projects.services.pipeline import (
|
||||
adopt_script_version,
|
||||
finish_storyboard_stage,
|
||||
finish_video_stage,
|
||||
sync_video_segments_to_script,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
POLL_DELAY_SECONDS = 10
|
||||
QUICK_SCRIPT_MODEL_NAME = "doubao-seed-2-1-pro-260628"
|
||||
SCRIPT_POLL_SECONDS = 5
|
||||
SCRIPT_TIMEOUT = timedelta(minutes=4)
|
||||
SCRIPT_STOLEN_AFTER = timedelta(seconds=25)
|
||||
@@ -70,6 +72,24 @@ def _is_finished(job: QuickCreateJob) -> bool:
|
||||
return job.status in _FINISHED_STATUSES
|
||||
|
||||
|
||||
def get_quick_script_model() -> ModelConfig | None:
|
||||
"""极速成片固定复用专业创作的豆包 Seed 2.1 Pro 脚本模型。
|
||||
|
||||
不回退到默认文本模型,避免默认配置切到 DeepSeek 后两条创作链路的成片质量不一致。
|
||||
"""
|
||||
return (
|
||||
ModelConfig.objects.select_related("provider")
|
||||
.filter(
|
||||
name=QUICK_SCRIPT_MODEL_NAME,
|
||||
capability=ModelConfig.Capability.TEXT,
|
||||
status=ModelConfig.Status.ACTIVE,
|
||||
provider__status="active",
|
||||
)
|
||||
.order_by("created_at")
|
||||
.first()
|
||||
)
|
||||
|
||||
|
||||
def _quick_settings(project: Project) -> dict:
|
||||
wizard = dict((project.metadata or {}).get("wizard") or {})
|
||||
return {
|
||||
@@ -97,16 +117,22 @@ TRANSIENT_RETRY_LIMIT = 8
|
||||
def _safe_error(exc: Exception) -> str:
|
||||
raw = str(exc or "").strip()
|
||||
lower = raw.lower()
|
||||
if QUICK_SCRIPT_MODEL_NAME in lower:
|
||||
return "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"
|
||||
if "insufficient credit" in lower or "额度不足" in raw:
|
||||
return "可用积分不足,极速成片已暂停"
|
||||
if "no active" in lower or "not configured" in lower or "没有可用" in raw:
|
||||
return "当前缺少可用的生成模型,请联系管理员配置"
|
||||
if "review" in lower or "审核" in raw:
|
||||
return "生成素材未通过审核,请进入专业模式调整后重试"
|
||||
if _is_retryable_exc(exc):
|
||||
return "脚本已保留,后续步骤遇到网络波动。点重试会从上次进度继续"
|
||||
return "极速成片暂未完成,请稍后重试或进入专业模式查看"
|
||||
|
||||
|
||||
def _is_retryable_exc(exc: Exception) -> bool:
|
||||
if isinstance(exc, TimeoutError):
|
||||
return True
|
||||
text = str(exc or "").lower()
|
||||
return any(
|
||||
token in text
|
||||
@@ -123,6 +149,10 @@ def _is_retryable_exc(exc: Exception) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _transient_internal(job: QuickCreateJob) -> bool:
|
||||
return _is_retryable_exc(Exception(str((job.metadata or {}).get("internal_error") or "")))
|
||||
|
||||
|
||||
def _videos_have_started(job: QuickCreateJob) -> bool:
|
||||
if (job.metadata or {}).get("video_started"):
|
||||
return True
|
||||
@@ -131,9 +161,12 @@ def _videos_have_started(job: QuickCreateJob) -> bool:
|
||||
|
||||
def _should_mark_project_failed(job: QuickCreateJob) -> bool:
|
||||
"""编排在「还没申请生成视频」时超时,不能把整个项目打成失败。"""
|
||||
if job.phase != QuickCreateJob.Phase.PRODUCTION:
|
||||
return True
|
||||
return _videos_have_started(job)
|
||||
if job.phase == QuickCreateJob.Phase.PRODUCTION:
|
||||
return _videos_have_started(job)
|
||||
# 脚本已经落库时,资产/脚本阶段的网络抖动不要把整个项目打成失败。
|
||||
if job.phase in {QuickCreateJob.Phase.SCRIPT, QuickCreateJob.Phase.ASSETS} and _adopted_script(job.project) is not None:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def restore_false_failed_quick_creates(team) -> None:
|
||||
@@ -241,9 +274,9 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
user = job.created_by or project.created_by
|
||||
if user is None:
|
||||
raise ValueError("极速成片任务缺少创建人")
|
||||
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
||||
model_config = get_quick_script_model()
|
||||
if model_config is None:
|
||||
raise ValueError("no active text model configured")
|
||||
raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured")
|
||||
|
||||
error_detail = ""
|
||||
stream = stream_script_agent(
|
||||
@@ -452,11 +485,8 @@ def _start_base_assets(job: QuickCreateJob) -> None:
|
||||
with transaction.atomic():
|
||||
job = QuickCreateJob.objects.select_for_update().select_related("project").get(id=job.id)
|
||||
metadata = dict(job.metadata or {})
|
||||
if metadata.get("base_asset_task_ids") or metadata.get("assets_started"):
|
||||
if metadata.get("base_asset_task_ids"):
|
||||
return
|
||||
metadata["assets_started"] = True
|
||||
job.metadata = metadata
|
||||
job.save(update_fields=["metadata", "updated_at"])
|
||||
entities = _ensure_fallback_entities(job.project)
|
||||
specs = [
|
||||
(BaseAssetGroup.Kind.PRODUCT, job.project.product.title, job.project.product.title),
|
||||
@@ -477,6 +507,7 @@ def _start_base_assets(job: QuickCreateJob) -> None:
|
||||
task_ids.append(str(task.id))
|
||||
metadata = dict(job.metadata or {})
|
||||
metadata["base_asset_task_ids"] = task_ids
|
||||
metadata["assets_started"] = True
|
||||
_save_job(job, metadata=metadata, message="正在生成商品、模特与场景资产", progress=52)
|
||||
stage, _ = ProjectStage.objects.get_or_create(project=job.project, stage=ProjectStage.Stage.BASE_ASSETS)
|
||||
stage.status = ProjectStage.Status.RUNNING
|
||||
@@ -622,14 +653,33 @@ def _reviews_ready(job: QuickCreateJob) -> bool | None:
|
||||
def _start_videos(job: QuickCreateJob) -> None:
|
||||
from apps.projects.tasks import poll_video_segment_task
|
||||
|
||||
adopted = _adopted_script(job.project)
|
||||
if adopted is not None:
|
||||
sync_video_segments_to_script(job.project, adopted)
|
||||
|
||||
settings = _quick_settings(job.project)
|
||||
segments = list(job.project.video_segments.order_by("sort_order"))
|
||||
claimed_ids: list = []
|
||||
with transaction.atomic():
|
||||
segments = list(
|
||||
VideoSegment.objects.select_for_update()
|
||||
.filter(project=job.project)
|
||||
.order_by("sort_order")
|
||||
)
|
||||
for segment in segments:
|
||||
if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.SUCCEEDED}:
|
||||
continue
|
||||
if video_segment_has_inflight_task(segment):
|
||||
continue
|
||||
if segment.status == VideoSegment.Status.QUEUED and timezone.now() - segment.updated_at < timedelta(minutes=2):
|
||||
continue
|
||||
segment.status = VideoSegment.Status.QUEUED
|
||||
segment.save(update_fields=["status", "updated_at"])
|
||||
claimed_ids.append(segment.id)
|
||||
|
||||
submitted = 0
|
||||
last_error: Exception | None = None
|
||||
for segment in segments:
|
||||
if segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}:
|
||||
submitted += 1
|
||||
continue
|
||||
for segment_id in claimed_ids:
|
||||
segment = VideoSegment.objects.get(id=segment_id)
|
||||
try:
|
||||
submit_video_segment(
|
||||
video_segment=segment,
|
||||
@@ -644,13 +694,22 @@ def _start_videos(job: QuickCreateJob) -> None:
|
||||
except Exception as exc: # noqa: BLE001 — 单镜提交失败下一轮再试,不把整单打断
|
||||
last_error = exc
|
||||
logger.warning("quick create job %s failed to start video %s: %s", job.id, segment.sort_order, exc)
|
||||
if segment.status == VideoSegment.Status.QUEUED and not video_segment_has_inflight_task(segment):
|
||||
segment.status = VideoSegment.Status.NOT_STARTED
|
||||
segment.save(update_fields=["status", "updated_at"])
|
||||
if not _is_retryable_exc(exc):
|
||||
raise
|
||||
break
|
||||
metadata = dict(job.metadata or {})
|
||||
if last_error is not None:
|
||||
metadata["internal_error"] = str(last_error)[:2000]
|
||||
if submitted >= len(segments) and segments:
|
||||
segments = list(job.project.video_segments.order_by("sort_order"))
|
||||
started = all(
|
||||
segment.status in {VideoSegment.Status.RUNNING, VideoSegment.Status.QUEUED, VideoSegment.Status.SUCCEEDED}
|
||||
or video_segment_has_inflight_task(segment)
|
||||
for segment in segments
|
||||
)
|
||||
if started and segments:
|
||||
metadata["video_started"] = True
|
||||
_save_job(
|
||||
job,
|
||||
@@ -757,6 +816,7 @@ def _advance_production(job: QuickCreateJob) -> int | None:
|
||||
if not review_state:
|
||||
return POLL_DELAY_SECONDS
|
||||
|
||||
job.refresh_from_db(fields=["metadata"])
|
||||
if not (job.metadata or {}).get("video_started"):
|
||||
_start_videos(job)
|
||||
return POLL_DELAY_SECONDS
|
||||
@@ -897,6 +957,17 @@ def recover_quick_create(job: QuickCreateJob) -> None:
|
||||
return
|
||||
if job.status == QuickCreateJob.Status.FAILED:
|
||||
_restore_project_after_orchestrator_timeout(job)
|
||||
job.refresh_from_db()
|
||||
retries = int((job.metadata or {}).get("transient_retries") or 0)
|
||||
if (
|
||||
job.status == QuickCreateJob.Status.FAILED
|
||||
and job.phase in {QuickCreateJob.Phase.SCRIPT, QuickCreateJob.Phase.ASSETS}
|
||||
and _adopted_script(job.project) is not None
|
||||
and _transient_internal(job)
|
||||
and retries < TRANSIENT_RETRY_LIMIT
|
||||
):
|
||||
_save_job(job, status=QuickCreateJob.Status.RUNNING, error_message="", message="网络波动,正在继续生成…")
|
||||
_enqueue_advance(job)
|
||||
return
|
||||
if _is_finished(job):
|
||||
return
|
||||
|
||||
@@ -45,15 +45,15 @@ class QuickCreateApiTests(TestCase):
|
||||
category=Asset.Category.PRODUCT_IMAGE,
|
||||
)
|
||||
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model")
|
||||
@patch("apps.projects.views.advance_quick_create_task.apply_async")
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model):
|
||||
def test_submit_creates_product_project_and_persistent_job(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
|
||||
store_asset.side_effect = self._uploaded_asset
|
||||
video_model_id = uuid.uuid4()
|
||||
get_model.side_effect = [
|
||||
object(),
|
||||
object(),
|
||||
SimpleNamespace(
|
||||
id=video_model_id,
|
||||
@@ -95,18 +95,20 @@ class QuickCreateApiTests(TestCase):
|
||||
self.assertEqual(enqueue.call_args.kwargs["args"], [str(job.id)])
|
||||
self.assertEqual(enqueue.call_args.kwargs["queue"], "airshelf.quick")
|
||||
require_worker_task.assert_called_once_with("apps.projects.tasks.advance_quick_create_task")
|
||||
self.assertEqual(get_model.call_count, 3)
|
||||
self.assertEqual(get_model.call_count, 2)
|
||||
get_quick_model.assert_called_once()
|
||||
self.assertEqual(len(response.data["product_images"]), 2)
|
||||
self.assertEqual(
|
||||
{item["asset_id"] for item in response.data["product_images"]},
|
||||
{str(image.asset_id) for image in product.images.order_by("sort_order")},
|
||||
)
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.views.advance_quick_create_task.apply_async")
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model):
|
||||
def test_submit_reuses_source_product_images(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
|
||||
source_asset = Asset.objects.create(
|
||||
team=self.team,
|
||||
created_by=self.user,
|
||||
@@ -117,7 +119,7 @@ class QuickCreateApiTests(TestCase):
|
||||
)
|
||||
source = Product.objects.create(team=self.team, created_by=self.user, title="旧商品", cover_asset=source_asset)
|
||||
ProductImage.objects.create(product=source, asset=source_asset, sort_order=0, is_primary=True)
|
||||
get_model.side_effect = [object(), object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})]
|
||||
get_model.side_effect = [object(), SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={})]
|
||||
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
@@ -138,11 +140,14 @@ class QuickCreateApiTests(TestCase):
|
||||
self.assertEqual(product.images.first().asset_id, source_asset.id)
|
||||
self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id))
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
def test_submit_rejects_foreign_source_product_images(self, require_worker_task):
|
||||
def test_submit_rejects_foreign_source_product_images(self, require_worker_task, get_model, get_quick_model):
|
||||
other = User.objects.create_user(username="quick-image-other", password="pass")
|
||||
other_team = Team.objects.create(name="Image Other Team", owner=other)
|
||||
foreign = Product.objects.create(team=other_team, created_by=other, title="别人的图")
|
||||
get_model.side_effect = [object(), SimpleNamespace(metadata={})]
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
@@ -193,9 +198,10 @@ class QuickCreateApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertFalse(Product.objects.filter(title="错误文件").exists())
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model):
|
||||
def test_submit_rejects_resolution_not_supported_by_selected_model(self, require_worker_task, get_model, get_quick_model):
|
||||
provider = ModelProvider.objects.create(name="quick-video-provider", display_name="Quick", status="active")
|
||||
model = ModelConfig.objects.create(
|
||||
provider=provider,
|
||||
@@ -520,6 +526,51 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.SUCCEEDED)
|
||||
self.assertEqual(self.job.phase, QuickCreateJob.Phase.COMPLETE)
|
||||
|
||||
@patch("apps.projects.services.quick_create.generate_base_asset", side_effect=TimeoutError("Timeout reading from socket"))
|
||||
def test_asset_start_timeout_retries_without_locking_the_job(self, _generate):
|
||||
script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场")
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.ASSETS
|
||||
self.job.save(update_fields=["status", "phase", "updated_at"])
|
||||
|
||||
delay = advance_quick_create(str(self.job.id))
|
||||
self.job.refresh_from_db()
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(delay, 10)
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
self.assertFalse(self.job.metadata.get("assets_started"))
|
||||
self.assertFalse(self.job.metadata.get("base_asset_task_ids"))
|
||||
self.assertNotEqual(self.project.status, Project.Status.FAILED)
|
||||
|
||||
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
|
||||
def test_recover_resumes_asset_timeout_after_script_exists(self, enqueue):
|
||||
script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场")
|
||||
self.job.status = QuickCreateJob.Status.FAILED
|
||||
self.job.phase = QuickCreateJob.Phase.ASSETS
|
||||
self.job.error_message = "脚本已保留,后续步骤遇到网络波动。点重试会从上次进度继续"
|
||||
self.job.metadata = {"internal_error": "Timeout reading from socket", "transient_retries": 1}
|
||||
self.job.save(update_fields=["status", "phase", "error_message", "metadata", "updated_at"])
|
||||
|
||||
recover_quick_create(self.job)
|
||||
self.job.refresh_from_db()
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
enqueue.assert_called_once()
|
||||
|
||||
def test_timeout_after_script_does_not_fail_project(self):
|
||||
script = ScriptVersion.objects.create(project=self.project, title="脚本", content="口播", is_adopted=True)
|
||||
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="开场")
|
||||
self.job.status = QuickCreateJob.Status.RUNNING
|
||||
self.job.phase = QuickCreateJob.Phase.ASSETS
|
||||
self.job.save(update_fields=["status", "phase", "updated_at"])
|
||||
|
||||
fail_quick_create(self.job, "脚本已保留,后续步骤遇到网络波动。点重试会从上次进度继续", internal_error="Timeout reading from socket")
|
||||
self.job.refresh_from_db()
|
||||
self.project.refresh_from_db()
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.FAILED)
|
||||
self.assertNotEqual(self.project.status, Project.Status.FAILED)
|
||||
|
||||
@patch("apps.projects.services.quick_create._reviews_ready", return_value=True)
|
||||
def test_production_counts_ready_videos_without_adding_version_ids(self, _reviews):
|
||||
self.project.video_segments.exclude(sort_order=0).delete()
|
||||
@@ -623,3 +674,16 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
self.assertEqual(call.kwargs["model_config_id"], "11111111-1111-4111-8111-111111111111")
|
||||
self.job.refresh_from_db()
|
||||
self.assertIn("30秒 16:9", self.job.message)
|
||||
|
||||
@patch("apps.projects.tasks.poll_video_segment_task.apply_async")
|
||||
@patch("apps.projects.services.quick_create.submit_video_segment")
|
||||
def test_start_videos_does_not_resubmit_when_called_twice(self, submit_video, _schedule_poll):
|
||||
self.project.video_segments.exclude(sort_order__lt=2).delete()
|
||||
self.assertEqual(self.project.video_segments.count(), 2)
|
||||
|
||||
_start_videos(self.job)
|
||||
_start_videos(self.job)
|
||||
|
||||
self.assertEqual(submit_video.call_count, 2)
|
||||
statuses = list(self.project.video_segments.order_by("sort_order").values_list("status", flat=True))
|
||||
self.assertEqual(statuses, [VideoSegment.Status.QUEUED, VideoSegment.Status.QUEUED])
|
||||
|
||||
@@ -499,7 +499,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if total_duration not in {15, 30, 45, 60}:
|
||||
return Response({"detail": "视频时长仅支持15、30、45或60秒"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
text_model = get_default_model(ModelConfig.Capability.TEXT)
|
||||
# 极速成片和专业创作使用同一款豆包脚本模型;绝不因默认模型变化而退回 DeepSeek。
|
||||
from .services.quick_create import get_quick_script_model
|
||||
|
||||
text_model = get_quick_script_model()
|
||||
image_model = get_default_model(ModelConfig.Capability.IMAGE)
|
||||
requested_video_model_id = str(request.data.get("video_model_config_id") or "").strip()
|
||||
video_model = None
|
||||
@@ -523,9 +526,14 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
missing = [
|
||||
label
|
||||
for model, label in ((text_model, "文本"), (image_model, "图像"), (video_model, "视频"))
|
||||
for model, label in ((image_model, "图像"), (video_model, "视频"))
|
||||
if model is None
|
||||
]
|
||||
if text_model is None:
|
||||
return Response(
|
||||
{"detail": "极速成片需要的豆包 Seed 2.1 Pro 模型未启用,请联系管理员配置"},
|
||||
status=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
if missing:
|
||||
return Response(
|
||||
{"detail": f"当前缺少可用的{'、'.join(missing)}模型,请联系管理员配置"},
|
||||
@@ -988,7 +996,13 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"""本项目在途的基础资产出图任务(立绘/商品图/三视图)。前端进基础资产趴时据此重建
|
||||
「生成中」占位卡 loading —— 出图在 worker 跑,刷新页面后内存态 genBusy 丢了,用它从后端认领恢复。"""
|
||||
project = self.get_object()
|
||||
inflight = [AITask.Status.CREATED, AITask.Status.RESERVED, AITask.Status.SUBMITTED, AITask.Status.POLLING]
|
||||
inflight = [
|
||||
AITask.Status.CREATED,
|
||||
AITask.Status.RESERVED,
|
||||
AITask.Status.SUBMITTED,
|
||||
AITask.Status.POLLING,
|
||||
AITask.Status.POSTPROCESSING,
|
||||
]
|
||||
image_types = [AITask.Type.PRODUCT_IMAGE, AITask.Type.PERSON_IMAGE, AITask.Type.SCENE_IMAGE]
|
||||
tasks = AITask.objects.filter(project=project, task_type__in=image_types, status__in=inflight)
|
||||
pending = []
|
||||
@@ -999,6 +1013,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"kind": payload.get("kind") or "",
|
||||
"label": payload.get("label") or "",
|
||||
"is_triview": bool(payload.get("triview_of")),
|
||||
"triview_of": payload.get("triview_of") or "",
|
||||
"status": task.status,
|
||||
})
|
||||
return Response({"pending": pending})
|
||||
|
||||
@@ -97,6 +97,8 @@ description: >
|
||||
- `visual_prompt` 由你自动生成,小白无需打字。
|
||||
- **商品图是真实外观的最高依据**:若任务消息附有商品视觉参考图,颜色、外形、材质、结构、配件和可见品牌标识必须以图为准;
|
||||
旁白、`type:"product"` 的 `visual_prompt` 与每镜 `visual` 不得凭商品名臆测或改色(例如不能把黑色耳机写成白色)。
|
||||
- **真实感不靠“唯一缺点”**:禁止输出「唯一缺点/唯一不足/唯一的小遗憾」等模板句,也不得为显得客观而编造商品缺点。
|
||||
用测试过程、适用边界、使用场景反差、可观察细节或使用习惯建议制造自然转折;每版优先换一个角度,避免重复套路。
|
||||
- **角色基础资产隔离**:`type:"character"` 的 `visual_prompt` 只能描述人物本身(年龄段、发型、穿着、气质、姿态),
|
||||
**禁止**出现商品名、产品、包装、品牌、Logo、价签、桌子、电脑、杯子、手持物或具体生活场景。
|
||||
角色图只用于锁脸锁人;商品只能在 `type:"product"`、`product_exposure` 与秒级 `visual` 中出现,避免角色图擅自重绘商品。
|
||||
|
||||
@@ -42,7 +42,8 @@
|
||||
带货转化率最高的组合,尤其适合中高客单价。
|
||||
- 人的**身份权威性**要在前 3 秒立住(从业年限 / 专业背景 / 测过多少款)
|
||||
- 读数特写时人可以出画外,但声音要连着不断
|
||||
- 必须说一个缺点,且要从「专业判断」的角度说,不是随口一句
|
||||
- 可信度不靠硬塞缺点:从测试过程、适用边界、场景反差、可观察细节或使用习惯中任选一个做自然转折。
|
||||
商品资料没有明确不足时,绝不编造;禁止使用「唯一缺点/唯一不足/唯一的小遗憾」模板句。
|
||||
|
||||
### 口播 × 场景种草
|
||||
最弱的一格,因为口播是「对镜说话」,天然打断场景沉浸。
|
||||
|
||||
@@ -53,8 +53,9 @@
|
||||
|
||||
## 四、诚实分寸
|
||||
|
||||
- **必须说一个缺点。** 缺点要真实但不致命(价格偏高、颜色少、需要适应期)
|
||||
- 说清**适合谁、不适合谁** —— 这既是诚实,也是精准筛选目标客户
|
||||
- 可信度来自可复现的测试过程、明确的适用边界或可观察细节,**不要求硬编一个缺点**。
|
||||
商品资料确实提供了限制条件时才可以如实说明;否则改为说清**适合谁、什么场景更合适、怎样用更顺手**。
|
||||
禁止使用「唯一缺点/唯一不足/唯一的小遗憾」这类模板句。
|
||||
- 不许伪造读数、不许剪掉不利结果
|
||||
- 不许用绝对化用语和医疗功效词(这条结构最容易踩,因为在「讲效果」)
|
||||
|
||||
@@ -91,7 +92,7 @@
|
||||
|
||||
- ❌ **开场就夸** —— 立场立刻崩塌,后面说什么都没用
|
||||
- ❌ **只有结论没有过程** —— 那不是测评,是广告
|
||||
- ❌ **全是优点没有缺点** —— 观众判定为软广
|
||||
- ❌ **用“唯一缺点”硬凑真实感** —— 这是模板广告;用测试过程、边界或细节建立可信度
|
||||
- ❌ **证据全是主观感受** —— 至少要有一个客观读数
|
||||
- ❌ **伪造或含糊读数** —— 涉嫌虚假宣传
|
||||
- ❌ **做成短剧演出来** —— 演绎的测评没有可信度,这个组合是禁用的
|
||||
|
||||
Reference in New Issue
Block a user