This commit is contained in:
2026-08-05 15:56:08 +08:00
parent 01729b1e0b
commit 2d9e2376b6
106 changed files with 10007 additions and 253 deletions
+351
View File
@@ -0,0 +1,351 @@
<?php
namespace app\service;
use app\model\UploadFile;
class VideoRenderService
{
/**
* 提取已生成镜头的最后一帧,作为下一镜头的真实视觉起点。
*/
public static function extractLastFrame(int $videoUploadId, int $userId): int
{
$video = UploadFile::where('id', $videoUploadId)
->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<int,array<string,mixed>> $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<int,array<string,mixed>> $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];
}
}