= 224) {
$stringTMP = mb_substr($string, $i, 3);
$i += 3;
} elseif (ord($stringTMP) >= 192) {
$stringTMP = mb_substr($string, $i, 2);
$i += 2;
} else {
++$i;
}
$stringLast[] = $stringTMP;
}
$stringLast = implode('', $stringLast);
if ($append) {
$stringLast .= '...';
}
return $stringLast;
}
/**
* 自定义日志
*
* @param $name
* @param string $filename
* @return \Illuminate\Log\Writer
*/
function myLog($name, $filename = '')
{
if (!$filename) {
$filename = $name;
}
$filename = $filename . '.log';
$logger = new Logger($name);
$writer = new \Illuminate\Log\Writer($logger);
$writer->useDailyFiles(storage_path('logs/' . $filename));
return $writer;
}
/**
* 业务调试日志
*
* @return Logger
*/
function commonLog()
{
$name = 'common';
$filename = $name . '.log';
$logger = new Logger($name);
$logger->pushHandler(new StreamHandler(storage_path('logs/' . $filename), 'debug'));
return $logger;
}
/**
* @param $name
* @return Logger
*/
function dLog($name)
{
$filename = $name . '-' . date('Y-m-d') . '.log';
$logger = new Logger($name);
$logger->pushHandler(new StreamHandler(storage_path('logs/' . $filename), 'debug'));
return $logger;
}
/**
* 获取对象或数组的属性值
*
* @param $param
* @param $key
* @param string $default
* @return mixed|string
*/
function getProp($param, $key, $default = '')
{
$result = $default;
if (is_object($param) && isset($param->$key)) {
$result = $param->$key;
}
if (is_array($param) && isset($param[$key])) {
$result = $param[$key];
}
return $result;
}
/**
* @param $data
* @param array $trans
* @return array
*/
function assetData($data, $trans = []): array
{
$result = [];
if (empty($data)) {
return $result;
}
if (empty($trans)) {
return $data;
}
foreach ($trans as $tran) {
[$originKey, $newKey, $conv, $default] = [$tran['o'], $tran['n'], $tran['conv'], $tran['default']];
if (isset($data[$originKey])) {
$result[$newKey] = $conv(getProp($data, $originKey, $default));
}
}
return $result;
}
/**
* 随机数
*
* @param int $num
* @return string
* @throws Exception
*/
function random($num = 16)
{
$bytes = random_bytes($num);
return bin2hex($bytes);
}
/**
* 生成订单
*
* @param string $prefix 订单前缀
* @return string
*/
function generateOrderSn($prefix = '')
{
return $prefix . date('YmdHis') . getMillisecond() . rand(1000, 9999);
}
/**
* 分页数据
*
* @param $data
* @return array
*/
function getMeta($data)
{
$currentPage = (int)$data->currentPage();
$lastPage = (int)$data->lastPage();
return [
'current_page' => $currentPage,
'next_page' => $currentPage >= $lastPage ? $lastPage : ++$currentPage,
'last_page' => $lastPage,
'per_page' => (int)$data->perPage(),
'total' => (int)$data->total(),
'is_end' => !$data->hasMorePages(),
'next_page_url' => (string)$data->nextPageUrl(),
'prev_page_url' => (string)$data->previousPageUrl()
];
}
/**
* 钉钉通知异常
*
* @param $message
*/
function sendNotice($message)
{
$webHook = env('DD_WEB_HOOK');
$data = [
'msgtype' => 'text',
'text' => [
'content' => $message
],
'at' => [
'isAll' => true
]
];
// 异步发送
$client = new GuzzleHttp\Client();
$client->post($webHook, ['json' => $data]);
}
/**
* 判断数据是合法的json数据
*
* @param $string
* @return bool
*/
function is_json($string)
{
json_decode($string);
return (json_last_error() == JSON_ERROR_NONE);
}
/**
* 获取ip地址
*
* @return mixed|string
*/
function getIpAddr()
{
$ipaddress = '';
if (isset($_SERVER['HTTP_CLIENT_IP']))
$ipaddress = $_SERVER['HTTP_CLIENT_IP'];
else if (isset($_SERVER['HTTP_X_FORWARDED_FOR']))
$ipaddress = $_SERVER['HTTP_X_FORWARDED_FOR'];
else if (isset($_SERVER['HTTP_X_FORWARDED']))
$ipaddress = $_SERVER['HTTP_X_FORWARDED'];
else if (isset($_SERVER['HTTP_FORWARDED_FOR']))
$ipaddress = $_SERVER['HTTP_FORWARDED_FOR'];
else if (isset($_SERVER['HTTP_FORWARDED']))
$ipaddress = $_SERVER['HTTP_FORWARDED'];
else if (isset($_SERVER['REMOTE_ADDR']))
$ipaddress = $_SERVER['REMOTE_ADDR'];
else
$ipaddress = 'UNKNOWN';
return $ipaddress;
}
/**
* @param int $num
* @return bool|string
* @throws Exception
*/
function randStr($num = 5)
{
$str = 'QWERTYUIOPASDFGHJKLZXCVBNM1234567890qwertyuiopasdfghjklzxcvbnm';
return substr(str_shuffle($str), random_int(0, strlen($str) - 11), $num);
}
/**
* 上传文件(火山云tos)
*
* @param $prefix 文件夹前缀
* @param $file 文件二进制
* @param $filename 文件名(不带后缀)
* @return mixed
*/
function uploadFileByTos($prefix, $file, $filename='')
{
if (!$filename) $filename = randStr(10) . '.' . $file->getClientOriginalExtension();
else $filename = $filename . '.' . $file->getClientOriginalExtension();
try {
$client = new TosClient([
'region' => env('VOLC_REGION'),
'endpoint' => env('VOLC_END_POINT'),
'ak' => env('VOLC_AK'),
'sk' => env('VOLC_SK'),
]);
$stream = fopen($file->getRealPath(), 'r');
$input = new PutObjectInput(env('VOLC_BUCKET'), "$prefix/$filename", $stream);
$output = $client->putObject($input);
if ($output->getRequestId()) {
return "https://".env('VOLC_BUCKET').'.'.env('VOLC_END_POINT')."/$prefix/$filename";
}
} catch (TosClientException $ex) {
dLog('exception')->info('saveToTos', [$ex->getMessage()]);
} catch (TosServerException $ex) {
dLog('exception')->info('saveToTos', [$ex->getRequestId(), $ex->getStatusCode(), $ex->getErrorCode()]);
}
return '';
}
/**
* 上传文件流(火山云tos)
*
* @param $prefix 文件夹前缀
* @param $file 文件二进制
* @param $filename 文件名(带后缀)
* @return mixed
*/
function uploadStreamByTos($prefix, $stream, $filename)
{
try {
$client = new TosClient([
'region' => env('VOLC_REGION'),
'endpoint' => env('VOLC_END_POINT'),
'ak' => env('VOLC_AK'),
'sk' => env('VOLC_SK'),
]);
$input = new PutObjectInput(env('VOLC_BUCKET'), "$prefix/$filename", $stream);
$output = $client->putObject($input);
if ($output->getRequestId()) {
return "https://".env('VOLC_BUCKET').'.'.env('VOLC_END_POINT')."/$prefix/$filename";
}
} catch (TosClientException $ex) {
dLog('exception')->info('saveToTos', [$ex->getMessage()]);
} catch (TosServerException $ex) {
dLog('exception')->info('saveToTos', [$ex->getRequestId(), $ex->getStatusCode(), $ex->getErrorCode()]);
}
return '';
}
// 从图片URL获取图片扩展名
function getImgExtFromUrl(string $url): ?string {
$imageInfo = @getimagesize($url);
if ($imageInfo !== false) {
// 返回MIME类型
return mimeToExt($imageInfo['mime']);
// 或者返回文件扩展名
// return image_type_to_extension($imageInfo[2], false);
}
return null;
}
/**
* 从视频URL获取视频扩展名
* 通过下载文件头部字节来识别真实的视频格式
*
* @param string $url 视频URL
* @return string 返回扩展名(如 .mp4),默认 .mp4
*/
function getVideoExtFromUrl(string $url): string {
try {
// 方法1:从URL路径中提取扩展名
$parsedUrl = parse_url($url);
$path = $parsedUrl['path'] ?? '';
$pathInfo = pathinfo($path);
$extension = $pathInfo['extension'] ?? '';
if ($extension) {
// 验证是否为常见视频格式
$validVideoExts = ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm', 'm4v', 'mpeg', 'mpg'];
if (in_array(strtolower($extension), $validVideoExts)) {
return '.' . strtolower($extension);
}
}
// 方法2:下载文件头部字节来识别格式
$client = new \GuzzleHttp\Client(['timeout' => 30]);
// 先尝试HEAD请求获取Content-Type
try {
$response = $client->head($url);
$contentType = $response->getHeader('Content-Type')[0] ?? '';
// 根据Content-Type映射扩展名
$mimeMap = [
'video/mp4' => '.mp4',
'video/x-msvideo' => '.avi',
'video/quicktime' => '.mov',
'video/x-ms-wmv' => '.wmv',
'video/x-flv' => '.flv',
'video/x-matroska' => '.mkv',
'video/webm' => '.webm',
'video/mpeg' => '.mpeg',
];
if (isset($mimeMap[$contentType])) {
return $mimeMap[$contentType];
}
} catch (\Exception $e) {
// HEAD请求失败,继续尝试其他方法
}
// 方法3:下载前几个字节分析文件魔数(Magic Number)
$response = $client->get($url, [
'stream' => true,
'headers' => [
'Range' => 'bytes=0-31' // 只下载前32字节
]
]);
$header = $response->getBody()->read(32);
// 根据文件魔数判断格式
// MP4/M4V: 以 ftyp 开头(偏移4-8字节)
if (strpos($header, 'ftyp') !== false) {
// 进一步判断是否为 M4V
if (strpos($header, 'M4V') !== false || strpos($header, 'm4v') !== false) {
return '.m4v';
}
return '.mp4';
}
// AVI: 以 RIFF 开头,包含 AVI
if (substr($header, 0, 4) === 'RIFF' && strpos($header, 'AVI') !== false) {
return '.avi';
}
// MOV: 包含 moov 或 mdat
if (strpos($header, 'moov') !== false || strpos($header, 'mdat') !== false) {
return '.mov';
}
// FLV: 以 FLV 开头
if (substr($header, 0, 3) === 'FLV') {
return '.flv';
}
// WebM: 以 0x1A 0x45 0xDF 0xA3 开头
if (ord($header[0]) === 0x1A && ord($header[1]) === 0x45 &&
ord($header[2]) === 0xDF && ord($header[3]) === 0xA3) {
return '.webm';
}
// MKV: 与WebM类似,但包含 matroska
if (strpos($header, 'matroska') !== false) {
return '.mkv';
}
// MPEG: 以 0x00 0x00 0x01 开头
if (ord($header[0]) === 0x00 && ord($header[1]) === 0x00 && ord($header[2]) === 0x01) {
return '.mpeg';
}
// 默认返回 .mp4(最常见的格式)
return '.mp4';
} catch (\Exception $e) {
// 出错时返回默认格式
return '.mp4';
}
}
// 辅助 MIME 映射函数
function mimeToExt(string $mime): ?string {
$map = [
'audio/wav' => '.wav',
'audio/x-ms-wma' => '.wma',
'video/x-ms-wmv' => '.wmv',
'video/mp4' => '.mp4',
'audio/mpeg' => '.mp3',
'audio/amr' => '.amr',
'application/vnd.rn-realmedia' => '.rm',
'audio/mid' => '.mid',
'image/bmp' => '.bmp',
'image/gif' => '.gif',
'image/png' => '.png',
'image/tiff' => '.tiff',
'image/jpeg' => '.jpg',
'application/pdf' => '.pdf',
];
return $map[$mime] ?? null;
}
/**
* 上传文件(阿里云oss)
*
* @param $prefix 文件夹前缀
* @param $file 文件二进制
* @param $filename 文件名(不带后缀)
* @return mixed
*/
function uploadFile($prefix, $file, $filename='')
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
if (!$filename) $filename = randStr(10) . '.' . $file->getClientOriginalExtension();
else $filename = $filename . '.' . $file->getClientOriginalExtension();
// 设置文件名称。
$object = env('OSS_DIRECTORY') . '/' . $prefix . '/' . $filename;
$provider = new \OSS\Credentials\StaticCredentialsProvider($accessKeyId, $accessKeySecret);
try {
$configs = [
"provider" => $provider,
"endpoint" => $endpoint,
"signatureVersion" => 'v4',
"region" => "cn-hangzhou"
];
$ossClient = new OssClient($configs);
$uploadRes = $ossClient->uploadFile($bucket, $object, $file->path());
} catch (OssException $e) {
dLog('exception')->info('saveImageToOss', [$e->getMessage()]);
return '';
}
// 替换域名
if (!isset($uploadRes['oss-request-url'])) return '';
$url = str_ireplace('zw-ai.oss-cn-hangzhou.aliyuncs.com', 'cdn-zwai.ycsd.cn', $uploadRes['oss-request-url']);
$url = urldecode($url);
return str_ireplace('http://', 'https://', $url);
}
/**
* 上传文件流(阿里云oss)
*
* @param $prefix 文件夹前缀
* @param $file 文件二进制
* @param $filename 文件名(带后缀)
* @return mixed
*/
function uploadStreamToOss($prefix, $stream, $filename) {
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
// if (!$filename) $filename = randStr(10) . '.' . $file->getClientOriginalExtension();
// 设置文件名称。
$object = env('OSS_DIRECTORY') . '/' . $prefix . '/' . $filename;
$provider = new \OSS\Credentials\StaticCredentialsProvider($accessKeyId, $accessKeySecret);
try{
$configs = [
"provider" => $provider,
"endpoint" => $endpoint,
"signatureVersion" => 'v4',
"region"=> "cn-hangzhou"
];
$ossClient = new OssClient($configs);
$uploadRes = $ossClient->putObject($bucket, $object, $stream);
} catch (OssException $e) {
dLog('exception')->info('saveImageToOss', [$e->getMessage()]);
return '';
}
// 替换域名
if (!isset($uploadRes['oss-request-url'])) return '';
$url = str_ireplace('zw-ai.oss-cn-hangzhou.aliyuncs.com', 'cdn-zwai.ycsd.cn', $uploadRes['oss-request-url']);
$url = urldecode($url);
return str_ireplace('http://', 'https://', $url);
}
/**
* 下载文件到本地
*
* @param $object
* @param $localFile
* @return string
*/
function downloadFile($object, $localFile)
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
try {
$ossClient = new OssClient($accessKeyId, $accessKeySecret, $endpoint);
$ossClient->getObject($bucket, $object, [
OssClient::OSS_FILE_DOWNLOAD => $localFile
]);
} catch (OssException $e) {
printf($e->getMessage());
return '';
}
}
/**
* 上传封面
*
* @param $file
* @return string
* @throws Exception
*/
function uploadCoverFile($file)
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
// 设置文件名称。
$object = 'books/cover/' . randStr(10) . '.' . $file->getClientOriginalExtension();
try {
$ossClient = new OssClient($accessKeyId, $accessKeySecret, $endpoint);
$ossImgBackData = $ossClient->uploadFile($bucket, $object, $file->path());
} catch (OssException $e) {
printf($e->getMessage());
return '';
}
$urlArr = parse_url($ossImgBackData['oss-request-url']);
return getProp($urlArr, 'path') ? 'http://' . $bucket . '.' . $endpoint . getProp($urlArr, 'path') : '';
}
function uploadAgreementFile($file)
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET');
$file_name = $file->getClientOriginalName();
$file_name = str_replace('.' . $file->getClientOriginalExtension(), '', $file_name);
// 设置文件名称。
$object = 'books/contract/' . $file_name . '-' . date('YmdHi') . '.' . $file->getClientOriginalExtension();
try {
$ossClient = new OssClient($accessKeyId, $accessKeySecret, $endpoint);
$ossImgBackData = $ossClient->uploadFile($bucket, $object, $file);
} catch (OssException $e) {
printf($e->getMessage());
return '';
}
$urlArr = parse_url($ossImgBackData['oss-request-url']);
return getProp($urlArr, 'path') ? 'http://' . $bucket . '.' . $endpoint . urldecode(getProp($urlArr, 'path')) : '';
}
/**
* 上传wap推荐图片
*
* @param $file
* @param $filename
* @return string
* @throws Exception
*/
function uploadWapRecommendPic($file, $filename)
{
// 阿里云主账号
$accessKeyId = env('OSS_ACCESS_ID');
$accessKeySecret = env('OSS_ACCESS_KEY');
$endpoint = env('OSS_END_POINT');
$bucket = env('OSS_BUCKET_YCSD');
// 设置文件名称。
$object = 'ycsd_web_3nd/images/homebanners/' . $filename . '.' . $file->getClientOriginalExtension();
try {
$ossClient = new OssClient($accessKeyId, $accessKeySecret, $endpoint);
$options = array(
OssClient::OSS_HEADERS => array(
'Content-Type' => 'image/jpeg',
'Content-Disposition' => 'inline'
),
);
$ossImgBackData = $ossClient->uploadFile($bucket, $object, $file->path(), $options);
} catch (OssException $e) {
printf($e->getMessage());
return '';
}
$urlArr = parse_url($ossImgBackData['oss-request-url']);
return getProp($urlArr, 'path') ? 'http://' . $bucket . '.' . $endpoint . getProp($urlArr, 'path') : '';
}
/**
* @param $path
* @return Generator
*/
function readFileContent($path)
{
if ($handle = fopen($path, 'r')) {
while (!feof($handle)) {
yield trim(fgets($handle));
}
fclose($handle);
}
}
function exportFileCsv(array $headers, array $data, string $filename)
{
header("Content-type:application/vnd.ms-excel");
header("Content-Disposition:attachment;filename=" . $filename . ".csv");
$headers = collect($headers)->map(function ($item) {
return "\"" . mb_convert_encoding($item, "GBK", "UTF-8") . "\"";
})->all();
echo implode(",", $headers);
echo "\r\n";
foreach ($data as $item) {
$rows = collect($item)->map(function ($row) {
return "\"" . mb_convert_encoding(is_numeric($row) && strlen($row) > 12 ? "'" . $row : $row, "GBK", "UTF-8") . "\"";
})->all();
echo implode(",", $rows);
echo "\r\n";
}
exit();
}
//function exportFileCsv(array $header, array $data, string $filename) {
// header('Content-Encoding: UTF-8');
// header("Content-type:application/vnd.ms-excel;charset=UTF-8");
// header('Content-Disposition: attachment;filename="' . $filename . '.csv"');
//
// //打开php标准输出流
// $fp = fopen('php://output', 'a');
//
// //添加BOM头,以UTF8编码导出CSV文件,如果文件头未添加BOM头,打开会出现乱码。
// fwrite($fp, chr(0xEF).chr(0xBB).chr(0xBF));
// //添加导出标题
// fputcsv($fp, $header);
//
// foreach ($data as $k => $item) {
// fputcsv($fp, $item);
// if ($k % 5000 == 0) {
// //每1万条数据就刷新缓冲区
// ob_flush();
// flush();
// }
// }
// exit();
//}
//function exportFileCsv(array $header, array $data, string $filename) {
//
// header('Content-Type: application/vnd.ms-excel');
// header('Content-Disposition: attachment;filename="'.$filename.'.csv"');
// header('Cache-Control: max-age=0');
//
// //打开PHP文件句柄,php://output 表示直接输出到浏览器
// $fp = fopen('php://output', 'a');
//
// //输出Excel列名信息
// foreach ($header as $key => $value) {
// //CSV的Excel支持GBK编码,一定要转换,否则乱码
// $header[$key] = iconv('utf-8', 'gbk', $value);
// }
//
// //将数据通过fputcsv写到文件句柄
// fputcsv($fp, $header);
//
// //计数器
// $num = 0;
//
// //每隔$limit行,刷新一下输出buffer,不要太大,也不要太小
// $limit = 10000;
//
// //逐行取出数据,不浪费内存
// $count = count($data);
// for ($i = 0; $i < $count; $i++) {
//
// $num++;
//
// //刷新一下输出buffer,防止由于数据过多造成问题
// if ($limit == $num) {
// ob_flush();
// flush();
// $num = 0;
// }
//
// $row = $data[$i];
// foreach ($row as $key => $value) {
// $row[$key] = iconv('utf-8', 'gbk', $value);
// }
//
// fputcsv($fp, $row);
// }
// exit();
//}
// 获取除空格外的字数
function getSize(string $content)
{
$content = preg_replace('/\s+/', '', $content);
return mb_strlen($content, 'utf-8');
}
// 获取word字符数(不计空格)
function getChargeSize(string $content)
{
//判断是否存在替换字符
$is_replace_count = substr_count($content, "龘");
try {
//先将回车换行符做特殊处理
$str = preg_replace('/(\r\n+|\s+| +)/', "龘", $content);
//处理英文字符数字,连续字母、数字、英文符号视为一个单词
$str = preg_replace('/[a-z_A-Z0-9-\.!@#\$%\\\^&\*\)\(\+=\{\}\[\]\/",\'<>~`\?:;|]/', "m", $str);
//合并字符m,连续字母、数字、英文符号视为一个单词
$str = preg_replace('/m+/', "*", $str);
//去掉回车换行符
$str = preg_replace('/龘+/', "", $str);
//返回字数
return mb_strlen($str) + $is_replace_count;
} catch (\Exception $e) {
return 0;
}
}
// 将阿拉伯数字转换成中文
function chineseNum($figure, $capital = false, $mode = true)
{
if ($figure == '0') return '零';
$numberChar = ['零', '一', '二', '三', '四', '五', '六', '七', '八', '九'];
$unitChar = ['', '十', '百', '千', '', '万', '亿', '兆', '京', '垓', '秭', '穣', '沟', '涧', '正', '载', '极', '恒河沙', '阿僧祇', '那由他', '不可思议', '无量大数'];
if ($capital !== false) {
$numberChar = ['零', '壹', '贰', '叁', '肆', '伍', '陆', '柒', '捌', '玖'];
$unitChar = ['', '拾', '佰', '仟', '', '万', '亿', '兆', '京', '垓', '秭', '穣', '沟', '涧', '正', '载', '极', '恒河沙', '阿僧祇', '那由他', '不可思议', '无量大数'];
}
$dec = "点";
$target = '';
$matches = [];
if ($mode) {
preg_match("/^0*(\d*)\.?(\d*)/", $figure, $matches);
} else {
preg_match("/(\d*)\.?(\d*)/", $figure, $matches);
}
list(, $number, $point) = $matches;
if ($point) {
$target = $dec . chineseNum($point, $capital, false);
}
if (!$number) {
return $target;
}
$str = strrev($number);
for ($i = 0; $i < strlen($str); $i++) {
$out[$i] = $numberChar[$str[$i]];
if ($mode === false) {
continue;
}
$out[$i] .= $str[$i] != '0' ? $unitChar[$i % 4] : '';
if ($i > 0 && $str[$i] + $str[$i - 1] == 0) {
$out[$i] = '';
}
if ($i % 4 == 0) {
$temp = substr($str, $i, 4);
$out[$i] = str_replace($numberChar[0], '', $out[$i]);
if (strrev($temp) > 0) {
$out[$i] .= $unitChar[4 + floor($i / 4)];
} else {
$out[$i] .= $numberChar[0];
}
}
}
$result = join('', array_reverse($out)) . $target;
return mb_substr($result, 0, 2) == '一十' ? mb_substr($result, 1) : $result;
}
function addPrefix($str)
{
if (!$str) return '';
if (mb_substr($str, 0, 4) == 'http') return $str;
if (mb_substr($str, 0, 5) == 'https') return $str;
if (mb_substr($str, 0, 7) == '/books/') return 'http://zwcontent.oss-cn-hangzhou.aliyuncs.com' . $str;
if (mb_substr($str, 0, 6) == '/card/') return 'http://zwcontent.oss-cn-hangzhou.aliyuncs.com' . $str;
if (mb_substr($str, 0, 15) == 'uploader/idcard') return 'http://zwcontent.oss-cn-hangzhou.aliyuncs.com/' . $str;
if (mb_substr($str, 0, 6) == '/cover') return 'https://cdn-newyc.ycsd.cn/ycsd_cover/covermiddle' . mb_substr($str, 6);
if (mb_substr($str, 0, 8) == '/images/') return 'https://cdn-newyc.ycsd.cn/ycsd_cover' . $str;
}
/**
* 章节内容排版
*
* @param $content
* @return string
*/
function filterContent($content)
{
if (!$content) return '';
$content = str_replace(
[' ', '
', '
', '
', ' ', '
', '
', '“', '”', '…', '‘', '’', '—'], [' ', PHP_EOL, PHP_EOL, PHP_EOL, ' ', '', PHP_EOL, '“', '”', '...', '‘', '’', '-'], $content); $content = preg_replace('/(\r\n)+/', PHP_EOL, $content); // 段落首字母前加两个中文空格 $string = explode(PHP_EOL, $content); foreach ($string as $line => $text) { $string[$line] = str_replace([' ', "\r\n", "\r", "\n", ' '], '', $string[$line]); if (!$string[$line]) { unset($string[$line]); } else { $string[$line] = $string[$line] . PHP_EOL; // if (mb_substr($string[$line], 0, 1) == ' ') { // $string[$line] = str_replace(' ', '', $string[$line]); // 去除多个空格 // $string[$line] = ' ' . $string[$line].PHP_EOL; // } // if (mb_substr($string[$line], 0, 2) != ' ') { // $string[$line] = ' ' . $string[$line].PHP_EOL; // } // if (mb_substr($string[$line], 0, 2) == ' ' && str_replace(' ', '', $string[$line])) { // $string[$line] .= PHP_EOL; // } } } $content = implode(PHP_EOL, $string); return $content; } /** * 书籍简介排版 * * @param $content * @return string */ function filterContent2($content) { if (!$content) return ''; $content = str_replace( [' ', '', '
', '“', '”', '…', '‘', '’', '—'], [' ', PHP_EOL, PHP_EOL, PHP_EOL, ' ', '', PHP_EOL, '“', '”', '...', '‘', '’', '-'], $content); $content = preg_replace('/(\r\n)+/', PHP_EOL, $content); // 段落首字母前加两个中文空格 $string = explode(PHP_EOL, $content); $content = ''; foreach ($string as $line => $text) { $string[$line] = str_replace([' ', "\r\n", "\r", "\n", ' ', '', '
', '“', '”', '…', '‘', '’', '—'], [' ', ' ', ' ', ' ', ' ', '', ' ', '“', '”', '...', '‘', '’', '-'], $content); $content = preg_replace('/(\r\n)+/', PHP_EOL, $content); // 段落首字母前加两个中文空格 $string = explode(PHP_EOL, $content); $content = ''; foreach ($string as $line => $text) { $string[$line] = str_replace([' ', "\r\n", "\r", "\n", ' ', '', '
', '“', '”', '…'], [' ', PHP_EOL, PHP_EOL, PHP_EOL, ' ', '', PHP_EOL, '“', '”', '...'], $content); $content = str_replace([" ", '“', '…', '”', ''], '', $content); // 段落首字母前加两个中文空格 $string = explode(PHP_EOL, $content); foreach ($string as $line => $text) { if (mb_substr($text, 0, 2) != ' ') $string[$line] = ' ' . $text; } $content = implode(PHP_EOL, $string); $content = mb_convert_encoding($content, 'UTF-8', 'UTF-8,GBK,GB2312'); $content = iconv('UTF-8', 'UTF-8//IGNORE', $content); return $content; } /** * 筛选出有效的id集合 * * @param array $ids * @return array */ function filterValidIds(array $ids): array { // 传参 if (empty($ids)) { return []; } $result = []; foreach ($ids as $id) { if (in_array($id, $result) || !is_numeric($id) || (int)$id < 1) { continue; } $result[] = (int)$id; } return $result; } function arrayToStr($map) { $isMap = isArrMap($map); $result = ""; if ($isMap) { $result = "map["; } $keyArr = array_keys($map); if ($isMap) { sort($keyArr); } $paramsArr = array(); foreach ($keyArr as $k) { $v = $map[$k]; if ($isMap) { if (is_array($v)) { $paramsArr[] = sprintf("%s:%s", $k, arrayToStr($v)); } else { $paramsArr[] = sprintf("%s:%s", $k, trim(strval($v))); } } else { if (is_array($v)) { $paramsArr[] = arrayToStr($v); } else { $paramsArr[] = trim(strval($v)); } } } $result = sprintf("%s%s", $result, join(" ", $paramsArr)); if (!$isMap) { $result = sprintf("[%s]", $result); } else { $result = sprintf("%s]", $result); } return $result; } function isArrMap($map) { foreach ($map as $k => $v) { if (is_string($k)) { return true; } } return false; } /** * 随机字符串 * * @param $length * @return string */ function makeRandStr($length): string { // 密码字符集,可任意添加你需要的字符 $str = [ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z', 'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z', '0', '1', '2', '3', '4', '5', '6', '7', '8', '9' ]; // 在 $str 中随机取 $length 个数组元素键名 $keys = array_rand($str, $length); $password = ''; for ($i = 0; $i < $length; $i++) { // 将 $length 个数组元素连接成字符串 $password .= $str[$keys[$i]]; } return $password; } /** * 导出数据为excel表格 * * @param $data 一个二维数组,结构如同从数据库查出来的数组 * @param $title excel的第一行标题,一个数组,如果为空则没有标题 * @param $filename 下载的文件名 * @examlpe10 */ function exportExcel($data = [], $title = [], $filename = 'report') { ob_end_clean(); ob_start(); header("Content-type:application/octet-stream"); header("Accept-Ranges:bytes"); header("Content-type:application/vnd.ms-excel"); header("Content-Disposition:attachment;filename=" . $filename . ".xls"); header("Pragma: no-cache"); header("Expires: 0"); //导出xls 开始 if (!empty($title)) { foreach ($title as $k => $v) { $title[$k] = iconv("UTF-8", "GB2312", $v); } $title = implode("\t", $title); echo "$title\n"; } if (!empty($data)) { foreach ($data as $key => $val) { foreach ($val as $ck => $cv) { $data[$key][$ck] = iconv("UTF-8", "GB2312", $cv); } $data[$key] = implode("\t", $data[$key]); } echo implode("\n", $data); } } /** * 导出csv文件 * @param string $name * @param array $headers * @param array $data * @return void */ function exportCsv(string $name, array $headers, array $data = []) { header('Content-Description: File Transfer'); header('Content-Type: application/csv'); header("Content-Disposition: attachment; filename=".$name.".csv"); header('Cache-Control: must-revalidate, post-check=0, pre-check=0'); $handle = fopen('php://output', 'w'); ob_clean(); fputcsv($handle, $headers); if ($data) { foreach ($data as $row) { fputcsv($handle, $row); } } ob_flush(); fclose($handle); die(); } // 树状分类 function buildCategoryTree($categories, $pid = 0) { $tree = []; foreach ($categories as $category) { if ($category['pid'] == $pid) { $children = buildCategoryTree($categories, $category['category_id']); if ($children) { $category['children'] = $children; } $tree[] = $category; } } return $tree; } // 获取文件的md5值 function getFileContentMD5($filePath){ //获取文件MD5的128位二进制数组 $md5Bytes = md5_file($filePath,true); //计算文件的Content-MD5 $contentMD5 = base64_encode($md5Bytes); return $contentMD5; } function getTextTokens($text) { // 方法1:按空格分词(西文较准) $words = preg_split('/\s+/', $text); $wordCount = count($words); // 方法2:按字符数估算(中文较准:1个汉字 ≈ 1.5-2 tokens) $charCount = mb_strlen($text); // 综合估算(根据语言调整权重) $tokenCount = $wordCount + $charCount * 0.5; // 示例公式 // 更简单:直接按字符数 * 系数(中文推荐) // $tokenCount = $charCount * 1.8; // 经验系数 return (int)ceil($tokenCount); } // 处理小说剧本文本 function handleScriptWords($text, $enable_emotion=1) { $text = preg_replace('/[\r\n]+/', PHP_EOL, $text); $text_arr = explode(PHP_EOL, $text); $roles = []; $words = []; $role_gender = []; // $sequence = 0; foreach ($text_arr as $line) { $line = trim($line); if ($enable_emotion) { $match_rule = '/^(.*?)\:(.*?)\{(.*?)\}$/'; $count = 4; } else { $match_rule = '/^(.*?)\:(.*?)$/'; $count = 3; } preg_match($match_rule, $line, $matches); if (count($matches) == $count) { $gender = '0'; // 角色部分拆分 preg_match('/^(.*?)\((.*?)\)$/', $matches[1], $matches2); if (count($matches2) == 3) { $role = $matches2[1]; $gender_arr = ['男'=>'1', '女'=>'2']; $gender = isset($gender_arr[$matches2[2]]) ? $gender_arr[$matches2[2]] : '0'; }else { $role = $matches[1]; } if (!in_array($role, $roles)) { $roles[] = $role; // 记录角色 $role_gender[$role] = $gender; } $words[] = [ 'role' => $role, 'gender' => $gender, 'text' => $matches[2], 'emotion' => $enable_emotion ? $matches[3] : '中性', ]; } } $new_words = []; $tmp = ''; $tmp_arr = []; $tmp_text = ''; // 将words数组按照role和emotion合并相邻的text内容,不相邻则跳过合并 foreach ($words as $word) { if(!$tmp) $tmp = $word['role'].'-'.$word['emotion']; if($tmp == $word['role'].'-'.$word['emotion']) { $tmp_text .= PHP_EOL.$word['text']; $tmp_arr = [ 'role' => $word['role'], 'gender' => $word['gender'], 'text' => trim($tmp_text, PHP_EOL), 'emotion' => $word['emotion'], ]; }else { // $sequence++; // $tmp_arr['sequence'] = $sequence; $new_words[] = $tmp_arr; $tmp = $word['role'].'-'.$word['emotion']; $tmp_text = $word['text']; $tmp_arr = [ 'role' => $word['role'], 'gender' => $word['gender'], 'text' => trim($tmp_text, PHP_EOL), 'emotion' => $word['emotion'], ]; } } if ($tmp_arr) { // $sequence++; // $tmp_arr['sequence'] = $sequence; $new_words[] = $tmp_arr; } return [ 'roles' => $roles, 'role_gender' => $role_gender, 'words' => $new_words, ]; } /** * 处理剧本内容 */ function handleScriptContent($originalContent) { // 定义原始内容 // $originalContent = <<<'EOD' // ###剧本名:西昆仑 // ###故事梗概 // 内容梗概:少女许芸重生回到悲剧发生前一个月,她本是许家假千金,真千金徐清遥因在徐家遭受表哥卞大伟侵犯及养母徐母包庇而心怀怨恨,最终导致许芸被逼联姻、家暴惨死。重生后,许芸提前让身份归位,试图避开悲剧。但她发现一个因暗恋她而与系统交易、令她重生的“空气人”始终在暗中保护她,她却因系统惩罚永远无法看见、听见他。许芸在躲避卞大伟侵害、应对原生家庭矛盾的过程中,逐渐感知到“空气人”的存在与深情。她努力追寻他的身份,得知他是她曾资助的学霸程景。两人在“看不见”的困境中相互靠近,经历生死危机与误解分离后,最终冲破系统束缚,得以相见相守,共度余生。 // ###剧本亮点 // 亮点1:重生救赎与无形守护 // 开篇以许芸死亡回忆切入,画面阴郁沉重,转场重生瞬间光影骤亮。许芸躲避悲剧时,“空气人”多次在危急关头无形相助——如车祸前拉扯卫衣、对抗卞大伟时扭打空气、深夜喂水亲吻等。镜头以许芸主观视角呈现,观众只见她反应却不见施救者,营造悬疑与浪漫交织的张力,突出“被深爱却不自知”的宿命感。 // 亮点2:系统惩罚下的情感博弈 // 许芸与“空气人”程景因系统设定无法直接接触,却通过“血脚印”“消失的面条”“深夜触碰”等细节传递暧昧与温情。许芸大胆挑逗、程景克制回避,两人在“看不见”的局限中试探彼此心意,既有荒诞喜感,又充满情感拉扯的酸涩与甜蜜,颠覆传统恋爱叙事。 // 亮点3:身份谜团与校园暗恋 // 许芸通过重点班花名册、志愿填报等线索,拼凑程景身份。场景在校园、阁楼间切换,许芸努力“听不见”程景信息时的茫然与执着,与夏颜的八卦互动形成反差,逐步揭开“资助者与被资助者”的前缘。观众随许芸一起解谜,沉浸式体验暗恋回溯的感动。 // 亮点4:生死危机与真相爆发 // 卞大伟持刀复仇段落紧张激烈,程景为救许芸受重伤“显形”血迹,却因伤情自卑选择分手。许芸在法院外闻到皂角味、假意跳楼逼其现身,将情感推向高潮。最终阁楼相见,系统束缚破除,两人记忆补全,过往守护细节——浮现,情感爆发力十足。 // 亮点5:跨越苦难的终成眷属 // 结局以十年后怀孕收尾,画面温暖明亮。许芸与程景回顾重生与守护历程,对话温情而释然。悲剧循环被打破,强调“坚持与爱可冲破既定命运”的主题,给予观众情感治愈与希望感。 // ###人物关系 // 许芸与程景:重生前为资助者与被资助者,程景长期暗恋许芸;重生后程景化为“空气人”默默守护,许芸逐渐感知并主动靠近,最终成为恋人、夫妻。 // 许芸与徐清遥:被抱错的真假千金,徐清遥因遭遇迁怒许芸,重生后许芸主动归位身份避免冲突。 // 许芸与徐母、卞大伟:徐母重男轻女、包庇侄子;卞大伟是侵犯者加害者,对许芸屡次施暴。 // 许芸与夏颜:同学兼闺蜜,夏颜单恋程景,一度误导许芸,后和解。 // 程景与系统:交易关系,程景以“许芸永远看不见他”为代价换她重生。 // ###核心矛盾 // 1. 许芸与既定悲剧命运的矛盾:她需避开卞大伟侵害、徐母压迫及徐清遥怨恨,改变惨死结局。 // 2. 许芸与程景“不可见”的系统惩罚矛盾:两人相爱却无法直接接触、沟通,情感传递受阻。 // 3. 程景的自卑与守护矛盾:他因出身、伤势自觉不配,一度逃避,与许芸的坚定追求形成拉扯。 // 4. 许芸与原生家庭的矛盾:徐母的苛待、卞大伟的威胁,迫使她反抗、逃离。 // ###主体列表 // 许芸-重生者:18岁少女,原许家假千金,惨死后重生,决心改变命运,性格从乖顺转向果敢。 // 许芸-追寻者:感知程景存在后,主动探寻其身份,大胆表达爱意,坚韧执着。 // 程景/“空气人”/1号同学:学霸,父母早逝,受许芸资助长大,暗恋许芸至深,以系统交易换她重生,默默守护。 // 卞大伟:徐母侄子,混混,侵犯徐清遥未遂后多次企图伤害许芸,最终入狱。 // 徐母:许芸生母,重男轻女,包庇卞大伟,苛待女儿。 // 徐父:怯懦但逐渐觉醒,最终与徐母离婚支持女儿。 // 徐清遥:真千金,因遭遇怨恨许芸,重生后未直接报复。 // 夏颜:许芸同学,暗恋程景,曾误导许芸,后和解。 // 系统:无形存在,设定“许芸永远看不见程景”的惩罚。 // ###美术风格 // 基础画风风格词:现代写实略带奇幻色调 // 整体采用细腻写实风格,色彩随情绪与剧情变化。重生前回忆片段采用冷灰、暗蓝色调,氛围压抑;重生后日常场景以自然光、生活化色彩为主,突出真实感。程景作为“空气人”存在时,镜头多以许芸主观视角拍摄,画面中他“缺席”却留有痕迹(如血脚印、水面波动),辅以微妙光影与音效暗示其存在。两人情感互动时,色调转为暖黄、柔光,烘托暧昧与温情。危机场景如遇袭、跳楼等,采用快速剪辑、手持晃动镜头与对比色,强化紧张感。结局部分画面明亮温暖,象征新生与希望。 // ###场景列表 // 徐家馄饨店:老旧街边小店,锅炉、案板杂乱,象征许芸压抑的原生家庭。 // 网吧:烟雾缭绕,光线昏暗,许芸在此熬夜躲避悲剧。 // 街道/路口:日常街景,车祸救人处,程景首次“无形”相助。 // 程景阁楼:狭小但整洁,书桌、床、矮几,两人主要相处空间,充满私密与温情。 // 学校教室/走廊:重点班与普通班场景,许芸探寻程景身份的主要场所。 // 医院:程景受伤住院处,许芸因看不见而无助。 // 法院外台阶:卞大伟宣判后,许芸与徐母冲突,程景暗中出现。 // 平台/草坪:许芸假意跳楼逼程景现身之处。 // ###分集剧本 // ##分集02 // 分集名: 街头相遇 // 场景描述:街道 / 日 / 外 // 出场角色:许芸、程景(无形)、老奶奶 // 台词内容: // 许芸:(内心独白,画外音)我重生了。回到悲剧发生前一个月。这一次,我绝不会再走上那条绝路。 // (许芸熬夜后恍惚过马路,汽车疾驰而来) // 许芸:(惊愕瞪大眼)啊! // (许芸被无形力量猛地向后拉扯,踉跄站稳) // 老奶奶:(拄拐走近)这司机开车横冲直撞,要不是小姑娘你反应快,一定会被撞到。 // 许芸:(环顾四周,茫然)不是我自己躲的…… // (许芸急切在人群中寻找,风吹过她的发梢) // 许芸:(低声,眼眶微红)是你吗?傻瓜……你看我惨死的时候,很痛吧。 // ##分集03 // 分集名: 重生 // 场景描述:街道 / 日 / 外 // 出场角色:许芸、程景(无形)、老奶奶 // 台词内容: // 许芸:(内心独白,画外音)我重生了。回到悲剧发生前一个月。这一次,我绝不会再走上那条绝路。 // (许芸熬夜后恍惚过马路,汽车疾驰而来) // 许芸:(惊愕瞪大眼)啊! // (许芸被无形力量猛地向后拉扯,踉跄站稳) // 老奶奶:(拄拐走近)这司机开车横冲直撞,要不是小姑娘你反应快,一定会被撞到。 // 许芸:(环顾四周,茫然)不是我自己躲的…… // (许芸急切在人群中寻找,风吹过她的发梢) // 许芸:(低声,眼眶微红)是你吗?傻瓜……你看我惨死的时候,很痛吧。 // 场景描述:徐家馄饨店 / 日 / 内 // 出场角色:许芸、徐母、卞大伟 // 台词内容: // 徐母:(挥舞扫帚打许芸)夜不归宿死哪儿去了?! // 许芸:(躲闪)去许家让清遥给我补课了。 // 徐母:(扔下扫帚,指案板)去包馄饨! // (许芸沉默洗手,余光瞥向锅炉后的卞大伟) // 卞大伟:(阴沉盯着许芸,无声冷笑) // 许芸:(内心独白)上辈子,就是他在今天毁了徐清遥。幸好,我躲过去了。 // ##分集04 // 分集名: 馄饨店 // 场景描述:徐家馄饨店 / 傍晚 / 内 // 出场角色:许芸、卞大伟、程景(无形) // 台词内容: // (许芸放学进门,卞大伟猛地拉住她) // 卞大伟:今天姑姑不在,看谁还能救你! // 许芸:(挣扎)放开我! // (桌椅撞倒,许芸掏刀,卞大伟突然怪异地摔倒) // 卞大伟:(朝空气怒吼)你他妈谁啊?! // 许芸:(愣住,随即眼睛一亮)是你! // (许芸趁机跑出,回头看见卞大伟与“空气”扭打) // 许芸:(朝屋内喊)我们走! // (许芸跑出,看到臭水沟溅起水花,地上出现血脚印) // 许芸:(追上)你受伤了! // (血脚印停住) // 许芸:(固执)我要跟你在一起。 // 场景描述:程景阁楼 / 夜 / 内 // 出场角色:许芸、程景(无形) // 台词内容: // (许芸坐在床边,矮几上出现两碗面,她对面的面条自动减少) // 许芸:(看着空气)你一直在看着我,对吧? // (许芸眼泪滴落) // 许芸:(哽咽)你太惨了……换我重生,你却连名字都不能让我知道。 // (许芸擦泪吃面) // 许芸:我今晚住你这。 // (许芸洗澡后穿T恤躺下,关灯) // 许芸:(黑暗中)试试看我能不能碰到你。 // (许芸伸手摸索,只触到空气) // 许芸:(沮丧)睡觉了。 // EOD; // $originalContent = <<<'EOD' // ###剧本名:看不见的守护者 // ###故事梗概 // 少女许芸重生回到悲剧发生前一个月,她本是豪门假千金,上辈子被真千金徐清遥报复,被迫联姻后惨死。一个暗恋她至深的陌生男人程景,用与系统交易的代价换她重生,代价是她永远看不见他。许芸利用先知改变命运,躲避了表哥卞大伟的侵犯,并试图回归平凡生活。在危机四伏的重生路上,那个“看不见”的守护者程景一次次救她于危难。许芸从恐惧、好奇到依赖,并主动追寻这个神秘存在。两人在“一个看得见,一个看不见”的荒诞设定下,发展出一段跨越生死、充满拉扯与救赎的深情。最终,许芸的执着冲破了系统规则,两人得以相见相守,共度余生。 // ###剧本亮点 // 亮点1:重生设定与“隐形”守护者的双重奇幻 // 开篇即用快速剪辑展现许芸上辈子的惨死与陌生男人程景为她下葬、与系统交易的画面,建立强烈的戏剧冲突与悬念。重生后,许芸视角中程景的“不存在”与观众视角(或通过环境互动)能感知的“存在”形成奇妙反差,如被无形力量救下车、凭空出现的面条、地上的血脚印等,视觉呈现新颖,情感张力十足。 // 亮点2:“看见”与“看不见”的极致情感拉扯 // 许芸对着一团空气说话、试探、甚至调情的场景,充满了戏剧性的孤独与浪漫。她努力感知程景的存在,通过细微痕迹(如血迹、水渍、移动的物品)与他“交流”。这种单向可见的互动,将暗恋的卑微与守护的深情推到极致,观众能深切共情两人近在咫尺却无法触及的煎熬与甜蜜。 // 亮点3:悬疑推进与身份揭秘的层层递进 // 剧本并非单纯恋爱,穿插着许芸对抗原生家庭伤害、智斗恶毒表哥卞大伟的主线。程景的“隐形”能力成为她破局的关键外挂,但也带来了新的危险。随着许芸通过学校花名册、资助信等线索一步步接近程景的真实身份,悬疑感与情感共鸣同步加深,直到最终高潮的“相见”时刻,情感得到彻底释放。 // ###人物关系 // 许芸与程景:被守护者与隐形守护者,后成为恋人、夫妻。许芸从依赖、好奇到深爱并主动追寻程景;程景则因感恩与深爱,默默守护许芸两世。 // 许芸与徐家父母:亲生父母,关系疏离冷漠。母亲重男轻女,偏袒侄子卞大伟;父亲怯懦,后期在许芸影响下有所改变。 // 许芸与卞大伟:表哥,施暴者与主要反派,对许芸有侵犯意图,是前期主要威胁。 // 许芸与徐清遥:身份互换的真假千金,上辈子是仇敌,这辈子因许芸提前归位身份而关系未彻底恶化,但存在潜在矛盾。 // 许芸与夏颜:高中同桌兼闺蜜,后期因同时喜欢程景而产生嫉妒与隔阂,是推动程景“分手”谎言的工具人。 // ###核心矛盾 // 1. **生存矛盾**:重生后的许芸需躲避上辈子的悲剧命运(被卞大伟侵犯、被家庭出卖联姻),在恶劣原生环境中挣扎求生。 // 2. **感知矛盾**:系统规则导致许芸无法看见、听见程景,而程景深爱并守护着她。两人之间存在极致的“接触”渴望与“隔绝”现实的矛盾。 // 3. **情感矛盾**:许芸在依赖与追寻程景的过程中渐生情愫,但程景因自卑(身份差距)、身体创伤(以为不育)及系统限制,一度选择逃避和“分手”,造成两人的情感拉扯。 // 4. **身份矛盾**:真假千金的错位人生带来的家庭伦理与社会地位冲突,是许芸前世悲剧的根源,也是她今生需要面对和化解的阴影。 // ###主体列表 // 许芸-重生迷茫:刚重生时,心怀恐惧与决绝,试图改变命运。 // 许芸-坚韧求生:面对原生家庭压迫,冷静周旋,努力读书改变命运。 // 许芸-主动追寻:对“空气人”程景从好奇到依赖,再到大胆主动地试探与追求。 // 程景-隐形守护:始终在许芸身边,默默保护、照顾她,情感深沉而克制。 // 程景-隐忍挣扎:因系统限制和自身顾虑,承受着无法被感知的孤独与爱而不得的痛苦。 // 卞大伟-阴狠施暴:对许芸心怀不轨,是具象化的危险来源。 // 徐母-偏执刻薄:重男轻女,维护侄子,是家庭伤害的主要施加者。 // 徐父-怯懦转变:前期麻木,后期在女儿影响下逐渐觉醒。 // 夏颜-闺蜜变情敌:前期提供帮助,后期因嫉妒程景而说谎离间。 // “系统”:无形的规则设定,制造了主角间“看不见”的核心障碍。 // ###美术风格 // 基础画风风格词:写实细腻的现代都市情感风格,带有轻微悬疑色彩。 // 整体采用电影质感的写实风格,色彩随情绪和剧情变化。许芸重生前的回忆片段使用冷峻、压抑的蓝灰色调,对比强烈。重生后的现实生活,色调偏向自然光感,但徐家馄饨店等场景色调偏暗、拥挤,体现压抑感。当“隐形”的程景互动时,画面注重细节特写(如自动移动的物体、凹陷的床垫、凭空出现的水杯),并辅以微妙的光影变化或轻柔的焦点虚化来暗示他的“存在”。许芸与程景的独处时光,尤其是阁楼场景,色调转为温暖、柔和的橙黄色,营造私密与暧昧氛围。最终相见时刻,光线明亮饱满,色彩鲜活,象征隔阂的打破与希望降临。 // ###场景列表 // 徐家馄饨店:拥挤、老旧、充满烟火气的小店,是许芸重生后的主要生活场景,也是危机发生地。 // 学校教室/走廊:青春校园环境,是许芸努力学习、与夏颜交流的主要场景。 // 网吧包间:许芸躲避第一夜危机的地点,嘈杂、昏暗。 // 城市街道/路口:多次发生危机(车祸、被卞大伟堵截)和程景隐形救援的场景。 // 程景的阁楼:狭小但整洁,是两人的秘密基地和情感发酵的主要空间,充满生活气息与私密感。 // 医院:程景受伤后治疗的地点,也是许芸感到无助与寻找他的地方。 // 法院门口:卞大伟宣判后,徐母闹事、许芸再次感知到程景存在的场景。 // ###分集剧本 // ##分集01 // 分集名:重生与隐形救援 // 分镜01 // 场景描述:徐家馄饨店后院卧室,清晨,光线昏暗。许芸从噩梦中惊醒,额头上全是冷汗。 // 运镜:特写许芸惊恐睁大的双眼,快速闪回上辈子被家暴、惨死、以及一个模糊男人背影为她下葬的碎片画面。 // 出场角色:许芸 // 台词内容: // 许芸(内心独白,带着颤音):我重生了…源于一个男人偏执的暗恋。 // 分镜02 // 场景描述:馄饨店前厅,清晨。徐母穿戴整齐,对正在收拾书包的许芸吩咐。卞大伟在锅炉后假装忙碌,眼神阴鸷地偷瞄许芸。 // 运镜:中景,先聚焦徐母严厉的脸,然后镜头平移,扫过乖巧点头的许芸,最后落在卞大伟身上,给他一个眼神特写。 // 出场角色:许芸,徐母,卞大伟 // 台词内容: // 徐母:今天店就交给你表哥看着,晚上一放学就回来帮他干活。 // 许芸:(低头)嗯。 // 徐母:(转身离开) // (许芸目光扫向卞大伟,内心独白):上辈子,就是他侮辱了徐清遥…也是我一切悲剧的开始。 // 分镜03 // 场景描述:网吧包间,夜。许芸坐在电脑前,屏幕光映着她苍白的脸。周围烟雾缭绕,嘈杂。 // 运镜:固定机位,侧面拍摄许芸蜷缩在椅子里的身影,环境音突出键盘声和隐约的游戏音效。 // 出场角色:许芸 // 台词内容: // 许芸(内心独白):我在这里躲了一夜…可脑子里全是上辈子的事。徐清遥的恨,养父母的冷漠,那个老畜生的折磨…还有,那个给我收尸的陌生男人… // 分镜04 // 场景描述:城市街道,清晨。许芸背着书包,困倦地过马路。一辆车急速驶来。 // 运镜:主观镜头,许芸视线模糊地看向冲来的车。紧接着第三视角,许芸的卫衣后领突然向后一紧,她整个人被“拽”离原地,车子擦身而过。 // 出场角色:许芸,路人老奶奶 // 台词内容: // (急刹车声) // 老奶奶:(拄着拐走近)这司机开车横冲直撞,要不是小姑娘你反应快… // 许芸:(惊魂未定,四下张望)不是我自己躲的… // (内心独白):是他!他就在我身边! // 分镜05 // 场景描述:馄饨店后屋,白天。许芸刚进门,徐母拿着扫帚劈头盖脸打来。卞大伟在门口阴沉地看着。 // 运镜:手持晃动镜头,模拟挨打的混乱感。穿插卞大伟站在逆光门口的阴沉中景。 // 出场角色:许芸,徐母,卞大伟 // 台词内容: // 徐母:死丫头!夜不归宿死哪儿去了? // 许芸:(躲闪)去许家让清遥给我补课了! // 徐母:(打累了,扔下扫帚)去包馄饨! // (许芸沉默地洗手,开始包馄饨,余光警惕着卞大伟) // ##分集02 // 分集名:危机再现与阁楼初夜 // 分镜01 // 场景描述:馄饨店后屋,傍晚。许芸放学推门进来,被埋伏的卞大伟一把抓住手腕。 // 运镜:快速推近,特写许芸惊愕的脸和卞大伟狞笑的手。镜头跟随挣扎的两人摇晃,撞倒桌椅。 // 出场角色:许芸,卞大伟 // 台词内容: // 许芸:你干什么?放开我! // 卞大伟:(喘着粗气)今天可没人救你了! // (许芸挣扎中摸出兜里的小刀) // 分镜02 // 场景描述:同上场景。卞大伟正要压倒许芸,突然怪叫着向后仰倒,仿佛被无形力量击中。 // 运镜:慢镜头,卞大伟身体不自然地扭曲、摔倒。许芸趁机爬起,震惊地看着“空气”。 // 出场角色:许芸,卞大伟 // 台词内容: // 卞大伟:(对着空气怒吼)你他妈谁啊?! // 许芸:(心口狂跳,看向卞大伟扭打的方向)是他…只能是他! // 许芸:(朝“空气”大喊)我们走! // 分镜03 // 场景描述:老旧居民楼楼梯,傍晚。许芸跟着一串突然出现在地上的“血脚印”奔跑。 // 运镜:俯拍镜头,跟随许芸上楼的脚步和地上断断续续的血脚印。 // 出场角色:许芸 // 台词内容: // 许芸:(对着前方的空气)你受伤了! // (血脚印停住) // 许芸:(执拗地)我要跟你在一起。 // 分镜04 // 场景描述:狭小阁楼内,夜。许芸坐在床边,好奇地打量房间。小矮几上凭空出现两碗热气腾腾的面条。 // 运镜:全景展示阁楼布局,然后聚焦到矮几上“自动出现”的面碗。对面碗里的面条开始“自动”减少。 // 出场角色:许芸 // 台词内容: // 许芸:(看着对面“自动”减少的面条,眼泪突然掉下来)啪嗒…好惨…这男的太惨了… // 分镜05 // 场景描述:阁楼床上,夜。许芸穿着宽大T恤躺在床上。她突然睁开眼,感觉嘴唇上有温热柔软的触感。 // 运镜:特写许芸猛然睁大的眼睛,和微微湿润的嘴唇。背景是浴室隐约的水声。 // 出场角色:许芸 // 台词内容: // 许芸:(内心惊愕)他在偷吻我?我…感觉到了? // (她闭眼假装睡着,感觉脖颈又被亲了一下,忍不住轻颤) // (浴室水声再次响起) // 许芸:(嘴角微微勾起)好像…发现了系统的BUG? // ##分集03 // 分集名:身份追寻与最终相见 // 分镜01 // 场景描述:学校教室,白天。许芸拿着重点班花名册,请同桌夏颜念名字。夏颜念到第一个名字时,许芸一脸茫然。 // 运镜:过肩镜头,从许芸视角看夏颜的嘴在动,但配以表示“无声”或“雪花噪音”的音效和画面扭曲效果。 // 出场角色:许芸,夏颜 // 台词内容: // 夏颜:(念名单)… // 许芸:(打断,急切地)夏夏,等高考后,你能帮我打听一下1号同学报哪所大学吗?我想跟他一起。 // 夏颜:(皱眉,嘴巴张合——许芸听不清) // 许芸:(自顾自点头,眼冒星星)对,我喜欢1号同学! // 分镜02 // 场景描述:法院外台阶,白天。卞大伟宣判后被带走,徐母发疯般冲过来要打许芸,被徐父拦住。许芸下台阶时,突然停下,嗅了嗅空气。 // 运镜:中景拍摄混乱场面,然后特写许芸突然愣住、微微抽动鼻翼的表情。 // 出场角色:许芸,徐母,徐父 // 台词内容: // 徐母:(尖叫)许芸!你毁了我的一切! // 许芸:(对徐父)爸,拉住她!(下台阶,脚步一顿)…皂角味? // (内心独白):他来了?为什么… // 分镜03 // 场景描述:程景家楼下小平台,傍晚。许芸站在平台边缘,下面是草坪。 // 运镜:全景,许芸孤身站在平台边,风吹动她的头发。然后切换为她主观视角,看向下方。 // 出场角色:许芸 // 台词内容: // 许芸:(环顾四周,大声说)1号同学,没有你,我不想活了! // (作势要跳) // 分镜04 // 场景描述:同上场景。许芸身体前倾跌落的瞬间,被一股无形的力量拦腰抱住,然后轻轻“丢”在平台地面上。 // 运镜:高速摄影,捕捉许芸跌落瞬间被“空气”拦截并缓缓放下的超现实画面。 // 出场角色:许芸 // 台词内容: // 许芸:(被抱住时惊喜)1号同学!我就知道…(被丢到地上,屁股疼)哎哟! // 许芸:(爬起来,对着空气喊)你非要这么折磨我吗?还是你和夏颜在一起了?! // 分镜05 // 场景描述:阁楼内,夜。许芸被浴室水声吵醒,迷迷糊糊走过去,赫然看见一个裸身男人(程景)在洗澡。 // 运镜:从许芸迷糊的主观镜头,到看清浴室景象时猛地拉近、定格在程景背影的震惊视角。 // 出场角色:许芸,程景 // 台词内容: // 许芸:(惊吓后退)啊!你…你是谁?! // (程景转身,两人对视) // 程景:(震惊,试探)许芸?你能看见我? // 许芸:(警惕)我当然能看见你!变-态! // (程景扯掉浴巾,许芸惊呼转身又转回) // 程景:(快速穿裤子)我就是你的1号同学。 // 分镜06 // 场景描述:阁楼内,片刻后。许芸接着夏颜的电话,程景站在一旁。接完电话,许芸走向程景。 // 运镜:双人镜头,许芸一边接电话,一边目光复杂地看着程景。挂电话后,她主动靠近,镜头柔和。 // 出场角色:许芸,程景 // 台词内容: // 许芸:(对电话)…这挺好,可以毫无顾忌了。(挂电话,走向程景) // 许芸:(圈住程景脖子)夏颜说,你受伤了,可能做不了爸爸? // 程景:(眼神一暗)…嗯。 // 许芸:(微笑)没关系。我只想跟你好好地在一起。 // (两人相拥,窗外天色渐亮) // EOD; // 使用更精确的正则表达式分割内容 $parts = []; // // 确保内容使用UTF-8编码 // if (!mb_check_encoding($originalContent, 'UTF-8')) { // $originalContent = mb_convert_encoding($originalContent, 'UTF-8', 'auto'); // } // 提取剧本名(###剧本名:后面的内容) if (preg_match('/###剧本名[::]\s*(.*?)(?=\n|$)/u', $originalContent, $summaryMatch)) { $parts['script_name'] = isset($summaryMatch[1]) ? trim($summaryMatch[1]) : ''; } // 提取故事梗概(直到遇到下一个###标记) preg_match('/###故事梗概\s*\n(.*?)(?=\n###|$)/s', $originalContent, $summaryMatch); $parts['intro'] = isset($summaryMatch[1]) ? trim($summaryMatch[1]) : ''; // 提取剧本亮点 preg_match('/###剧本亮点\s*\n(.*?)(?=\n###|$)/s', $originalContent, $highlightsMatch); $parts['highlights'] = isset($highlightsMatch[1]) ? trim($highlightsMatch[1]) : ''; // 提取人物关系 preg_match('/###人物关系\s*\n(.*?)(?=\n###|$)/s', $originalContent, $relationsMatch); $parts['role_relationship'] = isset($relationsMatch[1]) ? trim($relationsMatch[1]) : ''; // 提取核心矛盾 preg_match('/###核心矛盾\s*\n(.*?)(?=\n###|$)/s', $originalContent, $contradictionsMatch); $parts['core_contradiction'] = isset($contradictionsMatch[1]) ? trim($contradictionsMatch[1]) : ''; // 提取主体列表 preg_match('/###主体列表\s*\n(.*?)(?=\n###|$)/s', $originalContent, $subjectsMatch); $rolesText = isset($subjectsMatch[1]) ? trim($subjectsMatch[1]) : ''; $parts['roles'] = parseRolesFromText($rolesText); // 提取美术风格 preg_match('/###美术风格\s*\n(.*?)(?=\n###|$)/s', $originalContent, $artStyleMatch); $parts['art_style'] = isset($artStyleMatch[1]) ? trim($artStyleMatch[1]) : ''; // 提取场景列表 preg_match('/###场景列表\s*\n(.*?)(?=\n###|$)/s', $originalContent, $scenesMatch); $scenesText = isset($scenesMatch[1]) ? trim($scenesMatch[1]) : ''; $parts['scenes'] = parseScenesFromText($scenesText); // 提取分集剧本并按分集拆分 - 兼容多种格式 $fullScript = ''; // 尝试匹配不同的分集剧本标题格式,添加Unicode支持 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]); } 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 = []; // 匹配所有分集标题、分集名和内容 - 兼容新旧两种格式,添加Unicode支持 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]); // 根据分集内容拆分出分镜数组 // 分集内容如下: 分镜01\n场景描述:周氏庄园,布置成黎映雪的灵堂。黑白肃穆。\n运镜:从黎映雪的遗像缓慢摇到下方一脸胡茬、双目无光的周翊然\n出场角色:周翊然,沐婉兮\n台词内容:\n(吊唁人群已散。沐婉兮走近周翊然)\n沐婉兮:翊然……\n周翊然:(躲开,声音沙哑)别碰我!\n(他已从爷爷处得知全部真相,痛苦不堪)\n沐婉兮:就算当初不是我救的你,但我们这么多年的感情都是真的!\n周翊然:(眼神陌生)从一个谎言开始的感情,你觉得是真的吗?\n分镜02\n场景描述:灵堂,接上场。冲突升级。\n运镜:快速剪辑,捕捉沐婉兮砸遗像和周翊然暴怒的瞬间\n出场角色:周翊然,沐婉兮\n台词内容:\n沐婉兮:周翊然,你看清楚,黎映雪已经死了!现在我才是你老婆!\n周翊然:(冷笑)离婚协议我已经让律师准备好了。\n沐婉兮:(震惊)你想离婚?休想!\n周翊然:(不理她,凝视遗像)\n沐婉兮:(被激怒,冲到遗像前砸碎相框)她已经死了!为什么死了还要阻碍我们?!\n分镜03\n场景描述:灵堂,接上场。\n运镜:特写周翊然小心翼翼拾起照片的手,和沐婉兮被拖走的挣扎\n出场角色:周翊然,沐婉兮,保镖\n台词内容:\n周翊然:(暴怒,推开沐婉兮,蹲下小心拾起照片)来人!\n(保镖进来)\n周翊然:把她带去医院,打胎!!\n沐婉兮:(惊慌)翊然!这是我们的孩子!你不可以!\n周翊然:(冷漠)这个世界上,只有黎映雪可以为我生孩子。你,不配。\n(保镖拖走哭喊的沐婉兮。周翊然轻抚照片,泪流满面)\n周翊然:(喃喃)映雪,对不起……是我的错…… // 需通过"分镜"及序号来拆分 $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]); // // 按照指定字段提取内容 // $sceneDesc = ''; // $cameraMove = ''; // $characters = ''; // $dialogue = ''; // // 场景描述 // if (preg_match('/场景描述:(.*?)(?=\n运镜:|\n出场角色:|\n台词内容:|$)/i', $segmentContent, $descMatch)) { // $sceneDesc = trim($descMatch[1]); // } // // 运镜 // if (preg_match('/运镜:(.*?)(?=\n出场角色:|\n台词内容:|$)/i', $segmentContent, $moveMatch)) { // $cameraMove = trim($moveMatch[1]); // } // // 出场角色 // if (preg_match('/出场角色:(.*?)(?=\n台词内容:|$)/i', $segmentContent, $charMatch)) { // $characters = trim($charMatch[1]); // } // // 台词内容 // if (preg_match('/台词内容:(.*?)(?=\n|$)/s', $segmentContent, $dialogueMatch)) { // $dialogue = trim($dialogueMatch[1]); // } $segments[] = [ 'segment_number' => $segmentNumber, 'segment_content' => $segmentContent, // 'scene_description' => $sceneDesc, // 'camera_movement' => $cameraMove, // 'characters' => $characters, // 'dialogue' => $dialogue ]; } $episodes[] = [ 'episode_number' => $episodeNumber, 'title' => $episodeName, 'content' => $episodeContent, 'segments' => $segments, ]; } $parts['episodes'] = $episodes; return $parts; } /** * 远程图片压缩(尽可能保持原图 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 = ["16:9", "4:3", "1:1", "3:4", "9:16", "21:9"]; 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; } $line = str_replace(':', ':', $line); if (strstr($line, ':')) { $line_arr = explode(':', $line); if (count($line_arr) == 2) { $roles[] = [ 'role' => $line_arr[0], 'description' => $line_arr[1], ]; } } } 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); if (count($line_arr) == 2) { $scenes[] = [ 'scene' => $line_arr[0], 'description' => $line_arr[1], ]; } } } return $scenes; }