diff --git a/app/Http/Controllers/ProgramConstructController.php b/app/Http/Controllers/ProgramConstructController.php index f9881f1..891e90f 100644 --- a/app/Http/Controllers/ProgramConstructController.php +++ b/app/Http/Controllers/ProgramConstructController.php @@ -7,6 +7,7 @@ use App\Models\ProgramVideos; use App\Models\VideoComments; use App\Models\VideoParts; use App\Util\ProgramVideoUtil; +use App\Util\VideoOcrUtil; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\QueryException; use Illuminate\Http\Request; @@ -60,7 +61,8 @@ class ProgramConstructController extends BaseController public function edit(Request $request, Programs $program) { return view("program.construct.create", [ - "program"=>$program + "program"=>$program, + "ocr_pivots"=>VideoOcrUtil::panel_pivots($program), ]); } // endregion diff --git a/app/Http/Controllers/ProgramVideoConstructController.php b/app/Http/Controllers/ProgramVideoConstructController.php index 9dc7201..40c147a 100644 --- a/app/Http/Controllers/ProgramVideoConstructController.php +++ b/app/Http/Controllers/ProgramVideoConstructController.php @@ -5,6 +5,7 @@ namespace App\Http\Controllers; use App\Models\Programs; use App\Models\ProgramVideos; use App\Util\ProgramVideoUtil; +use App\Util\VideoOcrUtil; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; use Illuminate\Support\Carbon; @@ -61,7 +62,8 @@ class ProgramVideoConstructController extends BaseController public function edit(Request $request, ProgramVideos $program_video) { return view("program.construct.video.create", [ - "program_video" => $program_video + "program_video" => $program_video, + "ocr_pivots" => VideoOcrUtil::panel_pivots($program_video->program), ]); } diff --git a/app/Util/VideoOcrUtil.php b/app/Util/VideoOcrUtil.php index bfaf87c..1e81af1 100644 --- a/app/Util/VideoOcrUtil.php +++ b/app/Util/VideoOcrUtil.php @@ -2,6 +2,8 @@ namespace App\Util; +use App\Models\Programs; +use App\Models\ProgramVideos; use App\Models\VideoOcrRecords; use App\Models\VideoParts; use Carbon\Carbon; @@ -198,6 +200,43 @@ class VideoOcrUtil ]; } + /** + * 建设页 OCR 面板所需的片段元数据(轻量;帧级重数据由前端按返回的 CDN URL 直接拉取)。 + * 每个 pivot:id/label/bvid/起止分P与秒/urls(片段范围内有 OCR 的分P → CDN 全量 JSON URL)。 + * 起止时间未填时对应 sec 为 null(前端不做该侧 in_range 裁剪)。 + */ + public static function panel_pivots(Programs $program): array + { + $pivots = $program->video_pivots; + if ($pivots->isEmpty()) { + return []; + } + $bvids = $pivots->pluck("video_bvid")->unique()->all(); + $ocrParts = VideoOcrRecords::query()->whereIn("video_bvid", $bvids) + ->select("video_bvid", "part_num")->distinct()->get() + ->groupBy("video_bvid")->map(fn($rows) => $rows->pluck("part_num")->map(fn($n) => (int)$n)->all())->all(); + return $pivots->map(function (ProgramVideos $pivot) use ($ocrParts) { + $startPart = (int)$pivot->start_part; + $stopPart = (int)$pivot->stop_part; + $urls = []; + foreach (($ocrParts[$pivot->video_bvid] ?? []) as $partNum) { + if ($partNum >= $startPart && $partNum <= $stopPart) { + $urls[$partNum] = Storage::url(static::cdn_path($pivot->video_bvid, $partNum)); + } + } + return [ + "id" => $pivot->id, + "label" => "{$pivot->video_bvid} P{$pivot->start_part} {$pivot->start_time} → P{$pivot->stop_part} {$pivot->stop_time}", + "bvid" => $pivot->video_bvid, + "start_part" => $startPart, + "start_sec" => $pivot->start_sec === "" ? null : $pivot->start_sec, + "stop_part" => $stopPart, + "stop_sec" => $pivot->stop_sec === "" ? null : $pivot->stop_sec, + "urls" => $urls, + ]; + })->values()->all(); + } + private static function time_to_seconds($time): ?float { if (is_numeric($time)) { diff --git a/resources/js/component/ocr_fill.js b/resources/js/component/ocr_fill.js new file mode 100644 index 0000000..331606b --- /dev/null +++ b/resources/js/component/ocr_fill.js @@ -0,0 +1,313 @@ +/** + * 建设页片段 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, // 暴露给离线校验 + }; +})(); diff --git a/resources/views/program/construct/create.blade.php b/resources/views/program/construct/create.blade.php index 2e5aa22..5359d2b 100644 --- a/resources/views/program/construct/create.blade.php +++ b/resources/views/program/construct/create.blade.php @@ -9,7 +9,9 @@ @include("common.header")
-
+ @php($show_ocr_panel = $program->id && !empty($ocr_pivots)) +
+

@if($program->id) 编辑节目 @else 添加节目 @endif @@ -27,19 +29,19 @@
- name) }}" + name) }}" 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">
- difficulty) }}" + difficulty) }}" 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">
- desc) }}" + desc) }}" 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">
@@ -110,8 +112,25 @@

@endif +
+ @if($show_ocr_panel) +
+
+

片段 OCR

+
加载中…
+
+
+ @endif
+@if($show_ocr_panel) + + + +@endif @include("common.footer") \ No newline at end of file diff --git a/resources/views/program/construct/video/create.blade.php b/resources/views/program/construct/video/create.blade.php index 6b6cc35..b07b036 100644 --- a/resources/views/program/construct/video/create.blade.php +++ b/resources/views/program/construct/video/create.blade.php @@ -9,7 +9,9 @@ @include("common.header")
-
+ @php($show_ocr_panel = (bool)$program_video->id) +
+

关联视频修改 @@ -39,12 +41,12 @@

开始节点

- start_part) }}" + start_part) }}" 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">
- start_time) }}" + start_time) }}" 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">
@@ -61,12 +63,12 @@

结束节点

- stop_part) }}" + stop_part) }}" 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">
- stop_time) }}" + stop_time) }}" 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">
@@ -112,8 +114,26 @@ @endif
@endif +
+ @if($show_ocr_panel) +
+
+

片段 OCR

+
加载中…
+
+
+ @endif
+@if($show_ocr_panel) + + + +@endif @include("common.footer") \ No newline at end of file diff --git a/webpack.mix.js b/webpack.mix.js index a9901e4..eba6292 100644 --- a/webpack.mix.js +++ b/webpack.mix.js @@ -30,6 +30,7 @@ if (mix.inProduction()) { mix .js('resources/js/component/from_select.js', 'public/js/component') .js('resources/js/component/ocr_canvas.js', 'public/js/component') + .js('resources/js/component/ocr_fill.js', 'public/js/component') .js('resources/js/app.js', 'public/js') .extract(['axios', 'lodash']) .js('resources/js/webauthn.js', 'public/js')