diff --git a/python-api/app/ai/prompts/user/title.txt b/python-api/app/ai/prompts/user/title.txt new file mode 100644 index 0000000..9e5bfca --- /dev/null +++ b/python-api/app/ai/prompts/user/title.txt @@ -0,0 +1,12 @@ +请根据以下脚本内容,创作一个${title_type_desc}。 + +脚本内容: +${script_content} + +要求: +- 类型:${title_type_desc} +- 字数限制:严格控制在 ${max_length} 个字以内(含标点) +- 风格:短视频平台风格,吸引眼球,口语化,避免生僻字 +- 大标题需提炼核心卖点,小标题需补充说明或制造悬念 + +直接返回标题文字,不要加引号、书名号或任何额外说明。 \ No newline at end of file diff --git a/python-api/app/ai/prompts/user/title_system.txt b/python-api/app/ai/prompts/user/title_system.txt new file mode 100644 index 0000000..4d8da52 --- /dev/null +++ b/python-api/app/ai/prompts/user/title_system.txt @@ -0,0 +1,9 @@ +你是一位专业的短视频标题创作专家,擅长为口播类短视频创作吸引眼球的标题。 + +创作原则: +1. 标题要紧扣脚本核心内容,提炼最有吸引力的点 +2. 语言口语化,符合短视频平台(抖音、快手)风格 +3. 善用数字、疑问、对比等手法增强吸引力 +4. 严格控制字数,不超过用户指定的限制 +5. 直接返回标题文字,不要加引号、书名号或其他装饰符号 +6. 不要返回任何解释、说明或JSON格式 diff --git a/python-api/app/api/v1/script.py b/python-api/app/api/v1/script.py index 73352a4..d1dd25d 100644 --- a/python-api/app/api/v1/script.py +++ b/python-api/app/api/v1/script.py @@ -15,10 +15,13 @@ from fastapi import APIRouter, Request from fastapi.responses import StreamingResponse from app.schemas.common import ApiResponse, success_response -from app.ai.prompts import list_categories +from app.ai.model_router import get_model_router +from app.ai.prompts import list_categories, load_prompt, render_template from app.schemas.script import ( CategoryItem, GenerateScriptRequest, + GenerateTitleRequest, + GenerateTitleResponse, ModelHealthResponse, PolishRequest, ScriptGenerationEvent, @@ -202,3 +205,63 @@ async def test_model(request: TestModelRequest): data=TestModelResponse(**result), message="模型测试完成" if result["success"] else f"模型测试失败: {result.get('error')}", ) + + +@router.post("/generate-title", response_model=ApiResponse[GenerateTitleResponse]) +async def generate_title(request: GenerateTitleRequest): + """ + 根据脚本内容智能生成标题 + + 调用 LLM 根据脚本内容生成大标题或小标题。 + 提示词从文件加载,支持热更新。 + """ + model_router = await get_model_router() + + # 加载提示词 + system_prompt = load_prompt("user/title_system") + user_template = load_prompt("user/title") + + if not system_prompt or not user_template: + return success_response( + code=500, + message="标题生成提示词文件缺失", + data=None, + ) + + # 渲染用户提示词 + title_type_desc = "大标题(主标题,提炼核心卖点,吸睛)" if request.title_type == "main" else "小标题(副标题,补充说明或制造悬念)" + user_prompt = render_template( + user_template, + title_type=request.title_type, + title_type_desc=title_type_desc, + script_content=request.script_content, + max_length=request.max_length, + ) + + try: + result = await model_router.generate( + prompt=user_prompt, + system_prompt=system_prompt, + task_type="script", + temperature=0.8, + max_tokens=64, + ) + + title = result.content.strip() if result.content else "" + # 去除可能的引号 + title = title.strip('"').strip("'").strip('「」').strip('『』').strip('《》') + # 截断到最大长度 + if len(title) > request.max_length: + title = title[:request.max_length] + + return success_response( + data=GenerateTitleResponse(title=title), + message="标题生成成功", + ) + except Exception as e: + logger.error(f"[generate_title] 标题生成失败: {e}") + return success_response( + code=500, + message=f"标题生成失败: {str(e)}", + data=None, + ) diff --git a/python-api/app/schemas/script.py b/python-api/app/schemas/script.py index 55c3e8a..4cb6fda 100644 --- a/python-api/app/schemas/script.py +++ b/python-api/app/schemas/script.py @@ -109,3 +109,17 @@ class TestModelResponse(BaseModel): response_time: float | None = Field(None, description="响应时间(毫秒)") error: str | None = Field(None, description="错误信息") checked_at: str | None = Field(None, description="检查时间 ISO 格式") + + +class GenerateTitleRequest(BaseModel): + """生成标题请求""" + + script_content: str = Field(..., description="脚本内容(utterances 文本拼接)", min_length=1) + title_type: str = Field(..., description="标题类型:main(大标题) / sub(小标题)") + max_length: int = Field(default=8, ge=1, le=100, description="最大字数限制") + + +class GenerateTitleResponse(BaseModel): + """生成标题响应""" + + title: str = Field(..., description="生成的标题") diff --git a/tauri-app/src/api/modules/script.ts b/tauri-app/src/api/modules/script.ts index b852377..843ff1c 100644 --- a/tauri-app/src/api/modules/script.ts +++ b/tauri-app/src/api/modules/script.ts @@ -65,6 +65,22 @@ export interface ModelHealthResponse { error?: string; } +/** + * 生成标题请求参数 + */ +export interface GenerateTitleParams { + scriptContent: string; + titleType: 'main' | 'sub'; + maxLength?: number; +} + +/** + * 生成标题响应 + */ +export interface GenerateTitleResponse { + title: string; +} + /** * 测试模型请求 */ @@ -175,6 +191,20 @@ export const scriptApi = { }); }, + /** + * 智能生成标题 + * POST /script/generate-title + * + * 根据脚本内容生成大标题或小标题 + */ + generateTitle: async (params: GenerateTitleParams): Promise => { + return client.post('/script/generate-title', { + script_content: params.scriptContent, + title_type: params.titleType, + max_length: params.maxLength, + }); + }, + /** * AI 润色脚本 * POST /script/polish diff --git a/tauri-app/src/pages/VideoCreation/SubtitleBurning.css b/tauri-app/src/pages/VideoCreation/SubtitleBurning.css index 5f6bee3..f14583e 100644 --- a/tauri-app/src/pages/VideoCreation/SubtitleBurning.css +++ b/tauri-app/src/pages/VideoCreation/SubtitleBurning.css @@ -340,6 +340,17 @@ border-bottom: none; } +/* 标题输入框行(输入框 + 生成按钮) */ +.title-input-row { + display: flex; + gap: var(--spacing-sm); + align-items: center; +} + +.title-input-row .title-input { + flex: 1; +} + /* 标题输入框 */ .title-input { width: 100%; @@ -361,6 +372,15 @@ color: var(--text-tertiary); } +/* 智能生成按钮 */ +.title-generate-btn { + white-space: nowrap; + font-size: var(--font-xs); + padding: var(--spacing-xs) var(--spacing-sm); + border-radius: var(--radius-md); + flex-shrink: 0; +} + /* 压制字幕按钮 - 固定在底部 */ .burn-btn { width: 100%; diff --git a/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx b/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx index 75baa81..dca3844 100644 --- a/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx +++ b/tauri-app/src/pages/VideoCreation/SubtitleBurning.tsx @@ -11,6 +11,7 @@ import { invoke } from '@tauri-apps/api/core'; import { homeDir } from '@tauri-apps/api/path'; import { useProjectStore, saveMetaToLocalFile } from '../../store'; import { getCurrentProjectId } from '../../api/modules/localStorage'; +import { scriptApi } from '../../api/modules/script'; import { useLocalVideo } from '../../hooks/useLocalVideo'; import { useCanvasSubtitleRenderer } from '../../hooks/useCanvasSubtitleRenderer'; @@ -79,6 +80,7 @@ export default function SubtitleBurning() { const alignment = useProjectStore(state => state.subtitleAlignment); const [isBurning, setIsBurning] = useState(false); const [previewMode, setPreviewMode] = useState<'style' | 'result'>('style'); + const [isGeneratingTitle, setIsGeneratingTitle] = useState(false); // 视频播放相关 const videoRef = useRef(null); @@ -111,6 +113,37 @@ export default function SubtitleBurning() { useProjectStore.setState({ subTitle: value }); saveMetaToLocalFile({ subTitle: value }); }; + + // 智能生成标题 + const handleGenerateTitle = async (titleType: 'main' | 'sub') => { + const utterances = alignment?.utterances; + if (!utterances || utterances.length === 0) { + toast.error('暂无脚本内容,请先生成脚本'); + return; + } + const scriptContent = utterances.map(u => u.text).join('\n'); + const maxLength = titleType === 'main' ? 8 : 30; + + setIsGeneratingTitle(true); + try { + const res = await scriptApi.generateTitle({ + scriptContent, + titleType, + maxLength, + }); + if (titleType === 'main') { + setMainTitle(res.title); + } else { + setSubTitle(res.title); + } + toast.success(`${titleType === 'main' ? '大标题' : '小标题'}生成成功`); + } catch (e) { + const msg = e instanceof Error ? e.message : '生成失败'; + toast.error(msg); + } finally { + setIsGeneratingTitle(false); + } + }; const setMainTitlePreset = (value: string) => { useProjectStore.setState({ mainTitlePreset: value }); saveMetaToLocalFile({ mainTitlePreset: value }); @@ -318,14 +351,24 @@ export default function SubtitleBurning() { {/* 大标题设置 */}
- setMainTitle(e.target.value)} - maxLength={8} - /> +
+ setMainTitle(e.target.value)} + maxLength={8} + /> + +
{MAIN_TITLE_PRESETS.map(preset => ( +
{SUB_TITLE_PRESETS.map(preset => (