FixNewVideoTasks720pCommand.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281
  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. 480p 档任务切换为 720p(处理 480p -> 720p 档位残留);
  9. * 2. 检查 sr_720p / 720p / 480p 成功任务的 result_url、compressed_url 是否真的是 720p:
  10. * - result 为 480p 档 -> 本地超分到 720p,重新压缩,更新任务两列 URL;
  11. * - result 已是 720p+ 但 compressed 为 480p 档 -> 基于 result 重新压缩;
  12. * - 同步关联 mp_episode_segments 的 origin_video_url / video_url(仅 URL,其余字段不动)。
  13. *
  14. * 注意:本命令依赖 ffmpeg/ffprobe 且需在 APP_ENV != local 的环境执行
  15. * (upgrade480pTo720p / compressVideo 在 local 会短路)。
  16. */
  17. class FixNewVideoTasks720pCommand extends Command
  18. {
  19. /**
  20. * @var string
  21. */
  22. protected $signature = 'video:fix-new-tasks-720p
  23. {--min-id=12835 : 只处理 id 大于等于该值的任务}
  24. {--apply : 真正执行超分与更新;不带此参数时仅探测并输出报告}';
  25. /**
  26. * @var string
  27. */
  28. protected $description = '检查并修复 id>=12835 新数据任务的 720p 交付(超分 result/compressed 并同步分镜 URL)';
  29. /**
  30. * @return int
  31. */
  32. public function handle(): int
  33. {
  34. if (env('APP_ENV') === 'local') {
  35. $this->error('本命令需要在 APP_ENV != local 且安装 ffmpeg/ffprobe 的环境执行(local 下超分/压缩 helper 会短路)。');
  36. return 1;
  37. }
  38. $apply = (bool)$this->option('apply');
  39. $minId = (int)$this->option('min-id');
  40. $this->info($apply ? '开始执行修复(--apply)...' : '检查模式:仅探测与输出报告,加 --apply 才会超分并更新。');
  41. $tasks = DB::table('mp_generate_video_tasks')
  42. ->select([
  43. 'id', 'video_resolution', 'status', 'api_type',
  44. 'alias_segment_id', 'alias_act_id', 'result_url', 'compressed_url',
  45. ])
  46. ->where('id', '>=', $minId)
  47. ->whereIn('video_resolution', ['sr_720p', '720p', '480p'])
  48. ->where('status', 'success')
  49. ->whereNotNull('result_url')
  50. ->where('result_url', '<>', '')
  51. ->orderBy('id')
  52. ->get();
  53. $this->line(sprintf('候选任务数:%d', $tasks->count()));
  54. $alreadyOk = 0;
  55. $needResultFix = 0;
  56. $needCompressedFix = 0;
  57. $probeFailed = 0;
  58. $updated = 0;
  59. foreach ($tasks as $task) {
  60. $resultProbe = $this->probeUrl($task->result_url);
  61. if (!$resultProbe['ok']) {
  62. $probeFailed++;
  63. $this->warn(sprintf('#%d 探测 result_url 失败:%s', $task->id, $task->result_url));
  64. continue;
  65. }
  66. $resultShort = min($resultProbe['width'], $resultProbe['height']);
  67. $resultIs480 = $resultShort > 0 && abs($resultShort - 480) <= 60;
  68. $compressedShort = null;
  69. if (!empty($task->compressed_url)) {
  70. $compProbe = $this->probeUrl($task->compressed_url);
  71. if ($compProbe['ok']) {
  72. $compressedShort = min($compProbe['width'], $compProbe['height']);
  73. }
  74. }
  75. $compressedIs480 = $compressedShort !== null && abs($compressedShort - 480) <= 60;
  76. $this->line(sprintf(
  77. '#%d [%s] result=%dx%d compressed=%s%s',
  78. $task->id,
  79. $task->video_resolution,
  80. $resultProbe['width'],
  81. $resultProbe['height'],
  82. $compressedShort === null ? '?' : $compressedShort,
  83. $compressedShort === null ? ' (探测失败)' : ''
  84. ));
  85. $needChange = false;
  86. $newResult = $task->result_url;
  87. $newCompressed = $task->compressed_url;
  88. if ($resultIs480) {
  89. $needResultFix++;
  90. $needChange = true;
  91. if ($apply) {
  92. $enhanced = upgrade480pTo720p($task->result_url, 'video', false, '720p');
  93. if ($enhanced && $enhanced !== $task->result_url) {
  94. $newResult = $enhanced;
  95. $compressed = compressVideo($enhanced);
  96. $newCompressed = $compressed ?: $enhanced;
  97. $this->line(sprintf(
  98. ' -> 超分完成:%s%s',
  99. $newResult,
  100. $compressed ? '' : '(重新压缩失败,compressed_url 使用超分结果)'
  101. ));
  102. } else {
  103. $this->warn(sprintf('#%d 超分失败,保留原 result_url', $task->id));
  104. $needChange = false;
  105. }
  106. }
  107. } elseif ($compressedIs480 && !empty($task->compressed_url)) {
  108. $needCompressedFix++;
  109. $needChange = true;
  110. if ($apply) {
  111. $compressed = compressVideo($task->result_url);
  112. if ($compressed) {
  113. $newCompressed = $compressed;
  114. $this->line(' -> 基于已合格的 result 重新压缩完成');
  115. } else {
  116. $this->warn(sprintf('#%d 重新压缩失败,compressed_url 保持不变', $task->id));
  117. $needChange = false;
  118. }
  119. }
  120. } else {
  121. $alreadyOk++;
  122. }
  123. if (!$needChange) {
  124. continue;
  125. }
  126. if ($apply) {
  127. DB::table('mp_generate_video_tasks')
  128. ->where('id', $task->id)
  129. ->update([
  130. 'result_url' => $newResult,
  131. 'compressed_url' => $newCompressed,
  132. ]);
  133. $segmentSynced = $this->syncSegmentUrls($task, $newResult, $newCompressed);
  134. $this->line(sprintf('#%d 已更新任务 URL(分镜同步:%s)', $task->id, $segmentSynced ? '是' : '跳过/无关联'));
  135. $updated++;
  136. }
  137. }
  138. $this->info(sprintf(
  139. '汇总:待超分 result=%d,需重压 compressed=%d,已合格=%d,探测失败=%d,已更新任务=%d',
  140. $needResultFix,
  141. $needCompressedFix,
  142. $alreadyOk,
  143. $probeFailed,
  144. $updated
  145. ));
  146. if (!$apply) {
  147. $this->line('以上为检查结果;确认后在生产环境加 --apply 执行。');
  148. }
  149. return 0;
  150. }
  151. /**
  152. * 下载并探测视频分辨率
  153. *
  154. * @param string $url
  155. * @return array{ok:bool, width:int, height:int}
  156. */
  157. private function probeUrl(string $url): array
  158. {
  159. $tempDir = storage_path('app/temp/video_fix_check');
  160. if (!is_dir($tempDir)) {
  161. mkdir($tempDir, 0775, true);
  162. }
  163. $inputFile = '';
  164. try {
  165. $ext = getVideoExtFromUrl($url) ?: '.mp4';
  166. $inputFile = $tempDir . '/' . uniqid('vfix_') . bin2hex(random_bytes(3)) . $ext;
  167. $client = new Client(['timeout' => 300, 'verify' => false]);
  168. $client->get($url, ['sink' => $inputFile]);
  169. if (!file_exists($inputFile) || filesize($inputFile) <= 0) {
  170. return ['ok' => false, 'width' => 0, 'height' => 0];
  171. }
  172. $ffprobe = env('FFPROBE_PATH', 'ffprobe');
  173. $cmd = '"' . $ffprobe . '" -v quiet -print_format json -show_streams "' . $inputFile . '" 2>&1';
  174. $output = shell_exec($cmd);
  175. $info = json_decode((string)$output, true);
  176. $width = 0;
  177. $height = 0;
  178. if (isset($info['streams']) && is_array($info['streams'])) {
  179. foreach ($info['streams'] as $stream) {
  180. if (isset($stream['codec_type']) && $stream['codec_type'] === 'video') {
  181. $width = (int)($stream['width'] ?? 0);
  182. $height = (int)($stream['height'] ?? 0);
  183. break;
  184. }
  185. }
  186. }
  187. return ['ok' => $width > 0 && $height > 0, 'width' => $width, 'height' => $height];
  188. } catch (\Throwable $e) {
  189. return ['ok' => false, 'width' => 0, 'height' => 0];
  190. } finally {
  191. if ($inputFile && file_exists($inputFile)) {
  192. @unlink($inputFile);
  193. }
  194. }
  195. }
  196. /**
  197. * 同步关联 mp_episode_segments 的 URL(仅 origin_video_url / video_url)
  198. * 仅当分镜当前指向的就是该任务旧 URL 时才更新,避免覆盖同一分镜更新任务的产物。
  199. *
  200. * @param object $task
  201. * @param string $newResultUrl
  202. * @param string $newCompressedUrl
  203. * @return bool
  204. */
  205. private function syncSegmentUrls($task, string $newResultUrl, string $newCompressedUrl): bool
  206. {
  207. if (!empty($task->alias_segment_id)) {
  208. $segment = DB::table('mp_episode_segments')
  209. ->where('segment_id', $task->alias_segment_id)
  210. ->first(['id', 'origin_video_url', 'video_url']);
  211. if (!$segment) {
  212. return false;
  213. }
  214. $pointsToThisTask = $segment->origin_video_url === $task->result_url
  215. || $segment->video_url === $task->compressed_url;
  216. if (!$pointsToThisTask) {
  217. return false;
  218. }
  219. DB::table('mp_episode_segments')
  220. ->where('id', $segment->id)
  221. ->update([
  222. 'origin_video_url' => $newResultUrl,
  223. 'video_url' => $newCompressedUrl,
  224. ]);
  225. return true;
  226. }
  227. if (!empty($task->alias_act_id)) {
  228. $segment = DB::table('mp_episode_segments')
  229. ->where('id', $task->alias_act_id)
  230. ->first(['id', 'origin_video_url', 'video_url']);
  231. if (!$segment) {
  232. return false;
  233. }
  234. $pointsToThisTask = $segment->origin_video_url === $task->result_url
  235. || $segment->video_url === $task->compressed_url;
  236. if (!$pointsToThisTask) {
  237. return false;
  238. }
  239. DB::table('mp_episode_segments')
  240. ->where('id', $segment->id)
  241. ->update([
  242. 'origin_video_url' => $newResultUrl,
  243. 'video_url' => $newCompressedUrl,
  244. ]);
  245. return true;
  246. }
  247. return false;
  248. }
  249. }