Files
omniai-web/src/features/subtitle-removal/SubtitleRemovalPage.tsx
T
stringadmin 468d1d27dd fix: 全站页面保活机制、登录拦截优化、UI修复与功能完善
- 移除未登录全页面拦截,改为浏览自由 + 功能使用时弹窗
- 修复PageTransition退出动画卡死导致黑屏的bug
- CanvasPage添加加载中状态避免首次访问黑屏假死
- 全站7个工具页添加页面保活机制,切页后台任务不中断
- 修复未登录时401误触发"用户已在别处登录"弹窗
- 删除MorePage模板板块、微信登录、EcommerceTemplates/SizeTemplate路由
- 剧本评分接入DashScope qwen3.7-max直连API
- 电商视频生成重构为3阶段可视管线(策划→生成图片→生成视频)
- 电商视频保活增强:异步函数直接写localStorage避免卸载丢失
- Workbench侧边栏移除mode过滤,三模式共用同一对话列表
- 首页更新轮播图/背景视频、按钮跳转修正、文案优化
- AppShell顶栏新增网站备案信息按钮
- 多个页面的terminate/cancel按钮覆盖、单镜头重试、批量保存下载

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-06-03 01:39:06 +08:00

452 lines
17 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 } 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 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 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);
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" 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;