You've already forked lubo_comment_query
220 lines
8.4 KiB
PHP
220 lines
8.4 KiB
PHP
<?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";
|
|
}
|
|
}
|