Files
omniai-web/src/features/subtitle-removal/SubtitleRemovalPage.tsx
T

468 lines
18 KiB
TypeScript
Raw Normal View History

2026-06-02 12:38:01 +08:00
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";
2026-06-02 12:38:01 +08:00
import { aiGenerationClient } from "../../api/aiGenerationClient";
import { waitForTask } from "../../api/taskSubscription";
import { saveToolTaskState, loadToolTaskState, clearToolTaskState } from "../workbench/toolKeepalive";
2026-06-02 12:38:01 +08:00
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);
2026-06-02 12:38:01 +08:00
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(() => {});
}, []);
2026-06-02 12:38:01 +08:00
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) => {
2026-06-02 12:38:01 +08:00
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); };
2026-06-02 12:38:01 +08:00
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 });
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) {
setResultUrl(e.resultUrl);
setStatus("字幕去除完成");
setTaskProgress(100);
clearToolTaskState("subtitle");
saveToolTaskState("subtitle", { 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("已取消");
clearToolTaskState("subtitle");
2026-06-02 12:38:01 +08:00
}, [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}
2026-06-02 12:38:01 +08:00
<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>
2026-06-02 12:38:01 +08:00
</div>
)}
</section>
</main>
<TaskStatusBar taskId={activeTaskId} status={status} progress={taskProgress} idleLabel="字幕去除" />
</section>
);
}
export default SubtitleRemovalPage;