diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index ebd22c0..67aedc4 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5112,7 +5112,7 @@ dependencies = [ [[package]] name = "tauri-app" -version = "0.1.9" +version = "0.1.11" dependencies = [ "aes-gcm", "async-trait", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c21bbb7..23187cc 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tauri-app" -version = "0.1.9" +version = "0.1.11" description = "A Tauri App" authors = ["you"] edition = "2021" diff --git a/src-tauri/build.rs b/src-tauri/build.rs index 20962c9..82027f3 100644 --- a/src-tauri/build.rs +++ b/src-tauri/build.rs @@ -15,6 +15,10 @@ const APP_COMMANDS: &[&str] = &[ "run_nodejs_script", "run_nodejs_script_source", "list_nodejs_scripts", + "run_python_script", + "run_python_script_source", + "list_python_scripts", + "stop_python_script", "read_local_file_base64", "write_local_file_base64", "get_media_duration_seconds", diff --git a/src-tauri/permissions/default.toml b/src-tauri/permissions/default.toml index c4f59c5..3fa1507 100644 --- a/src-tauri/permissions/default.toml +++ b/src-tauri/permissions/default.toml @@ -17,6 +17,10 @@ permissions = [ "allow-run-nodejs-script", "allow-run-nodejs-script-source", "allow-list-nodejs-scripts", + "allow-run-python-script", + "allow-run-python-script-source", + "allow-list-python-scripts", + "allow-stop-python-script", "allow-read-local-file-base64", "allow-write-local-file-base64", "allow-get-media-duration-seconds", diff --git a/src-tauri/resources/cover-python/advanced_cover_generator.py b/src-tauri/resources/cover-python/advanced_cover_generator.py index 7f0e179..69d65ad 100644 --- a/src-tauri/resources/cover-python/advanced_cover_generator.py +++ b/src-tauri/resources/cover-python/advanced_cover_generator.py @@ -693,12 +693,13 @@ class AdvancedCoverGenerator: print(f"Converted text position from string to dict: {text_position}", file=sys.stderr) # 加载字体 - font_path = self._get_font_path(font_family) + from modules.chinese_font import load_cjk_font + try: - font = ImageFont.truetype(font_path, font_size) - except: - print(f"Warning: Failed to load font {font_path}, using default", file=sys.stderr) - font = ImageFont.load_default() + font = load_cjk_font(font_family, font_size, font_weight) + except Exception as e: + print(f"Warning: Failed to load title font: {e}", file=sys.stderr) + font = load_cjk_font("SimHei", font_size, font_weight) # ✅ 关键修复:支持多行文字,使用anchor='mm'实现中心对齐 # 计算文字中心点位置(百分比转像素) @@ -949,8 +950,8 @@ class AdvancedCoverGenerator: font_size = font_size_from_config # 重新加载字体 try: - font = ImageFont.truetype(font_path, font_size) - except: + font = load_cjk_font(font_family, font_size, font_weight) + except Exception: pass # 获取排版参数 - 优先使用 titles.main 中的值,否则使用顶级配置,最后使用默认值 @@ -1391,12 +1392,15 @@ class AdvancedCoverGenerator: print(f"Converted subtitle position from string to dict: {subtitle_position}", file=sys.stderr) # 加载副标题字体 - subtitle_font_path = self._get_font_path(subtitle_font_family) + from modules.chinese_font import load_cjk_font + try: - subtitle_font = ImageFont.truetype(subtitle_font_path, subtitle_font_size) - except: - print(f"Warning: Failed to load subtitle font {subtitle_font_path}, using default", file=sys.stderr) - subtitle_font = ImageFont.load_default() + subtitle_font = load_cjk_font( + subtitle_font_family, subtitle_font_size, subtitle_font_weight + ) + except Exception as e: + print(f"Warning: Failed to load subtitle font: {e}", file=sys.stderr) + subtitle_font = load_cjk_font("SimHei", subtitle_font_size, subtitle_font_weight) # 计算副标题中心点位置(百分比转像素) # ✅ 修复旋转坐标问题:有旋转时Y轴需要反转,无旋转时不需要 @@ -1442,8 +1446,14 @@ class AdvancedCoverGenerator: subtitle_font_size = subtitle_font_size_from_config # 重新加载字体 try: - subtitle_font = ImageFont.truetype(subtitle_font_path, subtitle_font_size) - except: + from modules.chinese_font import load_cjk_font + + subtitle_font = load_cjk_font( + subtitle_font_family, + subtitle_font_size, + subtitle_font_weight, + ) + except Exception: pass # 获取排版参数 - 优先使用 titles.sub 中的值,否则使用顶级配置,最后使用默认值 @@ -1937,13 +1947,13 @@ class AdvancedCoverGenerator: break # 如果还是找不到,使用默认字体 - if not font_path or not os.path.exists(font_path): - print(f"Warning: Font file not found for '{font_family}', using fallback", file=sys.stderr) - if font_path: - print(f" Attempted path: {font_path}", file=sys.stderr) - return 'C:\\Windows\\Fonts\\msyh.ttc' - - return font_path + try: + from modules.chinese_font import resolve_cjk_font_path + + return resolve_cjk_font_path(font_family, 400) + except Exception as e: + print(f"Warning: _get_font_path fallback: {e}", file=sys.stderr) + return str(Path(os.environ.get("WINDIR", "C:/Windows")) / "Fonts" / "simhei.ttf") # ✅ 标准封面尺寸 3:4 比例(与前端 CoverCustomEditor.vue 一致) COVER_WIDTH = 1080 diff --git a/src-tauri/resources/cover-python/cover_generator.py b/src-tauri/resources/cover-python/cover_generator.py index 4a06ee1..c47609a 100644 --- a/src-tauri/resources/cover-python/cover_generator.py +++ b/src-tauri/resources/cover-python/cover_generator.py @@ -115,11 +115,16 @@ def add_text_to_image(img, title, config): style = config.get('style', 'default') # default, outline, blur_bg, gradient, split - # 加载字体 + # 加载字体(禁止 load_default,否则中文乱码) try: - font = ImageFont.truetype(font_path, font_size) - except: - font = ImageFont.load_default() + from modules.chinese_font import load_cjk_font + + font = load_cjk_font(font_family, font_size) + except Exception as e: + print(f"[cover_generator] font load failed: {e}, retry SimHei", file=sys.stderr) + from modules.chinese_font import load_cjk_font + + font = load_cjk_font("SimHei", font_size) # 创建绘图对象 draw = ImageDraw.Draw(pil_img) diff --git a/src-tauri/resources/cover-python/modules/chinese_font.py b/src-tauri/resources/cover-python/modules/chinese_font.py new file mode 100644 index 0000000..6ba7b26 --- /dev/null +++ b/src-tauri/resources/cover-python/modules/chinese_font.py @@ -0,0 +1,161 @@ +# -*- coding: utf-8 -*- +"""封面中文 PIL / ffmpeg 字体解析(避免 load_default 导致乱码)""" + +from __future__ import annotations + +import os +import platform +import sys +from pathlib import Path +from typing import List, Optional + +try: + from PIL import ImageFont +except ImportError: + ImageFont = None # type: ignore + +_FAMILY_ALIASES = { + "microsoft yahei": "Microsoft YaHei", + "微软雅黑": "Microsoft YaHei", + "simhei": "SimHei", + "黑体": "SimHei", + "simsun": "SimSun", + "宋体": "SimSun", + "arial": "Arial", + "times new roman": "Times New Roman", +} + +_WIN_FAMILY_FILES = { + "SimHei": ["simhei.ttf"], + "Microsoft YaHei": ["msyh.ttc", "msyh.ttf", "msyhbd.ttc"], + "SimSun": ["simsun.ttc", "simsunb.ttf"], + "Arial": ["arial.ttf", "arialbd.ttf"], + "Times New Roman": ["times.ttf", "timesbd.ttf"], +} + + +def _scripts_dir() -> Optional[Path]: + env = os.environ.get("AICLIENT_COVER_SCRIPTS_DIR", "").strip() + if env: + p = Path(env) + if p.is_dir(): + return p + # advanced_cover_generator.py 位于 scripts/cover/ + here = Path(__file__).resolve().parent.parent + if (here / "advanced_cover_generator.py").is_file(): + return here + return None + + +def _windows_fonts_dir() -> Path: + windir = os.environ.get("WINDIR", "C:\\Windows") + return Path(windir) / "Fonts" + + +def _normalize_family(font_family: Optional[str]) -> str: + raw = (font_family or "Microsoft YaHei").strip() + key = raw.lower() + return _FAMILY_ALIASES.get(key, raw) + + +def _candidate_filenames(family: str, weight: int = 400) -> List[str]: + names = list(_WIN_FAMILY_FILES.get(family, [])) + if weight >= 700 and family == "Microsoft YaHei": + names = ["msyhbd.ttc", "msyhl.ttc"] + names + # 通用回退:.ttf 优先于 .ttc(PIL 对 ttf 更稳) + fallback = ["simhei.ttf", "msyh.ttc", "simsun.ttc", "msyh.ttf"] + out: List[str] = [] + for n in names + fallback: + if n not in out: + out.append(n) + return out + + +def resolve_cjk_font_path( + font_family: Optional[str] = None, + weight: int = 400, +) -> str: + """ + 解析可用于 PIL / ffmpeg 的中文字体文件路径。 + """ + family = _normalize_family(font_family) + candidates = _candidate_filenames(family, weight) + + scripts = _scripts_dir() + if scripts: + fonts_dir = scripts / "fonts" + if fonts_dir.is_dir(): + for name in candidates: + p = fonts_dir / name + if p.is_file(): + return str(p.resolve()) + + if platform.system() == "Windows": + win_fonts = _windows_fonts_dir() + for name in candidates: + p = win_fonts / name + if p.is_file(): + return str(p.resolve()) + + if platform.system() == "Darwin": + mac_candidates = [ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/STHeiti Light.ttc", + "/Library/Fonts/Arial Unicode.ttf", + ] + for p in mac_candidates: + if os.path.isfile(p): + return p + + # Linux + linux_candidates = [ + "/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc", + "/usr/share/fonts/truetype/wqy/wqy-microhei.ttc", + "/usr/share/fonts/truetype/droid/DroidSansFallbackFull.ttf", + ] + for p in linux_candidates: + if os.path.isfile(p): + return p + + # 最后尝试:Windows 黑体 + if platform.system() == "Windows": + simhei = _windows_fonts_dir() / "simhei.ttf" + if simhei.is_file(): + return str(simhei.resolve()) + + raise FileNotFoundError( + f"未找到中文字体(family={family}),请在 cover/fonts 放置 simhei.ttf 或安装系统字体" + ) + + +def load_cjk_font( + font_family: Optional[str], + size: int, + weight: int = 400, +) -> "ImageFont.FreeTypeFont": + """加载支持中文的 PIL 字体,禁止回退到 load_default。""" + if ImageFont is None: + raise RuntimeError("PIL ImageFont 不可用") + + path = resolve_cjk_font_path(font_family, weight) + ext = os.path.splitext(path)[1].lower() + + if ext == ".ttc": + last_err: Optional[Exception] = None + for index in range(8): + try: + font = ImageFont.truetype(path, size, index=index) + print( + f"[chinese_font] Loaded TTC index={index}: {path} size={size}", + file=sys.stderr, + ) + return font + except OSError as e: + last_err = e + continue + raise OSError(f"无法加载字体集合 {path}: {last_err}") + + font = ImageFont.truetype(path, size) + print(f"[chinese_font] Loaded: {path} size={size}", file=sys.stderr) + return font diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index e42cb16..4721137 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -8,6 +8,7 @@ pub mod sound_asr; pub mod video; pub mod fs_util; pub mod nodejs; +pub mod pythonscript; pub mod publish; pub mod quickjs; pub mod software_info; diff --git a/src-tauri/src/commands/pythonscript.rs b/src-tauri/src/commands/pythonscript.rs new file mode 100644 index 0000000..a2858b6 --- /dev/null +++ b/src-tauri/src/commands/pythonscript.rs @@ -0,0 +1,214 @@ +//! Python 流水线命令:封面等脚本由后端 `/api/v1/python-scripts` 下发, +//! 由打包的 `python.exe` 执行。 +//! +//! 前端用法: +//! ```ignore +//! import { invoke } from "@tauri-apps/api/core"; +//! import { listen } from "@tauri-apps/api/event"; +//! +//! const off = await listen("python:event", (e) => console.log(e.payload)); +//! const result = await invoke("run_python_script", { +//! scriptName: "cover_generate.py", +//! params: { videoPath, titleText, effectStyle }, +//! }); +//! off(); +//! ``` + +use serde::Serialize; +use serde_json::Value; +use std::collections::HashMap; +use tauri::{AppHandle, Emitter, Window}; +use tokio::sync::{mpsc, watch, Mutex}; +use tokio::time::{timeout, Duration}; + +use crate::app_config::AppConfig; +use crate::js_runtime::PipelineEvent; +use crate::pythonscript; + +const EVENT_NAME: &str = "python:event"; + +#[derive(Default)] +pub struct PythonTaskRegistry { + inner: Mutex>>, +} + +#[derive(Debug, Serialize, Clone)] +#[serde(tag = "type", rename_all = "camelCase")] +pub enum FrontendEvent { + Progress { + #[serde(rename = "scriptName")] + script_name: String, + status: String, + }, + Log { + #[serde(rename = "scriptName")] + script_name: String, + level: String, + msg: String, + #[serde(skip_serializing_if = "Option::is_none")] + fields: Option, + }, +} + +fn log_python_event(script_name: &str, level: &str, msg: &str, fields: &Option) { + let extra = fields + .as_ref() + .map(|f| format!(" {f}")) + .unwrap_or_default(); + let line = format!("[{script_name}] {msg}{extra}"); + match level { + "error" => log::error!(target: "pythonscript", "{line}"), + "warn" => log::warn!(target: "pythonscript", "{line}"), + _ => log::info!(target: "pythonscript", "{line}"), + } +} + +pub(crate) fn spawn_event_pump( + app: AppHandle, + window: Window, + script_name: String, + mut rx: mpsc::UnboundedReceiver, +) -> tokio::task::JoinHandle<()> { + let win_label = window.label().to_string(); + tokio::spawn(async move { + while let Some(ev) = rx.recv().await { + let payload = match &ev { + PipelineEvent::Progress(s) => FrontendEvent::Progress { + script_name: script_name.clone(), + status: s.clone(), + }, + PipelineEvent::Log { + level, + msg, + fields, + } => { + log_python_event(&script_name, level, msg, fields); + FrontendEvent::Log { + script_name: script_name.clone(), + level: level.clone(), + msg: msg.clone(), + fields: fields.clone(), + } + } + }; + let _ = app.emit_to(&win_label, EVENT_NAME, payload); + } + }) +} + +async fn finish_event_pump(mut pump: tokio::task::JoinHandle<()>) { + if timeout(Duration::from_millis(300), &mut pump).await.is_err() { + pump.abort(); + let _ = pump.await; + } +} + +/// 按脚本名运行:从后端拉取脚本及本地 import 依赖,spawn python 子进程。 +#[tauri::command] +pub async fn run_python_script( + app: AppHandle, + window: Window, + app_config: tauri::State<'_, AppConfig>, + registry: tauri::State<'_, PythonTaskRegistry>, + script_name: String, + params: Value, +) -> Result { + let (cancel_tx, cancel_rx) = watch::channel(false); + { + let mut map = registry.inner.lock().await; + if map.contains_key(&script_name) { + return Err(format!("脚本正在运行: {script_name}")); + } + map.insert(script_name.clone(), cancel_tx); + } + + let (tx, rx) = mpsc::unbounded_channel::(); + let pump = spawn_event_pump(app, window, script_name.clone(), rx); + let config_env = app_config.env_for_node().await; + + let result = pythonscript::run_python_script( + script_name.clone(), + params, + tx, + config_env, + cancel_rx, + ) + .await + .map_err(|e| e.to_string()); + + { + let mut map = registry.inner.lock().await; + map.remove(&script_name); + } + + let result = result?; + + finish_event_pump(pump).await; + Ok(result) +} + +/// 调试:直接执行传入的 Python 源码(仍从服务端拉取 import 依赖)。 +#[tauri::command] +pub async fn run_python_script_source( + app: AppHandle, + window: Window, + app_config: tauri::State<'_, AppConfig>, + registry: tauri::State<'_, PythonTaskRegistry>, + script_source: String, + params: Value, +) -> Result { + let script_key = "".to_string(); + let (cancel_tx, cancel_rx) = watch::channel(false); + { + let mut map = registry.inner.lock().await; + if map.contains_key(&script_key) { + return Err("调试 Python 脚本正在运行".to_string()); + } + map.insert(script_key.clone(), cancel_tx); + } + + let (tx, rx) = mpsc::unbounded_channel::(); + let pump = spawn_event_pump(app, window, script_key.clone(), rx); + let config_env = app_config.env_for_node().await; + + let result = pythonscript::run_python_script_source( + script_source, + params, + tx, + config_env, + cancel_rx, + ) + .await + .map_err(|e| e.to_string()); + + { + let mut map = registry.inner.lock().await; + map.remove(&script_key); + } + + let result = result?; + + finish_event_pump(pump).await; + Ok(result) +} + +#[tauri::command] +pub async fn list_python_scripts() -> Result, String> { + pythonscript::list_scripts().await.map_err(|e| e.to_string()) +} + +#[tauri::command] +pub async fn stop_python_script( + registry: tauri::State<'_, PythonTaskRegistry>, + script_name: String, +) -> Result { + let sender = { + let map = registry.inner.lock().await; + map.get(&script_name).cloned() + }; + if let Some(tx) = sender { + tx.send(true).map_err(|e| e.to_string())?; + return Ok(true); + } + Ok(false) +} diff --git a/src-tauri/src/cover_python.rs b/src-tauri/src/cover_python.rs index 05829c9..70efdf3 100644 --- a/src-tauri/src/cover_python.rs +++ b/src-tauri/src/cover_python.rs @@ -1,5 +1,7 @@ -//! 封面 Python 运行时与脚本目录定位(对齐 Electron bundled python-runtime) +//! 封面 Python 运行时定位(bundled `resources/pythonruntime/python.exe`) +//! 封面脚本由服务端下发,不在此目录打包。 +use std::collections::HashMap; use std::path::{Path, PathBuf}; fn exe_name() -> &'static str { @@ -10,7 +12,43 @@ fn exe_name() -> &'static str { } } -/// bundled `python-runtimebackup` 或系统 / 环境变量 Python。 +fn pythonruntime_candidates() -> Vec { + let mut out = Vec::new(); + // 开发源码树优先:target/debug/resources 可能是残缺副本(缺 PIL 等) + out.push( + PathBuf::from("src-tauri/resources/pythonruntime").join(exe_name()), + ); + out.push(PathBuf::from("resources/pythonruntime").join(exe_name())); + if let Ok(exe) = std::env::current_exe() { + if let Some(dir) = exe.parent() { + out.push( + dir.join("resources") + .join("pythonruntime") + .join(exe_name()), + ); + } + } + out +} + +/// `python.exe` 所在目录即 PYTHONHOME(含 Lib/site-packages)。 +pub fn python_home(python_exe: &Path) -> PathBuf { + python_exe + .parent() + .map(|p| p.to_path_buf()) + .unwrap_or_else(|| PathBuf::from(".")) +} + +fn site_packages_dir(python_exe: &Path) -> PathBuf { + python_home(python_exe).join("Lib").join("site-packages") +} + +fn runtime_has_pillow(python_exe: &Path) -> bool { + let site = site_packages_dir(python_exe); + site.join("PIL").is_dir() || site.join("Pillow").is_dir() +} + +/// bundled `resources/pythonruntime/python.exe` 或 `AICLIENT_PYTHON_PATH`。 pub fn locate_python() -> Option { if let Ok(p) = std::env::var("AICLIENT_PYTHON_PATH") { let pb = PathBuf::from(&p); @@ -18,35 +56,46 @@ pub fn locate_python() -> Option { return Some(pb); } } - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let bundled = dir - .join("resources") - .join("resources-bundles") - .join("python-runtimebackup") - .join(exe_name()); - if bundled.exists() { - return Some(bundled); - } - let cover_only = dir.join("resources").join("cover-python").join(exe_name()); - if cover_only.exists() { - return Some(cover_only); - } + for path in pythonruntime_candidates() { + if path.is_file() && runtime_has_pillow(&path) { + return Some(path); } } - let dev_bundled = PathBuf::from("src-tauri/resources/resources-bundles/python-runtimebackup") - .join(exe_name()); - if dev_bundled.exists() { - return Some(dev_bundled); - } - let dev_cover = PathBuf::from("src-tauri/resources/cover-python").join(exe_name()); - if dev_cover.exists() { - return Some(dev_cover); + for path in pythonruntime_candidates() { + if path.is_file() { + return Some(path); + } } None } -/// 封面脚本目录(`advanced_cover_generator.py` 等)。 +/// 为 bundled Python 子进程设置 PYTHONHOME / PYTHONPATH(否则找不到 PIL/cv2)。 +pub fn apply_python_runtime_env( + env: &mut HashMap, + python_exe: &Path, + bundle_dir: Option<&Path>, +) { + let home = python_home(python_exe); + env.insert( + "PYTHONHOME".into(), + home.to_string_lossy().into_owned(), + ); + let site = site_packages_dir(python_exe); + let sep = if cfg!(windows) { ";" } else { ":" }; + let mut paths = vec![site.to_string_lossy().into_owned()]; + if let Some(dir) = bundle_dir { + paths.push(dir.to_string_lossy().into_owned()); + } + env.insert("PYTHONPATH".into(), paths.join(sep)); + env.remove("PYTHONUSERBASE"); + env.insert("PYTHONNOUSERSITE".into(), "1".into()); + env.insert( + "AICLIENT_PYTHON_HOME".into(), + home.to_string_lossy().into_owned(), + ); +} + +/// 开发期本地脚本目录(发布构建由服务端拉取,不依赖此路径)。 pub fn locate_cover_scripts_dir() -> Option { if let Ok(p) = std::env::var("AICLIENT_COVER_SCRIPTS_DIR") { let pb = PathBuf::from(&p); @@ -54,18 +103,6 @@ pub fn locate_cover_scripts_dir() -> Option { return Some(pb); } } - if let Ok(exe) = std::env::current_exe() { - if let Some(dir) = exe.parent() { - let bundled = dir.join("resources").join("cover-python"); - if bundled.join("advanced_cover_generator.py").is_file() { - return Some(bundled); - } - } - } - let dev = PathBuf::from("src-tauri/resources/cover-python"); - if dev.join("advanced_cover_generator.py").is_file() { - return Some(dev); - } let backend = PathBuf::from("../pythonbackend/scripts/cover"); if backend.join("advanced_cover_generator.py").is_file() { return backend.canonicalize().ok(); @@ -89,10 +126,10 @@ mod tests { use super::*; #[test] - fn cover_scripts_dev_path_exists() { - let dev = Path::new("src-tauri/resources/cover-python/advanced_cover_generator.py"); + fn pythonruntime_dev_path() { + let dev = PathBuf::from("src-tauri/resources/pythonruntime/python.exe"); if dev.is_file() { - assert!(locate_cover_scripts_dir().is_some()); + assert!(locate_python().is_some()); } } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 25f0b87..00e1b61 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ pub mod ffmpeg; pub mod http; pub mod js_runtime; pub mod nodejs; +pub mod pythonscript; pub mod agent_id; pub mod oem_context; pub mod oem_id; @@ -60,6 +61,7 @@ pub fn run() { .manage(video_store::VideoStore::new()) .manage(publish_store::PublishStore::new()) .manage(commands::nodejs::NodeTaskRegistry::default()) + .manage(commands::pythonscript::PythonTaskRegistry::default()) .setup(|app| { let handle = app.handle().clone(); @@ -131,6 +133,12 @@ pub fn run() { commands::nodejs::run_nodejs_script_source, commands::nodejs::list_nodejs_scripts, commands::nodejs::stop_nodejs_script, + commands::pythonscript::run_python_script, + commands::pythonscript::run_python_script_source, + commands::pythonscript::list_python_scripts, + commands::pythonscript::stop_python_script, + + commands::fs_util::read_local_file_base64, commands::fs_util::write_local_file_base64, commands::fs_util::get_media_duration_seconds, diff --git a/src-tauri/src/pythonscript.rs b/src-tauri/src/pythonscript.rs new file mode 100644 index 0000000..5826753 --- /dev/null +++ b/src-tauri/src/pythonscript.rs @@ -0,0 +1,591 @@ +//! Bundled Python runner:脚本由后端 `GET /api/v1/python-scripts?name=.py` 下发, +//! 在 bundled `python.exe` 中执行,协议与 Node 路径对齐(`__EVT__` / `__RESULT__` / `__python_main`)。 + +use std::collections::{HashMap, HashSet}; +use std::path::{Path, PathBuf}; +use std::process::Stdio; + +use once_cell::sync::Lazy; +use regex::Regex; +use serde_json::Value; +use thiserror::Error; +use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader}; +use tokio::process::Command; +use tokio::sync::mpsc::UnboundedSender; +use tokio::sync::watch; +use tokio::time::{timeout, Duration}; +use uuid::Uuid; + +use crate::cover_python; +use crate::js_runtime::PipelineEvent; + +static RE_SAFE_SCRIPT: Lazy = Lazy::new(|| { + Regex::new(r"^[a-zA-Z0-9][a-zA-Z0-9._/-]*\.py$").unwrap() +}); + +static RE_FROM_IMPORT: Lazy = + Lazy::new(|| Regex::new(r"(?m)^\s*from\s+([\w.]+)\s+import").unwrap()); + +static RE_IMPORT: Lazy = + Lazy::new(|| Regex::new(r"(?m)^\s*import\s+([\w.]+)").unwrap()); + +static RE_FROM_REL: Lazy = + Lazy::new(|| Regex::new(r"(?m)^\s*from\s+\.([\w]+)\s+import").unwrap()); + +const INLINE_ENTRY: &str = "__inline_entry__.py"; + +#[cfg(windows)] +fn hide_console_window(cmd: &mut Command) { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + cmd.as_std_mut().creation_flags(CREATE_NO_WINDOW); +} + +#[cfg(not(windows))] +fn hide_console_window(_cmd: &mut Command) {} + +#[derive(Debug, Error)] +pub enum PythonError { + #[error("python.exe 未找到(请检查 src-tauri/resources/pythonruntime/python.exe)")] + NotFound, + #[error("io: {0}")] + Io(#[from] std::io::Error), + #[error("拉取脚本失败: {0}")] + FetchScript(String), + #[error("python 进程非零退出 status={status:?}, stderr={stderr}")] + NonZero { status: Option, stderr: String }, + #[error("脚本未输出 __RESULT__ 行")] + NoResult, + #[error("json: {0}")] + Json(#[from] serde_json::Error), + #[error("任务已取消")] + Cancelled, +} + +fn normalize_script_name(name: &str) -> Option { + let raw = name.trim().replace('\\', "/"); + if raw.contains("..") { + return None; + } + if RE_SAFE_SCRIPT.is_match(&raw) { + Some(raw) + } else { + None + } +} + +fn module_to_rel_path(module: &str) -> Option { + if module.starts_with("modules.") { + return Some(format!("{}.py", module.replace('.', "/"))); + } + if module == "modules" { + return Some("modules/__init__.py".into()); + } + match module { + "advanced_cover_generator" | "cover_generator" | "cover_generate" => { + Some(format!("{}.py", module)) + } + _ => None, + } +} + +fn relative_import_to_path(current_rel: &str, sub: &str) -> Option { + let parent = Path::new(current_rel).parent().unwrap_or(Path::new("")); + let joined = parent.join(format!("{}.py", sub)); + let s = joined.to_string_lossy().replace('\\', "/"); + Some(s) +} + +fn extract_local_deps(source: &str, current_rel: &str) -> Vec { + let mut out = Vec::new(); + for cap in RE_FROM_IMPORT.captures_iter(source) { + if let Some(m) = cap.get(1) { + if let Some(p) = module_to_rel_path(m.as_str()) { + out.push(p); + } + } + } + for cap in RE_IMPORT.captures_iter(source) { + if let Some(m) = cap.get(1) { + if let Some(p) = module_to_rel_path(m.as_str()) { + out.push(p); + } + } + } + for cap in RE_FROM_REL.captures_iter(source) { + if let Some(sub) = cap.get(1) { + if let Some(p) = relative_import_to_path(current_rel, sub.as_str()) { + out.push(p); + } + } + } + out +} + +async fn fetch_python_script_source(script_name: &str) -> Result { + let q = urlencoding::encode(script_name); + let path = format!("/python-scripts?name={q}"); + let (_status, v) = crate::api_client::request_json("GET", &path, None, HashMap::new()) + .await + .map_err(PythonError::FetchScript)?; + if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) { + let msg = v + .get("message") + .and_then(|x| x.as_str()) + .unwrap_or("未知错误"); + return Err(PythonError::FetchScript(msg.to_string())); + } + let source = v + .get("data") + .and_then(|d| d.get("source")) + .and_then(|s| s.as_str()) + .ok_or_else(|| PythonError::FetchScript("响应缺少 data.source".into()))?; + Ok(source.to_string()) +} + +async fn fetch_python_script_source_optional( + script_name: &str, + required: bool, +) -> Result, PythonError> { + match fetch_python_script_source(script_name).await { + Ok(s) => Ok(Some(s)), + Err(PythonError::FetchScript(msg)) + if !required && (msg.contains("不存在") || msg.contains("无效的脚本")) => + { + log::warn!( + target: "pythonscript", + "skip missing optional script dependency: {script_name} ({msg})" + ); + Ok(None) + } + Err(e) => Err(e), + } +} + +async fn resolve_script_bundle(entry: &str) -> Result, PythonError> { + let entry_owned = normalize_script_name(entry) + .ok_or_else(|| PythonError::FetchScript(format!("无效的入口脚本名: {entry}")))?; + + let mut modules: HashMap = HashMap::new(); + let mut stack = vec![entry_owned.clone()]; + let mut seen: HashSet = HashSet::new(); + + while let Some(name) = stack.pop() { + if !seen.insert(name.clone()) { + continue; + } + if modules.contains_key(&name) { + continue; + } + let required = name == entry_owned; + let Some(source) = fetch_python_script_source_optional(&name, required).await? else { + continue; + }; + for dep in extract_local_deps(&source, &name) { + if !seen.contains(&dep) { + stack.push(dep); + } + } + modules.insert(name, source); + } + + Ok(modules) +} + +async fn resolve_script_bundle_for_inline( + inline_source: &str, +) -> Result, PythonError> { + let mut modules: HashMap = HashMap::new(); + let mut stack: Vec = extract_local_deps(inline_source, INLINE_ENTRY) + .into_iter() + .collect(); + let mut seen: HashSet = HashSet::new(); + + while let Some(name) = stack.pop() { + if !seen.insert(name.clone()) { + continue; + } + if modules.contains_key(&name) { + continue; + } + let Some(source) = fetch_python_script_source_optional(&name, false).await? else { + continue; + }; + for dep in extract_local_deps(&source, &name) { + if !seen.contains(&dep) { + stack.push(dep); + } + } + modules.insert(name, source); + } + + modules.insert(INLINE_ENTRY.to_string(), inline_source.to_string()); + Ok(modules) +} + +fn entry_module_name(entry_rel: &str) -> String { + Path::new(entry_rel) + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("user_entry") + .to_string() +} + +fn build_file_runner(entry_rel: &str) -> String { + let entry_file = entry_rel.replace('\\', "/"); + let entry_mod = entry_module_name(entry_rel); + format!( + r#" +# ---- aiclient auto-generated python runner (do not edit) ---- +import importlib.util +import json +import os +import sys + +def _emit(obj): + sys.stderr.write("__EVT__" + json.dumps(obj, ensure_ascii=False) + "\n") + sys.stderr.flush() + +class _Native: + @staticmethod + def emit_progress(status): + _emit({{"type": "progress", "status": str(status)}}) + + @staticmethod + def log(level, msg, fields=None): + _emit({{ + "type": "log", + "level": str(level or "info"), + "msg": str(msg), + "fields": fields, + }}) + +def main(): + raw = sys.stdin.read() + params = json.loads(raw) if raw.strip() else None + bundle_dir = os.getcwd() + if bundle_dir not in sys.path: + sys.path.insert(0, bundle_dir) + entry_file = os.path.join(bundle_dir, "{entry_file}") + entry_mod = "{entry_mod}" + spec = importlib.util.spec_from_file_location(entry_mod, entry_file) + if spec is None or spec.loader is None: + raise RuntimeError("无法加载入口脚本: " + entry_file) + mod = importlib.util.module_from_spec(spec) + mod.__dict__["__native"] = _Native + sys.modules[entry_mod] = mod + spec.loader.exec_module(mod) + fn = getattr(mod, "__python_main", None) + if fn is None: + raise RuntimeError("脚本未定义 __python_main(params)") + result = fn(params) + sys.stdout.write("__RESULT__" + json.dumps(result, ensure_ascii=False) + "\n") + sys.stdout.flush() + +if __name__ == "__main__": + try: + main() + except Exception as e: + import traceback + _emit({{"type": "log", "level": "error", "msg": traceback.format_exc()}}) + sys.exit(2) +"# + ) +} + +async fn write_script_bundle( + modules: &HashMap, + entry: &str, +) -> Result<(PathBuf, PathBuf), PythonError> { + let tmp_dir = std::env::temp_dir().join("aiclient-python"); + tokio::fs::create_dir_all(&tmp_dir).await?; + let bundle_dir = tmp_dir.join(format!("bundle-{}", Uuid::new_v4())); + tokio::fs::create_dir_all(&bundle_dir).await?; + + for (name, source) in modules { + let rel = name.replace('\\', "/"); + let dest = bundle_dir.join(&rel); + if let Some(parent) = dest.parent() { + tokio::fs::create_dir_all(parent).await?; + } + tokio::fs::write(&dest, source).await?; + } + + let runner_path = bundle_dir.join("_runner.py"); + tokio::fs::write(&runner_path, build_file_runner(entry)).await?; + + Ok((bundle_dir, runner_path)) +} + +pub async fn list_scripts() -> Result, PythonError> { + let (_status, v) = + crate::api_client::request_json("GET", "/python-scripts/list", None, HashMap::new()) + .await + .map_err(PythonError::FetchScript)?; + if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) { + let msg = v + .get("message") + .and_then(|x| x.as_str()) + .unwrap_or("未知错误"); + return Err(PythonError::FetchScript(msg.to_string())); + } + let names = v + .get("data") + .and_then(|d| d.get("names")) + .and_then(|n| n.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|x| x.as_str().map(|s| s.to_string())) + .collect::>() + }) + .unwrap_or_default(); + Ok(names) +} + +pub async fn run_python_script( + script_name: String, + params: Value, + events: UnboundedSender, + config_env: HashMap, + mut cancel_rx: watch::Receiver, +) -> Result { + let entry = normalize_script_name(&script_name) + .ok_or_else(|| PythonError::FetchScript(format!("无效的脚本名称: {script_name}")))?; + let modules = resolve_script_bundle(&entry).await?; + run_script_bundle(&entry, modules, params, events, config_env, &mut cancel_rx).await +} + +pub async fn run_python_script_source( + source: String, + params: Value, + events: UnboundedSender, + config_env: HashMap, + mut cancel_rx: watch::Receiver, +) -> Result { + let modules = resolve_script_bundle_for_inline(&source).await?; + run_script_bundle( + INLINE_ENTRY, + modules, + params, + events, + config_env, + &mut cancel_rx, + ) + .await +} + +async fn run_script_bundle( + entry: &str, + modules: HashMap, + params: Value, + events: UnboundedSender, + config_env: HashMap, + cancel_rx: &mut watch::Receiver, +) -> Result { + let python = cover_python::locate_python().ok_or(PythonError::NotFound)?; + + let (bundle_dir, script_path) = write_script_bundle(&modules, entry).await?; + + let mut cmd = Command::new(&python); + cmd.arg("-u") + .arg(&script_path) + .kill_on_drop(true) + .current_dir(&bundle_dir) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + + let mut env_map: HashMap = std::env::vars().collect(); + env_map.insert("PYTHONIOENCODING".into(), "utf-8".into()); + env_map.insert("PYTHONUTF8".into(), "1".into()); + cover_python::apply_python_runtime_env(&mut env_map, &python, Some(&bundle_dir)); + for (k, v) in config_env { + env_map.insert(k, v); + } + if let Some(ffmpeg) = crate::ffmpeg::locate_ffmpeg() { + if ffmpeg.exists() { + env_map.insert( + "AICLIENT_FFMPEG_PATH".into(), + ffmpeg.to_string_lossy().into_owned(), + ); + let path_key = "PATH"; + let new_path = cover_python::prepend_ffmpeg_to_path( + env_map.get(path_key).map(|s| s.as_str()).unwrap_or(""), + ); + env_map.insert(path_key.into(), new_path); + } + } + env_map.insert( + "AICLIENT_COVER_SCRIPTS_DIR".into(), + bundle_dir.to_string_lossy().into_owned(), + ); + env_map.insert( + "AICLIENT_PYTHON_PATH".into(), + python.to_string_lossy().into_owned(), + ); + for (k, v) in env_map { + cmd.env(k, v); + } + + hide_console_window(&mut cmd); + let mut child = cmd.spawn()?; + + if let Some(mut stdin) = child.stdin.take() { + let json = serde_json::to_vec(¶ms)?; + stdin.write_all(&json).await?; + stdin.shutdown().await?; + } + + let stderr = child.stderr.take().expect("stderr piped"); + let events_tx = events.clone(); + let mut stderr_task = tokio::spawn(async move { + let mut reader = BufReader::new(stderr).lines(); + let mut tail = String::new(); + while let Ok(Some(line)) = reader.next_line().await { + if let Some(rest) = line.strip_prefix("__EVT__") { + if let Ok(v) = serde_json::from_str::(rest) { + let typ = v.get("type").and_then(|x| x.as_str()).unwrap_or(""); + match typ { + "progress" => { + let s = v + .get("status") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let _ = events_tx.send(PipelineEvent::Progress(s)); + } + "log" => { + let level = v + .get("level") + .and_then(|x| x.as_str()) + .unwrap_or("info") + .to_string(); + let msg = v + .get("msg") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(); + let fields = v.get("fields").cloned().filter(|x| !x.is_null()); + let _ = events_tx.send(PipelineEvent::Log { level, msg, fields }); + } + _ => { + let _ = events_tx.send(PipelineEvent::Log { + level: "info".into(), + msg: rest.to_string(), + fields: None, + }); + } + } + } + } else if !line.is_empty() { + let _ = events_tx.send(PipelineEvent::Log { + level: "warn".into(), + msg: line.clone(), + fields: None, + }); + tail.push_str(&line); + tail.push('\n'); + } + } + tail + }); + + let stdout = child.stdout.take().expect("stdout piped"); + let events_tx2 = events.clone(); + let mut stdout_task = tokio::spawn(async move { + let mut reader = BufReader::new(stdout).lines(); + while let Ok(Some(line)) = reader.next_line().await { + if let Some(rest) = line.strip_prefix("__RESULT__") { + return Some(rest.to_string()); + } else if !line.is_empty() { + let _ = events_tx2.send(PipelineEvent::Log { + level: "info".into(), + msg: line, + fields: None, + }); + } + } + None + }); + + let (status, cancelled, result_line, stdout_done) = tokio::select! { + result = &mut stdout_task => { + let result_line = result.unwrap_or(None); + let status = match timeout(Duration::from_secs(120), child.wait()).await { + Ok(status) => status?, + Err(_) => { + let _ = child.start_kill(); + child.wait().await? + } + }; + (status, false, result_line, true) + } + status = child.wait() => (status?, false, None, false), + _ = wait_for_cancel(cancel_rx) => { + let _ = child.start_kill(); + let status = child.wait().await?; + (status, true, None, false) + } + }; + + let stderr_tail = match timeout(Duration::from_millis(500), &mut stderr_task).await { + Ok(joined) => joined.unwrap_or_default(), + Err(_) => { + stderr_task.abort(); + let _ = stderr_task.await; + String::new() + } + }; + + let result_line_fallback = if stdout_done { + None + } else { + match timeout(Duration::from_millis(500), &mut stdout_task).await { + Ok(joined) => joined.unwrap_or(None), + Err(_) => { + stdout_task.abort(); + let _ = stdout_task.await; + None + } + } + }; + let result_line = result_line.or(result_line_fallback); + + let _ = tokio::fs::remove_dir_all(&bundle_dir).await; + + if cancelled { + return Err(PythonError::Cancelled); + } + + if let Some(result_str) = result_line { + if !status.success() { + log::warn!( + target: "pythonscript", + "python exited non-zero after __RESULT__: status={:?}, stderr_tail={}", + status.code(), + stderr_tail + ); + } + let v: Value = serde_json::from_str(&result_str)?; + return Ok(v); + } + + if !status.success() { + return Err(PythonError::NonZero { + status: status.code(), + stderr: stderr_tail, + }); + } + Err(PythonError::NoResult) +} + +async fn wait_for_cancel(cancel_rx: &mut watch::Receiver) { + if *cancel_rx.borrow() { + return; + } + while cancel_rx.changed().await.is_ok() { + if *cancel_rx.borrow() { + return; + } + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index d81fd04..2bab315 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "aiclient", - "version": "0.1.9", + "version": "0.1.11", "identifier": "com.aiclient.desktop", "build": { "beforeDevCommand": "npm run dev --prefix ../aiclient_ui", diff --git a/templates/cover-template-人像居中.json b/templates/cover-template-人像居中.json new file mode 100644 index 0000000..54ef3f3 --- /dev/null +++ b/templates/cover-template-人像居中.json @@ -0,0 +1,176 @@ +{ + "version": "1.0.0", + "description": "封面模板导出配置", + "timestamp": "2026-06-02T12:13:49.997Z", + "coverTemplate": { + "id": "v08ezr6v3x-mkdite6i", + "name": "人像居中", + "config": { + "backgroundEnabled": false, + "backgroundImagePath": null, + "backgroundBlurEnabled": true, + "backgroundBlurIntensity": 30, + "backgroundSize": 100, + "backgroundPosition": { + "x": 50, + "y": 50 + }, + "personImagePath": null, + "personSize": 54.99999999999999, + "personPosition": { + "x": 53.935185185185205, + "y": 34.375 + }, + "personRotation": 0, + "personBorderEnabled": true, + "personBorderStyle": "solid", + "personBorderColor": "#FFFFFF", + "personBorderWidth": 8, + "personBorderDashLength": null, + "personBorderGapLength": null, + "autoSplitText": true, + "titleMaxLength": 6, + "subtitleMaxLength": 12, + "titleText": "托德揭秘芒格", + "titleFontFamily": "墨趣古风体", + "titleFontSize": 110, + "titleFontWeight": 100, + "titleColor": "#ffffff", + "titleStrokeColor": "#000000", + "titleStrokeWidth": 0, + "titleShadowColor": "#000000", + "titleShadowOffsetX": 0, + "titleShadowOffsetY": 0, + "titleShadowBlur": 0, + "titleShadowEnabled": false, + "titleShadowLayers": [ + { + "id": 1765435502861.9124, + "offsetX": -2, + "offsetY": -2, + "blur": 12, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1765435502861.0488, + "offsetX": 2, + "offsetY": 2, + "blur": 16, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1765435502861.4207, + "offsetX": 0, + "offsetY": 0, + "blur": 14, + "color": "#000000", + "opacity": 100, + "enabled": true + } + ], + "titlePosition": { + "x": 39.814814814814795, + "y": 69.40972222222223 + }, + "titleRotation": 0, + "titleDirection": "horizontal", + "titleCharSpacing": 12, + "titleLineSpacing": 118, + "titleMaxCharsPerLine": 10, + "titleBackgroundEnabled": false, + "titleBackgroundColor": "#000000", + "titleBackgroundOpacity": 70, + "titleBackgroundShape": "rectangle", + "titleBackgroundSize": { + "width": 30, + "height": 10 + }, + "titleBackgroundPosition": { + "x": 50, + "y": 30 + }, + "titleBackgroundPoints": [], + "titleBackgroundRadius": 10, + "titleBackgroundRotation": 0, + "subtitleText": "投资稳的原因", + "subtitleFontFamily": "庞门正道标题体免费版", + "subtitleFontSize": 117, + "subtitleFontWeight": 500, + "subtitleColor": "#eaf273", + "subtitleStrokeColor": "#000000", + "subtitleStrokeWidth": 0, + "subtitleShadowColor": "#000000", + "subtitleShadowOffsetX": 0, + "subtitleShadowOffsetY": 0, + "subtitleShadowBlur": 0, + "subtitleShadowEnabled": false, + "subtitleShadowLayers": [ + { + "id": 1765435591571.2886, + "offsetX": -2, + "offsetY": -2, + "blur": 2, + "color": "#FFFFFF", + "opacity": 60, + "enabled": true + }, + { + "id": 1765435591571.856, + "offsetX": 2, + "offsetY": 2, + "blur": 0, + "color": "#000000", + "opacity": 65, + "enabled": true + }, + { + "id": 1765435591571.3809, + "offsetX": 0, + "offsetY": 0, + "blur": 4, + "color": "#000000", + "opacity": 50, + "enabled": true + } + ], + "subtitlePosition": { + "x": 58.796296296296305, + "y": 77.84722222222221 + }, + "subtitleRotation": 0, + "subtitleDirection": "horizontal", + "subtitleCharSpacing": 12, + "subtitleLineSpacing": 72, + "subtitleMaxCharsPerLine": 15, + "subtitleBackgroundEnabled": false, + "subtitleBackgroundColor": "#000000", + "subtitleBackgroundOpacity": 70, + "subtitleBackgroundShape": "rectangle", + "subtitleBackgroundSize": { + "width": 30, + "height": 10 + }, + "subtitleBackgroundPosition": { + "x": 50, + "y": 60 + }, + "subtitleBackgroundPoints": [], + "subtitleBackgroundRadius": 10, + "subtitleBackgroundRotation": 0, + "maskEnabled": false, + "maskImagePath": null, + "maskSize": 100, + "maskPosition": { + "x": 50, + "y": 50 + }, + "maskColor": null, + "maskOpacity": 100, + "maskShape": "rectangle" + } + } +} \ No newline at end of file diff --git a/templates/cover-template-大四字报.json b/templates/cover-template-大四字报.json new file mode 100644 index 0000000..fe6a4ae --- /dev/null +++ b/templates/cover-template-大四字报.json @@ -0,0 +1,164 @@ +{ + "version": "1.0.0", + "description": "封面模板导出配置", + "timestamp": "2026-06-02T12:13:38.566Z", + "coverTemplate": { + "id": "705fa010-5bf3-4041-b624-0fd364bea5ad", + "name": "大四字报", + "config": { + "backgroundEnabled": true, + "backgroundImagePath": null, + "backgroundBlurEnabled": false, + "backgroundBlurIntensity": 30, + "backgroundSize": 100, + "backgroundPosition": { + "x": 50, + "y": 50 + }, + "personImagePath": null, + "personSize": 33.125, + "personPosition": { + "x": 22.68518518518519, + "y": 78.125 + }, + "personRotation": 0, + "personBorderEnabled": false, + "personBorderStyle": "solid", + "personBorderColor": "#FFFFFF", + "personBorderWidth": 8, + "personBorderDashLength": null, + "personBorderGapLength": null, + "autoSplitText": true, + "titleMaxLength": 4, + "subtitleMaxLength": 30, + "titleText": "礼貌回归", + "titleFontFamily": "USMCCyuanjiantecu", + "titleFontSize": 375, + "titleFontWeight": 400, + "titleColor": "#e6ca19", + "titleStrokeColor": "#000000", + "titleStrokeWidth": 0, + "titleShadowColor": "#000000", + "titleShadowOffsetX": 0, + "titleShadowOffsetY": 0, + "titleShadowBlur": 0, + "titleShadowEnabled": false, + "titleShadowLayers": [ + { + "id": 1765355385133.3418, + "offsetX": 20, + "offsetY": 20, + "blur": 30, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1765355385133.7036, + "offsetX": -20, + "offsetY": -20, + "blur": 30, + "color": "#000000", + "opacity": 100, + "enabled": true + } + ], + "titlePosition": { + "x": 52.54629629629629, + "y": 48.40277777777778 + }, + "titleRotation": 0, + "titleDirection": "horizontal", + "titleCharSpacing": 58, + "titleLineSpacing": 150, + "titleMaxCharsPerLine": 2, + "titleBackgroundEnabled": false, + "titleBackgroundColor": "#000000", + "titleBackgroundOpacity": 70, + "titleBackgroundShape": "polygon", + "titleBackgroundSize": { + "width": 30, + "height": 10 + }, + "titleBackgroundPosition": { + "x": 95.83333333333333, + "y": 68.88888888888889 + }, + "titleBackgroundPoints": [ + { + "x": -330.24691358024694, + "y": -15.440538194444414 + }, + { + "x": 40, + "y": -40 + }, + { + "x": 40, + "y": 40 + }, + { + "x": -40, + "y": 40 + }, + { + "x": -114.9691358024691, + "y": 36.642795138888864 + }, + { + "x": -96.45061728395058, + "y": -3.2877604166666865 + } + ], + "titleBackgroundRadius": 10, + "titleBackgroundRotation": 0, + "subtitleText": "行动亲爱的观众朋友们", + "subtitleFontFamily": "USMCCyuanjiantecu", + "subtitleFontSize": 122, + "subtitleFontWeight": 500, + "subtitleColor": "#e37a16", + "subtitleStrokeColor": "#000000", + "subtitleStrokeWidth": 5, + "subtitleShadowColor": "#ff0000", + "subtitleShadowOffsetX": 41, + "subtitleShadowOffsetY": 50, + "subtitleShadowBlur": 13, + "subtitleShadowEnabled": false, + "subtitleShadowLayers": [], + "subtitlePosition": { + "x": 51.15740740740741, + "y": 83.75 + }, + "subtitleRotation": 0, + "subtitleDirection": "horizontal", + "subtitleCharSpacing": 0, + "subtitleLineSpacing": 72, + "subtitleMaxCharsPerLine": 8, + "subtitleBackgroundEnabled": false, + "subtitleBackgroundColor": "#000000", + "subtitleBackgroundOpacity": 70, + "subtitleBackgroundShape": "rectangle", + "subtitleBackgroundSize": { + "width": 30, + "height": 10 + }, + "subtitleBackgroundPosition": { + "x": 50, + "y": 60 + }, + "subtitleBackgroundPoints": [], + "subtitleBackgroundRadius": 10, + "subtitleBackgroundRotation": 0, + "maskEnabled": false, + "maskImagePath": null, + "maskSize": 100, + "maskPosition": { + "x": 50, + "y": 50 + }, + "maskColor": null, + "maskOpacity": 100, + "maskShape": "rectangle" + } + } +} \ No newline at end of file diff --git a/templates/cover-template-斜黄白.json b/templates/cover-template-斜黄白.json new file mode 100644 index 0000000..d0d5dd7 --- /dev/null +++ b/templates/cover-template-斜黄白.json @@ -0,0 +1,137 @@ +{ + "version": "1.0.0", + "description": "封面模板导出配置", + "timestamp": "2026-06-02T12:12:07.312Z", + "coverTemplate": { + "id": "8f17c467-c69c-45b2-8011-94a6c3071d16", + "name": "斜黄白", + "config": { + "backgroundEnabled": false, + "backgroundImagePath": null, + "backgroundBlurEnabled": false, + "backgroundBlurIntensity": 10, + "backgroundSize": 100, + "backgroundPosition": { + "x": 50, + "y": 50 + }, + "personImagePath": null, + "personSize": 80, + "personPosition": { + "x": 50, + "y": 50 + }, + "personRotation": 0, + "personBorderEnabled": false, + "personBorderStyle": "solid", + "personBorderColor": "#FFFFFF", + "personBorderWidth": 8, + "personBorderDashLength": null, + "personBorderGapLength": null, + "autoSplitText": true, + "titleMaxLength": 4, + "subtitleMaxLength": 10, + "titleText": "礼貌回归", + "titleFontFamily": "斗鱼追光体2.0", + "titleFontSize": 120, + "titleFontWeight": 700, + "titleColor": "#f5d400", + "titleStrokeColor": "#000000", + "titleStrokeWidth": 3, + "titleShadowColor": "#000000", + "titleShadowOffsetX": 0, + "titleShadowOffsetY": 0, + "titleShadowBlur": 0, + "titleShadowEnabled": false, + "titleShadowLayers": [], + "titlePosition": { + "x": 42.59259259259258, + "y": 56.909722222222214 + }, + "titleRotation": 338, + "titleDirection": "horizontal", + "titleCharSpacing": 24, + "titleLineSpacing": 144, + "titleMaxCharsPerLine": 10, + "titleBackgroundEnabled": true, + "titleBackgroundColor": "#f5f5f5", + "titleBackgroundOpacity": 25, + "titleBackgroundShape": "polygon", + "titleBackgroundSize": { + "width": 30, + "height": 10 + }, + "titleBackgroundPosition": { + "x": 44.21296296296296, + "y": 32.951388888888886 + }, + "titleBackgroundPoints": [ + { + "x": 226.08024691358025, + "y": -79.67664930555557 + }, + { + "x": 250.7716049382716, + "y": 218.93446180555554 + }, + { + "x": -260.8024691358025, + "y": 691.1566840277777 + }, + { + "x": -290.12345679012344, + "y": 366.50390624999994 + } + ], + "titleBackgroundRadius": 10, + "titleBackgroundRotation": 0, + "subtitleText": "行动亲爱的观众朋友们", + "subtitleFontFamily": "斗鱼追光体2.0", + "subtitleFontSize": 82, + "subtitleFontWeight": 500, + "subtitleColor": "#FFFFFF", + "subtitleStrokeColor": "#000000", + "subtitleStrokeWidth": 1, + "subtitleShadowColor": "#000000", + "subtitleShadowOffsetX": 0, + "subtitleShadowOffsetY": 0, + "subtitleShadowBlur": 0, + "subtitleShadowEnabled": false, + "subtitleShadowLayers": [], + "subtitlePosition": { + "x": 45.37037037037037, + "y": 66.5625 + }, + "subtitleRotation": 338, + "subtitleDirection": "horizontal", + "subtitleCharSpacing": 23, + "subtitleLineSpacing": 72, + "subtitleMaxCharsPerLine": 17, + "subtitleBackgroundEnabled": false, + "subtitleBackgroundColor": "#000000", + "subtitleBackgroundOpacity": 70, + "subtitleBackgroundShape": "rectangle", + "subtitleBackgroundSize": { + "width": 30, + "height": 10 + }, + "subtitleBackgroundPosition": { + "x": 50, + "y": 60 + }, + "subtitleBackgroundPoints": [], + "subtitleBackgroundRadius": 10, + "subtitleBackgroundRotation": 0, + "maskEnabled": false, + "maskImagePath": null, + "maskSize": 100, + "maskPosition": { + "x": 50, + "y": 50 + }, + "maskColor": null, + "maskOpacity": 100, + "maskShape": "rectangle" + } + } +} \ No newline at end of file diff --git a/templates/cover-template-粉红虚白.json b/templates/cover-template-粉红虚白.json new file mode 100644 index 0000000..05ea2cc --- /dev/null +++ b/templates/cover-template-粉红虚白.json @@ -0,0 +1,148 @@ +{ + "version": "1.0.0", + "description": "封面模板导出配置", + "timestamp": "2026-06-02T12:13:56.933Z", + "coverTemplate": { + "id": "43t1uykj8zg-mkdite6i", + "name": "粉红虚白", + "config": { + "backgroundEnabled": false, + "backgroundImagePath": null, + "backgroundBlurEnabled": true, + "backgroundBlurIntensity": 50, + "backgroundSize": 100, + "backgroundPosition": { + "x": 50, + "y": 50 + }, + "personImagePath": null, + "personSize": 81.7361111111111, + "personPosition": { + "x": 49.768518518518526, + "y": 60.41666666666666 + }, + "personRotation": 0, + "personBorderEnabled": true, + "personBorderStyle": "dashed", + "personBorderColor": "#ffffff", + "personBorderWidth": 8, + "personBorderDashLength": null, + "personBorderGapLength": null, + "autoSplitText": true, + "titleMaxLength": 4, + "subtitleMaxLength": 11, + "titleText": "长期持有", + "titleFontFamily": "庞门正道标题体免费版", + "titleFontSize": 165, + "titleFontWeight": 100, + "titleColor": "#e8c4df", + "titleStrokeColor": "#e147c0", + "titleStrokeWidth": 6, + "titleShadowColor": "#000000", + "titleShadowOffsetX": 0, + "titleShadowOffsetY": 0, + "titleShadowBlur": 0, + "titleShadowEnabled": false, + "titleShadowLayers": [], + "titlePosition": { + "x": 36.80555555555553, + "y": 11.944444444444452 + }, + "titleRotation": 0, + "titleDirection": "horizontal", + "titleCharSpacing": 11, + "titleLineSpacing": 118, + "titleMaxCharsPerLine": 10, + "titleBackgroundEnabled": false, + "titleBackgroundColor": "#000000", + "titleBackgroundOpacity": 70, + "titleBackgroundShape": "rectangle", + "titleBackgroundSize": { + "width": 30, + "height": 10 + }, + "titleBackgroundPosition": { + "x": 50, + "y": 30 + }, + "titleBackgroundPoints": [], + "titleBackgroundRadius": 10, + "titleBackgroundRotation": 0, + "subtitleText": "才是真省心又赚钱", + "subtitleFontFamily": "方正粗黑宋简体", + "subtitleFontSize": 117, + "subtitleFontWeight": 500, + "subtitleColor": "#FFFFFF", + "subtitleStrokeColor": "#ef62e3", + "subtitleStrokeWidth": 5, + "subtitleShadowColor": "#000000", + "subtitleShadowOffsetX": 0, + "subtitleShadowOffsetY": 0, + "subtitleShadowBlur": 0, + "subtitleShadowEnabled": false, + "subtitleShadowLayers": [ + { + "id": 1765435591571.2886, + "offsetX": -2, + "offsetY": -2, + "blur": 2, + "color": "#FFFFFF", + "opacity": 60, + "enabled": true + }, + { + "id": 1765435591571.856, + "offsetX": 2, + "offsetY": 2, + "blur": 0, + "color": "#000000", + "opacity": 65, + "enabled": true + }, + { + "id": 1765435591571.3809, + "offsetX": 0, + "offsetY": 0, + "blur": 4, + "color": "#000000", + "opacity": 50, + "enabled": true + } + ], + "subtitlePosition": { + "x": 51.3888888888889, + "y": 23.333333333333304 + }, + "subtitleRotation": 0, + "subtitleDirection": "horizontal", + "subtitleCharSpacing": 12, + "subtitleLineSpacing": 72, + "subtitleMaxCharsPerLine": 15, + "subtitleBackgroundEnabled": false, + "subtitleBackgroundColor": "#000000", + "subtitleBackgroundOpacity": 70, + "subtitleBackgroundShape": "rectangle", + "subtitleBackgroundSize": { + "width": 30, + "height": 10 + }, + "subtitleBackgroundPosition": { + "x": 50, + "y": 60 + }, + "subtitleBackgroundPoints": [], + "subtitleBackgroundRadius": 10, + "subtitleBackgroundRotation": 0, + "maskEnabled": false, + "maskImagePath": null, + "maskSize": 100, + "maskPosition": { + "x": 50, + "y": 50 + }, + "maskColor": null, + "maskOpacity": 100, + "maskShape": "rectangle" + } + } +} \ No newline at end of file diff --git a/templates/cover-template-红黄虚描.json b/templates/cover-template-红黄虚描.json new file mode 100644 index 0000000..6b48acc --- /dev/null +++ b/templates/cover-template-红黄虚描.json @@ -0,0 +1,176 @@ +{ + "version": "1.0.0", + "description": "封面模板导出配置", + "timestamp": "2026-06-02T12:14:00.925Z", + "coverTemplate": { + "id": "sb8mm7l4bjf-mkdite6i", + "name": "红黄虚描", + "config": { + "backgroundEnabled": false, + "backgroundImagePath": null, + "backgroundBlurEnabled": true, + "backgroundBlurIntensity": 60, + "backgroundSize": 100, + "backgroundPosition": { + "x": 50, + "y": 50 + }, + "personImagePath": null, + "personSize": 65.06944444444444, + "personPosition": { + "x": 62.73148148148148, + "y": 67.0138888888889 + }, + "personRotation": 0, + "personBorderEnabled": true, + "personBorderStyle": "dashed", + "personBorderColor": "#FFFFFF", + "personBorderWidth": 12, + "personBorderDashLength": null, + "personBorderGapLength": null, + "autoSplitText": true, + "titleMaxLength": 4, + "subtitleMaxLength": 10, + "titleText": "托德揭秘", + "titleFontFamily": "泼墨体", + "titleFontSize": 165, + "titleFontWeight": 100, + "titleColor": "#d4ec74", + "titleStrokeColor": "#000000", + "titleStrokeWidth": 0, + "titleShadowColor": "#000000", + "titleShadowOffsetX": 0, + "titleShadowOffsetY": 0, + "titleShadowBlur": 0, + "titleShadowEnabled": false, + "titleShadowLayers": [ + { + "id": 1765435502861.9124, + "offsetX": -2, + "offsetY": -2, + "blur": 12, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1765435502861.0488, + "offsetX": 2, + "offsetY": 2, + "blur": 16, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1765435502861.4207, + "offsetX": 0, + "offsetY": 0, + "blur": 14, + "color": "#000000", + "opacity": 100, + "enabled": true + } + ], + "titlePosition": { + "x": 55.09259259259257, + "y": 15.59027777777779 + }, + "titleRotation": 0, + "titleDirection": "horizontal", + "titleCharSpacing": 19, + "titleLineSpacing": 118, + "titleMaxCharsPerLine": 9, + "titleBackgroundEnabled": false, + "titleBackgroundColor": "#000000", + "titleBackgroundOpacity": 70, + "titleBackgroundShape": "rectangle", + "titleBackgroundSize": { + "width": 30, + "height": 10 + }, + "titleBackgroundPosition": { + "x": 50, + "y": 30 + }, + "titleBackgroundPoints": [], + "titleBackgroundRadius": 10, + "titleBackgroundRotation": 0, + "subtitleText": "芒格投资稳的原因", + "subtitleFontFamily": "MaokenAssortedSans-Lite", + "subtitleFontSize": 117, + "subtitleFontWeight": 500, + "subtitleColor": "#7e1010", + "subtitleStrokeColor": "#ffffff", + "subtitleStrokeWidth": 6, + "subtitleShadowColor": "#000000", + "subtitleShadowOffsetX": 0, + "subtitleShadowOffsetY": 0, + "subtitleShadowBlur": 0, + "subtitleShadowEnabled": false, + "subtitleShadowLayers": [ + { + "id": 1765435591571.2886, + "offsetX": -2, + "offsetY": -2, + "blur": 2, + "color": "#FFFFFF", + "opacity": 60, + "enabled": true + }, + { + "id": 1765435591571.856, + "offsetX": 2, + "offsetY": 2, + "blur": 0, + "color": "#000000", + "opacity": 65, + "enabled": true + }, + { + "id": 1765435591571.3809, + "offsetX": 0, + "offsetY": 0, + "blur": 4, + "color": "#000000", + "opacity": 50, + "enabled": true + } + ], + "subtitlePosition": { + "x": 18.98148148148148, + "y": 64.47916666666667 + }, + "subtitleRotation": 0, + "subtitleDirection": "vertical", + "subtitleCharSpacing": 12, + "subtitleLineSpacing": 72, + "subtitleMaxCharsPerLine": 15, + "subtitleBackgroundEnabled": false, + "subtitleBackgroundColor": "#000000", + "subtitleBackgroundOpacity": 70, + "subtitleBackgroundShape": "rectangle", + "subtitleBackgroundSize": { + "width": 30, + "height": 10 + }, + "subtitleBackgroundPosition": { + "x": 50, + "y": 60 + }, + "subtitleBackgroundPoints": [], + "subtitleBackgroundRadius": 10, + "subtitleBackgroundRotation": 0, + "maskEnabled": false, + "maskImagePath": null, + "maskSize": 100, + "maskPosition": { + "x": 50, + "y": 50 + }, + "maskColor": null, + "maskOpacity": 100, + "maskShape": "rectangle" + } + } +} \ No newline at end of file diff --git a/templates/cover-template-蓝底白字.json b/templates/cover-template-蓝底白字.json new file mode 100644 index 0000000..c8d2ab2 --- /dev/null +++ b/templates/cover-template-蓝底白字.json @@ -0,0 +1,139 @@ +{ + "version": "1.0.0", + "description": "封面模板导出配置", + "timestamp": "2026-06-02T12:13:43.982Z", + "coverTemplate": { + "id": "6404718a-1b1c-4148-9807-ad3c57a53e0c", + "name": "蓝底白字", + "config": { + "backgroundEnabled": false, + "backgroundImagePath": null, + "backgroundBlurEnabled": false, + "backgroundBlurIntensity": 10, + "backgroundSize": 100, + "backgroundPosition": { + "x": 50, + "y": 50 + }, + "personImagePath": null, + "personSize": 80, + "personPosition": { + "x": 50, + "y": 50 + }, + "personRotation": 0, + "personBorderEnabled": false, + "personBorderStyle": "solid", + "personBorderColor": "#FFFFFF", + "personBorderWidth": 8, + "personBorderDashLength": null, + "personBorderGapLength": null, + "autoSplitText": true, + "titleMaxLength": 4, + "subtitleMaxLength": 5, + "titleText": "礼貌回归", + "titleFontFamily": "USMCCyuanjiantecu", + "titleFontSize": 120, + "titleFontWeight": 700, + "titleColor": "#cbcdb1", + "titleStrokeColor": "#000000", + "titleStrokeWidth": 0, + "titleShadowColor": "#000000", + "titleShadowOffsetX": 0, + "titleShadowOffsetY": 0, + "titleShadowBlur": 0, + "titleShadowEnabled": false, + "titleShadowLayers": [ + { + "id": 1765449844664.8303, + "offsetX": 0, + "offsetY": 0, + "blur": 8, + "color": "#000000", + "opacity": 80, + "enabled": true + }, + { + "id": 1765449844664.415, + "offsetX": 0, + "offsetY": 0, + "blur": 16, + "color": "#000000", + "opacity": 40, + "enabled": true + } + ], + "titlePosition": { + "x": 50.694444444444436, + "y": 63.506944444444436 + }, + "titleRotation": 0, + "titleDirection": "horizontal", + "titleCharSpacing": 24, + "titleLineSpacing": 144, + "titleMaxCharsPerLine": 10, + "titleBackgroundEnabled": false, + "titleBackgroundColor": "#000000", + "titleBackgroundOpacity": 70, + "titleBackgroundShape": "rectangle", + "titleBackgroundSize": { + "width": 30, + "height": 10 + }, + "titleBackgroundPosition": { + "x": 50, + "y": 30 + }, + "titleBackgroundPoints": [], + "titleBackgroundRadius": 10, + "titleBackgroundRotation": 0, + "subtitleText": "行动亲爱的", + "subtitleFontFamily": "千图厚黑体", + "subtitleFontSize": 187, + "subtitleFontWeight": 100, + "subtitleColor": "#FFFFFF", + "subtitleStrokeColor": "#000000", + "subtitleStrokeWidth": 10, + "subtitleShadowColor": "#000000", + "subtitleShadowOffsetX": 0, + "subtitleShadowOffsetY": 0, + "subtitleShadowBlur": 0, + "subtitleShadowEnabled": false, + "subtitleShadowLayers": [], + "subtitlePosition": { + "x": 49.53703703703704, + "y": 74.20138888888889 + }, + "subtitleRotation": 0, + "subtitleDirection": "horizontal", + "subtitleCharSpacing": -23, + "subtitleLineSpacing": 72, + "subtitleMaxCharsPerLine": 15, + "subtitleBackgroundEnabled": true, + "subtitleBackgroundColor": "#599dd9", + "subtitleBackgroundOpacity": 25, + "subtitleBackgroundShape": "rectangle", + "subtitleBackgroundSize": { + "width": 161.0185185185185, + "height": 30.48611111111111 + }, + "subtitleBackgroundPosition": { + "x": 50.23148148148148, + "y": 67.11805555555556 + }, + "subtitleBackgroundPoints": [], + "subtitleBackgroundRadius": 10, + "subtitleBackgroundRotation": 0, + "maskEnabled": false, + "maskImagePath": null, + "maskSize": 100, + "maskPosition": { + "x": 50, + "y": 50 + }, + "maskColor": null, + "maskOpacity": 100, + "maskShape": "rectangle" + } + } +} \ No newline at end of file diff --git a/templates/cover-template-青蓝虚描.json b/templates/cover-template-青蓝虚描.json new file mode 100644 index 0000000..b201306 --- /dev/null +++ b/templates/cover-template-青蓝虚描.json @@ -0,0 +1,167 @@ +{ + "version": "1.0.0", + "description": "封面模板导出配置", + "timestamp": "2026-06-02T12:13:52.918Z", + "coverTemplate": { + "id": "1xwrrcvf7a5-mkdite6i", + "name": "青蓝虚描", + "config": { + "backgroundEnabled": false, + "backgroundImagePath": null, + "backgroundBlurEnabled": true, + "backgroundBlurIntensity": 100, + "backgroundSize": 100, + "backgroundPosition": { + "x": 50, + "y": 50 + }, + "personImagePath": null, + "personSize": 56.73611111111111, + "personPosition": { + "x": 35.87962962962963, + "y": 71.52777777777777 + }, + "personRotation": 0, + "personBorderEnabled": true, + "personBorderStyle": "dashed", + "personBorderColor": "#149fc2", + "personBorderWidth": 14, + "personBorderDashLength": null, + "personBorderGapLength": null, + "autoSplitText": true, + "titleMaxLength": 4, + "subtitleMaxLength": 11, + "titleText": "托德揭秘", + "titleFontFamily": "泼墨体", + "titleFontSize": 115, + "titleFontWeight": 350, + "titleColor": "#149fc2", + "titleStrokeColor": "#000000", + "titleStrokeWidth": 7, + "titleShadowColor": "#000000", + "titleShadowOffsetX": 0, + "titleShadowOffsetY": 0, + "titleShadowBlur": 0, + "titleShadowEnabled": false, + "titleShadowLayers": [ + { + "id": 1765435502861.9124, + "offsetX": -2, + "offsetY": -2, + "blur": 12, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1765435502861.0488, + "offsetX": 2, + "offsetY": 2, + "blur": 16, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1765435502861.4207, + "offsetX": 0, + "offsetY": 0, + "blur": 14, + "color": "#000000", + "opacity": 100, + "enabled": true + } + ], + "titlePosition": { + "x": 90.9722222222222, + "y": 34.16666666666667 + }, + "titleRotation": 0, + "titleDirection": "vertical", + "titleCharSpacing": 50, + "titleLineSpacing": 118, + "titleMaxCharsPerLine": 10, + "titleBackgroundEnabled": false, + "titleBackgroundColor": "#000000", + "titleBackgroundOpacity": 70, + "titleBackgroundShape": "rectangle", + "titleBackgroundSize": { + "width": 30, + "height": 10 + }, + "titleBackgroundPosition": { + "x": 50, + "y": 30 + }, + "titleBackgroundPoints": [], + "titleBackgroundRadius": 10, + "titleBackgroundRotation": 0, + "subtitleText": "芒格投资稳的原因", + "subtitleFontFamily": "杨任东竹石体", + "subtitleFontSize": 87, + "subtitleFontWeight": 500, + "subtitleColor": "#149fc2", + "subtitleStrokeColor": "#000000", + "subtitleStrokeWidth": 0, + "subtitleShadowColor": "#000000", + "subtitleShadowOffsetX": 0, + "subtitleShadowOffsetY": 0, + "subtitleShadowBlur": 0, + "subtitleShadowEnabled": false, + "subtitleShadowLayers": [ + { + "id": 1768540523070.3923, + "offsetX": 0, + "offsetY": 0, + "blur": 2, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1768540523070.0796, + "offsetX": 0, + "offsetY": 0, + "blur": 2, + "color": "#000000", + "opacity": 100, + "enabled": true + } + ], + "subtitlePosition": { + "x": 76.85185185185186, + "y": 58.40277777777778 + }, + "subtitleRotation": 0, + "subtitleDirection": "vertical", + "subtitleCharSpacing": 12, + "subtitleLineSpacing": 72, + "subtitleMaxCharsPerLine": 15, + "subtitleBackgroundEnabled": false, + "subtitleBackgroundColor": "#000000", + "subtitleBackgroundOpacity": 70, + "subtitleBackgroundShape": "rectangle", + "subtitleBackgroundSize": { + "width": 30, + "height": 10 + }, + "subtitleBackgroundPosition": { + "x": 50, + "y": 60 + }, + "subtitleBackgroundPoints": [], + "subtitleBackgroundRadius": 10, + "subtitleBackgroundRotation": 0, + "maskEnabled": false, + "maskImagePath": null, + "maskSize": 100, + "maskPosition": { + "x": 50, + "y": 50 + }, + "maskColor": null, + "maskOpacity": 100, + "maskShape": "rectangle" + } + } +} \ No newline at end of file diff --git a/templates/cover-template-黄白实描.json b/templates/cover-template-黄白实描.json new file mode 100644 index 0000000..a282baf --- /dev/null +++ b/templates/cover-template-黄白实描.json @@ -0,0 +1,176 @@ +{ + "version": "1.0.0", + "description": "封面模板导出配置", + "timestamp": "2026-06-02T12:13:46.948Z", + "coverTemplate": { + "id": "485183be-6eed-4eae-b74a-8591f08b69fa", + "name": "黄白实描", + "config": { + "backgroundEnabled": false, + "backgroundImagePath": null, + "backgroundBlurEnabled": true, + "backgroundBlurIntensity": 30, + "backgroundSize": 100, + "backgroundPosition": { + "x": 50, + "y": 50 + }, + "personImagePath": null, + "personSize": 70.27777777777777, + "personPosition": { + "x": 35.87962962962963, + "y": 64.75694444444444 + }, + "personRotation": 0, + "personBorderEnabled": true, + "personBorderStyle": "solid", + "personBorderColor": "#FFFFFF", + "personBorderWidth": 32, + "personBorderDashLength": null, + "personBorderGapLength": null, + "autoSplitText": true, + "titleMaxLength": 4, + "subtitleMaxLength": 6, + "titleText": "置顶96", + "titleFontFamily": "1.程荣光刻楷", + "titleFontSize": 300, + "titleFontWeight": 1000, + "titleColor": "#ffde05", + "titleStrokeColor": "#000000", + "titleStrokeWidth": 0, + "titleShadowColor": "#000000", + "titleShadowOffsetX": 0, + "titleShadowOffsetY": 0, + "titleShadowBlur": 0, + "titleShadowEnabled": false, + "titleShadowLayers": [ + { + "id": 1765435502861.9124, + "offsetX": -2, + "offsetY": -2, + "blur": 12, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1765435502861.0488, + "offsetX": 2, + "offsetY": 2, + "blur": 16, + "color": "#000000", + "opacity": 100, + "enabled": true + }, + { + "id": 1765435502861.4207, + "offsetX": 0, + "offsetY": 0, + "blur": 14, + "color": "#000000", + "opacity": 100, + "enabled": true + } + ], + "titlePosition": { + "x": 91.89814814814812, + "y": 66.45833333333334 + }, + "titleRotation": 0, + "titleDirection": "vertical", + "titleCharSpacing": -20, + "titleLineSpacing": 118, + "titleMaxCharsPerLine": 10, + "titleBackgroundEnabled": false, + "titleBackgroundColor": "#000000", + "titleBackgroundOpacity": 70, + "titleBackgroundShape": "rectangle", + "titleBackgroundSize": { + "width": 30, + "height": 10 + }, + "titleBackgroundPosition": { + "x": 50, + "y": 30 + }, + "titleBackgroundPoints": [], + "titleBackgroundRadius": 10, + "titleBackgroundRotation": 0, + "subtitleText": "4高丹草新品", + "subtitleFontFamily": "USMCCyuanjiantecu", + "subtitleFontSize": 117, + "subtitleFontWeight": 500, + "subtitleColor": "#FFFFFF", + "subtitleStrokeColor": "#000000", + "subtitleStrokeWidth": 0, + "subtitleShadowColor": "#000000", + "subtitleShadowOffsetX": 0, + "subtitleShadowOffsetY": 0, + "subtitleShadowBlur": 0, + "subtitleShadowEnabled": false, + "subtitleShadowLayers": [ + { + "id": 1765435591571.2886, + "offsetX": -2, + "offsetY": -2, + "blur": 2, + "color": "#FFFFFF", + "opacity": 60, + "enabled": true + }, + { + "id": 1765435591571.856, + "offsetX": 2, + "offsetY": 2, + "blur": 0, + "color": "#000000", + "opacity": 65, + "enabled": true + }, + { + "id": 1765435591571.3809, + "offsetX": 0, + "offsetY": 0, + "blur": 4, + "color": "#000000", + "opacity": 50, + "enabled": true + } + ], + "subtitlePosition": { + "x": 47.45370370370371, + "y": 12.743055555555557 + }, + "subtitleRotation": 0, + "subtitleDirection": "horizontal", + "subtitleCharSpacing": 12, + "subtitleLineSpacing": 72, + "subtitleMaxCharsPerLine": 15, + "subtitleBackgroundEnabled": false, + "subtitleBackgroundColor": "#000000", + "subtitleBackgroundOpacity": 70, + "subtitleBackgroundShape": "rectangle", + "subtitleBackgroundSize": { + "width": 30, + "height": 10 + }, + "subtitleBackgroundPosition": { + "x": 50, + "y": 60 + }, + "subtitleBackgroundPoints": [], + "subtitleBackgroundRadius": 10, + "subtitleBackgroundRotation": 0, + "maskEnabled": false, + "maskImagePath": null, + "maskSize": 100, + "maskPosition": { + "x": 50, + "y": 50 + }, + "maskColor": null, + "maskOpacity": 100, + "maskShape": "rectangle" + } + } +} \ No newline at end of file