352 lines
14 KiB
PHP
352 lines
14 KiB
PHP
<?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];
|
||
}
|
||
}
|