You've already forked lubo_comment_query
OCR
This commit is contained in:
@@ -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;
|
||||
|
||||
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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user