Initial 智念AIGC platform

This commit is contained in:
inman
2026-05-29 10:26:02 +08:00
commit f9c3393f84
86 changed files with 14741 additions and 0 deletions

386
components/image-editor.tsx Normal file
View File

@@ -0,0 +1,386 @@
"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;
}
}