|
|
@@ -9365,5 +9365,438 @@ class AnimeService
|
|
|
'acts' => $return_acts
|
|
|
];
|
|
|
}
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 导出动漫剧本为PDF(参照示例PDF版式)
|
|
|
+ * @param int $animeId 动漫ID
|
|
|
+ * @param int $episodeCount 导出集数,默认8,传-1表示全部
|
|
|
+ * @param string|null $savePath 指定保存路径(为空则直接下载输出)
|
|
|
+ * @return string|null
|
|
|
+ */
|
|
|
+ public function exportAnimeScriptAsPdf($animeId, $episodeCount = 8, $savePath = null) {
|
|
|
+ // 图片下载与转换可能占用较多内存
|
|
|
+ ini_set('memory_limit', '512M');
|
|
|
+
|
|
|
+ $anime = DB::table('mp_animes')->where('id', $animeId)->where('is_deleted', 0)->first();
|
|
|
+ if (!$anime) Utils::throwError('20003:该动漫不存在!');
|
|
|
+ $anime = (array)$anime;
|
|
|
+
|
|
|
+ // 获取分集(默认展示版本,按集数升序)
|
|
|
+ $episodeQuery = DB::table('mp_anime_episodes')
|
|
|
+ ->where('anime_id', $animeId)
|
|
|
+ ->where('is_default', 1)
|
|
|
+ ->orderBy('episode_number')
|
|
|
+ ->orderBy('id');
|
|
|
+ if ($episodeCount > 0) {
|
|
|
+ $episodeQuery->limit($episodeCount);
|
|
|
+ }
|
|
|
+ $episodes = $episodeQuery->get()->map(function ($value) {
|
|
|
+ return (array)$value;
|
|
|
+ })->toArray();
|
|
|
+ if (!$episodes) Utils::throwError('20003:该动漫暂无分集数据');
|
|
|
+
|
|
|
+ // 聚合主体/场景/道具列表(前N集内容,按名称去重)
|
|
|
+ $roles = $this->mergeEpisodeItems($episodes, 'role');
|
|
|
+ $scenes = $this->mergeEpisodeItems($episodes, 'scene');
|
|
|
+ $props = $this->mergeEpisodeItems($episodes, 'prop');
|
|
|
+
|
|
|
+ // 组装分集剧本数据
|
|
|
+ $episodeScripts = [];
|
|
|
+ foreach ($episodes as $episode) {
|
|
|
+ $segments = DB::table('mp_episode_segments')
|
|
|
+ ->where('anime_id', $animeId)
|
|
|
+ ->where('episode_id', $episode['id'])
|
|
|
+ ->get();
|
|
|
+
|
|
|
+ // 按act_number分组,同一片段多版本时取最新一条(id最大)
|
|
|
+ $acts = [];
|
|
|
+ foreach ($segments as $segment) {
|
|
|
+ $segment = (array)$segment;
|
|
|
+ $actNumber = (int)getProp($segment, 'act_number', 0);
|
|
|
+ if ($actNumber <= 0) continue;
|
|
|
+ if (!isset($acts[$actNumber]) || (int)$segment['id'] > (int)$acts[$actNumber]['id']) {
|
|
|
+ $acts[$actNumber] = [
|
|
|
+ 'id' => (int)$segment['id'],
|
|
|
+ 'act_number' => $actNumber,
|
|
|
+ 'duration' => getProp($segment, 'act_duration', ''),
|
|
|
+ 'content' => getProp($segment, 'act_content', ''),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+ }
|
|
|
+ ksort($acts);
|
|
|
+
|
|
|
+ // 旁白音色:优先取分集roles中"旁白"的voice_prompt,否则从片段内容解析
|
|
|
+ $narratorVoice = '';
|
|
|
+ $episodeRoles = json_decode(getProp($episode, 'roles', ''), true) ?: [];
|
|
|
+ foreach ($episodeRoles as $role) {
|
|
|
+ if (getProp($role, 'role') === '旁白') {
|
|
|
+ $narratorVoice = trim((string)getProp($role, 'voice_prompt', ''));
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if ($narratorVoice === '') {
|
|
|
+ foreach ($acts as $act) {
|
|
|
+ if (preg_match('/旁白音色[::]\s*([^\n]+)/u', $act['content'], $match)) {
|
|
|
+ $narratorVoice = trim($match[1]);
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $episodeScripts[] = [
|
|
|
+ 'episode_number' => getProp($episode, 'episode_number'),
|
|
|
+ 'title' => getProp($episode, 'title', ''),
|
|
|
+ 'act_count' => count($acts),
|
|
|
+ 'narrator_voice' => $narratorVoice,
|
|
|
+ 'acts' => array_values($acts),
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ // 并发下载列表图片(主体/场景/道具),url => 临时文件
|
|
|
+ $imageCache = $this->downloadImagesConcurrently(array_merge($roles, $scenes, $props));
|
|
|
+
|
|
|
+ // 构建PDF
|
|
|
+ $pdf = $this->buildAnimeScriptPdf($anime, $roles, $scenes, $props, $episodeScripts, $imageCache);
|
|
|
+
|
|
|
+ // 图片已嵌入PDF,清理临时文件
|
|
|
+ foreach ($imageCache as $imgFile) {
|
|
|
+ @unlink($imgFile);
|
|
|
+ }
|
|
|
+
|
|
|
+ $filename = $anime['anime_name'] . '_' . date('YmdHis');
|
|
|
+ if ($savePath) {
|
|
|
+ $pdf->Output($savePath, 'F');
|
|
|
+ return $savePath;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 直接下载输出
|
|
|
+ header('Content-Type: application/pdf');
|
|
|
+ header("Content-Disposition: attachment; filename*=UTF-8''" . rawurlencode($filename . '.pdf'));
|
|
|
+ $pdf->Output($filename . '.pdf', 'D');
|
|
|
+ exit();
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 聚合分集的主体/场景/道具列表,按名称去重(保留首次出现)
|
|
|
+ * @param array $episodes 分集数据
|
|
|
+ * @param string $nameKey role|scene|prop
|
|
|
+ * @return array
|
|
|
+ */
|
|
|
+ private function mergeEpisodeItems(array $episodes, $nameKey) {
|
|
|
+ $fieldMap = [
|
|
|
+ 'role' => 'roles',
|
|
|
+ 'scene' => 'scenes',
|
|
|
+ 'prop' => 'props',
|
|
|
+ ];
|
|
|
+ $field = isset($fieldMap[$nameKey]) ? $fieldMap[$nameKey] : $nameKey;
|
|
|
+ $items = [];
|
|
|
+ foreach ($episodes as $episode) {
|
|
|
+ $list = json_decode(getProp($episode, $field, ''), true) ?: [];
|
|
|
+ foreach ($list as $item) {
|
|
|
+ if (!is_array($item)) continue;
|
|
|
+ $name = trim((string)getProp($item, $nameKey, ''));
|
|
|
+ if ($name === '') continue;
|
|
|
+ // 旁白不进入主体列表(仅作为旁白音色来源)
|
|
|
+ if ($nameKey === 'role' && $name === '旁白') continue;
|
|
|
+ if (!isset($items[$name])) {
|
|
|
+ $items[$name] = $item;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return array_values($items);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 构建动漫剧本PDF
|
|
|
+ * @param array $anime 动漫信息
|
|
|
+ * @param array $roles 主体列表
|
|
|
+ * @param array $scenes 场景列表
|
|
|
+ * @param array $props 道具列表
|
|
|
+ * @param array $episodeScripts 分集剧本数据
|
|
|
+ * @param array $imageCache 图片缓存(url => 临时文件路径)
|
|
|
+ * @return \TCPDF
|
|
|
+ */
|
|
|
+ private function buildAnimeScriptPdf($anime, $roles, $scenes, $props, $episodeScripts, $imageCache = []) {
|
|
|
+ $pdf = new \TCPDF('P', 'mm', 'A4', true, 'UTF-8', false);
|
|
|
+ $pdf->SetCreator('动漫剧本导出系统');
|
|
|
+ $pdf->SetAuthor('系统');
|
|
|
+ $pdf->SetTitle(getProp($anime, 'anime_name', '动漫剧本'));
|
|
|
+ $pdf->SetSubject('动漫剧本导出');
|
|
|
+ $pdf->SetMargins(15, 15, 15);
|
|
|
+ $pdf->SetAutoPageBreak(true, 15);
|
|
|
+
|
|
|
+ // 剧本名
|
|
|
+ $pdf->AddPage();
|
|
|
+ $pdf->SetFont('simsun', 'B', 20);
|
|
|
+ $pdf->Cell(0, 15, getProp($anime, 'anime_name', ''), 0, 1, 'C');
|
|
|
+ $pdf->Ln(5);
|
|
|
+
|
|
|
+ // 故事梗概
|
|
|
+ $intro = trim((string)getProp($anime, 'intro', ''));
|
|
|
+ if ($intro !== '') {
|
|
|
+ $this->pdfSectionTitle($pdf, '故事梗概');
|
|
|
+ $this->pdfBody($pdf, $intro);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 美术风格
|
|
|
+ $artStyle = trim((string)getProp($anime, 'art_style', ''));
|
|
|
+ if ($artStyle !== '') {
|
|
|
+ $this->pdfSectionTitle($pdf, '美术风格');
|
|
|
+ $this->pdfBody($pdf, $artStyle);
|
|
|
+ }
|
|
|
+
|
|
|
+ // 主体列表(文字+图片)
|
|
|
+ $this->pdfSectionTitle($pdf, '主体列表');
|
|
|
+ foreach ($roles as $index => $role) {
|
|
|
+ $this->pdfBody($pdf, ($index + 1) . '. ' . getProp($role, 'role', '') . ':' . getProp($role, 'description', ''));
|
|
|
+ }
|
|
|
+ $this->pdfItemImages($pdf, $roles, 'role', $imageCache);
|
|
|
+
|
|
|
+ // 场景列表(文字+图片)
|
|
|
+ $this->pdfSectionTitle($pdf, '场景列表');
|
|
|
+ foreach ($scenes as $index => $scene) {
|
|
|
+ $this->pdfBody($pdf, ($index + 1) . '. ' . getProp($scene, 'scene', '') . ':' . getProp($scene, 'description', ''));
|
|
|
+ }
|
|
|
+ $this->pdfItemImages($pdf, $scenes, 'scene', $imageCache);
|
|
|
+
|
|
|
+ // 道具列表(文字+图片)
|
|
|
+ $this->pdfSectionTitle($pdf, '道具列表');
|
|
|
+ foreach ($props as $index => $prop) {
|
|
|
+ $this->pdfBody($pdf, ($index + 1) . '. ' . getProp($prop, 'prop', '') . ':' . getProp($prop, 'description', ''));
|
|
|
+ }
|
|
|
+ $this->pdfItemImages($pdf, $props, 'prop', $imageCache);
|
|
|
+
|
|
|
+ // 分镜剧本
|
|
|
+ $this->pdfSectionTitle($pdf, '分镜剧本');
|
|
|
+ foreach ($episodeScripts as $episodeScript) {
|
|
|
+ $episodeTitle = '第' . getProp($episodeScript, 'episode_number') . '集';
|
|
|
+ $episodeTitleRaw = trim((string)getProp($episodeScript, 'title', ''));
|
|
|
+ if ($episodeTitleRaw !== '') {
|
|
|
+ // title 字段可能已带"第N集"前缀,避免重复
|
|
|
+ if (preg_match('/^第\d+集[::\s]/u', $episodeTitleRaw)) {
|
|
|
+ $episodeTitle = $episodeTitleRaw;
|
|
|
+ } else {
|
|
|
+ $episodeTitle .= ':' . $episodeTitleRaw;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ $this->pdfSubTitle($pdf, $episodeTitle, 13);
|
|
|
+ $this->pdfBody($pdf, '片段数量:' . getProp($episodeScript, 'act_count', 0));
|
|
|
+ if (getProp($episodeScript, 'narrator_voice') !== '') {
|
|
|
+ $this->pdfBody($pdf, '旁白音色:' . getProp($episodeScript, 'narrator_voice'));
|
|
|
+ }
|
|
|
+ foreach (getProp($episodeScript, 'acts', []) as $act) {
|
|
|
+ $actTitle = '片段' . getProp($act, 'act_number');
|
|
|
+ if (getProp($act, 'duration') !== '' && getProp($act, 'duration') !== null) {
|
|
|
+ $actTitle .= ' 时长 ' . getProp($act, 'duration') . 's';
|
|
|
+ }
|
|
|
+ $this->pdfSubTitle($pdf, $actTitle, 12);
|
|
|
+ if (getProp($act, 'content') !== '') {
|
|
|
+ $this->pdfBody($pdf, getProp($act, 'content'));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ return $pdf;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * PDF章节标题(带分隔线)
|
|
|
+ */
|
|
|
+ private function pdfSectionTitle($pdf, $title) {
|
|
|
+ if ($pdf->GetY() > 230) {
|
|
|
+ $pdf->AddPage();
|
|
|
+ }
|
|
|
+ $pdf->SetFont('simsun', 'B', 15);
|
|
|
+ $pdf->Cell(0, 10, $title, 0, 1, 'L');
|
|
|
+ $y = $pdf->GetY();
|
|
|
+ $pdf->SetLineWidth(0.4);
|
|
|
+ $pdf->Line(15, $y, 195, $y);
|
|
|
+ $pdf->Ln(4);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * PDF子标题(集数/片段标题)
|
|
|
+ */
|
|
|
+ private function pdfSubTitle($pdf, $title, $size = 13) {
|
|
|
+ if ($pdf->GetY() > 240) {
|
|
|
+ $pdf->AddPage();
|
|
|
+ }
|
|
|
+ $pdf->SetFont('simsun', 'B', $size);
|
|
|
+ $pdf->Cell(0, 8, $title, 0, 1, 'L');
|
|
|
+ $pdf->Ln(1);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * PDF正文段落
|
|
|
+ */
|
|
|
+ private function pdfBody($pdf, $text) {
|
|
|
+ $pdf->SetFont('simsun', '', 11);
|
|
|
+ $pdf->MultiCell(0, 6, $text, 0, 'L');
|
|
|
+ $pdf->Ln(2);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 输出列表项图片(每张图单独一页,图下标注名称)
|
|
|
+ * @param \TCPDF $pdf
|
|
|
+ * @param array $items 列表项
|
|
|
+ * @param string $nameKey role|scene|prop
|
|
|
+ * @param array $imageCache 图片缓存(url => 临时文件路径)
|
|
|
+ */
|
|
|
+ private function pdfItemImages($pdf, array $items, $nameKey, array $imageCache) {
|
|
|
+ foreach ($items as $item) {
|
|
|
+ $url = trim((string)getProp($item, 'url', ''));
|
|
|
+ $name = trim((string)getProp($item, $nameKey, ''));
|
|
|
+ if ($url === '' || !isset($imageCache[$url])) continue;
|
|
|
+
|
|
|
+ $imgPath = $imageCache[$url];
|
|
|
+
|
|
|
+ $size = @getimagesize($imgPath);
|
|
|
+ if (!$size || $size[0] <= 0 || $size[1] <= 0) {
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+
|
|
|
+ $pdf->AddPage();
|
|
|
+ // 图片按比例缩放,居中偏上,为底部名称标注预留空间
|
|
|
+ $maxW = 160;
|
|
|
+ $maxH = 170;
|
|
|
+ $ratio = min($maxW / $size[0], $maxH / $size[1], 1);
|
|
|
+ $drawW = $size[0] * $ratio;
|
|
|
+ $drawH = $size[1] * $ratio;
|
|
|
+ $x = (210 - $drawW) / 2;
|
|
|
+ $y = max(15, (297 - $drawH) / 2 - 10);
|
|
|
+ $pdf->Image($imgPath, $x, $y, $drawW, $drawH, '', '', '', false, 300, '', false, false, 0, false, false, false);
|
|
|
+
|
|
|
+ // 图下标注名称
|
|
|
+ $pdf->SetFont('simsun', 'B', 13);
|
|
|
+ $pdf->SetY(297 - 28);
|
|
|
+ $pdf->Cell(0, 8, $name, 0, 1, 'C');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 并发下载远程图片到临时文件,并统一缩放转为JPEG
|
|
|
+ * @param array $items 包含url字段的列表项
|
|
|
+ * @return array url => 临时文件路径
|
|
|
+ */
|
|
|
+ private function downloadImagesConcurrently(array $items) {
|
|
|
+ // 收集唯一的图片URL
|
|
|
+ $urls = [];
|
|
|
+ foreach ($items as $item) {
|
|
|
+ $url = trim((string)getProp($item, 'url', ''));
|
|
|
+ if ($url !== '') $urls[$url] = true;
|
|
|
+ }
|
|
|
+ if (!$urls) return [];
|
|
|
+ $urls = array_keys($urls);
|
|
|
+
|
|
|
+ $tmpDir = sys_get_temp_dir();
|
|
|
+ $rawFiles = [];
|
|
|
+ foreach ($urls as $url) {
|
|
|
+ $rawFile = $tmpDir . '/anime_pdf_raw_' . md5($url) . '.img';
|
|
|
+ $rawFiles[$url] = $rawFile;
|
|
|
+ }
|
|
|
+
|
|
|
+ $client = new Client(['timeout' => 180, 'verify' => false]);
|
|
|
+
|
|
|
+ // 低并发下载,sink写入文件避免大图占用内存
|
|
|
+ $generator = (function () use ($urls, $client, $rawFiles) {
|
|
|
+ foreach ($urls as $url) {
|
|
|
+ yield $url => $client->getAsync($url, ['sink' => $rawFiles[$url]]);
|
|
|
+ }
|
|
|
+ })();
|
|
|
+
|
|
|
+ $errors = [];
|
|
|
+ $each = new \GuzzleHttp\Promise\EachPromise($generator, [
|
|
|
+ 'concurrency' => 4,
|
|
|
+ 'rejected' => function ($reason, $url) use (&$errors) {
|
|
|
+ $errors[$url] = $reason ? $reason->getMessage() : 'unknown';
|
|
|
+ },
|
|
|
+ ]);
|
|
|
+ $each->promise()->wait();
|
|
|
+
|
|
|
+ // 失败的图片串行重试一次(独占带宽,提高成功率)
|
|
|
+ foreach ($urls as $url) {
|
|
|
+ if (!isset($errors[$url])) continue;
|
|
|
+ try {
|
|
|
+ $client->get($url, ['sink' => $rawFiles[$url], 'timeout' => 300]);
|
|
|
+ unset($errors[$url]);
|
|
|
+ } catch (\Throwable $e) {
|
|
|
+ // 重试仍失败
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ $cache = [];
|
|
|
+ foreach ($urls as $url) {
|
|
|
+ $rawFile = $rawFiles[$url];
|
|
|
+ if (isset($errors[$url])) {
|
|
|
+ dLog('anime')->error('导出PDF图片下载失败', [
|
|
|
+ 'url' => $url,
|
|
|
+ 'error' => $errors[$url],
|
|
|
+ ]);
|
|
|
+ @unlink($rawFile);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ $converted = $this->convertImageFile($rawFile, $url);
|
|
|
+ if ($converted !== null) {
|
|
|
+ $cache[$url] = $converted;
|
|
|
+ } else {
|
|
|
+ @unlink($rawFile);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return $cache;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将已下载的图片文件缩放并转为JPEG
|
|
|
+ * @param string $rawFile 原始图片临时文件
|
|
|
+ * @param string $url 图片地址(用于命名)
|
|
|
+ * @return string|null 转换后的文件路径,转换失败返回null
|
|
|
+ */
|
|
|
+ private function convertImageFile($rawFile, $url) {
|
|
|
+ $content = @file_get_contents($rawFile);
|
|
|
+ if ($content === false || $content === '') return null;
|
|
|
+
|
|
|
+ if (!function_exists('imagecreatefromstring')) return null;
|
|
|
+
|
|
|
+ $image = @imagecreatefromstring($content);
|
|
|
+ if ($image === false) return null;
|
|
|
+
|
|
|
+ $image = $this->resizeImageForPdf($image, 2048);
|
|
|
+ $jpeg = dirname($rawFile) . '/anime_pdf_img_' . md5($url) . '.jpg';
|
|
|
+ $blank = imagecreatetruecolor(imagesx($image), imagesy($image));
|
|
|
+ $white = imagecolorallocate($blank, 255, 255, 255);
|
|
|
+ imagefill($blank, 0, 0, $white);
|
|
|
+ imagecopy($blank, $image, 0, 0, 0, 0, imagesx($image), imagesy($image));
|
|
|
+ $saved = imagejpeg($blank, $jpeg, 85);
|
|
|
+ imagedestroy($image);
|
|
|
+ imagedestroy($blank);
|
|
|
+ if ($saved) {
|
|
|
+ @unlink($rawFile);
|
|
|
+ return $jpeg;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 图片等比缩放(限制最大边,降低内存与PDF体积)
|
|
|
+ * @param resource $image GD图片资源
|
|
|
+ * @param int $maxEdge 最大边长
|
|
|
+ * @return resource
|
|
|
+ */
|
|
|
+ private function resizeImageForPdf($image, $maxEdge) {
|
|
|
+ $width = imagesx($image);
|
|
|
+ $height = imagesy($image);
|
|
|
+ $max = max($width, $height);
|
|
|
+ if ($max <= $maxEdge) return $image;
|
|
|
+
|
|
|
+ $ratio = $maxEdge / $max;
|
|
|
+ $newWidth = max(1, (int)round($width * $ratio));
|
|
|
+ $newHeight = max(1, (int)round($height * $ratio));
|
|
|
+ $resized = imagecreatetruecolor($newWidth, $newHeight);
|
|
|
+ imagealphablending($resized, false);
|
|
|
+ imagesavealpha($resized, true);
|
|
|
+ imagecopyresampled($resized, $image, 0, 0, 0, 0, $newWidth, $newHeight, $width, $height);
|
|
|
+ imagedestroy($image);
|
|
|
+ return $resized;
|
|
|
+ }
|
|
|
}
|
|
|
|