You've already forked lubo_comment_query
OCR
This commit is contained in:
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* 视频右侧栏 OCR 结果 canvas 复刻绘制。
|
||||
* 数据源:CDN 上的分P全量 JSON({space:[w,h], recs:[[ts,[[x1,y1,x2,y2,text],...]],...]})
|
||||
* 或服务器注入的单帧快照({ts, lines:[[x1,y1,x2,y2,text],...]})。
|
||||
*/
|
||||
(function () {
|
||||
// 按框坐标 1:1 复刻:黑底、黄框、白字,字号按框高自适应
|
||||
function drawFrame(canvas, space, lines) {
|
||||
const sw = space[0], sh = space[1];
|
||||
const cssWidth = canvas.clientWidth || 200;
|
||||
const scale = cssWidth / sw;
|
||||
const cssHeight = Math.max(1, Math.round(sh * scale));
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.round(cssWidth * dpr);
|
||||
canvas.height = Math.round(cssHeight * dpr);
|
||||
canvas.style.height = cssHeight + "px";
|
||||
const ctx = canvas.getContext("2d");
|
||||
ctx.setTransform(dpr * scale, 0, 0, dpr * scale, 0, 0);
|
||||
ctx.fillStyle = "#000000";
|
||||
ctx.fillRect(0, 0, sw, sh);
|
||||
ctx.strokeStyle = "#eab308";
|
||||
ctx.fillStyle = "#ffffff";
|
||||
ctx.textBaseline = "middle";
|
||||
lines.forEach(function (line) {
|
||||
const x1 = line[0], y1 = line[1], x2 = line[2], y2 = line[3], text = line[4];
|
||||
ctx.lineWidth = 2 / scale;
|
||||
ctx.strokeRect(x1, y1, x2 - x1, y2 - y1);
|
||||
if (text) {
|
||||
ctx.font = Math.max(4, (y2 - y1) * 0.75) + "px sans-serif";
|
||||
ctx.fillText(text, x1 + 2 / scale, (y1 + y2) / 2, x2 - x1 - 4 / scale);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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 = (n) => String(n).padStart(2, "0");
|
||||
return h + ":" + pad(m) + ":" + pad(s);
|
||||
}
|
||||
|
||||
// 单帧 space:按本帧最大框坐标向上取整到 16 的倍数(与导出端一致)
|
||||
function frameSpace(lines) {
|
||||
let maxX = 0, maxY = 0;
|
||||
lines.forEach(function (line) {
|
||||
maxX = Math.max(maxX, line[2]);
|
||||
maxY = Math.max(maxY, line[3]);
|
||||
});
|
||||
return [Math.max(16, Math.ceil(maxX / 16) * 16), Math.max(16, Math.ceil(maxY / 16) * 16)];
|
||||
}
|
||||
|
||||
// 稿件详情页:CDN 全量 + 进度条联动(展示 ts<=t 最近一条)
|
||||
function initPlayer(panel) {
|
||||
const urls = JSON.parse(panel.dataset.urls || "{}");
|
||||
const canvas = panel.querySelector("canvas");
|
||||
const slider = panel.querySelector("input[type=range]");
|
||||
const timeLabel = panel.querySelector("[data-ocr-time]");
|
||||
const status = panel.querySelector("[data-ocr-status]");
|
||||
const tabs = panel.querySelectorAll("[data-ocr-part]");
|
||||
let data = null;
|
||||
let loadSeq = 0;
|
||||
|
||||
function showStatus(text) {
|
||||
if (status) {
|
||||
status.textContent = text;
|
||||
status.style.display = text ? "" : "none";
|
||||
}
|
||||
}
|
||||
|
||||
function currentIndex(t) {
|
||||
// 二分:ts<=t 的最后一条;t 早于首条时展示首条
|
||||
const recs = data.recs;
|
||||
let lo = 0, hi = recs.length - 1, ans = 0;
|
||||
while (lo <= hi) {
|
||||
const mid = (lo + hi) >> 1;
|
||||
if (recs[mid][0] <= t) { ans = mid; lo = mid + 1; } else { hi = mid - 1; }
|
||||
}
|
||||
return ans;
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!data || data.recs.length === 0) {
|
||||
return;
|
||||
}
|
||||
const t = parseFloat(slider.value);
|
||||
const idx = currentIndex(t);
|
||||
timeLabel.textContent = formatHms(t) + "(OCR @" + formatHms(data.recs[idx][0]) + ")";
|
||||
drawFrame(canvas, data.space, data.recs[idx][1]);
|
||||
}
|
||||
|
||||
function load(partNum) {
|
||||
const seq = ++loadSeq;
|
||||
data = null;
|
||||
showStatus("加载中…");
|
||||
tabs.forEach(function (tab) {
|
||||
tab.classList.toggle("font-bold", tab.dataset.ocrPart === String(partNum));
|
||||
});
|
||||
fetch(urls[partNum])
|
||||
.then((response) => {
|
||||
if (!response.ok) { throw new Error("HTTP " + response.status); }
|
||||
return response.json();
|
||||
})
|
||||
.then((payload) => {
|
||||
if (seq !== loadSeq) { return; }
|
||||
data = payload;
|
||||
slider.max = payload.recs.length ? Math.floor(payload.recs[payload.recs.length - 1][0]) : 0;
|
||||
showStatus("");
|
||||
render();
|
||||
})
|
||||
.catch(() => {
|
||||
if (seq !== loadSeq) { return; }
|
||||
showStatus("OCR 数据加载失败");
|
||||
});
|
||||
}
|
||||
|
||||
slider.addEventListener("input", render);
|
||||
tabs.forEach(function (tab) {
|
||||
tab.addEventListener("click", function () {
|
||||
slider.value = 0;
|
||||
load(tab.dataset.ocrPart);
|
||||
});
|
||||
});
|
||||
const firstPart = Object.keys(urls)[0];
|
||||
if (firstPart !== undefined) {
|
||||
load(firstPart);
|
||||
}
|
||||
}
|
||||
|
||||
// 节目详情页:服务器注入的单帧快照
|
||||
function initSnapshots(scope) {
|
||||
(scope || document).querySelectorAll("canvas[data-ocr-frame]").forEach(function (canvas) {
|
||||
const frame = JSON.parse(canvas.dataset.ocrFrame);
|
||||
drawFrame(canvas, frameSpace(frame.lines), frame.lines);
|
||||
});
|
||||
}
|
||||
|
||||
window.OcrCanvas = {
|
||||
drawFrame: drawFrame,
|
||||
initPlayer: initPlayer,
|
||||
initSnapshots: initSnapshots,
|
||||
};
|
||||
})();
|
||||
@@ -0,0 +1,91 @@
|
||||
<html lang="zh">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>OCR 导入 - 录播查询</title>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link href="{{ mix('/css/app.css') }}" rel="stylesheet"/>
|
||||
</head>
|
||||
<body class="bg-gray-50 dark:bg-gray-900 min-h-screen flex flex-col text-gray-900 dark:text-gray-100">
|
||||
@include("common.header")
|
||||
|
||||
<main class="flex-grow container mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="max-w-3xl mx-auto">
|
||||
<div class="md:flex md:items-center md:justify-between mb-8">
|
||||
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
OCR 结果导入
|
||||
</h1>
|
||||
<a href="{{ url()->previous() }}" class="mt-4 md:mt-0 inline-flex items-center text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
|
||||
<svg class="h-4 w-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 19l-7-7m0 0l7-7m-7 7h18"></path></svg>
|
||||
返回
|
||||
</a>
|
||||
</div>
|
||||
|
||||
@if(session("import_results"))
|
||||
<div class="mb-6 bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
|
||||
<ul class="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@foreach(session("import_results") as $result)
|
||||
<li class="px-6 py-3 flex items-start text-sm">
|
||||
@if($result["ok"])
|
||||
<svg class="h-5 w-5 text-green-500 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg>
|
||||
@else
|
||||
<svg class="h-5 w-5 text-red-500 mr-2 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path></svg>
|
||||
@endif
|
||||
<span class="text-gray-900 dark:text-gray-100 font-mono mr-2">{{ $result["file"] }}</span>
|
||||
<span class="text-gray-600 dark:text-gray-400">{{ $result["message"] }}</span>
|
||||
</li>
|
||||
@endforeach
|
||||
</ul>
|
||||
</div>
|
||||
@endif
|
||||
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg overflow-hidden">
|
||||
<form class="p-6 space-y-6" action="" method="post" enctype="multipart/form-data">
|
||||
@csrf
|
||||
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||
<div class="sm:col-span-2">
|
||||
<label for="video_bvid" class="block text-sm font-medium text-gray-700 dark:text-gray-300">BVID(留空按文件名自动匹配)</label>
|
||||
<input type="text" name="video_bvid" id="video_bvid" value="{{ old('video_bvid') }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm py-2">
|
||||
</div>
|
||||
<div>
|
||||
<label for="part_num" class="block text-sm font-medium text-gray-700 dark:text-gray-300">分P号</label>
|
||||
<input type="number" name="part_num" id="part_num" min="1" value="{{ old('part_num') }}"
|
||||
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm py-2">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">OCR 结果文件 (支持批量)</label>
|
||||
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed rounded-md hover:border-indigo-500 transition-colors">
|
||||
<div class="space-y-1 text-center">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none" viewBox="0 0 48 48" aria-hidden="true">
|
||||
<path d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
<div class="flex text-sm text-gray-600 dark:text-gray-400">
|
||||
<label for="file-upload" class="relative cursor-pointer bg-white dark:bg-gray-800 rounded-md font-medium text-indigo-600 dark:text-indigo-400 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
|
||||
<span>上传文件</span>
|
||||
<input id="file-upload" name="file[]" multiple type="file" accept=".jsonl" class="sr-only" onchange="document.getElementById('file-count').innerText = this.files.length + ' 个文件已选择'">
|
||||
</label>
|
||||
<p class="pl-1">或拖拽文件到这里</p>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500">result_*.jsonl 格式;同一分P重复导入会整体覆盖</p>
|
||||
<p class="text-sm text-indigo-600 font-medium" id="file-count"></p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@include("common.form_error")
|
||||
|
||||
<div class="flex items-center justify-end pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<button type="submit" class="inline-flex justify-center py-2 px-6 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
开始导入
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@include("common.footer")
|
||||
</body>
|
||||
</html>
|
||||
@@ -47,6 +47,10 @@
|
||||
节目开始位置
|
||||
@endif
|
||||
</x-links.video_link>
|
||||
@if($video_pivot->start_ocr)
|
||||
<canvas class="mt-1 w-full max-w-[180px] bg-black rounded" data-ocr-frame="{{ json_encode($video_pivot->start_ocr, JSON_UNESCAPED_UNICODE) }}"></canvas>
|
||||
<div class="text-xs text-gray-500">OCR @ {{ gmdate("H:i:s", (int)$video_pivot->start_ocr["ts"]) }}</div>
|
||||
@endif
|
||||
</td>
|
||||
<td class="border align-bottom">
|
||||
<x-links.video_link :bvid="$video_pivot->video_bvid" :part="$video_pivot->stop_part" :time="$video_pivot->stop_time">
|
||||
@@ -57,11 +61,17 @@
|
||||
节目结束位置
|
||||
@endif
|
||||
</x-links.video_link>
|
||||
@if($video_pivot->stop_ocr)
|
||||
<canvas class="mt-1 w-full max-w-[180px] bg-black rounded" data-ocr-frame="{{ json_encode($video_pivot->stop_ocr, JSON_UNESCAPED_UNICODE) }}"></canvas>
|
||||
<div class="text-xs text-gray-500">OCR @ {{ gmdate("H:i:s", (int)$video_pivot->stop_ocr["ts"]) }}</div>
|
||||
@endif
|
||||
</td>
|
||||
</tr>
|
||||
@endforeach
|
||||
</tbody>
|
||||
</table>
|
||||
<script src="{{ mix('/js/component/ocr_canvas.js') }}"></script>
|
||||
<script>document.addEventListener("DOMContentLoaded", function () { OcrCanvas.initSnapshots(); });</script>
|
||||
@include("common.footer")
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col lg:flex-row gap-8">
|
||||
<div class="flex-grow min-w-0">
|
||||
|
||||
<!-- Desktop Table -->
|
||||
<div class="hidden lg:block bg-white dark:bg-gray-800 shadow overflow-hidden rounded-lg mb-8">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
@@ -154,11 +157,39 @@
|
||||
导入直播弹幕
|
||||
</a>
|
||||
@endif
|
||||
<a href="{{ url(route("ocr.construct.import.page", ["video_bvid"=>$video->bvid])) }}" class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||
导入OCR结果
|
||||
</a>
|
||||
</div>
|
||||
@endauth
|
||||
</div>
|
||||
|
||||
@if($ocr_urls->isNotEmpty())
|
||||
<div class="w-full lg:w-72 flex-shrink-0" id="ocr-panel" data-urls="{{ $ocr_urls->toJson() }}">
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-4 lg:sticky lg:top-20">
|
||||
<h2 class="text-lg font-bold text-gray-900 dark:text-white mb-3">右侧栏 OCR</h2>
|
||||
@if($ocr_urls->count() > 1)
|
||||
<div class="flex flex-wrap gap-2 mb-3">
|
||||
@foreach($ocr_urls as $part_num => $ocr_url)
|
||||
<button type="button" data-ocr-part="{{ $part_num }}" class="px-2 py-1 text-sm rounded border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:border-indigo-500 transition-colors">P{{ $part_num }}</button>
|
||||
@endforeach
|
||||
</div>
|
||||
@endif
|
||||
<canvas class="w-full bg-black rounded"></canvas>
|
||||
<input type="range" min="0" max="0" step="1" value="0" class="w-full mt-3 accent-indigo-600">
|
||||
<div data-ocr-time class="text-xs text-gray-500 dark:text-gray-400 mt-1"></div>
|
||||
<div data-ocr-status class="text-xs text-gray-400 dark:text-gray-500 mt-1" style="display:none"></div>
|
||||
</div>
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</main>
|
||||
@if($ocr_urls->isNotEmpty())
|
||||
<script src="{{ mix('/js/component/ocr_canvas.js') }}"></script>
|
||||
<script>document.addEventListener("DOMContentLoaded", function () { OcrCanvas.initPlayer(document.getElementById("ocr-panel")); });</script>
|
||||
@endif
|
||||
@include("common.footer")
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user