Просмотр исходного кода

新增导出动漫剧本接口

lh 1 месяц назад
Родитель
Сommit
e5c92ad330
3 измененных файлов с 455 добавлено и 0 удалено
  1. 19 0
      app/Http/Controllers/Anime/AnimeController.php
  2. 433 0
      app/Services/Anime/AnimeService.php
  3. 3 0
      routes/api.php

+ 19 - 0
app/Http/Controllers/Anime/AnimeController.php

@@ -1264,6 +1264,25 @@ class AnimeController extends BaseController
     }
 
     /**
+     * 导出动漫剧本为PDF
+     * @param Request $request
+     */
+    public function exportAnimeScriptPdf(Request $request) {
+        // 忽略超时限制(图片下载可能耗时较长)
+        set_time_limit(0);
+        ini_set('max_execution_time', '0');
+        ini_set('memory_limit', '512M');
+
+        $animeId = (int)$request->input('anime_id', 0);
+        $episodeCount = (int)$request->input('episode_count', 8);
+        if ($animeId <= 0) {
+            Utils::throwError('20003:缺少anime_id');
+        }
+
+        return $this->AnimeService->exportAnimeScriptAsPdf($animeId, $episodeCount);
+    }
+
+    /**
      * 导出分集的全部图片(下载并打包)
      */
     public function exportEpisodeImages(Request $request) {

+ 433 - 0
app/Services/Anime/AnimeService.php

@@ -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;
+    }
 }
 

+ 3 - 0
routes/api.php

@@ -234,6 +234,9 @@ Route::group(['middleware' => ['bindToken', 'bindExportToken', 'checkLogin']], f
             // 导出全部分镜
             Route::get('exportEpisodeImages', [AnimeController::class, 'exportEpisodeImages']);
 
+            // 导出动漫剧本为PDF
+            Route::get('exportScriptPdf', [AnimeController::class, 'exportAnimeScriptPdf']);
+
             Route::get('globalProducts', [AnimeController::class, 'globalProducts']);     // 角色库
             Route::post('createProduct', [AnimeController::class, 'createProduct']);      // 创建资产
             Route::post('editProduct', [AnimeController::class, 'editProduct']);          // 编辑资产