'English(英文)', self::SCREEN_TEXT_LANGUAGE_NONE => '不显示画面文字', default => '简体中文', }; } public static function normalizeShotDurationMode(?string $mode): string { return in_array($mode, [ self::SHOT_DURATION_AUTO, self::SHOT_DURATION_FIVE, self::SHOT_DURATION_TEN, ], true) ? (string) $mode : self::SHOT_DURATION_AUTO; } public static function shotDurationModeLabel(?string $mode): string { return match (self::normalizeShotDurationMode($mode)) { self::SHOT_DURATION_FIVE => '每镜 5 秒', self::SHOT_DURATION_TEN => '每镜 10 秒', default => 'AI 智能 5/10 秒', }; } /** * @return int[] 每项只会是 5 或 10,且总和严格等于项目时长。 */ public static function shotDurationsForMode( int $duration, string $mode, ?int $requestedCount = null ): array { $duration = self::normalizeDuration($duration); $mode = self::normalizeShotDurationMode($mode); if ($mode === self::SHOT_DURATION_FIVE) { return array_fill(0, max(1, intdiv($duration, 5)), 5); } if ($mode === self::SHOT_DURATION_TEN) { $durations = array_fill(0, intdiv($duration, 10), 10); if ($duration % 10 !== 0) { $durations[] = 5; } return $durations ?: [5]; } $minimumCount = (int) ceil($duration / 10); $maximumCount = max(1, intdiv($duration, 5)); $shotCount = $requestedCount ?? (int) round($duration / 7.5); $shotCount = max($minimumCount, min($maximumCount, $shotCount)); $tenSecondCount = max(0, intdiv($duration - ($shotCount * 5), 5)); $durations = array_fill(0, $shotCount, 5); if ($tenSecondCount <= 0) { return $durations; } for ($i = 0; $i < $tenSecondCount; $i++) { $index = min( $shotCount - 1, (int) floor((($i + 0.5) * $shotCount) / $tenSecondCount) ); $durations[$index] = 10; } return $durations; } /** * 根据素材密度估算叙事所需的基础时长。后续分镜模型会在不改变总时长的 * 前提下,把它组合为 5 秒转折镜头与 10 秒连续表演镜头。 */ public static function recommendDuration(string $idea): int { $text = trim($idea); if ($text === '') { return 5; } $compact = preg_replace('/\s+/u', '', $text) ?? $text; $characterCount = mb_strlen($compact); $paragraphs = array_values(array_filter( preg_split('/\R+/u', $text) ?: [], fn (string $line): bool => trim($line) !== '' )); preg_match_all( '/(?:[\p{Han}A-Za-z0-9·()()]{1,16}[::]|[“\"][^”\"\n]{2,40}[”\"])/u', $text, $dialogueMatches ); preg_match_all( '/(?:就在这时|紧接着|下一秒|与此同时|随后|突然|片刻后|画面切换|场景切换|转场|黑暗。|清晨|深夜)/u', $text, $transitionMatches ); $estimatedShots = max( 1, (int) ceil($characterCount / 110), (int) ceil(count($paragraphs) / 2), count($dialogueMatches[0] ?? []), (int) ceil(count($transitionMatches[0] ?? []) / 2) + 1 ); return $estimatedShots * 5; } /** * @return array{title:string,script:string,shots:array>} */ public static function plan( string $idea, int $duration, string $aspectRatio, string $style, string $voiceLanguage = 'zh-CN', string $characterOrigin = self::CHARACTER_ORIGIN_EAST_ASIAN, string $screenTextLanguage = self::SCREEN_TEXT_LANGUAGE_ZH_CN, string $shotDurationMode = self::SHOT_DURATION_FIVE ): array { $voiceLanguage = VideoDubService::normalizeLanguage($voiceLanguage); $characterOrigin = self::normalizeCharacterOrigin($characterOrigin); $screenTextLanguage = self::normalizeScreenTextLanguage($screenTextLanguage); $shotDurationMode = self::normalizeShotDurationMode($shotDurationMode); $rawIdea = trim($idea); $dialoguePool = self::extractScriptDialogues($rawIdea); $sceneSoundPool = self::extractSceneSounds($rawIdea); $idea = trim(preg_replace('/\s+/u', ' ', $rawIdea) ?? $rawIdea); $visualIdea = self::visualIdea($idea); $duration = self::normalizeDuration($duration); $shotDurations = self::shotDurationsForMode($duration, $shotDurationMode); $shotCount = count($shotDurations); $title = self::titleFromIdea($idea); $shots = []; $timeline = []; $sceneSoundsByShot = self::distributeSceneSounds($sceneSoundPool, $shotCount); $timelineCursor = 0; for ($i = 0; $i < $shotCount; $i++) { $beatIndex = $shotCount === 1 ? 0 : (int) round(($i / ($shotCount - 1)) * (count(self::BEATS) - 1)); [$beatTitle, $beatDescription, $camera] = self::BEATS[$beatIndex]; $shotNo = $i + 1; $shotDuration = (int) $shotDurations[$i]; $profile = self::SHOT_PROFILES[$beatIndex]; $dialogueData = self::dialogueForShot( $dialoguePool, $beatIndex, $i, $shotCount ); $dialogue = $dialogueData['text']; $timelineEntry = self::buildTimelineEntry( $beatTitle, $beatDescription, $camera, $profile, $shotNo, $shotCount, $timelineCursor, $shotDuration, $visualIdea, $dialogue, $dialogueData['speaker'], $dialogueData['delivery'], $voiceLanguage ); if ($dialogue === '' && !empty($sceneSoundsByShot[$i])) { $timelineEntry['audio_mode'] = VideoDubService::AUDIO_SCENE; $timelineEntry['voice_source'] = 'h3_scene_sound'; $timelineEntry['sound_effects'] = $sceneSoundsByShot[$i]; $timelineEntry['rhythm_sound'] .= ';重点同步场景声:' . implode('、', $sceneSoundsByShot[$i]); } $timelineEntry['screen_text_language'] = $screenTextLanguage; $basePrompt = self::compilePrompt( $visualIdea, $style, $aspectRatio, $timelineEntry, $characterOrigin, $screenTextLanguage ); $timeline[] = $timelineEntry; $shots[] = [ 'shot_no' => $shotNo, 'title' => "镜头 {$shotNo} · {$beatTitle}", 'prompt' => $basePrompt, 'dialogue' => $dialogueData['speaker'] !== '' ? $dialogueData['speaker'] . ':' . $dialogue : $dialogue, 'duration_seconds' => $shotDuration, 'workflow_type' => 'fl2va', 'status' => 'draft', 'seed' => random_int(1, PHP_INT_MAX), 'meta' => [ 'beat' => $beatTitle, 'camera' => $camera, 'aspect_ratio' => $aspectRatio, 'base_prompt' => $basePrompt, 'timeline' => $timelineEntry, 'negative_prompt' => self::NEGATIVE_CONSTRAINTS, 'screen_text_language' => $screenTextLanguage, 'shot_duration_mode' => $shotDurationMode, 'director_prompt_version' => substr(hash('sha256', self::directorSystemPrompt()), 0, 12), ], ]; $timelineCursor += $shotDuration; } return [ 'title' => $title, 'script' => self::compileTimelineDocument($idea, $duration, $timeline, $shots), 'shots' => $shots, 'analysis' => ['source' => 'local', 'model' => 'deterministic-fallback'], ]; } /** * 长文章先交给语言模型完成角色、对白、屏幕文字与场景声音分类;任何模型 * 故障都会无感回退到 plan(),保证创作流程仍可用。 */ public static function planWithAi( string $idea, int $duration, string $aspectRatio, string $style, string $voiceLanguage = 'zh-CN', string $characterOrigin = self::CHARACTER_ORIGIN_EAST_ASIAN, string $screenTextLanguage = self::SCREEN_TEXT_LANGUAGE_ZH_CN, string $shotDurationMode = self::SHOT_DURATION_FIVE ): array { $duration = self::normalizeDuration($duration); $characterOrigin = self::normalizeCharacterOrigin($characterOrigin); $screenTextLanguage = self::normalizeScreenTextLanguage($screenTextLanguage); $shotDurationMode = self::normalizeShotDurationMode($shotDurationMode); $analysis = ShortDramaStoryAnalysisService::analyze( $idea, $duration, $shotDurationMode, $characterOrigin, $screenTextLanguage ); return $analysis ? self::planFromAnalysis($idea, $duration, $aspectRatio, $style, $voiceLanguage, $characterOrigin, $screenTextLanguage, $shotDurationMode, $analysis) : self::plan($idea, $duration, $aspectRatio, $style, $voiceLanguage, $characterOrigin, $screenTextLanguage, $shotDurationMode); } private static function planFromAnalysis( string $idea, int $duration, string $aspectRatio, string $style, string $voiceLanguage, string $characterOrigin, string $screenTextLanguage, string $shotDurationMode, array $analysis ): array { $voiceLanguage = VideoDubService::normalizeLanguage($voiceLanguage); $analysisShots = array_values($analysis['shots'] ?? []); $shotCount = max(1, count($analysisShots)); $characterMap = []; foreach ($analysis['characters'] ?? [] as $character) { if (!is_array($character) || trim((string) ($character['name'] ?? '')) === '') { continue; } $characterMap[(string) $character['name']] = $character; } $sceneMap = []; foreach ($analysis['scenes'] ?? [] as $sceneDefinition) { if (!is_array($sceneDefinition)) { continue; } $sceneId = trim((string) ($sceneDefinition['scene_id'] ?? '')); if ($sceneId !== '') { $sceneMap[$sceneId] = $sceneDefinition; } } $storyIdea = trim((string) ($analysis['logline'] ?? '')) ?: self::visualIdea($idea); $shots = []; $timeline = []; $timelineCursor = 0; for ($i = 0; $i < $shotCount; $i++) { $aiShot = is_array($analysisShots[$i] ?? null) ? $analysisShots[$i] : []; $shotDuration = in_array((int) ($aiShot['duration_seconds'] ?? 5), [5, 10], true) ? (int) $aiShot['duration_seconds'] : 5; $beatIndex = $shotCount === 1 ? 0 : (int) round(($i / ($shotCount - 1)) * (count(self::BEATS) - 1)); [$beatTitle, $beatDescription, $cameraHint] = self::BEATS[$beatIndex]; $profile = self::SHOT_PROFILES[$beatIndex]; $shotNo = $i + 1; $dialogue = trim((string) ($aiShot['dialogue'] ?? '')); $speaker = trim((string) ($aiShot['dialogue_speaker'] ?? '')); $dialogueType = (string) ($aiShot['dialogue_type'] ?? 'none'); $delivery = trim((string) ($aiShot['dialogue_delivery'] ?? '自然')) ?: '自然'; $soundEffects = is_array($aiShot['sound_effects'] ?? null) ? array_values(array_filter(array_map('trim', $aiShot['sound_effects']))) : []; $entry = self::buildTimelineEntry( trim((string) ($aiShot['title'] ?? '')) ?: $beatTitle, trim((string) ($aiShot['action'] ?? '')) ?: $beatDescription, trim((string) ($aiShot['camera'] ?? '')) ?: $cameraHint, $profile, $shotNo, $shotCount, $timelineCursor, $shotDuration, $storyIdea, $dialogue, $speaker, $delivery, $voiceLanguage ); $shotCharacterNames = is_array($aiShot['characters'] ?? null) ? array_values(array_filter(array_map('trim', $aiShot['characters']))) : []; $anchors = []; foreach ($shotCharacterNames as $name) { $character = $characterMap[$name] ?? null; if (!$character) { $anchors[] = $name . '的外观、服装和体型沿用首次出场设定'; continue; } $anchors[] = implode(',', array_values(array_filter([ (string) ($character['name'] ?? $name), (string) ($character['gender'] ?? ''), (string) ($character['age'] ?? ''), (string) ($character['appearance'] ?? ''), ]))); } $sceneId = trim((string) ($aiShot['scene_id'] ?? '')); $sceneDefinition = $sceneMap[$sceneId] ?? []; $scene = implode(';', array_values(array_filter([ $sceneId !== '' ? "场景编号 {$sceneId}" : '', trim((string) ($sceneDefinition['location'] ?? $aiShot['scene'] ?? '')), trim((string) ($sceneDefinition['layout'] ?? '')), trim((string) ($sceneDefinition['lighting'] ?? '')), trim((string) ($sceneDefinition['props'] ?? '')), trim((string) ($sceneDefinition['atmosphere'] ?? '')), ]))); $visual = trim((string) ($aiShot['visual'] ?? '')); $stateBefore = trim((string) ($aiShot['character_state_before'] ?? '')); $stateAfter = trim((string) ($aiShot['character_state_after'] ?? '')); $propState = trim((string) ($aiShot['prop_state'] ?? '')); $continuityReason = trim((string) ($aiShot['continuity_from_previous'] ?? '')); $actionBeats = is_array($aiShot['action_beats'] ?? null) ? array_values(array_filter(array_map('trim', $aiShot['action_beats']))) : []; $detailedAction = $actionBeats ? implode(';', $actionBeats) : trim((string) ($aiShot['action'] ?? '')); $screenEvent = trim((string) ($aiShot['screen_event'] ?? '')); $screenText = $screenTextLanguage === self::SCREEN_TEXT_LANGUAGE_NONE ? '' : trim((string) ($aiShot['screen_text'] ?? '')); $audioMode = match (true) { $dialogue !== '' && $dialogueType === 'narration' => VideoDubService::AUDIO_NARRATION, $dialogue !== '' && $speaker !== '' => VideoDubService::AUDIO_CHARACTER, !empty($soundEffects) => VideoDubService::AUDIO_SCENE, default => VideoDubService::AUDIO_AMBIENT, }; $speakerGender = self::genderForSpeaker($speaker, $characterMap); $entry = array_replace($entry, array_filter([ 'scene' => $scene, 'scene_id' => $sceneId, 'source_excerpt' => trim((string) ($aiShot['source_excerpt'] ?? '')), 'characters' => $shotCharacterNames, 'identity_anchor' => $anchors ? implode(';', $anchors) . ';所有锚点跨镜头完全一致' : $entry['identity_anchor'], 'start_frame' => implode(';', array_values(array_filter([$scene, $visual, $stateBefore]))), 'action_expression' => $detailedAction ?: $entry['action_expression'], 'spatial_layers' => implode(';', array_values(array_filter([ $entry['spatial_layers'], trim((string) ($sceneDefinition['layout'] ?? '')), $propState, ]))), 'cinematography' => trim((string) ($aiShot['camera'] ?? '')) ?: $entry['cinematography'], 'end_frame' => implode(';', array_values(array_filter([ trim((string) ($aiShot['end_frame'] ?? '')), $stateAfter, $propState, ]))) ?: $entry['end_frame'], 'transition' => implode(';', array_values(array_filter([ $continuityReason, trim((string) ($aiShot['transition'] ?? '')), ]))) ?: $entry['transition'], 'narrative_continuity' => $entry['narrative_continuity'] . ';本镜头必须只表现原文锚点:' . trim((string) ($aiShot['source_excerpt'] ?? '')), 'character_state_before' => $stateBefore, 'character_state_after' => $stateAfter, 'prop_state' => $propState, 'continuity_from_previous' => $continuityReason, 'action_beats' => $actionBeats, 'screen_event' => $screenEvent, 'screen_text' => $screenText, 'screen_text_language' => $screenTextLanguage, 'sound_effects' => $soundEffects, 'audio_mode' => $audioMode, 'voice_source' => match ($audioMode) { VideoDubService::AUDIO_CHARACTER => 'h3_native', VideoDubService::AUDIO_NARRATION => 'cosyvoice', VideoDubService::AUDIO_SCENE => 'h3_scene_sound', default => 'clean_ambient', }, 'speaker_gender' => $speakerGender, 'mouth_mode' => $audioMode === VideoDubService::AUDIO_CHARACTER ? 'native_lip_sync' : 'closed_no_speech', 'rhythm_sound' => $soundEffects ? $entry['rhythm_sound'] . ';场景/动作声音:' . implode('、', $soundEffects) : $entry['rhythm_sound'], ], fn ($value) => $value !== '' && $value !== null)); $basePrompt = self::compilePrompt( $storyIdea, $style, $aspectRatio, $entry, $characterOrigin, $screenTextLanguage ); $timeline[] = $entry; $shots[] = [ 'shot_no' => $shotNo, 'title' => '镜头 ' . $shotNo . ' · ' . (trim((string) ($aiShot['title'] ?? '')) ?: $beatTitle), 'prompt' => $basePrompt, 'dialogue' => $speaker !== '' && $dialogue !== '' ? $speaker . ':' . $dialogue : $dialogue, 'duration_seconds' => $shotDuration, 'workflow_type' => 'fl2va', 'status' => 'draft', 'seed' => random_int(1, PHP_INT_MAX), 'meta' => [ 'beat' => $aiShot['title'] ?? $beatTitle, 'camera' => $entry['cinematography'], 'aspect_ratio' => $aspectRatio, 'base_prompt' => $basePrompt, 'timeline' => $entry, 'negative_prompt' => self::NEGATIVE_CONSTRAINTS, 'screen_text_language' => $screenTextLanguage, 'shot_duration_mode' => $shotDurationMode, 'scene_id' => $sceneId, 'analysis_source' => 'ai', 'analysis_model' => (string) ($analysis['model'] ?? ''), 'director_prompt_version' => substr(hash('sha256', self::directorSystemPrompt()), 0, 12), ], ]; $timelineCursor += $shotDuration; } $title = trim((string) ($analysis['title'] ?? '')) ?: self::titleFromIdea($idea); return [ 'title' => $title, 'script' => self::compileTimelineDocument($idea, $duration, $timeline, $shots, $analysis), 'shots' => $shots, 'analysis' => $analysis, ]; } private static function normalizeDuration(int $duration): int { return max(5, (int) ceil(max(1, $duration) / 5) * 5); } public static function recompileShotPrompt( string $idea, string $shotPrompt, array $characters, string $style, string $aspectRatio, string $voiceLanguage = 'zh-CN', string $characterOrigin = self::CHARACTER_ORIGIN_EAST_ASIAN, string $screenTextLanguage = self::SCREEN_TEXT_LANGUAGE_ZH_CN ): string { $idea = self::visualIdea(trim(preg_replace('/\s+/u', ' ', $idea) ?? $idea)); $characterOrigin = self::normalizeCharacterOrigin($characterOrigin); $screenTextLanguage = self::normalizeScreenTextLanguage($screenTextLanguage); $characterText = []; foreach ($characters as $index => $character) { $description = trim((string) ($character['description'] ?? '')); $name = trim((string) ($character['name'] ?? ('角色' . ($index + 1)))); $tag = ''; $characterText[] = $description === '' ? "{$tag} 是 {$name},保持其脸型、发型、体型和服装完全一致" : "{$tag} 是 {$name}({$description}),保持身份和服装完全一致"; } $parts = [ '目标视频模型:' . self::targetModel(), "短剧剧情:{$idea}", "当前镜头:{$shotPrompt}", $characterText ? '固定角色:' . implode(';', $characterText) : '', "整体风格:{$style},{$aspectRatio} 构图,24fps", self::characterOriginPrompt($characterOrigin), self::screenTextPipelinePrompt($screenTextLanguage), '角色锚点在整集内保持一致,人物数量准确,动作、视线、空间和光影连续', '禁止项:' . self::NEGATIVE_CONSTRAINTS, '这是单个连续电影镜头,不是分屏、拼贴、故事板或多机位合集', '只采用电影镜头逻辑和视觉方法,不模仿或复现任何具体人物、品牌、台词和已有故事', ]; return implode('。', array_values(array_filter($parts, fn ($part) => trim($part) !== ''))) . '。'; } private static function titleFromIdea(string $idea): string { $title = preg_replace('/[,。!?,.!?;;::\n\r].*$/u', '', $idea) ?? $idea; $title = trim($title); if ($title === '') { return '未命名短剧'; } if (mb_strlen($title) > 18) { $title = mb_substr($title, 0, 18) . '…'; } return $title; } private static function compilePrompt( string $idea, string $style, string $aspectRatio, array $timeline, string $characterOrigin, string $screenTextLanguage ): string { $durationSeconds = in_array((int) ($timeline['duration_seconds'] ?? 5), [5, 10], true) ? (int) $timeline['duration_seconds'] : 5; return implode('。', [ '目标视频模型:' . self::targetModel(), "故事设定:{$idea}", self::characterOriginPrompt($characterOrigin), "时间轴:{$timeline['timecode']}", "主体身份与外观锚点:{$timeline['identity_anchor']}", "起始画面:{$timeline['start_frame']}", "人物动作与表情:{$timeline['action_expression']}", "场景空间与前中后景:{$timeline['spatial_layers']}", "景别、机位、焦段、构图和景深:{$timeline['cinematography']}", "摄影机运动:{$timeline['camera_motion']},{$timeline['motion_speed']}", "光线、综合色温和材质:{$timeline['lighting']};{$timeline['material']}", "节奏、特效和环境声音:{$timeline['rhythm_sound']}", !empty($timeline['sound_effects']) ? '必须与画面同步的场景/动作声音:' . implode('、', $timeline['sound_effects']) : '场景声音:仅保留连续环境底音,不添加人物声音', self::screenTextPrompt($timeline, $screenTextLanguage), self::voicePrompt( (string) $timeline['voice_language'], (string) $timeline['dialogue_speaker'], (string) $timeline['dialogue'], (string) $timeline['audio_mode'], (string) $timeline['dialogue_delivery'], (string) $timeline['speaker_gender'], is_array($timeline['sound_effects'] ?? null) ? $timeline['sound_effects'] : [] ), "长内容连续性:{$timeline['narrative_continuity']}", !empty($timeline['character_state_before']) ? "人物前置状态:{$timeline['character_state_before']}" : '', !empty($timeline['prop_state']) ? "关键道具状态:{$timeline['prop_state']}" : '', "结束画面:{$timeline['end_frame']}", "镜头衔接:{$timeline['transition']}", "整体风格:{$style},{$aspectRatio} 构图,24fps,真实连续的{$durationSeconds}秒单镜头;镜头内部禁止剪切、换场或时间跳跃", '禁止项:' . self::NEGATIVE_CONSTRAINTS, '只学习镜头逻辑和视觉方法,不照抄人物、品牌、台词和具体故事', ]) . '。'; } private static function characterOriginPrompt(string $origin): string { if (self::normalizeCharacterOrigin($origin) === self::CHARACTER_ORIGIN_GLOBAL) { return '人物选角:允许来自任意国家和族裔的自然真实面孔,根据原文背景、姓名与场景合理分配;同一角色一旦建立,国别、族裔、脸型、肤色和五官必须全剧固定,不能跨镜头随机改变'; } return '人物选角:所有没有上传参考图的新生成人物统一采用自然真实的东亚东方人面孔,以中国文化语境为默认;保留年龄、性别和个体差异;同一角色的脸型、肤色和五官全剧固定;已上传的角色参考图优先,不强行改变其身份'; } private static function screenTextPipelinePrompt(string $language): string { $language = self::normalizeScreenTextLanguage($language); if ($language === self::SCREEN_TEXT_LANGUAGE_NONE) { return '画面文字配置:不显示任何场景文字、屏幕字、标题、字母、数字或界面字符;用灯光、图形和人物反应表达信息'; } $label = self::screenTextLanguageLabel($language); return "画面文字配置:只允许最终成片出现{$label};MiniMax H3 不直接绘制字符,只生成干净、稳定、可跟踪的空白承载区域,精确文字由后期合成,禁止乱码、伪文字、随机字母、错别字、文字漂移和闪烁"; } private static function screenTextPrompt(array $timeline, string $language): string { $language = self::normalizeScreenTextLanguage($language); $screenEvent = trim((string) ($timeline['screen_event'] ?? '')); $screenText = trim((string) ($timeline['screen_text'] ?? '')); if ($language === self::SCREEN_TEXT_LANGUAGE_NONE || ($screenEvent === '' && $screenText === '')) { return self::screenTextPipelinePrompt($language); } $label = self::screenTextLanguageLabel($language); $exactText = $screenText !== '' ? ";后期必须准确显示:『{$screenText}』" : ''; return "屏幕/场景文字事件:{$screenEvent};目标语言为{$label}{$exactText};文字只作画面信息,绝不能被读成对白或旁白;H3 仅预留无字符的稳定承载区域,禁止模型直接生成可读字符,最终文字由后期精确烧录"; } private static function buildTimelineEntry( string $beatTitle, string $beatDescription, string $cameraHint, array $profile, int $shotNo, int $shotCount, int $startSecond, int $durationSeconds, string $idea, string $dialogue, string $dialogueSpeaker, string $dialogueDelivery, string $voiceLanguage ): array { $durationSeconds = in_array($durationSeconds, [5, 10], true) ? $durationSeconds : 5; $endSecond = $startSecond + $durationSeconds; $isFirst = $shotNo === 1; $isLast = $shotNo === $shotCount; $chapterNo = intdiv($shotNo - 1, 6) + 1; $chapterCount = (int) ceil($shotCount / 6); $audioMode = self::audioMode($dialogueSpeaker, $dialogue); $speakerGender = self::speakerGender($dialogueSpeaker); [$shotSize, $cinematography, $cameraMotion, $motionSpeed, $lighting, $spatial, $material, $rhythm] = $profile; return [ 'shot_no' => $shotNo, 'duration_seconds' => $durationSeconds, 'timecode' => self::timecode($startSecond) . '-' . self::timecode($endSecond), 'beat' => $beatTitle, 'chapter_no' => $chapterNo, 'chapter_count' => $chapterCount, 'dialogue' => $dialogue, 'dialogue_speaker' => $dialogueSpeaker, 'dialogue_delivery' => $dialogueDelivery, 'audio_mode' => $audioMode, 'voice_source' => match ($audioMode) { 'character_dialogue' => 'h3_native', 'narration' => 'cosyvoice', default => 'clean_ambient', }, 'speaker_gender' => $speakerGender, 'mouth_mode' => $audioMode === 'character_dialogue' ? 'native_lip_sync' : 'closed_no_speech', 'voice_language' => $voiceLanguage, 'voice_timing' => $isLast ? 'end' : 'start', 'scene' => '', 'scene_id' => '', 'source_excerpt' => '', 'characters' => [], 'character_state_before' => '', 'character_state_after' => '', 'prop_state' => '', 'continuity_from_previous' => '', 'action_beats' => [], 'sound_effects' => [], 'screen_event' => '', 'screen_text' => '', 'screen_text_language' => self::SCREEN_TEXT_LANGUAGE_ZH_CN, 'narrative_continuity' => $shotNo === 1 ? "第 1 章共 {$chapterCount} 章,只建立故事起因、人物目标和空间关系,不提前跳到结局" : "当前为第 {$chapterNo}/{$chapterCount} 章,严格承接镜头 " . ($shotNo - 1) . ' 已发生的事件、人物已知信息和情绪状态;不得重讲开场、重置关系、凭空增加地点或跳过因果过程', 'identity_anchor' => '核心人物的脸型、五官比例、发型、年龄感、体型、服装版型、主色和配饰沿用项目角色资产;无参考图时也必须在后续镜头保持首次建立的外观', 'start_frame' => $isFirst ? "先以稳定画面建立“{$idea}”的空间和人物位置,主体动作尚未完成" : '承接上一镜头结束时的人物位置、视线方向、动作相位、服装状态、光线方向和背景物位置', 'action_expression' => $durationSeconds === 10 ? "0-2 秒建立上一状态,2-7 秒完成{$beatDescription},7-10 秒呈现结果与反应;动作轨迹单一、可读、符合重力和关节活动范围,表情连续变化" : "0-1 秒承接上一状态,1-4 秒完成{$beatDescription},4-5 秒稳定在可衔接结果;动作轨迹单一、可读、符合重力和关节活动范围", 'spatial_layers' => $spatial, 'shot_size' => $shotSize, 'cinematography' => "{$shotSize};{$cinematography};摄影提示 {$cameraHint}", 'camera_motion' => $cameraMotion, 'motion_speed' => $motionSpeed, 'lighting' => $lighting, 'material' => $material, 'rhythm_sound' => $rhythm . ';特效仅在剧情需要时出现,必须服从真实空间、遮挡和光照关系', 'end_frame' => $isLast ? '人物动作在悬念或情绪落点处稳定停住,保留清楚的最终姿态和视线,最后画面可自然保持至少半秒' : '人物完成本镜头核心动作的当前阶段,留下明确的运动方向、视线或关键物位置供下一镜头连续承接', 'transition' => $isLast ? '声音尾音和稳定构图作为本集结束点,不额外复制人物或插入无关画面' : '以下一镜头的同方向动作、视线、声音或构图形状进行匹配衔接,禁止跳轴、瞬移和动作重置', ]; } private static function voicePrompt( string $language, string $speaker, string $dialogue, string $audioMode, string $delivery, string $speakerGender, array $soundEffects = [] ): string { $language = VideoDubService::normalizeLanguage($language); if ($language === VideoDubService::LANG_NONE) { return 'H3 联合音画规则:只生成连续环境声和动作声,没有对白、旁白、歌声或其他人类发声;所有人物保持自然闭口;绝对不能出现字幕、标题、文字、字母、数字、标志或界面'; } if ($audioMode === 'character_dialogue' && $dialogue !== '') { $genderRule = $speakerGender === 'auto' ? '声音的性别、年龄和身份必须与画面中实际说话者完全一致' : "使用{$speakerGender}声音,且年龄和身份与画面中说话者一致"; return "MiniMax H3 原生联合音画对白:只有画面中的{$speaker}本人开口,以{$delivery}方式准确说出标准普通话“{$dialogue}”;{$genderRule};对白开始前闭口,说话时自然口型逐字同步,说完立即闭口;禁止旁白、画外音、第二个人声、女性替男角发声、男性替女角发声、含糊语言、伪语言、重复台词和额外台词;生成与场景一致的连续环境声;绝对不能出现字幕、标题、文字或水印"; } if ($audioMode === 'narration' && $dialogue !== '') { return "后期普通话旁白规则:{$speaker}将在后期以{$delivery}方式说出“{$dialogue}”;本次 H3 不生成人声,画面内所有人物全程闭口、不做说话口型;只生成视觉内容,原始 H3 音轨将在后期丢弃;绝对不能出现字幕、标题、文字或水印"; } if ($audioMode === VideoDubService::AUDIO_SCENE) { $soundText = $soundEffects ? implode('、', $soundEffects) : '与画面同步的环境声和动作声'; return "H3 原生场景声音规则:准确生成{$soundText};这些都是环境或物理动作声音,不是台词;没有对白、旁白、画外音、歌声、喊叫、含糊人声或伪语言;所有画面人物自然闭口,不做说话口型;绝对不能出现字幕、标题、文字或水印"; } return 'H3 原生环境声音规则:保留与当前空间、动作和镜头距离同步的真实环境底音与物理动作声;没有对白、旁白、歌声或其他人类发声;所有画面人物保持自然闭口,不做大喊或连续说话口型;绝对不能出现字幕、标题、文字或水印'; } /** * H3 只接收视觉语义,明确移除用户原文里的台词文字,避免模型把台词烧成字幕。 */ private static function visualIdea(string $idea): string { $idea = preg_replace( '/^(?:请|帮我)?(?:生成|制作|做)?(?:一段|一个)?(?:节奏[\p{Han}]{0,10})?(?:的)?(?:悬疑|古风|都市|搞笑|情感)?(?:短剧|视频)[,,::\s]*/u', '', $idea ) ?? $idea; $idea = preg_replace( '/(?:大声|突然|低声|轻声|急切地)?(?:喊道?|说道?|说|叫道?|问道?|回答)[::]\s*[“\"]?[^,。!?!?;;\n”\"]{1,30}[”\"]?/u', '', $idea ) ?? $idea; $idea = preg_replace('/[“\"][^”\"]{1,80}[”\"]/u', '做出相应表情动作', $idea) ?? $idea; // PHP trim/rtrim charlists operate on bytes. Putting multibyte Chinese // punctuation in the charlist can remove only the final byte of a Han // character (for example "里"), producing invalid UTF-8 that MySQL // correctly refuses to store. Keep all Unicode trimming regex-based. $idea = preg_replace('/^[\s,,。;;]+|[\s,,。;;]+$/u', '', $idea) ?? trim($idea); $idea = preg_replace('/(?:的同时|同时|并且|然后)$/u', '', $idea) ?? $idea; $idea = preg_replace('/^[\s,,。;;]+|[\s,,。;;]+$/u', '', $idea) ?? trim($idea); if ($idea === '') { return '人物在真实场景中完成清晰、连续的剧情动作'; } // AI 规划不可用时也要保证单镜头 prompt 不超过 MySQL TEXT 与 H3 // 上下文的安全范围;保留开头和结尾比直接截断更利于维持因果结局。 if (mb_strlen($idea) > 1600) { $idea = mb_substr($idea, 0, 1100) . '……(中段连续剧情由完整剧本约束)……' . mb_substr($idea, -450); } return $idea; } /** * 从用户剧本中读取“角色:台词”结构。舞台、动作和音效不会被当成对白。 * * @return array */ private static function extractScriptDialogues(string $script): array { $lines = preg_split('/\R/u', $script) ?: []; $speaker = ''; $capturedForSpeaker = false; $dialogues = []; $ignoredSpeakers = ['舞台', '背景', '音效', '场景', '灯光', '动作', '音乐']; foreach ($lines as $line) { $line = trim($line); if ($line === '') { if ($capturedForSpeaker) { $speaker = ''; $capturedForSpeaker = false; } continue; } if (preg_match('/^([\p{Han}A-Za-z0-9·()()]{1,16})[::]$/u', $line, $match)) { $speaker = trim((string) $match[1]); $speakerBase = preg_replace('/[((].*$/u', '', $speaker) ?? $speaker; if (in_array($speakerBase, $ignoredSpeakers, true) || preg_match('/(?:跪拜|出场|退场|走到|跳出|齐喊)$/u', $speakerBase)) { $speaker = ''; } $capturedForSpeaker = false; continue; } if ($speaker === '' || preg_match('/^[((【\[].*[))】\]]$/u', $line)) { if (preg_match('/^[((【\[]/u', $line)) { $speaker = ''; $capturedForSpeaker = false; } continue; } $text = preg_replace('/^[\s“”\"]+|[\s“”\"]+$/u', '', $line) ?? $line; $text = preg_replace('/\s+/u', '', $text) ?? $text; if (mb_strlen($text) < 2 || !preg_match('/[\p{Han}]/u', $text)) { continue; } if (mb_strlen($text) > 18) { $text = mb_substr($text, 0, 18, 'UTF-8'); $text = preg_replace('/[,,;;]+$/u', '', $text) . '。'; } $dialogues[] = ['speaker' => $speaker, 'text' => $text, 'delivery' => '自然']; $capturedForSpeaker = true; } // 支持一句话描述里的“说:…… / 大声喊:……”形式,例如“结尾大声喊:有鬼啊”。 if (preg_match_all( '/((?:大声|突然|低声|轻声|急切地)?(?:喊道?|说道?|说|叫道?|问道?|回答))[::]\s*[“\"]?([^,。!?!?;;\n”\"]{1,30})[”\"]?/u', $script, $inlineMatches, PREG_SET_ORDER )) { foreach ($inlineMatches as $inlineMatch) { $text = trim((string) ($inlineMatch[2] ?? '')); if ($text === '' || !preg_match('/[\p{Han}]/u', $text)) { continue; } if (mb_strlen($text) > 18) { $text = mb_substr($text, 0, 18, 'UTF-8'); } $verb = (string) ($inlineMatch[1] ?? '说'); $text = preg_replace('/[,,;;。!?!?]+$/u', '', $text) ?? $text; $text .= str_contains($verb, '喊') ? '!' : '。'; if (!array_filter($dialogues, fn (array $item): bool => $item['text'] === $text)) { $dialogues[] = [ 'speaker' => '主角', 'text' => $text, 'delivery' => self::deliveryFromVerb($verb), ]; } } } return $dialogues; } /** @return array{speaker:string,text:string,delivery:string} */ private static function dialogueForShot( array $dialoguePool, int $beatIndex, int $shotIndex, int $shotCount ): array { if ($dialoguePool) { if (count($dialoguePool) === 1 && $shotCount > 1 && $shotIndex < $shotCount - 1) { return ['speaker' => '', 'text' => '', 'delivery' => '无']; } $poolIndex = $shotCount <= 1 ? 0 : (int) round(($shotIndex / ($shotCount - 1)) * (count($dialoguePool) - 1)); return $dialoguePool[min($poolIndex, count($dialoguePool) - 1)]; } return ['speaker' => '', 'text' => '', 'delivery' => '无']; } private static function audioMode(string $speaker, string $dialogue): string { if (trim($dialogue) === '') { return 'ambient_only'; } return preg_match('/(?:旁白|画外音|解说|内心独白)/u', $speaker) ? 'narration' : 'character_dialogue'; } private static function speakerGender(string $speaker): string { if (preg_match('/(?:女|母|妈|姐|妹|妻|奶奶|阿姨|小姐|夫人|公主|皇后)/u', $speaker)) { return '女性'; } if (preg_match('/(?:男|父|爸|哥|弟|夫|爷爷|叔叔|先生|王子|皇帝)/u', $speaker)) { return '男性'; } return 'auto'; } private static function deliveryFromVerb(string $verb): string { return match (true) { str_contains($verb, '喊'), str_contains($verb, '叫') => '惊恐而短促地大喊', str_contains($verb, '低声'), str_contains($verb, '轻声') => '压低音量轻声', str_contains($verb, '问') => '自然询问', default => '自然', }; } /** * AI 不可用时的声音兜底:只提取明确的拟声词或“某某声”,绝不把引号、 * 冒号后的台词当成环境声音。 * * @return string[] */ private static function extractSceneSounds(string $script): array { $sounds = []; $patterns = [ '/(?:滋[滋…\.\s]*|嘎吱[嘎吱…\.\s]*|咔嚓[咔嚓…\.\s]*|砰[砰…\.\s]*|嗡[嗡…\.\s]*)/u', '/[^,。!?\n]{0,18}(?:电流|发动机|风|雨|雷|脚步|撞击|敲击|呼吸|喘息|哭|回音|蜂鸣|刹车|轮胎)[^,。!?\n]{0,18}(?:声|响|轰鸣|杂音|呻吟)/u', ]; foreach ($patterns as $pattern) { if (!preg_match_all($pattern, $script, $matches)) { continue; } foreach ($matches[0] as $match) { $match = trim(preg_replace('/\s+/u', ' ', (string) $match) ?? (string) $match); if ($match !== '' && mb_strlen($match) <= 60 && !in_array($match, $sounds, true)) { $sounds[] = $match; } if (count($sounds) >= 18) { break 2; } } } return $sounds; } /** @return array */ private static function distributeSceneSounds(array $sounds, int $shotCount): array { if (!$sounds || $shotCount < 1) { return []; } $result = []; $soundCount = count($sounds); foreach ($sounds as $index => $sound) { $shotIndex = $soundCount === 1 ? min($shotCount - 1, intdiv($shotCount, 2)) : (int) round(($index / ($soundCount - 1)) * ($shotCount - 1)); $result[$shotIndex] ??= []; if (count($result[$shotIndex]) < 4) { $result[$shotIndex][] = $sound; } } return $result; } private static function genderForSpeaker(string $speaker, array $characterMap): string { $gender = trim((string) ($characterMap[$speaker]['gender'] ?? '')); if (in_array($gender, ['男性', '男'], true)) { return '男性'; } if (in_array($gender, ['女性', '女'], true)) { return '女性'; } return self::speakerGender($speaker); } private static function compileTimelineDocument( string $idea, int $duration, array $timeline, array $shots, array $analysis = [] ): string { $lines = [ '【内置导演系统提示词】', self::directorSystemPrompt(), '', '【剧本语义分析】', ($analysis['source'] ?? 'local') === 'ai' ? '由 AI 自动识别人物、对白、画外旁白、屏幕文字与场景声音' : 'AI 不可用,本次使用本地确定性分析', trim((string) ($analysis['logline'] ?? '')), '', '【项目设定】', $idea, '', "【完整镜头时间轴|总时长 {$duration} 秒】", ]; foreach ($timeline as $entry) { $lines[] = "镜头 {$entry['shot_no']} [{$entry['timecode']}|{$entry['duration_seconds']} 秒] {$entry['beat']}"; if (!empty($entry['source_excerpt'])) { $lines[] = "- 原文锚点:{$entry['source_excerpt']}"; } if (!empty($entry['scene_id']) || !empty($entry['scene'])) { $lines[] = '- 场景状态:' . implode('|', array_values(array_filter([ (string) ($entry['scene_id'] ?? ''), (string) ($entry['scene'] ?? ''), ]))); } if (!empty($entry['character_state_before'])) { $lines[] = "- 人物开镜状态:{$entry['character_state_before']}"; } $lines[] = "- 起始画面:{$entry['start_frame']}"; $lines[] = "- 人物动作与表情:{$entry['action_expression']}"; if (!empty($entry['action_beats'])) { $lines[] = '- 动作节拍:' . implode(';', $entry['action_beats']); } $lines[] = $entry['dialogue'] !== '' ? "- 音轨:{$entry['audio_mode']}|{$entry['dialogue_speaker']}以{$entry['dialogue_delivery']}方式说:{$entry['dialogue']}" : (!empty($entry['sound_effects']) ? '- 音轨:scene_sound|' . implode('、', $entry['sound_effects']) . ';无旁白、无角色对白' : '- 音轨:仅连续环境声,无旁白、无角色对白'); if (!empty($entry['screen_event'])) { $lines[] = "- 屏幕/文字事件:{$entry['screen_event']}(只作为画面信息,不朗读)"; } $lines[] = "- 长内容连续性:{$entry['narrative_continuity']}"; $lines[] = "- 摄影机运动:{$entry['camera_motion']},{$entry['motion_speed']}"; if (!empty($entry['character_state_after'])) { $lines[] = "- 人物收镜状态:{$entry['character_state_after']}"; } if (!empty($entry['prop_state'])) { $lines[] = "- 道具收镜状态:{$entry['prop_state']}"; } $lines[] = "- 结束画面:{$entry['end_frame']}"; $lines[] = "- 镜头衔接:{$entry['transition']}"; $lines[] = ''; } $lines[] = '【' . self::targetModel() . ' 成品生成提示词】'; foreach ($shots as $shot) { $lines[] = "镜头 {$shot['shot_no']}:{$shot['prompt']}"; } $lines[] = ''; $lines[] = '【统一禁止项】'; $lines[] = self::NEGATIVE_CONSTRAINTS . '。'; return implode("\n", $lines); } private static function timecode(int $seconds): string { return sprintf('%02d:%02d', intdiv($seconds, 60), $seconds % 60); } }