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