feat(脚本): 按流程文档重写脚本阶段(自动提取人物/场景 + 标签胶囊改写 + 基础资产按标签 seed)

后端:
- 出稿后新增轻量提取调用,从脚本抽取人物/场景标签及每个标签的建议生图提示词,
  写入 project.metadata(cast/scenes/cast_prompts/scene_prompts)
- 提取调用接入计费闭环(reserve→charge/release,记为 script_optimization);
  best-effort:无模型/预扣失败/调用失败/解析失败都吞掉返回空,绝不阻断出稿
- generate_base_asset 增加 label,落 group.metadata,把生成结果归回脚本标签卡

前端:
- 设定卡风格补齐到 5 个(真实测评/痛点种草/小红书种草/开箱测评/对比展示)
- 标签改为待确认胶囊流:增删不立即落库,排进队列在输入框上方显示可撤销胶囊,
  点发送才整体重写;待删划线、待加虚线
- 基础资产人物/场景按提取标签逐个 seed 卡,显示 AI 提示词、可改后生成

测试:新增提取+计费+release+label 共 4 个用例,全套 24 个通过(sqlite test 设置)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-16 17:59:41 +08:00
co-authored by Claude Opus 4.8
parent 76b3ce94f9
commit 366e077c05
8 changed files with 370 additions and 46 deletions
+118 -2
View File
@@ -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"])
+83 -1
View File
@@ -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")
+1 -1
View File
@@ -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"])
+1 -1
View File
@@ -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 永远秒回,故每轮间隔等待
+1 -1
View File
@@ -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 }) {
+12
View File
@@ -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; }
+145 -36
View File
@@ -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<string, string> = { pain: "痛点种草", review: "开箱测评", compare: "对比展示" };
// 脚本风格预设(顺序对齐设计稿设定卡下拉:真实测评 / 痛点种草 / 小红书种草 / 开箱测评 / 对比展示)
const WIZ_STYLE_LABEL: Record<string, string> = { real: "真实测评", pain: "痛点种草", xhs: "小红书种草", review: "开箱测评", compare: "对比展示" };
const WIZ_PERSONA_LABEL: Record<string, string> = { 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<string, unknown>) => Promise<unknown>;
onAdoptVideoVersion: (segmentId: string, versionId: string) => Promise<unknown>;
onGenerateVoiceover: (payload: { items: Array<{ index: number; text: string }>; voice_type?: string }) => Promise<unknown>;
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<unknown>;
onGenerateStoryboard: (prompt: string) => void;
onSkipStoryboard: () => Promise<unknown>;
@@ -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<TagEdit[]>([]);
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<DraftShot[]>([]);
@@ -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: {
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-style">{(() => { const k = project.metadata?.wizard?.script_style; return k ? (WIZ_STYLE_LABEL[k] || k) : "待确认"; })()}</span></span>
<span className="pill neutral script-brief-pill"><span className="k"></span><span className="v" id="brief-persona">{(() => { const k = project.metadata?.wizard?.persona; return k ? (WIZ_PERSONA_LABEL[k] || k) : "待确认"; })()}</span></span>
</div>
{/* 行34 · 该脚本的人物 / 场景标签:可编辑(增删 chip);出现分镜后才展示 */}
{/* 行34/流程步骤3 · 人物 / 场景标签:AI 出稿自动提取;增删不立即生效,排进待确认队列,
点发送才整体重写。待删的灰删除线、待加的虚线高亮,均可在输入框上方胶囊撤销。 */}
<div className="script-tags" id="script-tags">
<div className="tag-group" data-kind="char">
<span className="tg-lbl">// 人物</span>
{castTags.map((tag, i) => (
<span className="script-chip" key={`cast-${i}-${tag}`}>
{(["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 (
<div className="tag-group" data-kind={kind === "cast" ? "char" : "scene"} key={kind}>
<span className="tg-lbl">// {label}</span>
{baseTags.map((tag, i) => {
const pendingRemove = removing.has(tag);
return (
<span className={`script-chip${pendingRemove ? " pending-remove" : ""}`} key={`${kind}-${i}-${tag}`}>
{tag}
<button type="button" className="chip-x" aria-label={`移除人物 ${tag}`} onClick={() => saveCastTags(castTags.filter((_, j) => j !== i))}>×</button>
<button type="button" className="chip-x" aria-label={`${pendingRemove ? "恢复" : "移除"}${label} ${tag}`} title={pendingRemove ? "待删除 · 点恢复" : undefined} onClick={() => toggleRemoveTag(kind, tag)}>{pendingRemove ? "↺" : "×"}</button>
</span>
);
})}
{adds.map((tag) => (
<span className="script-chip pending-add" key={`${kind}-add-${tag}`} title="待添加 · 点发送生效">
{tag}
<button type="button" className="chip-x" aria-label={`撤销添加${label} ${tag}`} onClick={() => queueRemoveTag(kind, tag)}>×</button>
</span>
))}
<AddTagInline placeholder="人物名" onAdd={(v) => { if (!castTags.includes(v)) saveCastTags([...castTags, v]); }} ariaLabel="添加人物" />
</div>
<div className="tag-group" data-kind="scene">
<span className="tg-lbl">// 场景</span>
{sceneTags.map((tag, i) => (
<span className="script-chip" key={`scene-${i}-${tag}`}>
{tag}
<button type="button" className="chip-x" aria-label={`移除场景 ${tag}`} onClick={() => saveSceneTags(sceneTags.filter((_, j) => j !== i))}>×</button>
</span>
))}
<AddTagInline placeholder="场景名" onAdd={(v) => { if (!sceneTags.includes(v)) saveSceneTags([...sceneTags, v]); }} ariaLabel="添加场景" />
<AddTagInline placeholder={`${label}`} onAdd={(v) => queueAddTag(kind, v)} ariaLabel={`添加${label}`} />
</div>
);
})}
</div>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" id="chat-regen-btn" disabled={loading} onClick={() => void runScriptGeneration("整体重新生成 · 突出商品卖点,节奏紧凑,适合短视频投放", "整体重写")}> </button>
@@ -1824,6 +1888,15 @@ export function PipelinePage(props: {
</div>
<div className="chat-input">
<div className="chat-input-card">
{/* 流程步骤3 · 待确认的标签增删:悬浮显示「添加/删除人物/场景:xxx」,× 撤销,点发送整体重写 */}
<div className="chat-tagedit-row" id="chat-tagedit-row" hidden={pendingTagEdits.length === 0}>
{pendingTagEdits.map((edit) => (
<span className={`chip tagedit-chip ${edit.op}`} key={edit.id} title={`${tagEditVerb(edit)}(点发送确定更改)`}>
{tagEditVerb(edit)}
<button type="button" aria-label={`撤销 ${tagEditVerb(edit)}`} style={{ marginLeft: 4, background: "none", border: 0, cursor: "pointer", color: "inherit" }} onClick={() => undoTagEdit(edit.id)}>×</button>
</span>
))}
</div>
<div className="chat-attach-row" id="chat-attach-row" hidden={chatAttachments.length === 0}>
{chatAttachments.map((att, index) => (
<span className="chip" key={`${att.name}-${index}`} style={{ marginRight: 6 }}>
@@ -1838,13 +1911,7 @@ export function PipelinePage(props: {
// 行31 · Enter 发送,Shift+Enter 换行(输入法组合期不触发)
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
event.preventDefault();
if (loading) return;
if (setupOpen) { void runScriptWithSetup(); return; }
const text = chatText.trim();
if (!text) return;
setChatText("");
setChatAttachments([]);
void runScriptGeneration(text);
submitChat();
}
}}></textarea>
<input ref={chatFileRef} type="file" accept=".txt,.md,.text,text/plain" style={{ display: "none" }} onChange={onPickScriptFile} />
@@ -1853,7 +1920,7 @@ export function PipelinePage(props: {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"><path d="M12 5v14M5 12h14" /></svg>
</button>
<span className="spacer"></span>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || (!setupOpen && !chatText.trim())} onClick={() => { if (setupOpen) { void runScriptWithSetup(); return; } const text = chatText.trim(); if (!text) return; setChatText(""); setChatAttachments([]); void runScriptGeneration(text); }}>
<button className="chat-send-btn" id="chat-send-btn" type="button" title="发送" aria-label="发送" disabled={loading || (!setupOpen && !chatText.trim() && pendingTagEdits.length === 0)} onClick={submitChat}>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round"><path d="M5 12h14M13 6l6 6-6 6" /></svg>
</button>
</div>
@@ -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 (
<section className="asset-sec" id={`asset-sec-${kind}`} key={kind}>
<div className="sec-h">
<h3>{KIND_LABEL[kind]} · {list.length} </h3>
<h3>{KIND_LABEL[kind]} · {list.length}{tags.length ? ` / ${tags.length}` : ""} </h3>
<span className="spacer"></span>
<button className="btn-aigen" type="button" data-stop disabled={loading} onClick={() => onGenerateBaseAsset(kind, genPrompt)}>
<svg className="ai-spark" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6L12 3z" /><path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7L19 14z" /></svg>
AI {KIND_LABEL[kind]}
{KIND_LABEL[kind]}
</button>
</div>
{/* 流程步骤4 · 脚本提取出但还没生成的人物/场景:逐个 seed 卡,显示 AI 提示词,可改后生成 */}
{pendingTags.length > 0 && (
<div className="asset-grid-2" style={{ marginBottom: list.length ? "10px" : 0 }}>
{pendingTags.map((tag) => {
const seedKey = `seed:${kind}:${tag}`;
const promptValue = assetPromptDraft[seedKey] ?? tagPrompt(tag);
return (
<div className="asset-card-2 asset-seed" data-asset-kind={kind} data-seed-tag={tag} key={seedKey}>
<div className="placeholder thumb-2"><span className="ph-frame">{tag} · </span></div>
<div className="body-2">
<div className="hstack">
<strong style={{ fontSize: "14px" }}>{tag}</strong>
<span className="spacer"></span>
<span className="pill neutral"><span className="dot"></span></span>
</div>
<textarea className="asset-prompt-edit" rows={3} placeholder="描述这个素材的提示词…" value={promptValue} onChange={(e) => setAssetPromptDraft((m) => ({ ...m, [seedKey]: e.target.value }))} />
<div className="asset-card-actions">
<span className="spacer"></span>
<button className="btn btn-primary btn-sm" type="button" disabled={loading} onClick={() => onGenerateBaseAsset(kind, (promptValue.trim() || tagPrompt(tag)), tag)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M12 3l1.6 4.4L18 9l-4.4 1.6L12 15l-1.6-4.4L6 9l4.4-1.6z" /></svg>
AI
</button>
</div>
</div>
</div>
);
})}
</div>
)}
{list.length ? (
<div className="asset-grid-2">
{list.map((group, gi) => {
@@ -1968,14 +2074,17 @@ export function PipelinePage(props: {
const cands = (group.candidate_assets ?? []).filter((id) => id !== group.adopted_asset).slice(0, 4);
// 行38 · 可编辑提示词:草稿优先,落空回退原 prompt;重跑/替换都带它
const promptValue = assetPromptDraft[group.id] ?? group.prompt ?? "";
// 流程步骤4 · 该卡对应的脚本标签(若有),作卡片标题并在重跑/替换时保留归类
const label = group.metadata?.label || "";
const cardName = label || assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`;
return (
<div className="asset-card-2" data-asset-kind={kind} data-asset-id={group.id} key={group.id}>
<div className={`placeholder thumb-2${mainUrl ? " has-mock-media" : ""}`} style={mainUrl ? mediaStyle(mainUrl) : undefined}>
<span className="ph-frame">{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</span>
<span className="ph-frame">{cardName}</span>
</div>
<div className="body-2">
<div className="hstack">
<strong style={{ fontSize: "14px" }}>{assetName(group.adopted_asset) || `${KIND_LABEL[kind]} ${gi + 1}`}</strong>
<strong style={{ fontSize: "14px" }}>{cardName}</strong>
<span className="spacer"></span>
{group.adopted_asset
? <span className="pill ok"><span className="dot"></span></span>
@@ -1998,12 +2107,12 @@ export function PipelinePage(props: {
)}
{/* 行38 · 重跑 / 替换:重跑=据当前提示词重新生成;替换=有候选先采下一张,无候选回退重生成 */}
<div className="asset-card-actions">
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt))}>
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt), label)}>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round" style={{ marginRight: 4 }}><path d="M4 12a8 8 0 0 1 14-5.5L21 9" /><path d="M21 4v5h-5" /><path d="M20 12a8 8 0 0 1-14 5.5L3 15" /><path d="M3 20v-5h5" /></svg>
</button>
<span className="spacer"></span>
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => { if (cands.length) { void onAdoptBaseAsset(group.id, cands[0]); } else { onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt)); } }}></button>
<button className="btn btn-ghost btn-sm" type="button" disabled={loading} onClick={() => { if (cands.length) { void onAdoptBaseAsset(group.id, cands[0]); } else { onGenerateBaseAsset(kind, (promptValue.trim() || genPrompt), label); } }}></button>
</div>
</div>
</div>
+5
View File
@@ -183,6 +183,8 @@ export type Project = {
adopted_asset_url?: string;
candidate_assets: string[];
candidate_asset_urls?: Record<string, string>;
// 流程步骤4 · label = 该资产对应的脚本提取标签(人物/场景名),把生成结果归回标签卡
metadata?: { label?: string } & Record<string, unknown>;
created_at?: string;
}>;
storyboard_versions: StoryboardVersion[];
@@ -191,6 +193,9 @@ export type Project = {
wizard?: { duration?: string; script_style?: string; persona?: string; selling_point_ids?: string[] };
cast?: string[];
scenes?: string[];
// 流程步骤3/4 · 脚本提取出的每个人物/场景的建议生图提示词(基础资产据此 seed 卡片)
cast_prompts?: Record<string, string>;
scene_prompts?: Record<string, string>;
} & Record<string, unknown>;
created_at: string;
updated_at: string;