name, $article, ShortDramaPlannerService::normalizeScreenTextLanguage($screenTextLanguage) ) : null; } catch (\Throwable $error) { Log::warning('Short drama AI story analysis fallback: ' . $error->getMessage()); return null; } } private static function requestAnalysis(AiModel $model, string $prompt): string { if ((string) $model->provider === 'dify') { $result = DifyService::chat( $model, $prompt, [], null, 'short-drama-planner-' . substr(hash('sha256', $prompt), 0, 16) ); return trim((string) ($result['answer'] ?? '')); } $result = OpenAIService::chat($model, [ [ 'role' => 'system', 'content' => '你是专业短剧编剧、分镜师和影视声音设计师。严格输出用户要求的 JSON,不输出思考过程或 Markdown。', ], ['role' => 'user', 'content' => $prompt], ]); return trim((string) ($result['choices'][0]['message']['content'] ?? '')); } private static function buildPrompt( string $article, int $targetDuration, string $shotDurationMode, string $characterOrigin, string $screenTextLanguage ): string { $castingRule = $characterOrigin === ShortDramaPlannerService::CHARACTER_ORIGIN_GLOBAL ? '允许任意国家和族裔的真实面孔,按照原文背景、姓名和场景合理分配;每个角色的国别、族裔、肤色和五官必须跨镜头固定。' : '所有原文未明确指定国别、且没有用户参考图的角色,统一设计为自然真实的东亚东方人面孔,以中国文化语境为默认;角色脸型、肤色和五官必须跨镜头固定。'; $screenTextRule = match ($screenTextLanguage) { ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_EN_US => 'screen_text 必须把原文需要显示的内容准确翻译成自然 English,只输出最终应显示的英文,不保留中文,不添加解释。', ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE => 'screen_text 必须始终为空字符串;文字事件只用无字图形、灯光变化、人物反应或空白屏幕表达。', default => 'screen_text 必须使用准确、通顺的简体中文,只输出最终应显示的文字,不添加引号、标签或解释。', }; $minimumShots = (int) ceil($targetDuration / 10); $maximumShots = max(1, intdiv($targetDuration, 5)); $durationRule = match ($shotDurationMode) { ShortDramaPlannerService::SHOT_DURATION_FIVE => "必须恰好输出 {$maximumShots} 个镜头,每个 duration_seconds 都是 5,总和 {$targetDuration} 秒。", ShortDramaPlannerService::SHOT_DURATION_TEN => '优先每镜 10 秒;只有总时长不能被 10 整除时,最后一个镜头允许 5 秒;总和必须为 ' . $targetDuration . ' 秒。', default => "由你按剧情节奏在 5 秒和 10 秒之间逐镜选择;镜头数必须在 {$minimumShots}-{$maximumShots} 之间,duration_seconds 总和严格等于 {$targetDuration} 秒。", }; return << {$article} PROMPT; } private static function compactArticle(string $article): string { if (mb_strlen($article) <= self::MAX_ARTICLE_CHARS) { return $article; } $part = intdiv(self::MAX_ARTICLE_CHARS - 80, 3); $middleStart = max(0, intdiv(mb_strlen($article) - $part, 2)); return mb_substr($article, 0, $part) . "\n\n【原文过长,此处省略部分连续描写】\n\n" . mb_substr($article, $middleStart, $part) . "\n\n【原文过长,此处省略部分连续描写】\n\n" . mb_substr($article, -$part); } private static function decodeJsonObject(string $answer): ?array { $answer = preg_replace('/.*?<\/think>/isu', '', $answer) ?? $answer; $answer = preg_replace('/^```(?:json)?\s*|\s*```$/iu', '', trim($answer)) ?? trim($answer); $decoded = json_decode($answer, true); if (is_array($decoded)) { return $decoded; } $start = strpos($answer, '{'); $end = strrpos($answer, '}'); if ($start === false || $end === false || $end <= $start) { return null; } $decoded = json_decode(substr($answer, $start, $end - $start + 1), true); return is_array($decoded) ? $decoded : null; } private static function normalizeAnalysis( array $analysis, int $targetDuration, string $shotDurationMode, string $modelName, string $article, string $screenTextLanguage ): ?array { $rawShots = array_values(array_filter( is_array($analysis['shots'] ?? null) ? $analysis['shots'] : [], 'is_array' )); $shotDurations = self::normalizeShotDurations( $rawShots, $targetDuration, $shotDurationMode ); if ($shotDurations === null) { return null; } $shotCount = count($shotDurations); $rawShots = array_slice($rawShots, 0, $shotCount); $characters = []; foreach (array_slice(is_array($analysis['characters'] ?? null) ? $analysis['characters'] : [], 0, self::MAX_CHARACTERS) as $character) { if (!is_array($character)) { continue; } $name = self::text($character['name'] ?? '', 30); if ($name === '') { continue; } $characters[] = [ 'name' => $name, 'gender' => self::text($character['gender'] ?? '未知', 8) ?: '未知', 'age' => self::text($character['age'] ?? '', 20), 'appearance' => self::text($character['appearance'] ?? '', 260), ]; } $scenes = []; $sceneIds = []; foreach (array_slice(is_array($analysis['scenes'] ?? null) ? $analysis['scenes'] : [], 0, 24) as $scene) { if (!is_array($scene)) { continue; } $sceneId = self::text($scene['scene_id'] ?? '', 20); if ($sceneId === '' || isset($sceneIds[$sceneId])) { continue; } $sourceAnchor = self::optionalText($scene['source_anchor'] ?? '', 100); $sourceOffset = $sourceAnchor !== '' ? strpos($article, $sourceAnchor) : false; $sceneIds[$sceneId] = true; $scenes[] = [ 'scene_id' => $sceneId, 'source_anchor' => $sourceOffset !== false ? $sourceAnchor : '', '_source_offset' => $sourceOffset !== false ? (int) $sourceOffset : -1, 'location' => self::optionalText($scene['location'] ?? '', 180), 'layout' => self::optionalText($scene['layout'] ?? '', 300), 'lighting' => self::optionalText($scene['lighting'] ?? '', 220), 'props' => self::optionalText($scene['props'] ?? '', 260), 'atmosphere' => self::optionalText($scene['atmosphere'] ?? '', 180), ]; } usort($scenes, static function (array $left, array $right): int { $leftOffset = (int) ($left['_source_offset'] ?? -1); $rightOffset = (int) ($right['_source_offset'] ?? -1); if ($leftOffset < 0) { return $rightOffset < 0 ? 0 : 1; } if ($rightOffset < 0) { return -1; } return $leftOffset <=> $rightOffset; }); $dialogueEvents = self::sourceDialogueEvents( $article, array_column($characters, 'name') ); $usedDialogueEvents = []; $lastSourceOffset = -1; $shots = []; foreach ($rawShots as $index => $shot) { $shotDuration = (int) $shotDurations[$index]; $sourceExcerptReplaced = false; $resolvedSourceOffset = -1; $sourceExcerpt = self::sourceExcerptForShot( $article, self::optionalText($shot['source_excerpt'] ?? '', 180), $index, $shotCount, $lastSourceOffset, $sourceExcerptReplaced, $resolvedSourceOffset ); $screenEvent = self::optionalText($shot['screen_event'] ?? '', 240); $screenText = $screenTextLanguage === ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE ? '' : self::optionalText($shot['screen_text'] ?? '', 100); if ($screenEvent === '') { $screenText = ''; } $sceneId = self::text($shot['scene_id'] ?? '', 20); if ($sceneId === '' || ($sceneIds && !isset($sceneIds[$sceneId]))) { $sceneId = (string) ($scenes[0]['scene_id'] ?? 'S1'); } $anchoredSceneId = ''; foreach ($scenes as $sceneDefinition) { $sceneOffset = (int) ($sceneDefinition['_source_offset'] ?? -1); if ($sceneOffset < 0 || $resolvedSourceOffset < 0 || $sceneOffset > $resolvedSourceOffset) { continue; } $anchoredSceneId = (string) ($sceneDefinition['scene_id'] ?? ''); } if ($anchoredSceneId !== '') { $sceneId = $anchoredSceneId; } $actionBeats = []; $rawActionBeats = $sourceExcerptReplaced ? [] : (is_array($shot['action_beats'] ?? null) ? $shot['action_beats'] : []); foreach (array_slice($rawActionBeats, 0, 4) as $beat) { $beat = self::optionalText($beat, 180); if ($beat !== '') { $actionBeats[] = $beat; } } $requiredBeatCount = $shotDuration === 10 ? 3 : 2; if (count($actionBeats) < $requiredBeatCount) { $action = $sourceExcerptReplaced ? '严格将原文锚点转化为一个连续、可见且不增写剧情的动作:' . $sourceExcerpt : self::optionalText($shot['action'] ?? '', 400); if ($action !== '') { $actionBeats = $shotDuration === 10 ? [ '0-2秒:承接人物、道具和空间的上一状态', "2-7秒:{$action}", '7-10秒:动作结果与人物反应稳定落点', ] : [ "0-4秒:{$action}", '4-5秒:动作结果稳定落点', ]; } } $shotCharacters = array_values(array_filter(array_map( fn ($name) => self::text($name, 30), is_array($shot['characters'] ?? null) ? $shot['characters'] : [] ))); $dialogue = is_array($shot['dialogue'] ?? null) ? $shot['dialogue'] : []; $dialogueType = (string) ($dialogue['type'] ?? $shot['dialogue_type'] ?? 'none'); if (!in_array($dialogueType, ['character_dialogue', 'narration', 'none'], true)) { $dialogueType = 'none'; } $dialogueText = self::optionalText( $dialogue['text'] ?? $shot['dialogue_text'] ?? (is_string($shot['dialogue'] ?? null) ? $shot['dialogue'] : ''), 80 ); $speaker = self::text($dialogue['speaker'] ?? $shot['dialogue_speaker'] ?? '', 30); if ($dialogueText === '' || ($dialogueType === 'character_dialogue' && $speaker === '')) { $dialogueType = 'none'; $dialogueText = ''; $speaker = ''; } if ($dialogueType !== 'none') { $verified = self::verifyDialogue( $dialogueText, $speaker, $dialogueEvents, $usedDialogueEvents, $index, $shotCount, $article, array_column($characters, 'name') ); if ($verified === null) { $dialogueType = 'none'; $dialogueText = ''; $speaker = ''; } else { $dialogueText = $verified['text']; $speaker = $verified['speaker'] ?: $speaker; } } if ($dialogueType === 'none' && $screenEvent === '') { $inferredDialogue = self::dialogueFromExcerpt( $sourceExcerpt, $shotCharacters, $dialogueEvents, $usedDialogueEvents, array_column($characters, 'name') ); if ($inferredDialogue !== null) { $dialogueType = 'character_dialogue'; $dialogueText = $inferredDialogue['text']; $speaker = $inferredDialogue['speaker']; } } $soundEffects = []; $rawSounds = is_array($shot['sound_effects'] ?? null) ? $shot['sound_effects'] : preg_split('/[,,;;、\n]+/u', (string) ($shot['sound_effects'] ?? '')); foreach (array_slice($rawSounds ?: [], 0, 8) as $rawSound) { foreach (preg_split('/[,,;;、]+/u', (string) $rawSound) ?: [] as $sound) { $sound = self::text($sound, 60); if ($sound !== '' && !preg_match('/(?:台词|说话|对白|旁白|人声|怒吼|喊叫|惨叫)/u', $sound) && self::soundSupportedByArticle($sound, $article) && !in_array($sound, $soundEffects, true)) { $soundEffects[] = $sound; } if (count($soundEffects) >= 5) { break 2; } } } if (!$soundEffects) { $soundEffects = self::inferSoundEffects($sourceExcerpt); } $shots[] = [ 'shot_no' => $index + 1, 'duration_seconds' => $shotDuration, 'title' => self::text($shot['title'] ?? ('镜头 ' . ($index + 1)), 70), 'source_excerpt' => $sourceExcerpt, 'scene_id' => $sceneId, 'scene' => self::optionalText($shot['scene'] ?? '', 220), 'characters' => $shotCharacters, 'visual' => $sourceExcerptReplaced ? '严格呈现原文锚点,不添加未出现的地点、人物、道具或事件:' . $sourceExcerpt : self::optionalText($shot['visual'] ?? '', 500), 'action' => $sourceExcerptReplaced ? '严格按原文锚点表现连续动作:' . $sourceExcerpt : self::optionalText($shot['action'] ?? '', 400), 'action_beats' => $actionBeats, 'character_state_before' => $sourceExcerptReplaced ? '承接上一镜头已经建立的人物位置、朝向、姿势、视线、手持物和情绪' : self::optionalText($shot['character_state_before'] ?? '', 400), 'character_state_after' => $sourceExcerptReplaced ? '停在原文锚点所述动作完成后的真实位置、姿势、视线和情绪' : self::optionalText($shot['character_state_after'] ?? '', 400), 'prop_state' => $sourceExcerptReplaced ? '只保留原文锚点明确出现的道具,并维持其动作后的真实位置' : self::optionalText($shot['prop_state'] ?? '', 300), 'camera' => self::optionalText($shot['camera'] ?? '', 240), 'end_frame' => self::optionalText($shot['end_frame'] ?? '', 300), 'continuity_from_previous' => self::optionalText($shot['continuity_from_previous'] ?? '', 300), 'transition' => self::optionalText($shot['transition'] ?? '', 220), 'dialogue_type' => $dialogueType, 'dialogue_speaker' => $speaker, 'dialogue' => $dialogueText, 'dialogue_delivery' => self::text($dialogue['delivery'] ?? '自然', 40) ?: '自然', 'sound_effects' => $soundEffects, 'screen_event' => $screenEvent, 'screen_text' => $screenText, ]; } if (!$scenes) { $scenes[] = [ 'scene_id' => 'S1', 'location' => (string) ($shots[0]['scene'] ?? ''), 'layout' => '空间格局、出入口和前中后景沿用首镜建立状态', 'lighting' => '光向、色温、天气和实景光保持连续', 'props' => '关键道具的位置与状态逐镜承接', 'atmosphere' => '材质与环境底色保持连续', ]; } for ($index = 1, $count = count($shots); $index < $count; $index++) { if ($shots[$index]['scene_id'] !== $shots[$index - 1]['scene_id']) { continue; } if ($shots[$index - 1]['character_state_after'] !== '') { $shots[$index]['character_state_before'] = $shots[$index - 1]['character_state_after']; } $shots[$index]['continuity_from_previous'] = implode(';', array_values(array_filter([ '同一场景,人物开镜状态严格等于上一镜头收镜状态', (string) $shots[$index]['continuity_from_previous'], ]))); } foreach ($scenes as &$sceneDefinition) { unset($sceneDefinition['_source_offset']); } unset($sceneDefinition); return [ 'source' => 'ai', 'model' => $modelName, 'title' => self::text($analysis['title'] ?? '', 80), 'logline' => self::text($analysis['logline'] ?? '', 300), 'characters' => $characters, 'scenes' => $scenes, 'shots' => $shots, ]; } /** @return int[]|null */ private static function normalizeShotDurations( array $rawShots, int $targetDuration, string $mode ): ?array { $mode = ShortDramaPlannerService::normalizeShotDurationMode($mode); if ($mode !== ShortDramaPlannerService::SHOT_DURATION_AUTO) { $durations = ShortDramaPlannerService::shotDurationsForMode($targetDuration, $mode); return count($rawShots) >= count($durations) ? $durations : null; } $minimumCount = (int) ceil($targetDuration / 10); $maximumCount = max(1, intdiv($targetDuration, 5)); $shotCount = count($rawShots); if ($shotCount < $minimumCount) { return null; } $shotCount = min($maximumCount, $shotCount); $tenSecondCount = max(0, intdiv($targetDuration - ($shotCount * 5), 5)); $durations = array_fill(0, $shotCount, 5); $assigned = []; foreach (array_slice($rawShots, 0, $shotCount) as $index => $shot) { if ($tenSecondCount <= 0 || (int) ($shot['duration_seconds'] ?? 5) !== 10) { continue; } $durations[$index] = 10; $assigned[$index] = true; $tenSecondCount--; } if ($tenSecondCount > 0) { $fallback = ShortDramaPlannerService::shotDurationsForMode( $targetDuration, ShortDramaPlannerService::SHOT_DURATION_AUTO, $shotCount ); foreach ($fallback as $index => $duration) { if ($tenSecondCount <= 0) { break; } if ($duration === 10 && empty($assigned[$index])) { $durations[$index] = 10; $assigned[$index] = true; $tenSecondCount--; } } } if ($tenSecondCount > 0) { foreach ($durations as $index => $duration) { if ($tenSecondCount <= 0) { break; } if ($duration === 5) { $durations[$index] = 10; $tenSecondCount--; } } } return array_sum($durations) === $targetDuration ? $durations : null; } private static function sourceExcerptForShot( string $article, string $requested, int $index, int $shotCount, int &$lastSourceOffset, bool &$sourceExcerptReplaced, int &$resolvedSourceOffset ): string { $sourceExcerptReplaced = false; $resolvedSourceOffset = -1; $requested = trim($requested); if (mb_strlen($requested) >= 6) { $position = strpos($article, $requested, max(0, $lastSourceOffset + 1)); $targetRatio = $shotCount <= 1 ? 0.0 : $index / ($shotCount - 1); $positionRatio = $position === false ? -1.0 : $position / max(1, strlen($article) - strlen($requested)); $positionTolerance = max(0.18, 0.45 / sqrt(max(1, $shotCount))); if ($position !== false && abs($positionRatio - $targetRatio) <= $positionTolerance) { $lastSourceOffset = $position; $resolvedSourceOffset = $position; return self::optionalText($requested, 100); } } $sourceExcerptReplaced = true; $segments = array_values(array_filter( preg_split( '/(?<=[。!?!?;;])|\R+/u', $article, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_OFFSET_CAPTURE ) ?: [], fn (array $segment): bool => trim((string) ($segment[0] ?? '')) !== '' )); if ($segments) { $paragraphIndex = $shotCount <= 1 ? 0 : (int) round(($index / ($shotCount - 1)) * (count($segments) - 1)); for ($candidate = $paragraphIndex, $count = count($segments); $candidate < $count; $candidate++) { $offset = (int) ($segments[$candidate][1] ?? 0); if ($offset <= $lastSourceOffset) { continue; } $lastSourceOffset = $offset; $resolvedSourceOffset = $offset; return self::optionalText(trim((string) $segments[$candidate][0]), 100); } } $start = min( max(0, mb_strlen($article) - 1), $shotCount <= 1 ? 0 : (int) floor(($index / $shotCount) * mb_strlen($article)) ); $fallback = self::optionalText(mb_substr($article, $start, 100), 100); $fallbackOffset = $fallback !== '' ? strpos($article, $fallback) : false; if ($fallbackOffset !== false && $fallbackOffset > $lastSourceOffset) { $lastSourceOffset = $fallbackOffset; $resolvedSourceOffset = $fallbackOffset; } return $fallback; } /** * AI 偶尔把台词只写进 source_excerpt、却把 dialogue 标成 none;这里仅从 * 已经在原文中验证过的对白事件恢复,屏幕文字镜头由调用方提前排除。 * * @param string[] $shotCharacters * @param array $events * @param array $usedEvents * @param string[] $knownCharacterNames * @return array{speaker:string,text:string}|null */ private static function dialogueFromExcerpt( string $excerpt, array $shotCharacters, array $events, array &$usedEvents, array $knownCharacterNames ): ?array { $excerptNormalized = self::normalizeComparable($excerpt); if ($excerptNormalized === '') { return null; } foreach ($events as $eventIndex => $event) { if (!empty($usedEvents[$eventIndex])) { continue; } $eventNormalized = self::normalizeComparable((string) $event['text']); $probe = mb_substr($eventNormalized, 0, min(12, mb_strlen($eventNormalized))); if ($probe === '' || !str_contains($excerptNormalized, $probe)) { continue; } $speaker = ''; foreach ($knownCharacterNames as $knownName) { if ($knownName !== '' && str_contains($excerpt, $knownName)) { $speaker = $knownName; break; } } if ($speaker === '' && count($shotCharacters) === 1 && in_array($shotCharacters[0], $knownCharacterNames, true)) { $speaker = $shotCharacters[0]; } if ($speaker === '' && in_array($event['speaker'], $knownCharacterNames, true)) { $speaker = (string) $event['speaker']; } if ($speaker === '') { continue; } $usedEvents[$eventIndex] = true; return [ 'speaker' => $speaker, 'text' => self::bestExactClause((string) $event['text'], $excerptNormalized), ]; } return null; } /** @return string[] */ private static function inferSoundEffects(string $excerpt): array { $catalog = [ '老式电流杂音' => '/(?:电流|滋滋).{0,12}(?:杂音|声|响)?/u', '发动机低沉轰鸣' => '/发动机.{0,12}(?:轰鸣|声|响)/u', '急促喘息与呼吸声' => '/(?:喘息|呼吸).{0,8}(?:声|急促|沉重)?/u', '压抑哭声' => '/(?:哭声|呜咽|哭泣)/u', '车身嘎吱声' => '/嘎吱/u', '撞击与拍打声' => '/(?:撞击|拍打|砸在|砰砰)/u', '骨骼咔嚓声' => '/咔嚓/u', '短促惨叫声' => '/惨叫/u', '蜂群般嗡鸣' => '/(?:嗡嗡|蜂鸣|蜜蜂.{0,8}振翅)/u', '轮胎与崎岖路面摩擦声' => '/(?:轮胎|土路|颠簸).{0,12}(?:摩擦|声|响)?/u', ]; $sounds = []; foreach ($catalog as $label => $pattern) { if (preg_match($pattern, $excerpt)) { $sounds[] = $label; } } return array_slice($sounds, 0, 5); } /** * @return array */ private static function sourceDialogueEvents(string $article, array $characterNames): array { $events = []; $ignoredSpeaker = '/(?:屏幕|文字|红字|账单|网页|显示屏|木牌|系统|剧本|片名|类型|场景|正文|标题|提示)/u'; $patterns = [ '/([^\n。!?]{0,70})(?:说道?|喊道?|叫道?|问道?|回答|嘟囔(?:了)?(?:一句)?|低语|怒吼(?:着)?|骂骂咧咧)[^::\n]{0,10}[::]\s*[“\"]?([^。!?!?\n”\"]{1,100}[。!?!?]?)/u', '/([\p{Han}A-Za-z0-9·()()]{1,20})(?:台词)?[::]\s*[“\"]?([^。!?!?\n”\"]{1,100}[。!?!?]?)/u', ]; foreach ($patterns as $pattern) { if (!preg_match_all($pattern, $article, $matches, PREG_SET_ORDER | PREG_OFFSET_CAPTURE)) { continue; } foreach ($matches as $match) { $prefix = trim((string) ($match[1][0] ?? '')); if ($prefix === '' || preg_match($ignoredSpeaker, $prefix)) { continue; } $text = self::cleanSourceDialogue((string) ($match[2][0] ?? '')); if ($text === '') { continue; } $offset = (int) ($match[0][1] ?? 0); $speaker = self::sanitizeSpeaker(self::inferSpeaker($prefix, $characterNames)); if (in_array($speaker, ['他', '她'], true)) { $speaker = self::resolvePronounSpeaker($article, $offset, $characterNames) ?: $speaker; } if ($speaker === '' || preg_match($ignoredSpeaker, $speaker)) { continue; } $key = self::normalizeComparable($text); $existing = $events[$key] ?? null; if (!$existing || (in_array($existing['speaker'], ['他', '她'], true) && !in_array($speaker, ['他', '她'], true))) { $events[$key] = ['speaker' => $speaker, 'text' => $text, 'offset' => $offset]; } } } $events = array_values($events); usort($events, fn (array $a, array $b) => $a['offset'] <=> $b['offset']); return $events; } private static function inferSpeaker(string $prefix, array $characterNames): string { $bestName = ''; $bestPosition = -1; foreach ($characterNames as $name) { $name = trim((string) $name); if ($name === '') { continue; } $position = mb_strrpos($prefix, $name); if ($position !== false && $position >= $bestPosition) { $bestName = $name; $bestPosition = $position; } } if ($bestName !== '') { return $bestName; } if (preg_match('/(他|她)[^,。!?\n]{0,10}(?:问|说|喊|叫|惨叫|怒吼|嘟囔)?$/u', $prefix, $embeddedPronoun)) { return (string) $embeddedPronoun[1]; } if (preg_match('/^(他|她)(?:低声|轻声|小声|急切地|突然|缓缓|冷冷地)?(?:问|说|喊|叫)?$/u', $prefix, $pronoun)) { return (string) $pronoun[1]; } if (preg_match_all( '/(?:[\p{Han}]{2,4}(?:男人|女人|女孩|男孩|司机|乘客|少年|少女)|[\p{Han}]{2,4}|他|她)/u', $prefix, $names )) { return trim((string) end($names[0])); } return ''; } private static function sanitizeSpeaker(string $speaker): string { $speaker = trim($speaker); if (preg_match('/^(他|她)(?:低声|轻声|小声|急切地|突然|缓缓|冷冷地|问|说|喊|叫)*/u', $speaker, $pronoun)) { return (string) $pronoun[1]; } if (preg_match('/^(?:随后|然后|接着|突然|低声|轻声|小声|怒吼|嘟囔|说道?|喊道?|问道?)$/u', $speaker)) { return ''; } return $speaker; } private static function resolvePronounSpeaker(string $article, int $offset, array $characterNames): string { $context = mb_substr(substr($article, 0, max(0, $offset)), -400); $bestName = ''; $bestPosition = -1; foreach ($characterNames as $name) { $name = trim((string) $name); if ($name === '') { continue; } $position = mb_strrpos($context, $name); if ($position !== false && $position >= $bestPosition) { $bestName = $name; $bestPosition = $position; } } return $bestName; } private static function cleanSourceDialogue(string $text): string { $text = preg_replace('/^[\s“”\"]+|[\s“”\"]+$/u', '', $text) ?? $text; // 一次说话只取首个完整语义句,防止把后面的作者描写一并当成台词。 if (preg_match('/^(.{1,80}?[。!?!?])/u', $text, $sentence)) { $text = (string) $sentence[1]; } return self::optionalText($text, 80); } /** * @param array $events * @param array $usedEvents * @return array{speaker:string,text:string}|null */ private static function verifyDialogue( string $requestedText, string $requestedSpeaker, array $events, array &$usedEvents, int $shotIndex, int $shotCount, string $article, array $knownCharacterNames ): ?array { $requestedNormalized = self::normalizeComparable($requestedText); if ($requestedNormalized === '') { return null; } $bestIndex = null; $bestScore = 0.0; $articleBytes = max(1, strlen($article)); $targetRatio = $shotCount <= 1 ? 0.0 : $shotIndex / ($shotCount - 1); foreach ($events as $eventIndex => $event) { if (!empty($usedEvents[$eventIndex])) { continue; } $eventNormalized = self::normalizeComparable($event['text']); $similarity = self::characterSimilarity($requestedNormalized, $eventNormalized); if (str_contains($eventNormalized, $requestedNormalized) || str_contains($requestedNormalized, $eventNormalized)) { $similarity = max($similarity, 0.92); } $speakerScore = self::speakerMatches($requestedSpeaker, $event['speaker']) ? 0.2 : 0.0; $eventRatio = $event['offset'] / $articleBytes; $positionScore = max(0.0, 0.12 - abs($targetRatio - $eventRatio) * 0.12); $score = $similarity + $speakerScore + $positionScore; if ($similarity >= 0.34 && $score > $bestScore) { $bestScore = $score; $bestIndex = $eventIndex; } } if ($bestIndex === null) { // “啊、呀、嗯”等短促惨叫/应答可能没有冒号,但必须确实存在于原文。 if (mb_strlen($requestedNormalized) <= 3 && preg_match('/(?:^|[^\p{Han}])' . preg_quote($requestedNormalized, '/') . '(?:[^\p{Han}]|$)/u', $article)) { return ['speaker' => $requestedSpeaker, 'text' => mb_substr($requestedText, 0, 18)]; } $exactSourceText = self::exactStandaloneDialogue( $article, $requestedText, $requestedSpeaker, $knownCharacterNames ); if ($exactSourceText !== null) { return ['speaker' => $requestedSpeaker, 'text' => $exactSourceText]; } return null; } $usedEvents[$bestIndex] = true; $event = $events[$bestIndex]; $speaker = in_array($event['speaker'], ['他', '她'], true) ? $requestedSpeaker : $event['speaker']; return [ 'speaker' => $speaker, 'text' => self::bestExactClause($event['text'], $requestedNormalized), ]; } private static function speakerMatches(string $left, string $right): bool { $left = trim($left); $right = trim($right); return $left !== '' && $right !== '' && ( $left === $right || in_array($right, ['他', '她'], true) || str_contains($left, $right) || str_contains($right, $left) ); } private static function exactStandaloneDialogue( string $article, string $requestedText, string $requestedSpeaker, array $knownCharacterNames ): ?string { if ($requestedSpeaker === '' || !in_array($requestedSpeaker, $knownCharacterNames, true)) { return null; } $needle = preg_replace('/^[\s“”\"]+|[\s“”\",,。!?!?;;]+$/u', '', $requestedText) ?? ''; if ($needle === '' || mb_strlen($needle) > 40) { return null; } $offset = strpos($article, $needle); if ($offset === false) { return null; } $before = mb_substr(substr($article, 0, $offset), -100); $after = mb_substr(substr($article, $offset + strlen($needle)), 0, 100); $screenContext = preg_match('/(?:屏幕|红字|文字|网页|显示|浮现|写着|印着|账单|木牌|视网膜|剧本|提词器)/u', $before); $afterWithoutOwnPunctuation = preg_replace('/^[\s,,。!?!?;;]+/u', '', $after) ?? $after; $afterFirstSentence = preg_split('/[。!?!?]/u', $afterWithoutOwnPunctuation, 2)[0] ?? $afterWithoutOwnPunctuation; $speakerActionAfter = str_contains($afterFirstSentence, $requestedSpeaker) && preg_match('/(?:说|问|喊|叫|皱|咬|笑|怒|愣|抬手|开口|嘴|声音)/u', $afterFirstSentence); if ($screenContext && !$speakerActionAfter) { return null; } return mb_substr($needle, 0, 18); } private static function bestExactClause(string $sourceText, string $requestedNormalized): string { $clauses = preg_split('/[,,。!?!?;;]+/u', $sourceText) ?: [$sourceText]; $best = ''; $bestScore = -1.0; foreach ($clauses as $clause) { $clause = trim($clause); if ($clause === '') { continue; } $candidate = mb_strlen($clause) > 18 ? mb_substr($clause, 0, 18) : $clause; $score = self::characterSimilarity(self::normalizeComparable($candidate), $requestedNormalized); if ($score > $bestScore) { $best = $candidate; $bestScore = $score; } } return $best !== '' ? $best : mb_substr($sourceText, 0, 18); } private static function normalizeComparable(string $text): string { return preg_replace('/[^\p{Han}A-Za-z0-9]+/u', '', mb_strtolower($text)) ?? ''; } private static function characterSimilarity(string $left, string $right): float { if ($left === '' || $right === '') { return 0.0; } $leftChars = array_values(array_unique(preg_split('//u', $left, -1, PREG_SPLIT_NO_EMPTY) ?: [])); $rightChars = array_values(array_unique(preg_split('//u', $right, -1, PREG_SPLIT_NO_EMPTY) ?: [])); $shared = count(array_intersect($leftChars, $rightChars)); return $shared / max(1, max(count($leftChars), count($rightChars))); } private static function soundSupportedByArticle(string $sound, string $article): bool { // 先校验具体声源。仅凭“轰鸣/撞击”等相似动作词不能把原文中的发动机 // 偷换成空调、雷声或其他不存在的物体。 $soundSources = [ '空调', '发动机', '车厢', '公交车', '电流', '麦克风', '电脑', '鼠标', '键盘', '玻璃', '车窗', '车门', '脚步', '风', '雨', '雷', '轮胎', '刹车', '衣物', '金属', '座椅', ]; foreach ($soundSources as $source) { if (str_contains($sound, $source) && !str_contains($article, $source)) { return false; } } $keywords = [ '电流', '滋', '发动机', '轰鸣', '喘息', '呼吸', '哭', '回音', '砰', '拍打', '撞击', '嘎吱', '咔嚓', '嗡', '蜂鸣', '脚步', '摩擦', '风', '雨', '雷', '键盘', '鼠标', '点击', '刹车', '轮胎', '玻璃', '惨叫', ]; foreach ($keywords as $keyword) { if (str_contains($sound, $keyword) && str_contains($article, $keyword)) { return true; } } $core = preg_replace('/(?:环境|场景|动作|连续|远处|沉闷|剧烈|急促|轻微|清晰|声音|声响|声)$/u', '', $sound) ?? $sound; foreach (preg_split('/[\s,,、;;]+/u', $core) ?: [] as $word) { if (mb_strlen($word) >= 2 && str_contains($article, $word)) { return true; } } return false; } private static function optionalText(mixed $value, int $maxLength): string { $value = self::text($value, $maxLength); return preg_match('/^(?:无|没有|无此项|空|空字符串|none|null|n\/a|-+)$/iu', $value) ? '' : $value; } private static function text(mixed $value, int $maxLength): string { $value = trim(preg_replace('/\s+/u', ' ', (string) $value) ?? (string) $value); return mb_strlen($value) > $maxLength ? mb_substr($value, 0, $maxLength) : $value; } }