FixNewVideoTasks720pCommand.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397
  1. <?php
  2. namespace App\Console\Commands;
  3. use GuzzleHttp\Client;
  4. use Illuminate\Console\Command;
  5. use Illuminate\Support\Facades\DB;
  6. /**
  7. * 处理 id >= 12835 的新数据视频任务(动漫 act/分镜链路):
  8. * 1. 仅处理 video_resolution=480p 且 mp_anime_episodes 对应分集设置为 sr_720p 的任务
  9. * (通过 alias_act_id/alias_segment_id 关联 mp_episode_segments 再取分集 video_resolution),
  10. * 分集未设置 sr_720p 的不做超分修复;
  11. * 2. task_id 为空的任务视为用户手动上传的视频,不做检测与超分处理;
  12. * 3. 检查上述任务的 result_url、compressed_url 是否真的是 720p:
  13. * - result 为 480p 档 -> 本地超分到 720p,重新压缩,更新任务两列 URL;
  14. * - result 已是 720p+ 但 compressed 为 480p 档 -> 基于 result 重新压缩;
  15. * - 同步关联 mp_episode_segments 的 origin_video_url / video_url(仅 URL,其余字段不动);
  16. * - 同步关联 mp_anime_records 中 assistant 记录(video_url 指向该任务旧 result_url)为修复后的 result_url。
  17. *
  18. * 注意:本命令依赖 ffmpeg/ffprobe 且需在 APP_ENV != local 的环境执行
  19. * (upgrade480pTo720p / compressVideo 在 local 会短路)。
  20. */
  21. class FixNewVideoTasks720pCommand extends Command
  22. {
  23. /**
  24. * @var string
  25. */
  26. protected $signature = 'video:fix-new-tasks-720p
  27. {--min-id=12835 : 只处理 id 大于等于该值的任务}
  28. {--apply : 真正执行超分与更新;不带此参数时仅探测并输出报告}';
  29. /**
  30. * @var string
  31. */
  32. protected $description = '检查并修复 id>=12835 新数据任务的 720p 交付(超分 result/compressed 并同步分镜 URL)';
  33. /**
  34. * @return int
  35. */
  36. public function handle(): int
  37. {
  38. if (env('APP_ENV') === 'local') {
  39. $this->error('本命令需要在 APP_ENV != local 且安装 ffmpeg/ffprobe 的环境执行(local 下超分/压缩 helper 会短路)。');
  40. return 1;
  41. }
  42. $apply = (bool)$this->option('apply');
  43. $minId = (int)$this->option('min-id');
  44. $this->info($apply ? '开始执行修复(--apply)...' : '检查模式:仅探测与输出报告,加 --apply 才会超分并更新。');
  45. $tasks = DB::table('mp_generate_video_tasks')
  46. ->select([
  47. 'id', 'video_resolution', 'status', 'api_type',
  48. 'alias_segment_id', 'alias_act_id', 'task_id', 'result_url', 'compressed_url',
  49. ])
  50. ->where('id', '>=', $minId)
  51. // 仅处理 480p 任务;task_id 为空 = 用户手动上传的视频,不处理
  52. ->whereRaw('LOWER(TRIM(video_resolution)) = ?', ['480p'])
  53. ->whereNotNull('task_id')
  54. ->where('task_id', '<>', '')
  55. ->where('status', 'success')
  56. ->whereNotNull('result_url')
  57. ->where('result_url', '<>', '')
  58. ->orderBy('id')
  59. ->get();
  60. $this->line(sprintf('候选任务数:%d', $tasks->count()));
  61. $alreadyOk = 0;
  62. $needResultFix = 0;
  63. $needCompressedFix = 0;
  64. $probeFailed = 0;
  65. $updated = 0;
  66. $skippedManual = 0;
  67. $skippedEpisode = 0;
  68. foreach ($tasks as $task) {
  69. // 防御:task_id 为空视为人工上传视频(查询已排除,双保险)
  70. if (empty($task->task_id)) {
  71. $skippedManual++;
  72. $this->warn(sprintf('#%d task_id 为空(疑似用户手动上传),跳过检测与超分', $task->id));
  73. continue;
  74. }
  75. // 仅当对应分集设置的 video_resolution 为 sr_720p 时才需要修复
  76. $episodeResolution = $this->resolveEpisodeResolution($task);
  77. if ($episodeResolution !== 'sr_720p') {
  78. $skippedEpisode++;
  79. $this->line(sprintf(
  80. '#%d 对应分集 video_resolution=%s(非 sr_720p),跳过超分修复',
  81. $task->id,
  82. $episodeResolution === '' ? '未知' : $episodeResolution
  83. ));
  84. continue;
  85. }
  86. $resultProbe = $this->probeUrl($task->result_url);
  87. if (!$resultProbe['ok']) {
  88. $probeFailed++;
  89. $this->warn(sprintf('#%d 探测 result_url 失败:%s', $task->id, $task->result_url));
  90. continue;
  91. }
  92. $resultShort = min($resultProbe['width'], $resultProbe['height']);
  93. $resultIs480 = $resultShort > 0 && abs($resultShort - 480) <= 60;
  94. $compressedShort = null;
  95. if (!empty($task->compressed_url)) {
  96. $compProbe = $this->probeUrl($task->compressed_url);
  97. if ($compProbe['ok']) {
  98. $compressedShort = min($compProbe['width'], $compProbe['height']);
  99. }
  100. }
  101. $compressedIs480 = $compressedShort !== null && abs($compressedShort - 480) <= 60;
  102. $this->line(sprintf(
  103. '#%d [%s] result=%dx%d compressed=%s%s',
  104. $task->id,
  105. $task->video_resolution,
  106. $resultProbe['width'],
  107. $resultProbe['height'],
  108. $compressedShort === null ? '?' : $compressedShort,
  109. $compressedShort === null ? ' (探测失败)' : ''
  110. ));
  111. $needChange = false;
  112. $newResult = $task->result_url;
  113. $newCompressed = $task->compressed_url;
  114. if ($resultIs480) {
  115. $needResultFix++;
  116. $needChange = true;
  117. if ($apply) {
  118. $enhanced = upgrade480pTo720p($task->result_url, 'video', false, '720p');
  119. if ($enhanced && $enhanced !== $task->result_url) {
  120. $newResult = $enhanced;
  121. $compressed = compressVideo($enhanced);
  122. $newCompressed = $compressed ?: $enhanced;
  123. $this->line(sprintf(
  124. ' -> 超分完成:%s%s',
  125. $newResult,
  126. $compressed ? '' : '(重新压缩失败,compressed_url 使用超分结果)'
  127. ));
  128. } else {
  129. $this->warn(sprintf('#%d 超分失败,保留原 result_url', $task->id));
  130. $needChange = false;
  131. }
  132. }
  133. } elseif ($compressedIs480 && !empty($task->compressed_url)) {
  134. $needCompressedFix++;
  135. $needChange = true;
  136. if ($apply) {
  137. $compressed = compressVideo($task->result_url);
  138. if ($compressed) {
  139. $newCompressed = $compressed;
  140. $this->line(' -> 基于已合格的 result 重新压缩完成');
  141. } else {
  142. $this->warn(sprintf('#%d 重新压缩失败,compressed_url 保持不变', $task->id));
  143. $needChange = false;
  144. }
  145. }
  146. } else {
  147. $alreadyOk++;
  148. }
  149. if (!$needChange) {
  150. continue;
  151. }
  152. if ($apply) {
  153. DB::table('mp_generate_video_tasks')
  154. ->where('id', $task->id)
  155. ->update([
  156. 'result_url' => $newResult,
  157. 'compressed_url' => $newCompressed,
  158. ]);
  159. $segmentSynced = $this->syncSegmentUrls($task, $newResult, $newCompressed);
  160. $recordSynced = $this->syncAnimeRecordUrls($task, $newResult);
  161. // 只更新不新增;完全没有 assistant 记录时需要即时通知人工确认
  162. if ($recordSynced === 0) {
  163. $assistantTotal = DB::table('mp_anime_records')
  164. ->where('video_task_id', $task->id)
  165. ->where('role', 'assistant')
  166. ->count();
  167. if ($assistantTotal === 0) {
  168. $notice = sprintf(
  169. '任务#%d 在 mp_anime_records 中完全没有 assistant 记录(只更新不新增),请人工确认是否需要补录',
  170. $task->id
  171. );
  172. $this->warn($notice);
  173. dLog('command')->warning($notice, [
  174. 'task_id' => $task->id,
  175. 'old_result_url' => $task->result_url,
  176. 'new_result_url' => $newResult,
  177. ]);
  178. logDB('command', 'warning', $notice, [
  179. 'task_id' => $task->id,
  180. 'old_result_url' => $task->result_url,
  181. 'new_result_url' => $newResult,
  182. ]);
  183. } else {
  184. $this->warn(sprintf(
  185. '任务#%d 有 %d 条 assistant 记录但均未指向旧 result_url,跳过 anime_records 同步',
  186. $task->id,
  187. $assistantTotal
  188. ));
  189. }
  190. }
  191. $this->line(sprintf(
  192. '#%d 已更新任务 URL(分镜同步:%s;anime_records 同步:%d 条)',
  193. $task->id,
  194. $segmentSynced ? '是' : '跳过/无关联',
  195. $recordSynced
  196. ));
  197. $updated++;
  198. }
  199. }
  200. $this->info(sprintf(
  201. '汇总:待超分 result=%d,需重压 compressed=%d,已合格=%d,探测失败=%d,已更新任务=%d,跳过人工上传=%d,跳过非sr_720p分集=%d',
  202. $needResultFix,
  203. $needCompressedFix,
  204. $alreadyOk,
  205. $probeFailed,
  206. $updated,
  207. $skippedManual,
  208. $skippedEpisode
  209. ));
  210. if (!$apply) {
  211. $this->line('以上为检查结果;确认后在生产环境加 --apply 执行。');
  212. }
  213. return 0;
  214. }
  215. /**
  216. * 下载并探测视频分辨率
  217. *
  218. * @param string $url
  219. * @return array{ok:bool, width:int, height:int}
  220. */
  221. private function probeUrl(string $url): array
  222. {
  223. $tempDir = storage_path('app/temp/video_fix_check');
  224. if (!is_dir($tempDir)) {
  225. mkdir($tempDir, 0775, true);
  226. }
  227. $inputFile = '';
  228. try {
  229. $ext = getVideoExtFromUrl($url) ?: '.mp4';
  230. $inputFile = $tempDir . '/' . uniqid('vfix_') . bin2hex(random_bytes(3)) . $ext;
  231. $client = new Client(['timeout' => 300, 'verify' => false]);
  232. $client->get($url, ['sink' => $inputFile]);
  233. if (!file_exists($inputFile) || filesize($inputFile) <= 0) {
  234. return ['ok' => false, 'width' => 0, 'height' => 0];
  235. }
  236. $ffprobe = env('FFPROBE_PATH', 'ffprobe');
  237. $cmd = '"' . $ffprobe . '" -v quiet -print_format json -show_streams "' . $inputFile . '" 2>&1';
  238. $output = shell_exec($cmd);
  239. $info = json_decode((string)$output, true);
  240. $width = 0;
  241. $height = 0;
  242. if (isset($info['streams']) && is_array($info['streams'])) {
  243. foreach ($info['streams'] as $stream) {
  244. if (isset($stream['codec_type']) && $stream['codec_type'] === 'video') {
  245. $width = (int)($stream['width'] ?? 0);
  246. $height = (int)($stream['height'] ?? 0);
  247. break;
  248. }
  249. }
  250. }
  251. return ['ok' => $width > 0 && $height > 0, 'width' => $width, 'height' => $height];
  252. } catch (\Throwable $e) {
  253. return ['ok' => false, 'width' => 0, 'height' => 0];
  254. } finally {
  255. if ($inputFile && file_exists($inputFile)) {
  256. @unlink($inputFile);
  257. }
  258. }
  259. }
  260. /**
  261. * 通过任务的 alias_act_id / alias_segment_id 关联分镜,再取对应分集的 video_resolution
  262. *
  263. * @param object $task
  264. * @return string 小写分集分辨率;无法关联时返回空字符串
  265. */
  266. private function resolveEpisodeResolution($task): string
  267. {
  268. $segment = null;
  269. if (!empty($task->alias_act_id)) {
  270. $segment = DB::table('mp_episode_segments')
  271. ->where('id', $task->alias_act_id)
  272. ->first(['episode_id']);
  273. } elseif (!empty($task->alias_segment_id)) {
  274. $segment = DB::table('mp_episode_segments')
  275. ->where('segment_id', $task->alias_segment_id)
  276. ->first(['episode_id']);
  277. }
  278. if (!$segment || empty($segment->episode_id)) {
  279. return '';
  280. }
  281. $episode = DB::table('mp_anime_episodes')
  282. ->where('id', $segment->episode_id)
  283. ->first(['video_resolution']);
  284. return strtolower(trim((string)($episode->video_resolution ?? '')));
  285. }
  286. /**
  287. * 同步 mp_anime_records 中 assistant 对话记录的 video_url
  288. * 仅更新 video_task_id 匹配、role=assistant 且 video_url 仍指向该任务旧 result_url 的行,
  289. * 避免覆盖同 act 其他任务写入的记录。
  290. *
  291. * @param object $task
  292. * @param string $newResultUrl
  293. * @return int 更新的记录数
  294. */
  295. private function syncAnimeRecordUrls($task, string $newResultUrl): int
  296. {
  297. return DB::table('mp_anime_records')
  298. ->where('video_task_id', $task->id)
  299. ->where('role', 'assistant')
  300. ->where('video_url', $task->result_url)
  301. ->update(['video_url' => $newResultUrl]);
  302. }
  303. /**
  304. * 同步关联 mp_episode_segments 的 URL(仅 origin_video_url / video_url)
  305. * 仅当分镜当前指向的就是该任务旧 URL 时才更新,避免覆盖同一分镜更新任务的产物。
  306. *
  307. * @param object $task
  308. * @param string $newResultUrl
  309. * @param string $newCompressedUrl
  310. * @return bool
  311. */
  312. private function syncSegmentUrls($task, string $newResultUrl, string $newCompressedUrl): bool
  313. {
  314. if (!empty($task->alias_segment_id)) {
  315. $segment = DB::table('mp_episode_segments')
  316. ->where('segment_id', $task->alias_segment_id)
  317. ->first(['id', 'origin_video_url', 'video_url']);
  318. if (!$segment) {
  319. return false;
  320. }
  321. $pointsToThisTask = $segment->origin_video_url === $task->result_url
  322. || $segment->video_url === $task->compressed_url;
  323. if (!$pointsToThisTask) {
  324. return false;
  325. }
  326. DB::table('mp_episode_segments')
  327. ->where('id', $segment->id)
  328. ->update([
  329. 'origin_video_url' => $newResultUrl,
  330. 'video_url' => $newCompressedUrl,
  331. ]);
  332. return true;
  333. }
  334. if (!empty($task->alias_act_id)) {
  335. $segment = DB::table('mp_episode_segments')
  336. ->where('id', $task->alias_act_id)
  337. ->first(['id', 'origin_video_url', 'video_url']);
  338. if (!$segment) {
  339. return false;
  340. }
  341. $pointsToThisTask = $segment->origin_video_url === $task->result_url
  342. || $segment->video_url === $task->compressed_url;
  343. if (!$pointsToThisTask) {
  344. return false;
  345. }
  346. DB::table('mp_episode_segments')
  347. ->where('id', $segment->id)
  348. ->update([
  349. 'origin_video_url' => $newResultUrl,
  350. 'video_url' => $newCompressedUrl,
  351. ]);
  352. return true;
  353. }
  354. return false;
  355. }
  356. }