2026-06-02 12:38:01 +08:00
|
|
|
|
import {
|
|
|
|
|
|
CameraOutlined,
|
|
|
|
|
|
ColumnWidthOutlined,
|
|
|
|
|
|
CustomerServiceOutlined,
|
|
|
|
|
|
DeleteOutlined,
|
|
|
|
|
|
DownloadOutlined,
|
|
|
|
|
|
EditOutlined,
|
|
|
|
|
|
FileImageOutlined,
|
|
|
|
|
|
FolderAddOutlined,
|
|
|
|
|
|
FontSizeOutlined,
|
|
|
|
|
|
LinkOutlined,
|
|
|
|
|
|
PictureOutlined,
|
|
|
|
|
|
ScissorOutlined,
|
|
|
|
|
|
SwapOutlined,
|
|
|
|
|
|
ThunderboltOutlined,
|
|
|
|
|
|
VideoCameraOutlined,
|
|
|
|
|
|
} from "@ant-design/icons";
|
2026-06-05 18:01:55 +08:00
|
|
|
|
import { useCallback, useEffect, useRef, useState, type DragEvent } from "react";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
import { aiGenerationClient } from "../../api/aiGenerationClient";
|
|
|
|
|
|
import { waitForTask } from "../../api/taskSubscription";
|
2026-06-03 01:39:06 +08:00
|
|
|
|
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
|
2026-06-02 12:38:01 +08:00
|
|
|
|
import { translateTaskError } from "../../utils/translateTaskError";
|
|
|
|
|
|
import { getServerBaseUrl, isServerRequestError } from "../../api/serverConnection";
|
|
|
|
|
|
import { summarizeUrl, formatFileSize, fileToDataUrl, wait } from "../../utils/toolPageUtils";
|
|
|
|
|
|
import TaskStatusBar from "../../components/TaskStatusBar";
|
|
|
|
|
|
import BeforeAfterCompare from "../../components/BeforeAfterCompare";
|
|
|
|
|
|
import { addToolResultToAssetLibrary, saveToolResultToLocal } from "../workbench/toolResultActions";
|
|
|
|
|
|
import type { WebImageWorkbenchTool, WebViewKey } from "../../types";
|
|
|
|
|
|
|
|
|
|
|
|
type UpscaleMode = "image" | "video";
|
|
|
|
|
|
type ImageScale = "2x" | "4x";
|
|
|
|
|
|
type VideoMinLen = 540 | 720;
|
|
|
|
|
|
|
|
|
|
|
|
const videoStyles = [
|
|
|
|
|
|
{ value: 0, label: "日式漫画", color: "#f9a8d4" },
|
|
|
|
|
|
{ value: 1, label: "美式漫画", color: "#fbbf24" },
|
|
|
|
|
|
{ value: 2, label: "清新漫画", color: "#86efac" },
|
|
|
|
|
|
{ value: 3, label: "3D卡通", color: "#93c5fd" },
|
|
|
|
|
|
{ value: 4, label: "国风卡通", color: "#fca5a5" },
|
|
|
|
|
|
{ value: 5, label: "纸艺风格", color: "#d6d3d1" },
|
|
|
|
|
|
{ value: 6, label: "简易插画", color: "#a5b4fc" },
|
|
|
|
|
|
{ value: 7, label: "国风水墨", color: "#94a3b8" },
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
function formatCreateTaskError(error: unknown, taskMode: UpscaleMode): string {
|
|
|
|
|
|
if (isServerRequestError(error) && error.status === 404) {
|
|
|
|
|
|
const baseUrl = getServerBaseUrl() || window.location.origin;
|
|
|
|
|
|
const route = taskMode === "image" ? "/api/ai/image/super-resolve" : "/api/ai/video/super-resolve";
|
|
|
|
|
|
return `当前连接的服务端 ${baseUrl} 还没有部署 ${route} 接口,请更新并重启服务端后再试`;
|
|
|
|
|
|
}
|
|
|
|
|
|
return error instanceof Error ? error.message : "超分任务创建失败,请稍后重试";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
interface ResolutionUpscalePageProps {
|
|
|
|
|
|
isAuthenticated?: boolean;
|
|
|
|
|
|
onOpenMore?: () => void;
|
|
|
|
|
|
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
|
|
|
|
|
|
onSelectView?: (view: WebViewKey) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function ResolutionUpscalePage({
|
|
|
|
|
|
isAuthenticated = false,
|
|
|
|
|
|
onOpenMore,
|
|
|
|
|
|
onOpenImageTool,
|
|
|
|
|
|
onSelectView,
|
|
|
|
|
|
}: ResolutionUpscalePageProps) {
|
|
|
|
|
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
|
|
|
|
const pollRunRef = useRef(0);
|
|
|
|
|
|
const cancelRef = useRef(false);
|
|
|
|
|
|
const [mode, setMode] = useState<UpscaleMode>("image");
|
|
|
|
|
|
const [imageScale, setImageScale] = useState<ImageScale>("2x");
|
|
|
|
|
|
const [keepOriginalStyle, setKeepOriginalStyle] = useState(true);
|
|
|
|
|
|
const [videoStyle, setVideoStyle] = useState(0);
|
|
|
|
|
|
const [videoMinLen, setVideoMinLen] = useState<VideoMinLen>(540);
|
|
|
|
|
|
const [videoFps, setVideoFps] = useState(15);
|
|
|
|
|
|
const [useSR, setUseSR] = useState(true);
|
|
|
|
|
|
const [sourceName, setSourceName] = useState("");
|
|
|
|
|
|
const [sourceFile, setSourceFile] = useState<File | null>(null);
|
|
|
|
|
|
const [sourceUrl, setSourceUrl] = useState("");
|
|
|
|
|
|
const [sourcePreview, setSourcePreview] = useState("");
|
|
|
|
|
|
const [resultPreview, setResultPreview] = useState("");
|
|
|
|
|
|
const [sourceDimensions, setSourceDimensions] = useState<{ width: number; height: number } | null>(null);
|
|
|
|
|
|
const [videoViewMode, setVideoViewMode] = useState<"source" | "result">("source");
|
|
|
|
|
|
const [status, setStatus] = useState("上传素材并选择参数后点击开始超分");
|
|
|
|
|
|
const [activeTaskId, setActiveTaskId] = useState("");
|
|
|
|
|
|
const [taskProgress, setTaskProgress] = useState(0);
|
|
|
|
|
|
const [isProcessing, setIsProcessing] = useState(false);
|
|
|
|
|
|
const [isDownloading, setIsDownloading] = useState(false);
|
|
|
|
|
|
const [isSavingAsset, setIsSavingAsset] = useState(false);
|
2026-06-05 18:01:55 +08:00
|
|
|
|
const [isDragging, setIsDragging] = useState(false);
|
2026-06-02 12:38:01 +08:00
|
|
|
|
const activeTaskIdRef = useRef(activeTaskId);
|
|
|
|
|
|
activeTaskIdRef.current = activeTaskId;
|
2026-06-03 01:39:06 +08:00
|
|
|
|
const keepaliveRestoredRef = useRef(false);
|
2026-06-02 12:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [sourcePreview]);
|
|
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (resultPreview.startsWith("blob:")) URL.revokeObjectURL(resultPreview);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [resultPreview]);
|
|
|
|
|
|
|
2026-06-03 01:39:06 +08:00
|
|
|
|
// Keep-alive: restore saved task on mount
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (keepaliveRestoredRef.current) return;
|
|
|
|
|
|
keepaliveRestoredRef.current = true;
|
|
|
|
|
|
const saved = loadToolTaskState("upscale");
|
|
|
|
|
|
if (!saved || saved.resultUrl) return;
|
|
|
|
|
|
setSourceName(saved.sourceName || "");
|
|
|
|
|
|
setSourceUrl(saved.sourceUrl || "");
|
|
|
|
|
|
setIsProcessing(true);
|
|
|
|
|
|
cancelRef.current = false;
|
|
|
|
|
|
pollRunRef.current += 1;
|
|
|
|
|
|
void waitForTaskResult(saved.taskId, mode).catch(() => {});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2026-06-02 12:38:01 +08:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
return () => {
|
2026-06-03 01:39:06 +08:00
|
|
|
|
// Stop polling but keep server task alive — keep-alive will resume on remount
|
2026-06-02 12:38:01 +08:00
|
|
|
|
pollRunRef.current += 1;
|
|
|
|
|
|
cancelRef.current = true;
|
|
|
|
|
|
};
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
|
|
|
|
|
const clearSource = () => {
|
|
|
|
|
|
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
|
|
|
|
|
|
setSourceName("");
|
|
|
|
|
|
setSourceFile(null);
|
|
|
|
|
|
setSourceUrl("");
|
|
|
|
|
|
setSourcePreview("");
|
|
|
|
|
|
setResultPreview("");
|
|
|
|
|
|
setSourceDimensions(null);
|
|
|
|
|
|
setVideoViewMode("source");
|
|
|
|
|
|
setActiveTaskId("");
|
|
|
|
|
|
setTaskProgress(0);
|
|
|
|
|
|
setStatus("已清空素材");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleModeChange = (nextMode: UpscaleMode) => {
|
|
|
|
|
|
if (nextMode === mode) return;
|
|
|
|
|
|
clearSource();
|
|
|
|
|
|
setMode(nextMode);
|
|
|
|
|
|
setStatus(nextMode === "image" ? "已切换为图片超分,请上传图片素材" : "已切换为视频超分,请上传视频素材");
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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("");
|
|
|
|
|
|
setSourceDimensions(null);
|
|
|
|
|
|
setVideoViewMode("source");
|
|
|
|
|
|
setActiveTaskId("");
|
|
|
|
|
|
setTaskProgress(0);
|
|
|
|
|
|
setStatus(`已导入 ${file.name}`);
|
|
|
|
|
|
event.currentTarget.value = "";
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-06-05 18:01:55 +08:00
|
|
|
|
const processDroppedFile = (file: File) => {
|
|
|
|
|
|
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
|
|
|
|
|
|
setSourceName(file.name);
|
|
|
|
|
|
setSourceFile(file);
|
|
|
|
|
|
setSourceUrl("");
|
|
|
|
|
|
setSourcePreview(URL.createObjectURL(file));
|
|
|
|
|
|
setResultPreview("");
|
|
|
|
|
|
setSourceDimensions(null);
|
|
|
|
|
|
setVideoViewMode("source");
|
|
|
|
|
|
setActiveTaskId("");
|
|
|
|
|
|
setTaskProgress(0);
|
|
|
|
|
|
setStatus(`已导入 ${file.name}`);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDragOver = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); };
|
|
|
|
|
|
const handleDragLeave = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); };
|
|
|
|
|
|
const handleDrop = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = e.dataTransfer.files[0]; if (file) processDroppedFile(file); };
|
|
|
|
|
|
|
2026-06-02 12:38:01 +08:00
|
|
|
|
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("");
|
|
|
|
|
|
setVideoViewMode("source");
|
|
|
|
|
|
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 || (mode === "image" ? "image/png" : "video/mp4"),
|
|
|
|
|
|
});
|
|
|
|
|
|
return uploaded.signedUrl || uploaded.url;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const waitForTaskResult = async (taskId: string, taskMode: UpscaleMode) => {
|
|
|
|
|
|
const runId = ++pollRunRef.current;
|
|
|
|
|
|
setActiveTaskId(taskId);
|
|
|
|
|
|
setTaskProgress(5);
|
2026-06-03 01:39:06 +08:00
|
|
|
|
saveToolTaskState("upscale", { taskId, sourceName, sourceUrl, status: `任务 ${taskId}`, progress: 5 });
|
2026-06-02 12:38:01 +08:00
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
setVideoViewMode("result");
|
|
|
|
|
|
setStatus(`${taskMode === "image" ? "图片" : "视频"}超分完成:${summarizeUrl(e.resultUrl)}`);
|
|
|
|
|
|
setTaskProgress(100);
|
2026-06-03 01:39:06 +08:00
|
|
|
|
clearToolTaskState("upscale");
|
|
|
|
|
|
saveToolTaskState("upscale", { taskId, resultUrl: e.resultUrl, resultPreview: e.resultUrl, sourceName, sourceUrl, status: "完成", progress: 100 });
|
2026-06-02 12:38:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
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("已取消");
|
2026-06-03 01:39:06 +08:00
|
|
|
|
clearToolTaskState("upscale");
|
2026-06-02 12:38:01 +08:00
|
|
|
|
}, [activeTaskId]);
|
|
|
|
|
|
|
|
|
|
|
|
const handleDownload = async () => {
|
|
|
|
|
|
if (!resultPreview || isDownloading) return;
|
|
|
|
|
|
setIsDownloading(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const isVideo = mode === "video";
|
|
|
|
|
|
const filenameBase = isVideo ? `upscaled-video-${Date.now()}` : `upscaled-image-${Date.now()}`;
|
|
|
|
|
|
await saveToolResultToLocal({
|
|
|
|
|
|
url: resultPreview,
|
|
|
|
|
|
name: filenameBase,
|
|
|
|
|
|
type: isVideo ? "video" : "image",
|
|
|
|
|
|
isVideo,
|
|
|
|
|
|
taskId: activeTaskId || undefined,
|
|
|
|
|
|
tags: ["工具盒", "超分辨率", isVideo ? "生成视频" : "生成图片"],
|
|
|
|
|
|
});
|
|
|
|
|
|
setStatus("下载完成");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setStatus(error instanceof Error ? error.message : "下载失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsDownloading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSaveToAssets = async () => {
|
|
|
|
|
|
if (!resultPreview || isSavingAsset) return;
|
|
|
|
|
|
setIsSavingAsset(true);
|
|
|
|
|
|
try {
|
|
|
|
|
|
const isVideo = mode === "video";
|
|
|
|
|
|
const status = await addToolResultToAssetLibrary({
|
|
|
|
|
|
url: resultPreview,
|
|
|
|
|
|
name: `upscaled-${isVideo ? "video" : "image"}-${Date.now()}.${isVideo ? "mp4" : "png"}`,
|
|
|
|
|
|
description: `工具盒${isVideo ? "视频" : "图片"}超分生成结果`,
|
|
|
|
|
|
type: isVideo ? "video" : "image",
|
|
|
|
|
|
isVideo,
|
|
|
|
|
|
taskId: activeTaskId || undefined,
|
|
|
|
|
|
tags: ["工具盒", "超分辨率", isVideo ? "生成视频" : "生成图片"],
|
|
|
|
|
|
});
|
|
|
|
|
|
setStatus(status === "server" ? "已加入资产库" : "已加入本地资产库");
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setStatus(error instanceof Error ? error.message : "加入资产库失败");
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsSavingAsset(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleStart = async () => {
|
|
|
|
|
|
if (!isAuthenticated) {
|
|
|
|
|
|
setStatus("请先登录后再创建超分任务");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!sourcePreview) {
|
|
|
|
|
|
setStatus(mode === "image" ? "请先上传需要提升分辨率的图片" : "请先上传需要提升分辨率的视频");
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (isProcessing) return;
|
|
|
|
|
|
|
|
|
|
|
|
setIsProcessing(true);
|
|
|
|
|
|
setResultPreview("");
|
|
|
|
|
|
setTaskProgress(0);
|
|
|
|
|
|
cancelRef.current = false;
|
|
|
|
|
|
try {
|
|
|
|
|
|
const generationUrl = await getSourceGenerationUrl();
|
|
|
|
|
|
setStatus(`素材已就绪:${summarizeUrl(generationUrl)},正在提交任务`);
|
|
|
|
|
|
|
|
|
|
|
|
if (mode === "image") {
|
|
|
|
|
|
const result = await aiGenerationClient.createImageSuperResolveTask({
|
|
|
|
|
|
imageUrl: generationUrl,
|
|
|
|
|
|
scale: imageScale,
|
|
|
|
|
|
});
|
|
|
|
|
|
await waitForTaskResult(result.taskId, "image");
|
|
|
|
|
|
} else if (keepOriginalStyle) {
|
|
|
|
|
|
const result = await aiGenerationClient.createVideoSuperResolveTask({
|
|
|
|
|
|
videoUrl: generationUrl,
|
|
|
|
|
|
});
|
|
|
|
|
|
await waitForTaskResult(result.taskId, "video");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const result = await aiGenerationClient.createVideoSuperResolveTask({
|
|
|
|
|
|
videoUrl: generationUrl,
|
|
|
|
|
|
provider: "dashscope-style-transform",
|
|
|
|
|
|
style: videoStyle,
|
|
|
|
|
|
videoFps,
|
|
|
|
|
|
minLen: videoMinLen,
|
|
|
|
|
|
useSR,
|
|
|
|
|
|
animateEmotion: true,
|
|
|
|
|
|
});
|
|
|
|
|
|
await waitForTaskResult(result.taskId, "video");
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setStatus(formatCreateTaskError(error, mode));
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setIsProcessing(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const currentVideoStyleLabel = keepOriginalStyle
|
|
|
|
|
|
? "纯超分"
|
|
|
|
|
|
: videoStyles.find((style) => style.value === videoStyle)?.label || "日式漫画";
|
|
|
|
|
|
const scaleFactor = Number.parseInt(imageScale, 10);
|
|
|
|
|
|
const sourceSizeText = sourceDimensions ? `原图 ${sourceDimensions.width}*${sourceDimensions.height}` : "原图";
|
|
|
|
|
|
const resultSizeText = sourceDimensions
|
|
|
|
|
|
? `结果图 ${sourceDimensions.width * scaleFactor}*${sourceDimensions.height * scaleFactor}`
|
|
|
|
|
|
: "结果图";
|
|
|
|
|
|
const modeLabel = mode === "image" ? `图片超分 ${imageScale}` : keepOriginalStyle ? "纯超分(保持原风格)" : `${currentVideoStyleLabel} ${videoMinLen}P${useSR ? " + SR" : ""}`;
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<section className="image-workbench-page resolution-upscale-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" className="is-active" onClick={() => onSelectView?.("resolutionUpscale")}>
|
|
|
|
|
|
<ColumnWidthOutlined />
|
|
|
|
|
|
分辨率提升
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" 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}>
|
|
|
|
|
|
<ColumnWidthOutlined />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<strong>分辨率提升</strong>
|
|
|
|
|
|
<div className="image-workbench-mode-tabs" role="tablist" aria-label="素材类型">
|
|
|
|
|
|
<button type="button" className={mode === "image" ? "is-active" : ""} onClick={() => handleModeChange("image")}>
|
|
|
|
|
|
<PictureOutlined />
|
|
|
|
|
|
图片
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button type="button" className={mode === "video" ? "is-active" : ""} onClick={() => handleModeChange("video")}>
|
|
|
|
|
|
<VideoCameraOutlined />
|
|
|
|
|
|
视频
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<main className="image-workbench-layout image-workbench-layout--camera resolution-upscale-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={mode === "image" ? "image/png,image/jpeg,image/webp" : "video/mp4,video/quicktime,video/webm,video/*"}
|
|
|
|
|
|
onChange={handleFileChange}
|
|
|
|
|
|
/>
|
2026-06-05 18:01:55 +08:00
|
|
|
|
<div
|
|
|
|
|
|
className={`image-workbench-upload-shell${isDragging ? " is-dragging" : ""}`}
|
|
|
|
|
|
onDragOver={handleDragOver}
|
|
|
|
|
|
onDragLeave={handleDragLeave}
|
|
|
|
|
|
onDrop={handleDrop}
|
|
|
|
|
|
>
|
|
|
|
|
|
{isDragging ? <div className="image-workbench-upload-drop-overlay"><span>释放文件以上传</span></div> : null}
|
2026-06-02 12:38:01 +08:00
|
|
|
|
<button type="button" className="image-workbench-upload" onClick={() => fileInputRef.current?.click()}>
|
|
|
|
|
|
{sourcePreview && mode === "image" ? <img src={sourcePreview} alt="" /> : <FileImageOutlined />}
|
|
|
|
|
|
<strong>{sourceName || (mode === "image" ? "选择图片" : "选择视频")}</strong>
|
|
|
|
|
|
<span>{mode === "image" ? "PNG / JPG / WebP" : "MP4 / MOV / WebM"}</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>
|
|
|
|
|
|
{mode === "image" ? (
|
|
|
|
|
|
<label className="image-workbench-select">
|
|
|
|
|
|
<span>放大倍数</span>
|
|
|
|
|
|
<select value={imageScale} onChange={(event) => setImageScale(event.target.value as ImageScale)}>
|
|
|
|
|
|
<option value="2x">2x</option>
|
|
|
|
|
|
<option value="4x">4x</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<div className="resolution-upscale-style-chips">
|
|
|
|
|
|
<span className="resolution-upscale-style-chips__title">模式</span>
|
|
|
|
|
|
<div className="resolution-upscale-style-chips__grid">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`resolution-upscale-style-chip${keepOriginalStyle ? " is-active" : ""}`}
|
|
|
|
|
|
onClick={() => setKeepOriginalStyle(true)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="resolution-upscale-style-chip__swatch" style={{ background: "#6ee7b7" }} />
|
|
|
|
|
|
保持原风格
|
|
|
|
|
|
</button>
|
|
|
|
|
|
{videoStyles.map((style) => (
|
|
|
|
|
|
<button
|
|
|
|
|
|
key={style.value}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`resolution-upscale-style-chip${!keepOriginalStyle && videoStyle === style.value ? " is-active" : ""}`}
|
|
|
|
|
|
onClick={() => { setKeepOriginalStyle(false); setVideoStyle(style.value); }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<span className="resolution-upscale-style-chip__swatch" style={{ background: style.color }} />
|
|
|
|
|
|
{style.label}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{!keepOriginalStyle && (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<label className="image-workbench-select">
|
|
|
|
|
|
<span>输出短边</span>
|
|
|
|
|
|
<select value={videoMinLen} onChange={(event) => setVideoMinLen(Number(event.target.value) as VideoMinLen)}>
|
|
|
|
|
|
<option value={540}>540P</option>
|
|
|
|
|
|
<option value={720}>720P</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<label className="image-workbench-select">
|
|
|
|
|
|
<span>帧率</span>
|
|
|
|
|
|
<select value={videoFps} onChange={(event) => setVideoFps(Number(event.target.value))}>
|
|
|
|
|
|
<option value={15}>15 FPS</option>
|
|
|
|
|
|
<option value={20}>20 FPS</option>
|
|
|
|
|
|
<option value={25}>25 FPS</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</label>
|
|
|
|
|
|
<div className="image-workbench-section-title">
|
|
|
|
|
|
<h3>超分增强</h3>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={`image-workbench-toggle ${useSR ? "is-active" : ""}`}
|
|
|
|
|
|
aria-pressed={useSR}
|
|
|
|
|
|
onClick={() => setUseSR((current) => !current)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="image-workbench-actions">
|
|
|
|
|
|
<button type="button" className="image-workbench-primary" onClick={() => void handleStart()} disabled={isProcessing}>
|
|
|
|
|
|
<ColumnWidthOutlined />
|
|
|
|
|
|
{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 resolution-upscale-canvas" aria-label="分辨率提升画布">
|
|
|
|
|
|
{sourcePreview ? (
|
|
|
|
|
|
mode === "image" ? (
|
|
|
|
|
|
<div className="resolution-upscale-image-stage">
|
|
|
|
|
|
<BeforeAfterCompare
|
|
|
|
|
|
sourceSrc={sourcePreview}
|
|
|
|
|
|
resultSrc={resultPreview || sourcePreview}
|
|
|
|
|
|
sourceLabel={sourceSizeText}
|
|
|
|
|
|
resultLabel={resultPreview ? resultSizeText : "等待结果"}
|
|
|
|
|
|
sourceAlt="原图预览"
|
|
|
|
|
|
resultAlt="超分结果预览"
|
|
|
|
|
|
onSourceLoad={(width, height) => setSourceDimensions({ width, height })}
|
|
|
|
|
|
/>
|
|
|
|
|
|
{resultPreview && (
|
|
|
|
|
|
<div className="resolution-upscale-result-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>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="resolution-upscale-video-stage">
|
|
|
|
|
|
{resultPreview && (
|
|
|
|
|
|
<div className="resolution-upscale-video-header">
|
|
|
|
|
|
<div className="resolution-upscale-video-toggle">
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={videoViewMode === "source" ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => setVideoViewMode("source")}
|
|
|
|
|
|
>
|
|
|
|
|
|
原始
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={videoViewMode === "result" ? "is-active" : ""}
|
|
|
|
|
|
onClick={() => setVideoViewMode("result")}
|
|
|
|
|
|
>
|
|
|
|
|
|
超分结果
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="resolution-upscale-result-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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<video src={videoViewMode === "result" && resultPreview ? resultPreview : sourcePreview} controls muted playsInline />
|
|
|
|
|
|
<span>{resultPreview ? (videoViewMode === "result" ? "超分结果" : "原始视频") : "原始视频"} · {modeLabel}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)
|
|
|
|
|
|
) : (
|
2026-06-05 18:01:55 +08:00
|
|
|
|
<div className="studio-canvas-ghost">
|
|
|
|
|
|
<div className="studio-canvas-ghost__icon">
|
|
|
|
|
|
<ThunderboltOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="studio-canvas-ghost__title">{mode === "image" ? "拖拽或选择图片" : "拖拽或选择视频"}</div>
|
|
|
|
|
|
<div className="studio-canvas-ghost__hint">{mode === "image" ? "支持 PNG / JPG / WebP" : "支持 MP4 / MOV / WebM"}</div>
|
|
|
|
|
|
</div>
|
2026-06-02 12:38:01 +08:00
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</main>
|
|
|
|
|
|
|
|
|
|
|
|
<TaskStatusBar taskId={activeTaskId} status={status} progress={taskProgress} idleLabel={modeLabel} />
|
|
|
|
|
|
</section>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export default ResolutionUpscalePage;
|