Przeglądaj źródła

1.新增文生视频通用接口2.增加对资产库非空资产名的校验

lh 1 miesiąc temu
rodzic
commit
62c2fd1e46

+ 117 - 0
app/Http/Controllers/Anime/AnimeController.php

@@ -2392,6 +2392,123 @@ class AnimeController extends BaseController
     }
 
     /**
+     * 文生视频(通用)
+     * 支持传入视频参数、提示词、参考图等信息,不支持products入参
+     */
+    public function generateVideo(Request $request) {
+        // 忽略所有超时限制
+        set_time_limit(0);
+        ini_set('max_execution_time', '0');
+
+        $data = $request->all();
+
+        // 创建视频生成任务
+        $result = $this->AnimeService->generateVideo($data);
+        $taskId = $result['task_id'];
+
+        // 设置 SSE 响应头
+        return response()->stream(function () use ($taskId, $result) {
+            // 立即发送 init 消息
+            echo "data: " . json_encode([
+                'type' => 'init',
+                'data' => $result
+            ]) . "\n\n";
+            if (ob_get_level() > 0) ob_flush();
+            flush();
+
+            $startTime = time();
+            $maxDuration = 3600; // 超时设置
+            $checkInterval = 5; // 检查间隔
+            $lastQueueMessageTime = time(); // 记录上次发送队列消息的时间
+            $queueMessageInterval = 60; // 队列消息发送间隔(秒)
+
+            while (time() - $startTime < $maxDuration) {
+                try {
+                    // 查询任务状态
+                    $task = \App\Models\MpGenerateVideoTask::find($taskId);
+                    if (!$task) {
+                        echo "data: " . json_encode([
+                            'type' => 'error',
+                            'message' => '任务不存在'
+                        ]) . "\n\n";
+                        if (ob_get_level() > 0) ob_flush();
+                        flush();
+                        break;
+                    }
+
+                    // 如果任务完成(成功或失败),结束连接
+                    if (in_array($task->status, [
+                        'success',
+                        'failed'
+                    ])) {
+                        echo "data: " . json_encode([
+                            'type' => 'completed',
+                            'data' => [
+                                'task_id' => $task->id,
+                                'status' => $task->status,
+                                'video_url' => $task->compressed_url ?: $task->result_url,
+                                'origin_video_url' => $task->result_url,
+                                'last_frame_url' => $task->last_frame_url,
+                                'error_message' => $task->error_message ? mapErrorMessage($task->error_message) : ''
+                            ]
+                        ]) . "\n\n";
+                        if (ob_get_level() > 0) ob_flush();
+                        flush();
+                        break;
+                    }
+
+                    // 每分钟发送一次队列状态消息
+                    $currentTime = time();
+                    if ($currentTime - $lastQueueMessageTime >= $queueMessageInterval) {
+                        echo "data: " . json_encode([
+                            'type' => 'queue',
+                            'data' => [
+                                'task_id' => $task->id,
+                                'status' => $task->status,
+                                'elapsed_time' => $currentTime - $startTime,
+                                'message' => '任务正在队列中执行...'
+                            ]
+                        ]) . "\n\n";
+                        if (ob_get_level() > 0) ob_flush();
+                        flush();
+                        $lastQueueMessageTime = $currentTime;
+                    }
+
+                    sleep($checkInterval);
+
+                } catch (\Exception $e) {
+                    echo "data: " . json_encode([
+                        'type' => 'error',
+                        'message' => '查询任务状态失败: ' . $e->getMessage()
+                    ]) . "\n\n";
+                    if (ob_get_level() > 0) ob_flush();
+                    flush();
+                    sleep($checkInterval);
+                }
+            }
+
+            // 超时处理
+            if (time() - $startTime >= $maxDuration) {
+                echo "data: " . json_encode([
+                    'type' => 'time_out',
+                    'message' => '任务执行超时,请稍后查询任务状态',
+                    'data' => [
+                        'task_id' => $taskId
+                    ]
+                ]) . "\n\n";
+                if (ob_get_level() > 0) ob_flush();
+                flush();
+            }
+
+        }, 200, [
+            'Content-Type' => 'text/event-stream',
+            'Cache-Control' => 'no-cache',
+            'Connection' => 'keep-alive',
+            'X-Accel-Buffering' => 'no', // 禁用 Nginx 缓冲
+        ]);
+    }
+
+    /**
      * 批量生成片段视频
      */
     public function batchSetActVideos(Request $request) {

+ 179 - 10
app/Services/Anime/AnimeService.php

@@ -1071,6 +1071,9 @@ class AnimeService
 
         try {
             $target_role_name = getProp($target_roles, 'role');
+            if (empty($target_role_name)) {
+                Utils::throwError('20003:角色名称不能为空');
+            }
             
             // 如果是删除操作
             if ($is_deleted == 1) {
@@ -1301,6 +1304,9 @@ class AnimeService
 
         try {
             $target_scene_name = getProp($target_scenes, 'scene');
+            if (empty($target_scene_name)) {
+                Utils::throwError('20003:场景名称不能为空');
+            }
             
             // 如果是删除操作
             if ($is_deleted == 1) {
@@ -1515,6 +1521,9 @@ class AnimeService
 
         try {
             $target_prop_name = getProp($target_props, 'prop');
+            if (empty($target_prop_name)) {
+                Utils::throwError('20003:道具名称不能为空');
+            }
             
             // 如果是删除操作
             if ($is_deleted == 1) {
@@ -3368,31 +3377,44 @@ class AnimeService
             if ($episode) {
                 $roles = json_decode(getProp($episode, 'roles', '[]'), true) ?: [];
                 $scenes = json_decode(getProp($episode, 'scenes', '[]'), true) ?: [];
+                $props = json_decode(getProp($episode, 'props', '[]'), true) ?: [];
                 $extra_products = json_decode(getProp($episode, 'extra_products', '[]'), true) ?: [];
                 
-                // 先合并roles和scenes
+                // 先合并roles、scenes和props
                 // 处理角色数组
                 foreach ($roles as $role) {
                     $product = $role;
                     // 将role字段重命名为product_name
-                    if (isset($product['role'])) {
+                    if (is_array($product) && !empty($product['role'] ?? null)) {
                         $product['product_name'] = $product['role'];
                         unset($product['role']);
+                        $product['product'] = 1; // 标记类型为角色
+                        $products[$product['product_name']] = $product; // 使用product_name作为key便于后续覆盖
                     }
-                    $product['product'] = 1; // 标记类型为角色
-                    $products[$product['product_name']] = $product; // 使用product_name作为key便于后续覆盖
                 }
                 
                 // 处理场景数组
                 foreach ($scenes as $scene) {
                     $product = $scene;
                     // 将scene字段重命名为product_name
-                    if (isset($product['scene'])) {
+                    if (is_array($product) && !empty($product['scene'] ?? null)) {
                         $product['product_name'] = $product['scene'];
                         unset($product['scene']);
+                        $product['product'] = 2; // 标记类型为场景
+                        $products[$product['product_name']] = $product; // 使用product_name作为key便于后续覆盖
+                    }
+                }
+
+                // 处理道具数组
+                foreach ($props as $prop) {
+                    $product = $prop;
+                    // 将prop字段重命名为product_name
+                    if (is_array($product) && !empty($product['prop'] ?? null)) {
+                        $product['product_name'] = $product['prop'];
+                        unset($product['prop']);
+                        $product['product'] = 3; // 标记类型为道具
+                        $products[$product['product_name']] = $product; // 使用product_name作为key便于后续覆盖
                     }
-                    $product['product'] = 2; // 标记类型为场景
-                    $products[$product['product_name']] = $product; // 使用product_name作为key便于后续覆盖
                 }
                 
                 // extra_products优先级更高,直接覆盖同名的roles和scenes
@@ -5068,6 +5090,146 @@ class AnimeService
     }
 
     /**
+     * 文生视频通用方法
+     * 支持传入视频参数、提示词、参考图等信息,不支持products入参
+     * 用户提示词不做任何处理,直接组装参数后根据模型调用
+     *
+     * @param array $data 请求参数
+     * @return array
+     */
+    public function generateVideo($data) {
+        $prompt = getProp($data, 'prompt');
+        if (empty($prompt)) {
+            Utils::throwError('20003:提示词不能为空');
+        }
+
+        $model = getProp($data, 'model');
+        if (!$model) {
+            $model = 'zhizhen-20';
+        }
+        // 验证模型是否在可用模型表中
+        if (!DB::table('mp_video_models')->where('model', $model)->where('is_enabled', 1)->exists()) {
+            Utils::throwError('20003:该模型已不可用,请更换!');
+        }
+
+        $video_duration = getProp($data, 'video_duration', 5);
+        $video_resolution = getProp($data, 'video_resolution', '480p');
+        $ratio = getProp($data, 'ratio', '9:16');
+        $generate_audio = getProp($data, 'generate_audio', 1);
+        $seed = getProp($data, 'seed', -1);
+        $first_frame_url = getProp($data, 'first_frame_url');
+        $tail_frame_url = getProp($data, 'tail_frame_url');
+
+        // 参考图(支持数组或JSON字符串,最多9张)
+        $reference_images = getProp($data, 'reference_images', []);
+        if (is_string($reference_images)) {
+            $reference_images = json_decode($reference_images, true) ?: [];
+        }
+        if (!is_array($reference_images)) {
+            Utils::throwError('20003:参考图格式不正确');
+        }
+        $reference_images = array_values(array_unique(array_filter($reference_images)));
+        $reference_images = array_slice($reference_images, 0, 9);
+
+        // 构建视频生成参数(用户参数直接透传,提示词不做处理)
+        $videoParams = [
+            'model' => $model,
+            'prompt' => $prompt,
+            'video_duration' => $video_duration,
+            'video_resolution' => $video_resolution,
+            'seed' => $seed,
+            'ratio' => $ratio,
+            'generate_audio' => (int)$generate_audio === 1 ? true : false,
+            'draft' => getProp($data, 'draft', false),
+            'watermark' => getProp($data, 'watermark', false),
+            'camera_fixed' => getProp($data, 'camera_fixed', false),
+        ];
+
+        if ($first_frame_url) $videoParams['first_frame_url'] = $first_frame_url;
+        if ($tail_frame_url) $videoParams['tail_frame_url'] = $tail_frame_url;
+
+        // 构建content数组
+        $videoParams['content'] = [
+            [
+                'type' => 'text',
+                'text' => $prompt,
+            ]
+        ];
+
+        // 根据模型选择不同的视频生成方法
+        if (strpos($model, 'jimeng') !== false) {
+            // 即梦模型参数调整
+            unset($videoParams['content']); // 即梦不使用content格式
+            $task = $this->aiVideoGenerationService->createJimengTask($videoParams);
+        } elseif (strpos($model, 'kling') !== false) {
+            // 可灵模型参数调整
+            $videoParams['aspect_ratio'] = $videoParams['ratio']; // 可灵使用aspect_ratio
+            unset($videoParams['ratio']);
+            unset($videoParams['content']); // 可灵不使用content格式
+            $task = $this->aiVideoGenerationService->createKelingOmniTask($videoParams);
+        } elseif (strpos($model, 'zhizhen') !== false) {
+            // 智帧等新模型使用统一API
+            $unifiedParams = [
+                'model_code' => $model,
+                'prompt' => $prompt,
+                'video_duration' => $video_duration,
+                'video_resolution' => strtolower($video_resolution),
+                'video_ratio' => $ratio,
+                'seed' => $seed,
+            ];
+
+            // 构建parameters参数
+            $parameters = [
+                'seconds' => $unifiedParams['video_duration'],
+                'resolution' => $unifiedParams['video_resolution'],
+                'ratio' => $ratio,
+                'generate_audio' => (int)$generate_audio === 1 ? true : false,
+            ];
+
+            // 首帧和尾帧
+            if ($first_frame_url) $parameters['first_frame_url'] = $first_frame_url;
+            if ($tail_frame_url) $parameters['last_frame_url'] = $tail_frame_url;
+
+            // 参考图处理
+            if ($reference_images) {
+                $unifiedParams['ref_image_url'] = json_encode($reference_images, 256);
+                $reference_images = $this->aiImageGenerationService->processReferenceImagesToAssets($reference_images, $unifiedParams['prompt']);
+                $parameters['reference_images'] = $reference_images;
+                $parameters['video_mode'] = 'multi_image';
+                $parameters['mode'] = 'multi_image';
+            }
+
+            $unifiedParams['parameters'] = $parameters;
+
+            // 调用统一API创建任务
+            $task = $this->aiVideoGenerationService->createUnifiedApiTask($unifiedParams);
+        } elseif (strpos($model, 'doubao-seedance-2.0') !== false) {
+            // 豆包Seedance 2.0(百度AI网关):图片需要先上传为素材
+            $videoParams['reference_images'] = $reference_images;
+
+            if ($reference_images) {
+                // 参考图上传为百度网关素材
+                $reference_image_assets = $this->aiVideoGenerationService->processReferenceImagesToSeedance20Assets($reference_images, $videoParams['prompt']);
+                $videoParams['content'][0]['text'] = $videoParams['prompt'];
+                $videoParams['reference_image_assets'] = $reference_image_assets;
+            }
+
+            $task = $this->aiVideoGenerationService->createSeedance20Task($videoParams);
+        } else {
+            $task = $this->aiVideoGenerationService->createSeedanceTask($videoParams);
+        }
+
+        return [
+            'task_id' => $task->id,
+            'status' => $task->status,
+            'model' => $model,
+            'video_duration' => $video_duration,
+            'video_resolution' => $video_resolution,
+            'ratio' => $ratio
+        ];
+    }
+
+    /**
      * 根据提示词内容智能计算最优视频时长
      * 
      * @param string $segmentContent 分镜内容
@@ -7755,7 +7917,7 @@ class AnimeService
                     }
                     
                     // 根据动态查找的列索引提取数据
-                    $product_name = isset($row[$nameColumnIndex]) ? trim($row[$nameColumnIndex]) : '';
+                    $product_name = isset($row[$nameColumnIndex]) ? trim((string)$row[$nameColumnIndex]) : '';
                     $sequence_str = isset($row[$sequenceColumnIndex]) ? trim($row[$sequenceColumnIndex]) : '';
                     $pic_prompt = '';
                     
@@ -7781,8 +7943,15 @@ class AnimeService
 
                     // 处理product_name两边的符号
                     $product_name = preg_replace('/^[\s*\[\]【】[]{}{}\x{3000}]+|[\s*\[\]【】[]{}{}\x{3000}]+$/u', '', $product_name);
-                    // 如果名称不存在则跳过
-                    if (!$product_name) continue;
+                    // 如果名称不存在或仅剩占位符号,则跳过
+                    if (!$product_name || preg_match('/^[-—_]+$/u', $product_name)) {
+                        dLog('anime')->warning('资产名称无效,跳过保存', [
+                            'script_id' => $script_id,
+                            'type' => $type,
+                            'product_name' => $product_name
+                        ]);
+                        continue;
+                    }
                     
                     // 直接插入新资产到 mp_products 表(parent_id 指向对应类型的根文件夹)
                     $product_id = DB::table('mp_products')->insertGetId([

+ 17 - 13
app/Services/DeepSeek/DeepSeekService.php

@@ -325,6 +325,22 @@ class DeepSeekService
         $script_id = getProp($data, 'script_id', 0); // 新增: 剧本ID
         $sequence = getProp($data, 'sequence', 0); // 新增: 剧集序号
         
+        // 新增: 如果提供了script_id,提前校验是否已有生成中的任务
+        $generateTaskId = 0;
+        if ($script_id > 0) {
+            $uid = Site::getUid();
+            $existingTask = DB::table('mp_script_generate_tasks')
+                ->where('uid', $uid)
+                ->where('script_id', $script_id)
+                ->orderByDesc('id')
+                ->first();
+
+            // 如果已有生成中的任务,直接提示,避免前端长时间等待
+            if ($existingTask && in_array(getProp($existingTask, 'status'), ['pending', 'processing'])) {
+                Utils::throwError('20003:该剧本资产正在生成中,请稍后重试');
+            }
+        }
+
         // 新增: 如果提供了script_id,从数据库获取剧本内容作为原文
         $originalContent = '';
         if ($script_id > 0) {
@@ -538,21 +554,9 @@ class DeepSeekService
             }
         }
         
-        // 新增: 如果提供了script_id,创建剧本资产生成任务
-        $generateTaskId = 0;
+        // 新增: 如果提供了script_id,创建剧本资产生成任务(所有参数校验通过后创建,避免校验失败留下卡在生成中的任务)
         if ($script_id > 0) {
             $uid = Site::getUid();
-            $existingTask = DB::table('mp_script_generate_tasks')
-                ->where('uid', $uid)
-                ->where('script_id', $script_id)
-                ->orderByDesc('id')
-                ->first();
-
-            // 如果已有生成中的任务,则提示该剧本资产正在生成
-            if ($existingTask && in_array(getProp($existingTask, 'status'), ['pending', 'processing'])) {
-                Utils::throwError('20003:该剧本资产正在生成中,请稍后重试');
-            }
-
             // 创建新的生成任务
             $generateTaskId = DB::table('mp_script_generate_tasks')->insertGetId([
                 'uid'        => $uid,

+ 1 - 0
routes/api.php

@@ -207,6 +207,7 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
             Route::get('confirmActs', [AnimeController::class, 'confirmActs']);                         // 确认片段分镜
             Route::get('saveActVideoGenerateParams', [AnimeController::class, 'saveActVideoGenerateParams']);   // 保存生成视频参数
             Route::post('createActVideoTask', [AnimeController::class, 'createActVideoTask']);          // 片段转视频
+            Route::post('generateVideo', [AnimeController::class, 'generateVideo']);                     // 文生视频(通用)
             Route::post('batchSetActVideos', [AnimeController::class, 'batchSetActVideos']);            // 片段一键转视频
             
             // 完整视频合成任务