You've already forked lubo_comment_query
314 lines
14 KiB
JavaScript
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, // 暴露给离线校验
|
|
};
|
|
})();
|