Files
omniai-web/src/features/subtitle-removal/SubtitleRemovalPage.tsx
T
OmniAI Developer 5b87594e36 feat: 多页面拖拽上传、滚动条精简、UI优化
- 剧本评测/分辨率提升/数字人/角色迁移/图片工作台/去水印/电商:新增外部拖拽文件上传
- 电商:爆款图复刻上传框支持拖拽+大滚动条,短视频/模特图/详情图滚动条精简回退
- 图片工作台:右侧输出面板移至左侧提示词上方,删除局部重绘遮罩/结果框
- 数字人:生成按钮改为「开始生成」
- 局部重绘:编辑遮罩→编辑页面
- 对话框生成器:新增对话/视频模式、模型/速度/深度选择按钮
- 视频时长默认改为5秒
- 工具箱页面空状态logo统一绿底亮色图标
- 多处CSS滚动条和布局优化
2026-06-05 18:01:55 +08:00

468 lines
18 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,
CustomerServiceOutlined,
DeleteOutlined,
DownloadOutlined,
EditOutlined,
FileImageOutlined,
FolderAddOutlined,
FontSizeOutlined,
HighlightOutlined,
LinkOutlined,
SwapOutlined,
VideoCameraOutlined,
} from "@ant-design/icons";
import { useCallback, useEffect, useRef, useState, type CSSProperties, type DragEvent } 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 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 [isDragging, setIsDragging] = useState(false);
const activeTaskIdRef = useRef(activeTaskId);
activeTaskIdRef.current = activeTaskId;
const keepaliveRestoredRef = useRef(false);
// Keep-alive: restore saved task on mount
useEffect(() => {
if (keepaliveRestoredRef.current) return;
keepaliveRestoredRef.current = true;
const saved = loadToolTaskState("subtitle");
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("");
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 processDroppedFile = (file: File) => {
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 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 handleFileDrop = (e: DragEvent<HTMLDivElement>) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("video/")); if (file) processDroppedFile(file); };
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);
saveToolTaskState("subtitle", { 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) {
setResultUrl(e.resultUrl);
setStatus("字幕去除完成");
setTaskProgress(100);
clearToolTaskState("subtitle");
saveToolTaskState("subtitle", { 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("subtitle");
}, [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${isDragging ? " is-dragging" : ""}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
>
{isDragging ? <div className="image-workbench-upload-drop-overlay"><span></span></div> : null}
<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="studio-canvas-ghost"
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleFileDrop}
>
<div className="studio-canvas-ghost__icon">
<VideoCameraOutlined />
</div>
<div className="studio-canvas-ghost__title"></div>
<div className="studio-canvas-ghost__hint"> MP4 1GB 1080P</div>
</div>
)}
</section>
</main>
<TaskStatusBar taskId={activeTaskId} status={status} progress={taskProgress} idleLabel="字幕去除" />
</section>
);
}
export default SubtitleRemovalPage;