requireAccess(); if (!is_array($user)) { return $user; } return $this->success([ 'templates' => $this->templates(), 'projects' => $this->projectList((int) $user['id']), 'defaults' => [ 'aspect_ratio' => '9:16', 'duration' => 'auto', 'quality' => 'fast', 'style' => '电影写实', 'mode' => 'auto', 'voice_language' => VideoDubService::LANG_MANDARIN, 'show_subtitles' => true, 'character_origin' => ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN, 'screen_text_language' => ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN, 'shot_duration_mode' => ShortDramaPlannerService::SHOT_DURATION_AUTO, ], 'voice_languages' => [ ['value' => VideoDubService::LANG_MANDARIN, 'label' => '普通话 · H3 原生口型同步(推荐)'], ['value' => VideoDubService::LANG_NONE, 'label' => '无配音'], ['value' => VideoDubService::LANG_NATIVE, 'label' => 'H3 原生音轨(可能不清晰)'], ], 'subtitle_options' => [ ['value' => true, 'label' => '显示字幕'], ['value' => false, 'label' => '关闭字幕'], ], 'character_origins' => [ ['value' => ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN, 'label' => '东方面孔(东亚)'], ['value' => ShortDramaPlannerService::CHARACTER_ORIGIN_GLOBAL, 'label' => '全球面孔(国家不限)'], ], 'screen_text_languages' => [ ['value' => ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN, 'label' => '简体中文'], ['value' => ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_EN_US, 'label' => 'English(英文)'], ['value' => ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE, 'label' => '不显示画面文字'], ], 'shot_duration_modes' => [ ['value' => ShortDramaPlannerService::SHOT_DURATION_AUTO, 'label' => 'AI 智能 · 5/10 秒(推荐)'], ['value' => ShortDramaPlannerService::SHOT_DURATION_FIVE, 'label' => '每镜 5 秒 · 节奏更快'], ['value' => ShortDramaPlannerService::SHOT_DURATION_TEN, 'label' => '每镜 10 秒 · 表演更完整'], ], 'engine' => [ 'target_model' => ShortDramaPlannerService::targetModel(), 'workflow_version' => MiniMaxH3Service::WORKFLOW_VERSION, 'workflow' => MiniMaxH3Service::workflowManifest(), 'shot_concurrency' => MiniMaxH3Service::workerSummary()['effective_concurrency'], 'worker_pool' => MiniMaxH3Service::workerSummary(), 'director_prompt_enabled' => true, 'director_prompt_version' => substr( hash('sha256', ShortDramaPlannerService::directorSystemPrompt()), 0, 12 ), ], ]); } public function index() { $user = $this->requireAccess(); if (!is_array($user)) { return $user; } return $this->success($this->projectList((int) $user['id'])); } public function createProject() { $user = $this->requireAccess(); if (!is_array($user)) { return $user; } $input = $this->request->post(); $idea = trim((string) ($input['idea'] ?? $input['script'] ?? '')); if (mb_strlen($idea) < 5) { return $this->error('请至少用一句话描述想拍的短剧', 422); } if (mb_strlen($idea) > 50000) { return $this->error('剧本内容不能超过 50000 字', 422); } $requestedDuration = (string) ($input['duration'] ?? 'auto'); $durationMode = $requestedDuration === 'auto' ? 'auto' : 'fixed'; $duration = $durationMode === 'auto' ? ShortDramaPlannerService::recommendDuration($idea) : (int) $requestedDuration; if ($durationMode === 'fixed' && !in_array($duration, [5, 10, 30, 60, 90], true)) { $duration = 30; } $aspectRatio = ($input['aspect_ratio'] ?? '9:16') === '16:9' ? '16:9' : '9:16'; $requestedQuality = (string) ($input['quality'] ?? 'fast'); $quality = in_array($requestedQuality, ['fast', 'standard', 'high'], true) ? $requestedQuality : 'fast'; $style = trim((string) ($input['style'] ?? '电影写实')) ?: '电影写实'; $voiceLanguage = VideoDubService::normalizeLanguage((string) ($input['voice_language'] ?? 'zh-CN')); $showSubtitles = filter_var( $input['show_subtitles'] ?? true, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE ); $showSubtitles = $showSubtitles ?? true; $characterOrigin = ShortDramaPlannerService::normalizeCharacterOrigin( (string) ($input['character_origin'] ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN) ); $screenTextLanguage = ShortDramaPlannerService::normalizeScreenTextLanguage( (string) ($input['screen_text_language'] ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN) ); $shotDurationMode = ShortDramaPlannerService::normalizeShotDurationMode( (string) ($input['shot_duration_mode'] ?? ShortDramaPlannerService::SHOT_DURATION_AUTO) ); $plan = ShortDramaPlannerService::planWithAi( $idea, $duration, $aspectRatio, $style, $voiceLanguage, $characterOrigin, $screenTextLanguage, $shotDurationMode ); $result = Db::transaction(function () use ($user, $idea, $duration, $durationMode, $aspectRatio, $quality, $style, $voiceLanguage, $showSubtitles, $characterOrigin, $screenTextLanguage, $shotDurationMode, $plan) { $project = VideoProject::create([ 'user_id' => (int) $user['id'], 'title' => $plan['title'], 'idea' => $idea, 'style' => $style, 'aspect_ratio' => $aspectRatio, 'episode_duration' => $duration, 'duration_mode' => $durationMode, 'quality' => $quality, 'voice_language' => $voiceLanguage, 'show_subtitles' => $showSubtitles ? 1 : 0, 'character_origin' => $characterOrigin, 'screen_text_language' => $screenTextLanguage, 'shot_duration_mode' => $shotDurationMode, 'status' => 'storyboard', ]); $episode = VideoEpisode::create([ 'project_id' => (int) $project->id, 'episode_no' => 1, 'title' => '第 1 集', 'script' => $plan['script'], 'status' => 'storyboard', 'progress' => 10, 'progress_message' => '分镜已准备好,请确认角色后生成', ]); foreach ($plan['shots'] as $shot) { $shot['episode_id'] = (int) $episode->id; VideoShot::create($shot); } return [$project, $episode]; }); [$project, $episode] = $result; if (!empty($input['auto_generate'])) { try { $this->submitEpisode($project, $episode, []); } catch (\Throwable $error) { $episode->save([ 'status' => 'needs_attention', 'progress_message' => $this->progressErrorMessage('', $error), ]); } } return $this->success( $this->projectDetail((int) $project->id, (int) $user['id']), '短剧项目已创建', 201 ); } public function show($id) { $user = $this->requireAccess(); if (!is_array($user)) { return $user; } $detail = $this->projectDetail((int) $id, (int) $user['id']); return $detail ? $this->success($detail) : $this->error('短剧项目不存在', 404); } public function addCharacter($id) { $user = $this->requireAccess(); if (!is_array($user)) { return $user; } $project = $this->findProject((int) $id, (int) $user['id']); if (!$project) { return $this->error('短剧项目不存在', 404); } if (VideoCharacter::where('project_id', $project->id)->count() >= 9) { return $this->error('一个项目最多添加 9 个固定角色', 422); } $input = $this->request->post(); $name = trim((string) ($input['name'] ?? '')); if ($name === '' || mb_strlen($name) > 80) { return $this->error('请填写 1-80 字的角色名称', 422); } $uploadId = (int) ($input['reference_upload_id'] ?? 0); if ($uploadId > 0) { $upload = UploadFile::where('id', $uploadId) ->where('user_id', (int) $user['id']) ->where('file_type', 'image') ->find(); if (!$upload) { return $this->error('角色参考图不存在或不属于当前用户', 422); } } $character = VideoCharacter::create([ 'project_id' => (int) $project->id, 'user_id' => (int) $user['id'], 'name' => $name, 'description' => mb_substr(trim((string) ($input['description'] ?? '')), 0, 1000), 'reference_upload_id' => $uploadId > 0 ? $uploadId : null, 'voice_key' => trim((string) ($input['voice_key'] ?? '')) ?: null, 'is_locked' => 1, 'asset_version' => 1, ]); $this->refreshDraftPrompts($project); return $this->success($this->characterData($character), '角色已锁定', 201); } public function deleteCharacter($id, $characterId) { $user = $this->requireAccess(); if (!is_array($user)) { return $user; } $project = $this->findProject((int) $id, (int) $user['id']); if (!$project) { return $this->error('短剧项目不存在', 404); } $character = VideoCharacter::where('id', (int) $characterId) ->where('project_id', (int) $project->id) ->find(); if (!$character) { return $this->error('角色不存在', 404); } $character->delete(); $this->refreshDraftPrompts($project); return $this->success(null, '角色已移除'); } public function generate($id) { $user = $this->requireAccess(); if (!is_array($user)) { return $user; } $project = $this->findProject((int) $id, (int) $user['id']); if (!$project) { return $this->error('短剧项目不存在', 404); } $episodeId = (int) ($this->request->post('episode_id') ?? 0); $episodeQuery = VideoEpisode::where('project_id', (int) $project->id); $episode = $episodeId > 0 ? $episodeQuery->where('id', $episodeId)->find() : $episodeQuery->order('episode_no', 'desc')->find(); if (!$episode) { return $this->error('分集不存在', 404); } $characters = VideoCharacter::where('project_id', (int) $project->id) ->order('id') ->select() ->toArray(); try { $summary = $this->submitEpisode($project, $episode, $characters); } catch (\Throwable $error) { return $this->error($error->getMessage(), 502); } return $this->success($summary, '视频生成任务已提交'); } public function status($id) { $user = $this->requireAccess(); if (!is_array($user)) { return $user; } $project = $this->findProject((int) $id, (int) $user['id']); if (!$project) { return $this->error('短剧项目不存在', 404); } $lockName = 'short_drama_project_' . (int) $project->id; $lockRows = Db::query('SELECT GET_LOCK(?, 0) AS acquired', [$lockName]); $acquired = (int) ($lockRows[0]['acquired'] ?? 0) === 1; if ($acquired) { try { $episodes = VideoEpisode::where('project_id', (int) $project->id) ->order('episode_no') ->select(); foreach ($episodes as $episode) { $this->refreshEpisodeStatus($project, $episode, (int) $user['id']); } } finally { Db::query('SELECT RELEASE_LOCK(?)', [$lockName]); } } return $this->success($this->projectDetail((int) $project->id, (int) $user['id'])); } public function retryShot($id, $shotId) { $user = $this->requireAccess(); if (!is_array($user)) { return $user; } $project = $this->findProject((int) $id, (int) $user['id']); if (!$project) { return $this->error('短剧项目不存在', 404); } $shot = VideoShot::alias('s') ->join('video_episodes e', 's.episode_id = e.id') ->where('s.id', (int) $shotId) ->where('e.project_id', (int) $project->id) ->field('s.*') ->find(); if (!$shot) { return $this->error('镜头不存在', 404); } $input = $this->request->post(); $reason = (string) ($input['reason'] ?? 'replace'); $meta = is_array($shot->meta) ? $shot->meta : []; $planned = ['shots' => []]; $plannedShot = null; // AI 已完成的语义分类必须在重试时原样保留,不能重新退化成固定节拍, // 否则“人物对白/场景声/屏幕文字”的判定会被覆盖。旧项目才走本地补全。 if (($meta['analysis_source'] ?? '') !== 'ai') { $planned = ShortDramaPlannerService::plan( (string) $project->idea, (int) $project->episode_duration, (string) $project->aspect_ratio, (string) $project->style, (string) ($project->voice_language ?? 'zh-CN'), (string) ($project->character_origin ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN), (string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN), (string) ($project->shot_duration_mode ?? ShortDramaPlannerService::SHOT_DURATION_FIVE) ); $plannedShot = $planned['shots'][max(0, (int) $shot->shot_no - 1)] ?? null; } if (is_array($plannedShot)) { $meta = array_replace($meta, $plannedShot['meta'] ?? []); } if ($reason === 'identity') { $meta['identity_boost'] = true; } unset( $meta['raw_output_upload_id'], $meta['dubbed_language'], $meta['voice_source'], $meta['audio_policy_version'], $meta['continuity_frame_upload_id'], $meta['continuity_from_shot_id'] ); $characters = VideoCharacter::where('project_id', (int) $project->id)->order('id')->select()->toArray(); $prompt = trim((string) ($plannedShot['prompt'] ?? $meta['base_prompt'] ?? $shot->prompt)); if ($characters) { $prompt = ShortDramaPlannerService::recompileShotPrompt( (string) $project->idea, $prompt, $characters, (string) $project->style, (string) $project->aspect_ratio, (string) ($project->voice_language ?? 'zh-CN'), (string) ($project->character_origin ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN), (string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN) ); } if ($reason === 'action') { $prompt = preg_replace('/。+$/u', '', $prompt) ?? $prompt; $prompt .= '。动作保持简单、单一、连贯,不要突然切换场景或生成多余人物。'; } $shot->save([ 'status' => 'draft', 'prompt_id' => null, 'output_upload_id' => null, 'error_message' => null, 'seed' => random_int(1, PHP_INT_MAX), 'meta' => $meta, 'prompt' => $prompt, 'dialogue' => (string) ($plannedShot['dialogue'] ?? $shot->dialogue), ]); $episode = VideoEpisode::where('id', (int) $shot->episode_id) ->where('project_id', (int) $project->id) ->find(); if (!$episode) { return $this->error('分集不存在', 404); } // 中间镜头改变后,后续镜头的动作和尾帧依据全部失效,必须从这里重新接力。 $followingShots = VideoShot::where('episode_id', (int) $episode->id) ->where('shot_no', '>', (int) $shot->shot_no) ->select(); foreach ($followingShots as $followingShot) { $followingPlanned = $planned['shots'][max(0, (int) $followingShot->shot_no - 1)] ?? null; $followingMeta = is_array($followingShot->meta) ? $followingShot->meta : []; if (is_array($followingPlanned)) { $followingMeta = array_replace($followingMeta, $followingPlanned['meta'] ?? []); } unset( $followingMeta['raw_output_upload_id'], $followingMeta['dubbed_language'], $followingMeta['voice_source'], $followingMeta['audio_policy_version'], $followingMeta['continuity_frame_upload_id'], $followingMeta['continuity_from_shot_id'] ); $followingPrompt = trim((string) ($followingPlanned['prompt'] ?? $followingShot->prompt)); if ($characters) { $followingPrompt = ShortDramaPlannerService::recompileShotPrompt( (string) $project->idea, $followingPrompt, $characters, (string) $project->style, (string) $project->aspect_ratio, (string) ($project->voice_language ?? 'zh-CN'), (string) ($project->character_origin ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN), (string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN) ); } $followingShot->save([ 'status' => 'draft', 'prompt_id' => null, 'output_upload_id' => null, 'error_message' => null, 'seed' => random_int(1, PHP_INT_MAX), 'meta' => $followingMeta, 'prompt' => $followingPrompt, 'dialogue' => (string) ($followingPlanned['dialogue'] ?? $followingShot->dialogue), ]); } $episode->save([ 'status' => 'generating', 'progress' => 15, 'progress_message' => '正在从修改的镜头重新建立连续性', 'final_upload_id' => null, ]); $project->save(['status' => 'generating']); try { $this->submitEpisode($project, $episode, $characters); } catch (\Throwable $error) { $shot->save(['status' => 'error', 'error_message' => $error->getMessage()]); return $this->error($error->getMessage(), 502); } return $this->success($this->shotData($shot), '镜头已重新生成'); } public function deleteProject($id) { $user = $this->requireAccess(); if (!is_array($user)) { return $user; } $project = $this->findProject((int) $id, (int) $user['id']); if (!$project) { return $this->error('短剧项目不存在', 404); } $episodeIds = VideoEpisode::where('project_id', (int) $project->id)->column('id'); $uploadIds = VideoEpisode::where('project_id', (int) $project->id) ->whereNotNull('final_upload_id') ->column('final_upload_id'); if ($episodeIds) { $shots = VideoShot::whereIn('episode_id', $episodeIds)->select(); foreach ($shots as $shot) { if ((int) $shot->output_upload_id > 0) { $uploadIds[] = (int) $shot->output_upload_id; } $meta = is_array($shot->meta) ? $shot->meta : []; if ((int) ($meta['continuity_frame_upload_id'] ?? 0) > 0) { $uploadIds[] = (int) $meta['continuity_frame_upload_id']; } if ((int) ($meta['raw_output_upload_id'] ?? 0) > 0) { $uploadIds[] = (int) $meta['raw_output_upload_id']; } } } $project->delete(); $this->deleteGeneratedUploads(array_values(array_unique(array_map('intval', $uploadIds))), (int) $user['id']); return $this->success(null, '短剧和生成的视频已删除'); } private function submitEpisode(VideoProject $project, VideoEpisode $episode, array $characters): array { $active = VideoShot::where('episode_id', (int) $episode->id) ->whereIn('status', ['queued', 'running', 'waiting']) ->count(); if ($active > 0) { return [ 'submitted' => 0, 'failed' => 0, 'message' => "当前批次还有 {$active} 个镜头正在生成", ]; } // ComfyUI 的单个地址只有一个执行队列。同地址提交三条只是排队,不是真并发; // 有固定角色时才把镜头分发到不同地址,无角色时仍逐镜头承接真实尾帧。 $workers = array_slice(MiniMaxH3Service::workers(), 0, self::SHOT_CONCURRENCY); $batchLimit = $characters ? count($workers) : 1; $shots = VideoShot::where('episode_id', (int) $episode->id) ->whereIn('status', ['draft', 'error']) ->order('shot_no') ->limit($batchLimit) ->select(); if ($shots->count() === 0) { throw new \RuntimeException('没有需要生成的镜头'); } $firstShot = $shots[0]; $previousShot = VideoShot::where('episode_id', (int) $episode->id) ->where('shot_no', '=', (int) $firstShot->shot_no - 1) ->where('status', 'completed') ->find(); $continuityUploadId = null; if ($previousShot) { $previousMeta = is_array($previousShot->meta) ? $previousShot->meta : []; $continuityUploadId = (int) ($previousMeta['continuity_frame_upload_id'] ?? 0) ?: null; } $submittedCount = 0; $errors = []; $referenceCache = []; foreach ($shots as $batchIndex => $shot) { $worker = $workers[$batchIndex % count($workers)]; $workerId = (int) $worker->id; if (!array_key_exists($workerId, $referenceCache)) { $referenceCache[$workerId] = MiniMaxH3Service::prepareReferenceFiles( $project, $characters, $worker ); } // 一批的第一个镜头承接上一批真实尾帧;其余两个依靠固定角色图和导演提示词并发生成。 $shotContinuityUploadId = $batchIndex === 0 ? $continuityUploadId : null; try { $submitted = MiniMaxH3Service::submitShot( $shot, $project, $characters, $referenceCache[$workerId], $shotContinuityUploadId, $worker ); $meta = is_array($shot->meta) ? $shot->meta : []; $meta['reference_count'] = $submitted['reference_count']; $meta['continuity_applied'] = $submitted['continuity_applied']; $meta['audio_mode'] = $submitted['audio_mode']; $meta['workflow_version'] = $submitted['workflow_version']; $meta['workflow_settings'] = $submitted['applied_settings'] ?? []; $meta['comfy_worker_id'] = $submitted['worker_id']; $meta['comfy_worker_name'] = $submitted['worker_name']; $meta['concurrency'] = $batchLimit; $meta['batch_position'] = $batchIndex + 1; $meta['submitted_at'] = date(DATE_ATOM); unset($meta['missing_poll_count']); if ($previousShot && $submitted['continuity_applied']) { $meta['continuity_from_shot_id'] = (int) $previousShot->id; } $shot->save([ 'status' => 'queued', 'prompt_id' => $submitted['prompt_id'], 'workflow_type' => $submitted['workflow_type'], 'error_message' => null, 'meta' => $meta, ]); $submittedCount++; } catch (\Throwable $error) { $shot->save(['status' => 'error', 'error_message' => $error->getMessage()]); $errors[] = "镜头 {$shot->shot_no}: {$error->getMessage()}"; } } if ($submittedCount === 0) { throw new \RuntimeException($errors[0] ?? '本批镜头提交失败'); } $firstShotNo = (int) $firstShot->shot_no; $lastShotNo = (int) $shots[$shots->count() - 1]->shot_no; $episode->save([ 'status' => 'generating', 'progress' => 15, 'progress_message' => $batchLimit > 1 ? "正在由 {$batchLimit} 个独立 ComfyUI 节点并发生成镜头 {$firstShotNo}-{$lastShotNo}" : "正在生成连续镜头 {$firstShotNo},完成后自动接力下一镜头", ]); $project->save(['status' => 'generating']); return [ 'submitted' => $submittedCount, 'failed' => count($errors), 'message' => $batchLimit > 1 ? "镜头 {$firstShotNo}-{$lastShotNo} 已分发到 {$batchLimit} 个独立 ComfyUI 节点" : "镜头 {$firstShotNo} 已提交,将通过真实尾帧逐镜头接力", ]; } private function refreshEpisodeStatus(VideoProject $project, VideoEpisode $episode, int $userId): void { $shots = VideoShot::where('episode_id', (int) $episode->id)->order('shot_no')->select(); $voiceOutputsChanged = false; foreach ($shots as $shot) { if ((string) $shot->status === 'completed') { try { $voiceOutputsChanged = $this->ensureShotVoice($shot, $project, $userId) || $voiceOutputsChanged; } catch (\Throwable $error) { $shot->save(['status' => 'error', 'error_message' => $error->getMessage()]); } continue; } if (!in_array((string) $shot->status, ['queued', 'running', 'waiting'], true) || trim((string) $shot->prompt_id) === '') { continue; } $meta = is_array($shot->meta) ? $shot->meta : []; $workerId = (int) ($meta['comfy_worker_id'] ?? 0) ?: null; $inspection = MiniMaxH3Service::inspect((string) $shot->prompt_id, $workerId); if ($inspection['state'] === 'done') { try { $stored = MiniMaxH3Service::storeVideo($inspection['files'][0], $userId, $workerId); $voice = VideoDubService::replaceVoice( (int) $stored['id'], $userId, (string) $shot->dialogue, (string) ($project->voice_language ?? 'zh-CN'), (int) $shot->duration_seconds, (string) ($meta['timeline']['voice_timing'] ?? 'start'), is_array($meta['timeline'] ?? null) ? $meta['timeline'] : [] ); if ($voice['processed']) { $meta['raw_output_upload_id'] = (int) $stored['id']; } $meta['dubbed_language'] = $voice['language']; $meta['voice_source'] = $voice['source']; $meta['audio_policy_version'] = $voice['policy_version']; $meta['continuity_frame_upload_id'] = VideoRenderService::extractLastFrame( (int) $voice['id'], $userId ); $shot->save([ 'status' => 'completed', 'output_upload_id' => $voice['id'], 'error_message' => null, 'meta' => $meta, ]); } catch (\Throwable $error) { $shot->save(['status' => 'error', 'error_message' => $error->getMessage()]); } } elseif ($inspection['state'] === 'error') { $shot->save(['status' => 'error', 'error_message' => $inspection['error']]); } elseif ($inspection['state'] === 'missing') { $missingPollCount = (int) ($meta['missing_poll_count'] ?? 0) + 1; $meta['missing_poll_count'] = $missingPollCount; if ($missingPollCount >= 3) { $autoRetryCount = (int) ($meta['auto_retry_count'] ?? 0); if ($autoRetryCount < 2) { $meta['auto_retry_count'] = $autoRetryCount + 1; unset($meta['missing_poll_count']); $shot->save([ 'status' => 'draft', 'prompt_id' => null, 'error_message' => null, 'meta' => $meta, ]); } else { $shot->save([ 'status' => 'error', 'error_message' => 'ComfyUI 任务连续丢失 3 次,请检查远端队列或显存', 'meta' => $meta, ]); } } else { $shot->save(['status' => 'waiting', 'meta' => $meta]); } } else { $shot->save(['status' => $inspection['state'] === 'queued' ? 'queued' : 'running']); } } $shotRows = VideoShot::where('episode_id', (int) $episode->id)->order('shot_no')->select()->toArray(); $total = count($shotRows); $completed = count(array_filter($shotRows, fn ($shot) => $shot['status'] === 'completed')); $errors = count(array_filter($shotRows, fn ($shot) => $shot['status'] === 'error')); $active = count(array_filter($shotRows, fn ($shot) => in_array($shot['status'], ['queued', 'running', 'waiting'], true))); $drafts = count(array_filter($shotRows, fn ($shot) => $shot['status'] === 'draft')); $progress = $total > 0 ? 15 + (int) floor(($completed / $total) * 75) : 0; if ($total > 0 && $completed === $total) { if (!(int) $episode->final_upload_id || $voiceOutputsChanged) { try { $previousFinalUploadId = (int) $episode->final_upload_id; $finalUploadId = VideoRenderService::concatenate( $shotRows, $userId, (bool) ($project->show_subtitles ?? false), (string) $project->aspect_ratio, (string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE), (string) ($project->quality ?? 'standard') ); $episode->save([ 'final_upload_id' => $finalUploadId, 'status' => 'completed', 'progress' => 100, 'progress_message' => '成片已完成', ]); $project->save(['status' => 'completed']); if ($previousFinalUploadId > 0 && $previousFinalUploadId !== $finalUploadId) { $this->deleteGeneratedUploads([$previousFinalUploadId], $userId); } } catch (\Throwable $error) { $episode->save([ 'status' => 'needs_attention', 'progress' => 92, 'progress_message' => $this->progressErrorMessage('镜头已完成,合成失败:', $error), ]); $project->save(['status' => 'needs_attention']); } } return; } if ($active === 0 && $errors > 0) { $episode->save([ 'status' => 'needs_attention', 'progress' => max(15, $progress), 'progress_message' => "有 {$errors} 个镜头需要重新生成", ]); $project->save(['status' => 'needs_attention']); } elseif ($active === 0 && $drafts > 0) { try { $characters = VideoCharacter::where('project_id', (int) $project->id) ->order('id') ->select() ->toArray(); $this->submitEpisode($project, $episode, $characters); $nextShots = VideoShot::where('episode_id', (int) $episode->id) ->whereIn('status', ['queued', 'running', 'waiting']) ->order('shot_no') ->select(); $activeShotNumbers = array_map( fn ($shot) => (int) $shot->shot_no, $nextShots->all() ); $episode->save([ 'status' => 'generating', 'progress' => min(90, max(15, $progress)), 'progress_message' => $activeShotNumbers ? (count($activeShotNumbers) > 1 ? '上一批完成,正在并发生成镜头 ' : '上一镜头完成,正在连续生成镜头 ') . implode('、', $activeShotNumbers) . "({$completed}/{$total})" : "正在准备下一批镜头({$completed}/{$total})", ]); } catch (\Throwable $error) { $episode->save([ 'status' => 'needs_attention', 'progress' => max(15, $progress), 'progress_message' => $this->progressErrorMessage('连续镜头提交失败:', $error), ]); $project->save(['status' => 'needs_attention']); } } else { $hasCharacters = VideoCharacter::where('project_id', (int) $project->id)->count() > 0; $episode->save([ 'status' => 'generating', 'progress' => min(90, max(15, $progress)), 'progress_message' => $hasCharacters && $active > 1 ? "正在并发生成 {$active} 个镜头(已完成 {$completed}/{$total})" : "正在生成当前镜头(已完成 {$completed}/{$total})", ]); } } /** * 为迁移前已经生成完成的镜头补做所选语言的音轨。 */ private function ensureShotVoice(VideoShot $shot, VideoProject $project, int $userId): bool { $language = VideoDubService::normalizeLanguage((string) ($project->voice_language ?? 'zh-CN')); $meta = is_array($shot->meta) ? $shot->meta : []; if (($meta['dubbed_language'] ?? '') === $language && ($meta['audio_policy_version'] ?? '') === VideoDubService::POLICY_VERSION) { return false; } $previousOutputUploadId = (int) $shot->output_upload_id; $rawUploadId = (int) ($meta['raw_output_upload_id'] ?? 0); if ($rawUploadId <= 0) { $rawUploadId = (int) $shot->output_upload_id; } if ($rawUploadId <= 0) { throw new \RuntimeException('镜头原始视频不存在,无法补做配音'); } $voice = VideoDubService::replaceVoice( $rawUploadId, $userId, (string) $shot->dialogue, $language, (int) $shot->duration_seconds, (string) ($meta['timeline']['voice_timing'] ?? 'start'), is_array($meta['timeline'] ?? null) ? $meta['timeline'] : [] ); if ($voice['processed']) { $meta['raw_output_upload_id'] = $rawUploadId; } $meta['dubbed_language'] = $voice['language']; $meta['voice_source'] = $voice['source']; $meta['audio_policy_version'] = $voice['policy_version']; $shot->save([ 'output_upload_id' => (int) $voice['id'], 'meta' => $meta, 'error_message' => null, ]); return (bool) $voice['processed'] || $previousOutputUploadId !== (int) $voice['id']; } private function progressErrorMessage(string $prefix, \Throwable $error): string { $message = trim($prefix . preg_replace('/\s+/u', ' ', $error->getMessage())); return mb_strlen($message) > 240 ? mb_substr($message, 0, 239) . '…' : $message; } private function deleteGeneratedUploads(array $uploadIds, int $userId): void { if (!$uploadIds) { return; } $uploads = UploadFile::whereIn('id', $uploadIds) ->where('user_id', $userId) ->select(); foreach ($uploads as $upload) { $path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path); if (is_file($path)) { @unlink($path); } $upload->delete(); } } private function refreshDraftPrompts(VideoProject $project): void { $characters = VideoCharacter::where('project_id', (int) $project->id)->order('id')->select()->toArray(); $episodes = VideoEpisode::where('project_id', (int) $project->id)->select(); foreach ($episodes as $episode) { $shots = VideoShot::where('episode_id', (int) $episode->id) ->whereIn('status', ['draft', 'error']) ->select(); foreach ($shots as $shot) { $meta = is_array($shot->meta) ? $shot->meta : []; $basePrompt = trim((string) ($meta['base_prompt'] ?? $shot->prompt)); $shot->save([ 'prompt' => ShortDramaPlannerService::recompileShotPrompt( (string) $project->idea, $basePrompt, $characters, (string) $project->style, (string) $project->aspect_ratio, (string) ($project->voice_language ?? 'zh-CN'), (string) ($project->character_origin ?? ShortDramaPlannerService::CHARACTER_ORIGIN_EAST_ASIAN), (string) ($project->screen_text_language ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_ZH_CN) ), 'workflow_type' => $characters ? 'ref2va' : 'fl2va', ]); } } } private function projectList(int $userId): array { $projects = VideoProject::where('user_id', $userId)->order('updated_at', 'desc')->select(); $result = []; foreach ($projects as $project) { $episode = VideoEpisode::where('project_id', (int) $project->id)->order('episode_no', 'desc')->find(); $result[] = [ 'id' => (int) $project->id, 'title' => (string) $project->title, 'idea' => (string) $project->idea, 'aspect_ratio' => (string) $project->aspect_ratio, 'duration' => (int) $project->episode_duration, 'duration_mode' => (string) ($project->duration_mode ?? 'fixed'), 'quality' => (string) $project->quality, 'voice_language' => (string) ($project->voice_language ?? 'native'), 'voice_language_label' => VideoDubService::label((string) ($project->voice_language ?? 'native')), 'show_subtitles' => (bool) ($project->show_subtitles ?? false), 'subtitle_label' => !empty($project->show_subtitles) ? '显示字幕' : '关闭字幕', 'character_origin' => ShortDramaPlannerService::normalizeCharacterOrigin((string) ($project->character_origin ?? '')), 'character_origin_label' => ShortDramaPlannerService::characterOriginLabel((string) ($project->character_origin ?? '')), 'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage((string) ($project->screen_text_language ?? '')), 'screen_text_language_label' => ShortDramaPlannerService::screenTextLanguageLabel((string) ($project->screen_text_language ?? '')), 'shot_duration_mode' => ShortDramaPlannerService::normalizeShotDurationMode((string) ($project->shot_duration_mode ?? '')), 'shot_duration_mode_label' => ShortDramaPlannerService::shotDurationModeLabel((string) ($project->shot_duration_mode ?? '')), 'status' => (string) $project->status, 'updated_at' => $project->updated_at, 'character_count' => VideoCharacter::where('project_id', (int) $project->id)->count(), 'episode' => $episode ? [ 'id' => (int) $episode->id, 'episode_no' => (int) $episode->episode_no, 'status' => (string) $episode->status, 'progress' => (int) $episode->progress, 'progress_message' => (string) $episode->progress_message, 'final_url' => $this->uploadUrl((int) $episode->final_upload_id), ] : null, ]; } return $result; } private function projectDetail(int $projectId, int $userId): ?array { $project = $this->findProject($projectId, $userId); if (!$project) { return null; } $characters = VideoCharacter::where('project_id', $projectId)->order('id')->select(); $episodes = VideoEpisode::where('project_id', $projectId)->order('episode_no')->select(); $episodeData = []; foreach ($episodes as $episode) { $shots = VideoShot::where('episode_id', (int) $episode->id)->order('shot_no')->select(); $episodeData[] = [ 'id' => (int) $episode->id, 'episode_no' => (int) $episode->episode_no, 'title' => (string) $episode->title, 'script' => (string) $episode->script, 'status' => (string) $episode->status, 'progress' => (int) $episode->progress, 'progress_message' => (string) $episode->progress_message, 'final_upload_id' => (int) $episode->final_upload_id, 'final_url' => $this->uploadUrl((int) $episode->final_upload_id), 'shots' => array_map(fn ($shot) => $this->shotData($shot), $shots->all()), ]; } return [ 'id' => (int) $project->id, 'title' => (string) $project->title, 'idea' => (string) $project->idea, 'style' => (string) $project->style, 'aspect_ratio' => (string) $project->aspect_ratio, 'duration' => (int) $project->episode_duration, 'duration_mode' => (string) ($project->duration_mode ?? 'fixed'), 'quality' => (string) $project->quality, 'voice_language' => (string) ($project->voice_language ?? 'native'), 'voice_language_label' => VideoDubService::label((string) ($project->voice_language ?? 'native')), 'show_subtitles' => (bool) ($project->show_subtitles ?? false), 'subtitle_label' => !empty($project->show_subtitles) ? '显示字幕' : '关闭字幕', 'character_origin' => ShortDramaPlannerService::normalizeCharacterOrigin((string) ($project->character_origin ?? '')), 'character_origin_label' => ShortDramaPlannerService::characterOriginLabel((string) ($project->character_origin ?? '')), 'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage((string) ($project->screen_text_language ?? '')), 'screen_text_language_label' => ShortDramaPlannerService::screenTextLanguageLabel((string) ($project->screen_text_language ?? '')), 'shot_duration_mode' => ShortDramaPlannerService::normalizeShotDurationMode((string) ($project->shot_duration_mode ?? '')), 'shot_duration_mode_label' => ShortDramaPlannerService::shotDurationModeLabel((string) ($project->shot_duration_mode ?? '')), 'status' => (string) $project->status, 'characters' => array_map(fn ($character) => $this->characterData($character), $characters->all()), 'episodes' => $episodeData, 'created_at' => $project->created_at, 'updated_at' => $project->updated_at, ]; } private function characterData(VideoCharacter|array $character): array { $data = is_array($character) ? $character : $character->toArray(); return [ 'id' => (int) $data['id'], 'name' => (string) $data['name'], 'description' => (string) ($data['description'] ?? ''), 'reference_upload_id' => (int) ($data['reference_upload_id'] ?? 0), 'reference_url' => $this->uploadUrl((int) ($data['reference_upload_id'] ?? 0)), 'is_locked' => !empty($data['is_locked']), 'asset_version' => (int) ($data['asset_version'] ?? 1), ]; } private function shotData(VideoShot|array $shot): array { $data = is_array($shot) ? $shot : $shot->toArray(); $meta = is_array($data['meta'] ?? null) ? $data['meta'] : []; $timeline = is_array($meta['timeline'] ?? null) ? $meta['timeline'] : []; $audioMode = VideoDubService::normalizeAudioMode((string) ($timeline['audio_mode'] ?? 'ambient_only')); return [ 'id' => (int) $data['id'], 'shot_no' => (int) $data['shot_no'], 'title' => (string) $data['title'], 'prompt' => (string) $data['prompt'], 'dialogue' => (string) ($data['dialogue'] ?? ''), 'dialogue_speaker' => (string) ($timeline['dialogue_speaker'] ?? ''), 'dialogue_delivery' => (string) ($timeline['dialogue_delivery'] ?? ''), 'audio_mode' => $audioMode, 'audio_mode_label' => match ($audioMode) { VideoDubService::AUDIO_CHARACTER => '角色原声 · H3 口型同步', VideoDubService::AUDIO_NARRATION => '画外旁白 · 后期精确普通话', VideoDubService::AUDIO_SCENE => '原生场景声 · 无人物说话', default => '干净环境音 · 无人物声音', }, 'scene' => (string) ($timeline['scene'] ?? ''), 'scene_id' => (string) ($timeline['scene_id'] ?? ''), 'source_excerpt' => (string) ($timeline['source_excerpt'] ?? ''), 'action_beats' => is_array($timeline['action_beats'] ?? null) ? array_values($timeline['action_beats']) : [], 'character_state_before' => (string) ($timeline['character_state_before'] ?? ''), 'character_state_after' => (string) ($timeline['character_state_after'] ?? ''), 'continuity_from_previous' => (string) ($timeline['continuity_from_previous'] ?? ''), 'sound_effects' => is_array($timeline['sound_effects'] ?? null) ? array_values($timeline['sound_effects']) : [], 'screen_event' => (string) ($timeline['screen_event'] ?? ''), 'screen_text' => (string) ($timeline['screen_text'] ?? ''), 'screen_text_language' => ShortDramaPlannerService::normalizeScreenTextLanguage( (string) ($timeline['screen_text_language'] ?? ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE) ), 'analysis_source' => (string) ($meta['analysis_source'] ?? 'local'), 'voice_source' => (string) ($meta['voice_source'] ?? $timeline['voice_source'] ?? ''), 'workflow_version' => (string) ($meta['workflow_version'] ?? ''), 'workflow_settings' => is_array($meta['workflow_settings'] ?? null) ? $meta['workflow_settings'] : [], 'duration_seconds' => (int) $data['duration_seconds'], 'workflow_type' => (string) $data['workflow_type'], 'status' => (string) $data['status'], 'progress_text' => $this->shotProgressText((string) $data['status']), 'output_upload_id' => (int) ($data['output_upload_id'] ?? 0), 'output_url' => $this->uploadUrl((int) ($data['output_upload_id'] ?? 0)), 'error_message' => (string) ($data['error_message'] ?? ''), ]; } private function uploadUrl(int $uploadId): ?string { if ($uploadId <= 0) { return null; } $storedName = UploadFile::where('id', $uploadId)->value('stored_name'); return $storedName ? '/api/uploads/' . rawurlencode((string) $storedName) : null; } private function shotProgressText(string $status): string { return match ($status) { 'queued' => '排队中', 'running', 'waiting' => '正在生成', 'completed' => '已完成', 'error' => '需要处理', default => '等待生成', }; } private function findProject(int $projectId, int $userId): ?VideoProject { return VideoProject::where('id', $projectId)->where('user_id', $userId)->find(); } private function requireAccess(): mixed { if (!SettingsService::isFeatureEnabled('short_drama')) { return $this->error('短剧工坊暂未开放', 404); } $user = $this->authUser(); if (GuestAccessService::isGuest($user)) { return $this->error('请登录后使用短剧工坊', 403); } if (empty($user['id'])) { return $this->error('登录状态已失效', 401); } return $user; } private function templates(): array { return [ ['id' => 'urban_reverse', 'name' => '都市反转', 'icon' => '⚡', 'prompt' => '一段都市情感短剧,主角在最绝望时发现事情完全不是表面看到的那样,结尾强反转。'], ['id' => 'ancient_romance', 'name' => '古风情缘', 'icon' => '🌙', 'prompt' => '一段古风短剧,身份对立的两个人在危机中被迫联手,克制而有张力。'], ['id' => 'suspense', 'name' => '悬疑追凶', 'icon' => '🔍', 'prompt' => '一段节奏紧凑的悬疑短剧,主角发现一个不可能存在的线索,并在结尾揭开更大阴谋。'], ['id' => 'family', 'name' => '家庭情感', 'icon' => '🏠', 'prompt' => '一段真实克制的家庭情感短剧,通过一个生活细节揭示家人之间长期隐藏的爱与误解。'], ['id' => 'comedy', 'name' => '轻松搞笑', 'icon' => '😄', 'prompt' => '一段节奏明快的生活喜剧,主角为了掩盖一个小失误不断制造更大的误会。'], ]; } }