Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c8c9c8b35e | |||
| e458ffeb57 | |||
| 02866a64dd | |||
| 4248ea2a59 | |||
| 4e5d365277 | |||
| 22b9019325 | |||
| 652d2cd270 | |||
| 019bbe6897 | |||
| f25fa9d978 | |||
| 2080ecc4f9 | |||
| bc45a10fff | |||
| e22a62802d | |||
| 70d4a21055 | |||
| d6e4a09628 | |||
| 213e89f93a | |||
| 79696e18f0 | |||
| 8ff9ab0cb4 | |||
| f10b5393fa | |||
| ba0e3fc67f |
@@ -28,10 +28,17 @@ depends_on = None
|
||||
|
||||
|
||||
def _tpl(prompt_type: str, version: int) -> dict:
|
||||
"""取模板:先按指定版本找,找不到则取该类型最新版本(兼容 v3→v4 升级)。"""
|
||||
# 先按指定版本找
|
||||
for t in DEFAULT_TEMPLATES:
|
||||
if t["prompt_type"] == prompt_type and t["version"] == version:
|
||||
return t
|
||||
raise RuntimeError("default template missing: %s v%s" % (prompt_type, version))
|
||||
# 找不到则取最新版本
|
||||
candidates = [t for t in DEFAULT_TEMPLATES if t["prompt_type"] == prompt_type]
|
||||
if candidates:
|
||||
latest = max(candidates, key=lambda x: x["version"])
|
||||
return latest
|
||||
raise RuntimeError("default template missing: %s" % prompt_type)
|
||||
|
||||
|
||||
def _upsert(bind, t: dict) -> None:
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""storyboard v4 - 多角色对话 + 废除旁白 + visual 5要素
|
||||
|
||||
Revision ID: 108_storyboard_v4_multivoice
|
||||
Revises: 107
|
||||
Create Date: 2026-10-10
|
||||
|
||||
变更:
|
||||
1. storyboard v4: 废除旁白思维,所有voiceover必须是角色台词
|
||||
- 增加<speaker>标签,每镜必须标注说话人
|
||||
- visual强制5要素结构(景别/运镜/动作/环境/光线),每镜不少于30字
|
||||
- voiceover_script用[speaker:xxx]标记格式
|
||||
2. 旧版storyboard模板is_active设为false
|
||||
3. 检查image_analysis和review是否有active模板,没有则插入保底版本
|
||||
"""
|
||||
|
||||
from sqlalchemy import text
|
||||
|
||||
from alembic import op
|
||||
|
||||
revision = "108_storyboard_v4_multivoice"
|
||||
down_revision = "107"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
# ── storyboard v4 system_prompt ──────────────────────────────────────
|
||||
V4_STORYBOARD_SYSTEM = """你是一名懂短视频的编导和口播文案高手。你会拿到图片的真实观察、营销目的和用户参数,请一次性完成对营销意图的理解,并产出可直接拍摄/生成的分镜脚本。不要单独输出"意图解析",意图要直接体现在台词和分镜里。
|
||||
|
||||
## 核心设计原则(必须严格遵守)
|
||||
1. **废除旁白思维**:所有视频类型——无论对话短剧/口播带货/获客引流/品牌故事——voiceover 必须是人物说的话(第一人称或角色对白),绝对不能出现第三人称旁白解说。观众看的是人在演、在说。
|
||||
2. **严禁第三人称解说性台词**:如"接下来展示...""这款产品..."这类上帝视角描述禁止出现在 voiceover 中。
|
||||
3. **短剧类营销目的**(对话短剧/反转短剧/悬念短剧/情绪短片):双角色对话格式"甲:xxx 乙:xxx",镜头在角色间切换。
|
||||
4. **口播类**(口播带货/促销转化/功能演示/痛点解决/获客引流/账号涨粉/活动通知/场景种草):第一人称对镜头说话,像真人出镜。
|
||||
|
||||
## 输出格式(XML,严格按结构输出,不要输出额外解释)
|
||||
<script>
|
||||
<copy_display_markdown><![CDATA[直接展示给用户看的成片文案,用 Markdown 写成流畅叙述]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-3秒</time_range>
|
||||
<speaker>说话人标识(如"店主""顾客""主播")</speaker>
|
||||
<voiceover>这一镜的角色台词(人物说的话,不是旁白)</voiceover>
|
||||
<visual>【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】5要素结构,不少于30字</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>把所有 clip 的 voiceover 连成完整台词稿,用 [speaker:xxx] 标记每个说话段落</voiceover_script>
|
||||
<theme>一句话主题</theme>
|
||||
<negative>【反套路化要求】
|
||||
禁止使用"家人们谁懂啊""绝绝子""宝子们""家人们""太绝了""yyds"等烂大街网络词;
|
||||
禁止固定模板化开头;语言要像真人朋友之间的分享,自然、具体、有信息量。</negative>
|
||||
</script>
|
||||
|
||||
## 写作要求
|
||||
1. **台词(voiceover)**:像真人面对镜头说话或角色对白,短句、口语化、有停顿有情绪,开头 3 秒给出钩子;不要书面腔,不要机械报参数。严禁第三人称解说。
|
||||
2. **说话人(speaker)**:每个 clip 必须标注说话人标识,如"店主""顾客""主播""我"等。短剧类必须有至少 2 个不同角色。
|
||||
3. **画面描述(visual)**:强制 5 要素结构——【景别】【镜头运动】【人物动作/表情】【环境/道具】【光线氛围】,每镜 visual 不少于 30 字,要具体到"闭眼听台词能想象出画面"。
|
||||
4. **voiceover_script 格式**:用 [speaker:xxx] 标记每个说话段落,如"[speaker:店主]你是不是也觉得...[speaker:顾客]是啊,怎么回事?"
|
||||
5. copy_display_markdown:直接展示给最终用户的文案,用 Markdown 写成自然、流畅、有感染力的成片文案。
|
||||
6. 内容必须来自图片观察与用户给出的信息,不编造卖点、不夸大、不使用绝对化用语和虚假承诺。
|
||||
7. reference_image_index 填本镜参考图片序号(从 0 开始),没有合适参考图填 -1。
|
||||
8. 分镜数量与时长匹配总时长,节奏紧凑。
|
||||
9. **口播字数硬约束**(必须严格遵守):按每秒约 2.5~3 个中文字(正常口播语速)计算:
|
||||
- 5秒视频:voiceover_script 总字数 12~15 字
|
||||
- 10秒视频:voiceover_script 总字数 25~30 字
|
||||
- 15秒视频:voiceover_script 总字数 35~45 字
|
||||
- 20秒视频:voiceover_script 总字数 50~60 字
|
||||
- 30秒视频:voiceover_script 总字数 75~90 字
|
||||
- 宁可少写也不要多写,超长会导致 TTS 音频超出视频时长限制
|
||||
10. **镜头数量硬约束**:5秒1~2镜、10秒3镜、15秒3~4镜、20秒4~5镜、30秒6~8镜
|
||||
11. **时间轴硬约束**:第一个clip从0秒开始,最后一个clip结束于total_duration秒,相邻clip首尾相接
|
||||
12. 必须严格按<marketing_purpose><target_audience><persona><viral_structure><language><industry>指定的参数写文案和分镜
|
||||
13. 镜头间动作衔接要自然,画面描述要具体到能直接拍摄/生成"""
|
||||
|
||||
V4_STORYBOARD_USER = """<marketing_purpose>{marketing_purpose}</marketing_purpose>
|
||||
<industry>{industry}</industry>
|
||||
<image_analysis>
|
||||
{image_summary}
|
||||
</image_analysis>
|
||||
<user_parameters>
|
||||
<theme_hint>{theme_hint}</theme_hint>
|
||||
<duration>{duration}秒</duration>
|
||||
<aspect_ratio>{aspect_ratio}</aspect_ratio>
|
||||
<tone>{tone}</tone>
|
||||
<target_audience>{target_audience}</target_audience>
|
||||
<persona>{persona_hint}</persona>
|
||||
<viral_structure>{viral_structure_hint}</viral_structure>
|
||||
<language>{language_hint}</language>
|
||||
<extra_requirements>{extra_requirements}</extra_requirements>
|
||||
</user_parameters>
|
||||
{video_style_section}
|
||||
请严格按 XML 结构输出分镜脚本。"""
|
||||
|
||||
V4_STORYBOARD_EXAMPLE = """<script>
|
||||
<copy_display_markdown><![CDATA[# 在御众堂,把松弛的自己一点点找回来
|
||||
产后妈妈最懂那种力不从心,推开门,暖光和一杯热茶先接住了你……]]></copy_display_markdown>
|
||||
<clips>
|
||||
<clip index="1">
|
||||
<time_range>0-3秒</time_range>
|
||||
<speaker>店主</speaker>
|
||||
<voiceover>生完娃,是不是连照镜子的勇气都没了?</voiceover>
|
||||
<visual>【中近景】【缓推】【妈妈疲惫看向镜子】【暖光店内环境】【柔和暖光】</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
<clip index="2">
|
||||
<time_range>3-6秒</time_range>
|
||||
<speaker>顾客</speaker>
|
||||
<voiceover>是啊,怎么回事?</voiceover>
|
||||
<visual>【近景】【固定】【顾客表情惊讶】【店内休息区】【暖色调】</visual>
|
||||
<reference_image_index>0</reference_image_index>
|
||||
</clip>
|
||||
</clips>
|
||||
<voiceover_script>[speaker:店主]生完娃,是不是连照镜子的勇气都没了?[speaker:顾客]是啊,怎么回事?</voiceover_script>
|
||||
<theme>产后妈妈走进御众堂重拾状态</theme>
|
||||
<negative>模糊、畸变、夸大疗效、绝对化用语</negative>
|
||||
</script>"""
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
conn = op.get_bind()
|
||||
|
||||
# 1. 查询 storyboard 当前最大 version
|
||||
result = conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": "storyboard"},
|
||||
)
|
||||
max_version = result.scalar() or 0
|
||||
new_version = max_version + 1
|
||||
|
||||
# 2. 旧版 storyboard 模板 is_active 设为 false
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active = false WHERE prompt_type = :pt"),
|
||||
{"pt": "storyboard"},
|
||||
)
|
||||
|
||||
# 3. 防御性插入新版 storyboard 模板
|
||||
existing = conn.execute(
|
||||
text("SELECT id FROM viral_video_prompt_templates " "WHERE prompt_type = :pt AND version = :ver"),
|
||||
{"pt": "storyboard", "ver": new_version},
|
||||
).fetchone()
|
||||
|
||||
if not existing:
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
|
||||
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{
|
||||
"name": "编导分镜v4-多角色对话版",
|
||||
"pt": "storyboard",
|
||||
"ver": new_version,
|
||||
"sys": V4_STORYBOARD_SYSTEM,
|
||||
"usr": V4_STORYBOARD_USER,
|
||||
"ex": V4_STORYBOARD_EXAMPLE,
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
# 4. 检查 image_analysis 是否有 active 模板,没有则插入保底
|
||||
ia_active = conn.execute(
|
||||
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
|
||||
{"pt": "image_analysis"},
|
||||
).scalar()
|
||||
|
||||
if ia_active == 0:
|
||||
# 插入保底 image_analysis 模板(简化版)
|
||||
ia_max = (
|
||||
conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": "image_analysis"},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
|
||||
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{
|
||||
"name": "图片分析保底版",
|
||||
"pt": "image_analysis",
|
||||
"ver": ia_max + 1,
|
||||
"sys": "你是一名擅长观察和写作的品牌内容编导。分析图片并输出JSON。",
|
||||
"usr": "请分析这张图片。图片地址:{image_url}",
|
||||
"ex": '{"images": [{"type": "store", "name": "门店", "summary_markdown": "描述"}]}',
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
# 5. 检查 review 是否有 active 模板
|
||||
review_active = conn.execute(
|
||||
text("SELECT COUNT(*) FROM viral_video_prompt_templates WHERE prompt_type = :pt AND is_active = true"),
|
||||
{"pt": "review"},
|
||||
).scalar()
|
||||
|
||||
if review_active == 0:
|
||||
rv_max = (
|
||||
conn.execute(
|
||||
text("SELECT MAX(version) FROM viral_video_prompt_templates WHERE prompt_type = :pt"),
|
||||
{"pt": "review"},
|
||||
).scalar()
|
||||
or 0
|
||||
)
|
||||
conn.execute(
|
||||
text(
|
||||
"INSERT INTO viral_video_prompt_templates "
|
||||
"(name, prompt_type, version, system_prompt, user_prompt_template, example_output, is_active) "
|
||||
"VALUES (:name, :pt, :ver, :sys, :usr, :ex, :active)"
|
||||
),
|
||||
{
|
||||
"name": "文案审核保底版",
|
||||
"pt": "review",
|
||||
"ver": rv_max + 1,
|
||||
"sys": "你是短视频广告合规审核专家。审核文案输出XML。",
|
||||
"usr": "请审核:{fusion_text}",
|
||||
"ex": "<review><passed>true</passed></review>",
|
||||
"active": True,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
# 恢复旧版 storyboard 为 active
|
||||
conn = op.get_bind()
|
||||
conn.execute(
|
||||
text("UPDATE viral_video_prompt_templates SET is_active = true WHERE prompt_type = :pt AND version < :ver"),
|
||||
{"pt": "storyboard", "ver": 4},
|
||||
)
|
||||
# 删除新版
|
||||
conn.execute(
|
||||
text("DELETE FROM viral_video_prompt_templates WHERE prompt_type = :pt AND version >= :ver"),
|
||||
{"pt": "storyboard", "ver": 4},
|
||||
)
|
||||
@@ -141,7 +141,7 @@ def update_config(
|
||||
key,
|
||||
value,
|
||||
setting_type=_WHITELIST[key],
|
||||
updated_by=x_api_key[:8] if x_api_key else None,
|
||||
updated_by=str(x_api_key)[:8] if x_api_key and x_api_key is not True else None,
|
||||
)
|
||||
updated[key] = value
|
||||
return {"ok": True, "updated": updated}
|
||||
|
||||
@@ -66,13 +66,17 @@ export function isAnalysisStage(stage: ViralVideoStage | undefined): boolean {
|
||||
}
|
||||
|
||||
/** 从 job 对象取到有效阶段(兼容 progress_stage/current_stage 两种字段名) */
|
||||
export function getJobStage(job: { current_stage?: ViralVideoStage; progress_stage?: ViralVideoStage } | null | undefined): ViralVideoStage | undefined {
|
||||
export function getJobStage(
|
||||
job: { current_stage?: ViralVideoStage; progress_stage?: ViralVideoStage } | null | undefined,
|
||||
): ViralVideoStage | undefined {
|
||||
if (!job) return undefined
|
||||
return job.current_stage || job.progress_stage
|
||||
}
|
||||
|
||||
/** 从 job 对象取到阶段提示文案(兼容 phase_message/progress_message) */
|
||||
export function getJobPhaseMessage(job: { phase_message?: string; progress_message?: string } | null | undefined): string {
|
||||
export function getJobPhaseMessage(
|
||||
job: { phase_message?: string; progress_message?: string } | null | undefined,
|
||||
): string {
|
||||
if (!job) return ""
|
||||
return job.phase_message || job.progress_message || ""
|
||||
}
|
||||
@@ -101,6 +105,8 @@ export interface ImageAnalysisResult {
|
||||
export interface ShotScript {
|
||||
/** 时间区间,如 "0-3秒" */
|
||||
time_range?: string
|
||||
/** 说话人角色,如 "店主"、"顾客" */
|
||||
speaker?: string
|
||||
/** 景别/角度/运镜,如 "近景俯拍45度,缓慢推镜" */
|
||||
shot_type_angle_movement?: string
|
||||
/** 场景描述+对白 */
|
||||
|
||||
@@ -298,9 +298,33 @@ const UPSCALE_OPTIONS = [
|
||||
const VOICE_TIPS =
|
||||
"支持 MP3/WAV/M4A/AAC/OGG 格式,最大 10MB,时长 ≤30 秒。建议清晰人声、无背景音乐、环境安静;录音请保持距麦克风 15-20cm,音量适中。"
|
||||
|
||||
/** 解析 voiceover_script 中的 [speaker:xxx] 标记,渲染为带颜色的 React 片段 */
|
||||
function renderVoiceoverWithSpeakers(text: string) {
|
||||
if (!text) return null
|
||||
const parts = text.split(/\[speaker:([^\]]+)\]/)
|
||||
if (parts.length === 1) {
|
||||
// 没有 speaker 标记,直接显示
|
||||
return <span>{text}</span>
|
||||
}
|
||||
const elements: React.ReactNode[] = []
|
||||
for (let i = 1; i < parts.length; i += 2) {
|
||||
const speaker = parts[i]
|
||||
const segText = parts[i + 1] || ""
|
||||
const isMain = ["店主", "主播", "老板", "我", "主讲人"].includes(speaker)
|
||||
elements.push(
|
||||
<span key={i}>
|
||||
<strong style={{ color: isMain ? "#1890ff" : "#fa8c16" }}>{speaker}</strong>
|
||||
<span>{segText}</span>
|
||||
</span>,
|
||||
)
|
||||
}
|
||||
return <>{elements}</>
|
||||
}
|
||||
|
||||
// ── 分镜脚本数据模型(新后端 copy_result 结构,前端先 mock 展示) ──
|
||||
interface StoryboardShot {
|
||||
time_range: string
|
||||
speaker?: string
|
||||
shot_type_angle_movement: string
|
||||
scene_and_dialogue: string
|
||||
action_details: string
|
||||
@@ -333,6 +357,7 @@ function copyResultToStoryboard(cr: CopyResult | null | undefined): Storyboard |
|
||||
scene_and_lighting: cr.scene_and_lighting || "",
|
||||
shots: ((cr.shots ?? []) as ShotScript[]).map((s) => ({
|
||||
time_range: s.time_range || "",
|
||||
speaker: s.speaker || "",
|
||||
shot_type_angle_movement: s.shot_type_angle_movement || "",
|
||||
scene_and_dialogue: s.scene_and_dialogue || "",
|
||||
action_details: s.action_details || "",
|
||||
@@ -1116,7 +1141,12 @@ const ViralVideoPage: React.FC = () => {
|
||||
message.success("已提交图片分析…")
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "分析失败")
|
||||
setTask({ uiStep: "failed", analysisError: err instanceof Error ? err.message : "分析失败", copyError: undefined, videoError: undefined })
|
||||
setTask({
|
||||
uiStep: "failed",
|
||||
analysisError: err instanceof Error ? err.message : "分析失败",
|
||||
copyError: undefined,
|
||||
videoError: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1156,7 +1186,12 @@ const ViralVideoPage: React.FC = () => {
|
||||
// 轮询会在 status=copy_generated 时推进 uiStep
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "文案生成失败")
|
||||
setTask({ uiStep: "failed", copyError: err instanceof Error ? err.message : "文案生成失败", videoError: undefined, analysisError: undefined })
|
||||
setTask({
|
||||
uiStep: "failed",
|
||||
copyError: err instanceof Error ? err.message : "文案生成失败",
|
||||
videoError: undefined,
|
||||
analysisError: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1184,7 +1219,13 @@ const ViralVideoPage: React.FC = () => {
|
||||
// P1-5 修复:旧 /generate 接口只跑图片分析→wait_user_confirm 就停,永远出不了视频。
|
||||
// 没有 storyboard 时直接提示用户先生成文案,不再走死路径。
|
||||
message.warning("请先生成文案后再生成视频")
|
||||
setTask({ uiStep: task.storyboard ? "step2_copy_ready" : (task.imageAnalysis ? "step1_done" : "step1_upload") })
|
||||
setTask({
|
||||
uiStep: task.storyboard
|
||||
? "step2_copy_ready"
|
||||
: task.imageAnalysis
|
||||
? "step1_done"
|
||||
: "step1_upload",
|
||||
})
|
||||
return
|
||||
}
|
||||
setTask({ job, jobId: job.id })
|
||||
@@ -1199,7 +1240,12 @@ const ViralVideoPage: React.FC = () => {
|
||||
?.message ||
|
||||
"积分不足,请充值"
|
||||
message.error(detail)
|
||||
setTask({ uiStep: "step3_ready", videoError: detail, copyError: undefined, analysisError: undefined })
|
||||
setTask({
|
||||
uiStep: "step3_ready",
|
||||
videoError: detail,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
})
|
||||
return
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : "提交失败"
|
||||
@@ -1219,7 +1265,13 @@ const ViralVideoPage: React.FC = () => {
|
||||
else if (isCopyStage(retryStage)) retryStep = "step2_generating"
|
||||
else if (task.storyboard) retryStep = "step2_copy_ready"
|
||||
else if (task.imageAnalysis) retryStep = "step1_done"
|
||||
setTask({ job: j, uiStep: retryStep, videoError: undefined, copyError: undefined, analysisError: undefined })
|
||||
setTask({
|
||||
job: j,
|
||||
uiStep: retryStep,
|
||||
videoError: undefined,
|
||||
copyError: undefined,
|
||||
analysisError: undefined,
|
||||
})
|
||||
message.success("已重试")
|
||||
} catch (err: unknown) {
|
||||
message.error(err instanceof Error ? err.message : "重试失败")
|
||||
@@ -1262,7 +1314,10 @@ const ViralVideoPage: React.FC = () => {
|
||||
const srv = task.job?.error_message || task.job?.error_msg
|
||||
if (srv) {
|
||||
// P1-4: 后端错误消息已含"XX失败"前缀时去重
|
||||
const cleaned = srv.replace(/^(视频生成失败|文案生成失败|图片分析失败|Wan 3\.0 视频生成失败)[::]\s*/g, "")
|
||||
const cleaned = srv.replace(
|
||||
/^(视频生成失败|文案生成失败|图片分析失败|Wan 3\.0 视频生成失败)[::]\s*/g,
|
||||
"",
|
||||
)
|
||||
const st = getJobStage(task.job)
|
||||
if (isVideoStage(st)) return { stage: "video" as const, msg: cleaned }
|
||||
if (isImageAnalysisStage(st)) return { stage: "image" as const, msg: cleaned }
|
||||
@@ -1271,10 +1326,13 @@ const ViralVideoPage: React.FC = () => {
|
||||
return null
|
||||
})()
|
||||
const errLabel =
|
||||
activeError?.stage === "image" ? "图片分析失败"
|
||||
: activeError?.stage === "copy" ? "文案生成失败"
|
||||
: activeError?.stage === "video" ? "视频生成失败"
|
||||
: ""
|
||||
activeError?.stage === "image"
|
||||
? "图片分析失败"
|
||||
: activeError?.stage === "copy"
|
||||
? "文案生成失败"
|
||||
: activeError?.stage === "video"
|
||||
? "视频生成失败"
|
||||
: ""
|
||||
const step2Enabled =
|
||||
task.uiStep === "step1_done" ||
|
||||
task.uiStep === "step2_generating" ||
|
||||
@@ -1650,6 +1708,30 @@ const ViralVideoPage: React.FC = () => {
|
||||
{sh.time_range}
|
||||
</strong>
|
||||
)}
|
||||
{sh.speaker && (
|
||||
<span
|
||||
className="vv-sb-speaker-tag"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
padding: "2px 8px",
|
||||
marginRight: 8,
|
||||
marginLeft: 8,
|
||||
borderRadius: 4,
|
||||
fontSize: 12,
|
||||
fontWeight: 500,
|
||||
backgroundColor: ["店主", "主播", "老板", "我", "主讲人"].includes(
|
||||
sh.speaker,
|
||||
)
|
||||
? "#e6f7ff"
|
||||
: "#fff7e6",
|
||||
color: ["店主", "主播", "老板", "我", "主讲人"].includes(sh.speaker)
|
||||
? "#1890ff"
|
||||
: "#fa8c16",
|
||||
}}
|
||||
>
|
||||
{sh.speaker}
|
||||
</span>
|
||||
)}
|
||||
<p className="vv-sb-field">
|
||||
<strong className="vv-sb-field-k">景别/角度与运镜:</strong>
|
||||
{editingField === shotKey("shot_type_angle_movement") ? (
|
||||
@@ -1924,7 +2006,7 @@ const ViralVideoPage: React.FC = () => {
|
||||
if (!locked) setEditingField("voiceover_script")
|
||||
}}
|
||||
>
|
||||
{sb.voiceover_script}
|
||||
{renderVoiceoverWithSpeakers(sb.voiceover_script)}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
@@ -2803,6 +2885,21 @@ const ViralVideoPage: React.FC = () => {
|
||||
setAssetPicker((p) => ({ ...p, open: false }))
|
||||
}}
|
||||
/>
|
||||
{task.marketingPurpose && task.marketingPurpose.includes("短剧") && (
|
||||
<div
|
||||
style={{
|
||||
padding: "8px 12px",
|
||||
marginBottom: 8,
|
||||
backgroundColor: "#fff7e6",
|
||||
border: "1px solid #ffd591",
|
||||
borderRadius: 4,
|
||||
fontSize: 13,
|
||||
color: "#d46b08",
|
||||
}}
|
||||
>
|
||||
💡 短剧模式将自动为对手戏角色分配配角音色
|
||||
</div>
|
||||
)}
|
||||
<PresetVoicePickerModal
|
||||
open={voicePickerOpen}
|
||||
voices={presetVoices}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { useCallback, useEffect, useRef } from "react"
|
||||
import { getViralVideoJob } from "@/api/viral-video"
|
||||
import { isAnalysisStage, getJobStage, type ViralVideoJob, type ViralVideoStatus } from "@/api/viral-video/types"
|
||||
import {
|
||||
isAnalysisStage,
|
||||
getJobStage,
|
||||
type ViralVideoJob,
|
||||
type ViralVideoStatus,
|
||||
} from "@/api/viral-video/types"
|
||||
|
||||
const TERMINAL: ViralVideoStatus[] = ["completed", "failed", "cancelled"]
|
||||
|
||||
|
||||
@@ -525,6 +525,67 @@ def _viral_structure_hint(structure: str) -> str:
|
||||
return "【爆款结构:未指定】自由组织,保证开头有钩子、中段有卖点、结尾有行动引导"
|
||||
|
||||
|
||||
def _marketing_purpose_hint(mp: str) -> str:
|
||||
"""根据营销目的提供创作指导 hint。
|
||||
|
||||
覆盖前端 20 个 PURPOSES,分 4 类指导:
|
||||
- 短剧类:多角色对话,格式"角色:台词",禁止旁白,角色间镜头切换
|
||||
- 口播类:第一人称对镜头说话,真人出镜感
|
||||
- 门店类:店主或探店博主出镜讲解+场景展示
|
||||
- 品牌类:主演出镜说话或角色对白,禁止上帝视角旁白
|
||||
"""
|
||||
mp = (mp or "").strip()
|
||||
if not mp:
|
||||
return ""
|
||||
|
||||
# 短剧类(多角色对话)
|
||||
if any(k in mp for k in ["对话短剧", "反转短剧", "悬念短剧", "情绪短片"]):
|
||||
return "【短剧模式】必须双/多角色对话格式,台词用'角色:xxx'格式,镜头在角色间切换,禁止第三人称旁白解说。"
|
||||
|
||||
# 口播类(第一人称对镜头说话)
|
||||
if any(
|
||||
k in mp
|
||||
for k in ["口播带货", "促销转化", "功能演示", "痛点解决", "获客引流", "账号涨粉", "活动通知", "场景种草"]
|
||||
):
|
||||
return "【口播模式】第一人称对镜头说话,像真人出镜,有语气停顿和情绪,禁止第三人称旁白。"
|
||||
|
||||
# 门店类(店主/探店博主出镜)
|
||||
if any(k in mp for k in ["门店发现", "到店实录", "招牌体验", "同城团购"]):
|
||||
return "【门店模式】店主或探店博主出镜讲解+场景展示,第一人称或角色对白,禁止上帝视角旁白。"
|
||||
|
||||
# 品牌类(主演出镜或角色对白)
|
||||
if any(k in mp for k in ["品牌主张", "品牌故事", "生活方式", "创意概念"]):
|
||||
return "【品牌模式】主演出镜说话或角色对白,禁止上帝视角旁白,要有品牌调性和情感共鸣。"
|
||||
|
||||
# 未匹配的营销目的:通用 hint
|
||||
return "【通用模式】避免上帝视角旁白,优先第一人称或角色对话。"
|
||||
|
||||
|
||||
def _target_customer_hint(tc: str) -> str:
|
||||
"""根据目标客户提供台词风格指导。"""
|
||||
tc = (tc or "").strip()
|
||||
if not tc:
|
||||
return ""
|
||||
|
||||
# 年轻人
|
||||
if any(k in tc for k in ["18-25", "年轻", "学生", "Z世代", "95后", "00后"]):
|
||||
return "【目标年轻客群】台词要活泼、有梗、节奏快,可用网络流行语。"
|
||||
|
||||
# 中年人
|
||||
if any(k in tc for k in ["30-45", "中年", "家庭", "宝妈", "职场"]):
|
||||
return "【目标中年客群】台词要实用、有共鸣,强调性价比和品质。"
|
||||
|
||||
# 老年人
|
||||
if any(k in tc for k in ["50+", "老年", "退休", "银发"]):
|
||||
return "【目标老年客群】台词要清晰、慢节奏,强调健康和实惠。"
|
||||
|
||||
# 高端
|
||||
if any(k in tc for k in ["高端", "商务", "精英", "白领"]):
|
||||
return "【目标高端客群】台词要专业、有格调,强调品质和身份。"
|
||||
|
||||
return ""
|
||||
|
||||
|
||||
def _language_hint(language: str) -> str:
|
||||
"""根据 language 代码返回语言提示。"""
|
||||
lang = (language or "zh-CN").strip().lower()
|
||||
@@ -764,8 +825,9 @@ def _script_from_xml(raw: str, job: ViralVideoJob) -> dict | None:
|
||||
ref_idx = xp.attr_int(ref_raw, -1) if ref_raw not in (None, "") else -1
|
||||
if not isinstance(ref_idx, int) or ref_idx < 0:
|
||||
ref_idx = None
|
||||
speaker = xp.text_of(body, "speaker") or "主播"
|
||||
if voice:
|
||||
voice_parts.append(voice)
|
||||
voice_parts.append(f"[speaker:{speaker}]{voice}")
|
||||
shot = {
|
||||
"time_range": a.get("time_range") or xp.text_of(body, "time_range") or f"{i * 3}-{(i + 1) * 3}秒",
|
||||
"shot_type_angle_movement": visual or "中景平视,固定镜头",
|
||||
@@ -1259,6 +1321,45 @@ def _resolve_tts_voice_id(job: ViralVideoJob) -> str:
|
||||
return raw_voice_id or default_voice
|
||||
|
||||
|
||||
def _parse_speaker_segments(text: str) -> list[tuple[str, str]]:
|
||||
"""解析 voiceover_script 中的 [speaker:xxx] 标记,返回 [(speaker, text), ...] 列表。"""
|
||||
import re
|
||||
|
||||
if not text:
|
||||
return []
|
||||
|
||||
# 匹配 [speaker:xxx]text 格式
|
||||
pattern = r"\[speaker:([^\]]+)\]([^\[]*)"
|
||||
matches = re.findall(pattern, text)
|
||||
|
||||
if not matches:
|
||||
# 没有 speaker 标记,返回单个默认角色
|
||||
return [("主播", text)]
|
||||
|
||||
return [(speaker.strip(), seg_text.strip()) for speaker, seg_text in matches if seg_text.strip()]
|
||||
|
||||
|
||||
def _get_speaker_voice_id(speaker: str, main_voice_id: str, is_main_voice_male: bool = True) -> str:
|
||||
"""根据角色名分配合适的音色 ID。
|
||||
|
||||
主角(店主/主播/老板等)使用用户选择的音色,配角自动分配异性音色。
|
||||
"""
|
||||
# 主角列表
|
||||
main_speakers = ["店主", "主播", "老板", "我", "主讲人", "店长"]
|
||||
|
||||
# 配角列表
|
||||
sub_speakers = ["顾客", "客人", "朋友", "闺蜜", "路人", "店员"]
|
||||
|
||||
if speaker in main_speakers or not any(s in speaker for s in sub_speakers):
|
||||
return main_voice_id
|
||||
|
||||
# 配角分配异性音色
|
||||
if is_main_voice_male:
|
||||
return "longxiaochun" # 女声
|
||||
else:
|
||||
return "longsanshu" # 男声
|
||||
|
||||
|
||||
def _step_tts(job: ViralVideoJob, voiceover_script: str):
|
||||
"""步骤 5: CosyVoice 整段配音 → 返回本地 MP3 Path;失败返回 None。
|
||||
|
||||
|
||||
@@ -10,7 +10,22 @@ source "${SCRIPT_DIR}/ci_env.sh"
|
||||
|
||||
|
||||
echo "=== Installing mypy ==="
|
||||
python3 -m pip install -q mypy
|
||||
# pip�容错: 默认�(阿里云)缺文件时fallback到清�/官方�(2026-10-10 librt-0.6.0 metadata 404)
|
||||
MYPY_SPEC="mypy<1.19"
|
||||
INSTALL_OK=0
|
||||
python3 -m pip install -q "$MYPY_SPEC" && INSTALL_OK=1 || true
|
||||
if [ "$INSTALL_OK" != "1" ]; then
|
||||
echo "WARN: default pip index failed, retry tsinghua mirror..."
|
||||
python3 -m pip install -q -i https://pypi.tuna.tsinghua.edu.cn/simple "$MYPY_SPEC" && INSTALL_OK=1 || true
|
||||
fi
|
||||
if [ "$INSTALL_OK" != "1" ]; then
|
||||
echo "WARN: tsinghua mirror failed, retry pypi.org..."
|
||||
python3 -m pip install -q -i https://pypi.org/simple "$MYPY_SPEC" && INSTALL_OK=1 || true
|
||||
fi
|
||||
if [ "$INSTALL_OK" != "1" ]; then
|
||||
echo "ERROR: pip install mypy failed on all indexes"
|
||||
exit 1
|
||||
fi
|
||||
mypy --version
|
||||
echo ""
|
||||
echo "=== Running mypy type check (hard gate mode) ==="
|
||||
|
||||
Reference in New Issue
Block a user