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

完成视频生图相关接口

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

+ 15 - 6
app/Console/Test/TestCommand.php

@@ -50,14 +50,23 @@ class TestCommand extends Command
      */
     public function handle()
     {
+
+        $url = 'https://zw-audiobook.tos-cn-beijing.volces.com/video/ai_generation_1772704798_69a9541e94bef';
+        $video_name = 'ai_generation_' . time() . '_' . uniqid();
+        $video_ext = getVideoExtFromUrl($url);
+        $video_name = $video_name . $video_ext;
+        dump($video_name);
+        $video_url = uploadStreamByTos('video', $url, $video_name);
+        dd($video_url);
+
         // $url = 'https://zw-audiobook.tos-cn-beijing.volces.com/image/ai_generation_1772525682_69a69872e3a87..png';
-        $url = 'https://zw-audiobook.tos-cn-beijing.volces.com/image/ai_generation_1772525682_69a69872e3a87..png';
-        $compressed_url = compressRemoteImageUrlToSize($url);
-        // $compressed_url = compressRemoteImageUrlToSizeImagick($url);
-        $filename = 'ai_generation_1772525682_69a69872e3a87.png';
-        $url = uploadStreamByTos('image', $compressed_url, $filename);
+        // // $url = 'https://zw-audiobook.tos-cn-beijing.volces.com/image/ai_generation_1772525682_69a69872e3a87.png';
+        // $compressed_url = compressRemoteImageUrlToSize($url);
+        // // $compressed_url = compressRemoteImageUrlToSizeImagick($url);
+        // $filename = storage_path('app/public/video_frames/compressed.png');
+        // // $url = uploadStreamByTos('image', $compressed_url, $filename);
         // $url = file_put_contents($filename, $compressed_url);
-        dd($url);
+        // dd($url);
 
         // // 转存阿里云oss文件到tos
         // $audio_effects = DB::table('mp_timbres')->where('id', 246)->get();

+ 140 - 85
app/Http/Controllers/AIGeneration/VideoGenerationController.php

@@ -37,47 +37,63 @@ class VideoGenerationController extends BaseController
             'prompt' => 'required|string|max:1000',
             'seed' => 'required|integer',
             'video_duration' => 'required|in:5,10',
+            'video_resolution' => 'required|in:720P,1080P'
         ];
         
         $baseMessages = [
             'prompt.required' => '提示词不能为空',
             'prompt.max' => '提示词不能超过1000个字符',
+            'seed.required' => '随机种子不能为空',
+            'seed.integer' => '随机种子必须是整数',
             'video_duration.required' => '请选择视频时长',
             'video_duration.in' => '视频时长只能是5s或10s',
+            'video_resolution.required' => '请选择视频分辨率',
+            'video_resolution.in' => '视频分辨率只能是720P或1080P',
         ];
         
-        // 根据传入参数决定验证规则
-        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',
-                'tail_frame_file' => 'required|image|mimes:jpeg,png|max:4800',
-            ]);
-            
-            $validationMessages = array_merge($baseMessages, [
-                'first_frame_file.required' => '首帧图片不能为空',
+        // 动态构建验证规则
+        $validationRules = $baseRules;
+        $validationMessages = $baseMessages;
+        
+        // 检查是否有首帧URL
+        if (isset($data['first_frame_url'])) {
+            $validationRules['first_frame_url'] = 'url';
+            $validationMessages['first_frame_url.url'] = '首帧图片URL格式不正确';
+        }
+        
+        // 检查是否有尾帧URL
+        if (isset($data['tail_frame_url'])) {
+            $validationRules['tail_frame_url'] = 'url';
+            $validationMessages['tail_frame_url.url'] = '尾帧图片URL格式不正确';
+        }
+        
+        // 检查是否有首帧文件
+        if ($request->hasFile('first_frame_file')) {
+            $validationRules['first_frame_file'] = 'image|mimes:jpeg,png|max:4800';
+            $validationMessages = array_merge($validationMessages, [
                 'first_frame_file.image' => '首帧必须是图片文件',
                 'first_frame_file.mimes' => '首帧图片必须是jpeg,png格式',
                 'first_frame_file.max' => '首帧图片大小不能超过4.7MB',
-                'tail_frame_file.required' => '尾帧图片不能为空',
+            ]);
+        }
+        
+        // 检查是否有尾帧文件
+        if ($request->hasFile('tail_frame_file')) {
+            $validationRules['tail_frame_file'] = 'image|mimes:jpeg,png|max:4800';
+            $validationMessages = array_merge($validationMessages, [
                 'tail_frame_file.image' => '尾帧必须是图片文件',
                 'tail_frame_file.mimes' => '尾帧图片必须是jpeg,png格式',
                 'tail_frame_file.max' => '尾帧图片大小不能超过4.7MB',
             ]);
         }
+
+        if (isset($data['tail_frame_url']) && !isset($data['first_frame_url'])) {
+            Utils::throwError('1002:提供尾帧URL时必须提供首帧URL');
+        }
+
+        if ($request->hasFile('tail_frame_file') && !$request->has('first_frame_file')) {
+            Utils::throwError('1002:提供尾帧文件时必须提供首帧文件');
+        }
         
         $validator = Validator::make($data, $validationRules, $validationMessages);
         
@@ -85,78 +101,57 @@ class VideoGenerationController extends BaseController
             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');
+        $firstFrame = null;
+        $tailFrame = null;
 
-            $firstFrameExtension = $firstFrameFile->getClientOriginalExtension();
-            $tailFrameExtension = $tailFrameFile->getClientOriginalExtension();
-            
-            // 验证图片分辨率和比例
-            $firstFrameInfo = @getimagesize($firstFrameFile->getPathname());
-            $tailFrameInfo = @getimagesize($tailFrameFile->getPathname());
-            
-            if (!$firstFrameInfo || !$tailFrameInfo) {
-                Utils::throwError('1003:图片格式无效');
+        // 处理首帧图片(文件上传方式)
+        if ($request->hasFile('first_frame_file')) {
+            $firstFrameFile = $request->file('first_frame_file');
+            $firstFrame = $this->processFrameFile($firstFrameFile, 'first');
+            $data['first_frame_url'] = $firstFrame['url'];
+        }
+        // 处理首帧图片(URL方式)
+        elseif (isset($data['first_frame_url']) && !empty($data['first_frame_url'])) {
+            $firstFrame = Utils::getRemoteImageInfo($data['first_frame_url'], true);
+            if (!$firstFrame) {
+                Utils::throwError('1003:首帧图片获取失败');
             }
-            
-            $firstFrame = [
-                'width' => $firstFrameInfo[0],
-                'height' => $firstFrameInfo[1],
-                'size' => $firstFrameFile->getSize(),
-            ];
-            
-            $tailFrame = [
-                'width' => $tailFrameInfo[0],
-                'height' => $tailFrameInfo[1],
-                'size' => $tailFrameFile->getSize(),
-            ];
-            
-            // 本地保存
-            $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);
+        // 处理尾帧图片(文件上传方式)
+        if ($request->hasFile('tail_frame_file')) {
+            $tailFrameFile = $request->file('tail_frame_file');
+            $tailFrame = $this->processFrameFile($tailFrameFile, 'tail');
+            $data['tail_frame_url'] = $tailFrame['url'];
+        }
+        // 处理尾帧图片(URL方式)
+        elseif (isset($data['tail_frame_url']) && !empty($data['tail_frame_url'])) {
             $tailFrame = Utils::getRemoteImageInfo($data['tail_frame_url'], true);
-            if (!$firstFrame || !$tailFrame) {
-                Utils::throwError('1003:图片获取失败');
+            if (!$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) {
+            $this->validateFrameSpecs($firstFrame, '首帧');
         }
-        if ($firstFrame['width'] / $firstFrame['height'] > 3 || $tailFrame['width'] / $tailFrame['height'] > 3) {
-            Utils::throwError('1003:图片长边与短边比例不能超过3');
+        
+        // 验证尾帧图片规格(如果存在)
+        if ($tailFrame) {
+            $this->validateFrameSpecs($tailFrame, '尾帧');
         }
-        if ($firstFrame['width'] / $firstFrame['height'] != $tailFrame['width'] / $tailFrame['height']) {
-            Utils::throwError('1003:尾帧图片与首帧图片比例相同');
+        
+        // 如果首尾帧都存在,验证比例是否相同
+        if ($firstFrame && $tailFrame) {
+            $firstRatio = round($firstFrame['width'] / $firstFrame['height'], 2);
+            $tailRatio = round($tailFrame['width'] / $tailFrame['height'], 2);
+            
+            if ($firstRatio != $tailRatio) {
+                Utils::throwError('1003:尾帧图片与首帧图片比例必须相同');
+            }
         }
 
-        dd($data);
-
         try {
             $task = $this->aiVideoGenerationService->createVideoGenerationTask($data);
 
@@ -198,6 +193,66 @@ class VideoGenerationController extends BaseController
     }
 
     /**
+     * 处理帧图片文件
+     * 
+     * @param \Illuminate\Http\UploadedFile $file
+     * @param string $frameType 帧类型(first/tail)
+     * @return array
+     */
+    private function processFrameFile($file, string $frameType): array
+    {
+        // 验证图片信息
+        $frameInfo = @getimagesize($file->getPathname());
+        
+        if (!$frameInfo) {
+            Utils::throwError('1003:' . ($frameType === 'first' ? '首帧' : '尾帧') . '图片格式无效');
+        }
+        
+        $frameData = [
+            'width' => $frameInfo[0],
+            'height' => $frameInfo[1],
+            'size' => $file->getSize(),
+        ];
+        
+        // 上传图片到服务器
+        try {
+            $frameData['url'] = uploadFileByTos('image', $file);
+        } catch (\Exception $e) {
+            Utils::throwError('1003:' . ($frameType === 'first' ? '首帧' : '尾帧') . '图片上传失败:' . $e->getMessage());
+        }
+        
+        return $frameData;
+    }
+
+    /**
+     * 验证帧图片规格
+     * 
+     * @param array $frame
+     * @param string $frameName
+     * @return void
+     */
+    private function validateFrameSpecs(array $frame, string $frameName): void
+    {
+        // 1.图片文件最大4.7MB
+        if ($frame['size'] > 4.7 * 1024 * 1024) {
+            Utils::throwError('1003:' . $frameName . '图片大小不能超过4.7MB');
+        }
+        
+        // 2.图片分辨率最大: 4096*4096, 最短边不低于320
+        $minDimension = min($frame['width'], $frame['height']);
+        $maxDimension = max($frame['width'], $frame['height']);
+        
+        if ($maxDimension > 4096 || $minDimension < 320) {
+            Utils::throwError('1003:' . $frameName . '图片分辨率不能超过4096*4096,且最短边不能低于320');
+        }
+        
+        // 3.图片长边与短边比例在3以内
+        if ($maxDimension / $minDimension > 3) {
+            Utils::throwError('1003:' . $frameName . '图片长边与短边比例不能超过3');
+        }
+    }
+
+    /**
      * 获取视频任务列表
      *
      * @param Request $request

+ 173 - 14
app/Libs/Helpers.php

@@ -279,10 +279,8 @@ function uploadFileByTos($prefix, $file, $filename='')
         $client = new TosClient([
             'region'    => env('VOLC_REGION'),
             'endpoint'  => env('VOLC_END_POINT'),
-            // 'ak'        => env('VOLC_AK'),
-            // 'sk'        => env('VOLC_SK'),
-            'ak'        => 'AKLTNDRjMzdiODkwMThhNDdhZThhOTI3MDg5MDkzYzExNTg',
-            'sk'        => 'TXpOak4yWmxZemxrTVRVMU5EZzVNMkprT0dJMU9HSTBaR1kzWW1NMk5qaw==',
+            'ak'        => env('VOLC_AK'),
+            'sk'        => env('VOLC_SK'),
         ]);
         
         $stream = fopen($file->getRealPath(), 'r');
@@ -315,10 +313,8 @@ 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'        => 'AKLTNDRjMzdiODkwMThhNDdhZThhOTI3MDg5MDkzYzExNTg',
-            'sk'        => 'TXpOak4yWmxZemxrTVRVMU5EZzVNMkprT0dJMU9HSTBaR1kzWW1NMk5qaw==',
+            'ak'        => env('VOLC_AK'),
+            'sk'        => env('VOLC_SK'),
         ]);
 
         $input = new PutObjectInput(env('VOLC_BUCKET'),  "$prefix/$filename", $stream);
@@ -335,8 +331,8 @@ function uploadStreamByTos($prefix, $stream, $filename)
     return '';
 }
 
-// 从图片内容获取图片扩展名
-function getExtFromContent(string $url): ?string {
+// 从图片URL获取图片扩展名
+function getImgExtFromUrl(string $url): ?string {
     $imageInfo = @getimagesize($url);
     if ($imageInfo !== false) {
         // 返回MIME类型
@@ -348,6 +344,116 @@ function getExtFromContent(string $url): ?string {
     return null;
 }
 
+/**
+ * 从视频URL获取视频扩展名
+ * 通过下载文件头部字节来识别真实的视频格式
+ * 
+ * @param string $url 视频URL
+ * @return string 返回扩展名(如 .mp4),默认 .mp4
+ */
+function getVideoExtFromUrl(string $url): string {
+    try {
+        // 方法1:从URL路径中提取扩展名
+        $parsedUrl = parse_url($url);
+        $path = $parsedUrl['path'] ?? '';
+        $pathInfo = pathinfo($path);
+        $extension = $pathInfo['extension'] ?? '';
+        
+        if ($extension) {
+            // 验证是否为常见视频格式
+            $validVideoExts = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', 'mpeg', 'mpg'];
+            if (in_array(strtolower($extension), $validVideoExts)) {
+                return '.' . strtolower($extension);
+            }
+        }
+        
+        // 方法2:下载文件头部字节来识别格式
+        $client = new \GuzzleHttp\Client(['timeout' => 30]);
+        
+        // 先尝试HEAD请求获取Content-Type
+        try {
+            $response = $client->head($url);
+            $contentType = $response->getHeader('Content-Type')[0] ?? '';
+            
+            // 根据Content-Type映射扩展名
+            $mimeMap = [
+                'video/mp4'        => '.mp4',
+                'video/x-msvideo'  => '.avi',
+                'video/quicktime'  => '.mov',
+                'video/x-ms-wmv'   => '.wmv',
+                'video/x-flv'      => '.flv',
+                'video/x-matroska' => '.mkv',
+                'video/webm'       => '.webm',
+                'video/mpeg'       => '.mpeg',
+            ];
+            
+            if (isset($mimeMap[$contentType])) {
+                return $mimeMap[$contentType];
+            }
+        } catch (\Exception $e) {
+            // HEAD请求失败,继续尝试其他方法
+        }
+        
+        // 方法3:下载前几个字节分析文件魔数(Magic Number)
+        $response = $client->get($url, [
+            'stream' => true,
+            'headers' => [
+                'Range' => 'bytes=0-31'  // 只下载前32字节
+            ]
+        ]);
+        
+        $header = $response->getBody()->read(32);
+        
+        // 根据文件魔数判断格式
+        // MP4/M4V: 以 ftyp 开头(偏移4-8字节)
+        if (strpos($header, 'ftyp') !== false) {
+            // 进一步判断是否为 M4V
+            if (strpos($header, 'M4V') !== false || strpos($header, 'm4v') !== false) {
+                return '.m4v';
+            }
+            return '.mp4';
+        }
+        
+        // AVI: 以 RIFF 开头,包含 AVI
+        if (substr($header, 0, 4) === 'RIFF' && strpos($header, 'AVI') !== false) {
+            return '.avi';
+        }
+        
+        // MOV: 包含 moov 或 mdat
+        if (strpos($header, 'moov') !== false || strpos($header, 'mdat') !== false) {
+            return '.mov';
+        }
+        
+        // FLV: 以 FLV 开头
+        if (substr($header, 0, 3) === 'FLV') {
+            return '.flv';
+        }
+        
+        // WebM: 以 0x1A 0x45 0xDF 0xA3 开头
+        if (ord($header[0]) === 0x1A && ord($header[1]) === 0x45 && 
+            ord($header[2]) === 0xDF && ord($header[3]) === 0xA3) {
+            return '.webm';
+        }
+        
+        // MKV: 与WebM类似,但包含 matroska
+        if (strpos($header, 'matroska') !== false) {
+            return '.mkv';
+        }
+        
+        // MPEG: 以 0x00 0x00 0x01 开头
+        if (ord($header[0]) === 0x00 && ord($header[1]) === 0x00 && ord($header[2]) === 0x01) {
+            return '.mpeg';
+        }
+        
+        // 默认返回 .mp4(最常见的格式)
+        return '.mp4';
+        
+    } catch (\Exception $e) {
+        // 出错时返回默认格式
+        return '.mp4';
+    }
+}
+
 // 辅助 MIME 映射函数
 function mimeToExt(string $mime): ?string {
     $map = [
@@ -2090,11 +2196,12 @@ function handleScriptContent($originalContent) {
  * 远程图片压缩(尽可能保持原图 fidelity,压缩到不超过 maxBytes 字节)
  * 采用有损/无损组合策略:保留原始格式尽量不失真,若需要则通过尺寸缩放和质量调节来降低体积。
  *
- * @param string $url       远程图片 URL
- * @param int    $maxBytes  最大字节数,默认 2MB
- * @return string|null      返回压缩后的图片二进制数据,失败时返回 null
+ * @param string      $url         远程图片 URL
+ * @param int         $maxBytes    最大字节数,默认 3MB
+ * @param string|null $aspectRatio 目标长宽比,如 "16:9"、"4:3"、"1:1" 等,null 则保持原图比例
+ * @return string|null              返回压缩后的图片二进制数据,失败时返回 null
  */
-function compressRemoteImageUrlToSize(string $url, int $maxBytes = 3 * 1024 * 1024): ?string
+function compressRemoteImageUrlToSize(string $url, int $maxBytes = 3 * 1024 * 1024, ?string $aspectRatio = null): ?string
 {
     // 1) 下载图片数据(使用 Guzzle 以避免 allow_url_fopen 依赖)
     try {
@@ -2122,6 +2229,57 @@ function compressRemoteImageUrlToSize(string $url, int $maxBytes = 3 * 1024 * 10
     $origW = imagesx($srcImg);
     $origH = imagesy($srcImg);
 
+    // 3.2) 计算目标尺寸(根据长宽比调整)
+    $targetW = $origW;
+    $targetH = $origH;
+    
+    if ($aspectRatio !== null) {
+        // 解析长宽比,如 "16:9" -> [16, 9]
+        $validRatios = ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"];
+        if (in_array($aspectRatio, $validRatios)) {
+            list($ratioW, $ratioH) = explode(':', $aspectRatio);
+            $ratioW = (float)$ratioW;
+            $ratioH = (float)$ratioH;
+            $targetRatio = $ratioW / $ratioH;
+            $currentRatio = $origW / $origH;
+            
+            // 根据目标比例裁剪图片
+            if ($currentRatio > $targetRatio) {
+                // 原图更宽,需要裁剪宽度
+                $targetW = (int)round($origH * $targetRatio);
+                $targetH = $origH;
+            } else {
+                // 原图更高,需要裁剪高度
+                $targetW = $origW;
+                $targetH = (int)round($origW / $targetRatio);
+            }
+        }
+    }
+    
+    // 如果需要裁剪,创建裁剪后的图像
+    if ($targetW !== $origW || $targetH !== $origH) {
+        $croppedImg = imagecreatetruecolor($targetW, $targetH);
+        imagealphablending($croppedImg, false);
+        imagesavealpha($croppedImg, true);
+        
+        // 处理透明背景
+        if (in_array(strtolower($mime), ['image/png','image/webp'])) {
+            $transparent = imagecolorallocatealpha($croppedImg, 0, 0, 0, 127);
+            imagefill($croppedImg, 0, 0, $transparent);
+        }
+        
+        // 居中裁剪
+        $srcX = (int)(($origW - $targetW) / 2);
+        $srcY = (int)(($origH - $targetH) / 2);
+        imagecopy($croppedImg, $srcImg, 0, 0, $srcX, $srcY, $targetW, $targetH);
+        
+        // 替换原图
+        safeDestroyImage($srcImg);
+        $srcImg = $croppedImg;
+        $origW = $targetW;
+        $origH = $targetH;
+    }
+
     // 3.1) 内部渲染成不同格式的字符串
     $render = function($srcRes, string $mimeType, int $quality) {
         ob_start();
@@ -2239,6 +2397,7 @@ function compressRemoteImageUrlToSize(string $url, int $maxBytes = 3 * 1024 * 10
     safeDestroyImage($srcImg);
     return $bestData;
 }
+
 /**
  * 安全销毁 GD 图像资源(兼容 PHP7.4+ 的资源管理)
  * 通过引用传递并在销毁后置空变量,避免未定义变量的问题

+ 2 - 0
app/Models/MpGeneratePicTask.php

@@ -26,6 +26,7 @@ class MpGeneratePicTask extends Model
         'completed_at',
         'created_at',
         'updated_at',
+        'result_json'
     ];
 
     protected $casts = [
@@ -33,6 +34,7 @@ class MpGeneratePicTask extends Model
         'ref_img_url' => 'array',
         'mask_img_url' => 'array',
         'extra_params' => 'array',
+        'result_json' => 'array',
         'started_at' => 'datetime',
         'completed_at' => 'datetime',
     ];

+ 11 - 2
app/Models/MpGenerateVideoTask.php

@@ -26,10 +26,13 @@ class MpGenerateVideoTask extends Model
         'extra_params',
         'started_at',
         'completed_at',
+        'video_resolution',
+        'result_json',
     ];
 
     protected $casts = [
         'extra_params' => 'array',
+        'result_json'  => 'array',
     ];
 
     /**
@@ -41,7 +44,11 @@ class MpGenerateVideoTask extends Model
     public function updateStatus(string $status, array $data = []): void
     {
         $updateData = ['status' => $status];
+        if (isset($data['result_json'])) {
+            $updateData['result_json'] = $data['result_json'];
+        }
         
+
         if (isset($data['result_url'])) {
             $updateData['result_url'] = $data['result_url'];
         }
@@ -54,8 +61,10 @@ class MpGenerateVideoTask extends Model
             $updateData['extra_params'] = $data['extra_params'];
         }
         
-        if ($status === self::STATUS_PROCESSING && !$this->started_at) {
-            $updateData['started_at'] = now();
+        if ($status === self::STATUS_PROCESSING) {
+            if (isset($data['task_id'])) $updateData['task_id'] = $data['task_id'];
+
+            if (!$this->started_at) $updateData['started_at'] = now();
         }
         
         if (in_array($status, [self::STATUS_SUCCESS, self::STATUS_FAILED]) && !$this->completed_at) {

+ 14 - 10
app/Services/AIGeneration/AIImageGenerationService.php

@@ -63,12 +63,12 @@ class AIImageGenerationService
     {
         try {
             // 验证环境变量配置
-            $accessKey = env('VOLC_AK');
-            $secretKey = env('VOLC_SK');
+            $accessKey = env('VOLC_AI_AK');
+            $secretKey = env('VOLC_AI_SK');
                 
             if (empty($accessKey) || empty($secretKey)) {
                 $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
-                    'error_message' => '火山引擎访问密钥未配置,请检查环境变量VOLC_AK和VOLC_SK'
+                    'error_message' => '火山引擎访问密钥未配置,请检查环境变量AK和SK'
                 ]);
                 return;
             }
@@ -196,8 +196,8 @@ class AIImageGenerationService
                 '/', // 使用根路径
                 [], // Query参数
                 json_encode($apiParams),
-                env('VOLC_AK'),
-                env('VOLC_SK'),
+                env('VOLC_AI_AK'),
+                env('VOLC_AI_SK'),
                 'cv',
                 'cn-north-1',
                 'CVSync2AsyncGetResult',
@@ -213,7 +213,8 @@ class AIImageGenerationService
                 return [
                     'status' => 'failed',
                     'error_message' => $responseData['message'] ?? 'API Error',
-                    'result_url' => null
+                    'result_url' => null,
+                    'result_json' => $responseData['data'] ?? []
                 ];
             }
     
@@ -224,7 +225,8 @@ class AIImageGenerationService
             $returnData = [
                 'status' => $taskStatus,
                 'error_message' => null,
-                'result_url' => null
+                'result_url' => null,
+                'result_json' => $result
             ];
     
             // 如果任务成功,获取结果URL
@@ -232,7 +234,7 @@ class AIImageGenerationService
                 $result_urls = [];
                 foreach ($result['image_urls'] as $url) {
                     $pic_name = 'ai_generation_' . time() . '_' . uniqid();
-                    $pic_ext = getExtFromContent($url);
+                    $pic_ext = getImgExtFromUrl($url);
                     $pic_name = $pic_name . $pic_ext;
                     
                     if ($pic_ext === '.png') {
@@ -282,11 +284,13 @@ class AIImageGenerationService
 
             if ($statusInfo['status'] === 'success') {
                 $task->updateStatus(MpGeneratePicTask::STATUS_SUCCESS, [
-                    'result_url' => $statusInfo['result_url']
+                    'result_url'    => $statusInfo['result_url'],
+                    'result_json'   => $statusInfo['result_json']
                 ]);
             } elseif ($statusInfo['status'] === 'failed') {
                 $task->updateStatus(MpGeneratePicTask::STATUS_FAILED, [
-                    'error_message' => $statusInfo['error_message']
+                    'error_message' => $statusInfo['error_message'],
+                    'result_json'   => $statusInfo['result_json'] ?? []
                 ]);
             }
             // 如果仍然是处理中状态,不做任何操作

+ 50 - 17
app/Services/AIGeneration/AIVideoGenerationService.php

@@ -38,7 +38,8 @@ class AIVideoGenerationService
             'first_frame_url' => $params['first_frame_url'] ?? null,
             'tail_frame_url' => $params['tail_frame_url'] ?? null,
             'seed' => $params['seed'] ?? -1,
-            'frames' => $params['frames'] ?? 121,
+            'frames' => $params['video_duration'] ? ($params['video_duration'] * 24 + 1) : 121,
+            'video_resolution' => $params['video_resolution'] ?? '720P',
             'status' => MpGenerateVideoTask::STATUS_PENDING,
         ]);
 
@@ -58,30 +59,40 @@ class AIVideoGenerationService
     {
         try {
             // 验证环境变量配置
-            $accessKey = env('VOLC_AK');
-            $secretKey = env('VOLC_SK');
+            $accessKey = env('VOLC_AI_AK');
+            $secretKey = env('VOLC_AI_SK');
                 
             if (empty($accessKey) || empty($secretKey)) {
                 $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
-                    'error_message' => '火山引擎访问密钥未配置,请检查环境变量VOLC_AK和VOLC_SK'
+                    'error_message' => '火山引擎访问密钥未配置,请检查环境变量AK和SK'
                 ]);
                 return;
             }
+
+            // 根据任务类型选择对应的req_key
+            if ($task->first_frame_url && $task->tail_frame_url) {
+                $req_key = $task->video_resolution == '720P' ? 'jimeng_i2v_first_tail_v30' : 'jimeng_i2v_first_tail_v30_1080';
+            }else if($task->first_frame_url){
+                $req_key = $task->video_resolution == '720P' ? 'jimeng_i2v_first_v30' : 'jimeng_i2v_first_v30_1080';
+            }else{
+                $req_key = $task->video_resolution == '720P' ? 'jimeng_t2v_v30' : 'jimeng_t2v_v30_1080p';
+            }
                 
             // 构建即梦AI 3.0 视频生成API请求参数
             $apiParams = [
-                'req_key' => 'jimeng_i2v_first_tail_v30',
+                'req_key' => $req_key,
                 'prompt' => $task->prompt,
                 'seed' => $task->seed,
                 'frames' => $task->frames,
             ];
-            
+
             // 添加图片URL参数
-            if ($task->first_frame_url && $task->tail_frame_url) {
+            if ($task->first_frame_url) {
                 $apiParams['image_urls'] = [
-                    $task->first_frame_url,
-                    $task->tail_frame_url
+                    $task->first_frame_url
                 ];
+
+                if ($task->tail_frame_url) $apiParams['image_urls'][] = $task->tail_frame_url;
             }
             
             // 调用即梦AI 3.0 视频生成API提交任务
@@ -116,6 +127,7 @@ class AIVideoGenerationService
                 if (isset($responseData['data']['task_id'])) {
                     $updateData['task_id'] = $responseData['data']['task_id'];
                     $updateData['extra_params'] = $responseData['data'] ?? [];
+                    $updateData['extra_params']['req_key'] = $req_key;
                 }
 
                 $task->updateStatus(MpGenerateVideoTask::STATUS_PROCESSING, $updateData);
@@ -147,10 +159,19 @@ class AIVideoGenerationService
                     'result_url' => null
                 ];
             }
+
+            // 根据任务类型选择对应的req_key
+            if ($task->first_frame_url && $task->tail_frame_url) {
+                $req_key = $task->video_resolution == '720P' ? 'jimeng_i2v_first_tail_v30' : 'jimeng_i2v_first_tail_v30_1080';
+            }else if($task->first_frame_url){
+                $req_key = $task->video_resolution == '720P' ? 'jimeng_i2v_first_v30' : 'jimeng_i2v_first_v30_1080';
+            }else{
+                $req_key = $task->video_resolution == '720P' ? 'jimeng_t2v_v30' : 'jimeng_t2v_v30_1080p';
+            }
     
             // 构建查询参数
             $apiParams = [
-                'req_key' => 'jimeng_i2v_first_tail_v30',
+                'req_key' => $req_key,
                 'task_id' => $apiTaskId,
                 'req_json' => '{"return_url":true}' // 返回URL格式
             ];
@@ -162,8 +183,8 @@ class AIVideoGenerationService
                 '/', // 使用根路径
                 [], // Query参数
                 json_encode($apiParams),
-                env('VOLC_AK'),
-                env('VOLC_SK'),
+                env('VOLC_AI_AK'),
+                env('VOLC_AI_SK'),
                 'cv',
                 'cn-north-1',
                 'CVSync2AsyncGetResult',
@@ -179,7 +200,8 @@ class AIVideoGenerationService
                 return [
                     'status' => 'failed',
                     'error_message' => $responseData['message'] ?? 'API Error',
-                    'result_url' => null
+                    'result_url' => null,
+                    'result_json' => $responseData['data'] ?? []
                 ];
             }
     
@@ -190,12 +212,21 @@ class AIVideoGenerationService
             $returnData = [
                 'status' => $taskStatus,
                 'error_message' => null,
-                'result_url' => null
+                'result_url' => null,
+                'result_json' => $result
             ];
     
             // 如果任务成功,获取结果URL
             if ($taskStatus === 'done' && isset($result['video_url'])) {
-                $returnData['result_url'] = $result['video_url'];
+                if (isset($result['video_url'])) {
+                    $video_name = 'ai_generation_' . time() . '_' . uniqid();
+                    $video_ext = getVideoExtFromUrl($result['video_url']);
+                    $video_name = $video_name . $video_ext;
+                    // 将视频另存到tos
+                    $url = uploadStreamByTos('video', file_get_contents($result['video_url']), $video_name);
+                    $returnData['result_url'] = $url;
+                }
+                
                 $returnData['status'] = 'success';
             } elseif (in_array($taskStatus, ['not_found', 'expired'])) {
                 $returnData['status'] = 'failed';
@@ -232,11 +263,13 @@ class AIVideoGenerationService
 
             if ($statusInfo['status'] === 'success') {
                 $task->updateStatus(MpGenerateVideoTask::STATUS_SUCCESS, [
-                    'result_url' => $statusInfo['result_url']
+                    'result_url'    => $statusInfo['result_url'],
+                    'result_json'   => $statusInfo['result_json']
                 ]);
             } elseif ($statusInfo['status'] === 'failed') {
                 $task->updateStatus(MpGenerateVideoTask::STATUS_FAILED, [
-                    'error_message' => $statusInfo['error_message']
+                    'error_message' => $statusInfo['error_message'],
+                    'result_json'   => $statusInfo['result_json'] ?? []
                 ]);
             }
             // 如果仍然是处理中状态,不做任何操作