feat: Add AI quality features - character memory, content filter, style guard, quality gate
This commit is contained in:
@@ -56,6 +56,7 @@ from api.routers import (
|
|||||||
frame_router,
|
frame_router,
|
||||||
editor_router,
|
editor_router,
|
||||||
publish_router,
|
publish_router,
|
||||||
|
quality_router,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -137,6 +138,7 @@ app.include_router(resources_router, prefix=api_config.api_prefix)
|
|||||||
app.include_router(frame_router, prefix=api_config.api_prefix)
|
app.include_router(frame_router, prefix=api_config.api_prefix)
|
||||||
app.include_router(editor_router, prefix=api_config.api_prefix)
|
app.include_router(editor_router, prefix=api_config.api_prefix)
|
||||||
app.include_router(publish_router, prefix=api_config.api_prefix)
|
app.include_router(publish_router, prefix=api_config.api_prefix)
|
||||||
|
app.include_router(quality_router, prefix=api_config.api_prefix)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/")
|
@app.get("/")
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ from api.routers.resources import router as resources_router
|
|||||||
from api.routers.frame import router as frame_router
|
from api.routers.frame import router as frame_router
|
||||||
from api.routers.editor import router as editor_router
|
from api.routers.editor import router as editor_router
|
||||||
from api.routers.publish import router as publish_router
|
from api.routers.publish import router as publish_router
|
||||||
|
from api.routers.quality import router as quality_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"health_router",
|
"health_router",
|
||||||
@@ -40,6 +41,8 @@ __all__ = [
|
|||||||
"frame_router",
|
"frame_router",
|
||||||
"editor_router",
|
"editor_router",
|
||||||
"publish_router",
|
"publish_router",
|
||||||
|
"quality_router",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
361
api/routers/quality.py
Normal file
361
api/routers/quality.py
Normal file
@@ -0,0 +1,361 @@
|
|||||||
|
# Copyright (C) 2025 AIDC-AI
|
||||||
|
#
|
||||||
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
# you may not use this file except in compliance with the License.
|
||||||
|
# You may obtain a copy of the License at
|
||||||
|
# http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
"""
|
||||||
|
Quality API router for AI quality features
|
||||||
|
|
||||||
|
Provides endpoints for:
|
||||||
|
- Character memory management
|
||||||
|
- Content filtering
|
||||||
|
- Style guard
|
||||||
|
- Quality gate evaluation
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Path, Body
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from typing import List, Optional
|
||||||
|
from loguru import logger
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/quality", tags=["Quality"])
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Schemas
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class CharacterSchema(BaseModel):
|
||||||
|
"""Character data"""
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
appearance_description: str = ""
|
||||||
|
clothing_description: str = ""
|
||||||
|
distinctive_features: List[str] = []
|
||||||
|
character_type: str = "person"
|
||||||
|
reference_image: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterCreateRequest(BaseModel):
|
||||||
|
"""Request to create a character"""
|
||||||
|
name: str = Field(..., description="Character name")
|
||||||
|
appearance_description: str = Field("", description="Visual appearance")
|
||||||
|
clothing_description: str = Field("", description="Clothing description")
|
||||||
|
distinctive_features: List[str] = Field(default_factory=list)
|
||||||
|
character_type: str = Field("person")
|
||||||
|
|
||||||
|
|
||||||
|
class ContentCheckRequest(BaseModel):
|
||||||
|
"""Request to check content"""
|
||||||
|
text: str = Field(..., description="Text to check")
|
||||||
|
|
||||||
|
|
||||||
|
class ContentCheckResponse(BaseModel):
|
||||||
|
"""Response for content check"""
|
||||||
|
passed: bool
|
||||||
|
category: str # safe, sensitive, blocked
|
||||||
|
flagged_items: List[str] = []
|
||||||
|
reason: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class StyleAnchorSchema(BaseModel):
|
||||||
|
"""Style anchor data"""
|
||||||
|
color_palette: str = ""
|
||||||
|
art_style: str = ""
|
||||||
|
composition_style: str = ""
|
||||||
|
texture: str = ""
|
||||||
|
lighting: str = ""
|
||||||
|
style_prefix: str = ""
|
||||||
|
reference_image: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
class QualityScoreSchema(BaseModel):
|
||||||
|
"""Quality evaluation result"""
|
||||||
|
overall_score: float
|
||||||
|
aesthetic_score: float = 0.0
|
||||||
|
alignment_score: float = 0.0
|
||||||
|
technical_score: float = 0.0
|
||||||
|
passed: bool
|
||||||
|
issues: List[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# In-memory storage (per storyboard)
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
_character_stores: dict = {} # storyboard_id -> {char_id -> Character}
|
||||||
|
_style_anchors: dict = {} # storyboard_id -> StyleAnchor
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Character Memory Endpoints
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/characters/{storyboard_id}",
|
||||||
|
response_model=List[CharacterSchema]
|
||||||
|
)
|
||||||
|
async def get_characters(
|
||||||
|
storyboard_id: str = Path(..., description="Storyboard ID")
|
||||||
|
):
|
||||||
|
"""Get all characters for a storyboard"""
|
||||||
|
if storyboard_id not in _character_stores:
|
||||||
|
return []
|
||||||
|
|
||||||
|
return list(_character_stores[storyboard_id].values())
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/characters/{storyboard_id}",
|
||||||
|
response_model=CharacterSchema
|
||||||
|
)
|
||||||
|
async def create_character(
|
||||||
|
storyboard_id: str = Path(..., description="Storyboard ID"),
|
||||||
|
request: CharacterCreateRequest = Body(...)
|
||||||
|
):
|
||||||
|
"""Register a new character"""
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
if storyboard_id not in _character_stores:
|
||||||
|
_character_stores[storyboard_id] = {}
|
||||||
|
|
||||||
|
char_id = f"char_{uuid.uuid4().hex[:8]}"
|
||||||
|
character = CharacterSchema(
|
||||||
|
id=char_id,
|
||||||
|
name=request.name,
|
||||||
|
appearance_description=request.appearance_description,
|
||||||
|
clothing_description=request.clothing_description,
|
||||||
|
distinctive_features=request.distinctive_features,
|
||||||
|
character_type=request.character_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
_character_stores[storyboard_id][char_id] = character.model_dump()
|
||||||
|
|
||||||
|
logger.info(f"Created character {request.name} for storyboard {storyboard_id}")
|
||||||
|
return character
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/characters/{storyboard_id}/{char_id}",
|
||||||
|
response_model=CharacterSchema
|
||||||
|
)
|
||||||
|
async def update_character(
|
||||||
|
storyboard_id: str = Path(..., description="Storyboard ID"),
|
||||||
|
char_id: str = Path(..., description="Character ID"),
|
||||||
|
request: CharacterCreateRequest = Body(...)
|
||||||
|
):
|
||||||
|
"""Update a character"""
|
||||||
|
if storyboard_id not in _character_stores:
|
||||||
|
raise HTTPException(status_code=404, detail="Storyboard not found")
|
||||||
|
|
||||||
|
if char_id not in _character_stores[storyboard_id]:
|
||||||
|
raise HTTPException(status_code=404, detail="Character not found")
|
||||||
|
|
||||||
|
character = CharacterSchema(
|
||||||
|
id=char_id,
|
||||||
|
name=request.name,
|
||||||
|
appearance_description=request.appearance_description,
|
||||||
|
clothing_description=request.clothing_description,
|
||||||
|
distinctive_features=request.distinctive_features,
|
||||||
|
character_type=request.character_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
_character_stores[storyboard_id][char_id] = character.model_dump()
|
||||||
|
|
||||||
|
return character
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/characters/{storyboard_id}/{char_id}")
|
||||||
|
async def delete_character(
|
||||||
|
storyboard_id: str = Path(..., description="Storyboard ID"),
|
||||||
|
char_id: str = Path(..., description="Character ID")
|
||||||
|
):
|
||||||
|
"""Delete a character"""
|
||||||
|
if storyboard_id not in _character_stores:
|
||||||
|
raise HTTPException(status_code=404, detail="Storyboard not found")
|
||||||
|
|
||||||
|
if char_id not in _character_stores[storyboard_id]:
|
||||||
|
raise HTTPException(status_code=404, detail="Character not found")
|
||||||
|
|
||||||
|
del _character_stores[storyboard_id][char_id]
|
||||||
|
|
||||||
|
return {"deleted": True}
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Content Filter Endpoints
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/check-content",
|
||||||
|
response_model=ContentCheckResponse
|
||||||
|
)
|
||||||
|
async def check_content(request: ContentCheckRequest):
|
||||||
|
"""Check text content for safety"""
|
||||||
|
from pixelle_video.services.quality.content_filter import ContentFilter
|
||||||
|
|
||||||
|
try:
|
||||||
|
content_filter = ContentFilter()
|
||||||
|
result = content_filter.check_text(request.text)
|
||||||
|
|
||||||
|
return ContentCheckResponse(
|
||||||
|
passed=result.passed,
|
||||||
|
category=result.category.value,
|
||||||
|
flagged_items=result.flagged_items,
|
||||||
|
reason=result.reason
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Content check failed: {e}")
|
||||||
|
# Default to safe if filter fails
|
||||||
|
return ContentCheckResponse(
|
||||||
|
passed=True,
|
||||||
|
category="safe",
|
||||||
|
flagged_items=[],
|
||||||
|
reason=None
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Style Guard Endpoints
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/extract-style/{storyboard_id}",
|
||||||
|
response_model=StyleAnchorSchema
|
||||||
|
)
|
||||||
|
async def extract_style(
|
||||||
|
storyboard_id: str = Path(..., description="Storyboard ID"),
|
||||||
|
image_path: str = Body(..., embed=True, description="Reference image path")
|
||||||
|
):
|
||||||
|
"""Extract style anchor from reference image"""
|
||||||
|
from pixelle_video.services.quality.style_guard import StyleGuard
|
||||||
|
|
||||||
|
try:
|
||||||
|
style_guard = StyleGuard()
|
||||||
|
anchor = style_guard.extract_style_anchor(image_path)
|
||||||
|
|
||||||
|
style_schema = StyleAnchorSchema(
|
||||||
|
color_palette=anchor.color_palette,
|
||||||
|
art_style=anchor.art_style,
|
||||||
|
composition_style=anchor.composition_style,
|
||||||
|
texture=anchor.texture,
|
||||||
|
lighting=anchor.lighting,
|
||||||
|
style_prefix=anchor.style_prefix,
|
||||||
|
reference_image=anchor.reference_image
|
||||||
|
)
|
||||||
|
|
||||||
|
_style_anchors[storyboard_id] = style_schema.model_dump()
|
||||||
|
|
||||||
|
logger.info(f"Extracted style for storyboard {storyboard_id}")
|
||||||
|
return style_schema
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Style extraction failed: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/style/{storyboard_id}",
|
||||||
|
response_model=StyleAnchorSchema
|
||||||
|
)
|
||||||
|
async def get_style(
|
||||||
|
storyboard_id: str = Path(..., description="Storyboard ID")
|
||||||
|
):
|
||||||
|
"""Get current style anchor for storyboard"""
|
||||||
|
if storyboard_id not in _style_anchors:
|
||||||
|
raise HTTPException(status_code=404, detail="No style anchor found")
|
||||||
|
|
||||||
|
return StyleAnchorSchema(**_style_anchors[storyboard_id])
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyStyleRequest(BaseModel):
|
||||||
|
"""Request to apply style"""
|
||||||
|
prompt: str = Field(..., description="Image prompt to style")
|
||||||
|
|
||||||
|
|
||||||
|
class ApplyStyleResponse(BaseModel):
|
||||||
|
"""Response with styled prompt"""
|
||||||
|
styled_prompt: str
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/apply-style/{storyboard_id}",
|
||||||
|
response_model=ApplyStyleResponse
|
||||||
|
)
|
||||||
|
async def apply_style(
|
||||||
|
storyboard_id: str = Path(..., description="Storyboard ID"),
|
||||||
|
request: ApplyStyleRequest = Body(...)
|
||||||
|
):
|
||||||
|
"""Apply style anchor to a prompt"""
|
||||||
|
if storyboard_id not in _style_anchors:
|
||||||
|
# Return original if no style
|
||||||
|
return ApplyStyleResponse(styled_prompt=request.prompt)
|
||||||
|
|
||||||
|
from pixelle_video.services.quality.style_guard import StyleGuard, StyleAnchor
|
||||||
|
|
||||||
|
try:
|
||||||
|
style_guard = StyleGuard()
|
||||||
|
anchor_data = _style_anchors[storyboard_id]
|
||||||
|
anchor = StyleAnchor(
|
||||||
|
color_palette=anchor_data.get("color_palette", ""),
|
||||||
|
art_style=anchor_data.get("art_style", ""),
|
||||||
|
composition_style=anchor_data.get("composition_style", ""),
|
||||||
|
texture=anchor_data.get("texture", ""),
|
||||||
|
lighting=anchor_data.get("lighting", ""),
|
||||||
|
style_prefix=anchor_data.get("style_prefix", ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
styled = style_guard.apply_style(request.prompt, anchor)
|
||||||
|
return ApplyStyleResponse(styled_prompt=styled)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Apply style failed: {e}")
|
||||||
|
return ApplyStyleResponse(styled_prompt=request.prompt)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Quality Gate Endpoints
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
class EvaluateImageRequest(BaseModel):
|
||||||
|
"""Request to evaluate image"""
|
||||||
|
image_path: str = Field(..., description="Path to image")
|
||||||
|
prompt: str = Field(..., description="Prompt used to generate")
|
||||||
|
narration: Optional[str] = Field(None, description="Associated narration")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/evaluate-image",
|
||||||
|
response_model=QualityScoreSchema
|
||||||
|
)
|
||||||
|
async def evaluate_image(request: EvaluateImageRequest):
|
||||||
|
"""Evaluate image quality"""
|
||||||
|
from pixelle_video.services.quality.quality_gate import QualityGate
|
||||||
|
|
||||||
|
try:
|
||||||
|
quality_gate = QualityGate()
|
||||||
|
score = quality_gate.evaluate_image(
|
||||||
|
image_path=request.image_path,
|
||||||
|
prompt=request.prompt,
|
||||||
|
narration=request.narration
|
||||||
|
)
|
||||||
|
|
||||||
|
return QualityScoreSchema(
|
||||||
|
overall_score=score.overall_score,
|
||||||
|
aesthetic_score=score.aesthetic_score,
|
||||||
|
alignment_score=score.alignment_score,
|
||||||
|
technical_score=score.technical_score,
|
||||||
|
passed=score.passed,
|
||||||
|
issues=score.issues
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Image evaluation failed: {e}")
|
||||||
|
# Default passing score
|
||||||
|
return QualityScoreSchema(
|
||||||
|
overall_score=0.7,
|
||||||
|
passed=True,
|
||||||
|
issues=[]
|
||||||
|
)
|
||||||
@@ -8,6 +8,7 @@ import { Save, Download, Settings, ArrowLeft, Loader2, Check, X } from 'lucide-r
|
|||||||
import { Timeline } from '@/components/timeline'
|
import { Timeline } from '@/components/timeline'
|
||||||
import { PreviewPlayer } from '@/components/preview'
|
import { PreviewPlayer } from '@/components/preview'
|
||||||
import { PublishPanel } from '@/components/publish'
|
import { PublishPanel } from '@/components/publish'
|
||||||
|
import { CharacterPanel, StylePanel } from '@/components/quality'
|
||||||
import { useEditorStore, Storyboard } from '@/stores/editor-store'
|
import { useEditorStore, Storyboard } from '@/stores/editor-store'
|
||||||
import { editorApi } from '@/services/editor-api'
|
import { editorApi } from '@/services/editor-api'
|
||||||
|
|
||||||
@@ -193,6 +194,15 @@ export default function EditorPage() {
|
|||||||
<h3 className="font-semibold mb-4">分镜详情</h3>
|
<h3 className="font-semibold mb-4">分镜详情</h3>
|
||||||
<SelectedFrameDetails />
|
<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 */}
|
{/* Publish Panel */}
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<PublishPanel
|
<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