Просмотр исходного кода

新增压缩图片和创建图生视频功能

lh 4 месяцев назад
Родитель
Сommit
524806cd1b

+ 1 - 1
app/Console/Commands/CheckImageGenerationTasksCommand.php

@@ -12,7 +12,7 @@ class CheckImageGenerationTasksCommand extends Command
      *
      * @var string
      */
-    protected $signature = 'image-generation:check-tasks';
+    protected $signature = 'AIGeneration:checkImgTasks';
 
     /**
      * The console command description.

+ 45 - 0
app/Console/Commands/CheckVideoGenerationTasksCommand.php

@@ -0,0 +1,45 @@
+<?php
+
+namespace App\Console\Commands;
+
+use App\Services\AIGeneration\AIVideoGenerationService;
+use Illuminate\Console\Command;
+
+class CheckVideoGenerationTasksCommand extends Command
+{
+    /**
+     * The name and signature of the console command.
+     *
+     * @var string
+     */
+    protected $signature = 'AIGeneration:checkVideoTasks';
+
+    /**
+     * The console command description.
+     *
+     * @var string
+     */
+    protected $description = '检查视频生成任务状态并更新结果';
+
+    /**
+     * Execute the console command.
+     *
+     * @return int
+     */
+    public function handle(AIVideoGenerationService $videoGenerationService)
+    {
+        $this->info('开始检查视频生成任务状态...');
+        
+        try {
+            // 更新所有待处理的视频生成任务状态
+            $videoGenerationService->updatePendingTasks();
+            
+            $this->info('视频生成任务状态检查完成');
+        } catch (\Exception $e) {
+            $this->error('视频生成任务状态检查失败: ' . $e->getMessage());
+            return 1;
+        }
+
+        return 0;
+    }
+}

+ 4 - 1
app/Console/Kernel.php

@@ -23,7 +23,10 @@ class Kernel extends ConsoleKernel
     protected function schedule(Schedule $schedule)
     {
         // 每5分钟检查一次图片生成任务状态
-        $schedule->command('image-generation:check-tasks')->everyFiveMinutes();
+        $schedule->command('AIGeneration:checkImgTasks')->everyFiveMinutes();
+        
+        // 每5分钟检查一次视频生成任务状态
+        $schedule->command('AIGeneration:checkVideoTasks')->everyFiveMinutes();
     }
 
     /**

+ 3 - 2
app/Http/Controllers/AIGeneration/ImageGenerationController.php

@@ -16,8 +16,9 @@ class ImageGenerationController extends BaseController
     use ApiResponse;
     private $aiImageGenerationService;
 
-    public function __construct(AIImageGenerationService $aiImageGenerationService)
-    {
+    public function __construct(
+        AIImageGenerationService $aiImageGenerationService
+    ) {
         $this->aiImageGenerationService = $aiImageGenerationService;
     }
 

+ 213 - 0
app/Http/Controllers/AIGeneration/VideoGenerationController.php

@@ -0,0 +1,213 @@
+<?php
+
+namespace App\Http\Controllers\AIGeneration;
+
+use Illuminate\Routing\Controller as BaseController;
+use App\Libs\Utils;
+use App\Services\AIGeneration\AIVideoGenerationService;
+use App\Transformer\AIGeneration\AIGenerationTransformer;
+use Illuminate\Http\Request;
+use Illuminate\Http\JsonResponse;
+use Illuminate\Support\Facades\Validator;
+use App\Libs\ApiResponse;
+
+class VideoGenerationController extends BaseController
+{
+    use ApiResponse;
+    private $aiVideoGenerationService;
+
+    public function __construct(
+        AIVideoGenerationService $aiVideoGenerationService
+    ) {
+        $this->aiVideoGenerationService = $aiVideoGenerationService;
+    }
+
+    /**
+     * 创建视频生成任务
+     *
+     * @param Request $request
+     * @return JsonResponse
+     */
+    public function createTask(Request $request): JsonResponse
+    {
+        $data = $request->all();
+        
+        // 首先验证基本参数
+        $baseRules = [
+            'prompt' => 'required|string|max:1000',
+            'seed' => 'required|integer',
+            'video_duration' => 'required|in:5,10',
+        ];
+        
+        $baseMessages = [
+            'prompt.required' => '提示词不能为空',
+            'prompt.max' => '提示词不能超过1000个字符',
+            'video_duration.required' => '请选择视频时长',
+            'video_duration.in' => '视频时长只能是5s或10s',
+        ];
+        
+        // 根据传入参数决定验证规则
+        if (isset($data['first_frame_url']) && isset($data['tail_frame_url'])) {
+            // URL 方式
+            $validationRules = array_merge($baseRules, [
+                'first_frame_url' => 'required|url',
+                'tail_frame_url' => 'required|url',
+            ]);
+            
+            $validationMessages = array_merge($baseMessages, [
+                'first_frame_url.required' => '首帧图片URL不能为空',
+                'first_frame_url.url' => '首帧图片URL格式不正确',
+                'tail_frame_url.required' => '尾帧图片URL不能为空',
+                'tail_frame_url.url' => '尾帧图片URL格式不正确',
+            ]);
+        } else {
+            // 上传文件方式
+            $validationRules = array_merge($baseRules, [
+                'first_frame_file' => 'required|image|mimes:jpeg,png|max:4800', // 4.7MB = 4915KB
+                'tail_frame_file' => 'required|image|mimes:jpeg,png|max:4800',
+            ]);
+            
+            $validationMessages = array_merge($baseMessages, [
+                'first_frame_file.required' => '首帧图片不能为空',
+                'first_frame_file.image' => '首帧必须是图片文件',
+                'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式',
+                'first_frame_file.max' => '首帧图片大小不能超过4.7MB',
+                'tail_frame_file.required' => '尾帧图片不能为空',
+                'tail_frame_file.image' => '尾帧必须是图片文件',
+                'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式',
+                'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB',
+            ]);
+        }
+        
+        $validator = Validator::make($data, $validationRules, $validationMessages);
+        
+        if ($validator->fails()) {
+            Utils::throwError('1002:'.$validator->errors()->first());
+        }
+
+        // 处理上传的图片
+        if ($request->hasFile('first_frame_file') && $request->hasFile('tail_frame_file')) {
+            $firstFrameFile = $request->file('first_frame_file');
+            $tailFrameFile = $request->file('tail_frame_file');
+
+            $firstFrameExtension = $firstFrameFile->getClientOriginalExtension();
+            $tailFrameExtension = $tailFrameFile->getClientOriginalExtension();
+            
+            // 验证图片分辨率和比例
+            $firstFrameInfo = @getimagesize($firstFrameFile->getPathname());
+            $tailFrameInfo = @getimagesize($tailFrameFile->getPathname());
+            
+            if (!$firstFrameInfo || !$tailFrameInfo) {
+                Utils::throwError('1003:图片格式无效');
+            }
+            
+            $firstFrame = [
+                'width' => $firstFrameInfo[0],
+                'height' => $firstFrameInfo[1],
+                'size' => $firstFrameFile->getSize(),
+            ];
+            
+            $tailFrame = [
+                'width' => $tailFrameInfo[0],
+                'height' => $tailFrameInfo[1],
+                'size' => $tailFrameFile->getSize(),
+            ];
+            
+            // 上传图片到服务器并获取URL
+            $firstFramePath = $firstFrameFile->store('video_frames', 'public');
+            $tailFramePath = $tailFrameFile->store('video_frames', 'public');
+
+            // 获取图片绝对路径
+            $firstFramePath = storage_path('app/public/' . $firstFramePath);
+            $tailFramePath = storage_path('app/public/' . $tailFramePath);
+            
+            // 图片上传的方式则获取图片的图片文件base64编码
+            $data['first_frame_base64'] = 'data:image/' . $firstFrameExtension . ';base64,' . base64_encode(file_get_contents($firstFramePath));
+            $data['tail_frame_base64'] = 'data:image/' . $tailFrameExtension . ';base64,' . base64_encode(file_get_contents($tailFramePath));
+
+            unlink($firstFramePath);
+            unlink($tailFramePath);
+
+        } else {
+            // 使用URL方式,验证远程图片
+            $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true);
+            $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true);
+            if (!$firstFrame || !$tailFrame) {
+                Utils::throwError('1003:图片获取失败');
+            }
+        }
+        
+        // 判断首尾帧图片是否满足以下条件:
+        // 1.图片文件最大4.7MB
+        // 2.图片分辨率最大: 4096*4096, 最短边不低于320
+        // 3.图片长边与短边比例在3以内
+        // 4.尾帧图片与首帧图片比例相同
+        if ($firstFrame['size'] > 4.7 * 1024 * 1024) {
+            Utils::throwError('1003:图片大小不能超过4.7MB');
+        }
+        if ($firstFrame['width'] > 4096 || $firstFrame['height'] > 4096 || $firstFrame['width'] < 320 || $firstFrame['height'] < 320) {
+            Utils::throwError('1003:图片分辨率不能超过4096*4096,且最短边不能低于320');
+        }
+        if ($firstFrame['width'] / $firstFrame['height'] > 3 || $tailFrame['width'] / $tailFrame['height'] > 3) {
+            Utils::throwError('1003:图片长边与短边比例不能超过3');
+        }
+        if ($firstFrame['width'] / $firstFrame['height'] != $tailFrame['width'] / $tailFrame['height']) {
+            Utils::throwError('1003:尾帧图片与首帧图片比例相同');
+        }
+
+        dd($data);
+
+        try {
+            $task = $this->aiVideoGenerationService->createVideoGenerationTask($data);
+
+            return $this->success(['task_id' => $task->task_id, 'status' => $task->status]);
+        } catch (\Exception $e) {
+            Utils::throwError('1001:'.$e->getMessage());
+        }
+    }
+
+    /**
+     * 查询视频任务状态
+     *
+     * @param string $taskId
+     * @return JsonResponse
+     */
+    public function taskStatus(string $taskId): JsonResponse
+    {
+        $task = \App\Models\MpGenerateVideoTask::where('task_id', $taskId)->first();
+
+        if (!$task) {
+            return response()->json([
+                'success' => false,
+                'message' => '任务不存在'
+            ], 404);
+        }
+
+        return response()->json([
+            'success' => true,
+            'data' => [
+                'task_id' => $task->task_id,
+                'status' => $task->status,
+                'result_url' => $task->result_url,
+                'error_message' => $task->error_message,
+                'created_at' => $task->created_at,
+                'started_at' => $task->started_at,
+                'completed_at' => $task->completed_at
+            ]
+        ]);
+    }
+
+    /**
+     * 获取视频任务列表
+     *
+     * @param Request $request
+     * @return mixed
+     */
+    public function taskList(Request $request)
+    {
+        $data = $request->all();
+
+        $result = $this->aiVideoGenerationService->getTaskList($data);
+        return $this->success($result, [new AIGenerationTransformer(), 'newBuildTaskList']);
+    }
+}

+ 171 - 2
app/Libs/Helpers.php

@@ -312,8 +312,10 @@ function uploadStreamByTos($prefix, $stream, $filename)
         $client = new TosClient([
             'region'    => env('VOLC_REGION'),
             'endpoint'  => env('VOLC_END_POINT'),
-            'ak'        => env('VOLC_AK'),
-            'sk'        => env('VOLC_SK'),
+            // 'ak'        => env('VOLC_AK'),
+            // 'sk'        => env('VOLC_SK'),
+            'ak'        => 'AKLTNDRjMzdiODkwMThhNDdhZThhOTI3MDg5MDkzYzExNTg',
+            'sk'        => 'TXpOak4yWmxZemxrTVRVMU5EZzVNMkprT0dJMU9HSTBaR1kzWW1NMk5qaw==',
         ]);
 
         $input = new PutObjectInput(env('VOLC_BUCKET'),  "$prefix/$filename", $stream);
@@ -2079,3 +2081,170 @@ function handleScriptContent($originalContent) {
         
     return $parts;
 }
+
+
+/**
+ * 远程图片压缩(尽可能保持原图 fidelity,压缩到不超过 maxBytes 字节)
+ * 采用有损/无损组合策略:保留原始格式尽量不失真,若需要则通过尺寸缩放和质量调节来降低体积。
+ *
+ * @param string $url       远程图片 URL
+ * @param int    $maxBytes  最大字节数,默认 2MB
+ * @return string|null      返回压缩后的图片二进制数据,失败时返回 null
+ */
+function compressRemoteImageUrlToSize(string $url, int $maxBytes = 3 * 1024 * 1024): ?string
+{
+    // 1) 下载图片数据(使用 Guzzle 以避免 allow_url_fopen 依赖)
+    try {
+        $client = new Client(['timeout' => 30]);
+        $response = $client->get($url, ['stream' => true]);
+        if ($response->getStatusCode() !== 200) {
+            return null;
+        }
+        $data = $response->getBody()->getContents();
+    } catch (\Exception $e) {
+        return null;
+    }
+
+    if (!$data) return null;
+
+    // 2) 识别图片类型
+    $imgInfo = @getimagesizefromstring($data);
+    $mime    = $imgInfo['mime'] ?? '';
+    // 3) 载入图像对象
+    $srcImg = @imagecreatefromstring($data);
+    if (!$srcImg) {
+        return null;
+    }
+    $origW = imagesx($srcImg);
+    $origH = imagesy($srcImg);
+
+    // 3.1) 内部渲染成不同格式的字符串
+    $render = function($srcRes, string $mimeType, int $quality) {
+        ob_start();
+        switch (strtolower($mimeType)) {
+            case 'image/jpeg':
+            case 'image/jpg':
+            case 'image/pjpeg':
+                // 输出 JPEG
+                imagejpeg($srcRes, null, $quality);
+                break;
+            case 'image/png':
+                // 将 quality 映射到 PNG 的 compression level (0-9)
+                $level = (int)round((100 - $quality) / 11.11);
+                if ($level < 0) $level = 0;
+                if ($level > 9) $level = 9;
+                imagepng($srcRes, null, $level);
+                break;
+            case 'image/webp':
+                if (function_exists('imagewebp')) {
+                    imagewebp($srcRes, null, $quality);
+                } else {
+                    imagejpeg($srcRes, null, $quality);
+                }
+                break;
+            default:
+                imagejpeg($srcRes, null, $quality);
+                break;
+        }
+        $out = ob_get_contents();
+        ob_end_clean();
+        return $out;
+    };
+
+    // 4) 尝试策略:尽量保留原图尺寸,逐步降维/降质量,直到 <= maxBytes
+    $tryList = [];
+    // 原始尺寸,尽量保留
+    $tryList[] = ['scale'=>1.0, 'mime'=>$mime, 'quality'=>100];
+    // 逐步缩小尺寸
+    for ($s = 0.9; $s >= 0.2; $s -= 0.1) {
+        $tryList[] = ['scale'=>$s, 'mime'=>$mime, 'quality'=>90];
+    }
+    // 一系列质量等级(用于 JPEG/WebP)
+    $qualityLevels = [95, 90, 85, 75, 60, 50, 40, 30];
+    foreach ($qualityLevels as $q) {
+        $tryList[] = ['scale'=>1.0, 'mime'=>$mime, 'quality'=>$q];
+    }
+
+    $bestData = null;
+    foreach ($tryList as $cand) {
+        $scale  = isset($cand['scale']) ? (float)$cand['scale'] : 1.0;
+        $mimeT  = $cand['mime'] ?? $mime;
+        $quality = isset($cand['quality']) ? (int)$cand['quality'] : 90;
+
+        $w = (int)round($origW * $scale);
+        $h = (int)round($origH * $scale);
+        $src = $srcImg;
+        $tempImg = null;
+        if ($scale < 1.0) {
+            $tempImg = imagecreatetruecolor($w, $h);
+            // 处理透明通道
+            imagealphablending($tempImg, false);
+            imagesavealpha($tempImg, true);
+            if (in_array(strtolower($mime), ['image/png','image/webp'])) {
+                $transparent = imagecolorallocatealpha($tempImg, 0, 0, 0, 127);
+                imagefill($tempImg, 0, 0, $transparent);
+            }
+            // 确保 $srcImg 有效
+            if (!$srcImg || (!is_resource($srcImg) && !($srcImg instanceof \GdImage))) {
+                safeDestroyImage($tempImg);
+                continue;
+            }
+            imagecopyresampled($tempImg, $srcImg, 0, 0, 0, 0, $w, $h, $origW, $origH);
+            $src = $tempImg;
+        }
+        $imageBytes = $render($src, $mimeT, $quality);
+        if ($tempImg !== null) {
+            safeDestroyImage($tempImg);
+        }
+        if ($imageBytes !== false && strlen($imageBytes) <= $maxBytes) {
+            $bestData = $imageBytes;
+            break;
+        }
+    }
+    // 5) 回退策略:若仍未达到要求,尝试更大幅度降解到一个合理的小尺寸 JPEG
+    if ($bestData === null) {
+        $tmpW = max(1, (int)round($origW * 0.5));
+        $tmpH = max(1, (int)round($origH * 0.5));
+        $tmpImg = imagecreatetruecolor($tmpW, $tmpH);
+        imagealphablending($tmpImg, false);
+        imagesavealpha($tmpImg, true);
+        if (in_array(strtolower($mime), ['image/png','image/webp'])) {
+            $transparent = imagecolorallocatealpha($tmpImg, 0, 0, 0, 127);
+            imagefill($tmpImg, 0, 0, $transparent);
+        }
+        imagecopyresampled($tmpImg, $srcImg, 0, 0, 0, 0, $tmpW, $tmpH, $origW, $origH);
+        ob_start();
+        if (in_array(strtolower($mime), ['image/jpeg','image/jpg','image/pjpeg'])) {
+            imagejpeg($tmpImg, null, 75);
+        } elseif (strtolower($mime) === 'image/png') {
+            imagepng($tmpImg, null, 6);
+        } elseif (function_exists('imagewebp')) {
+            imagewebp($tmpImg, null, 75);
+        } else {
+            imagejpeg($tmpImg, null, 75);
+        }
+        $tmpBytes = ob_get_contents();
+        ob_end_clean();
+        safeDestroyImage($tmpImg);
+        if ($tmpBytes !== '' && strlen($tmpBytes) <= $maxBytes) {
+            $bestData = $tmpBytes;
+        }
+    }
+
+    // 6) 清理
+    safeDestroyImage($srcImg);
+    return $bestData;
+}
+/**
+ * 安全销毁 GD 图像资源(兼容 PHP7.4+ 的资源管理)
+ * 通过引用传递并在销毁后置空变量,避免未定义变量的问题
+ *
+ * @param resource|\GdImage|null &$img GD 图像资源或对象(PHP7.4 为 resource,PHP8.0+ 为 GdImage)
+ */
+function safeDestroyImage(&$img)
+{
+    if (is_resource($img) || (is_object($img) && $img instanceof \GdImage)) {
+        @imagedestroy($img);
+    }
+    $img = null;
+}

+ 60 - 1
app/Libs/Utils.php

@@ -255,6 +255,65 @@ class Utils
                 'updated_at'    => date('Y-m-d H:i:s'),
             ]);
         }
-        return '请在抖音搜索“火猫小说”后搜索口令'.$code.',开始阅读吧!';
+        return '请在抖音搜索"火猫小说"后搜索口令'.$code.',开始阅读吧!';
+    }
+    
+    /**
+     * 获取远程图片信息
+     * @param string $url 图片URL
+     * @param bool $needSize 是否需要获取文件大小
+     * @return array|false
+     */
+    public static function getRemoteImageInfo($url, $needSize = false)
+    {
+        try {
+            // 使用getimagesize获取图片信息
+            $imageInfo = @getimagesize($url);
+                
+            if ($imageInfo === false) {
+                return false;
+            }
+                
+            $info = [
+                'width' => $imageInfo[0],
+                'height' => $imageInfo[1],
+                'mime' => $imageInfo['mime'] ?? '',
+                'size' => 0,
+                'extension' => ''
+            ];
+
+            if ($info['mime']) {
+                $info['extension'] = mimeToExt($info['mime']);
+            }
+                
+            // 如果需要获取文件大小
+            if ($needSize) {
+                $headers = @get_headers($url, 1);
+                if ($headers && isset($headers['Content-Length'])) {
+                    $info['size'] = is_array($headers['Content-Length']) ? 
+                                   (int)end($headers['Content-Length']) : 
+                                   (int)$headers['Content-Length'];
+                } else {
+                    // 如果无法通过headers获取,尝试下载部分内容来估算大小
+                    $context = stream_context_create([
+                        'http' => [
+                            'method' => 'GET',
+                            'header' => 'Range: bytes=0-1023', // 只下载前1KB来估算
+                            'timeout' => 10
+                        ]
+                    ]);
+                        
+                    $partialContent = @file_get_contents($url, false, $context);
+                    if ($partialContent !== false) {
+                        // 这只是一个估算值,实际大小可能不同
+                        $info['size'] = strlen($partialContent);
+                    }
+                }
+            }
+                
+            return $info;
+        } catch (\Exception $e) {
+            return false;
+        }
     }
 }

+ 67 - 0
app/Models/MpGenerateVideoTask.php

@@ -0,0 +1,67 @@
+<?php
+
+namespace App\Models;
+
+use Illuminate\Database\Eloquent\Model;
+
+class MpGenerateVideoTask extends Model
+{
+    protected $table = 'mp_generate_video_tasks';
+
+    const STATUS_PENDING = 'pending';
+    const STATUS_PROCESSING = 'processing';
+    const STATUS_SUCCESS = 'success';
+    const STATUS_FAILED = 'failed';
+
+    protected $fillable = [
+        'task_id',
+        'prompt',
+        'first_frame_url',
+        'tail_frame_url',
+        'seed',
+        'frames',
+        'status',
+        'result_url',
+        'error_message',
+        'extra_params',
+        'started_at',
+        'completed_at',
+    ];
+
+    protected $casts = [
+        'extra_params' => 'array',
+    ];
+
+    /**
+     * 更新任务状态
+     * @param string $status
+     * @param array $data
+     * @return void
+     */
+    public function updateStatus(string $status, array $data = []): void
+    {
+        $updateData = ['status' => $status];
+        
+        if (isset($data['result_url'])) {
+            $updateData['result_url'] = $data['result_url'];
+        }
+        
+        if (isset($data['error_message'])) {
+            $updateData['error_message'] = $data['error_message'];
+        }
+        
+        if (isset($data['extra_params'])) {
+            $updateData['extra_params'] = $data['extra_params'];
+        }
+        
+        if ($status === self::STATUS_PROCESSING && !$this->started_at) {
+            $updateData['started_at'] = now();
+        }
+        
+        if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_FAILED]) && !$this->completed_at) {
+            $updateData['completed_at'] = now();
+        }
+        
+        $this->update($updateData);
+    }
+}

+ 3 - 3
app/Services/AIGeneration/AIImageGenerationService.php

@@ -233,11 +233,11 @@ class AIImageGenerationService
                 foreach ($result['image_urls'] as $url) {
                     $pic_name = 'ai_generation_' . time() . '_' . uniqid();
                     $pic_ext = getExtFromContent($url);
-                    $pic_name = $pic_name . '.' . $pic_ext;
+                    $pic_name = $pic_name . $pic_ext;
                     
                     // 将图片另存到tos
-                    // $url = uploadStreamByTos('img_generation', file_get_contents($url), $pic_name);
-                    $result_urls[] = $url;  
+                    $url = uploadStreamByTos('image', file_get_contents($url), $pic_name);
+                    $result_urls[] = $url;
                 }
                 $returnData['result_url'] = $result_urls;
                 $returnData['status'] = 'success';

+ 269 - 0
app/Services/AIGeneration/AIVideoGenerationService.php

@@ -0,0 +1,269 @@
+<?php
+
+namespace App\Services\AIGeneration;
+
+use App\Models\MpGenerateVideoTask;
+use App\Services\VolcEngineService;
+use GuzzleHttp\Client;
+use GuzzleHttp\Exception\GuzzleException;
+
+class AIVideoGenerationService
+{
+    private $volcEngineService;
+    private $httpClient;
+
+    public function __construct(VolcEngineService $volcEngineService)
+    {
+        $this->volcEngineService = $volcEngineService;
+        $this->httpClient = new Client([
+            'timeout' => 300,
+        ]);
+    }
+
+    /**
+     * 异步创建视频生成任务
+     *
+     * @param array $params
+     * @return MpGenerateVideoTask
+     */
+    public function createVideoGenerationTask(array $params): MpGenerateVideoTask
+    {
+        // 生成唯一的任务ID
+        $taskId = 'vid_gen_' . time() . '_' . uniqid();
+
+        // 创建任务记录
+        $task = MpGenerateVideoTask::create([
+            'task_id' => $taskId,
+            'prompt' => $params['prompt'] ?? '',
+            'first_frame_url' => $params['first_frame_url'] ?? null,
+            'tail_frame_url' => $params['tail_frame_url'] ?? null,
+            'seed' => $params['seed'] ?? -1,
+            'frames' => $params['frames'] ?? 121,
+            'status' => MpGenerateVideoTask::STATUS_PENDING,
+        ]);
+
+        // 发送异步请求到即梦AI API创建任务
+        $this->submitTaskToApi($task);
+
+        return $task;
+    }
+
+    /**
+     * 提交任务到即梦AI API
+     *
+     * @param MpGenerateVideoTask $task
+     * @return void
+     */
+    private function submitTaskToApi(MpGenerateVideoTask $task): void
+    {
+        try {
+            // 验证环境变量配置
+            $accessKey = env('VOLC_AK');
+            $secretKey = env('VOLC_SK');
+                
+            if (empty($accessKey) || empty($secretKey)) {
+                $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
+                    'error_message' => '火山引擎访问密钥未配置,请检查环境变量VOLC_AK和VOLC_SK'
+                ]);
+                return;
+            }
+                
+            // 构建即梦AI 3.0 视频生成API请求参数
+            $apiParams = [
+                'req_key' => 'jimeng_i2v_first_tail_v30',
+                'prompt' => $task->prompt,
+                'seed' => $task->seed,
+                'frames' => $task->frames,
+            ];
+            
+            // 添加图片URL参数
+            if ($task->first_frame_url && $task->tail_frame_url) {
+                $apiParams['image_urls'] = [
+                    $task->first_frame_url,
+                    $task->tail_frame_url
+                ];
+            }
+            
+            // 调用即梦AI 3.0 视频生成API提交任务
+            $response = $this->volcEngineService->request(
+                'POST',
+                'visual.volcengineapi.com', // 即梦AI API域名
+                '/', // 使用根路径
+                [], // Query参数
+                json_encode($apiParams),
+                $accessKey,
+                $secretKey,
+                'cv', // 服务标识
+                'cn-north-1', // 区域
+                'CVSync2AsyncSubmitTask',
+                '2022-08-31',
+                'application/json'
+            );
+
+            $responseData = json_decode($response['body'], true);
+            dLog('generate')->info('即梦AI 3.0 视频生成API提交任务响应: ', $responseData);
+                
+            if ($responseData['code'] !== 10000) {
+                // API返回错误
+                $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
+                    'error_message' => $responseData['message'] ?? 'API Error',
+                    'extra_params' => $responseData['data'] ?? []
+                ]);
+            } else {
+                // 任务提交成功,更新状态为处理中
+                $updateData = [];
+                // 存储API返回的任务ID
+                if (isset($responseData['data']['task_id'])) {
+                    $updateData['task_id'] = $responseData['data']['task_id'];
+                    $updateData['extra_params'] = $responseData['data'] ?? [];
+                }
+
+                $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData);
+            }
+        } catch (\Exception $e) {
+            // 记录错误
+            $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
+                'error_message' => 'API请求失败: ' . $e->getMessage()
+            ]);
+        }
+    }
+
+    /**
+     * 查询任务状态
+     *
+     * @param MpGenerateVideoTask $task
+     * @return array
+     */
+    public function queryTaskStatus(MpGenerateVideoTask $task): array
+    {
+        try {
+            // 获取API任务ID
+            $apiTaskId = $task->task_id ?? null;
+                
+            if (!$apiTaskId) {
+                return [
+                    'status' => 'failed',
+                    'error_message' => 'API任务ID不存在',
+                    'result_url' => null
+                ];
+            }
+    
+            // 构建查询参数
+            $apiParams = [
+                'req_key' => 'jimeng_i2v_first_tail_v30',
+                'task_id' => $apiTaskId,
+                'req_json' => '{"return_url":true}' // 返回URL格式
+            ];
+    
+            // 调用即梦AI 3.0 视频生成API查询任务状态
+            $response = $this->volcEngineService->request(
+                'POST',
+                'visual.volcengineapi.com',
+                '/', // 使用根路径
+                [], // Query参数
+                json_encode($apiParams),
+                env('VOLC_AK'),
+                env('VOLC_SK'),
+                'cv',
+                'cn-north-1',
+                'CVSync2AsyncGetResult',
+                '2022-08-31',
+                'application/json'
+            );
+    
+            $responseData = json_decode($response['body'], true);
+            dLog('generate')->info('即梦AI 3.0 视频生成API查询任务状态响应: ', $responseData);
+    
+            if ($responseData['code'] !== 10000) {
+                // API返回错误
+                return [
+                    'status' => 'failed',
+                    'error_message' => $responseData['message'] ?? 'API Error',
+                    'result_url' => null
+                ];
+            }
+    
+            // 解析API响应
+            $result = $responseData['data'] ?? [];
+            $taskStatus = $result['status'] ?? 'failed';
+    
+            $returnData = [
+                'status' => $taskStatus,
+                'error_message' => null,
+                'result_url' => null
+            ];
+    
+            // 如果任务成功,获取结果URL
+            if ($taskStatus === 'done' && isset($result['video_url'])) {
+                $returnData['result_url'] = $result['video_url'];
+                $returnData['status'] = 'success';
+            } elseif (in_array($taskStatus, ['not_found', 'expired'])) {
+                $returnData['status'] = 'failed';
+                $returnData['error_message'] = '任务未找到或已过期';
+            } elseif ($taskStatus === 'failed') {
+                $returnData['status'] = 'failed';
+                $returnData['error_message'] = '任务执行失败';
+            }
+            // in_queue 或 generating状态保持原status
+    
+            return $returnData;
+        } catch (\Exception $e) {
+            return [
+                'status' => 'failed',
+                'error_message' => $e->getMessage(),
+                'result_url' => null
+            ];
+        }
+    }
+
+    /**
+     * 更新所有待处理任务的状态
+     *
+     * @return void
+     */
+    public function updatePendingTasks(): void
+    {
+        // 获取所有处理中的任务
+        $tasks = MpGenerateVideoTask::where('status', MpGenerateVideoTask::STATUS_PROCESSING)
+            ->get();
+
+        foreach ($tasks as $task) {
+            $statusInfo = $this->queryTaskStatus($task);
+
+            if ($statusInfo['status'] === 'success') {
+                $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, [
+                    'result_url' => $statusInfo['result_url']
+                ]);
+            } elseif ($statusInfo['status'] === 'failed') {
+                $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
+                    'error_message' => $statusInfo['error_message']
+                ]);
+            }
+            // 如果仍然是处理中状态,不做任何操作
+            
+            // 处理:如果任务处理超过12小时,标记为失败
+            $processingTime = now()->diffInHours($task->created_at);
+            if ($processingTime > 12) {
+                $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
+                    'error_message' => '任务处理超时(超过12小时)'
+                ]);
+            }
+        }
+    }
+    
+    public function getTaskList($data)
+    {
+        $task_id = getProp($data, 'task_id');
+        $status = getProp($data, 'status');
+
+        $query = MpGenerateVideoTask::select('*');
+        if ($task_id) {
+            $query->where('task_id', $task_id);
+        }
+        if ($status) {
+            $query->where('status', $status);
+        }
+
+        return $query->orderBy('created_at', 'desc')->paginate();
+    }
+}

+ 8 - 1
routes/api.php

@@ -5,6 +5,7 @@ use App\Http\Controllers\DeepSeek\DeepSeekController;
 use App\Http\Controllers\Book\BookController;
 use App\Http\Controllers\Timbre\TimbreController;
 use App\Http\Controllers\AIGeneration\ImageGenerationController;
+use App\Http\Controllers\AIGeneration\VideoGenerationController;
 use Illuminate\Support\Facades\Route;
 
 /*
@@ -92,11 +93,17 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
         Route::post('generateScript', [DeepSeekController::class, 'generateScript']);
     });
 
-    // 图片生成相关路由
+    // AI生成
     Route::group(['prefix' => 'AIGeneration'], function () {
+        // 图片生成相关路由
         Route::post('image/createTask', [ImageGenerationController::class, 'createTask']);
         Route::get('image/taskStatus', [ImageGenerationController::class, 'taskStatus']);
         Route::get('image/taskList', [ImageGenerationController::class, 'taskList']);
+        
+        // 视频生成相关路由
+        Route::post('video/createTask', [VideoGenerationController::class, 'createTask']);
+        Route::get('video/taskStatus', [VideoGenerationController::class, 'taskStatus']);
+        Route::get('video/taskList', [VideoGenerationController::class, 'taskList']);
     });
     
 });