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 +3 -0
- mpup/__main__.py +5 -0
- mpup/background.py +122 -0
- mpup/cli.py +159 -0
- mpup/config.py +187 -0
- mpup/cover.py +244 -0
- mpup/discovery.py +33 -0
- mpup/enriched_audio.py +147 -0
- mpup/itunes.py +112 -0
- mpup/lyrics.py +190 -0
- mpup/lyrics_from_qq.py +46 -0
- mpup/metadata.py +159 -0
- mpup/pipeline.py +305 -0
- mpup/remotion_bridge.py +130 -0
- mpup/render_job.py +118 -0
- mpup/video.py +160 -0
- mpup/workspace.py +92 -0
- mpup-0.1.0.dist-info/METADATA +135 -0
- mpup-0.1.0.dist-info/RECORD +21 -0
- mpup-0.1.0.dist-info/WHEEL +4 -0
- mpup-0.1.0.dist-info/entry_points.txt +2 -0
mpup/__init__.py
ADDED
mpup/__main__.py
ADDED
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)
|