diff --git a/app/Http/Controllers/OcrConstructController.php b/app/Http/Controllers/OcrConstructController.php new file mode 100644 index 0000000..d593021 --- /dev/null +++ b/app/Http/Controllers/OcrConstructController.php @@ -0,0 +1,110 @@ +has("video_bvid")) { + $bvid = $request->get("video_bvid"); + $video = Videos::query()->where("bvid", "=", $bvid)->first(); + if ($video == null) { + $view->withErrors([ + "video_bvid" => "系统无此对应视频", + ]); + } else { + $request->session()->flashInput([ + "video_bvid" => $bvid + ]); + } + } + return $view; + } + + public function do_import(Request $request) + { + $request->validate([ + 'video_bvid' => ['nullable', 'string'], + 'part_num' => ['nullable', 'int', 'min:1'], + 'file.*' => ['required', 'file'] + ]); + $manualBvid = trim((string)$request->get("video_bvid", "")); + $manualPartNum = $request->get("part_num"); + $files = $request->file("file"); + if (!is_array($files)) { + $files = [$files]; + } + if ($manualBvid !== "" && sizeof($files) > 1) { + return back()->withInput()->withErrors([ + "file" => "手工指定稿件时一次只能上传一个文件", + ]); + } + + $results = []; + foreach ($files as $file) { + $results[] = $this->import_one($file, $manualBvid, $manualPartNum); + } + return back()->withInput()->with("import_results", $results); + } + + private function import_one($file, string $manualBvid, $manualPartNum): array + { + $name = $file->getClientOriginalName(); + try { + [$bvid, $partNum] = $this->resolve_target($name, $manualBvid, $manualPartNum); + $records = VideoOcrUtil::parse_jsonl_records($file->getRealPath()); + $count = VideoOcrUtil::import_records($bvid, $partNum, $records); + $message = "导入 {$count} 条 → {$bvid} P{$partNum}"; + try { + VideoOcrUtil::export_to_cdn($bvid, $partNum); + } catch (\Exception $e) { + $message .= "(CDN 同步失败:{$e->getMessage()})"; + } + return ["file" => $name, "ok" => true, "message" => $message]; + } catch (\Exception $e) { + return ["file" => $name, "ok" => false, "message" => $e->getMessage()]; + } + } + + /** + * 确定导入目标 [bvid, part_num]:手工指定优先,否则按文件名时间戳自动匹配。 + */ + private function resolve_target(string $filename, string $manualBvid, $manualPartNum): array + { + if ($manualBvid !== "") { + $video = Videos::query()->where("bvid", "=", $manualBvid)->first(); + if ($video == null) { + throw new \InvalidArgumentException("系统无此对应视频:{$manualBvid}"); + } + if ($manualPartNum !== null && $manualPartNum !== "") { + return [$manualBvid, (int)$manualPartNum]; + } + $parts = $video->parts; + if (sizeof($parts) === 1) { + return [$manualBvid, (int)$parts[0]->part_num]; + } + throw new \InvalidArgumentException("该稿件有 " . sizeof($parts) . " 个分P,请手工填写分P号"); + } + $recordedAt = VideoOcrUtil::parse_jsonl_filename($filename); + if ($recordedAt === null) { + throw new \InvalidArgumentException("文件名无法解析录制时间,请手工指定 BVID 与分P"); + } + $matches = VideoOcrUtil::match_parts($recordedAt); + if (sizeof($matches) === 0) { + throw new \InvalidArgumentException("按 {$recordedAt->format("Y-m-d H:i")} 未匹配到分P,请手工指定 BVID 与分P"); + } + if (sizeof($matches) > 1) { + $desc = $matches->map(fn(VideoParts $p) => "{$p->bvid} P{$p->part_num}")->implode("、"); + throw new \InvalidArgumentException("匹配到多个分P({$desc}),请手工指定"); + } + return [$matches[0]->bvid, (int)$matches[0]->part_num]; + } +} diff --git a/app/Http/Controllers/ProgramQueryController.php b/app/Http/Controllers/ProgramQueryController.php index db91847..5f0cd0c 100644 --- a/app/Http/Controllers/ProgramQueryController.php +++ b/app/Http/Controllers/ProgramQueryController.php @@ -3,6 +3,7 @@ namespace App\Http\Controllers; use App\Models\Programs; +use App\Util\VideoOcrUtil; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; @@ -43,9 +44,15 @@ class ProgramQueryController extends BaseController } public function videos(Request $request, Programs $program) { + $videos = $program->video_pivots; + foreach ($videos as $video_pivot) { + // 节目开始/结束位置的 OCR 快照(无 OCR 数据的分P为 null,视图不展示) + $video_pivot->start_ocr = VideoOcrUtil::find_frame_at($video_pivot->video_bvid, $video_pivot->start_part, $video_pivot->start_time); + $video_pivot->stop_ocr = VideoOcrUtil::find_frame_at($video_pivot->video_bvid, $video_pivot->stop_part ?? 1, $video_pivot->stop_time); + } return view("program.video.index", [ "program" => $program, - "videos" => $program->video_pivots, + "videos" => $videos, ]); } } diff --git a/app/Http/Controllers/VideoQueryController.php b/app/Http/Controllers/VideoQueryController.php index c840170..a944285 100644 --- a/app/Http/Controllers/VideoQueryController.php +++ b/app/Http/Controllers/VideoQueryController.php @@ -2,9 +2,12 @@ namespace App\Http\Controllers; +use App\Models\VideoOcrRecords; use App\Models\Videos; +use App\Util\VideoOcrUtil; use Illuminate\Http\Request; use Illuminate\Routing\Controller as BaseController; +use Illuminate\Support\Facades\Storage; class VideoQueryController extends BaseController { @@ -16,10 +19,15 @@ class VideoQueryController extends BaseController } else { $comment = null; } + // 有 OCR 数据的分P → CDN 全量文件 URL(固定路径可推导) + $ocr_urls = VideoOcrRecords::query()->where("video_bvid", "=", $video->bvid) + ->select("part_num")->distinct()->orderBy("part_num")->pluck("part_num") + ->mapWithKeys(fn(int $partNum) => [$partNum => Storage::url(VideoOcrUtil::cdn_path($video->bvid, $partNum))]); return view("video.index", [ "video" => $video, "video_pivots" => $pivots, "comment" => $comment, + "ocr_urls" => $ocr_urls, ]); } } diff --git a/app/Models/VideoOcrRecords.php b/app/Models/VideoOcrRecords.php new file mode 100644 index 0000000..dcc01d3 --- /dev/null +++ b/app/Models/VideoOcrRecords.php @@ -0,0 +1,22 @@ + 'array', + 'created_at' => 'datetime:Y-m-d H:i:s', + ]; + + public function video(): \Illuminate\Database\Eloquent\Relations\BelongsTo + { + return $this->belongsTo(Videos::class, "video_bvid", "bvid"); + } +} diff --git a/app/Models/Videos.php b/app/Models/Videos.php index 575b835..f50d02d 100644 --- a/app/Models/Videos.php +++ b/app/Models/Videos.php @@ -46,4 +46,9 @@ class Videos extends Model { return $this->hasMany(VideoParts::class, "bvid", "bvid"); } + + public function ocr_records(): \Illuminate\Database\Eloquent\Relations\HasMany + { + return $this->hasMany(VideoOcrRecords::class, "video_bvid", "bvid"); + } } diff --git a/app/Util/VideoOcrUtil.php b/app/Util/VideoOcrUtil.php new file mode 100644 index 0000000..bfaf87c --- /dev/null +++ b/app/Util/VideoOcrUtil.php @@ -0,0 +1,219 @@ + 23 || $minute > 59 || $second > 59) { + continue; + } + return Carbon::create($year, $month, $day, $hour, $minute, $second); + } + return null; + } + + /** + * 从 OCR jsonl 文件名解析录制开始时间(去 result_ 前缀与扩展名)。 + */ + public static function parse_jsonl_filename(string $filename): ?Carbon + { + $stem = basename($filename); + $stem = preg_replace("/\.jsonl$/i", "", $stem); + $stem = preg_replace("/^result_/i", "", $stem); + return static::parse_recording_time($stem); + } + + /** + * 按录制时间匹配分P(精确到分钟,容忍秒级偏差),返回匹配的 VideoParts 集合(0~N 个)。 + */ + public static function match_parts(Carbon $recordedAt): Collection + { + $candidates = VideoParts::query() + ->where("title", "like", "%" . $recordedAt->format("Ymd") . "%") + ->orWhere("title", "like", "%" . $recordedAt->format("Y-m-d") . "%") + ->get(); + $target = $recordedAt->format("Y-m-d H:i"); + return $candidates->filter(function (VideoParts $part) use ($target) { + $partTime = static::parse_recording_time($part->title); + return $partTime !== null && $partTime->format("Y-m-d H:i") === $target; + })->values(); + } + + /** + * 解析 OCR jsonl 文件内容为记录数组,供 import_records 使用。 + * 格式非法时抛 InvalidArgumentException。 + */ + public static function parse_jsonl_records(string $path): array + { + $handle = fopen($path, "r"); + if ($handle === false) { + throw new InvalidArgumentException("无法打开文件:{$path}"); + } + $records = []; + $lineNo = 0; + try { + while (($line = fgets($handle)) !== false) { + $lineNo++; + $line = trim($line); + if ($line === "") { + continue; + } + $row = json_decode($line, true); + if (!is_array($row) || !isset($row["ts"]) || !isset($row["lines"]) || !is_array($row["lines"])) { + throw new InvalidArgumentException("第 {$lineNo} 行格式非法"); + } + $texts = []; + foreach ($row["lines"] as $ocrLine) { + $text = trim($ocrLine["text"] ?? ""); + if ($text !== "") { + $texts[] = $text; + } + } + $records[] = [ + "frame" => (int)($row["frame"] ?? 0), + "ts" => (float)$row["ts"], + "ocr_lines" => json_encode($row["lines"], JSON_UNESCAPED_UNICODE), + "plain_text" => implode("\n", $texts), + ]; + } + } finally { + fclose($handle); + } + if (sizeof($records) === 0) { + throw new InvalidArgumentException("文件无有效 OCR 记录"); + } + return $records; + } + + /** + * 导入 OCR 记录:按 (video_bvid, part_num) 事务内整删整插,返回导入条数。 + */ + public static function import_records(string $bvid, int $partNum, array $records): int + { + $now = time(); + foreach ($records as &$record) { + $record["video_bvid"] = $bvid; + $record["part_num"] = $partNum; + $record["created_at"] = $now; + unset($record); + } + DB::transaction(function () use ($bvid, $partNum, $records) { + VideoOcrRecords::query()->where("video_bvid", "=", $bvid)->where("part_num", "=", $partNum)->delete(); + foreach (array_chunk($records, 500) as $chunk) { + VideoOcrRecords::insert($chunk); + } + }); + return sizeof($records); + } + + /** + * 将指定分P的全量 OCR 记录生成紧凑 JSON 覆盖上传 CDN(固定路径,无版本化),返回访问 URL。 + * 格式:{"bvid","part","space":[w,h],"recs":[[ts,[[x1,y1,x2,y2,text],...]],...]},recs 按 ts 升序。 + * space 为实际识别区域:全部框最大坐标向上取整到 16 的倍数(NV12 对齐)。 + */ + public static function export_to_cdn(string $bvid, int $partNum): string + { + $records = VideoOcrRecords::query() + ->where("video_bvid", "=", $bvid)->where("part_num", "=", $partNum) + ->orderBy("ts")->get(); + if ($records->isEmpty()) { + throw new InvalidArgumentException("该分P无 OCR 记录:{$bvid} P{$partNum}"); + } + $maxX = 0; + $maxY = 0; + $recs = []; + foreach ($records as $record) { + $lines = []; + foreach ($record->ocr_lines as $line) { + $box = $line["box"]; + $maxX = max($maxX, $box[2]); + $maxY = max($maxY, $box[3]); + $lines[] = [$box[0], $box[1], $box[2], $box[3], $line["text"]]; + } + $recs[] = [$record->ts, $lines]; + } + $space = [max(16, (int)(ceil($maxX / 16) * 16)), max(16, (int)(ceil($maxY / 16) * 16))]; + $path = static::cdn_path($bvid, $partNum); + Storage::put($path, json_encode([ + "bvid" => $bvid, + "part" => $partNum, + "space" => $space, + "recs" => $recs, + ], JSON_UNESCAPED_UNICODE)); + return Storage::url($path); + } + + /** + * 取分P 内指定时间之前最近的一帧 OCR,返回 ["ts"=>秒, "lines"=>[[x1,y1,x2,y2,text],...]]。 + * $time 支持 "H:i:s"(小时可超 24)或秒数;无法解析或无匹配记录时返回 null。 + */ + public static function find_frame_at(string $bvid, int $partNum, $time): ?array + { + $seconds = static::time_to_seconds($time); + if ($seconds === null) { + return null; + } + $record = VideoOcrRecords::query() + ->where("video_bvid", "=", $bvid)->where("part_num", "=", $partNum) + ->where("ts", "<=", $seconds)->orderByDesc("ts")->first(); + if ($record === null) { + return null; + } + return [ + "ts" => $record->ts, + "lines" => array_map( + fn(array $line) => [$line["box"][0], $line["box"][1], $line["box"][2], $line["box"][3], $line["text"]], + $record->ocr_lines + ), + ]; + } + + private static function time_to_seconds($time): ?float + { + if (is_numeric($time)) { + return (float)$time; + } + if (is_string($time) && preg_match("/^(\d+):(\d{1,2}):(\d{1,2})(\.\d+)?$/", $time, $m)) { + return (int)$m[1] * 3600 + (int)$m[2] * 60 + (int)$m[3] + (float)($m[4] ?? 0); + } + return null; + } + + /** + * 分P OCR 结果的 CDN 对象路径(固定、可推导,覆盖上传)。 + */ + public static function cdn_path(string $bvid, int $partNum): string + { + return "ocr/{$bvid}/p{$partNum}.json"; + } +} diff --git a/resources/js/component/ocr_canvas.js b/resources/js/component/ocr_canvas.js new file mode 100644 index 0000000..cc302b9 --- /dev/null +++ b/resources/js/component/ocr_canvas.js @@ -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, + }; +})(); diff --git a/resources/views/ocr/construct/import.blade.php b/resources/views/ocr/construct/import.blade.php new file mode 100644 index 0000000..e3aa86c --- /dev/null +++ b/resources/views/ocr/construct/import.blade.php @@ -0,0 +1,91 @@ + + + + OCR 导入 - 录播查询 + + + + + @include("common.header") + +
+
+
+

+ OCR 结果导入 +

+ + + 返回 + +
+ + @if(session("import_results")) +
+
    + @foreach(session("import_results") as $result) +
  • + @if($result["ok"]) + + @else + + @endif + {{ $result["file"] }} + {{ $result["message"] }} +
  • + @endforeach +
+
+ @endif + +
+
+ @csrf + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ +
+ +

或拖拽文件到这里

+
+

result_*.jsonl 格式;同一分P重复导入会整体覆盖

+

+
+
+
+ + @include("common.form_error") + +
+ +
+
+
+
+
+ @include("common.footer") + + diff --git a/resources/views/program/video/index.blade.php b/resources/views/program/video/index.blade.php index be487cd..6068c0a 100644 --- a/resources/views/program/video/index.blade.php +++ b/resources/views/program/video/index.blade.php @@ -47,6 +47,10 @@ 节目开始位置 @endif + @if($video_pivot->start_ocr) + +
OCR @ {{ gmdate("H:i:s", (int)$video_pivot->start_ocr["ts"]) }}
+ @endif @@ -57,11 +61,17 @@ 节目结束位置 @endif + @if($video_pivot->stop_ocr) + +
OCR @ {{ gmdate("H:i:s", (int)$video_pivot->stop_ocr["ts"]) }}
+ @endif @endforeach + + @include("common.footer") diff --git a/resources/views/video/index.blade.php b/resources/views/video/index.blade.php index 468d67c..257cb1c 100644 --- a/resources/views/video/index.blade.php +++ b/resources/views/video/index.blade.php @@ -19,6 +19,9 @@ +
+
+