Files
omniai-web/src/features/watermark-removal/WatermarkRemovalPage.tsx
T

425 lines
16 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.
import {
CameraOutlined,
ColumnWidthOutlined,
CustomerServiceOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
FileImageOutlined,
FolderAddOutlined,
FontSizeOutlined,
LinkOutlined,
ScissorOutlined,
SwapOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState } from "react";
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
import { summarizeUrl, formatFileSize, fileToDataUrl } from "../../utils/toolPageUtils";
import TaskStatusBar from "../../components/TaskStatusBar";
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
interface WatermarkRemovalPageProps {
isAuthenticated?: boolean;
onOpenMore?: () => void;
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
onSelectView?: (view: WebViewKey) => void;
}
function WatermarkRemovalPage({
isAuthenticated = false,
onOpenMore,
onOpenImageTool,
onSelectView,
}: WatermarkRemovalPageProps) {
const fileInputRef = useRef<HTMLInputElement>(null);
const pollRunRef = useRef(0);
const cancelRef = useRef(false);
const [sourceName, setSourceName] = useState("");
const [sourceFile, setSourceFile] = useState<File | null>(null);
const [sourceUrl, setSourceUrl] = useState("");
const [sourcePreview, setSourcePreview] = useState("");
const [resultPreview, setResultPreview] = useState("");
const [status, setStatus] = useState("上传含水印的图片,点击开始去水印");
const [activeTaskId, setActiveTaskId] = useState("");
const [taskProgress, setTaskProgress] = useState(0);
const [isProcessing, setIsProcessing] = useState(false);
const [isSavingAsset, setIsSavingAsset] = useState(false);
const [isDownloading, setIsDownloading] = useState(false);
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
useEffect(() => {
return () => {
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
};
}, [sourcePreview]);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("watermark");
if (!saved || saved.resultUrl) return;
setSourceName(saved.sourceName || "");
setSourceUrl(saved.sourceUrl || "");
setIsProcessing(true);
cancelRef.current = false;
pollRunRef.current += 1;
void waitForTaskResult(saved.taskId).catch(() => {});
}, []);
useEffect(() => {
return () => {
pollRunRef.current += 1;
cancelRef.current = true;
};
}, []);
const clearSource = () => {
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
setSourceName("");
setSourceFile(null);
setSourceUrl("");
setSourcePreview("");
setResultPreview("");
setActiveTaskId("");
setTaskProgress(0);
setStatus("已清空素材");
};
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (!file) return;
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
setSourceName(file.name);
setSourceFile(file);
setSourceUrl("");
setSourcePreview(URL.createObjectURL(file));
setResultPreview("");
setActiveTaskId("");
setTaskProgress(0);
setStatus(`已导入 ${file.name}`);
event.currentTarget.value = "";
};
const handleFileDrop = (event: React.DragEvent) => {
event.preventDefault();
const file = Array.from(event.dataTransfer.files).find((f) => f.type.startsWith("image/"));
if (!file) return;
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
setSourceName(file.name);
setSourceFile(file);
setSourceUrl("");
setSourcePreview(URL.createObjectURL(file));
setResultPreview("");
setActiveTaskId("");
setTaskProgress(0);
setStatus(`已导入 ${file.name}`);
};
const handleImportUrl = () => {
const normalizedUrl = sourceUrl.trim();
if (!/^https?:\/\//i.test(normalizedUrl)) {
setStatus("请输入可访问的 HTTP/HTTPS 图片 URL");
return;
}
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
setSourceFile(null);
setSourceName(summarizeUrl(normalizedUrl));
setSourcePreview(normalizedUrl);
setResultPreview("");
setActiveTaskId("");
setTaskProgress(0);
setStatus(`已导入 URL${summarizeUrl(normalizedUrl)}`);
};
const getSourceGenerationUrl = async () => {
if (!sourceFile) return sourcePreview;
setStatus(`正在上传 ${sourceFile.name}${formatFileSize(sourceFile.size)}`);
const uploaded = await aiGenerationClient.uploadAsset({
dataUrl: await fileToDataUrl(sourceFile),
name: sourceFile.name,
mimeType: sourceFile.type || "image/png",
});
return uploaded.signedUrl || uploaded.url;
};
const waitForTaskResult = async (taskId: string) => {
const runId = ++pollRunRef.current;
setActiveTaskId(taskId);
setTaskProgress(5);
saveToolTaskState("watermark", { taskId, sourceName, sourceUrl, status: `任务 ${taskId}`, progress: 5 });
await waitForTask(taskId, {
abortRef: cancelRef,
onProgress: (e) => {
if (pollRunRef.current !== runId) return;
const progress = Math.max(0, Math.min(100, Math.trunc(e.progress || 0)));
setTaskProgress(progress);
setStatus(`任务 ${taskId} ${e.status},进度 ${progress}%`);
if (e.status === "completed" && e.resultUrl) {
setResultPreview(e.resultUrl);
setStatus("去水印完成");
setTaskProgress(100);
clearToolTaskState("watermark");
saveToolTaskState("watermark", { taskId, resultUrl: e.resultUrl, resultPreview: e.resultUrl, sourceName, sourceUrl, status: "完成", progress: 100 });
}
},
});
if (cancelRef.current) throw new Error("已取消");
if (pollRunRef.current !== runId) throw new Error("任务已被替换");
};
const handleCancel = useCallback(() => {
cancelRef.current = true;
if (activeTaskId) {
aiGenerationClient.cancelTask(activeTaskId).catch(() => {});
}
setIsProcessing(false);
setStatus("已取消");
clearToolTaskState("watermark");
}, [activeTaskId]);
const handleDownload = async () => {
if (!resultPreview || isDownloading) return;
setIsDownloading(true);
try {
await saveToolResultToLocal({
url: resultPreview,
name: "watermark-removed",
type: "image",
taskId: activeTaskId || undefined,
tags: ["工具盒", "去水印", "生成图片"],
});
setStatus("下载完成");
} catch (error) {
setStatus(error instanceof Error ? error.message : "下载失败");
} finally {
setIsDownloading(false);
}
};
const handleSaveToAssets = async () => {
if (!resultPreview || isSavingAsset) return;
setIsSavingAsset(true);
try {
const status = await addToolResultToAssetLibrary({
url: resultPreview,
name: `watermark-removed-${Date.now()}.png`,
description: "工具盒去水印生成结果",
type: "image",
taskId: activeTaskId || undefined,
tags: ["工具盒", "去水印", "生成图片"],
});
setStatus(status === "server" ? "已加入资产库" : "已加入本地资产库");
} catch (error) {
setStatus(error instanceof Error ? error.message : "加入资产库失败");
} finally {
setIsSavingAsset(false);
}
};
const handleStart = async () => {
if (!isAuthenticated) {
setStatus("请先登录后再使用去水印功能");
return;
}
if (!sourcePreview) {
setStatus("请先上传需要去水印的图片");
return;
}
if (isProcessing) return;
setIsProcessing(true);
setResultPreview("");
setTaskProgress(0);
cancelRef.current = false;
try {
const generationUrl = await getSourceGenerationUrl();
setStatus(`素材已就绪,正在提交去水印任务`);
const result = await aiGenerationClient.createImageEditTask({
imageUrl: generationUrl,
function: "remove_watermark",
prompt: "去除图像中的文字",
n: 1,
});
await waitForTaskResult(result.taskId);
} catch (error) {
if (isServerRequestError(error) && error.status === 404) {
const baseUrl = getServerBaseUrl() || window.location.origin;
setStatus(`服务端 ${baseUrl} 还没有部署 /api/ai/image/edit 接口,请更新服务端`);
} else {
setStatus(error instanceof Error ? error.message : "去水印任务失败,请稍后重试");
}
} finally {
setIsProcessing(false);
}
};
return (
<section className="image-workbench-page watermark-removal-page">
<header className="image-workbench-topbar">
<button type="button" className="image-workbench-back-to-more" onClick={onOpenMore}>
</button>
<div className="image-workbench-tool-strip" aria-label="功能入口">
<button type="button" onClick={() => onOpenImageTool?.("workbench")}>
<EditOutlined />
</button>
<button type="button" onClick={() => onOpenImageTool?.("inpaint")}>
<ScissorOutlined />
</button>
<button type="button" onClick={() => onOpenImageTool?.("camera")}>
<CameraOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("digitalHuman")}>
<CustomerServiceOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("characterMix")}>
<SwapOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("resolutionUpscale")}>
<ColumnWidthOutlined />
</button>
<button type="button" className="is-active" onClick={() => onSelectView?.("watermarkRemoval")}>
<DeleteOutlined />
</button>
<button type="button" onClick={() => onSelectView?.("subtitleRemoval")}>
<FontSizeOutlined />
</button>
</div>
</header>
<div className="image-workbench-subbar">
<button type="button" className="image-workbench-icon-btn" aria-label="返回工具盒" onClick={onOpenMore}>
<DeleteOutlined />
</button>
<strong></strong>
</div>
<main className="image-workbench-layout image-workbench-layout--camera watermark-removal-layout">
<aside className="image-workbench-panel image-workbench-panel--left">
<section className="image-workbench-control-card">
<div className="image-workbench-section-title">
<h3></h3>
<span>{sourcePreview ? "已上传" : "待上传"}</span>
</div>
<input
ref={fileInputRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={handleFileChange}
/>
<div className="image-workbench-upload-shell" onDragOver={(e) => e.preventDefault()} onDrop={handleFileDrop}>
<button type="button" className="image-workbench-upload" onClick={() => fileInputRef.current?.click()}>
{sourcePreview ? <img src={sourcePreview} alt="" /> : <FileImageOutlined />}
<strong>{sourceName || "拖拽或选择图片"}</strong>
<span>PNG / JPG / WebP</span>
</button>
{sourcePreview ? (
<button type="button" className="image-workbench-upload-remove" aria-label="删除素材" onClick={clearSource}>
×
</button>
) : null}
</div>
<div className="image-workbench-url-row">
<label>
<LinkOutlined />
<input
value={sourceUrl}
placeholder="粘贴图片 URL"
onChange={(event) => setSourceUrl(event.target.value)}
/>
</label>
<button type="button" onClick={handleImportUrl}></button>
</div>
</section>
<section className="image-workbench-control-card">
<h3></h3>
<p className="watermark-removal-hint">
使 AI
</p>
</section>
<div className="image-workbench-actions watermark-removal-actions">
<button type="button" className="image-workbench-primary" onClick={() => void handleStart()} disabled={isProcessing}>
<DeleteOutlined />
{isProcessing ? "处理中..." : "开始去水印"}
</button>
{isProcessing && (
<button type="button" className="image-workbench-cancel" onClick={handleCancel}>
</button>
)}
</div>
</aside>
<section className="image-workbench-canvas image-workbench-canvas--camera watermark-removal-canvas" aria-label="去水印画布">
{sourcePreview ? (
<div className="watermark-removal-compare">
<div className="watermark-removal-compare__panel">
<span className="watermark-removal-compare__label"></span>
<img src={sourcePreview} alt="原图预览" />
</div>
<div className="watermark-removal-compare__panel">
<span className="watermark-removal-compare__label">{resultPreview ? "去水印结果" : "等待结果"}</span>
{resultPreview ? (
<>
<img src={resultPreview} alt="去水印结果" />
<div className="watermark-removal-compare__actions">
<button type="button" onClick={() => void handleSaveToAssets()} disabled={isSavingAsset}>
<FolderAddOutlined />
{isSavingAsset ? "加入中" : "加入资产库"}
</button>
<button type="button" onClick={() => void handleDownload()} disabled={isDownloading}>
<DownloadOutlined />
{isDownloading ? "保存中" : "保存本地"}
</button>
</div>
</>
) : (
<div className="watermark-removal-compare__placeholder">
<DeleteOutlined />
<span>{isProcessing ? "处理中..." : "点击开始去水印"}</span>
</div>
)}
</div>
</div>
) : (
<button
type="button"
className="image-workbench-empty image-workbench-empty--button"
onClick={() => fileInputRef.current?.click()}
onDragOver={(e) => e.preventDefault()}
onDrop={handleFileDrop}
>
<DeleteOutlined />
<strong></strong>
<span> PNG / JPG / WebP</span>
</button>
)}
</section>
</main>
<TaskStatusBar taskId={activeTaskId} status={status} progress={taskProgress} idleLabel="去水印" />
</section>
);
}
export default WatermarkRemovalPage;