/** * 建设页片段 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); } // 填入 用:必须 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, // 暴露给离线校验 }; })();