节目建设页 OCR 侧栏点击填入

This commit is contained in:
2026-07-30 03:38:05 +08:00
parent ec88e6a806
commit 8914a2a609
7 changed files with 407 additions and 11 deletions
@@ -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
@@ -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),
]);
}
+39
View File
@@ -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)) {
+313
View File
@@ -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);
}
// 填入 <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, // 暴露给离线校验
};
})();
@@ -9,7 +9,9 @@
@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">
@php($show_ocr_panel = $program->id && !empty($ocr_pivots))
<div class="@if($show_ocr_panel) lg:flex lg:gap-6 @else max-w-3xl mx-auto @endif">
<div class="@if($show_ocr_panel) flex-1 min-w-0 max-w-3xl @endif">
<div class="md:flex md:items-center md:justify-between mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
@if($program->id) 编辑节目 @else 添加节目 @endif
@@ -27,19 +29,19 @@
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">节目名称</label>
<input type="text" name="name" id="name" required autocomplete="off" value="{{ old("name", $program->name) }}"
<input type="text" name="name" id="name" required autocomplete="off" data-ocr-target="name" value="{{ old("name", $program->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">
</div>
<div>
<label for="difficulty" class="block text-sm font-medium text-gray-700 dark:text-gray-300">节目难度</label>
<input type="text" name="difficulty" id="difficulty" autocomplete="off" value="{{ old("difficulty", $program->difficulty) }}"
<input type="text" name="difficulty" id="difficulty" autocomplete="off" data-ocr-target="difficulty" value="{{ old("difficulty", $program->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">
</div>
<div>
<label for="desc" class="block text-sm font-medium text-gray-700 dark:text-gray-300">节目要求</label>
<input type="text" name="desc" id="desc" autocomplete="off" value="{{ old("desc", $program->desc) }}"
<input type="text" name="desc" id="desc" autocomplete="off" data-ocr-target="desc" value="{{ old("desc", $program->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">
</div>
@@ -110,8 +112,25 @@
</a>
</div>
@endif
</div>
@if($show_ocr_panel)
<div class="w-full lg:w-96 flex-shrink-0 mt-8 lg:mt-0" id="ocr-fill-panel"
data-mode="text"
data-pivots='@json($ocr_pivots ?? [])'
data-target-labels='{"name":"节目名称","difficulty":"节目难度","desc":"节目要求"}'>
<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>
<div data-ocr-fill-body class="text-sm text-gray-500 dark:text-gray-400">加载中…</div>
</div>
</div>
@endif
</div>
</main>
@if($show_ocr_panel)
<script src="{{ mix('/js/manifest.js') }}"></script>
<script src="{{ mix('/js/component/ocr_fill.js') }}"></script>
<script>document.addEventListener("DOMContentLoaded", function () { OcrFill.init(document.getElementById("ocr-fill-panel")); });</script>
@endif
@include("common.footer")
</body>
</html>
@@ -9,7 +9,9 @@
@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">
@php($show_ocr_panel = (bool)$program_video->id)
<div class="@if($show_ocr_panel) lg:flex lg:gap-6 @else max-w-3xl mx-auto @endif">
<div class="@if($show_ocr_panel) flex-1 min-w-0 max-w-3xl @endif">
<div class="md:flex md:items-center md:justify-between mb-8">
<h1 class="text-2xl font-bold text-gray-900 dark:text-white">
关联视频修改
@@ -39,12 +41,12 @@
<h3 class="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider">开始节点</h3>
<div>
<label for="start_part" class="block text-sm font-medium text-gray-700 dark:text-gray-300">P数</label>
<input type="number" name="start_part" id="start_part" value="{{ old("start_part", $program_video->start_part) }}"
<input type="number" name="start_part" id="start_part" data-ocr-target="start" data-ocr-kind="part" value="{{ old("start_part", $program_video->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">
</div>
<div>
<label for="start_time" class="block text-sm font-medium text-gray-700 dark:text-gray-300">时间点</label>
<input type="time" step="1" name="start_time" id="start_time" value="{{ old("start_time", $program_video->start_time) }}"
<input type="time" step="1" name="start_time" id="start_time" data-ocr-target="start" data-ocr-kind="time" value="{{ old("start_time", $program_video->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">
</div>
<div>
@@ -61,12 +63,12 @@
<h3 class="text-sm font-semibold text-gray-900 dark:text-white uppercase tracking-wider">结束节点</h3>
<div>
<label for="stop_part" class="block text-sm font-medium text-gray-700 dark:text-gray-300">P数</label>
<input type="number" name="stop_part" id="stop_part" value="{{ old("stop_part", $program_video->stop_part) }}"
<input type="number" name="stop_part" id="stop_part" data-ocr-target="stop" data-ocr-kind="part" value="{{ old("stop_part", $program_video->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">
</div>
<div>
<label for="stop_time" class="block text-sm font-medium text-gray-700 dark:text-gray-300">时间点</label>
<input type="time" step="1" name="stop_time" id="stop_time" value="{{ old("stop_time", $program_video->stop_time) }}"
<input type="time" step="1" name="stop_time" id="stop_time" data-ocr-target="stop" data-ocr-kind="time" value="{{ old("stop_time", $program_video->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">
</div>
<div>
@@ -112,8 +114,26 @@
@endif
</div>
@endif
</div>
@if($show_ocr_panel)
<div class="w-full lg:w-96 flex-shrink-0 mt-8 lg:mt-0" id="ocr-fill-panel"
data-mode="time"
data-pivot="{{ $program_video->id }}"
data-pivots='@json($ocr_pivots ?? [])'
data-target-labels='{"start":"开始节点","stop":"结束节点"}'>
<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>
<div data-ocr-fill-body class="text-sm text-gray-500 dark:text-gray-400">加载中…</div>
</div>
</div>
@endif
</div>
</main>
@if($show_ocr_panel)
<script src="{{ mix('/js/manifest.js') }}"></script>
<script src="{{ mix('/js/component/ocr_fill.js') }}"></script>
<script>document.addEventListener("DOMContentLoaded", function () { OcrFill.init(document.getElementById("ocr-fill-panel")); });</script>
@endif
@include("common.footer")
</body>
</html>
+1
View File
@@ -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')