1116 lines
57 KiB
PHP
1116 lines
57 KiB
PHP
<?php
|
||
|
||
namespace app\service;
|
||
|
||
/**
|
||
* 短剧分镜规划器。
|
||
*
|
||
* 第一版保持无外部依赖:即使文本模型暂时不可用,也能立即把用户想法拆成
|
||
* 可编辑、可提交给 H3 的分镜。后续可在不改变 API 的前提下接入剧本模型。
|
||
*/
|
||
class ShortDramaPlannerService
|
||
{
|
||
public const CHARACTER_ORIGIN_EAST_ASIAN = 'east_asian';
|
||
public const CHARACTER_ORIGIN_GLOBAL = 'global';
|
||
public const SCREEN_TEXT_LANGUAGE_ZH_CN = 'zh-CN';
|
||
public const SCREEN_TEXT_LANGUAGE_EN_US = 'en-US';
|
||
public const SCREEN_TEXT_LANGUAGE_NONE = 'none';
|
||
public const SHOT_DURATION_AUTO = 'auto';
|
||
public const SHOT_DURATION_FIVE = '5';
|
||
public const SHOT_DURATION_TEN = '10';
|
||
|
||
private const NEGATIVE_CONSTRAINTS = '禁止人物变脸、五官漂移、发型或服装改变、人物数量变化、重复人物、肢体错误、额外手指、关节反折、背景闪烁、物体瞬移、动作断裂、穿模、比例突变、光线跳变和物理关系失真;禁止字幕、标题、水印、品牌标志和界面元素';
|
||
|
||
private const BEATS = [
|
||
['开场钩子', '用一个立刻抓住注意力的异常画面建立人物和处境', 'wide establishing shot, immediate visual hook'],
|
||
['冲突出现', '关键人物进入画面,新的冲突被迅速触发', 'medium shot, clear character blocking'],
|
||
['情绪特写', '主角意识到问题,情绪发生可见变化', 'cinematic close-up, subtle facial performance'],
|
||
['主动行动', '主角作出明确决定并开始行动', 'tracking shot, purposeful natural motion'],
|
||
['冲突升级', '阻碍突然升级,人物关系变得紧张', 'dynamic medium shot, controlled camera movement'],
|
||
['信息揭示', '一个视觉细节揭示新的真相', 'insert shot followed by reaction close-up'],
|
||
['正面对抗', '主要人物正面交锋,动作简洁清楚', 'shot reverse shot, strong spatial continuity'],
|
||
['局势逆转', '原本占优的一方突然失去控制', 'smooth push-in, dramatic but realistic timing'],
|
||
['情感落点', '角色用克制的表演完成本集情绪落点', 'steady close-up, restrained performance'],
|
||
['结尾悬念', '在最关键的信息出现时结束,为下一集留下悬念', 'final reveal, hold on the last expression'],
|
||
];
|
||
|
||
/**
|
||
* 按故事节拍轮换的电影摄影模板。内容只描述镜头逻辑和视觉方法,
|
||
* 不携带任何参考作品的人物、品牌、台词或具体故事。
|
||
*/
|
||
private const SHOT_PROFILES = [
|
||
['大全景转中景', '略低机位,24mm 广角,三分法构图,中等景深', '稳定器缓慢前推', '先静止 0.5 秒,再平稳匀速推进', '侧后方主光,冷环境光配少量暖色实景光,综合色温约 4300K', '前景用环境物轻微遮挡,中景放置主体,后景交代完整空间纵深', '真实皮肤、织物与磨损表面,保留细微高光和空气透视', '低频环境底噪、远处空间声,节奏由静到动'],
|
||
['中景', '平视机位,35mm 焦段,主体位于视觉动线交点,浅中景深', '小幅横移并轻微跟随', '与人物步速一致,避免突然加速', '侧前方柔光,冷暖平衡,综合色温约 4800K', '前景留出动作方向,中景人物关系清晰,后景保持地理位置稳定', '自然织物褶皱、金属和玻璃反射不过曝', '脚步声与环境声同步,冲突点前短暂停顿'],
|
||
['近景特写', '平视略侧机位,65mm 中长焦,眼睛落在上三分线,浅景深', '极慢推近', '匀速且幅度克制,以表情变化为节拍', '眼神侧前方柔光,背景冷色,肤色保持自然中性', '前景干净,中景面部为唯一视觉中心,后景散景位置固定', '皮肤纹理真实但不过度锐化,眼球高光稳定', '保留呼吸和衣料摩擦声,音乐或环境声轻微收束'],
|
||
['中近景跟拍', '肩部高度机位,40mm 焦段,中心偏置构图,中等景深', '稳定器顺势跟拍', '动作开始后柔和加速,主体始终保持画面尺度', '顶部环境光加侧向轮廓光,综合色温约 4600K', '前景运动物形成速度参照,中景主体清晰,后景透视连续', '真实运动模糊、地面摩擦和衣料动态', '脚步与动作节奏同步,环境声随空间变化自然过渡'],
|
||
['动态中景', '略低机位,32mm 焦段,对角线构图,中等景深', '短距离环绕后停稳', '中速运动,转折处缓入缓出', '侧逆光增强轮廓,局部暖光形成冲突,综合色温约 4400K', '前景建立阻碍,中景呈现人物动作,后景方向保持不变', '硬质表面反射受控,阴影边缘真实', '动作音效明确但不过度,冲突点提升节奏'],
|
||
['物件特写转反应近景', '俯视或侧俯机位,70mm 焦段,中心构图,浅景深', '先锁定细节,再小幅拉焦到人物反应', '拉焦平缓,摄影机主体位置基本稳定', '窄束侧光强调关键细节,综合色温约 5000K', '前景关键物,中景人物反应,后景虚化但位置不可跳变', '细节材质清晰,边缘高光自然,无塑料感', '细小物件声先出现,随后留出反应停顿'],
|
||
['双人中景', '平视机位,50mm 标准焦段,正反打空间轴线明确,浅中景深', '克制的左右微移或静态构图', '保持匀速,禁止跨轴和突兀摇摆', '双方脸部光比一致,主光方向固定,综合色温约 4700K', '前景肩部提供关系,中景对话主体,后景空间锚点固定', '肤色和服装材质一致,避免锐度与曝光跳变', '环境声连续,人物动作之间保留自然反应时间'],
|
||
['中近景转特写', '略低平视机位,55mm 焦段,主体居中偏侧,浅景深', '平滑推近并在情绪点停住', '前段中速、后段减速,结束前保持稳定', '侧面硬光与柔和补光形成层次,综合色温约 4500K', '前景逐渐退出,中景主体占比增加,后景光斑不闪烁', '面部、头发与衣料细节连续,光泽方向一致', '环境底噪逐渐降低,逆转信息出现时强调单一声音'],
|
||
['静态情绪特写', '眼平机位,85mm 长焦,中心稳定构图,极浅景深', '几乎静止,仅有细微呼吸式漂移', '极慢,避免无意义移动', '大面积柔光从固定侧面进入,综合色温约 5000K', '前景无干扰,中景面部表演完整,后景散景稳定', '自然皮肤微表情、泪光或呼吸细节,避免蜡像质感', '保留呼吸和空间静音,节奏克制'],
|
||
['揭示性近景', '平视略低机位,58mm 焦段,留出视线方向,浅中景深', '缓慢推近后定格', '前 4 秒匀速推进,最后 1 秒完全停住', '侧逆光勾勒轮廓,关键区域暖光,综合色温约 4600K', '前景线索、中景人物反应、后景悬念信息形成三层关系', '线索材质清晰,背景曝光与色彩稳定', '揭示点出现明确环境音,结尾保留尾音以衔接下一镜头'],
|
||
];
|
||
|
||
public static function directorSystemPrompt(): string
|
||
{
|
||
return trim((string) config('short_drama.director_system_prompt'));
|
||
}
|
||
|
||
public static function targetModel(): string
|
||
{
|
||
return trim((string) config('short_drama.target_model', 'MiniMax H3')) ?: 'MiniMax H3';
|
||
}
|
||
|
||
public static function normalizeCharacterOrigin(?string $origin): string
|
||
{
|
||
return in_array($origin, [self::CHARACTER_ORIGIN_EAST_ASIAN, self::CHARACTER_ORIGIN_GLOBAL], true)
|
||
? (string) $origin
|
||
: self::CHARACTER_ORIGIN_EAST_ASIAN;
|
||
}
|
||
|
||
public static function characterOriginLabel(?string $origin): string
|
||
{
|
||
return self::normalizeCharacterOrigin($origin) === self::CHARACTER_ORIGIN_GLOBAL
|
||
? '全球面孔(国家不限)'
|
||
: '东方面孔(东亚)';
|
||
}
|
||
|
||
public static function normalizeScreenTextLanguage(?string $language): string
|
||
{
|
||
return in_array($language, [
|
||
self::SCREEN_TEXT_LANGUAGE_ZH_CN,
|
||
self::SCREEN_TEXT_LANGUAGE_EN_US,
|
||
self::SCREEN_TEXT_LANGUAGE_NONE,
|
||
], true) ? (string) $language : self::SCREEN_TEXT_LANGUAGE_ZH_CN;
|
||
}
|
||
|
||
public static function screenTextLanguageLabel(?string $language): string
|
||
{
|
||
return match (self::normalizeScreenTextLanguage($language)) {
|
||
self::SCREEN_TEXT_LANGUAGE_EN_US => '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<int,array<string,mixed>>}
|
||
*/
|
||
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 = '<Picture ' . ($index + 1) . '>';
|
||
$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<int,array{speaker:string,text:string,delivery:string}>
|
||
*/
|
||
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<int,string[]> */
|
||
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);
|
||
}
|
||
}
|