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:
@@ -36,6 +36,8 @@ from api.schemas.editor import (
|
|||||||
RegenerateImageResponse,
|
RegenerateImageResponse,
|
||||||
RegenerateAudioRequest,
|
RegenerateAudioRequest,
|
||||||
RegenerateAudioResponse,
|
RegenerateAudioResponse,
|
||||||
|
InpaintRequest,
|
||||||
|
InpaintResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/editor", tags=["Editor"])
|
router = APIRouter(prefix="/editor", tags=["Editor"])
|
||||||
@@ -577,3 +579,107 @@ async def regenerate_frame_audio(
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Audio regeneration failed: {e}")
|
logger.error(f"Audio regeneration failed: {e}")
|
||||||
raise HTTPException(status_code=500, detail=str(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))
|
||||||
|
|||||||
@@ -108,3 +108,16 @@ class RegenerateAudioResponse(BaseModel):
|
|||||||
duration: float
|
duration: float
|
||||||
success: bool = True
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
8
frontend/src/components/inpainting/index.ts
Normal file
8
frontend/src/components/inpainting/index.ts
Normal 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'
|
||||||
340
frontend/src/components/inpainting/inpainting-canvas.tsx
Normal file
340
frontend/src/components/inpainting/inpainting-canvas.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
})
|
||||||
115
frontend/src/components/inpainting/inpainting-toolbar.tsx
Normal file
115
frontend/src/components/inpainting/inpainting-toolbar.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
frontend/src/components/inpainting/tools/brush-tool.ts
Normal file
69
frontend/src/components/inpainting/tools/brush-tool.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
69
frontend/src/components/inpainting/tools/eraser-tool.ts
Normal file
69
frontend/src/components/inpainting/tools/eraser-tool.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
10
frontend/src/components/inpainting/tools/index.ts
Normal file
10
frontend/src/components/inpainting/tools/index.ts
Normal 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'
|
||||||
107
frontend/src/components/inpainting/tools/lasso-tool.ts
Normal file
107
frontend/src/components/inpainting/tools/lasso-tool.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
93
frontend/src/components/inpainting/tools/rect-tool.ts
Normal file
93
frontend/src/components/inpainting/tools/rect-tool.ts
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useRef, useCallback } from 'react'
|
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||||
import { Play, Pause, SkipBack, SkipForward, Maximize } from 'lucide-react'
|
import { Play, Pause, SkipBack, SkipForward, Maximize, Paintbrush } from 'lucide-react'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Slider } from '@/components/ui/slider'
|
import { Slider } from '@/components/ui/slider'
|
||||||
import { useEditorStore } from '@/stores/editor-store'
|
import { useEditorStore } from '@/stores/editor-store'
|
||||||
|
import { InpaintingCanvas, InpaintingToolbar, type InpaintingCanvasRef } from '@/components/inpainting'
|
||||||
|
import { editorApi } from '@/services/editor-api'
|
||||||
|
|
||||||
export function PreviewPlayer() {
|
export function PreviewPlayer() {
|
||||||
const {
|
const {
|
||||||
@@ -14,10 +16,25 @@ export function PreviewPlayer() {
|
|||||||
setPlaying,
|
setPlaying,
|
||||||
setCurrentTime,
|
setCurrentTime,
|
||||||
selectedFrameId,
|
selectedFrameId,
|
||||||
setSelectedFrameId
|
setSelectedFrameId,
|
||||||
|
// Inpainting state
|
||||||
|
isInpaintingMode,
|
||||||
|
inpaintingTool,
|
||||||
|
brushSize,
|
||||||
|
setInpaintingMode,
|
||||||
|
setInpaintingTool,
|
||||||
|
setBrushSize,
|
||||||
|
setCurrentMask,
|
||||||
|
resetInpainting,
|
||||||
} = useEditorStore()
|
} = useEditorStore()
|
||||||
|
|
||||||
const timerRef = useRef<NodeJS.Timeout | null>(null)
|
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
|
// Get current frame based on currentTime
|
||||||
const getCurrentFrameIndex = useCallback(() => {
|
const getCurrentFrameIndex = useCallback(() => {
|
||||||
@@ -69,12 +86,96 @@ export function PreviewPlayer() {
|
|||||||
|
|
||||||
const selectedFrame = storyboard?.frames.find((f) => f.id === selectedFrameId)
|
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 formatTime = (seconds: number) => {
|
||||||
const mins = Math.floor(seconds / 60)
|
const mins = Math.floor(seconds / 60)
|
||||||
const secs = Math.floor(seconds % 60)
|
const secs = Math.floor(seconds % 60)
|
||||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
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
|
// Navigate to previous/next frame
|
||||||
const goToPrevFrame = () => {
|
const goToPrevFrame = () => {
|
||||||
if (!storyboard?.frames.length) return
|
if (!storyboard?.frames.length) return
|
||||||
@@ -109,8 +210,40 @@ export function PreviewPlayer() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full">
|
<div className="flex flex-col h-full">
|
||||||
{/* Video/Image Preview Area - Fixed height */}
|
{/* Video/Image Preview Area - Fixed height */}
|
||||||
<div className="flex-1 min-h-0 flex items-center justify-center bg-black">
|
<div
|
||||||
{selectedFrame?.imagePath ? (
|
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
|
<img
|
||||||
src={selectedFrame.imagePath}
|
src={selectedFrame.imagePath}
|
||||||
alt="Preview"
|
alt="Preview"
|
||||||
@@ -165,6 +298,15 @@ export function PreviewPlayer() {
|
|||||||
<SkipForward className="h-4 w-4" />
|
<SkipForward className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="flex-1" />
|
<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">
|
<Button variant="ghost" size="icon">
|
||||||
<Maximize className="h-4 w-4" />
|
<Maximize className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ export interface PreviewResponse {
|
|||||||
frames_count: number
|
frames_count: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface InpaintResponse {
|
||||||
|
image_path: string
|
||||||
|
success: boolean
|
||||||
|
}
|
||||||
|
|
||||||
class EditorApiClient {
|
class EditorApiClient {
|
||||||
private baseUrl: string
|
private baseUrl: string
|
||||||
|
|
||||||
@@ -191,6 +196,37 @@ class EditorApiClient {
|
|||||||
|
|
||||||
return response.json()
|
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
|
// Export singleton instance
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { create } from 'zustand'
|
import { create } from 'zustand'
|
||||||
|
|
||||||
|
// Inpainting types
|
||||||
|
export type InpaintingTool = 'brush' | 'eraser' | 'rect' | 'lasso'
|
||||||
|
|
||||||
export interface StoryboardFrame {
|
export interface StoryboardFrame {
|
||||||
id: string
|
id: string
|
||||||
index: number
|
index: number
|
||||||
@@ -24,6 +27,13 @@ interface EditorState {
|
|||||||
isPlaying: boolean
|
isPlaying: boolean
|
||||||
currentTime: number
|
currentTime: number
|
||||||
|
|
||||||
|
// Inpainting state
|
||||||
|
isInpaintingMode: boolean
|
||||||
|
inpaintingTool: InpaintingTool
|
||||||
|
brushSize: number
|
||||||
|
currentMask: string | null
|
||||||
|
inpaintPrompt: string
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
setStoryboard: (storyboard: Storyboard) => void
|
setStoryboard: (storyboard: Storyboard) => void
|
||||||
selectFrame: (frameId: string | null) => void
|
selectFrame: (frameId: string | null) => void
|
||||||
@@ -33,6 +43,14 @@ interface EditorState {
|
|||||||
updateFrame: (frameId: string, updates: Partial<StoryboardFrame>) => void
|
updateFrame: (frameId: string, updates: Partial<StoryboardFrame>) => void
|
||||||
setPlaying: (playing: boolean) => void
|
setPlaying: (playing: boolean) => void
|
||||||
setCurrentTime: (time: number | ((prev: number) => number)) => 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) => ({
|
export const useEditorStore = create<EditorState>((set, get) => ({
|
||||||
@@ -41,6 +59,13 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
isPlaying: false,
|
isPlaying: false,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
|
|
||||||
|
// Inpainting initial state
|
||||||
|
isInpaintingMode: false,
|
||||||
|
inpaintingTool: 'brush' as InpaintingTool,
|
||||||
|
brushSize: 20,
|
||||||
|
currentMask: null,
|
||||||
|
inpaintPrompt: '',
|
||||||
|
|
||||||
setStoryboard: (storyboard) => set({ storyboard }),
|
setStoryboard: (storyboard) => set({ storyboard }),
|
||||||
|
|
||||||
selectFrame: (frameId) => set({ selectedFrameId: frameId }),
|
selectFrame: (frameId) => set({ selectedFrameId: frameId }),
|
||||||
@@ -117,6 +142,20 @@ export const useEditorStore = create<EditorState>((set, get) => ({
|
|||||||
set({ currentTime: time })
|
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: '',
|
||||||
|
}),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -285,3 +285,76 @@ class MediaService(ComfyBaseService):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Media generation error: {e}")
|
logger.error(f"Media generation error: {e}")
|
||||||
raise
|
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
|
||||||
|
|||||||
5
workflows/runninghub/image_inpaint.json
Normal file
5
workflows/runninghub/image_inpaint.json
Normal 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"
|
||||||
|
}
|
||||||
149
workflows/selfhost/image_inpaint_flux.json
Normal file
149
workflows/selfhost/image_inpaint_flux.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user