= 224) {
$stringTMP = mb_substr($string, $i, 3);
$i += 3;
} elseif (ord($stringTMP) >= 192) {
$stringTMP = mb_substr($string, $i, 2);
$i += 2;
} else {
++$i;
}
$stringLast[] = $stringTMP;
}
$stringLast = implode('', $stringLast);
if ($append) {
$stringLast .= '...';
}
return $stringLast;
}
/**
* 自定义日志
*
* @param $name
* @param string $filename
* @return \Illuminate\Log\Writer
*/
function myLog($name, $filename = '')
{
if (!$filename) {
$filename = $name;
}
$filename = $filename . '.log';
$logger = new Logger($name);
$writer = new \Illuminate\Log\Writer($logger);
$writer->useDailyFiles(storage_path('logs/' . $filename));
return $writer;
}
/**
* 业务调试日志
*
* @return Logger
*/
function commonLog()
{
$name = 'common';
$filename = $name . '.log';
$logger = new Logger($name);
$logger->pushHandler(new StreamHandler(storage_path('logs/' . $filename), 'debug'));
return $logger;
}
/**
* @param $name
* @return Logger
*/
function dLog($name)
{
$filename = $name . '-' . date('Y-m-d') . '.log';
$logger = new Logger($name);
$logger->pushHandler(new StreamHandler(storage_path('logs/' . $filename), 'debug'));
return $logger;
}
/**
* 获取对象或数组的属性值
*
* @param $param
* @param $key
* @param string $default
* @return mixed|string
*/
function getProp($param, $key, $default = '')
{
$result = $default;
if (is_object($param) && isset($param->$key)) {
$result = $param->$key;
}
if (is_array($param) && isset($param[$key])) {
$result = $param[$key];
}
return $result;
}
/**
* @param $data
* @param array $trans
* @return array
*/
function assetData($data, $trans = []): array
{
$result = [];
if (empty($data)) {
return $result;
}
if (empty($trans)) {
return $data;
}
foreach ($trans as $tran) {
[$originKey, $newKey, $conv, $default] = [$tran['o'], $tran['n'], $tran['conv'], $tran['default']];
if (isset($data[$originKey])) {
$result[$newKey] = $conv(getProp($data, $originKey, $default));
}
}
return $result;
}
/**
* 随机数
*
* @param int $num
* @return string
* @throws Exception
*/
function random($num = 16)
{
$bytes = random_bytes($num);
return bin2hex($bytes);
}
/**
* 生成订单
*
* @param string $prefix 订单前缀
* @return string
*/
function generateOrderSn($prefix = '')
{
return $prefix . date('YmdHis') . getMillisecond() . rand(1000, 9999);
}
/**
* 分页数据
*
* @param $data
* @return array
*/
function getMeta($data)
{
$currentPage = (int)$data->currentPage();
$lastPage = (int)$data->lastPage();
return [
'current_page' => $currentPage,
'next_page' => $currentPage >= $lastPage ? $lastPage : ++$currentPage,
'last_page' => $lastPage,
'per_page' => (int)$data->perPage(),
'total' => (int)$data->total(),
'is_end' => !$data->hasMorePages(),
'next_page_url' => (string)$data->nextPageUrl(),
'prev_page_url' => (string)$data->previousPageUrl()
];
}
/**
* 钉钉通知异常
*
* @param $message
*/
function sendNotice($message)
{
$webHook = env('DD_WEB_HOOK');
$data = [
'msgtype' => 'text',
'text' => [
'content' => $message
],
'at' => [
'isAll' => true
]
];
// 异步发送
$client = new GuzzleHttp\Client();
$client->post($webHook, ['json' => $data]);
}
/**
* 判断数据是合法的json数据
*
* @param $string
* @return bool
*/
function is_json($string)
{
json_decode($string);
return (json_last_error() == JSON_ERROR_NONE);
}
/**
* 获取ip地址
*
* @return mixed|string
*/
function getIpAddr()
{
$ipaddress = '';
if (isset($_SERVER['HTTP_CLIENT_IP']))
$ipaddress = $_SERVER['HTTP_CLIENT_IP'];
else if (isset($_SERVER['HTTP_X_FORWARDED_FOR']))
$ipaddress = $_SERVER['HTTP_X_FORWARDED_FOR'];
else if (isset($_SERVER['HTTP_X_FORWARDED']))
$ipaddress = $_SERVER['HTTP_X_FORWARDED'];
else if (isset($_SERVER['HTTP_FORWARDED_FOR']))
$ipaddress = $_SERVER['HTTP_FORWARDED_FOR'];
else if (isset($_SERVER['HTTP_FORWARDED']))
$ipaddress = $_SERVER['HTTP_FORWARDED'];
else if (isset($_SERVER['REMOTE_ADDR']))
$ipaddress = $_SERVER['REMOTE_ADDR'];
else
$ipaddress = 'UNKNOWN';
return $ipaddress;
}
/**
* @param int $num
* @return bool|string
* @throws Exception
*/
function randStr($num = 5)
{
$str = 'QWERTYUIOPASDFGHJKLZXCVBNM1234567890qwertyuiopasdfghjklzxcvbnm';
return substr(str_shuffle($str), random_int(0, strlen($str) - 11), $num);
}
/**
* 上传文件(火山云tos)
*
* @param $prefix 文件夹前缀
* @param $file 文件二进制
* @param $filename 文件名(不带后缀)
* @return mixed
*/
function uploadFileByTos($prefix, $file, $filename='')
{
if (!$filename) $filename = randStr(10) . '.' . $file->getClientOriginalExtension();
else $filename = $filename . '.' . $file->getClientOriginalExtension();
try {
$client = new TosClient([
'region' => env('VOLC_REGION'),
'endpoint' => env('VOLC_END_POINT'),
'ak' => env('VOLC_AK'),
'sk' => env('VOLC_SK'),
]);
$stream = fopen($file->getRealPath(), 'r');
$input = new PutObjectInput(env('VOLC_BUCKET'), "$prefix/$filename", $stream);
$output = $client->putObject($input);
if ($output->getRequestId()) {
return "https://".env('VOLC_BUCKET').'.'.env('VOLC_END_POINT')."/$prefix/$filename";
}
} catch (TosClientException $ex) {
dLog('exception')->info('saveToTos', [$ex->getMessage()]);
} catch (TosServerException $ex) {
dLog('exception')->info('saveToTos', [$ex->getRequestId(), $ex->getStatusCode(), $ex->getErrorCode()]);
}
return '';
}
/**
* 上传文件流(火山云tos)
*
* @param $prefix 文件夹前缀
* @param $file 文件二进制
* @param $filename 文件名(带后缀)
* @return mixed
*/
function uploadStreamByTos($prefix, $stream, $filename)
{
try {
$client = new TosClient([
'region' => env('VOLC_REGION'),
'endpoint' => env('VOLC_END_POINT'),
'ak' => env('VOLC_AK'),
'sk' => env('VOLC_SK'),
]);
$input = new PutObjectInput(env('VOLC_BUCKET'), "$prefix/$filename", $stream);
$output = $client->putObject($input);
if ($output->getRequestId()) {
return "https://".env('VOLC_BUCKET').'.'.env('VOLC_END_POINT')."/$prefix/$filename";
}
} catch (TosClientException $ex) {
dLog('exception')->info('saveToTos', [$ex->getMessage()]);
} catch (TosServerException $ex) {
dLog('exception')->info('saveToTos', [$ex->getRequestId(), $ex->getStatusCode(), $ex->getErrorCode()]);
}
return '';
}
// 从图片URL获取图片扩展名
function getImgExtFromUrl(string $url): ?string {
$imageInfo = @getimagesize($url);
if ($imageInfo !== false) {
// 返回MIME类型
return mimeToExt($imageInfo['mime']);
// 或者返回文件扩展名
// return image_type_to_extension($imageInfo[2], false);
}
return null;
}
/**
* 从视频URL获取视频扩展名
* 通过下载文件头部字节来识别真实的视频格式
*
* @param string $url 视频URL
* @return string 返回扩展名(如 .mp4),默认 .mp4
*/
function getVideoExtFromUrl(string $url): string {
try {
// 方法1:从URL路径中提取扩展名
$parsedUrl = parse_url($url);
$path = $parsedUrl['path'] ?? '';
$pathInfo = pathinfo($path);
$extension = $pathInfo['extension'] ?? '';
if ($extension) {
// 验证是否为常见视频格式
$validVideoExts = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', 'mpeg', 'mpg'];
if (in_array(strtolower($extension), $validVideoExts)) {
return '.' . strtolower($extension);
}
}
// 方法2:下载文件头部字节来识别格式
$client = new \GuzzleHttp\Client(['timeout' => 30]);
// 先尝试HEAD请求获取Content-Type
try {
$response = $client->head($url);
$contentType = $response->getHeader('Content-Type')[0] ?? '';
// 根据Content-Type映射扩展名
$mimeMap = [
'video/mp4' => '.mp4',
'video/x-msvideo' => '.avi',
'video/quicktime' => '.mov',
'video/x-ms-wmv' => '.wmv',
'video/x-flv' => '.flv',
'video/x-matroska' => '.mkv',
'video/webm' => '.webm',
'video/mpeg' => '.mpeg',
];
if (isset($mimeMap[$contentType])) {
return $mimeMap[$contentType];
}
} catch (\Exception $e) {
// HEAD请求失败,继续尝试其他方法
}
// 方法3:下载前几个字节分析文件魔数(Magic Number)
$response = $client->get($url, [
'stream' => true,
'headers' => [
'Range' => 'bytes=0-31' // 只下载前32字节
]
]);
$header = $response->getBody()->read(32);
// 根据文件魔数判断格式
// MP4/M4V: 以 ftyp 开头(偏移4-8字节)
if (strpos($header, 'ftyp') !== false) {
// 进一步判断是否为 M4V
if (strpos($header, 'M4V') !== false || strpos($header, 'm4v') !== false) {
return '.m4v';
}
return '.mp4';
}
// AVI: 以 RIFF 开头,包含 AVI
if (substr($header, 0, 4) === 'RIFF' && strpos($header, 'AVI') !== false) {
return '.avi';
}
// MOV: 包含 moov 或 mdat
if (strpos($header, 'moov') !== false || strpos($header, 'mdat') !== false) {
return '.mov';
}
// FLV: 以 FLV 开头
if (substr($header, 0, 3) === 'FLV') {
return '.flv';
}
// WebM: 以 0x1A 0x45 0xDF 0xA3 开头
if (ord($header[0]) === 0x1A && ord($header[1]) === 0x45 &&
ord($header[2]) === 0xDF && ord($header[3]) === 0xA3) {
return '.webm';
}
// MKV: 与WebM类似,但包含 matroska
if (strpos($header, 'matroska') !== false) {
return '.mkv';
}
// MPEG: 以 0x00 0x00 0x01 开头
if (ord($header[0]) === 0x00 && ord($header[1]) === 0x00 && ord($header[2]) === 0x01) {
return '.mpeg';
}
// 默认返回 .mp4(最常见的格式)
return '.mp4';
} catch (\Exception $e) {
// 出错时返回默认格式
return '.mp4';
}
}
// 辅助 MIME 映射函数
function mimeToExt(string $mime): ?string {
$map = [
'audio/wav' => '.wav',
'audio/x-ms-wma' => '.wma',
'video/x-ms-wmv' => '.wmv',
'video/mp4' => '.mp4',
'audio/mpeg' => '.mp3',
'audio/amr' => '.amr',
'application/vnd.rn-realmedia' => '.rm',
'audio/mid' => '.mid',
'image/bmp' => '.bmp',
'image/gif' => '.gif',
'image/png' => '.png',
'image/tiff' => '.tiff',
'image/jpeg' => '.jpg',
'application/pdf' => '.pdf',
];
return $map[$mime] ?? null;
}
/**
* 上传文件(阿里云oss)
*
* @param $prefix 文件夹前缀
* @param $file 文件二进制
* @param $filename 文件名(不带后缀)
* @return mixed
*/
function uploadFile($prefix, $file, $filename='')
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
if (!$filename) $filename = randStr(10) . '.' . $file->getClientOriginalExtension();
else $filename = $filename . '.' . $file->getClientOriginalExtension();
// 设置文件名称。
$object = env('OSS_DIRECTORY') . '/' . $prefix . '/' . $filename;
$provider = new \OSS\Credentials\StaticCredentialsProvider($accessKeyId, $accessKeySecret);
try {
$configs = [
"provider" => $provider,
"endpoint" => $endpoint,
"signatureVersion" => 'v4',
"region" => "cn-hangzhou"
];
$ossClient = new OssClient($configs);
$uploadRes = $ossClient->uploadFile($bucket, $object, $file->path());
} catch (OssException $e) {
dLog('exception')->info('saveImageToOss', [$e->getMessage()]);
return '';
}
// 替换域名
if (!isset($uploadRes['oss-request-url'])) return '';
$url = str_ireplace('zw-ai.oss-cn-hangzhou.aliyuncs.com', 'cdn-zwai.ycsd.cn', $uploadRes['oss-request-url']);
$url = urldecode($url);
return str_ireplace('http://', 'https://', $url);
}
/**
* 上传文件流(阿里云oss)
*
* @param $prefix 文件夹前缀
* @param $file 文件二进制
* @param $filename 文件名(带后缀)
* @return mixed
*/
function uploadStreamToOss($prefix, $stream, $filename) {
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
// if (!$filename) $filename = randStr(10) . '.' . $file->getClientOriginalExtension();
// 设置文件名称。
$object = env('OSS_DIRECTORY') . '/' . $prefix . '/' . $filename;
$provider = new \OSS\Credentials\StaticCredentialsProvider($accessKeyId, $accessKeySecret);
try{
$configs = [
"provider" => $provider,
"endpoint" => $endpoint,
"signatureVersion" => 'v4',
"region"=> "cn-hangzhou"
];
$ossClient = new OssClient($configs);
$uploadRes = $ossClient->putObject($bucket, $object, $stream);
} catch (OssException $e) {
dLog('exception')->info('saveImageToOss', [$e->getMessage()]);
return '';
}
// 替换域名
if (!isset($uploadRes['oss-request-url'])) return '';
$url = str_ireplace('zw-ai.oss-cn-hangzhou.aliyuncs.com', 'cdn-zwai.ycsd.cn', $uploadRes['oss-request-url']);
$url = urldecode($url);
return str_ireplace('http://', 'https://', $url);
}
/**
* 下载文件到本地
*
* @param $object
* @param $localFile
* @return string
*/
function downloadFile($object, $localFile)
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
try {
$ossClient = new OssClient($accessKeyId, $accessKeySecret, $endpoint);
$ossClient->getObject($bucket, $object, [
OssClient::OSS_FILE_DOWNLOAD => $localFile
]);
} catch (OssException $e) {
printf($e->getMessage());
return '';
}
}
/**
* 上传封面
*
* @param $file
* @return string
* @throws Exception
*/
function uploadCoverFile($file)
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
// 设置文件名称。
$object = 'books/cover/' . randStr(10) . '.' . $file->getClientOriginalExtension();
try {
$ossClient = new OssClient($accessKeyId, $accessKeySecret, $endpoint);
$ossImgBackData = $ossClient->uploadFile($bucket, $object, $file->path());
} catch (OssException $e) {
printf($e->getMessage());
return '';
}
$urlArr = parse_url($ossImgBackData['oss-request-url']);
return getProp($urlArr, 'path') ? 'http://' . $bucket . '.' . $endpoint . getProp($urlArr, 'path') : '';
}
function uploadAgreementFile($file)
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
$file_name = $file->getClientOriginalName();
$file_name = str_replace('.' . $file->getClientOriginalExtension(), '', $file_name);
// 设置文件名称。
$object = 'books/contract/' . $file_name . '-' . date('YmdHi') . '.' . $file->getClientOriginalExtension();
try {
$ossClient = new OssClient($accessKeyId, $accessKeySecret, $endpoint);
$ossImgBackData = $ossClient->uploadFile($bucket, $object, $file);
} catch (OssException $e) {
printf($e->getMessage());
return '';
}
$urlArr = parse_url($ossImgBackData['oss-request-url']);
return getProp($urlArr, 'path') ? 'http://' . $bucket . '.' . $endpoint . urldecode(getProp($urlArr, 'path')) : '';
}
/**
* 上传wap推荐图片
*
* @param $file
* @param $filename
* @return string
* @throws Exception
*/
function uploadWapRecommendPic($file, $filename)
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET_YCSD');
// 设置文件名称。
$object = 'ycsd_web_3nd/images/homebanners/' . $filename . '.' . $file->getClientOriginalExtension();
try {
$ossClient = new OssClient($accessKeyId, $accessKeySecret, $endpoint);
$options = array(
OssClient::OSS_HEADERS => array(
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'inline'
),
);
$ossImgBackData = $ossClient->uploadFile($bucket, $object, $file->path(), $options);
} catch (OssException $e) {
printf($e->getMessage());
return '';
}
$urlArr = parse_url($ossImgBackData['oss-request-url']);
return getProp($urlArr, 'path') ? 'http://' . $bucket . '.' . $endpoint . getProp($urlArr, 'path') : '';
}
/**
* @param $path
* @return Generator
*/
function readFileContent($path)
{
if ($handle = fopen($path, 'r')) {
while (!feof($handle)) {
yield trim(fgets($handle));
}
fclose($handle);
}
}
function exportFileCsv(array $headers, array $data, string $filename)
{
header("Content-type:application/vnd.ms-excel");
header("Content-Disposition:attachment;filename=" . $filename . ".csv");
$headers = collect($headers)->map(function ($item) {
return "\"" . mb_convert_encoding($item, "GBK", "UTF-8") . "\"";
})->all();
echo implode(",", $headers);
echo "\r\n";
foreach ($data as $item) {
$rows = collect($item)->map(function ($row) {
return "\"" . mb_convert_encoding(is_numeric($row) && strlen($row) > 12 ? "'" . $row : $row, "GBK", "UTF-8") . "\"";
})->all();
echo implode(",", $rows);
echo "\r\n";
}
exit();
}
//function exportFileCsv(array $header, array $data, string $filename) {
// header('Content-Encoding: UTF-8');
// header("Content-type:application/vnd.ms-excel;charset=UTF-8");
// header('Content-Disposition: attachment;filename="' . $filename . '.csv"');
//
// //打开php标准输出流
// $fp = fopen('php://output', 'a');
//
// //添加BOM头,以UTF8编码导出CSV文件,如果文件头未添加BOM头,打开会出现乱码。
// fwrite($fp, chr(0xEF).chr(0xBB).chr(0xBF));
// //添加导出标题
// fputcsv($fp, $header);
//
// foreach ($data as $k => $item) {
// fputcsv($fp, $item);
// if ($k % 5000 == 0) {
// //每1万条数据就刷新缓冲区
// ob_flush();
// flush();
// }
// }
// exit();
//}
//function exportFileCsv(array $header, array $data, string $filename) {
//
// header('Content-Type: application/vnd.ms-excel');
// header('Content-Disposition: attachment;filename="'.$filename.'.csv"');
// header('Cache-Control: max-age=0');
//
// //打开PHP文件句柄,php://output 表示直接输出到浏览器
// $fp = fopen('php://output', 'a');
//
// //输出Excel列名信息
// foreach ($header as $key => $value) {
// //CSV的Excel支持GBK编码,一定要转换,否则乱码
// $header[$key] = iconv('utf-8', 'gbk', $value);
// }
//
// //将数据通过fputcsv写到文件句柄
// fputcsv($fp, $header);
//
// //计数器
// $num = 0;
//
// //每隔$limit行,刷新一下输出buffer,不要太大,也不要太小
// $limit = 10000;
//
// //逐行取出数据,不浪费内存
// $count = count($data);
// for ($i = 0; $i < $count; $i++) {
//
// $num++;
//
// //刷新一下输出buffer,防止由于数据过多造成问题
// if ($limit == $num) {
// ob_flush();
// flush();
// $num = 0;
// }
//
// $row = $data[$i];
// foreach ($row as $key => $value) {
// $row[$key] = iconv('utf-8', 'gbk', $value);
// }
//
// fputcsv($fp, $row);
// }
// exit();
//}
// 获取除空格外的字数
function getSize(string $content)
{
$content = preg_replace('/\s+/', '', $content);
return mb_strlen($content, 'utf-8');
}
// 获取word字符数(不计空格)
function getChargeSize(string $content)
{
//判断是否存在替换字符
$is_replace_count = substr_count($content, "龘");
try {
//先将回车换行符做特殊处理
$str = preg_replace('/(\r\n+|\s+| +)/', "龘", $content);
//处理英文字符数字,连续字母、数字、英文符号视为一个单词
$str = preg_replace('/[a-z_A-Z0-9-\.!@#\$%\\\^&\*\)\(\+=\{\}\[\]\/",\'<>~`\?:;|]/', "m", $str);
//合并字符m,连续字母、数字、英文符号视为一个单词
$str = preg_replace('/m+/', "*", $str);
//去掉回车换行符
$str = preg_replace('/龘+/', "", $str);
//返回字数
return mb_strlen($str) + $is_replace_count;
} catch (\Exception $e) {
return 0;
}
}
// 将阿拉伯数字转换成中文
function chineseNum($figure, $capital = false, $mode = true)
{
if ($figure == '0') return '零';
$numberChar = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
$unitChar = ['', '十', '百', '千', '', '万', '亿', '兆', '京', '垓', '秭', '穣', '沟', '涧', '正', '载', '极', '恒河沙', '阿僧祇', '那由他', '不可思议', '无量大数'];
if ($capital !== false) {
$numberChar = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
$unitChar = ['', '拾', '佰', '仟', '', '万', '亿', '兆', '京', '垓', '秭', '穣', '沟', '涧', '正', '载', '极', '恒河沙', '阿僧祇', '那由他', '不可思议', '无量大数'];
}
$dec = "点";
$target = '';
$matches = [];
if ($mode) {
preg_match("/^0*(\d*)\.?(\d*)/", $figure, $matches);
} else {
preg_match("/(\d*)\.?(\d*)/", $figure, $matches);
}
list(, $number, $point) = $matches;
if ($point) {
$target = $dec . chineseNum($point, $capital, false);
}
if (!$number) {
return $target;
}
$str = strrev($number);
for ($i = 0; $i < strlen($str); $i++) {
$out[$i] = $numberChar[$str[$i]];
if ($mode === false) {
continue;
}
$out[$i] .= $str[$i] != '0' ? $unitChar[$i % 4] : '';
if ($i > 0 && $str[$i] + $str[$i - 1] == 0) {
$out[$i] = '';
}
if ($i % 4 == 0) {
$temp = substr($str, $i, 4);
$out[$i] = str_replace($numberChar[0], '', $out[$i]);
if (strrev($temp) > 0) {
$out[$i] .= $unitChar[4 + floor($i / 4)];
} else {
$out[$i] .= $numberChar[0];
}
}
}
$result = join('', array_reverse($out)) . $target;
return mb_substr($result, 0, 2) == '一十' ? mb_substr($result, 1) : $result;
}
function addPrefix($str)
{
if (!$str) return '';
if (mb_substr($str, 0, 4) == 'http') return $str;
if (mb_substr($str, 0, 5) == 'https') return $str;
if (mb_substr($str, 0, 7) == '/books/') return 'http://zwcontent.oss-cn-hangzhou.aliyuncs.com' . $str;
if (mb_substr($str, 0, 6) == '/card/') return 'http://zwcontent.oss-cn-hangzhou.aliyuncs.com' . $str;
if (mb_substr($str, 0, 15) == 'uploader/idcard') return 'http://zwcontent.oss-cn-hangzhou.aliyuncs.com/' . $str;
if (mb_substr($str, 0, 6) == '/cover') return 'https://cdn-newyc.ycsd.cn/ycsd_cover/covermiddle' . mb_substr($str, 6);
if (mb_substr($str, 0, 8) == '/images/') return 'https://cdn-newyc.ycsd.cn/ycsd_cover' . $str;
}
/**
* 章节内容排版
*
* @param $content
* @return string
*/
function filterContent($content)
{
if (!$content) return '';
$content = str_replace(
[' ', '
', '
', '
', ' ', '
', '
', '“', '”', '…', '‘', '’', '—'], [' ', PHP_EOL, PHP_EOL, PHP_EOL, ' ', '', PHP_EOL, '“', '”', '...', '‘', '’', '-'], $content); $content = preg_replace('/(\r\n)+/', PHP_EOL, $content); // 段落首字母前加两个中文空格 $string = explode(PHP_EOL, $content); foreach ($string as $line => $text) { $string[$line] = str_replace([' ', "\r\n", "\r", "\n", ' '], '', $string[$line]); if (!$string[$line]) { unset($string[$line]); } else { $string[$line] = $string[$line] . PHP_EOL; // if (mb_substr($string[$line], 0, 1) == ' ') { // $string[$line] = str_replace(' ', '', $string[$line]); // 去除多个空格 // $string[$line] = ' ' . $string[$line].PHP_EOL; // } // if (mb_substr($string[$line], 0, 2) != ' ') { // $string[$line] = ' ' . $string[$line].PHP_EOL; // } // if (mb_substr($string[$line], 0, 2) == ' ' && str_replace(' ', '', $string[$line])) { // $string[$line] .= PHP_EOL; // } } } $content = implode(PHP_EOL, $string); return $content; } /** * 书籍简介排版 * * @param $content * @return string */ function filterContent2($content) { if (!$content) return ''; $content = str_replace( [' ', '', '
', '“', '”', '…', '‘', '’', '—'], [' ', PHP_EOL, PHP_EOL, PHP_EOL, ' ', '', PHP_EOL, '“', '”', '...', '‘', '’', '-'], $content); $content = preg_replace('/(\r\n)+/', PHP_EOL, $content); // 段落首字母前加两个中文空格 $string = explode(PHP_EOL, $content); $content = ''; foreach ($string as $line => $text) { $string[$line] = str_replace([' ', "\r\n", "\r", "\n", ' ', '', '
', '“', '”', '…', '‘', '’', '—'], [' ', ' ', ' ', ' ', ' ', '', ' ', '“', '”', '...', '‘', '’', '-'], $content); $content = preg_replace('/(\r\n)+/', PHP_EOL, $content); // 段落首字母前加两个中文空格 $string = explode(PHP_EOL, $content); $content = ''; foreach ($string as $line => $text) { $string[$line] = str_replace([' ', "\r\n", "\r", "\n", ' ', '', '
', '“', '”', '…'], [' ', PHP_EOL, PHP_EOL, PHP_EOL, ' ', '', PHP_EOL, '“', '”', '...'], $content); $content = str_replace([" ", '“', '…', '”', ''], '', $content); // 段落首字母前加两个中文空格 $string = explode(PHP_EOL, $content); foreach ($string as $line => $text) { if (mb_substr($text, 0, 2) != ' ') $string[$line] = ' ' . $text; } $content = implode(PHP_EOL, $string); $content = mb_convert_encoding($content, 'UTF-8', 'UTF-8,GBK,GB2312'); $content = iconv('UTF-8', 'UTF-8//IGNORE', $content); return $content; } /** * 筛选出有效的id集合 * * @param array $ids * @return array */ function filterValidIds(array $ids): array { // 传参 if (empty($ids)) { return []; } $result = []; foreach ($ids as $id) { if (in_array($id, $result) || !is_numeric($id) || (int)$id < 1) { continue; } $result[] = (int)$id; } return $result; } function arrayToStr($map) { $isMap = isArrMap($map); $result = ""; if ($isMap) { $result = "map["; } $keyArr = array_keys($map); if ($isMap) { sort($keyArr); } $paramsArr = array(); foreach ($keyArr as $k) { $v = $map[$k]; if ($isMap) { if (is_array($v)) { $paramsArr[] = sprintf("%s:%s", $k, arrayToStr($v)); } else { $paramsArr[] = sprintf("%s:%s", $k, trim(strval($v))); } } else { if (is_array($v)) { $paramsArr[] = arrayToStr($v); } else { $paramsArr[] = trim(strval($v)); } } } $result = sprintf("%s%s", $result, join(" ", $paramsArr)); if (!$isMap) { $result = sprintf("[%s]", $result); } else { $result = sprintf("%s]", $result); } return $result; } function isArrMap($map) { foreach ($map as $k => $v) { if (is_string($k)) { return true; } } return false; } /** * 随机字符串 * * @param $length * @return string */ function makeRandStr($length): string { // 密码字符集,可任意添加你需要的字符 $str = [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' ]; // 在 $str 中随机取 $length 个数组元素键名 $keys = array_rand($str, $length); $password = ''; for ($i = 0; $i < $length; $i++) { // 将 $length 个数组元素连接成字符串 $password .= $str[$keys[$i]]; } return $password; } /** * 导出数据为excel表格 * * @param $data 一个二维数组,结构如同从数据库查出来的数组 * @param $title excel的第一行标题,一个数组,如果为空则没有标题 * @param $filename 下载的文件名 * @examlpe10 */ function exportExcel($data = [], $title = [], $filename = 'report') { ob_end_clean(); ob_start(); header("Content-type:application/octet-stream"); header("Accept-Ranges:bytes"); header("Content-type:application/vnd.ms-excel"); header("Content-Disposition:attachment;filename=" . $filename . ".xls"); header("Pragma: no-cache"); header("Expires: 0"); //导出xls 开始 if (!empty($title)) { foreach ($title as $k => $v) { $title[$k] = iconv("UTF-8", "GB2312", $v); } $title = implode("\t", $title); echo "$title\n"; } if (!empty($data)) { foreach ($data as $key => $val) { foreach ($val as $ck => $cv) { $data[$key][$ck] = iconv("UTF-8", "GB2312", $cv); } $data[$key] = implode("\t", $data[$key]); } echo implode("\n", $data); } } /** * 导出csv文件 * @param string $name * @param array $headers * @param array $data * @return void */ function exportCsv(string $name, array $headers, array $data = []) { header('Content-Description: File Transfer'); header('Content-Type: application/csv'); header("Content-Disposition: attachment; filename=".$name.".csv"); header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); $handle = fopen('php://output', 'w'); ob_clean(); fputcsv($handle, $headers); if ($data) { foreach ($data as $row) { fputcsv($handle, $row); } } ob_flush(); fclose($handle); die(); } // 树状分类 function buildCategoryTree($categories, $pid = 0) { $tree = []; foreach ($categories as $category) { if ($category['pid'] == $pid) { $children = buildCategoryTree($categories, $category['category_id']); if ($children) { $category['children'] = $children; } $tree[] = $category; } } return $tree; } // 获取文件的md5值 function getFileContentMD5($filePath){ //获取文件MD5的128位二进制数组 $md5Bytes = md5_file($filePath,true); //计算文件的Content-MD5 $contentMD5 = base64_encode($md5Bytes); return $contentMD5; } function getTextTokens($text) { // 方法1:按空格分词(西文较准) $words = preg_split('/\s+/', $text); $wordCount = count($words); // 方法2:按字符数估算(中文较准:1个汉字 ≈ 1.5-2 tokens) $charCount = mb_strlen($text); // 综合估算(根据语言调整权重) $tokenCount = $wordCount + $charCount * 0.5; // 示例公式 // 更简单:直接按字符数 * 系数(中文推荐) // $tokenCount = $charCount * 1.8; // 经验系数 return (int)ceil($tokenCount); } // 处理小说剧本文本 function handleScriptWords($text, $enable_emotion=1) { $text = preg_replace('/[\r\n]+/', PHP_EOL, $text); $text_arr = explode(PHP_EOL, $text); $roles = []; $words = []; $role_gender = []; // $sequence = 0; foreach ($text_arr as $line) { $line = trim($line); if ($enable_emotion) { $match_rule = '/^(.*?)\:(.*?)\{(.*?)\}$/'; $count = 4; } else { $match_rule = '/^(.*?)\:(.*?)$/'; $count = 3; } preg_match($match_rule, $line, $matches); if (count($matches) == $count) { $gender = '0'; // 角色部分拆分 preg_match('/^(.*?)\((.*?)\)$/', $matches[1], $matches2); if (count($matches2) == 3) { $role = $matches2[1]; $gender_arr = ['男'=>'1', '女'=>'2']; $gender = isset($gender_arr[$matches2[2]]) ? $gender_arr[$matches2[2]] : '0'; }else { $role = $matches[1]; } if (!in_array($role, $roles)) { $roles[] = $role; // 记录角色 $role_gender[$role] = $gender; } $words[] = [ 'role' => $role, 'gender' => $gender, 'text' => $matches[2], 'emotion' => $enable_emotion ? $matches[3] : '中性', ]; } } $new_words = []; $tmp = ''; $tmp_arr = []; $tmp_text = ''; // 将words数组按照role和emotion合并相邻的text内容,不相邻则跳过合并 foreach ($words as $word) { if(!$tmp) $tmp = $word['role'].'-'.$word['emotion']; if($tmp == $word['role'].'-'.$word['emotion']) { $tmp_text .= PHP_EOL.$word['text']; $tmp_arr = [ 'role' => $word['role'], 'gender' => $word['gender'], 'text' => trim($tmp_text, PHP_EOL), 'emotion' => $word['emotion'], ]; }else { // $sequence++; // $tmp_arr['sequence'] = $sequence; $new_words[] = $tmp_arr; $tmp = $word['role'].'-'.$word['emotion']; $tmp_text = $word['text']; $tmp_arr = [ 'role' => $word['role'], 'gender' => $word['gender'], 'text' => trim($tmp_text, PHP_EOL), 'emotion' => $word['emotion'], ]; } } if ($tmp_arr) { // $sequence++; // $tmp_arr['sequence'] = $sequence; $new_words[] = $tmp_arr; } return [ 'roles' => $roles, 'role_gender' => $role_gender, 'words' => $new_words, ]; } function extractScriptContent($originalContent) { if (!$originalContent) return []; // 使用更精确的正则表达式分割内容 $parts = []; // 提取剧本名(###剧本名:后面的内容,支持多个空格) preg_match('/###\s*剧本名\s*[::]\s*(.*?)(?=\n|$)/u', $originalContent, $scriptNameMatch); $parts['script_name'] = isset($scriptNameMatch[1]) ? trim($scriptNameMatch[1]) : ''; // 提取故事梗概(直到遇到下一个###标记,支持多个空格) preg_match('/###\s*故事梗概\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $summaryMatch); $parts['intro'] = isset($summaryMatch[1]) ? trim($summaryMatch[1]) : ''; // 提取剧本亮点(支持多个空格) preg_match('/###\s*剧本亮点\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $highlightsMatch); $parts['highlights'] = isset($highlightsMatch[1]) ? trim($highlightsMatch[1]) : ''; // 提取人物关系(支持多个空格) preg_match('/###\s*人物关系\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $relationsMatch); $parts['role_relationship'] = isset($relationsMatch[1]) ? trim($relationsMatch[1]) : ''; // 提取核心矛盾(支持多个空格) preg_match('/###\s*核心矛盾\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $contradictionsMatch); $parts['core_contradiction'] = isset($contradictionsMatch[1]) ? trim($contradictionsMatch[1]) : ''; // 提取主体列表(支持多个空格) preg_match('/###\s*主体列表\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $subjectsMatch); $rolesText = isset($subjectsMatch[1]) ? trim($subjectsMatch[1]) : ''; $parts['roles'] = parseRolesFromText($rolesText); // 提取美术风格(支持多个空格) preg_match('/###\s*美术风格\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $artStyleMatch); $parts['art_style'] = isset($artStyleMatch[1]) ? trim($artStyleMatch[1]) : ''; // 提取场景列表(支持多个空格) preg_match('/###\s*场景列表\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $scenesMatch); $scenesText = isset($scenesMatch[1]) ? trim($scenesMatch[1]) : ''; $parts['scenes'] = parseScenesFromText($scenesText); // 提取分集详细内容(多剧集模式,支持多个空格) preg_match('/###\s*分集剧本\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $contentMatch); $detailedContent = isset($contentMatch[1]) ? trim($contentMatch[1]) : ''; // 提取原文内容(单剧集模式,AI生成的原文,支持多个空格) preg_match('/###\s*原文内容\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $originalContentMatch); $generatedContent = isset($originalContentMatch[1]) ? trim($originalContentMatch[1]) : ''; // 优先使用原文内容,其次使用分集详细内容 // $parts['content'] = $generatedContent ?: $detailedContent; // 解析分集剧本内容 $episodes = []; if ($detailedContent) { // 按 ##分集 分割 preg_match_all('/##\s*分集(\d+)\s*\n(.*?)(?=\n\s*##分集|\z)/s', $detailedContent, $episodeMatches, PREG_SET_ORDER); foreach ($episodeMatches as $episodeMatch) { $episodeNum = $episodeMatch[1]; $episodeContent = trim($episodeMatch[2]); $episode = [ 'episode_number' => $episodeNum, 'episode_content' => $episodeContent, 'episode_name' => '', 'scene_description' => '', 'camera_movement' => '', 'characters' => '', 'dialogues' => [] ]; // 提取分集名(格式:##分集01第一集: 第一集的标题) if (preg_match('/^第.*?集\s*[::]\s*(.+?)(?=\n|$)/u', $episodeContent, $nameMatch)) { $episode['episode_name'] = trim($nameMatch[1]); } // 提取场景描述 if (preg_match('/场景描述\s*[::]\s*(.+?)(?=\n|$)/u', $episodeContent, $sceneMatch)) { $episode['scene_description'] = trim($sceneMatch[1]); } // 提取运镜 if (preg_match('/运镜\s*[::]\s*(.+?)(?=\n|$)/u', $episodeContent, $cameraMatch)) { $episode['camera_movement'] = trim($cameraMatch[1]); } // 提取出场角色 if (preg_match('/出场角色\s*[::]\s*(.+?)(?=\n|$)/u', $episodeContent, $charactersMatch)) { $episode['characters'] = trim($charactersMatch[1]); } // 提取台词内容 if (preg_match('/台词内容\s*[::]\s*\n(.*?)$/su', $episodeContent, $dialoguesMatch)) { $dialoguesText = trim($dialoguesMatch[1]); $dialogueLines = explode("\n", $dialoguesText); foreach ($dialogueLines as $line) { $line = trim($line); if (empty($line)) { continue; } // 匹配格式:角色名: 对话内容 或 角色名:对话内容 if (preg_match('/^(.+?)\s*[::]\s*(.+)$/u', $line, $dialogueMatch)) { $episode['dialogues'][] = [ 'character' => trim($dialogueMatch[1]), 'text' => trim($dialogueMatch[2]) ]; } } } $episodes[] = $episode; } } $parts['episodes'] = $episodes; return $parts; } /** * 处理剧本内容 */ function handleScriptContent($originalContent) { if (!$originalContent) return []; // 使用更精确的正则表达式分割内容 $parts = []; // // 确保内容使用UTF-8编码 // if (!mb_check_encoding($originalContent, 'UTF-8')) { // $originalContent = mb_convert_encoding($originalContent, 'UTF-8', 'auto'); // } // 提取剧本名(###剧本名:后面的内容,支持多个空格) preg_match('/###\s*剧本名\s*[::]\s*(.*?)(?=\n|$)/u', $originalContent, $scriptNameMatch); $parts['script_name'] = isset($scriptNameMatch[1]) ? trim($scriptNameMatch[1]) : ''; // 提取故事梗概(直到遇到下一个###标记,支持多个空格) preg_match('/###\s*故事梗概\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $summaryMatch); $parts['intro'] = isset($summaryMatch[1]) ? trim($summaryMatch[1]) : ''; // 提取剧本亮点(支持多个空格) preg_match('/###\s*剧本亮点\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $highlightsMatch); $parts['highlights'] = isset($highlightsMatch[1]) ? trim($highlightsMatch[1]) : ''; // 提取人物关系(支持多个空格) preg_match('/###\s*人物关系\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $relationsMatch); $parts['role_relationship'] = isset($relationsMatch[1]) ? trim($relationsMatch[1]) : ''; // 提取核心矛盾(支持多个空格) preg_match('/###\s*核心矛盾\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $contradictionsMatch); $parts['core_contradiction'] = isset($contradictionsMatch[1]) ? trim($contradictionsMatch[1]) : ''; // 提取主体列表(支持多个空格) preg_match('/###\s*主体列表\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $subjectsMatch); $rolesText = isset($subjectsMatch[1]) ? trim($subjectsMatch[1]) : ''; $parts['roles'] = parseRolesFromText($rolesText); // 提取美术风格(支持多个空格) preg_match('/###\s*美术风格\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $artStyleMatch); $parts['art_style'] = isset($artStyleMatch[1]) ? trim($artStyleMatch[1]) : ''; // 提取场景列表(支持多个空格) preg_match('/###\s*场景列表\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $scenesMatch); $scenesText = isset($scenesMatch[1]) ? trim($scenesMatch[1]) : ''; $parts['scenes'] = parseScenesFromText($scenesText); // 提取分集详细内容(多剧集模式,支持多个空格) preg_match('/###\s*分集详细内容\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $contentMatch); $detailedContent = isset($contentMatch[1]) ? trim($contentMatch[1]) : ''; // 提取原文内容(单剧集模式,AI生成的原文,支持多个空格) preg_match('/###\s*原文内容\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $originalContentMatch); $generatedContent = isset($originalContentMatch[1]) ? trim($originalContentMatch[1]) : ''; // 优先使用原文内容,其次使用分集详细内容 $parts['content'] = $generatedContent ?: $detailedContent; $parts['episode_title'] = ''; $parts['acts'] = []; // 单剧集格式:顶层是大纲信息,分镜部分采用 handleEpisodeContent 的结构标准 $singleStoryboard = ''; if (preg_match('/###\s*分镜剧本\s*\n(.*?)(?=\n\s*###[^#]|\z)/su', $originalContent, $singleStoryboardMatch)) { $singleStoryboard = trim($singleStoryboardMatch[1]); } if ($singleStoryboard !== '') { $episodeContent = $singleStoryboard; if (!preg_match('/第\d+集[::\s]+/u', $episodeContent)) { $defaultEpisodeTitle = '第1集:' . ($parts['script_name'] ?? '未命名'); $episodeContent = $defaultEpisodeTitle . "\n\n" . $episodeContent; } $episodeSections = []; $episodeSections[] = $episodeContent; if ($parts['intro'] !== '') { $episodeSections[] = "###故事梗概\n" . $parts['intro']; } if ($parts['art_style'] !== '') { $episodeSections[] = "###美术风格\n" . $parts['art_style']; } if ($rolesText !== '') { $episodeSections[] = "###主体列表\n" . $rolesText; } if ($scenesText !== '') { $episodeSections[] = "###场景列表\n" . $scenesText; } if (strpos($episodeContent, '###分镜剧本') === false) { $episodeSections[] = "###分镜剧本\n" . $singleStoryboard; } $episode_arr = handleEpisodeContent(implode("\n\n", $episodeSections)); if (!empty($episode_arr['acts'])) { $parts['episode_title'] = getProp($episode_arr, 'episode_title'); $parts['acts'] = getProp($episode_arr, 'acts', []); if (empty($parts['roles'])) { $parts['roles'] = getProp($episode_arr, 'roles', []); } if (empty($parts['scenes'])) { $parts['scenes'] = getProp($episode_arr, 'scenes', []); } } } if (empty($parts['acts'])) { $fallbackEpisodeArr = handleEpisodeContent($originalContent); if (!empty($fallbackEpisodeArr['acts'])) { $parts['episode_title'] = getProp($fallbackEpisodeArr, 'episode_title'); $parts['acts'] = getProp($fallbackEpisodeArr, 'acts', []); if (empty($parts['intro'])) { $parts['intro'] = getProp($fallbackEpisodeArr, 'intro'); } if (empty($parts['art_style'])) { $parts['art_style'] = getProp($fallbackEpisodeArr, 'art_style'); } if (empty($parts['roles'])) { $parts['roles'] = getProp($fallbackEpisodeArr, 'roles', []); } if (empty($parts['scenes'])) { $parts['scenes'] = getProp($fallbackEpisodeArr, 'scenes', []); } } } // 多剧集格式:继续兼容旧的分集剧本结构 $fullScript = ''; if (preg_match('/###分集剧本\s*\n(.*)/su', $originalContent, $storyboardMatch)) { $fullScript = trim($storyboardMatch[1]); } elseif (preg_match('/##分集剧本\s*\n(.*)/su', $originalContent, $storyboardMatch)) { $fullScript = trim($storyboardMatch[1]); } elseif (preg_match('/分集剧本[::]\s*\n(.*)/su', $originalContent, $storyboardMatch)) { $fullScript = trim($storyboardMatch[1]); } if (empty($fullScript) && preg_match('/(##分集.*)/su', $originalContent, $fallbackMatch)) { $fullScript = trim($fallbackMatch[1]); } $episodes = []; preg_match_all('/##分集(\d+)\s*\n分集名[::]\s*(.*?)\s*\n(?:场景描述[::].*?\n出场角色[::].*?\n台词内容[::]\s*\n)?(.*?)(?=\n##分集|$)/su', $fullScript, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $episodeNumber = (int)trim($match[1]); $episodeName = trim($match[2]); $episodeContent = trim($match[3]); $segments = []; preg_match_all('/分镜(\d+)\s*\n(.*?)(?=\n分镜\d+|\z)/s', $episodeContent, $segmentMatches, PREG_SET_ORDER); foreach ($segmentMatches as $segMatch) { $segmentNumber = (int)trim($segMatch[1]); $segmentContent = trim($segMatch[2]); $segments[] = [ 'segment_number' => $segmentNumber, 'segment_content' => $segmentContent, ]; } $episodes[] = [ 'episode_number' => $episodeNumber, 'title' => $episodeName, 'content' => $episodeContent, 'segments' => $segments, ]; } if (empty($episodes) && !empty($parts['acts'])) { $singleSegments = []; foreach ($parts['acts'] as $act) { $actSegments = getProp($act, 'segments', []); if (!is_array($actSegments)) { continue; } foreach ($actSegments as $segment) { $singleSegments[] = [ 'segment_number' => getProp($segment, 'segment_number'), 'segment_content' => getProp($segment, 'segment_content'), ]; } } $episodeTitle = $parts['episode_title'] ?: ('第1集:' . ($parts['script_name'] ?? '未命名')); $episodeName = preg_replace('/^第\d+集[::]\s*/u', '', $episodeTitle); $episodes[] = [ 'episode_number' => 1, 'title' => $episodeName, 'content' => $singleStoryboard, 'segments' => $singleSegments, ]; } $parts['episodes'] = $episodes; return $parts; } function handleEpisodeContent($originalContent) { if (!$originalContent) return []; // 解析剧集内容 $result = [ 'episode_title' => '', 'intro' => '', 'art_style' => '', 'roles' => [], 'scenes' => [], 'acts' => [] ]; // 提取剧集标题 - 匹配"第xx集:标题"或"第xx集 标题"格式,排除###标记 if (preg_match('/第(\d+)集[::\s]+([^#\n]+?)(?=\s*###|\s*$|\n)/u', $originalContent, $titleMatch)) { $result['episode_title'] = '第' . $titleMatch[1] . '集:' . trim($titleMatch[2]); } // 提取故事梗概 - 兼容多种格式:###故事梗概、### 故事梗概、### 故事梗概 if (preg_match('/###\s*故事梗概\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $summaryMatch)) { $result['intro'] = trim($summaryMatch[1]); } // 提取美术风格 - 兼容多种格式 if (preg_match('/###\s*美术风格\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $styleMatch)) { $result['art_style'] = trim($styleMatch[1]); } // 提取主体列表 - 兼容多种格式 if (preg_match('/###\s*主体列表\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $charactersMatch)) { $charactersText = trim($charactersMatch[1]); $characterLines = explode("\n", $charactersText); foreach ($characterLines as $line) { $line = trim($line); if (empty($line)) continue; // 兼容中文冒号:和英文冒号:,同时提取音色信息 if (preg_match('/^([^::]+)[::](.+)$/u', $line, $charMatch)) { $role = trim($charMatch[1]); $description = trim($charMatch[2]); $timbreName = null; // 检查描述末尾是否有{主体图片提示词}{{音色名}}格式 $picPrompt = null; if (preg_match('/^(.*?)\{([^}]+)\}\{\{([^}]+)\}\}\s*$/u', $description, $fullMatch)) { // 匹配格式:主体描述{主体图片提示词}{{音色名}} $description = trim($fullMatch[1]); $picPrompt = trim($fullMatch[2]); $timbreName = trim($fullMatch[3]); } elseif (preg_match('/^(.*?)\{\{([^}]+)\}\}\s*$/u', $description, $timbreMatch)) { // 兼容旧格式:主体描述{{音色名}} $description = trim($timbreMatch[1]); $timbreName = trim($timbreMatch[2]); } $roleData = [ 'role' => $role, 'description' => $description ]; // 如果有主体图片提示词,添加到数组中 if ($picPrompt) { // 检查开头是否有"全景,正面拍摄。" $prefix = '全景,正面拍摄。'; $prefixParts = ['全景', '正面拍摄']; // 检查是否包含完整前缀 if (strpos($picPrompt, $prefix) !== 0) { // 检查是否包含部分前缀词汇(不论位置) $hasAnyPrefix = false; foreach ($prefixParts as $part) { if (mb_strpos($picPrompt, $part) !== false) { $hasAnyPrefix = true; break; } } if ($hasAnyPrefix) { // 移除所有"全景"和"正面拍摄"词汇(包括它们后面的标点) $picPrompt = preg_replace('/全景[,,、。. ]*/u', '', $picPrompt); $picPrompt = preg_replace('/正面拍摄[,,、。. ]*/u', '', $picPrompt); // 清理开头和结尾的标点符号和空格 $picPrompt = preg_replace('/^[。,,、 ]+|[。,,、 ]+$/u', '', $picPrompt); } // 在开头添加完整前缀 $picPrompt = $prefix . $picPrompt; } // 检查是否包含"姿态:"或"姿态:" if (preg_match('/姿态[::]/u', $picPrompt)) { // 如果有姿态描述,统一替换为"姿态:站立" $picPrompt = preg_replace('/姿态[::][^.。\n]+/u', '姿态:站立', $picPrompt); } else { // 如果没有姿态描述,在末尾添加"姿态:站立" $picPrompt = preg_replace('/[。,, ]+$/u', '', $picPrompt) . '。姿态:站立。'; } $roleData['pic_prompt'] = $picPrompt; } // 如果有音色信息,添加到数组中 if ($timbreName) { $timbre = DB::table('mp_timbres')->where('is_enabled', 1)->where('timbre_name', 'like', "%{$timbreName}%")->orderBy('id')->select('timbre_type', 'audio_url')->first(); if ($timbre) { $roleData['voice_name'] = $timbreName; $roleData['voice_type'] = getProp($timbre, 'timbre_type'); $roleData['voice_audio_url'] = getProp($timbre, 'audio_url');; } }else { // 从描述或人物提示词中获取"男"或"女",赋予默认音色,获取不到则使用旁白音色 if ($picPrompt) { if (strstr($picPrompt, '男')) { $roleData['voice_name'] = '阳光青年'; }elseif (strstr($picPrompt, '女')) { $roleData['voice_name'] = '爽快思思'; }else { if ($description) { if (strstr($description, '男')) { $roleData['voice_name'] = '阳光青年'; }elseif (strstr($description, '女')) { $roleData['voice_name'] = '爽快思思'; }else { $roleData['voice_name'] = '旁白'; } } } } if (!empty($roleData['voice_name'])) { if ($roleData['voice_name'] == '旁白') { $roleData['voice_type'] = 'zh_male_linjiananhai_moon_bigtts'; $roleData['voice_audio_url'] = 'https://zw-audiobook.tos-cn-beijing.volces.com/demonstrate/zh_male_linjiananhai_moon_bigtts.wav'; }else { $timbre = DB::table('mp_timbres') ->where('is_enabled', 1) ->where('timbre_name', 'like', "%".$roleData['voice_name']."%") ->orderBy('id') ->select('timbre_type', 'audio_url') ->first(); if ($timbre) { $roleData['voice_type'] = getProp($timbre, 'timbre_type'); $roleData['voice_audio_url'] = getProp($timbre, 'audio_url'); } } } } $result['roles'][] = $roleData; } } // 加入旁白角色(如果不存在) $hasNarrator = false; foreach ($result['roles'] as $role) { if (isset($role['role']) && $role['role'] === '旁白') { $hasNarrator = true; break; } } if (!$hasNarrator) { $result['roles'][] = [ 'role' => '旁白', 'description' => '负责叙述剧情、补充说明和情感渲染的非视觉角色。', 'pic_prompt' => '', 'voice_name' => '旁白', 'voice_type' => 'zh_male_linjiananhai_moon_bigtts', 'voice_audio_url' => 'https://zw-audiobook.tos-cn-beijing.volces.com/demonstrate/zh_male_linjiananhai_moon_bigtts.wav' ]; } } // 提取场景列表 - 兼容多种格式 if (preg_match('/###\s*场景列表\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $scenesMatch)) { $scenesText = trim($scenesMatch[1]); $sceneLines = explode("\n", $scenesText); foreach ($sceneLines as $line) { $line = trim($line); if (empty($line)) continue; // 兼容中文冒号:和英文冒号: if (preg_match('/^([^::]+)[::](.+)$/u', $line, $sceneMatch)) { $scene = trim($sceneMatch[1]); $description = trim($sceneMatch[2]); $picPrompt = null; // 检查描述末尾是否有{场景图片提示词}格式 if (preg_match('/^(.*?)\{([^}]+)\}\s*$/u', $description, $promptMatch)) { // 匹配格式:场景描述{场景图片提示词} $description = trim($promptMatch[1]); $picPrompt = trim($promptMatch[2]); } $sceneData = [ 'scene' => $scene, 'description' => $description ]; // 如果有场景图片提示词,添加到数组中 if ($picPrompt) { $sceneData['pic_prompt'] = $picPrompt; } $result['scenes'][] = $sceneData; } } } // 提取分镜剧本 - 兼容多种格式 if (preg_match('/###\s*分镜剧本\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $storyboardMatch)) { $storyboardText = trim($storyboardMatch[1]); // 按幕分割 - 修复第1幕识别和乱码问题 $acts = []; // 先在开头添加换行符,确保第1幕也能被正确分割 $normalizedText = "\n" . $storyboardText; $parts = preg_split('/\n\s*##/', $normalizedText); foreach ($parts as $part) { $part = trim($part); if (empty($part)) continue; // 如果不是以"第"开头,跳过 if (!preg_match('/^第\d+幕/', $part)) { continue; } // 分离标题和内容 $lines = explode("\n", $part, 2); $actTitle = trim($lines[0]); $actContent = isset($lines[1]) ? trim($lines[1]) : ''; // 解析幕标题,提取序号和详细信息 - 修复乱码问题 if (preg_match('/^第(\d+)幕[::]?\s*(.*)$/u', $actTitle, $actTitleMatch)) { $actNumber = intval($actTitleMatch[1]); $actDetails = trim($actTitleMatch[2]); // 如果详细信息为空或者就是冒号,使用完整标题 if (empty($actDetails) || $actDetails === ':' || $actDetails === ':') { $actDetails = $actTitle; } } else { $actNumber = count($acts) + 1; $actDetails = $actTitle; } // 解析该幕下的分镜 $segments = []; $segmentPattern = '/分镜(\d+)\s*\n(.*?)(?=\n+\s*分镜\d+|\z)/s'; preg_match_all($segmentPattern, $actContent, $segmentMatches, PREG_SET_ORDER); foreach ($segmentMatches as $segmentMatch) { $segmentNumber = intval($segmentMatch[1]); $segmentContent = trim($segmentMatch[2]); // 解析分镜详细信息 $segmentData = [ 'segment_id' => date('YmdHis') . mt_rand(1000, 9999) . str_pad($segmentNumber, 3, "0", STR_PAD_LEFT), // 生成唯一ID(后续可在生成任务里查看历史版本) 'segment_number' => $segmentNumber, 'segment_content' => $segmentContent, 'description' => '', 'composition' => '', 'camera_movement' => '', 'voice_actor' => '', 'dialogue' => '', 'frame_type' => '', 'scene' => '', // 新增:场景 'characters' => '', // 新增:出镜角色 'tail_frame' => '', // 新增:尾帧描述 // 新增字段 'emotion' => '中性', // 新增: 情感 'gender' => '0', // 新增: 性别(0未知,1男,2女) 'speed_ratio' => 0, // 新增: 语速 'loudness_ratio' => 0, // 新增: 音量 'emotion_scale' => 4, // 新增: 语调 'pitch' => 0, // 新增: 音调 ]; // 提取各个字段 - 兼容中文冒号和英文冒号,支持多种表达方式 if (preg_match('/(?:画面描述|镜头描述|场景描述)[::]\s*([^\n]+)/u', $segmentContent, $descMatch)) { $segmentData['description'] = trim($descMatch[1]); } if (preg_match('/(?:构图设计|构图|镜头构图)[::]\s*([^\n]+)/u', $segmentContent, $compMatch)) { $segmentData['composition'] = trim($compMatch[1]); } if (preg_match('/(?:运镜调度|运镜|镜头运动|摄影机运动)[::]\s*([^\n]+)/u', $segmentContent, $cameraMatch)) { $segmentData['camera_movement'] = trim($cameraMatch[1]); } if (preg_match('/(?:配音角色|配音|角色|声优)[::]\s*([^\n]+)/u', $segmentContent, $voiceMatch)) { $segmentData['voice_actor'] = trim($voiceMatch[1]); } if (preg_match('/(?:台词内容|台词|对白|对话)[::]\s*([^\n]+)/u', $segmentContent, $dialogueMatch)) { $dialogue = trim($dialogueMatch[1]) == '无' ? '' : trim($dialogueMatch[1]); // 确保台词使用中文左右双引号 if (!empty($dialogue)) { $dialogue = preg_replace('/^["”]/u', '“', $dialogue); // 替换句首英文双引号或中文右双引号 $dialogue = preg_replace('/["“]$/u', '”', $dialogue); // 替换句尾英文双引号或中文左双引号 // 句首或句尾没有中文双引号,则分别添加中文左右双引号 if (!preg_match('/^[“]/', $dialogue)) { $dialogue = '“' . $dialogue; } if (!preg_match('/[”]$/', $dialogue)) { $dialogue .= '”'; } // 将修改后的台词替换回 $segmentContent $originalDialogue = $dialogueMatch[1]; $segmentContent = str_replace($originalDialogue, $dialogue, $segmentContent); } $segmentData['dialogue'] = $dialogue; } if (preg_match('/(?:画面类型|镜头类型|类型)[::]\s*([^\n]+)/u', $segmentContent, $frameMatch)) { $segmentData['frame_type'] = trim($frameMatch[1]); } // 新增:场景字段 if (preg_match('/(?:场景|拍摄场景|背景场景|环境)[::]\s*([^\n]+)/u', $segmentContent, $sceneMatch)) { $segmentData['scene'] = trim($sceneMatch[1]); } // 新增:出镜角色字段 if (preg_match('/(?:出镜角色|角色出镜|登场角色|人物)[::]\s*([^\n]+)/u', $segmentContent, $charactersMatch)) { $segmentData['characters'] = trim($charactersMatch[1]); } // 新增:尾帧描述字段 if (preg_match('/(?:尾帧描述|尾帧|结束帧|最后一帧|结尾画面|结束画面)[::]\s*([^\n]+)/u', $segmentContent, $tailFrameMatch)) { $segmentData['tail_frame'] = trim($tailFrameMatch[1]); } $replaceEmptyArr = []; // 新增:情感字段 if (preg_match('/(?:情感|情绪|感情)[::]\s*([^\n]+)/u', $segmentContent, $emotionMatch)) { $replaceEmptyArr[] = trim($emotionMatch[0]); $segmentData['emotion'] = trim($emotionMatch[1]); } // 新增:性别字段 if (preg_match('/(?:性别)[::]\s*([^\n]+)/u', $segmentContent, $genderMatch)) { $replaceEmptyArr[] = trim($genderMatch[0]); $genderStr = trim($genderMatch[1]); if (strpos($genderStr, '男') !== false || $genderStr === '1') { $segmentData['gender'] = '1'; } elseif (strpos($genderStr, '女') !== false || $genderStr === '2') { $segmentData['gender'] = '2'; } else { $segmentData['gender'] = '0'; } } // 新增:语速字段 if (preg_match('/(?:语速|说话速度)[::]\s*([-+]?[0-9]*\.?[0-9]+)/u', $segmentContent, $speedMatch)) { $replaceEmptyArr[] = trim($speedMatch[0]); $segmentData['speed_ratio'] = (float)trim($speedMatch[1]); } // 新增:音量字段 if (preg_match('/(?:音量|声音大小)[::]\s*([-+]?[0-9]*\.?[0-9]+)/u', $segmentContent, $loudnessMatch)) { $replaceEmptyArr[] = trim($loudnessMatch[0]); $segmentData['loudness_ratio'] = (float)trim($loudnessMatch[1]); } // 新增:情感强度字段 if (preg_match('/(?:情感强度|情绪强度)[::]\s*([0-9]+)/u', $segmentContent, $scaleMatch)) { $replaceEmptyArr[] = trim($scaleMatch[0]); $segmentData['emotion_scale'] = (int)trim($scaleMatch[1]); } // 新增:音调字段 if (preg_match('/(?:音调|音高)[::]\s*([-+]?[0-9]+)/u', $segmentContent, $pitchMatch)) { $replaceEmptyArr[] = trim($pitchMatch[0]); $segmentData['pitch'] = (int)trim($pitchMatch[1]); } $segmentData['segment_content'] = str_replace($replaceEmptyArr, '', $segmentContent); // 去除多余的换行符(将连续多个换行符替换为单个换行符) $segmentData['segment_content'] = preg_replace('/\n{2,}/', "\n", $segmentData['segment_content']); $segmentData['segment_content'] = trim($segmentData['segment_content']); $segments[] = $segmentData; } $acts[] = [ 'act_number' => $actNumber, 'act_title' => $actTitle, 'act_details' => $actDetails, 'segments' => $segments ]; } $result['acts'] = $acts; } return $result; } /** * 处理剧本内容(全能模式) */ function handleScriptContentForAce($originalContent) { if (!$originalContent) return []; // 使用更精确的正则表达式分割内容 $parts = []; // // 确保内容使用UTF-8编码 // if (!mb_check_encoding($originalContent, 'UTF-8')) { // $originalContent = mb_convert_encoding($originalContent, 'UTF-8', 'auto'); // } // 提取剧本名(###剧本名:后面的内容,支持多个空格) preg_match('/###\s*剧本名\s*[::]\s*(.*?)(?=\n|$)/u', $originalContent, $scriptNameMatch); $parts['script_name'] = isset($scriptNameMatch[1]) ? trim($scriptNameMatch[1]) : ''; // 提取故事梗概(直到遇到下一个###标记,支持多个空格) preg_match('/###\s*故事梗概\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $summaryMatch); $parts['intro'] = isset($summaryMatch[1]) ? trim($summaryMatch[1]) : ''; // 提取剧本亮点(支持多个空格) preg_match('/###\s*剧本亮点\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $highlightsMatch); $parts['highlights'] = isset($highlightsMatch[1]) ? trim($highlightsMatch[1]) : ''; // 提取人物关系(支持多个空格) preg_match('/###\s*人物关系\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $relationsMatch); $parts['role_relationship'] = isset($relationsMatch[1]) ? trim($relationsMatch[1]) : ''; // 提取核心矛盾(支持多个空格) preg_match('/###\s*核心矛盾\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $contradictionsMatch); $parts['core_contradiction'] = isset($contradictionsMatch[1]) ? trim($contradictionsMatch[1]) : ''; // 提取主体列表(支持多个空格) preg_match('/###\s*主体列表\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $subjectsMatch); $rolesText = isset($subjectsMatch[1]) ? trim($subjectsMatch[1]) : ''; $parts['roles'] = parseRolesFromTextForAce($rolesText); // 提取美术风格(支持多个空格) preg_match('/###\s*美术风格\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $artStyleMatch); $parts['art_style'] = isset($artStyleMatch[1]) ? trim($artStyleMatch[1]) : ''; // 提取场景列表(支持多个空格) preg_match('/###\s*场景列表\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $scenesMatch); $scenesText = isset($scenesMatch[1]) ? trim($scenesMatch[1]) : ''; $parts['scenes'] = parseScenesFromText($scenesText); // 提取分集详细内容(多剧集模式,支持多个空格) preg_match('/###\s*分集详细内容\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $contentMatch); $detailedContent = isset($contentMatch[1]) ? trim($contentMatch[1]) : ''; // 提取原文内容(单剧集模式,AI生成的原文,支持多个空格) preg_match('/###\s*原文内容\s*\n(.*?)(?=\n\s*###|$)/s', $originalContent, $originalContentMatch); $generatedContent = isset($originalContentMatch[1]) ? trim($originalContentMatch[1]) : ''; // 优先使用原文内容,其次使用分集详细内容 $parts['content'] = $generatedContent ?: $detailedContent; $parts['episode_title'] = ''; $parts['acts'] = []; // 单剧集格式:顶层是大纲信息,分段部分采用 handleEpisodeContentForAce 的结构标准 $singleStoryboard = ''; if (preg_match('/###\s*分镜剧本\s*\n(.*?)(?=\n\s*###[^#]|\z)/su', $originalContent, $singleStoryboardMatch)) { $singleStoryboard = trim($singleStoryboardMatch[1]); } elseif (preg_match('/###\s*分段剧本\s*\n(.*?)(?=\n\s*###[^#]|\z)/su', $originalContent, $singleStoryboardMatch)) { $singleStoryboard = trim($singleStoryboardMatch[1]); } if ($singleStoryboard !== '') { // 构造符合 handleEpisodeContentForAce 期望的输入格式 $episodeContent = ''; // 如果分镜剧本内容不包含剧集标题,添加默认标题 if (!preg_match('/第\d+集[::\s]+/u', $singleStoryboard)) { $defaultEpisodeTitle = '第1集:' . ($parts['script_name'] ?? '未命名'); $episodeContent = $defaultEpisodeTitle . "\n\n"; } // 组装完整的剧集内容,按 handleEpisodeContentForAce 期望的结构 $episodeSections = []; // 添加故事梗概(如果有) if (!empty($parts['intro'])) { $episodeSections[] = "###故事梗概\n" . $parts['intro']; } // 添加美术风格(如果有) if (!empty($parts['art_style'])) { $episodeSections[] = "###美术风格\n" . $parts['art_style']; } // 添加主体列表(如果有) if (!empty($rolesText)) { $episodeSections[] = "###主体列表\n" . $rolesText; } // 添加场景列表(如果有) if (!empty($scenesText)) { $episodeSections[] = "###场景列表\n" . $scenesText; } // 添加分镜剧本内容(兼容"分镜剧本"和"分段剧本") if (strpos($singleStoryboard, '###分镜剧本') === false && strpos($singleStoryboard, '###分段剧本') === false) { $episodeSections[] = "###分镜剧本\n" . $singleStoryboard; } else { $episodeSections[] = $singleStoryboard; } // 拼接完整内容 $episodeContent .= implode("\n\n", $episodeSections); // 调用 handleEpisodeContentForAce 方法解析 $episode_arr = handleEpisodeContentForAce($episodeContent); if (!empty($episode_arr['acts'])) { $parts['episode_title'] = getProp($episode_arr, 'episode_title', ''); $parts['acts'] = getProp($episode_arr, 'acts', []); } // 更新roles和scenes $episode_roles = getProp($episode_arr, 'roles'); $episode_scenes = getProp($episode_arr, 'scenes'); if ($episode_roles) $parts['roles'] = $episode_roles; if ($episode_scenes) $parts['scenes'] = $episode_scenes; } // 如果上面的处理没有得到 acts,尝试直接用原始内容调用 handleEpisodeContentForAce if (empty($parts['acts'])) { $fallbackEpisodeArr = handleEpisodeContentForAce($originalContent); if (!empty($fallbackEpisodeArr['acts'])) { $parts['episode_title'] = getProp($fallbackEpisodeArr, 'episode_title', ''); $parts['acts'] = getProp($fallbackEpisodeArr, 'acts', []); if (empty($parts['intro'])) { $parts['intro'] = getProp($fallbackEpisodeArr, 'intro', ''); } if (empty($parts['art_style'])) { $parts['art_style'] = getProp($fallbackEpisodeArr, 'art_style', ''); } } // 更新roles和scenes $episode_roles = getProp($fallbackEpisodeArr, 'roles'); $episode_scenes = getProp($fallbackEpisodeArr, 'scenes'); if ($episode_roles) $parts['roles'] = $episode_roles; if ($episode_scenes) $parts['scenes'] = $episode_scenes; } // 多剧集格式:继续兼容旧的分集剧本结构 $fullScript = ''; if (preg_match('/###分集剧本\s*\n(.*)/su', $originalContent, $storyboardMatch)) { $fullScript = trim($storyboardMatch[1]); } elseif (preg_match('/##分集剧本\s*\n(.*)/su', $originalContent, $storyboardMatch)) { $fullScript = trim($storyboardMatch[1]); } elseif (preg_match('/分集剧本[::]\s*\n(.*)/su', $originalContent, $storyboardMatch)) { $fullScript = trim($storyboardMatch[1]); } if (empty($fullScript) && preg_match('/(##分集.*)/su', $originalContent, $fallbackMatch)) { $fullScript = trim($fallbackMatch[1]); } $episodes = []; preg_match_all('/##分集(\d+)\s*\n分集名[::]\s*(.*?)\s*\n(?:场景描述[::].*?\n出场角色[::].*?\n台词内容[::]\s*\n)?(.*?)(?=\n##分集|$)/su', $fullScript, $matches, PREG_SET_ORDER); foreach ($matches as $match) { $episodeNumber = (int)trim($match[1]); $episodeName = trim($match[2]); $episodeContent = trim($match[3]); $segments = []; preg_match_all('/分镜(\d+)\s*\n(.*?)(?=\n分镜\d+|\z)/s', $episodeContent, $segmentMatches, PREG_SET_ORDER); foreach ($segmentMatches as $segMatch) { $segmentNumber = (int)trim($segMatch[1]); $segmentContent = trim($segMatch[2]); $segments[] = [ 'segment_number' => $segmentNumber, 'segment_content' => $segmentContent, ]; } $episodes[] = [ 'episode_number' => $episodeNumber, 'title' => $episodeName, 'content' => $episodeContent, 'segments' => $segments, ]; } if (empty($episodes) && !empty($parts['acts'])) { $singleSegments = []; foreach ($parts['acts'] as $act) { $actSegments = getProp($act, 'segments', []); if (!is_array($actSegments)) { continue; } foreach ($actSegments as $segment) { $singleSegments[] = [ 'segment_number' => getProp($segment, 'segment_number'), 'segment_content' => getProp($segment, 'segment_content'), ]; } } $episodeTitle = $parts['episode_title'] ?: ('第1集:' . ($parts['script_name'] ?? '未命名')); $episodeName = preg_replace('/^第\d+集[::]\s*/u', '', $episodeTitle); $episodes[] = [ 'episode_number' => 1, 'title' => $episodeName, 'content' => $singleStoryboard, 'segments' => $singleSegments, ]; } $parts['episodes'] = $episodes; return $parts; } /** * 将主体列表文本拆分为主体数组 * * @param string $rolesText 主体列表文本内容 * @return array 主体名称数组 */ function parseRolesFromTextForAce(string $rolesText): array { if (empty($rolesText)) { return []; } $roles = []; // 按行分割文本 $lines = explode("\n", $rolesText); foreach ($lines as $line) { $line = trim($line); if (empty($line)) { continue; } if (preg_match('/^([^::]+)[::](.+)$/u', $line, $charMatch)) { $role = trim($charMatch[1]); $description = trim($charMatch[2]); $timbreName = null; // 检查描述末尾是否有{主体图片提示词}{{音色名}}格式 $picPrompt = null; $timbrePrompt = null; if (preg_match('/^(.*?)\{([^}]+)\}\{\{([^}]+)\}\}\s*$/u', $description, $fullMatch)) { // 匹配格式:主体描述{主体图片提示词}{{音色名}} $description = trim($fullMatch[1]); $picPrompt = trim($fullMatch[2]); $timbrePrompt = trim($fullMatch[3]); } elseif (preg_match('/^(.*?)\{\{([^}]+)\}\}\s*$/u', $description, $timbreMatch)) { // 兼容旧格式:主体描述{{音色名}} $description = trim($timbreMatch[1]); $timbrePrompt = trim($timbreMatch[2]); } $roleData = [ 'role' => $role, 'description' => $description, ]; // 如果有主体图片提示词,添加到数组中 if ($picPrompt) $roleData['pic_prompt'] = $picPrompt; // 如果有音色提示词,添加到数组中 if ($timbrePrompt) $roleData['voice_prompt'] = $timbrePrompt; if ($timbreName) { $timbre = DB::table('mp_timbres') ->where('is_enabled', 1) ->where('timbre_name', 'like', "%{$timbreName}%") ->orderBy('id') ->select('timbre_type', 'audio_url') ->first(); if ($timbre) { $roleData['voice_name'] = $timbreName; $roleData['voice_type'] = getProp($timbre, 'timbre_type'); $roleData['voice_audio_url'] = getProp($timbre, 'audio_url'); } } // else { // // 从描述或人物提示词中获取"男"或"女",赋予默认音色,获取不到则使用旁白音色 // if ($picPrompt) { // if (strstr($picPrompt, '男')) { // $roleData['voice_name'] = '阳光青年'; // }elseif (strstr($picPrompt, '女')) { // $roleData['voice_name'] = '爽快思思'; // }else { // if ($description) { // if (strstr($description, '男')) { // $roleData['voice_name'] = '阳光青年'; // }elseif (strstr($description, '女')) { // $roleData['voice_name'] = '爽快思思'; // }else { // $roleData['voice_name'] = '旁白'; // } // } // } // } // if (!empty($roleData['voice_name'])) { // if ($roleData['voice_name'] == '旁白') { // $roleData['voice_type'] = 'zh_male_linjiananhai_moon_bigtts'; // $roleData['voice_audio_url'] = 'https://zw-audiobook.tos-cn-beijing.volces.com/demonstrate/zh_male_linjiananhai_moon_bigtts.wav'; // }else { // $timbre = DB::table('mp_timbres') // ->where('is_enabled', 1) // ->where('timbre_name', 'like', "%".$roleData['voice_name']."%") // ->orderBy('id') // ->select('timbre_type', 'audio_url') // ->first(); // if ($timbre) { // $roleData['voice_type'] = getProp($timbre, 'timbre_type'); // $roleData['voice_audio_url'] = getProp($timbre, 'audio_url'); // } // } // } // } $roles[] = $roleData; } } $hasNarrator = false; foreach ($roles as $role) { if (getProp($role, 'role') === '旁白') { $hasNarrator = true; break; } } if (!$hasNarrator) { $roles[] = [ 'role' => '旁白', 'description' => '负责叙述剧情、补充说明和情感渲染的非视觉角色。', 'pic_prompt' => '', 'voice_prompt' => '', // 'voice_name' => '旁白', // 'voice_type' => 'zh_male_linjiananhai_moon_bigtts', // 'voice_audio_url' => 'https://zw-audiobook.tos-cn-beijing.volces.com/demonstrate/zh_male_linjiananhai_moon_bigtts.wav' ]; } return $roles; } function handleEpisodeContentForAce($originalContent) { if (!$originalContent) return []; // 解析剧集内容 $result = [ 'episode_title' => '', 'intro' => '', 'art_style' => '', 'roles' => [], 'scenes' => [], 'acts' => [] ]; // 提取剧集标题 - 匹配"第xx集:标题"或"第xx集 标题"格式,排除###标记 if (preg_match('/第(\d+)集[::\s]+([^#\n]+?)(?=\s*###|\s*$|\n)/u', $originalContent, $titleMatch)) { $result['episode_title'] = '第' . $titleMatch[1] . '集:' . trim($titleMatch[2]); } // 提取故事梗概 - 兼容多种格式:###故事梗概、### 故事梗概、### 故事梗概 if (preg_match('/###\s*故事梗概\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $summaryMatch)) { $result['intro'] = trim($summaryMatch[1]); } // 提取美术风格 - 兼容多种格式 if (preg_match('/###\s*美术风格\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $styleMatch)) { $result['art_style'] = trim($styleMatch[1]); } // 提取主体列表 - 兼容多种格式 if (preg_match('/###\s*主体列表\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $charactersMatch)) { $charactersText = trim($charactersMatch[1]); $characterLines = explode("\n", $charactersText); foreach ($characterLines as $line) { $line = trim($line); if (empty($line)) continue; // 兼容中文冒号:和英文冒号:,同时提取音色信息 if (preg_match('/^([^::]+)[::](.+)$/u', $line, $charMatch)) { $role = trim($charMatch[1]); $description = trim($charMatch[2]); $timbreName = null; // 检查描述末尾是否有{主体图片提示词}{{音色名}}格式 $picPrompt = null; $timbrePrompt = null; // 修复正则:使用更精确的匹配,支持花括号嵌套 // 格式1: 描述{pic_prompt} {{voice_prompt}} if (preg_match('/^(.*?)\{(.+?)\}\s*\{\{(.+?)\}\}\s*$/u', $description, $fullMatch)) { // 匹配格式:主体描述{主体图片提示词}{{音色名}} $description = trim($fullMatch[1]); $picPrompt = trim($fullMatch[2]); $timbrePrompt = trim($fullMatch[3]); } // 格式2: 描述{{voice_prompt}}(兼容旧格式,只有音色) elseif (preg_match('/^(.*?)\{\{(.+?)\}\}\s*$/u', $description, $timbreMatch)) { // 兼容旧格式:主体描述{{音色名}} $description = trim($timbreMatch[1]); $timbrePrompt = trim($timbreMatch[2]); } // 格式3: 描述{pic_prompt}(只有图片提示词) elseif (preg_match('/^(.*?)\{(.+?)\}\s*$/u', $description, $picMatch)) { $description = trim($picMatch[1]); $picPrompt = trim($picMatch[2]); } $roleData = [ 'role' => $role, 'description' => $description ]; // 如果有主体图片提示词,添加到数组中 if ($picPrompt) { // 检查开头是否有"全景,正面拍摄。" $prefix = '全景,正面拍摄。'; $prefixParts = ['全景', '正面拍摄']; // 检查是否包含完整前缀 if (strpos($picPrompt, $prefix) !== 0) { // 检查是否包含部分前缀词汇(不论位置) $hasAnyPrefix = false; foreach ($prefixParts as $part) { if (mb_strpos($picPrompt, $part) !== false) { $hasAnyPrefix = true; break; } } if ($hasAnyPrefix) { // 移除所有"全景"和"正面拍摄"词汇(包括它们后面的标点) $picPrompt = preg_replace('/全景[,,、。. ]*/u', '', $picPrompt); $picPrompt = preg_replace('/正面拍摄[,,、。. ]*/u', '', $picPrompt); // 清理开头和结尾的标点符号和空格 $picPrompt = preg_replace('/^[。,,、 ]+|[。,,、 ]+$/u', '', $picPrompt); } // 在开头添加完整前缀 $picPrompt = $prefix . $picPrompt; } // 检查是否包含"姿态:"或"姿态:" if (preg_match('/姿态[::]/u', $picPrompt)) { // 如果有姿态描述,统一替换为"姿态:站立" $picPrompt = preg_replace('/姿态[::][^.。\n]+/u', '姿态:站立', $picPrompt); } else { // 如果没有姿态描述,在末尾添加"姿态:站立" $picPrompt = preg_replace('/[。,, ]+$/u', '', $picPrompt) . '。姿态:站立。'; } $roleData['pic_prompt'] = $picPrompt; } if ($timbrePrompt) $roleData['voice_prompt'] = $timbrePrompt; $result['roles'][] = $roleData; } } // // 加入旁白角色(如果不存在) // $hasNarrator = false; // foreach ($result['roles'] as $role) { // if (isset($role['role']) && $role['role'] === '旁白') { // $hasNarrator = true; // break; // } // } // if (!$hasNarrator) { // $result['roles'][] = [ // 'role' => '旁白', // 'description' => '负责叙述剧情、补充说明和情感渲染的非视觉角色。', // 'pic_prompt' => '', // 'voice_name' => '旁白', // 'voice_type' => 'zh_male_linjiananhai_moon_bigtts', // 'voice_audio_url' => 'https://zw-audiobook.tos-cn-beijing.volces.com/demonstrate/zh_male_linjiananhai_moon_bigtts.wav' // ]; // } } // 提取场景列表 - 兼容多种格式 if (preg_match('/###\s*场景列表\s*\n(.*?)(?=\n\s*###[^#]|\z)/s', $originalContent, $scenesMatch)) { $scenesText = trim($scenesMatch[1]); $sceneLines = explode("\n", $scenesText); foreach ($sceneLines as $line) { $line = trim($line); if (empty($line)) continue; // 兼容中文冒号:和英文冒号: if (preg_match('/^([^::]+)[::](.+)$/u', $line, $sceneMatch)) { $scene = trim($sceneMatch[1]); $description = trim($sceneMatch[2]); $picPrompt = null; // 检查描述末尾是否有{场景图片提示词}格式 if (preg_match('/^(.*?)\{([^}]+)\}\s*$/u', $description, $promptMatch)) { // 匹配格式:场景描述{场景图片提示词} $description = trim($promptMatch[1]); $picPrompt = trim($promptMatch[2]); } $sceneData = [ 'scene' => $scene, 'description' => $description ]; // 如果有场景图片提示词,添加到数组中 if ($picPrompt) { $sceneData['pic_prompt'] = $picPrompt; } $result['scenes'][] = $sceneData; } } } // 提取分镜剧本 - 兼容多种格式(旧格式:###分镜剧本,新格式:###分段剧本) $storyboardPattern = '/###\s*(?:分镜剧本|分段剧本)\s*\n(.*?)(?=\n\s*###[^#]|\z)/s'; if (preg_match($storyboardPattern, $originalContent, $storyboardMatch)) { $storyboardText = trim($storyboardMatch[1]); // 提取旁白音色(新格式独有) $narratorVoice = ''; if (preg_match('/旁白音色[::]\s*([^\n]+)/u', $storyboardText, $narratorMatch)) { $narratorVoice = trim($narratorMatch[1]); // 将旁白加入到roles数组 $hasNarrator = false; foreach ($result['roles'] as $role) { if (isset($role['role']) && $role['role'] === '旁白') { $hasNarrator = true; break; } } if (!$hasNarrator) { $result['roles'][] = [ 'role' => '旁白', 'description' => '负责叙述剧情、补充说明和情感渲染的非视觉角色。', 'pic_prompt' => '', 'voice_prompt' => $narratorVoice ]; } } // 按幕/片段分割 - 兼容"第X幕"和"片段X"两种格式 $acts = []; // 先在开头添加换行符,确保第1幕/片段1也能被正确分割 $normalizedText = "\n" . $storyboardText; $parts = preg_split('/\n\s*##/', $normalizedText); foreach ($parts as $part) { $part = trim($part); if (empty($part)) continue; // 兼容"第X幕"和"片段X"两种格式 if (!preg_match('/^(?:第\d+幕|片段\d+)/', $part)) { continue; } // 分离标题和内容 $lines = explode("\n", $part, 2); $actTitle = trim($lines[0]); $actContent = isset($lines[1]) ? trim($lines[1]) : ''; // 解析标题,提取序号和详细信息 if (preg_match('/^第(\d+)幕[::]?\s*(.*)$/u', $actTitle, $actTitleMatch)) { // 旧格式:第X幕 $actNumber = intval($actTitleMatch[1]); $actDetails = trim($actTitleMatch[2]); if (empty($actDetails) || $actDetails === ':' || $actDetails === ':') { $actDetails = $actTitle; } } elseif (preg_match('/^片段(\d+)\s*$/u', $actTitle, $segmentTitleMatch)) { // 新格式:片段X $actNumber = intval($segmentTitleMatch[1]); $actDetails = $actTitle; } else { $actNumber = count($acts) + 1; $actDetails = $actTitle; } // 提取时长(新格式独有,仅保留数字) $actDuration = ''; if (preg_match('/时长[::]\s*([^\n]+)/u', $actContent, $actDurationMatch)) { $actDurationStr = trim($actDurationMatch[1]); // 提取数字部分(支持整数和小数) if (preg_match('/([-+]?[0-9]*\.?[0-9]+)/', $actDurationStr, $numMatch)) { $actDuration = $numMatch[1]; } } // 解析该幕/片段下的分镜 $segments = []; $segmentPattern = '/分镜(\d+)\s*\n(.*?)(?=\n+\s*分镜\d+|\z)/s'; preg_match_all($segmentPattern, $actContent, $segmentMatches, PREG_SET_ORDER); foreach ($segmentMatches as $segmentMatch) { $segmentNumber = intval($segmentMatch[1]); $segmentContent = trim($segmentMatch[2]); // 解析分镜详细信息 $segmentData = [ 'segment_id' => date('YmdHis') . mt_rand(1000, 9999) . str_pad($segmentNumber, 3, "0", STR_PAD_LEFT), 'segment_number' => $segmentNumber, 'segment_content' => $segmentContent, 'description' => '', 'composition' => '', 'camera_movement' => '', 'voice_actor' => '', 'dialogue' => '', 'frame_type' => '', 'scene' => '', 'characters' => '', 'tail_frame' => '', 'emotion' => '中性', 'gender' => '0', 'speed_ratio' => 0, 'loudness_ratio' => 0, 'emotion_scale' => 4, 'pitch' => 0, ]; // 提取场景 if (preg_match('/(?:场景|拍摄场景|背景场景|环境)[::]\s*([^\n]+)/u', $segmentContent, $sceneMatch)) { $segmentData['scene'] = trim($sceneMatch[1]); } // 提取画面描述 - 兼容新格式的方括号标注 if (preg_match('/(?:画面|画面描述|镜头描述|场景描述)[::]\s*(\[.*?\])?\s*([^\n]+)/u', $segmentContent, $descMatch)) { $frameTypeInDesc = isset($descMatch[1]) ? trim($descMatch[1], '[]') : ''; $description = trim($descMatch[2]); if (!empty($frameTypeInDesc)) { $segmentData['frame_type'] = $frameTypeInDesc; } $segmentData['description'] = $description; } // 提取运镜 if (preg_match('/(?:运镜|运镜调度|镜头运动|摄影机运动)[::]\s*([^\n]+)/u', $segmentContent, $cameraMatch)) { $segmentData['camera_movement'] = trim($cameraMatch[1]); } // 提取配音台词 - 兼容新格式"中文配音:[角色] 台词" // 修复:排除背景音效等非台词内容 if (preg_match('/配音台词[::]\s*(?:中文配音[::]?)?\s*(?:\[([^\]]+)\])?\s*([^\n]*?)(?=\n|$)/u', $segmentContent, $dialogueMatch)) { $voiceActor = isset($dialogueMatch[1]) ? trim($dialogueMatch[1]) : ''; $dialogue = isset($dialogueMatch[2]) ? trim($dialogueMatch[2]) : ''; // 排除非台词内容(如果匹配到的是空或者是其他字段标记) if (!empty($dialogue) && preg_match('/^(?:背景音效|音效|场景|画面|运镜|构图)[::]/u', $dialogue)) { $dialogue = ''; // 如果匹配到的是其他字段,清空 } if (!empty($voiceActor)) { $segmentData['voice_actor'] = $voiceActor; } if ($dialogue && $dialogue !== '无') { // 确保台词使用中文左右双引号 $dialogue = preg_replace('/^[""]/u', '"', $dialogue); $dialogue = preg_replace('/[""]$/u', '"', $dialogue); if (!preg_match('/^["]/', $dialogue)) { $dialogue = '"' . $dialogue; } if (!preg_match('/["]$/', $dialogue)) { $dialogue .= '"'; } $segmentData['dialogue'] = $dialogue; } } // 兼容旧格式的台词字段 // 修复:排除背景音效等非台词内容 if (empty($segmentData['dialogue']) && preg_match('/(?:台词内容|台词|对白|对话)[::]\s*([^\n]*?)(?=\n|$)/u', $segmentContent, $oldDialogueMatch)) { $dialogue = trim($oldDialogueMatch[1]); // 排除非台词内容 if (!empty($dialogue) && preg_match('/^(?:背景音效|音效|场景|画面|运镜|构图)[::]/u', $dialogue)) { $dialogue = ''; // 如果匹配到的是其他字段,清空 } if ($dialogue && $dialogue !== '无') { $dialogue = preg_replace('/^[""]/u', '"', $dialogue); $dialogue = preg_replace('/[""]$/u', '"', $dialogue); if (!preg_match('/^["]/', $dialogue)) { $dialogue = '"' . $dialogue; } if (!preg_match('/["]$/', $dialogue)) { $dialogue .= '"'; } $segmentData['dialogue'] = $dialogue; } } // 兼容旧格式的配音角色字段 if (empty($segmentData['voice_actor']) && preg_match('/(?:配音角色|配音|角色|声优)[::]\s*([^\n]+)/u', $segmentContent, $voiceMatch)) { $segmentData['voice_actor'] = trim($voiceMatch[1]); } // 提取构图 if (preg_match('/(?:构图设计|构图|镜头构图)[::]\s*([^\n]+)/u', $segmentContent, $compMatch)) { $segmentData['composition'] = trim($compMatch[1]); } // 提取画面类型 if (empty($segmentData['frame_type']) && preg_match('/(?:画面类型|镜头类型|类型)[::]\s*([^\n]+)/u', $segmentContent, $frameMatch)) { $segmentData['frame_type'] = trim($frameMatch[1]); } // 提取出镜角色 if (preg_match('/(?:出镜角色|角色出镜|登场角色|人物)[::]\s*([^\n]+)/u', $segmentContent, $charactersMatch)) { $segmentData['characters'] = trim($charactersMatch[1]); } // 提取尾帧描述 if (preg_match('/(?:尾帧描述|尾帧|结束帧|最后一帧|结尾画面|结束画面)[::]\s*([^\n]+)/u', $segmentContent, $tailFrameMatch)) { $segmentData['tail_frame'] = trim($tailFrameMatch[1]); } $replaceEmptyArr = []; // 提取情感 if (preg_match('/(?:情感|情绪|感情)[::]\s*([^\n]+)/u', $segmentContent, $emotionMatch)) { $replaceEmptyArr[] = trim($emotionMatch[0]); $segmentData['emotion'] = trim($emotionMatch[1]); } // 提取性别 if (preg_match('/(?:性别)[::]\s*([^\n]+)/u', $segmentContent, $genderMatch)) { $replaceEmptyArr[] = trim($genderMatch[0]); $genderStr = trim($genderMatch[1]); if (strpos($genderStr, '男') !== false || $genderStr === '1') { $segmentData['gender'] = '1'; } elseif (strpos($genderStr, '女') !== false || $genderStr === '2') { $segmentData['gender'] = '2'; } else { $segmentData['gender'] = '0'; } } // 提取语速 if (preg_match('/(?:语速|说话速度)[::]\s*([-+]?[0-9]*\.?[0-9]+)/u', $segmentContent, $speedMatch)) { $replaceEmptyArr[] = trim($speedMatch[0]); $segmentData['speed_ratio'] = (float)trim($speedMatch[1]); } // 提取音量 if (preg_match('/(?:音量|声音大小)[::]\s*([-+]?[0-9]*\.?[0-9]+)/u', $segmentContent, $loudnessMatch)) { $replaceEmptyArr[] = trim($loudnessMatch[0]); $segmentData['loudness_ratio'] = (float)trim($loudnessMatch[1]); } // 提取情感强度 if (preg_match('/(?:情感强度|情绪强度)[::]\s*([0-9]+)/u', $segmentContent, $scaleMatch)) { $replaceEmptyArr[] = trim($scaleMatch[0]); $segmentData['emotion_scale'] = (int)trim($scaleMatch[1]); } // 提取音调 if (preg_match('/(?:音调|音高)[::]\s*([-+]?[0-9]+)/u', $segmentContent, $pitchMatch)) { $replaceEmptyArr[] = trim($pitchMatch[0]); $segmentData['pitch'] = (int)trim($pitchMatch[1]); } $segmentData['segment_content'] = str_replace($replaceEmptyArr, '', $segmentContent); $segmentData['segment_content'] = preg_replace('/\n{2,}/', "\n", $segmentData['segment_content']); $segmentData['segment_content'] = trim($segmentData['segment_content']); $segments[] = $segmentData; } $acts[] = [ 'act_number' => $actNumber, 'act_title' => $actTitle, 'act_details' => $actDetails, 'act_duration' => $actDuration, 'segments' => $segments ]; } $result['acts'] = $acts; } return $result; } /** * 远程图片压缩(尽可能保持原图 fidelity,压缩到不超过 maxBytes 字节) * 采用有损/无损组合策略:保留原始格式尽量不失真,若需要则通过尺寸缩放和质量调节来降低体积。 * * @param string $url 远程图片 URL * @param int $maxBytes 最大字节数,默认 3MB * @param string|null $aspectRatio 目标长宽比,如 "16:9"、"4:3"、"1:1" 等,null 则保持原图比例 * @return string|null 返回压缩后的图片二进制数据,失败时返回 null */ function compressRemoteImageUrlToSize(string $url, int $maxBytes = 3 * 1024 * 1024, ?string $aspectRatio = null): ?string { // 1) 下载图片数据(使用 Guzzle 以避免 allow_url_fopen 依赖) try { $client = new Client(['timeout' => 30]); $response = $client->get($url, ['stream' => true]); if ($response->getStatusCode() !== 200) { return null; } $data = $response->getBody()->getContents(); } catch (\Exception $e) { return null; } if (!$data) return null; // 2) 识别图片类型 $imgInfo = @getimagesizefromstring($data); $mime = $imgInfo['mime'] ?? ''; if ($mime == 'image/jpeg') return null; // JPEG 不做有损压缩 // 3) 载入图像对象 $srcImg = @imagecreatefromstring($data); if (!$srcImg) { return null; } $origW = imagesx($srcImg); $origH = imagesy($srcImg); // 3.2) 计算目标尺寸(根据长宽比调整) $targetW = $origW; $targetH = $origH; if ($aspectRatio !== null) { // 解析长宽比,如 "16:9" -> [16, 9] $validRatios = array_keys(BaseConst::IMAGE_RATIOS); if (in_array($aspectRatio, $validRatios)) { list($ratioW, $ratioH) = explode(':', $aspectRatio); $ratioW = (float)$ratioW; $ratioH = (float)$ratioH; $targetRatio = $ratioW / $ratioH; $currentRatio = $origW / $origH; // 根据目标比例裁剪图片 if ($currentRatio > $targetRatio) { // 原图更宽,需要裁剪宽度 $targetW = (int)round($origH * $targetRatio); $targetH = $origH; } else { // 原图更高,需要裁剪高度 $targetW = $origW; $targetH = (int)round($origW / $targetRatio); } } } // 如果需要裁剪,创建裁剪后的图像 if ($targetW !== $origW || $targetH !== $origH) { $croppedImg = imagecreatetruecolor($targetW, $targetH); imagealphablending($croppedImg, false); imagesavealpha($croppedImg, true); // 处理透明背景 if (in_array(strtolower($mime), ['image/png','image/webp'])) { $transparent = imagecolorallocatealpha($croppedImg, 0, 0, 0, 127); imagefill($croppedImg, 0, 0, $transparent); } // 居中裁剪 $srcX = (int)(($origW - $targetW) / 2); $srcY = (int)(($origH - $targetH) / 2); imagecopy($croppedImg, $srcImg, 0, 0, $srcX, $srcY, $targetW, $targetH); // 替换原图 safeDestroyImage($srcImg); $srcImg = $croppedImg; $origW = $targetW; $origH = $targetH; } // 3.1) 内部渲染成不同格式的字符串 $render = function($srcRes, string $mimeType, int $quality) { ob_start(); switch (strtolower($mimeType)) { case 'image/jpeg': case 'image/jpg': case 'image/pjpeg': // 输出 JPEG imagejpeg($srcRes, null, $quality); break; case 'image/png': // 将 quality 映射到 PNG 的 compression level (0-9) $level = (int)round((100 - $quality) / 11.11); if ($level < 0) $level = 0; if ($level > 9) $level = 9; imagepng($srcRes, null, $level); break; case 'image/webp': if (function_exists('imagewebp')) { imagewebp($srcRes, null, $quality); } else { imagejpeg($srcRes, null, $quality); } break; default: imagejpeg($srcRes, null, $quality); break; } $out = ob_get_contents(); ob_end_clean(); return $out; }; // 4) 尝试策略:尽量保留原图尺寸,逐步降维/降质量,直到 <= maxBytes $tryList = []; // 原始尺寸,尽量保留 $tryList[] = ['scale'=>1.0, 'mime'=>$mime, 'quality'=>100]; // 逐步缩小尺寸 for ($s = 0.9; $s >= 0.2; $s -= 0.1) { $tryList[] = ['scale'=>$s, 'mime'=>$mime, 'quality'=>90]; } // 一系列质量等级(用于 JPEG/WebP) $qualityLevels = [95, 90, 85, 75, 60, 50, 40, 30]; foreach ($qualityLevels as $q) { $tryList[] = ['scale'=>1.0, 'mime'=>$mime, 'quality'=>$q]; } $bestData = null; foreach ($tryList as $cand) { $scale = isset($cand['scale']) ? (float)$cand['scale'] : 1.0; $mimeT = $cand['mime'] ?? $mime; $quality = isset($cand['quality']) ? (int)$cand['quality'] : 90; $w = (int)round($origW * $scale); $h = (int)round($origH * $scale); $src = $srcImg; $tempImg = null; if ($scale < 1.0) { $tempImg = imagecreatetruecolor($w, $h); // 处理透明通道 imagealphablending($tempImg, false); imagesavealpha($tempImg, true); if (in_array(strtolower($mime), ['image/png','image/webp'])) { $transparent = imagecolorallocatealpha($tempImg, 0, 0, 0, 127); imagefill($tempImg, 0, 0, $transparent); } // 确保 $srcImg 有效 if (!$srcImg || (!is_resource($srcImg) && !($srcImg instanceof \GdImage))) { safeDestroyImage($tempImg); continue; } imagecopyresampled($tempImg, $srcImg, 0, 0, 0, 0, $w, $h, $origW, $origH); $src = $tempImg; } $imageBytes = $render($src, $mimeT, $quality); if ($tempImg !== null) { safeDestroyImage($tempImg); } if ($imageBytes !== false && strlen($imageBytes) <= $maxBytes) { $bestData = $imageBytes; break; } } // 5) 回退策略:若仍未达到要求,尝试更大幅度降解到一个合理的小尺寸 JPEG if ($bestData === null) { $tmpW = max(1, (int)round($origW * 0.5)); $tmpH = max(1, (int)round($origH * 0.5)); $tmpImg = imagecreatetruecolor($tmpW, $tmpH); imagealphablending($tmpImg, false); imagesavealpha($tmpImg, true); if (in_array(strtolower($mime), ['image/png','image/webp'])) { $transparent = imagecolorallocatealpha($tmpImg, 0, 0, 0, 127); imagefill($tmpImg, 0, 0, $transparent); } imagecopyresampled($tmpImg, $srcImg, 0, 0, 0, 0, $tmpW, $tmpH, $origW, $origH); ob_start(); if (in_array(strtolower($mime), ['image/jpeg','image/jpg','image/pjpeg'])) { imagejpeg($tmpImg, null, 75); } elseif (strtolower($mime) === 'image/png') { imagepng($tmpImg, null, 6); } elseif (function_exists('imagewebp')) { imagewebp($tmpImg, null, 75); } else { imagejpeg($tmpImg, null, 75); } $tmpBytes = ob_get_contents(); ob_end_clean(); safeDestroyImage($tmpImg); if ($tmpBytes !== '' && strlen($tmpBytes) <= $maxBytes) { $bestData = $tmpBytes; } } // 6) 清理 safeDestroyImage($srcImg); return $bestData; } /** * 安全销毁 GD 图像资源(兼容 PHP7.4+ 的资源管理) * 通过引用传递并在销毁后置空变量,避免未定义变量的问题 * * @param resource|\GdImage|null &$img GD 图像资源或对象(PHP7.4 为 resource,PHP8.0+ 为 GdImage) */ function safeDestroyImage(&$img) { if (is_resource($img) || (is_object($img) && $img instanceof \GdImage)) { @imagedestroy($img); } $img = null; } // /** // * 使用 Imagick 将远程图片压缩至不超过 maxBytes(默认3MB)内 // * 目标:在尽量保留原始格式和质量的前提下进行压缩,避免内存暴涨 // * 依赖:Imagick 扩展与 ImageMagick 库,下载使用 Guzzle 获取图片 blob // * // * @param string $url // * @param int $maxBytes 最大字节数,默认 3*1024*1024 // * @return string|null 经过压缩后的图片 blob,失败返回 null // */ // function compressRemoteImageUrlToSizeImagick(string $url, int $maxBytes = 3 * 1024 * 1024): ?string // { // // 兼容性检查 // if (!extension_loaded('imagick') || !class_exists('Imagick')) { // return null; // } // try { // $client = new Client(['timeout' => 30]); // // 使用流式下载,将数据写入临时文件,避免一次性加载到内存 // $response = $client->get($url, ['stream' => true]); // if ($response->getStatusCode() !== 200) { // return null; // } // $body = $response->getBody(); // $tmp = tmpfile(); // if ($tmp === false) { // return null; // } // stream_copy_to_stream($body, $tmp); // rewind($tmp); // $imagick = new Imagick(); // $imagick->readImageFile($tmp); // $origW = $imagick->getImageWidth(); // $origH = $imagick->getImageHeight(); // $format = strtolower($imagick->getImageFormat()); // // 尝试序列:尺寸缩放 + 质量调节 // $scales = [1.0, 0.9, 0.8, 0.6, 0.4, 0.25]; // $qualities = [95, 90, 85, 75, 60, 40, 30, 20]; // $bestBlob = null; // foreach ($scales as $scale) { // foreach ($qualities as $q) { // $clone = clone $imagick; // if ($scale < 1.0) { // $w = (int)round($origW * $scale); // $h = (int)round($origH * $scale); // if ($w < 1 || $h < 1) { // $clone->destroy(); // continue; // } // $clone->resizeImage($w, $h, Imagick::FILTER_LANCZOS, 1); // } // $clone->setImageFormat($format); // $clone->setImageCompressionQuality($q); // $blob = $clone->getImageBlob(); // $clone->destroy(); // if ($blob !== false && strlen($blob) <= $maxBytes) { // $bestBlob = $blob; // break 2; // } // } // } // if ($bestBlob !== null) { // $imagick->destroy(); // fclose($tmp); // return $bestBlob; // } // // 回退:尝试强制输出为当前格式的一个中等质量版本 // $fallback = clone $imagick; // $fallback->setImageFormat($format); // $fallback->setImageCompressionQuality(75); // $blob = $fallback->getImageBlob(); // $fallback->destroy(); // $imagick->destroy(); // fclose($tmp); // if ($blob !== false && strlen($blob) <= $maxBytes) { // return $blob; // } // } catch (\Exception $e) { // return null; // } // return null; // } /** * 将主体列表文本拆分为主体数组 * * @param string $rolesText 主体列表文本内容 * @return array 主体名称数组 */ function parseRolesFromText(string $rolesText): array { if (empty($rolesText)) { return []; } $roles = []; // 按行分割文本 $lines = explode("\n", $rolesText); foreach ($lines as $line) { $line = trim($line); if (empty($line)) { continue; } if (preg_match('/^([^::]+)[::](.+)$/u', $line, $charMatch)) { $role = trim($charMatch[1]); $description = trim($charMatch[2]); $timbreName = null; // 检查描述末尾是否有{主体图片提示词}{{音色名}}格式 $picPrompt = null; if (preg_match('/^(.*?)\{([^}]+)\}\{\{([^}]+)\}\}\s*$/u', $description, $fullMatch)) { // 匹配格式:主体描述{主体图片提示词}{{音色名}} $description = trim($fullMatch[1]); $picPrompt = trim($fullMatch[2]); $timbreName = trim($fullMatch[3]); } elseif (preg_match('/^(.*?)\{\{([^}]+)\}\}\s*$/u', $description, $timbreMatch)) { // 兼容旧格式:主体描述{{音色名}} $description = trim($timbreMatch[1]); $timbreName = trim($timbreMatch[2]); } $roleData = [ 'role' => $role, 'description' => $description, ]; // 如果有主体图片提示词,添加到数组中 if ($picPrompt) { $roleData['pic_prompt'] = $picPrompt; } if ($timbreName) { $timbre = DB::table('mp_timbres') ->where('is_enabled', 1) ->where('timbre_name', 'like', "%{$timbreName}%") ->orderBy('id') ->select('timbre_type', 'audio_url') ->first(); if ($timbre) { $roleData['voice_name'] = $timbreName; $roleData['voice_type'] = getProp($timbre, 'timbre_type'); $roleData['voice_audio_url'] = getProp($timbre, 'audio_url'); } }else { // 从描述或人物提示词中获取"男"或"女",赋予默认音色,获取不到则使用旁白音色 if ($picPrompt) { if (strstr($picPrompt, '男')) { $roleData['voice_name'] = '阳光青年'; }elseif (strstr($picPrompt, '女')) { $roleData['voice_name'] = '爽快思思'; }else { if ($description) { if (strstr($description, '男')) { $roleData['voice_name'] = '阳光青年'; }elseif (strstr($description, '女')) { $roleData['voice_name'] = '爽快思思'; }else { $roleData['voice_name'] = '旁白'; } } } } if (!empty($roleData['voice_name'])) { if ($roleData['voice_name'] == '旁白') { $roleData['voice_type'] = 'zh_male_linjiananhai_moon_bigtts'; $roleData['voice_audio_url'] = 'https://zw-audiobook.tos-cn-beijing.volces.com/demonstrate/zh_male_linjiananhai_moon_bigtts.wav'; }else { $timbre = DB::table('mp_timbres') ->where('is_enabled', 1) ->where('timbre_name', 'like', "%".$roleData['voice_name']."%") ->orderBy('id') ->select('timbre_type', 'audio_url') ->first(); if ($timbre) { $roleData['voice_type'] = getProp($timbre, 'timbre_type'); $roleData['voice_audio_url'] = getProp($timbre, 'audio_url'); } } } } $roles[] = $roleData; } } $hasNarrator = false; foreach ($roles as $role) { if (getProp($role, 'role') === '旁白') { $hasNarrator = true; break; } } if (!$hasNarrator) { $roles[] = [ 'role' => '旁白', 'description' => '负责叙述剧情、补充说明和情感渲染的非视觉角色。', 'pic_prompt' => '', 'voice_name' => '旁白', 'voice_type' => 'zh_male_linjiananhai_moon_bigtts', 'voice_audio_url' => 'https://zw-audiobook.tos-cn-beijing.volces.com/demonstrate/zh_male_linjiananhai_moon_bigtts.wav' ]; } return $roles; } /** * 将场景列表文本拆分为主体数组 * * @param string $rolesText 场景列表列表文本内容 * @return array 场景名称数组 */ function parseScenesFromText(string $scenesText): array { if (empty($scenesText)) { return []; } $scenes = []; // 按行分割文本 $lines = explode("\n", $scenesText); foreach ($lines as $line) { $line = trim($line); if (empty($line)) { continue; } $line = str_replace(':', ':', $line); if (strstr($line, ':')) { $line_arr = explode(':', $line, 2); if (count($line_arr) == 2) { $scene = trim($line_arr[0]); $description = trim($line_arr[1]); $picPrompt = null; // 检查描述末尾是否有{场景图片提示词}格式 if (preg_match('/^(.*?)\{([^}]+)\}\s*$/u', $description, $promptMatch)) { // 匹配格式:场景描述{场景图片提示词} $description = trim($promptMatch[1]); $picPrompt = trim($promptMatch[2]); } $sceneData = [ 'scene' => $scene, 'description' => $description, ]; // 如果有场景图片提示词,添加到数组中 if ($picPrompt) { $sceneData['pic_prompt'] = $picPrompt; } $scenes[] = $sceneData; } } } return $scenes; } /** * 记录日志到数据库 * * @param string $channel 日志频道/目录名 * @param string $level 日志级别 (info, error, warning, debug) * @param string $message 日志信息 * @param array $context 日志详细数据 * @return void */ function logDB($channel, $level, $message, $context = []) { try { // 获取调用堆栈信息 $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2); $caller = $trace[1] ?? $trace[0]; // 在 create 之前添加: $jsonEncoded = json_encode($context, JSON_INVALID_UTF8_SUBSTITUTE); $cleanContext = json_decode($jsonEncoded, true); \App\Models\SystemLog::create([ 'channel' => $channel, 'level' => $level, 'message' => $message, 'context' => $cleanContext, 'file' => $caller['file'] ?? '', 'line' => $caller['line'] ?? 0, 'log_time' => date('Y-m-d H:i:s'), ]); } catch (\Exception $e) { // 记录日志失败时不影响主流程 \Log::error('记录日志到数据库失败: ' . $e->getMessage()); } } function filterScriptContent($content) { if (!$content) return ''; if (mb_substr($content, 0, 6) == '确认分镜大纲') return '确认分镜大纲'; if (mb_strlen($content) > 500) return '我已按照您的要求完成策划并将内容更新到您右侧的策划文档。'; return $content; } /** * 根据错误信息返回映射错误说明 * * @param string $errorMessage 错误信息 * @return string 映射后的错误说明 */ function mapErrorMessage($errorMessage) { if (empty($errorMessage)) { return '未知异常,请重试'; } // 按优先级匹配错误信息 if (stripos($errorMessage, 'OutputImageSensitiveContentDetected') !== false) { return '输出结果含有敏感词,请重试'; } if (stripos($errorMessage, '系统繁忙,请稍后再试') !== false) { return '系统繁忙,请稍后再试'; } if (stripos($errorMessage, 'InternalServiceError') !== false) { return '外部服务器网络错误,请稍后重试'; } if (stripos($errorMessage, 'InputTextSensitiveContentDetected') !== false) { return '输入文字含有敏感词,请调整后重试'; } if (stripos($errorMessage, 'ServerOverloaded') !== false) { return '当前系统过载,请稍后重试'; } if (stripos($errorMessage, 'The request failed because the output video may be related to copyright restrictions') !== false) { return '涉及到版权问题无法生成,请调整后重试'; } if (stripos($errorMessage, 'sensitive') !== false) { return '含有敏感词,请调整后重试'; } // 默认返回 return '未知异常,请重试'; } /** * 将480p视频升级到720p * * 重要说明: * 从480p到720p的简单放大无法从根本上提升画质,因为原始像素信息有限。 * 当前方案已经使用了FFmpeg的最佳参数,但仍然无法创造原本不存在的细节。 * 当前FFmpeg方案的局限性: * - 只能做到"尽可能好的放大",无法增加真实细节 * - 锐化和滤镜只能增强现有细节,不能创造新细节 * - 最终画质取决于原始480p视频的质量 * * 使用FFmpeg保持宽高比和画质尽量不丢失 * * @param string $videoUrl 视频URL地址 * @param string $prefix 上传文件夹前缀(如:'videos') * @return string 返回升级后的视频URL,失败返回原视频URL */ function upgrade480pTo720p($videoUrl, $prefix = 'videos') { if (env('APP_ENV') == 'local') return $videoUrl; try { // 创建临时目录 $tempDir = storage_path('app/temp/videos'); if (!is_dir($tempDir)) { mkdir($tempDir, 0775, true); chmod($tempDir, 0775); } // 生成唯一文件名 $uniqueId = uniqid('video_upgrade_') . bin2hex(random_bytes(4)); $videoExt = getVideoExtFromUrl($videoUrl); $inputFile = $tempDir . '/' . $uniqueId . '_input' . $videoExt; $outputFile = $tempDir . '/' . $uniqueId . '_output.mp4'; // 下载视频到本地 dLog('video_upgrade')->info('开始下载视频进行升级', ['url' => $videoUrl]); $client = new \GuzzleHttp\Client(['timeout' => 300]); $response = $client->get($videoUrl); $fileContent = $response->getBody()->getContents(); if (file_put_contents($inputFile, $fileContent) === false) { dLog('video_upgrade')->error('视频文件写入失败', [ 'url' => $videoUrl, 'file' => $inputFile ]); return $videoUrl; } chmod($inputFile, 0664); if (!file_exists($inputFile)) { dLog('video_upgrade')->error('视频下载失败', ['url' => $videoUrl]); return $videoUrl; } $inputFileSize = filesize($inputFile); dLog('video_upgrade')->info('视频下载成功,准备升级到720p', [ 'url' => $videoUrl, 'size' => $inputFileSize, 'size_mb' => round($inputFileSize / 1024 / 1024, 2) . 'MB' ]); // 获取原视频的宽高信息 $ffprobePath = env('FFPROBE_PATH', 'ffprobe'); $probeCmd = "$ffprobePath -v error -select_streams v:0 -show_entries stream=width,height -of csv=s=x:p=0 \"$inputFile\""; $dimensionOutput = trim(shell_exec($probeCmd)); if (!$dimensionOutput || strpos($dimensionOutput, 'x') === false) { dLog('video_upgrade')->error('无法获取视频分辨率', ['file' => $inputFile]); @unlink($inputFile); return $videoUrl; } list($origWidth, $origHeight) = explode('x', $dimensionOutput); $origWidth = (int)$origWidth; $origHeight = (int)$origHeight; $origAspectRatio = $origWidth / $origHeight; dLog('video_upgrade')->info('原视频分辨率', [ 'width' => $origWidth, 'height' => $origHeight, 'aspect_ratio' => round($origAspectRatio, 4) ]); // 判断视频是横屏还是竖屏 // 横屏(16:9): 宽高比 > 1, 目标分辨率 1280*720 // 竖屏(9:16): 宽高比 < 1, 目标分辨率 720*1280 if ($origAspectRatio > 1) { // 横屏视频 - 目标分辨率1280*720 $targetAspectRatio = 16 / 9; // 1280/720 ≈ 1.7778 if (abs($origAspectRatio - $targetAspectRatio) < 0.01) { // 原视频宽高比接近16:9,直接缩放到1280*720 $targetWidth = 1280; $targetHeight = 720; } else { // 根据原视频宽高比,保持比例缩放 // 以较长边(高度720)为基准 $targetHeight = 720; $targetWidth = (int)round($targetHeight * $origAspectRatio); // 确保是偶数 if ($targetWidth % 2 != 0) $targetWidth += 1; // 如果计算出的宽度超过1280,则以宽度为基准重新计算 if ($targetWidth > 1280) { $targetWidth = 1280; $targetHeight = (int)round($targetWidth / $origAspectRatio); if ($targetHeight % 2 != 0) $targetHeight += 1; } } } else { // 竖屏视频 - 目标分辨率720*1280 $targetAspectRatio = 9 / 16; // 720/1280 = 0.5625 if (abs($origAspectRatio - $targetAspectRatio) < 0.01) { // 原视频宽高比接近9:16,直接缩放到720*1280 $targetWidth = 720; $targetHeight = 1280; } else { // 根据原视频宽高比,保持比例缩放 // 以较长边(高度1280)为基准 $targetHeight = 1280; $targetWidth = (int)round($targetHeight * $origAspectRatio); // 确保是偶数 if ($targetWidth % 2 != 0) $targetWidth += 1; // 如果计算出的宽度超过720,则以宽度为基准重新计算 if ($targetWidth > 720) { $targetWidth = 720; $targetHeight = (int)round($targetWidth / $origAspectRatio); if ($targetHeight % 2 != 0) $targetHeight += 1; } } } dLog('video_upgrade')->info('目标分辨率', [ 'width' => $targetWidth, 'height' => $targetHeight, 'aspect_ratio' => round($targetWidth / $targetHeight, 4), 'orientation' => $origAspectRatio > 1 ? 'landscape' : 'portrait' ]); // 使用FFmpeg进行视频升级 // 方案说明: // 从480p到720p的简单放大无法从根本上提升画质,因为原始信息量有限 // 以下是几种可选方案: // 【方案1】超采样+多重滤镜(当前方案,适合快速处理) // 优点: 速度较快,兼容性好 // 缺点: 画质提升有限,无法创造原本不存在的细节 // 【方案2】Real-ESRGAN AI超分辨率(推荐,需要额外部署) // 使用AI模型进行超分辨率,可以真实提升画质 // 需要部署: https://github.com/xinntao/Real-ESRGAN // 命令示例: realesrgan-ncnn-vulkan -i input.mp4 -o output.mp4 -s 1.5 // 【方案3】使用FFmpeg的SR滤镜(需要编译支持) // FFmpeg 4.4+ 支持DNN超分辨率 // 需要OpenVINO或TensorFlow支持 // 当前使用方案1进行处理,如需更好画质,建议: // 1. 直接生成720p视频,而不是升级480p // 2. 或部署Real-ESRGAN等AI超分辨率服务 // 优化的FFmpeg参数(已尽可能提升质量) // 滤镜链: // 1. scale: 先放大到2倍,使用超采样 // 2. scale: 再缩放到目标分辨率,使用Lanczos算法 // 3. eq: 轻微增强对比度和饱和度 // 4. unsharp: 锐化增强细节 // 5. hqdn3d: 降噪 // 6. cas: AMD的对比度自适应锐化(如FFmpeg支持) $ffmpegPath = env('FFMPEG_PATH', 'ffmpeg'); // 检查FFmpeg是否支持cas滤镜 $checkCasCmd = "$ffmpegPath -filters 2>&1 | findstr /C:\"cas\""; $hasCas = !empty(shell_exec($checkCasCmd)); // 构建滤镜链 $filterChain = []; // 1. 先进行超采样放大(放大到目标尺寸的1.2倍) $superWidth = (int)($targetWidth * 1.2); $superHeight = (int)($targetHeight * 1.2); if ($superWidth % 2 != 0) $superWidth += 1; if ($superHeight % 2 != 0) $superHeight += 1; $filterChain[] = "scale={$superWidth}:{$superHeight}:flags=lanczos"; // 2. 轻微增强对比度和饱和度 $filterChain[] = "eq=contrast=1.05:saturation=1.05:brightness=0.02"; // 3. 锐化 $filterChain[] = "unsharp=7:7:1.5:7:7:0.8"; // 4. 降噪 $filterChain[] = "hqdn3d=1.0:1.0:4:4"; // 5. 再次缩放到目标分辨率 $filterChain[] = "scale={$targetWidth}:{$targetHeight}:flags=lanczos"; // 6. 如果支持cas,添加对比度自适应锐化 if ($hasCas) { $filterChain[] = "cas=0.5"; } // 7. 最后再次轻微锐化 $filterChain[] = "unsharp=5:5:0.8:5:5:0.4"; $vfFilter = implode(',', $filterChain); // 编码参数优化 // -c:v libx264: H.264编码 // -preset slow: 慢速编码(veryslow太慢,slow是较好的平衡点) // -crf 15: 极高质量(15比16更高,接近无损) // -tune film: 针对电影内容 // -profile:v high: High Profile // -level 4.2: 支持更高分辨率和码率 // -pix_fmt yuv420p: 标准像素格式 // -x264-params: 额外的x264参数 // - aq-mode=3: 自适应量化模式3(最佳) // - aq-strength=0.8: 自适应量化强度 // - deblock=-1,-1: 轻微减少去块滤波(保留更多细节) // - ref=5: 参考帧数量 // - me=umh: 运动估计算法(高质量) // - subme=10: 子像素运动估计(最高) // - psy-rd=1.0,0.15: 心理视觉优化 // -c:a copy: 音频复制 // -movflags +faststart: 快速启动 $x264Params = "aq-mode=3:aq-strength=0.8:deblock=-1,-1:ref=5:me=umh:subme=10:psy-rd=1.0,0.15"; $ffmpegCmd = "$ffmpegPath -i \"$inputFile\" -vf \"$vfFilter\" -c:v libx264 -preset slow -crf 15 -tune film -profile:v high -level 4.2 -pix_fmt yuv420p -x264-params \"$x264Params\" -c:a copy -movflags +faststart -y \"$outputFile\" 2>&1"; dLog('video_upgrade')->info('开始升级视频到720p', ['command' => $ffmpegCmd]); $output = shell_exec($ffmpegCmd); if (!file_exists($outputFile)) { dLog('video_upgrade')->error('视频升级失败', [ 'input' => $inputFile, 'output' => $output ]); @unlink($inputFile); return $videoUrl; } chmod($outputFile, 0664); $outputFileSize = filesize($outputFile); $sizeRatio = round($outputFileSize / $inputFileSize, 2); dLog('video_upgrade')->info('视频升级成功', [ 'input_size' => round($inputFileSize / 1024 / 1024, 2) . 'MB', 'output_size' => round($outputFileSize / 1024 / 1024, 2) . 'MB', 'size_ratio' => $sizeRatio, 'target_resolution' => "{$targetWidth}x{$targetHeight}" ]); // 上传到TOS - 使用普通的文件名,不暴露是升级后的视频 $video_name = 'ai_generation_' . time() . '_' . uniqid() . '.mp4'; $uploadedUrl = uploadStreamByTos($prefix, file_get_contents($outputFile), $video_name); // 清理临时文件 @unlink($inputFile); @unlink($outputFile); if (!$uploadedUrl) { dLog('video_upgrade')->error('升级后的视频上传失败'); return $videoUrl; } dLog('video_upgrade')->info('升级后的视频上传成功', ['url' => $uploadedUrl]); return $uploadedUrl; } catch (\Exception $e) { dLog('video_upgrade')->error('视频升级过程出错', [ 'url' => $videoUrl, 'error' => $e->getMessage() ]); // 清理可能存在的临时文件 if (isset($inputFile) && file_exists($inputFile)) { @unlink($inputFile); } if (isset($outputFile) && file_exists($outputFile)) { @unlink($outputFile); } // 出错时返回原视频URL return $videoUrl; } }