This commit is contained in:
2026-07-29 10:40:52 +08:00
parent b7ad3fda49
commit 7c89d13f81
12 changed files with 654 additions and 1 deletions
@@ -0,0 +1,110 @@
<?php
namespace App\Http\Controllers;
use App\Models\VideoParts;
use App\Models\Videos;
use App\Util\VideoOcrUtil;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
class OcrConstructController extends BaseController
{
public function page(Request $request)
{
$view = view("ocr.construct.import");
if ($request->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];
}
}
@@ -3,6 +3,7 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\Programs; use App\Models\Programs;
use App\Util\VideoOcrUtil;
use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController; use Illuminate\Routing\Controller as BaseController;
@@ -43,9 +44,15 @@ class ProgramQueryController extends BaseController
} }
public function videos(Request $request, Programs $program) { 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", [ return view("program.video.index", [
"program" => $program, "program" => $program,
"videos" => $program->video_pivots, "videos" => $videos,
]); ]);
} }
} }
@@ -2,9 +2,12 @@
namespace App\Http\Controllers; namespace App\Http\Controllers;
use App\Models\VideoOcrRecords;
use App\Models\Videos; use App\Models\Videos;
use App\Util\VideoOcrUtil;
use Illuminate\Http\Request; use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController; use Illuminate\Routing\Controller as BaseController;
use Illuminate\Support\Facades\Storage;
class VideoQueryController extends BaseController class VideoQueryController extends BaseController
{ {
@@ -16,10 +19,15 @@ class VideoQueryController extends BaseController
} else { } else {
$comment = null; $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", [ return view("video.index", [
"video" => $video, "video" => $video,
"video_pivots" => $pivots, "video_pivots" => $pivots,
"comment" => $comment, "comment" => $comment,
"ocr_urls" => $ocr_urls,
]); ]);
} }
} }
+22
View File
@@ -0,0 +1,22 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class VideoOcrRecords extends Model
{
protected $guarded = [];
protected $table = "video_ocr_records";
protected $dateFormat = 'U';
public $timestamps = false;
protected $casts = [
'ocr_lines' => '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");
}
}
+5
View File
@@ -46,4 +46,9 @@ class Videos extends Model
{ {
return $this->hasMany(VideoParts::class, "bvid", "bvid"); return $this->hasMany(VideoParts::class, "bvid", "bvid");
} }
public function ocr_records(): \Illuminate\Database\Eloquent\Relations\HasMany
{
return $this->hasMany(VideoOcrRecords::class, "video_bvid", "bvid");
}
} }
+219
View File
@@ -0,0 +1,219 @@
<?php
namespace App\Util;
use App\Models\VideoOcrRecords;
use App\Models\VideoParts;
use Carbon\Carbon;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Storage;
use InvalidArgumentException;
class VideoOcrUtil
{
// 紧凑日期时间:20190731_1945 / 20250102_200304 / 20200227-0436 / bl20241218_202612-弹幕版
const PATTERN_COMPACT = "/(?<!\d)(\d{4})(\d{2})(\d{2})[-_](\d{2})(\d{2})(\d{2})?(?!\d)/";
// 分隔日期时间:永恒de草薙_2025-01-02_20-03-45
const PATTERN_DASHED = "/(?<!\d)(\d{4})-(\d{1,2})-(\d{1,2})_(\d{1,2})-(\d{1,2})(?:-(\d{1,2}))?(?!\d)/";
// 直播标记格式:[2024-01-02].[20_03] / [2024-01-02].[20_03_45]
const PATTERN_BRACKET = "/\[(\d{4})-(\d{1,2})-(\d{1,2})\]\.\[(\d{1,2})_(\d{1,2})(?:_(\d{1,2}))?\]/";
/**
* 从文件名或分P标题解析录制开始时间,覆盖录制文件与 B站分P 的全部已知命名格式。
* 无法解析或时间非法时返回 null
*/
public static function parse_recording_time(?string $name): ?Carbon
{
if (!$name) {
return null;
}
foreach ([static::PATTERN_COMPACT, static::PATTERN_DASHED, static::PATTERN_BRACKET] as $pattern) {
if (!preg_match($pattern, $name, $m)) {
continue;
}
[$year, $month, $day, $hour, $minute] = [(int)$m[1], (int)$m[2], (int)$m[3], (int)$m[4], (int)$m[5]];
$second = isset($m[6]) && $m[6] !== "" ? (int)$m[6] : 0;
if (!checkdate($month, $day, $year) || $hour > 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";
}
}
+144
View File
@@ -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 @endif
</x-links.video_link> </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>
<td class="border align-bottom"> <td class="border align-bottom">
<x-links.video_link :bvid="$video_pivot->video_bvid" :part="$video_pivot->stop_part" :time="$video_pivot->stop_time"> <x-links.video_link :bvid="$video_pivot->video_bvid" :part="$video_pivot->stop_part" :time="$video_pivot->stop_time">
@@ -57,11 +61,17 @@
节目结束位置 节目结束位置
@endif @endif
</x-links.video_link> </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> </td>
</tr> </tr>
@endforeach @endforeach
</tbody> </tbody>
</table> </table>
<script src="{{ mix('/js/component/ocr_canvas.js') }}"></script>
<script>document.addEventListener("DOMContentLoaded", function () { OcrCanvas.initSnapshots(); });</script>
@include("common.footer") @include("common.footer")
</body> </body>
</html> </html>
+31
View File
@@ -19,6 +19,9 @@
</a> </a>
</div> </div>
<div class="flex flex-col lg:flex-row gap-8">
<div class="flex-grow min-w-0">
<!-- Desktop Table --> <!-- Desktop Table -->
<div class="hidden lg:block bg-white dark:bg-gray-800 shadow overflow-hidden rounded-lg mb-8"> <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"> <table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
@@ -154,11 +157,39 @@
导入直播弹幕 导入直播弹幕
</a> </a>
@endif @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> </div>
@endauth @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> </div>
</main> </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") @include("common.footer")
</body> </body>
</html> </html>
+5
View File
@@ -69,6 +69,11 @@ Route::prefix("/construct")->middleware("auth:web")->group(function (Router $rou
$router->get("/batch_import", ["\\App\\Http\\Controllers\\DanmakuConstructController", "page"])->name("danmaku.construct.batch_import.page"); $router->get("/batch_import", ["\\App\\Http\\Controllers\\DanmakuConstructController", "page"])->name("danmaku.construct.batch_import.page");
$router->post("/batch_import", ["\\App\\Http\\Controllers\\DanmakuConstructController", "do_import"])->name("danmaku.construct.batch_import"); $router->post("/batch_import", ["\\App\\Http\\Controllers\\DanmakuConstructController", "do_import"])->name("danmaku.construct.batch_import");
}); });
// OCR 结果维护
Route::prefix("/ocr")->group(function (Router $router) {
$router->get("/import", ["\\App\\Http\\Controllers\\OcrConstructController", "page"])->name("ocr.construct.import.page");
$router->post("/import", ["\\App\\Http\\Controllers\\OcrConstructController", "do_import"])->name("ocr.construct.import");
});
}); });
Route::prefix("/user")->middleware("auth:web")->group(function (Router $router) { Route::prefix("/user")->middleware("auth:web")->group(function (Router $router) {
$router->post("/webauthn/options", ["\\App\\Http\\Controllers\\UserWebAuthnController", "register_options"])->name("user.webauthn.bind.options"); $router->post("/webauthn/options", ["\\App\\Http\\Controllers\\UserWebAuthnController", "register_options"])->name("user.webauthn.bind.options");
+1
View File
@@ -29,6 +29,7 @@ if (mix.inProduction()) {
mix mix
.js('resources/js/component/from_select.js', 'public/js/component') .js('resources/js/component/from_select.js', 'public/js/component')
.js('resources/js/component/ocr_canvas.js', 'public/js/component')
.js('resources/js/app.js', 'public/js') .js('resources/js/app.js', 'public/js')
.extract(['axios', 'lodash']) .extract(['axios', 'lodash'])
.js('resources/js/webauthn.js', 'public/js') .js('resources/js/webauthn.js', 'public/js')