11
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -30,3 +30,5 @@ src-tauri/resources/nodejs/
|
||||
src-tauri/resources/resources-bundles/python-runtime/
|
||||
src-tauri/resources/resources-bundles/python-runtime/Lib/site-packages/
|
||||
src-tauri/resources/resources-bundles/models/
|
||||
src-tauri/resources/resources-bundles/python-runtimebackup/
|
||||
src-tauri/resources/resources-bundles/eSpeak/
|
||||
|
||||
2490
src-tauri/resources/cover-python/advanced_cover_generator.py
Normal file
2490
src-tauri/resources/cover-python/advanced_cover_generator.py
Normal file
File diff suppressed because it is too large
Load Diff
369
src-tauri/resources/cover-python/cover_generator.py
Normal file
369
src-tauri/resources/cover-python/cover_generator.py
Normal file
@@ -0,0 +1,369 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
视频封面生成器
|
||||
支持从视频提取帧并添加标题文字
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
import cv2
|
||||
|
||||
# ⚠️ 修复模块路径:确保 Python 能找到 modules 和其他本地模块
|
||||
# 将脚本所在目录(python 目录)添加到 sys.path
|
||||
script_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
if script_dir not in sys.path:
|
||||
sys.path.insert(0, script_dir)
|
||||
print(f"[PATH] Added to sys.path: {script_dir}", file=sys.stderr)
|
||||
|
||||
def extract_frame_from_video(video_path, timestamp=0):
|
||||
"""从视频中提取指定时间的帧"""
|
||||
try:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
|
||||
# 设置到指定时间(秒)
|
||||
cap.set(cv2.CAP_PROP_POS_MSEC, timestamp * 1000)
|
||||
|
||||
ret, frame = cap.read()
|
||||
cap.release()
|
||||
|
||||
if not ret:
|
||||
return None
|
||||
|
||||
# 转换BGR到RGB
|
||||
frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
|
||||
return Image.fromarray(frame_rgb)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error extracting frame: {e}", file=sys.stderr)
|
||||
return None
|
||||
|
||||
def hex_to_rgb(hex_color):
|
||||
"""将十六进制颜色转换为RGB元组"""
|
||||
if isinstance(hex_color, str):
|
||||
hex_color = hex_color.lstrip('#')
|
||||
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
return tuple(hex_color)
|
||||
|
||||
def add_text_to_image(img, title, config):
|
||||
"""在图像上添加标题文字"""
|
||||
pil_img = img.copy().convert('RGBA')
|
||||
|
||||
width, height = pil_img.size
|
||||
|
||||
# 获取配置参数 - 支持新格式(来自前端的配置)
|
||||
# 新格式:titleFontSize, titleColor, titleFontFamily, titlePosition, titleStrokeWidth, titleStrokeColor
|
||||
# 旧格式:fontSize, fontColor, fontPath, position, style
|
||||
|
||||
font_size = config.get('titleFontSize', config.get('fontSize', int(height * 0.08)))
|
||||
|
||||
# 处理颜色格式:新格式是十六进制字符串(如 "#eb1414"),旧格式是RGB元组
|
||||
title_color = config.get('titleColor', config.get('fontColor', '#FFFFFF'))
|
||||
font_color = hex_to_rgb(title_color)
|
||||
|
||||
# 阴影参数
|
||||
shadow_color = hex_to_rgb(config.get('titleShadowColor', '#000000'))
|
||||
shadow_offset_x = config.get('titleShadowOffsetX', 0)
|
||||
shadow_offset_y = config.get('titleShadowOffsetY', 0)
|
||||
shadow_blur_raw = config.get('titleShadowBlur', 0)
|
||||
# 修复:PIL的GaussianBlur效果比CSS重,需要缩小系数以匹配前端预览
|
||||
# 经验值:CSS box-shadow blur 与 PIL GaussianBlur 的比例约为 2.5:1
|
||||
shadow_blur = int(shadow_blur_raw * 0.4) if shadow_blur_raw > 0 else 0
|
||||
|
||||
# 描边参数
|
||||
stroke_width = config.get('titleStrokeWidth', 0)
|
||||
stroke_color = hex_to_rgb(config.get('titleStrokeColor', '#000000'))
|
||||
|
||||
print(f"[DEBUG] Text rendering params: stroke_width={stroke_width}, stroke_color={stroke_color}", file=sys.stderr)
|
||||
|
||||
font_family = config.get('titleFontFamily', config.get('fontPath', 'SimHei'))
|
||||
|
||||
# 使用 font_manager 查找字体(支持系统字体和ziti目录字体)
|
||||
font_path = None
|
||||
try:
|
||||
from modules.font_manager import get_font_manager
|
||||
font_manager = get_font_manager()
|
||||
font_path = font_manager.find_font(font_family, 400)
|
||||
if font_path:
|
||||
print(f"[DEBUG] Font found via font_manager: {font_family} -> {font_path}", file=sys.stderr)
|
||||
else:
|
||||
print(f"[DEBUG] Font not found via font_manager: {font_family}, using fallback", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] font_manager not available: {e}, using hardcoded fonts", file=sys.stderr)
|
||||
|
||||
# 回退到硬编码字体路径
|
||||
if not font_path:
|
||||
if font_family == 'SimHei':
|
||||
font_path = 'C:\\Windows\\Fonts\\simhei.ttf'
|
||||
elif font_family == 'SimSun':
|
||||
font_path = 'C:\\Windows\\Fonts\\simsun.ttc'
|
||||
elif font_family == 'Microsoft YaHei':
|
||||
font_path = 'C:\\Windows\\Fonts\\msyh.ttc'
|
||||
else:
|
||||
font_path = config.get('fontPath', 'C:\\Windows\\Fonts\\msyh.ttc')
|
||||
print(f"[DEBUG] Using hardcoded font path: {font_path}", file=sys.stderr)
|
||||
|
||||
# 位置:新格式是百分比坐标 {x, y},旧格式是 'top', 'center', 'bottom'
|
||||
title_position = config.get('titlePosition', {})
|
||||
if isinstance(title_position, dict):
|
||||
position = 'custom'
|
||||
else:
|
||||
position = config.get('position', 'top') # top, center, bottom
|
||||
|
||||
style = config.get('style', 'default') # default, outline, blur_bg, gradient, split
|
||||
|
||||
# 加载字体
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# 创建绘图对象
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
# 计算文字尺寸
|
||||
bbox = draw.textbbox((0, 0), title, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
|
||||
# 计算文字位置
|
||||
if position == 'custom' and isinstance(title_position, dict):
|
||||
# 使用自定义位置(百分比)
|
||||
x = int(width * title_position.get('x', 50) / 100) - text_width // 2
|
||||
y = int(height * title_position.get('y', 80) / 100) - text_height // 2
|
||||
else:
|
||||
x = (width - text_width) // 2
|
||||
if position == 'top':
|
||||
y = int(height * 0.08)
|
||||
elif position == 'center':
|
||||
y = (height - text_height) // 2
|
||||
elif position == 'bottom':
|
||||
y = int(height * 0.85) - text_height
|
||||
else:
|
||||
y = int(height * 0.08)
|
||||
|
||||
# 辅助函数:绘制带阴影和描边的文字
|
||||
def draw_text_with_effects(draw_obj, pos_x, pos_y, text, font, color, stroke_w, stroke_c, shadow_ox, shadow_oy, shadow_b, shadow_c):
|
||||
"""绘制带阴影和描边的文字"""
|
||||
# 1. 先绘制阴影(如果有偏移)
|
||||
if shadow_ox != 0 or shadow_oy != 0:
|
||||
if shadow_b > 0:
|
||||
# 创建阴影图层并模糊
|
||||
shadow_layer = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
|
||||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||||
shadow_draw.text((pos_x + shadow_ox, pos_y + shadow_oy), text, font=font, fill=shadow_c + (180,))
|
||||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_b))
|
||||
return shadow_layer
|
||||
else:
|
||||
draw_obj.text((pos_x + shadow_ox, pos_y + shadow_oy), text, font=font, fill=shadow_c + (180,))
|
||||
|
||||
# 2. 绘制描边
|
||||
if stroke_w > 0:
|
||||
for adj_x in range(-stroke_w, stroke_w + 1):
|
||||
for adj_y in range(-stroke_w, stroke_w + 1):
|
||||
if adj_x != 0 or adj_y != 0:
|
||||
draw_obj.text((pos_x + adj_x, pos_y + adj_y), text, font=font, fill=stroke_c + (255,))
|
||||
|
||||
# 3. 绘制主文字
|
||||
draw_obj.text((pos_x, pos_y), text, font=font, fill=color + (255,))
|
||||
return None
|
||||
|
||||
# 应用样式
|
||||
if style == 'default':
|
||||
# 默认风格:黑色半透明背景条
|
||||
padding = 30
|
||||
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
|
||||
overlay_draw = ImageDraw.Draw(overlay)
|
||||
overlay_draw.rectangle(
|
||||
[x - padding, y - padding, x + text_width + padding, y + text_height + padding],
|
||||
fill=(0, 0, 0, 180)
|
||||
)
|
||||
pil_img = Image.alpha_composite(pil_img, overlay)
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
# 绘制阴影
|
||||
if shadow_offset_x != 0 or shadow_offset_y != 0:
|
||||
if shadow_blur > 0:
|
||||
shadow_layer = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
|
||||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||||
shadow_draw.text((x + shadow_offset_x, y + shadow_offset_y), title, font=font, fill=shadow_color + (180,))
|
||||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
|
||||
pil_img = Image.alpha_composite(pil_img, shadow_layer)
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
else:
|
||||
draw.text((x + shadow_offset_x, y + shadow_offset_y), title, font=font, fill=shadow_color + (180,))
|
||||
|
||||
# 绘制描边
|
||||
if stroke_width > 0:
|
||||
print(f"[DEBUG] Drawing text stroke: width={stroke_width}, color={stroke_color}", file=sys.stderr)
|
||||
for adj_x in range(-stroke_width, stroke_width + 1):
|
||||
for adj_y in range(-stroke_width, stroke_width + 1):
|
||||
if adj_x != 0 or adj_y != 0:
|
||||
draw.text((x + adj_x, y + adj_y), title, font=font, fill=stroke_color + (255,))
|
||||
else:
|
||||
print(f"[DEBUG] Stroke disabled: stroke_width={stroke_width}", file=sys.stderr)
|
||||
|
||||
draw.text((x, y), title, font=font, fill=font_color + (255,))
|
||||
|
||||
elif style == 'outline':
|
||||
# 描边风格
|
||||
# 绘制阴影
|
||||
if shadow_offset_x != 0 or shadow_offset_y != 0:
|
||||
if shadow_blur > 0:
|
||||
shadow_layer = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
|
||||
shadow_draw = ImageDraw.Draw(shadow_layer)
|
||||
shadow_draw.text((x + shadow_offset_x, y + shadow_offset_y), title, font=font, fill=shadow_color + (180,))
|
||||
shadow_layer = shadow_layer.filter(ImageFilter.GaussianBlur(radius=shadow_blur))
|
||||
pil_img = Image.alpha_composite(pil_img, shadow_layer)
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
else:
|
||||
draw.text((x + shadow_offset_x, y + shadow_offset_y), title, font=font, fill=shadow_color + (180,))
|
||||
|
||||
outline_width = stroke_width if stroke_width > 0 else 3
|
||||
outline_color = stroke_color
|
||||
for adj_x in range(-outline_width, outline_width + 1):
|
||||
for adj_y in range(-outline_width, outline_width + 1):
|
||||
draw.text((x + adj_x, y + adj_y), title, font=font, fill=outline_color + (255,))
|
||||
draw.text((x, y), title, font=font, fill=font_color + (255,))
|
||||
|
||||
elif style == 'blur_bg':
|
||||
# 模糊背景风格
|
||||
padding = 50
|
||||
bg_region = pil_img.crop((
|
||||
max(0, x - padding),
|
||||
max(0, y - padding),
|
||||
min(width, x + text_width + padding),
|
||||
min(height, y + text_height + padding)
|
||||
))
|
||||
bg_region = bg_region.filter(ImageFilter.GaussianBlur(radius=15))
|
||||
pil_img.paste(bg_region, (max(0, x - padding), max(0, y - padding)))
|
||||
|
||||
# 添加半透明遮罩
|
||||
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
|
||||
overlay_draw = ImageDraw.Draw(overlay)
|
||||
overlay_draw.rectangle(
|
||||
[x - padding, y - padding, x + text_width + padding, y + text_height + padding],
|
||||
fill=(0, 0, 0, 120)
|
||||
)
|
||||
pil_img = Image.alpha_composite(pil_img, overlay)
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
draw.text((x, y), title, font=font, fill=font_color + (255,))
|
||||
|
||||
elif style == 'gradient':
|
||||
# 渐变背景风格
|
||||
gradient_height = text_height + 100
|
||||
gradient = Image.new('RGBA', (width, gradient_height), (0, 0, 0, 0))
|
||||
gradient_draw = ImageDraw.Draw(gradient)
|
||||
for i in range(gradient_height):
|
||||
alpha = int(200 * (i / gradient_height))
|
||||
gradient_draw.rectangle([0, i, width, i + 1], fill=(0, 0, 0, alpha))
|
||||
|
||||
pil_img.paste(gradient, (0, max(0, y - 50)), gradient)
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
draw.text((x, y), title, font=font, fill=font_color + (255,))
|
||||
|
||||
elif style == 'split':
|
||||
# 分栏风格
|
||||
padding = 40
|
||||
left_x = padding
|
||||
|
||||
# 左侧标题背景
|
||||
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
|
||||
overlay_draw = ImageDraw.Draw(overlay)
|
||||
overlay_draw.rectangle(
|
||||
[left_x - 20, y - 20, left_x + text_width + 20, y + text_height + 20],
|
||||
fill=(0, 0, 0, 180)
|
||||
)
|
||||
pil_img = Image.alpha_composite(pil_img, overlay)
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
draw.text((left_x, y), title, font=font, fill=font_color + (255,))
|
||||
|
||||
# 右侧装饰
|
||||
accent_text = config.get('accentText', '✨')
|
||||
accent_bbox = draw.textbbox((0, 0), accent_text, font=font)
|
||||
accent_width = accent_bbox[2] - accent_bbox[0]
|
||||
right_x = width - accent_width - padding
|
||||
|
||||
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
|
||||
overlay_draw = ImageDraw.Draw(overlay)
|
||||
overlay_draw.rectangle(
|
||||
[right_x - 25, y - 25, right_x + accent_width + 25, y + text_height + 25],
|
||||
fill=(255, 193, 7, 200)
|
||||
)
|
||||
pil_img = Image.alpha_composite(pil_img, overlay)
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
draw.text((right_x, y), accent_text, font=font, fill=(0, 0, 0, 255))
|
||||
|
||||
return pil_img.convert('RGB')
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Generate video cover with title')
|
||||
parser.add_argument('--video', required=True, help='Path to video file')
|
||||
parser.add_argument('--title', required=True, help='Title text')
|
||||
parser.add_argument('--output', required=True, help='Output image path')
|
||||
parser.add_argument('--config', default='{}', help='JSON configuration')
|
||||
parser.add_argument('--preview', action='store_true', help='Preview mode')
|
||||
parser.add_argument('--timestamp', type=float, default=0, help='Frame timestamp in seconds')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
# 解析配置
|
||||
config = json.loads(args.config)
|
||||
|
||||
# 诊断日志:输出接收到的配置参数
|
||||
print(f"[DEBUG] Received config: {json.dumps(config, indent=2, ensure_ascii=False)}", file=sys.stderr)
|
||||
print(f"[DEBUG] titleStrokeWidth: {config.get('titleStrokeWidth', 'NOT SET')}", file=sys.stderr)
|
||||
print(f"[DEBUG] titleStrokeColor: {config.get('titleStrokeColor', 'NOT SET')}", file=sys.stderr)
|
||||
print(f"[DEBUG] titleFontFamily: {config.get('titleFontFamily', 'NOT SET')}", file=sys.stderr)
|
||||
|
||||
# 检查输入是图像还是视频
|
||||
video_path = args.video
|
||||
if video_path.lower().endswith(('.jpg', '.jpeg', '.png', '.bmp', '.webp')):
|
||||
# 直接加载图像
|
||||
print(f"Loading image: {video_path}")
|
||||
try:
|
||||
frame = Image.open(video_path)
|
||||
except Exception as e:
|
||||
print(f"Error: Failed to load image: {e}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
else:
|
||||
# 提取视频帧
|
||||
print(f"Extracting frame from video: {video_path}")
|
||||
frame = extract_frame_from_video(video_path, args.timestamp)
|
||||
|
||||
if frame is None:
|
||||
print("Error: Failed to extract frame from video", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
# 调整图片大小
|
||||
width, height = frame.size
|
||||
max_width = config.get('maxWidth', 1920)
|
||||
if width > max_width:
|
||||
scale = max_width / width
|
||||
new_width = max_width
|
||||
new_height = int(height * scale)
|
||||
frame = frame.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# 添加标题
|
||||
print(f"Adding title: {args.title}")
|
||||
result = add_text_to_image(frame, args.title, config)
|
||||
|
||||
# 保存结果
|
||||
os.makedirs(os.path.dirname(args.output), exist_ok=True)
|
||||
result.save(args.output, quality=95)
|
||||
print(f"Cover saved to: {args.output}")
|
||||
|
||||
sys.exit(0)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
165
src-tauri/resources/cover-python/cover_preview_generator.py
Normal file
165
src-tauri/resources/cover-python/cover_preview_generator.py
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
实时封面预览生成器(移植版:调用 cover_generator.py,不依赖 subtitle_cover_generator_simple)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import hashlib
|
||||
import tempfile
|
||||
import subprocess
|
||||
from typing import Dict, Any
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
if SCRIPT_DIR not in sys.path:
|
||||
sys.path.insert(0, SCRIPT_DIR)
|
||||
|
||||
COVER_GENERATOR = os.path.join(SCRIPT_DIR, "cover_generator.py")
|
||||
|
||||
STYLE_CONFIGS = {
|
||||
"default": {
|
||||
"titlePosition": "top",
|
||||
"titleColor": "#FFFFFF",
|
||||
"titleFontSize": 72,
|
||||
"titleStrokeWidth": 0,
|
||||
},
|
||||
"blur-bg": {
|
||||
"titlePosition": "top",
|
||||
"titleColor": "#FFFFFF",
|
||||
"titleFontSize": 72,
|
||||
"backgroundBlurEnabled": True,
|
||||
},
|
||||
"outline": {
|
||||
"titlePosition": "top",
|
||||
"titleColor": "#FFFFFF",
|
||||
"titleFontSize": 72,
|
||||
"titleStrokeWidth": 4,
|
||||
"titleStrokeColor": "#000000",
|
||||
},
|
||||
"gradient": {
|
||||
"titlePosition": "top",
|
||||
"titleColor": "#FFFFFF",
|
||||
"titleFontSize": 72,
|
||||
},
|
||||
"split": {
|
||||
"titlePosition": "center",
|
||||
"titleColor": "#FFFFFF",
|
||||
"titleFontSize": 64,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class CoverPreviewGenerator:
|
||||
def __init__(self):
|
||||
self.temp_dir = os.path.join(tempfile.gettempdir(), "aiclient-cover-previews")
|
||||
os.makedirs(self.temp_dir, exist_ok=True)
|
||||
self.preview_cache = {}
|
||||
self.COVER_STYLES = list(STYLE_CONFIGS.keys())
|
||||
|
||||
def get_video_hash(self, video_path: str) -> str:
|
||||
h = hashlib.md5()
|
||||
with open(video_path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(4096), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()[:16]
|
||||
|
||||
def _run_cover_generator(self, video_path: str, title: str, output_path: str, style: str) -> bool:
|
||||
if not os.path.isfile(COVER_GENERATOR):
|
||||
print(f"Missing {COVER_GENERATOR}", file=sys.stderr)
|
||||
return False
|
||||
config = dict(STYLE_CONFIGS.get(style, STYLE_CONFIGS["default"]))
|
||||
python_exe = os.environ.get("AICLIENT_PYTHON_PATH") or sys.executable
|
||||
cmd = [
|
||||
python_exe,
|
||||
COVER_GENERATOR,
|
||||
"--video",
|
||||
video_path,
|
||||
"--title",
|
||||
title,
|
||||
"--output",
|
||||
output_path,
|
||||
"--config",
|
||||
json.dumps(config, ensure_ascii=False),
|
||||
"--timestamp",
|
||||
"1",
|
||||
]
|
||||
env = os.environ.copy()
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
try:
|
||||
r = subprocess.run(
|
||||
cmd,
|
||||
cwd=SCRIPT_DIR,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=120,
|
||||
env=env,
|
||||
)
|
||||
if r.returncode != 0:
|
||||
print(r.stderr or r.stdout, file=sys.stderr)
|
||||
return r.returncode == 0 and os.path.isfile(output_path)
|
||||
except Exception as e:
|
||||
print(f"cover_generator failed: {e}", file=sys.stderr)
|
||||
return False
|
||||
|
||||
def generate_preview(self, video_path: str, style: str, output_path: str) -> bool:
|
||||
return self._run_cover_generator(
|
||||
video_path,
|
||||
"封面预览",
|
||||
output_path,
|
||||
style,
|
||||
)
|
||||
|
||||
def generate_all_previews(self, video_path: str) -> Dict[str, Any]:
|
||||
if not os.path.exists(video_path):
|
||||
return {"error": f"Video file not found: {video_path}"}
|
||||
|
||||
video_hash = self.get_video_hash(video_path)
|
||||
if video_hash in self.preview_cache:
|
||||
return self.preview_cache[video_hash]
|
||||
|
||||
previews = []
|
||||
errors = []
|
||||
for style in self.COVER_STYLES:
|
||||
output_path = os.path.join(self.temp_dir, f"preview_{video_hash}_{style}.jpg")
|
||||
ok = self.generate_preview(video_path, style, output_path)
|
||||
if ok:
|
||||
previews.append({"style": style, "path": output_path})
|
||||
else:
|
||||
errors.append(style)
|
||||
|
||||
response = {
|
||||
"previews": previews,
|
||||
"videoHash": video_hash,
|
||||
"tempDir": self.temp_dir,
|
||||
}
|
||||
if errors:
|
||||
response["errors"] = errors
|
||||
self.preview_cache[video_hash] = response
|
||||
return response
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Generate cover style previews")
|
||||
parser.add_argument("video_path", help="Path to the video file")
|
||||
parser.add_argument("--output-dir", help="Output directory for previews")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.exists(args.video_path):
|
||||
print(json.dumps({"error": f"not found: {args.video_path}"}, ensure_ascii=False))
|
||||
sys.exit(1)
|
||||
|
||||
gen = CoverPreviewGenerator()
|
||||
if args.output_dir:
|
||||
gen.temp_dir = args.output_dir
|
||||
os.makedirs(gen.temp_dir, exist_ok=True)
|
||||
|
||||
result = gen.generate_all_previews(args.video_path)
|
||||
print(json.dumps(result, ensure_ascii=False))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
235
src-tauri/resources/cover-python/custom_template_cover_fix.py
Normal file
235
src-tauri/resources/cover-python/custom_template_cover_fix.py
Normal file
@@ -0,0 +1,235 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
自定义模板封面修复补丁
|
||||
将这段代码复制到 subtitle_cover_generator_simple.py 替换 _generate_custom_template_cover 方法
|
||||
"""
|
||||
|
||||
def _generate_custom_template_cover(self, frame):
|
||||
"""
|
||||
使用自定义模板生成封面 (修复版)
|
||||
|
||||
Args:
|
||||
frame: 视频帧
|
||||
|
||||
Returns:
|
||||
生成的封面路径
|
||||
"""
|
||||
try:
|
||||
print("Generating custom template cover (fixed version)...", file=sys.stderr)
|
||||
template = self.template
|
||||
|
||||
# 转换为PIL图像
|
||||
image = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
||||
width, height = image.size
|
||||
|
||||
# 1. 应用背景模糊
|
||||
if template.get('background', {}).get('blurEnabled', False):
|
||||
blur_radius = template['background'].get('blurRadius', 15)
|
||||
print(f"Applying background blur: {blur_radius}", file=sys.stderr)
|
||||
image = image.filter(ImageFilter.GaussianBlur(radius=blur_radius))
|
||||
|
||||
# 2. 处理人像描边(如果启用RMBG)
|
||||
if template.get('portrait', {}).get('strokeEnabled', False):
|
||||
if RMBG_AVAILABLE:
|
||||
try:
|
||||
print("Extracting portrait for stroke effect...", file=sys.stderr)
|
||||
portrait = rembg_remove(image)
|
||||
|
||||
# 创建描边效果
|
||||
stroke_color = template['portrait'].get('strokeColor', '#FFD700')
|
||||
stroke_width = template['portrait'].get('strokeWidth', 3)
|
||||
stroke_type = template['portrait'].get('strokeType', 'solid')
|
||||
|
||||
print(f"Stroke: color={stroke_color}, width={stroke_width}, type={stroke_type}", file=sys.stderr)
|
||||
|
||||
# 合成带描边的人像
|
||||
if portrait.mode == 'RGBA':
|
||||
# 获取alpha通道作为mask
|
||||
alpha = portrait.split()[3]
|
||||
|
||||
# 对mask进行边缘检测
|
||||
alpha_np = np.array(alpha)
|
||||
edges = cv2.Canny(alpha_np, 30, 100)
|
||||
|
||||
# 转换描边颜色
|
||||
stroke_rgb = self._hex_to_rgb(stroke_color)
|
||||
outline_layer = Image.new('RGBA', (width, height), (*stroke_rgb, 0))
|
||||
|
||||
# 创建多层描边
|
||||
for i in range(stroke_width, 0, -1):
|
||||
# 外层更粗的描边
|
||||
kernel_size = i * 3
|
||||
kernel = np.ones((kernel_size, kernel_size), np.uint8)
|
||||
layer_edges = cv2.dilate(edges, kernel, iterations=1)
|
||||
|
||||
# 转换为PIL图像
|
||||
layer_pil = Image.fromarray(layer_edges)
|
||||
layer_colored = Image.new('RGBA', (width, height), (*stroke_rgb, 255))
|
||||
layer_colored.putalpha(layer_pil)
|
||||
|
||||
# 合并到描边层
|
||||
outline_layer = Image.alpha_composite(outline_layer, layer_colored)
|
||||
|
||||
# 创建结果:背景 + 描边 + 人像
|
||||
result = Image.new('RGB', (width, height))
|
||||
result.paste(image, (0, 0))
|
||||
|
||||
# 将描边和人像合成到背景上
|
||||
result_rgba = result.convert('RGBA')
|
||||
result_rgba = Image.alpha_composite(result_rgba, outline_layer)
|
||||
result_rgba = Image.alpha_composite(result_rgba, portrait)
|
||||
image = result_rgba.convert('RGB')
|
||||
|
||||
print("Portrait stroke effect applied successfully", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Portrait stroke failed: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
else:
|
||||
print("RMBG not available, skipping portrait stroke", file=sys.stderr)
|
||||
|
||||
# 3. 添加蒙版(如果有)
|
||||
mask_config = template.get('mask', {})
|
||||
mask_path = mask_config.get('imagePath', '')
|
||||
if mask_path and os.path.exists(mask_path):
|
||||
try:
|
||||
print(f"Adding mask from: {mask_path}", file=sys.stderr)
|
||||
mask_img = Image.open(mask_path).convert('RGBA')
|
||||
|
||||
# 获取蒙版位置和大小
|
||||
mask_pos = mask_config.get('position', {'x': 10, 'y': 10})
|
||||
mask_size = mask_config.get('size', {'width': 30, 'height': 20})
|
||||
mask_opacity = mask_config.get('opacity', 0.8)
|
||||
|
||||
mask_width = int(mask_size['width'] / 100 * width)
|
||||
mask_height = int(mask_size['height'] / 100 * height)
|
||||
mask_x = int(mask_pos['x'] / 100 * width)
|
||||
mask_y = int(mask_pos['y'] / 100 * height)
|
||||
|
||||
# 调整蒙版大小和透明度
|
||||
mask_img = mask_img.resize((mask_width, mask_height), Image.LANCZOS)
|
||||
if mask_opacity < 1.0:
|
||||
alpha = mask_img.split()[3]
|
||||
alpha = alpha.point(lambda p: int(p * mask_opacity))
|
||||
mask_img.putalpha(alpha)
|
||||
|
||||
# 将蒙版粘贴到图像上
|
||||
image_rgba = image.convert('RGBA')
|
||||
image_rgba.paste(mask_img, (mask_x - mask_width // 2, mask_y - mask_height // 2), mask_img)
|
||||
image = image_rgba.convert('RGB')
|
||||
|
||||
print("Mask applied successfully", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"Failed to add mask: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
|
||||
# 4. 添加标题
|
||||
draw = ImageDraw.Draw(image)
|
||||
|
||||
# 加载字体
|
||||
try:
|
||||
font_main = ImageFont.truetype("msyh.ttc", template['titles']['main'].get('fontSize', 60))
|
||||
font_sub = ImageFont.truetype("msyh.ttc", template['titles']['sub'].get('fontSize', 40))
|
||||
except:
|
||||
try:
|
||||
font_main = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", template['titles']['main'].get('fontSize', 60))
|
||||
font_sub = ImageFont.truetype("C:/Windows/Fonts/msyh.ttc", template['titles']['sub'].get('fontSize', 40))
|
||||
except:
|
||||
font_main = ImageFont.load_default()
|
||||
font_sub = ImageFont.load_default()
|
||||
print("Warning: Failed to load custom font, using default", file=sys.stderr)
|
||||
|
||||
# 绘制主标题(支持自动换行)
|
||||
main_title = template['titles']['main']
|
||||
if main_title.get('text'):
|
||||
main_text = main_title['text']
|
||||
main_color = self._hex_to_rgb(main_title.get('color', '#FFFFFF'))
|
||||
main_pos_x = int(width * main_title['position']['x'] / 100)
|
||||
main_pos_y = int(height * main_title['position']['y'] / 100)
|
||||
max_chars_per_line = main_title.get('maxLength', 4) # 每行最多字符数
|
||||
|
||||
# 将文字分行
|
||||
lines = []
|
||||
for i in range(0, len(main_text), max_chars_per_line):
|
||||
lines.append(main_text[i:i + max_chars_per_line])
|
||||
|
||||
# 计算行高和总高度
|
||||
line_height = int(font_main.size * 1.2) # 行高为字体大小的1.2倍
|
||||
total_height = len(lines) * line_height
|
||||
|
||||
# 计算起始Y坐标,使多行文字垂直居中
|
||||
start_y = main_pos_y - total_height // 2 + line_height // 2
|
||||
|
||||
# 逐行绘制
|
||||
for i, line in enumerate(lines):
|
||||
line_y = start_y + i * line_height
|
||||
|
||||
# 绘制描边
|
||||
if main_title.get('strokeWidth', 0) > 0:
|
||||
stroke_color = self._hex_to_rgb(main_title.get('strokeColor', '#000000'))
|
||||
for offset_x in range(-main_title['strokeWidth'], main_title['strokeWidth'] + 1):
|
||||
for offset_y in range(-main_title['strokeWidth'], main_title['strokeWidth'] + 1):
|
||||
if offset_x == 0 and offset_y == 0:
|
||||
continue
|
||||
draw.text((main_pos_x + offset_x, line_y + offset_y),
|
||||
line, font=font_main, fill=stroke_color, anchor='mm')
|
||||
|
||||
# 绘制主体
|
||||
draw.text((main_pos_x, line_y), line, font=font_main, fill=main_color, anchor='mm')
|
||||
|
||||
print(f"Drew main title: '{main_text}' ({len(lines)} lines) at ({main_pos_x}, {main_pos_y})", file=sys.stderr)
|
||||
|
||||
# 绘制副标题(支持自动换行)
|
||||
sub_title = template['titles']['sub']
|
||||
if sub_title.get('text'):
|
||||
sub_text = sub_title['text']
|
||||
sub_color = self._hex_to_rgb(sub_title.get('color', '#FFFFFF'))
|
||||
sub_pos_x = int(width * sub_title['position']['x'] / 100)
|
||||
sub_pos_y = int(height * sub_title['position']['y'] / 100)
|
||||
max_chars_per_line = sub_title.get('maxLength', 3) # 每行最多字符数
|
||||
|
||||
# 将文字分行
|
||||
lines = []
|
||||
for i in range(0, len(sub_text), max_chars_per_line):
|
||||
lines.append(sub_text[i:i + max_chars_per_line])
|
||||
|
||||
# 计算行高和总高度
|
||||
line_height = int(font_sub.size * 1.2) # 行高为字体大小的1.2倍
|
||||
total_height = len(lines) * line_height
|
||||
|
||||
# 计算起始Y坐标,使多行文字垂直居中
|
||||
start_y = sub_pos_y - total_height // 2 + line_height // 2
|
||||
|
||||
# 逐行绘制
|
||||
for i, line in enumerate(lines):
|
||||
line_y = start_y + i * line_height
|
||||
|
||||
# 绘制描边
|
||||
if sub_title.get('strokeWidth', 0) > 0:
|
||||
stroke_color = self._hex_to_rgb(sub_title.get('strokeColor', '#000000'))
|
||||
for offset_x in range(-sub_title['strokeWidth'], sub_title['strokeWidth'] + 1):
|
||||
for offset_y in range(-sub_title['strokeWidth'], sub_title['strokeWidth'] + 1):
|
||||
if offset_x == 0 and offset_y == 0:
|
||||
continue
|
||||
draw.text((sub_pos_x + offset_x, line_y + offset_y),
|
||||
line, font=font_sub, fill=stroke_color, anchor='mm')
|
||||
|
||||
# 绘制主体
|
||||
draw.text((sub_pos_x, line_y), line, font=font_sub, fill=sub_color, anchor='mm')
|
||||
|
||||
print(f"Drew sub title: '{sub_text}' ({len(lines)} lines) at ({sub_pos_x}, {sub_pos_y})", file=sys.stderr)
|
||||
|
||||
# 保存封面
|
||||
cover_path = os.path.join(self.output_dir, 'cover_custom_template.png')
|
||||
image.save(cover_path, quality=95)
|
||||
|
||||
print(f"Custom template cover generated successfully: {cover_path}", file=sys.stderr)
|
||||
return cover_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error generating custom template cover: {e}", file=sys.stderr)
|
||||
import traceback
|
||||
traceback.print_exc(file=sys.stderr)
|
||||
raise Exception(f"Custom template cover generation failed: {str(e)}")
|
||||
35
src-tauri/resources/cover-python/find_font.py
Normal file
35
src-tauri/resources/cover-python/find_font.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""
|
||||
查找指定字体文件路径
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加模块路径
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from modules.font_manager import get_font_manager
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("Usage: python find_font.py <font_family> [font_weight]", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
font_family = sys.argv[1]
|
||||
font_weight = int(sys.argv[2]) if len(sys.argv) > 2 else 400
|
||||
|
||||
try:
|
||||
font_manager = get_font_manager()
|
||||
font_path = font_manager.find_font(font_family, font_weight)
|
||||
|
||||
if font_path:
|
||||
print(font_path)
|
||||
else:
|
||||
print("") # 返回空字符串表示未找到
|
||||
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
83
src-tauri/resources/cover-python/generate_cover_previews.py
Normal file
83
src-tauri/resources/cover-python/generate_cover_previews.py
Normal file
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
批量生成封面模板预览图(CLI,对齐 Electron 资源目录生成)
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
import subprocess
|
||||
|
||||
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
COVER_GENERATOR = os.path.join(SCRIPT_DIR, "cover_generator.py")
|
||||
|
||||
COVER_STYLES = ["default", "blur-bg", "outline", "gradient", "split"]
|
||||
|
||||
STYLE_CONFIGS = {
|
||||
"default": {"titlePosition": "top", "titleColor": "#FFFFFF", "titleFontSize": 72},
|
||||
"blur-bg": {"titlePosition": "top", "titleColor": "#FFFFFF", "backgroundBlurEnabled": True},
|
||||
"outline": {
|
||||
"titlePosition": "top",
|
||||
"titleColor": "#FFFFFF",
|
||||
"titleStrokeWidth": 4,
|
||||
"titleStrokeColor": "#000000",
|
||||
},
|
||||
"gradient": {"titlePosition": "top", "titleColor": "#FFFFFF"},
|
||||
"split": {"titlePosition": "center", "titleColor": "#FFFFFF"},
|
||||
}
|
||||
|
||||
|
||||
def generate_all_cover_previews(video_path: str, preview_dir: str, demo_title: str) -> bool:
|
||||
os.makedirs(preview_dir, exist_ok=True)
|
||||
python_exe = os.environ.get("AICLIENT_PYTHON_PATH") or sys.executable
|
||||
ok_count = 0
|
||||
|
||||
for style in COVER_STYLES:
|
||||
out_path = os.path.join(preview_dir, f"cover-preview-{style}.jpg")
|
||||
config = dict(STYLE_CONFIGS.get(style, STYLE_CONFIGS["default"]))
|
||||
cmd = [
|
||||
python_exe,
|
||||
COVER_GENERATOR,
|
||||
"--video",
|
||||
video_path,
|
||||
"--title",
|
||||
demo_title,
|
||||
"--output",
|
||||
out_path,
|
||||
"--config",
|
||||
json.dumps(config, ensure_ascii=False),
|
||||
"--timestamp",
|
||||
"1",
|
||||
]
|
||||
try:
|
||||
r = subprocess.run(cmd, cwd=SCRIPT_DIR, capture_output=True, text=True, timeout=120)
|
||||
if r.returncode == 0 and os.path.isfile(out_path):
|
||||
print(f"✓ {out_path}", file=sys.stderr)
|
||||
ok_count += 1
|
||||
else:
|
||||
print(f"✗ {style}: {r.stderr or r.stdout}", file=sys.stderr)
|
||||
except Exception as e:
|
||||
print(f"✗ {style}: {e}", file=sys.stderr)
|
||||
|
||||
return ok_count > 0
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate cover template preview images")
|
||||
parser.add_argument("--video", required=True, help="Source video path")
|
||||
parser.add_argument("--output-dir", required=True, help="Output directory")
|
||||
parser.add_argument("--title", default="封面预览标题示例", help="Demo title text")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isfile(args.video):
|
||||
print(f"Error: video not found: {args.video}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
success = generate_all_cover_previews(args.video, args.output_dir, args.title)
|
||||
sys.exit(0 if success else 1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
168
src-tauri/resources/cover-python/generate_image_cover.py
Normal file
168
src-tauri/resources/cover-python/generate_image_cover.py
Normal file
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env python
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
为图片生成5种字幕封面风格
|
||||
"""
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
|
||||
def add_text_with_style(img, title, style_name, font_path):
|
||||
"""在图像上添加指定风格的标题文字"""
|
||||
pil_img = img.copy()
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
width, height = pil_img.size
|
||||
|
||||
# 设置字体大小
|
||||
font_size = int(height * 0.12)
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except:
|
||||
font = ImageFont.load_default()
|
||||
|
||||
# 计算标题位置(顶部居中)
|
||||
bbox = draw.textbbox((0, 0), title, font=font)
|
||||
text_width = bbox[2] - bbox[0]
|
||||
text_height = bbox[3] - bbox[1]
|
||||
x = (width - text_width) // 2
|
||||
y = int(height * 0.08) # 距离顶部8%的位置
|
||||
|
||||
if style_name == "default":
|
||||
# 默认风格:黑色背景条 + 白色文字
|
||||
padding = 30
|
||||
draw.rectangle(
|
||||
[x - padding, y - padding, x + text_width + padding, y + text_height + padding],
|
||||
fill=(0, 0, 0, 200)
|
||||
)
|
||||
draw.text((x, y), title, font=font, fill=(255, 255, 255))
|
||||
|
||||
elif style_name == "blur_bg":
|
||||
# 模糊背景风格
|
||||
bg_region = pil_img.crop((x - 50, y - 50, x + text_width + 50, y + text_height + 50))
|
||||
bg_region = bg_region.filter(Image.BLUR)
|
||||
pil_img.paste(bg_region, (x - 50, y - 50))
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
# 添加半透明黑色遮罩
|
||||
overlay = Image.new('RGBA', pil_img.size, (0, 0, 0, 0))
|
||||
overlay_draw = ImageDraw.Draw(overlay)
|
||||
overlay_draw.rectangle([x - 50, y - 50, x + text_width + 50, y + text_height + 50], fill=(0, 0, 0, 150))
|
||||
pil_img = Image.alpha_composite(pil_img.convert('RGBA'), overlay).convert('RGB')
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
draw.text((x, y), title, font=font, fill=(255, 255, 255))
|
||||
|
||||
elif style_name == "outline":
|
||||
# 描边风格:白色文字 + 黑色描边
|
||||
outline_width = 4
|
||||
for adj_x in range(-outline_width, outline_width + 1):
|
||||
for adj_y in range(-outline_width, outline_width + 1):
|
||||
draw.text((x + adj_x, y + adj_y), title, font=font, fill=(0, 0, 0))
|
||||
draw.text((x, y), title, font=font, fill=(255, 255, 255))
|
||||
|
||||
elif style_name == "gradient":
|
||||
# 渐变背景风格
|
||||
gradient_height = text_height + 100
|
||||
gradient = Image.new('RGBA', (width, gradient_height), (0, 0, 0, 0))
|
||||
gradient_draw = ImageDraw.Draw(gradient)
|
||||
for i in range(gradient_height):
|
||||
alpha = int(250 * (i / gradient_height))
|
||||
gradient_draw.rectangle([0, i, width, i + 1], fill=(0, 0, 0, alpha))
|
||||
|
||||
pil_img_rgba = pil_img.convert('RGBA')
|
||||
pil_img_rgba.paste(gradient, (0, y - 50), gradient)
|
||||
pil_img = pil_img_rgba.convert('RGB')
|
||||
draw = ImageDraw.Draw(pil_img)
|
||||
|
||||
draw.text((x, y), title, font=font, fill=(255, 255, 255))
|
||||
|
||||
elif style_name == "split":
|
||||
# 分栏风格:左侧标题 + 右侧装饰
|
||||
padding = 40
|
||||
|
||||
# 左侧标题
|
||||
left_x = padding
|
||||
draw.rectangle(
|
||||
[left_x - 20, y - 20, left_x + text_width + 20, y + text_height + 20],
|
||||
fill=(0, 0, 0, 180)
|
||||
)
|
||||
draw.text((left_x, y), title, font=font, fill=(255, 255, 255))
|
||||
|
||||
# 右侧装饰元素
|
||||
right_text = "✨ 精彩"
|
||||
right_bbox = draw.textbbox((0, 0), right_text, font=font)
|
||||
right_width = right_bbox[2] - right_bbox[0]
|
||||
right_x = width - right_width - padding
|
||||
draw.rectangle(
|
||||
[right_x - 25, y - 25, right_x + right_width + 25, y + text_height + 25],
|
||||
fill=(255, 193, 7, 200) # 金黄色背景
|
||||
)
|
||||
draw.text((right_x, y), right_text, font=font, fill=(0, 0, 0))
|
||||
|
||||
return pil_img
|
||||
|
||||
def main():
|
||||
# 输入图片路径
|
||||
image_path = r"C:\aigcpanel-main\微信图片_20251125215233_633_235.jpg"
|
||||
title = "我是个美女啊啊啊"
|
||||
|
||||
# 输出目录
|
||||
output_dir = r"C:\aigcpanel-main\public\cover-previews"
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# 字体路径
|
||||
font_path = r"C:\Windows\Fonts\msyh.ttc" # 微软雅黑
|
||||
|
||||
# 读取图片
|
||||
print("正在读取图片...")
|
||||
try:
|
||||
img = Image.open(image_path)
|
||||
print(f"图片尺寸:{img.size}")
|
||||
except Exception as e:
|
||||
print(f"❌ 无法读取图片: {e}")
|
||||
return
|
||||
|
||||
# 调整图片大小以便预览
|
||||
width, height = img.size
|
||||
if width > 1280:
|
||||
scale = 1280 / width
|
||||
new_width = 1280
|
||||
new_height = int(height * scale)
|
||||
img = img.resize((new_width, new_height))
|
||||
print(f"调整图片尺寸为:{img.size}")
|
||||
|
||||
# 定义5种风格
|
||||
styles = [
|
||||
("default", "默认风格:黑色半透明背景条"),
|
||||
("blur_bg", "模糊背景风格:背景模糊效果"),
|
||||
("outline", "描边风格:文字带黑色描边"),
|
||||
("gradient", "渐变背景风格:渐变半透明遮罩"),
|
||||
("split", "分栏风格:标题+装饰元素")
|
||||
]
|
||||
|
||||
# 生成每种风格的封面
|
||||
for style_name, description in styles:
|
||||
print(f"生成 {style_name} 风格封面:{description}")
|
||||
|
||||
# 复制原始图片
|
||||
styled_img = img.copy()
|
||||
|
||||
# 应用风格
|
||||
styled_img = add_text_with_style(styled_img, title, style_name, font_path)
|
||||
|
||||
# 保存封面
|
||||
output_path = os.path.join(output_dir, f"cover-title-{style_name}.png")
|
||||
styled_img.save(output_path)
|
||||
print(f"✅ 已保存:{output_path}")
|
||||
|
||||
print("\n" + "="*60)
|
||||
print("🎉 所有封面生成完成!")
|
||||
print(f"📁 保存位置:{output_dir}")
|
||||
print("="*60)
|
||||
print(f"\n标题:{title}")
|
||||
print("\n封面列表:")
|
||||
for style_name, description in styles:
|
||||
print(f" • cover-title-{style_name}.png - {description}")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
107
src-tauri/resources/cover-python/get_font_name.py
Normal file
107
src-tauri/resources/cover-python/get_font_name.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
读取字体文件的内部Family名称(用于ASS字幕)
|
||||
"""
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加模块路径
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
try:
|
||||
from fontTools.ttLib import TTFont
|
||||
except ImportError:
|
||||
try:
|
||||
import fontTools
|
||||
except ImportError:
|
||||
print("", file=sys.stderr) # 返回空字符串,表示无法读取
|
||||
sys.exit(1)
|
||||
|
||||
def get_font_family_name(font_path: str) -> str:
|
||||
"""
|
||||
获取字体名称。
|
||||
优先使用文件名(不含扩展名),这样可以保证与ziti目录中的文件名匹配。
|
||||
例如:墨趣古风体.ttf → 返回 "墨趣古风体"
|
||||
"""
|
||||
try:
|
||||
# 优先返回文件名(不含扩展名)
|
||||
# 这样可以确保与ziti目录中的文件名匹配,避免使用字体内部的元数据
|
||||
# 字体文件内部的Family Name有时是拼音(如"MoQuGuFengTi"),导致libass找不到字体
|
||||
from pathlib import Path
|
||||
filename = Path(font_path).stem # 获取文件名,不含扩展名
|
||||
if filename:
|
||||
# 确保返回的是正确编码的字符串(对于中文字体名)
|
||||
return str(filename)
|
||||
|
||||
# 如果文件名为空,尝试读取字体内部的Family名称作为备选
|
||||
font = TTFont(font_path)
|
||||
# 读取name表中的Family名称(通常在第1或第16条记录中)
|
||||
for record in font['name'].names:
|
||||
# nameID 1 = Font Family Name
|
||||
if record.nameID == 1:
|
||||
# 尝试解码,优先使用Unicode编码
|
||||
try:
|
||||
if hasattr(record, 'string'):
|
||||
if isinstance(record.string, bytes):
|
||||
return record.string.decode('utf-16-be') if record.isUnicode() else record.string.decode('latin-1')
|
||||
return str(record.string)
|
||||
except:
|
||||
pass
|
||||
# 如果找不到nameID 1,尝试其他nameID
|
||||
for record in font['name'].names:
|
||||
if record.nameID == 16: # Typographic Family Name
|
||||
try:
|
||||
if hasattr(record, 'string'):
|
||||
if isinstance(record.string, bytes):
|
||||
return record.string.decode('utf-16-be') if record.isUnicode() else record.string.decode('latin-1')
|
||||
return str(record.string)
|
||||
except:
|
||||
pass
|
||||
font.close()
|
||||
except Exception as e:
|
||||
pass
|
||||
|
||||
return ""
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 2:
|
||||
print("", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
font_path = sys.argv[1]
|
||||
|
||||
if not Path(font_path).exists():
|
||||
print("", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
family_name = get_font_family_name(font_path)
|
||||
if family_name:
|
||||
print(family_name)
|
||||
else:
|
||||
print("") # 返回空字符串表示无法读取
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print("", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
26
src-tauri/resources/cover-python/get_fonts.py
Normal file
26
src-tauri/resources/cover-python/get_fonts.py
Normal file
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
获取所有可用字体列表
|
||||
"""
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# 添加模块路径
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from modules.font_manager import get_font_manager
|
||||
|
||||
def main():
|
||||
try:
|
||||
font_manager = get_font_manager()
|
||||
fonts = font_manager.get_all_available_fonts()
|
||||
|
||||
# 输出JSON格式
|
||||
print(json.dumps(fonts, ensure_ascii=False))
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"Error: {str(e)}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
6
src-tauri/resources/cover-python/modules/__init__.py
Normal file
6
src-tauri/resources/cover-python/modules/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
modules 包初始化文件
|
||||
"""
|
||||
|
||||
# 这个文件是必需的,让 Python 将 modules 目录识别为一个包
|
||||
# 从而支持相对导入(如 from .utils import ...)
|
||||
2654
src-tauri/resources/cover-python/modules/cover.py
Normal file
2654
src-tauri/resources/cover-python/modules/cover.py
Normal file
File diff suppressed because it is too large
Load Diff
117
src-tauri/resources/cover-python/modules/cover_templates.py
Normal file
117
src-tauri/resources/cover-python/modules/cover_templates.py
Normal file
@@ -0,0 +1,117 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
封面模板定义文件
|
||||
Cover Template Definitions
|
||||
"""
|
||||
|
||||
from typing import Dict, Any, Tuple
|
||||
|
||||
# 封面模板配置
|
||||
COVER_TEMPLATES: Dict[str, Dict[str, Any]] = {
|
||||
'professional': {
|
||||
'name': '专业商务风',
|
||||
'description': '金色描边 + 中度虚化 + 人物清晰',
|
||||
'blur_radius': 25, # 背景模糊半径
|
||||
'outline_color': (255, 215, 0), # 金色 (R, G, B)
|
||||
'outline_width': 8, # 描边宽度
|
||||
'brightness': 0.9, # 亮度调整 (0.0-1.0)
|
||||
'contrast': 1.0, # 对比度
|
||||
'preview_path': '/assets/templates/professional.jpg'
|
||||
},
|
||||
'vibrant': {
|
||||
'name': '活力青春风',
|
||||
'description': '粉色描边 + 轻度虚化 + 明亮色调',
|
||||
'blur_radius': 20,
|
||||
'outline_color': (255, 105, 180), # 粉色 (R, G, B)
|
||||
'outline_width': 6,
|
||||
'brightness': 1.1,
|
||||
'contrast': 1.05,
|
||||
'preview_path': '/assets/templates/vibrant.jpg'
|
||||
},
|
||||
'elegant': {
|
||||
'name': '优雅高级风',
|
||||
'description': '银色描边 + 重度虚化 + 柔和光效',
|
||||
'blur_radius': 30,
|
||||
'outline_color': (192, 192, 192), # 银色 (R, G, B)
|
||||
'outline_width': 10,
|
||||
'brightness': 0.85,
|
||||
'contrast': 0.95,
|
||||
'preview_path': '/assets/templates/elegant.jpg'
|
||||
},
|
||||
'classic': {
|
||||
'name': '经典商务风',
|
||||
'description': '白色描边 + 轻度虚化 + 简洁风格',
|
||||
'blur_radius': 15,
|
||||
'outline_color': (255, 255, 255), # 白色 (R, G, B)
|
||||
'outline_width': 5,
|
||||
'brightness': 0.95,
|
||||
'contrast': 1.0,
|
||||
'preview_path': '/assets/templates/classic.jpg'
|
||||
},
|
||||
'dramatic': {
|
||||
'name': '戏剧艺术风',
|
||||
'description': '红色描边 + 重度虚化 + 强烈对比',
|
||||
'blur_radius': 35,
|
||||
'outline_color': (220, 20, 60), # 红色 (R, G, B)
|
||||
'outline_width': 12,
|
||||
'brightness': 0.8,
|
||||
'contrast': 1.2,
|
||||
'preview_path': '/assets/templates/dramatic.jpg'
|
||||
}
|
||||
}
|
||||
|
||||
def get_template(template_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取模板配置
|
||||
|
||||
Args:
|
||||
template_id: 模板ID
|
||||
|
||||
Returns:
|
||||
模板配置字典
|
||||
"""
|
||||
return COVER_TEMPLATES.get(template_id, COVER_TEMPLATES['professional'])
|
||||
|
||||
def get_all_templates() -> Dict[str, Dict[str, Any]]:
|
||||
"""
|
||||
获取所有模板配置
|
||||
|
||||
Returns:
|
||||
所有模板配置字典
|
||||
"""
|
||||
return COVER_TEMPLATES.copy()
|
||||
|
||||
def get_template_names() -> Dict[str, str]:
|
||||
"""
|
||||
获取模板ID和名称映射
|
||||
|
||||
Returns:
|
||||
{template_id: template_name}
|
||||
"""
|
||||
return {k: v['name'] for k, v in COVER_TEMPLATES.items()}
|
||||
|
||||
def get_template_descriptions() -> Dict[str, str]:
|
||||
"""
|
||||
获取模板ID和描述映射
|
||||
|
||||
Returns:
|
||||
{template_id: template_description}
|
||||
"""
|
||||
return {k: v['description'] for k, v in COVER_TEMPLATES.items()}
|
||||
|
||||
def get_template_preview_paths() -> Dict[str, str]:
|
||||
"""
|
||||
获取模板ID和预览图路径映射
|
||||
|
||||
Returns:
|
||||
{template_id: preview_path}
|
||||
"""
|
||||
return {k: v['preview_path'] for k, v in COVER_TEMPLATES.items()}
|
||||
|
||||
# 模板效果参数范围定义(用于UI验证)
|
||||
TEMPLATE_PARAM_RANGES = {
|
||||
'blur_radius': {'min': 0, 'max': 50, 'default': 25},
|
||||
'outline_width': {'min': 0, 'max': 20, 'default': 8},
|
||||
'brightness': {'min': 0.5, 'max': 1.5, 'default': 1.0},
|
||||
'contrast': {'min': 0.5, 'max': 1.5, 'default': 1.0}
|
||||
}
|
||||
517
src-tauri/resources/cover-python/modules/font_manager.py
Normal file
517
src-tauri/resources/cover-python/modules/font_manager.py
Normal file
@@ -0,0 +1,517 @@
|
||||
"""
|
||||
字体管理模块
|
||||
负责查找和管理字体文件,支持预置字体、系统字体、ziti目录字体和Google Fonts
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import platform
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
import json
|
||||
|
||||
# 尝试导入loguru,如果不可用则使用print作为替代
|
||||
try:
|
||||
from loguru import logger
|
||||
except ImportError:
|
||||
# Fallback logger that uses print
|
||||
class logger:
|
||||
@staticmethod
|
||||
def info(msg, *args, **kwargs):
|
||||
print(f"[INFO] {msg}", file=sys.stderr)
|
||||
@staticmethod
|
||||
def warning(msg, *args, **kwargs):
|
||||
print(f"[WARN] {msg}", file=sys.stderr)
|
||||
@staticmethod
|
||||
def error(msg, *args, **kwargs):
|
||||
print(f"[ERROR] {msg}", file=sys.stderr)
|
||||
@staticmethod
|
||||
def debug(msg, *args, **kwargs):
|
||||
print(f"[DEBUG] {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
class FontManager:
|
||||
"""字体管理器"""
|
||||
|
||||
def __init__(self):
|
||||
# 计算项目根目录(从 python/modules 向上两级)
|
||||
# __file__ 应该是 .../python/modules/font_manager.py
|
||||
current_file = Path(__file__).resolve()
|
||||
python_dir = current_file.parent.parent # python 目录
|
||||
project_root = python_dir.parent # 项目根目录
|
||||
|
||||
self.bundled_fonts_dir = Path("fonts/bundled")
|
||||
self.fonts_metadata_file = self.bundled_fonts_dir / "fonts_metadata.json"
|
||||
|
||||
# 🔧 修复ziti字体路径:支持ASAR打包环境
|
||||
# 检查是否在打包环境中运行
|
||||
app_root = os.environ.get('APP_ROOT', None)
|
||||
if app_root:
|
||||
ziti_path_bundle = Path(app_root) / "resources-bundles" / "ziti"
|
||||
if ziti_path_bundle.exists():
|
||||
self.ziti_fonts_dir = ziti_path_bundle
|
||||
print(f"✅ [font_manager] 找到ziti字体目录(bundle): {ziti_path_bundle}")
|
||||
else:
|
||||
ziti_path = Path(app_root) / "extra" / "common" / "fonts" / "ziti"
|
||||
if ziti_path.exists():
|
||||
self.ziti_fonts_dir = ziti_path
|
||||
print(f"✅ [font_manager] 找到ziti字体目录: {ziti_path}")
|
||||
else:
|
||||
ziti_path_fallback1 = Path(app_root) / "app.asar.unpacked" / "ziti"
|
||||
if ziti_path_fallback1.exists():
|
||||
self.ziti_fonts_dir = ziti_path_fallback1
|
||||
print(f"✅ [font_manager] 找到ziti字体目录(回退1): {ziti_path_fallback1}")
|
||||
else:
|
||||
ziti_path_fallback2 = Path(app_root) / "ziti"
|
||||
self.ziti_fonts_dir = ziti_path_fallback2
|
||||
print(f"⚠️ [font_manager] 使用回退路径: {ziti_path_fallback2} (存在: {ziti_path_fallback2.exists()})")
|
||||
else:
|
||||
self.ziti_fonts_dir = project_root / "ziti"
|
||||
print(f"🔧 [font_manager] 开发环境ziti路径: {self.ziti_fonts_dir}")
|
||||
self._bundled_fonts_cache: Optional[Dict] = None
|
||||
self._system_fonts_cache: Optional[List[Dict]] = None
|
||||
self._ziti_fonts_cache: Optional[List[Dict]] = None # 新增:ziti字体缓存
|
||||
|
||||
# 诊断日志
|
||||
logger.debug(f"FontManager 初始化: current_file={current_file}, python_dir={python_dir}, project_root={project_root}")
|
||||
logger.debug(f"ziti_fonts_dir={self.ziti_fonts_dir.absolute()}, exists={self.ziti_fonts_dir.exists()}")
|
||||
|
||||
def get_bundled_fonts(self) -> List[Dict]:
|
||||
"""获取预置字体列表"""
|
||||
if self._bundled_fonts_cache is not None:
|
||||
return self._bundled_fonts_cache.get("fonts", [])
|
||||
|
||||
if not self.fonts_metadata_file.exists():
|
||||
logger.warning(f"字体元数据文件不存在: {self.fonts_metadata_file}")
|
||||
return []
|
||||
|
||||
try:
|
||||
with open(self.fonts_metadata_file, "r", encoding="utf-8") as f:
|
||||
metadata = json.load(f)
|
||||
self._bundled_fonts_cache = metadata
|
||||
return metadata.get("fonts", [])
|
||||
except Exception as e:
|
||||
logger.error(f"读取字体元数据失败: {e}")
|
||||
return []
|
||||
|
||||
def scan_ziti_fonts(self) -> List[Dict]:
|
||||
"""扫描ziti目录中的字体文件"""
|
||||
if self._ziti_fonts_cache is not None:
|
||||
return self._ziti_fonts_cache
|
||||
|
||||
ziti_fonts = []
|
||||
|
||||
logger.info(f"正在扫描 ziti 目录: {self.ziti_fonts_dir.absolute()}")
|
||||
logger.info(f" 当前工作目录: {os.getcwd()}")
|
||||
logger.info(f" APP_ROOT环境变量: {os.environ.get('APP_ROOT', 'None')}")
|
||||
|
||||
if not self.ziti_fonts_dir.exists():
|
||||
logger.warning(f"❌ ziti目录不存在: {self.ziti_fonts_dir.absolute()}")
|
||||
logger.warning(f" 请检查路径配置和资源文件是否正确打包")
|
||||
return []
|
||||
else:
|
||||
logger.info(f"✅ ziti目录存在,开始扫描字体文件...")
|
||||
|
||||
try:
|
||||
# 支持的字体格式
|
||||
font_extensions = ['.ttf', '.otf', '.ttc', '.woff', '.woff2']
|
||||
|
||||
# 扫描ziti目录中的所有字体文件
|
||||
for font_file in self.ziti_fonts_dir.iterdir():
|
||||
if font_file.is_file() and font_file.suffix.lower() in font_extensions:
|
||||
# 从文件名提取字体信息
|
||||
font_name = font_file.stem
|
||||
|
||||
# 尝试解析字体名称和变体
|
||||
display_name = font_name
|
||||
family = font_name
|
||||
weight = 400
|
||||
|
||||
# 检测常见的字体变体
|
||||
name_lower = font_name.lower()
|
||||
if 'bold' in name_lower:
|
||||
weight = 700
|
||||
elif 'semibold' in name_lower:
|
||||
weight = 600
|
||||
elif 'medium' in name_lower:
|
||||
weight = 500
|
||||
elif 'light' in name_lower:
|
||||
weight = 300
|
||||
elif 'thin' in name_lower:
|
||||
weight = 100
|
||||
elif 'black' in name_lower or 'extrabold' in name_lower:
|
||||
weight = 900
|
||||
|
||||
# 清理family名称(移除变体后缀)
|
||||
for variant in ['-Bold', '-SemiBold', '-Medium', '-Light', '-Thin', '-Black', '-ExtraBold', '-Regular']:
|
||||
if family.endswith(variant):
|
||||
family = family[:-len(variant)]
|
||||
break
|
||||
|
||||
ziti_fonts.append({
|
||||
'family': family,
|
||||
'display_name': display_name,
|
||||
'path': str(font_file.absolute()),
|
||||
'source': 'ziti',
|
||||
'weight': weight,
|
||||
'category': 'sans-serif', # 默认分类
|
||||
'languages': ['zh-CN', 'ja', 'en'] # 假设支持中日英
|
||||
})
|
||||
|
||||
logger.info(f"✅ 从ziti目录扫描到 {len(ziti_fonts)} 个字体")
|
||||
if len(ziti_fonts) > 0:
|
||||
logger.debug(f" 扫描到的字体: {', '.join([f['family'] for f in ziti_fonts[:5]])}" +
|
||||
(f" 等({len(ziti_fonts)}个)" if len(ziti_fonts) > 5 else ""))
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"❌ 扫描ziti目录字体失败: {e}")
|
||||
import traceback
|
||||
logger.error(f" 追踪: {traceback.format_exc()}")
|
||||
|
||||
# 去重(按family和weight)
|
||||
seen = set()
|
||||
unique_fonts = []
|
||||
for font in ziti_fonts:
|
||||
key = (font['family'], font.get('weight', 400))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_fonts.append(font)
|
||||
|
||||
self._ziti_fonts_cache = unique_fonts
|
||||
return unique_fonts
|
||||
|
||||
def scan_system_fonts(self) -> List[Dict]:
|
||||
"""扫描系统已安装的字体"""
|
||||
if self._system_fonts_cache is not None:
|
||||
return self._system_fonts_cache
|
||||
|
||||
system_fonts = []
|
||||
system = platform.system()
|
||||
|
||||
try:
|
||||
if system == 'Windows':
|
||||
font_dir = Path("C:/Windows/Fonts")
|
||||
if font_dir.exists():
|
||||
# 常见Windows字体
|
||||
common_fonts = {
|
||||
'simhei.ttf': {'family': 'SimHei', 'display_name': '黑体'},
|
||||
'simsun.ttc': {'family': 'SimSun', 'display_name': '宋体'},
|
||||
'msyh.ttc': {'family': 'Microsoft YaHei', 'display_name': '微软雅黑'},
|
||||
'msyhbd.ttc': {'family': 'Microsoft YaHei', 'display_name': '微软雅黑 Bold', 'weight': 700},
|
||||
'arial.ttf': {'family': 'Arial', 'display_name': 'Arial'},
|
||||
'arialbd.ttf': {'family': 'Arial', 'display_name': 'Arial Bold', 'weight': 700},
|
||||
'times.ttf': {'family': 'Times New Roman', 'display_name': 'Times New Roman'},
|
||||
'timesbd.ttf': {'family': 'Times New Roman', 'display_name': 'Times New Roman Bold', 'weight': 700},
|
||||
}
|
||||
|
||||
for font_file, font_info in common_fonts.items():
|
||||
font_path = font_dir / font_file
|
||||
if font_path.exists():
|
||||
system_fonts.append({
|
||||
'family': font_info['family'],
|
||||
'display_name': font_info['display_name'],
|
||||
'path': str(font_path),
|
||||
'source': 'system',
|
||||
'weight': font_info.get('weight', 400)
|
||||
})
|
||||
|
||||
elif system == 'Darwin': # macOS
|
||||
font_dirs = [
|
||||
Path("/System/Library/Fonts"),
|
||||
Path("/Library/Fonts"),
|
||||
Path.home() / "Library/Fonts"
|
||||
]
|
||||
|
||||
common_fonts = {
|
||||
'PingFang.ttc': {'family': 'PingFang SC', 'display_name': '苹方'},
|
||||
'Arial.ttf': {'family': 'Arial', 'display_name': 'Arial'},
|
||||
}
|
||||
|
||||
for font_dir in font_dirs:
|
||||
if font_dir.exists():
|
||||
for font_file, font_info in common_fonts.items():
|
||||
font_path = font_dir / font_file
|
||||
if font_path.exists():
|
||||
system_fonts.append({
|
||||
'family': font_info['family'],
|
||||
'display_name': font_info['display_name'],
|
||||
'path': str(font_path),
|
||||
'source': 'system',
|
||||
'weight': 400
|
||||
})
|
||||
|
||||
elif system == 'Linux':
|
||||
font_dirs = [
|
||||
Path("/usr/share/fonts/truetype"),
|
||||
Path("/usr/share/fonts/TTF"),
|
||||
Path.home() / ".fonts"
|
||||
]
|
||||
|
||||
for font_dir in font_dirs:
|
||||
if font_dir.exists():
|
||||
# 查找常见字体
|
||||
for font_file in font_dir.rglob("*.ttf"):
|
||||
font_name = font_file.stem.lower()
|
||||
if 'dejavu' in font_name or 'liberation' in font_name:
|
||||
system_fonts.append({
|
||||
'family': font_file.stem,
|
||||
'display_name': font_file.stem,
|
||||
'path': str(font_file),
|
||||
'source': 'system',
|
||||
'weight': 400
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"扫描系统字体失败: {e}")
|
||||
|
||||
# 去重(按family和weight)
|
||||
seen = set()
|
||||
unique_fonts = []
|
||||
for font in system_fonts:
|
||||
key = (font['family'], font.get('weight', 400))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
unique_fonts.append(font)
|
||||
|
||||
self._system_fonts_cache = unique_fonts
|
||||
return unique_fonts
|
||||
|
||||
def find_font(self, font_family: str, font_weight: int = 400) -> Optional[str]:
|
||||
"""查找字体文件(按优先级:预置 > ziti > 系统 > 默认)
|
||||
|
||||
Args:
|
||||
font_family: 字体名称
|
||||
font_weight: 字体粗细 (100-1000)
|
||||
|
||||
Returns:
|
||||
字体文件路径,如果找不到返回None
|
||||
"""
|
||||
# 1. 优先查找预置字体
|
||||
bundled_font = self._find_bundled_font(font_family, font_weight)
|
||||
if bundled_font:
|
||||
return bundled_font
|
||||
|
||||
# 2. 查找ziti目录字体
|
||||
ziti_font = self._find_ziti_font(font_family, font_weight)
|
||||
if ziti_font:
|
||||
return ziti_font
|
||||
|
||||
# 3. 查找系统字体
|
||||
system_font = self._find_system_font(font_family, font_weight)
|
||||
if system_font:
|
||||
return system_font
|
||||
|
||||
# 4. 返回None(使用默认字体)
|
||||
return None
|
||||
|
||||
def _find_bundled_font(self, font_family: str, font_weight: int) -> Optional[str]:
|
||||
"""查找预置字体"""
|
||||
if not self.bundled_fonts_dir.exists():
|
||||
return None
|
||||
|
||||
bundled_fonts = self.get_bundled_fonts()
|
||||
|
||||
# 查找匹配的字体
|
||||
for font_info in bundled_fonts:
|
||||
if font_info.get('family') == font_family:
|
||||
font_dir = self.bundled_fonts_dir / font_info.get('path', '')
|
||||
|
||||
# 根据font_weight选择变体
|
||||
variant = self._get_variant_for_weight(font_weight)
|
||||
|
||||
# 查找字体文件(支持TTF和OTF格式)
|
||||
for ext in [".ttf", ".otf"]:
|
||||
variant_file = font_dir / f"{variant}{ext}"
|
||||
if variant_file.exists():
|
||||
return str(variant_file)
|
||||
|
||||
# 如果找不到指定变体,尝试查找Regular
|
||||
for ext in [".ttf", ".otf"]:
|
||||
regular_file = font_dir / f"Regular{ext}"
|
||||
if regular_file.exists():
|
||||
return str(regular_file)
|
||||
|
||||
return None
|
||||
|
||||
def _find_ziti_font(self, font_family: str, font_weight: int) -> Optional[str]:
|
||||
"""查找ziti目录字体(支持模糊匹配和名称映射)"""
|
||||
if not font_family:
|
||||
return None
|
||||
|
||||
ziti_fonts = self.scan_ziti_fonts()
|
||||
|
||||
if not ziti_fonts:
|
||||
logger.debug(f"ziti目录中没有字体文件,无法查找: {font_family}")
|
||||
return None
|
||||
|
||||
# 字体名称映射表(与前端保持一致)
|
||||
# 映射:显示名称/别名 -> 文件名(不包含扩展名)
|
||||
font_name_map = {
|
||||
'Dymon手写体': 'Dymon-ShouXieTi',
|
||||
'Dymon-ShouXieTi': 'Dymon-ShouXieTi',
|
||||
'猫啃杂糅体': 'MaokenAssortedSans',
|
||||
'MaokenAssortedSans': 'MaokenAssortedSans',
|
||||
'猫啃杂糅体 Lite': 'MaokenAssortedSans-Lite',
|
||||
'MaokenAssortedSans-Lite': 'MaokenAssortedSans-Lite',
|
||||
'Murecho 黑体': 'Murecho-Black',
|
||||
'Murecho-Black': 'Murecho-Black',
|
||||
'Murecho 粗体': 'Murecho-Bold',
|
||||
'Murecho-Bold': 'Murecho-Bold',
|
||||
'墨趣古风体': '墨趣古风体',
|
||||
'平方张亚玲黑方体': '平方张亚玲黑方体',
|
||||
'胡晓波骚包体': '胡晓波骚包体2.0',
|
||||
'胡晓波骚包体2.0': '胡晓波骚包体2.0',
|
||||
}
|
||||
|
||||
# 标准化字体名称(移除空格、统一大小写)
|
||||
def normalize_name(name: str) -> str:
|
||||
return name.replace(' ', '').replace('-', '').replace('_', '').lower()
|
||||
|
||||
# 尝试通过映射表转换字体名称
|
||||
mapped_font_family = font_name_map.get(font_family, font_family)
|
||||
normalized_target = normalize_name(mapped_font_family)
|
||||
|
||||
logger.debug(f"查找ziti字体: '{font_family}' -> 映射: '{mapped_font_family}' -> 标准化: '{normalized_target}', 权重: {font_weight}")
|
||||
|
||||
# 查找匹配的字体
|
||||
best_match = None
|
||||
min_weight_diff = float('inf')
|
||||
|
||||
for font_info in ziti_fonts:
|
||||
family = font_info.get('family', '')
|
||||
display_name = font_info.get('display_name', '')
|
||||
path = font_info.get('path', '')
|
||||
|
||||
# 获取文件名(不含扩展名)用于匹配
|
||||
file_stem = None
|
||||
if path:
|
||||
from pathlib import Path
|
||||
file_stem = Path(path).stem
|
||||
|
||||
# 多种匹配方式:精确匹配、模糊匹配、文件名匹配
|
||||
is_match = False
|
||||
match_type = None
|
||||
|
||||
# 1. 精确匹配
|
||||
if family == font_family or family == mapped_font_family:
|
||||
is_match = True
|
||||
match_type = f"精确匹配(family={family})"
|
||||
elif display_name == font_family or display_name == mapped_font_family:
|
||||
is_match = True
|
||||
match_type = f"精确匹配(display_name={display_name})"
|
||||
# 2. 模糊匹配(忽略大小写和空格)
|
||||
elif normalize_name(family) == normalized_target:
|
||||
is_match = True
|
||||
match_type = f"模糊匹配(family={family})"
|
||||
elif normalize_name(display_name) == normalized_target:
|
||||
is_match = True
|
||||
match_type = f"模糊匹配(display_name={display_name})"
|
||||
# 3. 文件名匹配
|
||||
elif file_stem and normalize_name(file_stem) == normalized_target:
|
||||
is_match = True
|
||||
match_type = f"文件名匹配(file_stem={file_stem})"
|
||||
|
||||
if is_match:
|
||||
font_weight_info = font_info.get('weight', 400)
|
||||
weight_diff = abs(font_weight_info - font_weight)
|
||||
|
||||
logger.debug(f"找到匹配字体: {match_type}, 路径: {path}, 权重: {font_weight_info}, 权重差: {weight_diff}")
|
||||
|
||||
# 找到权重最接近的字体
|
||||
if weight_diff < min_weight_diff:
|
||||
min_weight_diff = weight_diff
|
||||
best_match = path
|
||||
|
||||
if best_match:
|
||||
logger.info(f"找到ziti字体 '{font_family}': {best_match}")
|
||||
else:
|
||||
logger.warning(f"未找到ziti字体 '{font_family}',已扫描{len(ziti_fonts)}个字体文件")
|
||||
|
||||
return best_match
|
||||
|
||||
def _find_system_font(self, font_family: str, font_weight: int) -> Optional[str]:
|
||||
"""查找系统字体"""
|
||||
system_fonts = self.scan_system_fonts()
|
||||
|
||||
# 查找匹配的字体
|
||||
for font_info in system_fonts:
|
||||
if font_info.get('family') == font_family:
|
||||
font_weight_info = font_info.get('weight', 400)
|
||||
|
||||
# 如果权重匹配(或接近),返回字体路径
|
||||
if abs(font_weight_info - font_weight) <= 100:
|
||||
return font_info.get('path')
|
||||
|
||||
return None
|
||||
|
||||
def _get_variant_for_weight(self, font_weight: int) -> str:
|
||||
"""根据字体权重获取变体名称"""
|
||||
if font_weight >= 700:
|
||||
return "Bold"
|
||||
elif font_weight >= 600:
|
||||
return "SemiBold"
|
||||
elif font_weight >= 500:
|
||||
return "Medium"
|
||||
else:
|
||||
return "Regular"
|
||||
|
||||
def get_all_available_fonts(self) -> List[Dict]:
|
||||
"""获取所有可用字体(合并预置、ziti和系统字体)"""
|
||||
all_fonts = []
|
||||
|
||||
# 添加预置字体
|
||||
bundled_fonts = self.get_bundled_fonts()
|
||||
for font_info in bundled_fonts:
|
||||
all_fonts.append({
|
||||
'value': font_info.get('family'),
|
||||
'label': font_info.get('display_name', font_info.get('family')),
|
||||
'source': 'bundled',
|
||||
'category': font_info.get('category', 'sans-serif'),
|
||||
'languages': font_info.get('languages', [])
|
||||
})
|
||||
|
||||
# 添加ziti目录字体
|
||||
ziti_fonts = self.scan_ziti_fonts()
|
||||
seen_families = {f.get('family') for f in bundled_fonts}
|
||||
|
||||
for font_info in ziti_fonts:
|
||||
family = font_info.get('family')
|
||||
if family not in seen_families:
|
||||
all_fonts.append({
|
||||
'value': family,
|
||||
'label': font_info.get('display_name', family),
|
||||
'source': 'ziti',
|
||||
'category': font_info.get('category', 'sans-serif'),
|
||||
'languages': font_info.get('languages', []),
|
||||
'path': font_info.get('path')
|
||||
})
|
||||
seen_families.add(family)
|
||||
|
||||
# 添加系统字体(去重)
|
||||
system_fonts = self.scan_system_fonts()
|
||||
|
||||
for font_info in system_fonts:
|
||||
family = font_info.get('family')
|
||||
if family not in seen_families:
|
||||
all_fonts.append({
|
||||
'value': family,
|
||||
'label': font_info.get('display_name', family),
|
||||
'source': 'system',
|
||||
'category': 'sans-serif',
|
||||
'languages': []
|
||||
})
|
||||
seen_families.add(family)
|
||||
|
||||
return all_fonts
|
||||
|
||||
|
||||
# 全局字体管理器实例
|
||||
_font_manager: Optional[FontManager] = None
|
||||
|
||||
|
||||
def get_font_manager() -> FontManager:
|
||||
"""获取全局字体管理器实例"""
|
||||
global _font_manager
|
||||
if _font_manager is None:
|
||||
_font_manager = FontManager()
|
||||
return _font_manager
|
||||
|
||||
1050
src-tauri/resources/cover-python/modules/segment.py
Normal file
1050
src-tauri/resources/cover-python/modules/segment.py
Normal file
File diff suppressed because it is too large
Load Diff
312
src-tauri/resources/cover-python/modules/utils.py
Normal file
312
src-tauri/resources/cover-python/modules/utils.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
工具函数模块
|
||||
提供字幕封面生成过程中需要的通用工具函数
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Tuple, Optional, List, Dict, Any
|
||||
|
||||
# 尝试导入loguru,如果不可用则使用print作为替代
|
||||
try:
|
||||
from loguru import logger
|
||||
except ImportError:
|
||||
# Fallback logger that uses print
|
||||
class logger:
|
||||
@staticmethod
|
||||
def info(msg, *args, **kwargs):
|
||||
print(f"[INFO] {msg}", file=sys.stderr)
|
||||
@staticmethod
|
||||
def warning(msg, *args, **kwargs):
|
||||
print(f"[WARN] {msg}", file=sys.stderr)
|
||||
@staticmethod
|
||||
def error(msg, *args, **kwargs):
|
||||
print(f"[ERROR] {msg}", file=sys.stderr)
|
||||
@staticmethod
|
||||
def debug(msg, *args, **kwargs):
|
||||
print(f"[DEBUG] {msg}", file=sys.stderr)
|
||||
|
||||
# 尝试导入图像处理库
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
CV2_AVAILABLE = True
|
||||
except ImportError:
|
||||
CV2_AVAILABLE = False
|
||||
logger.warning("opencv-python不可用,图像处理功能受限")
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
PIL_AVAILABLE = True
|
||||
except ImportError:
|
||||
PIL_AVAILABLE = False
|
||||
logger.warning("PIL不可用,图像处理功能受限")
|
||||
|
||||
|
||||
def ensure_dir(path: str) -> Path:
|
||||
"""确保目录存在"""
|
||||
path_obj = Path(path)
|
||||
path_obj.mkdir(parents=True, exist_ok=True)
|
||||
return path_obj
|
||||
|
||||
|
||||
def get_video_info(video_path: str) -> Dict[str, Any]:
|
||||
"""获取视频信息"""
|
||||
if not CV2_AVAILABLE:
|
||||
# 如果opencv不可用,返回基本信息
|
||||
try:
|
||||
import os
|
||||
file_size = os.path.getsize(video_path)
|
||||
return {
|
||||
'fps': 30, # 默认值
|
||||
'frame_count': 0,
|
||||
'width': 1920, # 默认值
|
||||
'height': 1080, # 默认值
|
||||
'duration': 0, # 未知
|
||||
'path': video_path,
|
||||
'file_size': file_size,
|
||||
'note': '视频信息不完整,opencv不可用'
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取基本视频信息失败: {e}")
|
||||
raise
|
||||
|
||||
try:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
raise ValueError(f"无法打开视频文件: {video_path}")
|
||||
|
||||
# 获取视频属性
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
|
||||
height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
|
||||
duration = frame_count / fps if fps > 0 else 0
|
||||
|
||||
cap.release()
|
||||
|
||||
return {
|
||||
'fps': fps,
|
||||
'frame_count': frame_count,
|
||||
'width': width,
|
||||
'height': height,
|
||||
'duration': duration,
|
||||
'path': video_path
|
||||
}
|
||||
except Exception as e:
|
||||
logger.error(f"获取视频信息失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def extract_frame_at_time(video_path: str, time_seconds: float):
|
||||
"""在指定时间抽取视频帧"""
|
||||
if not CV2_AVAILABLE:
|
||||
logger.warning("opencv不可用,无法抽取视频帧")
|
||||
# 返回一个占位符
|
||||
return None
|
||||
|
||||
try:
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
raise ValueError(f"无法打开视频文件: {video_path}")
|
||||
|
||||
# 设置帧位置
|
||||
fps = cap.get(cv2.CAP_PROP_FPS)
|
||||
frame_number = int(time_seconds * fps)
|
||||
cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
|
||||
|
||||
ret, frame = cap.read()
|
||||
cap.release()
|
||||
|
||||
if not ret:
|
||||
raise ValueError(f"无法读取帧: {frame_number}")
|
||||
|
||||
return frame
|
||||
except Exception as e:
|
||||
logger.error(f"抽帧失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def save_image(image, output_path: str, quality: int = 95) -> None:
|
||||
"""保存图像
|
||||
|
||||
如果输出路径是PNG格式,会保留alpha通道(透明背景)
|
||||
如果是JPEG格式,会转换为RGB格式
|
||||
"""
|
||||
try:
|
||||
ensure_dir(os.path.dirname(output_path))
|
||||
|
||||
is_png = output_path.lower().endswith('.png')
|
||||
|
||||
if CV2_AVAILABLE and isinstance(image, np.ndarray):
|
||||
# 使用OpenCV保存
|
||||
if is_png and image.shape[2] == 4:
|
||||
# PNG格式且有alpha通道,保存为BGRA(OpenCV使用BGR格式)
|
||||
# 确保图像是BGRA格式(B, G, R, A)
|
||||
# 如果输入是RGBA(R, G, B, A),需要转换为BGRA
|
||||
if image.dtype != np.uint8:
|
||||
image = image.astype(np.uint8)
|
||||
# OpenCV的imwrite会自动处理BGRA格式
|
||||
success = cv2.imwrite(output_path, image, [cv2.IMWRITE_PNG_COMPRESSION, 3])
|
||||
elif is_png:
|
||||
# PNG格式但没有alpha通道,转换为RGB
|
||||
if image.shape[2] == 3:
|
||||
success = cv2.imwrite(output_path, image, [cv2.IMWRITE_PNG_COMPRESSION, 3])
|
||||
else:
|
||||
# 如果是单通道,转换为3通道
|
||||
if len(image.shape) == 2:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
success = cv2.imwrite(output_path, image, [cv2.IMWRITE_PNG_COMPRESSION, 3])
|
||||
else:
|
||||
# JPEG格式,确保是3通道BGR
|
||||
if image.shape[2] == 4:
|
||||
# 有alpha通道,先合成到白色背景
|
||||
bgr = image[:, :, :3]
|
||||
alpha = image[:, :, 3:4] / 255.0
|
||||
white_bg = np.ones_like(bgr) * 255
|
||||
image = (bgr * alpha + white_bg * (1 - alpha)).astype(np.uint8)
|
||||
elif len(image.shape) == 2:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
success = cv2.imwrite(output_path, image, [cv2.IMWRITE_JPEG_QUALITY, quality])
|
||||
|
||||
if not success:
|
||||
raise ValueError(f"保存图像失败: {output_path}")
|
||||
elif PIL_AVAILABLE and hasattr(image, 'save'):
|
||||
# 使用PIL保存
|
||||
if is_png:
|
||||
image.save(output_path, 'PNG', compress_level=3)
|
||||
else:
|
||||
# JPEG格式,确保是RGB
|
||||
if image.mode == 'RGBA':
|
||||
# 合成到白色背景
|
||||
rgb = Image.new('RGB', image.size, (255, 255, 255))
|
||||
rgb.paste(image, mask=image.split()[3])
|
||||
image = rgb
|
||||
image.save(output_path, 'JPEG', quality=quality)
|
||||
else:
|
||||
raise ValueError("没有可用的图像保存方法")
|
||||
|
||||
logger.info(f"图像已保存: {output_path}")
|
||||
except Exception as e:
|
||||
logger.error(f"保存图像失败: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def format_time(seconds: float) -> str:
|
||||
"""格式化时间为 HH:MM:SS.mmm"""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
secs = int(seconds % 60)
|
||||
milliseconds = int((seconds % 1) * 1000)
|
||||
|
||||
return "02d"
|
||||
|
||||
|
||||
def create_temp_dir(prefix: str = "subtitle_cover_") -> str:
|
||||
"""创建临时目录"""
|
||||
import tempfile
|
||||
temp_dir = tempfile.mkdtemp(prefix=prefix)
|
||||
logger.info(f"创建临时目录: {temp_dir}")
|
||||
return temp_dir
|
||||
|
||||
|
||||
def cleanup_temp_dir(temp_dir: str) -> None:
|
||||
"""清理临时目录"""
|
||||
try:
|
||||
import shutil
|
||||
if os.path.exists(temp_dir):
|
||||
shutil.rmtree(temp_dir)
|
||||
logger.info(f"清理临时目录: {temp_dir}")
|
||||
except Exception as e:
|
||||
logger.warning(f"清理临时目录失败: {e}")
|
||||
|
||||
|
||||
def validate_file_exists(file_path: str, file_type: str = "文件") -> None:
|
||||
"""验证文件是否存在"""
|
||||
if not os.path.exists(file_path):
|
||||
raise FileNotFoundError(f"{file_type}不存在: {file_path}")
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
raise ValueError(f"{file_type}不是文件: {file_path}")
|
||||
|
||||
|
||||
def get_file_size_mb(file_path: str) -> float:
|
||||
"""获取文件大小(MB)"""
|
||||
size_bytes = os.path.getsize(file_path)
|
||||
return size_bytes / (1024 * 1024)
|
||||
|
||||
|
||||
def calculate_aspect_ratio(width: int, height: int) -> float:
|
||||
"""计算宽高比"""
|
||||
return width / height if height > 0 else 0
|
||||
|
||||
|
||||
def resize_image(image: np.ndarray, target_width: int, target_height: int,
|
||||
keep_aspect_ratio: bool = True) -> np.ndarray:
|
||||
"""调整图像大小"""
|
||||
if keep_aspect_ratio:
|
||||
# 保持宽高比
|
||||
h, w = image.shape[:2]
|
||||
aspect_ratio = w / h
|
||||
|
||||
if target_width / target_height > aspect_ratio:
|
||||
# 目标更宽,以高度为准
|
||||
new_width = int(target_height * aspect_ratio)
|
||||
new_height = target_height
|
||||
else:
|
||||
# 目标更高,以宽度为准
|
||||
new_width = target_width
|
||||
new_height = int(target_width / aspect_ratio)
|
||||
|
||||
resized = cv2.resize(image, (new_width, new_height))
|
||||
else:
|
||||
# 不保持宽高比,直接缩放
|
||||
resized = cv2.resize(image, (target_width, target_height))
|
||||
|
||||
return resized
|
||||
|
||||
|
||||
def blend_images(background: np.ndarray, foreground: np.ndarray,
|
||||
position: Tuple[int, int] = (0, 0)) -> np.ndarray:
|
||||
"""将前景图像合成到背景图像上"""
|
||||
x, y = position
|
||||
h, w = foreground.shape[:2]
|
||||
|
||||
# 确保位置不超出边界
|
||||
bg_h, bg_w = background.shape[:2]
|
||||
x = max(0, min(x, bg_w - w))
|
||||
y = max(0, min(y, bg_h - h))
|
||||
|
||||
# 创建ROI
|
||||
roi = background[y:y+h, x:x+w]
|
||||
|
||||
# 如果前景有alpha通道,进行透明合成
|
||||
if foreground.shape[2] == 4:
|
||||
# 分离颜色和alpha通道
|
||||
foreground_rgb = foreground[:, :, :3]
|
||||
alpha = foreground[:, :, 3] / 255.0
|
||||
|
||||
# 扩展alpha到3通道
|
||||
alpha = np.stack([alpha] * 3, axis=2)
|
||||
|
||||
# 透明合成
|
||||
blended = foreground_rgb * alpha + roi * (1 - alpha)
|
||||
background[y:y+h, x:x+w] = blended.astype(np.uint8)
|
||||
else:
|
||||
# 直接覆盖
|
||||
background[y:y+h, x:x+w] = foreground
|
||||
|
||||
return background
|
||||
|
||||
|
||||
def apply_blur(image: np.ndarray, kernel_size: int = 15) -> np.ndarray:
|
||||
"""应用模糊效果"""
|
||||
return cv2.GaussianBlur(image, (kernel_size, kernel_size), 0)
|
||||
|
||||
|
||||
def apply_gradient_overlay(image: np.ndarray, color: Tuple[int, int, int],
|
||||
opacity: float = 0.5) -> np.ndarray:
|
||||
"""应用渐变覆盖"""
|
||||
overlay = np.full_like(image, color, dtype=np.uint8)
|
||||
return cv2.addWeighted(image, 1 - opacity, overlay, opacity, 0)
|
||||
Submodule src-tauri/resources/resources-bundles/InfiniteTalk deleted from fd63149725
Binary file not shown.
@@ -1,96 +0,0 @@
|
||||
Copyright (c) 2022-11-02, ZERO子 (https://github.com/Skr-ZERO),
|
||||
with Reserved Font Name “Assorted”“什锦”.
|
||||
|
||||
Copyright (c) 2022-11-01, Umihotaru (https://umihotaru.work/),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -1,435 +0,0 @@
|
||||
OFL FAQ - Frequently Asked Questions about the SIL Open Font License (OFL)
|
||||
Version 1.1-update6 - December 2020
|
||||
The OFL FAQ is copyright (c) 2005-2020 SIL International.
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
(See http://scripts.sil.org/OFL for updates)
|
||||
|
||||
|
||||
CONTENTS OF THIS FAQ
|
||||
1 USING AND DISTRIBUTING FONTS LICENSED UNDER THE OFL
|
||||
2 USING OFL FONTS FOR WEB PAGES AND ONLINE WEB FONT SERVICES
|
||||
3 MODIFYING OFL-LICENSED FONTS
|
||||
4 LICENSING YOUR ORIGINAL FONTS UNDER THE OFL
|
||||
5 CHOOSING RESERVED FONT NAMES
|
||||
6 ABOUT THE FONTLOG
|
||||
7 MAKING CONTRIBUTIONS TO OFL PROJECTS
|
||||
8 ABOUT THE LICENSE ITSELF
|
||||
9 ABOUT SIL INTERNATIONAL
|
||||
APPENDIX A - FONTLOG EXAMPLE
|
||||
|
||||
1 USING AND DISTRIBUTING FONTS LICENSED UNDER THE OFL
|
||||
|
||||
1.1 Can I use the fonts for a book or other print publication, to create logos or other graphics or even to manufacture objects based on their outlines?
|
||||
Yes. You are very welcome to do so. Authors of fonts released under the OFL allow you to use their font software as such for any kind of design work. No additional license or permission is required, unlike with some other licenses. Some examples of these uses are: logos, posters, business cards, stationery, video titling, signage, t-shirts, personalised fabric, 3D-printed/laser-cut shapes, sculptures, rubber stamps, cookie cutters and lead type.
|
||||
|
||||
1.1.1 Does that restrict the license or distribution of that artwork?
|
||||
No. You remain the author and copyright holder of that newly derived graphic or object. You are simply using an open font in the design process. It is only when you redistribute, bundle or modify the font itself that other conditions of the license have to be respected (see below for more details).
|
||||
|
||||
1.1.2 Is any kind of acknowledgement required?
|
||||
No. Font authors may appreciate being mentioned in your artwork's acknowledgements alongside the name of the font, possibly with a link to their website, but that is not required.
|
||||
|
||||
1.2 Can the fonts be included with Free/Libre and Open Source Software collections such as GNU/Linux and BSD distributions and repositories?
|
||||
Yes! Fonts licensed under the OFL can be freely included alongside other software under FLOSS (Free/Libre and Open Source Software) licenses. Since fonts are typically aggregated with, not merged into, existing software, there is little need to be concerned about incompatibility with existing software licenses. You may also repackage the fonts and the accompanying components in a .rpm or .deb package (or other similar package formats or installers) and include them in distribution CD/DVDs and online repositories. (Also see section 5.9 about rebuilding from source.)
|
||||
|
||||
1.3 I want to distribute the fonts with my program. Does this mean my program also has to be Free/Libre and Open Source Software?
|
||||
No. Only the portions based on the Font Software are required to be released under the OFL. The intent of the license is to allow aggregation or bundling with software under restricted licensing as well.
|
||||
|
||||
1.4 Can I sell a software package that includes these fonts?
|
||||
Yes, you can do this with both the Original Version and a Modified Version of the fonts. Examples of bundling made possible by the OFL would include: word processors, design and publishing applications, training and educational software, games and entertainment software, mobile device applications, etc.
|
||||
|
||||
1.5 Can I include the fonts on a CD of freeware or commercial fonts?
|
||||
Yes, as long some other font or software is also on the disk, so the OFL font is not sold by itself.
|
||||
|
||||
1.6 Why won't the OFL let me sell the fonts alone?
|
||||
The intent is to keep people from making money by simply redistributing the fonts. The only people who ought to profit directly from the fonts should be the original authors, and those authors have kindly given up potential direct income to distribute their fonts under the OFL. Please honour and respect their contribution!
|
||||
|
||||
1.7 What about sharing OFL fonts with friends on a CD, DVD or USB stick?
|
||||
You are very welcome to share open fonts with friends, family and colleagues through removable media. Just remember to include the full font package, including any copyright notices and licensing information as available in OFL.txt. In the case where you sell the font, it has to come bundled with software.
|
||||
|
||||
1.8 Can I host the fonts on a web site for others to use?
|
||||
Yes, as long as you make the full font package available. In most cases it may be best to point users to the main site that distributes the Original Version so they always get the most recent stable and complete version. See also discussion of web fonts in Section 2.
|
||||
|
||||
1.9 Can I host the fonts on a server for use over our internal network?
|
||||
Yes. If the fonts are transferred from the server to the client computer by means that allow them to be used even if the computer is no longer attached to the network, the full package (copyright notices, licensing information, etc.) should be included.
|
||||
|
||||
1.10 Does the full OFL license text always need to accompany the font?
|
||||
The only situation in which an OFL font can be distributed without the text of the OFL (either in a separate file or in font metadata), is when a font is embedded in a document or bundled within a program. In the case of metadata included within a font, it is legally sufficient to include only a link to the text of the OFL on http://scripts.sil.org/OFL, but we strongly recommend against this. Most modern font formats include metadata fields that will accept the full OFL text, and full inclusion increases the likelihood that users will understand and properly apply the license.
|
||||
|
||||
1.11 What do you mean by 'embedding'? How does that differ from other means of distribution?
|
||||
By 'embedding' we mean inclusion of the font in a document or file in a way that makes extraction (and redistribution) difficult or clearly discouraged. In many cases the names of embedded fonts might also not be obvious to those reading the document, the font data format might be altered, and only a subset of the font - only the glyphs required for the text - might be included. Any other means of delivering a font to another person is considered 'distribution', and needs to be accompanied by any copyright notices and licensing information available in OFL.txt.
|
||||
|
||||
1.12 So can I embed OFL fonts in my document?
|
||||
Yes, either in full or a subset. The restrictions regarding font modification and redistribution do not apply, as the font is not intended for use outside the document.
|
||||
|
||||
1.13 Does embedding alter the license of the document itself?
|
||||
No. Referencing or embedding an OFL font in any document does not change the license of the document itself. The requirement for fonts to remain under the OFL does not apply to any document created using the fonts and their derivatives. Similarly, creating any kind of graphic using a font under OFL does not make the resulting artwork subject to the OFL.
|
||||
|
||||
1.14 If OFL fonts are extracted from a document in which they are embedded (such as a PDF file), what can be done with them? Is this a risk to author(s)?
|
||||
The few utilities that can extract fonts embedded in a PDF will typically output limited amounts of outlines - not a complete font. To create a working font from this method is much more difficult and time consuming than finding the source of the original OFL font. So there is little chance that an OFL font would be extracted and redistributed inappropriately through this method. Even so, copyright laws address any misrepresentation of authorship. All Font Software released under the OFL and marked as such by the author(s) is intended to remain under this license regardless of the distribution method, and cannot be redistributed under any other license. We strongly discourage any font extraction - we recommend directly using the font sources instead - but if you extract font outlines from a document, please be considerate: respect the work of the author(s) and the licensing model.
|
||||
|
||||
1.15 What about distributing fonts with a document? Within a compressed folder structure? Is it distribution, bundling or embedding?
|
||||
Certain document formats may allow the inclusion of an unmodified font within their file structure which may consist of a compressed folder containing the various resources forming the document (such as pictures and thumbnails). Including fonts within such a structure is understood as being different from embedding but rather similar to bundling (or mere aggregation) which the license explicitly allows. In this case the font is conveyed unchanged whereas embedding a font usually transforms it from the original format. The OFL does not allow anyone to extract the font from such a structure to then redistribute it under another license. The explicit permission to redistribute and embed does not cancel the requirement for the Font Software to remain under the license chosen by its author(s). Even if the font travels inside the document as one of its assets, it should not lose its authorship information and licensing.
|
||||
|
||||
1.16 What about ebooks shipping with open fonts?
|
||||
The requirements differ depending on whether the fonts are linked, embedded or distributed (bundled or aggregated). Some ebook formats use web technologies to do font linking via @font-face, others are designed for font embedding, some use fonts distributed with the document or reading software, and a few rely solely on the fonts already present on the target system. The license requirements depend on the type of inclusion as discussed in 1.15.
|
||||
|
||||
1.17 Can Font Software released under the OFL be subject to URL-based access restrictions methods or DRM (Digital Rights Management) mechanisms?
|
||||
Yes, but these issues are out-of-scope for the OFL. The license itself neither encourages their use nor prohibits them since such mechanisms are not implemented in the components of the Font Software but through external software. Such restrictions are put in place for many different purposes corresponding to various usage scenarios. One common example is to limit potentially dangerous cross-site scripting attacks. However, in the spirit of libre/open fonts and unrestricted writing systems, we strongly encourage open sharing and reuse of OFL fonts, and the establishment of an environment where such restrictions are unnecessary. Note that whether you wish to use such mechanisms or you prefer not to, you must still abide by the rules set forth by the OFL when using fonts released by their authors under this license. Derivative fonts must be licensed under the OFL, even if they are part of a service for which you charge fees and/or for which access to source code is restricted. You may not sell the fonts on their own - they must be part of a larger software package, bundle or subscription plan. For example, even if the OFL font is distributed in a software package or via an online service using a DRM mechanism, the user would still have the right to extract that font, use, study, modify and redistribute it under the OFL.
|
||||
|
||||
1.18 I've come across a font released under the OFL. How can I easily get more information about the Original Version? How can I know where it stands compared to the Original Version or other Modified Versions?
|
||||
Consult the copyright statement(s) in the license for ways to contact the original authors. Consult the FONTLOG (see section 6 for more details and examples) for information on how the font differs from the Original Version, and get in touch with the various contributors via the information in the acknowledgement section. Please consider using the Original Versions of the fonts whenever possible.
|
||||
|
||||
1.19 What do you mean in condition 4 of the OFL's permissions and conditions? Can you provide examples of abusive promotion / endorsement / advertisement vs. normal acknowledgement?
|
||||
The intent is that the goodwill and reputation of the author(s) should not be used in a way that makes it sound like the original author(s) endorse or approve of a specific Modified Version or software bundle. For example, it would not be right to advertise a word processor by naming the author(s) in a listing of software features, or to promote a Modified Version on a web site by saying "designed by ...". However, it would be appropriate to acknowledge the author(s) if your software package has a list of people who deserve thanks. We realize that this can seem to be a grey area, but the standard used to judge an acknowledgement is that if the acknowledgement benefits the author(s) it is allowed, but if it primarily benefits other parties, or could reflect poorly on the author(s), then it is not.
|
||||
|
||||
1.20 I'm writing a small app for mobile platforms, do I need to include the whole package?
|
||||
If you bundle a font under the OFL with your mobile app you must comply with the terms of the license. At a minimum you must include the copyright statement, the license notice and the license text. A mention of this information in your About box or Changelog, with a link to where the font package is from, is good practice, and the extra space needed to carry these items is very small. You do not, however, need to include the full contents of the font package - only the fonts you use and the copyright and license that apply to them. For example, if you only use the regular weight in your app, you do not need to include the italic and bold versions.
|
||||
|
||||
1.21 What about including OFL fonts by default in my firmware or dedicated operating system?
|
||||
Many such systems are restricted and turned into appliances so that users cannot study or modify them. Using open fonts to increase quality and language coverage is a great idea, but you need to be aware that if there is a way for users to extract fonts you cannot legally prevent them from doing that. The fonts themselves, including any changes you make to them, must be distributed under the OFL even if your firmware has a more restrictive license. If you do transform the fonts and change their formats when you include them in your firmware you must respect any names reserved by the font authors via the RFN mechanism and pick your own font name. Alternatively if you directly add a font under the OFL to the font folder of your firmware without modifying or optimizing it you are simply bundling the font like with any other software collection, and do not need to make any further changes.
|
||||
|
||||
1.22 Can I make and publish CMS themes or templates that use OFL fonts? Can I include the fonts themselves in the themes or templates? Can I sell the whole package?
|
||||
Yes, you are very welcome to integrate open fonts into themes and templates for your preferred CMS and make them more widely available. Remember that you can only sell the fonts and your CMS add-on as part of a software bundle. (See 1.4 for details and examples about selling bundles).
|
||||
|
||||
1.23 Can OFL fonts be included in services that deliver fonts to the desktop from remote repositories? Even if they contain both OFL and non-OFL fonts?
|
||||
Yes. Some foundries have set up services to deliver fonts to subscribers directly to desktops from their online repositories; similarly, plugins are available to preview and use fonts directly in your design tool or publishing suite. These services may mix open and restricted fonts in the same channel, however they should make a clear distinction between them to users. These services should also not hinder users (such as through DRM or obfuscation mechanisms) from extracting and using the OFL fonts in other environments, or continuing to use OFL fonts after subscription terms have ended, as those uses are specifically allowed by the OFL.
|
||||
|
||||
1.24 Can services that provide or distribute OFL fonts restrict my use of them?
|
||||
No. The terms of use of such services cannot replace or restrict the terms of the OFL, as that would be the same as distributing the fonts under a different license, which is not allowed. You are still entitled to use, modify and redistribute them as the original authors have intended outside of the sole control of that particular distribution channel. Note, however, that the fonts provided by these services may differ from the Original Versions.
|
||||
|
||||
|
||||
2 USING OFL FONTS FOR WEBPAGES AND ONLINE WEB FONT SERVICES
|
||||
|
||||
NOTE: This section often refers to a separate paper on 'Web Fonts & RFNs'. This is available at http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
2.1 Can I make webpages using these fonts?
|
||||
Yes! Go ahead! Using CSS (Cascading Style Sheets) is recommended. Your three best options are:
|
||||
- referring directly in your stylesheet to open fonts which may be available on the user's system
|
||||
- providing links to download the full package of the font - either from your own website or from elsewhere - so users can install it themselves
|
||||
- using @font-face to distribute the font directly to browsers. This is recommended and explicitly allowed by the licensing model because it is distribution. The font file itself is distributed with other components of the webpage. It is not embedded in the webpage but referenced through a web address which will cause the browser to retrieve and use the corresponding font to render the webpage (see 1.11 and 1.15 for details related to embedding fonts into documents). As you take advantage of the @font-face cross-platform standard, be aware that web fonts are often tuned for a web environment and not intended for installation and use outside a browser. The reasons in favour of using web fonts are to allow design of dynamic text elements instead of static graphics, to make it easier for content to be localized and translated, indexed and searched, and all this with cross-platform open standards without depending on restricted extensions or plugins. You should check the CSS cascade (the order in which fonts are being called or delivered to your users) when testing.
|
||||
|
||||
2.2 Can I make and use WOFF (Web Open Font Format) versions of OFL fonts?
|
||||
Yes, but you need to be careful. A change in font format normally is considered modification, and Reserved Font Names (RFNs) cannot be used. Because of the design of the WOFF format, however, it is possible to create a WOFF version that is not considered modification, and so would not require a name change. You are allowed to create, use and distribute a WOFF version of an OFL font without changing the font name, but only if:
|
||||
|
||||
- the original font data remains unchanged except for WOFF compression, and
|
||||
- WOFF-specific metadata is either omitted altogether or present and includes, unaltered, the contents of all equivalent metadata in the original font.
|
||||
|
||||
If the original font data or metadata is changed, or the WOFF-specific metadata is incomplete, the font must be considered a Modified Version, the OFL restrictions would apply and the name of the font must be changed: any RFNs cannot be used and copyright notices and licensing information must be included and cannot be deleted or modified. You must come up with a unique name - we recommend one corresponding to your domain or your particular web application. Be aware that only the original author(s) can use RFNs. This is to prevent collisions between a derivative tuned to your audience and the original upstream version and so to reduce confusion.
|
||||
|
||||
Please note that most WOFF conversion tools and online services do not meet the two requirements listed above, and so their output must be considered a Modified Version. So be very careful and check to be sure that the tool or service you're using is compressing unchanged data and completely and accurately reflecting the original font metadata.
|
||||
|
||||
2.3 What about other web font formats such as EOT/EOTLite/CWT/etc.?
|
||||
In most cases these formats alter the original font data more than WOFF, and do not completely support appropriate metadata, so their use must be considered modification and RFNs may not be used. However, there may be certain formats or usage scenarios that may allow the use of RFNs. See http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
2.4 Can I make OFL fonts available through web font online services?
|
||||
Yes, you are welcome to include OFL fonts in online web font services as long as you properly meet all the conditions of the license. The origin and open status of the font should be clear among the other fonts you are hosting. Authorship, copyright notices and license information must be sufficiently visible to your users or subscribers so they know where the font comes from and the rights granted by the author(s). Make sure the font file contains the needed copyright notice(s) and licensing information in its metadata. Please double-check the accuracy of every field to prevent contradictory information. Other font formats, including EOT/EOTLite/CWT and superior alternatives like WOFF, already provide fields for this information. Remember that if you modify the font within your library or convert it to another format for any reason the OFL restrictions apply and you need to change the names accordingly. Please respect the author's wishes as expressed in the OFL and do not misrepresent original designers and their work. Don't lump quality open fonts together with dubious freeware or public domain fonts. Consider how you can best work with the original designers and foundries, support their efforts and generate goodwill that will benefit your service. (See 1.17 for details related to URL-based access restrictions methods or DRM mechanisms).
|
||||
|
||||
2.5 Some web font formats and services provide ways of "optimizing" the font for a particular website or web application; is that allowed?
|
||||
Yes, it is permitted, but remember that these optimized versions are Modified Versions and so must follow OFL requirements like appropriate renaming. Also you need to bear in mind the other important parameters beyond compression, speed and responsiveness: you need to consider the audience of your particular website or web application, as choosing some optimization parameters may turn out to be less than ideal for them. Subsetting by removing certain glyphs or features may seriously limit functionality of the font in various languages that your users expect. It may also introduce degradation of quality in the rendering or specific bugs on the various target platforms compared to the original font from upstream. In other words, remember that one person's optimized font may be another person's missing feature. Various advanced typographic features (OpenType, Graphite or AAT) are also available through CSS and may provide the desired effects without the need to modify the font.
|
||||
|
||||
2.6 Is subsetting a web font considered modification?
|
||||
Yes. Removing any parts of the font when delivering a web font to a browser, including unused glyphs and smart font code, is considered modification. This is permitted by the OFL but would not normally allow the use of RFNs. Some newer subsetting technologies may be able to subset in a way that allows users to effectively have access to the complete font, including smart font behaviour. See 2.8 and http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
2.7 Are there any situations in which a modified web font could use RFNs?
|
||||
Yes. If a web font is optimized only in ways that preserve Functional Equivalence (see 2.8), then it may use RFNs, as it reasonably represents the Original Version and respects the intentions of the author(s) and the main purposes of the RFN mechanism (avoids collisions, protects authors, minimizes support, encourages derivatives). However this is technically very difficult and often impractical, so a much better scenario is for the web font service or provider to sign a separate agreement with the author(s) that allows the use of RFNs for Modified Versions.
|
||||
|
||||
2.8 How do you know if an optimization to a web font preserves Functional Equivalence?
|
||||
Functional Equivalence is described in full in the 'Web fonts and RFNs' paper at http://scripts.sil.org/OFL_web_fonts_and_RFNs, in general, an optimized font is deemed to be Functionally Equivalent (FE) to the Original Version if it:
|
||||
|
||||
- Supports the same full character inventory. If a character can be properly displayed using the Original Version, then that same character, encoded correctly on a web page, will display properly.
|
||||
- Provides the same smart font behavior. Any dynamic shaping behavior that works with the Original Version should work when optimized, unless the browser or environment does not support it. There does not need to be guaranteed support in the client, but there should be no forced degradation of smart font or shaping behavior, such as the removal or obfuscation of OpenType, Graphite or AAT tables.
|
||||
- Presents text with no obvious degradation in visual quality. The lettershapes should be equally (or more) readable, within limits of the rendering platform.
|
||||
- Preserves original author, project and license metadata. At a minimum, this should include: Copyright and authorship; The license as stated in the Original Version, whether that is the full text of the OFL or a link to the web version; Any RFN declarations; Information already present in the font or documentation that points back to the Original Version, such as a link to the project or the author's website.
|
||||
|
||||
If an optimized font meets these requirements, and so is considered to be FE, then it's very likely that the original author would feel that the optimized font is a good and reasonable equivalent. If it falls short of any of these requirements, the optimized font does not reasonably represent the Original Version, and so should be considered to be a Modified Version. Like other Modified Versions, it would not be allowed to use any RFNs and you simply need to pick your own font name.
|
||||
|
||||
2.9 Isn't use of web fonts another form of embedding?
|
||||
No. Unlike embedded fonts in a PDF, web fonts are not an integrated part of the document itself. They are not specific to a single document and are often applied to thousands of documents around the world. The font data is not stored alongside the document data and often originates from a different location. The ease by which the web fonts used by a document may be identified and downloaded for desktop use demonstrates that they are philosophically and technically separate from the web pages that specify them. See http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
2.10 So would it be better to not use RFNs at all if you want your font to be distributed by a web fonts service?
|
||||
No. Although the OFL does not require authors to use RFNs, the RFN mechanism is an important part of the OFL model and completely compatible with web font services. If that web font service modifies the fonts, then the best solution is to sign a separate agreement for the use of any RFNs. It is perfectly valid for an author to not declare any RFNs, but before they do so they need to fully understand the benefits they are giving up, and the overall negative effect of allowing many different versions bearing the same name to be widely distributed. As a result, we don't generally recommend it.
|
||||
|
||||
2.11 What should an agreement for the use of RFNs say? Are there any examples?
|
||||
There is no prescribed format for this agreement, as legal systems vary, and no recommended examples. Authors may wish to add specific clauses to further restrict use, require author review of Modified Versions, establish user support mechanisms or provide terms for ending the agreement. Such agreements are usually not public, and apply only to the main parties. However, it would be very beneficial for web font services to clearly state when they have established such agreements, so that the public understands clearly that their service is operating appropriately.
|
||||
|
||||
See the separate paper on 'Web Fonts & RFNs' for in-depth discussion of issues related to the use of RFNs for web fonts. This is available at http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
|
||||
3 MODIFYING OFL-LICENSED FONTS
|
||||
|
||||
3.1 Can I change the fonts? Are there any limitations to what things I can and cannot change?
|
||||
You are allowed to change anything, as long as such changes do not violate the terms of the license. In other words, you are not allowed to remove the copyright statement(s) from the font, but you could put additional information into it that covers your contribution. See the placeholders in the OFL header template for recommendations on where to add your own statements. (Remember that, when authors have reserved names via the RFN mechanism, you need to change the internal names of the font to your own font name when making your modified version even if it is just a small change.)
|
||||
|
||||
3.2 I have a font that needs a few extra glyphs - can I take them from an OFL licensed font and copy them into mine?
|
||||
Yes, but if you distribute that font to others it must be under the OFL, and include the information mentioned in condition 2 of the license.
|
||||
|
||||
3.3 Can I charge people for my additional work? In other words, if I add a bunch of special glyphs or OpenType/Graphite/AAT code, can I sell the enhanced font?
|
||||
Not by itself. Derivative fonts must be released under the OFL and cannot be sold by themselves. It is permitted, however, to include them in a larger software package (such as text editors, office suites or operating systems), even if the larger package is sold. In that case, you are strongly encouraged, but not required, to also make that derived font easily and freely available outside of the larger package.
|
||||
|
||||
3.4 Can I pay someone to enhance the fonts for my use and distribution?
|
||||
Yes. This is a good way to fund the further development of the fonts. Keep in mind, however, that if the font is distributed to others it must be under the OFL. You won't be able to recover your investment by exclusively selling the font, but you will be making a valuable contribution to the community. Please remember how you have benefited from the contributions of others.
|
||||
|
||||
3.5 I need to make substantial revisions to the font to make it work with my program. It will be a lot of work, and a big investment, and I want to be sure that it can only be distributed with my program. Can I restrict its use?
|
||||
No. If you redistribute a Modified Version of the font it must be under the OFL. You may not restrict it in any way beyond what the OFL permits and requires. This is intended to ensure that all released improvements to the fonts become available to everyone. But you will likely get an edge over competitors by being the first to distribute a bundle with the enhancements. Again, please remember how you have benefited from the contributions of others.
|
||||
|
||||
3.6 Do I have to make any derivative fonts (including extended source files, build scripts, documentation, etc.) publicly available?
|
||||
No, but please consider sharing your improvements with others. You may find that you receive in return more than what you gave.
|
||||
|
||||
3.7 If a trademark is claimed in the OFL font, does that trademark need to remain in modified fonts?
|
||||
Yes. Any trademark notices must remain in any derivative fonts to respect trademark laws, but you may add any additional trademarks you claim, officially registered or not. For example if an OFL font called "Foo" contains a notice that "Foo is a trademark of Acme", then if you rename the font to "Bar" when creating a Modified Version, the new trademark notice could say "Foo is a trademark of Acme Inc. - Bar is a trademark of Roadrunner Technologies Ltd.". Trademarks work alongside the OFL and are not subject to the terms of the licensing agreement. The OFL does not grant any rights under trademark law. Bear in mind that trademark law varies from country to country and that there are no international trademark conventions as there are for copyright. You may need to significantly invest in registering and defending a trademark for it to remain valid in the countries you are interested in. This may be costly for an individual independent designer.
|
||||
|
||||
3.8 If I commit changes to a font (or publish a branch in a DVCS) as part of a public open source software project, do I have to change the internal font names?
|
||||
Only if there are declared RFNs. Making a public commit or publishing a public branch is effectively redistributing your modifications, so any change to the font will require that you do not use the RFNs. Even if there are no RFNs, it may be useful to change the name or add a suffix indicating that a particular version of the font is still in development and not released yet. This will clearly indicate to users and fellow designers that this particular font is not ready for release yet. See section 5 for more details.
|
||||
|
||||
|
||||
4 LICENSING YOUR ORIGINAL FONTS UNDER THE OFL
|
||||
|
||||
4.1 Can I use the SIL OFL for my own fonts?
|
||||
Yes! We heartily encourage everyone to use the OFL to distribute their own original fonts. It is a carefully constructed license that allows great freedom along with enough artistic integrity protection for the work of the authors as well as clear rules for other contributors and those who redistribute the fonts. The licensing model is used successfully by various organisations, both for-profit and not-for-profit, to release fonts of varying levels of scope and complexity.
|
||||
|
||||
4.2 What do I have to do to apply the OFL to my font?
|
||||
If you want to release your fonts under the OFL, we recommend you do the following:
|
||||
|
||||
4.2.1 Put your copyright and Reserved Font Names information at the beginning of the main OFL.txt file in place of the dedicated placeholders (marked with the <> characters). Include this file in your release package.
|
||||
|
||||
4.2.2 Put your copyright and the OFL text with your chosen Reserved Font Name(s) into your font files (the copyright and license fields). A link to the OFL text on the OFL web site is an acceptable (but not recommended) alternative. Also add this information to any other components (build scripts, glyph databases, documentation, test files, etc). Accurate metadata in your font files is beneficial to you as an increasing number of applications are exposing this information to the user. For example, clickable links can bring users back to your website and let them know about other work you have done or services you provide. Depending on the format of your fonts and sources, you can use template human-readable headers or machine-readable metadata. You should also double-check that there is no conflicting metadata in the font itself contradicting the license, such as the fstype bits in the os2 table or fields in the name table.
|
||||
|
||||
4.2.3 Write an initial FONTLOG.txt for your font and include it in the release package (see Section 6 and Appendix A for details including a template).
|
||||
|
||||
4.2.4 Include the relevant practical documentation on the license by adding the current OFL-FAQ.txt file in your package.
|
||||
|
||||
4.2.5 If you wish you can use the OFL graphics (http://scripts.sil.org/OFL_logo) on your website.
|
||||
|
||||
4.3 Will you make my font OFL for me?
|
||||
We won't do the work for you. We can, however, try to answer your questions, unfortunately we do not have the resources to review and check your font packages for correct use of the OFL. We recommend you turn to designers, foundries or consulting companies with experience in doing open font design to provide this service to you.
|
||||
|
||||
4.4 Will you distribute my OFL font for me?
|
||||
No, although if the font is of sufficient quality and general interest we may include a link to it on our partial list of OFL fonts on the OFL web site. You may wish to consider other open font catalogs or hosting services, such as the Unifont Font Guide (http://unifont.org/fontguide), The League of Movable Type (http://theleagueofmovabletype.com) or the Open Font Library (http://openfontlibrary.org/), which despite the name has no direct relationship to the OFL or SIL. We do not endorse any particular catalog or hosting service - it is your responsibility to determine if the service is right for you and if it treats authors with fairness.
|
||||
|
||||
4.5 Why should I use the OFL for my fonts?
|
||||
- to meet needs for fonts that can be modified to support lesser-known languages
|
||||
- to provide a legal and clear way for people to respect your work but still use it (and reduce piracy)
|
||||
- to involve others in your font project
|
||||
- to enable your fonts to be expanded with new weights and improved writing system/language support
|
||||
- to allow more technical font developers to add features to your design (such as OpenType, Graphite or AAT support)
|
||||
- to renew the life of an old font lying on your hard drive with no business model
|
||||
- to allow your font to be included in Libre Software operating systems like Ubuntu
|
||||
- to give your font world status and wide, unrestricted distribution
|
||||
- to educate students about quality typeface and font design
|
||||
- to expand your test base and get more useful feedback
|
||||
- to extend your reach to new markets when users see your metadata and go to your website
|
||||
- to get your font more easily into one of the web font online services
|
||||
- to attract attention for your commercial fonts
|
||||
- to make money through web font services
|
||||
- to make money by bundling fonts with applications
|
||||
- to make money adjusting and extending existing open fonts
|
||||
- to get a better chance that foundations/NGOs/charities/companies who commission fonts will pick you
|
||||
- to be part of a sharing design and development community
|
||||
- to give back and contribute to a growing body of font sources
|
||||
|
||||
|
||||
5 CHOOSING RESERVED FONT NAMES
|
||||
|
||||
5.1 What are Reserved Font Names?
|
||||
These are font names, or portions of font names, that the author has chosen to reserve for use only with the Original Version of the font, or for Modified Version(s) created by the original author.
|
||||
|
||||
5.2 Why can't I use the Reserved Font Names in my derivative font names? I'd like people to know where the design came from.
|
||||
The best way to acknowledge the source of the design is to thank the original authors and any other contributors in the files that are distributed with your revised font (although no acknowledgement is required). The FONTLOG is a natural place to do this. Reserved Font Names ensure that the only fonts that have the original names are the unmodified Original Versions. This allows designers to maintain artistic integrity while allowing collaboration to happen. It eliminates potential confusion and name conflicts. When choosing a name, be creative and avoid names that reuse almost all the same letters in the same order or sound like the original. It will help everyone if Original Versions and Modified Versions can easily be distinguished from one another and from other derivatives. Any substitution and matching mechanism is outside the scope of the license.
|
||||
|
||||
5.3 What do you mean by "primary name as presented to the user"? Are you referring to the font menu name?
|
||||
Yes, this applies to the font menu name and other mechanisms that specify a font in a document. It would be fine, however, to keep a text reference to the original fonts in the description field, in your modified source file or in documentation provided alongside your derivative as long as no one could be confused that your modified source is the original. But you cannot use the Reserved Font Names in any way to identify the font to the user (unless the Copyright Holder(s) allow(s) it through a separate agreement). Users who install derivatives (Modified Versions) on their systems should not see any of the original Reserved Font Names in their font menus, for example. Again, this is to ensure that users are not confused and do not mistake one font for another and so expect features only another derivative or the Original Version can actually offer.
|
||||
|
||||
5.4 Am I not allowed to use any part of the Reserved Font Names?
|
||||
You may not use individual words from the Reserved Font Names, but you would be allowed to use parts of words, as long as you do not use any word from the Reserved Font Names entirely. We do not recommend using parts of words because of potential confusion, but it is allowed. For example, if "Foobar" was a Reserved Font Name, you would be allowed to use "Foo" or "bar", although we would not recommend it. Such an unfortunate choice would confuse the users of your fonts as well as make it harder for other designers to contribute.
|
||||
|
||||
5.5 So what should I, as an author, identify as Reserved Font Names?
|
||||
Original authors are encouraged to name their fonts using clear, distinct names, and only declare the unique parts of the name as Reserved Font Names. For example, the author of a font called "Foobar Sans" would declare "Foobar" as a Reserved Font Name, but not "Sans", as that is a common typographical term, and may be a useful word to use in a derivative font name. Reserved Font Names should also be single words for simplicity and legibility. A font called "Flowing River" should have Reserved Font Names "Flowing" and "River", not "Flowing River". You also need to be very careful about reserving font names which are already linked to trademarks (whether registered or not) which you do not own.
|
||||
|
||||
5.6 Do I, as an author, have to identify any Reserved Font Names?
|
||||
No. RFNs are optional and not required, but we encourage you to use them. This is primarily to avoid confusion between your work and Modified Versions. As an author you can release a font under the OFL and not declare any Reserved Font Names. There may be situations where you find that using no RFNs and letting your font be changed and modified - including any kind of modification - without having to change the original name is desirable. However you need to be fully aware of the consequences. There will be no direct way for end-users and other designers to distinguish your Original Version from many Modified Versions that may be created. You have to trust whoever is making the changes and the optimizations to not introduce problematic changes. The RFNs you choose for your own creation have value to you as an author because they allow you to maintain artistic integrity and keep some control over the distribution channel to your end-users. For discussion of RFNs and web fonts see section 2.
|
||||
|
||||
5.7 Are any names (such as the main font name) reserved by default?
|
||||
No. That is a change to the license as of version 1.1. If you want any names to be Reserved Font Names, they must be specified after the copyright statement(s).
|
||||
|
||||
5.8 Is there any situation in which I can use Reserved Font Names for a Modified Version?
|
||||
The Copyright Holder(s) can give certain trusted parties the right to use any of the Reserved Font Names through separate written agreements. For example, even if "Foobar" is a RFN, you could write up an agreement to give company "XYZ" the right to distribute a modified version with a name that includes "Foobar". This allows for freedom without confusion. The existence of such an agreement should be made as clear as possible to downstream users and designers in the distribution package and the relevant documentation. They need to know if they are a party to the agreement or not and what they are practically allowed to do or not even if all the details of the agreement are not public.
|
||||
|
||||
5.9 Do font rebuilds require a name change? Do I have to change the name of the font when my packaging workflow includes a full rebuild from source?
|
||||
Yes, all rebuilds which change the font data and the smart code are Modified Versions and the requirements of the OFL apply: you need to respect what the Author(s) have chosen in terms of Reserved Font Names. However if a package (or installer) is simply a wrapper or a compressed structure around the final font - leaving them intact on the inside - then no name change is required. Please get in touch with the author(s) and copyright holder(s) to inquire about the presence of font sources beyond the final font file(s) and the recommended build path. That build path may very well be non-trivial and hard to reproduce accurately by the maintainer. If a full font build path is made available by the upstream author(s) please be aware that any regressions and changes you may introduce when doing a rebuild for packaging purposes is your own responsibility as a package maintainer since you are effectively creating a separate branch. You should make it very clear to your users that your rebuilt version is not the canonical one from upstream.
|
||||
|
||||
5.10 Can I add other Reserved Font Names when making a derivative font?
|
||||
Yes. List your additional Reserved Font Names after your additional copyright statement, as indicated with example placeholders at the top of the OFL.txt file. Be sure you do not remove any existing RFNs but only add your own. RFN statements should be placed next to the copyright statement of the relevant author as indicated in the OFL.txt template to make them visible to designers wishing to make their separate version.
|
||||
|
||||
|
||||
6 ABOUT THE FONTLOG
|
||||
|
||||
6.1 What is this FONTLOG thing exactly?
|
||||
It has three purposes: 1) to provide basic information on the font to users and other designers and developers, 2) to document changes that have been made to the font or accompanying files, either by the original authors or others, and 3) to provide a place to acknowledge authors and other contributors. Please use it!
|
||||
|
||||
6.2 Is the FONTLOG required?
|
||||
It is not a requirement of the license, but we strongly recommend you have one.
|
||||
|
||||
6.3 Am I required to update the FONTLOG when making Modified Versions?
|
||||
No, but users, designers and other developers might get very frustrated with you if you don't. People need to know how derivative fonts differ from the original, and how to take advantage of the changes, or build on them. There are utilities that can help create and maintain a FONTLOG, such as the FONTLOG support in FontForge.
|
||||
|
||||
6.4 What should the FONTLOG look like?
|
||||
It is typically a separate text file (FONTLOG.txt), but can take other formats. It commonly includes these four sections:
|
||||
|
||||
- brief header describing the FONTLOG itself and name of the font family
|
||||
- Basic Font Information - description of the font family, purpose and breadth
|
||||
- ChangeLog - chronological listing of changes
|
||||
- Acknowledgements - list of authors and contributors with contact information
|
||||
|
||||
It could also include other sections, such as: where to find documentation, how to make contributions, information on contributing organizations, source code details, and a short design guide. See Appendix A for an example FONTLOG.
|
||||
|
||||
|
||||
7 MAKING CONTRIBUTIONS TO OFL PROJECTS
|
||||
|
||||
7.1 Can I contribute work to OFL projects?
|
||||
In many cases, yes. It is common for OFL fonts to be developed by a team of people who welcome contributions from the wider community. Contact the original authors for specific information on how to participate in their projects.
|
||||
|
||||
7.2 Why should I contribute my changes back to the original authors?
|
||||
It would benefit many people if you contributed back in response to what you've received. Your contributions and improvements to the fonts and other components could be a tremendous help and would encourage others to contribute as well and 'give back'. You will then benefit from other people's contributions as well. Sometimes maintaining your own separate version takes more effort than merging back with the original. Be aware that any contributions, however, must be either your own original creation or work that you own, and you may be asked to affirm that clearly when you contribute.
|
||||
|
||||
7.3 I've made some very nice improvements to the font. Will you consider adopting them and putting them into future Original Versions?
|
||||
Most authors would be very happy to receive such contributions. Keep in mind that it is unlikely that they would want to incorporate major changes that would require additional work on their end. Any contributions would likely need to be made for all the fonts in a family and match the overall design and style. Authors are encouraged to include a guide to the design with the fonts. It would also help to have contributions submitted as patches or clearly marked changes - the use of smart source revision control systems like subversion, mercurial, git or bzr is a good idea. Please follow the recommendations given by the author(s) in terms of preferred source formats and configuration parameters for sending contributions. If this is not indicated in a FONTLOG or other documentation of the font, consider asking them directly. Examples of useful contributions are bug fixes, additional glyphs, stylistic alternates (and the smart font code to access them) or improved hinting. Keep in mind that some kinds of changes (esp. hinting) may be technically difficult to integrate.
|
||||
|
||||
7.4 How can I financially support the development of OFL fonts?
|
||||
It is likely that most authors of OFL fonts would accept financial contributions - contact them for instructions on how to do this. Such contributions would support future development. You can also pay for others to enhance the fonts and contribute the results back to the original authors for inclusion in the Original Version.
|
||||
|
||||
|
||||
8 ABOUT THE LICENSE ITSELF
|
||||
|
||||
8.1 I see that this is version 1.1 of the license. Will there be later changes?
|
||||
Version 1.1 is the first minor revision of the OFL. We are confident that version 1.1 will meet most needs, but are open to future improvements. Any revisions would be for future font releases, and previously existing licenses would remain in effect. No retroactive changes are possible, although the Copyright Holder(s) can re-release the font under a revised OFL. All versions will be available on our web site: http://scripts.sil.org/OFL.
|
||||
|
||||
8.2 Does this license restrict the rights of the Copyright Holder(s)?
|
||||
No. The Copyright Holder(s) still retain(s) all the rights to their creation; they are only releasing a portion of it for use in a specific way. For example, the Copyright Holder(s) may choose to release a 'basic' version of their font under the OFL, but sell a restricted 'enhanced' version under a different license. They may also choose to release the same font under both the OFL and some other license. Only the Copyright Holder(s) can do this, and doing so does not change the terms of the OFL as it applies to that font.
|
||||
|
||||
8.3 Is the OFL a contract or a license?
|
||||
The OFL is a worldwide license based on international copyright agreements and conventions. It is not a contract and so does not require you to sign it to have legal validity. By using, modifying and redistributing components under the OFL you indicate that you accept the license.
|
||||
|
||||
8.4 I really like the terms of the OFL, but want to change it a little. Am I allowed to take ideas and actual wording from the OFL and put them into my own custom license for distributing my fonts?
|
||||
We strongly recommend against creating your very own unique open licensing model. Using a modified or derivative license will likely cut you off - along with the font(s) under that license - from the community of designers using the OFL, potentially expose you and your users to legal liabilities, and possibly put your work and rights at risk. The OFL went though a community and legal review process that took years of effort, and that review is only applicable to an unmodified OFL. The text of the OFL has been written by SIL (with review and consultation from the community) and is copyright (c) 2005-2017 SIL International. You may re-use the ideas and wording (in part, not in whole) in another non-proprietary license provided that you call your license by another unambiguous name, that you do not use the preamble, that you do not mention SIL and that you clearly present your license as different from the OFL so as not to cause confusion by being too similar to the original. If you feel the OFL does not meet your needs for an open license, please contact us.
|
||||
|
||||
8.5 Can I quote from the OFL FAQ?
|
||||
Yes, SIL gives permission to quote from the OFL FAQ (OFL-FAQ.txt), in whole or in part, provided that the quoted text is:
|
||||
|
||||
- unmodified,
|
||||
- used to help explain the intent of the OFL, rather than cause misunderstanding, and
|
||||
- accompanied with the following attribution: "From the OFL FAQ (OFL-FAQ.txt), copyright (c) 2005-2020 SIL International. Used by permission. http://scripts.sil.org/OFL-FAQ_web".
|
||||
|
||||
8.6 Can I translate the license and the FAQ into other languages?
|
||||
SIL certainly recognises the need for people who are not familiar with English to be able to understand the OFL and its use. Making the license very clear and readable has been a key goal for the OFL, but we know that people understand their own language best.
|
||||
|
||||
If you are an experienced translator, you are very welcome to translate the OFL and OFL-FAQ so that designers and users in your language community can understand the license better. But only the original English version of the license has legal value and has been approved by the community. Translations do not count as legal substitutes and should only serve as a way to explain the original license. SIL - as the author and steward of the license for the community at large - does not approve any translation of the OFL as legally valid because even small translation ambiguities could be abused and create problems.
|
||||
|
||||
SIL gives permission to publish unofficial translations into other languages provided that they comply with the following guidelines:
|
||||
|
||||
- Put the following disclaimer in both English and the target language stating clearly that the translation is unofficial:
|
||||
|
||||
"This is an unofficial translation of the SIL Open Font License into <language_name>. It was not published by SIL International, and does not legally state the distribution terms for fonts that use the OFL. A release under the OFL is only valid when using the original English text. However, we recognize that this unofficial translation will help users and designers not familiar with English to better understand and use the OFL. We encourage designers who consider releasing their creation under the OFL to read the OFL-FAQ in their own language if it is available. Please go to http://scripts.sil.org/OFL for the official version of the license and the accompanying OFL-FAQ."
|
||||
|
||||
- Keep your unofficial translation current and update it at our request if needed, for example if there is any ambiguity which could lead to confusion.
|
||||
|
||||
If you start such a unofficial translation effort of the OFL and OFL-FAQ please let us know.
|
||||
|
||||
8.7 Does the OFL have an explicit expiration term?
|
||||
No, the implicit intent of the OFL is that the permissions granted are perpetual and irrevocable.
|
||||
|
||||
|
||||
9 ABOUT SIL INTERNATIONAL
|
||||
|
||||
9.1 Who is SIL International and what do they do?
|
||||
SIL serves language communities worldwide, building their capacity for sustainable language development, by means of research, translation, training and materials development. SIL makes its services available to all without regard to religious belief, political ideology, gender, race, or ethnic background. SIL's members and volunteers share a Christian commitment.
|
||||
|
||||
9.2 What does this have to do with font licensing?
|
||||
The ability to read, write, type and publish in one's own language is one of the most critical needs for millions of people around the world. This requires fonts that are widely available and support lesser-known languages. SIL develops - and encourages others to develop - a complete stack of writing systems implementation components available under open licenses. This open stack includes input methods, smart fonts, smart rendering libraries and smart applications. There has been a need for a common open license that is specifically applicable to fonts and related software (a crucial component of this stack), so SIL developed the SIL Open Font License with the help of the Free/Libre and Open Source Software community.
|
||||
|
||||
9.3 How can I contact SIL?
|
||||
Our main web site is: http://www.sil.org/
|
||||
Our site about complex scripts is: http://scripts.sil.org/
|
||||
Information about this license (and contact information) is at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
APPENDIX A - FONTLOG EXAMPLE
|
||||
|
||||
Here is an example of the recommended format for a FONTLOG, although other formats are allowed.
|
||||
|
||||
-----
|
||||
FONTLOG for the GlobalFontFamily fonts
|
||||
|
||||
This file provides detailed information on the GlobalFontFamily Font Software. This information should be distributed along with the GlobalFontFamily fonts and any derivative works.
|
||||
|
||||
Basic Font Information
|
||||
|
||||
GlobalFontFamily is a Unicode typeface family that supports all languages that use the Latin script and its variants, and could be expanded to support other scripts.
|
||||
|
||||
NewWorldFontFamily is based on the GlobalFontFamily and also supports Greek, Hebrew, Cyrillic and Armenian.
|
||||
|
||||
More specifically, this release supports the following Unicode ranges...
|
||||
This release contains...
|
||||
Documentation can be found at...
|
||||
To contribute to the project...
|
||||
|
||||
ChangeLog
|
||||
|
||||
10 December 2010 (Fred Foobar) GlobalFontFamily-devel version 1.4
|
||||
- fix new build and testing system (bug #123456)
|
||||
|
||||
1 August 2008 (Tom Parker) GlobalFontFamily version 1.2.1
|
||||
- Tweaked the smart font code (Branch merged with trunk version)
|
||||
- Provided improved build and debugging environment for smart behaviours
|
||||
|
||||
7 February 2007 (Pat Johnson) NewWorldFontFamily Version 1.3
|
||||
- Added Greek and Cyrillic glyphs
|
||||
|
||||
7 March 2006 (Fred Foobar) NewWorldFontFamily Version 1.2
|
||||
- Tweaked contextual behaviours
|
||||
|
||||
1 Feb 2005 (Jane Doe) NewWorldFontFamily Version 1.1
|
||||
- Improved build script performance and verbosity
|
||||
- Extended the smart code documentation
|
||||
- Corrected minor typos in the documentation
|
||||
- Fixed position of combining inverted breve below (U+032F)
|
||||
- Added OpenType/Graphite smart code for Armenian
|
||||
- Added Armenian glyphs (U+0531 -> U+0587)
|
||||
- Released as "NewWorldFontFamily"
|
||||
|
||||
1 Jan 2005 (Joe Smith) GlobalFontFamily Version 1.0
|
||||
- Initial release
|
||||
|
||||
Acknowledgements
|
||||
|
||||
If you make modifications be sure to add your name (N), email (E), web-address (if you have one) (W) and description (D). This list is in alphabetical order.
|
||||
|
||||
N: Jane Doe
|
||||
E: jane@university.edu
|
||||
W: http://art.university.edu/projects/fonts
|
||||
D: Contributor - Armenian glyphs and code
|
||||
|
||||
N: Fred Foobar
|
||||
E: fred@foobar.org
|
||||
W: http://foobar.org
|
||||
D: Contributor - misc Graphite fixes
|
||||
|
||||
N: Pat Johnson
|
||||
E: pat@fontstudio.org
|
||||
W: http://pat.fontstudio.org
|
||||
D: Designer - Greek & Cyrillic glyphs based on Roman design
|
||||
|
||||
N: Tom Parker
|
||||
E: tom@company.com
|
||||
W: http://www.company.com/tom/projects/fonts
|
||||
D: Engineer - original smart font code
|
||||
|
||||
N: Joe Smith
|
||||
E: joe@fontstudio.org
|
||||
W: http://joe.fontstudio.org
|
||||
D: Designer - original Roman glyphs
|
||||
|
||||
Fontstudio.org is an not-for-profit design group whose purpose is...
|
||||
Foobar.org is a distributed community of developers...
|
||||
Company.com is a small business who likes to support community designers...
|
||||
University.edu is a renowned educational institution with a strong design department...
|
||||
-----
|
||||
@@ -1,96 +0,0 @@
|
||||
Copyright (c) 2022-11-02, ZERO子 (https://github.com/Skr-ZERO),
|
||||
with Reserved Font Name “Assorted”“什锦”.
|
||||
|
||||
Copyright (c) 2022-11-01, Umihotaru (https://umihotaru.work/),
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
@@ -1,435 +0,0 @@
|
||||
OFL FAQ - Frequently Asked Questions about the SIL Open Font License (OFL)
|
||||
Version 1.1-update6 - December 2020
|
||||
The OFL FAQ is copyright (c) 2005-2020 SIL International.
|
||||
Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
|
||||
(See http://scripts.sil.org/OFL for updates)
|
||||
|
||||
|
||||
CONTENTS OF THIS FAQ
|
||||
1 USING AND DISTRIBUTING FONTS LICENSED UNDER THE OFL
|
||||
2 USING OFL FONTS FOR WEB PAGES AND ONLINE WEB FONT SERVICES
|
||||
3 MODIFYING OFL-LICENSED FONTS
|
||||
4 LICENSING YOUR ORIGINAL FONTS UNDER THE OFL
|
||||
5 CHOOSING RESERVED FONT NAMES
|
||||
6 ABOUT THE FONTLOG
|
||||
7 MAKING CONTRIBUTIONS TO OFL PROJECTS
|
||||
8 ABOUT THE LICENSE ITSELF
|
||||
9 ABOUT SIL INTERNATIONAL
|
||||
APPENDIX A - FONTLOG EXAMPLE
|
||||
|
||||
1 USING AND DISTRIBUTING FONTS LICENSED UNDER THE OFL
|
||||
|
||||
1.1 Can I use the fonts for a book or other print publication, to create logos or other graphics or even to manufacture objects based on their outlines?
|
||||
Yes. You are very welcome to do so. Authors of fonts released under the OFL allow you to use their font software as such for any kind of design work. No additional license or permission is required, unlike with some other licenses. Some examples of these uses are: logos, posters, business cards, stationery, video titling, signage, t-shirts, personalised fabric, 3D-printed/laser-cut shapes, sculptures, rubber stamps, cookie cutters and lead type.
|
||||
|
||||
1.1.1 Does that restrict the license or distribution of that artwork?
|
||||
No. You remain the author and copyright holder of that newly derived graphic or object. You are simply using an open font in the design process. It is only when you redistribute, bundle or modify the font itself that other conditions of the license have to be respected (see below for more details).
|
||||
|
||||
1.1.2 Is any kind of acknowledgement required?
|
||||
No. Font authors may appreciate being mentioned in your artwork's acknowledgements alongside the name of the font, possibly with a link to their website, but that is not required.
|
||||
|
||||
1.2 Can the fonts be included with Free/Libre and Open Source Software collections such as GNU/Linux and BSD distributions and repositories?
|
||||
Yes! Fonts licensed under the OFL can be freely included alongside other software under FLOSS (Free/Libre and Open Source Software) licenses. Since fonts are typically aggregated with, not merged into, existing software, there is little need to be concerned about incompatibility with existing software licenses. You may also repackage the fonts and the accompanying components in a .rpm or .deb package (or other similar package formats or installers) and include them in distribution CD/DVDs and online repositories. (Also see section 5.9 about rebuilding from source.)
|
||||
|
||||
1.3 I want to distribute the fonts with my program. Does this mean my program also has to be Free/Libre and Open Source Software?
|
||||
No. Only the portions based on the Font Software are required to be released under the OFL. The intent of the license is to allow aggregation or bundling with software under restricted licensing as well.
|
||||
|
||||
1.4 Can I sell a software package that includes these fonts?
|
||||
Yes, you can do this with both the Original Version and a Modified Version of the fonts. Examples of bundling made possible by the OFL would include: word processors, design and publishing applications, training and educational software, games and entertainment software, mobile device applications, etc.
|
||||
|
||||
1.5 Can I include the fonts on a CD of freeware or commercial fonts?
|
||||
Yes, as long some other font or software is also on the disk, so the OFL font is not sold by itself.
|
||||
|
||||
1.6 Why won't the OFL let me sell the fonts alone?
|
||||
The intent is to keep people from making money by simply redistributing the fonts. The only people who ought to profit directly from the fonts should be the original authors, and those authors have kindly given up potential direct income to distribute their fonts under the OFL. Please honour and respect their contribution!
|
||||
|
||||
1.7 What about sharing OFL fonts with friends on a CD, DVD or USB stick?
|
||||
You are very welcome to share open fonts with friends, family and colleagues through removable media. Just remember to include the full font package, including any copyright notices and licensing information as available in OFL.txt. In the case where you sell the font, it has to come bundled with software.
|
||||
|
||||
1.8 Can I host the fonts on a web site for others to use?
|
||||
Yes, as long as you make the full font package available. In most cases it may be best to point users to the main site that distributes the Original Version so they always get the most recent stable and complete version. See also discussion of web fonts in Section 2.
|
||||
|
||||
1.9 Can I host the fonts on a server for use over our internal network?
|
||||
Yes. If the fonts are transferred from the server to the client computer by means that allow them to be used even if the computer is no longer attached to the network, the full package (copyright notices, licensing information, etc.) should be included.
|
||||
|
||||
1.10 Does the full OFL license text always need to accompany the font?
|
||||
The only situation in which an OFL font can be distributed without the text of the OFL (either in a separate file or in font metadata), is when a font is embedded in a document or bundled within a program. In the case of metadata included within a font, it is legally sufficient to include only a link to the text of the OFL on http://scripts.sil.org/OFL, but we strongly recommend against this. Most modern font formats include metadata fields that will accept the full OFL text, and full inclusion increases the likelihood that users will understand and properly apply the license.
|
||||
|
||||
1.11 What do you mean by 'embedding'? How does that differ from other means of distribution?
|
||||
By 'embedding' we mean inclusion of the font in a document or file in a way that makes extraction (and redistribution) difficult or clearly discouraged. In many cases the names of embedded fonts might also not be obvious to those reading the document, the font data format might be altered, and only a subset of the font - only the glyphs required for the text - might be included. Any other means of delivering a font to another person is considered 'distribution', and needs to be accompanied by any copyright notices and licensing information available in OFL.txt.
|
||||
|
||||
1.12 So can I embed OFL fonts in my document?
|
||||
Yes, either in full or a subset. The restrictions regarding font modification and redistribution do not apply, as the font is not intended for use outside the document.
|
||||
|
||||
1.13 Does embedding alter the license of the document itself?
|
||||
No. Referencing or embedding an OFL font in any document does not change the license of the document itself. The requirement for fonts to remain under the OFL does not apply to any document created using the fonts and their derivatives. Similarly, creating any kind of graphic using a font under OFL does not make the resulting artwork subject to the OFL.
|
||||
|
||||
1.14 If OFL fonts are extracted from a document in which they are embedded (such as a PDF file), what can be done with them? Is this a risk to author(s)?
|
||||
The few utilities that can extract fonts embedded in a PDF will typically output limited amounts of outlines - not a complete font. To create a working font from this method is much more difficult and time consuming than finding the source of the original OFL font. So there is little chance that an OFL font would be extracted and redistributed inappropriately through this method. Even so, copyright laws address any misrepresentation of authorship. All Font Software released under the OFL and marked as such by the author(s) is intended to remain under this license regardless of the distribution method, and cannot be redistributed under any other license. We strongly discourage any font extraction - we recommend directly using the font sources instead - but if you extract font outlines from a document, please be considerate: respect the work of the author(s) and the licensing model.
|
||||
|
||||
1.15 What about distributing fonts with a document? Within a compressed folder structure? Is it distribution, bundling or embedding?
|
||||
Certain document formats may allow the inclusion of an unmodified font within their file structure which may consist of a compressed folder containing the various resources forming the document (such as pictures and thumbnails). Including fonts within such a structure is understood as being different from embedding but rather similar to bundling (or mere aggregation) which the license explicitly allows. In this case the font is conveyed unchanged whereas embedding a font usually transforms it from the original format. The OFL does not allow anyone to extract the font from such a structure to then redistribute it under another license. The explicit permission to redistribute and embed does not cancel the requirement for the Font Software to remain under the license chosen by its author(s). Even if the font travels inside the document as one of its assets, it should not lose its authorship information and licensing.
|
||||
|
||||
1.16 What about ebooks shipping with open fonts?
|
||||
The requirements differ depending on whether the fonts are linked, embedded or distributed (bundled or aggregated). Some ebook formats use web technologies to do font linking via @font-face, others are designed for font embedding, some use fonts distributed with the document or reading software, and a few rely solely on the fonts already present on the target system. The license requirements depend on the type of inclusion as discussed in 1.15.
|
||||
|
||||
1.17 Can Font Software released under the OFL be subject to URL-based access restrictions methods or DRM (Digital Rights Management) mechanisms?
|
||||
Yes, but these issues are out-of-scope for the OFL. The license itself neither encourages their use nor prohibits them since such mechanisms are not implemented in the components of the Font Software but through external software. Such restrictions are put in place for many different purposes corresponding to various usage scenarios. One common example is to limit potentially dangerous cross-site scripting attacks. However, in the spirit of libre/open fonts and unrestricted writing systems, we strongly encourage open sharing and reuse of OFL fonts, and the establishment of an environment where such restrictions are unnecessary. Note that whether you wish to use such mechanisms or you prefer not to, you must still abide by the rules set forth by the OFL when using fonts released by their authors under this license. Derivative fonts must be licensed under the OFL, even if they are part of a service for which you charge fees and/or for which access to source code is restricted. You may not sell the fonts on their own - they must be part of a larger software package, bundle or subscription plan. For example, even if the OFL font is distributed in a software package or via an online service using a DRM mechanism, the user would still have the right to extract that font, use, study, modify and redistribute it under the OFL.
|
||||
|
||||
1.18 I've come across a font released under the OFL. How can I easily get more information about the Original Version? How can I know where it stands compared to the Original Version or other Modified Versions?
|
||||
Consult the copyright statement(s) in the license for ways to contact the original authors. Consult the FONTLOG (see section 6 for more details and examples) for information on how the font differs from the Original Version, and get in touch with the various contributors via the information in the acknowledgement section. Please consider using the Original Versions of the fonts whenever possible.
|
||||
|
||||
1.19 What do you mean in condition 4 of the OFL's permissions and conditions? Can you provide examples of abusive promotion / endorsement / advertisement vs. normal acknowledgement?
|
||||
The intent is that the goodwill and reputation of the author(s) should not be used in a way that makes it sound like the original author(s) endorse or approve of a specific Modified Version or software bundle. For example, it would not be right to advertise a word processor by naming the author(s) in a listing of software features, or to promote a Modified Version on a web site by saying "designed by ...". However, it would be appropriate to acknowledge the author(s) if your software package has a list of people who deserve thanks. We realize that this can seem to be a grey area, but the standard used to judge an acknowledgement is that if the acknowledgement benefits the author(s) it is allowed, but if it primarily benefits other parties, or could reflect poorly on the author(s), then it is not.
|
||||
|
||||
1.20 I'm writing a small app for mobile platforms, do I need to include the whole package?
|
||||
If you bundle a font under the OFL with your mobile app you must comply with the terms of the license. At a minimum you must include the copyright statement, the license notice and the license text. A mention of this information in your About box or Changelog, with a link to where the font package is from, is good practice, and the extra space needed to carry these items is very small. You do not, however, need to include the full contents of the font package - only the fonts you use and the copyright and license that apply to them. For example, if you only use the regular weight in your app, you do not need to include the italic and bold versions.
|
||||
|
||||
1.21 What about including OFL fonts by default in my firmware or dedicated operating system?
|
||||
Many such systems are restricted and turned into appliances so that users cannot study or modify them. Using open fonts to increase quality and language coverage is a great idea, but you need to be aware that if there is a way for users to extract fonts you cannot legally prevent them from doing that. The fonts themselves, including any changes you make to them, must be distributed under the OFL even if your firmware has a more restrictive license. If you do transform the fonts and change their formats when you include them in your firmware you must respect any names reserved by the font authors via the RFN mechanism and pick your own font name. Alternatively if you directly add a font under the OFL to the font folder of your firmware without modifying or optimizing it you are simply bundling the font like with any other software collection, and do not need to make any further changes.
|
||||
|
||||
1.22 Can I make and publish CMS themes or templates that use OFL fonts? Can I include the fonts themselves in the themes or templates? Can I sell the whole package?
|
||||
Yes, you are very welcome to integrate open fonts into themes and templates for your preferred CMS and make them more widely available. Remember that you can only sell the fonts and your CMS add-on as part of a software bundle. (See 1.4 for details and examples about selling bundles).
|
||||
|
||||
1.23 Can OFL fonts be included in services that deliver fonts to the desktop from remote repositories? Even if they contain both OFL and non-OFL fonts?
|
||||
Yes. Some foundries have set up services to deliver fonts to subscribers directly to desktops from their online repositories; similarly, plugins are available to preview and use fonts directly in your design tool or publishing suite. These services may mix open and restricted fonts in the same channel, however they should make a clear distinction between them to users. These services should also not hinder users (such as through DRM or obfuscation mechanisms) from extracting and using the OFL fonts in other environments, or continuing to use OFL fonts after subscription terms have ended, as those uses are specifically allowed by the OFL.
|
||||
|
||||
1.24 Can services that provide or distribute OFL fonts restrict my use of them?
|
||||
No. The terms of use of such services cannot replace or restrict the terms of the OFL, as that would be the same as distributing the fonts under a different license, which is not allowed. You are still entitled to use, modify and redistribute them as the original authors have intended outside of the sole control of that particular distribution channel. Note, however, that the fonts provided by these services may differ from the Original Versions.
|
||||
|
||||
|
||||
2 USING OFL FONTS FOR WEBPAGES AND ONLINE WEB FONT SERVICES
|
||||
|
||||
NOTE: This section often refers to a separate paper on 'Web Fonts & RFNs'. This is available at http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
2.1 Can I make webpages using these fonts?
|
||||
Yes! Go ahead! Using CSS (Cascading Style Sheets) is recommended. Your three best options are:
|
||||
- referring directly in your stylesheet to open fonts which may be available on the user's system
|
||||
- providing links to download the full package of the font - either from your own website or from elsewhere - so users can install it themselves
|
||||
- using @font-face to distribute the font directly to browsers. This is recommended and explicitly allowed by the licensing model because it is distribution. The font file itself is distributed with other components of the webpage. It is not embedded in the webpage but referenced through a web address which will cause the browser to retrieve and use the corresponding font to render the webpage (see 1.11 and 1.15 for details related to embedding fonts into documents). As you take advantage of the @font-face cross-platform standard, be aware that web fonts are often tuned for a web environment and not intended for installation and use outside a browser. The reasons in favour of using web fonts are to allow design of dynamic text elements instead of static graphics, to make it easier for content to be localized and translated, indexed and searched, and all this with cross-platform open standards without depending on restricted extensions or plugins. You should check the CSS cascade (the order in which fonts are being called or delivered to your users) when testing.
|
||||
|
||||
2.2 Can I make and use WOFF (Web Open Font Format) versions of OFL fonts?
|
||||
Yes, but you need to be careful. A change in font format normally is considered modification, and Reserved Font Names (RFNs) cannot be used. Because of the design of the WOFF format, however, it is possible to create a WOFF version that is not considered modification, and so would not require a name change. You are allowed to create, use and distribute a WOFF version of an OFL font without changing the font name, but only if:
|
||||
|
||||
- the original font data remains unchanged except for WOFF compression, and
|
||||
- WOFF-specific metadata is either omitted altogether or present and includes, unaltered, the contents of all equivalent metadata in the original font.
|
||||
|
||||
If the original font data or metadata is changed, or the WOFF-specific metadata is incomplete, the font must be considered a Modified Version, the OFL restrictions would apply and the name of the font must be changed: any RFNs cannot be used and copyright notices and licensing information must be included and cannot be deleted or modified. You must come up with a unique name - we recommend one corresponding to your domain or your particular web application. Be aware that only the original author(s) can use RFNs. This is to prevent collisions between a derivative tuned to your audience and the original upstream version and so to reduce confusion.
|
||||
|
||||
Please note that most WOFF conversion tools and online services do not meet the two requirements listed above, and so their output must be considered a Modified Version. So be very careful and check to be sure that the tool or service you're using is compressing unchanged data and completely and accurately reflecting the original font metadata.
|
||||
|
||||
2.3 What about other web font formats such as EOT/EOTLite/CWT/etc.?
|
||||
In most cases these formats alter the original font data more than WOFF, and do not completely support appropriate metadata, so their use must be considered modification and RFNs may not be used. However, there may be certain formats or usage scenarios that may allow the use of RFNs. See http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
2.4 Can I make OFL fonts available through web font online services?
|
||||
Yes, you are welcome to include OFL fonts in online web font services as long as you properly meet all the conditions of the license. The origin and open status of the font should be clear among the other fonts you are hosting. Authorship, copyright notices and license information must be sufficiently visible to your users or subscribers so they know where the font comes from and the rights granted by the author(s). Make sure the font file contains the needed copyright notice(s) and licensing information in its metadata. Please double-check the accuracy of every field to prevent contradictory information. Other font formats, including EOT/EOTLite/CWT and superior alternatives like WOFF, already provide fields for this information. Remember that if you modify the font within your library or convert it to another format for any reason the OFL restrictions apply and you need to change the names accordingly. Please respect the author's wishes as expressed in the OFL and do not misrepresent original designers and their work. Don't lump quality open fonts together with dubious freeware or public domain fonts. Consider how you can best work with the original designers and foundries, support their efforts and generate goodwill that will benefit your service. (See 1.17 for details related to URL-based access restrictions methods or DRM mechanisms).
|
||||
|
||||
2.5 Some web font formats and services provide ways of "optimizing" the font for a particular website or web application; is that allowed?
|
||||
Yes, it is permitted, but remember that these optimized versions are Modified Versions and so must follow OFL requirements like appropriate renaming. Also you need to bear in mind the other important parameters beyond compression, speed and responsiveness: you need to consider the audience of your particular website or web application, as choosing some optimization parameters may turn out to be less than ideal for them. Subsetting by removing certain glyphs or features may seriously limit functionality of the font in various languages that your users expect. It may also introduce degradation of quality in the rendering or specific bugs on the various target platforms compared to the original font from upstream. In other words, remember that one person's optimized font may be another person's missing feature. Various advanced typographic features (OpenType, Graphite or AAT) are also available through CSS and may provide the desired effects without the need to modify the font.
|
||||
|
||||
2.6 Is subsetting a web font considered modification?
|
||||
Yes. Removing any parts of the font when delivering a web font to a browser, including unused glyphs and smart font code, is considered modification. This is permitted by the OFL but would not normally allow the use of RFNs. Some newer subsetting technologies may be able to subset in a way that allows users to effectively have access to the complete font, including smart font behaviour. See 2.8 and http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
2.7 Are there any situations in which a modified web font could use RFNs?
|
||||
Yes. If a web font is optimized only in ways that preserve Functional Equivalence (see 2.8), then it may use RFNs, as it reasonably represents the Original Version and respects the intentions of the author(s) and the main purposes of the RFN mechanism (avoids collisions, protects authors, minimizes support, encourages derivatives). However this is technically very difficult and often impractical, so a much better scenario is for the web font service or provider to sign a separate agreement with the author(s) that allows the use of RFNs for Modified Versions.
|
||||
|
||||
2.8 How do you know if an optimization to a web font preserves Functional Equivalence?
|
||||
Functional Equivalence is described in full in the 'Web fonts and RFNs' paper at http://scripts.sil.org/OFL_web_fonts_and_RFNs, in general, an optimized font is deemed to be Functionally Equivalent (FE) to the Original Version if it:
|
||||
|
||||
- Supports the same full character inventory. If a character can be properly displayed using the Original Version, then that same character, encoded correctly on a web page, will display properly.
|
||||
- Provides the same smart font behavior. Any dynamic shaping behavior that works with the Original Version should work when optimized, unless the browser or environment does not support it. There does not need to be guaranteed support in the client, but there should be no forced degradation of smart font or shaping behavior, such as the removal or obfuscation of OpenType, Graphite or AAT tables.
|
||||
- Presents text with no obvious degradation in visual quality. The lettershapes should be equally (or more) readable, within limits of the rendering platform.
|
||||
- Preserves original author, project and license metadata. At a minimum, this should include: Copyright and authorship; The license as stated in the Original Version, whether that is the full text of the OFL or a link to the web version; Any RFN declarations; Information already present in the font or documentation that points back to the Original Version, such as a link to the project or the author's website.
|
||||
|
||||
If an optimized font meets these requirements, and so is considered to be FE, then it's very likely that the original author would feel that the optimized font is a good and reasonable equivalent. If it falls short of any of these requirements, the optimized font does not reasonably represent the Original Version, and so should be considered to be a Modified Version. Like other Modified Versions, it would not be allowed to use any RFNs and you simply need to pick your own font name.
|
||||
|
||||
2.9 Isn't use of web fonts another form of embedding?
|
||||
No. Unlike embedded fonts in a PDF, web fonts are not an integrated part of the document itself. They are not specific to a single document and are often applied to thousands of documents around the world. The font data is not stored alongside the document data and often originates from a different location. The ease by which the web fonts used by a document may be identified and downloaded for desktop use demonstrates that they are philosophically and technically separate from the web pages that specify them. See http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
2.10 So would it be better to not use RFNs at all if you want your font to be distributed by a web fonts service?
|
||||
No. Although the OFL does not require authors to use RFNs, the RFN mechanism is an important part of the OFL model and completely compatible with web font services. If that web font service modifies the fonts, then the best solution is to sign a separate agreement for the use of any RFNs. It is perfectly valid for an author to not declare any RFNs, but before they do so they need to fully understand the benefits they are giving up, and the overall negative effect of allowing many different versions bearing the same name to be widely distributed. As a result, we don't generally recommend it.
|
||||
|
||||
2.11 What should an agreement for the use of RFNs say? Are there any examples?
|
||||
There is no prescribed format for this agreement, as legal systems vary, and no recommended examples. Authors may wish to add specific clauses to further restrict use, require author review of Modified Versions, establish user support mechanisms or provide terms for ending the agreement. Such agreements are usually not public, and apply only to the main parties. However, it would be very beneficial for web font services to clearly state when they have established such agreements, so that the public understands clearly that their service is operating appropriately.
|
||||
|
||||
See the separate paper on 'Web Fonts & RFNs' for in-depth discussion of issues related to the use of RFNs for web fonts. This is available at http://scripts.sil.org/OFL_web_fonts_and_RFNs
|
||||
|
||||
|
||||
3 MODIFYING OFL-LICENSED FONTS
|
||||
|
||||
3.1 Can I change the fonts? Are there any limitations to what things I can and cannot change?
|
||||
You are allowed to change anything, as long as such changes do not violate the terms of the license. In other words, you are not allowed to remove the copyright statement(s) from the font, but you could put additional information into it that covers your contribution. See the placeholders in the OFL header template for recommendations on where to add your own statements. (Remember that, when authors have reserved names via the RFN mechanism, you need to change the internal names of the font to your own font name when making your modified version even if it is just a small change.)
|
||||
|
||||
3.2 I have a font that needs a few extra glyphs - can I take them from an OFL licensed font and copy them into mine?
|
||||
Yes, but if you distribute that font to others it must be under the OFL, and include the information mentioned in condition 2 of the license.
|
||||
|
||||
3.3 Can I charge people for my additional work? In other words, if I add a bunch of special glyphs or OpenType/Graphite/AAT code, can I sell the enhanced font?
|
||||
Not by itself. Derivative fonts must be released under the OFL and cannot be sold by themselves. It is permitted, however, to include them in a larger software package (such as text editors, office suites or operating systems), even if the larger package is sold. In that case, you are strongly encouraged, but not required, to also make that derived font easily and freely available outside of the larger package.
|
||||
|
||||
3.4 Can I pay someone to enhance the fonts for my use and distribution?
|
||||
Yes. This is a good way to fund the further development of the fonts. Keep in mind, however, that if the font is distributed to others it must be under the OFL. You won't be able to recover your investment by exclusively selling the font, but you will be making a valuable contribution to the community. Please remember how you have benefited from the contributions of others.
|
||||
|
||||
3.5 I need to make substantial revisions to the font to make it work with my program. It will be a lot of work, and a big investment, and I want to be sure that it can only be distributed with my program. Can I restrict its use?
|
||||
No. If you redistribute a Modified Version of the font it must be under the OFL. You may not restrict it in any way beyond what the OFL permits and requires. This is intended to ensure that all released improvements to the fonts become available to everyone. But you will likely get an edge over competitors by being the first to distribute a bundle with the enhancements. Again, please remember how you have benefited from the contributions of others.
|
||||
|
||||
3.6 Do I have to make any derivative fonts (including extended source files, build scripts, documentation, etc.) publicly available?
|
||||
No, but please consider sharing your improvements with others. You may find that you receive in return more than what you gave.
|
||||
|
||||
3.7 If a trademark is claimed in the OFL font, does that trademark need to remain in modified fonts?
|
||||
Yes. Any trademark notices must remain in any derivative fonts to respect trademark laws, but you may add any additional trademarks you claim, officially registered or not. For example if an OFL font called "Foo" contains a notice that "Foo is a trademark of Acme", then if you rename the font to "Bar" when creating a Modified Version, the new trademark notice could say "Foo is a trademark of Acme Inc. - Bar is a trademark of Roadrunner Technologies Ltd.". Trademarks work alongside the OFL and are not subject to the terms of the licensing agreement. The OFL does not grant any rights under trademark law. Bear in mind that trademark law varies from country to country and that there are no international trademark conventions as there are for copyright. You may need to significantly invest in registering and defending a trademark for it to remain valid in the countries you are interested in. This may be costly for an individual independent designer.
|
||||
|
||||
3.8 If I commit changes to a font (or publish a branch in a DVCS) as part of a public open source software project, do I have to change the internal font names?
|
||||
Only if there are declared RFNs. Making a public commit or publishing a public branch is effectively redistributing your modifications, so any change to the font will require that you do not use the RFNs. Even if there are no RFNs, it may be useful to change the name or add a suffix indicating that a particular version of the font is still in development and not released yet. This will clearly indicate to users and fellow designers that this particular font is not ready for release yet. See section 5 for more details.
|
||||
|
||||
|
||||
4 LICENSING YOUR ORIGINAL FONTS UNDER THE OFL
|
||||
|
||||
4.1 Can I use the SIL OFL for my own fonts?
|
||||
Yes! We heartily encourage everyone to use the OFL to distribute their own original fonts. It is a carefully constructed license that allows great freedom along with enough artistic integrity protection for the work of the authors as well as clear rules for other contributors and those who redistribute the fonts. The licensing model is used successfully by various organisations, both for-profit and not-for-profit, to release fonts of varying levels of scope and complexity.
|
||||
|
||||
4.2 What do I have to do to apply the OFL to my font?
|
||||
If you want to release your fonts under the OFL, we recommend you do the following:
|
||||
|
||||
4.2.1 Put your copyright and Reserved Font Names information at the beginning of the main OFL.txt file in place of the dedicated placeholders (marked with the <> characters). Include this file in your release package.
|
||||
|
||||
4.2.2 Put your copyright and the OFL text with your chosen Reserved Font Name(s) into your font files (the copyright and license fields). A link to the OFL text on the OFL web site is an acceptable (but not recommended) alternative. Also add this information to any other components (build scripts, glyph databases, documentation, test files, etc). Accurate metadata in your font files is beneficial to you as an increasing number of applications are exposing this information to the user. For example, clickable links can bring users back to your website and let them know about other work you have done or services you provide. Depending on the format of your fonts and sources, you can use template human-readable headers or machine-readable metadata. You should also double-check that there is no conflicting metadata in the font itself contradicting the license, such as the fstype bits in the os2 table or fields in the name table.
|
||||
|
||||
4.2.3 Write an initial FONTLOG.txt for your font and include it in the release package (see Section 6 and Appendix A for details including a template).
|
||||
|
||||
4.2.4 Include the relevant practical documentation on the license by adding the current OFL-FAQ.txt file in your package.
|
||||
|
||||
4.2.5 If you wish you can use the OFL graphics (http://scripts.sil.org/OFL_logo) on your website.
|
||||
|
||||
4.3 Will you make my font OFL for me?
|
||||
We won't do the work for you. We can, however, try to answer your questions, unfortunately we do not have the resources to review and check your font packages for correct use of the OFL. We recommend you turn to designers, foundries or consulting companies with experience in doing open font design to provide this service to you.
|
||||
|
||||
4.4 Will you distribute my OFL font for me?
|
||||
No, although if the font is of sufficient quality and general interest we may include a link to it on our partial list of OFL fonts on the OFL web site. You may wish to consider other open font catalogs or hosting services, such as the Unifont Font Guide (http://unifont.org/fontguide), The League of Movable Type (http://theleagueofmovabletype.com) or the Open Font Library (http://openfontlibrary.org/), which despite the name has no direct relationship to the OFL or SIL. We do not endorse any particular catalog or hosting service - it is your responsibility to determine if the service is right for you and if it treats authors with fairness.
|
||||
|
||||
4.5 Why should I use the OFL for my fonts?
|
||||
- to meet needs for fonts that can be modified to support lesser-known languages
|
||||
- to provide a legal and clear way for people to respect your work but still use it (and reduce piracy)
|
||||
- to involve others in your font project
|
||||
- to enable your fonts to be expanded with new weights and improved writing system/language support
|
||||
- to allow more technical font developers to add features to your design (such as OpenType, Graphite or AAT support)
|
||||
- to renew the life of an old font lying on your hard drive with no business model
|
||||
- to allow your font to be included in Libre Software operating systems like Ubuntu
|
||||
- to give your font world status and wide, unrestricted distribution
|
||||
- to educate students about quality typeface and font design
|
||||
- to expand your test base and get more useful feedback
|
||||
- to extend your reach to new markets when users see your metadata and go to your website
|
||||
- to get your font more easily into one of the web font online services
|
||||
- to attract attention for your commercial fonts
|
||||
- to make money through web font services
|
||||
- to make money by bundling fonts with applications
|
||||
- to make money adjusting and extending existing open fonts
|
||||
- to get a better chance that foundations/NGOs/charities/companies who commission fonts will pick you
|
||||
- to be part of a sharing design and development community
|
||||
- to give back and contribute to a growing body of font sources
|
||||
|
||||
|
||||
5 CHOOSING RESERVED FONT NAMES
|
||||
|
||||
5.1 What are Reserved Font Names?
|
||||
These are font names, or portions of font names, that the author has chosen to reserve for use only with the Original Version of the font, or for Modified Version(s) created by the original author.
|
||||
|
||||
5.2 Why can't I use the Reserved Font Names in my derivative font names? I'd like people to know where the design came from.
|
||||
The best way to acknowledge the source of the design is to thank the original authors and any other contributors in the files that are distributed with your revised font (although no acknowledgement is required). The FONTLOG is a natural place to do this. Reserved Font Names ensure that the only fonts that have the original names are the unmodified Original Versions. This allows designers to maintain artistic integrity while allowing collaboration to happen. It eliminates potential confusion and name conflicts. When choosing a name, be creative and avoid names that reuse almost all the same letters in the same order or sound like the original. It will help everyone if Original Versions and Modified Versions can easily be distinguished from one another and from other derivatives. Any substitution and matching mechanism is outside the scope of the license.
|
||||
|
||||
5.3 What do you mean by "primary name as presented to the user"? Are you referring to the font menu name?
|
||||
Yes, this applies to the font menu name and other mechanisms that specify a font in a document. It would be fine, however, to keep a text reference to the original fonts in the description field, in your modified source file or in documentation provided alongside your derivative as long as no one could be confused that your modified source is the original. But you cannot use the Reserved Font Names in any way to identify the font to the user (unless the Copyright Holder(s) allow(s) it through a separate agreement). Users who install derivatives (Modified Versions) on their systems should not see any of the original Reserved Font Names in their font menus, for example. Again, this is to ensure that users are not confused and do not mistake one font for another and so expect features only another derivative or the Original Version can actually offer.
|
||||
|
||||
5.4 Am I not allowed to use any part of the Reserved Font Names?
|
||||
You may not use individual words from the Reserved Font Names, but you would be allowed to use parts of words, as long as you do not use any word from the Reserved Font Names entirely. We do not recommend using parts of words because of potential confusion, but it is allowed. For example, if "Foobar" was a Reserved Font Name, you would be allowed to use "Foo" or "bar", although we would not recommend it. Such an unfortunate choice would confuse the users of your fonts as well as make it harder for other designers to contribute.
|
||||
|
||||
5.5 So what should I, as an author, identify as Reserved Font Names?
|
||||
Original authors are encouraged to name their fonts using clear, distinct names, and only declare the unique parts of the name as Reserved Font Names. For example, the author of a font called "Foobar Sans" would declare "Foobar" as a Reserved Font Name, but not "Sans", as that is a common typographical term, and may be a useful word to use in a derivative font name. Reserved Font Names should also be single words for simplicity and legibility. A font called "Flowing River" should have Reserved Font Names "Flowing" and "River", not "Flowing River". You also need to be very careful about reserving font names which are already linked to trademarks (whether registered or not) which you do not own.
|
||||
|
||||
5.6 Do I, as an author, have to identify any Reserved Font Names?
|
||||
No. RFNs are optional and not required, but we encourage you to use them. This is primarily to avoid confusion between your work and Modified Versions. As an author you can release a font under the OFL and not declare any Reserved Font Names. There may be situations where you find that using no RFNs and letting your font be changed and modified - including any kind of modification - without having to change the original name is desirable. However you need to be fully aware of the consequences. There will be no direct way for end-users and other designers to distinguish your Original Version from many Modified Versions that may be created. You have to trust whoever is making the changes and the optimizations to not introduce problematic changes. The RFNs you choose for your own creation have value to you as an author because they allow you to maintain artistic integrity and keep some control over the distribution channel to your end-users. For discussion of RFNs and web fonts see section 2.
|
||||
|
||||
5.7 Are any names (such as the main font name) reserved by default?
|
||||
No. That is a change to the license as of version 1.1. If you want any names to be Reserved Font Names, they must be specified after the copyright statement(s).
|
||||
|
||||
5.8 Is there any situation in which I can use Reserved Font Names for a Modified Version?
|
||||
The Copyright Holder(s) can give certain trusted parties the right to use any of the Reserved Font Names through separate written agreements. For example, even if "Foobar" is a RFN, you could write up an agreement to give company "XYZ" the right to distribute a modified version with a name that includes "Foobar". This allows for freedom without confusion. The existence of such an agreement should be made as clear as possible to downstream users and designers in the distribution package and the relevant documentation. They need to know if they are a party to the agreement or not and what they are practically allowed to do or not even if all the details of the agreement are not public.
|
||||
|
||||
5.9 Do font rebuilds require a name change? Do I have to change the name of the font when my packaging workflow includes a full rebuild from source?
|
||||
Yes, all rebuilds which change the font data and the smart code are Modified Versions and the requirements of the OFL apply: you need to respect what the Author(s) have chosen in terms of Reserved Font Names. However if a package (or installer) is simply a wrapper or a compressed structure around the final font - leaving them intact on the inside - then no name change is required. Please get in touch with the author(s) and copyright holder(s) to inquire about the presence of font sources beyond the final font file(s) and the recommended build path. That build path may very well be non-trivial and hard to reproduce accurately by the maintainer. If a full font build path is made available by the upstream author(s) please be aware that any regressions and changes you may introduce when doing a rebuild for packaging purposes is your own responsibility as a package maintainer since you are effectively creating a separate branch. You should make it very clear to your users that your rebuilt version is not the canonical one from upstream.
|
||||
|
||||
5.10 Can I add other Reserved Font Names when making a derivative font?
|
||||
Yes. List your additional Reserved Font Names after your additional copyright statement, as indicated with example placeholders at the top of the OFL.txt file. Be sure you do not remove any existing RFNs but only add your own. RFN statements should be placed next to the copyright statement of the relevant author as indicated in the OFL.txt template to make them visible to designers wishing to make their separate version.
|
||||
|
||||
|
||||
6 ABOUT THE FONTLOG
|
||||
|
||||
6.1 What is this FONTLOG thing exactly?
|
||||
It has three purposes: 1) to provide basic information on the font to users and other designers and developers, 2) to document changes that have been made to the font or accompanying files, either by the original authors or others, and 3) to provide a place to acknowledge authors and other contributors. Please use it!
|
||||
|
||||
6.2 Is the FONTLOG required?
|
||||
It is not a requirement of the license, but we strongly recommend you have one.
|
||||
|
||||
6.3 Am I required to update the FONTLOG when making Modified Versions?
|
||||
No, but users, designers and other developers might get very frustrated with you if you don't. People need to know how derivative fonts differ from the original, and how to take advantage of the changes, or build on them. There are utilities that can help create and maintain a FONTLOG, such as the FONTLOG support in FontForge.
|
||||
|
||||
6.4 What should the FONTLOG look like?
|
||||
It is typically a separate text file (FONTLOG.txt), but can take other formats. It commonly includes these four sections:
|
||||
|
||||
- brief header describing the FONTLOG itself and name of the font family
|
||||
- Basic Font Information - description of the font family, purpose and breadth
|
||||
- ChangeLog - chronological listing of changes
|
||||
- Acknowledgements - list of authors and contributors with contact information
|
||||
|
||||
It could also include other sections, such as: where to find documentation, how to make contributions, information on contributing organizations, source code details, and a short design guide. See Appendix A for an example FONTLOG.
|
||||
|
||||
|
||||
7 MAKING CONTRIBUTIONS TO OFL PROJECTS
|
||||
|
||||
7.1 Can I contribute work to OFL projects?
|
||||
In many cases, yes. It is common for OFL fonts to be developed by a team of people who welcome contributions from the wider community. Contact the original authors for specific information on how to participate in their projects.
|
||||
|
||||
7.2 Why should I contribute my changes back to the original authors?
|
||||
It would benefit many people if you contributed back in response to what you've received. Your contributions and improvements to the fonts and other components could be a tremendous help and would encourage others to contribute as well and 'give back'. You will then benefit from other people's contributions as well. Sometimes maintaining your own separate version takes more effort than merging back with the original. Be aware that any contributions, however, must be either your own original creation or work that you own, and you may be asked to affirm that clearly when you contribute.
|
||||
|
||||
7.3 I've made some very nice improvements to the font. Will you consider adopting them and putting them into future Original Versions?
|
||||
Most authors would be very happy to receive such contributions. Keep in mind that it is unlikely that they would want to incorporate major changes that would require additional work on their end. Any contributions would likely need to be made for all the fonts in a family and match the overall design and style. Authors are encouraged to include a guide to the design with the fonts. It would also help to have contributions submitted as patches or clearly marked changes - the use of smart source revision control systems like subversion, mercurial, git or bzr is a good idea. Please follow the recommendations given by the author(s) in terms of preferred source formats and configuration parameters for sending contributions. If this is not indicated in a FONTLOG or other documentation of the font, consider asking them directly. Examples of useful contributions are bug fixes, additional glyphs, stylistic alternates (and the smart font code to access them) or improved hinting. Keep in mind that some kinds of changes (esp. hinting) may be technically difficult to integrate.
|
||||
|
||||
7.4 How can I financially support the development of OFL fonts?
|
||||
It is likely that most authors of OFL fonts would accept financial contributions - contact them for instructions on how to do this. Such contributions would support future development. You can also pay for others to enhance the fonts and contribute the results back to the original authors for inclusion in the Original Version.
|
||||
|
||||
|
||||
8 ABOUT THE LICENSE ITSELF
|
||||
|
||||
8.1 I see that this is version 1.1 of the license. Will there be later changes?
|
||||
Version 1.1 is the first minor revision of the OFL. We are confident that version 1.1 will meet most needs, but are open to future improvements. Any revisions would be for future font releases, and previously existing licenses would remain in effect. No retroactive changes are possible, although the Copyright Holder(s) can re-release the font under a revised OFL. All versions will be available on our web site: http://scripts.sil.org/OFL.
|
||||
|
||||
8.2 Does this license restrict the rights of the Copyright Holder(s)?
|
||||
No. The Copyright Holder(s) still retain(s) all the rights to their creation; they are only releasing a portion of it for use in a specific way. For example, the Copyright Holder(s) may choose to release a 'basic' version of their font under the OFL, but sell a restricted 'enhanced' version under a different license. They may also choose to release the same font under both the OFL and some other license. Only the Copyright Holder(s) can do this, and doing so does not change the terms of the OFL as it applies to that font.
|
||||
|
||||
8.3 Is the OFL a contract or a license?
|
||||
The OFL is a worldwide license based on international copyright agreements and conventions. It is not a contract and so does not require you to sign it to have legal validity. By using, modifying and redistributing components under the OFL you indicate that you accept the license.
|
||||
|
||||
8.4 I really like the terms of the OFL, but want to change it a little. Am I allowed to take ideas and actual wording from the OFL and put them into my own custom license for distributing my fonts?
|
||||
We strongly recommend against creating your very own unique open licensing model. Using a modified or derivative license will likely cut you off - along with the font(s) under that license - from the community of designers using the OFL, potentially expose you and your users to legal liabilities, and possibly put your work and rights at risk. The OFL went though a community and legal review process that took years of effort, and that review is only applicable to an unmodified OFL. The text of the OFL has been written by SIL (with review and consultation from the community) and is copyright (c) 2005-2017 SIL International. You may re-use the ideas and wording (in part, not in whole) in another non-proprietary license provided that you call your license by another unambiguous name, that you do not use the preamble, that you do not mention SIL and that you clearly present your license as different from the OFL so as not to cause confusion by being too similar to the original. If you feel the OFL does not meet your needs for an open license, please contact us.
|
||||
|
||||
8.5 Can I quote from the OFL FAQ?
|
||||
Yes, SIL gives permission to quote from the OFL FAQ (OFL-FAQ.txt), in whole or in part, provided that the quoted text is:
|
||||
|
||||
- unmodified,
|
||||
- used to help explain the intent of the OFL, rather than cause misunderstanding, and
|
||||
- accompanied with the following attribution: "From the OFL FAQ (OFL-FAQ.txt), copyright (c) 2005-2020 SIL International. Used by permission. http://scripts.sil.org/OFL-FAQ_web".
|
||||
|
||||
8.6 Can I translate the license and the FAQ into other languages?
|
||||
SIL certainly recognises the need for people who are not familiar with English to be able to understand the OFL and its use. Making the license very clear and readable has been a key goal for the OFL, but we know that people understand their own language best.
|
||||
|
||||
If you are an experienced translator, you are very welcome to translate the OFL and OFL-FAQ so that designers and users in your language community can understand the license better. But only the original English version of the license has legal value and has been approved by the community. Translations do not count as legal substitutes and should only serve as a way to explain the original license. SIL - as the author and steward of the license for the community at large - does not approve any translation of the OFL as legally valid because even small translation ambiguities could be abused and create problems.
|
||||
|
||||
SIL gives permission to publish unofficial translations into other languages provided that they comply with the following guidelines:
|
||||
|
||||
- Put the following disclaimer in both English and the target language stating clearly that the translation is unofficial:
|
||||
|
||||
"This is an unofficial translation of the SIL Open Font License into <language_name>. It was not published by SIL International, and does not legally state the distribution terms for fonts that use the OFL. A release under the OFL is only valid when using the original English text. However, we recognize that this unofficial translation will help users and designers not familiar with English to better understand and use the OFL. We encourage designers who consider releasing their creation under the OFL to read the OFL-FAQ in their own language if it is available. Please go to http://scripts.sil.org/OFL for the official version of the license and the accompanying OFL-FAQ."
|
||||
|
||||
- Keep your unofficial translation current and update it at our request if needed, for example if there is any ambiguity which could lead to confusion.
|
||||
|
||||
If you start such a unofficial translation effort of the OFL and OFL-FAQ please let us know.
|
||||
|
||||
8.7 Does the OFL have an explicit expiration term?
|
||||
No, the implicit intent of the OFL is that the permissions granted are perpetual and irrevocable.
|
||||
|
||||
|
||||
9 ABOUT SIL INTERNATIONAL
|
||||
|
||||
9.1 Who is SIL International and what do they do?
|
||||
SIL serves language communities worldwide, building their capacity for sustainable language development, by means of research, translation, training and materials development. SIL makes its services available to all without regard to religious belief, political ideology, gender, race, or ethnic background. SIL's members and volunteers share a Christian commitment.
|
||||
|
||||
9.2 What does this have to do with font licensing?
|
||||
The ability to read, write, type and publish in one's own language is one of the most critical needs for millions of people around the world. This requires fonts that are widely available and support lesser-known languages. SIL develops - and encourages others to develop - a complete stack of writing systems implementation components available under open licenses. This open stack includes input methods, smart fonts, smart rendering libraries and smart applications. There has been a need for a common open license that is specifically applicable to fonts and related software (a crucial component of this stack), so SIL developed the SIL Open Font License with the help of the Free/Libre and Open Source Software community.
|
||||
|
||||
9.3 How can I contact SIL?
|
||||
Our main web site is: http://www.sil.org/
|
||||
Our site about complex scripts is: http://scripts.sil.org/
|
||||
Information about this license (and contact information) is at: http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
APPENDIX A - FONTLOG EXAMPLE
|
||||
|
||||
Here is an example of the recommended format for a FONTLOG, although other formats are allowed.
|
||||
|
||||
-----
|
||||
FONTLOG for the GlobalFontFamily fonts
|
||||
|
||||
This file provides detailed information on the GlobalFontFamily Font Software. This information should be distributed along with the GlobalFontFamily fonts and any derivative works.
|
||||
|
||||
Basic Font Information
|
||||
|
||||
GlobalFontFamily is a Unicode typeface family that supports all languages that use the Latin script and its variants, and could be expanded to support other scripts.
|
||||
|
||||
NewWorldFontFamily is based on the GlobalFontFamily and also supports Greek, Hebrew, Cyrillic and Armenian.
|
||||
|
||||
More specifically, this release supports the following Unicode ranges...
|
||||
This release contains...
|
||||
Documentation can be found at...
|
||||
To contribute to the project...
|
||||
|
||||
ChangeLog
|
||||
|
||||
10 December 2010 (Fred Foobar) GlobalFontFamily-devel version 1.4
|
||||
- fix new build and testing system (bug #123456)
|
||||
|
||||
1 August 2008 (Tom Parker) GlobalFontFamily version 1.2.1
|
||||
- Tweaked the smart font code (Branch merged with trunk version)
|
||||
- Provided improved build and debugging environment for smart behaviours
|
||||
|
||||
7 February 2007 (Pat Johnson) NewWorldFontFamily Version 1.3
|
||||
- Added Greek and Cyrillic glyphs
|
||||
|
||||
7 March 2006 (Fred Foobar) NewWorldFontFamily Version 1.2
|
||||
- Tweaked contextual behaviours
|
||||
|
||||
1 Feb 2005 (Jane Doe) NewWorldFontFamily Version 1.1
|
||||
- Improved build script performance and verbosity
|
||||
- Extended the smart code documentation
|
||||
- Corrected minor typos in the documentation
|
||||
- Fixed position of combining inverted breve below (U+032F)
|
||||
- Added OpenType/Graphite smart code for Armenian
|
||||
- Added Armenian glyphs (U+0531 -> U+0587)
|
||||
- Released as "NewWorldFontFamily"
|
||||
|
||||
1 Jan 2005 (Joe Smith) GlobalFontFamily Version 1.0
|
||||
- Initial release
|
||||
|
||||
Acknowledgements
|
||||
|
||||
If you make modifications be sure to add your name (N), email (E), web-address (if you have one) (W) and description (D). This list is in alphabetical order.
|
||||
|
||||
N: Jane Doe
|
||||
E: jane@university.edu
|
||||
W: http://art.university.edu/projects/fonts
|
||||
D: Contributor - Armenian glyphs and code
|
||||
|
||||
N: Fred Foobar
|
||||
E: fred@foobar.org
|
||||
W: http://foobar.org
|
||||
D: Contributor - misc Graphite fixes
|
||||
|
||||
N: Pat Johnson
|
||||
E: pat@fontstudio.org
|
||||
W: http://pat.fontstudio.org
|
||||
D: Designer - Greek & Cyrillic glyphs based on Roman design
|
||||
|
||||
N: Tom Parker
|
||||
E: tom@company.com
|
||||
W: http://www.company.com/tom/projects/fonts
|
||||
D: Engineer - original smart font code
|
||||
|
||||
N: Joe Smith
|
||||
E: joe@fontstudio.org
|
||||
W: http://joe.fontstudio.org
|
||||
D: Designer - original Roman glyphs
|
||||
|
||||
Fontstudio.org is an not-for-profit design group whose purpose is...
|
||||
Foobar.org is a distributed community of developers...
|
||||
Company.com is a small business who likes to support community designers...
|
||||
University.edu is a renowned educational institution with a strong design department...
|
||||
-----
|
||||
@@ -3,6 +3,8 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine as _};
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use crate::ffmpeg;
|
||||
|
||||
fn normalize_path(path: &str) -> PathBuf {
|
||||
Path::new(path)
|
||||
.components()
|
||||
@@ -16,6 +18,15 @@ fn is_allowed_local_media(path: &Path) -> bool {
|
||||
if lower.contains("aiclient-voice-preview-cache")
|
||||
|| lower.contains("aiclient-node-pipeline")
|
||||
|| lower.contains("aiclient-generated-speech")
|
||||
|| lower.contains("aiclient-runninghub")
|
||||
|| lower.contains("aiclient-digital-human")
|
||||
|| lower.contains("generated_audios")
|
||||
|| lower.contains("generated_videos")
|
||||
|| lower.contains("aiclient-video-edit")
|
||||
|| lower.contains("aiclient-subtitle-bgm")
|
||||
|| lower.contains("aiclient-cover")
|
||||
|| lower.contains("aiclient-cover-previews")
|
||||
|| lower.contains("avatars")
|
||||
{
|
||||
return true;
|
||||
}
|
||||
@@ -45,3 +56,21 @@ pub async fn read_local_file_base64(path: String) -> Result<String, String> {
|
||||
.map_err(|e| format!("读取文件失败: {e}"))?;
|
||||
Ok(STANDARD.encode(bytes))
|
||||
}
|
||||
|
||||
/// 获取本地音视频时长(秒),供口播视频云端任务对齐音频长度。
|
||||
#[tauri::command]
|
||||
pub async fn get_media_duration_seconds(path: String) -> Result<f64, String> {
|
||||
let normalized = normalize_path(path.trim());
|
||||
if normalized.as_os_str().is_empty() {
|
||||
return Err("路径为空".into());
|
||||
}
|
||||
if !normalized.is_file() {
|
||||
return Err(format!("文件不存在: {}", normalized.display()));
|
||||
}
|
||||
if !is_allowed_local_media(&normalized) {
|
||||
return Err("不允许读取该路径下的文件".into());
|
||||
}
|
||||
ffmpeg::probe_media_duration(&normalized)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
@@ -6,4 +6,5 @@ pub mod avatar;
|
||||
pub mod video;
|
||||
pub mod fs_util;
|
||||
pub mod nodejs;
|
||||
pub mod publish;
|
||||
pub mod quickjs;
|
||||
|
||||
@@ -63,7 +63,7 @@ fn log_node_event(script_name: &str, level: &str, msg: &str, fields: &Option<Val
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_event_pump(
|
||||
pub(crate) fn spawn_event_pump(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
script_name: String,
|
||||
|
||||
276
src-tauri/src/commands/publish.rs
Normal file
276
src-tauri/src/commands/publish.rs
Normal file
@@ -0,0 +1,276 @@
|
||||
//! 视频发布:账号管理与 Node 发布脚本桥接
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tauri::{AppHandle, Manager, State, Window};
|
||||
|
||||
use crate::app_config::AppConfig;
|
||||
use crate::publish_db::PlatformAccountRecord;
|
||||
use crate::publish_store::PublishStore;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishLoginParams {
|
||||
pub platform: String,
|
||||
pub account_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishCheckLoginParams {
|
||||
pub platform: String,
|
||||
pub account_id: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub check_browser: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishPlatformItem {
|
||||
pub platform: String,
|
||||
pub account_id: Option<i64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PublishExecuteParams {
|
||||
pub platforms: Vec<PublishPlatformItem>,
|
||||
pub video_path: String,
|
||||
pub cover_path: String,
|
||||
pub title: String,
|
||||
pub description: Option<String>,
|
||||
pub tags: Option<Vec<String>>,
|
||||
#[serde(default)]
|
||||
pub auto_publish: bool,
|
||||
}
|
||||
|
||||
async fn run_publish_script(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
script_name: &str,
|
||||
params: Value,
|
||||
) -> Result<Value, String> {
|
||||
let app_config = app.state::<AppConfig>();
|
||||
let config_env = app_config.env_for_node().await;
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
|
||||
let pump = crate::commands::nodejs::spawn_event_pump(
|
||||
app.clone(),
|
||||
window,
|
||||
script_name.to_string(),
|
||||
rx,
|
||||
);
|
||||
let result = crate::nodejs::run_node_script(
|
||||
script_name.to_string(),
|
||||
params,
|
||||
tx,
|
||||
config_env,
|
||||
)
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
pump.await.ok();
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_list_accounts(
|
||||
store: State<'_, PublishStore>,
|
||||
platform: Option<String>,
|
||||
) -> Result<Vec<PlatformAccountRecord>, String> {
|
||||
store.list(platform).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_delete_account(
|
||||
store: State<'_, PublishStore>,
|
||||
account_id: i64,
|
||||
) -> Result<(), String> {
|
||||
store.delete(account_id).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_login(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
store: State<'_, PublishStore>,
|
||||
app_config: State<'_, AppConfig>,
|
||||
params: PublishLoginParams,
|
||||
) -> Result<Value, String> {
|
||||
let _ = app_config;
|
||||
let result = run_publish_script(
|
||||
app,
|
||||
window,
|
||||
"publish_login.js",
|
||||
json!({
|
||||
"platform": params.platform,
|
||||
"accountId": params.account_id,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if result.get("success").and_then(|v| v.as_bool()) == Some(true) {
|
||||
let nickname = result
|
||||
.get("nickname")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let uid = result
|
||||
.get("uid")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let cookies = result
|
||||
.get("cookies")
|
||||
.map(|v| serde_json::to_string(v).unwrap_or_else(|_| "[]".to_string()))
|
||||
.unwrap_or_else(|| "[]".to_string());
|
||||
let rec = store
|
||||
.upsert_login(
|
||||
params.platform.clone(),
|
||||
nickname,
|
||||
uid,
|
||||
cookies,
|
||||
params.account_id,
|
||||
)
|
||||
.await?;
|
||||
return Ok(json!({
|
||||
"success": true,
|
||||
"message": result.get("message").and_then(|v| v.as_str()).unwrap_or("登录成功"),
|
||||
"account": rec,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_check_login(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
store: State<'_, PublishStore>,
|
||||
params: PublishCheckLoginParams,
|
||||
) -> Result<Value, String> {
|
||||
if !params.check_browser {
|
||||
let accounts = store.list(Some(params.platform.clone())).await?;
|
||||
let account = if let Some(id) = params.account_id {
|
||||
accounts.into_iter().find(|a| a.id == id)
|
||||
} else {
|
||||
accounts
|
||||
.into_iter()
|
||||
.find(|a| a.login_status == "active" && a.has_cookies)
|
||||
};
|
||||
if let Some(acc) = account {
|
||||
return Ok(json!({
|
||||
"success": true,
|
||||
"isLoggedIn": acc.login_status == "active" && acc.has_cookies,
|
||||
"userInfo": { "nickname": acc.nickname, "userId": acc.uid },
|
||||
}));
|
||||
}
|
||||
return Ok(json!({
|
||||
"success": true,
|
||||
"isLoggedIn": false,
|
||||
}));
|
||||
}
|
||||
|
||||
let mut cookies = json!([]);
|
||||
if let Some(id) = params.account_id {
|
||||
if let Some((_, c)) = store.get_with_cookies(id).await? {
|
||||
cookies = serde_json::from_str(&c).unwrap_or(json!([]));
|
||||
}
|
||||
} else if let Ok(list) = store.list(Some(params.platform.clone())).await {
|
||||
if let Some(active) = list
|
||||
.iter()
|
||||
.find(|a| a.login_status == "active" && a.has_cookies)
|
||||
{
|
||||
if let Some((_, c)) = store.get_with_cookies(active.id).await? {
|
||||
cookies = serde_json::from_str(&c).unwrap_or(json!([]));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let result = run_publish_script(
|
||||
app,
|
||||
window,
|
||||
"publish_check_login.js",
|
||||
json!({
|
||||
"platform": params.platform,
|
||||
"accountId": params.account_id,
|
||||
"cookies": cookies,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
|
||||
if result.get("isLoggedIn").and_then(|v| v.as_bool()) == Some(true) {
|
||||
if let (Some(nickname), Some(account_id)) = (
|
||||
result
|
||||
.get("userInfo")
|
||||
.and_then(|u| u.get("nickname"))
|
||||
.and_then(|v| v.as_str()),
|
||||
params.account_id,
|
||||
) {
|
||||
let _ = store.set_login_status(account_id, "active".to_string()).await;
|
||||
if let Ok(Some((mut rec, _))) = store.get_with_cookies(account_id).await {
|
||||
if !nickname.is_empty() {
|
||||
rec.nickname = nickname.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if let Some(account_id) = params.account_id {
|
||||
let _ = store.set_login_status(account_id, "inactive".to_string()).await;
|
||||
}
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn publish_execute(
|
||||
app: AppHandle,
|
||||
window: Window,
|
||||
store: State<'_, PublishStore>,
|
||||
params: PublishExecuteParams,
|
||||
) -> Result<Value, String> {
|
||||
let mut platform_payloads = Vec::new();
|
||||
for item in ¶ms.platforms {
|
||||
let account_id = item.account_id;
|
||||
let (rec, cookies) = if let Some(id) = account_id {
|
||||
store
|
||||
.get_with_cookies(id)
|
||||
.await?
|
||||
.ok_or_else(|| format!("未找到账号 id={id}"))?
|
||||
} else {
|
||||
let list = store.list(Some(item.platform.clone())).await?;
|
||||
let active = list
|
||||
.into_iter()
|
||||
.find(|a| a.login_status == "active" && a.has_cookies)
|
||||
.ok_or_else(|| format!("平台 {} 无已登录账号", item.platform))?;
|
||||
let full = store
|
||||
.get_with_cookies(active.id)
|
||||
.await?
|
||||
.ok_or_else(|| "读取账号失败".to_string())?;
|
||||
full
|
||||
};
|
||||
if rec.login_status != "active" {
|
||||
return Err(format!("账号 {} 未登录", rec.nickname));
|
||||
}
|
||||
platform_payloads.push(json!({
|
||||
"platform": item.platform,
|
||||
"accountId": rec.id,
|
||||
"cookies": serde_json::from_str::<Value>(&cookies).unwrap_or(json!([])),
|
||||
"nickname": rec.nickname,
|
||||
}));
|
||||
}
|
||||
|
||||
run_publish_script(
|
||||
app,
|
||||
window,
|
||||
"video_publish.js",
|
||||
json!({
|
||||
"platforms": platform_payloads,
|
||||
"videoPath": params.video_path,
|
||||
"coverPath": params.cover_path,
|
||||
"title": params.title,
|
||||
"description": params.description.unwrap_or_default(),
|
||||
"tags": params.tags.unwrap_or_default(),
|
||||
"autoPublish": params.auto_publish,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
}
|
||||
98
src-tauri/src/cover_python.rs
Normal file
98
src-tauri/src/cover_python.rs
Normal file
@@ -0,0 +1,98 @@
|
||||
//! 封面 Python 运行时与脚本目录定位(对齐 Electron bundled python-runtime)
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
fn exe_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"python.exe"
|
||||
} else {
|
||||
"python3"
|
||||
}
|
||||
}
|
||||
|
||||
/// bundled `python-runtimebackup` 或系统 / 环境变量 Python。
|
||||
pub fn locate_python() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("AICLIENT_PYTHON_PATH") {
|
||||
let pb = PathBuf::from(&p);
|
||||
if pb.is_file() {
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
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);
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 封面脚本目录(`advanced_cover_generator.py` 等)。
|
||||
pub fn locate_cover_scripts_dir() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("AICLIENT_COVER_SCRIPTS_DIR") {
|
||||
let pb = PathBuf::from(&p);
|
||||
if pb.join("advanced_cover_generator.py").is_file() {
|
||||
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();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 将 ffmpeg 所在目录 prepend 到 PATH(供 Python 子进程调用 ffmpeg)。
|
||||
pub fn prepend_ffmpeg_to_path(env_path: &str) -> String {
|
||||
let sep = if cfg!(windows) { ";" } else { ":" };
|
||||
if let Some(ffmpeg) = crate::ffmpeg::locate_ffmpeg() {
|
||||
if let Some(dir) = ffmpeg.parent() {
|
||||
return format!("{}{}{}", dir.display(), sep, env_path);
|
||||
}
|
||||
}
|
||||
env_path.to_string()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cover_scripts_dev_path_exists() {
|
||||
let dev = Path::new("src-tauri/resources/cover-python/advanced_cover_generator.py");
|
||||
if dev.is_file() {
|
||||
assert!(locate_cover_scripts_dir().is_some());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,57 @@ fn exe_name() -> &'static str {
|
||||
if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }
|
||||
}
|
||||
|
||||
fn ffprobe_exe_name() -> &'static str {
|
||||
if cfg!(windows) {
|
||||
"ffprobe.exe"
|
||||
} else {
|
||||
"ffprobe"
|
||||
}
|
||||
}
|
||||
|
||||
/// 与 `locate_ffmpeg` 相同目录策略,优先 bundled `binaries/ffprobe`。
|
||||
pub fn locate_ffprobe() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("AICLIENT_FFPROBE_PATH") {
|
||||
let pb = PathBuf::from(p);
|
||||
if pb.exists() {
|
||||
return Some(pb);
|
||||
}
|
||||
}
|
||||
if let Some(ffmpeg) = locate_ffmpeg() {
|
||||
if let Some(dir) = ffmpeg.parent() {
|
||||
let sibling = dir.join(ffprobe_exe_name());
|
||||
if sibling.exists() {
|
||||
return Some(sibling);
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Ok(exe) = std::env::current_exe() {
|
||||
if let Some(dir) = exe.parent() {
|
||||
for candidate in [
|
||||
dir.join("binaries").join(ffprobe_exe_name()),
|
||||
dir.join(ffprobe_exe_name()),
|
||||
dir.join("..")
|
||||
.join("..")
|
||||
.join("binaries")
|
||||
.join(ffprobe_exe_name()),
|
||||
] {
|
||||
if candidate.exists() {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
for dev in [
|
||||
PathBuf::from("src-tauri/binaries").join(ffprobe_exe_name()),
|
||||
PathBuf::from("binaries").join(ffprobe_exe_name()),
|
||||
] {
|
||||
if dev.exists() {
|
||||
return Some(dev);
|
||||
}
|
||||
}
|
||||
Some(PathBuf::from(ffprobe_exe_name()))
|
||||
}
|
||||
|
||||
pub fn locate_ffmpeg() -> Option<PathBuf> {
|
||||
if let Ok(p) = std::env::var("AICLIENT_FFMPEG_PATH") {
|
||||
let pb = PathBuf::from(p);
|
||||
@@ -86,3 +137,62 @@ pub async fn extract_audio(video_path: &Path, dest_wav: &Path) -> Result<(), Ffm
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_duration_hms(text: &str) -> Option<f64> {
|
||||
let marker = "Duration:";
|
||||
let idx = text.find(marker)?;
|
||||
let rest = text[idx + marker.len()..].trim_start();
|
||||
let time_part = rest.split(',').next()?.trim();
|
||||
let mut parts = time_part.split(':');
|
||||
let hours: f64 = parts.next()?.parse().ok()?;
|
||||
let minutes: f64 = parts.next()?.parse().ok()?;
|
||||
let seconds: f64 = parts.next()?.parse().ok()?;
|
||||
Some(hours * 3600.0 + minutes * 60.0 + seconds)
|
||||
}
|
||||
|
||||
/// 读取媒体时长(秒),优先 ffprobe,回退解析 `ffmpeg -i` 的 stderr。
|
||||
pub async fn probe_media_duration(path: &Path) -> Result<f64, FfmpegError> {
|
||||
if !path.is_file() {
|
||||
return Err(FfmpegError::Io(std::io::Error::new(
|
||||
std::io::ErrorKind::NotFound,
|
||||
format!("文件不存在: {}", path.display()),
|
||||
)));
|
||||
}
|
||||
|
||||
if let Some(ffprobe) = locate_ffprobe() {
|
||||
let output = Command::new(&ffprobe)
|
||||
.arg("-v")
|
||||
.arg("error")
|
||||
.arg("-show_entries")
|
||||
.arg("format=duration")
|
||||
.arg("-of")
|
||||
.arg("default=noprint_wrappers=1:nokey=1")
|
||||
.arg(path)
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if let Ok(secs) = stdout.trim().parse::<f64>() {
|
||||
if secs > 0.0 {
|
||||
return Ok(secs);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let ffmpeg = locate_ffmpeg().ok_or(FfmpegError::NotFound)?;
|
||||
let output = Command::new(&ffmpeg)
|
||||
.arg("-i")
|
||||
.arg(path)
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::piped())
|
||||
.output()
|
||||
.await?;
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
parse_duration_hms(&stderr).ok_or_else(|| FfmpegError::Failed {
|
||||
status: output.status.code(),
|
||||
stderr: format!("无法解析媒体时长: {}", stderr.chars().take(400).collect::<String>()),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -12,11 +12,14 @@ pub mod asr;
|
||||
pub mod auth_session;
|
||||
pub mod chat;
|
||||
pub mod commands;
|
||||
pub mod cover_python;
|
||||
pub mod ffmpeg;
|
||||
pub mod http;
|
||||
pub mod js_runtime;
|
||||
pub mod nodejs;
|
||||
pub mod oss;
|
||||
pub mod publish_db;
|
||||
pub mod publish_store;
|
||||
|
||||
use tauri::Manager;
|
||||
|
||||
@@ -30,6 +33,7 @@ pub fn run() {
|
||||
.manage(avatar_store::AvatarStore::new())
|
||||
.manage(audio_store::AudioStore::new())
|
||||
.manage(video_store::VideoStore::new())
|
||||
.manage(publish_store::PublishStore::new())
|
||||
.setup(|app| {
|
||||
let handle = app.handle().clone();
|
||||
tauri::async_runtime::block_on(async move {
|
||||
@@ -37,6 +41,7 @@ pub fn run() {
|
||||
let avatar_store = handle.state::<avatar_store::AvatarStore>();
|
||||
let audio_store = handle.state::<audio_store::AudioStore>();
|
||||
let video_store = handle.state::<video_store::VideoStore>();
|
||||
let publish_store = handle.state::<publish_store::PublishStore>();
|
||||
let dir = handle
|
||||
.path()
|
||||
.app_data_dir()
|
||||
@@ -46,6 +51,7 @@ pub fn run() {
|
||||
avatar_store.init(dir.join("avatars.db")).await?;
|
||||
audio_store.init(dir.join("generated_audios.db")).await?;
|
||||
video_store.init(dir.join("generated_videos.db")).await?;
|
||||
publish_store.init(dir.join("publish_accounts.db")).await?;
|
||||
Ok::<(), String>(())
|
||||
})
|
||||
.map_err(|e| -> Box<dyn std::error::Error> { e.into() })?;
|
||||
@@ -77,6 +83,7 @@ pub fn run() {
|
||||
commands::nodejs::run_nodejs_script_source,
|
||||
commands::nodejs::list_nodejs_scripts,
|
||||
commands::fs_util::read_local_file_base64,
|
||||
commands::fs_util::get_media_duration_seconds,
|
||||
commands::avatar::list_avatars,
|
||||
commands::avatar::insert_avatar,
|
||||
commands::avatar::update_avatar_name,
|
||||
@@ -90,7 +97,19 @@ pub fn run() {
|
||||
commands::video::insert_generated_video,
|
||||
commands::video::import_generated_video,
|
||||
commands::video::delete_generated_video,
|
||||
commands::publish::publish_list_accounts,
|
||||
commands::publish::publish_login,
|
||||
commands::publish::publish_check_login,
|
||||
commands::publish::publish_execute,
|
||||
commands::publish::publish_delete_account,
|
||||
])
|
||||
.run(tauri::generate_context!())
|
||||
.expect("error while running tauri application");
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while building tauri application")
|
||||
.run(|app_handle, event| {
|
||||
if let tauri::RunEvent::Ready = event {
|
||||
if let Some(window) = app_handle.get_webview_window("main") {
|
||||
let _ = window.maximize();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -369,6 +369,12 @@ async fn run_script_bundle(
|
||||
cmd.env("AICLIENT_FFMPEG_PATH", ffmpeg_path);
|
||||
}
|
||||
}
|
||||
if let Some(py) = crate::cover_python::locate_python() {
|
||||
cmd.env("AICLIENT_PYTHON_PATH", py);
|
||||
}
|
||||
if let Some(dir) = crate::cover_python::locate_cover_scripts_dir() {
|
||||
cmd.env("AICLIENT_COVER_SCRIPTS_DIR", dir);
|
||||
}
|
||||
let mut child = cmd.spawn()?;
|
||||
|
||||
// 把 params JSON 灌进 stdin
|
||||
|
||||
193
src-tauri/src/publish_db.rs
Normal file
193
src-tauri/src/publish_db.rs
Normal file
@@ -0,0 +1,193 @@
|
||||
//! 平台发布账号 SQLite(对齐 Electron platform_accounts)
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::Serialize;
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum PublishDbError {
|
||||
#[error("{0}")]
|
||||
Db(#[from] rusqlite::Error),
|
||||
#[error("{0}")]
|
||||
Message(String),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct PlatformAccountRecord {
|
||||
pub id: i64,
|
||||
pub platform: String,
|
||||
pub nickname: String,
|
||||
pub uid: String,
|
||||
pub avatar: String,
|
||||
pub login_status: String,
|
||||
pub has_cookies: bool,
|
||||
pub updated_at: i64,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct PublishDb {
|
||||
conn: Mutex<Connection>,
|
||||
}
|
||||
|
||||
impl PublishDb {
|
||||
pub fn open(path: PathBuf) -> Result<Self, PublishDbError> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.map_err(|e| PublishDbError::Message(format!("创建目录失败: {e}")))?;
|
||||
}
|
||||
let conn = Connection::open(path)?;
|
||||
conn.execute_batch(
|
||||
r#"
|
||||
PRAGMA journal_mode = WAL;
|
||||
CREATE TABLE IF NOT EXISTS platform_accounts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
platform TEXT NOT NULL,
|
||||
nickname TEXT NOT NULL DEFAULT '',
|
||||
uid TEXT NOT NULL DEFAULT '',
|
||||
avatar TEXT NOT NULL DEFAULT '',
|
||||
cookies TEXT NOT NULL DEFAULT '[]',
|
||||
tokens TEXT NOT NULL DEFAULT '',
|
||||
login_status TEXT NOT NULL DEFAULT 'inactive',
|
||||
expires_at INTEGER,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_platform_accounts_platform
|
||||
ON platform_accounts(platform);
|
||||
"#,
|
||||
)?;
|
||||
Ok(Self {
|
||||
conn: Mutex::new(conn),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn list(&self, platform: Option<&str>) -> Result<Vec<PlatformAccountRecord>, PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
let (sql, use_platform) = match platform {
|
||||
Some(_) => (
|
||||
"SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at
|
||||
FROM platform_accounts WHERE platform = ?1 ORDER BY updated_at DESC",
|
||||
true,
|
||||
),
|
||||
None => (
|
||||
"SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at
|
||||
FROM platform_accounts ORDER BY platform ASC, updated_at DESC",
|
||||
false,
|
||||
),
|
||||
};
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let map_row = |row: &rusqlite::Row<'_>| {
|
||||
let cookies: String = row.get(5)?;
|
||||
Ok(PlatformAccountRecord {
|
||||
id: row.get(0)?,
|
||||
platform: row.get(1)?,
|
||||
nickname: row.get(2)?,
|
||||
uid: row.get(3)?,
|
||||
avatar: row.get(4)?,
|
||||
login_status: row.get(6)?,
|
||||
has_cookies: cookies.len() > 4,
|
||||
updated_at: row.get(7)?,
|
||||
})
|
||||
};
|
||||
let rows = if use_platform {
|
||||
stmt.query_map(params![platform.unwrap()], map_row)?
|
||||
} else {
|
||||
stmt.query_map([], map_row)?
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
for r in rows {
|
||||
out.push(r?);
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn get(&self, id: i64) -> Result<Option<(PlatformAccountRecord, String)>, PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT id, platform, nickname, uid, avatar, cookies, login_status, updated_at
|
||||
FROM platform_accounts WHERE id = ?1",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![id])?;
|
||||
if let Some(row) = rows.next()? {
|
||||
let cookies: String = row.get(5)?;
|
||||
let rec = PlatformAccountRecord {
|
||||
id: row.get(0)?,
|
||||
platform: row.get(1)?,
|
||||
nickname: row.get(2)?,
|
||||
uid: row.get(3)?,
|
||||
avatar: row.get(4)?,
|
||||
login_status: row.get(6)?,
|
||||
has_cookies: cookies.len() > 4,
|
||||
updated_at: row.get(7)?,
|
||||
};
|
||||
return Ok(Some((rec, cookies)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
pub fn upsert_login(
|
||||
&self,
|
||||
platform: &str,
|
||||
nickname: &str,
|
||||
uid: &str,
|
||||
cookies_json: &str,
|
||||
account_id: Option<i64>,
|
||||
) -> Result<PlatformAccountRecord, PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
let expires = now + 30_i64 * 24 * 60 * 60 * 1000;
|
||||
|
||||
if let Some(id) = account_id {
|
||||
conn.execute(
|
||||
"UPDATE platform_accounts SET nickname = ?1, uid = ?2, cookies = ?3,
|
||||
login_status = 'active', expires_at = ?4, updated_at = ?5 WHERE id = ?6",
|
||||
params![nickname, uid, cookies_json, expires, now, id],
|
||||
)?;
|
||||
return self
|
||||
.get(id)?
|
||||
.map(|(r, _)| r)
|
||||
.ok_or_else(|| PublishDbError::Message("更新账号失败".into()));
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO platform_accounts (platform, nickname, uid, avatar, cookies, tokens,
|
||||
login_status, expires_at, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, '', ?4, '', 'active', ?5, ?6, ?6)",
|
||||
params![platform, nickname, uid, cookies_json, expires, now],
|
||||
)?;
|
||||
let id = conn.last_insert_rowid();
|
||||
self.get(id)?
|
||||
.map(|(r, _)| r)
|
||||
.ok_or_else(|| PublishDbError::Message("插入账号失败".into()))
|
||||
}
|
||||
|
||||
pub fn set_login_status(&self, id: i64, status: &str) -> Result<(), PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
let now = chrono::Utc::now().timestamp_millis();
|
||||
conn.execute(
|
||||
"UPDATE platform_accounts SET login_status = ?1, updated_at = ?2 WHERE id = ?3",
|
||||
params![status, now, id],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete(&self, id: i64) -> Result<(), PublishDbError> {
|
||||
let conn = self.conn.lock().map_err(|_| {
|
||||
PublishDbError::Message("数据库锁异常".into())
|
||||
})?;
|
||||
conn.execute("DELETE FROM platform_accounts WHERE id = ?1", params![id])?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
92
src-tauri/src/publish_store.rs
Normal file
92
src-tauri/src/publish_store.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! 发布账号 Tauri 状态
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
use crate::publish_db::{PlatformAccountRecord, PublishDb};
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct PublishStore {
|
||||
inner: Arc<RwLock<Option<Arc<PublishDb>>>>,
|
||||
}
|
||||
|
||||
impl PublishStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub async fn init(&self, db_path: PathBuf) -> Result<(), String> {
|
||||
let db = tokio::task::spawn_blocking(move || {
|
||||
PublishDb::open(db_path).map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| e.to_string())??;
|
||||
*self.inner.write().await = Some(Arc::new(db));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn with_db<F, T>(&self, f: F) -> Result<T, String>
|
||||
where
|
||||
F: FnOnce(Arc<PublishDb>) -> Result<T, String> + Send + 'static,
|
||||
T: Send + 'static,
|
||||
{
|
||||
let db = self
|
||||
.inner
|
||||
.read()
|
||||
.await
|
||||
.clone()
|
||||
.ok_or_else(|| "发布账号数据库未初始化".to_string())?;
|
||||
tokio::task::spawn_blocking(move || f(db))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
pub async fn list(&self, platform: Option<String>) -> Result<Vec<PlatformAccountRecord>, String> {
|
||||
self.with_db(move |db| {
|
||||
db.list(platform.as_deref())
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_with_cookies(
|
||||
&self,
|
||||
id: i64,
|
||||
) -> Result<Option<(PlatformAccountRecord, String)>, String> {
|
||||
self.with_db(move |db| db.get(id).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn upsert_login(
|
||||
&self,
|
||||
platform: String,
|
||||
nickname: String,
|
||||
uid: String,
|
||||
cookies_json: String,
|
||||
account_id: Option<i64>,
|
||||
) -> Result<PlatformAccountRecord, String> {
|
||||
self.with_db(move |db| {
|
||||
db.upsert_login(
|
||||
&platform,
|
||||
&nickname,
|
||||
&uid,
|
||||
&cookies_json,
|
||||
account_id,
|
||||
)
|
||||
.map_err(|e| e.to_string())
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn set_login_status(&self, id: i64, status: String) -> Result<(), String> {
|
||||
self.with_db(move |db| db.set_login_status(id, &status).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn delete(&self, id: i64) -> Result<(), String> {
|
||||
self.with_db(move |db| db.delete(id).map_err(|e| e.to_string()))
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,7 @@
|
||||
"title": "aiclient",
|
||||
"width": 800,
|
||||
"height": 600,
|
||||
"maximized": true,
|
||||
"decorations": false,
|
||||
"resizable": true
|
||||
}
|
||||
|
||||
@@ -24,6 +24,9 @@ const menuItems = computed(() => {
|
||||
if (auth.isAdmin) {
|
||||
items.push({ name: "admin", label: "管理", to: "/admin", icon: "admin" });
|
||||
}
|
||||
if ( auth.isOEM) {
|
||||
items.push({ name: "admin", label: "管理", to: "/oem", icon: "admin" });
|
||||
}
|
||||
if (auth.isAgent) {
|
||||
items.push({ name: "agent", label: "代理", to: "/agent", icon: "agent" });
|
||||
}
|
||||
|
||||
30
src/components/oem/OemSubNav.vue
Normal file
30
src/components/oem/OemSubNav.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<script setup>
|
||||
import { computed } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
|
||||
const menuItems = [
|
||||
{ name: "admin-overview", label: "概览", to: "/admin" },
|
||||
{ name: "admin-users", label: "用户管理", to: "/admin/users" },
|
||||
{ name: "admin-card-keys", label: "卡密管理", to: "/admin/card-keys" },
|
||||
{ name: "admin-desktop-config", label: "桌面配置", to: "/admin/desktop-config" },
|
||||
];
|
||||
|
||||
const activeName = computed(() => route.name);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="admin-sub-nav" aria-label="管理后台导航">
|
||||
<p class="admin-sub-nav__title">管理后台</p>
|
||||
<RouterLink
|
||||
v-for="item in menuItems"
|
||||
:key="item.name"
|
||||
:to="item.to"
|
||||
class="admin-sub-nav__item"
|
||||
:class="{ 'admin-sub-nav__item--active': activeName === item.name }"
|
||||
>
|
||||
{{ item.label }}
|
||||
</RouterLink>
|
||||
</nav>
|
||||
</template>
|
||||
@@ -1,9 +1,57 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const { pipInPicture, autoCutBreath, greenScreen } = storeToRefs(workflow);
|
||||
const {
|
||||
pipInPicture,
|
||||
autoCutBreath,
|
||||
greenScreen,
|
||||
videoProcessing,
|
||||
greenScreenBackgroundPath,
|
||||
generatedVideoSrc,
|
||||
generatedVideoPath,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
||||
const canProcess = computed(
|
||||
() =>
|
||||
hasSourceVideo.value &&
|
||||
!videoProcessing.value &&
|
||||
(autoCutBreath.value || pipInPicture.value || greenScreen.value),
|
||||
);
|
||||
|
||||
const greenScreenFileName = computed(() => {
|
||||
const p = greenScreenBackgroundPath.value;
|
||||
if (!p) return "";
|
||||
return p.split(/[/\\]/).pop() || p;
|
||||
});
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onAutoProcess() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.autoProcessVideo());
|
||||
}
|
||||
|
||||
async function onPickGreenScreen() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.chooseGreenScreenBackground());
|
||||
}
|
||||
|
||||
async function onPickPipMaterial() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.choosePipMaterial());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -13,15 +61,77 @@ const { pipInPicture, autoCutBreath, greenScreen } = storeToRefs(workflow);
|
||||
<Checkbox v-model="pipInPicture" binary />
|
||||
画中画
|
||||
</label>
|
||||
<Button
|
||||
v-if="pipInPicture"
|
||||
label="选择画中画素材"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full"
|
||||
:disabled="videoProcessing"
|
||||
@click="onPickPipMaterial"
|
||||
/>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="autoCutBreath" binary />
|
||||
自动剪气口
|
||||
</label>
|
||||
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="greenScreen" binary />
|
||||
启动绿幕切换
|
||||
</label>
|
||||
<Button label="自动处理" size="small" class="w-full" disabled />
|
||||
<div v-if="greenScreen" class="flex flex-col gap-1 pl-6">
|
||||
<Button
|
||||
:label="greenScreenFileName ? '重新选择背景图' : '上传替换图片'"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
outlined
|
||||
class="w-full"
|
||||
:disabled="videoProcessing"
|
||||
@click="onPickGreenScreen"
|
||||
/>
|
||||
<p v-if="greenScreenFileName" class="truncate text-xs text-slate-400">
|
||||
{{ greenScreenFileName }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
label="自动处理"
|
||||
size="small"
|
||||
class="w-full"
|
||||
:loading="videoProcessing"
|
||||
:disabled="!canProcess"
|
||||
@click="onAutoProcess"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="!hasSourceVideo"
|
||||
class="mt-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 02 生成口播视频
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<div class="dashboard-preview-box mt-3">
|
||||
<div class="mb-2 text-xs text-slate-400">编辑预览</div>
|
||||
<div class="dashboard-aspect-video">
|
||||
<video
|
||||
v-if="generatedVideoSrc"
|
||||
:src="generatedVideoSrc"
|
||||
controls
|
||||
class="h-full w-full rounded object-contain"
|
||||
/>
|
||||
<span v-else class="text-xs text-gray-400">处理后将在此预览</span>
|
||||
</div>
|
||||
</div>
|
||||
</DashboardCard>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
|
||||
@@ -11,12 +12,56 @@ const {
|
||||
keywordsDescribe,
|
||||
keywordsAction,
|
||||
keywordsEmotion,
|
||||
titleTagsGenerating,
|
||||
scriptContent,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const canGenerate = computed(
|
||||
() => Boolean(scriptContent.value?.trim()) && !titleTagsGenerating.value,
|
||||
);
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onGenerateTitleTags() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.generateTitleTags());
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardCard title="标题标签关键词" step="04">
|
||||
<Button label="生成标题标签关键词" size="small" class="mb-3 w-full" />
|
||||
<Button
|
||||
label="生成标题标签关键词"
|
||||
size="small"
|
||||
class="mb-3 w-full"
|
||||
:loading="titleTagsGenerating"
|
||||
:disabled="!canGenerate"
|
||||
@click="onGenerateTitleTags"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!scriptContent?.trim()"
|
||||
class="mb-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 01 填写或生成视频文案
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mb-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<div class="dashboard-field-label mb-1">生成的标题(可编辑)</div>
|
||||
<Textarea
|
||||
|
||||
@@ -1,26 +1,96 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
import { SUBTITLE_TEMPLATES } from "../../config/subtitleTemplates.js";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const { autoSubtitle, smartSubtitle, bgmEnabled, bgmVolume } = storeToRefs(workflow);
|
||||
const {
|
||||
autoSubtitle,
|
||||
smartSubtitle,
|
||||
bgmEnabled,
|
||||
bgmVolume,
|
||||
subtitleTemplateId,
|
||||
subtitleBgmGenerating,
|
||||
generatedVideoPath,
|
||||
bgmPreviewSrc,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
const templateDialogVisible = ref(false);
|
||||
|
||||
const templateOptions = SUBTITLE_TEMPLATES.map((t) => ({
|
||||
label: t.label,
|
||||
value: t.id,
|
||||
}));
|
||||
|
||||
const hasSourceVideo = computed(() => Boolean(generatedVideoPath.value));
|
||||
|
||||
const canGenerate = computed(
|
||||
() =>
|
||||
hasSourceVideo.value &&
|
||||
!subtitleBgmGenerating.value &&
|
||||
(autoSubtitle.value || bgmEnabled.value) &&
|
||||
(!bgmEnabled.value || Boolean(workflow.bgmPath)),
|
||||
);
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onGenerate() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.generateSubtitleAndBgm());
|
||||
}
|
||||
|
||||
async function onPickBgm() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.chooseBgm());
|
||||
}
|
||||
|
||||
async function onPreviewBgm() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await workflow.previewBgm();
|
||||
if (result?.message) showFeedback(result);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardCard title="字幕和音乐" step="05" :grow="true">
|
||||
<label class="flex items-center gap-2 text-sm">
|
||||
<label class="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="autoSubtitle" binary />
|
||||
自动生成字幕
|
||||
</label>
|
||||
<label class="mt-2 flex items-center gap-2 text-sm">
|
||||
<Checkbox v-model="smartSubtitle" binary />
|
||||
<p class="dashboard-muted mt-1 pl-6 text-xs">
|
||||
提取视频音频并识别,烧录 SRT 字幕到画面
|
||||
</p>
|
||||
|
||||
<label class="mt-2 flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="smartSubtitle" binary :disabled="!autoSubtitle" />
|
||||
启用智能字幕
|
||||
</label>
|
||||
<div class="mt-2 flex items-center gap-2">
|
||||
<Button label="模板选择" size="small" outlined />
|
||||
<span class="dashboard-muted truncate text-sm">已选模板: 未选择</span>
|
||||
<p class="dashboard-muted mt-1 pl-6 text-xs">
|
||||
用步骤 01 文案替换识别文本,保留语音时间轴
|
||||
</p>
|
||||
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<Button
|
||||
label="模板选择"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!autoSubtitle"
|
||||
@click="templateDialogVisible = true"
|
||||
/>
|
||||
<span class="dashboard-muted truncate text-sm">
|
||||
已选模板: {{ workflow.subtitleTemplateLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<label class="mt-3 flex items-center gap-2 text-sm">
|
||||
|
||||
<label class="mt-3 flex items-center gap-2 text-sm text-slate-200">
|
||||
<Checkbox v-model="bgmEnabled" binary />
|
||||
添加背景音乐
|
||||
</label>
|
||||
@@ -34,14 +104,77 @@ const { autoSubtitle, smartSubtitle, bgmEnabled, bgmVolume } = storeToRefs(workf
|
||||
class="w-20"
|
||||
/>
|
||||
<span class="text-sm text-slate-400">%</span>
|
||||
<Button label="选择音乐" size="small" severity="secondary" :disabled="!bgmEnabled" />
|
||||
<Button label="试听" size="small" outlined :disabled="!bgmEnabled" />
|
||||
<Button
|
||||
label="选择音乐"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
:disabled="!bgmEnabled || subtitleBgmGenerating"
|
||||
@click="onPickBgm"
|
||||
/>
|
||||
<Button
|
||||
label="试听"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="!bgmEnabled || !workflow.bgmPath"
|
||||
@click="onPreviewBgm"
|
||||
/>
|
||||
</div>
|
||||
<p v-if="workflow.bgmFileName" class="mt-1 truncate text-xs text-slate-400">
|
||||
{{ workflow.bgmFileName }}
|
||||
</p>
|
||||
<audio
|
||||
v-if="bgmPreviewSrc"
|
||||
:src="bgmPreviewSrc"
|
||||
controls
|
||||
class="mt-2 h-8 w-full"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!hasSourceVideo"
|
||||
class="mt-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 02 生成口播视频
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
label="自动生成字幕和BGM"
|
||||
size="small"
|
||||
class="mt-3 w-full"
|
||||
:disabled="!autoSubtitle && !bgmEnabled"
|
||||
:loading="subtitleBgmGenerating"
|
||||
:disabled="!canGenerate"
|
||||
@click="onGenerate"
|
||||
/>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="templateDialogVisible"
|
||||
header="字幕模板"
|
||||
modal
|
||||
:style="{ width: '22rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="opt in templateOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="subtitleTemplateId"
|
||||
:input-id="`tpl-${opt.value}`"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label :for="`tpl-${opt.value}`" class="cursor-pointer text-sm">
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</DashboardCard>
|
||||
</template>
|
||||
|
||||
@@ -1,15 +1,178 @@
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
import { COVER_TEMPLATES } from "../../config/coverTemplates.js";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const {
|
||||
titleGenerated,
|
||||
generatedVideoPath,
|
||||
processedVideoPath,
|
||||
coverGenerating,
|
||||
coverImageSrc,
|
||||
coverTemplateId,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
const settingsVisible = ref(false);
|
||||
|
||||
const templateOptions = COVER_TEMPLATES.map((t) => ({
|
||||
label: t.label,
|
||||
value: t.id,
|
||||
}));
|
||||
|
||||
const hasSourceVideo = computed(
|
||||
() => Boolean(processedVideoPath.value || generatedVideoPath.value),
|
||||
);
|
||||
|
||||
const hasTitle = computed(() => Boolean(String(titleGenerated.value || "").trim()));
|
||||
|
||||
const canGenerate = computed(
|
||||
() => hasSourceVideo.value && hasTitle.value && !coverGenerating.value,
|
||||
);
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onGenerateCover() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.generateCover());
|
||||
}
|
||||
|
||||
async function onPickMaterial() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await workflow.chooseCoverMaterial();
|
||||
if (result?.message) showFeedback(result);
|
||||
}
|
||||
|
||||
function applySettings() {
|
||||
settingsVisible.value = false;
|
||||
feedback.value = {
|
||||
severity: "success",
|
||||
message: "封面设置已保存,可点击「自动生成封面」",
|
||||
};
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardCard title="封面制作" step="06">
|
||||
<Button label="自动生成封面" size="small" class="w-full" />
|
||||
<Button label="封面设置" size="small" severity="secondary" class="mt-2 w-full" />
|
||||
<Button
|
||||
label="自动生成封面"
|
||||
size="small"
|
||||
class="w-full"
|
||||
:loading="coverGenerating"
|
||||
:disabled="!canGenerate"
|
||||
@click="onGenerateCover"
|
||||
/>
|
||||
<Button
|
||||
label="封面设置"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
class="mt-2 w-full"
|
||||
@click="settingsVisible = true"
|
||||
/>
|
||||
|
||||
<p
|
||||
v-if="!hasSourceVideo"
|
||||
class="mt-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 02 生成口播视频
|
||||
</p>
|
||||
<p
|
||||
v-else-if="!hasTitle"
|
||||
class="mt-2 text-xs text-amber-400/90"
|
||||
>
|
||||
请先在步骤 04 生成标题文字
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<div class="mt-3">
|
||||
<div class="dashboard-field-label mb-2">封面预览</div>
|
||||
<div class="dashboard-aspect-video">
|
||||
<span class="dashboard-muted text-sm">暂无封面预览</span>
|
||||
<div class="dashboard-aspect-video overflow-hidden">
|
||||
<img
|
||||
v-if="coverImageSrc"
|
||||
:src="coverImageSrc"
|
||||
alt="封面预览"
|
||||
class="h-full w-full object-contain"
|
||||
/>
|
||||
<span v-else class="dashboard-muted text-sm">暂无封面预览</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="settingsVisible"
|
||||
header="封面设置"
|
||||
modal
|
||||
:style="{ width: '24rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div>
|
||||
<div class="dashboard-field-label mb-2">样式模板</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<div
|
||||
v-for="opt in templateOptions"
|
||||
:key="opt.value"
|
||||
class="flex items-center gap-2"
|
||||
>
|
||||
<RadioButton
|
||||
v-model="coverTemplateId"
|
||||
:input-id="`cover-tpl-${opt.value}`"
|
||||
:value="opt.value"
|
||||
/>
|
||||
<label :for="`cover-tpl-${opt.value}`" class="cursor-pointer text-sm">
|
||||
{{ opt.label }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="dashboard-field-label mb-1">封面素材视频(可选)</div>
|
||||
<p class="dashboard-muted mb-2 text-xs">
|
||||
不选择时从当前口播视频第 3 秒抽帧;可上传其他视频作为封面底图。
|
||||
「虚化/抠图」类模板需 bundled Python 运行时(首次较慢)。
|
||||
</p>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<Button
|
||||
label="选择视频"
|
||||
size="small"
|
||||
outlined
|
||||
:disabled="coverGenerating"
|
||||
@click="onPickMaterial"
|
||||
/>
|
||||
<Button
|
||||
v-if="workflow.coverCustomVideoPath"
|
||||
label="清除"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="workflow.coverCustomVideoPath = ''"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="workflow.coverMaterialFileName"
|
||||
class="mt-1 truncate text-xs text-slate-400"
|
||||
>
|
||||
{{ workflow.coverMaterialFileName }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<template #footer>
|
||||
<Button label="确定" size="small" @click="applySettings" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</DashboardCard>
|
||||
</template>
|
||||
|
||||
@@ -1,33 +1,219 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
|
||||
const workflow = useWorkflowStore();
|
||||
const {
|
||||
publishPlatforms,
|
||||
publishing,
|
||||
publishLoggingIn,
|
||||
titleGenerated,
|
||||
coverImagePath,
|
||||
generatedVideoPath,
|
||||
processedVideoPath,
|
||||
publishScheduledAt,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
const scheduleDialogVisible = ref(false);
|
||||
const scheduleDate = ref(null);
|
||||
|
||||
const hasVideo = computed(
|
||||
() => Boolean(processedVideoPath.value || generatedVideoPath.value),
|
||||
);
|
||||
const hasCover = computed(() => Boolean(coverImagePath.value));
|
||||
const hasTitle = computed(() => Boolean(String(titleGenerated.value || "").trim()));
|
||||
|
||||
const canPublish = computed(
|
||||
() =>
|
||||
hasVideo.value &&
|
||||
hasCover.value &&
|
||||
hasTitle.value &&
|
||||
!publishing.value &&
|
||||
publishPlatforms.value.some((p) => p.checked),
|
||||
);
|
||||
|
||||
const accountOptions = (platform) =>
|
||||
(platform.accounts || []).map((a) => ({
|
||||
label: a.nickname || `账号 #${a.id}`,
|
||||
value: a.id,
|
||||
}));
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await workflow.refreshPublishAccounts();
|
||||
});
|
||||
|
||||
async function onPublish() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.publishVideo({ autoPublish: true }));
|
||||
}
|
||||
|
||||
async function onPublishManual() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.publishVideo({ autoPublish: false }));
|
||||
}
|
||||
|
||||
function openSchedule() {
|
||||
scheduleDate.value = null;
|
||||
scheduleDialogVisible.value = true;
|
||||
}
|
||||
|
||||
function confirmSchedule() {
|
||||
if (!scheduleDate.value) {
|
||||
feedback.value = { severity: "error", message: "请选择发布时间" };
|
||||
return;
|
||||
}
|
||||
const d =
|
||||
scheduleDate.value instanceof Date
|
||||
? scheduleDate.value
|
||||
: new Date(scheduleDate.value);
|
||||
showFeedback(workflow.schedulePublishVideo(d));
|
||||
scheduleDialogVisible.value = false;
|
||||
}
|
||||
|
||||
function cancelSchedule() {
|
||||
workflow.clearPublishSchedule();
|
||||
feedback.value = { severity: "success", message: "已取消定时发布" };
|
||||
}
|
||||
|
||||
async function onLogin(platform) {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(await workflow.loginPublishPlatform(platform.key));
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardCard title="视频发布" step="07" :grow="true">
|
||||
<div
|
||||
v-for="(platform, index) in workflow.publishPlatforms"
|
||||
:key="index"
|
||||
v-for="platform in publishPlatforms"
|
||||
:key="platform.key"
|
||||
class="dashboard-platform-row mb-2"
|
||||
>
|
||||
<label class="flex shrink-0 items-center gap-2 text-sm" style="min-width: 70px">
|
||||
<label class="flex shrink-0 items-center gap-2 text-sm" style="min-width: 86px">
|
||||
<Checkbox v-model="platform.checked" binary />
|
||||
{{ platform.label }}
|
||||
<span>{{ platform.label }}</span>
|
||||
<span
|
||||
v-if="platform.loggedIn"
|
||||
class="text-xs text-emerald-400"
|
||||
:title="platform.nickname"
|
||||
>
|
||||
✓
|
||||
</span>
|
||||
</label>
|
||||
<Select
|
||||
v-model="platform.account"
|
||||
v-model="platform.accountId"
|
||||
:options="accountOptions(platform)"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
placeholder="选择账号"
|
||||
size="small"
|
||||
class="min-w-0 flex-1"
|
||||
:disabled="!accountOptions(platform).length"
|
||||
/>
|
||||
<Button
|
||||
label="登录"
|
||||
size="small"
|
||||
text
|
||||
:loading="publishLoggingIn === platform.key"
|
||||
@click="onLogin(platform)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="!hasVideo" class="mt-1 text-xs text-amber-400/90">
|
||||
请先在步骤 02 生成口播视频
|
||||
</p>
|
||||
<p v-else-if="!hasCover" class="mt-1 text-xs text-amber-400/90">
|
||||
请先在步骤 06 生成封面
|
||||
</p>
|
||||
<p v-else-if="!hasTitle" class="mt-1 text-xs text-amber-400/90">
|
||||
请先在步骤 04 生成标题
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-sm"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<ul
|
||||
v-if="workflow.publishResults?.length"
|
||||
class="mt-2 space-y-1 text-xs text-slate-300"
|
||||
>
|
||||
<li v-for="(r, idx) in workflow.publishResults" :key="idx">
|
||||
{{ r.platform }}:
|
||||
<span :class="r.success ? 'text-emerald-400' : r.pending ? 'text-amber-400' : 'text-red-400'">
|
||||
{{ r.message || r.status }}
|
||||
</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p v-if="publishScheduledAt" class="mt-2 text-xs text-sky-400">
|
||||
定时发布:{{ new Date(publishScheduledAt).toLocaleString("zh-CN") }}
|
||||
<Button label="取消" size="small" text class="ml-1" @click="cancelSchedule" />
|
||||
</p>
|
||||
|
||||
<div class="mt-3 flex gap-2">
|
||||
<Button label="发布" size="small" class="flex-1" />
|
||||
<Button label="定时发布" size="small" class="flex-1" />
|
||||
<Button
|
||||
label="发布"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
:loading="publishing"
|
||||
:disabled="!canPublish"
|
||||
@click="onPublish"
|
||||
/>
|
||||
<Button
|
||||
label="半自动"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
severity="secondary"
|
||||
:loading="publishing"
|
||||
:disabled="!canPublish"
|
||||
title="填写内容后由您在浏览器中手动点发布"
|
||||
@click="onPublishManual"
|
||||
/>
|
||||
<Button
|
||||
label="定时发布"
|
||||
size="small"
|
||||
class="flex-1"
|
||||
severity="secondary"
|
||||
:disabled="!canPublish || publishing"
|
||||
@click="openSchedule"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p class="mt-2 text-center text-xs leading-relaxed text-gray-400 opacity-80">
|
||||
首次发布:请手动关闭浏览器内出现的任何弹窗提示(说明等),防止干扰脚本,下次即可流畅运行
|
||||
</p>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="scheduleDialogVisible"
|
||||
header="定时发布"
|
||||
modal
|
||||
:style="{ width: '22rem' }"
|
||||
>
|
||||
<DatePicker
|
||||
v-model="scheduleDate"
|
||||
show-time
|
||||
hour-format="24"
|
||||
date-format="yy-mm-dd"
|
||||
placeholder="选择发布时间"
|
||||
class="w-full"
|
||||
/>
|
||||
<template #footer>
|
||||
<Button label="取消" size="small" text @click="scheduleDialogVisible = false" />
|
||||
<Button label="确定" size="small" @click="confirmSchedule" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</DashboardCard>
|
||||
</template>
|
||||
|
||||
@@ -1,20 +1,114 @@
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { storeToRefs } from "pinia";
|
||||
import { useWorkflowStore } from "../../stores/workflow.js";
|
||||
|
||||
const router = useRouter();
|
||||
const workflow = useWorkflowStore();
|
||||
const {
|
||||
oneClickRunning,
|
||||
oneClickStatus,
|
||||
oneClickProgress,
|
||||
} = storeToRefs(workflow);
|
||||
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
function showFeedback(result) {
|
||||
if (!result?.message) return;
|
||||
feedback.value = {
|
||||
severity: result.ok ? "success" : "error",
|
||||
message: result.message,
|
||||
};
|
||||
}
|
||||
|
||||
async function onStartOneClick() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const result = await workflow.startOneClickAuto();
|
||||
showFeedback(result);
|
||||
}
|
||||
|
||||
function onStopTask() {
|
||||
feedback.value = { severity: "", message: "" };
|
||||
showFeedback(workflow.stopOneClickAuto());
|
||||
}
|
||||
|
||||
function onClearData() {
|
||||
if (oneClickRunning.value) {
|
||||
feedback.value = {
|
||||
severity: "error",
|
||||
message: "一键任务运行中,请先停止任务",
|
||||
};
|
||||
return;
|
||||
}
|
||||
workflow.clearData();
|
||||
feedback.value = { severity: "success", message: "已清除数据" };
|
||||
}
|
||||
|
||||
function onOpenAgentConfig() {
|
||||
router.push({ name: "local-config" });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="dashboard-card p-3">
|
||||
<button type="button" class="dashboard-one-click">开启一键自动</button>
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
<Button label="停止任务" size="small" text severity="danger" />
|
||||
<Button label="清除数据" size="small" text severity="danger" @click="onClearData" />
|
||||
<button
|
||||
type="button"
|
||||
class="dashboard-one-click"
|
||||
:disabled="oneClickRunning"
|
||||
@click="onStartOneClick"
|
||||
>
|
||||
{{ oneClickRunning ? "一键自动运行中…" : "开启一键自动" }}
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-if="oneClickRunning || oneClickProgress > 0"
|
||||
class="one-click-progress mt-2"
|
||||
>
|
||||
<div class="mb-1 flex items-center justify-between gap-2 text-xs text-slate-400">
|
||||
<span class="truncate">{{ oneClickStatus || "准备中…" }}</span>
|
||||
<span class="shrink-0 tabular-nums">{{ oneClickProgress }}%</span>
|
||||
</div>
|
||||
<Button label="智能体配置" size="small" outlined class="mt-2 w-full" />
|
||||
<div class="one-click-progress-track">
|
||||
<div
|
||||
class="one-click-progress-bar"
|
||||
:style="{ width: `${Math.min(100, Math.max(0, oneClickProgress))}%` }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="feedback.message"
|
||||
class="mt-2 text-xs"
|
||||
:class="feedback.severity === 'error' ? 'text-red-400' : 'text-emerald-400'"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</p>
|
||||
|
||||
<div class="mt-2 grid grid-cols-2 gap-2">
|
||||
<Button
|
||||
label="停止任务"
|
||||
size="small"
|
||||
text
|
||||
severity="danger"
|
||||
:disabled="!oneClickRunning"
|
||||
@click="onStopTask"
|
||||
/>
|
||||
<Button
|
||||
label="清除数据"
|
||||
size="small"
|
||||
text
|
||||
severity="danger"
|
||||
:disabled="oneClickRunning"
|
||||
@click="onClearData"
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
label="智能体配置"
|
||||
size="small"
|
||||
outlined
|
||||
class="mt-2 w-full"
|
||||
@click="onOpenAgentConfig"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
112
src/config/coverTemplates.js
Normal file
112
src/config/coverTemplates.js
Normal file
@@ -0,0 +1,112 @@
|
||||
/** 封面样式预设(简易 ffmpeg + 高级 Python 模板) */
|
||||
|
||||
export const COVER_TEMPLATES = [
|
||||
{
|
||||
id: "default",
|
||||
label: "底部白字(快速)",
|
||||
titleFontSize: 90,
|
||||
titleFontColor: "#FFFFFF",
|
||||
titlePosition: "bottom",
|
||||
},
|
||||
{
|
||||
id: "top",
|
||||
label: "顶部白字(快速)",
|
||||
titleFontSize: 80,
|
||||
titleFontColor: "#FFFFFF",
|
||||
titlePosition: "top",
|
||||
},
|
||||
{
|
||||
id: "center",
|
||||
label: "居中黄字(快速)",
|
||||
titleFontSize: 88,
|
||||
titleFontColor: "#FFD700",
|
||||
titlePosition: "center",
|
||||
},
|
||||
{
|
||||
id: "pil_stroke",
|
||||
label: "描边标题(PIL)",
|
||||
titleFontSize: 96,
|
||||
titleColor: "#FFFFFF",
|
||||
titlePosition: "bottom",
|
||||
titleStrokeWidth: 4,
|
||||
titleStrokeColor: "#000000",
|
||||
titleFontFamily: "Microsoft YaHei",
|
||||
},
|
||||
{
|
||||
id: "advanced_blur",
|
||||
label: "虚化背景 + 抠图",
|
||||
backgroundBlurEnabled: true,
|
||||
blurBackground: true,
|
||||
extractPerson: true,
|
||||
personSize: 88,
|
||||
titleFontSize: 72,
|
||||
titleFontColor: "#FFFFFF",
|
||||
titlePosition: "bottom",
|
||||
titleFontFamily: "Microsoft YaHei",
|
||||
titleBackgroundEnabled: false,
|
||||
},
|
||||
{
|
||||
id: "advanced_outline",
|
||||
label: "人物描边强调",
|
||||
extractPerson: true,
|
||||
personOutlineColor: "#FFD700",
|
||||
personOutlineWidth: 6,
|
||||
personSize: 90,
|
||||
backgroundBlurEnabled: true,
|
||||
titleFontSize: 80,
|
||||
titleFontColor: "#FFFFFF",
|
||||
titlePosition: "top",
|
||||
titleFontFamily: "Microsoft YaHei",
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} templateId
|
||||
*/
|
||||
export function getCoverTemplate(templateId) {
|
||||
return (
|
||||
COVER_TEMPLATES.find((t) => t.id === templateId) || COVER_TEMPLATES[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 Electron `isNewTemplate` 一致
|
||||
* @param {Record<string, unknown>} config
|
||||
*/
|
||||
export function isAdvancedCoverConfig(config) {
|
||||
if (!config || typeof config !== "object") return false;
|
||||
return (
|
||||
config.blurBackground !== undefined ||
|
||||
config.extractPerson !== undefined ||
|
||||
config.personOutlineColor !== undefined ||
|
||||
config.personOutlineWidth !== undefined ||
|
||||
config.maskImagePath !== undefined ||
|
||||
Boolean(config.titleFontFamily) ||
|
||||
config.titleBackgroundEnabled !== undefined ||
|
||||
config.personSize !== undefined ||
|
||||
config.backgroundBlurEnabled !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} tpl
|
||||
* @param {{ customVideoPath?: string, overrides?: Record<string, unknown> }} [extra]
|
||||
*/
|
||||
export function buildCoverEffectStyle(tpl, extra = {}) {
|
||||
const { id: _id, label: _label, ...style } = tpl;
|
||||
return {
|
||||
...style,
|
||||
...(extra.overrides || {}),
|
||||
customVideoPath: extra.customVideoPath || "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} titleGenerated
|
||||
*/
|
||||
export function pickCoverTitleText(titleGenerated) {
|
||||
const raw = String(titleGenerated || "").trim();
|
||||
if (!raw) return "";
|
||||
const line = raw.split(/\r?\n/)[0].trim();
|
||||
return line.slice(0, 80);
|
||||
}
|
||||
63
src/config/subtitleTemplates.js
Normal file
63
src/config/subtitleTemplates.js
Normal file
@@ -0,0 +1,63 @@
|
||||
/** 字幕样式预设(对齐 Electron 常用底部白字黑边,供 ffmpeg force_style) */
|
||||
export const SUBTITLE_TEMPLATES = [
|
||||
{
|
||||
id: "default",
|
||||
label: "默认白字黑边",
|
||||
fontName: "Microsoft YaHei",
|
||||
fontSize: 24,
|
||||
primaryColor: "#FFFFFF",
|
||||
outlineColor: "#000000",
|
||||
outline: 2,
|
||||
marginV: 40,
|
||||
},
|
||||
{
|
||||
id: "large",
|
||||
label: "大号字幕",
|
||||
fontName: "Microsoft YaHei",
|
||||
fontSize: 32,
|
||||
primaryColor: "#FFFFFF",
|
||||
outlineColor: "#000000",
|
||||
outline: 3,
|
||||
marginV: 48,
|
||||
},
|
||||
{
|
||||
id: "yellow",
|
||||
label: "黄色强调",
|
||||
fontName: "Microsoft YaHei",
|
||||
fontSize: 26,
|
||||
primaryColor: "#FFD700",
|
||||
outlineColor: "#000000",
|
||||
outline: 2,
|
||||
marginV: 42,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* @param {string} templateId
|
||||
*/
|
||||
export function getSubtitleTemplate(templateId) {
|
||||
return (
|
||||
SUBTITLE_TEMPLATES.find((t) => t.id === templateId) ||
|
||||
SUBTITLE_TEMPLATES[0]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{ fontName?: string, fontSize?: number, primaryColor?: string, outlineColor?: string, outline?: number, marginV?: number }} tpl
|
||||
*/
|
||||
export function buildFfmpegForceStyle(tpl) {
|
||||
const hexToAss = (hex) => {
|
||||
const h = hex.replace("#", "");
|
||||
const r = h.substring(0, 2);
|
||||
const g = h.substring(2, 4);
|
||||
const b = h.substring(4, 6);
|
||||
return `&H00${b}${g}${r}`.toUpperCase();
|
||||
};
|
||||
const fontName = tpl.fontName || "Microsoft YaHei";
|
||||
const fontSize = tpl.fontSize || 24;
|
||||
const primary = hexToAss(tpl.primaryColor || "#FFFFFF");
|
||||
const outline = hexToAss(tpl.outlineColor || "#000000");
|
||||
const outlineW = tpl.outline ?? 2;
|
||||
const marginV = tpl.marginV ?? 40;
|
||||
return `FontName=${fontName},FontSize=${fontSize},PrimaryColour=${primary},OutlineColour=${outline},Outline=${outlineW},Alignment=2,MarginV=${marginV}`;
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter, createWebHistory } from "vue-router";
|
||||
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../stores/auth";
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } from "../stores/auth";
|
||||
|
||||
|
||||
|
||||
@@ -191,7 +191,69 @@ const mainChildren = [
|
||||
],
|
||||
|
||||
},
|
||||
{
|
||||
|
||||
path: "oem",
|
||||
|
||||
component: () => import("../views/OemView.vue"),
|
||||
|
||||
meta: { title: "管理", requiresRole: ROLE_OEM},
|
||||
|
||||
redirect: { name: "oem-overview" },
|
||||
|
||||
children: [
|
||||
|
||||
{
|
||||
|
||||
path: "",
|
||||
|
||||
name: "oem-overview",
|
||||
|
||||
component: () => import("../views/oem/OemOverviewView.vue"),
|
||||
|
||||
meta: { title: "概览" },
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
path: "users",
|
||||
|
||||
name: "oem-users",
|
||||
|
||||
component: () => import("../views/oem/OemUsersView.vue"),
|
||||
|
||||
meta: { title: "用户管理" },
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
path: "desktop-config",
|
||||
|
||||
name: "admin-desktop-config",
|
||||
|
||||
component: () => import("../views/oem/OemDesktopConfigView.vue"),
|
||||
|
||||
meta: { title: "桌面配置" },
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
path: "card-keys",
|
||||
|
||||
name: "admin-card-keys",
|
||||
|
||||
component: () => import("../views/oem/OemCardKeysView.vue"),
|
||||
|
||||
meta: { title: "卡密管理" },
|
||||
|
||||
},
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
{
|
||||
|
||||
path: "agent",
|
||||
|
||||
113
src/services/coverGenerate.js
Normal file
113
src/services/coverGenerate.js
Normal file
@@ -0,0 +1,113 @@
|
||||
import { convertFileSrc, invoke, isTauri } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import {
|
||||
buildCoverEffectStyle,
|
||||
getCoverTemplate,
|
||||
pickCoverTitleText,
|
||||
} from "../config/coverTemplates.js";
|
||||
|
||||
const COVER_SCRIPT = "cover_generate.js";
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickCoverMaterialVideo() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "视频",
|
||||
extensions: ["mp4", "mov", "mkv", "avi", "webm"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
*/
|
||||
export function localImageToPlayableUrl(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("请在 Tauri 桌面端预览封面");
|
||||
}
|
||||
return convertFileSrc(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeGenerateCover(store) {
|
||||
const videoPath = String(
|
||||
store.processedVideoPath || store.generatedVideoPath || "",
|
||||
).trim();
|
||||
if (!videoPath) {
|
||||
return { ok: false, message: "请先在步骤 02 生成口播视频(步骤 03/05 处理后亦可)" };
|
||||
}
|
||||
|
||||
const titleText = pickCoverTitleText(store.titleGenerated);
|
||||
if (!titleText) {
|
||||
return { ok: false, message: "请先在步骤 04 生成标题文字" };
|
||||
}
|
||||
|
||||
const tpl = getCoverTemplate(store.coverTemplateId || "default");
|
||||
const effectStyle = buildCoverEffectStyle(tpl, {
|
||||
customVideoPath: store.coverCustomVideoPath || "",
|
||||
});
|
||||
|
||||
store.coverGenerating = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: COVER_SCRIPT,
|
||||
params: {
|
||||
videoPath,
|
||||
titleText,
|
||||
effectStyle,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.coverPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "封面生成失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
store.coverImagePath = result.coverPath;
|
||||
store.coverImageSrc = localImageToPlayableUrl(result.coverPath);
|
||||
if (Array.isArray(result.previewImages) && result.previewImages.length) {
|
||||
store.coverPreviewImages = result.previewImages;
|
||||
}
|
||||
|
||||
const modeHint =
|
||||
result.mode === "advanced_python"
|
||||
? "(高级模板)"
|
||||
: result.mode === "pil_python"
|
||||
? "(PIL)"
|
||||
: "";
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result.message || "封面生成完成") + modeHint,
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `封面生成失败:${msg}` };
|
||||
} finally {
|
||||
store.coverGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function chooseCoverMaterial(store) {
|
||||
const path = await pickCoverMaterialVideo();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
store.coverCustomVideoPath = path;
|
||||
return { ok: true, message: "已选择封面素材视频" };
|
||||
}
|
||||
15
src/services/mediaDuration.js
Normal file
15
src/services/mediaDuration.js
Normal file
@@ -0,0 +1,15 @@
|
||||
import { invoke, isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
/**
|
||||
* @param {string} filePath
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
export async function getMediaDurationSeconds(filePath) {
|
||||
if (!isTauri()) {
|
||||
throw new Error("获取媒体时长需在 Tauri 桌面端使用");
|
||||
}
|
||||
const path = String(filePath || "").trim();
|
||||
if (!path) return 0;
|
||||
const secs = await invoke("get_media_duration_seconds", { path });
|
||||
return typeof secs === "number" && secs > 0 ? secs : 0;
|
||||
}
|
||||
200
src/services/oneClickPipeline.js
Normal file
200
src/services/oneClickPipeline.js
Normal file
@@ -0,0 +1,200 @@
|
||||
/**
|
||||
* 一键智能流程(对齐 Electron executeOneClickIntelligence / kS)
|
||||
*
|
||||
* 顺序:标题标签 → 语音 → 口播视频 → 视频编辑 → 字幕/BGM → 封面 →(发布待实现则跳过)
|
||||
*/
|
||||
|
||||
const STEP_DEFS = [
|
||||
{ key: "titleTags", label: "正在生成标题标签关键词…", progress: 10 },
|
||||
{ key: "speech", label: "正在生成语音…", progress: 25 },
|
||||
{ key: "video", label: "正在生成口播视频…", progress: 45 },
|
||||
{ key: "videoEdit", label: "正在自动剪辑视频…", progress: 58 },
|
||||
{ key: "subtitleBgm", label: "正在生成字幕和 BGM…", progress: 75 },
|
||||
{ key: "cover", label: "正在生成封面…", progress: 90 },
|
||||
{ key: "publish", label: "正在发布视频…", progress: 96 },
|
||||
];
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
function throwIfCancelled(store) {
|
||||
if (store.oneClickCancelRequested) {
|
||||
throw new Error("用户已取消一键智能流程");
|
||||
}
|
||||
}
|
||||
|
||||
function setProgress(store, label, percent) {
|
||||
store.oneClickStatus = label;
|
||||
store.oneClickProgress = percent;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {() => boolean} isBusy
|
||||
* @param {number} timeoutMs
|
||||
*/
|
||||
async function waitUntilIdle(isBusy, timeoutMs) {
|
||||
const start = Date.now();
|
||||
while (isBusy()) {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
throw new Error("操作超时,请重试");
|
||||
}
|
||||
await sleep(400);
|
||||
}
|
||||
}
|
||||
|
||||
function assertOk(result, fallback) {
|
||||
if (!result?.ok) {
|
||||
throw new Error(result?.message || fallback);
|
||||
}
|
||||
}
|
||||
|
||||
function hasVideoEditOptions(store) {
|
||||
return store.autoCutBreath || store.pipInPicture || store.greenScreen;
|
||||
}
|
||||
|
||||
function shouldRunSubtitleBgm(store) {
|
||||
return store.autoSubtitle || store.bgmEnabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeOneClickPipeline(store) {
|
||||
if (store.oneClickRunning) {
|
||||
return { ok: false, message: "一键流程正在运行中" };
|
||||
}
|
||||
|
||||
const script = String(store.scriptContent || "").trim();
|
||||
if (!script) {
|
||||
return { ok: false, message: "请先在文案编辑区输入口播文案" };
|
||||
}
|
||||
if (!store.avatarSelect) {
|
||||
return { ok: false, message: "请先在步骤 02 选择形象" };
|
||||
}
|
||||
if (store.greenScreen && !store.greenScreenBackgroundPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已勾选绿幕切换,请先上传替换背景图,或取消绿幕后再一键运行",
|
||||
};
|
||||
}
|
||||
if (store.bgmEnabled && !store.bgmPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已启用背景音乐,请先选择音乐文件,或关闭 BGM 后再一键运行",
|
||||
};
|
||||
}
|
||||
if (store.pipInPicture) {
|
||||
const { loadVideoMixCutSettings } = await import("./videoEditProcess.js");
|
||||
const mix = await loadVideoMixCutSettings();
|
||||
const hasReplacements =
|
||||
Array.isArray(mix?.replacements) && mix.replacements.length > 0;
|
||||
const hasSimple = Boolean(mix?.simplePip?.materialPath);
|
||||
if (!hasReplacements && !hasSimple) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已启用画中画,请先在步骤 03 选择画中画素材",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
store.oneClickRunning = true;
|
||||
store.oneClickCancelRequested = false;
|
||||
setProgress(store, "检查文案内容…", 2);
|
||||
|
||||
const savedSubtitleFlags = {
|
||||
autoSubtitle: store.autoSubtitle,
|
||||
smartSubtitle: store.smartSubtitle,
|
||||
};
|
||||
|
||||
try {
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[0].label, STEP_DEFS[0].progress);
|
||||
assertOk(await store.generateTitleTags(), "标题标签关键词生成失败");
|
||||
await waitUntilIdle(() => store.titleTagsGenerating, 120_000);
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[1].label, STEP_DEFS[1].progress);
|
||||
assertOk(await store.generateSpeech(), "语音生成失败");
|
||||
await waitUntilIdle(() => store.speechGenerating, 300_000);
|
||||
if (!store.generatedAudioPath) {
|
||||
throw new Error("语音生成失败,请检查 TTS 与网络配置");
|
||||
}
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[2].label, STEP_DEFS[2].progress);
|
||||
assertOk(await store.generateTalkingVideo(), "口播视频生成失败");
|
||||
await waitUntilIdle(() => store.videoGenerating, 600_000);
|
||||
if (!store.generatedVideoPath) {
|
||||
throw new Error("口播视频生成失败,请检查数字人/云端配置");
|
||||
}
|
||||
throwIfCancelled(store);
|
||||
|
||||
if (hasVideoEditOptions(store)) {
|
||||
setProgress(store, STEP_DEFS[3].label, STEP_DEFS[3].progress);
|
||||
assertOk(await store.autoProcessVideo(), "视频剪辑失败");
|
||||
await waitUntilIdle(() => store.videoProcessing, 300_000);
|
||||
throwIfCancelled(store);
|
||||
} else {
|
||||
setProgress(store, "跳过视频编辑(未勾选剪辑项)", 55);
|
||||
await sleep(200);
|
||||
}
|
||||
|
||||
if (!shouldRunSubtitleBgm(store)) {
|
||||
store.autoSubtitle = true;
|
||||
}
|
||||
setProgress(store, STEP_DEFS[4].label, STEP_DEFS[4].progress);
|
||||
assertOk(await store.generateSubtitleAndBgm(), "字幕/BGM 处理失败");
|
||||
await waitUntilIdle(() => store.subtitleBgmGenerating, 300_000);
|
||||
throwIfCancelled(store);
|
||||
|
||||
setProgress(store, STEP_DEFS[5].label, STEP_DEFS[5].progress);
|
||||
assertOk(await store.generateCover(), "封面生成失败");
|
||||
await waitUntilIdle(() => store.coverGenerating, 120_000);
|
||||
throwIfCancelled(store);
|
||||
|
||||
const hasPublish = store.publishPlatforms?.some((p) => p.checked);
|
||||
if (hasPublish) {
|
||||
setProgress(store, STEP_DEFS[6].label, STEP_DEFS[6].progress);
|
||||
assertOk(await store.publishVideo({ autoPublish: true }), "视频发布失败");
|
||||
await waitUntilIdle(() => store.publishing, 600_000);
|
||||
throwIfCancelled(store);
|
||||
} else {
|
||||
setProgress(store, "未选择发布平台,跳过发布", 98);
|
||||
}
|
||||
|
||||
setProgress(store, "一键智能流程全部完成", 100);
|
||||
return { ok: true, message: "一键智能流程全部完成" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
const cancelled = store.oneClickCancelRequested;
|
||||
setProgress(store, cancelled ? "已停止" : `失败:${msg}`, store.oneClickProgress);
|
||||
return {
|
||||
ok: false,
|
||||
message: cancelled ? "已停止一键智能流程" : msg,
|
||||
cancelled,
|
||||
};
|
||||
} finally {
|
||||
store.autoSubtitle = savedSubtitleFlags.autoSubtitle;
|
||||
store.smartSubtitle = savedSubtitleFlags.smartSubtitle;
|
||||
store.oneClickRunning = false;
|
||||
store.titleTagsGenerating = false;
|
||||
store.speechGenerating = false;
|
||||
store.videoGenerating = false;
|
||||
store.videoProcessing = false;
|
||||
store.subtitleBgmGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export function stopOneClickPipeline(store) {
|
||||
if (!store.oneClickRunning) {
|
||||
return { ok: false, message: "当前没有运行中的一键任务" };
|
||||
}
|
||||
store.oneClickCancelRequested = true;
|
||||
store.oneClickStatus = "正在停止…";
|
||||
return { ok: true, message: "已请求停止,将在当前步骤结束后中断" };
|
||||
}
|
||||
151
src/services/subtitleBgmGenerate.js
Normal file
151
src/services/subtitleBgmGenerate.js
Normal file
@@ -0,0 +1,151 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { ensureAppConfigReady } from "../config/videoPipeline.js";
|
||||
import {
|
||||
buildFfmpegForceStyle,
|
||||
getSubtitleTemplate,
|
||||
} from "../config/subtitleTemplates.js";
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localAudioToPlayableUrl } from "./localAudio.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
|
||||
const SUBTITLE_BGM_SCRIPT = "subtitle_bgm_generate.js";
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickBgmFile() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "音频",
|
||||
extensions: ["mp3", "wav", "flac", "aac", "m4a", "ogg"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
function formatHistoryTime() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeGenerateSubtitleAndBgm(store) {
|
||||
const inputVideo = String(store.generatedVideoPath || "").trim();
|
||||
if (!inputVideo) {
|
||||
return { ok: false, message: "请先在步骤 02 生成口播视频(步骤 03 编辑后亦可)" };
|
||||
}
|
||||
|
||||
if (!store.autoSubtitle && !store.bgmEnabled) {
|
||||
return { ok: false, message: "请至少勾选「自动生成字幕」或「添加背景音乐」" };
|
||||
}
|
||||
|
||||
if (store.autoSubtitle) {
|
||||
const cfg = await ensureAppConfigReady();
|
||||
if (!cfg.ok) {
|
||||
return { ok: false, message: cfg.message };
|
||||
}
|
||||
}
|
||||
|
||||
if (store.bgmEnabled && !store.bgmPath) {
|
||||
return { ok: false, message: "已启用背景音乐,请先点击「选择音乐」" };
|
||||
}
|
||||
|
||||
const tpl = getSubtitleTemplate(store.subtitleTemplateId || "default");
|
||||
const subtitleForceStyle = buildFfmpegForceStyle(tpl);
|
||||
|
||||
store.subtitleBgmGenerating = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: SUBTITLE_BGM_SCRIPT,
|
||||
params: {
|
||||
inputVideo,
|
||||
autoSubtitle: store.autoSubtitle,
|
||||
smartSubtitle: store.smartSubtitle,
|
||||
scriptContent: store.scriptContent || "",
|
||||
subtitleForceStyle,
|
||||
bgmEnabled: store.bgmEnabled,
|
||||
bgmPath: store.bgmPath || null,
|
||||
bgmVolume: store.bgmVolume,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.videoPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "字幕/BGM 处理失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
if (result.srtPath) {
|
||||
store.subtitleSrtPath = result.srtPath;
|
||||
}
|
||||
|
||||
const baseName =
|
||||
store.videoHistory.find((v) => v.id === store.currentVideoId)?.name ||
|
||||
"口播视频";
|
||||
const displayName = `${baseName} · 字幕BGM · ${formatHistoryTime()}`;
|
||||
const record = await importGeneratedVideo(
|
||||
result.videoPath,
|
||||
displayName,
|
||||
store.avatarSelect ?? null,
|
||||
store.currentAudioId ?? null,
|
||||
);
|
||||
const videoSrc = localVideoToPlayableUrl(record.filePath);
|
||||
|
||||
store.generatedVideoPath = record.filePath;
|
||||
store.generatedVideoSrc = videoSrc;
|
||||
store.processedVideoPath = record.filePath;
|
||||
store.processedVideoSrc = videoSrc;
|
||||
store.currentVideoId = record.id;
|
||||
await store.refreshVideoHistory();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result.message || "处理完成"),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `处理失败:${msg}` };
|
||||
} finally {
|
||||
store.subtitleBgmGenerating = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function previewBgm(store) {
|
||||
const p = String(store.bgmPath || "").trim();
|
||||
if (!p) {
|
||||
return { ok: false, message: "请先选择音乐" };
|
||||
}
|
||||
try {
|
||||
store.bgmPreviewSrc = await localAudioToPlayableUrl(p);
|
||||
return { ok: true, message: "" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: `无法试听:${msg}` };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function chooseBgm(store) {
|
||||
const path = await pickBgmFile();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
store.bgmPath = path;
|
||||
store.bgmPreviewSrc = "";
|
||||
return { ok: true, message: "已选择背景音乐" };
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { loadAppConfigMap } from "../config/videoPipeline.js";
|
||||
import { useAvatarStore } from "../stores/avatar.js";
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
import { getMediaDurationSeconds } from "./mediaDuration.js";
|
||||
|
||||
const RUNNINGHUB_SCRIPT = "runninghub_generate.js";
|
||||
const INFINITETALK_API_SCRIPT = "infinitetalk_api_generate.js";
|
||||
@@ -41,6 +42,25 @@ export async function executeGenerateTalkingVideo(store) {
|
||||
return { ok: false, message: modeCheck.message };
|
||||
}
|
||||
|
||||
let audioDuration = 0;
|
||||
if (scriptName === RUNNINGHUB_SCRIPT) {
|
||||
try {
|
||||
audioDuration = await getMediaDurationSeconds(audioPath);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return {
|
||||
ok: false,
|
||||
message: `无法读取音频时长(需 ffmpeg/ffprobe): ${msg}`,
|
||||
};
|
||||
}
|
||||
if (audioDuration <= 0) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "无法读取音频时长,请确认已安装 ffmpeg 且音频文件有效",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
store.videoGenerating = true;
|
||||
|
||||
try {
|
||||
@@ -49,6 +69,7 @@ export async function executeGenerateTalkingVideo(store) {
|
||||
params: {
|
||||
audioPath,
|
||||
avatarVideoPath: avatar.filePath,
|
||||
audioDuration,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -161,4 +182,6 @@ export async function selectVideoHistory(store, videoId) {
|
||||
store.generatedVideoPath = item.filePath;
|
||||
store.generatedVideoSrc =
|
||||
item.videoSrc || localVideoToPlayableUrl(item.filePath);
|
||||
store.processedVideoPath = "";
|
||||
store.processedVideoSrc = "";
|
||||
}
|
||||
|
||||
90
src/services/titleTagsGenerate.js
Normal file
90
src/services/titleTagsGenerate.js
Normal file
@@ -0,0 +1,90 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
import { ensureLlmConfigReady } from "../config/videoPipeline.js";
|
||||
|
||||
const TITLE_TAGS_SCRIPT = "title_tags_generate.js";
|
||||
const TITLE_TAG_PROMPT_KEY = "TITLE_TAG_PROMPT";
|
||||
|
||||
const KEYWORD_GROUPS = [
|
||||
{ key: "重点词/成语词", field: "keywordsFocus" },
|
||||
{ key: "描述词", field: "keywordsDescribe" },
|
||||
{ key: "行动词", field: "keywordsAction" },
|
||||
{ key: "情感词", field: "keywordsEmotion" },
|
||||
];
|
||||
|
||||
/**
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
async function loadTitleTagPromptOverride() {
|
||||
try {
|
||||
const rows = await invoke("list_local_app_config");
|
||||
const row = Array.isArray(rows)
|
||||
? rows.find((r) => r.name === TITLE_TAG_PROMPT_KEY)
|
||||
: null;
|
||||
return row?.value?.trim() || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成标题、标签、关键词(对齐 Electron generateTitleTags)
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeGenerateTitleTags(store) {
|
||||
const content = String(store.scriptContent || "").trim();
|
||||
if (!content) {
|
||||
return { ok: false, message: "请先填写视频文案" };
|
||||
}
|
||||
|
||||
const cfgReady = await ensureLlmConfigReady();
|
||||
if (!cfgReady.ok) {
|
||||
return { ok: false, message: cfgReady.message };
|
||||
}
|
||||
|
||||
const titleTagPrompt =
|
||||
(await loadTitleTagPromptOverride()) ||
|
||||
store.titleTagPrompt?.trim() ||
|
||||
null;
|
||||
|
||||
store.titleTagsGenerating = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: TITLE_TAGS_SCRIPT,
|
||||
params: {
|
||||
content,
|
||||
titleTagPrompt,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success) {
|
||||
if (result?.rawContent) {
|
||||
store.titleGenerated = String(result.rawContent).trim();
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result.error || "结果解析失败,已将原始内容填入标题框"),
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "生成失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
store.titleGenerated = String(result.title || "").trim();
|
||||
store.tagsGenerated = String(result.tags || "").trim();
|
||||
|
||||
const kw = result.keywords || {};
|
||||
for (const { key, field } of KEYWORD_GROUPS) {
|
||||
store[field] = String(kw[key] || "").trim();
|
||||
}
|
||||
|
||||
return { ok: true, message: "标题、标签和关键词生成完成" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `生成失败:${msg}` };
|
||||
} finally {
|
||||
store.titleTagsGenerating = false;
|
||||
}
|
||||
}
|
||||
177
src/services/videoEditProcess.js
Normal file
177
src/services/videoEditProcess.js
Normal file
@@ -0,0 +1,177 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
|
||||
import { importGeneratedVideo } from "./videoDb.js";
|
||||
import { localVideoToPlayableUrl } from "./localVideo.js";
|
||||
|
||||
const VIDEO_EDIT_SCRIPT = "video_edit_process.js";
|
||||
const MIX_CUT_CONFIG_KEY = "videoMixCutSettings";
|
||||
|
||||
/**
|
||||
* 从本地 SQLite 配置读取混剪设置(对齐 Electron videoMixCutSettings)
|
||||
* @returns {Promise<object | null>}
|
||||
*/
|
||||
export async function loadVideoMixCutSettings() {
|
||||
try {
|
||||
const rows = await invoke("list_local_app_config");
|
||||
const row = Array.isArray(rows)
|
||||
? rows.find((r) => r.name === MIX_CUT_CONFIG_KEY)
|
||||
: null;
|
||||
if (!row?.value) return null;
|
||||
return JSON.parse(row.value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存/合并 simplePip 到本地混剪配置
|
||||
* @param {object} simplePip
|
||||
*/
|
||||
export async function saveSimplePipConfig(simplePip) {
|
||||
const existing = (await loadVideoMixCutSettings()) || {};
|
||||
const next = {
|
||||
...existing,
|
||||
enabled: true,
|
||||
displayMode: existing.displayMode || "pip",
|
||||
simplePip,
|
||||
};
|
||||
await invoke("set_local_app_config", {
|
||||
name: MIX_CUT_CONFIG_KEY,
|
||||
value: JSON.stringify(next),
|
||||
});
|
||||
return next;
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择绿幕替换背景图
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickGreenScreenBackground() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "图片",
|
||||
extensions: ["jpg", "jpeg", "png", "webp", "bmp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
/**
|
||||
* 选择画中画素材(视频或图片)
|
||||
* @returns {Promise<string | null>}
|
||||
*/
|
||||
export async function pickPipMaterial() {
|
||||
const selected = await open({
|
||||
multiple: false,
|
||||
filters: [
|
||||
{
|
||||
name: "视频或图片",
|
||||
extensions: ["mp4", "mov", "webm", "mkv", "m4v", "jpg", "jpeg", "png", "webp"],
|
||||
},
|
||||
],
|
||||
});
|
||||
if (selected == null) return null;
|
||||
if (Array.isArray(selected)) return selected[0] || null;
|
||||
return String(selected);
|
||||
}
|
||||
|
||||
function formatHistoryTime() {
|
||||
const d = new Date();
|
||||
const p = (n) => String(n).padStart(2, "0");
|
||||
return `${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动处理视频(对齐 Electron autoProcessVideo)
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executeAutoProcessVideo(store) {
|
||||
const inputVideo = String(store.generatedVideoPath || "").trim();
|
||||
if (!inputVideo) {
|
||||
return { ok: false, message: "请先在步骤 02 生成口播视频" };
|
||||
}
|
||||
|
||||
if (!store.autoCutBreath && !store.pipInPicture && !store.greenScreen) {
|
||||
return { ok: false, message: "请至少勾选一项处理选项" };
|
||||
}
|
||||
|
||||
if (store.greenScreen && !store.greenScreenBackgroundPath) {
|
||||
return { ok: false, message: "已启用绿幕切换,请先上传替换背景图" };
|
||||
}
|
||||
|
||||
let mixCutSettings = null;
|
||||
if (store.pipInPicture) {
|
||||
mixCutSettings = await loadVideoMixCutSettings();
|
||||
const hasReplacements =
|
||||
Array.isArray(mixCutSettings?.replacements) &&
|
||||
mixCutSettings.replacements.length > 0;
|
||||
const hasSimple = Boolean(mixCutSettings?.simplePip?.materialPath);
|
||||
if (!hasReplacements && !hasSimple) {
|
||||
return {
|
||||
ok: false,
|
||||
message: "已启用画中画,请先点击「选择画中画素材」",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
store.videoProcessing = true;
|
||||
|
||||
try {
|
||||
const result = await invoke("run_nodejs_script", {
|
||||
scriptName: VIDEO_EDIT_SCRIPT,
|
||||
params: {
|
||||
inputVideo,
|
||||
autoCutBreath: store.autoCutBreath,
|
||||
pipInPicture: store.pipInPicture,
|
||||
greenScreen: store.greenScreen,
|
||||
backgroundImage: store.greenScreenBackgroundPath || null,
|
||||
silenceThreshold: store.silenceThreshold,
|
||||
silenceDuration: store.silenceDuration,
|
||||
minPauseDuration: store.minPauseDuration,
|
||||
mixCutSettings,
|
||||
},
|
||||
});
|
||||
|
||||
if (!result?.success || !result?.videoPath) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || "视频处理失败,请重试"),
|
||||
};
|
||||
}
|
||||
|
||||
const baseName =
|
||||
store.videoHistory.find((v) => v.id === store.currentVideoId)?.name ||
|
||||
"口播视频";
|
||||
const displayName = `${baseName} · 已编辑 · ${formatHistoryTime()}`;
|
||||
const record = await importGeneratedVideo(
|
||||
result.videoPath,
|
||||
displayName,
|
||||
store.avatarSelect ?? null,
|
||||
store.currentAudioId ?? null,
|
||||
);
|
||||
const videoSrc = localVideoToPlayableUrl(record.filePath);
|
||||
|
||||
store.generatedVideoPath = record.filePath;
|
||||
store.generatedVideoSrc = videoSrc;
|
||||
store.processedVideoPath = record.filePath;
|
||||
store.processedVideoSrc = videoSrc;
|
||||
store.currentVideoId = record.id;
|
||||
await store.refreshVideoHistory();
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result.message || "视频处理完成"),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err || "未知错误");
|
||||
return { ok: false, message: `视频处理失败: ${msg}` };
|
||||
} finally {
|
||||
store.videoProcessing = false;
|
||||
}
|
||||
}
|
||||
176
src/services/videoPublish.js
Normal file
176
src/services/videoPublish.js
Normal file
@@ -0,0 +1,176 @@
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
|
||||
export const PUBLISH_PLATFORM_DEFS = [
|
||||
{ key: "douyin", label: "抖音" },
|
||||
{ key: "kuaishou", label: "快手" },
|
||||
{ key: "shipin", label: "视频号" },
|
||||
{ key: "xiaohongshu", label: "小红书" },
|
||||
];
|
||||
|
||||
/**
|
||||
* @returns {typeof PUBLISH_PLATFORM_DEFS[number][]}
|
||||
*/
|
||||
export function createDefaultPublishPlatforms() {
|
||||
return PUBLISH_PLATFORM_DEFS.map((p) => ({
|
||||
key: p.key,
|
||||
label: p.label,
|
||||
checked: false,
|
||||
accountId: null,
|
||||
accounts: [],
|
||||
loggedIn: false,
|
||||
nickname: "",
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function refreshPublishAccounts(store) {
|
||||
try {
|
||||
const all = await invoke("publish_list_accounts", { platform: null });
|
||||
const list = Array.isArray(all) ? all : [];
|
||||
for (const row of store.publishPlatforms) {
|
||||
row.accounts = list.filter((a) => a.platform === row.key);
|
||||
const active = row.accounts.find(
|
||||
(a) => a.loginStatus === "active" && a.hasCookies,
|
||||
);
|
||||
if (active) {
|
||||
row.loggedIn = true;
|
||||
row.nickname = active.nickname || "";
|
||||
if (!row.accountId) row.accountId = active.id;
|
||||
} else {
|
||||
row.loggedIn = false;
|
||||
row.nickname = "";
|
||||
}
|
||||
}
|
||||
return { ok: true, message: "" };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: msg };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
* @param {string} platformKey
|
||||
*/
|
||||
export async function loginPublishPlatform(store, platformKey) {
|
||||
const row = store.publishPlatforms.find((p) => p.key === platformKey);
|
||||
if (!row) return { ok: false, message: "未知平台" };
|
||||
|
||||
store.publishLoggingIn = platformKey;
|
||||
try {
|
||||
const result = await invoke("publish_login", {
|
||||
platform: platformKey,
|
||||
accountId: row.accountId || null,
|
||||
});
|
||||
if (!result?.success) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.error || result?.message || "登录失败"),
|
||||
};
|
||||
}
|
||||
if (result.account) {
|
||||
row.accountId = result.account.id;
|
||||
row.loggedIn = true;
|
||||
row.nickname = result.account.nickname || "";
|
||||
}
|
||||
await refreshPublishAccounts(store);
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result.message || "登录成功"),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: `登录失败:${msg}` };
|
||||
} finally {
|
||||
store.publishLoggingIn = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {ReturnType<typeof import('../stores/workflow.js').useWorkflowStore>} store
|
||||
*/
|
||||
export async function executePublishVideo(store, { autoPublish = true } = {}) {
|
||||
const selected = store.publishPlatforms.filter((p) => p.checked);
|
||||
if (!selected.length) {
|
||||
return { ok: false, message: "请至少选择一个发布平台" };
|
||||
}
|
||||
|
||||
const videoPath = String(
|
||||
store.processedVideoPath || store.generatedVideoPath || "",
|
||||
).trim();
|
||||
if (!videoPath) {
|
||||
return { ok: false, message: "请先生成口播视频" };
|
||||
}
|
||||
|
||||
const coverPath = String(store.coverImagePath || "").trim();
|
||||
if (!coverPath) {
|
||||
return { ok: false, message: "请先在步骤 06 生成封面" };
|
||||
}
|
||||
|
||||
const title = String(store.titleGenerated || "").trim();
|
||||
if (!title) {
|
||||
return { ok: false, message: "请先在步骤 04 生成标题" };
|
||||
}
|
||||
|
||||
for (const row of selected) {
|
||||
const check = await invoke("publish_check_login", {
|
||||
platform: row.key,
|
||||
accountId: row.accountId || null,
|
||||
checkBrowser: false,
|
||||
});
|
||||
if (!check?.isLoggedIn) {
|
||||
const label = row.label || row.key;
|
||||
return { ok: false, message: `请先登录:${label}` };
|
||||
}
|
||||
row.loggedIn = true;
|
||||
row.nickname = check?.userInfo?.nickname || row.nickname;
|
||||
}
|
||||
|
||||
const tags = String(store.tagsGenerated || "")
|
||||
.split(/[,,\s#]+/)
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const description =
|
||||
String(store.scriptContent || "").trim().slice(0, 500) || title;
|
||||
|
||||
store.publishing = true;
|
||||
store.publishStatus = "";
|
||||
store.publishResults = [];
|
||||
|
||||
try {
|
||||
const result = await invoke("publish_execute", {
|
||||
platforms: selected.map((p) => ({
|
||||
platform: p.key,
|
||||
accountId: p.accountId || null,
|
||||
})),
|
||||
videoPath,
|
||||
coverPath,
|
||||
title: title.split(/\r?\n/)[0].slice(0, 30),
|
||||
description,
|
||||
tags,
|
||||
autoPublish,
|
||||
});
|
||||
|
||||
store.publishResults = Array.isArray(result?.results) ? result.results : [];
|
||||
|
||||
if (!result?.success && !store.publishResults.some((r) => r.pending)) {
|
||||
return {
|
||||
ok: false,
|
||||
message: String(result?.message || result?.error || "发布失败"),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
message: String(result?.message || "发布流程完成"),
|
||||
};
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
return { ok: false, message: `发布失败:${msg}` };
|
||||
} finally {
|
||||
store.publishing = false;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,8 @@ const USER_KEY = "aiclient_current_user";
|
||||
export const ROLE_ADMIN = 1;
|
||||
/** 代理 */
|
||||
export const ROLE_AGENT = 2;
|
||||
export const ROLE_OEM=3;
|
||||
|
||||
|
||||
async function syncAuthSessionToRust(token, user) {
|
||||
try {
|
||||
@@ -58,6 +60,7 @@ export const useAuthStore = defineStore("auth", {
|
||||
roleId: (state) => state.currentUser?.role_id ?? null,
|
||||
isAdmin: (state) => state.currentUser?.role_id === ROLE_ADMIN,
|
||||
isAgent: (state) => state.currentUser?.role_id === ROLE_AGENT,
|
||||
isOEM : (state) => state.currentUser?.role_id === ROLE_OEM,
|
||||
},
|
||||
|
||||
actions: {
|
||||
|
||||
@@ -25,6 +25,34 @@ import {
|
||||
executeGenerateTalkingVideo,
|
||||
selectVideoHistory,
|
||||
} from "../services/talkingVideoGenerate.js";
|
||||
import {
|
||||
executeAutoProcessVideo,
|
||||
pickGreenScreenBackground,
|
||||
pickPipMaterial,
|
||||
saveSimplePipConfig,
|
||||
} from "../services/videoEditProcess.js";
|
||||
import { executeGenerateTitleTags } from "../services/titleTagsGenerate.js";
|
||||
import {
|
||||
chooseBgm,
|
||||
executeGenerateSubtitleAndBgm,
|
||||
previewBgm,
|
||||
} from "../services/subtitleBgmGenerate.js";
|
||||
import {
|
||||
chooseCoverMaterial,
|
||||
executeGenerateCover,
|
||||
} from "../services/coverGenerate.js";
|
||||
import { COVER_TEMPLATES } from "../config/coverTemplates.js";
|
||||
import {
|
||||
executeOneClickPipeline,
|
||||
stopOneClickPipeline,
|
||||
} from "../services/oneClickPipeline.js";
|
||||
import {
|
||||
createDefaultPublishPlatforms,
|
||||
executePublishVideo,
|
||||
loginPublishPlatform,
|
||||
refreshPublishAccounts,
|
||||
} from "../services/videoPublish.js";
|
||||
import { SUBTITLE_TEMPLATES } from "../config/subtitleTemplates.js";
|
||||
import { listGeneratedAudios } from "../services/audioDb.js";
|
||||
import { listGeneratedVideos } from "../services/videoDb.js";
|
||||
import { localAudioToPlayableUrl } from "../services/localAudio.js";
|
||||
@@ -41,20 +69,6 @@ const NODEJS_EVENT = "nodejs:event";
|
||||
|
||||
|
||||
|
||||
const defaultPublishPlatforms = () => [
|
||||
|
||||
{ label: "*音", checked: false, account: null },
|
||||
|
||||
{ label: "*手", checked: false, account: null },
|
||||
|
||||
{ label: "*频号", checked: false, account: null },
|
||||
|
||||
{ label: "*红书", checked: false, account: null },
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
state: () => ({
|
||||
@@ -89,6 +103,10 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
topicRewritePrompt:
|
||||
"请仿写以下标题:{{content}}。要求保持原意和风格,但用不同的表达方式,更加吸引人。",
|
||||
|
||||
/** 标题标签关键词提示词(对齐 zhenqianba titleTagPrompt) */
|
||||
titleTagPrompt:
|
||||
"你是短视频标题与标签助手。分析文案内容 {{content}},生成吸引人的标题、相关标签,以及四个分组关键词(重点词/成语词、描述词、行动词、情感词,每组 0-2 个词)。",
|
||||
|
||||
videoLinkModalVisible: false,
|
||||
|
||||
videoShareText: "",
|
||||
@@ -195,10 +213,29 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
greenScreen: false,
|
||||
|
||||
videoProcessing: false,
|
||||
|
||||
/** 绿幕替换背景图本地路径 */
|
||||
greenScreenBackgroundPath: "",
|
||||
|
||||
/** 编辑后视频(与 generated 同步更新,便于后续步骤区分) */
|
||||
processedVideoPath: "",
|
||||
|
||||
processedVideoSrc: "",
|
||||
|
||||
/** 剪气口参数(对齐 Electron settings) */
|
||||
silenceThreshold: -40,
|
||||
|
||||
silenceDuration: 1,
|
||||
|
||||
minPauseDuration: 0.15,
|
||||
|
||||
|
||||
|
||||
// 04 标题标签关键词
|
||||
|
||||
titleTagsGenerating: false,
|
||||
|
||||
titleGenerated: "",
|
||||
|
||||
tagsGenerated: "",
|
||||
@@ -227,11 +264,62 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
subtitleTemplate: null,
|
||||
|
||||
/** 字幕样式模板 id(见 subtitleTemplates.js) */
|
||||
subtitleTemplateId: "default",
|
||||
|
||||
subtitleBgmGenerating: false,
|
||||
|
||||
subtitleSrtPath: "",
|
||||
|
||||
bgmPath: "",
|
||||
|
||||
bgmPreviewSrc: "",
|
||||
|
||||
|
||||
|
||||
// 06 封面制作
|
||||
|
||||
coverGenerating: false,
|
||||
|
||||
coverImagePath: "",
|
||||
|
||||
coverImageSrc: "",
|
||||
|
||||
/** 高级封面步骤预览图路径列表(Python 返回) */
|
||||
coverPreviewImages: [],
|
||||
|
||||
coverTemplateId: "default",
|
||||
|
||||
/** 封面素材视频(可选,覆盖口播视频抽帧) */
|
||||
coverCustomVideoPath: "",
|
||||
|
||||
|
||||
|
||||
// 07 视频发布
|
||||
|
||||
publishPlatforms: defaultPublishPlatforms(),
|
||||
publishPlatforms: createDefaultPublishPlatforms(),
|
||||
|
||||
publishing: false,
|
||||
|
||||
publishLoggingIn: "",
|
||||
|
||||
publishStatus: "",
|
||||
|
||||
publishResults: [],
|
||||
|
||||
publishScheduledAt: null,
|
||||
|
||||
publishScheduleTimerId: null,
|
||||
|
||||
// 一键自动
|
||||
|
||||
oneClickRunning: false,
|
||||
|
||||
oneClickCancelRequested: false,
|
||||
|
||||
oneClickStatus: "",
|
||||
|
||||
oneClickProgress: 0,
|
||||
|
||||
}),
|
||||
|
||||
@@ -269,6 +357,35 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
|
||||
keywordCountEmotion: (state) => countLines(state.keywordsEmotion),
|
||||
|
||||
subtitleTemplateLabel: (state) => {
|
||||
const tpl = SUBTITLE_TEMPLATES.find(
|
||||
(t) => t.id === (state.subtitleTemplateId || "default"),
|
||||
);
|
||||
return tpl?.label || "未选择";
|
||||
},
|
||||
|
||||
coverTemplateLabel: (state) => {
|
||||
const tpl = COVER_TEMPLATES.find(
|
||||
(t) => t.id === (state.coverTemplateId || "default"),
|
||||
);
|
||||
return tpl?.label || "未选择";
|
||||
},
|
||||
|
||||
coverMaterialFileName: (state) => {
|
||||
const p = String(state.coverCustomVideoPath || "").trim();
|
||||
if (!p) return "";
|
||||
const parts = p.replace(/\\/g, "/").split("/");
|
||||
return parts[parts.length - 1] || p;
|
||||
},
|
||||
|
||||
bgmFileName: (state) => {
|
||||
const p = state.bgmPath;
|
||||
if (!p) return "";
|
||||
return p.split(/[/\\]/).pop() || p;
|
||||
},
|
||||
|
||||
oneClickBusy: (state) => state.oneClickRunning,
|
||||
|
||||
ipBenchmarkCount: (state) => state.ipBenchmarks.length,
|
||||
|
||||
selectedBenchmark: (state) =>
|
||||
@@ -511,11 +628,109 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
await selectVideoHistory(this, videoId);
|
||||
},
|
||||
|
||||
async autoProcessVideo() {
|
||||
return executeAutoProcessVideo(this);
|
||||
},
|
||||
|
||||
async chooseGreenScreenBackground() {
|
||||
const path = await pickGreenScreenBackground();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
this.greenScreenBackgroundPath = path;
|
||||
return { ok: true, message: "已选择绿幕背景图" };
|
||||
},
|
||||
|
||||
async choosePipMaterial() {
|
||||
const path = await pickPipMaterial();
|
||||
if (!path) return { ok: false, message: "" };
|
||||
await saveSimplePipConfig({
|
||||
materialPath: path,
|
||||
startTime: 0,
|
||||
displayMode: "pip",
|
||||
pipPosition: "bottom-right",
|
||||
pipSizePercent: 30,
|
||||
pipScaleMode: "fit",
|
||||
});
|
||||
this.pipInPicture = true;
|
||||
return { ok: true, message: "已选择画中画素材" };
|
||||
},
|
||||
|
||||
async generateTitleTags() {
|
||||
return executeGenerateTitleTags(this);
|
||||
},
|
||||
|
||||
async generateSubtitleAndBgm() {
|
||||
return executeGenerateSubtitleAndBgm(this);
|
||||
},
|
||||
|
||||
async chooseBgm() {
|
||||
return chooseBgm(this);
|
||||
},
|
||||
|
||||
async previewBgm() {
|
||||
return previewBgm(this);
|
||||
},
|
||||
|
||||
async generateCover() {
|
||||
return executeGenerateCover(this);
|
||||
},
|
||||
|
||||
async chooseCoverMaterial() {
|
||||
return chooseCoverMaterial(this);
|
||||
},
|
||||
|
||||
async refreshPublishAccounts() {
|
||||
return refreshPublishAccounts(this);
|
||||
},
|
||||
|
||||
async loginPublishPlatform(platformKey) {
|
||||
return loginPublishPlatform(this, platformKey);
|
||||
},
|
||||
|
||||
async publishVideo(options) {
|
||||
return executePublishVideo(this, options);
|
||||
},
|
||||
|
||||
clearPublishSchedule() {
|
||||
if (this.publishScheduleTimerId) {
|
||||
clearTimeout(this.publishScheduleTimerId);
|
||||
this.publishScheduleTimerId = null;
|
||||
}
|
||||
this.publishScheduledAt = null;
|
||||
},
|
||||
|
||||
schedulePublishVideo(scheduledAt) {
|
||||
this.clearPublishSchedule();
|
||||
const target = scheduledAt instanceof Date ? scheduledAt : new Date(scheduledAt);
|
||||
const delayMs = target.getTime() - Date.now();
|
||||
if (delayMs <= 0) {
|
||||
return { ok: false, message: "发布时间必须晚于当前时间" };
|
||||
}
|
||||
this.publishScheduledAt = target.toISOString();
|
||||
this.publishScheduleTimerId = setTimeout(async () => {
|
||||
this.publishScheduleTimerId = null;
|
||||
this.publishScheduledAt = null;
|
||||
await executePublishVideo(this, { autoPublish: true });
|
||||
}, delayMs);
|
||||
return {
|
||||
ok: true,
|
||||
message: `已设置定时发布:${target.toLocaleString("zh-CN")}`,
|
||||
};
|
||||
},
|
||||
|
||||
async startOneClickAuto() {
|
||||
return executeOneClickPipeline(this);
|
||||
},
|
||||
|
||||
stopOneClickAuto() {
|
||||
return stopOneClickPipeline(this);
|
||||
},
|
||||
|
||||
resetAll() {
|
||||
|
||||
this.$reset();
|
||||
|
||||
this.publishPlatforms = defaultPublishPlatforms();
|
||||
this.publishPlatforms = createDefaultPublishPlatforms();
|
||||
this.clearPublishSchedule();
|
||||
|
||||
},
|
||||
|
||||
|
||||
@@ -503,6 +503,27 @@ body {
|
||||
box-shadow: 0 4px 16px rgba(168, 85, 247, 0.35);
|
||||
}
|
||||
|
||||
.dashboard-one-click:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
filter: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.one-click-progress-track {
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.one-click-progress-bar {
|
||||
height: 100%;
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(90deg, #a855f7, #d946ef);
|
||||
transition: width 0.35s ease;
|
||||
}
|
||||
|
||||
/* 复选框选中:无实心 primary 底,保持深色背景 + 蓝色描边/勾 */
|
||||
:root {
|
||||
--p-checkbox-checked-background: rgba(255, 255, 255, 0.05);
|
||||
|
||||
12
src/views/OemView.vue
Normal file
12
src/views/OemView.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup>
|
||||
import OemSubNav from "../components/oem/OemSubNav.vue";
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-layout">
|
||||
<AdminSubNav />
|
||||
<div class="admin-layout__content custom-scrollbar">
|
||||
<RouterView />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../../stores/auth.js";
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT,ROLE_OEM } from "../../stores/auth.js";
|
||||
import {
|
||||
listAdminUsersApi,
|
||||
createAdminUserApi,
|
||||
@@ -14,12 +14,14 @@ const ROLE_OPTIONS = [
|
||||
{ label: "普通用户", value: 0 },
|
||||
{ label: "管理员", value: 1 },
|
||||
{ label: "代理", value: 2 },
|
||||
{ label: "OEM", value: 3 },
|
||||
];
|
||||
|
||||
const ROLE_LABELS = {
|
||||
0: "普通用户",
|
||||
[ROLE_ADMIN]: "管理员",
|
||||
[ROLE_AGENT]: "代理",
|
||||
[ROLE_OEM]: "OEM",
|
||||
};
|
||||
|
||||
const users = ref([]);
|
||||
|
||||
369
src/views/oem/OemCardKeysView.vue
Normal file
369
src/views/oem/OemCardKeysView.vue
Normal file
@@ -0,0 +1,369 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
import { useAuthStore } from "../../stores/auth.js";
|
||||
import {
|
||||
listAdminCardKeysApi,
|
||||
createAdminCardKeysApi,
|
||||
updateAdminCardKeyApi,
|
||||
deleteAdminCardKeyApi,
|
||||
} from "../../api/adminCardKeys.js";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const STATUS_OPTIONS = [
|
||||
{ label: "全部", value: "all" },
|
||||
{ label: "未使用", value: "unused" },
|
||||
{ label: "已激活", value: "used" },
|
||||
];
|
||||
|
||||
const cards = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const statusFilter = ref("all");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMode = ref("create");
|
||||
const editingId = ref(null);
|
||||
|
||||
const emptyForm = () => ({
|
||||
serial_number: "",
|
||||
duration_days: 30,
|
||||
count: 1,
|
||||
remark: "",
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
dialogMode.value === "create" ? "生成卡密" : "编辑卡密",
|
||||
);
|
||||
|
||||
const isActivated = computed(() => dialogMode.value === "edit" && !!editingActivatedAt.value);
|
||||
const editingActivatedAt = ref(null);
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function showMessage(severity, message) {
|
||||
feedback.value = { severity, message };
|
||||
}
|
||||
|
||||
async function loadCards() {
|
||||
loading.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const res = await listAdminCardKeysApi(auth.token, {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
status: statusFilter.value,
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "加载卡密列表失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
cards.value = Array.isArray(data?.items) ? data.items : [];
|
||||
total.value = typeof data?.total === "number" ? data.total : 0;
|
||||
if (typeof data?.page === "number") page.value = data.page;
|
||||
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
||||
}
|
||||
|
||||
async function onPage(event) {
|
||||
page.value = event.page + 1;
|
||||
pageSize.value = event.rows;
|
||||
await loadCards();
|
||||
}
|
||||
|
||||
function onStatusChange() {
|
||||
page.value = 1;
|
||||
loadCards();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
editingActivatedAt.value = null;
|
||||
form.value = emptyForm();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(row) {
|
||||
dialogMode.value = "edit";
|
||||
editingId.value = row.id;
|
||||
editingActivatedAt.value = row.activated_at;
|
||||
form.value = {
|
||||
serial_number: row.serial_number,
|
||||
duration_days: row.duration_days,
|
||||
count: 1,
|
||||
remark: row.remark || "",
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function copySerial(serial) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(serial);
|
||||
showMessage("success", "序列号已复制");
|
||||
} catch {
|
||||
showMessage("warn", "复制失败,请手动复制");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveCard() {
|
||||
if (form.value.duration_days < 1) {
|
||||
showMessage("warn", "时长至少 1 天");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
|
||||
let res;
|
||||
if (dialogMode.value === "create") {
|
||||
const payload = {
|
||||
duration_days: form.value.duration_days,
|
||||
count: form.value.count,
|
||||
remark: form.value.remark.trim() || null,
|
||||
};
|
||||
const serial = form.value.serial_number.trim();
|
||||
if (serial) {
|
||||
if (form.value.count !== 1) {
|
||||
saving.value = false;
|
||||
showMessage("warn", "指定序列号时数量只能为 1");
|
||||
return;
|
||||
}
|
||||
payload.serial_number = serial.toUpperCase();
|
||||
}
|
||||
res = await createAdminCardKeysApi(auth.token, payload);
|
||||
} else {
|
||||
res = await updateAdminCardKeyApi(auth.token, editingId.value, {
|
||||
duration_days: isActivated.value ? undefined : form.value.duration_days,
|
||||
remark: form.value.remark.trim() || null,
|
||||
});
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "保存失败");
|
||||
return;
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
showMessage("success", res.message || "保存成功");
|
||||
await loadCards();
|
||||
}
|
||||
|
||||
async function removeCard(row) {
|
||||
if (row.activated_at) {
|
||||
showMessage("warn", "已激活卡密不能删除");
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = window.confirm(`确定删除卡密「${row.serial_number}」?`);
|
||||
if (!ok) return;
|
||||
|
||||
const res = await deleteAdminCardKeyApi(auth.token, row.id);
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "删除失败");
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage("success", res.message || "已删除");
|
||||
if (cards.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
}
|
||||
await loadCards();
|
||||
}
|
||||
|
||||
watch(statusFilter, onStatusChange);
|
||||
|
||||
onMounted(() => {
|
||||
loadCards();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page admin-card-keys">
|
||||
<div class="admin-users__header">
|
||||
<div>
|
||||
<h1 class="admin-page__title gradient-text">卡密管理</h1>
|
||||
<p class="text-sm text-text-muted">生成、查看与管理 VIP 激活卡密</p>
|
||||
</div>
|
||||
<Button label="生成卡密" @click="openCreateDialog" />
|
||||
</div>
|
||||
|
||||
<Message
|
||||
v-if="feedback.message"
|
||||
:severity="feedback.severity"
|
||||
:closable="true"
|
||||
class="admin-users__message"
|
||||
@close="feedback.message = ''"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</Message>
|
||||
|
||||
<div class="admin-card-keys__toolbar">
|
||||
<label class="text-sm text-text-muted">状态筛选</label>
|
||||
<Select
|
||||
v-model="statusFilter"
|
||||
:options="STATUS_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="admin-card-keys__status-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="cards"
|
||||
:loading="loading"
|
||||
lazy
|
||||
paginator
|
||||
:rows="pageSize"
|
||||
:total-records="total"
|
||||
:first="(page - 1) * pageSize"
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||
current-page-report-template="共 {totalRecords} 条"
|
||||
striped-rows
|
||||
size="small"
|
||||
class="admin-users__table"
|
||||
data-key="id"
|
||||
@page="onPage"
|
||||
>
|
||||
<Column field="serial_number" header="序列号" style="min-width: 11rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-card-keys__serial">
|
||||
<code class="text-xs">{{ data.serial_number }}</code>
|
||||
<Button
|
||||
label="复制"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="copySerial(data.serial_number)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="duration_days" header="时长(天)" style="width: 6rem" />
|
||||
<Column field="created_at" header="创建时间">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="activated_at" header="激活时间">
|
||||
<template #body="{ data }">
|
||||
<Tag
|
||||
v-if="data.activated_at"
|
||||
:value="formatDateTime(data.activated_at)"
|
||||
severity="success"
|
||||
/>
|
||||
<Tag v-else value="未使用" severity="secondary" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="username" header="使用者">
|
||||
<template #body="{ data }">
|
||||
{{ data.username || "—" }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="remark" header="备注">
|
||||
<template #body="{ data }">
|
||||
{{ data.remark || "—" }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-users__actions">
|
||||
<Button
|
||||
label="编辑"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="openEditDialog(data)"
|
||||
/>
|
||||
<Button
|
||||
label="删除"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
:disabled="!!data.activated_at"
|
||||
@click="removeCard(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<p class="py-6 text-center text-sm text-text-muted">暂无卡密</p>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="dialogTitle"
|
||||
modal
|
||||
:style="{ width: '28rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<template v-if="dialogMode === 'create'">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">时长(天)</label>
|
||||
<InputNumber v-model="form.duration_days" :min="1" :max="3650" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">生成数量</label>
|
||||
<InputNumber v-model="form.count" :min="1" :max="100" class="w-full" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">自定义序列号(可选,仅 1 张)</label>
|
||||
<InputText
|
||||
v-model="form.serial_number"
|
||||
class="w-full font-mono uppercase"
|
||||
placeholder="留空则自动生成"
|
||||
:disabled="form.count > 1"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">序列号</label>
|
||||
<InputText
|
||||
:model-value="form.serial_number"
|
||||
class="w-full font-mono"
|
||||
disabled
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">时长(天)</label>
|
||||
<InputNumber
|
||||
v-model="form.duration_days"
|
||||
:min="1"
|
||||
:max="3650"
|
||||
class="w-full"
|
||||
:disabled="isActivated"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">备注</label>
|
||||
<Textarea v-model="form.remark" rows="2" class="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||
<Button label="保存" :loading="saving" @click="saveCard" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
325
src/views/oem/OemDesktopConfigView.vue
Normal file
325
src/views/oem/OemDesktopConfigView.vue
Normal file
@@ -0,0 +1,325 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useAuthStore } from "../../stores/auth.js";
|
||||
import {
|
||||
listAdminDesktopConfigsApi,
|
||||
createAdminDesktopConfigApi,
|
||||
updateAdminDesktopConfigApi,
|
||||
deleteAdminDesktopConfigApi,
|
||||
} from "../../api/adminDesktopConfigs.js";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const configs = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const keyword = ref("");
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMode = ref("create");
|
||||
const editingId = ref(null);
|
||||
|
||||
const emptyForm = () => ({
|
||||
name: "",
|
||||
value: "",
|
||||
mark: "",
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
dialogMode.value === "create" ? "新建配置" : "编辑配置",
|
||||
);
|
||||
|
||||
const valuePreview = computed(() => {
|
||||
const v = form.value.value || "";
|
||||
if (v.length <= 120) return v;
|
||||
return `${v.slice(0, 120)}…`;
|
||||
});
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function showMessage(severity, message) {
|
||||
feedback.value = { severity, message };
|
||||
}
|
||||
|
||||
function truncateValue(value, max = 48) {
|
||||
if (!value) return "—";
|
||||
if (value.length <= max) return value;
|
||||
return `${value.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
async function loadConfigs() {
|
||||
loading.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const res = await listAdminDesktopConfigsApi(auth.token, {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
keyword: keyword.value,
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "加载配置失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
configs.value = Array.isArray(data?.items) ? data.items : [];
|
||||
total.value = typeof data?.total === "number" ? data.total : 0;
|
||||
if (typeof data?.page === "number") page.value = data.page;
|
||||
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
||||
}
|
||||
|
||||
async function onPage(event) {
|
||||
page.value = event.page + 1;
|
||||
pageSize.value = event.rows;
|
||||
await loadConfigs();
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
page.value = 1;
|
||||
loadConfigs();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(row) {
|
||||
dialogMode.value = "edit";
|
||||
editingId.value = row.id;
|
||||
form.value = {
|
||||
name: row.name,
|
||||
value: row.value,
|
||||
mark: row.mark || "",
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
async function copyName(name) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(name);
|
||||
showMessage("success", "键名已复制");
|
||||
} catch {
|
||||
showMessage("warn", "复制失败");
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
const name = form.value.name.trim();
|
||||
if (!name) {
|
||||
showMessage("warn", "配置键名不能为空");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
value: form.value.value,
|
||||
mark: form.value.mark.trim() || null,
|
||||
};
|
||||
|
||||
let res;
|
||||
if (dialogMode.value === "create") {
|
||||
res = await createAdminDesktopConfigApi(auth.token, payload);
|
||||
} else {
|
||||
res = await updateAdminDesktopConfigApi(auth.token, editingId.value, {
|
||||
value: payload.value,
|
||||
mark: payload.mark,
|
||||
});
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "保存失败");
|
||||
return;
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
showMessage("success", res.message || "保存成功");
|
||||
await loadConfigs();
|
||||
}
|
||||
|
||||
async function removeConfig(row) {
|
||||
const ok = window.confirm(`确定删除配置「${row.name}」?桌面端需重新登录后生效。`);
|
||||
if (!ok) return;
|
||||
|
||||
const res = await deleteAdminDesktopConfigApi(auth.token, row.id);
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "删除失败");
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage("success", res.message || "已删除");
|
||||
if (configs.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
}
|
||||
await loadConfigs();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfigs();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page admin-desktop-config">
|
||||
<div class="admin-users__header">
|
||||
<div>
|
||||
<h1 class="admin-page__title gradient-text">桌面配置</h1>
|
||||
<p class="text-sm text-text-muted">
|
||||
管理 desktop_configs 键值,下发至桌面端 Node 环境变量(AICLIENT_CFG_*)
|
||||
</p>
|
||||
</div>
|
||||
<Button label="新建配置" @click="openCreateDialog" />
|
||||
</div>
|
||||
|
||||
<Message
|
||||
v-if="feedback.message"
|
||||
:severity="feedback.severity"
|
||||
:closable="true"
|
||||
class="admin-users__message"
|
||||
@close="feedback.message = ''"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</Message>
|
||||
|
||||
<div class="admin-card-keys__toolbar">
|
||||
<InputText
|
||||
v-model="keyword"
|
||||
placeholder="按键名搜索,如 LLM_API_KEY"
|
||||
class="admin-desktop-config__search"
|
||||
@keyup.enter="onSearch"
|
||||
/>
|
||||
<Button label="搜索" severity="secondary" @click="onSearch" />
|
||||
</div>
|
||||
|
||||
<DataTable
|
||||
:value="configs"
|
||||
:loading="loading"
|
||||
lazy
|
||||
paginator
|
||||
:rows="pageSize"
|
||||
:total-records="total"
|
||||
:first="(page - 1) * pageSize"
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||
current-page-report-template="共 {totalRecords} 条"
|
||||
striped-rows
|
||||
size="small"
|
||||
class="admin-users__table"
|
||||
data-key="id"
|
||||
@page="onPage"
|
||||
>
|
||||
<Column field="name" header="键名" style="min-width: 10rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-card-keys__serial">
|
||||
<code class="text-xs">{{ data.name }}</code>
|
||||
<Button label="复制" size="small" severity="secondary" text @click="copyName(data.name)" />
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="value" header="值">
|
||||
<template #body="{ data }">
|
||||
<span class="admin-desktop-config__value" :title="data.value">
|
||||
{{ truncateValue(data.value) }}
|
||||
</span>
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="mark" header="备注">
|
||||
<template #body="{ data }">
|
||||
{{ data.mark || "—" }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="updated_at" header="更新时间">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.updated_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-users__actions">
|
||||
<Button
|
||||
label="编辑"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="openEditDialog(data)"
|
||||
/>
|
||||
<Button
|
||||
label="删除"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
@click="removeConfig(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<p class="py-6 text-center text-sm text-text-muted">暂无配置</p>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="dialogTitle"
|
||||
modal
|
||||
:style="{ width: '32rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">键名 (name)</label>
|
||||
<InputText
|
||||
v-model="form.name"
|
||||
class="w-full font-mono uppercase"
|
||||
placeholder="如 LLM_API_KEY"
|
||||
:disabled="dialogMode === 'edit'"
|
||||
/>
|
||||
<p v-if="dialogMode === 'edit'" class="text-xs text-text-muted">
|
||||
键名创建后不可修改,避免环境变量映射错乱
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">值 (value)</label>
|
||||
<Textarea
|
||||
v-model="form.value"
|
||||
rows="6"
|
||||
class="w-full font-mono text-sm"
|
||||
placeholder="支持长文本或 JSON,如 OSS_CONFIG"
|
||||
/>
|
||||
<p v-if="valuePreview && form.value.length > 120" class="text-xs text-text-muted">
|
||||
预览:{{ valuePreview }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">备注 (mark)</label>
|
||||
<InputText v-model="form.mark" class="w-full" placeholder="可选说明" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||
<Button label="保存" :loading="saving" @click="saveConfig" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
14
src/views/oem/OemOverviewView.vue
Normal file
14
src/views/oem/OemOverviewView.vue
Normal file
@@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
import { useAuthStore } from "../../stores/auth";
|
||||
|
||||
const auth = useAuthStore();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page">
|
||||
<h1 class="admin-page__title gradient-text">概览</h1>
|
||||
<p class="text-sm text-text-muted">
|
||||
欢迎,{{ auth.currentUser?.username }}。在此查看系统运行概况。
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
364
src/views/oem/OemUsersView.vue
Normal file
364
src/views/oem/OemUsersView.vue
Normal file
@@ -0,0 +1,364 @@
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from "vue";
|
||||
import { useAuthStore, ROLE_ADMIN, ROLE_AGENT } from "../../stores/auth.js";
|
||||
import {
|
||||
listAdminUsersApi,
|
||||
createAdminUserApi,
|
||||
updateAdminUserApi,
|
||||
deleteAdminUserApi,
|
||||
} from "../../api/adminUsers.js";
|
||||
|
||||
const auth = useAuthStore();
|
||||
|
||||
const ROLE_OPTIONS = [
|
||||
{ label: "普通用户", value: 0 },
|
||||
{ label: "管理员", value: 1 },
|
||||
{ label: "代理", value: 2 },
|
||||
];
|
||||
|
||||
const ROLE_LABELS = {
|
||||
0: "普通用户",
|
||||
[ROLE_ADMIN]: "管理员",
|
||||
[ROLE_AGENT]: "代理",
|
||||
};
|
||||
|
||||
const users = ref([]);
|
||||
const total = ref(0);
|
||||
const page = ref(1);
|
||||
const pageSize = ref(20);
|
||||
const loading = ref(false);
|
||||
const saving = ref(false);
|
||||
const feedback = ref({ severity: "", message: "" });
|
||||
|
||||
const dialogVisible = ref(false);
|
||||
const dialogMode = ref("create");
|
||||
const editingId = ref(null);
|
||||
|
||||
const emptyForm = () => ({
|
||||
username: "",
|
||||
password: "",
|
||||
phone: "",
|
||||
role_id: 0,
|
||||
vip_end_time: "",
|
||||
clear_vip_end_time: false,
|
||||
});
|
||||
|
||||
const form = ref(emptyForm());
|
||||
|
||||
const dialogTitle = computed(() =>
|
||||
dialogMode.value === "create" ? "新建用户" : "编辑用户",
|
||||
);
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) return "—";
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function toDatetimeLocalValue(iso) {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function roleLabel(roleId) {
|
||||
return ROLE_LABELS[roleId] ?? `角色 ${roleId}`;
|
||||
}
|
||||
|
||||
function roleSeverity(roleId) {
|
||||
if (roleId === ROLE_ADMIN) return "info";
|
||||
if (roleId === ROLE_AGENT) return "warn";
|
||||
return "secondary";
|
||||
}
|
||||
|
||||
function showMessage(severity, message) {
|
||||
feedback.value = { severity, message };
|
||||
}
|
||||
|
||||
async function loadUsers() {
|
||||
loading.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
const res = await listAdminUsersApi(auth.token, {
|
||||
page: page.value,
|
||||
pageSize: pageSize.value,
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "加载用户列表失败");
|
||||
return;
|
||||
}
|
||||
|
||||
const data = res.data;
|
||||
users.value = Array.isArray(data?.items) ? data.items : [];
|
||||
total.value = typeof data?.total === "number" ? data.total : 0;
|
||||
if (typeof data?.page === "number") page.value = data.page;
|
||||
if (typeof data?.page_size === "number") pageSize.value = data.page_size;
|
||||
}
|
||||
|
||||
async function onPage(event) {
|
||||
page.value = event.page + 1;
|
||||
pageSize.value = event.rows;
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
function openCreateDialog() {
|
||||
dialogMode.value = "create";
|
||||
editingId.value = null;
|
||||
form.value = emptyForm();
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function openEditDialog(row) {
|
||||
dialogMode.value = "edit";
|
||||
editingId.value = row.id;
|
||||
form.value = {
|
||||
username: row.username,
|
||||
password: "",
|
||||
phone: row.phone || "",
|
||||
role_id: row.role_id,
|
||||
vip_end_time: toDatetimeLocalValue(row.vip_end_time),
|
||||
clear_vip_end_time: false,
|
||||
};
|
||||
dialogVisible.value = true;
|
||||
}
|
||||
|
||||
function buildPayload() {
|
||||
const payload = {
|
||||
username: form.value.username.trim(),
|
||||
phone: form.value.phone.trim() || null,
|
||||
role_id: form.value.role_id,
|
||||
};
|
||||
|
||||
if (form.value.password.trim()) {
|
||||
payload.password = form.value.password;
|
||||
}
|
||||
|
||||
if (form.value.clear_vip_end_time) {
|
||||
payload.clear_vip_end_time = true;
|
||||
} else if (form.value.vip_end_time) {
|
||||
payload.vip_end_time = new Date(form.value.vip_end_time).toISOString();
|
||||
}
|
||||
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function saveUser() {
|
||||
if (!form.value.username.trim()) {
|
||||
showMessage("warn", "用户名不能为空");
|
||||
return;
|
||||
}
|
||||
if (dialogMode.value === "create" && form.value.password.length < 6) {
|
||||
showMessage("warn", "密码至少 6 位");
|
||||
return;
|
||||
}
|
||||
|
||||
saving.value = true;
|
||||
feedback.value = { severity: "", message: "" };
|
||||
|
||||
const payload = buildPayload();
|
||||
let res;
|
||||
|
||||
if (dialogMode.value === "create") {
|
||||
if (!payload.password) {
|
||||
saving.value = false;
|
||||
showMessage("warn", "请设置初始密码");
|
||||
return;
|
||||
}
|
||||
res = await createAdminUserApi(auth.token, payload);
|
||||
} else {
|
||||
res = await updateAdminUserApi(auth.token, editingId.value, payload);
|
||||
}
|
||||
|
||||
saving.value = false;
|
||||
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "保存失败");
|
||||
return;
|
||||
}
|
||||
|
||||
dialogVisible.value = false;
|
||||
showMessage("success", res.message || "保存成功");
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
async function removeUser(row) {
|
||||
if (row.id === auth.currentUser?.id) {
|
||||
showMessage("warn", "不能删除当前登录账号");
|
||||
return;
|
||||
}
|
||||
|
||||
const ok = window.confirm(`确定删除用户「${row.username}」?此操作不可恢复。`);
|
||||
if (!ok) return;
|
||||
|
||||
const res = await deleteAdminUserApi(auth.token, row.id);
|
||||
if (!res.ok) {
|
||||
showMessage("error", res.message || "删除失败");
|
||||
return;
|
||||
}
|
||||
|
||||
showMessage("success", res.message || "已删除");
|
||||
if (users.value.length === 1 && page.value > 1) {
|
||||
page.value -= 1;
|
||||
}
|
||||
await loadUsers();
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadUsers();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="admin-page admin-users">
|
||||
<div class="admin-users__header">
|
||||
<div>
|
||||
<h1 class="admin-page__title gradient-text">用户管理</h1>
|
||||
|
||||
</div>
|
||||
<Button label="新建用户" @click="openCreateDialog" />
|
||||
</div>
|
||||
|
||||
<Message
|
||||
v-if="feedback.message"
|
||||
:severity="feedback.severity"
|
||||
:closable="true"
|
||||
class="admin-users__message"
|
||||
@close="feedback.message = ''"
|
||||
>
|
||||
{{ feedback.message }}
|
||||
</Message>
|
||||
|
||||
<DataTable
|
||||
:value="users"
|
||||
:loading="loading"
|
||||
lazy
|
||||
paginator
|
||||
:rows="pageSize"
|
||||
:total-records="total"
|
||||
:first="(page - 1) * pageSize"
|
||||
:rows-per-page-options="[10, 20, 50]"
|
||||
paginator-template="FirstPageLink PrevPageLink PageLinks NextPageLink LastPageLink RowsPerPageDropdown"
|
||||
current-page-report-template="共 {totalRecords} 条"
|
||||
striped-rows
|
||||
size="small"
|
||||
class="admin-users__table"
|
||||
data-key="id"
|
||||
@page="onPage"
|
||||
>
|
||||
<Column field="id" header="ID" style="width: 4rem" />
|
||||
<Column field="username" header="用户名" />
|
||||
<Column field="phone" header="手机号">
|
||||
<template #body="{ data }">
|
||||
{{ data.phone || "—" }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="role_id" header="角色" style="width: 7rem">
|
||||
<template #body="{ data }">
|
||||
<Tag :value="roleLabel(data.role_id)" :severity="roleSeverity(data.role_id)" />
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="vip_end_time" header="VIP 到期">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.vip_end_time) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column field="created_at" header="注册时间">
|
||||
<template #body="{ data }">
|
||||
{{ formatDateTime(data.created_at) }}
|
||||
</template>
|
||||
</Column>
|
||||
<Column header="操作" style="width: 9rem">
|
||||
<template #body="{ data }">
|
||||
<div class="admin-users__actions">
|
||||
<Button
|
||||
label="编辑"
|
||||
size="small"
|
||||
severity="secondary"
|
||||
text
|
||||
@click="openEditDialog(data)"
|
||||
/>
|
||||
<Button
|
||||
label="删除"
|
||||
size="small"
|
||||
severity="danger"
|
||||
text
|
||||
:disabled="data.id === auth.currentUser?.id"
|
||||
@click="removeUser(data)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Column>
|
||||
<template #empty>
|
||||
<p class="py-6 text-center text-sm text-text-muted">暂无用户</p>
|
||||
</template>
|
||||
</DataTable>
|
||||
|
||||
<Dialog
|
||||
v-model:visible="dialogVisible"
|
||||
:header="dialogTitle"
|
||||
modal
|
||||
class="admin-users-dialog"
|
||||
:style="{ width: '28rem' }"
|
||||
>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">用户名</label>
|
||||
<InputText v-model="form.username" class="w-full" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">
|
||||
{{ dialogMode === "create" ? "密码" : "新密码(留空不修改)" }}
|
||||
</label>
|
||||
<Password
|
||||
v-model="form.password"
|
||||
:feedback="false"
|
||||
toggle-mask
|
||||
fluid
|
||||
input-class="w-full"
|
||||
:placeholder="dialogMode === 'create' ? '至少 6 位' : '留空则不修改'"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">手机号(可选)</label>
|
||||
<InputText v-model="form.phone" class="w-full" autocomplete="off" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">角色</label>
|
||||
<Select
|
||||
v-model="form.role_id"
|
||||
:options="ROLE_OPTIONS"
|
||||
option-label="label"
|
||||
option-value="value"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label class="text-xs text-text-muted uppercase">VIP 到期时间</label>
|
||||
<InputText
|
||||
v-model="form.vip_end_time"
|
||||
type="datetime-local"
|
||||
class="w-full"
|
||||
:disabled="form.clear_vip_end_time"
|
||||
/>
|
||||
<label v-if="dialogMode === 'edit'" class="flex items-center gap-2 text-sm text-text-muted">
|
||||
<Checkbox v-model="form.clear_vip_end_time" :binary="true" input-id="clear-vip" />
|
||||
<span>清除 VIP 到期时间</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #footer>
|
||||
<Button label="取消" severity="secondary" text @click="dialogVisible = false" />
|
||||
<Button label="保存" :loading="saving" @click="saveUser" />
|
||||
</template>
|
||||
</Dialog>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user