408 lines
15 KiB
TypeScript
408 lines
15 KiB
TypeScript
|
|
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 { 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;
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
return () => {
|
|||
|
|
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
|
|||
|
|
};
|
|||
|
|
}, [sourcePreview]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
return () => {
|
|||
|
|
pollRunRef.current += 1;
|
|||
|
|
cancelRef.current = true;
|
|||
|
|
if (activeTaskIdRef.current) {
|
|||
|
|
aiGenerationClient.cancelTask(activeTaskIdRef.current).catch(() => {});
|
|||
|
|
}
|
|||
|
|
};
|
|||
|
|
}, []);
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
|
|||
|
|
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);
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
});
|
|||
|
|
|
|||
|
|
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("已取消");
|
|||
|
|
}, [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">
|
|||
|
|
<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} style={{ marginTop: 6 }}>
|
|||
|
|
取消
|
|||
|
|
</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;
|