Compare commits

...

2 Commits

7 changed files with 69 additions and 42 deletions

View File

@@ -457,12 +457,13 @@ async def generate_project(
from core.database import SessionLocal
chapter_index = data.chapter_index
force = data.force
async def run_generation():
async_db = SessionLocal()
try:
db_user = crud.get_user_by_id(async_db, current_user.id)
await _generate(project_id, db_user, async_db, chapter_index=chapter_index)
await _generate(project_id, db_user, async_db, chapter_index=chapter_index, force=force)
finally:
async_db.close()

View File

@@ -536,7 +536,7 @@ async def _bootstrap_character_voices(segments, user, backend, backend_type: str
logger.error(f"Failed to bootstrap voice for design_id={design.id}: {e}", exc_info=True)
async def generate_project(project_id: int, user: User, db: Session, chapter_index: Optional[int] = None, cancel_event: Optional[asyncio.Event] = None) -> None:
async def generate_project(project_id: int, user: User, db: Session, chapter_index: Optional[int] = None, cancel_event: Optional[asyncio.Event] = None, force: bool = False) -> None:
project = db.query(AudiobookProject).filter(AudiobookProject.id == project_id).first()
if not project:
return
@@ -551,6 +551,11 @@ async def generate_project(project_id: int, user: User, db: Session, chapter_ind
crud.update_audiobook_project_status(db, project_id, "generating")
segments = crud.list_audiobook_segments(db, project_id, chapter_index=chapter_index)
if force:
for s in segments:
if s.status == "done":
crud.update_audiobook_segment_status(db, s.id, "pending")
segments = crud.list_audiobook_segments(db, project_id, chapter_index=chapter_index)
pending_segments = [s for s in segments if s.status in ("pending", "error")]
if not pending_segments:
if chapter_index is None:

View File

@@ -457,7 +457,7 @@ class IndexTTS2Backend:
vec = [0.0] * 8
score = 0.8 if len(matched) == 1 else 0.5
for idx in matched:
vec[idx] = score
vec[idx] = 0.2 if idx == 1 else score
return vec
async def generate(

View File

@@ -58,6 +58,7 @@ class AudiobookAnalyzeRequest(BaseModel):
class AudiobookGenerateRequest(BaseModel):
chapter_index: Optional[int] = None
force: bool = False
class AudiobookCharacterUpdate(BaseModel):

View File

@@ -22,6 +22,7 @@ const buttonVariants = cva(
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
xs: "h-6 rounded-md px-2 text-xs",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},

View File

@@ -118,9 +118,10 @@ export const audiobookApi = {
await apiClient.post(`/audiobook/projects/${projectId}/chapters/${chapterId}/parse`)
},
generate: async (id: number, chapterIndex?: number): Promise<void> => {
generate: async (id: number, chapterIndex?: number, force?: boolean): Promise<void> => {
await apiClient.post(`/audiobook/projects/${id}/generate`, {
chapter_index: chapterIndex ?? null,
force: force ?? false,
})
},

View File

@@ -409,7 +409,7 @@ function ProjectListSidebar({
<button
key={p.id}
onClick={() => onSelect(p.id)}
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors hover:bg-muted/60 ${
className={`w-full text-left rounded-lg border p-3 flex flex-col gap-1.5 transition-colors hover:bg-muted/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 ${
selectedId === p.id
? 'bg-background border-border shadow-sm'
: 'bg-background/50 border-border/40'
@@ -541,7 +541,7 @@ function CharactersPanel({
return (
<button
key={ch.id}
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/60 border-b border-border/30 transition-colors"
className="w-full flex items-center gap-1.5 px-2 py-1.5 text-left hover:bg-muted/60 border-b border-border/30 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
onClick={() => onScrollToChapter(ch.id)}
>
<span className={`w-1.5 h-1.5 rounded-full shrink-0 ${dotClass}`} />
@@ -562,7 +562,7 @@ function CharactersPanel({
</span>
<div className="flex items-center gap-1">
{!isActive && status !== 'pending' && charCount > 0 && (
<Button size="sm" variant="ghost" className="h-6 text-xs px-2 text-muted-foreground" onClick={onAnalyze} disabled={loadingAction}>
<Button size="xs" variant="ghost" className="text-muted-foreground" onClick={onAnalyze} disabled={loadingAction}>
{t('projectCard.reanalyze')}
</Button>
)}
@@ -737,7 +737,7 @@ function ChaptersPanel({
generatingChapterIndices: Set<number>
sequentialPlayingId: number | null
onParseChapter: (chapterId: number, title?: string) => void
onGenerate: (chapterIndex?: number) => void
onGenerate: (chapterIndex?: number, force?: boolean) => void
onParseAll: () => void
onGenerateAll: () => void
onProcessAll: () => void
@@ -758,6 +758,7 @@ function ChaptersPanel({
const [regeneratingSegs, setRegeneratingSegs] = useState<Set<number>>(new Set())
const [audioVersions, setAudioVersions] = useState<Record<number, number>>({})
const prevSegStatusRef = useRef<Record<number, string>>({})
const initialExpandDoneRef = useRef(false)
useEffect(() => {
if (!scrollToChapterId) return
@@ -823,12 +824,23 @@ function ChaptersPanel({
const generatingChapterIds = detail.chapters
.filter(ch => segments.some(s => s.chapter_index === ch.chapter_index && s.status === 'generating'))
.map(ch => ch.id)
if (generatingChapterIds.length === 0) return
setExpandedChapters(prev => {
const next = new Set(prev)
generatingChapterIds.forEach(id => next.add(id))
return next.size === prev.size ? prev : next
})
if (generatingChapterIds.length > 0) {
setExpandedChapters(prev => {
const next = new Set(prev)
generatingChapterIds.forEach(id => next.add(id))
return next.size === prev.size ? prev : next
})
return
}
if (!initialExpandDoneRef.current) {
initialExpandDoneRef.current = true
const chapterIdsWithSegs = detail.chapters
.filter(ch => segments.some(s => s.chapter_index === ch.chapter_index))
.map(ch => ch.id)
if (chapterIdsWithSegs.length > 0) {
setExpandedChapters(new Set(chapterIdsWithSegs))
}
}
}, [segments, detail])
const hasChapters = detail && detail.chapters.length > 0 && ['ready', 'generating', 'done'].includes(status)
@@ -842,17 +854,17 @@ function ChaptersPanel({
{hasChapters && (
<div className="flex items-center gap-1 flex-wrap">
{detail!.chapters.some(c => ['pending', 'error', 'ready'].includes(c.status)) && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={onParseAll}>
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onParseAll}>
{t('projectCard.chapters.parseAll')}
</Button>
)}
{detail!.chapters.some(c => c.status === 'ready') && (
<Button size="sm" variant="outline" className="h-6 text-xs px-2" disabled={loadingAction} onClick={onGenerateAll}>
<Button size="xs" variant="outline" disabled={loadingAction} onClick={onGenerateAll}>
{t('projectCard.chapters.generateAll')}
</Button>
)}
{detail!.chapters.some(c => ['pending', 'error'].includes(c.status)) && detail!.chapters.some(c => c.status === 'ready') && (
<Button size="sm" className="h-6 text-xs px-2" disabled={loadingAction} onClick={onProcessAll}>
<Button size="xs" disabled={loadingAction} onClick={onProcessAll}>
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : t('projectCard.chapters.processAll')}
</Button>
)}
@@ -882,7 +894,7 @@ function ChaptersPanel({
<div key={ch.id} id={`ch-${ch.id}`}>
{/* Chapter header — flat, full-width, click to expand */}
<button
className="w-full flex items-center gap-2 px-3 py-2.5 bg-muted/40 hover:bg-muted/70 border-b text-left transition-colors"
className="w-full flex items-center gap-2 px-3 py-2.5 bg-muted/40 hover:bg-muted/70 border-b text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1"
onClick={toggleChExpand}
>
<span className="shrink-0 text-muted-foreground">
@@ -891,7 +903,7 @@ function ChaptersPanel({
<span className="text-xs font-medium flex-1 truncate">{chTitle}</span>
<span className="shrink-0 flex items-center gap-1.5" onClick={e => e.stopPropagation()}>
{ch.status === 'pending' && (
<Button size="sm" variant="outline" className="h-5 text-[11px] px-2" onClick={() => onParseChapter(ch.id, ch.title)}>
<Button size="xs" variant="outline" onClick={() => onParseChapter(ch.id, ch.title)}>
{t('projectCard.chapters.parse')}
</Button>
)}
@@ -902,13 +914,13 @@ function ChaptersPanel({
)}
{ch.status === 'ready' && !chGenerating && !chAllDone && !generatingChapterIndices.has(ch.chapter_index) && (
<>
<Button size="sm" variant="outline" className="h-5 text-[11px] px-2" disabled={loadingAction} onClick={() => {
<Button size="xs" variant="outline" disabled={loadingAction} onClick={() => {
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
onGenerate(ch.chapter_index)
}}>
{t('projectCard.chapters.generate')}
</Button>
<Button size="sm" variant="ghost" className="h-5 text-[11px] px-1.5 text-muted-foreground" disabled={loadingAction} onClick={() => onParseChapter(ch.id, ch.title)}>
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => onParseChapter(ch.id, ch.title)}>
{t('projectCard.chapters.reparse')}
</Button>
</>
@@ -918,16 +930,22 @@ function ChaptersPanel({
<Loader2 className="h-3 w-3 animate-spin" />{t('projectCard.chapters.segmentProgress', { done: chDone, total: chTotal })}
</span>
)}
{ch.status === 'ready' && chAllDone && (
{(ch.status === 'done' || (ch.status === 'ready' && chAllDone)) && (
<>
<span className="text-[11px] text-muted-foreground">{t('projectCard.chapters.doneBadge', { count: chDone })}</span>
<Button size="sm" variant="ghost" className="h-5 w-5 p-0" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
<Button size="xs" variant="ghost" className="text-muted-foreground" disabled={loadingAction} onClick={() => {
setExpandedChapters(prev => { const n = new Set(prev); n.add(ch.id); return n })
onGenerate(ch.chapter_index, true)
}}>
<RefreshCw className="h-3 w-3" />{t('projectCard.chapters.generate')}
</Button>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => onDownload(ch.chapter_index)} title={t('projectCard.downloadAll')}>
<Download className="h-3 w-3" />
</Button>
</>
)}
{ch.status === 'error' && (
<Button size="sm" variant="outline" className="h-5 text-[11px] px-2 text-destructive border-destructive/40" onClick={() => onParseChapter(ch.id, ch.title)}>
<Button size="xs" variant="outline" className="text-destructive border-destructive/40" onClick={() => onParseChapter(ch.id, ch.title)}>
{t('projectCard.chapters.reparse')}
</Button>
)}
@@ -973,19 +991,19 @@ function ChaptersPanel({
<div className="ml-auto flex items-center gap-0.5 shrink-0">
{!isEditing ? (
<>
<Button size="icon" variant="ghost" className="h-5 w-5" onClick={() => startEdit(seg)} disabled={isRegenerating} title={t('projectCard.segments.edit')}>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => startEdit(seg)} disabled={isRegenerating} title={t('projectCard.segments.edit')}>
<Pencil className="h-3 w-3" />
</Button>
<Button size="icon" variant="ghost" className="h-5 w-5" onClick={() => handleRegenerate(seg.id)} disabled={isRegenerating} title={t('projectCard.segments.regenerate')}>
<Button size="icon" variant="ghost" className="h-6 w-6" onClick={() => handleRegenerate(seg.id)} disabled={isRegenerating} title={t('projectCard.segments.regenerate')}>
<RefreshCw className="h-3 w-3" />
</Button>
</>
) : (
<>
<Button size="icon" variant="ghost" className="h-5 w-5 text-destructive" onClick={cancelEdit} title={t('projectCard.segments.cancel')}>
<Button size="icon" variant="ghost" className="h-6 w-6 text-destructive" onClick={cancelEdit} title={t('projectCard.segments.cancel')}>
<X className="h-3 w-3" />
</Button>
<Button size="icon" variant="ghost" className="h-5 w-5 text-primary" onClick={() => saveEdit(seg.id)} disabled={isSaving} title={t('projectCard.segments.save')}>
<Button size="icon" variant="ghost" className="h-6 w-6 text-primary" onClick={() => saveEdit(seg.id)} disabled={isSaving} title={t('projectCard.segments.save')}>
{isSaving ? <Loader2 className="h-3 w-3 animate-spin" /> : <Check className="h-3 w-3" />}
</Button>
</>
@@ -1179,7 +1197,7 @@ export default function Audiobook() {
}
}, [segments, generatingChapterIndices])
const shouldPoll = isPolling || ['analyzing', 'generating'].includes(status) || hasParsingChapter || generatingChapterIndices.size > 0
const shouldPoll = isPolling || ['analyzing', 'generating'].includes(status) || hasParsingChapter || generatingChapterIndices.size > 0 || segments.some(s => s.status === 'generating')
useEffect(() => {
if (!shouldPoll || !selectedProjectId) return
@@ -1235,7 +1253,7 @@ export default function Audiobook() {
}
}
const handleGenerate = async (chapterIndex?: number) => {
const handleGenerate = async (chapterIndex?: number, force?: boolean) => {
if (!selectedProject) return
setLoadingAction(true)
if (chapterIndex !== undefined) {
@@ -1244,7 +1262,7 @@ export default function Audiobook() {
setIsPolling(true)
}
try {
await audiobookApi.generate(selectedProject.id, chapterIndex)
await audiobookApi.generate(selectedProject.id, chapterIndex, force)
toast.success(chapterIndex !== undefined
? t('projectCard.chapters.generateStarted', { index: chapterIndex + 1 })
: t('projectCard.chapters.generateAllStarted'))
@@ -1365,7 +1383,6 @@ export default function Audiobook() {
if (!selectedProject) return
try {
await audiobookApi.regenerateSegment(selectedProject.id, segmentId)
setIsPolling(true)
fetchSegments()
} catch (e: any) {
toast.error(formatApiError(e))
@@ -1479,22 +1496,22 @@ export default function Audiobook() {
{t(`status.${displayStatus}`, { defaultValue: displayStatus })}
</Badge>
{status === 'pending' && (
<Button size="sm" className="h-7 text-xs px-2" onClick={handleAnalyze} disabled={loadingAction}>
<Button size="sm" onClick={handleAnalyze} disabled={loadingAction}>
{t('projectCard.analyze')}
</Button>
)}
{status === 'ready' && (
<Button size="sm" className="h-7 text-xs px-2" onClick={handleProcessAll} disabled={loadingAction}>
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin mr-1" /> : null}
<Button size="sm" onClick={handleProcessAll} disabled={loadingAction}>
{loadingAction ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
{t('projectCard.processAll')}
</Button>
)}
{status === 'done' && (
<Button size="sm" variant="outline" className="h-7 text-xs px-2" onClick={() => handleDownload()} disabled={loadingAction}>
<Download className="h-3 w-3 mr-1" />{t('projectCard.downloadAll')}
<Button size="sm" variant="outline" onClick={() => handleDownload()} disabled={loadingAction}>
<Download className="h-3 w-3" />{t('projectCard.downloadAll')}
</Button>
)}
<Button size="icon" variant="ghost" className="h-7 w-7 shrink-0" onClick={handleDelete}>
<Button size="icon" variant="ghost" className="h-8 w-8 shrink-0" onClick={handleDelete}>
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
@@ -1532,14 +1549,14 @@ export default function Audiobook() {
{chaptersError > 0 && (
<>
<span className="text-destructive">({t('projectCard.chaptersError', { count: chaptersError })})</span>
<Button size="sm" variant="outline" className="h-5 text-[10px] px-1.5 text-destructive border-destructive/40" onClick={handleRetryFailed}>
<Button size="xs" variant="outline" className="text-destructive border-destructive/40" onClick={handleRetryFailed}>
{t('projectCard.retryFailed')}
</Button>
</>
)}
</div>
{chaptersParsing > 0 && totalCount > 0 && (
<Button size="sm" variant="ghost" className="h-5 text-[10px] px-1.5 text-destructive" onClick={handleCancelBatch}>
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
{t('projectCard.cancelParsing')}
</Button>
)}
@@ -1555,7 +1572,7 @@ export default function Audiobook() {
<span>{t('projectCard.segmentsProgress', { done: doneCount, total: totalCount })}</span>
</div>
{!chaptersParsing && hasGenerating && (
<Button size="sm" variant="ghost" className="h-5 text-[10px] px-1.5 text-destructive" onClick={handleCancelBatch}>
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
{t('projectCard.cancelGenerating')}
</Button>
)}
@@ -1565,7 +1582,7 @@ export default function Audiobook() {
)}
{chaptersParsing > 0 && !totalCount && (
<div className="flex justify-end">
<Button size="sm" variant="ghost" className="h-5 text-[10px] px-1.5 text-destructive" onClick={handleCancelBatch}>
<Button size="xs" variant="ghost" className="text-destructive" onClick={handleCancelBatch}>
{t('projectCard.cancelParsing')}
</Button>
</div>
@@ -1586,6 +1603,7 @@ export default function Audiobook() {
onScrollToChapter={(id) => setScrollToChapterId(id)}
/>
<ChaptersPanel
key={selectedProject.id}
project={selectedProject}
detail={detail}
segments={segments}