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;
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,
]);
}
}
+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");
}
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";
}
}