Files
chat/backend/app/service/VideoRenderService.php
2026-08-05 15:56:08 +08:00

352 lines
14 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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];
}
}