"use client"; import { useEffect, useRef, useState } from "react"; import { Eraser, ImageUp, Loader2, Paintbrush, Save, Upload } from "lucide-react"; import clsx from "clsx"; import { clampPage, pageItems, Pagination } from "@/components/pagination"; import { crossfadeIn, pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import type { Asset, GenerationJob } from "@/lib/types"; export type ImageEditMode = "inpaint" | "upscale"; const PICKER_PAGE_SIZE = 8; export function ImageEditor({ initialMode = "inpaint", hideModeSwitch = false }: { initialMode?: ImageEditMode; hideModeSwitch?: boolean; }) { const [assets, setAssets] = useState([]); const [jobs, setJobs] = useState([]); const [selectedId, setSelectedId] = useState(""); const [pickerOpen, setPickerOpen] = useState(false); const [pickerPage, setPickerPage] = useState(1); const [prompt, setPrompt] = useState(""); const [brush, setBrush] = useState(42); const [mode, setMode] = useState(initialMode); const [resolution, setResolution] = useState<"4k" | "8k">("4k"); const [scale, setScale] = useState(50); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); const [uploading, setUploading] = useState(false); const editorRef = useRef(null); const pickerRef = useRef(null); const feedbackRef = useRef(null); const canvasRef = useRef(null); const imageRef = useRef(null); const drawing = useRef(false); const outputAssetIds = new Set(jobs.flatMap((job) => job.outputAssetIds)); const imageAssets = assets.filter((asset) => isResultImageAsset(asset, outputAssetIds)); const selected = assets.find((asset) => asset.id === selectedId && isEditableImageAsset(asset)); const imageUrl = selected ? displayAssetUrl(selected.url) : undefined; const pickerTotal = imageAssets.length; const visibleImageAssets = pageItems(imageAssets, pickerPage, PICKER_PAGE_SIZE); async function refreshLibrary(nextSelectedId = selectedId) { const [assetResponse, imageJobResponse] = await Promise.all([ fetch("/api/assets", { cache: "no-store" }), fetch("/api/generations/image", { cache: "no-store" }) ]); const [assetPayload, jobPayload] = await Promise.all([assetResponse.json(), imageJobResponse.json()]); if (!assetResponse.ok) throw new Error(assetPayload.error || "读取资产失败"); if (!imageJobResponse.ok) throw new Error(jobPayload.error || "读取图片任务失败"); const nextAssets = assetPayload.assets || []; const nextImageAssets = nextAssets.filter(isEditableImageAsset); setAssets(nextAssets); setJobs(jobPayload.jobs || []); if (nextSelectedId && nextImageAssets.some((asset: Asset) => asset.id === nextSelectedId)) { setSelectedId(nextSelectedId); } else if (nextSelectedId) { setSelectedId(""); } } useEffect(() => { refreshLibrary().catch((err) => setError(err instanceof Error ? err.message : String(err))); }, []); useEffect(() => { setMode(initialMode); }, [initialMode]); useEffect(() => { setPickerPage(1); }, [pickerOpen]); useEffect(() => { setPickerPage((page) => clampPage(page, pickerTotal, PICKER_PAGE_SIZE)); }, [pickerTotal]); useEffect(() => { if (mode !== "inpaint" || !imageUrl) return; window.requestAnimationFrame(resetCanvas); }, [mode, imageUrl]); useEffect(() => { return runScopedMotion(editorRef, (scope) => revealChildren(scope)); }, []); useEffect(() => { crossfadeIn(pickerRef.current); }, [pickerOpen, pickerPage]); useEffect(() => { pulseFeedback(feedbackRef.current); }, [error, notice]); function resetCanvas() { const canvas = canvasRef.current; const image = imageRef.current; if (!canvas || !image) return; const width = image.naturalWidth || 1024; const height = image.naturalHeight || 1024; canvas.width = width; canvas.height = height; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.clearRect(0, 0, width, height); } function paint(event: React.PointerEvent) { const canvas = canvasRef.current; if (!canvas || !drawing.current) return; const rect = canvas.getBoundingClientRect(); const x = ((event.clientX - rect.left) / rect.width) * canvas.width; const y = ((event.clientY - rect.top) / rect.height) * canvas.height; const ctx = canvas.getContext("2d"); if (!ctx) return; ctx.fillStyle = "white"; ctx.beginPath(); ctx.arc(x, y, brush, 0, Math.PI * 2); ctx.fill(); } async function runEdit() { setBusy(true); setError(null); setNotice(null); try { if (!selected) throw new Error("请选择一张图片资产,或先上传图片。"); const endpoint = mode === "inpaint" ? `/api/assets/${selected.id}/inpaint` : `/api/assets/${selected.id}/upscale`; const body = mode === "inpaint" ? { prompt, maskDataUrl: buildMaskDataUrl(canvasRef.current) } : { resolution, scale }; const response = await fetch(endpoint, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "提交失败"); setNotice(`${mode === "inpaint" ? "局部重绘" : "超清"}任务已提交。请到「结果」里的任务查看进度,完成后结果资产会自动保留。`); setPrompt(""); resetCanvas(); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); } } async function uploadImage(files: FileList | null) { if (!files?.length) return; setUploading(true); setError(null); try { const formData = new FormData(); formData.append("files", files[0]); const response = await fetch("/api/assets/upload", { method: "POST", body: formData }); const payload = await response.json(); if (!response.ok) throw new Error(payload.error || "上传失败"); const uploaded = (payload.assets || []).find((asset: Asset) => isEditableImageAsset(asset)) as Asset | undefined; if (!uploaded) throw new Error("请上传图片文件。"); setPickerOpen(false); await refreshLibrary(uploaded.id); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setUploading(false); } } return (

选择素材与处理方式

{!hideModeSwitch ? (
) : null}
{selected ? ( ) : ( )}
{selected?.name || "未选择图片"} {selected ? sourceLabel(selected.source) : "从资产任务选择,或直接上传图片"}
{pickerOpen ? (
{visibleImageAssets.map((asset) => ( ))} {!imageAssets.length ? (
暂无结果图片,可先生成图片,或直接上传图片。
) : null}
) : null} {mode === "inpaint" ? ( <>
setPrompt(event.target.value)} />
setBrush(Number(event.target.value))} />
) : ( <>
setScale(Number(event.target.value))} />
)}
{mode === "inpaint" ? ( ) : null}
{error || notice ? (
{error ?
{error}
: null} {notice ?
{notice}
: null}
) : null}

{mode === "inpaint" ? "黑色保留,白色重绘" : "素材增强"}

{imageUrl ? (
待处理素材 {mode === "inpaint" ? ( { drawing.current = true; event.currentTarget.setPointerCapture(event.pointerId); paint(event); }} onPointerMove={paint} onPointerUp={() => { drawing.current = false; }} /> ) : null}
) : (
选择素材后即可开始局部重绘或超清。
)}

输出结果会自动保存到结果。

); } function isEditableImageAsset(asset: Asset) { if (asset.kind === "mask") return false; if (asset.tags.includes("mask")) return false; if (typeof asset.metadata.maskRule === "string") return false; const contentType = typeof asset.metadata.contentType === "string" ? asset.metadata.contentType.toLowerCase() : ""; return asset.kind === "image" || asset.kind === "reference" || contentType.startsWith("image/"); } function isResultImageAsset(asset: Asset, outputAssetIds: Set) { if (!outputAssetIds.has(asset.id)) return false; if (!isEditableImageAsset(asset)) return false; return asset.source === "generated" || asset.source === "edited" || asset.source === "upscaled"; } function sourceLabel(source: Asset["source"]) { if (source === "upload") return "上传"; if (source === "generated") return "生成"; if (source === "edited") return "重绘"; if (source === "upscaled") return "超清"; if (source === "seed") return "示例"; return "外部"; } function capabilityLabel(capability: GenerationJob["capability"]) { if (capability === "image.generate") return "图片生成"; if (capability === "image.inpaint") return "局部重绘"; if (capability === "image.upscale") return "智能超清"; return "图片任务"; } function buildMaskDataUrl(canvas: HTMLCanvasElement | null) { if (!canvas) return undefined; const mask = document.createElement("canvas"); mask.width = canvas.width; mask.height = canvas.height; const ctx = mask.getContext("2d"); if (!ctx) return undefined; ctx.fillStyle = "black"; ctx.fillRect(0, 0, mask.width, mask.height); ctx.drawImage(canvas, 0, 0); return mask.toDataURL("image/png"); } function displayAssetUrl(url: string) { if (typeof window === "undefined") return url; try { const parsed = new URL(url, window.location.origin); if (parsed.hostname === "0.0.0.0") { parsed.protocol = window.location.protocol; parsed.host = window.location.host; return parsed.toString(); } return parsed.toString(); } catch { return url; } }