|
|
@@ -376,6 +376,468 @@ class AnimeController extends BaseController
|
|
|
}
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * 批量生成分镜视频
|
|
|
+ */
|
|
|
+ public function batchSetSegmentVideos(Request $request) {
|
|
|
+ $data = $request->all();
|
|
|
+
|
|
|
+ // 验证参数
|
|
|
+ $validator = Validator::make($data, [
|
|
|
+ 'anime_id' => 'required|string',
|
|
|
+ 'episode_number' => 'required|integer',
|
|
|
+ ], [
|
|
|
+ 'anime_id.required' => '动漫对话ID不能为空',
|
|
|
+ 'episode_number.required' => '剧集序号不能为空',
|
|
|
+ 'episode_number.integer' => '剧集序号必须是整数',
|
|
|
+ ]);
|
|
|
+
|
|
|
+ if ($validator->fails()) {
|
|
|
+ Utils::throwError('1002:' . $validator->errors()->first());
|
|
|
+ }
|
|
|
+
|
|
|
+ $animeId = $data['anime_id'];
|
|
|
+ $episodeId = $data['episode_id'];
|
|
|
+ $episodeNumber = $data['episode_number'];
|
|
|
+
|
|
|
+ // 获取所有分镜信息
|
|
|
+ $segments = DB::table('mp_episode_segments')
|
|
|
+ ->where('anime_id', $animeId)
|
|
|
+ ->where('episode_id', $episodeId)
|
|
|
+ ->orderBy('segment_number')
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ if ($segments->isEmpty()) {
|
|
|
+ Utils::throwError('20003:未找到分镜数据');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 批量创建视频任务
|
|
|
+ $taskIds = [];
|
|
|
+ $segmentTasks = [];
|
|
|
+
|
|
|
+ foreach ($segments as $segment) {
|
|
|
+ try {
|
|
|
+ // 检查是否已有视频或正在生成中
|
|
|
+ if (!empty($segment->video_url) || $segment->video_task_status === '生成中') {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取分镜内容
|
|
|
+ $segmentContent = $segment->segment_content ?: '';
|
|
|
+ $tailFrame = $segment->tail_frame ?: '';
|
|
|
+
|
|
|
+ // 构建完整的提示词
|
|
|
+ $fullPrompt = $segmentContent;
|
|
|
+ if ($tailFrame) {
|
|
|
+ $fullPrompt .= "\n尾帧描述:$tailFrame";
|
|
|
+ }
|
|
|
+
|
|
|
+ // 智能选择视频时长
|
|
|
+ $videoDuration = $this->calculateOptimalVideoDuration($segmentContent, $tailFrame);
|
|
|
+
|
|
|
+ // 构建视频生成参数
|
|
|
+ $videoParams = [
|
|
|
+ 'model' => 'doubao-seedance-1-5-pro-251215',
|
|
|
+ 'prompt' => $fullPrompt,
|
|
|
+ 'video_duration' => $videoDuration,
|
|
|
+ 'video_resolution' => '720P',
|
|
|
+ 'seed' => -1,
|
|
|
+ 'ratio' => '16:9',
|
|
|
+ 'generate_audio' => false,
|
|
|
+ 'draft' => false,
|
|
|
+ 'watermark' => false,
|
|
|
+ 'camera_fixed' => false,
|
|
|
+ 'callback_url' => 'http://mpaudio.yqsd.cn'
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 如果分镜有图片,作为首帧
|
|
|
+ if (!empty($segment->img_url)) {
|
|
|
+ $videoParams['first_frame_url'] = $segment->img_url;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 构建content数组
|
|
|
+ $videoParams['content'] = [
|
|
|
+ [
|
|
|
+ 'type' => 'text',
|
|
|
+ 'text' => $videoParams['prompt'],
|
|
|
+ ]
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 如果有首帧图片,添加到content中
|
|
|
+ if (isset($videoParams['first_frame_url'])) {
|
|
|
+ $videoParams['content'][] = [
|
|
|
+ 'type' => 'image_url',
|
|
|
+ 'image_url' => [
|
|
|
+ 'url' => $videoParams['first_frame_url'],
|
|
|
+ ],
|
|
|
+ 'role' => 'first_frame',
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ // 创建视频任务
|
|
|
+ $task = $this->AIVideoGenerationService->createSeedanceTask($videoParams);
|
|
|
+ $taskIds[] = $task->id;
|
|
|
+
|
|
|
+ // 更新分镜表的视频任务信息
|
|
|
+ DB::table('mp_episode_segments')
|
|
|
+ ->where('segment_id', $segment->segment_id)
|
|
|
+ ->update([
|
|
|
+ 'video_task_id' => $task->id,
|
|
|
+ 'video_task_status' => '生成中',
|
|
|
+ 'updated_at' => date('Y-m-d H:i:s')
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $segmentTasks[] = [
|
|
|
+ 'segment_id' => $segment->segment_id,
|
|
|
+ 'task_id' => $task->id,
|
|
|
+ 'segment_number' => $segment->segment_number,
|
|
|
+ ];
|
|
|
+
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ dLog('anime')->error('创建分镜视频任务失败: ' . $e->getMessage(), [
|
|
|
+ 'segment_id' => $segment->segment_id,
|
|
|
+ 'anime_id' => $animeId,
|
|
|
+ 'episode_number' => $episodeNumber
|
|
|
+ ]);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (empty($taskIds)) {
|
|
|
+ Utils::throwError('20003:没有需要生成视频的分镜');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 记录开始时间(包含创建任务的时间)
|
|
|
+ $startTime = time();
|
|
|
+ $maxDuration = 1800; // 30分钟超时
|
|
|
+
|
|
|
+ // 设置 SSE 响应头并开始长连接
|
|
|
+ return response()->stream(function () use ($taskIds, $segmentTasks, $animeId, $episodeNumber, $episodeId, $startTime, $maxDuration) {
|
|
|
+ // 发送初始任务创建信息
|
|
|
+ echo "data: " . json_encode([
|
|
|
+ 'type' => 'tasks_created',
|
|
|
+ 'data' => [
|
|
|
+ 'anime_id' => $animeId,
|
|
|
+ 'episode_id' => $episodeId,
|
|
|
+ 'episode_number' => $episodeNumber,
|
|
|
+ 'total_tasks' => count($taskIds),
|
|
|
+ 'task_ids' => $taskIds,
|
|
|
+ 'segments' => $segmentTasks,
|
|
|
+ 'start_time' => $startTime,
|
|
|
+ 'max_duration' => $maxDuration
|
|
|
+ ]
|
|
|
+ ]) . "\n\n";
|
|
|
+ ob_flush();
|
|
|
+ flush();
|
|
|
+
|
|
|
+ $checkInterval = 10; // 每10秒检查一次
|
|
|
+ $completedTasks = [];
|
|
|
+ $failedTasks = [];
|
|
|
+
|
|
|
+ // 检查是否已经超时(包含创建任务的时间)
|
|
|
+ while (time() - $startTime < $maxDuration) {
|
|
|
+ try {
|
|
|
+ $allCompleted = true;
|
|
|
+ $currentStatus = [];
|
|
|
+
|
|
|
+ foreach ($segmentTasks as $segmentTask) {
|
|
|
+ $taskId = $segmentTask['task_id'];
|
|
|
+ $segmentId = $segmentTask['segment_id'];
|
|
|
+
|
|
|
+ // 跳过已完成或失败的任务
|
|
|
+ if (in_array($taskId, $completedTasks) || in_array($taskId, $failedTasks)) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 查询任务状态
|
|
|
+ $task = \App\Models\MpGenerateVideoTask::find($taskId);
|
|
|
+ if (!$task) {
|
|
|
+ $failedTasks[] = $taskId;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果任务还在处理中,查询最新状态
|
|
|
+ if ($task->status === 'processing') {
|
|
|
+ $statusResult = $this->AIVideoGenerationService->querySeedanceTaskStatus($task);
|
|
|
+ if (isset($statusResult['status'])) {
|
|
|
+ // 更新任务状态
|
|
|
+ $task->update([
|
|
|
+ 'status' => $statusResult['status'],
|
|
|
+ 'result_url' => $statusResult['result_url'] ?? null,
|
|
|
+ 'last_frame_url' => $statusResult['last_frame_url'] ?? '',
|
|
|
+ 'error_message' => $statusResult['error_message'] ?? null,
|
|
|
+ 'completed_at' => in_array($statusResult['status'], [
|
|
|
+ 'success',
|
|
|
+ 'failed'
|
|
|
+ ]) ? now() : null
|
|
|
+ ]);
|
|
|
+
|
|
|
+ // 如果任务成功,更新分镜表
|
|
|
+ if ($statusResult['status'] === 'success' && isset($statusResult['result_url'])) {
|
|
|
+ DB::table('mp_episode_segments')
|
|
|
+ ->where('segment_id', $segmentId)
|
|
|
+ ->update([
|
|
|
+ 'video_url' => $statusResult['result_url'],
|
|
|
+ 'video_task_status' => '已完成',
|
|
|
+ 'last_frame_url' => $statusResult['last_frame_url'] ?? '',
|
|
|
+ 'updated_at' => date('Y-m-d H:i:s')
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $completedTasks[] = $taskId;
|
|
|
+
|
|
|
+ // 发送单个任务完成通知
|
|
|
+ echo "data: " . json_encode([
|
|
|
+ 'type' => 'task_completed',
|
|
|
+ 'data' => [
|
|
|
+ 'segment_id' => $segmentId,
|
|
|
+ 'task_id' => $taskId,
|
|
|
+ 'segment_number' => $segmentTask['segment_number'],
|
|
|
+ 'status' => 'success',
|
|
|
+ 'video_url' => $statusResult['result_url'],
|
|
|
+ 'last_frame_url' => $statusResult['last_frame_url'] ?? '',
|
|
|
+ 'completed_count' => count($completedTasks),
|
|
|
+ 'total_count' => count($segmentTasks)
|
|
|
+ ]
|
|
|
+ ]) . "\n\n";
|
|
|
+ ob_flush();
|
|
|
+ flush();
|
|
|
+
|
|
|
+ } elseif ($statusResult['status'] === 'failed') {
|
|
|
+ DB::table('mp_episode_segments')
|
|
|
+ ->where('segment_id', $segmentId)
|
|
|
+ ->update([
|
|
|
+ 'video_task_status' => '失败',
|
|
|
+ 'updated_at' => date('Y-m-d H:i:s')
|
|
|
+ ]);
|
|
|
+
|
|
|
+ $failedTasks[] = $taskId;
|
|
|
+
|
|
|
+ // 发送任务失败通知
|
|
|
+ echo "data: " . json_encode([
|
|
|
+ 'type' => 'task_failed',
|
|
|
+ 'data' => [
|
|
|
+ 'segment_id' => $segmentId,
|
|
|
+ 'task_id' => $taskId,
|
|
|
+ 'segment_number' => $segmentTask['segment_number'],
|
|
|
+ 'status' => 'failed',
|
|
|
+ 'error_message' => $statusResult['error_message'] ?? '视频生成失败',
|
|
|
+ 'failed_count' => count($failedTasks),
|
|
|
+ 'total_count' => count($segmentTasks)
|
|
|
+ ]
|
|
|
+ ]) . "\n\n";
|
|
|
+ ob_flush();
|
|
|
+ flush();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 收集当前状态
|
|
|
+ $currentStatus[] = [
|
|
|
+ 'segment_id' => $segmentId,
|
|
|
+ 'task_id' => $taskId,
|
|
|
+ 'segment_number' => $segmentTask['segment_number'],
|
|
|
+ 'status' => $task->status,
|
|
|
+ 'progress' => $this->getTaskProgress($task->status),
|
|
|
+ 'video_url' => $task->result_url,
|
|
|
+ 'error_message' => $task->error_message
|
|
|
+ ];
|
|
|
+
|
|
|
+ // 检查是否还有未完成的任务
|
|
|
+ if (!in_array($task->status, ['success', 'failed'])) {
|
|
|
+ $allCompleted = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 发送整体进度更新
|
|
|
+ echo "data: " . json_encode([
|
|
|
+ 'type' => 'progress_update',
|
|
|
+ 'data' => [
|
|
|
+ 'anime_id' => $animeId,
|
|
|
+ 'episode_id' => $episodeId,
|
|
|
+ 'episode_number' => $episodeNumber,
|
|
|
+ 'completed_count' => count($completedTasks),
|
|
|
+ 'failed_count' => count($failedTasks),
|
|
|
+ 'total_count' => count($segmentTasks),
|
|
|
+ 'elapsed_time' => time() - $startTime,
|
|
|
+ 'remaining_time' => max(0, $maxDuration - (time() - $startTime)),
|
|
|
+ 'segments_status' => $currentStatus
|
|
|
+ ]
|
|
|
+ ]) . "\n\n";
|
|
|
+ ob_flush();
|
|
|
+ flush();
|
|
|
+
|
|
|
+ // 如果所有任务都完成了,结束连接
|
|
|
+ if ($allCompleted) {
|
|
|
+ // 获取最终结果
|
|
|
+ $finalSegments = DB::table('mp_episode_segments')
|
|
|
+ ->where('anime_id', $animeId)
|
|
|
+ ->where('episode_id', $episodeId)
|
|
|
+ ->orderBy('segment_number')
|
|
|
+ ->select('segment_id', 'segment_number', 'video_url', 'video_task_status', 'last_frame_url')
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ echo "data: " . json_encode([
|
|
|
+ 'type' => 'all_completed',
|
|
|
+ 'data' => [
|
|
|
+ 'anime_id' => $animeId,
|
|
|
+ 'episode_id' => $episodeId,
|
|
|
+ 'episode_number' => $episodeNumber,
|
|
|
+ 'completed_count' => count($completedTasks),
|
|
|
+ 'failed_count' => count($failedTasks),
|
|
|
+ 'total_count' => count($segmentTasks),
|
|
|
+ 'total_elapsed_time' => time() - $startTime,
|
|
|
+ 'segments' => $finalSegments->map(function($segment) {
|
|
|
+ return [
|
|
|
+ 'segment_id' => $segment->segment_id,
|
|
|
+ 'segment_number' => $segment->segment_number,
|
|
|
+ 'video_url' => $segment->video_url,
|
|
|
+ 'status' => $segment->video_task_status,
|
|
|
+ 'last_frame_url' => $segment->last_frame_url
|
|
|
+ ];
|
|
|
+ })->toArray()
|
|
|
+ ]
|
|
|
+ ]) . "\n\n";
|
|
|
+ ob_flush();
|
|
|
+ flush();
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 在睡眠前再次检查是否超时,避免不必要的等待
|
|
|
+ if (time() - $startTime >= $maxDuration) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ sleep($checkInterval);
|
|
|
+
|
|
|
+ } catch (\Exception $e) {
|
|
|
+ echo "data: " . json_encode([
|
|
|
+ 'type' => 'error',
|
|
|
+ 'message' => '查询任务状态失败: ' . $e->getMessage(),
|
|
|
+ 'elapsed_time' => time() - $startTime,
|
|
|
+ 'remaining_time' => max(0, $maxDuration - (time() - $startTime))
|
|
|
+ ]) . "\n\n";
|
|
|
+ ob_flush();
|
|
|
+ flush();
|
|
|
+
|
|
|
+ // 检查是否超时,如果超时则停止循环
|
|
|
+ if (time() - $startTime >= $maxDuration) {
|
|
|
+ break;
|
|
|
+ }
|
|
|
+
|
|
|
+ sleep($checkInterval);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 超时处理 - 只有在真正超时时才执行
|
|
|
+ if (time() - $startTime >= $maxDuration) {
|
|
|
+ // 获取当前所有任务状态
|
|
|
+ $timeoutSegments = DB::table('mp_episode_segments')
|
|
|
+ ->where('anime_id', $animeId)
|
|
|
+ ->where('episode_id', $episodeId)
|
|
|
+ ->orderBy('segment_number')
|
|
|
+ ->select('segment_id', 'segment_number', 'video_task_id', 'video_task_status', 'video_url')
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ echo "data: " . json_encode([
|
|
|
+ 'type' => 'timeout',
|
|
|
+ 'message' => '批量视频生成已超时30分钟,停止状态查询',
|
|
|
+ 'data' => [
|
|
|
+ 'anime_id' => $animeId,
|
|
|
+ 'episode_id' => $episodeId,
|
|
|
+ 'episode_number' => $episodeNumber,
|
|
|
+ 'completed_count' => count($completedTasks),
|
|
|
+ 'failed_count' => count($failedTasks),
|
|
|
+ 'total_count' => count($segmentTasks),
|
|
|
+ 'total_elapsed_time' => time() - $startTime,
|
|
|
+ 'timeout_duration' => $maxDuration,
|
|
|
+ 'segments' => $timeoutSegments->map(function($segment) {
|
|
|
+ return [
|
|
|
+ 'segment_id' => $segment->segment_id,
|
|
|
+ 'segment_number' => $segment->segment_number,
|
|
|
+ 'task_id' => $segment->video_task_id,
|
|
|
+ 'status' => $segment->video_task_status,
|
|
|
+ 'video_url' => $segment->video_url
|
|
|
+ ];
|
|
|
+ })->toArray()
|
|
|
+ ]
|
|
|
+ ]) . "\n\n";
|
|
|
+ ob_flush();
|
|
|
+ flush();
|
|
|
+ }
|
|
|
+
|
|
|
+ }, 200, [
|
|
|
+ 'Content-Type' => 'text/event-stream',
|
|
|
+ 'Cache-Control' => 'no-cache',
|
|
|
+ 'Connection' => 'keep-alive',
|
|
|
+ 'X-Accel-Buffering' => 'no', // 禁用 Nginx 缓冲
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 计算最优视频时长(从 AnimeService 复制)
|
|
|
+ */
|
|
|
+ private function calculateOptimalVideoDuration($segmentContent, $tailFrame = '') {
|
|
|
+ // 基础时长
|
|
|
+ $baseDuration = 4;
|
|
|
+
|
|
|
+ // 根据内容长度调整
|
|
|
+ $contentLength = mb_strlen($segmentContent);
|
|
|
+ if ($contentLength > 200) {
|
|
|
+ $baseDuration += 2;
|
|
|
+ } elseif ($contentLength > 100) {
|
|
|
+ $baseDuration += 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 根据内容复杂度调整
|
|
|
+ $complexity = $this->analyzeContentComplexity($segmentContent);
|
|
|
+ if ($complexity > 0.7) {
|
|
|
+ $baseDuration += 2;
|
|
|
+ } elseif ($complexity > 0.5) {
|
|
|
+ $baseDuration += 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 如果有尾帧描述,增加时长
|
|
|
+ if (!empty($tailFrame)) {
|
|
|
+ $baseDuration += 1;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 限制在合理范围内
|
|
|
+ return min(max($baseDuration, 4), 12);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 分析内容复杂度(从 AnimeService 复制)
|
|
|
+ */
|
|
|
+ private function analyzeContentComplexity($text) {
|
|
|
+ $complexity = 0;
|
|
|
+
|
|
|
+ // 动作词汇
|
|
|
+ $actionWords = ['跑', '跳', '飞', '战斗', '攻击', '移动', '转身', '冲刺', '闪避', '追逐'];
|
|
|
+ foreach ($actionWords as $word) {
|
|
|
+ if (strpos($text, $word) !== false) {
|
|
|
+ $complexity += 0.1;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 场景变化词汇
|
|
|
+ $sceneWords = ['切换', '转场', '变化', '移动', '远景', '近景', '特写'];
|
|
|
+ foreach ($sceneWords as $word) {
|
|
|
+ if (strpos($text, $word) !== false) {
|
|
|
+ $complexity += 0.15;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 情感词汇
|
|
|
+ $emotionWords = ['激动', '愤怒', '悲伤', '惊讶', '恐惧', '兴奋'];
|
|
|
+ foreach ($emotionWords as $word) {
|
|
|
+ if (strpos($text, $word) !== false) {
|
|
|
+ $complexity += 0.05;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return min($complexity, 1.0);
|
|
|
+ }
|
|
|
+
|
|
|
// public function scriptList(Request $request) {
|
|
|
// $data = $request->all();
|
|
|
// $result = $this->AnimeService->scriptList($data);
|