feat: VRSub 单体应用(WOV 单机版)初始提交
为视频生成 VR 双眼字幕的单体实现:FastAPI 后端、调度器与全部节点 (提音/转写/翻译/ASS/抽帧/OCR/LLM 过滤)在单进程内运行。 - 节点协议(wov_sdk 数据模型)与分布式版保持一致,预留回退桥梁 - 工作流即数据:DAG 存于 workflows/*.json,模型/链路改动只改数据 - 调度器:拓扑顺序执行、断点续跑(产物重建)、任务暂停/继续 - 抽帧按帧间隔(select 按帧号精确取帧),VLM OCR 与 LLM 过滤使用 自适应线程池弹性并发,并打印数据处理速度进度日志 - 100% 行覆盖率(pytest --cov-fail-under=100)
This commit is contained in:
@@ -0,0 +1,647 @@
|
||||
// VRSub 静态前端公共脚本:所有页面共用的 API 封装、渲染函数与事件绑定。
|
||||
// 首页只负责发起任务;任务管理页展示全部任务的进度、产物下载与失败重试。
|
||||
|
||||
// 演示"视频字幕生成"工作流的 DAG 定义,预填在工作流编排页。
|
||||
const DEMO_WORKFLOW = {
|
||||
name: "视频字幕生成",
|
||||
version: 1,
|
||||
nodes: [
|
||||
{
|
||||
id: "extract",
|
||||
node_type: "ffmpeg-extract",
|
||||
params: { sample_rate: 16000, channels: 1 },
|
||||
inputs: { video_uri: "input.video_uri" },
|
||||
},
|
||||
{
|
||||
id: "asr",
|
||||
node_type: "faster-whisper",
|
||||
params: { language: "ja" },
|
||||
inputs: { audio_uri: "extract.audio_uri" },
|
||||
},
|
||||
{
|
||||
id: "translate",
|
||||
node_type: "llm-translate",
|
||||
params: { target_language: "zh-CN" },
|
||||
inputs: { srt_uri: "asr.srt_uri" },
|
||||
},
|
||||
{
|
||||
id: "ass",
|
||||
node_type: "srt-to-dual-eye-ass",
|
||||
params: { resolution: "3840x1920" },
|
||||
inputs: { cn_srt_uri: "translate.cn_srt_uri" },
|
||||
},
|
||||
],
|
||||
edges: [
|
||||
{ from: "extract", to: "asr" },
|
||||
{ from: "asr", to: "translate" },
|
||||
{ from: "translate", to: "ass" },
|
||||
],
|
||||
entry_inputs: { video_uri: "file" },
|
||||
final_outputs: {
|
||||
cn_srt: "translate.cn_srt_uri",
|
||||
ass: "ass.ass_uri",
|
||||
},
|
||||
};
|
||||
|
||||
// 统一封装 fetch:自动携带 JSON 头、解析响应并在失败时抛出可读错误。
|
||||
async function api(path, options = {}) {
|
||||
const response = await fetch(path, {
|
||||
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
||||
...options,
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
// FastAPI 的校验错误 detail 可能是数组,统一序列化为字符串展示。
|
||||
const detail = data && data.detail ? JSON.stringify(data.detail) : response.statusText;
|
||||
throw new Error(`${response.status} ${detail}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// 转义用户可控文本,防止 XSS 注入到表格或状态 HTML 中。
|
||||
function escapeHtml(value) {
|
||||
return String(value)
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
}
|
||||
|
||||
// 根据状态生成带语义颜色的徽章 HTML。
|
||||
function badge(status) {
|
||||
// 统一转小写比较,兼容后端返回的不同大小写。
|
||||
const value = String(status).toLowerCase();
|
||||
const className =
|
||||
value === "completed" || value === "ok"
|
||||
? "ok"
|
||||
: value === "error" || value === "failed"
|
||||
? "error"
|
||||
: "warn";
|
||||
return `<span class="badge ${className}">${escapeHtml(status)}</span>`;
|
||||
}
|
||||
|
||||
// 把 ISO 时间格式化为本地时间;非法值原样返回。
|
||||
function formatTime(value) {
|
||||
if (!value) return "";
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? value : date.toLocaleString();
|
||||
}
|
||||
|
||||
// 计算距给定时间的流逝时长,用于任务页展示已运行时间。
|
||||
function formatElapsed(value) {
|
||||
if (!value) return "";
|
||||
const seconds = Math.floor((Date.now() - new Date(value).getTime()) / 1000);
|
||||
if (Number.isNaN(seconds) || seconds < 0) return "";
|
||||
if (seconds < 60) return `${seconds} 秒`;
|
||||
return `${Math.floor(seconds / 60)} 分钟`;
|
||||
}
|
||||
|
||||
// 健康检查失败时在页面顶部展示后端不可用提示。
|
||||
async function loadHealth() {
|
||||
try {
|
||||
await api("/health");
|
||||
} catch (error) {
|
||||
const banner = document.createElement("div");
|
||||
banner.className = "result";
|
||||
banner.textContent = `后端不可用:${error.message}`;
|
||||
document.body.prepend(banner);
|
||||
}
|
||||
}
|
||||
|
||||
// 加载工作流列表并渲染发布状态与操作按钮。
|
||||
async function loadWorkflows() {
|
||||
const workflows = await api("/api/admin/workflows");
|
||||
const tbody = document.getElementById("workflowList");
|
||||
if (!tbody) return;
|
||||
tbody.innerHTML = workflows.length
|
||||
? workflows
|
||||
.map(
|
||||
(workflow) => `
|
||||
<tr>
|
||||
<td>${escapeHtml(workflow.id)}</td>
|
||||
<td>${escapeHtml(workflow.name)}</td>
|
||||
<td>${escapeHtml(workflow.latest_version)}</td>
|
||||
<td>${workflow.published ? badge("ok") : badge("draft")}</td>
|
||||
<td>
|
||||
<button class="danger" data-delete-workflow="${escapeHtml(workflow.id)}">删除</button>
|
||||
${workflow.published ? "" : `<button data-publish-workflow="${escapeHtml(workflow.id)}">发布</button>`}
|
||||
</td>
|
||||
</tr>`,
|
||||
)
|
||||
.join("")
|
||||
: '<tr><td colspan="5">暂无工作流</td></tr>';
|
||||
}
|
||||
|
||||
// 加载已发布的工作流(应用),填充首页的下拉选择框。
|
||||
// 已发布工作流缓存(id → {name, definition}),供按需切换 OCR 面板。
|
||||
let workflowApps = [];
|
||||
// 需要 crop 的节点 ID(如 frame-extract)与用户框选的 crop 值。
|
||||
let cropNodeId = null;
|
||||
let selectedCrop = null;
|
||||
// 框选模式:开启后才让画布接收鼠标事件,平时不拦截视频控件。
|
||||
let drawMode = false;
|
||||
// 当前选择的视频文件:change 时保存,提交时使用(避免被重置清空 input 丢失)。
|
||||
let selectedVideoFile = null;
|
||||
|
||||
async function loadWorkflowOptions() {
|
||||
const select = document.getElementById("workflowSelect");
|
||||
if (!select) return;
|
||||
workflowApps = await api("/api/apps");
|
||||
select.innerHTML = workflowApps.length
|
||||
? workflowApps
|
||||
.map(
|
||||
(app) =>
|
||||
`<option value="${escapeHtml(app.id)}">${escapeHtml(app.name)}</option>`,
|
||||
)
|
||||
.join("")
|
||||
: '<option value="">暂无可用工作流</option>';
|
||||
// 切换工作流时按需展示框选面板。
|
||||
select.addEventListener("change", onWorkflowChange);
|
||||
await onWorkflowChange();
|
||||
}
|
||||
|
||||
// 切换工作流:若 DAG 中存在带 crop 参数的节点(字幕 OCR 流程),
|
||||
// 切换到框选面板;否则使用标准上传。
|
||||
async function onWorkflowChange() {
|
||||
const select = document.getElementById("workflowSelect");
|
||||
const ocrCard = document.getElementById("ocrCard");
|
||||
const standardCard = document.getElementById("standardCard");
|
||||
if (!select || !ocrCard || !standardCard) return;
|
||||
cropNodeId = null;
|
||||
selectedCrop = null;
|
||||
const app = workflowApps.find((item) => item.id === select.value);
|
||||
// 数据驱动判断:节点 params 中声明了 crop 即需要框选。
|
||||
const cropNode = (app?.definition?.nodes || []).find(
|
||||
(node) => node.params && node.params.crop !== undefined,
|
||||
);
|
||||
const needsCrop = Boolean(cropNode);
|
||||
cropNodeId = needsCrop ? cropNode.id : null;
|
||||
ocrCard.hidden = !needsCrop;
|
||||
standardCard.hidden = needsCrop;
|
||||
resetOcrPanel();
|
||||
if (needsCrop) {
|
||||
document.getElementById("ocrProgress").textContent = "请选择视频并框选字幕区域";
|
||||
}
|
||||
}
|
||||
|
||||
// 重置 OCR 面板:清除视频、画布与框选状态。
|
||||
function resetOcrPanel() {
|
||||
const video = document.getElementById("ocrVideo");
|
||||
const canvas = document.getElementById("ocrCanvas");
|
||||
const fileInput = document.getElementById("ocrVideoFile");
|
||||
const cropValue = document.getElementById("cropValue");
|
||||
const submit = document.getElementById("ocrSubmit");
|
||||
selectedCrop = null;
|
||||
if (video) video.removeAttribute("src");
|
||||
if (canvas) {
|
||||
canvas.width = 0;
|
||||
canvas.height = 0;
|
||||
}
|
||||
if (fileInput) fileInput.value = "";
|
||||
if (cropValue) cropValue.value = "";
|
||||
if (submit) submit.disabled = true;
|
||||
selectedVideoFile = null;
|
||||
exitDrawMode();
|
||||
}
|
||||
|
||||
// 用 crop.js 把画布上的框选矩形归一化为 crop 比例并展示。
|
||||
function applyCropRect(rect) {
|
||||
const video = document.getElementById("ocrVideo");
|
||||
const box = document.getElementById("videoBox");
|
||||
if (!video.videoWidth || !box) return;
|
||||
const crop = rectToCrop(
|
||||
rect,
|
||||
video.videoWidth,
|
||||
video.videoHeight,
|
||||
box.clientWidth,
|
||||
box.clientHeight,
|
||||
);
|
||||
selectedCrop = crop;
|
||||
document.getElementById("cropValue").value = crop.join(", ");
|
||||
document.getElementById("ocrSubmit").disabled = false;
|
||||
document.getElementById("ocrProgress").textContent =
|
||||
`已框选 crop=[${crop.join(", ")}],可提交任务`;
|
||||
}
|
||||
|
||||
// 进入框选模式:暂停视频、隐藏原生控件、启用画布绘制。
|
||||
function enterDrawMode() {
|
||||
const video = document.getElementById("ocrVideo");
|
||||
const canvas = document.getElementById("ocrCanvas");
|
||||
const toggle = document.getElementById("ocrDrawMode");
|
||||
if (!video.videoWidth) {
|
||||
document.getElementById("ocrProgress").textContent = "请先选择视频并定位到有字幕的画面";
|
||||
return;
|
||||
}
|
||||
drawMode = true;
|
||||
video.pause();
|
||||
video.removeAttribute("controls"); // 隐藏进度条,避免遮挡框选操作。
|
||||
canvas.classList.add("drawable");
|
||||
if (toggle) toggle.textContent = "退出框选模式";
|
||||
document.getElementById("ocrProgress").textContent = "请拖动框选字幕区域";
|
||||
}
|
||||
|
||||
// 退出框选模式:恢复原生控件,画布不再拦截指针。
|
||||
function exitDrawMode() {
|
||||
const video = document.getElementById("ocrVideo");
|
||||
const canvas = document.getElementById("ocrCanvas");
|
||||
const toggle = document.getElementById("ocrDrawMode");
|
||||
drawMode = false;
|
||||
if (video) video.setAttribute("controls", "");
|
||||
if (canvas) canvas.classList.remove("drawable");
|
||||
if (toggle) toggle.textContent = "进入框选模式";
|
||||
}
|
||||
|
||||
// 初始化 OCR 面板:视频预览 + 画布拖动框选。
|
||||
function setupOcrPanel() {
|
||||
const fileInput = document.getElementById("ocrVideoFile");
|
||||
const video = document.getElementById("ocrVideo");
|
||||
const canvas = document.getElementById("ocrCanvas");
|
||||
const resetButton = document.getElementById("ocrReset");
|
||||
if (!fileInput || !video || !canvas) return;
|
||||
|
||||
// 选择视频后显示预览并设置画布尺寸与坐标换算。
|
||||
// 注意顺序:必须先 reset(会清空旧 src),再设置新 src,否则被清掉。
|
||||
fileInput.addEventListener("change", () => {
|
||||
const file = fileInput.files[0];
|
||||
if (!file) return;
|
||||
resetOcrPanel();
|
||||
// 重置会清空 input,这里把文件引用保存下来供提交使用。
|
||||
selectedVideoFile = file;
|
||||
video.src = URL.createObjectURL(file);
|
||||
video.load();
|
||||
video.onloadedmetadata = () => {
|
||||
const box = document.getElementById("videoBox");
|
||||
canvas.width = box.clientWidth;
|
||||
canvas.height = box.clientHeight;
|
||||
document.getElementById("ocrProgress").textContent = "请在预览中拖动框选字幕区域";
|
||||
};
|
||||
});
|
||||
|
||||
// 框选模式开关:进入/退出。
|
||||
const drawToggle = document.getElementById("ocrDrawMode");
|
||||
if (drawToggle) {
|
||||
drawToggle.addEventListener("click", () => {
|
||||
if (drawMode) {
|
||||
exitDrawMode();
|
||||
} else {
|
||||
enterDrawMode();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// 拖动绘制框选矩形:mousedown 起点 → mousemove 更新 → mouseup 生成 crop。
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
let drawing = false;
|
||||
canvas.addEventListener("mousedown", (event) => {
|
||||
if (!drawMode) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
startX = event.clientX - rect.left;
|
||||
startY = event.clientY - rect.top;
|
||||
drawing = true;
|
||||
});
|
||||
canvas.addEventListener("mousemove", (event) => {
|
||||
if (!drawing || !drawMode) return;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
ctx.strokeStyle = "#ff5252";
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(Math.min(startX, x), Math.min(startY, y), Math.abs(x - startX), Math.abs(y - startY));
|
||||
});
|
||||
canvas.addEventListener("mouseup", (event) => {
|
||||
if (!drawing || !drawMode) return;
|
||||
drawing = false;
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const x = event.clientX - rect.left;
|
||||
const y = event.clientY - rect.top;
|
||||
const w = Math.abs(x - startX);
|
||||
const h = Math.abs(y - startY);
|
||||
if (w < 5 || h < 5) return;
|
||||
applyCropRect({
|
||||
x: Math.min(startX, x),
|
||||
y: Math.min(startY, y),
|
||||
w,
|
||||
h,
|
||||
});
|
||||
});
|
||||
|
||||
// 清除框选:清空画布与 crop,禁用提交。
|
||||
resetButton.addEventListener("click", () => {
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
selectedCrop = null;
|
||||
document.getElementById("cropValue").value = "";
|
||||
document.getElementById("ocrSubmit").disabled = true;
|
||||
document.getElementById("ocrProgress").textContent = "请在预览中拖动框选字幕区域";
|
||||
});
|
||||
}
|
||||
|
||||
// 提交字幕 OCR 任务:携带 crop 覆盖参数,框选完成才可用。
|
||||
async function submitOcr() {
|
||||
const fileInput = document.getElementById("ocrVideoFile");
|
||||
const progress = document.getElementById("ocrProgress");
|
||||
if (!selectedCrop) {
|
||||
progress.textContent = "请先框选字幕区域";
|
||||
return;
|
||||
}
|
||||
const file = selectedVideoFile;
|
||||
if (!file) {
|
||||
progress.textContent = "请先选择视频文件";
|
||||
return;
|
||||
}
|
||||
const workflowId = document.getElementById("workflowSelect").value;
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
// 把框选的 crop 传给需要它的节点(如 frame-extract)。
|
||||
form.append("params", JSON.stringify({ [cropNodeId]: { crop: selectedCrop } }));
|
||||
progress.textContent = "上传中...";
|
||||
try {
|
||||
const response = await fetch(`/api/apps/${encodeURIComponent(workflowId)}/runs`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText);
|
||||
}
|
||||
progress.textContent = `任务 ${data.id} 已创建,正在跳转到任务管理...`;
|
||||
window.location.href = `/tasks.html?run=${encodeURIComponent(data.id)}`;
|
||||
} catch (error) {
|
||||
progress.textContent = `创建失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染单个任务的进度条 HTML;失败任务用红色填充。
|
||||
function progressBar(percent, failed) {
|
||||
return `<div class="progress"><div class="progress-bar ${failed ? "error" : ""}" style="width:${Math.min(100, percent)}%"></div></div>`;
|
||||
}
|
||||
|
||||
// 为已完成任务生成最终产物下载链接(cn_srt / ass)。
|
||||
function artifactLinks(runId, status) {
|
||||
if (status !== "COMPLETED") return "-";
|
||||
return `
|
||||
<div class="downloads-inline">
|
||||
<a class="download-link" href="/api/runs/${encodeURIComponent(runId)}/artifacts/cn_srt">中文 SRT</a>
|
||||
<a class="download-link" href="/api/runs/${encodeURIComponent(runId)}/artifacts/ass">VR ASS</a>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 加载最近任务并渲染任务表格:状态、当前节点、进度条、产物与重试。
|
||||
async function loadRuns() {
|
||||
const tbody = document.getElementById("runList");
|
||||
if (!tbody) return;
|
||||
const runs = await api("/api/runs");
|
||||
tbody.innerHTML = runs.length
|
||||
? runs
|
||||
.map((run) => {
|
||||
const percent = Math.round((run.progress || 0) * 100);
|
||||
const failed = run.status === "FAILED";
|
||||
// RUNNING/QUEUED 附加耗时或排队提示,其余状态只显示徽章。
|
||||
const statusHtml =
|
||||
run.status === "RUNNING"
|
||||
? `${badge(run.status)} <span class="muted">已运行 ${formatElapsed(run.updated_at)}</span>`
|
||||
: run.status === "QUEUED"
|
||||
? `${badge(run.status)} <span class="muted">排队中</span>`
|
||||
: run.status === "PAUSED"
|
||||
? `${badge(run.status)} <span class="muted">已暂停 ${formatElapsed(run.updated_at)}</span>`
|
||||
: badge(run.status);
|
||||
// 失败任务提供重试,所有任务均可删除。
|
||||
// 排队/运行中可暂停,暂停后可继续,失败可重试,所有任务可删除。
|
||||
const canPause = run.status === "RUNNING" || run.status === "QUEUED";
|
||||
const canResume = run.status === "PAUSED";
|
||||
const actions = `
|
||||
${canPause ? `<button class="warn" data-pause-run="${escapeHtml(run.id)}">暂停</button>` : ""}
|
||||
${canResume ? `<button class="warn" data-resume-run="${escapeHtml(run.id)}">继续</button>` : ""}
|
||||
${failed ? `<button class="danger" data-retry-run="${escapeHtml(run.id)}">重试</button>` : ""}
|
||||
<button class="danger" data-delete-run="${escapeHtml(run.id)}">删除</button>
|
||||
`;
|
||||
return `
|
||||
<tr>
|
||||
<td title="${escapeHtml(run.error || "")}">${escapeHtml(run.id)}</td>
|
||||
<td>${escapeHtml(run.workflow_id)}</td>
|
||||
<td>${statusHtml}</td>
|
||||
<td>${escapeHtml(run.current_node_id || "-")}</td>
|
||||
<td>${progressBar(percent, failed)} ${percent}%</td>
|
||||
<td>${escapeHtml(formatTime(run.created_at))}</td>
|
||||
<td>${artifactLinks(run.id, run.status)}</td>
|
||||
<td>${actions}</td>
|
||||
</tr>`;
|
||||
})
|
||||
.join("")
|
||||
: '<tr><td colspan="8">暂无任务,请到首页发起。</td></tr>';
|
||||
}
|
||||
|
||||
// 请求后端重试失败任务,成功后刷新列表。
|
||||
async function retryRun(runId) {
|
||||
try {
|
||||
const result = await api(`/api/runs/${runId}/retry`, { method: "POST" });
|
||||
alert(`任务 ${result.id} 已重新排队`);
|
||||
await loadRuns();
|
||||
} catch (error) {
|
||||
alert(`重试失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 暂停任务:排队或运行中的任务置为 PAUSED,运行中的任务在节点边界停下。
|
||||
async function pauseRun(runId) {
|
||||
try {
|
||||
const result = await api(`/api/runs/${runId}/pause`, { method: "POST" });
|
||||
alert(`任务 ${result.id} 已暂停`);
|
||||
await loadRuns();
|
||||
} catch (error) {
|
||||
alert(`暂停失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 继续任务:PAUSED 恢复排队,由调度器从断点继续执行。
|
||||
async function resumeRun(runId) {
|
||||
try {
|
||||
const result = await api(`/api/runs/${runId}/resume`, { method: "POST" });
|
||||
alert(`任务 ${result.id} 已恢复执行`);
|
||||
await loadRuns();
|
||||
} catch (error) {
|
||||
alert(`继续失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
// 删除任务:二次确认后调用后端删除接口并刷新列表。
|
||||
async function deleteRun(runId) {
|
||||
if (!window.confirm(`确认删除任务 ${runId}?相关产物文件将一并删除。`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api(`/api/runs/${runId}`, { method: "DELETE" });
|
||||
await loadRuns();
|
||||
} catch (error) {
|
||||
alert(`删除失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 创建或更新工作流:解析 DAG JSON 后提交,随后刷新列表。
|
||||
async function createWorkflow() {
|
||||
const workflowId = document.getElementById("workflowId").value.trim();
|
||||
const name = document.getElementById("workflowName").value.trim();
|
||||
const description = document.getElementById("workflowDescription").value.trim();
|
||||
let definition;
|
||||
try {
|
||||
definition = JSON.parse(document.getElementById("workflowDefinition").value);
|
||||
} catch (error) {
|
||||
alert(`DAG JSON 无效:${error.message}`);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api("/api/admin/workflows", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
id: workflowId || undefined,
|
||||
name,
|
||||
description,
|
||||
definition,
|
||||
}),
|
||||
});
|
||||
alert("工作流已保存");
|
||||
await loadWorkflows();
|
||||
} catch (error) {
|
||||
alert(`保存失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 发布指定工作流,使其出现在用户应用中心。
|
||||
async function publishWorkflow(workflowId) {
|
||||
try {
|
||||
await api(`/api/admin/workflows/${workflowId}/publish`, { method: "POST" });
|
||||
await loadWorkflows();
|
||||
} catch (error) {
|
||||
alert(`发布失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 删除指定工作流。
|
||||
async function deleteWorkflow(workflowId) {
|
||||
try {
|
||||
await api(`/api/admin/workflows/${workflowId}`, { method: "DELETE" });
|
||||
await loadWorkflows();
|
||||
} catch (error) {
|
||||
alert(`删除失败:${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 首页发起任务:选择工作流并上传视频,创建任务后跳转到任务管理页查看进度。
|
||||
async function uploadVideo() {
|
||||
const workflowId = document.getElementById("workflowSelect").value;
|
||||
const fileInput = document.getElementById("videoFile");
|
||||
const progress = document.getElementById("runProgress");
|
||||
if (!workflowId) {
|
||||
progress.textContent = "暂无可用工作流";
|
||||
return;
|
||||
}
|
||||
if (!fileInput.files.length) {
|
||||
progress.textContent = "请先选择视频文件";
|
||||
return;
|
||||
}
|
||||
const form = new FormData();
|
||||
form.append("file", fileInput.files[0]);
|
||||
progress.textContent = "上传中...";
|
||||
try {
|
||||
const response = await fetch(`/api/apps/${encodeURIComponent(workflowId)}/runs`, {
|
||||
method: "POST",
|
||||
body: form,
|
||||
});
|
||||
const data = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
throw new Error(data && data.detail ? JSON.stringify(data.detail) : response.statusText);
|
||||
}
|
||||
progress.textContent = `任务 ${data.id} 已创建,正在跳转到任务管理...`;
|
||||
// 首页只负责发起任务,进度展示交给任务管理页。
|
||||
window.location.href = `/tasks.html?run=${encodeURIComponent(data.id)}`;
|
||||
} catch (error) {
|
||||
progress.textContent = `创建失败:${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 全局点击委托:按按钮上的 data-* 属性分发到对应操作。
|
||||
document.addEventListener("click", (event) => {
|
||||
const deleteWorkflowButton = event.target.closest("[data-delete-workflow]");
|
||||
if (deleteWorkflowButton) {
|
||||
deleteWorkflow(deleteWorkflowButton.dataset.deleteWorkflow);
|
||||
return;
|
||||
}
|
||||
const publishWorkflowButton = event.target.closest("[data-publish-workflow]");
|
||||
if (publishWorkflowButton) {
|
||||
publishWorkflow(publishWorkflowButton.dataset.publishWorkflow);
|
||||
return;
|
||||
}
|
||||
const retryButton = event.target.closest("[data-retry-run]");
|
||||
if (retryButton) {
|
||||
retryRun(retryButton.dataset.retryRun);
|
||||
return;
|
||||
}
|
||||
const pauseButton = event.target.closest("[data-pause-run]");
|
||||
if (pauseButton) {
|
||||
pauseRun(pauseButton.dataset.pauseRun);
|
||||
return;
|
||||
}
|
||||
const resumeButton = event.target.closest("[data-resume-run]");
|
||||
if (resumeButton) {
|
||||
resumeRun(resumeButton.dataset.resumeRun);
|
||||
return;
|
||||
}
|
||||
const deleteRunButton = event.target.closest("[data-delete-run]");
|
||||
if (deleteRunButton) {
|
||||
deleteRun(deleteRunButton.dataset.deleteRun);
|
||||
}
|
||||
});
|
||||
|
||||
// 页面初始化:预填 DAG、绑定按钮事件并加载对应页面数据。
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
const workflowDefinition = document.getElementById("workflowDefinition");
|
||||
if (workflowDefinition) {
|
||||
workflowDefinition.value = JSON.stringify(DEMO_WORKFLOW, null, 2);
|
||||
}
|
||||
const createWorkflowButton = document.getElementById("createWorkflow");
|
||||
if (createWorkflowButton) {
|
||||
createWorkflowButton.addEventListener("click", createWorkflow);
|
||||
}
|
||||
const publishWorkflowButton = document.getElementById("publishWorkflow");
|
||||
if (publishWorkflowButton) {
|
||||
publishWorkflowButton.addEventListener("click", () => {
|
||||
publishWorkflow(document.getElementById("workflowId").value.trim());
|
||||
});
|
||||
}
|
||||
const uploadButton = document.getElementById("uploadVideo");
|
||||
if (uploadButton) {
|
||||
uploadButton.addEventListener("click", uploadVideo);
|
||||
// 首页加载已发布工作流供用户选择。
|
||||
await loadWorkflowOptions();
|
||||
// 字幕 OCR 面板:视频预览 + 拖动框选(仅需 crop 的工作流展示)。
|
||||
setupOcrPanel();
|
||||
}
|
||||
const ocrSubmitButton = document.getElementById("ocrSubmit");
|
||||
if (ocrSubmitButton) {
|
||||
ocrSubmitButton.addEventListener("click", submitOcr);
|
||||
}
|
||||
|
||||
await loadHealth();
|
||||
if (document.getElementById("workflowList")) {
|
||||
await loadWorkflows();
|
||||
}
|
||||
// 任务管理页:每 3 秒刷新一次全部任务的进度,支持高亮跳转参数 run=ID。
|
||||
if (document.getElementById("runList")) {
|
||||
await loadRuns();
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const target = params.get("run");
|
||||
if (target) {
|
||||
const row = [...document.querySelectorAll("#runList tr")].find((item) =>
|
||||
item.textContent.includes(target),
|
||||
);
|
||||
if (row) {
|
||||
row.scrollIntoView({ block: "center" });
|
||||
row.style.background = "#fff7db";
|
||||
}
|
||||
}
|
||||
setInterval(loadRuns, 3000);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
// WOV OCR 前端 crop 归一化工具(纯函数,供 node 单测与浏览器共用)。
|
||||
// 负责把用户在视频预览上框选的矩形(显示坐标)与 crop 比例 [x,y,w,h](0~1)
|
||||
// 互转,映射基于视频固有分辨率并处理 object-fit: contain 的留边(letterbox)。
|
||||
|
||||
// 计算 <video> 在指定容器内 contain 显示后的实际渲染矩形(容器坐标)。
|
||||
function videoDisplayRect(videoWidth, videoHeight, boxWidth, boxHeight) {
|
||||
const scale = Math.min(boxWidth / videoWidth, boxHeight / videoHeight);
|
||||
const w = videoWidth * scale;
|
||||
const h = videoHeight * scale;
|
||||
return { x: (boxWidth - w) / 2, y: (boxHeight - h) / 2, w, h };
|
||||
}
|
||||
|
||||
// 框选矩形(容器坐标)→ crop 比例 [x, y, w, h],钳制到 0~1,保留 3 位小数。
|
||||
function rectToCrop(rect, videoWidth, videoHeight, boxWidth, boxHeight) {
|
||||
const display = videoDisplayRect(videoWidth, videoHeight, boxWidth, boxHeight);
|
||||
const clamp = (v) => Math.min(1, Math.max(0, Math.round(v * 1000) / 1000));
|
||||
return [
|
||||
clamp((rect.x - display.x) / display.w),
|
||||
clamp((rect.y - display.y) / display.h),
|
||||
clamp(rect.w / display.w),
|
||||
clamp(rect.h / display.h),
|
||||
];
|
||||
}
|
||||
|
||||
// crop 比例 → 框选矩形(容器坐标),用于回显。
|
||||
function cropToRect(crop, videoWidth, videoHeight, boxWidth, boxHeight) {
|
||||
const display = videoDisplayRect(videoWidth, videoHeight, boxWidth, boxHeight);
|
||||
return {
|
||||
x: display.x + crop[0] * display.w,
|
||||
y: display.y + crop[1] * display.h,
|
||||
w: crop[2] * display.w,
|
||||
h: crop[3] * display.h,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof module !== "undefined" && module.exports) {
|
||||
module.exports = { videoDisplayRect, rectToCrop, cropToRect };
|
||||
}
|
||||
Executable
+348
@@ -0,0 +1,348 @@
|
||||
/* VRSub 静态前端全局样式:定义色彩变量、布局与通用组件样式。 */
|
||||
|
||||
/* 设计令牌:集中管理配色,后续换肤只需修改变量。 */
|
||||
:root {
|
||||
--bg: #f5f7fa;
|
||||
--surface: #ffffff;
|
||||
--border: #d7dde6;
|
||||
--text: #1c2733;
|
||||
--muted: #66748a;
|
||||
--primary: #1769aa;
|
||||
--danger: #b42318;
|
||||
--ok: #177245;
|
||||
--radius: 8px;
|
||||
}
|
||||
|
||||
/* 全局盒模型与基础排版重置。 */
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 页面主体:浅灰背景与默认文字颜色。 */
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: "Segoe UI", "Microsoft YaHei", sans-serif;
|
||||
color: var(--text);
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
/* 顶部导航栏:品牌标题与页面导航。 */
|
||||
.topbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 28px;
|
||||
background: var(--surface);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* 品牌标题样式。 */
|
||||
.brand {
|
||||
font-size: 20px;
|
||||
font-weight: 700;
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 导航链接横向排列。 */
|
||||
nav {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
/* 导航链接默认使用弱化色,悬停时高亮。 */
|
||||
nav a {
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
nav a:hover {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 内容容器:限制最大宽度并居中。 */
|
||||
.container {
|
||||
max-width: 1080px;
|
||||
margin: 0 auto;
|
||||
padding: 28px 20px 60px;
|
||||
}
|
||||
|
||||
/* 一级标题与二级标题的字号控制。 */
|
||||
h1 {
|
||||
margin: 0 0 8px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
/* 弱化文字:用于说明、时间等次要信息。 */
|
||||
.muted {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 应用中心卡片网格:自适应列数的最小宽度布局。 */
|
||||
.app-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 18px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
|
||||
/* 卡片与面板共用白底、边框和圆角外观。 */
|
||||
.card,
|
||||
.panel {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* 面板之间保持纵向间距。 */
|
||||
.panel {
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
/* 禁用态样式:降低透明度表达不可用。 */
|
||||
.disabled {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 表单标签:块级显示并加粗。 */
|
||||
label {
|
||||
display: block;
|
||||
margin: 10px 0 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
/* 输入控件统一样式,宽度撑满容器。 */
|
||||
input,
|
||||
select,
|
||||
textarea {
|
||||
width: 100%;
|
||||
padding: 9px 11px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
font: inherit;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
/* 文本域使用等宽字体并允许纵向拉伸。 */
|
||||
textarea {
|
||||
font-family: Consolas, monospace;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
/* 按钮基础样式。 */
|
||||
button {
|
||||
margin-top: 12px;
|
||||
padding: 9px 16px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* 按钮悬停强调边框与文字颜色。 */
|
||||
button:hover {
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 主按钮:填充品牌色。 */
|
||||
button.primary {
|
||||
background: var(--primary);
|
||||
border-color: var(--primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
/* 幽灵按钮:透明背景,用于次要操作。 */
|
||||
button.ghost {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 操作区与表单网格:弹性换行排列。 */
|
||||
.actions,
|
||||
.form-grid {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
/* 表单网格:默认两列,宽字段占整行。 */
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
/* 宽字段跨满整行。 */
|
||||
.form-grid .wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
/* 表格容器:窄屏时允许横向滚动。 */
|
||||
.table-wrap {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* 表格基础样式。 */
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* 表头与单元格的间距、对齐与分隔线。 */
|
||||
th,
|
||||
td {
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 表头使用弱化色与小字号。 */
|
||||
th {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* 结果块:深色等宽字体,适合展示 JSON 或日志。 */
|
||||
.result {
|
||||
margin: 16px 0 0;
|
||||
padding: 14px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: #0f1720;
|
||||
color: #d7e5f3;
|
||||
font-family: Consolas, monospace;
|
||||
font-size: 13px;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* 下载区:横向排列的下载链接。 */
|
||||
.downloads {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
/* 下载链接:描边按钮式链接。 */
|
||||
.download-link {
|
||||
display: inline-block;
|
||||
padding: 8px 14px;
|
||||
border: 1px solid var(--primary);
|
||||
border-radius: 6px;
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 下载链接悬停时轻微填充背景。 */
|
||||
.download-link:hover {
|
||||
background: #e8f1f9;
|
||||
}
|
||||
|
||||
/* 状态徽章基础样式。 */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 3px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* 成功徽章:绿色背景。 */
|
||||
.badge.ok {
|
||||
background: #e2f4ea;
|
||||
color: var(--ok);
|
||||
}
|
||||
|
||||
/* 警告徽章:黄色背景。 */
|
||||
.badge.warn {
|
||||
background: #fff2d9;
|
||||
color: #8a5a00;
|
||||
}
|
||||
|
||||
/* 错误徽章:红色背景。 */
|
||||
.badge.error {
|
||||
background: #fde8e6;
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* 危险操作按钮文字颜色。 */
|
||||
.danger {
|
||||
color: var(--danger);
|
||||
}
|
||||
|
||||
/* 任务进度条容器:圆角底槽。 */
|
||||
.progress {
|
||||
width: 120px;
|
||||
height: 8px;
|
||||
border-radius: 999px;
|
||||
background: #e5e7eb;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 任务进度条填充:按百分比宽度显示进度。 */
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: var(--primary, #1a73e8);
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
/* 失败任务的进度条使用红色填充。 */
|
||||
.progress-bar.error {
|
||||
background: var(--danger, #d93025);
|
||||
}
|
||||
|
||||
/* 任务页产物下载链接组。 */
|
||||
.downloads-inline {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* 字幕 OCR 面板:视频预览容器,画布绝对覆盖用于框选。 */
|
||||
.video-box {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
background: #000;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.video-box video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.video-box canvas {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
/* 默认不拦截指针事件,保证原生视频控件(进度条等)可操作。 */
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 框选模式下画布才接收鼠标事件。 */
|
||||
.video-box canvas.drawable {
|
||||
pointer-events: auto;
|
||||
cursor: crosshair;
|
||||
}
|
||||
Reference in New Issue
Block a user