feat: Add inpainting (局部重绘) feature for timeline editor

- Add canvas-based mask drawing tools (brush, eraser, rect, lasso)
- Add undo/redo history support for mask editing
- Integrate inpainting UI into preview player
- Add backend API endpoint for inpainting requests
- Add MediaService.inpaint method with ComfyUI workflow support
- Add Flux inpainting workflows for selfhost and RunningHub

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
empty
2026-01-05 23:44:51 +08:00
parent 56db9bf9d2
commit 79a6c2ef3e
17 changed files with 1444 additions and 5 deletions

View File

@@ -36,6 +36,8 @@ from api.schemas.editor import (
RegenerateImageResponse,
RegenerateAudioRequest,
RegenerateAudioResponse,
InpaintRequest,
InpaintResponse,
)
router = APIRouter(prefix="/editor", tags=["Editor"])
@@ -577,3 +579,107 @@ async def regenerate_frame_audio(
except Exception as e:
logger.error(f"Audio regeneration failed: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post(
"/storyboard/{storyboard_id}/frames/{frame_id}/inpaint",
response_model=InpaintResponse
)
async def inpaint_frame_image(
storyboard_id: str = Path(..., description="Storyboard/task ID"),
frame_id: str = Path(..., description="Frame ID"),
request: InpaintRequest = None
):
"""
Inpaint (局部重绘) frame image
Uses mask to selectively regenerate parts of the image.
"""
if storyboard_id not in _storyboard_cache:
raise HTTPException(status_code=404, detail=f"Storyboard {storyboard_id} not found")
storyboard = _storyboard_cache[storyboard_id]
frames = storyboard["frames"]
# Find frame
target_frame = None
frame_index = 0
for i, frame in enumerate(frames):
if frame["id"] == frame_id:
target_frame = frame
frame_index = i
break
if not target_frame:
raise HTTPException(status_code=404, detail=f"Frame {frame_id} not found")
# Get original image path
original_image = target_frame.get("image_path")
if not original_image:
raise HTTPException(status_code=400, detail="No image to inpaint")
if not request or not request.mask:
raise HTTPException(status_code=400, detail="Mask is required")
try:
from api.dependencies import get_pixelle_video
import base64
import tempfile
import os
pixelle_video = await get_pixelle_video()
# Save mask to temp file
mask_data = base64.b64decode(request.mask)
output_dir = f"output/{storyboard_id}"
os.makedirs(output_dir, exist_ok=True)
mask_path = f"{output_dir}/mask_{frame_index}.png"
with open(mask_path, 'wb') as f:
f.write(mask_data)
# Get prompt
prompt = request.prompt or target_frame.get("image_prompt", "")
# Call inpaint service
# Convert URL back to file path (URL format: http://localhost:8000/api/files/{relative_path})
image_file_path = original_image
if "/api/files/" in original_image:
image_file_path = "output/" + original_image.split("/api/files/")[-1]
result = await pixelle_video.media.inpaint(
image_path=image_file_path,
mask_path=mask_path,
prompt=prompt,
denoise_strength=request.denoise_strength,
)
if result and result.url:
# Save inpainted image
import aiohttp
image_path = f"{output_dir}/frame_{frame_index}_inpainted.png"
async with aiohttp.ClientSession() as session:
async with session.get(result.url) as resp:
if resp.status == 200:
with open(image_path, 'wb') as f:
f.write(await resp.read())
# Update frame
target_frame["image_path"] = _path_to_url(image_path)
_storyboard_cache[storyboard_id] = storyboard
logger.info(f"Inpainted image for frame {frame_id}")
return InpaintResponse(
image_path=target_frame["image_path"],
success=True
)
else:
raise HTTPException(status_code=500, detail="Inpainting failed")
except ImportError as e:
logger.error(f"Failed to import dependencies: {e}")
raise HTTPException(status_code=500, detail="Inpainting service not available")
except Exception as e:
logger.error(f"Inpainting failed: {e}")
raise HTTPException(status_code=500, detail=str(e))

View File

@@ -108,3 +108,16 @@ class RegenerateAudioResponse(BaseModel):
duration: float
success: bool = True
class InpaintRequest(BaseModel):
"""Request to inpaint (局部重绘) frame image"""
mask: str = Field(..., description="Base64 encoded mask image (white=inpaint, black=keep)")
prompt: Optional[str] = Field(None, description="Optional prompt for inpainted region")
denoise_strength: float = Field(0.8, ge=0.0, le=1.0, description="Denoise strength (0.0-1.0)")
class InpaintResponse(BaseModel):
"""Response after inpainting"""
image_path: str
success: bool = True

View File

@@ -0,0 +1,65 @@
/**
* useCanvasHistory - Undo/Redo hook for canvas operations
*/
import { useState, useCallback } from 'react'
interface UseCanvasHistoryOptions {
maxHistory?: number
}
export function useCanvasHistory(options: UseCanvasHistoryOptions = {}) {
const { maxHistory = 50 } = options
const [history, setHistory] = useState<string[]>([])
const [historyIndex, setHistoryIndex] = useState(-1)
// Save current state to history
const saveState = useCallback((dataUrl: string) => {
setHistory(prev => {
// Remove any future states if we're not at the end
const newHistory = prev.slice(0, historyIndex + 1)
newHistory.push(dataUrl)
// Limit history size
if (newHistory.length > maxHistory) {
newHistory.shift()
return newHistory
}
return newHistory
})
setHistoryIndex(prev => Math.min(prev + 1, maxHistory - 1))
}, [historyIndex, maxHistory])
// Undo - return previous state
const undo = useCallback((): string | null => {
if (historyIndex <= 0) return null
const newIndex = historyIndex - 1
setHistoryIndex(newIndex)
return history[newIndex]
}, [history, historyIndex])
// Redo - return next state
const redo = useCallback((): string | null => {
if (historyIndex >= history.length - 1) return null
const newIndex = historyIndex + 1
setHistoryIndex(newIndex)
return history[newIndex]
}, [history, historyIndex])
// Clear history
const clearHistory = useCallback(() => {
setHistory([])
setHistoryIndex(-1)
}, [])
return {
saveState,
undo,
redo,
clearHistory,
canUndo: historyIndex > 0,
canRedo: historyIndex < history.length - 1,
historyLength: history.length,
}
}

View File

@@ -0,0 +1,8 @@
/**
* Inpainting Components - Export all components
*/
export { InpaintingCanvas, type InpaintingCanvasProps, type InpaintingCanvasRef } from './inpainting-canvas'
export { InpaintingToolbar, type InpaintingToolbarProps } from './inpainting-toolbar'
export { useCanvasHistory } from './hooks/use-canvas-history'
export * from './tools'

View File

@@ -0,0 +1,340 @@
'use client'
/**
* InpaintingCanvas - Core canvas component for mask drawing
*/
import { useRef, useEffect, useCallback, useState, forwardRef, useImperativeHandle } from 'react'
import { BrushTool, EraserTool, RectTool, LassoTool, type ToolType, type Point } from './tools'
import { useCanvasHistory } from './hooks/use-canvas-history'
export interface InpaintingCanvasProps {
imageSrc: string
width: number
height: number
activeTool: ToolType
brushSize: number
onMaskChange?: (maskDataUrl: string | null) => void
}
export interface InpaintingCanvasRef {
undo: () => void
redo: () => void
clear: () => void
exportMask: () => string | null
canUndo: boolean
canRedo: boolean
}
const MASK_COLOR = 'rgba(255, 0, 0, 0.5)'
export const InpaintingCanvas = forwardRef<InpaintingCanvasRef, InpaintingCanvasProps>(function InpaintingCanvas({
imageSrc,
width,
height,
activeTool,
brushSize,
onMaskChange,
}, ref) {
// Canvas refs
const containerRef = useRef<HTMLDivElement>(null)
const imageCanvasRef = useRef<HTMLCanvasElement>(null)
const maskCanvasRef = useRef<HTMLCanvasElement>(null)
const previewCanvasRef = useRef<HTMLCanvasElement>(null)
// Tool refs
const brushToolRef = useRef<BrushTool | null>(null)
const eraserToolRef = useRef<EraserTool | null>(null)
const rectToolRef = useRef<RectTool | null>(null)
const lassoToolRef = useRef<LassoTool | null>(null)
// History
const { saveState, undo, redo, clearHistory, canUndo, canRedo } = useCanvasHistory()
// State
const [isLoaded, setIsLoaded] = useState(false)
// Initialize tools when mask canvas is ready
useEffect(() => {
const maskCanvas = maskCanvasRef.current
const previewCanvas = previewCanvasRef.current
if (!maskCanvas || !previewCanvas) return
const ctx = maskCanvas.getContext('2d')
if (!ctx) return
brushToolRef.current = new BrushTool(ctx, { size: brushSize, color: MASK_COLOR })
eraserToolRef.current = new EraserTool(ctx, { size: brushSize })
rectToolRef.current = new RectTool(ctx, { color: MASK_COLOR })
lassoToolRef.current = new LassoTool(ctx, { color: MASK_COLOR })
rectToolRef.current.setPreviewCanvas(previewCanvas)
lassoToolRef.current.setPreviewCanvas(previewCanvas)
}, [brushSize])
// Update brush size
useEffect(() => {
brushToolRef.current?.setSize(brushSize)
eraserToolRef.current?.setSize(brushSize)
}, [brushSize])
// Load image
useEffect(() => {
const imageCanvas = imageCanvasRef.current
if (!imageCanvas || !imageSrc) return
const ctx = imageCanvas.getContext('2d')
if (!ctx) return
setIsLoaded(false)
const img = new Image()
// Only set crossOrigin for external URLs
if (!imageSrc.startsWith('data:') && !imageSrc.includes('localhost')) {
img.crossOrigin = 'anonymous'
}
img.onload = () => {
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, 0, 0, width, height)
setIsLoaded(true)
}
img.onerror = (e) => {
console.error('Failed to load image:', e)
// Still mark as loaded to show canvas (user can see error)
setIsLoaded(true)
}
img.src = imageSrc
}, [imageSrc, width, height])
// Get mouse position relative to canvas
const getCanvasPoint = useCallback((e: React.MouseEvent | React.TouchEvent): Point => {
const canvas = maskCanvasRef.current
if (!canvas) return { x: 0, y: 0 }
const rect = canvas.getBoundingClientRect()
const scaleX = canvas.width / rect.width
const scaleY = canvas.height / rect.height
if ('touches' in e) {
const touch = e.touches[0]
return {
x: (touch.clientX - rect.left) * scaleX,
y: (touch.clientY - rect.top) * scaleY,
}
}
return {
x: (e.clientX - rect.left) * scaleX,
y: (e.clientY - rect.top) * scaleY,
}
}, [])
// Save mask state after drawing
const saveMaskState = useCallback(() => {
const maskCanvas = maskCanvasRef.current
if (!maskCanvas) return
const dataUrl = maskCanvas.toDataURL('image/png')
saveState(dataUrl)
onMaskChange?.(dataUrl)
}, [saveState, onMaskChange])
// Mouse/Touch handlers
const handlePointerDown = useCallback((e: React.MouseEvent | React.TouchEvent) => {
const point = getCanvasPoint(e)
switch (activeTool) {
case 'brush':
brushToolRef.current?.startStroke(point)
break
case 'eraser':
eraserToolRef.current?.startErase(point)
break
case 'rect':
rectToolRef.current?.startRect(point)
break
case 'lasso':
lassoToolRef.current?.addPoint(point)
break
}
}, [activeTool, getCanvasPoint])
const handlePointerMove = useCallback((e: React.MouseEvent | React.TouchEvent) => {
const point = getCanvasPoint(e)
switch (activeTool) {
case 'brush':
brushToolRef.current?.continueStroke(point)
break
case 'eraser':
eraserToolRef.current?.continueErase(point)
break
case 'rect':
rectToolRef.current?.drawPreview(point)
break
}
}, [activeTool, getCanvasPoint])
const handlePointerUp = useCallback((e: React.MouseEvent | React.TouchEvent) => {
const point = getCanvasPoint(e)
switch (activeTool) {
case 'brush':
brushToolRef.current?.endStroke()
saveMaskState()
break
case 'eraser':
eraserToolRef.current?.endErase()
saveMaskState()
break
case 'rect':
rectToolRef.current?.endRect(point)
saveMaskState()
break
}
}, [activeTool, getCanvasPoint, saveMaskState])
// Double click to close lasso
const handleDoubleClick = useCallback(() => {
if (activeTool === 'lasso') {
lassoToolRef.current?.closePath()
saveMaskState()
}
}, [activeTool, saveMaskState])
// Undo/Redo handlers
const handleUndo = useCallback(() => {
const prevState = undo()
if (prevState && maskCanvasRef.current) {
const ctx = maskCanvasRef.current.getContext('2d')
if (!ctx) return
const img = new Image()
img.onload = () => {
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, 0, 0)
onMaskChange?.(prevState)
}
img.src = prevState
}
}, [undo, width, height, onMaskChange])
const handleRedo = useCallback(() => {
const nextState = redo()
if (nextState && maskCanvasRef.current) {
const ctx = maskCanvasRef.current.getContext('2d')
if (!ctx) return
const img = new Image()
img.onload = () => {
ctx.clearRect(0, 0, width, height)
ctx.drawImage(img, 0, 0)
onMaskChange?.(nextState)
}
img.src = nextState
}
}, [redo, width, height, onMaskChange])
// Clear mask
const handleClear = useCallback(() => {
const maskCanvas = maskCanvasRef.current
if (!maskCanvas) return
const ctx = maskCanvas.getContext('2d')
if (!ctx) return
ctx.clearRect(0, 0, width, height)
clearHistory()
onMaskChange?.(null)
}, [width, height, clearHistory, onMaskChange])
// Export mask as black/white base64
const exportMask = useCallback((): string | null => {
const maskCanvas = maskCanvasRef.current
if (!maskCanvas) return null
const ctx = maskCanvas.getContext('2d')
if (!ctx) return null
// Create temp canvas for black/white conversion
const tempCanvas = document.createElement('canvas')
tempCanvas.width = width
tempCanvas.height = height
const tempCtx = tempCanvas.getContext('2d')
if (!tempCtx) return null
// Get mask data
const imageData = ctx.getImageData(0, 0, width, height)
const data = imageData.data
// Convert to black/white (alpha > 0 = white, else black)
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3]
const value = alpha > 0 ? 255 : 0
data[i] = value // R
data[i + 1] = value // G
data[i + 2] = value // B
data[i + 3] = 255 // A
}
tempCtx.putImageData(imageData, 0, 0)
return tempCanvas.toDataURL('image/png').split(',')[1]
}, [width, height])
// Expose methods via ref
useImperativeHandle(ref, () => ({
undo: handleUndo,
redo: handleRedo,
clear: handleClear,
exportMask,
canUndo,
canRedo,
}), [handleUndo, handleRedo, handleClear, exportMask, canUndo, canRedo])
return (
<div
ref={containerRef}
className="relative"
style={{ width, height }}
>
{/* Image layer */}
<canvas
ref={imageCanvasRef}
width={width}
height={height}
className="absolute inset-0"
/>
{/* Mask layer */}
<canvas
ref={maskCanvasRef}
width={width}
height={height}
className="absolute inset-0"
onMouseDown={handlePointerDown}
onMouseMove={handlePointerMove}
onMouseUp={handlePointerUp}
onMouseLeave={handlePointerUp}
onTouchStart={handlePointerDown}
onTouchMove={handlePointerMove}
onTouchEnd={handlePointerUp}
onDoubleClick={handleDoubleClick}
style={{ cursor: activeTool === 'lasso' ? 'crosshair' : 'default' }}
/>
{/* Preview layer (for rect/lasso) */}
<canvas
ref={previewCanvasRef}
width={width}
height={height}
className="absolute inset-0 pointer-events-none"
/>
{/* Loading overlay */}
{!isLoaded && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<span className="text-white">Loading...</span>
</div>
)}
</div>
)
})

View File

@@ -0,0 +1,115 @@
'use client'
/**
* InpaintingToolbar - Toolbar for inpainting tools
*/
import { Paintbrush, Eraser, Square, Lasso, Undo2, Redo2, Trash2, X, Check } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import type { ToolType } from './tools'
export interface InpaintingToolbarProps {
activeTool: ToolType
brushSize: number
canUndo: boolean
canRedo: boolean
onToolChange: (tool: ToolType) => void
onBrushSizeChange: (size: number) => void
onUndo: () => void
onRedo: () => void
onClear: () => void
onCancel: () => void
onApply: () => void
isApplying?: boolean
}
const tools: { type: ToolType; icon: typeof Paintbrush; label: string }[] = [
{ type: 'brush', icon: Paintbrush, label: '画笔' },
{ type: 'eraser', icon: Eraser, label: '橡皮擦' },
{ type: 'rect', icon: Square, label: '矩形' },
{ type: 'lasso', icon: Lasso, label: '套索' },
]
export function InpaintingToolbar({
activeTool,
brushSize,
canUndo,
canRedo,
onToolChange,
onBrushSizeChange,
onUndo,
onRedo,
onClear,
onCancel,
onApply,
isApplying = false,
}: InpaintingToolbarProps) {
return (
<div className="flex items-center gap-2 p-2 bg-background/95 backdrop-blur rounded-lg shadow-lg border">
{/* Tool buttons */}
<div className="flex items-center gap-1">
{tools.map(({ type, icon: Icon, label }) => (
<Button
key={type}
variant={activeTool === type ? 'default' : 'ghost'}
size="icon"
onClick={() => onToolChange(type)}
title={label}
>
<Icon className="h-4 w-4" />
</Button>
))}
</div>
<div className="w-px h-6 bg-border" />
{/* Brush size slider */}
<div className="flex items-center gap-2 min-w-[120px]">
<span className="text-xs text-muted-foreground"></span>
<Slider
value={[brushSize]}
onValueChange={([v]) => onBrushSizeChange(v)}
min={1}
max={100}
step={1}
className="w-20"
/>
<span className="text-xs w-6">{brushSize}</span>
</div>
<div className="w-px h-6 bg-border" />
{/* Undo/Redo/Clear */}
<div className="flex items-center gap-1">
<Button variant="ghost" size="icon" onClick={onUndo} disabled={!canUndo} title="撤销">
<Undo2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onRedo} disabled={!canRedo} title="重做">
<Redo2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={onClear} title="清除">
<Trash2 className="h-4 w-4" />
</Button>
</div>
<div className="w-px h-6 bg-border" />
{/* Cancel/Apply */}
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" onClick={onCancel} disabled={isApplying}>
<X className="h-4 w-4 mr-1" />
</Button>
<Button size="sm" onClick={onApply} disabled={isApplying}>
{isApplying ? (
<span className="animate-spin mr-1"></span>
) : (
<Check className="h-4 w-4 mr-1" />
)}
</Button>
</div>
</div>
)
}

View File

@@ -0,0 +1,69 @@
/**
* Brush Tool - Free drawing tool for mask creation
*/
export interface Point {
x: number
y: number
}
export interface BrushToolOptions {
size: number
color: string
}
export class BrushTool {
private ctx: CanvasRenderingContext2D
private isDrawing: boolean = false
private lastPoint: Point | null = null
private options: BrushToolOptions
constructor(ctx: CanvasRenderingContext2D, options: BrushToolOptions) {
this.ctx = ctx
this.options = options
}
setSize(size: number) {
this.options.size = size
}
setColor(color: string) {
this.options.color = color
}
startStroke(point: Point) {
this.isDrawing = true
this.lastPoint = point
// Draw initial dot
this.ctx.fillStyle = this.options.color
this.ctx.beginPath()
this.ctx.arc(point.x, point.y, this.options.size / 2, 0, Math.PI * 2)
this.ctx.fill()
}
continueStroke(point: Point) {
if (!this.isDrawing || !this.lastPoint) return
this.ctx.strokeStyle = this.options.color
this.ctx.lineWidth = this.options.size
this.ctx.lineCap = 'round'
this.ctx.lineJoin = 'round'
this.ctx.beginPath()
this.ctx.moveTo(this.lastPoint.x, this.lastPoint.y)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.lastPoint = point
}
endStroke() {
this.isDrawing = false
this.lastPoint = null
}
isActive(): boolean {
return this.isDrawing
}
}

View File

@@ -0,0 +1,69 @@
/**
* Eraser Tool - Erase mask areas
*/
import { Point } from './brush-tool'
export interface EraserToolOptions {
size: number
}
export class EraserTool {
private ctx: CanvasRenderingContext2D
private isErasing: boolean = false
private lastPoint: Point | null = null
private options: EraserToolOptions
constructor(ctx: CanvasRenderingContext2D, options: EraserToolOptions) {
this.ctx = ctx
this.options = options
}
setSize(size: number) {
this.options.size = size
}
startErase(point: Point) {
this.isErasing = true
this.lastPoint = point
// Save composite operation
const prevOp = this.ctx.globalCompositeOperation
this.ctx.globalCompositeOperation = 'destination-out'
// Erase initial dot
this.ctx.beginPath()
this.ctx.arc(point.x, point.y, this.options.size / 2, 0, Math.PI * 2)
this.ctx.fill()
this.ctx.globalCompositeOperation = prevOp
}
continueErase(point: Point) {
if (!this.isErasing || !this.lastPoint) return
const prevOp = this.ctx.globalCompositeOperation
this.ctx.globalCompositeOperation = 'destination-out'
this.ctx.lineWidth = this.options.size
this.ctx.lineCap = 'round'
this.ctx.lineJoin = 'round'
this.ctx.beginPath()
this.ctx.moveTo(this.lastPoint.x, this.lastPoint.y)
this.ctx.lineTo(point.x, point.y)
this.ctx.stroke()
this.ctx.globalCompositeOperation = prevOp
this.lastPoint = point
}
endErase() {
this.isErasing = false
this.lastPoint = null
}
isActive(): boolean {
return this.isErasing
}
}

View File

@@ -0,0 +1,10 @@
/**
* Inpainting Tools - Export all tools
*/
export { BrushTool, type Point, type BrushToolOptions } from './brush-tool'
export { EraserTool, type EraserToolOptions } from './eraser-tool'
export { RectTool, type RectToolOptions } from './rect-tool'
export { LassoTool, type LassoToolOptions } from './lasso-tool'
export type ToolType = 'brush' | 'eraser' | 'rect' | 'lasso'

View File

@@ -0,0 +1,107 @@
/**
* Lasso Tool - Free polygon selection for mask
*/
import { Point } from './brush-tool'
export interface LassoToolOptions {
color: string
}
export class LassoTool {
private ctx: CanvasRenderingContext2D
private isDrawing: boolean = false
private points: Point[] = []
private options: LassoToolOptions
private previewCanvas: HTMLCanvasElement | null = null
constructor(ctx: CanvasRenderingContext2D, options: LassoToolOptions) {
this.ctx = ctx
this.options = options
}
setColor(color: string) {
this.options.color = color
}
setPreviewCanvas(canvas: HTMLCanvasElement) {
this.previewCanvas = canvas
}
// Add point on click
addPoint(point: Point) {
if (!this.isDrawing) {
this.isDrawing = true
this.points = []
}
this.points.push(point)
this.drawPreview()
}
// Draw preview polygon
private drawPreview() {
if (!this.previewCanvas || this.points.length === 0) return
const previewCtx = this.previewCanvas.getContext('2d')
if (!previewCtx) return
previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height)
// Draw polygon outline
previewCtx.strokeStyle = this.options.color
previewCtx.lineWidth = 2
previewCtx.setLineDash([5, 5])
previewCtx.beginPath()
previewCtx.moveTo(this.points[0].x, this.points[0].y)
for (let i = 1; i < this.points.length; i++) {
previewCtx.lineTo(this.points[i].x, this.points[i].y)
}
previewCtx.stroke()
// Draw points
this.points.forEach((p, i) => {
previewCtx.fillStyle = i === 0 ? '#00ff00' : this.options.color
previewCtx.beginPath()
previewCtx.arc(p.x, p.y, 4, 0, Math.PI * 2)
previewCtx.fill()
})
}
// Close and fill polygon on double-click
closePath() {
if (!this.isDrawing || this.points.length < 3) {
this.cancel()
return
}
// Fill polygon on mask
this.ctx.fillStyle = this.options.color
this.ctx.beginPath()
this.ctx.moveTo(this.points[0].x, this.points[0].y)
for (let i = 1; i < this.points.length; i++) {
this.ctx.lineTo(this.points[i].x, this.points[i].y)
}
this.ctx.closePath()
this.ctx.fill()
this.cancel()
}
cancel() {
this.isDrawing = false
this.points = []
if (this.previewCanvas) {
const ctx = this.previewCanvas.getContext('2d')
ctx?.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height)
}
}
isActive(): boolean {
return this.isDrawing
}
getPointCount(): number {
return this.points.length
}
}

View File

@@ -0,0 +1,93 @@
/**
* Rectangle Tool - Draw rectangular mask areas
*/
import { Point } from './brush-tool'
export interface RectToolOptions {
color: string
}
export class RectTool {
private ctx: CanvasRenderingContext2D
private isDrawing: boolean = false
private startPoint: Point | null = null
private options: RectToolOptions
private previewCanvas: HTMLCanvasElement | null = null
constructor(ctx: CanvasRenderingContext2D, options: RectToolOptions) {
this.ctx = ctx
this.options = options
}
setColor(color: string) {
this.options.color = color
}
setPreviewCanvas(canvas: HTMLCanvasElement) {
this.previewCanvas = canvas
}
startRect(point: Point) {
this.isDrawing = true
this.startPoint = point
}
// Draw preview rectangle (on preview layer)
drawPreview(currentPoint: Point) {
if (!this.isDrawing || !this.startPoint || !this.previewCanvas) return
const previewCtx = this.previewCanvas.getContext('2d')
if (!previewCtx) return
// Clear preview
previewCtx.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height)
// Draw preview rectangle
const x = Math.min(this.startPoint.x, currentPoint.x)
const y = Math.min(this.startPoint.y, currentPoint.y)
const width = Math.abs(currentPoint.x - this.startPoint.x)
const height = Math.abs(currentPoint.y - this.startPoint.y)
previewCtx.strokeStyle = this.options.color
previewCtx.lineWidth = 2
previewCtx.setLineDash([5, 5])
previewCtx.strokeRect(x, y, width, height)
}
// Finalize rectangle (on main mask layer)
endRect(endPoint: Point) {
if (!this.isDrawing || !this.startPoint) return
const x = Math.min(this.startPoint.x, endPoint.x)
const y = Math.min(this.startPoint.y, endPoint.y)
const width = Math.abs(endPoint.x - this.startPoint.x)
const height = Math.abs(endPoint.y - this.startPoint.y)
// Draw filled rectangle on mask
this.ctx.fillStyle = this.options.color
this.ctx.fillRect(x, y, width, height)
// Clear preview
if (this.previewCanvas) {
const previewCtx = this.previewCanvas.getContext('2d')
previewCtx?.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height)
}
this.isDrawing = false
this.startPoint = null
}
cancel() {
this.isDrawing = false
this.startPoint = null
if (this.previewCanvas) {
const previewCtx = this.previewCanvas.getContext('2d')
previewCtx?.clearRect(0, 0, this.previewCanvas.width, this.previewCanvas.height)
}
}
isActive(): boolean {
return this.isDrawing
}
}

View File

@@ -1,10 +1,12 @@
'use client'
import { useEffect, useRef, useCallback } from 'react'
import { Play, Pause, SkipBack, SkipForward, Maximize } from 'lucide-react'
import { useEffect, useRef, useCallback, useState } from 'react'
import { Play, Pause, SkipBack, SkipForward, Maximize, Paintbrush } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Slider } from '@/components/ui/slider'
import { useEditorStore } from '@/stores/editor-store'
import { InpaintingCanvas, InpaintingToolbar, type InpaintingCanvasRef } from '@/components/inpainting'
import { editorApi } from '@/services/editor-api'
export function PreviewPlayer() {
const {
@@ -14,10 +16,25 @@ export function PreviewPlayer() {
setPlaying,
setCurrentTime,
selectedFrameId,
setSelectedFrameId
setSelectedFrameId,
// Inpainting state
isInpaintingMode,
inpaintingTool,
brushSize,
setInpaintingMode,
setInpaintingTool,
setBrushSize,
setCurrentMask,
resetInpainting,
} = useEditorStore()
const timerRef = useRef<NodeJS.Timeout | null>(null)
const canvasContainerRef = useRef<HTMLDivElement>(null)
const inpaintingCanvasRef = useRef<InpaintingCanvasRef>(null)
const [canvasSize, setCanvasSize] = useState({ width: 0, height: 0 })
const [isApplying, setIsApplying] = useState(false)
const [canUndo, setCanUndo] = useState(false)
const [canRedo, setCanRedo] = useState(false)
// Get current frame based on currentTime
const getCurrentFrameIndex = useCallback(() => {
@@ -69,12 +86,96 @@ export function PreviewPlayer() {
const selectedFrame = storyboard?.frames.find((f) => f.id === selectedFrameId)
// Calculate canvas size when entering inpainting mode
useEffect(() => {
if (isInpaintingMode && selectedFrame?.imagePath) {
const img = new Image()
img.onload = () => {
const container = canvasContainerRef.current
if (!container) return
const containerRect = container.getBoundingClientRect()
const scale = Math.min(
containerRect.width / img.width,
containerRect.height / img.height
)
setCanvasSize({
width: Math.floor(img.width * scale),
height: Math.floor(img.height * scale),
})
}
img.src = selectedFrame.imagePath
}
}, [isInpaintingMode, selectedFrame?.imagePath])
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// Inpainting handlers
const handleEnterInpainting = () => {
if (selectedFrame?.imagePath) {
setPlaying(false)
setInpaintingMode(true)
}
}
const handleCancelInpainting = () => {
resetInpainting()
}
const handleApplyInpainting = async () => {
if (!storyboard || !selectedFrameId) return
// Get mask from canvas ref
const maskBase64 = inpaintingCanvasRef.current?.exportMask()
if (!maskBase64) {
console.warn('No mask to apply')
return
}
setIsApplying(true)
try {
const result = await editorApi.inpaintImage(
storyboard.id,
selectedFrameId,
maskBase64
)
if (result.success) {
// Update frame with new image
const { updateFrame } = useEditorStore.getState()
updateFrame(selectedFrameId, { imagePath: result.image_path })
}
} catch (err) {
console.error('Inpainting failed:', err)
} finally {
setIsApplying(false)
resetInpainting()
}
}
const handleMaskChange = (maskDataUrl: string | null) => {
setCurrentMask(maskDataUrl)
// Update undo/redo state from canvas ref
if (inpaintingCanvasRef.current) {
setCanUndo(inpaintingCanvasRef.current.canUndo)
setCanRedo(inpaintingCanvasRef.current.canRedo)
}
}
const handleUndo = () => {
inpaintingCanvasRef.current?.undo()
}
const handleRedo = () => {
inpaintingCanvasRef.current?.redo()
}
const handleClear = () => {
inpaintingCanvasRef.current?.clear()
}
// Navigate to previous/next frame
const goToPrevFrame = () => {
if (!storyboard?.frames.length) return
@@ -109,8 +210,40 @@ export function PreviewPlayer() {
return (
<div className="flex flex-col h-full">
{/* Video/Image Preview Area - Fixed height */}
<div className="flex-1 min-h-0 flex items-center justify-center bg-black">
{selectedFrame?.imagePath ? (
<div
ref={canvasContainerRef}
className="flex-1 min-h-0 flex items-center justify-center bg-black relative"
>
{isInpaintingMode && selectedFrame?.imagePath && canvasSize.width > 0 ? (
<>
<InpaintingCanvas
ref={inpaintingCanvasRef}
imageSrc={selectedFrame.imagePath}
width={canvasSize.width}
height={canvasSize.height}
activeTool={inpaintingTool}
brushSize={brushSize}
onMaskChange={handleMaskChange}
/>
{/* Inpainting Toolbar */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10">
<InpaintingToolbar
activeTool={inpaintingTool}
brushSize={brushSize}
canUndo={canUndo}
canRedo={canRedo}
onToolChange={setInpaintingTool}
onBrushSizeChange={setBrushSize}
onUndo={handleUndo}
onRedo={handleRedo}
onClear={handleClear}
onCancel={handleCancelInpainting}
onApply={handleApplyInpainting}
isApplying={isApplying}
/>
</div>
</>
) : selectedFrame?.imagePath ? (
<img
src={selectedFrame.imagePath}
alt="Preview"
@@ -165,6 +298,15 @@ export function PreviewPlayer() {
<SkipForward className="h-4 w-4" />
</Button>
<div className="flex-1" />
<Button
variant="ghost"
size="icon"
onClick={handleEnterInpainting}
disabled={!selectedFrame?.imagePath || isInpaintingMode}
title="局部重绘"
>
<Paintbrush className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon">
<Maximize className="h-4 w-4" />
</Button>

View File

@@ -31,6 +31,11 @@ export interface PreviewResponse {
frames_count: number
}
export interface InpaintResponse {
image_path: string
success: boolean
}
class EditorApiClient {
private baseUrl: string
@@ -191,6 +196,37 @@ class EditorApiClient {
return response.json()
}
/**
* Inpaint (局部重绘) image for a frame
*/
async inpaintImage(
storyboardId: string,
frameId: string,
mask: string,
prompt?: string,
denoiseStrength?: number
): Promise<InpaintResponse> {
const response = await fetch(
`${this.baseUrl}/editor/storyboard/${storyboardId}/frames/${frameId}/inpaint`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
mask,
prompt,
denoise_strength: denoiseStrength ?? 0.8,
}),
}
)
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }))
throw new Error(error.detail || `Failed to inpaint image: ${response.statusText}`)
}
return response.json()
}
}
// Export singleton instance

View File

@@ -1,5 +1,8 @@
import { create } from 'zustand'
// Inpainting types
export type InpaintingTool = 'brush' | 'eraser' | 'rect' | 'lasso'
export interface StoryboardFrame {
id: string
index: number
@@ -24,6 +27,13 @@ interface EditorState {
isPlaying: boolean
currentTime: number
// Inpainting state
isInpaintingMode: boolean
inpaintingTool: InpaintingTool
brushSize: number
currentMask: string | null
inpaintPrompt: string
// Actions
setStoryboard: (storyboard: Storyboard) => void
selectFrame: (frameId: string | null) => void
@@ -33,6 +43,14 @@ interface EditorState {
updateFrame: (frameId: string, updates: Partial<StoryboardFrame>) => void
setPlaying: (playing: boolean) => void
setCurrentTime: (time: number | ((prev: number) => number)) => void
// Inpainting actions
setInpaintingMode: (mode: boolean) => void
setInpaintingTool: (tool: InpaintingTool) => void
setBrushSize: (size: number) => void
setCurrentMask: (mask: string | null) => void
setInpaintPrompt: (prompt: string) => void
resetInpainting: () => void
}
export const useEditorStore = create<EditorState>((set, get) => ({
@@ -41,6 +59,13 @@ export const useEditorStore = create<EditorState>((set, get) => ({
isPlaying: false,
currentTime: 0,
// Inpainting initial state
isInpaintingMode: false,
inpaintingTool: 'brush' as InpaintingTool,
brushSize: 20,
currentMask: null,
inpaintPrompt: '',
setStoryboard: (storyboard) => set({ storyboard }),
selectFrame: (frameId) => set({ selectedFrameId: frameId }),
@@ -117,6 +142,20 @@ export const useEditorStore = create<EditorState>((set, get) => ({
set({ currentTime: time })
}
},
// Inpainting actions
setInpaintingMode: (mode) => set({ isInpaintingMode: mode }),
setInpaintingTool: (tool) => set({ inpaintingTool: tool }),
setBrushSize: (size) => set({ brushSize: size }),
setCurrentMask: (mask) => set({ currentMask: mask }),
setInpaintPrompt: (prompt) => set({ inpaintPrompt: prompt }),
resetInpainting: () => set({
isInpaintingMode: false,
inpaintingTool: 'brush',
brushSize: 20,
currentMask: null,
inpaintPrompt: '',
}),
}))

View File

@@ -285,3 +285,76 @@ class MediaService(ComfyBaseService):
except Exception as e:
logger.error(f"Media generation error: {e}")
raise
async def inpaint(
self,
image_path: str,
mask_path: str,
prompt: Optional[str] = None,
denoise_strength: float = 0.8,
workflow: Optional[str] = None,
**params
) -> MediaResult:
"""
Inpaint image using mask
Args:
image_path: Path to original image
mask_path: Path to mask image (white=inpaint, black=keep)
prompt: Optional prompt for inpainted region
denoise_strength: How much to change masked area (0.0-1.0)
workflow: Inpainting workflow to use
Returns:
MediaResult with inpainted image URL
"""
logger.info(f"🎨 Inpainting image: {image_path}")
logger.info(f" Mask: {mask_path}")
logger.info(f" Denoise: {denoise_strength}")
try:
# Resolve workflow
if workflow is None:
workflow = "selfhost/image_inpaint_flux.json"
workflow_info = self._resolve_workflow(workflow=workflow)
# Build workflow parameters
workflow_params = {
"image": image_path,
"mask": mask_path,
"denoise": denoise_strength,
}
if prompt:
workflow_params["prompt"] = prompt
workflow_params.update(params)
logger.debug(f"Inpaint workflow parameters: {workflow_params}")
# Execute workflow
kit = await self.core._get_or_create_comfykit()
if workflow_info["source"] == "runninghub" and "workflow_id" in workflow_info:
workflow_input = workflow_info["workflow_id"]
else:
workflow_input = workflow_info["path"]
result = await kit.execute(workflow_input, workflow_params)
if not result.images:
logger.error("No image generated from inpainting")
raise Exception("Inpainting failed - no image generated")
image_url = result.images[0]
logger.info(f"✅ Inpainted image: {image_url}")
return MediaResult(
media_type="image",
url=image_url
)
except Exception as e:
logger.error(f"Inpainting error: {e}")
raise

View File

@@ -0,0 +1,5 @@
{
"source": "runninghub",
"workflow_id": "YOUR_INPAINT_WORKFLOW_ID",
"description": "Flux Inpainting workflow on RunningHub - Replace workflow_id with your actual RunningHub workflow ID"
}

View File

@@ -0,0 +1,149 @@
{
"10": {
"inputs": {
"image": ""
},
"class_type": "LoadImage",
"_meta": {
"title": "$image.value!"
}
},
"11": {
"inputs": {
"image": "",
"channel": "alpha"
},
"class_type": "LoadImageMask",
"_meta": {
"title": "$mask.value!"
}
},
"12": {
"inputs": {
"grow_mask_by": 6,
"pixels": ["10", 0],
"vae": ["49", 0],
"mask": ["11", 0]
},
"class_type": "VAEEncodeForInpaint",
"_meta": {
"title": "VAE Encode (for Inpainting)"
}
},
"29": {
"inputs": {
"seed": 1067822190154760,
"steps": 20,
"cfg": 1,
"sampler_name": "euler",
"scheduler": "simple",
"denoise": 0.8,
"model": ["48", 0],
"positive": ["35", 0],
"negative": ["33", 0],
"latent_image": ["12", 0]
},
"class_type": "KSampler",
"_meta": {
"title": "KSampler"
}
},
"30": {
"inputs": {
"value": 0.8
},
"class_type": "easy float",
"_meta": {
"title": "$denoise.value"
}
},
"31": {
"inputs": {
"text": ["46", 0],
"clip": ["47", 0]
},
"class_type": "CLIPTextEncode",
"_meta": {
"title": "CLIP Text Encode (Prompt)"
}
},
"33": {
"inputs": {
"conditioning": ["31", 0]
},
"class_type": "ConditioningZeroOut",
"_meta": {
"title": "ConditioningZeroOut"
}
},
"35": {
"inputs": {
"guidance": 3.5,
"conditioning": ["31", 0]
},
"class_type": "FluxGuidance",
"_meta": {
"title": "FluxGuidance"
}
},
"36": {
"inputs": {
"filename_prefix": "inpaint",
"images": ["37", 0]
},
"class_type": "SaveImage",
"_meta": {
"title": "Save Image"
}
},
"37": {
"inputs": {
"samples": ["29", 0],
"vae": ["49", 0]
},
"class_type": "VAEDecode",
"_meta": {
"title": "VAE Decode"
}
},
"46": {
"inputs": {
"value": "inpaint region"
},
"class_type": "PrimitiveStringMultiline",
"_meta": {
"title": "$prompt.value!"
}
},
"47": {
"inputs": {
"clip_name1": "clip_l.safetensors",
"clip_name2": "t5xxl_fp8_e4m3fn.safetensors",
"type": "flux",
"device": "default"
},
"class_type": "DualCLIPLoader",
"_meta": {
"title": "DualCLIPLoader"
}
},
"48": {
"inputs": {
"unet_name": "flux1-dev.safetensors",
"weight_dtype": "default"
},
"class_type": "UNETLoader",
"_meta": {
"title": "Load Diffusion Model"
}
},
"49": {
"inputs": {
"vae_name": "ae.safetensors"
},
"class_type": "VAELoader",
"_meta": {
"title": "Load VAE"
}
}
}