Files
NianAIGC/components/image-editor.tsx
2026-05-29 10:26:02 +08:00

387 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"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<Asset[]>([]);
const [jobs, setJobs] = useState<GenerationJob[]>([]);
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<ImageEditMode>(initialMode);
const [resolution, setResolution] = useState<"4k" | "8k">("4k");
const [scale, setScale] = useState(50);
const [busy, setBusy] = useState(false);
const [notice, setNotice] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
const editorRef = useRef<HTMLDivElement | null>(null);
const pickerRef = useRef<HTMLDivElement | null>(null);
const feedbackRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const imageRef = useRef<HTMLImageElement | null>(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<HTMLCanvasElement>) {
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 (
<div className="grid cols-2" ref={editorRef}>
<section className="panel" data-animate>
<div className="panel-head">
<div>
<h2></h2>
</div>
</div>
{!hideModeSwitch ? (
<div className="segmented" style={{ marginBottom: 16 }}>
<button className={mode === "inpaint" ? "active" : ""} aria-pressed={mode === "inpaint"} onClick={() => setMode("inpaint")}></button>
<button className={mode === "upscale" ? "active" : ""} aria-pressed={mode === "upscale"} onClick={() => setMode("upscale")}></button>
</div>
) : null}
<div className="selected-asset-line editor-selection-summary">
{selected ? (
<img src={displayAssetUrl(selected.url)} alt="" />
) : (
<span className="selected-asset-placeholder"><ImageUp size={18} /></span>
)}
<div>
<strong>{selected?.name || "未选择图片"}</strong>
<span className="muted">{selected ? sourceLabel(selected.source) : "从资产任务选择,或直接上传图片"}</span>
</div>
<button className="button mini-button" type="button" onClick={() => setPickerOpen((open) => !open)}>
{pickerOpen ? "收起" : selected ? "更换素材" : "选择素材"}
</button>
<label className="upload-icon-button image-upload-button" title="上传本地图片">
{uploading ? <Loader2 className="spin" size={18} /> : <Upload size={18} />}
<span></span>
<input
type="file"
accept="image/*"
onChange={(event) => {
void uploadImage(event.target.files);
event.currentTarget.value = "";
}}
/>
</label>
</div>
{pickerOpen ? (
<div className="editor-picker-panel" ref={pickerRef}>
<div className="editor-picker-grid">
{visibleImageAssets.map((asset) => (
<button
className={clsx("editor-thumb-option", selectedId === asset.id && "selected")}
type="button"
key={asset.id}
onClick={() => {
setSelectedId(asset.id);
setPickerOpen(false);
}}
title={asset.name}
>
<img src={displayAssetUrl(asset.url)} alt={asset.name} />
<span className="editor-thumb-info">
<strong>{asset.name}</strong>
<span>{sourceLabel(asset.source)}</span>
</span>
</button>
))}
{!imageAssets.length ? (
<div className="callout editor-empty"></div>
) : null}
</div>
<Pagination page={pickerPage} pageSize={PICKER_PAGE_SIZE} total={pickerTotal} label="选择素材分页" onPageChange={setPickerPage} />
</div>
) : null}
{mode === "inpaint" ? (
<>
<div className="field">
<label htmlFor="editPrompt"></label>
<input id="editPrompt" value={prompt} onChange={(event) => setPrompt(event.target.value)} />
</div>
<div className="field">
<label htmlFor="brush">{brush}</label>
<input id="brush" type="range" min="8" max="120" value={brush} onChange={(event) => setBrush(Number(event.target.value))} />
</div>
</>
) : (
<>
<div className="field">
<label htmlFor="resolution"></label>
<select id="resolution" value={resolution} onChange={(event) => setResolution(event.target.value === "8k" ? "8k" : "4k")}>
<option value="4k">4K</option>
<option value="8k">8K</option>
</select>
</div>
<div className="field">
<label htmlFor="upscaleScale">{scale}</label>
<input id="upscaleScale" type="range" min="0" max="100" value={scale} onChange={(event) => setScale(Number(event.target.value))} />
</div>
</>
)}
<div className="toolbar">
<button className="button primary" onClick={runEdit} disabled={busy || !selected}>
{busy ? <Loader2 className="spin" size={18} /> : mode === "inpaint" ? <Paintbrush size={18} /> : <ImageUp size={18} />}
</button>
{mode === "inpaint" ? (
<button className="button" type="button" onClick={resetCanvas}>
<Eraser size={18} />
</button>
) : null}
</div>
{error || notice ? (
<div ref={feedbackRef}>
{error ? <div className="callout" role="alert" style={{ marginTop: 14 }}>{error}</div> : null}
{notice ? <div className="callout success-callout" role="status" aria-live="polite" style={{ marginTop: 14 }}>{notice}</div> : null}
</div>
) : null}
</section>
<section className="panel" data-animate>
<h2>{mode === "inpaint" ? "黑色保留,白色重绘" : "素材增强"}</h2>
{imageUrl ? (
<div className="editor-canvas-wrap">
<img ref={imageRef} src={imageUrl} alt="待处理素材" onLoad={resetCanvas} />
{mode === "inpaint" ? (
<canvas
ref={canvasRef}
onPointerDown={(event) => {
drawing.current = true;
event.currentTarget.setPointerCapture(event.pointerId);
paint(event);
}}
onPointerMove={paint}
onPointerUp={() => {
drawing.current = false;
}}
/>
) : null}
</div>
) : (
<div className="callout"></div>
)}
<p className="muted" style={{ marginTop: 14 }}>
<Save size={15} style={{ verticalAlign: "text-bottom" }} />
</p>
</section>
</div>
);
}
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<string>) {
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;
}
}