mpup 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mpup/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """MPUp song background and video generator."""
2
+
3
+ __version__ = "0.1.0"
mpup/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ from mpup.cli import entrypoint
2
+
3
+
4
+ if __name__ == "__main__":
5
+ entrypoint()
mpup/background.py ADDED
@@ -0,0 +1,122 @@
1
+ """Render song title and artist onto a background template."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import tempfile
7
+ from pathlib import Path
8
+
9
+ from PIL import Image, ImageDraw, ImageFont
10
+
11
+ from mpup.config import TemplateConfig, TextBox
12
+
13
+
14
+ FONT_CANDIDATES = (
15
+ Path("/System/Library/Fonts/PingFang.ttc"),
16
+ Path("/System/Library/Fonts/STHeiti Medium.ttc"),
17
+ Path("/System/Library/Fonts/Supplemental/Arial Unicode.ttf"),
18
+ Path("/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc"),
19
+ Path("/usr/share/fonts/truetype/wqy/wqy-zenhei.ttc"),
20
+ Path("C:/Windows/Fonts/msyh.ttc"),
21
+ )
22
+
23
+
24
+ def resolve_font(explicit_path: Path | None = None) -> Path:
25
+ """Resolve an explicit or common system CJK font."""
26
+ if explicit_path is not None:
27
+ candidate = explicit_path.expanduser()
28
+ if not candidate.is_file():
29
+ raise ValueError(f"字体文件不存在:{explicit_path}")
30
+ return candidate.resolve()
31
+ for candidate in FONT_CANDIDATES:
32
+ if candidate.is_file():
33
+ return candidate.resolve()
34
+ raise ValueError("未找到可用中文字体,请通过 --font 指定字体文件")
35
+
36
+
37
+ def fit_font(
38
+ draw: ImageDraw.ImageDraw,
39
+ text: str,
40
+ font_path: Path,
41
+ box: TextBox,
42
+ ) -> ImageFont.FreeTypeFont:
43
+ """Return the largest configured font that fits the text box."""
44
+ if not text:
45
+ raise ValueError("文本不能为空")
46
+ for size in range(box.max_font_size, box.min_font_size - 1, -1):
47
+ font = ImageFont.truetype(str(font_path), size=size)
48
+ left, top, right, bottom = draw.textbbox((0, 0), text, font=font)
49
+ if right - left <= box.rect.width and bottom - top <= box.rect.height:
50
+ return font
51
+ raise ValueError(
52
+ f"文本在最小字号 {box.min_font_size}px 下仍无法放入配置区域:{text}"
53
+ )
54
+
55
+
56
+ def _draw_text(
57
+ draw: ImageDraw.ImageDraw,
58
+ text: str,
59
+ font_path: Path,
60
+ box: TextBox,
61
+ config: TemplateConfig,
62
+ ) -> None:
63
+ font = fit_font(draw, text, font_path, box)
64
+ position = (box.horizontal_center, box.rect.center_y)
65
+ shadow = (
66
+ position[0] + config.shadow_offset,
67
+ position[1] + config.shadow_offset,
68
+ )
69
+ draw.text(shadow, text, font=font, fill=config.shadow_fill, anchor="mm")
70
+ draw.text(position, text, font=font, fill=config.text_fill, anchor="mm")
71
+
72
+
73
+ def render_background(
74
+ base_path: Path,
75
+ output_path: Path,
76
+ config: TemplateConfig,
77
+ title: str,
78
+ artist: str,
79
+ font_path: Path,
80
+ overwrite: bool = False,
81
+ ) -> None:
82
+ """Render a song-specific PNG and publish it atomically."""
83
+ if output_path.exists() and not overwrite:
84
+ raise FileExistsError(f"背景图已存在:{output_path}")
85
+ output_path.parent.mkdir(parents=True, exist_ok=True)
86
+
87
+ try:
88
+ with Image.open(base_path) as source:
89
+ source.load()
90
+ if source.size != (config.width, config.height):
91
+ raise ValueError(
92
+ "背景图尺寸与配置不一致:"
93
+ f"实际 {source.width}x{source.height},"
94
+ f"配置 {config.width}x{config.height}"
95
+ )
96
+ image = source.convert("RGB")
97
+ except OSError as error:
98
+ raise ValueError(f"无法读取背景图:{base_path}") from error
99
+
100
+ draw = ImageDraw.Draw(image)
101
+ _draw_text(draw, title, font_path, config.song_title, config)
102
+ _draw_text(draw, artist, font_path, config.artist, config)
103
+
104
+ temporary = tempfile.NamedTemporaryFile(
105
+ prefix=f".{output_path.stem}.",
106
+ suffix=".tmp.png",
107
+ dir=output_path.parent,
108
+ delete=False,
109
+ )
110
+ temporary_path = Path(temporary.name)
111
+ temporary.close()
112
+ try:
113
+ image.save(temporary_path, format="PNG")
114
+ with Image.open(temporary_path) as verification:
115
+ verification.load()
116
+ if verification.size != (config.width, config.height):
117
+ raise RuntimeError("生成的背景图尺寸验证失败")
118
+ os.replace(temporary_path, output_path)
119
+ except OSError as error:
120
+ raise RuntimeError(f"无法写入背景图:{output_path}") from error
121
+ finally:
122
+ temporary_path.unlink(missing_ok=True)
mpup/cli.py ADDED
@@ -0,0 +1,159 @@
1
+ """Command-line interface for the song background video pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import TextIO
10
+
11
+ from tqdm import tqdm
12
+
13
+ from mpup.background import resolve_font
14
+ from mpup.config import load_template_config
15
+ from mpup.discovery import discover_audio
16
+ from mpup.metadata import require_media_tools, require_remotion_runtime
17
+ from mpup.pipeline import BatchResult, ProgressEvent, ProgressReporter, process_batch
18
+
19
+
20
+ def build_parser() -> argparse.ArgumentParser:
21
+ parser = argparse.ArgumentParser(
22
+ prog="mpup",
23
+ description="根据音频元数据、封面和同步歌词生成 1080P 音乐视频",
24
+ )
25
+ parser.add_argument("input", metavar="INPUT", help="音频文件或音频目录")
26
+ parser.add_argument(
27
+ "--background",
28
+ default="assets/background.png",
29
+ metavar="PATH",
30
+ help="基础背景 PNG(默认:assets/background.png)",
31
+ )
32
+ parser.add_argument(
33
+ "--config",
34
+ default="assets/bg-cfg.json",
35
+ metavar="PATH",
36
+ help="背景布局 JSON(默认:assets/bg-cfg.json)",
37
+ )
38
+ parser.add_argument(
39
+ "--output-dir",
40
+ default="output",
41
+ metavar="PATH",
42
+ help="PNG 和 MP4 输出目录(默认:output)",
43
+ )
44
+ parser.add_argument(
45
+ "--font",
46
+ metavar="PATH",
47
+ help="中文 TrueType/OpenType 字体;默认自动发现",
48
+ )
49
+ parser.add_argument(
50
+ "--overwrite",
51
+ action="store_true",
52
+ help="允许覆盖已有的最终 MP4;中间产物会直接复用",
53
+ )
54
+ return parser
55
+
56
+
57
+ def _print_summary(result: BatchResult, stdout: TextIO) -> None:
58
+ for item in result.successes:
59
+ sources = ""
60
+ if item.cover_source is not None and item.lyrics_source is not None:
61
+ sources = f"(封面={item.cover_source},歌词={item.lyrics_source})"
62
+ audio_target = item.enriched_audio_path or item.input_path
63
+ print(
64
+ f"完成 {item.input_path} -> {audio_target} -> {item.video_path}{sources}",
65
+ file=stdout,
66
+ )
67
+ for warning in item.warnings:
68
+ print(f"警告 {item.input_path}:{warning}", file=stdout)
69
+ for item in result.skipped:
70
+ print(f"跳过 {item.input_path}:{item.message}", file=stdout)
71
+ for item in result.failed:
72
+ print(f"失败 {item.input_path}:{item.message}", file=stdout)
73
+ print(
74
+ f"完成:{len(result.successes)},"
75
+ f"跳过:{len(result.skipped)},"
76
+ f"失败:{len(result.failed)}",
77
+ file=stdout,
78
+ )
79
+
80
+
81
+ _STAGE_LABELS = {
82
+ "metadata": "读取元数据",
83
+ "cover": "获取封面",
84
+ "lyrics": "获取 LRC/歌词",
85
+ "enriched_audio": "写入增强 MP3",
86
+ "background": "绘制背景图",
87
+ "render_prepare": "准备 Remotion",
88
+ "remotion": "Remotion 渲染",
89
+ "publish": "校验并发布视频",
90
+ "completed": "处理完成",
91
+ }
92
+
93
+
94
+ def _progress_reporter(audio_paths: Sequence[Path], stdout: TextIO) -> ProgressReporter:
95
+ """Render detailed milestones for one song or one quiet bar for a batch."""
96
+ if len(audio_paths) == 1:
97
+ def report_single(event: ProgressEvent) -> None:
98
+ if event.input_path is None:
99
+ return
100
+ label = _STAGE_LABELS.get(event.stage)
101
+ if label is not None:
102
+ print(f"[{event.input_path.name}] {label}", file=stdout)
103
+
104
+ return report_single
105
+
106
+ progress_bar: object | None = None
107
+
108
+ def report_batch(event: ProgressEvent) -> None:
109
+ nonlocal progress_bar
110
+ if event.stage == "batch_start":
111
+ progress_bar = tqdm(total=len(audio_paths), desc="处理音频", unit="首")
112
+ elif event.stage == "completed" and progress_bar is not None:
113
+ progress_bar.set_postfix_str(event.input_path.name if event.input_path else "")
114
+ progress_bar.update()
115
+ elif event.stage == "batch_completed" and progress_bar is not None:
116
+ progress_bar.close()
117
+
118
+ return report_batch
119
+
120
+
121
+ def main(
122
+ argv: Sequence[str] | None = None,
123
+ *,
124
+ stdout: TextIO = sys.stdout,
125
+ stderr: TextIO = sys.stderr,
126
+ ) -> int:
127
+ args = build_parser().parse_args(argv)
128
+ try:
129
+ audio_paths = discover_audio(Path(args.input))
130
+ config = load_template_config(Path(args.config))
131
+ font_path = resolve_font(Path(args.font) if args.font else None)
132
+ ffmpeg, ffprobe = require_media_tools()
133
+ node, _remotion_entry = require_remotion_runtime()
134
+ progress = _progress_reporter(audio_paths, stdout)
135
+ result = process_batch(
136
+ audio_paths,
137
+ Path(args.background),
138
+ config,
139
+ Path(args.output_dir),
140
+ font_path,
141
+ ffmpeg,
142
+ ffprobe,
143
+ args.overwrite,
144
+ node=node,
145
+ progress=progress,
146
+ )
147
+ except KeyboardInterrupt:
148
+ print("错误:操作已中断", file=stderr)
149
+ return 130
150
+ except Exception as error:
151
+ print(f"错误:{error}", file=stderr)
152
+ return 2
153
+
154
+ _print_summary(result, stdout)
155
+ return result.exit_code
156
+
157
+
158
+ def entrypoint() -> None:
159
+ raise SystemExit(main())
mpup/config.py ADDED
@@ -0,0 +1,187 @@
1
+ """Load and validate background template configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Rect:
13
+ x: int
14
+ y: int
15
+ width: int
16
+ height: int
17
+
18
+ @property
19
+ def right(self) -> int:
20
+ return self.x + self.width
21
+
22
+ @property
23
+ def bottom(self) -> int:
24
+ return self.y + self.height
25
+
26
+ @property
27
+ def center_y(self) -> float:
28
+ return self.y + self.height / 2
29
+
30
+ def contains(self, other: "Rect") -> bool:
31
+ return (
32
+ self.x <= other.x
33
+ and self.y <= other.y
34
+ and self.right >= other.right
35
+ and self.bottom >= other.bottom
36
+ )
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class TextBox:
41
+ rect: Rect
42
+ horizontal_center: int
43
+ min_font_size: int
44
+ max_font_size: int
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class TemplateConfig:
49
+ width: int
50
+ height: int
51
+ song_title: TextBox
52
+ artist: TextBox
53
+ text_fill: str = "#F4F8FF"
54
+ shadow_fill: str = "#142A66"
55
+ shadow_offset: int = 3
56
+
57
+
58
+ def _mapping(value: object, path: str) -> dict[str, Any]:
59
+ if not isinstance(value, dict):
60
+ raise ValueError(f"{path} 必须是 JSON 对象")
61
+ return value
62
+
63
+
64
+ def _field(mapping: dict[str, Any], name: str, path: str) -> Any:
65
+ if name not in mapping:
66
+ raise ValueError(f"缺少配置字段:{path}.{name}")
67
+ return mapping[name]
68
+
69
+
70
+ def _integer(
71
+ mapping: dict[str, Any],
72
+ name: str,
73
+ path: str,
74
+ *,
75
+ allow_zero: bool = False,
76
+ ) -> int:
77
+ value = _field(mapping, name, path)
78
+ minimum = 0 if allow_zero else 1
79
+ if isinstance(value, bool) or not isinstance(value, int) or value < minimum:
80
+ qualifier = "非负整数" if allow_zero else "正整数"
81
+ raise ValueError(f"{path}.{name} 必须是{qualifier}")
82
+ return value
83
+
84
+
85
+ def _rect(mapping: dict[str, Any], path: str, canvas: Rect) -> Rect:
86
+ rect = Rect(
87
+ x=_integer(mapping, "x", path, allow_zero=True),
88
+ y=_integer(mapping, "y", path, allow_zero=True),
89
+ width=_integer(mapping, "width", path),
90
+ height=_integer(mapping, "height", path),
91
+ )
92
+ if not canvas.contains(rect):
93
+ raise ValueError(f"{path} 必须完整位于 canvas 内")
94
+ return rect
95
+
96
+
97
+ def _text_box(
98
+ mapping: dict[str, Any],
99
+ path: str,
100
+ canvas: Rect,
101
+ safe_area: Rect,
102
+ maximum_text_y: int,
103
+ ) -> TextBox:
104
+ rect = _rect(mapping, path, canvas)
105
+ if not safe_area.contains(rect):
106
+ raise ValueError(f"{path} 必须完整位于 speechBubble.safeTextArea 内")
107
+ if rect.bottom > maximum_text_y:
108
+ raise ValueError(f"{path} 不能超过 recommendations.maximumTextY")
109
+
110
+ horizontal_center = _integer(mapping, "horizontalCenter", path, allow_zero=True)
111
+ if not rect.x <= horizontal_center <= rect.right:
112
+ raise ValueError(f"{path}.horizontalCenter 必须位于文本框内")
113
+ if _field(mapping, "alignment", path) != "center":
114
+ raise ValueError(f"{path}.alignment 必须为 center")
115
+
116
+ font_path = f"{path}.recommendedFontSize"
117
+ font_sizes = _mapping(_field(mapping, "recommendedFontSize", path), font_path)
118
+ minimum = _integer(font_sizes, "min", font_path)
119
+ maximum = _integer(font_sizes, "max", font_path)
120
+ if minimum > maximum:
121
+ raise ValueError(f"{font_path}.min 不能大于 max")
122
+ if _field(font_sizes, "unit", font_path) != "px":
123
+ raise ValueError(f"{font_path}.unit 必须为 px")
124
+ return TextBox(rect, horizontal_center, minimum, maximum)
125
+
126
+
127
+ def load_template_config(path: Path) -> TemplateConfig:
128
+ """Load the supplied JSON template into validated typed values."""
129
+ try:
130
+ raw = path.read_text(encoding="utf-8")
131
+ except OSError as error:
132
+ raise ValueError(f"无法读取配置文件:{path}") from error
133
+ try:
134
+ payload = json.loads(raw)
135
+ except json.JSONDecodeError as error:
136
+ raise ValueError(f"配置文件包含无效 JSON:{path}") from error
137
+ root = _mapping(payload, "root")
138
+
139
+ canvas_raw = _mapping(_field(root, "canvas", "root"), "canvas")
140
+ width = _integer(canvas_raw, "width", "canvas")
141
+ height = _integer(canvas_raw, "height", "canvas")
142
+ if _field(canvas_raw, "coordinateOrigin", "canvas") != "top-left":
143
+ raise ValueError("canvas.coordinateOrigin 必须为 top-left")
144
+ canvas = Rect(0, 0, width, height)
145
+
146
+ bubble_raw = _mapping(_field(root, "speechBubble", "root"), "speechBubble")
147
+ bubble_bounds = _rect(
148
+ _mapping(_field(bubble_raw, "bounds", "speechBubble"), "speechBubble.bounds"),
149
+ "speechBubble.bounds",
150
+ canvas,
151
+ )
152
+ safe_area = _rect(
153
+ _mapping(
154
+ _field(bubble_raw, "safeTextArea", "speechBubble"),
155
+ "speechBubble.safeTextArea",
156
+ ),
157
+ "speechBubble.safeTextArea",
158
+ canvas,
159
+ )
160
+ if not bubble_bounds.contains(safe_area):
161
+ raise ValueError("speechBubble.safeTextArea 必须位于 speechBubble.bounds 内")
162
+
163
+ recommendations = _mapping(
164
+ _field(root, "recommendations", "root"), "recommendations"
165
+ )
166
+ maximum_text_y = _integer(
167
+ recommendations, "maximumTextY", "recommendations", allow_zero=True
168
+ )
169
+ if maximum_text_y > height:
170
+ raise ValueError("recommendations.maximumTextY 不能超过 canvas.height")
171
+
172
+ text_boxes = _mapping(_field(root, "textBoxes", "root"), "textBoxes")
173
+ song_title = _text_box(
174
+ _mapping(_field(text_boxes, "songTitle", "textBoxes"), "textBoxes.songTitle"),
175
+ "textBoxes.songTitle",
176
+ canvas,
177
+ safe_area,
178
+ maximum_text_y,
179
+ )
180
+ artist = _text_box(
181
+ _mapping(_field(text_boxes, "artist", "textBoxes"), "textBoxes.artist"),
182
+ "textBoxes.artist",
183
+ canvas,
184
+ safe_area,
185
+ maximum_text_y,
186
+ )
187
+ return TemplateConfig(width, height, song_title, artist)