330 lines
10 KiB
Python
330 lines
10 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""
|
||
桌面端封面生成入口(由 Tauri pythonscript runner 调用 __python_main)。
|
||
优先高级模板 → PIL → ffmpeg drawtext 回退。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import time
|
||
from pathlib import Path
|
||
|
||
SCRIPT_DIR = Path(__file__).resolve().parent
|
||
if str(SCRIPT_DIR) not in sys.path:
|
||
sys.path.insert(0, str(SCRIPT_DIR))
|
||
|
||
OUTPUT_DIR = Path(tempfile.gettempdir()) / "aiclient-cover"
|
||
IMAGE_EXT = {".jpg", ".jpeg", ".png", ".bmp", ".webp"}
|
||
|
||
|
||
def _emit_progress(status: str) -> None:
|
||
native = globals().get("__native")
|
||
if native is not None and hasattr(native, "emit_progress"):
|
||
native.emit_progress(status)
|
||
|
||
|
||
def _locate_ffmpeg() -> str | None:
|
||
env = os.environ.get("AICLIENT_FFMPEG_PATH", "").strip()
|
||
if env and os.path.isfile(env):
|
||
return env
|
||
return None
|
||
|
||
|
||
def _normalize_path(p: str) -> str:
|
||
return str(p).replace("\\", "/")
|
||
|
||
|
||
def _is_image(path: str) -> bool:
|
||
return Path(path).suffix.lower() in IMAGE_EXT
|
||
|
||
|
||
def _ensure_output_dir() -> Path:
|
||
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
||
return OUTPUT_DIR
|
||
|
||
|
||
def _exec_ffmpeg(args: list[str]) -> None:
|
||
ffmpeg = _locate_ffmpeg()
|
||
if not ffmpeg:
|
||
raise RuntimeError(
|
||
"未找到 ffmpeg,请配置 AICLIENT_FFMPEG_PATH 或 src-tauri/binaries/ffmpeg.exe"
|
||
)
|
||
r = subprocess.run(
|
||
[ffmpeg, *args],
|
||
capture_output=True,
|
||
text=True,
|
||
encoding="utf-8",
|
||
errors="replace",
|
||
)
|
||
if r.returncode != 0:
|
||
msg = (r.stderr or r.stdout or "").strip() or f"ffmpeg 退出码 {r.returncode}"
|
||
raise RuntimeError(msg)
|
||
|
||
|
||
def _locate_cjk_font() -> str | None:
|
||
scripts_dir = os.environ.get("AICLIENT_COVER_SCRIPTS_DIR") or str(SCRIPT_DIR)
|
||
windir = os.environ.get("WINDIR", r"C:\Windows")
|
||
candidates = [
|
||
os.path.join(scripts_dir, "fonts", "simhei.ttf"),
|
||
os.path.join(scripts_dir, "fonts", "msyh.ttc"),
|
||
os.path.join(windir, "Fonts", "simhei.ttf"),
|
||
os.path.join(windir, "Fonts", "msyh.ttc"),
|
||
os.path.join(windir, "Fonts", "simsun.ttc"),
|
||
]
|
||
for p in candidates:
|
||
if p and os.path.isfile(p):
|
||
return p
|
||
return None
|
||
|
||
|
||
def _prepare_frame(source_path: str, temp_frame_path: str) -> None:
|
||
if _is_image(source_path):
|
||
_emit_progress("正在加载图片素材…")
|
||
_exec_ffmpeg(
|
||
[
|
||
"-i",
|
||
source_path,
|
||
"-vf",
|
||
"scale=1080:1920:force_original_aspect_ratio=decrease,"
|
||
"pad=1080:1920:(ow-iw)/2:(oh-ih)/2:color=black",
|
||
"-y",
|
||
temp_frame_path,
|
||
]
|
||
)
|
||
return
|
||
_emit_progress("正在提取视频帧…")
|
||
_exec_ffmpeg(
|
||
[
|
||
"-ss",
|
||
"00:00:03",
|
||
"-i",
|
||
source_path,
|
||
"-vframes",
|
||
"1",
|
||
"-pix_fmt",
|
||
"yuvj420p",
|
||
"-c:v",
|
||
"mjpeg",
|
||
"-strict:v",
|
||
"unofficial",
|
||
"-f",
|
||
"image2",
|
||
"-y",
|
||
temp_frame_path,
|
||
]
|
||
)
|
||
|
||
|
||
def _escape_ffmpeg_path(file_path: str) -> str:
|
||
return file_path.replace("\\", "/").replace(":", "\\:")
|
||
|
||
|
||
def _run_ffmpeg_cover(
|
||
source_video: str,
|
||
title_text: str,
|
||
effect_style: dict,
|
||
cover_path: str,
|
||
temp_frame_path: str,
|
||
) -> None:
|
||
_prepare_frame(source_video, temp_frame_path)
|
||
title_font_size = effect_style.get("titleFontSize") or 90
|
||
title_font_color = effect_style.get("titleFontColor") or effect_style.get("titleColor") or "#FFFFFF"
|
||
title_position = effect_style.get("titlePosition")
|
||
if isinstance(title_position, dict):
|
||
text_pos = "y=h-text_h-50"
|
||
elif title_position == "top":
|
||
text_pos = "y=50"
|
||
elif title_position == "center":
|
||
text_pos = "y=(h-text_h)/2"
|
||
else:
|
||
text_pos = "y=h-text_h-50"
|
||
|
||
font_file = _locate_cjk_font()
|
||
if not font_file:
|
||
raise RuntimeError(
|
||
"未找到中文字体文件,无法使用 ffmpeg 绘制标题"
|
||
)
|
||
|
||
title_file = OUTPUT_DIR / f"cover_title_{int(time.time() * 1000)}.txt"
|
||
title_file.write_text(str(title_text), encoding="utf-8")
|
||
try:
|
||
font_opt = f"fontfile='{_escape_ffmpeg_path(font_file)}'"
|
||
text_opt = f"textfile='{_escape_ffmpeg_path(str(title_file))}'"
|
||
vf = (
|
||
f"drawtext={font_opt}:{text_opt}:fontsize={title_font_size}:"
|
||
f"fontcolor={title_font_color}:x=(w-text_w)/2:{text_pos}:"
|
||
"shadowcolor=black:shadowx=2:shadowy=2"
|
||
)
|
||
_emit_progress("正在叠加标题文字(ffmpeg)…")
|
||
_exec_ffmpeg(["-i", temp_frame_path, "-vf", vf, "-y", cover_path])
|
||
finally:
|
||
try:
|
||
title_file.unlink(missing_ok=True)
|
||
except OSError:
|
||
pass
|
||
|
||
|
||
def _is_advanced_cover_config(config: dict | None) -> bool:
|
||
if not config or not isinstance(config, dict):
|
||
return False
|
||
return (
|
||
config.get("blurBackground") is not None
|
||
or config.get("extractPerson") is not None
|
||
or config.get("personOutlineColor") is not None
|
||
or config.get("personOutlineWidth") is not None
|
||
or config.get("maskImagePath") is not None
|
||
or bool(config.get("titleFontFamily"))
|
||
or config.get("titleBackgroundEnabled") is not None
|
||
or config.get("personSize") is not None
|
||
or config.get("backgroundBlurEnabled") is not None
|
||
)
|
||
|
||
|
||
def _should_use_pil_cover(effect_style: dict) -> bool:
|
||
if _is_advanced_cover_config(effect_style):
|
||
return False
|
||
return (
|
||
bool(effect_style.get("titleStrokeWidth"))
|
||
or bool(effect_style.get("titleFontFamily"))
|
||
or bool(effect_style.get("titleColor"))
|
||
or (effect_style.get("titleShadowBlur") or 0) > 0
|
||
)
|
||
|
||
|
||
def _run_advanced(
|
||
video_path: str,
|
||
title_text: str,
|
||
output_path: str,
|
||
config: dict,
|
||
) -> dict:
|
||
from advanced_cover_generator import AdvancedCoverGenerator
|
||
|
||
_emit_progress("正在使用高级封面生成器…")
|
||
merged = {
|
||
**config,
|
||
"output": output_path,
|
||
"video": video_path,
|
||
# 与 Electron 一致:CLI title 仅主标题;勿用步骤 04 整句覆盖 titleText
|
||
"title": config.get("titleText") or title_text,
|
||
}
|
||
generator = AdvancedCoverGenerator(merged)
|
||
result = generator.generate()
|
||
if not result.get("success"):
|
||
return {
|
||
"success": False,
|
||
"error": result.get("error") or result.get("message") or "高级封面生成失败",
|
||
}
|
||
if not os.path.isfile(output_path):
|
||
return {"success": False, "error": "封面文件未生成"}
|
||
return {
|
||
"success": True,
|
||
"coverPath": result.get("coverPath") or output_path,
|
||
"previewImages": result.get("previewImages") or [],
|
||
"message": result.get("message") or "高级封面生成完成",
|
||
"mode": "advanced_python",
|
||
}
|
||
|
||
|
||
def _run_pil(
|
||
video_path: str,
|
||
title_text: str,
|
||
output_path: str,
|
||
config: dict,
|
||
timestamp: float = 3,
|
||
) -> dict:
|
||
import cover_generator as cg
|
||
|
||
_emit_progress("正在使用 PIL 封面生成器…")
|
||
frame = cg.extract_frame_from_video(video_path, timestamp)
|
||
if frame is None:
|
||
return {"success": False, "error": "无法从视频提取帧"}
|
||
width, height = frame.size
|
||
max_width = config.get("maxWidth", 1920)
|
||
if width > max_width:
|
||
scale = max_width / width
|
||
frame = frame.resize(
|
||
(max_width, int(height * scale)),
|
||
cg.Image.Resampling.LANCZOS,
|
||
)
|
||
result = cg.add_text_to_image(frame, title_text, config)
|
||
out_dir = os.path.dirname(output_path)
|
||
if out_dir:
|
||
os.makedirs(out_dir, exist_ok=True)
|
||
result.save(output_path, quality=95)
|
||
if not os.path.isfile(output_path):
|
||
return {"success": False, "error": "封面文件未生成"}
|
||
return {
|
||
"success": True,
|
||
"coverPath": output_path,
|
||
"message": "封面生成完成",
|
||
"mode": "pil_python",
|
||
}
|
||
|
||
|
||
def __python_main(params: dict | None) -> dict:
|
||
p = params or {}
|
||
video_path = str(p.get("videoPath") or "").strip()
|
||
title_text = str(p.get("titleText") or "").strip()
|
||
effect_style = p.get("effectStyle") or {}
|
||
if not isinstance(effect_style, dict):
|
||
effect_style = {}
|
||
|
||
if not video_path or not os.path.isfile(video_path):
|
||
return {
|
||
"success": False,
|
||
"error": "视频文件不存在,请先在步骤 02/03/05 生成视频",
|
||
}
|
||
if not title_text:
|
||
return {
|
||
"success": False,
|
||
"error": "标题文本不能为空,请先在步骤 04 生成标题",
|
||
}
|
||
|
||
_ensure_output_dir()
|
||
ts = int(time.time() * 1000)
|
||
temp_frame_path = str(OUTPUT_DIR / f"temp_frame_{ts}.jpg")
|
||
cover_path = str(OUTPUT_DIR / f"cover_{ts}.jpg")
|
||
|
||
source_video = video_path
|
||
custom = str(effect_style.get("customVideoPath") or "").strip()
|
||
if custom and os.path.isfile(custom):
|
||
source_video = custom
|
||
|
||
try:
|
||
if _is_advanced_cover_config(effect_style):
|
||
adv = _run_advanced(source_video, title_text, cover_path, effect_style)
|
||
if adv.get("success"):
|
||
return adv
|
||
_emit_progress(
|
||
f"高级生成失败,尝试回退:{str(adv.get('error') or '')[:80]}"
|
||
)
|
||
elif _should_use_pil_cover(effect_style):
|
||
pil = _run_pil(source_video, title_text, cover_path, effect_style)
|
||
if pil.get("success"):
|
||
return pil
|
||
_emit_progress("PIL 生成失败,尝试 ffmpeg 回退…")
|
||
|
||
_run_ffmpeg_cover(
|
||
source_video, title_text, effect_style, cover_path, temp_frame_path
|
||
)
|
||
if os.path.isfile(temp_frame_path):
|
||
try:
|
||
os.unlink(temp_frame_path)
|
||
except OSError:
|
||
pass
|
||
if not os.path.isfile(cover_path):
|
||
return {"success": False, "error": "封面文件未生成"}
|
||
return {
|
||
"success": True,
|
||
"coverPath": cover_path,
|
||
"message": "封面生成完成",
|
||
"mode": "ffmpeg",
|
||
}
|
||
except Exception as e:
|
||
return {"success": False, "error": str(e) or repr(e)}
|