完成极速成片

This commit is contained in:
Azmat@qq.com
2026-08-25 18:46:20 +08:00
parent df6784b90c
commit 2f70d3e8a0
15 changed files with 495 additions and 146 deletions
+33 -7
View File
@@ -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)
+47
View File
@@ -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")
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,6 +3329,13 @@ def submit_video_segment(
references=[],
team=project.team,
)
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,
@@ -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
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])
+18 -3
View File
@@ -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 @@
- ❌ **开场就夸** —— 立场立刻崩塌,后面说什么都没用
- ❌ **只有结论没有过程** —— 那不是测评,是广告
- ❌ **全是优点没有缺点** —— 观众判定为软广
- ❌ **用“唯一缺点”硬凑真实感** —— 这是模板广告;用测试过程、边界或细节建立可信度
- ❌ **证据全是主观感受** —— 至少要有一个客观读数
- ❌ **伪造或含糊读数** —— 涉嫌虚假宣传
- ❌ **做成短剧演出来** —— 演绎的测评没有可信度,这个组合是禁用的
+1 -1
View File
@@ -356,7 +356,7 @@ export const api = {
},
// 本项目在途的基础资产出图任务(刷新后据此重建「生成中」占位卡 loading)
pendingAssets(id: string) {
return request<{ pending: Array<{ id: string; kind: string; label: string; is_triview: boolean; status: string }> }>(
return request<{ pending: Array<{ id: string; kind: string; label: string; is_triview: boolean; triview_of?: string; status: string }> }>(
`/api/projects/${id}/pending-assets/`
);
},
+19
View File
@@ -920,7 +920,22 @@
background-position: center;
background-repeat: no-repeat;
}
.as-gen[data-asset-kind="person"] .as-gen-preview.ready {
background-image: none;
background-position: center top;
}
.as-gen-photo {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
object-position: center top;
pointer-events: none;
display: block;
}
.as-gen-preview.generating { display: grid; place-items: center; }
.as-gen-preview.ready.generating { display: block; }
.as-gen-meta {
display: flex;
align-items: center;
@@ -1601,6 +1616,7 @@
/* ── 流程步骤4 · 基础资产卡生成中 loading + 转圈 ── */
.asset-card-loading { position: absolute; inset: 0; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; background: var(--background-base); z-index: 2; }
.asset-card-loading.veil { background: color-mix(in srgb, var(--surface) 62%, transparent); }
.asset-card-loading .mono { font-family: var(--font-mono); font-size: 12px; color: var(--black-alpha-56); letter-spacing: .04em; }
.asset-spinner { width: 26px; height: 26px; border: 2.5px solid var(--heat-20); border-top-color: var(--heat); border-radius: 50%; animation: assetSpin .7s linear infinite; }
.asset-spinner.sm { width: 13px; height: 13px; border-width: 2px; display: inline-block; vertical-align: -2px; }
@@ -1729,6 +1745,9 @@
box-sizing: border-box;
border-radius: var(--r-md);
}
.asset-detail-lead .placeholder.ad-lead-img.has-mock-media {
background-position: center top;
}
@supports not (aspect-ratio: 9 / 16) {
.asset-detail-lead .placeholder.ad-lead-img { height: min(420px, 48vh); }
}
+8 -1
View File
@@ -67,7 +67,9 @@
.quick-create-page .quick-progress-node.done { color: var(--quick-blue); }
.quick-create-page .quick-progress-node.done .quick-progress-dot { border-color: var(--quick-blue); background: var(--quick-blue); box-shadow: 0 0 0 5px rgba(0,47,167,.08); }
.quick-create-page .quick-progress-node.done:not(:last-child)::after { background: var(--quick-blue); }
.quick-create-page .quick-progress-node.active .quick-progress-dot { box-shadow: 0 0 0 6px rgba(17,18,22,.09); }
.quick-create-page .quick-progress-node.active { color: var(--quick-blue); }
.quick-create-page .quick-progress-node.active .quick-progress-dot { border-color: var(--quick-blue); background: var(--quick-blue); box-shadow: 0 0 0 6px rgba(0,47,167,.11); animation: quick-progress-pulse 1.45s ease-out infinite; }
.quick-create-page .quick-progress-node.active .quick-progress-dot::before { content: ""; position: absolute; inset: -7px; border-radius: 50%; background: conic-gradient(transparent 0 46%,rgba(0,47,167,.18) 56%,var(--quick-blue) 100%); -webkit-mask: radial-gradient(circle,transparent 0 68%,#000 71%); mask: radial-gradient(circle,transparent 0 68%,#000 71%); animation: quick-progress-orbit 1.2s linear infinite; }
.quick-create-page .quick-generating-actions { display: flex; justify-content: center; margin-top: 22px; }.quick-create-page .quick-generating-actions button { min-width: 132px; min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-generating-actions svg { width: 17px; height: 17px; }
.quick-create-page .quick-state-complete { width: min(560px,100%); gap: 17px; }
.quick-create-page .quick-video-result-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 12px; }
@@ -91,6 +93,8 @@
.quick-create-page .quick-download-action { border-radius: 8px; }
.quick-create-page .quick-state-failed { justify-items: center; gap: 14px; text-align: center; }.quick-create-page .quick-state-failed h2,.quick-create-page .quick-state-failed p { margin: 0; }.quick-create-page .quick-state-failed p { max-width: 430px; color: var(--quick-muted); font-size: 13px; line-height: 1.7; }.quick-create-page .quick-failed-icon { width: 82px; height: 82px; display: grid; place-items: center; border-radius: 50%; color: #c83d4d; background: rgba(200,61,77,.08); }.quick-create-page .quick-failed-icon svg { width: 36px; height: 36px; }.quick-create-page .quick-failed-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 10px; margin-top: 8px; }.quick-create-page .quick-failed-actions button { min-height: 44px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; }.quick-create-page .quick-failed-actions svg { width: 17px; height: 17px; }
@keyframes quick-spinner-rotate { to { transform: rotate(360deg); } }
@keyframes quick-progress-pulse { 50% { box-shadow: 0 0 0 11px rgba(0,47,167,.04); } }
@keyframes quick-progress-orbit { to { transform: rotate(360deg); } }
.quick-create-page .quick-history { margin-top: 28px; }
.quick-create-page .quick-history-head { display: flex; align-items: baseline; justify-content: space-between; gap: 16px; margin-bottom: 12px; }
.quick-create-page .quick-history-head h2 { margin: 0; font-size: 20px; font-weight: 600; }
@@ -98,9 +102,12 @@
.quick-create-page .quick-history-list { display: grid; gap: 10px; }
.quick-create-page .quick-history-card { display: grid; grid-template-columns: 112px minmax(0,1fr) auto; align-items: center; gap: 16px; padding: 12px 14px; border: 1px solid rgba(34,42,54,.10); border-radius: 8px; background: #fff; box-shadow: inset 3px 0 0 var(--quick-blue); }
.quick-create-page .quick-history-thumb { position: relative; width: 112px; aspect-ratio: 16/9; overflow: hidden; padding: 0; border: 0; border-radius: 8px; background: #eef1f5; cursor: pointer; }
.quick-create-page .quick-history-thumb:disabled { cursor: default; }
.quick-create-page .quick-history-thumb img { width: 100%; height: 100%; display: block; object-fit: cover; }
.quick-create-page .quick-history-thumb-empty { width: 100%; height: 100%; display: grid; place-items: center; color: var(--quick-blue); }
.quick-create-page .quick-history-thumb-empty svg { width: 18px; height: 18px; }
.quick-create-page .quick-history-play { position: absolute; inset: 0; display: grid; place-items: center; color: #fff; background: rgba(0,0,0,.22); pointer-events: none; }
.quick-create-page .quick-history-play svg { width: 22px; height: 22px; }
.quick-create-page .quick-history-thumb small { position: absolute; right: 6px; bottom: 6px; padding: 1px 6px; border-radius: 4px; color: #fff; background: rgba(0,0,0,.62); font-size: 10px; line-height: 16px; }
.quick-create-page .quick-history-copy { min-width: 0; }
.quick-create-page .quick-history-badge { display: inline-flex; padding: 2px 8px; border-radius: 999px; color: var(--quick-blue); background: rgba(0,47,167,.08); font-size: 11px; font-weight: 600; }
+45 -18
View File
@@ -821,26 +821,46 @@ export function PipelinePage(props: {
// 行38/流程步骤4 · 单卡生成 loading:按「busyKey」分别记录,各按钮互不影响(生成三视图不挡新增人物)
const [genBusy, setGenBusy] = useState<Set<string>>(new Set());
// 刷新后从后端「认领」在途出图任务:出图在 worker 跑、内存态 genBusy 丢了,据此重建占位卡 loading
const [pendingGen, setPendingGen] = useState<Array<{ kind: string; label: string; is_triview: boolean }>>([]);
const [pendingGen, setPendingGen] = useState<Array<{ kind: string; label: string; is_triview: boolean; triview_of?: string }>>([]);
const pendingHas = (kind: string, label: string) =>
pendingGen.some((p) => p.kind === kind && !p.is_triview && (p.label || "") === (label || ""));
const pendingTriOf = (assetId?: string | null) =>
Boolean(assetId && pendingGen.some((p) => p.is_triview && p.triview_of === assetId));
const pendingTriFor = (kind: "person" | "scene", entity: AssetEntity) =>
pendingTriOf(entity.group.adopted_asset)
|| pendingGen.some((p) => p.is_triview && p.kind === kind && (p.label || "") === (entity.label || entity.name || ""));
// 立绘出图后 seed 卡会换成 ent 卡,busyKey 必须两边都记,否则三视图还在跑、卡片已经换身份,转圈会丢。
const entityGenKeys = (kind: "person" | "scene", ...names: Array<string | undefined | null>) => {
const keys: string[] = [];
const seen = new Set<string>();
for (const name of names) {
const tag = (name || "").trim();
if (!tag || seen.has(tag)) continue;
seen.add(tag);
keys.push(`ent:${kind}:${tag}`, `seed:${kind}:${tag}`);
}
return keys;
};
// 在途出图集合的稳定指纹:集合变化(开工/出片完成)才变 → 给审核轮询当「重启信号」,
// 让真人出片后自动送审的盾能被盯到变绿(否则审核轮询可能在送审前就停了)。仅集合变才变,不会每轮抖动。
const pendingGenKey = useMemo(() => pendingGen.map((p) => `${p.kind}:${p.label}:${p.is_triview}`).join("|"), [pendingGen]);
const pendingGenKey = useMemo(() => pendingGen.map((p) => `${p.kind}:${p.label}:${p.is_triview}:${p.triview_of || ""}`).join("|"), [pendingGen]);
const isBusy = (k: string) => genBusy.has(k);
const addBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.add(k); return n; });
const delBusy = (k: string) => setGenBusy((s) => { const n = new Set(s); n.delete(k); return n; });
type GenResult = { id?: string; adopted_asset?: string | null } | null;
async function genBaseAsset(kind: "product" | "person" | "scene", prompt: string, label: string | undefined, busyKey: string, referenceAssetId?: string): Promise<GenResult> {
if (genBusy.has(busyKey)) return null; // 同一按钮防连点(不同按钮可并发)
addBusy(busyKey);
const triKey = kind === "person" ? `${busyKey}:tri` : "";
if (triKey) addBusy(triKey);
const aliasKeys = kind === "product"
? [busyKey]
: [...new Set([busyKey, ...entityGenKeys(kind, label)])];
if (aliasKeys.some((k) => genBusy.has(k))) return null; // 同一按钮防连点(不同按钮可并发)
aliasKeys.forEach(addBusy);
const triKeys = kind === "person" ? aliasKeys.map((k) => `${k}:tri`) : [];
triKeys.forEach(addBusy);
try {
return (await onGenerateBaseAsset(kind, prompt, label, referenceAssetId)) as GenResult;
} finally {
delBusy(busyKey);
if (triKey) delBusy(triKey);
aliasKeys.forEach(delBusy);
triKeys.forEach(delBusy);
}
}
// 据某一版立绘资产生成它配套的三视图(loading 用 busyKey)
@@ -2831,7 +2851,7 @@ export function PipelinePage(props: {
}).catch(() => undefined);
}
prevPendingIdsRef.current = list.map((pending) => pending.id);
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview })));
setPendingGen(list.map((p) => ({ kind: p.kind, label: p.label, is_triview: p.is_triview, triview_of: p.triview_of })));
// 没有在途出图、本地也没有生成在跑 → 没什么可等,停轮询;再点生成(genBusy 变)时本 effect 会重订阅恢复。
if (list.length === 0 && genBusy.size === 0) { stopped = true; return; }
} catch {
@@ -3615,9 +3635,13 @@ export function PipelinePage(props: {
const mainUrl = groupMainUrl(grp);
const promptValue = assetPromptDraft[entity.key] ?? grp.prompt ?? "";
const entBK = `ent:${kind}:${entity.key}`;
const busy = isBusy(entBK) || isBusy(`${entBK}:tri`) || (!mainUrl && pendingHas(kind, entity.name));
const genKeys = entityGenKeys(kind, entity.key, entity.name, entity.label);
const portraitBusy = genKeys.some((k) => isBusy(k)) || (!mainUrl && pendingHas(kind, entity.name));
const triBusy = kind === "person" && (genKeys.some((k) => isBusy(`${k}:tri`)) || pendingTriFor(kind, entity));
const busy = portraitBusy || triBusy;
const loadingText = kind === "person" && mainUrl ? "三视图生成中…" : "生成中…";
const rs = (kind === "person" || kind === "scene") && grp.adopted_asset ? (reviews[grp.adopted_asset] || grp.adopted_asset_review || "") : "";
const previewState = busy ? " generating" : mainUrl ? " ready" : " pending";
const previewState = `${mainUrl ? " ready" : busy ? " generating" : " pending"}${busy ? " generating" : ""}`;
return (
<div className="as-gen" data-asset-kind={kind} data-asset-id={entity.key} key={entity.key}>
{grp.id && (delArmed === grp.id ? (
@@ -3632,14 +3656,15 @@ export function PipelinePage(props: {
))}
<div
className={`as-gen-preview${previewState}`}
style={mainUrl && !busy ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
style={mainUrl && kind !== "person" ? { ...mediaStyle(mainUrl), cursor: "pointer" } : { cursor: "pointer" }}
role="button"
tabIndex={0}
title="点击查看详情"
onClick={() => openAssetDetail(kind, entity)}
onKeyDown={(e) => { if (e.key === "Enter" || e.key === " ") { e.preventDefault(); openAssetDetail(kind, entity); } }}
>
{busy ? <span className="asset-card-loading"><span className="asset-spinner" aria-hidden="true"></span><span></span></span> : null}
{kind === "person" && mainUrl ? <img className="as-gen-photo" src={mainUrl} alt="" /> : null}
{busy ? <span className={`asset-card-loading${mainUrl ? " veil" : ""}`}><span className="asset-spinner" aria-hidden="true"></span><span>{loadingText}</span></span> : null}
</div>
<div className="as-gen-meta">
<h4 onClick={() => openAssetDetail(kind, entity)}>{entity.name}</h4>
@@ -3666,7 +3691,7 @@ export function PipelinePage(props: {
<span>{kind === "scene" ? "场景库替换" : "模特库替换"}</span>
</button>
<button className="as-ai-btn" type="button" data-stop disabled={busy} onClick={() => { const p = (assetPromptDraft[entity.key] ?? grp.prompt ?? "").trim() || genPrompt; const ref = kind === "person" && grp.adopted_asset ? grp.adopted_asset : undefined; void genBaseAsset(kind, p, entity.name, entBK, ref); }}>
<Sparkles /><span>{busy ? "生成中…" : "AI 生成"}</span>
<Sparkles /><span>{busy ? "生成中…" : mainUrl ? "重新生成" : "AI 生成"}</span>
</button>
</div>
</div>
@@ -4602,9 +4627,10 @@ export function PipelinePage(props: {
return false;
})();
const pBK = `addet-portrait:${entity.key}`;
const genKeysPortrait = entityGenKeys(adDetail.kind, entity.key, entity.name, entity.label);
// 没立绘时(尤其 seed 首次生成),立绘在 worker 跑 ~30s,本地 isBusy 早已落回 → 用 pendingHas 兜底转圈
const busyPortrait = isBusy(pBK) || (!portraitUrl && pendingHas(adDetail.kind, entity.name));
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`);
const busyPortrait = isBusy(pBK) || genKeysPortrait.some((k) => isBusy(k)) || (!portraitUrl && pendingHas(adDetail.kind, entity.name));
const busyTri = isBusy(`addet-tri:${entity.key}`) || isBusy(`${pBK}:tri`) || genKeysPortrait.some((k) => isBusy(`${k}:tri`)) || pendingTriOf(viewPortraitAsset) || pendingTriFor(adDetail.kind, entity);
async function regenPortrait() {
const prompt = adPrompt.trim() || grp.prompt || `${entity!.name},9:16 竖屏`;
// 重跑立绘 → 追加新立绘并自动接力该版三视图。
@@ -4674,15 +4700,16 @@ export function PipelinePage(props: {
{!triUrl && (busyTri
? <div style={{ display: "flex", flexDirection: "column", gap: 8, alignItems: "center" }}><span className="spinner" aria-hidden="true"></span><span className="ph-frame"></span></div>
: <span className="ph-frame"> / / · </span>)}
{triUrl && busyTri && <span className="sb-gen-veil" aria-hidden="true"><span className="spinner" /></span>}
</div>
{triUrl && <button className="ad-zoom-btn" type="button" title="查看大图" onClick={() => setPreview({ src: triUrl, kind: "image", name: `${entity.name} · 三视图` })}>{zoomSvg}</button>}
</div>
</div>
{!assetAlreadyHasTri && !busyTri && (
{(!assetAlreadyHasTri || busyTri) && (
<div className="asset-detail-tip">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="9" /><path d="M12 8v4M12 16h.01" /></svg>
<span>{viewPortraitAsset ? "三视图据这版立绘生成,保证正/侧/背多角度一致。点下方按钮生成。" : "请先生成左侧「立绘」,有了立绘才能据它生成三视图。"}</span>
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>AI </button>
<button className="ai-gen-btn" type="button" disabled={busyTri || !viewPortraitAsset} onClick={() => void regenTri()}>{busyTri ? "生成中…" : assetAlreadyHasTri ? "重新生成三视图" : "AI 生成三视图"}</button>
</div>
)}
{triVersions.length > 0 && (
+85 -62
View File
@@ -4,7 +4,6 @@ import {
ArrowLeft,
Boxes,
Clapperboard,
Download,
ImagePlus,
LayoutPanelTop,
RefreshCw,
@@ -15,10 +14,10 @@ import {
Upload,
WandSparkles,
X,
Columns2,
ArrowUpRight,
} from "lucide-react";
import { api, ApiError, type QuickCreateJob } from "../api";
import { ConfirmModal } from "../components/overlays";
import type { ModelConfig } from "../types";
import {
DEFAULT_BILLING_RATES,
@@ -70,14 +69,20 @@ function historyTitle(item: QuickCreateJob) {
return (item.title || item.product_name || "极速成片").replace(/ · 极速成片$/, "");
}
function jobIsComplete(item: QuickCreateJob) {
return item.status === "succeeded" || Boolean(item.result?.video_url) || Boolean(item.result?.video_segments?.some((clip) => clip.video_url));
}
function historyBadge(item: QuickCreateJob) {
if (item.status === "cancelled") return "已取消";
if (item.status === "succeeded" || item.result?.video_url || item.result?.video_segments?.some((clip) => clip.video_url)) {
return "已完成";
}
if (jobIsComplete(item)) return "已完成";
return "未完成";
}
function historyVideoUrl(item: QuickCreateJob) {
return item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
}
function savedJobId() {
try {
return localStorage.getItem(QUICK_JOB_KEY) || "";
@@ -107,12 +112,12 @@ export function QuickCreatePage({
const [images, setImages] = useState<File[]>([]);
const [savedImages, setSavedImages] = useState<Array<{ asset_id: string; url: string }>>([]);
const [sourceProductId, setSourceProductId] = useState("");
const [preview, setPreview] = useState("");
const [imagePreviews, setImagePreviews] = useState<string[]>([]);
const [jobId, setJobId] = useState(savedJobId);
const [job, setJob] = useState<QuickCreateJob | null>(null);
const [submitting, setSubmitting] = useState(false);
const [cancelling, setCancelling] = useState(false);
const [confirmCancel, setConfirmCancel] = useState(false);
const [serviceUnavailable, setServiceUnavailable] = useState(false);
const [unavailableMessage, setUnavailableMessage] = useState("");
const [history, setHistory] = useState<QuickCreateJob[]>([]);
@@ -131,10 +136,13 @@ export function QuickCreatePage({
const [videoModelId, setVideoModelId] = useState("");
const [billingRates, setBillingRates] = useState<BillingRates>(DEFAULT_BILLING_RATES);
const completedNoticeRef = useRef("");
const terminalNoticeRef = useRef("");
const watchedGeneratingRef = useRef(false);
const cancelRequestedRef = useRef(false);
const notifyRef = useRef(onNotify);
const projectCreatedRef = useRef(onProjectCreated);
const imageInputRef = useRef<HTMLInputElement>(null);
const productPrefillDoneRef = useRef(false);
useEffect(() => {
notifyRef.current = onNotify;
@@ -159,11 +167,12 @@ export function QuickCreatePage({
}, []);
useEffect(() => {
if (!initialProductId || jobId) return;
if (!initialProductId || jobId || productPrefillDoneRef.current) return;
let cancelled = false;
void api.product(initialProductId)
.then((product) => {
if (cancelled) return;
if (cancelled || productPrefillDoneRef.current) return;
productPrefillDoneRef.current = true;
setName((current) => current || product.title || "");
setSourceProductId((current) => current || product.id);
setSavedImages((current) => {
@@ -204,10 +213,6 @@ export function QuickCreatePage({
return () => urls.forEach((url) => URL.revokeObjectURL(url));
}, [images]);
useEffect(() => {
setPreview(imagePreviews[0] || savedImages.find((image) => image.url)?.url || "");
}, [imagePreviews, savedImages]);
useEffect(() => {
if (!jobId) return;
let cancelled = false;
@@ -216,7 +221,31 @@ export function QuickCreatePage({
try {
const next = await api.quickCreateStatus(jobId);
if (cancelled) return;
setJob(next);
if (next.status === "succeeded") {
if (watchedGeneratingRef.current && completedNoticeRef.current !== next.id) {
completedNoticeRef.current = next.id;
notifyRef.current?.("success", "极速成片已生成");
projectCreatedRef.current?.();
}
productPrefillDoneRef.current = true;
setHistory((current) => [next, ...current.filter((item) => item.id !== next.id)]);
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
watchedGeneratingRef.current = false;
setName("");
setImages([]);
setSavedImages([]);
setSourceProductId("");
setJob(null);
setJobId("");
setCancelling(false);
setConfirmCancel(false);
try {
localStorage.removeItem(QUICK_JOB_KEY);
} catch {
/* ignore */
}
return;
}
if (next.product_name) setName((current) => current || next.product_name);
if (next.product_images?.length) {
setSavedImages(next.product_images.filter((image) => image.asset_id));
@@ -229,16 +258,20 @@ export function QuickCreatePage({
setTotalDuration(next.settings.total_duration);
if (next.settings.video_model_config_id) setVideoModelId(next.settings.video_model_config_id);
}
if (next.status === "succeeded") {
if (completedNoticeRef.current !== next.id) {
completedNoticeRef.current = next.id;
notifyRef.current?.("success", "极速成片已生成");
projectCreatedRef.current?.();
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
if (next.status === "queued" || next.status === "running") watchedGeneratingRef.current = true;
setJob(next);
if (next.status === "failed" || next.status === "cancelled") {
if (terminalNoticeRef.current !== next.id) {
terminalNoticeRef.current = next.id;
const message = next.status === "cancelled"
? "极速成片已取消"
: next.error_message || "极速成片未完成,已完成的步骤会保留,可重试继续";
notifyRef.current?.(next.status === "cancelled" ? "info" : "error", message);
}
void api.quickCreateHistory().then((payload) => setHistory(payload.results || [])).catch(() => undefined);
watchedGeneratingRef.current = false;
return;
}
if (next.status === "failed" || next.status === "cancelled") return;
timer = window.setTimeout(poll, 2500);
} catch (error) {
if (cancelled) return;
@@ -312,6 +345,7 @@ export function QuickCreatePage({
setServiceUnavailable(false);
setUnavailableMessage("");
cancelRequestedRef.current = false;
terminalNoticeRef.current = "";
try {
const next = await api.retryQuickCreate(job.id);
setJob(next);
@@ -343,6 +377,7 @@ export function QuickCreatePage({
setServiceUnavailable(false);
setUnavailableMessage("");
cancelRequestedRef.current = false;
terminalNoticeRef.current = "";
try {
const form = new FormData();
form.append("name", name.trim());
@@ -395,9 +430,12 @@ export function QuickCreatePage({
setJob(null);
setJobId("");
setCancelling(false);
setConfirmCancel(false);
setServiceUnavailable(false);
setUnavailableMessage("");
completedNoticeRef.current = "";
terminalNoticeRef.current = "";
watchedGeneratingRef.current = false;
try {
localStorage.removeItem(QUICK_JOB_KEY);
} catch {
@@ -407,6 +445,7 @@ export function QuickCreatePage({
async function cancelGeneration() {
if (cancelling) return;
setConfirmCancel(false);
cancelRequestedRef.current = true;
if (!jobId) {
setSubmitting(false);
@@ -433,20 +472,17 @@ export function QuickCreatePage({
}
}
function requestCancelGeneration() {
if (!cancelling) setConfirmCancel(true);
}
const isGenerating = submitting || job?.status === "queued" || job?.status === "running";
const isComplete = job?.status === "succeeded";
const isCancelled = job?.status === "cancelled";
const isFailed = !isGenerating && (job?.status === "failed" || isCancelled || serviceUnavailable);
const displayUrls = [...savedImages.map((image) => image.url), ...imagePreviews];
const imageCount = savedImages.length + images.length;
const canRetry = Boolean(name.trim() && imageCount && selectedVideoModel);
const activePhase = submitting ? 0 : Math.max(0, Math.min(4, job?.phase_index ?? 0));
const result = job?.result;
const videoClips = result?.video_segments?.length
? result.video_segments
: result?.video_url
? [{ id: "final", sort_order: 0, duration_seconds: result.duration_seconds, video_url: result.video_url, poster_url: result.poster_url }]
: [];
const sceneCount = Math.max(1, Math.round(totalDuration / 15));
const videoEstimate = estimateCost(
selectedVideoModel,
@@ -458,7 +494,6 @@ export function QuickCreatePage({
const shellClass = [
"quick-create-shell",
isGenerating ? "is-generating" : "",
isComplete ? "is-complete" : "",
isFailed ? "is-failed" : "",
].filter(Boolean).join(" ");
@@ -544,7 +579,7 @@ export function QuickCreatePage({
<div className="quick-form-footer">
{isGenerating ? (
<button type="button" className="quick-cancel-button" onClick={() => void cancelGeneration()} disabled={cancelling}>
<button type="button" className="quick-cancel-button" onClick={requestCancelGeneration} disabled={cancelling}>
<X /><span>{cancelling ? "正在取消…" : "取消生成"}</span>
</button>
) : (
@@ -573,43 +608,16 @@ export function QuickCreatePage({
})}
</div>
<div className="quick-generating-actions">
<button type="button" className="secondary-action" onClick={() => void cancelGeneration()} disabled={cancelling}>
<button type="button" className="secondary-action" onClick={requestCancelGeneration} disabled={cancelling}>
<X />{cancelling ? "正在取消…" : "取消生成"}
</button>
</div>
</div>
<div className="quick-state quick-state-complete">
<div className={`quick-video-result-grid${videoClips.length === 1 ? " is-single" : ""}`}>
{videoClips.map((clip, index) => (
<button
key={clip.id}
type="button"
className="quick-video-result-card"
onClick={() => playClip(clip.video_url || result?.video_url, clip.poster_url || result?.poster_url || preview, `${index + 1}场视频`)}
>
<span className="quick-video-result-thumb">
{clip.poster_url || preview ? <img src={clip.poster_url || preview} alt={`${index + 1}场视频首帧`} /> : clip.video_url ? <video src={clip.video_url} muted playsInline preload="metadata" /> : null}
<span className="quick-video-play" aria-hidden="true"><Play /></span>
</span>
<span className="quick-video-result-meta"><strong>{index + 1}</strong><small>{clip.duration_seconds || 15}</small></span>
</button>
))}
</div>
<div className="quick-result-head"><div><h2>{videoClips.length || 1} </h2><p>{videoClips.length || 1} · {videoClips[0]?.duration_seconds || 15} · {result?.aspect_ratio || "9:16"} · {(result?.resolution || "720p").toUpperCase()} · {result?.video_model || "智能模型"}</p></div><span className="quick-result-badge"></span></div>
<div className={`quick-result-actions${videoClips.length > 1 ? "" : " is-single"}`}>
<button type="button" className="secondary-action" onClick={resetResult}><RefreshCw /></button>
{videoClips.length > 1 ? (
<button type="button" className="secondary-action" onClick={() => job && navigate("pipeline", { projectId: job.project_id })}><Columns2 /></button>
) : null}
{result?.video_url ? <a className="primary-action quick-download-action" href={result.video_url} target="_blank" rel="noreferrer" download><Download /></a> : <button type="button" className="primary-action" disabled><Download /></button>}
</div>
</div>
<div className="quick-state quick-state-failed">
<span className="quick-failed-icon"><AlertCircle /></span>
<h2>{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : "本次生成未完成"}</h2>
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的故事板不会重做。"}</p>
<h2>{serviceUnavailable ? "极速成片暂不可用" : isCancelled ? "已取消本次生成" : job?.phase === "script" ? "本次生成未完成" : "成片尚未完成"}</h2>
<p>{serviceUnavailable ? (unavailableMessage || "后台生成服务未就绪,请确认 Celery worker 已启动后再试。") : job?.error_message || "生成还没走完。点重试会从上次进度继续,已生成的脚本和素材会保留。"}</p>
<div className="quick-failed-actions">
{canRetry && !serviceUnavailable ? (
<button type="button" className="primary-action" onClick={() => void retryGeneration()}><RefreshCw /></button>
@@ -636,19 +644,22 @@ export function QuickCreatePage({
<div className="quick-history-list">
{history.map((item) => {
const poster = item.result?.poster_url || "";
const videoUrl = item.result?.video_url || item.result?.video_segments?.[0]?.video_url || "";
const videoUrl = historyVideoUrl(item);
const duration = item.result?.duration_seconds || item.settings?.total_duration || 15;
const scenes = item.result?.video_segments?.length || Math.max(1, Math.round(duration / 15));
const badge = historyBadge(item);
const canPlay = Boolean(videoUrl);
return (
<article key={item.id} className="quick-history-card">
<button
type="button"
className="quick-history-thumb"
onClick={() => playClip(videoUrl, poster, historyTitle(item))}
aria-label={`播放${historyTitle(item)}`}
aria-label={canPlay ? `播放${historyTitle(item)}` : historyTitle(item)}
disabled={!canPlay}
>
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty"><Play /></span>}
{poster ? <img src={poster} alt="" /> : <span className="quick-history-thumb-empty">{canPlay ? null : <Play />}</span>}
{canPlay ? <span className="quick-history-play" aria-hidden="true"><Play /></span> : null}
<small>{formatClock(duration)}</small>
</button>
<div className="quick-history-copy">
@@ -668,6 +679,18 @@ export function QuickCreatePage({
)}
</section>
<ConfirmModal
open={confirmCancel}
title="确认取消生成?"
subtitle="当前任务将停止"
detail="取消后,本次极速成片不会继续生成;已完成的商品和素材会保留在专业创作项目中。"
confirmText="确认取消"
icon={<AlertCircle size={16} />}
dismissable={!cancelling}
onCancel={() => { if (!cancelling) setConfirmCancel(false); }}
onConfirm={() => void cancelGeneration()}
/>
{playing ? (
<div className="quick-player-bg" onClick={() => setPlaying(null)} role="presentation">
<div className="quick-player" onClick={(event) => event.stopPropagation()} role="dialog" aria-modal="true" aria-label={playing.title}>