feat: Add AI quality features - character memory, content filter, style guard, quality gate
This commit is contained in:
@@ -8,6 +8,7 @@ import { Save, Download, Settings, ArrowLeft, Loader2, Check, X } from 'lucide-r
|
||||
import { Timeline } from '@/components/timeline'
|
||||
import { PreviewPlayer } from '@/components/preview'
|
||||
import { PublishPanel } from '@/components/publish'
|
||||
import { CharacterPanel, StylePanel } from '@/components/quality'
|
||||
import { useEditorStore, Storyboard } from '@/stores/editor-store'
|
||||
import { editorApi } from '@/services/editor-api'
|
||||
|
||||
@@ -193,6 +194,15 @@ export default function EditorPage() {
|
||||
<h3 className="font-semibold mb-4">分镜详情</h3>
|
||||
<SelectedFrameDetails />
|
||||
|
||||
{/* Quality Panels */}
|
||||
<div className="mt-6 space-y-4">
|
||||
<CharacterPanel storyboardId={storyboard?.id || ''} />
|
||||
<StylePanel
|
||||
storyboardId={storyboard?.id || ''}
|
||||
firstFrameImagePath={storyboard?.frames[0]?.imagePath}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Publish Panel */}
|
||||
<div className="mt-6">
|
||||
<PublishPanel
|
||||
|
||||
163
frontend/src/components/quality/character-panel.tsx
Normal file
163
frontend/src/components/quality/character-panel.tsx
Normal file
@@ -0,0 +1,163 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Plus, User, Trash2, Edit, Loader2 } from 'lucide-react'
|
||||
import { qualityApi, type Character } from '@/services/quality-api'
|
||||
|
||||
interface CharacterPanelProps {
|
||||
storyboardId: string
|
||||
}
|
||||
|
||||
export function CharacterPanel({ storyboardId }: CharacterPanelProps) {
|
||||
const [characters, setCharacters] = useState<Character[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isAdding, setIsAdding] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
|
||||
// Form state
|
||||
const [name, setName] = useState('')
|
||||
const [appearance, setAppearance] = useState('')
|
||||
const [clothing, setClothing] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
loadCharacters()
|
||||
}, [storyboardId])
|
||||
|
||||
const loadCharacters = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const data = await qualityApi.getCharacters(storyboardId)
|
||||
setCharacters(data)
|
||||
} catch (e) {
|
||||
console.error('Failed to load characters:', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!name.trim()) return
|
||||
|
||||
try {
|
||||
const newChar = await qualityApi.createCharacter(storyboardId, {
|
||||
name: name.trim(),
|
||||
appearance_description: appearance,
|
||||
clothing_description: clothing,
|
||||
distinctive_features: [],
|
||||
character_type: 'person',
|
||||
})
|
||||
setCharacters([...characters, newChar])
|
||||
resetForm()
|
||||
} catch (e) {
|
||||
console.error('Failed to add character:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async (charId: string) => {
|
||||
try {
|
||||
await qualityApi.deleteCharacter(storyboardId, charId)
|
||||
setCharacters(characters.filter(c => c.id !== charId))
|
||||
} catch (e) {
|
||||
console.error('Failed to delete character:', e)
|
||||
}
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
setName('')
|
||||
setAppearance('')
|
||||
setClothing('')
|
||||
setIsAdding(false)
|
||||
setEditingId(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 border rounded-lg bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||
<User className="h-4 w-4" />
|
||||
角色记忆
|
||||
</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setIsAdding(!isAdding)}
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{characters.map((char) => (
|
||||
<div
|
||||
key={char.id}
|
||||
className="flex items-center justify-between p-2 bg-muted/50 rounded text-sm"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">{char.name}</div>
|
||||
{char.appearance_description && (
|
||||
<div className="text-xs text-muted-foreground truncate max-w-[150px]">
|
||||
{char.appearance_description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => handleDelete(char.id)}
|
||||
>
|
||||
<Trash2 className="h-3 w-3 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{characters.length === 0 && !isAdding && (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">
|
||||
暂无角色
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isAdding && (
|
||||
<div className="space-y-2 pt-2 border-t">
|
||||
<input
|
||||
type="text"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="角色名称"
|
||||
className="w-full p-2 text-sm border rounded bg-background"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={appearance}
|
||||
onChange={(e) => setAppearance(e.target.value)}
|
||||
placeholder="外貌描述"
|
||||
className="w-full p-2 text-sm border rounded bg-background"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={clothing}
|
||||
onChange={(e) => setClothing(e.target.value)}
|
||||
placeholder="服装描述"
|
||||
className="w-full p-2 text-sm border rounded bg-background"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button size="sm" onClick={handleAdd}>
|
||||
添加
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onClick={resetForm}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
3
frontend/src/components/quality/index.ts
Normal file
3
frontend/src/components/quality/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export { CharacterPanel } from './character-panel'
|
||||
export { StylePanel } from './style-panel'
|
||||
export { QualityBadge } from './quality-badge'
|
||||
34
frontend/src/components/quality/quality-badge.tsx
Normal file
34
frontend/src/components/quality/quality-badge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
'use client'
|
||||
|
||||
interface QualityBadgeProps {
|
||||
score: number
|
||||
showScore?: boolean
|
||||
}
|
||||
|
||||
export function QualityBadge({ score, showScore = true }: QualityBadgeProps) {
|
||||
const getColor = () => {
|
||||
if (score >= 0.8) return 'bg-green-500'
|
||||
if (score >= 0.6) return 'bg-yellow-500'
|
||||
if (score >= 0.4) return 'bg-orange-500'
|
||||
return 'bg-red-500'
|
||||
}
|
||||
|
||||
const getLabel = () => {
|
||||
if (score >= 0.8) return '优'
|
||||
if (score >= 0.6) return '良'
|
||||
if (score >= 0.4) return '中'
|
||||
return '差'
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`inline-flex items-center gap-1 px-1.5 py-0.5 rounded text-[10px] font-medium text-white ${getColor()}`}
|
||||
title={`质量评分: ${Math.round(score * 100)}%`}
|
||||
>
|
||||
{getLabel()}
|
||||
{showScore && (
|
||||
<span className="opacity-80">{Math.round(score * 100)}</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
107
frontend/src/components/quality/style-panel.tsx
Normal file
107
frontend/src/components/quality/style-panel.tsx
Normal file
@@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Palette, RefreshCw, Loader2 } from 'lucide-react'
|
||||
import { qualityApi, type StyleAnchor } from '@/services/quality-api'
|
||||
|
||||
interface StylePanelProps {
|
||||
storyboardId: string
|
||||
firstFrameImagePath?: string
|
||||
}
|
||||
|
||||
export function StylePanel({ storyboardId, firstFrameImagePath }: StylePanelProps) {
|
||||
const [styleAnchor, setStyleAnchor] = useState<StyleAnchor | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [isExtracting, setIsExtracting] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
loadStyle()
|
||||
}, [storyboardId])
|
||||
|
||||
const loadStyle = async () => {
|
||||
try {
|
||||
setIsLoading(true)
|
||||
const style = await qualityApi.getStyle(storyboardId)
|
||||
setStyleAnchor(style)
|
||||
} catch (e) {
|
||||
console.error('Failed to load style:', e)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleExtract = async () => {
|
||||
if (!firstFrameImagePath) return
|
||||
|
||||
try {
|
||||
setIsExtracting(true)
|
||||
const style = await qualityApi.extractStyle(storyboardId, firstFrameImagePath)
|
||||
setStyleAnchor(style)
|
||||
} catch (e) {
|
||||
console.error('Failed to extract style:', e)
|
||||
} finally {
|
||||
setIsExtracting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3 p-3 border rounded-lg bg-card">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-medium text-sm flex items-center gap-2">
|
||||
<Palette className="h-4 w-4" />
|
||||
风格守卫
|
||||
</h4>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleExtract}
|
||||
disabled={isExtracting || !firstFrameImagePath}
|
||||
title="从首帧提取风格"
|
||||
>
|
||||
{isExtracting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : styleAnchor ? (
|
||||
<div className="space-y-1 text-xs">
|
||||
{styleAnchor.art_style && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground">风格:</span>
|
||||
<span>{styleAnchor.art_style}</span>
|
||||
</div>
|
||||
)}
|
||||
{styleAnchor.color_palette && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground">调色:</span>
|
||||
<span>{styleAnchor.color_palette}</span>
|
||||
</div>
|
||||
)}
|
||||
{styleAnchor.lighting && (
|
||||
<div className="flex gap-2">
|
||||
<span className="text-muted-foreground">光照:</span>
|
||||
<span>{styleAnchor.lighting}</span>
|
||||
</div>
|
||||
)}
|
||||
{styleAnchor.style_prefix && (
|
||||
<div className="mt-2 p-2 bg-muted/50 rounded text-[10px] break-all">
|
||||
{styleAnchor.style_prefix}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground text-center py-2">
|
||||
点击刷新按钮从首帧提取风格
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
208
frontend/src/services/quality-api.ts
Normal file
208
frontend/src/services/quality-api.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* Quality API Client
|
||||
*
|
||||
* Provides client methods for AI quality features:
|
||||
* - Character memory management
|
||||
* - Content filtering
|
||||
* - Style guard
|
||||
* - Quality gate evaluation
|
||||
*/
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:8000/api'
|
||||
|
||||
export interface Character {
|
||||
id: string
|
||||
name: string
|
||||
appearance_description: string
|
||||
clothing_description: string
|
||||
distinctive_features: string[]
|
||||
character_type: string
|
||||
reference_image?: string
|
||||
}
|
||||
|
||||
export interface ContentCheckResult {
|
||||
passed: boolean
|
||||
category: 'safe' | 'sensitive' | 'blocked'
|
||||
flagged_items: string[]
|
||||
reason?: string
|
||||
}
|
||||
|
||||
export interface StyleAnchor {
|
||||
color_palette: string
|
||||
art_style: string
|
||||
composition_style: string
|
||||
texture: string
|
||||
lighting: string
|
||||
style_prefix: string
|
||||
reference_image?: string
|
||||
}
|
||||
|
||||
export interface QualityScore {
|
||||
overall_score: number
|
||||
aesthetic_score: number
|
||||
alignment_score: number
|
||||
technical_score: number
|
||||
passed: boolean
|
||||
issues: string[]
|
||||
}
|
||||
|
||||
class QualityApiClient {
|
||||
private baseUrl = API_BASE
|
||||
|
||||
// ============================================================
|
||||
// Character Memory
|
||||
// ============================================================
|
||||
|
||||
async getCharacters(storyboardId: string): Promise<Character[]> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/quality/characters/${storyboardId}`
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get characters')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async createCharacter(
|
||||
storyboardId: string,
|
||||
data: Omit<Character, 'id'>
|
||||
): Promise<Character> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/quality/characters/${storyboardId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to create character')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async updateCharacter(
|
||||
storyboardId: string,
|
||||
charId: string,
|
||||
data: Omit<Character, 'id'>
|
||||
): Promise<Character> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/quality/characters/${storyboardId}/${charId}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(data),
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to update character')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async deleteCharacter(storyboardId: string, charId: string): Promise<void> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/quality/characters/${storyboardId}/${charId}`,
|
||||
{ method: 'DELETE' }
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to delete character')
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Content Filter
|
||||
// ============================================================
|
||||
|
||||
async checkContent(text: string): Promise<ContentCheckResult> {
|
||||
const response = await fetch(`${this.baseUrl}/quality/check-content`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ text }),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to check content')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Style Guard
|
||||
// ============================================================
|
||||
|
||||
async extractStyle(
|
||||
storyboardId: string,
|
||||
imagePath: string
|
||||
): Promise<StyleAnchor> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/quality/extract-style/${storyboardId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ image_path: imagePath }),
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to extract style')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async getStyle(storyboardId: string): Promise<StyleAnchor | null> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/quality/style/${storyboardId}`
|
||||
)
|
||||
if (response.status === 404) {
|
||||
return null
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to get style')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async applyStyle(
|
||||
storyboardId: string,
|
||||
prompt: string
|
||||
): Promise<{ styled_prompt: string }> {
|
||||
const response = await fetch(
|
||||
`${this.baseUrl}/quality/apply-style/${storyboardId}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt }),
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to apply style')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Quality Gate
|
||||
// ============================================================
|
||||
|
||||
async evaluateImage(
|
||||
imagePath: string,
|
||||
prompt: string,
|
||||
narration?: string
|
||||
): Promise<QualityScore> {
|
||||
const response = await fetch(`${this.baseUrl}/quality/evaluate-image`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
image_path: imagePath,
|
||||
prompt,
|
||||
narration,
|
||||
}),
|
||||
})
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to evaluate image')
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const qualityApi = new QualityApiClient()
|
||||
Reference in New Issue
Block a user