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 ), ]; } /** * 建设页 OCR 面板所需的片段元数据(轻量;帧级重数据由前端按返回的 CDN URL 直接拉取)。 * 每个 pivot:id/label/bvid/起止分P与秒/urls(片段范围内有 OCR 的分P → CDN 全量 JSON URL)。 * 起止时间未填时对应 sec 为 null(前端不做该侧 in_range 裁剪)。 */ public static function panel_pivots(Programs $program): array { $pivots = $program->video_pivots; if ($pivots->isEmpty()) { return []; } $bvids = $pivots->pluck("video_bvid")->unique()->all(); $ocrParts = VideoOcrRecords::query()->whereIn("video_bvid", $bvids) ->select("video_bvid", "part_num")->distinct()->get() ->groupBy("video_bvid")->map(fn($rows) => $rows->pluck("part_num")->map(fn($n) => (int)$n)->all())->all(); return $pivots->map(function (ProgramVideos $pivot) use ($ocrParts) { $startPart = (int)$pivot->start_part; $stopPart = (int)$pivot->stop_part; $urls = []; foreach (($ocrParts[$pivot->video_bvid] ?? []) as $partNum) { if ($partNum >= $startPart && $partNum <= $stopPart) { $urls[$partNum] = Storage::url(static::cdn_path($pivot->video_bvid, $partNum)); } } return [ "id" => $pivot->id, "label" => "{$pivot->video_bvid} P{$pivot->start_part} {$pivot->start_time} → P{$pivot->stop_part} {$pivot->stop_time}", "bvid" => $pivot->video_bvid, "start_part" => $startPart, "start_sec" => $pivot->start_sec === "" ? null : $pivot->start_sec, "stop_part" => $stopPart, "stop_sec" => $pivot->stop_sec === "" ? null : $pivot->stop_sec, "urls" => $urls, ]; })->values()->all(); } 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"; } }