diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index 5b560e2..bd24f06 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -1,3 +1,4 @@ +import json import re import subprocess import tempfile @@ -112,6 +113,102 @@ def parse_segment_fields(block: str) -> tuple[str, str]: return narration or visual, visual or narration +def build_cast_scene_extract_prompt(content: str) -> list[dict[str, str]]: + """轻量抽取提示词:从镜头脚本里提炼人物 / 场景标签,并给每个标签一句可直接生图的画面提示词。""" + system = ( + "你是短视频脚本分析助手。请从给定的镜头脚本中提取出现的『人物』和『场景』," + "并为每个人物 / 场景写一句可直接用于文生图的画面提示词(中文,30 字内,描述外形 / 着装 / 环境 / 光线,9:16 竖屏)。" + "人物指出镜的角色(例:女主、同事、闺蜜);场景指画面发生的地点或环境(例:卫生间、地铁、办公室)。" + "去重,人物与场景各最多 6 个。只输出一个 JSON 对象,不要 markdown 代码块,不要任何额外文字,格式如下:\n" + '{"cast":[{"name":"女主","prompt":"26岁都市女性,自然妆容,米色针织衫,柔和室内光,9:16竖屏"}],' + '"scenes":[{"name":"卫生间","prompt":"现代简约浴室,暖色灯光,干净台面,9:16竖屏"}]}' + ) + return [{"role": "system", "content": system}, {"role": "user", "content": f"镜头脚本如下:\n{content}".strip()}] + + +def _coerce_tag_entries(items: object, limit: int = 6) -> tuple[list[str], dict[str, str]]: + """把模型回的 [{"name","prompt"}] 列表整理成 (标签列表, {标签: 提示词}),去重保序、容错。""" + names: list[str] = [] + prompts: dict[str, str] = {} + if not isinstance(items, list): + return names, prompts + for item in items: + if isinstance(item, dict): + name = str(item.get("name") or "").strip() + prompt = str(item.get("prompt") or "").strip() + else: + name, prompt = str(item or "").strip(), "" + if not name or name in ("无", "暂无", "未提及") or name in names: + continue + names.append(name) + if prompt: + prompts[name] = prompt + if len(names) >= limit: + break + return names, prompts + + +def extract_cast_and_scenes(*, project, user, content: str) -> dict: + """轻量调一次文本模型,从脚本里抽取人物 / 场景标签及每个标签的建议生图提示词。 + + 出稿后的增益步骤(对齐流程文档「自动从脚本提取信息」),走完整的 AITask + 计费闭环 + (reserve→charge/release,任务类型记为 script_optimization)。但全程 best-effort: + 无可用模型 / 预扣失败 / 调用失败 / 解析失败都吞掉返回空,绝不阻断脚本生成主流程。 + 返回 {cast, scenes, cast_prompts, scene_prompts}。 + """ + empty = {"cast": [], "scenes": [], "cast_prompts": {}, "scene_prompts": {}} + model_config = get_default_model(ModelConfig.Capability.TEXT) + if model_config is None or not (content or "").strip(): + return empty + + messages = build_cast_scene_extract_prompt(content) + try: + task = create_ai_task( + project=project, + user=user, + task_type=AITask.Type.SCRIPT_OPTIMIZATION, + model_config=model_config, + request_payload={"model": model_config.name, "endpoint": model_config.endpoint, "messages": messages}, + ) + except Exception: + return empty # 余额不足等预扣失败:跳过提取,不挡出稿 + reservation = task.credit_reservation + + try: + task.status = AITask.Status.SUBMITTED + task.submitted_at = timezone.now() + task.save(update_fields=["status", "submitted_at", "updated_at"]) + + provider = VolcanoArkProvider(base_url=model_config.provider.base_url or None) + response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages) + text = provider.extract_text(response) + + # LLM 调用已真实消耗 token → 按成功计费(无论后续能否解析出标签) + with transaction.atomic(): + task.status = AITask.Status.SUCCEEDED + task.response_payload = response + task.actual_cost = task.estimated_cost + task.completed_at = timezone.now() + task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"]) + charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost) + + match = re.search(r"\{.*\}", text, re.DOTALL) # 容忍模型多裹了 markdown / 解释文字 + if not match: + return empty + data = json.loads(match.group(0)) + cast, cast_prompts = _coerce_tag_entries(data.get("cast")) + scenes, scene_prompts = _coerce_tag_entries(data.get("scenes")) + return {"cast": cast, "scenes": scenes, "cast_prompts": cast_prompts, "scene_prompts": scene_prompts} + except Exception as exc: + with transaction.atomic(): + task.status = AITask.Status.FAILED + task.error_message = str(exc) + task.completed_at = timezone.now() + task.save(update_fields=["status", "error_message", "completed_at", "updated_at"]) + release_credit(reservation=reservation, reason=str(exc)) + return empty + + def split_script_into_segments(content: str, count: int = 4) -> list[str]: """把一段脚本稳健地拆成 `count` 个分镜文本,保证每镜都非空、且所有内容都被分配到某一镜。 @@ -196,6 +293,9 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages) content = provider.extract_text(response) + # 出稿后自动提取人物 / 场景(best-effort,失败返回空,不挡主流程),供脚本页标签与基础资产 seed + extracted = extract_cast_and_scenes(project=project, user=user, content=content) + with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.response_payload = response @@ -222,6 +322,20 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id visual_prompt=visual, ) + # 把提取到的人物 / 场景(含每个标签的建议生图提示词)回填进 project.metadata: + # 脚本页标签自动读 cast/scenes,基础资产据 cast_prompts/scene_prompts seed 每张卡。 + # 仅在提取到内容时覆盖,空结果不清掉用户已有标签。 + if extracted["cast"] or extracted["scenes"]: + metadata = dict(project.metadata or {}) + if extracted["cast"]: + metadata["cast"] = extracted["cast"] + metadata["cast_prompts"] = extracted["cast_prompts"] + if extracted["scenes"]: + metadata["scenes"] = extracted["scenes"] + metadata["scene_prompts"] = extracted["scene_prompts"] + project.metadata = metadata + project.save(update_fields=["metadata", "updated_at"]) + stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT) stage.status = ProjectStage.Status.NEEDS_REVIEW stage.save(update_fields=["status", "updated_at"]) @@ -412,7 +526,7 @@ def _store_generated_media(*, team, user, project, task, media: str, name: str, return asset -def generate_base_asset(*, project, user, kind: str, prompt: str) -> BaseAssetGroup: +def generate_base_asset(*, project, user, kind: str, prompt: str, label: str = "") -> BaseAssetGroup: model_config = get_default_model(ModelConfig.Capability.IMAGE) if model_config is None: raise ValueError("no active image model configured") @@ -455,7 +569,9 @@ def generate_base_asset(*, project, user, kind: str, prompt: str) -> BaseAssetGr category=category, asset_type=Asset.Type.IMAGE, ) - group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt) + # label = 该资产对应的脚本提取标签(人物/场景名),用于把生成结果归回对应的标签卡 + group_meta = {"label": label.strip()} if label and label.strip() else {} + group = BaseAssetGroup.objects.create(project=project, kind=kind, task=task, prompt=prompt, metadata=group_meta) group.candidate_assets.add(asset) group.adopted_asset = asset group.save(update_fields=["adopted_asset", "updated_at"]) diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index ffeabe5..8dbe4ef 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -109,7 +109,8 @@ class ProjectApiTests(TestCase): self.assertEqual(response.status_code, 201) script = ScriptVersion.objects.get(project=project) self.assertEqual(script.segments.count(), 4) - self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1) + # 出稿(1)+ 人物/场景提取(1)各计费一次;此处 mock 提取返回非 JSON,提取仍按调用成功计费 + self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 2) @patch("apps.ai.services.VolcanoArkProvider") def test_rerun_script_segment_updates_one_segment_and_charges_once(self, provider_cls): @@ -152,6 +153,87 @@ class ProjectApiTests(TestCase): ) self.assertEqual(response.status_code, 404) + # ── 流程文档 步骤③④:出稿后自动提取人物/场景(含建议提示词)并计费 ── + @patch("apps.ai.services.VolcanoArkProvider") + def test_generate_script_extracts_cast_scenes_and_bills(self, provider_cls): + provider = provider_cls.return_value + script_text = "镜头1\n旁白:开场\n画面:地铁里女主掏出面膜\n\n镜头2\n旁白:卖点\n画面:同事围观" + # 提取调用让模型回 JSON(允许外面裹解释文字,验证容错) + extract_text = '这是结果:{"cast":[{"name":"女主","prompt":"26岁都市女性,自然妆"},{"name":"同事","prompt":"职场女性"}],"scenes":[{"name":"地铁","prompt":"早高峰地铁车厢"}]}' + provider.chat_completion.side_effect = [{"r": 1}, {"r": 2}] + provider.extract_text.side_effect = [script_text, extract_text] + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + ProjectStage.objects.create(project=project, stage=ProjectStage.Stage.SCRIPT) + + response = self.client.post(f"/api/projects/{project.id}/generate-script/", {"prompt": "x"}, format="json") + self.assertEqual(response.status_code, 201) + + project.refresh_from_db() + self.assertEqual(project.metadata.get("cast"), ["女主", "同事"]) + self.assertEqual(project.metadata.get("scenes"), ["地铁"]) + self.assertEqual(project.metadata.get("cast_prompts", {}).get("女主"), "26岁都市女性,自然妆") + self.assertEqual(project.metadata.get("scene_prompts", {}).get("地铁"), "早高峰地铁车厢") + # 出稿 + 提取,各计费一次 + self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 2) + + @patch("apps.ai.services.VolcanoArkProvider") + def test_extract_failure_releases_credit_and_keeps_script(self, provider_cls): + """提取调用抛错:必须退掉提取的预扣(不计费),且不影响脚本已出稿(出稿那笔照扣)。""" + provider = provider_cls.return_value + provider.chat_completion.side_effect = [{"r": 1}, RuntimeError("ark down")] + provider.extract_text.side_effect = ["镜头1\n旁白:x\n画面:y", AssertionError("不应走到")] + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + ProjectStage.objects.create(project=project, stage=ProjectStage.Stage.SCRIPT) + + response = self.client.post(f"/api/projects/{project.id}/generate-script/", {"prompt": "x"}, format="json") + self.assertEqual(response.status_code, 201) + self.assertEqual(ScriptVersion.objects.filter(project=project).count(), 1) + # 出稿计费 1 笔;提取失败 → release,不产生第 2 笔 CHARGE + self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1) + + @patch("apps.ai.services._store_generated_media") + @patch("apps.ai.services.get_image_provider") + def test_generate_base_asset_stores_tag_label(self, get_provider, store_media): + """流程步骤④:按脚本标签生成基础资产时,label 落进 group.metadata,供前端把结果归回标签卡。""" + ModelConfig.objects.create( + provider=self.provider, name="img-model", display_name="Img", + capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000", + ) + asset = Asset.objects.create( + team=self.team, created_by=self.user, name="女主", + asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON, + ) + store_media.return_value = asset + provider = get_provider.return_value + provider.image_generation.return_value = {"data": [{"url": "http://x/img.png"}]} + provider.extract_first_media_url.return_value = "http://x/img.png" + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + + response = self.client.post( + f"/api/projects/{project.id}/generate-base-asset/", + {"kind": "person", "prompt": "26岁都市女性", "label": "女主"}, + format="json", + ) + self.assertEqual(response.status_code, 201) + from apps.projects.models import BaseAssetGroup + group = BaseAssetGroup.objects.get(id=response.data["id"]) + self.assertEqual(group.metadata.get("label"), "女主") + + def test_extract_cast_and_scenes_parsing_is_robust(self): + """纯函数:_coerce_tag_entries 去重/容错;无可用模型时 extract 返回空且不抛。""" + from apps.ai.services import _coerce_tag_entries, extract_cast_and_scenes + names, prompts = _coerce_tag_entries( + [{"name": "女主", "prompt": "p1"}, {"name": "女主", "prompt": "dup"}, {"name": "无"}, "同事"] + ) + self.assertEqual(names, ["女主", "同事"]) # 去重 + 过滤占位「无」+ 容忍纯字符串项 + self.assertEqual(prompts["女主"], "p1") + ModelConfig.objects.filter(capability=ModelConfig.Capability.TEXT).update(status=ModelConfig.Status.DISABLED) + project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") + self.assertEqual( + extract_cast_and_scenes(project=project, user=self.user, content="脚本"), + {"cast": [], "scenes": [], "cast_prompts": {}, "scene_prompts": {}}, + ) + def test_adopt_video_version_remaps_timeline_draft(self): """切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。""" project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P") diff --git a/core/backend/apps/projects/views.py b/core/backend/apps/projects/views.py index 588de3b..2bba287 100644 --- a/core/backend/apps/projects/views.py +++ b/core/backend/apps/projects/views.py @@ -157,7 +157,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet): kind = request.data.get("kind") if kind not in BaseAssetGroup.Kind.values: return Response({"detail": "invalid base asset kind"}, status=status.HTTP_400_BAD_REQUEST) - group = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", "")) + group = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", ""), label=request.data.get("label", "")) stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS) stage.status = ProjectStage.Status.NEEDS_REVIEW stage.save(update_fields=["status", "updated_at"]) diff --git a/core/frontend/src/App.tsx b/core/frontend/src/App.tsx index 49e9f0f..39e0203 100644 --- a/core/frontend/src/App.tsx +++ b/core/frontend/src/App.tsx @@ -683,7 +683,7 @@ export function App() { } onAdoptVideoVersion={(segmentId, versionId) => action(() => api.adoptVideoVersion(pipelineProject.id, { video_segment_id: segmentId, version_id: versionId }), "已采用该版本")} onGenerateVoiceover={(payload) => action(() => api.generateVoiceover(pipelineProject.id, payload), "配音已生成")} - onGenerateBaseAsset={(kind, prompt) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt }), "基础资产已生成")} + onGenerateBaseAsset={(kind, prompt, label) => action(() => api.generateBaseAsset(pipelineProject.id, { kind, prompt, label }), "基础资产已生成")} onGenerateStoryboard={(prompt) => action(async () => { // 异步故事板:提交(秒回)后轮询;后端在后台线程逐帧生成,poll 永远秒回,故每轮间隔等待 diff --git a/core/frontend/src/api.ts b/core/frontend/src/api.ts index ec9a424..18e91aa 100644 --- a/core/frontend/src/api.ts +++ b/core/frontend/src/api.ts @@ -248,7 +248,7 @@ export const api = { generateVoiceover(projectId: string, payload: { items: Array<{ index: number; text: string }>; voice_type?: string; speed_ratio?: number }) { return request<{ voiceover: VoiceoverInfo }>(`/api/projects/${projectId}/generate-voiceover/`, { method: "POST", body: JSON.stringify(payload) }); }, - generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string }) { + generateBaseAsset(projectId: string, payload: { kind: "product" | "person" | "scene"; prompt: string; label?: string }) { return request(`/api/projects/${projectId}/generate-base-asset/`, { method: "POST", body: JSON.stringify(payload) }); }, adoptBaseAsset(projectId: string, payload: { group_id: string; asset_id: string }) { diff --git a/core/frontend/src/pipeline-page.css b/core/frontend/src/pipeline-page.css index ba2a1ff..dd3ff40 100644 --- a/core/frontend/src/pipeline-page.css +++ b/core/frontend/src/pipeline-page.css @@ -271,6 +271,9 @@ .asset-card-2 { background: var(--surface); border: 1px solid var(--border-faint); border-radius: var(--r-md); cursor: pointer; transition: border-color var(--t-base), box-shadow var(--t-base); overflow: hidden; display: flex; flex-direction: column; } .asset-card-2:hover { border-color: var(--heat-40); box-shadow: 0 1px 3px rgba(0,0,0,.04); } + /* 流程步骤4 · 来自脚本提取、尚未生成的 seed 卡:虚线边 + 占位灰底,区别于已生成卡 */ + .asset-card-2.asset-seed { border-style: dashed; cursor: default; } + .asset-card-2.asset-seed .thumb-2 { opacity: .7; } .asset-card-2 .thumb-2 { aspect-ratio: 1; } .asset-card-2 .body-2 { padding: 12px 14px; } .asset-card-2 .body-2 .btn:disabled, .asset-card-2 .body-2 .btn.disabled { background: transparent; border-color: transparent; color: var(--black-alpha-32); box-shadow: none; cursor: not-allowed; opacity: .72; transform: none; } @@ -495,6 +498,15 @@ .script-chip .chip-x:hover { background: var(--black-alpha-08); color: var(--accent-crimson); } .tag-add-input { height: 22px; width: 76px; padding: 0 8px; font-size: 12px; font-family: inherit; color: var(--accent-black); background: var(--surface); border: 1px solid var(--heat); border-radius: var(--r-pill); outline: none; } .tag-add-input::placeholder { color: var(--black-alpha-40); } + /* 流程步骤3 · 标签待确认态:待删划掉变灰、待加虚线高亮(点发送才真正重写) */ + .script-chip.pending-remove { color: var(--black-alpha-40); text-decoration: line-through; border-style: dashed; } + .script-chip.pending-add { border-style: dashed; border-color: var(--heat); color: var(--heat); background: var(--surface); } + /* 输入框上方的「待确认增删」胶囊行 */ + .chat-tagedit-row { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 0 8px; } + .chat-tagedit-row[hidden] { display: none; } + .chat-tagedit-row .tagedit-chip { font-size: 12px; } + .chat-tagedit-row .tagedit-chip.add { border-color: var(--heat); color: var(--heat); } + .chat-tagedit-row .tagedit-chip.remove { color: var(--black-alpha-48); text-decoration: line-through; } /* ── 行33 · 进度提示流 ── */ .progress-stream { display: flex; flex-direction: column; gap: 6px; } diff --git a/core/frontend/src/routes/pipeline.tsx b/core/frontend/src/routes/pipeline.tsx index 45e16ae..3b47b14 100644 --- a/core/frontend/src/routes/pipeline.tsx +++ b/core/frontend/src/routes/pipeline.tsx @@ -25,7 +25,8 @@ const VO_VOICES = [ { key: "BV002_streaming", label: "通用男声" }, ]; // 新建向导落进 metadata.wizard 的是选项 key,这里映射回中文(对齐 projects.tsx 的 WIZ_STYLES/WIZ_PERSONAS) -const WIZ_STYLE_LABEL: Record = { pain: "痛点种草", review: "开箱测评", compare: "对比展示" }; +// 脚本风格预设(顺序对齐设计稿设定卡下拉:真实测评 / 痛点种草 / 小红书种草 / 开箱测评 / 对比展示) +const WIZ_STYLE_LABEL: Record = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" }; const WIZ_PERSONA_LABEL: Record = { urban: "都市白领女性", bestie: "闺蜜种草", ceo: "总裁亲选", reviewer: "专业测评师", mom: "实用宝妈", genz: "学生党" }; const THEME_TIP = "好,用一句话描述主题(5-30 字),例如「熬夜党的早八续命面膜」,输入后点发送。"; // 视频片段状态 pill 文案(色调走 statusPill:ok/info/err/neutral) @@ -371,7 +372,7 @@ export function PipelinePage(props: { onSaveProjectMeta?: (meta: Record) => Promise; onAdoptVideoVersion: (segmentId: string, versionId: string) => Promise; onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise; - onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string) => void; + onGenerateBaseAsset: (kind: "product" | "person" | "scene", prompt: string, label?: string) => void; onAdoptBaseAsset: (groupId: string, assetId: string) => void | Promise; onGenerateStoryboard: (prompt: string) => void; onSkipStoryboard: () => Promise; @@ -425,9 +426,43 @@ export function PipelinePage(props: { // 后台刷新(action 后 refreshProjectDetail)回填的 metadata 同步到本地态,保证多端/重进一致 useEffect(() => { setCastTags(project.metadata?.cast ?? []); }, [project.metadata?.cast]); useEffect(() => { setSceneTags(project.metadata?.scenes ?? []); }, [project.metadata?.scenes]); - // 标签增删:先本地更新(即时反馈)再合并落库 - const saveCastTags = (next: string[]) => { setCastTags(next); void onSaveProjectMeta?.({ cast: next }); }; - const saveSceneTags = (next: string[]) => { setSceneTags(next); void onSaveProjectMeta?.({ scenes: next }); }; + // 行34/流程步骤3 · 标签「待确认修改」队列:点 chip × 或加新标签都不立即落库,而是排进这里, + // 在脚本助手输入框上方显示为可撤销胶囊;点发送才把这些增删拼成指令整体重写脚本(AI 重写后会重新提取标签)。 + type TagEdit = { id: number; op: "add" | "remove"; kind: "cast" | "scene"; value: string }; + const [pendingTagEdits, setPendingTagEdits] = useState([]); + const tagEditId = () => Date.now() + Math.floor(Math.random() * 1000); + const removingSet = (kind: "cast" | "scene") => + new Set(pendingTagEdits.filter((e) => e.kind === kind && e.op === "remove").map((e) => e.value)); + const addingList = (kind: "cast" | "scene") => + pendingTagEdits.filter((e) => e.kind === kind && e.op === "add").map((e) => e.value); + // 删除某标签:若它本是「待加」则直接撤掉那条 add(等于没加过);否则排一条 remove + function queueRemoveTag(kind: "cast" | "scene", value: string) { + setPendingTagEdits((list) => { + const addEntry = list.find((e) => e.kind === kind && e.op === "add" && e.value === value); + if (addEntry) return list.filter((e) => e !== addEntry); + if (list.some((e) => e.kind === kind && e.op === "remove" && e.value === value)) return list; + return [...list, { id: tagEditId(), op: "remove", kind, value }]; + }); + } + // 添加某标签:若它本是「待删」则撤掉删除(等于恢复);否则(且原本不存在)排一条 add + function queueAddTag(kind: "cast" | "scene", raw: string) { + const value = raw.trim(); + if (!value) return; + setPendingTagEdits((list) => { + const rmEntry = list.find((e) => e.kind === kind && e.op === "remove" && e.value === value); + if (rmEntry) return list.filter((e) => e !== rmEntry); + const base = kind === "cast" ? castTags : sceneTags; + if (base.includes(value) || list.some((e) => e.kind === kind && e.op === "add" && e.value === value)) return list; + return [...list, { id: tagEditId(), op: "add", kind, value }]; + }); + } + const undoTagEdit = (id: number) => setPendingTagEdits((list) => list.filter((e) => e.id !== id)); + // 已落库标签的 × :已排队待删 → 撤销恢复;否则排一条待删 + function toggleRemoveTag(kind: "cast" | "scene", value: string) { + const rm = pendingTagEdits.find((e) => e.kind === kind && e.op === "remove" && e.value === value); + if (rm) undoTagEdit(rm.id); else queueRemoveTag(kind, value); + } + const tagEditVerb = (e: TagEdit) => `${e.op === "add" ? "添加" : "删除"}${e.kind === "cast" ? "人物" : "场景"}:${e.value}`; // 行34 · 「添加分镜」插入的本地空白可编辑卡(尚未落库;失焦有内容才真正 onAddShot) type DraftShot = { id: string; afterId: string | null; narration: string; visual: string }; const [draftShots, setDraftShots] = useState([]); @@ -654,7 +689,28 @@ export function PipelinePage(props: { } await runScriptGeneration(`AI 全生 · 风格:${styleLabel},目标人群:${personaLabel}。突出商品卖点,节奏紧凑,适合短视频投放`, `${sourceLabel}:${styleLabel} · ${personaLabel}`, "ai"); } + // 流程步骤3 · 把待确认的标签增删拼成给 AI 的重写指令 + 用户消息标签 + function tagEditsInstruction() { + if (!pendingTagEdits.length) return ""; + const parts = pendingTagEdits.map((e) => `${e.op === "add" ? "添加" : "删除"}${e.kind === "cast" ? "人物" : "场景"}「${e.value}」`); + return `请据此调整并整体重写脚本:${parts.join("、")}。其余分镜尽量保持不变。`; + } + // 发送框统一入口:设定卡开着 → 走 setup 生成;否则把「待确认标签增删 + 输入文字」合成一条整体重写 + function submitChat() { + if (loading) return; + if (setupOpen) { void runScriptWithSetup(); return; } + const text = chatText.trim(); + const tagInstr = tagEditsInstruction(); + if (!text && !tagInstr) return; + const prompt = [tagInstr, text].filter(Boolean).join("\n"); + const label = [pendingTagEdits.map(tagEditVerb).join(" · "), text].filter(Boolean).join(" · "); + setChatText(""); + setChatAttachments([]); + setPendingTagEdits([]); + void runScriptGeneration(prompt, label || undefined); + } function clearChat() { + setPendingTagEdits([]); setChatText(""); setChatAttachments([]); setChatMode("ai"); @@ -1632,28 +1688,36 @@ export function PipelinePage(props: { 风格{(() => { const k = project.metadata?.wizard?.script_style; return k ? (WIZ_STYLE_LABEL[k] || k) : "待确认"; })()} 人物{(() => { const k = project.metadata?.wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()} - {/* 行34 · 该脚本的人物 / 场景标签:可编辑(增删 chip);出现分镜后才展示 */} + {/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列, + 点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */}
-
- // 人物 - {castTags.map((tag, i) => ( - - {tag} - - - ))} - { if (!castTags.includes(v)) saveCastTags([...castTags, v]); }} ariaLabel="添加人物" /> -
-
- // 场景 - {sceneTags.map((tag, i) => ( - - {tag} - - - ))} - { if (!sceneTags.includes(v)) saveSceneTags([...sceneTags, v]); }} ariaLabel="添加场景" /> -
+ {(["cast", "scene"] as const).map((kind) => { + const baseTags = kind === "cast" ? castTags : sceneTags; + const removing = removingSet(kind); + const adds = addingList(kind); + const label = kind === "cast" ? "人物" : "场景"; + return ( +
+ // {label} + {baseTags.map((tag, i) => { + const pendingRemove = removing.has(tag); + return ( + + {tag} + + + ); + })} + {adds.map((tag) => ( + + {tag} + + + ))} + queueAddTag(kind, v)} ariaLabel={`添加${label}`} /> +
+ ); + })}
@@ -1824,6 +1888,15 @@ export function PipelinePage(props: {
+ {/* 流程步骤3 · 待确认的标签增删:悬浮显示「添加/删除人物/场景:xxx」,× 撤销,点发送整体重写 */} + @@ -1948,19 +2015,58 @@ export function PipelinePage(props: { {(["person", "scene"] as const).map((kind) => { const list = groupsByKind(kind); + // 流程步骤4 · 人物←脚本提取的人物标签,场景←场景标签;提示词用脚本提取时 AI 生成的(metadata.*_prompts) + const tags = kind === "person" ? castTags : sceneTags; + const promptMap = (kind === "person" ? project.metadata?.cast_prompts : project.metadata?.scene_prompts) || {}; + const fallbackPrompt = (tag: string) => kind === "person" + ? `${tag},真人模特出镜,自然光,${productName} 上身展示,9:16 竖屏` + : `${tag},使用场景,氛围统一,干净构图,9:16 竖屏`; + const tagPrompt = (tag: string) => (promptMap[tag]?.trim() || fallbackPrompt(tag)); + // 已生成的标签(按 group.metadata.label 归类),其余标签显示为「待生成」seed 卡 + const generatedLabels = new Set(list.map((g) => g.metadata?.label).filter(Boolean) as string[]); + const pendingTags = tags.filter((t) => !generatedLabels.has(t)); const genPrompt = kind === "person" ? `${productName} 真人模特出镜,自然光,商品上身展示,9:16 竖屏` : `${productName} 使用场景,氛围统一,干净构图,9:16 竖屏`; return (
-

{KIND_LABEL[kind]} · {list.length} 个

+

{KIND_LABEL[kind]} · {list.length}{tags.length ? ` / ${tags.length}` : ""} 个

+ {/* 流程步骤4 · 脚本提取出但还没生成的人物/场景:逐个 seed 卡,显示 AI 提示词,可改后生成 */} + {pendingTags.length > 0 && ( +
+ {pendingTags.map((tag) => { + const seedKey = `seed:${kind}:${tag}`; + const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag); + return ( +
+
{tag} · 待生成
+
+
+ {tag} + + 来自脚本 +
+