fix(core): 测试清单 行24/28-39/46 — 流水线脚本&资产/消息页 + 单镜重跑后端接口
前端流水线(pipeline.tsx):脚本助手三选项指引(AI全生/一句话/自带脚本)+来源风格推荐;
风格/人物设定卡并持久化到 project.metadata;Enter发送/Shift+Enter换行;长文折叠10行;
生成进度流提示;分镜人物/场景标签可编辑并持久化+点击添加分镜;单镜重跑/删除即时反馈;
脚本助手记录按项目 localStorage 持久化;Stage2 基础资产补替换/重跑/可编辑提示词;
三视图采用弹窗+缺三视图气泡。
消息页(messages):复用全站 .search-inline 搜索框、去掉小标题背景方块。
视频项目(projects):列表按创建时间倒序,新建置顶。
性能:action 改后台 hydrate,消除创建/确认后白等(行29/37)。
后端(projects/ai):新增单镜 AI 重跑接口 POST /projects/{id}/rerun-script-segment/
(regenerate_script_segment 服务,复用 AITask+计费 reserve/charge/release;+2 单测)。
前端 build 通过;后端 check 0/无新迁移/apps.projects 14 测试通过。
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -236,6 +236,105 @@ def generate_project_script(*, project, user, user_prompt: str, selling_point_id
|
||||
raise
|
||||
|
||||
|
||||
def build_segment_rerun_prompt(*, project, segment, instruction: str = "") -> list[dict[str, str]]:
|
||||
"""单镜重跑提示词:带商品/卖点上下文 + 该镜当前内容 + 前后镜上下文(保连贯)+ 用户修改意见。
|
||||
只重写这一镜,严格输出「旁白:…/画面:…」两行,供 parse_segment_fields 精确解析。"""
|
||||
product = project.product
|
||||
selling_points = product.selling_points.all()
|
||||
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
|
||||
|
||||
script = segment.script_version
|
||||
siblings = list(script.segments.order_by("sort_order"))
|
||||
prev_seg = next((s for s in reversed(siblings) if s.sort_order < segment.sort_order), None)
|
||||
next_seg = next((s for s in siblings if s.sort_order > segment.sort_order), None)
|
||||
|
||||
def _brief(seg) -> str:
|
||||
narration = (seg.narration or "").strip()
|
||||
visual = (seg.visual_prompt or "").strip()
|
||||
return f"旁白:{narration or '无'};画面:{visual or '无'}"
|
||||
|
||||
system = (
|
||||
"你是电商短视频脚本导演。现在只需要**重写一条分镜**(其它分镜保持不变)。"
|
||||
"结合商品卖点、该镜当前内容、前后镜上下文和用户的修改意见,重新生成这一镜的旁白口播与画面描述,"
|
||||
"并保证与前后镜衔接连贯。严格按以下格式输出两行,不要输出镜头编号或任何其它内容:\n"
|
||||
"旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议"
|
||||
)
|
||||
context_lines = [
|
||||
f"商品标题:{product.title}",
|
||||
f"品牌:{product.brand or '未填写'}",
|
||||
f"类目:{product.category or '未填写'}",
|
||||
f"目标人群:{product.target_audience or '未填写'}",
|
||||
f"卖点:\n{selling_text or '未选择卖点,请根据商品信息自行提炼。'}",
|
||||
"",
|
||||
f"这是第 {segment.sort_order + 1} 镜(共 {len(siblings)} 镜),时长约 {segment.duration_seconds} 秒。",
|
||||
f"该镜当前内容:{_brief(segment)}",
|
||||
]
|
||||
if prev_seg is not None:
|
||||
context_lines.append(f"上一镜(保持不变,用于衔接):{_brief(prev_seg)}")
|
||||
if next_seg is not None:
|
||||
context_lines.append(f"下一镜(保持不变,用于衔接):{_brief(next_seg)}")
|
||||
context_lines.append("")
|
||||
context_lines.append(f"用户的修改意见:{instruction.strip() or '让这一镜更有吸引力、表达更清晰,并与前后镜自然衔接。'}")
|
||||
user = "\n".join(context_lines).strip()
|
||||
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
|
||||
|
||||
|
||||
def regenerate_script_segment(*, project, user, segment, instruction: str = "") -> ScriptVersion:
|
||||
"""单镜 AI 重跑:只重新生成该 ScriptSegment 的 narration + visual_prompt(其它镜不动),
|
||||
沿用 generate_project_script 的 AITask + 计费(reserve/charge/release)闭环,同步调 LLM。
|
||||
返回该 segment 所属的 ScriptVersion。"""
|
||||
model_config = get_default_model(ModelConfig.Capability.TEXT)
|
||||
if model_config is None:
|
||||
raise ValueError("no active text model configured")
|
||||
|
||||
messages = build_segment_rerun_prompt(project=project, segment=segment, instruction=instruction)
|
||||
payload = {
|
||||
"model": model_config.name,
|
||||
"endpoint": model_config.endpoint,
|
||||
"messages": messages,
|
||||
"script_segment": str(segment.id),
|
||||
}
|
||||
task = create_ai_task(
|
||||
project=project,
|
||||
user=user,
|
||||
task_type=AITask.Type.SCRIPT_OPTIMIZATION,
|
||||
model_config=model_config,
|
||||
request_payload=payload,
|
||||
)
|
||||
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)
|
||||
content = provider.extract_text(response)
|
||||
narration, visual = parse_segment_fields(content)
|
||||
|
||||
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)
|
||||
|
||||
segment.narration = narration
|
||||
segment.visual_prompt = visual
|
||||
segment.save(update_fields=["narration", "visual_prompt", "updated_at"])
|
||||
return segment.script_version
|
||||
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))
|
||||
raise
|
||||
|
||||
|
||||
def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None":
|
||||
"""用 ffmpeg 抽视频首帧作为封面(poster)并上传 TOS。best-effort:任何失败都返回 None,不影响视频资产落地。"""
|
||||
if not video_bytes:
|
||||
|
||||
Reference in New Issue
Block a user