11
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
ROLE_ADMIN = 1
|
||||
ROLE_AGENT = 2
|
||||
|
||||
ROLE_OEM=3
|
||||
ROLE_LABELS: dict[int, str] = {
|
||||
0: "普通用户",
|
||||
ROLE_ADMIN: "管理员",
|
||||
ROLE_AGENT: "代理",
|
||||
ROLE_OEM:"oem",
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class AdminUserCreate(BaseModel):
|
||||
username: str = Field(min_length=1, max_length=64)
|
||||
password: str = Field(min_length=6, max_length=128)
|
||||
phone: str | None = Field(default=None, max_length=64)
|
||||
role_id: int = Field(default=0, ge=0, le=2)
|
||||
role_id: int = Field(default=0, ge=0, le=3)
|
||||
vip_end_time: datetime | None = None
|
||||
|
||||
@field_validator("username")
|
||||
|
||||
25
scripts/cover/README.md
Normal file
25
scripts/cover/README.md
Normal file
@@ -0,0 +1,25 @@
|
||||
# 封面生成 Python 脚本
|
||||
|
||||
从 Electron `python-runtimebackup` 移植,供 Tauri 桌面端通过 Node `cover_generate.js` 调用。
|
||||
|
||||
## 目录
|
||||
|
||||
| 文件 | 用途 |
|
||||
|------|------|
|
||||
| `advanced_cover_generator.py` | 高级模板:虚化、抠图、描边、蒙版、多行标题等 |
|
||||
| `cover_generator.py` | 中等复杂度:抽帧 + PIL 叠字 |
|
||||
| `cover_preview_generator.py` | 多风格预览(CLI 输出 JSON) |
|
||||
| `generate_cover_previews.py` | 批量生成模板预览图 |
|
||||
| `generate_image_cover.py` | 静态图多风格封面 |
|
||||
| `custom_template_cover_fix.py` | 自定义模板修复参考(补丁说明) |
|
||||
| `modules/` | 人像分割、字体、封面模块 |
|
||||
|
||||
## 运行时
|
||||
|
||||
- Python:`AICLIENT_PYTHON_PATH`(默认 `src-tauri/resources/resources-bundles/python-runtimebackup/python.exe`)
|
||||
- 脚本目录:`AICLIENT_COVER_SCRIPTS_DIR`(默认 `src-tauri/resources/cover-python/`)
|
||||
- FFmpeg:需在 PATH 或 `AICLIENT_FFMPEG_PATH`
|
||||
|
||||
## 同步
|
||||
|
||||
开发时以本目录为源码;发布前请同步到 `aiclient/src-tauri/resources/cover-python/`(与 `resources/**/*` 一并打包)。
|
||||
2490
scripts/cover/advanced_cover_generator.py
Normal file
2490
scripts/cover/advanced_cover_generator.py
Normal file
File diff suppressed because it is too large
Load Diff
369
scripts/cover/cover_generator.py
Normal file
369
scripts/cover/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
scripts/cover/cover_preview_generator.py
Normal file
165
scripts/cover/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
scripts/cover/custom_template_cover_fix.py
Normal file
235
scripts/cover/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
scripts/cover/find_font.py
Normal file
35
scripts/cover/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
scripts/cover/generate_cover_previews.py
Normal file
83
scripts/cover/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
scripts/cover/generate_image_cover.py
Normal file
168
scripts/cover/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
scripts/cover/get_font_name.py
Normal file
107
scripts/cover/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
scripts/cover/get_fonts.py
Normal file
26
scripts/cover/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
scripts/cover/modules/__init__.py
Normal file
6
scripts/cover/modules/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
modules 包初始化文件
|
||||
"""
|
||||
|
||||
# 这个文件是必需的,让 Python 将 modules 目录识别为一个包
|
||||
# 从而支持相对导入(如 from .utils import ...)
|
||||
2654
scripts/cover/modules/cover.py
Normal file
2654
scripts/cover/modules/cover.py
Normal file
File diff suppressed because it is too large
Load Diff
117
scripts/cover/modules/cover_templates.py
Normal file
117
scripts/cover/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
scripts/cover/modules/font_manager.py
Normal file
517
scripts/cover/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
scripts/cover/modules/segment.py
Normal file
1050
scripts/cover/modules/segment.py
Normal file
File diff suppressed because it is too large
Load Diff
312
scripts/cover/modules/utils.py
Normal file
312
scripts/cover/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)
|
||||
186
scripts/nodejs/cover_generate.js
Normal file
186
scripts/nodejs/cover_generate.js
Normal file
@@ -0,0 +1,186 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 视频封面生成:优先高级 Python → PIL Python → ffmpeg drawtext 回退
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const pipelineNative = require("./pipeline_native.js");
|
||||
const coverPython = require("./cover_python_runner.js");
|
||||
|
||||
const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-cover");
|
||||
|
||||
function ensureDir() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
return OUTPUT_DIR;
|
||||
}
|
||||
|
||||
function execFfmpeg(args) {
|
||||
const ffmpeg = pipelineNative.locateFfmpeg();
|
||||
if (!ffmpeg) {
|
||||
throw new Error("未找到 ffmpeg,请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe");
|
||||
}
|
||||
const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true });
|
||||
if (r.status !== 0) {
|
||||
throw new Error((r.stderr || r.stdout || "").trim() || `ffmpeg 退出码 ${r.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function escapeDrawtext(text) {
|
||||
return String(text)
|
||||
.replace(/\\/g, "\\\\")
|
||||
.replace(/'/g, "'\\''")
|
||||
.replace(/:/g, "\\:")
|
||||
.replace(/%/g, "\\%");
|
||||
}
|
||||
|
||||
function buildTextPosition(titlePosition) {
|
||||
if (titlePosition === "top") return "y=50";
|
||||
if (titlePosition === "center") return "y=(h-text_h)/2";
|
||||
return "y=h-text_h-50";
|
||||
}
|
||||
|
||||
function runFfmpegCover({ sourceVideo, titleText, effectStyle, coverPath, tempFramePath, emit }) {
|
||||
emit?.("正在提取视频帧…");
|
||||
execFfmpeg([
|
||||
"-ss",
|
||||
"00:00:03",
|
||||
"-i",
|
||||
sourceVideo,
|
||||
"-vframes",
|
||||
"1",
|
||||
"-pix_fmt",
|
||||
"yuvj420p",
|
||||
"-c:v",
|
||||
"mjpeg",
|
||||
"-strict:v",
|
||||
"unofficial",
|
||||
"-f",
|
||||
"image2",
|
||||
"-y",
|
||||
tempFramePath,
|
||||
]);
|
||||
|
||||
const titleFontSize = effectStyle.titleFontSize ?? 90;
|
||||
const titleFontColor = effectStyle.titleFontColor || "#FFFFFF";
|
||||
const titlePosition = effectStyle.titlePosition || "bottom";
|
||||
const textPos = buildTextPosition(titlePosition);
|
||||
const escaped = escapeDrawtext(titleText);
|
||||
const vf = `drawtext=text='${escaped}':fontsize=${titleFontSize}:fontcolor=${titleFontColor}:x=(w-text_w)/2:${textPos}:shadowcolor=black:shadowx=2:shadowy=2`;
|
||||
|
||||
emit?.("正在叠加标题文字(ffmpeg)…");
|
||||
execFfmpeg(["-i", tempFramePath, "-vf", vf, "-y", coverPath]);
|
||||
}
|
||||
|
||||
function shouldUsePilCover(effectStyle) {
|
||||
if (coverPython.isAdvancedCoverConfig(effectStyle)) return false;
|
||||
return (
|
||||
(effectStyle.titleStrokeWidth && effectStyle.titleStrokeWidth > 0) ||
|
||||
Boolean(effectStyle.titleFontFamily) ||
|
||||
Boolean(effectStyle.titleColor) ||
|
||||
effectStyle.titleShadowBlur > 0
|
||||
);
|
||||
}
|
||||
|
||||
globalThis.__nodejsMain = async function main(params) {
|
||||
const p = params || {};
|
||||
const videoPath = String(p.videoPath || "").trim();
|
||||
const titleText = String(p.titleText || "").trim();
|
||||
const effectStyle = p.effectStyle || {};
|
||||
|
||||
if (!videoPath || !fs.existsSync(videoPath)) {
|
||||
return { success: false, error: "视频文件不存在,请先在步骤 02/03/05 生成视频" };
|
||||
}
|
||||
if (!titleText) {
|
||||
return { success: false, error: "标题文本不能为空,请先在步骤 04 生成标题" };
|
||||
}
|
||||
|
||||
ensureDir();
|
||||
const ts = Date.now();
|
||||
const tempFramePath = path.join(OUTPUT_DIR, `temp_frame_${ts}.jpg`);
|
||||
const coverPath = path.join(OUTPUT_DIR, `cover_${ts}.jpg`);
|
||||
|
||||
let sourceVideo = videoPath;
|
||||
const custom = String(effectStyle.customVideoPath || "").trim();
|
||||
if (custom && fs.existsSync(custom)) {
|
||||
sourceVideo = custom;
|
||||
}
|
||||
|
||||
const emit = (status) => globalThis.__native?.emitProgress?.(status);
|
||||
|
||||
try {
|
||||
if (coverPython.isAdvancedCoverConfig(effectStyle)) {
|
||||
const adv = coverPython.runAdvancedCoverGenerator({
|
||||
videoPath: sourceVideo,
|
||||
titleText,
|
||||
outputPath: coverPath,
|
||||
config: effectStyle,
|
||||
onProgress: emit,
|
||||
});
|
||||
if (adv.success) {
|
||||
return {
|
||||
success: true,
|
||||
coverPath: adv.coverPath,
|
||||
previewImages: adv.previewImages,
|
||||
message: adv.message || "高级封面生成完成",
|
||||
mode: "advanced_python",
|
||||
};
|
||||
}
|
||||
emit(`高级生成失败,尝试回退:${(adv.error || "").slice(0, 80)}`);
|
||||
} else if (shouldUsePilCover(effectStyle) && coverPython.locatePython()) {
|
||||
const pil = coverPython.runBasicCoverGenerator({
|
||||
videoPath: sourceVideo,
|
||||
titleText,
|
||||
outputPath: coverPath,
|
||||
config: effectStyle,
|
||||
timestamp: 3,
|
||||
onProgress: emit,
|
||||
});
|
||||
if (pil.success) {
|
||||
return {
|
||||
success: true,
|
||||
coverPath: pil.coverPath,
|
||||
message: pil.message || "封面生成完成",
|
||||
mode: "pil_python",
|
||||
};
|
||||
}
|
||||
emit(`PIL 生成失败,尝试 ffmpeg 回退…`);
|
||||
}
|
||||
|
||||
runFfmpegCover({
|
||||
sourceVideo,
|
||||
titleText,
|
||||
effectStyle,
|
||||
coverPath,
|
||||
tempFramePath,
|
||||
emit,
|
||||
});
|
||||
|
||||
if (fs.existsSync(tempFramePath)) {
|
||||
try {
|
||||
fs.unlinkSync(tempFramePath);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
if (!fs.existsSync(coverPath)) {
|
||||
return { success: false, error: "封面文件未生成" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
coverPath,
|
||||
message: "封面生成完成",
|
||||
mode: "ffmpeg",
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e.message || String(e),
|
||||
};
|
||||
}
|
||||
};
|
||||
203
scripts/nodejs/cover_python_runner.js
Normal file
203
scripts/nodejs/cover_python_runner.js
Normal file
@@ -0,0 +1,203 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 调用 bundled Python 封面脚本(对齐 Electron ipAgent:generateVideoCover)
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const pipelineNative = require("./pipeline_native.js");
|
||||
|
||||
function locatePython() {
|
||||
const env = process.env.AICLIENT_PYTHON_PATH;
|
||||
if (env && fs.existsSync(env)) return env;
|
||||
return null;
|
||||
}
|
||||
|
||||
function locateCoverScriptsDir() {
|
||||
const env = process.env.AICLIENT_COVER_SCRIPTS_DIR;
|
||||
if (env && fs.existsSync(path.join(env, "advanced_cover_generator.py"))) {
|
||||
return env;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathForPython(filePath) {
|
||||
if (process.platform === "win32") {
|
||||
return String(filePath).replace(/\\/g, "/");
|
||||
}
|
||||
return String(filePath);
|
||||
}
|
||||
|
||||
function buildPythonEnv() {
|
||||
const env = { ...process.env, PYTHONIOENCODING: "utf-8" };
|
||||
const ffmpeg = pipelineNative.locateFfmpeg();
|
||||
if (ffmpeg && fs.existsSync(ffmpeg)) {
|
||||
const ffmpegDir = path.dirname(ffmpeg);
|
||||
const sep = process.platform === "win32" ? ";" : ":";
|
||||
env.PATH = `${ffmpegDir}${sep}${env.PATH || ""}`;
|
||||
env.AICLIENT_FFMPEG_PATH = ffmpeg;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
/** 与 Electron main 中 isNewTemplate 判定一致 */
|
||||
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 {{ videoPath: string, titleText: string, outputPath: string, config: object, onProgress?: (s: string) => void }} opts
|
||||
*/
|
||||
function runAdvancedCoverGenerator(opts) {
|
||||
const python = locatePython();
|
||||
const scriptsDir = locateCoverScriptsDir();
|
||||
if (!python) {
|
||||
return { success: false, error: "未找到 Python 运行时,请检查 resources-bundles/python-runtimebackup" };
|
||||
}
|
||||
if (!scriptsDir) {
|
||||
return { success: false, error: "未找到封面脚本目录(cover-python)" };
|
||||
}
|
||||
|
||||
const script = path.join(scriptsDir, "advanced_cover_generator.py");
|
||||
if (!fs.existsSync(script)) {
|
||||
return { success: false, error: `缺少脚本: ${script}` };
|
||||
}
|
||||
|
||||
const configJson = JSON.stringify({
|
||||
...opts.config,
|
||||
output: opts.outputPath,
|
||||
customVideoPath: opts.config.customVideoPath || undefined,
|
||||
});
|
||||
|
||||
opts.onProgress?.("正在使用高级封面生成器…");
|
||||
|
||||
const args = [
|
||||
script,
|
||||
"--video",
|
||||
normalizePathForPython(opts.videoPath),
|
||||
"--title",
|
||||
opts.titleText,
|
||||
"--output",
|
||||
normalizePathForPython(opts.outputPath),
|
||||
"--config",
|
||||
configJson,
|
||||
];
|
||||
|
||||
const r = spawnSync(python, args, {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
cwd: scriptsDir,
|
||||
env: buildPythonEnv(),
|
||||
maxBuffer: 20 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (r.error) {
|
||||
return { success: false, error: r.error.message || String(r.error) };
|
||||
}
|
||||
|
||||
let previewImages = [];
|
||||
const stdout = (r.stdout || "").trim();
|
||||
if (stdout) {
|
||||
try {
|
||||
const lastLine = stdout.split("\n").filter(Boolean).pop();
|
||||
const parsed = JSON.parse(lastLine || stdout);
|
||||
if (parsed.previewImages) previewImages = parsed.previewImages;
|
||||
if (parsed.success === false) {
|
||||
return {
|
||||
success: false,
|
||||
error: parsed.error || parsed.message || "高级封面生成失败",
|
||||
stderr: r.stderr,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* 非 JSON 时仅检查文件 */
|
||||
}
|
||||
}
|
||||
|
||||
if (r.status !== 0 || !fs.existsSync(opts.outputPath)) {
|
||||
const errMsg =
|
||||
(r.stderr || "").trim() ||
|
||||
stdout ||
|
||||
`Python 退出码 ${r.status ?? "unknown"}`;
|
||||
return { success: false, error: errMsg };
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
coverPath: opts.outputPath,
|
||||
previewImages,
|
||||
message: "高级封面生成完成",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* PIL 中等复杂度封面(cover_generator.py)
|
||||
* @param {{ videoPath: string, titleText: string, outputPath: string, config: object, timestamp?: number, onProgress?: (s: string) => void }} opts
|
||||
*/
|
||||
function runBasicCoverGenerator(opts) {
|
||||
const python = locatePython();
|
||||
const scriptsDir = locateCoverScriptsDir();
|
||||
if (!python || !scriptsDir) {
|
||||
return { success: false, error: "未找到 Python 或封面脚本" };
|
||||
}
|
||||
|
||||
const script = path.join(scriptsDir, "cover_generator.py");
|
||||
if (!fs.existsSync(script)) {
|
||||
return { success: false, error: `缺少脚本: ${script}` };
|
||||
}
|
||||
|
||||
opts.onProgress?.("正在使用 PIL 封面生成器…");
|
||||
|
||||
const configJson = JSON.stringify(opts.config || {});
|
||||
const args = [
|
||||
script,
|
||||
"--video",
|
||||
normalizePathForPython(opts.videoPath),
|
||||
"--title",
|
||||
opts.titleText,
|
||||
"--output",
|
||||
normalizePathForPython(opts.outputPath),
|
||||
"--config",
|
||||
configJson,
|
||||
"--timestamp",
|
||||
String(opts.timestamp ?? 3),
|
||||
];
|
||||
|
||||
const r = spawnSync(python, args, {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
cwd: scriptsDir,
|
||||
env: buildPythonEnv(),
|
||||
maxBuffer: 10 * 1024 * 1024,
|
||||
});
|
||||
|
||||
if (r.status === 0 && fs.existsSync(opts.outputPath)) {
|
||||
return { success: true, coverPath: opts.outputPath, message: "封面生成完成" };
|
||||
}
|
||||
|
||||
return {
|
||||
success: false,
|
||||
error: (r.stderr || r.stdout || "").trim() || `Python 退出码 ${r.status}`,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
isAdvancedCoverConfig,
|
||||
runAdvancedCoverGenerator,
|
||||
runBasicCoverGenerator,
|
||||
locatePython,
|
||||
locateCoverScriptsDir,
|
||||
};
|
||||
@@ -259,6 +259,25 @@ function parseTranscriptionText(trans) {
|
||||
return fullText;
|
||||
}
|
||||
|
||||
/** @returns {Array<{ text: string, start: number, end: number }>} start/end 毫秒 */
|
||||
function parseTranscriptionSentences(trans) {
|
||||
const records = [];
|
||||
if (trans?.transcripts?.length) {
|
||||
for (const t of trans.transcripts) {
|
||||
if (t.sentences?.length) {
|
||||
for (const s of t.sentences) {
|
||||
const text = String(s.text || "").trim();
|
||||
if (!text) continue;
|
||||
const start = Number(s.begin_time ?? s.start_time ?? 0);
|
||||
const end = Number(s.end_time ?? s.end_time ?? start + 2000);
|
||||
records.push({ text, start, end });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options = {}) {
|
||||
const res = await fetch(url, options);
|
||||
const text = await res.text();
|
||||
@@ -537,7 +556,8 @@ async function aliyunAsrFiletrans(audioUrl, opts) {
|
||||
}
|
||||
const trans = await fetchJson(transUrl);
|
||||
const fullText = parseTranscriptionText(trans);
|
||||
return { success: true, text: fullText };
|
||||
const sentences = parseTranscriptionSentences(trans);
|
||||
return { success: true, text: fullText, sentences };
|
||||
}
|
||||
if (status === "FAILED" || status === "UNKNOWN") {
|
||||
const detail =
|
||||
@@ -597,10 +617,21 @@ async function localAsr(audioPath, info) {
|
||||
data.data?.records ||
|
||||
data.records ||
|
||||
[];
|
||||
const text = Array.isArray(records)
|
||||
? records.map((r) => r.text || "").join("")
|
||||
const sentences = Array.isArray(records)
|
||||
? records
|
||||
.map((r) => {
|
||||
const text = String(r.text || "").trim();
|
||||
if (!text) return null;
|
||||
const start = Number(r.start ?? r.begin_time ?? 0);
|
||||
const end = Number(r.end ?? r.end_time ?? start + 2000);
|
||||
return { text, start, end };
|
||||
})
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
const text = sentences.length
|
||||
? sentences.map((r) => r.text).join("")
|
||||
: data.text || data.data?.text || "";
|
||||
resolve({ success: true, text: String(text) });
|
||||
resolve({ success: true, text: String(text), sentences });
|
||||
} else {
|
||||
resolve({ success: false, error: data.msg || data.error || raw.slice(0, 200) });
|
||||
}
|
||||
@@ -656,8 +687,10 @@ module.exports = {
|
||||
aliyunOssUpload,
|
||||
aliyunAsrFiletrans,
|
||||
localAsr,
|
||||
parseTranscriptionSentences,
|
||||
openaiChat,
|
||||
resolveTtsModel,
|
||||
splitTextForTts,
|
||||
qwenTtsSynthesize,
|
||||
locateFfmpeg,
|
||||
};
|
||||
|
||||
121
scripts/nodejs/publish_browser.js
Normal file
121
scripts/nodejs/publish_browser.js
Normal file
@@ -0,0 +1,121 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 发布用 Puppeteer 浏览器(按平台/账号隔离 userDataDir)
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
|
||||
const {
|
||||
COMMON_BROWSER_ARGS,
|
||||
BROWSER_IGNORE_DEFAULT_ARGS,
|
||||
STEALTH_INIT_SCRIPT,
|
||||
DEFAULT_USER_AGENT,
|
||||
} = require("./douyin_browser_constants.js");
|
||||
|
||||
const browsers = new Map();
|
||||
|
||||
function getRuntimeDataPath(...parts) {
|
||||
const base = process.env.AICLIENT_DATA_DIR || path.join(os.homedir(), ".aiclient");
|
||||
return path.join(base, ...parts);
|
||||
}
|
||||
|
||||
function getChromiumExecutablePath() {
|
||||
const fromEnv =
|
||||
process.env.AICLIENT_CHROMIUM_PATH || process.env.PUPPETEER_EXECUTABLE_PATH;
|
||||
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
||||
const candidates = [];
|
||||
if (process.platform === "win32") {
|
||||
const pf = process.env.ProgramFiles || "C:\\Program Files";
|
||||
const local = process.env.LOCALAPPDATA || "";
|
||||
candidates.push(
|
||||
path.join(pf, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(local, "Google", "Chrome", "Application", "chrome.exe"),
|
||||
path.join(pf, "Microsoft", "Edge", "Application", "msedge.exe"),
|
||||
);
|
||||
}
|
||||
return candidates.find((p) => p && fs.existsSync(p));
|
||||
}
|
||||
|
||||
function loadPuppeteer() {
|
||||
try {
|
||||
return require("puppeteer-core");
|
||||
} catch {
|
||||
return require("puppeteer");
|
||||
}
|
||||
}
|
||||
|
||||
function contextKey(platform, accountId) {
|
||||
return accountId ? `publish_${platform}_${accountId}` : `publish_${platform}`;
|
||||
}
|
||||
|
||||
function userDataDir(platform, accountId) {
|
||||
const sub = accountId ? `${platform}_${accountId}` : platform;
|
||||
return getRuntimeDataPath("browser-data", "publish", sub);
|
||||
}
|
||||
|
||||
function delay(ms) {
|
||||
return new Promise((r) => setTimeout(r, ms));
|
||||
}
|
||||
|
||||
async function getPublishBrowser(platform, accountId) {
|
||||
const key = contextKey(platform, accountId);
|
||||
const existing = browsers.get(key);
|
||||
if (existing && existing.isConnected()) {
|
||||
return existing;
|
||||
}
|
||||
const puppeteer = loadPuppeteer();
|
||||
const dir = userDataDir(platform, accountId);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
const executablePath = getChromiumExecutablePath();
|
||||
if (!executablePath) {
|
||||
throw new Error(
|
||||
"未找到 Chrome/Edge,请安装浏览器或设置 AICLIENT_CHROMIUM_PATH",
|
||||
);
|
||||
}
|
||||
const browser = await puppeteer.launch({
|
||||
executablePath,
|
||||
headless: false,
|
||||
userDataDir: dir,
|
||||
args: COMMON_BROWSER_ARGS,
|
||||
ignoreDefaultArgs: BROWSER_IGNORE_DEFAULT_ARGS,
|
||||
defaultViewport: { width: 1280, height: 900 },
|
||||
});
|
||||
browser.on("disconnected", () => browsers.delete(key));
|
||||
browsers.set(key, browser);
|
||||
return browser;
|
||||
}
|
||||
|
||||
async function newPublishPage(platform, accountId, cookies) {
|
||||
const browser = await getPublishBrowser(platform, accountId);
|
||||
const page = await browser.newPage();
|
||||
await page.setUserAgent(DEFAULT_USER_AGENT);
|
||||
await page.evaluateOnNewDocument(STEALTH_INIT_SCRIPT);
|
||||
page.setDefaultNavigationTimeout(60000);
|
||||
page.setDefaultTimeout(60000);
|
||||
if (Array.isArray(cookies) && cookies.length) {
|
||||
try {
|
||||
await page.setCookie(...cookies);
|
||||
} catch (e) {
|
||||
console.warn("setCookie 失败:", e.message);
|
||||
}
|
||||
}
|
||||
return page;
|
||||
}
|
||||
|
||||
const LOGIN_URLS = {
|
||||
douyin: "https://creator.douyin.com/",
|
||||
kuaishou: "https://cp.kuaishou.com/",
|
||||
shipin: "https://channels.weixin.qq.com/",
|
||||
xiaohongshu: "https://creator.xiaohongshu.com/",
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
delay,
|
||||
getPublishBrowser,
|
||||
newPublishPage,
|
||||
userDataDir,
|
||||
LOGIN_URLS,
|
||||
getChromiumExecutablePath,
|
||||
};
|
||||
71
scripts/nodejs/publish_check_login.js
Normal file
71
scripts/nodejs/publish_check_login.js
Normal file
@@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
|
||||
const { delay, newPublishPage, LOGIN_URLS } = require("./publish_browser.js");
|
||||
async function checkDouyin(page) {
|
||||
await page.goto("https://creator.douyin.com/creator-micro/content/upload", {
|
||||
waitUntil: "domcontentloaded",
|
||||
});
|
||||
await delay(3000);
|
||||
const url = page.url();
|
||||
if (url.includes("login") || url.includes("passport")) {
|
||||
return { isLoggedIn: false };
|
||||
}
|
||||
const avatar = await page.$("#header-avatar");
|
||||
if (!avatar) return { isLoggedIn: false };
|
||||
let nickname = "";
|
||||
try {
|
||||
nickname = await page.$eval("#header-avatar", (el) => el.getAttribute("alt") || "");
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return {
|
||||
isLoggedIn: true,
|
||||
userInfo: { nickname: nickname || "抖音用户", userId: "" },
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.__nodejsMain = async function main(params) {
|
||||
const p = params || {};
|
||||
const platform = String(p.platform || "").trim();
|
||||
const accountId = p.accountId || null;
|
||||
const cookies = Array.isArray(p.cookies) ? p.cookies : [];
|
||||
|
||||
if (!LOGIN_URLS[platform]) {
|
||||
return { success: false, isLoggedIn: false, error: `不支持的平台: ${platform}` };
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在检测登录状态…");
|
||||
const page = await newPublishPage(platform, accountId, cookies);
|
||||
try {
|
||||
let result;
|
||||
if (platform === "douyin") {
|
||||
result = await checkDouyin(page);
|
||||
} else {
|
||||
await page.goto(LOGIN_URLS[platform], { waitUntil: "domcontentloaded" });
|
||||
await delay(3000);
|
||||
const url = page.url();
|
||||
const loggedIn = !["login", "passport", "auth"].some((k) => url.includes(k));
|
||||
result = {
|
||||
isLoggedIn: loggedIn,
|
||||
userInfo: loggedIn ? { nickname: "已登录", userId: "" } : undefined,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
isLoggedIn: Boolean(result.isLoggedIn),
|
||||
userInfo: result.userInfo,
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
isLoggedIn: false,
|
||||
message: e.message || String(e),
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
await page.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
};
|
||||
82
scripts/nodejs/publish_login.js
Normal file
82
scripts/nodejs/publish_login.js
Normal file
@@ -0,0 +1,82 @@
|
||||
"use strict";
|
||||
|
||||
const { delay, newPublishPage, LOGIN_URLS } = require("./publish_browser.js");
|
||||
|
||||
const LOGIN_SELECTORS = {
|
||||
douyin: ["#header-avatar", ".creator-avatar", 'input[type="file"]'],
|
||||
kuaishou: ['[class*="avatar"]', ".user-avatar", '[class*="upload"]'],
|
||||
shipin: ['[class*="avatar"]', ".avatar"],
|
||||
xiaohongshu: ['[class*="avatar"]', ".user-avatar", '[class*="upload"]'],
|
||||
};
|
||||
|
||||
const URL_EXCLUDE = {
|
||||
douyin: ["login", "passport"],
|
||||
kuaishou: ["login", "passport"],
|
||||
shipin: ["login", "auth", "qr"],
|
||||
xiaohongshu: ["login", "passport", "account"],
|
||||
};
|
||||
|
||||
async function checkLoggedIn(page, platform) {
|
||||
const url = page.url();
|
||||
const excludes = URL_EXCLUDE[platform] || URL_EXCLUDE.douyin;
|
||||
if (excludes.some((k) => url.includes(k))) return false;
|
||||
const sels = LOGIN_SELECTORS[platform] || LOGIN_SELECTORS.douyin;
|
||||
for (const sel of sels) {
|
||||
const el = await page.$(sel);
|
||||
if (el) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
globalThis.__nodejsMain = async function main(params) {
|
||||
const p = params || {};
|
||||
const platform = String(p.platform || "").trim();
|
||||
const accountId = p.accountId || null;
|
||||
const loginUrl = LOGIN_URLS[platform];
|
||||
if (!loginUrl) {
|
||||
return { success: false, error: `不支持的平台: ${platform}` };
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在打开登录页,请在浏览器中完成登录…");
|
||||
const page = await newPublishPage(platform, accountId, []);
|
||||
try {
|
||||
await page.goto(loginUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
|
||||
await delay(3000);
|
||||
|
||||
const maxMs = 300000;
|
||||
const start = Date.now();
|
||||
while (Date.now() - start < maxMs) {
|
||||
if (await checkLoggedIn(page, platform)) {
|
||||
const cookies = await page.cookies();
|
||||
let nickname = "";
|
||||
try {
|
||||
nickname = await page.$eval(
|
||||
".username, .account-name, .user-name, .nickname",
|
||||
(el) => (el.textContent || "").trim(),
|
||||
);
|
||||
} catch {
|
||||
nickname = "已登录用户";
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
message: "登录成功",
|
||||
nickname: nickname || "已登录用户",
|
||||
uid: `user_${Date.now()}`,
|
||||
cookies,
|
||||
};
|
||||
}
|
||||
await delay(2000);
|
||||
globalThis.__native?.emitProgress?.("等待登录完成…");
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: "登录超时(5 分钟),请重试",
|
||||
};
|
||||
} finally {
|
||||
try {
|
||||
await page.close();
|
||||
} catch {
|
||||
/* keep browser for user */
|
||||
}
|
||||
}
|
||||
};
|
||||
36
scripts/nodejs/publish_platform.js
Normal file
36
scripts/nodejs/publish_platform.js
Normal file
@@ -0,0 +1,36 @@
|
||||
"use strict";
|
||||
|
||||
const { newPublishPage } = require("./publish_browser.js");
|
||||
const { publishToDouyin } = require("./publish_to_douyin.js");
|
||||
|
||||
const PLATFORM_LABELS = {
|
||||
douyin: "抖音",
|
||||
kuaishou: "快手",
|
||||
shipin: "视频号",
|
||||
xiaohongshu: "小红书",
|
||||
};
|
||||
|
||||
async function publishToPlatform(platform, accountId, cookies, params) {
|
||||
const label = PLATFORM_LABELS[platform] || platform;
|
||||
if (platform === "douyin") {
|
||||
const page = await newPublishPage(platform, accountId, cookies);
|
||||
try {
|
||||
return await publishToDouyin(page, params);
|
||||
} finally {
|
||||
if (!params.keepPageOpen) {
|
||||
try {
|
||||
await page.close();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
status: "failed",
|
||||
message: `${label}自动发布即将支持,请先使用抖音`,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { publishToPlatform, PLATFORM_LABELS };
|
||||
197
scripts/nodejs/publish_to_douyin.js
Normal file
197
scripts/nodejs/publish_to_douyin.js
Normal file
@@ -0,0 +1,197 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 抖音创作者中心发布(对齐 Electron publishToDouyin,Puppeteer 版)
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const { delay } = require("./publish_browser.js");
|
||||
|
||||
async function clickIfVisible(page, selector) {
|
||||
const el = await page.$(selector);
|
||||
if (!el) return false;
|
||||
try {
|
||||
await el.click();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function typeIntoFirst(page, selectors, text) {
|
||||
for (const sel of selectors) {
|
||||
const el = await page.$(sel);
|
||||
if (!el) continue;
|
||||
try {
|
||||
await el.click({ clickCount: 3 });
|
||||
await page.keyboard.down("Control");
|
||||
await page.keyboard.press("a");
|
||||
await page.keyboard.up("Control");
|
||||
await page.keyboard.press("Backspace");
|
||||
await el.type(text, { delay: 30 });
|
||||
return true;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
async function publishToDouyin(page, params) {
|
||||
const { videoPath, coverPath, title, tags = [], autoPublish } = params;
|
||||
if (!fs.existsSync(videoPath)) throw new Error("视频文件不存在");
|
||||
if (!fs.existsSync(coverPath)) throw new Error("封面文件不存在");
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在打开抖音上传页…");
|
||||
const uploadUrl = "https://creator.douyin.com/creator-micro/content/upload";
|
||||
await page.goto(uploadUrl, { waitUntil: "domcontentloaded" });
|
||||
await delay(3000);
|
||||
|
||||
let currentUrl = page.url();
|
||||
if (currentUrl.includes("login") || currentUrl.includes("passport")) {
|
||||
throw new Error("未登录抖音,请先在步骤 07 点击「登录平台」");
|
||||
}
|
||||
|
||||
let avatar = await page.$("#header-avatar");
|
||||
if (!avatar) {
|
||||
await delay(5000);
|
||||
avatar = await page.$("#header-avatar");
|
||||
}
|
||||
if (!avatar) {
|
||||
throw new Error("抖音登录已过期,请重新登录");
|
||||
}
|
||||
|
||||
if (!page.url().includes("content/upload")) {
|
||||
await page.goto(uploadUrl, { waitUntil: "domcontentloaded" });
|
||||
await delay(3000);
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在上传视频…");
|
||||
await page.waitForSelector('input[type="file"]', { timeout: 60000 });
|
||||
const fileInputs = await page.$$('input[type="file"]');
|
||||
if (!fileInputs.length) throw new Error("未找到视频上传控件");
|
||||
await fileInputs[0].uploadFile(videoPath);
|
||||
await delay(5000);
|
||||
|
||||
globalThis.__native?.emitProgress?.("等待视频处理…");
|
||||
const start = Date.now();
|
||||
let ready = false;
|
||||
while (Date.now() - start < 300000 && !ready) {
|
||||
const titleBox = await page.$('textarea[placeholder*="标题"], input[placeholder*="标题"]');
|
||||
if (titleBox) {
|
||||
try {
|
||||
const disabled = await page.evaluate((el) => el.disabled, titleBox);
|
||||
if (!disabled) ready = true;
|
||||
} catch {
|
||||
ready = true;
|
||||
}
|
||||
}
|
||||
if (!ready) await delay(2000);
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在上传封面…");
|
||||
try {
|
||||
await clickIfVisible(page, 'button');
|
||||
const buttons = await page.$$("button");
|
||||
for (const btn of buttons) {
|
||||
const text = await page.evaluate((el) => el.textContent || "", btn);
|
||||
if (String(text).includes("选择封面")) {
|
||||
await btn.click();
|
||||
await delay(2000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
const imgInput = await page.$('input[type="file"][accept*="image"]');
|
||||
if (imgInput) {
|
||||
await imgInput.uploadFile(coverPath);
|
||||
await delay(3000);
|
||||
}
|
||||
const doneBtns = await page.$$("button");
|
||||
for (const btn of doneBtns) {
|
||||
const text = await page.evaluate((el) => el.textContent || "", btn);
|
||||
if (String(text).trim() === "完成") {
|
||||
await btn.click();
|
||||
await delay(2000);
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("封面上传跳过:", e.message);
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在填写标题与话题…");
|
||||
const titleText = String(title || "").slice(0, 30);
|
||||
await typeIntoFirst(
|
||||
page,
|
||||
[
|
||||
'input[placeholder*="填写作品标题"]',
|
||||
'input[placeholder*="标题"]',
|
||||
'textarea[placeholder*="标题"]',
|
||||
],
|
||||
titleText,
|
||||
);
|
||||
|
||||
const tagList = Array.isArray(tags) ? tags : [];
|
||||
if (tagList.length) {
|
||||
const descOk = await typeIntoFirst(
|
||||
page,
|
||||
[
|
||||
'textarea[placeholder*="描述"]',
|
||||
'textarea[placeholder*="简介"]',
|
||||
'motion[contenteditable="true"]',
|
||||
'div[contenteditable="true"]',
|
||||
],
|
||||
"",
|
||||
);
|
||||
if (descOk) {
|
||||
for (const tag of tagList) {
|
||||
const t = String(tag).trim();
|
||||
if (!t) continue;
|
||||
const clean = t.startsWith("#") ? t : `#${t}`;
|
||||
await page.keyboard.type(clean, { delay: 40 });
|
||||
await page.keyboard.press("Space");
|
||||
await delay(400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!autoPublish) {
|
||||
return {
|
||||
success: false,
|
||||
status: "manual_pending",
|
||||
message: "内容已填写,请在浏览器中检查并手动点击发布",
|
||||
videoUrl: page.url(),
|
||||
keepPageOpen: true,
|
||||
};
|
||||
}
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在点击发布…");
|
||||
const publishBtns = await page.$$("button");
|
||||
let clicked = false;
|
||||
for (const btn of publishBtns) {
|
||||
const text = (await page.evaluate((el) => el.textContent || "", btn)).trim();
|
||||
if (text === "发布" || text === "确定发布") {
|
||||
await btn.click();
|
||||
clicked = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!clicked) {
|
||||
return {
|
||||
success: false,
|
||||
status: "manual_pending",
|
||||
message: "未找到发布按钮,请手动发布",
|
||||
videoUrl: page.url(),
|
||||
};
|
||||
}
|
||||
|
||||
await delay(5000);
|
||||
return {
|
||||
success: true,
|
||||
status: "success",
|
||||
message: "已提交发布",
|
||||
videoUrl: page.url(),
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { publishToDouyin };
|
||||
@@ -169,8 +169,36 @@ async function uploadFile(baseUrl, apiKey, filePath) {
|
||||
return fileName;
|
||||
}
|
||||
|
||||
function locateFfmpegBinary() {
|
||||
const fromEnv = process.env.AICLIENT_FFMPEG_PATH;
|
||||
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
||||
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
|
||||
}
|
||||
|
||||
function locateFfprobeBinary() {
|
||||
const fromEnv = process.env.AICLIENT_FFPROBE_PATH;
|
||||
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
||||
const ffmpeg = locateFfmpegBinary();
|
||||
if (ffmpeg && ffmpeg !== "ffmpeg" && ffmpeg !== "ffmpeg.exe") {
|
||||
const dir = path.dirname(ffmpeg);
|
||||
const sibling = path.join(dir, process.platform === "win32" ? "ffprobe.exe" : "ffprobe");
|
||||
if (fs.existsSync(sibling)) return sibling;
|
||||
}
|
||||
return process.platform === "win32" ? "ffprobe.exe" : "ffprobe";
|
||||
}
|
||||
|
||||
function parseDurationFromFfmpegStderr(text) {
|
||||
const m = String(text || "").match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
||||
if (!m) return 0;
|
||||
const h = parseFloat(m[1]);
|
||||
const min = parseFloat(m[2]);
|
||||
const s = parseFloat(m[3]);
|
||||
if (!Number.isFinite(h + min + s)) return 0;
|
||||
return h * 3600 + min * 60 + s;
|
||||
}
|
||||
|
||||
function probeAudioDuration(audioPath) {
|
||||
const ffprobe = process.env.AICLIENT_FFPROBE_PATH || "ffprobe";
|
||||
const ffprobe = locateFfprobeBinary();
|
||||
const r = spawnSync(
|
||||
ffprobe,
|
||||
[
|
||||
@@ -184,9 +212,19 @@ function probeAudioDuration(audioPath) {
|
||||
],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (r.status !== 0) return 0;
|
||||
const n = parseFloat(String(r.stdout || "").trim());
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
if (r.status === 0) {
|
||||
const n = parseFloat(String(r.stdout || "").trim());
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
|
||||
const ffmpeg = locateFfmpegBinary();
|
||||
const r2 = spawnSync(ffmpeg, ["-i", audioPath], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
const fromStderr = parseDurationFromFfmpegStderr(r2.stderr || "");
|
||||
if (fromStderr > 0) return fromStderr;
|
||||
return 0;
|
||||
}
|
||||
|
||||
async function createTask(config, videoFileName, audioFileName, audioDuration) {
|
||||
@@ -379,6 +417,14 @@ globalThis.__nodejsMain = async function (params) {
|
||||
if (!Number.isFinite(audioDuration) || audioDuration <= 0) {
|
||||
audioDuration = probeAudioDuration(audioPath);
|
||||
}
|
||||
if (audioDuration <= 0) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"无法获取音频时长。云端口播需将时长传给 RunningHub(请安装 ffmpeg/ffprobe,或升级客户端后重试)",
|
||||
};
|
||||
}
|
||||
const durationSec = Math.ceil(audioDuration);
|
||||
|
||||
try {
|
||||
const videoFileName = await uploadFile(config.baseUrl, config.apiKey, videoPath);
|
||||
@@ -387,7 +433,7 @@ globalThis.__nodejsMain = async function (params) {
|
||||
config,
|
||||
videoFileName,
|
||||
audioFileName,
|
||||
audioDuration,
|
||||
durationSec,
|
||||
);
|
||||
const done = await waitForTask(config, taskId);
|
||||
const remoteUrl = pickResultUrl(done.results);
|
||||
@@ -398,7 +444,12 @@ globalThis.__nodejsMain = async function (params) {
|
||||
};
|
||||
}
|
||||
const localPath = await ensureLocalVideo(remoteUrl);
|
||||
return { success: true, videoPath: localPath, taskId };
|
||||
return {
|
||||
success: true,
|
||||
videoPath: localPath,
|
||||
taskId,
|
||||
audioDuration: durationSec,
|
||||
};
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message || String(e) };
|
||||
}
|
||||
|
||||
288
scripts/nodejs/subtitle_bgm_generate.js
Normal file
288
scripts/nodejs/subtitle_bgm_generate.js
Normal file
@@ -0,0 +1,288 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 自动生成字幕并混 BGM(对齐 Electron autoGenerateSubtitleAndBGM)
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const pipelineNative = require("./pipeline_native.js");
|
||||
const desktopConfig = require("./desktop_config.js");
|
||||
|
||||
const OUTPUT_DIR = path.join(os.tmpdir(), "aiclient-subtitle-bgm");
|
||||
|
||||
function ensureDir() {
|
||||
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
|
||||
return OUTPUT_DIR;
|
||||
}
|
||||
|
||||
function makeOutputPath(prefix, ext = "mp4") {
|
||||
return path.join(ensureDir(), `${prefix}_${Date.now()}.${ext}`);
|
||||
}
|
||||
|
||||
function execFfmpeg(args) {
|
||||
const ffmpeg = pipelineNative.locateFfmpeg();
|
||||
if (!ffmpeg) {
|
||||
throw new Error("未找到 ffmpeg,请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe");
|
||||
}
|
||||
const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true });
|
||||
if (r.status !== 0) {
|
||||
throw new Error((r.stderr || r.stdout || "").trim() || `ffmpeg 退出码 ${r.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function probeVideoDuration(videoPath) {
|
||||
const ffprobe = pipelineNative.locateFfmpeg()?.replace(/ffmpeg(\.exe)?$/i, "ffprobe$1");
|
||||
if (ffprobe && fs.existsSync(ffprobe)) {
|
||||
const r = spawnSync(
|
||||
ffprobe,
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (r.status === 0) {
|
||||
const n = parseFloat(String(r.stdout || "").trim());
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
}
|
||||
const ffmpeg = pipelineNative.locateFfmpeg();
|
||||
const r2 = spawnSync(ffmpeg, ["-i", videoPath], { encoding: "utf8", windowsHide: true });
|
||||
const stderr = r2.stderr || "";
|
||||
const m = stderr.match(/Duration:\s*(\d+):(\d+):(\d+(?:\.\d+)?)/);
|
||||
if (m) {
|
||||
return Number(m[1]) * 3600 + Number(m[2]) * 60 + Number(m[3]);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function splitScriptLines(text) {
|
||||
return String(text || "")
|
||||
.split(/(?<=[。!?.!?])\s*|\n+/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function alignScriptToRecords(script, records) {
|
||||
const lines = splitScriptLines(script);
|
||||
if (!lines.length || !records.length) return records;
|
||||
|
||||
if (lines.length === records.length) {
|
||||
return records.map((r, i) => ({ ...r, text: lines[i] }));
|
||||
}
|
||||
|
||||
const startMs = records[0].start;
|
||||
const endMs = records[records.length - 1].end;
|
||||
const total = Math.max(endMs - startMs, lines.length * 2000);
|
||||
const step = total / lines.length;
|
||||
return lines.map((text, i) => ({
|
||||
text,
|
||||
start: Math.round(startMs + i * step),
|
||||
end: Math.round(startMs + (i + 1) * step),
|
||||
}));
|
||||
}
|
||||
|
||||
function recordsFromTextEvenly(text, durationSec) {
|
||||
const lines = splitScriptLines(text);
|
||||
if (!lines.length) return [];
|
||||
const totalMs = Math.max(Math.round(durationSec * 1000), lines.length * 1500);
|
||||
const step = totalMs / lines.length;
|
||||
return lines.map((line, i) => ({
|
||||
text: line,
|
||||
start: Math.round(i * step),
|
||||
end: Math.round((i + 1) * step),
|
||||
}));
|
||||
}
|
||||
|
||||
function formatSrtTime(ms) {
|
||||
const h = Math.floor(ms / 3600000);
|
||||
const m = Math.floor((ms % 3600000) / 60000);
|
||||
const s = Math.floor((ms % 60000) / 1000);
|
||||
const msPart = Math.floor(ms % 1000);
|
||||
return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")},${String(msPart).padStart(3, "0")}`;
|
||||
}
|
||||
|
||||
function buildSrtContent(records) {
|
||||
return records
|
||||
.map((r, i) => {
|
||||
const start = formatSrtTime(r.start);
|
||||
const end = formatSrtTime(Math.max(r.end, r.start + 200));
|
||||
return `${i + 1}\n${start} --> ${end}\n${r.text}\n`;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function escapeSubPath(filePath) {
|
||||
return filePath.replace(/\\/g, "/").replace(/:/g, "\\:").replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
function addSubtitleToVideo(inputVideo, srtPath, forceStyle) {
|
||||
const outputVideo = makeOutputPath("with_subtitle");
|
||||
const style = forceStyle || "FontName=Microsoft YaHei,FontSize=24,PrimaryColour=&H00FFFFFF,OutlineColour=&H00000000,Outline=2,Alignment=2,MarginV=40";
|
||||
const sub = escapeSubPath(srtPath);
|
||||
const vf = `subtitles='${sub}':force_style='${style}'`;
|
||||
execFfmpeg(["-y", "-i", inputVideo, "-vf", vf, "-c:a", "copy", outputVideo]);
|
||||
return outputVideo;
|
||||
}
|
||||
|
||||
function addBgmToVideo(inputVideo, bgmPath, bgmVolume = 30) {
|
||||
const outputVideo = makeOutputPath("with_bgm");
|
||||
const ratio = Number(bgmVolume) / 100;
|
||||
const filter = `[0:a]volume=1.0[a0];[1:a]volume=${ratio}[a1];[a0][a1]amix=inputs=2:duration=first`;
|
||||
execFfmpeg([
|
||||
"-y",
|
||||
"-i",
|
||||
inputVideo,
|
||||
"-stream_loop",
|
||||
"-1",
|
||||
"-i",
|
||||
bgmPath,
|
||||
"-filter_complex",
|
||||
filter,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-shortest",
|
||||
outputVideo,
|
||||
]);
|
||||
return outputVideo;
|
||||
}
|
||||
|
||||
async function runAsr(audioPath, modelConfig, scriptContent) {
|
||||
const asrMode = modelConfig.asrMode || "online";
|
||||
if (asrMode === "online") {
|
||||
if (!modelConfig.aliyunApiKey) {
|
||||
throw new Error("在线 ASR 需配置 BAILIAN_API_KEY 或 DASHSCOPE_API_KEY");
|
||||
}
|
||||
if (!modelConfig.ossConfig?.bucket) {
|
||||
throw new Error("在线 ASR 需配置 OSS(上传音频供识别)");
|
||||
}
|
||||
globalThis.__native?.emitProgress?.("正在上传音频到 OSS…");
|
||||
const upload = await pipelineNative.aliyunOssUpload(audioPath, modelConfig.ossConfig);
|
||||
if (!upload?.success || !upload.url) {
|
||||
throw new Error(upload?.error || "OSS 上传失败");
|
||||
}
|
||||
globalThis.__native?.emitProgress?.("正在进行语音识别…");
|
||||
const asr = await pipelineNative.aliyunAsrFiletrans(upload.url, {
|
||||
apiKey: modelConfig.aliyunApiKey,
|
||||
model: "qwen3-asr-flash-filetrans",
|
||||
});
|
||||
if (!asr?.success) {
|
||||
throw new Error(asr?.error || "语音识别失败");
|
||||
}
|
||||
let records = Array.isArray(asr.sentences) ? asr.sentences : [];
|
||||
if (!records.length && asr.text) {
|
||||
records = recordsFromTextEvenly(asr.text, 60);
|
||||
}
|
||||
return { text: asr.text || "", records };
|
||||
}
|
||||
|
||||
const info = modelConfig.asrServerInfo;
|
||||
if (!info) {
|
||||
throw new Error("本地 ASR 需配置 ASR_SERVER_URL 或 ASR_SERVER_INFO");
|
||||
}
|
||||
globalThis.__native?.emitProgress?.("正在调用本地语音识别…");
|
||||
const asr = await pipelineNative.localAsr(audioPath, info);
|
||||
if (!asr?.success) {
|
||||
throw new Error(asr?.error || "本地语音识别失败");
|
||||
}
|
||||
let records = Array.isArray(asr.sentences) ? asr.sentences : [];
|
||||
if (!records.length && asr.text) {
|
||||
records = recordsFromTextEvenly(asr.text, 60);
|
||||
}
|
||||
return { text: asr.text || "", records };
|
||||
}
|
||||
|
||||
globalThis.__nodejsMain = async function main(params) {
|
||||
const p = params || {};
|
||||
const inputVideo = String(p.inputVideo || "").trim();
|
||||
if (!inputVideo || !fs.existsSync(inputVideo)) {
|
||||
return { success: false, error: "缺少或找不到输入视频,请先在步骤 02/03 生成并处理视频" };
|
||||
}
|
||||
|
||||
const autoSubtitle = Boolean(p.autoSubtitle);
|
||||
const bgmEnabled = Boolean(p.bgmEnabled);
|
||||
if (!autoSubtitle && !bgmEnabled) {
|
||||
return { success: false, error: "请至少勾选「自动生成字幕」或「添加背景音乐」" };
|
||||
}
|
||||
if (bgmEnabled) {
|
||||
const bgm = String(p.bgmPath || "").trim();
|
||||
if (!bgm || !fs.existsSync(bgm)) {
|
||||
return { success: false, error: "已启用背景音乐,请先选择音乐文件" };
|
||||
}
|
||||
}
|
||||
|
||||
const modelConfig = desktopConfig.buildModelConfig();
|
||||
const check = desktopConfig.validateModelConfig(modelConfig);
|
||||
if (autoSubtitle && !check.ok) {
|
||||
return { success: false, error: check.error };
|
||||
}
|
||||
if (autoSubtitle && (modelConfig.asrMode === "online" || !modelConfig.asrMode)) {
|
||||
const appCheck = desktopConfig.validateModelConfig(modelConfig);
|
||||
if (!appCheck.ok && modelConfig.asrMode === "online") {
|
||||
return { success: false, error: appCheck.error };
|
||||
}
|
||||
}
|
||||
|
||||
let current = inputVideo;
|
||||
let srtPath = "";
|
||||
|
||||
try {
|
||||
if (autoSubtitle) {
|
||||
globalThis.__native?.emitProgress?.("正在提取音频…");
|
||||
const audioPath = await pipelineNative.ffmpegExtractAudio(current);
|
||||
const scriptContent = String(p.scriptContent || "").trim();
|
||||
const { records: rawRecords } = await runAsr(audioPath, modelConfig, scriptContent);
|
||||
|
||||
let records = rawRecords;
|
||||
if (p.smartSubtitle !== false && scriptContent) {
|
||||
records = alignScriptToRecords(scriptContent, records);
|
||||
}
|
||||
if (!records.length) {
|
||||
const duration = probeVideoDuration(current);
|
||||
const fallbackText = scriptContent || " ";
|
||||
records = recordsFromTextEvenly(fallbackText, duration || 30);
|
||||
}
|
||||
if (!records.length) {
|
||||
return { success: false, error: "未识别到语音内容,无法生成字幕" };
|
||||
}
|
||||
|
||||
srtPath = makeOutputPath("subtitle", "srt");
|
||||
fs.writeFileSync(srtPath, buildSrtContent(records), "utf8");
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在烧录字幕到视频…");
|
||||
const forceStyle = String(p.subtitleForceStyle || "").trim();
|
||||
current = addSubtitleToVideo(current, srtPath, forceStyle || undefined);
|
||||
pipelineNative.deleteFile(audioPath);
|
||||
}
|
||||
|
||||
if (bgmEnabled) {
|
||||
globalThis.__native?.emitProgress?.("正在混合背景音乐…");
|
||||
current = addBgmToVideo(current, p.bgmPath, p.bgmVolume ?? 30);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
videoPath: current,
|
||||
srtPath: srtPath || null,
|
||||
message: autoSubtitle && bgmEnabled
|
||||
? "字幕与背景音乐处理完成"
|
||||
: autoSubtitle
|
||||
? "字幕生成完成"
|
||||
: "背景音乐添加完成",
|
||||
};
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: e.message || String(e),
|
||||
partialVideo: current !== inputVideo ? current : null,
|
||||
};
|
||||
}
|
||||
};
|
||||
174
scripts/nodejs/title_tags_generate.js
Normal file
174
scripts/nodejs/title_tags_generate.js
Normal file
@@ -0,0 +1,174 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 生成标题、标签、分组关键词(对齐 Electron ipAgent:generateTitleTags)
|
||||
*/
|
||||
const pipelineNative = require("./pipeline_native.js");
|
||||
const desktopConfig = require("./desktop_config.js");
|
||||
|
||||
const KEYWORD_GROUPS = ["重点词/成语词", "描述词", "行动词", "情感词"];
|
||||
|
||||
const DEFAULT_TITLE_TAG_PROMPT = `你是短视频标题与标签助手。分析以下文案内容,生成:
|
||||
1. 一个最吸引人的标题(不超过30字,若需多个建议可写在 title 字段内用换行分隔)
|
||||
2. 8-10 个相关话题标签(带 # 号)
|
||||
3. 四个分组关键词,每组 0-2 个词:重点词/成语词、描述词、行动词、情感词
|
||||
|
||||
文案内容:
|
||||
{{content}}
|
||||
|
||||
请只返回 JSON,不要 markdown,格式如下:
|
||||
{
|
||||
"title": "标题",
|
||||
"tags": ["#标签1", "#标签2"],
|
||||
"keywords": {
|
||||
"重点词/成语词": ["词1"],
|
||||
"描述词": [],
|
||||
"行动词": [],
|
||||
"情感词": []
|
||||
}
|
||||
}`;
|
||||
|
||||
function buildPrompt(template, content) {
|
||||
let prompt = String(template || "").trim();
|
||||
if (!prompt) prompt = DEFAULT_TITLE_TAG_PROMPT;
|
||||
return prompt
|
||||
.replace(/\{\{content\}\}/g, content)
|
||||
.replace(/\{content\}/g, content);
|
||||
}
|
||||
|
||||
function cleanJsonText(raw) {
|
||||
let text = String(raw || "").trim();
|
||||
text = text.replace(/```json\s*/gi, "");
|
||||
text = text.replace(/```\s*/g, "");
|
||||
text = text.trim();
|
||||
text = text.replace(/"/g, '"').replace(/"/g, '"');
|
||||
text = text.replace(/'/g, "'").replace(/'/g, "'");
|
||||
text = text.replace(/,/g, ",");
|
||||
const m = text.match(/\{[\s\S]*\}/);
|
||||
if (m) text = m[0];
|
||||
return text;
|
||||
}
|
||||
|
||||
function normalizeTags(tags) {
|
||||
if (Array.isArray(tags)) {
|
||||
return tags
|
||||
.map((t) => String(t || "").trim())
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
}
|
||||
return String(tags || "").trim();
|
||||
}
|
||||
|
||||
function normalizeKeywordGroup(val) {
|
||||
if (Array.isArray(val)) {
|
||||
return val
|
||||
.map((w) => String(w || "").trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
if (typeof val === "string") {
|
||||
return val
|
||||
.split(/[,,\n]/)
|
||||
.map((w) => w.trim())
|
||||
.filter(Boolean)
|
||||
.join("\n");
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseTitleTagsContent(content) {
|
||||
let title = "";
|
||||
let tags = "";
|
||||
const keywords = {};
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(cleanJsonText(content));
|
||||
title = String(parsed.title || "").trim();
|
||||
tags = normalizeTags(parsed.tags);
|
||||
const kw = parsed.keywords;
|
||||
if (kw && typeof kw === "object" && !Array.isArray(kw)) {
|
||||
for (const group of KEYWORD_GROUPS) {
|
||||
keywords[group] = normalizeKeywordGroup(kw[group]);
|
||||
}
|
||||
} else if (Array.isArray(kw)) {
|
||||
keywords["重点词/成语词"] = normalizeKeywordGroup(kw);
|
||||
} else if (typeof kw === "string") {
|
||||
keywords["重点词/成语词"] = normalizeKeywordGroup(kw);
|
||||
}
|
||||
} catch {
|
||||
const lines = String(content).split("\n");
|
||||
for (const line of lines) {
|
||||
if (/标题|Title/i.test(line)) {
|
||||
title = (line.split(/[::]/)[1] || "").trim() || line.trim();
|
||||
} else if (/标签|Tags|#/i.test(line)) {
|
||||
tags = (line.split(/[::]/)[1] || "").trim() || line.trim();
|
||||
} else if (/关键词|Keywords/i.test(line)) {
|
||||
keywords["重点词/成语词"] = normalizeKeywordGroup(
|
||||
(line.split(/[::]/)[1] || "").trim(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const group of KEYWORD_GROUPS) {
|
||||
if (!keywords[group]) keywords[group] = "";
|
||||
}
|
||||
|
||||
const hasKeywords = KEYWORD_GROUPS.some((g) => keywords[g]);
|
||||
if (!title && !tags && !hasKeywords) {
|
||||
return null;
|
||||
}
|
||||
return { title, tags, keywords };
|
||||
}
|
||||
|
||||
globalThis.__nodejsMain = async function main(params) {
|
||||
const p = params || {};
|
||||
const content = String(p.content || p.script || "").trim();
|
||||
if (!content) {
|
||||
return { success: false, error: "请先填写视频文案" };
|
||||
}
|
||||
|
||||
const modelConfig = desktopConfig.buildModelConfig();
|
||||
const check = desktopConfig.validateModelConfig(modelConfig);
|
||||
if (!check.ok) {
|
||||
return { success: false, error: check.error };
|
||||
}
|
||||
|
||||
const prompt = buildPrompt(p.titleTagPrompt, content);
|
||||
|
||||
globalThis.__native?.emitProgress?.("正在生成标题、标签和关键词…");
|
||||
|
||||
const chat = await pipelineNative.openaiChat({
|
||||
apiUrl: modelConfig.apiUrl,
|
||||
apiKey: modelConfig.apiKey,
|
||||
model: modelConfig.apiModelId,
|
||||
messages: [{ role: "user", content: prompt }],
|
||||
temperature: typeof p.temperature === "number" ? p.temperature : 0.7,
|
||||
max_tokens: p.max_tokens || 2000,
|
||||
});
|
||||
|
||||
if (!chat.success || !chat.content) {
|
||||
return { success: false, error: chat.error || "模型返回为空" };
|
||||
}
|
||||
|
||||
const parsed = parseTitleTagsContent(chat.content);
|
||||
if (!parsed) {
|
||||
return {
|
||||
success: false,
|
||||
error: "解析结果为空,请检查模型返回格式",
|
||||
rawContent: chat.content,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
title: parsed.title,
|
||||
tags: parsed.tags,
|
||||
keywords: parsed.keywords,
|
||||
titleTags: JSON.stringify({
|
||||
title: parsed.title,
|
||||
tags: parsed.tags,
|
||||
keywords: parsed.keywords,
|
||||
}),
|
||||
};
|
||||
};
|
||||
539
scripts/nodejs/video_edit_process.js
Normal file
539
scripts/nodejs/video_edit_process.js
Normal file
@@ -0,0 +1,539 @@
|
||||
/**
|
||||
* 视频编辑后处理(对齐 Electron ipAgent:autoProcessVideo)
|
||||
* - processSilenceDetection:自动剪气口
|
||||
* - processVideoMixCut:画中画 / 混剪
|
||||
* - processGreenScreen:绿幕替换
|
||||
*/
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const { spawnSync } = require("child_process");
|
||||
|
||||
const IMAGE_EXT = new Set([".jpg", ".jpeg", ".png", ".webp", ".bmp", ".gif"]);
|
||||
|
||||
function locateFfmpegBinary() {
|
||||
const fromEnv = process.env.AICLIENT_FFMPEG_PATH;
|
||||
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
||||
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
|
||||
}
|
||||
|
||||
function locateFfprobeBinary() {
|
||||
const fromEnv = process.env.AICLIENT_FFPROBE_PATH;
|
||||
if (fromEnv && fs.existsSync(fromEnv)) return fromEnv;
|
||||
const ffmpeg = locateFfmpegBinary();
|
||||
const dir = path.dirname(ffmpeg);
|
||||
const sibling = path.join(dir, process.platform === "win32" ? "ffprobe.exe" : "ffprobe");
|
||||
if (fs.existsSync(sibling)) return sibling;
|
||||
return process.platform === "win32" ? "ffprobe.exe" : "ffprobe";
|
||||
}
|
||||
|
||||
function execFfmpeg(args) {
|
||||
const ffmpeg = locateFfmpegBinary();
|
||||
const r = spawnSync(ffmpeg, args, { encoding: "utf8", windowsHide: true });
|
||||
if (r.status !== 0) {
|
||||
const err = (r.stderr || r.stdout || "").trim();
|
||||
throw new Error(err || `ffmpeg 退出码 ${r.status}`);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureOutputDir() {
|
||||
const dir = path.join(os.tmpdir(), "aiclient-video-edit");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
return dir;
|
||||
}
|
||||
|
||||
function makeOutputPath(prefix, ext = "mp4") {
|
||||
return path.join(ensureOutputDir(), `${prefix}_${Date.now()}.${ext}`);
|
||||
}
|
||||
|
||||
function parseDurationHms(text) {
|
||||
const marker = "Duration:";
|
||||
const idx = text.indexOf(marker);
|
||||
if (idx < 0) return 0;
|
||||
const rest = text.slice(idx + marker.length).trim();
|
||||
const timePart = rest.split(",")[0].trim();
|
||||
const parts = timePart.split(":").map(Number);
|
||||
if (parts.length < 3) return 0;
|
||||
const [h, m, s] = parts;
|
||||
if (![h, m, s].every((n) => Number.isFinite(n))) return 0;
|
||||
return h * 3600 + m * 60 + s;
|
||||
}
|
||||
|
||||
function probeVideoDuration(videoPath) {
|
||||
const ffprobe = locateFfprobeBinary();
|
||||
const r = spawnSync(
|
||||
ffprobe,
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-show_entries",
|
||||
"format=duration",
|
||||
"-of",
|
||||
"default=noprint_wrappers=1:nokey=1",
|
||||
videoPath,
|
||||
],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (r.status === 0) {
|
||||
const n = parseFloat(String(r.stdout || "").trim());
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
}
|
||||
const ffmpeg = locateFfmpegBinary();
|
||||
const r2 = spawnSync(ffmpeg, ["-i", videoPath], {
|
||||
encoding: "utf8",
|
||||
windowsHide: true,
|
||||
});
|
||||
return parseDurationHms(r2.stderr || "");
|
||||
}
|
||||
|
||||
function probeVideoResolution(videoPath) {
|
||||
const ffprobe = locateFfprobeBinary();
|
||||
const r = spawnSync(
|
||||
ffprobe,
|
||||
[
|
||||
"-v",
|
||||
"error",
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=width,height",
|
||||
"-of",
|
||||
"csv=p=0:s=x",
|
||||
videoPath,
|
||||
],
|
||||
{ encoding: "utf8", windowsHide: true },
|
||||
);
|
||||
if (r.status === 0) {
|
||||
const line = String(r.stdout || "").trim();
|
||||
const m = line.match(/^(\d+)x(\d+)$/);
|
||||
if (m) {
|
||||
return { width: Number(m[1]), height: Number(m[2]) };
|
||||
}
|
||||
}
|
||||
return { width: 1920, height: 1080 };
|
||||
}
|
||||
|
||||
function isImageFile(filePath) {
|
||||
return IMAGE_EXT.has(path.extname(filePath).toLowerCase());
|
||||
}
|
||||
|
||||
function processSilenceDetection(inputVideo, opts = {}) {
|
||||
const silenceThreshold = Number(opts.silenceThreshold ?? -40);
|
||||
const silenceDuration = Number(opts.silenceDuration ?? 1);
|
||||
const minPauseDuration = Number(opts.minPauseDuration ?? 0.15);
|
||||
const minPauseDurationMs = Math.round(minPauseDuration * 1000);
|
||||
const outputVideo = makeOutputPath("silence");
|
||||
const audioFilter = `silenceremove=stop_periods=-1:stop_duration=${silenceDuration}:stop_threshold=${silenceThreshold}dB,adelay=${minPauseDurationMs}ms|${minPauseDurationMs}ms`;
|
||||
execFfmpeg([
|
||||
"-i",
|
||||
inputVideo,
|
||||
"-af",
|
||||
audioFilter,
|
||||
"-c:v",
|
||||
"copy",
|
||||
"-y",
|
||||
outputVideo,
|
||||
]);
|
||||
return {
|
||||
outputVideo,
|
||||
message: `已删除超过 ${silenceDuration} 秒且低于 ${silenceThreshold}dB 的停顿`,
|
||||
};
|
||||
}
|
||||
|
||||
function processGreenScreen(inputVideo, backgroundImage) {
|
||||
const outputVideo = makeOutputPath("greenscreen");
|
||||
const filterComplex = [
|
||||
"[1:v][0:v]scale2ref=flags=lanczos[bg][vid]",
|
||||
"[vid]format=yuva444p,chromakey=0x00FF00:0.28:0.05,chromakey=0x40FF40:0.12:0.15[fg]",
|
||||
"[bg][fg]overlay=0:0:shortest=1,unsharp=3:3:0.5:3:3:0.5,format=yuv420p[out]",
|
||||
].join(";");
|
||||
execFfmpeg([
|
||||
"-i",
|
||||
inputVideo,
|
||||
"-loop",
|
||||
"1",
|
||||
"-i",
|
||||
backgroundImage,
|
||||
"-filter_complex",
|
||||
filterComplex,
|
||||
"-map",
|
||||
"[out]",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-crf",
|
||||
"18",
|
||||
"-c:a",
|
||||
"aac",
|
||||
"-b:a",
|
||||
"128k",
|
||||
"-shortest",
|
||||
"-y",
|
||||
outputVideo,
|
||||
]);
|
||||
return { outputVideo, message: "绿幕替换完成" };
|
||||
}
|
||||
|
||||
/** 对齐 Electron ipAgent:processVideoMixCut */
|
||||
function processVideoMixCut({
|
||||
inputVideo,
|
||||
replacements,
|
||||
outputPath,
|
||||
videoResolution,
|
||||
videoDuration,
|
||||
displayMode = "pip",
|
||||
}) {
|
||||
if (!replacements?.length) {
|
||||
throw new Error("混剪替换点为空");
|
||||
}
|
||||
for (const r of replacements) {
|
||||
if (!r.materialPath || !fs.existsSync(r.materialPath)) {
|
||||
throw new Error(`素材文件不存在: ${r.materialPath || "(空)"}`);
|
||||
}
|
||||
}
|
||||
|
||||
let filterComplex = "";
|
||||
let inputFiles = [inputVideo];
|
||||
let currentStream = "[0:v]";
|
||||
|
||||
const adjustedReplacements = replacements.map((r, i) => {
|
||||
let actualEndTime = r.startTime + Math.min(r.duration, r.materialDuration);
|
||||
for (let j = i + 1; j < replacements.length; j++) {
|
||||
const nextR = replacements[j];
|
||||
if (nextR.startTime < actualEndTime) {
|
||||
actualEndTime = nextR.startTime;
|
||||
}
|
||||
}
|
||||
return { ...r, actualEndTime };
|
||||
});
|
||||
|
||||
for (let i = 0; i < adjustedReplacements.length; i++) {
|
||||
const r = adjustedReplacements[i];
|
||||
const inputIndex = i + 1;
|
||||
const materialStream = `[${inputIndex}:v]`;
|
||||
const trimmedMaterialStream = `[m_trim${i}]`;
|
||||
const processedMaterialStream = `[m${i}]`;
|
||||
const mode = r.displayMode || displayMode || "pip";
|
||||
let scaleFilter = "";
|
||||
let overlayParam = "";
|
||||
|
||||
if (mode === "fullscreen") {
|
||||
scaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height},boxblur=25:2`;
|
||||
overlayParam = "0:0";
|
||||
} else {
|
||||
const pipSizePercent = r.pipSizePercent || 30;
|
||||
const pipWidth = Math.round((videoResolution.width * pipSizePercent) / 100);
|
||||
const pipHeight = Math.round((videoResolution.height * pipSizePercent) / 100);
|
||||
const pipScaleMode = r.pipScaleMode || "fit";
|
||||
let pipScaleFilter = "";
|
||||
if (pipScaleMode === "fit") {
|
||||
pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=decrease`;
|
||||
} else if (pipScaleMode === "fill") {
|
||||
pipScaleFilter = `scale=${pipWidth}:${pipHeight}:force_original_aspect_ratio=increase,crop=${pipWidth}:${pipHeight}`;
|
||||
} else {
|
||||
pipScaleFilter = `scale=${pipWidth}:${pipHeight}`;
|
||||
}
|
||||
const pipPos = r.pipPosition || "bottom-right";
|
||||
let pipX = 0;
|
||||
let pipY = 0;
|
||||
switch (pipPos) {
|
||||
case "top-left":
|
||||
pipX = 10;
|
||||
pipY = 10;
|
||||
break;
|
||||
case "top-center":
|
||||
pipX = (videoResolution.width - pipWidth) / 2;
|
||||
pipY = 10;
|
||||
break;
|
||||
case "top-right":
|
||||
pipX = videoResolution.width - pipWidth - 10;
|
||||
pipY = 10;
|
||||
break;
|
||||
case "center-left":
|
||||
pipX = 10;
|
||||
pipY = (videoResolution.height - pipHeight) / 2;
|
||||
break;
|
||||
case "center":
|
||||
pipX = (videoResolution.width - pipWidth) / 2;
|
||||
pipY = (videoResolution.height - pipHeight) / 2;
|
||||
break;
|
||||
case "center-right":
|
||||
pipX = videoResolution.width - pipWidth - 10;
|
||||
pipY = (videoResolution.height - pipHeight) / 2;
|
||||
break;
|
||||
case "bottom-left":
|
||||
pipX = 10;
|
||||
pipY = videoResolution.height - pipHeight - 10;
|
||||
break;
|
||||
case "bottom-center":
|
||||
pipX = (videoResolution.width - pipWidth) / 2;
|
||||
pipY = videoResolution.height - pipHeight - 10;
|
||||
break;
|
||||
case "bottom-right":
|
||||
default:
|
||||
pipX = videoResolution.width - pipWidth - 10;
|
||||
pipY = videoResolution.height - pipHeight - 10;
|
||||
break;
|
||||
}
|
||||
scaleFilter = pipScaleFilter;
|
||||
overlayParam = `${Math.round(pipX)}:${Math.round(pipY)}`;
|
||||
}
|
||||
|
||||
const actualDuration = Math.min(r.duration, r.materialDuration);
|
||||
const isImage = r.isImage ?? isImageFile(r.materialPath);
|
||||
|
||||
if (isImage) {
|
||||
const fpsFilter = "fps=25";
|
||||
const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`;
|
||||
filterComplex += `${materialStream}${fpsFilter},${setptsFilter}${trimmedMaterialStream};`;
|
||||
if (mode === "fullscreen") {
|
||||
const originalMinimized = r.originalVideoMinimized;
|
||||
if (originalMinimized?.enabled) {
|
||||
const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`;
|
||||
filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`;
|
||||
} else {
|
||||
const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`;
|
||||
filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`;
|
||||
}
|
||||
} else {
|
||||
filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`;
|
||||
}
|
||||
} else {
|
||||
const trimFilter = `trim=start=${(r.materialStartOffset || 0).toFixed(3)}:duration=${actualDuration.toFixed(3)}`;
|
||||
const setptsFilter = `setpts=PTS-STARTPTS+${r.startTime.toFixed(3)}/TB`;
|
||||
filterComplex += `${materialStream}${trimFilter},${setptsFilter}${trimmedMaterialStream};`;
|
||||
if (mode === "fullscreen") {
|
||||
const originalMinimized = r.originalVideoMinimized;
|
||||
if (originalMinimized?.enabled) {
|
||||
const fullscreenScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=increase,crop=${videoResolution.width}:${videoResolution.height}`;
|
||||
filterComplex += `${trimmedMaterialStream}${fullscreenScaleFilter}${processedMaterialStream};`;
|
||||
} else {
|
||||
const simpleScaleFilter = `scale=${videoResolution.width}:${videoResolution.height}:force_original_aspect_ratio=decrease`;
|
||||
filterComplex += `${trimmedMaterialStream}${simpleScaleFilter}${processedMaterialStream};`;
|
||||
}
|
||||
} else {
|
||||
filterComplex += `${trimmedMaterialStream}${scaleFilter}${processedMaterialStream};`;
|
||||
}
|
||||
}
|
||||
|
||||
const outputStream = `[v${i}]`;
|
||||
const enableExpr = `gte(t,${r.startTime.toFixed(3)})*lt(t,${r.actualEndTime.toFixed(3)})`;
|
||||
|
||||
if (mode === "fullscreen") {
|
||||
const originalMinimized = r.originalVideoMinimized;
|
||||
if (originalMinimized?.enabled) {
|
||||
const { shape, position, sizePercent } = originalMinimized;
|
||||
const originalSize = Math.floor(videoResolution.width * (sizePercent / 100));
|
||||
const splitStream1 = `[split${i}_1]`;
|
||||
const splitStream2 = `[split${i}_2]`;
|
||||
filterComplex += `${currentStream}split=2${splitStream1}${splitStream2};`;
|
||||
const origStream = `[orig${i}]`;
|
||||
let originalScaled = `${splitStream1}scale=${originalSize}:${originalSize}:force_original_aspect_ratio=increase,crop=${originalSize}:${originalSize}`;
|
||||
if (shape === "circle") {
|
||||
const radius = originalSize / 2;
|
||||
originalScaled += `,format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='if(lte(hypot(X-${radius},Y-${radius}),${radius}),255,0)'`;
|
||||
}
|
||||
originalScaled += origStream;
|
||||
filterComplex += `${originalScaled};`;
|
||||
const tmpStream = `[tmp${i}]`;
|
||||
filterComplex += `${splitStream2}${processedMaterialStream}overlay=0:0:enable='${enableExpr}'${tmpStream};`;
|
||||
const padding = 50;
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
switch (position) {
|
||||
case "top-left":
|
||||
x = padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "top-right":
|
||||
x = videoResolution.width - originalSize - padding;
|
||||
y = padding;
|
||||
break;
|
||||
case "bottom-left":
|
||||
x = padding;
|
||||
y = videoResolution.height - originalSize - padding;
|
||||
break;
|
||||
case "bottom-right":
|
||||
x = videoResolution.width - originalSize - padding;
|
||||
y = videoResolution.height - originalSize - padding;
|
||||
break;
|
||||
case "center":
|
||||
x = (videoResolution.width - originalSize) / 2;
|
||||
y = (videoResolution.height - originalSize) / 2;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
filterComplex += `${tmpStream}${origStream}overlay=${Math.round(x)}:${Math.round(y)}:enable='${enableExpr}'${outputStream};`;
|
||||
} else {
|
||||
const blurredBgStream = `[blurred_bg${i}]`;
|
||||
filterComplex += `${currentStream}boxblur=25:2:enable='${enableExpr}'${blurredBgStream};`;
|
||||
filterComplex += `${blurredBgStream}${processedMaterialStream}overlay=(W-w)/2:(H-h)/2:enable='${enableExpr}'${outputStream};`;
|
||||
}
|
||||
} else {
|
||||
filterComplex += `${currentStream}${processedMaterialStream}overlay=${overlayParam}:enable='${enableExpr}'${outputStream};`;
|
||||
}
|
||||
inputFiles.push(r.materialPath);
|
||||
currentStream = outputStream;
|
||||
}
|
||||
|
||||
filterComplex = filterComplex.slice(0, -1);
|
||||
filterComplex += `; ${currentStream}trim=start=0:duration=${videoDuration}[final_video]`;
|
||||
|
||||
const ffmpegArgs = [
|
||||
...inputFiles.flatMap((f) => ["-i", f]),
|
||||
"-filter_complex",
|
||||
filterComplex,
|
||||
"-map",
|
||||
"[final_video]",
|
||||
"-map",
|
||||
"0:a?",
|
||||
"-c:a",
|
||||
"copy",
|
||||
"-c:v",
|
||||
"libx264",
|
||||
"-preset",
|
||||
"medium",
|
||||
"-shortest",
|
||||
"-y",
|
||||
outputPath,
|
||||
];
|
||||
execFfmpeg(ffmpegArgs);
|
||||
return { outputVideo: outputPath, message: "混剪处理完成" };
|
||||
}
|
||||
|
||||
function normalizeReplacements(raw) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
return raw
|
||||
.map((r) => ({
|
||||
startTime: Number(r.startTime),
|
||||
duration: Number(r.duration),
|
||||
materialPath: String(r.materialPath || "").trim(),
|
||||
materialDuration: Number(r.materialDuration),
|
||||
materialStartOffset: Number(r.materialStartOffset || 0),
|
||||
displayMode: r.displayMode,
|
||||
pipPosition: r.pipPosition,
|
||||
pipSizePercent: r.pipSizePercent,
|
||||
pipScaleMode: r.pipScaleMode,
|
||||
isImage: r.isImage,
|
||||
originalVideoMinimized: r.originalVideoMinimized,
|
||||
}))
|
||||
.filter(
|
||||
(r) =>
|
||||
Number.isFinite(r.startTime) &&
|
||||
Number.isFinite(r.duration) &&
|
||||
r.duration > 0 &&
|
||||
r.materialPath &&
|
||||
Number.isFinite(r.materialDuration) &&
|
||||
r.materialDuration > 0,
|
||||
);
|
||||
}
|
||||
|
||||
function pickReplacementsFromSettings(settings, videoDuration) {
|
||||
if (!settings || typeof settings !== "object") return [];
|
||||
if (Array.isArray(settings.replacements) && settings.replacements.length) {
|
||||
return normalizeReplacements(settings.replacements);
|
||||
}
|
||||
const simple = settings.simplePip;
|
||||
if (simple?.materialPath && fs.existsSync(simple.materialPath)) {
|
||||
const matDur =
|
||||
Number(simple.materialDuration) > 0
|
||||
? Number(simple.materialDuration)
|
||||
: isImageFile(simple.materialPath)
|
||||
? videoDuration
|
||||
: probeVideoDuration(simple.materialPath);
|
||||
const startTime = Number(simple.startTime || 0);
|
||||
const duration = Number(simple.duration || videoDuration);
|
||||
return normalizeReplacements([
|
||||
{
|
||||
startTime,
|
||||
duration,
|
||||
materialPath: simple.materialPath,
|
||||
materialDuration: matDur,
|
||||
materialStartOffset: Number(simple.materialStartOffset || 0),
|
||||
displayMode: simple.displayMode || settings.displayMode || "pip",
|
||||
pipPosition: simple.pipPosition || "bottom-right",
|
||||
pipSizePercent: simple.pipSizePercent ?? 30,
|
||||
pipScaleMode: simple.pipScaleMode || "fit",
|
||||
isImage: simple.isImage ?? isImageFile(simple.materialPath),
|
||||
},
|
||||
]);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
globalThis.__nodejsMain = async function main(params) {
|
||||
const p = params || {};
|
||||
const inputVideo = String(p.inputVideo || "").trim();
|
||||
if (!inputVideo || !fs.existsSync(inputVideo)) {
|
||||
return { success: false, error: "缺少或找不到输入视频" };
|
||||
}
|
||||
|
||||
const steps = [];
|
||||
let current = inputVideo;
|
||||
|
||||
try {
|
||||
if (p.autoCutBreath) {
|
||||
globalThis.__native?.emitProgress?.("正在剪气口…");
|
||||
const r = processSilenceDetection(current, {
|
||||
silenceThreshold: p.silenceThreshold,
|
||||
silenceDuration: p.silenceDuration,
|
||||
minPauseDuration: p.minPauseDuration,
|
||||
});
|
||||
current = r.outputVideo;
|
||||
steps.push({ step: "silence", ...r });
|
||||
}
|
||||
|
||||
if (p.pipInPicture) {
|
||||
const settings = p.mixCutSettings || null;
|
||||
const replacements = pickReplacementsFromSettings(
|
||||
settings,
|
||||
probeVideoDuration(current),
|
||||
);
|
||||
if (!replacements.length) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
"已启用画中画,但未配置混剪素材。请在本地配置 videoMixCutSettings(含 replacements 或 simplePip),或使用「选择画中画素材」",
|
||||
};
|
||||
}
|
||||
globalThis.__native?.emitProgress?.("正在混剪/画中画…");
|
||||
const videoDuration = probeVideoDuration(current);
|
||||
const videoResolution = probeVideoResolution(current);
|
||||
const outputPath = makeOutputPath("mixcut");
|
||||
const r = processVideoMixCut({
|
||||
inputVideo: current,
|
||||
replacements,
|
||||
outputPath,
|
||||
videoResolution,
|
||||
videoDuration,
|
||||
displayMode: settings?.displayMode || "pip",
|
||||
});
|
||||
current = r.outputVideo;
|
||||
steps.push({ step: "mixcut", ...r });
|
||||
}
|
||||
|
||||
if (p.greenScreen) {
|
||||
const bg = String(p.backgroundImage || "").trim();
|
||||
if (!bg || !fs.existsSync(bg)) {
|
||||
return { success: false, error: "已启用绿幕切换,请先上传替换背景图" };
|
||||
}
|
||||
globalThis.__native?.emitProgress?.("正在绿幕替换…");
|
||||
const r = processGreenScreen(current, bg);
|
||||
current = r.outputVideo;
|
||||
steps.push({ step: "greenscreen", ...r });
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
videoPath: current,
|
||||
steps,
|
||||
message: steps.length ? steps[steps.length - 1].message : "未执行任何处理",
|
||||
};
|
||||
} catch (e) {
|
||||
return { success: false, error: e.message || String(e), partialVideo: current };
|
||||
}
|
||||
};
|
||||
84
scripts/nodejs/video_publish.js
Normal file
84
scripts/nodejs/video_publish.js
Normal file
@@ -0,0 +1,84 @@
|
||||
"use strict";
|
||||
|
||||
/**
|
||||
* 多平台视频发布(工作流步骤 07 / 一键自动)
|
||||
*/
|
||||
const { publishToPlatform, PLATFORM_LABELS } = require("./publish_platform.js");
|
||||
|
||||
globalThis.__nodejsMain = async function main(params) {
|
||||
const p = params || {};
|
||||
const platforms = Array.isArray(p.platforms) ? p.platforms : [];
|
||||
const videoPath = String(p.videoPath || "").trim();
|
||||
const coverPath = String(p.coverPath || "").trim();
|
||||
const title = String(p.title || "").trim();
|
||||
const description = String(p.description || title || "").trim();
|
||||
const tags = Array.isArray(p.tags) ? p.tags : [];
|
||||
const autoPublish = Boolean(p.autoPublish);
|
||||
|
||||
if (!platforms.length) {
|
||||
return { success: false, error: "请至少选择一个发布平台" };
|
||||
}
|
||||
if (!videoPath) return { success: false, error: "缺少视频路径" };
|
||||
if (!coverPath) return { success: false, error: "缺少封面路径,请先在步骤 06 生成封面" };
|
||||
if (!title) return { success: false, error: "缺少标题,请先在步骤 04 生成标题" };
|
||||
|
||||
const results = [];
|
||||
for (let i = 0; i < platforms.length; i++) {
|
||||
const item = platforms[i];
|
||||
const platform = item.platform;
|
||||
const accountId = item.accountId;
|
||||
const cookies = item.cookies || [];
|
||||
const label = PLATFORM_LABELS[platform] || platform;
|
||||
globalThis.__native?.emitProgress?.(
|
||||
`正在发布到${label}… (${i + 1}/${platforms.length})`,
|
||||
);
|
||||
try {
|
||||
const r = await publishToPlatform(platform, accountId, cookies, {
|
||||
videoPath,
|
||||
coverPath,
|
||||
title,
|
||||
description,
|
||||
tags,
|
||||
autoPublish,
|
||||
});
|
||||
results.push({
|
||||
platform: label,
|
||||
platformKey: platform,
|
||||
success: Boolean(r.success),
|
||||
pending:
|
||||
r.status === "manual_pending" || r.status === "verification_required",
|
||||
status: r.status || (r.success ? "success" : "failed"),
|
||||
message: r.message || "",
|
||||
videoUrl: r.videoUrl || "",
|
||||
});
|
||||
} catch (e) {
|
||||
results.push({
|
||||
platform: label,
|
||||
platformKey: platform,
|
||||
success: false,
|
||||
pending: false,
|
||||
status: "failed",
|
||||
message: e.message || String(e),
|
||||
});
|
||||
}
|
||||
if (i < platforms.length - 1) {
|
||||
await new Promise((r) => setTimeout(r, 2000));
|
||||
}
|
||||
}
|
||||
|
||||
const ok = results.filter((r) => r.success).length;
|
||||
const pending = results.filter((r) => r.pending).length;
|
||||
const failed = results.length - ok - pending;
|
||||
|
||||
return {
|
||||
success: ok > 0 || pending > 0,
|
||||
results,
|
||||
summary: { ok, pending, failed, total: results.length },
|
||||
message:
|
||||
ok === results.length
|
||||
? "全部发布成功"
|
||||
: pending > 0
|
||||
? `成功 ${ok},待确认 ${pending},失败 ${failed}`
|
||||
: `成功 ${ok},失败 ${failed}`,
|
||||
};
|
||||
};
|
||||
Reference in New Issue
Block a user