where('user_id', $userId) ->where('file_type', 'video') ->find(); if (!$video) { throw new \RuntimeException('无法读取上一镜头视频'); } $videoPath = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $video->file_path); if (!is_file($videoPath)) { throw new \RuntimeException('上一镜头视频文件不存在'); } $subdir = date('Y/m/d'); $storedBase = 'continuity_' . uniqid('', true) . '.jpg'; $relativePath = $subdir . '/' . $storedBase; $fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subdir); if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) { throw new \RuntimeException('无法创建镜头连续帧目录'); } $outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase; // H3 输出最后几帧有时会包含编码尾部黑帧,向前取 0.12 秒更稳定。 [$exitCode, $error] = self::run([ 'ffmpeg', '-y', '-sseof', '-0.12', '-i', $videoPath, '-frames:v', '1', '-q:v', '2', $outputPath, ]); if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) { @unlink($outputPath); throw new \RuntimeException('提取镜头尾帧失败: ' . mb_substr(trim($error), -400)); } $upload = UploadFile::create([ 'user_id' => $userId, 'original_name' => 'continuity_last_frame.jpg', 'stored_name' => $storedBase, 'file_path' => $relativePath, 'mime_type' => 'image/jpeg', 'file_size' => (int) filesize($outputPath), 'file_type' => 'image', ]); return (int) $upload->id; } /** * @param array> $shots */ public static function concatenate( array $shots, int $userId, bool $showSubtitles = false, string $aspectRatio = '9:16', string $screenTextLanguage = ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE, string $quality = 'standard' ): int { $uploadIds = []; $durations = []; $renderShots = []; foreach ($shots as $shot) { $uploadId = (int) ($shot['output_upload_id'] ?? 0); if ($uploadId <= 0) { continue; } $uploadIds[] = $uploadId; $durations[] = max(1, min(30, (int) ($shot['duration_seconds'] ?? 5))); $renderShots[] = $shot; } if (!$uploadIds) { throw new \RuntimeException('没有可合成的视频镜头'); } $overlayDocument = self::overlayDocument( $renderShots, $durations, $aspectRatio, $showSubtitles, $screenTextLanguage ); if (count($uploadIds) === 1 && $overlayDocument === null) { return $uploadIds[0]; } $uploads = UploadFile::whereIn('id', $uploadIds) ->where('user_id', $userId) ->select() ->column(null, 'id'); $paths = []; foreach ($uploadIds as $uploadId) { $upload = $uploads[$uploadId] ?? null; if (!$upload) { throw new \RuntimeException('部分视频镜头文件已不存在'); } $path = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, (string) $upload->file_path); if (!is_file($path)) { throw new \RuntimeException('视频镜头文件无法读取'); } $paths[] = $path; } $subdir = date('Y/m/d'); $storedBase = 'short_drama_' . uniqid('', true) . '.mp4'; $relativePath = $subdir . '/' . $storedBase; $fullDir = rtrim(config('upload.path'), '/\\') . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $subdir); if (!is_dir($fullDir) && !mkdir($fullDir, 0755, true) && !is_dir($fullDir)) { throw new \RuntimeException('无法创建短剧成片目录'); } $outputPath = $fullDir . DIRECTORY_SEPARATOR . $storedBase; $subtitlePath = null; if ($overlayDocument !== null) { $subtitlePath = $fullDir . DIRECTORY_SEPARATOR . 'subtitle_' . uniqid('', true) . '.ass'; if (file_put_contents($subtitlePath, $overlayDocument) === false) { throw new \RuntimeException('无法创建文字合成文件'); } } $command = ['ffmpeg', '-y']; foreach ($paths as $path) { $command[] = '-i'; $command[] = $path; } $filters = []; $concatInputs = ''; [$targetWidth, $targetHeight] = self::renderSize($aspectRatio, $quality); foreach ($durations as $index => $duration) { // 不同批次或重试镜头可能使用不同质量档位。concat 要求每路画面和 // 音轨参数完全一致,因此先统一尺寸、SAR、帧率、像素格式和双声道。 $filters[] = "[{$index}:v:0]trim=duration={$duration},setpts=PTS-STARTPTS," . "scale={$targetWidth}:{$targetHeight}:force_original_aspect_ratio=decrease," . "pad={$targetWidth}:{$targetHeight}:(ow-iw)/2:(oh-ih)/2:color=black," . "setsar=1,fps=24,format=yuv420p[v{$index}]"; $filters[] = "[{$index}:a:0]aresample=48000," . "aformat=sample_fmts=fltp:sample_rates=48000:channel_layouts=stereo," . "atrim=duration={$duration},asetpts=PTS-STARTPTS[a{$index}]"; $concatInputs .= "[v{$index}][a{$index}]"; } if (count($paths) === 1) { $videoOutput = '[v0]'; $audioOutput = '[a0]'; } else { $filters[] = $concatInputs . 'concat=n=' . count($paths) . ':v=1:a=1[vconcat][aout]'; $videoOutput = '[vconcat]'; $audioOutput = '[aout]'; } if ($subtitlePath !== null) { $filters[] = $videoOutput . "ass=filename='" . self::escapeFilterPath($subtitlePath) . "'[vout]"; $videoOutput = '[vout]'; } array_push( $command, '-filter_complex', implode(';', $filters), '-map', $videoOutput, '-map', $audioOutput, '-c:v', 'libx264', '-preset', 'veryfast', '-crf', '20', '-pix_fmt', 'yuv420p', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', '-movflags', '+faststart', $outputPath ); [$exitCode, $error] = self::run($command); if ($subtitlePath !== null) { @unlink($subtitlePath); } if ($exitCode !== 0 || !is_file($outputPath) || filesize($outputPath) === 0) { @unlink($outputPath); throw new \RuntimeException('视频合成失败: ' . mb_substr(trim($error), -600)); } $size = (int) filesize($outputPath); $upload = UploadFile::create([ 'user_id' => $userId, 'original_name' => 'short_drama_episode.mp4', 'stored_name' => $storedBase, 'file_path' => $relativePath, 'mime_type' => 'video/mp4', 'file_size' => $size, 'file_type' => 'video', ]); return (int) $upload->id; } /** * 生成后期 ASS 文字层。字幕与场景文字都不交给视频模型直接绘制,避免乱码、漂移和闪烁。 * * @param array> $shots * @param int[] $durations */ private static function overlayDocument( array $shots, array $durations, string $aspectRatio, bool $showSubtitles, string $screenTextLanguage ): ?string { $screenTextLanguage = ShortDramaPlannerService::normalizeScreenTextLanguage($screenTextLanguage); $portrait = $aspectRatio !== '16:9'; $playResX = $portrait ? 1080 : 1920; $playResY = $portrait ? 1920 : 1080; $fontSize = $portrait ? 54 : 48; $marginV = $portrait ? 150 : 72; $lineLength = $portrait ? 17 : 28; $cursor = 0.0; $events = []; foreach ($shots as $index => $shot) { $duration = (float) ($durations[$index] ?? 5); $meta = is_array($shot['meta'] ?? null) ? $shot['meta'] : []; if (!$meta && is_string($shot['meta'] ?? null)) { $decoded = json_decode((string) $shot['meta'], true); $meta = is_array($decoded) ? $decoded : []; } $timeline = is_array($meta['timeline'] ?? null) ? $meta['timeline'] : []; $dialogue = $showSubtitles ? self::cleanSubtitleText((string) ($shot['dialogue'] ?? ''), $lineLength) : ''; if ($dialogue !== '') { $placement = (string) ($timeline['voice_timing'] ?? 'start'); $start = $cursor + 0.18; if ($placement === 'end') { $start = $cursor + max(0.18, $duration - 2.8); } $end = max($start + 0.5, $cursor + $duration - 0.16); $events[] = 'Dialogue: 0,' . self::assTime($start) . ',' . self::assTime($end) . ',Default,,0,0,0,,' . $dialogue; } $sceneText = $screenTextLanguage !== ShortDramaPlannerService::SCREEN_TEXT_LANGUAGE_NONE ? self::cleanSceneText((string) ($timeline['screen_text'] ?? ''), $portrait ? 14 : 24) : ''; if ($sceneText !== '') { $start = $cursor + 0.35; $end = max($start + 0.6, $cursor + $duration - 0.25); $events[] = 'Dialogue: 1,' . self::assTime($start) . ',' . self::assTime($end) . ',SceneText,,0,0,0,,' . $sceneText; } $cursor += $duration; } if (!$events) { return null; } return "[Script Info]\n" . "ScriptType: v4.00+\n" . "PlayResX: {$playResX}\n" . "PlayResY: {$playResY}\n" . "WrapStyle: 0\n" . "ScaledBorderAndShadow: yes\n\n" . "[V4+ Styles]\n" . "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding\n" . "Style: Default,Noto Sans CJK SC,{$fontSize},&H00FFFFFF,&H00FFFFFF,&H50000000,&H78000000,-1,0,0,0,100,100,0,0,3,2,0,2,70,70,{$marginV},1\n" . 'Style: SceneText,Noto Sans CJK SC,' . ($portrait ? 62 : 54) . ',&H00FFFFFF,&H00FFFFFF,&H78000000,&HA0000000,-1,0,0,0,100,100,0,0,3,3,0,8,90,90,' . ($portrait ? 260 : 105) . ",1\n\n" . "[Events]\n" . "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n" . implode("\n", $events) . "\n"; } private static function cleanSubtitleText(string $text, int $lineLength): string { $text = trim($text); $text = preg_replace('/^[^::\n]{1,20}[::]\s*/u', '', $text) ?? $text; $text = preg_replace('/\s+/u', ' ', $text) ?? $text; $text = trim($text); if ($text === '') { return ''; } $text = mb_substr($text, 0, 80); $text = str_replace(['\\', '{', '}'], ['\', '(', ')'], $text); $lines = []; for ($offset = 0, $length = mb_strlen($text); $offset < $length; $offset += $lineLength) { $lines[] = mb_substr($text, $offset, $lineLength); } return implode('\\N', array_slice($lines, 0, 3)); } private static function cleanSceneText(string $text, int $lineLength): string { $text = preg_replace('/\s+/u', ' ', trim($text)) ?? trim($text); $text = preg_replace('/^[\s\"\'“”‘’]+|[\s\"\'“”‘’]+$/u', '', $text) ?? trim($text); if ($text === '') { return ''; } $text = mb_substr($text, 0, 100); $text = str_replace(['\\', '{', '}'], ['\', '(', ')'], $text); $lines = []; for ($offset = 0, $length = mb_strlen($text); $offset < $length; $offset += $lineLength) { $lines[] = mb_substr($text, $offset, $lineLength); } return implode('\\N', array_slice($lines, 0, 3)); } private static function assTime(float $seconds): string { $centiseconds = max(0, (int) round($seconds * 100)); $hours = intdiv($centiseconds, 360000); $minutes = intdiv($centiseconds % 360000, 6000); $secs = intdiv($centiseconds % 6000, 100); return sprintf('%d:%02d:%02d.%02d', $hours, $minutes, $secs, $centiseconds % 100); } private static function escapeFilterPath(string $path): string { return str_replace(['\\', "'", ':'], ['\\\\', "\\'", '\\:'], $path); } /** @return array{int,int} */ private static function renderSize(string $aspectRatio, string $quality): array { $portrait = $aspectRatio !== '16:9'; return match ($quality) { 'high' => $portrait ? [768, 1344] : [1344, 768], 'fast' => $portrait ? [512, 896] : [896, 512], default => $portrait ? [576, 1024] : [1024, 576], }; } /** @return array{int,string} */ private static function run(array $command): array { $pipes = []; $process = @proc_open($command, [ 0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w'], ], $pipes); if (!is_resource($process)) { throw new \RuntimeException('服务器未安装或无法启动 FFmpeg'); } fclose($pipes[0]); stream_get_contents($pipes[1]); fclose($pipes[1]); $error = (string) stream_get_contents($pipes[2]); fclose($pipes[2]); $code = proc_close($process); return [$code, $error]; } }