feat: 大小标题支持智能生成
后端: - 新增 POST /script/generate-title API - 新增提示词模板 title_system.txt / title.txt(文件管理) - 根据脚本内容调用 LLM 生成大标题(≤8字)/小标题(≤30字) 前端: - 大标题/小标题输入框右侧新增【智能生成】按钮 - 点击后根据 utterances 拼接脚本内容调用 API - 添加 title-input-row / title-generate-btn CSS 样式
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
请根据以下脚本内容,创作一个${title_type_desc}。
|
||||
|
||||
脚本内容:
|
||||
${script_content}
|
||||
|
||||
要求:
|
||||
- 类型:${title_type_desc}
|
||||
- 字数限制:严格控制在 ${max_length} 个字以内(含标点)
|
||||
- 风格:短视频平台风格,吸引眼球,口语化,避免生僻字
|
||||
- 大标题需提炼核心卖点,小标题需补充说明或制造悬念
|
||||
|
||||
直接返回标题文字,不要加引号、书名号或任何额外说明。
|
||||
@@ -0,0 +1,9 @@
|
||||
你是一位专业的短视频标题创作专家,擅长为口播类短视频创作吸引眼球的标题。
|
||||
|
||||
创作原则:
|
||||
1. 标题要紧扣脚本核心内容,提炼最有吸引力的点
|
||||
2. 语言口语化,符合短视频平台(抖音、快手)风格
|
||||
3. 善用数字、疑问、对比等手法增强吸引力
|
||||
4. 严格控制字数,不超过用户指定的限制
|
||||
5. 直接返回标题文字,不要加引号、书名号或其他装饰符号
|
||||
6. 不要返回任何解释、说明或JSON格式
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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="生成的标题")
|
||||
|
||||
@@ -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<GenerateTitleResponse> => {
|
||||
return client.post<GenerateTitleResponse>('/script/generate-title', {
|
||||
script_content: params.scriptContent,
|
||||
title_type: params.titleType,
|
||||
max_length: params.maxLength,
|
||||
});
|
||||
},
|
||||
|
||||
/**
|
||||
* AI 润色脚本
|
||||
* POST /script/polish
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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<HTMLVideoElement>(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() {
|
||||
{/* 大标题设置 */}
|
||||
<div className="style-section">
|
||||
<label className="panel-label">大标题样式</label>
|
||||
<input
|
||||
type="text"
|
||||
className="title-input"
|
||||
placeholder="输入大标题"
|
||||
value={mainTitle}
|
||||
onChange={(e) => setMainTitle(e.target.value)}
|
||||
maxLength={8}
|
||||
/>
|
||||
<div className="title-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="title-input"
|
||||
placeholder="输入大标题"
|
||||
value={mainTitle}
|
||||
onChange={(e) => setMainTitle(e.target.value)}
|
||||
maxLength={8}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost title-generate-btn"
|
||||
onClick={() => handleGenerateTitle('main')}
|
||||
disabled={isGeneratingTitle}
|
||||
title="根据脚本内容智能生成大标题"
|
||||
>
|
||||
{isGeneratingTitle ? '生成中...' : '智能生成'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="style-presets main-title-presets">
|
||||
{MAIN_TITLE_PRESETS.map(preset => (
|
||||
<button
|
||||
@@ -357,14 +400,24 @@ export default function SubtitleBurning() {
|
||||
{/* 小标题设置 */}
|
||||
<div className="style-section">
|
||||
<label className="panel-label">小标题样式</label>
|
||||
<input
|
||||
type="text"
|
||||
className="title-input"
|
||||
placeholder="输入小标题"
|
||||
value={subTitle}
|
||||
onChange={(e) => setSubTitle(e.target.value)}
|
||||
maxLength={30}
|
||||
/>
|
||||
<div className="title-input-row">
|
||||
<input
|
||||
type="text"
|
||||
className="title-input"
|
||||
placeholder="输入小标题"
|
||||
value={subTitle}
|
||||
onChange={(e) => setSubTitle(e.target.value)}
|
||||
maxLength={30}
|
||||
/>
|
||||
<button
|
||||
className="btn btn-ghost title-generate-btn"
|
||||
onClick={() => handleGenerateTitle('sub')}
|
||||
disabled={isGeneratingTitle}
|
||||
title="根据脚本内容智能生成小标题"
|
||||
>
|
||||
{isGeneratingTitle ? '生成中...' : '智能生成'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="style-presets sub-title-presets">
|
||||
{SUB_TITLE_PRESETS.map(preset => (
|
||||
<button
|
||||
|
||||
Reference in New Issue
Block a user