Files

314 lines
14 KiB
JavaScript

/**
* 建设页片段 OCR 侧栏:点击 OCR 结果填入表单(焦点跟随模式)。
* 数据通路:服务端只下发片段元数据(data-pivots:分P范围/起止秒/CDN URL),
* 帧级重数据由前端直接 fetch CDN 全量 JSON(依赖桶 CORS 放行本站;导入时已同步导出)。
* 连续文本相同的帧前端去重;in_range 依据 pivot 起止秒在前端计算。
* 模式:
* - text(节目编辑页):点击文本行填入最近聚焦的目标字段,支持 覆盖/追加。
* - time(关联视频编辑页):点击帧时间填入所在组(开始/结束)的 P数+时间点。
* 面板 data 属性:data-pivots(JSON)/ data-pivot(固定 pivot id,可空)/ data-mode / data-target-labels(JSON)。
*/
(function () {
// 展示用:小时不补零(与 ocr_canvas.js 一致)
function formatHms(seconds) {
seconds = Math.max(0, Math.floor(seconds));
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
const s = seconds % 60;
const pad = function (n) { return String(n).padStart(2, "0"); };
return h + ":" + pad(m) + ":" + pad(s);
}
// 填入 <input type="time"> 用:必须 HH:MM:SS 全补零,否则赋值被清空
function formatInputHms(seconds) {
seconds = Math.max(0, Math.floor(seconds));
const pad = function (n) { return String(n).padStart(2, "0"); };
return pad(Math.floor(seconds / 3600)) + ":" + pad(Math.floor((seconds % 3600) / 60)) + ":" + pad(seconds % 60);
}
function el(tag, className, text) {
const node = document.createElement(tag);
if (className) { node.className = className; }
if (text !== undefined) { node.textContent = text; }
return node;
}
function flash(input) {
input.style.transition = "background-color 0.4s";
input.style.backgroundColor = "#fef08a";
setTimeout(function () { input.style.backgroundColor = ""; }, 400);
}
// 多分P CDN 全量 JSON → 面板记录:合并、按 (part_num, ts) 排序、连续文本去重、标 in_range
function buildRecords(pivot, payloads) {
const recs = [];
payloads.forEach(function (payload) {
(payload.recs || []).forEach(function (rec) {
const texts = (rec[1] || [])
.map(function (line) { return (line[4] || "").trim(); })
.filter(function (text) { return text !== ""; });
recs.push({ part_num: payload.part, ts: rec[0], texts: texts });
});
});
recs.sort(function (a, b) { return a.part_num - b.part_num || a.ts - b.ts; });
const result = [];
let lastText = null;
recs.forEach(function (rec) {
const joined = rec.texts.join("\n");
if (joined === lastText) { return; }
lastText = joined;
let inRange = true;
if (pivot.start_sec !== null && rec.part_num === pivot.start_part && rec.ts < pivot.start_sec) {
inRange = false;
}
if (pivot.stop_sec !== null && rec.part_num === pivot.stop_part && rec.ts > pivot.stop_sec) {
inRange = false;
}
result.push({
part_num: rec.part_num,
ts: rec.ts,
time: formatHms(rec.ts),
in_range: inRange,
texts: rec.texts,
});
});
return result;
}
function init(panel) {
const pivots = JSON.parse(panel.dataset.pivots || "[]");
const fixedPivot = panel.dataset.pivot || "";
const mode = panel.dataset.mode || "text";
const labels = JSON.parse(panel.dataset.targetLabels || "{}");
const body = panel.querySelector("[data-ocr-fill-body]");
const targetInputs = Array.prototype.slice.call(document.querySelectorAll("[data-ocr-target]"));
const targetValues = [];
targetInputs.forEach(function (input) {
if (targetValues.indexOf(input.dataset.ocrTarget) === -1) {
targetValues.push(input.dataset.ocrTarget);
}
});
let pivot = null; // 当前片段元数据
let records = null; // 当前片段记录(buildRecords 结果)
let loadSeq = 0;
let currentTarget = targetValues[0] || null;
let appendMode = false;
let showOut = false;
let outFrames = [];
body.innerHTML = "";
body.className = "text-sm";
const status = el("div", "text-xs text-gray-400 dark:text-gray-500 mb-2");
const pivotRow = el("div", "mb-2");
const toolbar = el("div", "flex flex-wrap items-center gap-x-3 gap-y-2 mb-3 text-xs");
const list = el("div", "space-y-3 max-h-[70vh] overflow-y-auto pr-1");
body.appendChild(status);
body.appendChild(pivotRow);
body.appendChild(toolbar);
body.appendChild(list);
function showStatus(text) {
status.textContent = text;
status.style.display = text ? "" : "none";
}
function targetLabel(value) {
return labels[value] || value || "(无目标)";
}
// ---- 目标字段(焦点跟随 + 工具条 chip 切换)----
const chip = el("button", "px-2 py-1 rounded border border-indigo-400 text-indigo-600 dark:text-indigo-300 hover:bg-indigo-50 dark:hover:bg-indigo-900/40 transition-colors");
chip.type = "button";
chip.addEventListener("click", function () {
if (targetValues.length === 0) { return; }
const idx = targetValues.indexOf(currentTarget);
setTarget(targetValues[(idx + 1) % targetValues.length], true);
});
toolbar.appendChild(el("span", "text-gray-500 dark:text-gray-400", "填入到:"));
toolbar.appendChild(chip);
function setTarget(value, focusInput) {
currentTarget = value;
chip.textContent = targetLabel(value);
targetInputs.forEach(function (input) {
const active = input.dataset.ocrTarget === value;
input.style.outline = active ? "2px solid #6366f1" : "";
input.style.outlineOffset = active ? "1px" : "";
if (active && focusInput) { input.focus(); }
});
}
targetInputs.forEach(function (input) {
input.addEventListener("focusin", function () { setTarget(input.dataset.ocrTarget, false); });
});
// ---- 覆盖/追加(text 模式)----
if (mode === "text") {
const appendLabel = el("label", "inline-flex items-center gap-1 text-gray-600 dark:text-gray-300 cursor-pointer");
const appendBox = el("input", "rounded border-gray-300 dark:border-gray-600 text-indigo-600");
appendBox.type = "checkbox";
appendBox.addEventListener("change", function () { appendMode = appendBox.checked; });
appendLabel.appendChild(appendBox);
appendLabel.appendChild(document.createTextNode("追加"));
toolbar.appendChild(appendLabel);
}
// ---- 含片段外 ----
const outLabel = el("label", "inline-flex items-center gap-1 text-gray-600 dark:text-gray-300 cursor-pointer");
const outBox = el("input", "rounded border-gray-300 dark:border-gray-600 text-indigo-600");
outBox.type = "checkbox";
outBox.addEventListener("change", function () {
showOut = outBox.checked;
outFrames.forEach(function (frame) { frame.style.display = showOut ? "" : "none"; });
});
outLabel.appendChild(outBox);
outLabel.appendChild(document.createTextNode("含片段外"));
toolbar.appendChild(outLabel);
// ---- 填入动作 ----
function fillText(text) {
if (!currentTarget) { return; }
const input = targetInputs.find(function (i) { return i.dataset.ocrTarget === currentTarget; });
if (!input) { return; }
if (appendMode && input.value.trim() !== "") {
input.value = input.value.replace(/\s+$/, "") + " " + text;
} else {
input.value = text;
}
flash(input);
}
function fillTime(rec) {
const group = currentTarget || "start";
targetInputs.forEach(function (input) {
if (input.dataset.ocrTarget !== group) { return; }
if (input.dataset.ocrKind === "part") {
input.value = String(rec.part_num);
flash(input);
} else if (input.dataset.ocrKind === "time") {
input.value = formatInputHms(rec.ts);
flash(input);
}
});
}
// ---- 记录渲染 ----
function renderRecords() {
list.innerHTML = "";
outFrames = [];
if (!records || !records.length) {
showStatus("该片段暂无 OCR 数据");
return;
}
showStatus("");
records.forEach(function (rec) {
const frame = el("div", rec.in_range ? "" : "opacity-50");
if (!rec.in_range) {
outFrames.push(frame);
if (!showOut) { frame.style.display = "none"; }
}
const head = el("div", "flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400");
const clickZone = el("span", mode === "time" ? "inline-flex items-center gap-2 cursor-pointer hover:text-indigo-600 dark:hover:text-indigo-300" : "inline-flex items-center gap-2");
clickZone.appendChild(el("span", "font-mono font-semibold", rec.time));
clickZone.appendChild(el("span", "", "P" + rec.part_num));
if (mode === "time") {
clickZone.title = "点击填入" + targetLabel(currentTarget || "start");
clickZone.addEventListener("click", function () { fillTime(rec); });
}
head.appendChild(clickZone);
const link = el("a", "text-gray-400 hover:text-indigo-500", "↗");
link.href = "https://www.bilibili.com/video/" + pivot.bvid + "?p=" + rec.part_num + "&t=" + Math.floor(rec.ts);
link.target = "_blank";
link.title = "打开视频核对";
head.appendChild(link);
frame.appendChild(head);
const lines = el("div", "mt-0.5");
rec.texts.forEach(function (text) {
const line = el("div", "", text);
if (mode === "text") {
line.className = "px-1 -mx-1 rounded cursor-pointer hover:bg-indigo-50 dark:hover:bg-indigo-900/40 text-gray-800 dark:text-gray-200";
line.addEventListener("click", function () {
fillText(text);
line.style.transition = "background-color 0.4s";
line.style.backgroundColor = "#e0e7ff";
setTimeout(function () { line.style.backgroundColor = ""; }, 400);
});
} else {
line.className = "text-gray-600 dark:text-gray-400";
}
lines.appendChild(line);
});
frame.appendChild(lines);
list.appendChild(frame);
});
}
// ---- 片段选择 ----
function defaultPivot() {
if (fixedPivot) {
const found = pivots.find(function (p) { return String(p.id) === fixedPivot; });
if (found) { return found; }
}
const withOcr = pivots.find(function (p) { return Object.keys(p.urls || {}).length > 0; });
return withOcr || pivots[0] || null;
}
function renderPivotSelect() {
pivotRow.innerHTML = "";
if (fixedPivot || pivots.length <= 1) { return; }
const select = el("select", "w-full text-sm rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700");
pivots.forEach(function (p) {
const option = el("option", "", p.label + (Object.keys(p.urls || {}).length ? "" : "(无OCR)"));
option.value = String(p.id);
select.appendChild(option);
});
select.value = String(pivot.id);
select.addEventListener("change", function () {
const found = pivots.find(function (p) { return String(p.id) === select.value; });
if (found) { load(found); }
});
pivotRow.appendChild(select);
}
function load(nextPivot) {
const seq = ++loadSeq;
pivot = nextPivot;
records = null;
list.innerHTML = "";
renderPivotSelect();
const urls = Object.keys(pivot.urls || {}).map(function (partNum) { return pivot.urls[partNum]; });
if (!urls.length) {
showStatus("该片段暂无 OCR 数据");
return;
}
showStatus("加载中…");
Promise.all(urls.map(function (url) {
return fetch(url).then(function (response) {
if (!response.ok) { throw new Error("HTTP " + response.status); }
return response.json();
});
})).then(function (payloads) {
if (seq !== loadSeq) { return; }
records = buildRecords(pivot, payloads);
renderRecords();
}).catch(function () {
if (seq !== loadSeq) { return; }
showStatus("OCR 数据加载失败(检查 CDN CORS 配置或该分P是否已导出)");
});
}
setTarget(currentTarget, false);
if (!pivots.length) {
showStatus("该节目暂无关联视频");
return;
}
load(defaultPivot());
}
window.OcrFill = {
init: init,
_buildRecords: buildRecords, // 暴露给离线校验
};
})();