diff --git a/qwen3-tts-backend/api/audiobook.py b/qwen3-tts-backend/api/audiobook.py index 1eede74..ea29717 100644 --- a/qwen3-tts-backend/api/audiobook.py +++ b/qwen3-tts-backend/api/audiobook.py @@ -245,6 +245,54 @@ async def create_ai_script_project( return _project_to_response(project) +@router.post("/projects/{project_id}/regenerate-characters") +async def regenerate_characters( + project_id: int, + current_user: User = Depends(get_current_user), + db: Session = Depends(get_db), +): + project = crud.get_audiobook_project(db, project_id, current_user.id) + if not project: + raise HTTPException(status_code=404, detail="Project not found") + if project.source_type != "ai_generated": + raise HTTPException(status_code=400, detail="Only AI-generated projects support this operation") + if project.status in ("analyzing", "generating"): + raise HTTPException(status_code=400, detail=f"Project is currently {project.status}, please wait") + + cfg = project.script_config or {} + is_nsfw = cfg.get("nsfw_mode", False) + + if is_nsfw: + from db.crud import can_user_use_nsfw + if not can_user_use_nsfw(current_user): + raise HTTPException(status_code=403, detail="NSFW access not granted") + from db.crud import get_system_setting + if not get_system_setting(db, "grok_api_key") or not get_system_setting(db, "grok_base_url"): + raise HTTPException(status_code=400, detail="Grok config not set. Please configure Grok API key first.") + from core.audiobook_service import generate_ai_script_nsfw + service_fn = generate_ai_script_nsfw + else: + from db.crud import get_system_setting + if not get_system_setting(db, "llm_api_key") or not get_system_setting(db, "llm_base_url") or not get_system_setting(db, "llm_model"): + raise HTTPException(status_code=400, detail="LLM config not set. Please configure LLM API key first.") + from core.audiobook_service import generate_ai_script + service_fn = generate_ai_script + + from core.database import SessionLocal + user_id = current_user.id + + async def run(): + async_db = SessionLocal() + try: + db_user = crud.get_user_by_id(async_db, user_id) + await service_fn(project_id, db_user, async_db) + finally: + async_db.close() + + asyncio.create_task(run()) + return {"message": "Character regeneration started", "project_id": project_id} + + @router.post("/projects/{project_id}/continue-script") async def continue_script( project_id: int, diff --git a/qwen3-tts-backend/core/llm_service.py b/qwen3-tts-backend/core/llm_service.py index 54638f5..8912996 100644 --- a/qwen3-tts-backend/core/llm_service.py +++ b/qwen3-tts-backend/core/llm_service.py @@ -319,6 +319,29 @@ class LLMService: result = await self.stream_chat_json(system_prompt, user_message, max_tokens=4096, usage_callback=usage_callback) return result.get("chapters", []) + @staticmethod + def _emotion_limits(violence_level: int, eroticism_level: int) -> tuple[str, str]: + v = violence_level / 10 + e = eroticism_level / 10 + female_happy = round(0.35 + 0.45 * e, 2) + angry = round(0.15 + 0.65 * v, 2) + sad = round(0.10 + 0.40 * v, 2) + fear = round(0.10 + 0.60 * v, 2) + hate = round(0.35 + 0.25 * max(v, e), 2) + low = round(0.35 + 0.45 * e, 2) + surprise= round(0.10 + 0.40 * max(v, e), 2) + limits = ( + f"愤怒={angry}、悲伤={sad}、恐惧={fear}、厌恶={hate}、低沉={low}、惊讶={surprise}、" + f"开心:男性角色上限=0.35,女性角色上限={female_happy}" + ) + guidance_parts = [] + if violence_level >= 4: + guidance_parts.append(f"暴力程度{violence_level}/10,台词中的愤怒、恐惧、悲伤情绪必须强烈外露,不得克制") + if eroticism_level >= 4: + guidance_parts.append(f"色情程度{eroticism_level}/10,女性台词中的开心、低沉、挑逗情绪应充分表达") + guidance = ";".join(guidance_parts) + return limits, guidance + async def generate_chapter_script( self, genre: str, @@ -334,6 +357,19 @@ class LLMService: ) -> str: char_names = [c.get("name", "") for c in characters if c.get("name") not in ("narrator", "旁白")] names_str = "、".join(char_names) + limits_str, emo_guidance = self._emotion_limits(violence_level, eroticism_level) + emo_guidance_line = f"- {emo_guidance}\n" if emo_guidance else "" + max_level = max(violence_level, eroticism_level) + if max_level >= 9: + narrator_rule = "- 旁白全程必须主动标注情感,强烈场景情感需饱满,不得留空\n" + elif max_level >= 7: + narrator_rule = "- 旁白在激烈/情欲场景中必须添加情感标注,其余场景也应酌情标注\n" + elif max_level >= 5: + narrator_rule = "- 旁白在激烈/情欲场景中应添加情感标注,平淡过渡段落可省略\n" + elif max_level >= 3: + narrator_rule = "- 旁白在情绪明显的场景中可适当添加情感标注\n" + else: + narrator_rule = "- 旁白叙述一般不需要情感标注\n" system_prompt = ( "你是一个专业的有声书剧本创作助手。请根据章节信息创作完整的对话脚本。\n\n" "输出格式规则(严格遵守):\n" @@ -342,10 +378,11 @@ class LLMService: " 【角色名】\"对话内容\"(情感词:强度)\n\n" "情感标注规则:\n" "- 情感词可选:开心、愤怒、悲伤、恐惧、厌恶、低沉、惊讶\n" - "- 各情感强度上限(严格不超过):开心=0.35、愤怒=0.15、悲伤=0.1、恐惧=0.1、厌恶=0.35、低沉=0.35、惊讶=0.1\n" + f"- 各情感强度上限(严格不超过):{limits_str}\n" "- 情感不明显时可省略(情感词:强度)整个括号\n" - "- 旁白叙述一般不需要情感标注\n\n" - "其他规则:\n" + + narrator_rule + + emo_guidance_line + + "\n其他规则:\n" "- 旁白使用【旁白】标记\n" f"- 主要角色名从以下列表选择:{names_str}\n" "- 若剧情需要路人/群众/配角台词,可使用简短中文描述性名称(如:路人甲、镇民、警察、店员等),不必限于主角列表\n" diff --git a/qwen3-tts-frontend/src/lib/api/audiobook.ts b/qwen3-tts-frontend/src/lib/api/audiobook.ts index 21760fd..7024e1f 100644 --- a/qwen3-tts-frontend/src/lib/api/audiobook.ts +++ b/qwen3-tts-frontend/src/lib/api/audiobook.ts @@ -162,6 +162,10 @@ export const audiobookApi = { await apiClient.post(`/audiobook/projects/${id}/analyze`, { turbo: options?.turbo ?? false }) }, + regenerateCharacters: async (id: number): Promise => { + await apiClient.post(`/audiobook/projects/${id}/regenerate-characters`) + }, + updateCharacter: async ( projectId: number, charId: number, diff --git a/qwen3-tts-frontend/src/pages/Audiobook.tsx b/qwen3-tts-frontend/src/pages/Audiobook.tsx index 3a35dd3..b3dd825 100644 --- a/qwen3-tts-frontend/src/pages/Audiobook.tsx +++ b/qwen3-tts-frontend/src/pages/Audiobook.tsx @@ -1397,6 +1397,7 @@ function ChaptersPanel({ onParseAll, onGenerateAll, onProcessAll, + isBackgroundGenerating, onContinueScript, onDownload, onSequentialPlayingChange, @@ -1416,6 +1417,7 @@ function ChaptersPanel({ onParseAll: () => void onGenerateAll: () => void onProcessAll: () => void + isBackgroundGenerating: boolean onContinueScript?: () => void onDownload: (chapterIndex?: number) => void onSequentialPlayingChange: (id: number | null) => void @@ -1520,7 +1522,7 @@ function ChaptersPanel({ }, [segments, detail]) const isAIMode = project.source_type === 'ai_generated' - const hasChapters = detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status) + const hasChapters = detail && detail.chapters.length > 0 && ['analyzing', 'ready', 'generating', 'done'].includes(status) return (
@@ -1530,7 +1532,7 @@ function ChaptersPanel({ {hasChapters && (
- {detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && ( + {detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && !isBackgroundGenerating && ( + isBackgroundGenerating ? ( + + 排队中 + + ) : ( + + ) )} {ch.status === 'parsing' && ( @@ -1865,13 +1873,17 @@ export default function Audiobook() { }, [status, selectedProject, t]) const hasParsingChapter = detail?.chapters.some(c => c.status === 'parsing') ?? false + const isAiProject = selectedProject?.source_type === 'ai_generated' + const hasPendingChapters = detail?.chapters.some(c => c.status === 'pending') ?? false + const isBackgroundGenerating = isAiProject && (status === 'analyzing' || (status === 'ready' && hasPendingChapters)) useEffect(() => { if (!isPolling) return if (['analyzing', 'generating'].includes(status)) return if (hasParsingChapter) return + if (isAiProject && hasPendingChapters) return if (!segments.some(s => s.status === 'generating')) setIsPolling(false) - }, [isPolling, status, segments, hasParsingChapter]) + }, [isPolling, status, segments, hasParsingChapter, isAiProject, hasPendingChapters]) useEffect(() => { if (generatingChapterIndices.size === 0) return @@ -1891,7 +1903,7 @@ export default function Audiobook() { } }, [segments, generatingChapterIndices]) - const shouldPoll = isPolling || ['analyzing', 'generating'].includes(status) || hasParsingChapter || generatingChapterIndices.size > 0 || segments.some(s => s.status === 'generating') + const shouldPoll = isPolling || ['analyzing', 'generating'].includes(status) || hasParsingChapter || (isAiProject && hasPendingChapters) || generatingChapterIndices.size > 0 || segments.some(s => s.status === 'generating') useEffect(() => { if (!shouldPoll || !selectedProjectId) return @@ -1908,7 +1920,11 @@ export default function Audiobook() { setLoadingAction(true) setIsPolling(true) try { - await audiobookApi.analyze(selectedProject.id, { turbo: true }) + if (selectedProject.source_type === 'ai_generated') { + await audiobookApi.regenerateCharacters(selectedProject.id) + } else { + await audiobookApi.analyze(selectedProject.id, { turbo: true }) + } toast.success(t('projectCard.analyzeStarted')) fetchProjects() } catch (e: any) { @@ -1924,6 +1940,7 @@ export default function Audiobook() { setLoadingAction(true) try { await audiobookApi.confirmCharacters(selectedProject.id) + if (selectedProject.source_type === 'ai_generated') setIsPolling(true) toast.success(t('projectCard.confirm.chaptersRecognized')) fetchProjects() fetchDetail() @@ -2253,17 +2270,22 @@ export default function Audiobook() {
)} - {chaptersTotal > 0 && ['ready', 'generating', 'done'].includes(status) && (chaptersParsing > 0 || chaptersError > 0 || (totalCount > 0 && doneCount > 0)) && ( + {chaptersTotal > 0 && (isBackgroundGenerating || ['ready', 'generating', 'done'].includes(status)) && (isBackgroundGenerating || chaptersParsing > 0 || chaptersError > 0 || (totalCount > 0 && doneCount > 0)) && (
- {(chaptersParsing > 0 || chaptersError > 0 || chaptersParsed < chaptersTotal) && ( + {(isBackgroundGenerating || chaptersParsing > 0 || chaptersError > 0 || chaptersParsed < chaptersTotal) && (
- 📝 + {isBackgroundGenerating && chaptersParsing === 0 && !chaptersError + ? + : 📝} {t('projectCard.chaptersProgress', { parsed: chaptersParsed, total: chaptersTotal })} {chaptersParsing > 0 && ( ({t('projectCard.chaptersParsing', { count: chaptersParsing })}) )} + {isBackgroundGenerating && hasPendingChapters && chaptersParsing > 0 && ( + ({detail!.chapters.filter(c => c.status === 'pending').length} 排队中) + )} {chaptersError > 0 && ( <> ({t('projectCard.chaptersError', { count: chaptersError })}) @@ -2273,7 +2295,7 @@ export default function Audiobook() { )}
- {chaptersParsing > 0 && totalCount > 0 && ( + {(isBackgroundGenerating || chaptersParsing > 0) && ( @@ -2333,6 +2355,7 @@ export default function Audiobook() { onParseAll={handleParseAll} onGenerateAll={handleGenerateAll} onProcessAll={handleProcessAll} + isBackgroundGenerating={isBackgroundGenerating} onContinueScript={selectedProject.source_type === 'ai_generated' ? () => setShowContinueScript(true) : undefined} onDownload={handleDownload} onSequentialPlayingChange={setSequentialPlayingId}