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(null); const pollRunRef = useRef(0); const cancelRef = useRef(false); const [sourceName, setSourceName] = useState(""); const [sourceFile, setSourceFile] = useState(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) => { 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) => { e.preventDefault(); e.stopPropagation(); if (e.dataTransfer.types.includes("Files")) setIsDragging(true); }; const handleDragLeave = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.currentTarget === e.target || !e.currentTarget.contains(e.relatedTarget as Node)) setIsDragging(false); }; const handleFileDrop = (e: DragEvent) => { 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 (
字幕去除
{sourcePreview ? (
) : (
拖拽或选择视频
仅支持 MP4,最大 1GB,最高 1080P
)}
); } export default SubtitleRemovalPage;