Initial commit: OmniAI Web Frontend
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,434 @@
|
||||
import {
|
||||
CameraOutlined,
|
||||
CustomerServiceOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
EditOutlined,
|
||||
FileImageOutlined,
|
||||
FolderAddOutlined,
|
||||
FontSizeOutlined,
|
||||
HighlightOutlined,
|
||||
LinkOutlined,
|
||||
SwapOutlined,
|
||||
VideoCameraOutlined,
|
||||
} from "@ant-design/icons";
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } 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 SubtitleRegionPreset {
|
||||
id: string;
|
||||
label: string;
|
||||
desc: string;
|
||||
by: number;
|
||||
bh: number;
|
||||
}
|
||||
|
||||
const regionPresets: SubtitleRegionPreset[] = [
|
||||
{ id: "bottom", label: "底部字幕", desc: "最常见的字幕位置", by: 0.75, bh: 0.25 },
|
||||
{ id: "bottom-narrow", label: "底部单行", desc: "仅底部一小条区域", by: 0.85, bh: 0.15 },
|
||||
{ id: "top", label: "顶部字幕", desc: "顶部弹幕或标题区域", by: 0, bh: 0.2 },
|
||||
{ id: "full", label: "全屏扫描", desc: "去除画面中所有文字", by: 0, bh: 1 },
|
||||
];
|
||||
|
||||
interface SubtitleRemovalPageProps {
|
||||
isAuthenticated?: boolean;
|
||||
onOpenMore?: () => void;
|
||||
onOpenImageTool?: (tool: WebImageWorkbenchTool) => void;
|
||||
onSelectView?: (view: WebViewKey) => void;
|
||||
}
|
||||
|
||||
function formatCreateTaskError(error: unknown): string {
|
||||
if (isServerRequestError(error) && error.status === 404) {
|
||||
const baseUrl = getServerBaseUrl() || window.location.origin;
|
||||
return `当前连接的服务端 ${baseUrl} 还没有部署 /api/ai/video/erase-subtitles 接口,请更新并重启服务端后再试`;
|
||||
}
|
||||
return error instanceof Error ? error.message : "字幕去除任务创建失败,请稍后重试";
|
||||
}
|
||||
|
||||
function SubtitleRemovalPage({
|
||||
isAuthenticated = false,
|
||||
onOpenMore,
|
||||
onOpenImageTool,
|
||||
onSelectView,
|
||||
}: SubtitleRemovalPageProps) {
|
||||
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 [resultUrl, setResultUrl] = useState("");
|
||||
const [activePreset, setActivePreset] = useState("bottom");
|
||||
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);
|
||||
const activeTaskIdRef = useRef(activeTaskId);
|
||||
activeTaskIdRef.current = activeTaskId;
|
||||
|
||||
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("");
|
||||
setResultUrl("");
|
||||
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));
|
||||
setResultUrl("");
|
||||
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("video/"));
|
||||
if (!file) return;
|
||||
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
|
||||
setSourceName(file.name);
|
||||
setSourceFile(file);
|
||||
setSourceUrl("");
|
||||
setSourcePreview(URL.createObjectURL(file));
|
||||
setResultUrl("");
|
||||
setActiveTaskId("");
|
||||
setTaskProgress(0);
|
||||
setStatus(`已导入 ${file.name}`);
|
||||
};
|
||||
|
||||
const handleImportUrl = () => {
|
||||
const normalized = sourceUrl.trim();
|
||||
if (!/^https?:\/\//i.test(normalized)) {
|
||||
setStatus("请输入可访问的 HTTP/HTTPS 视频 URL");
|
||||
return;
|
||||
}
|
||||
if (sourcePreview.startsWith("blob:")) URL.revokeObjectURL(sourcePreview);
|
||||
setSourceFile(null);
|
||||
setSourceName(summarizeUrl(normalized));
|
||||
setSourcePreview(normalized);
|
||||
setResultUrl("");
|
||||
setActiveTaskId("");
|
||||
setTaskProgress(0);
|
||||
setStatus(`已导入 URL:${summarizeUrl(normalized)}`);
|
||||
};
|
||||
|
||||
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 || "video/mp4",
|
||||
});
|
||||
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) {
|
||||
setResultUrl(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 (!resultUrl || isDownloading) return;
|
||||
setIsDownloading(true);
|
||||
try {
|
||||
await saveToolResultToLocal({
|
||||
url: resultUrl,
|
||||
name: `subtitle-removed-${Date.now()}`,
|
||||
type: "video",
|
||||
isVideo: true,
|
||||
taskId: activeTaskId || undefined,
|
||||
tags: ["工具盒", "去字幕", "生成视频"],
|
||||
});
|
||||
setStatus("下载完成");
|
||||
} catch (error) {
|
||||
setStatus(error instanceof Error ? error.message : "下载失败");
|
||||
} finally {
|
||||
setIsDownloading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToAssets = async () => {
|
||||
if (!resultUrl || isSavingAsset) return;
|
||||
setIsSavingAsset(true);
|
||||
try {
|
||||
const status = await addToolResultToAssetLibrary({
|
||||
url: resultUrl,
|
||||
name: `subtitle-removed-${Date.now()}.mp4`,
|
||||
description: "工具盒去字幕生成结果",
|
||||
type: "video",
|
||||
isVideo: true,
|
||||
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);
|
||||
setResultUrl("");
|
||||
setTaskProgress(0);
|
||||
cancelRef.current = false;
|
||||
try {
|
||||
const generationUrl = await getSourceGenerationUrl();
|
||||
setStatus(`素材已就绪:${summarizeUrl(generationUrl)},正在提交任务`);
|
||||
const preset = regionPresets.find((p) => p.id === activePreset) || regionPresets[0];
|
||||
const result = await aiGenerationClient.createEraseSubtitlesTask({
|
||||
videoUrl: generationUrl,
|
||||
bx: 0,
|
||||
by: preset.by,
|
||||
bw: 1,
|
||||
bh: preset.bh,
|
||||
});
|
||||
await waitForTaskResult(result.taskId);
|
||||
} catch (error) {
|
||||
setStatus(formatCreateTaskError(error));
|
||||
} finally {
|
||||
setIsProcessing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="image-workbench-page subtitle-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")}>
|
||||
<HighlightOutlined />
|
||||
局部重绘
|
||||
</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")}>
|
||||
<VideoCameraOutlined />
|
||||
分辨率提升
|
||||
</button>
|
||||
<button type="button" onClick={() => onSelectView?.("watermarkRemoval")}>
|
||||
<DeleteOutlined />
|
||||
去水印
|
||||
</button>
|
||||
<button type="button" className="is-active">
|
||||
<FontSizeOutlined />
|
||||
字幕去除
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="image-workbench-subbar">
|
||||
<button type="button" className="image-workbench-icon-btn" aria-label="返回工具盒" onClick={onOpenMore}>
|
||||
<FontSizeOutlined />
|
||||
</button>
|
||||
<strong>字幕去除</strong>
|
||||
</div>
|
||||
|
||||
<main className="image-workbench-layout image-workbench-layout--camera subtitle-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="video/mp4"
|
||||
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()}>
|
||||
<FileImageOutlined />
|
||||
<strong>{sourceName || "拖拽或选择视频"}</strong>
|
||||
<span>仅支持 MP4,最大 1GB,最高 1080P</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>
|
||||
<div className="subtitle-removal-presets">
|
||||
{regionPresets.map((preset) => (
|
||||
<button
|
||||
key={preset.id}
|
||||
type="button"
|
||||
className={`subtitle-removal-preset${activePreset === preset.id ? " is-active" : ""}`}
|
||||
onClick={() => setActivePreset(preset.id)}
|
||||
>
|
||||
<div className="subtitle-removal-preset__visual">
|
||||
<div
|
||||
className="subtitle-removal-preset__region"
|
||||
style={{ "--region-top": `${preset.by * 100}%`, "--region-height": `${preset.bh * 100}%` } as CSSProperties}
|
||||
/>
|
||||
</div>
|
||||
<strong>{preset.label}</strong>
|
||||
<span>{preset.desc}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</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 subtitle-removal-canvas" aria-label="字幕去除画布">
|
||||
{sourcePreview ? (
|
||||
<div className="subtitle-removal-preview">
|
||||
<div className="subtitle-removal-preview__video-wrap">
|
||||
<video src={sourcePreview} controls />
|
||||
{!resultUrl && (
|
||||
<div
|
||||
className="subtitle-removal-preview__region-overlay"
|
||||
style={{
|
||||
"--region-top": `${(regionPresets.find((p) => p.id === activePreset) || regionPresets[0]).by * 100}%`,
|
||||
"--region-height": `${(regionPresets.find((p) => p.id === activePreset) || regionPresets[0]).bh * 100}%`,
|
||||
} as CSSProperties}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{resultUrl && (
|
||||
<div className="subtitle-removal-preview__video-wrap">
|
||||
<h4>去除字幕后</h4>
|
||||
<video src={resultUrl} controls />
|
||||
<div className="subtitle-removal-preview__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>
|
||||
) : (
|
||||
<div className="image-workbench-empty-canvas">
|
||||
<DeleteOutlined style={{ fontSize: 48, opacity: 0.2 }} />
|
||||
<p>上传视频后在此预览</p>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<TaskStatusBar taskId={activeTaskId} status={status} progress={taskProgress} idleLabel="字幕去除" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SubtitleRemovalPage;
|
||||
Reference in New Issue
Block a user