完成极速成片
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")
|
||||
|
||||
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])
|
||||
|
||||
@@ -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 @@
|
||||
|
||||
- ❌ **开场就夸** —— 立场立刻崩塌,后面说什么都没用
|
||||
- ❌ **只有结论没有过程** —— 那不是测评,是广告
|
||||
- ❌ **全是优点没有缺点** —— 观众判定为软广
|
||||
- ❌ **用“唯一缺点”硬凑真实感** —— 这是模板广告;用测试过程、边界或细节建立可信度
|
||||
- ❌ **证据全是主观感受** —— 至少要有一个客观读数
|
||||
- ❌ **伪造或含糊读数** —— 涉嫌虚假宣传
|
||||
- ❌ **做成短剧演出来** —— 演绎的测评没有可信度,这个组合是禁用的
|
||||
|
||||
@@ -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/`
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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}>
|
||||
|
||||
Reference in New Issue
Block a user