= 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", ' ', '
'], '', $string[$line]); if (!$string[$line]) { unset($string[$line]); } else { $string[$line] = '  ' . $string[$line] . '
'; $content .= $string[$line]; } } $content = trim($content, '
'); return $content; } /** * 书籍简介排版-抖音版 * * @param $content * @return string */ function filterIntro($content) { if (!$content) return ''; $content = str_replace( ['  ', '

', '
', '
', ' ', '

', '

', '“', '”', '…', '‘', '’', '—'], [' ', ' ', ' ', ' ', ' ', '', ' ', '“', '”', '...', '‘', '’', '-'], $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", ' ', '
'], '', $string[$line]); if (!$string[$line]) { unset($string[$line]); } else { $content .= $string[$line]; } } $content = trim($content, '
'); return $content; } /** * 压缩视频文件 * 压缩策略:3秒视频约1M~1.5M * * @param string $videoUrl 视频URL地址 * @param string $prefix 上传文件夹前缀(如:'videos') * @return string 返回压缩后的视频URL,失败返回空字符串 */ function compressVideo($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_') . bin2hex(random_bytes(4)); $videoExt = getVideoExtFromUrl($videoUrl); $inputFile = $tempDir . '/' . $uniqueId . '_input' . $videoExt; $outputFile = $tempDir . '/' . $uniqueId . '_output.mp4'; // 下载视频到本地 dLog('video_compress')->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_compress')->error('视频文件写入失败', [ 'url' => $videoUrl, 'file' => $inputFile, 'dir_writable' => is_writable($tempDir) ]); return ''; } // 设置文件权限 chmod($inputFile, 0664); if (!file_exists($inputFile)) { dLog('video_compress')->error('视频下载失败', ['url' => $videoUrl]); return ''; } $inputFileSize = filesize($inputFile); dLog('video_compress')->info('视频下载成功', [ 'url' => $videoUrl, 'size' => $inputFileSize, 'size_mb' => round($inputFileSize / 1024 / 1024, 2) . 'MB' ]); // 获取视频时长(秒) $ffprobePath = env('FFPROBE_PATH', 'ffprobe'); $durationCmd = "$ffprobePath -v error -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 \"$inputFile\""; $duration = (float)trim(shell_exec($durationCmd)); if ($duration <= 0) { dLog('video_compress')->error('无法获取视频时长', ['file' => $inputFile]); @unlink($inputFile); return ''; } // 计算目标码率 // 目标:3秒视频 = 1~1.5MB,即每秒约 0.33~0.5MB = 2730~4096 kbps // 使用中间值:3500 kbps $targetBitrate = 3500; // kbps // 根据视频时长动态调整码率,确保文件大小合理 // 目标文件大小 = duration * 0.4MB/s = duration * 400KB/s $targetFileSizeKB = $duration * 400; // KB $targetBitrateCalculated = (int)(($targetFileSizeKB * 8) / $duration); // kbps // 使用计算出的码率,但不低于1500kbps,不高于5000kbps $targetBitrate = max(1500, min(5000, $targetBitrateCalculated)); dLog('video_compress')->info('视频信息', [ 'duration' => $duration . 's', 'target_bitrate' => $targetBitrate . 'kbps', 'estimated_size' => round($targetFileSizeKB / 1024, 2) . 'MB' ]); // 使用FFmpeg压缩视频 // -c:v libx264: 使用H.264编码器 // -b:v: 视频码率 // -c:a aac: 音频编码器 // -b:a 128k: 音频码率 // -movflags +faststart: 优化网络播放 // -preset medium: 编码速度与质量平衡 $ffmpegPath = env('FFMPEG_PATH', 'ffmpeg'); $ffmpegCmd = "$ffmpegPath -i \"$inputFile\" -c:v libx264 -b:v {$targetBitrate}k -c:a aac -b:a 128k -movflags +faststart -preset medium -y \"$outputFile\" 2>&1"; dLog('video_compress')->info('开始压缩视频', ['command' => $ffmpegCmd]); $output = shell_exec($ffmpegCmd); if (!file_exists($outputFile)) { dLog('video_compress')->error('视频压缩失败', [ 'input' => $inputFile, 'output' => $output ]); @unlink($inputFile); return ''; } // 设置输出文件权限 chmod($outputFile, 0664); $outputFileSize = filesize($outputFile); $compressionRatio = round((1 - $outputFileSize / $inputFileSize) * 100, 2); dLog('video_compress')->info('视频压缩成功', [ 'input_size' => round($inputFileSize / 1024 / 1024, 2) . 'MB', 'output_size' => round($outputFileSize / 1024 / 1024, 2) . 'MB', 'compression_ratio' => $compressionRatio . '%', 'duration' => $duration . 's', 'size_per_second' => round($outputFileSize / $duration / 1024 / 1024, 2) . 'MB/s' ]); // 上传压缩后的视频到云存储 $stream = fopen($outputFile, 'r'); if (!$stream) { dLog('video_compress')->error('无法打开压缩后的视频文件', ['file' => $outputFile]); @unlink($inputFile); @unlink($outputFile); return ''; } $filename = $uniqueId . '.mp4'; // 根据环境变量选择上传方式 $uploadMethod = env('VIDEO_UPLOAD_METHOD', 'tos'); // 默认使用火山云 if ($uploadMethod === 'oss') { $compressedUrl = uploadStreamToOss($prefix, $stream, $filename); } else { $compressedUrl = uploadStreamByTos($prefix, $stream, $filename); } // 安全关闭文件流 if (is_resource($stream)) { fclose($stream); } // 清理临时文件 @unlink($inputFile); @unlink($outputFile); if (!$compressedUrl) { dLog('video_compress')->error('压缩视频上传失败'); return ''; } dLog('video_compress')->info('压缩视频上传成功', [ 'original_url' => $videoUrl, 'compressed_url' => $compressedUrl ]); return $compressedUrl; } catch (\Exception $e) { dLog('video_compress')->error('视频压缩异常', [ 'url' => $videoUrl, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); // 安全关闭文件流(如果存在) if (isset($stream) && is_resource($stream)) { @fclose($stream); } // 清理可能存在的临时文件 if (isset($inputFile) && file_exists($inputFile)) { @unlink($inputFile); } if (isset($outputFile) && file_exists($outputFile)) { @unlink($outputFile); } return ''; // 出错时返回空字符串 } } /** * 获取视频第一帧并上传 * 根据远程视频URL获取第一帧图片并保存到TOS或OSS(默认TOS) * * @param string $videoUrl 视频URL地址 * @param string $prefix 上传文件夹前缀(如:'videos') * @param string $uploadMethod 上传方式:'tos'(默认) 或 'oss' * @return string 返回图片URL地址,失败返回空字符串 */ function getVideoFirstFrame($videoUrl, $prefix = 'videos', $uploadMethod = 'tos') { if (env('APP_ENV') == 'local') return ''; try { // 创建临时目录 $tempDir = storage_path('app/temp/video_frames'); if (!is_dir($tempDir)) { mkdir($tempDir, 0775, true); chmod($tempDir, 0775); } // 生成唯一文件名 $uniqueId = uniqid('frame_') . bin2hex(random_bytes(4)); $videoExt = getVideoExtFromUrl($videoUrl); $inputFile = $tempDir . '/' . $uniqueId . '_video' . $videoExt; $outputFile = $tempDir . '/' . $uniqueId . '_frame.jpg'; // 下载视频到本地 dLog('video_frame')->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_frame')->error('视频文件写入失败', [ 'url' => $videoUrl, 'file' => $inputFile ]); return ''; } chmod($inputFile, 0664); if (!file_exists($inputFile)) { dLog('video_frame')->error('视频下载失败', ['url' => $videoUrl]); return ''; } dLog('video_frame')->info('视频下载成功', [ 'url' => $videoUrl, 'size_mb' => round(filesize($inputFile) / 1024 / 1024, 2) . 'MB' ]); // 使用FFmpeg提取第一帧 // -i: 输入文件 // -ss 00:00:00: 从视频开始位置(第0秒) // -vframes 1: 只提取1帧 // -q:v 2: 图片质量(1-31,数字越小质量越高) $ffmpegPath = env('FFMPEG_PATH', 'ffmpeg'); $ffmpegCmd = "$ffmpegPath -i \"$inputFile\" -ss 00:00:00 -vframes 1 -q:v 2 -y \"$outputFile\" 2>&1"; dLog('video_frame')->info('开始提取第一帧', ['command' => $ffmpegCmd]); $output = shell_exec($ffmpegCmd); if (!file_exists($outputFile)) { dLog('video_frame')->error('提取第一帧失败', [ 'input' => $inputFile, 'output' => $output ]); @unlink($inputFile); return ''; } chmod($outputFile, 0664); dLog('video_frame')->info('第一帧提取成功', [ 'size' => round(filesize($outputFile) / 1024, 2) . 'KB' ]); // 上传图片到云存储 $stream = fopen($outputFile, 'r'); if (!$stream) { dLog('video_frame')->error('无法打开图片文件', ['file' => $outputFile]); @unlink($inputFile); @unlink($outputFile); return ''; } $filename = $uniqueId . '.jpg'; // 根据参数选择上传方式,默认使用TOS if ($uploadMethod === 'oss') { $frameUrl = uploadStreamToOss($prefix, $stream, $filename); } else { $frameUrl = uploadStreamByTos($prefix, $stream, $filename); } // 安全关闭文件流 if (is_resource($stream)) { fclose($stream); } // 清理临时文件 @unlink($inputFile); @unlink($outputFile); if (!$frameUrl) { dLog('video_frame')->error('图片上传失败'); return ''; } dLog('video_frame')->info('图片上传成功', [ 'video_url' => $videoUrl, 'frame_url' => $frameUrl ]); return $frameUrl; } catch (\Exception $e) { dLog('video_frame')->error('提取视频第一帧异常', [ 'url' => $videoUrl, 'error' => $e->getMessage(), 'trace' => $e->getTraceAsString() ]); // 安全关闭文件流(如果存在) if (isset($stream) && is_resource($stream)) { @fclose($stream); } // 清理可能存在的临时文件 if (isset($inputFile) && file_exists($inputFile)) { @unlink($inputFile); } if (isset($outputFile) && file_exists($outputFile)) { @unlink($outputFile); } return ''; } } function sensitiveStr($list, $string) { $count = 0; //违规词的个数 $sensitiveWord = ''; //违规词 $stringAfter = $string; //替换后的内容 $total = count($list); $size = 500; $last = ceil($total / $size); $patternList = []; for ($page = 1; $page <= $last; $page++) { $arr = array_slice($list, $size * ($page - 1), $size); $filter = []; foreach ($arr as $v) { if (preg_match('/[^a-zA-Z0-9\|\p{Han}\·]/u', $v)) continue; $filter[] = $v; // $a = preg_replace('/[^a-zA-Z0-9\|\p{Han}]/u', '', $v); // if ($a) $filter[] = $a; } $pattern = "/" . implode("|", $filter) . "/i"; //定义正则表达式 if (preg_match_all($pattern, $string, $matches)) { //匹配到了结果 $patternList = array_merge($patternList, $matches[0]); //匹配到的数组 } } $sensitiveWord = ''; if ($patternList) { $count = count($patternList); // $sensitiveWord = implode(',', $patternList); //敏感词数组转字符串 $replaceArray = array_combine($patternList, array_fill(0, count($patternList), '*')); //把匹配到的数组进行合并,替换使用 $stringAfter = strtr($string, $replaceArray); //结果替换 // 将敏感词合并 $return_pattern = []; foreach ($patternList as $v) { if (!isset($return_pattern[$v])) { $return_pattern[$v] = [ 'word' => $v, 'count' => 1, ]; } else { $return_pattern[$v]['count'] += 1; } } foreach ($return_pattern as $v) { $sensitiveWord .= $v['word'] . ','; } } return [ 'count' => $count, 'sensitive_words' => trim($sensitiveWord, ','), 'content' => $stringAfter ]; } /** * 转换时间格式 * * @param $date * @param string $format * @return false|string */ function transDate($date, $format = 'Y-m-d H:i:s') { return strtotime($date) > 0 ? date($format, strtotime($date)) : ''; } /** * 根据网段获取计算所有IP * * @param string $segment 网段 '139.217.0.1/24' * @return array [网络地址:139.217.0.1 广播地址:139.217.0.255 IP列表: ['139.217.0.2','139.217.0.3'……'139.217.0.254']] */ function getIpBySegment($segment) { $segmentInfo = explode("/", $segment); $beginIpArray = explode(".", $segmentInfo[0]); $mask = intval($segmentInfo['1']); $endIp = array(); foreach ($beginIpArray as $ipKey => $item) { $beginFlag = 8 * ($ipKey); //0 8 16 24 $endFlag = 8 * ($ipKey + 1);//8 16 24 32 $decbinItem = str_pad(decbin($item), 8, "0", STR_PAD_LEFT); $endIp[] = $mask >= $endFlag ? $item : ($mask > $beginFlag ? bindec(str_pad(substr($decbinItem, 0, $mask - $beginFlag), 8, "1", STR_PAD_RIGHT)) : ($ipKey <= 2 ? pow(2, 8) - 1 : pow(2, 8) - 1)); } $ipArray = array(); for ($beginIp[0] = $beginIpArray[0]; $beginIp[0] <= $endIp[0]; $beginIp[0]++) { for ($beginIp[1] = $beginIpArray[1]; $beginIp[1] <= $endIp[1]; $beginIp[1]++) { for ($beginIp[2] = $beginIpArray[2]; $beginIp[2] <= $endIp[2]; $beginIp[2]++) { for ($beginIp[3] = $beginIpArray[3]; $beginIp[3] <= $endIp[3]; $beginIp[3]++) { $ipArray[] = implode(".", $beginIp); } } } } $network_ip_addr = $beginIpArray[0] . '.' . $beginIpArray[1] . '.' . $beginIpArray[2] . '.' . '0'; // 网络地址 $broadcast_ip_addr = end($ipArray); // 广播地址 if ($ipArray[0] == $network_ip_addr) { // 如果是网络地址则删掉 unset($ipArray[0]); } $last = count($ipArray); unset($ipArray[$last]); return [$network_ip_addr, $broadcast_ip_addr, $ipArray]; } /** * 在指定网段中分配子网段 * * @param string $segment 指定网段 * @param int $ipNum 需要的IP数 * @param array $usedIpArray 不可用(已经使用)的IP,默认为空数组 * @return bool|string 成功则返回分配的网段 */ function allocateSegment($segment, $ipNum, $usedIpArray = []) { $usedIpArray = empty($usedIpArray) ? [] : array_flip($usedIpArray); //计算需要多少个IP $i = 0; $ipCount = pow(2, $i); while ($ipCount < $ipNum) { $i++; $ipCount = pow(2, $i); } $newMask = 32 - $i; //大网段的开始和结束IP $segmentInfo = explode("/", $segment); //['139.217.0.1',24] $beginIpArray = explode(".", $segmentInfo[0]);//[139,217,0,1] $mask = intval($segmentInfo['1']); //24 if ($newMask < $mask) { return false; } $endIp = array(); $step = []; foreach ($beginIpArray as $ipKey => $item) { $beginFlag = 8 * ($ipKey); //0 8 16 24 $endFlag = 8 * ($ipKey + 1);//8 16 24 32 $step[$ipKey] = $newMask > $endFlag ? 1 : ($endFlag - $newMask < 8 ? pow(2, $endFlag - $newMask) : pow(2, 8)); $decbinItem = str_pad(decbin($item), 8, "0", STR_PAD_LEFT); $endIp[] = $mask >= $endFlag ? $item : ($mask > $beginFlag ? bindec(str_pad(substr($decbinItem, 0, $mask - $beginFlag), 8, "1", STR_PAD_RIGHT)) : ($ipKey <= 2 ? pow(2, 8) - 1 : pow(2, 8) - 1)); } //遍历生成网段 for ($beginIp[0] = $beginIpArray[0]; $beginIp[0] <= $endIp[0]; $beginIp[0] += $step[0]) { for ($beginIp[1] = $beginIpArray[1]; $beginIp[1] <= $endIp[1]; $beginIp[1] += $step[1]) { for ($beginIp[2] = $beginIpArray[2]; $beginIp[2] <= $endIp[2]; $beginIp[2] += $step[2]) { for ($beginIp[3] = $beginIpArray[3]; $beginIp[3] <= $endIp[3]; $beginIp[3] += $step[3]) { $newSegment = implode('.', $beginIp) . '/' . $newMask; //获取该网段所有的IP $ipArray = getIpBySegment($newSegment); $canUse = true; //判断该网段是否可用 if (!empty($usedIpArray)) { foreach ($ipArray as $ip) { if (isset($usedIpArray[$ip])) { $canUse = false; break; } } } if ($canUse) { return $newSegment; } } } } } return false; } function remove_xss($val) { // remove all non-printable characters. CR(0a) and LF(0b) and TAB(9) are allowed // this prevents some character re-spacing such as // note that you have to handle splits with \n, \r, and \t later since they *are* allowed in some inputs $val = preg_replace('/([\x00-\x08,\x0b-\x0c,\x0e-\x19])/', '', $val); // straight replacements, the user should never need these since they're normal characters // this prevents like $search = 'abcdefghijklmnopqrstuvwxyz'; $search .= 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'; $search .= '1234567890!@#$%^&*()'; $search .= '~`";:?+/={}[]-_|\'\\'; for ($i = 0; $i < strlen($search); $i++) { // ;? matches the ;, which is optional // 0{0,7} matches any padded zeros, which are optional and go up to 8 chars // @ @ search for the hex values $val = preg_replace('/(&#[xX]0{0,8}' . dechex(ord($search[$i])) . ';?)/i', $search[$i], $val); // with a ; // @ @ 0{0,7} matches '0' zero to seven times $val = preg_replace('/(�{0,8}' . ord($search[$i]) . ';?)/', $search[$i], $val); // with a ; } // now the only remaining whitespace attacks are \t, \n, and \r $ra1 = array('javascript', 'vbscript', 'expression', 'applet', 'meta', 'xml', 'blink', 'link', 'style', 'script', 'embed', 'object', 'iframe', 'frame', 'frameset', 'ilayer', 'layer', 'bgsound', 'title', 'base'); $ra2 = array( 'onabort', 'onactivate', 'onafterprint', 'onafterupdate', 'onbeforeactivate', 'onbeforecopy', 'onbeforecut', 'onbeforedeactivate', 'onbeforeeditfocus', 'onbeforepaste', 'onbeforeprint', 'onbeforeunload', 'onbeforeupdate', 'onblur', 'onbounce', 'oncellchange', 'onchange', 'onclick', 'oncontextmenu', 'oncontrolselect', 'oncopy', 'oncut', 'ondataavailable', 'ondatasetchanged', 'ondatasetcomplete', 'ondblclick', 'ondeactivate', 'ondrag', 'ondragend', 'ondragenter', 'ondragleave', 'ondragover', 'ondragstart', 'ondrop', 'onerror', 'onerrorupdate', 'onfilterchange', 'onfinish', 'onfocus', 'onfocusin', 'onfocusout', 'onhelp', 'onkeydown', 'onkeypress', 'onkeyup', 'onlayoutcomplete', 'onload', 'onlosecapture', 'onmousedown', 'onmouseenter', 'onmouseleave', 'onmousemove', 'onmouseout', 'onmouseover', 'onmouseup', 'onmousewheel', 'onmove', 'onmoveend', 'onmovestart', 'onpaste', 'onpropertychange', 'onreadystatechange', 'onreset', 'onresize', 'onresizeend', 'onresizestart', 'onrowenter', 'onrowexit', 'onrowsdelete', 'onrowsinserted', 'onscroll', 'onselect', 'onselectionchange', 'onselectstart', 'onstart', 'onstop', 'onsubmit', 'onunload' ); $ra = array_merge($ra1, $ra2); $found = true; // keep replacing as long as the previous round replaced something while ($found == true) { $val_before = $val; for ($i = 0; $i < sizeof($ra); $i++) { $pattern = '/'; for ($j = 0; $j < strlen($ra[$i]); $j++) { if ($j > 0) { $pattern .= '('; $pattern .= '(&#[xX]0{0,8}([9ab]);)'; $pattern .= '|'; $pattern .= '|(�{0,8}([9|10|13]);)'; $pattern .= ')*'; } $pattern .= $ra[$i][$j]; } $pattern .= '/i'; $replacement = substr($ra[$i], 0, 2) . '' . substr($ra[$i], 2); // add in <> to nerf the tag $val = preg_replace($pattern, $replacement, $val); // filter out the hex tags if ($val_before == $val) { // no replacements were made, so exit the loop $found = false; } } } return $val; } /** * 计算作者积分等级 * * @param $score */ function calcAuthorLevel($score): int { switch (true) { case $score <= 0: $level = 0; break; case $score <= 5000: $level = 1; break; case $score <= 50000: $level = 2; break; case $score <= 100000: $level = 3; break; case $score <= 300000: $level = 4; break; case $score <= 800000: $level = 5; break; case $score <= 1500000: $level = 6; break; case $score <= 2500000: $level = 7; break; case $score <= 5000000: $level = 8; break; case $score <= 10000000: $level = 9; break; default: $level = 10; break; } return $level; } /** * 运营数据(上传附件) * * @param $file * @return string * @throws Exception */ function uploadEnclosureFile($file) { // 阿里云主账号 $accessKeyId = env('OSS_ACCESS_ID'); $accessKeySecret = env('OSS_ACCESS_KEY'); $endpoint = env('OSS_END_POINT'); $bucket = env('OSS_BUCKET'); // 设置文件名称。 $object = 'books/enclosure/' . randStr(10) . '--' . $file->getClientOriginalName(); 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') : ''; } // 获取当前域名是http还是https function getHttpType() { return ((isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] == 'on') || (isset($_SERVER['HTTP_X_FORWARDED_PROTO']) && $_SERVER['HTTP_X_FORWARDED_PROTO'] == 'https')) ? 'https://' : 'http://'; } // 根据id生成唯一邀请码 function enCodeId($user_id) { $key = 'XzeTdSPQc1uYHRBVWmUE6x94q25g3krfCGhb8FjtDZvMNKJpnayw7s'; $num = strlen($key); $code = ''; // 邀请码 while ($user_id > 0) { // 转进制 $mod = $user_id % $num; // 求模 $user_id = ($user_id - $mod) / $num; $code = $key[$mod] . $code; } $code = str_pad($code, 6, 'A', STR_PAD_LEFT); // 不足用0补充 return $code; } // 根据邀请码解密为id function deCodeId($code) { $key = 'XzeTdSPQc1uYHRBVWmUE6x94q25g3krfCGhb8FjtDZvMNKJpnayw7s'; $num = strlen($key); if (strrpos($code, '0') !== false) $code = substr($code, strrpos($code, '0') + 1); $len = strlen($code); $code = strrev($code); $user_id = 0; for ($i = 0; $i < $len; $i++) { $user_id += strpos($key, $code[$i]) * pow($num, $i); } return $user_id; } /** * 将二维数组按其中的某个数组排序(此方法适用于将数据库数据按数组取出后自动按ID排序的情况 ps:即未按该数组排序) * * @param array $array 二维数组 * @param array $sort 排序数组 * @param string $field 排序字段(二维数组和排序数组相同的字段) * @return array */ function sortByArray(array $array, array $sort, string $field): array { $data = []; if (is_array($array) && is_array($sort)) { foreach ($sort as $v) { foreach ($array as $key => $val) { if ($v == $val[$field]) { array_push($data, $array[$key]); } } } } return $data; } /** * 将二维数组按其中的字段排序(正序或倒序) * * @param array $array 二维数组 * @param string $field 排序字段 * @param mixed $type 排序方式(3倒序,4正序) * @return array|mixed */ function sortByField(array $array, string $field, $type): array { if (is_array($array)) { array_multisort(array_column($array, $field), $type, $array); } return $array; } // 生成用户邀请码 function setUserInviteCode($id) { return \Vinkla\Hashids\Facades\Hashids::connection('invite')->encode($id); } // 解密用户邀请码 function decodeUserInviteCode($code) { return \Vinkla\Hashids\Facades\Hashids::connection('invite')->decode($code); } function getMillisecond() { list($microsecond, $time) = explode(' ', microtime()); return (float)sprintf('%.0f', (floatval($microsecond) + floatval($time)) * 1000); } function get_client_ip($type = 0, $adv = false) { $type = $type ? 1 : 0; static $ip = null; if (null !== $ip) { return $ip[$type]; } if ($adv) { if (isset($_SERVER['HTTP_X_FORWARDED_FOR'])) { $arr = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); $pos = array_search('unknown', $arr); if (false !== $pos) { unset($arr[$pos]); } $ip = trim($arr[0]); } elseif (isset($_SERVER['HTTP_CLIENT_IP'])) { $ip = $_SERVER['HTTP_CLIENT_IP']; } elseif (isset($_SERVER['REMOTE_ADDR'])) { $ip = $_SERVER['REMOTE_ADDR']; } } elseif (isset($_SERVER['REMOTE_ADDR'])) { $ip = $_SERVER['REMOTE_ADDR']; } // IP地址合法验证 $long = sprintf("%u", ip2long($ip)); $ip = $long ? array($ip, $long) : array('0.0.0.0', 0); return $ip[$type]; } /** * 获取真实IP */ function _getIp() { if (getenv('HTTP_X_FORWARDED_FOR')) { $ip = getenv('HTTP_X_FORWARDED_FOR'); } else if (getenv("HTTP_CLIENT_IP") && strcasecmp(getenv("HTTP_CLIENT_IP"), "unknown")) $ip = getenv("HTTP_CLIENT_IP"); else if (getenv("HTTP_X_FORWARD_FOR") && strcasecmp(getenv("HTTP_X_FORWARD_FOR"), "unknown")) $ip = getenv("HTTP_X_FORWARD_FOR"); else if (getenv("REMOTE_ADDR") && strcasecmp(getenv("REMOTE_ADDR"), "unknown")) $ip = getenv("REMOTE_ADDR"); else if (isset($_SERVER['REMOTE_ADDR']) && $_SERVER['REMOTE_ADDR'] && strcasecmp($_SERVER['REMOTE_ADDR'], "unknown")) $ip = $_SERVER['REMOTE_ADDR']; else $ip = "unknown"; return ($ip); } /** * 数组 转 对象 * * @param array $arr 数组 * @return object */ function array_to_object($arr) { if (gettype($arr) != 'array') { return; } foreach ($arr as $k => $v) { if (gettype($v) == 'array' || getType($v) == 'object') { $arr[$k] = (object)array_to_object($v); } } return (object)$arr; } /** * 对象 转 数组 * * @param object $obj 对象 * @return array */ function object_to_array($obj) { $obj = (array)$obj; foreach ($obj as $k => $v) { if (gettype($v) == 'resource') { return; } if (gettype($v) == 'object' || gettype($v) == 'array') { $obj[$k] = (array)object_to_array($v); } } return $obj; } /** * 检查是否为手机号码 */ function _isPhone($number) { return preg_match("/^1[34578][0-9]{9}$/", $number); } /** * 判断所传的参数是否缺少,如果缺少返回渠道的字段,正确返回0 * * @param array $param * @param array $must * @return int|mixed */ function checkParam(array $param, array $must) { foreach ($must as $item) { if (array_key_exists($item, $param) && $param[$item] != '') { } else { return $item; } } return 0; } /** * 对象 转 数组 * * @param object $obj 对象 * @return array */ function ignoreKeyInArray($targetArray, $delete_keys = [], $changes = []) { $change_keys = array_keys($changes); foreach ($targetArray as $key => $value) { if (in_array($key, $delete_keys) && isset($targetArray[$key])) unset($targetArray[$key]); if (in_array($key, $change_keys) && isset($targetArray[$key])) $targetArray[$key] = $changes[$key]; if (is_array($value)) ignoreKeyInArray($value, $delete_keys, $change_keys); } return $targetArray; } function itemTransform($trans, $data) { if ($data) { return $trans->transform($data); } else { return []; } } function collectionTransform($trans, $data) { $ret_data = []; if ($data) { foreach ($data as $item) { $ret_data[] = $trans->transform($item); } } return $ret_data; } function paginationTransform($trans, $paginator) { $ret = []; $ret['list'] = []; if ($paginator) { foreach ($paginator as $item) { $ret['list'][] = $trans->transform($item); } $ret['meta'] = [ 'total' => (int)$paginator->total(), 'per_page' => (int)$paginator->perPage(), 'current_page' => (int)$paginator->currentPage(), 'last_page' => (int)$paginator->lastPage(), 'next_page_url' => (string)$paginator->nextPageUrl(), 'prev_page_url' => (string)$paginator->previousPageUrl() ]; } return $ret; } /** * 加密site id */ function encodeDistributionChannelId($id) { $encrypt_pool = [ ]; if (isset($encrypt_pool[$id])) { return $encrypt_pool[$id]; } $hashids = new \Hashids\Hashids('', 16, 'abcdefghjklmnopqrstuvwxyz1234567890'); return $hashids->encode($id); } /** * 解密密site id */ function decodeDistributionChannelId($code) { $encrypt_pool = [ ]; if (isset($encrypt_pool[$code])) { return $encrypt_pool[$code]; } $hashids = new \Hashids\Hashids('', 16, 'abcdefghjklmnopqrstuvwxyz1234567890'); $res = $hashids->decode($code); if ($res && isset($res[0])) { return $res[0]; } return null; } //bid加密 function book_hash_encode($bid) { return Vinkla\Hashids\Facades\Hashids::encode($bid); } function decodeBid($encode_bid) { $bid = 0; try { $bid_arr = \Hashids::decode($encode_bid); if (isset($bid_arr[0])) { $bid = $bid_arr[0]; } } catch (\Exception $e) { return null; } return $bid; } /** * 获取当前域名 */ function _domain() { return str_replace('https://', '', str_replace('http://', '', url('/'))); } /** * 字符串转* * * @param $str // 待转的字符串 * @param $start // 转*起始位置 * @param int $end // 转*结束位置 * @param string $dot // 转换的字符(必须是单字符,默认是*) * @param string $charset // 编码方式 * @param string $end_char // 特殊字符(碰到此字符则确定end位置) * @return string */ function trans_pass($str, $start, $end = 0, $dot = "*", $charset = "UTF-8", $end_char = '@'): string { $len = mb_strlen($str, $charset); if ($start == 0 || $start > $len) { $start = 1; } if ($end != 0 && $end > $len) { $end = $len - 2; } if (strstr($str, $end_char)) { $end = $len - strrpos($str, $end_char); } $endStart = $len - $end; $top = mb_substr($str, 0, $start, $charset); $bottom = ""; if ($endStart > 0) { $bottom = mb_substr($str, $endStart, $end, $charset); } $len -= mb_strlen($top, $charset); $len -= mb_strlen($bottom, $charset); $newStr = $top; for ($i = 0; $i < $len; $i++) { $newStr .= $dot; } $newStr .= $bottom; return $newStr; } /** * 格式化章节内容 * * @param $content * @return false|string */ function formatContent($content) { if (!$content) return ''; $content = str_replace( ['  ', '

', '
', '
', ' ', '

', '

', '“', '”', '…'], [' ', 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; } }