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.
@@ -0,0 +1,130 @@
1
+ """Invoke the Node Remotion batch renderer and map its JSON-line results."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import subprocess
7
+ import tempfile
8
+ from collections.abc import Callable, Sequence
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Literal
12
+
13
+ from mpup.metadata import REMOTION_ENTRY
14
+ from mpup.render_job import RenderJob
15
+
16
+
17
+ RENDERER_SCRIPT = REMOTION_ENTRY.parents[1] / "render-batch.mjs"
18
+ Runner = Callable[..., subprocess.CompletedProcess[str]]
19
+ RenderStatus = Literal["success", "failed"]
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class RenderRequest:
24
+ job: RenderJob
25
+ output_path: Path
26
+
27
+
28
+ @dataclass(frozen=True)
29
+ class RenderOutcome:
30
+ job_id: str
31
+ status: RenderStatus
32
+ output_path: Path
33
+ error: str | None = None
34
+
35
+
36
+ def _bounded(value: object) -> str:
37
+ text = str(value).strip()
38
+ return text[-2000:]
39
+
40
+
41
+ def run_remotion_batch(
42
+ requests: Sequence[RenderRequest],
43
+ public_dir: Path,
44
+ *,
45
+ node: str = "node",
46
+ renderer_script: Path = RENDERER_SCRIPT,
47
+ composition_entry: Path = REMOTION_ENTRY,
48
+ runner: Runner = subprocess.run,
49
+ ) -> tuple[RenderOutcome, ...]:
50
+ """Render one manifest with Node and preserve the input request order."""
51
+ if not requests:
52
+ return ()
53
+
54
+ public_dir.parent.mkdir(parents=True, exist_ok=True)
55
+ manifest_path: Path | None = None
56
+ try:
57
+ manifest = {
58
+ "entryPoint": str(composition_entry.resolve()),
59
+ "publicDir": str(public_dir.resolve()),
60
+ "jobs": [
61
+ {
62
+ "props": request.job.to_dict(),
63
+ "outputPath": str(request.output_path.resolve()),
64
+ }
65
+ for request in requests
66
+ ],
67
+ }
68
+ with tempfile.NamedTemporaryFile(
69
+ mode="w",
70
+ encoding="utf-8",
71
+ dir=public_dir.parent,
72
+ prefix="manifest-",
73
+ suffix=".json",
74
+ delete=False,
75
+ ) as temporary:
76
+ manifest_path = Path(temporary.name)
77
+ json.dump(manifest, temporary, ensure_ascii=False, separators=(",", ":"))
78
+
79
+ command = [node, str(renderer_script), str(manifest_path)]
80
+ result = runner(command, capture_output=True, text=True, check=False)
81
+ if result.returncode != 0:
82
+ detail = _bounded(result.stderr) or "Node 渲染进程异常退出"
83
+ raise RuntimeError(f"Remotion 批次渲染失败:{detail}")
84
+
85
+ expected = {request.job.job_id: request for request in requests}
86
+ parsed: dict[str, RenderOutcome] = {}
87
+ for line in result.stdout.splitlines():
88
+ if not line.strip():
89
+ continue
90
+ try:
91
+ value = json.loads(line)
92
+ except json.JSONDecodeError as error:
93
+ raise RuntimeError("Remotion 返回了无效 JSON 行") from error
94
+ if not isinstance(value, dict):
95
+ raise RuntimeError("Remotion 返回了无效 JSON 结果")
96
+ job_id = value.get("jobId")
97
+ status = value.get("status")
98
+ if (
99
+ not isinstance(job_id, str)
100
+ or job_id not in expected
101
+ or job_id in parsed
102
+ or status not in ("success", "failed")
103
+ ):
104
+ raise RuntimeError("Remotion 返回了未知、重复或无效的歌曲结果")
105
+ request = expected[job_id]
106
+ error_text = None
107
+ if status == "failed":
108
+ error_text = _bounded(value.get("error") or "Remotion 渲染失败")
109
+ parsed[job_id] = RenderOutcome(
110
+ job_id,
111
+ status,
112
+ request.output_path,
113
+ error_text,
114
+ )
115
+
116
+ outcomes: list[RenderOutcome] = []
117
+ for request in requests:
118
+ outcome = parsed.get(request.job.job_id)
119
+ if outcome is None:
120
+ outcome = RenderOutcome(
121
+ request.job.job_id,
122
+ "failed",
123
+ request.output_path,
124
+ "Remotion 未返回该歌曲的渲染结果",
125
+ )
126
+ outcomes.append(outcome)
127
+ return tuple(outcomes)
128
+ finally:
129
+ if manifest_path is not None:
130
+ manifest_path.unlink(missing_ok=True)
mpup/render_job.py ADDED
@@ -0,0 +1,118 @@
1
+ """Shared JSON contract for Python staging and the Remotion composition."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+ from pathlib import PurePosixPath
8
+ from typing import Any
9
+
10
+ from mpup.lyrics import LyricCue
11
+
12
+
13
+ _JOB_ID = re.compile(r"^[A-Za-z0-9_-]+$")
14
+ _FIELDS = {
15
+ "jobId",
16
+ "audioPath",
17
+ "backgroundPath",
18
+ "coverPath",
19
+ "fontPath",
20
+ "title",
21
+ "artist",
22
+ "durationMs",
23
+ "lyrics",
24
+ }
25
+
26
+
27
+ def _public_path(field: str, value: object, job_id: str) -> str:
28
+ if not isinstance(value, str) or not value or "\\" in value:
29
+ raise ValueError(f"{field} 必须是安全的 public 相对路径")
30
+ path = PurePosixPath(value)
31
+ if path.is_absolute() or any(part in (".", "..") for part in path.parts):
32
+ raise ValueError(f"{field} 必须是安全的 public 相对路径")
33
+ if len(path.parts) < 3 or path.parts[:2] != ("jobs", job_id):
34
+ raise ValueError(f"{field} 必须位于 jobs/{job_id}/ 内")
35
+ return value
36
+
37
+
38
+ def _required_text(field: str, value: object) -> str:
39
+ if not isinstance(value, str) or not value.strip():
40
+ raise ValueError(f"{field} 不能为空")
41
+ return value
42
+
43
+
44
+ def _cue(value: object) -> LyricCue:
45
+ if not isinstance(value, dict) or set(value) != {
46
+ "text",
47
+ "startMs",
48
+ "endMs",
49
+ "timestampMs",
50
+ "confidence",
51
+ }:
52
+ raise ValueError("lyrics cue 字段无效")
53
+ text = _required_text("lyrics.text", value["text"])
54
+ start = value["startMs"]
55
+ end = value["endMs"]
56
+ if type(start) is not int or type(end) is not int or start < 0 or end <= start:
57
+ raise ValueError("lyrics cue 时间无效")
58
+ if value["timestampMs"] is not None or value["confidence"] is not None:
59
+ raise ValueError("lyrics cue 可空字段无效")
60
+ return LyricCue(text, start, end)
61
+
62
+
63
+ @dataclass(frozen=True)
64
+ class RenderJob:
65
+ job_id: str
66
+ audio_path: str
67
+ background_path: str
68
+ cover_path: str | None
69
+ font_path: str
70
+ title: str
71
+ artist: str
72
+ duration_ms: int
73
+ lyrics: tuple[LyricCue, ...] | None
74
+
75
+ @classmethod
76
+ def from_dict(cls, payload: object) -> RenderJob:
77
+ if not isinstance(payload, dict) or set(payload) != _FIELDS:
78
+ raise ValueError("RenderJob JSON 字段不完整或包含未知字段")
79
+ job_id = _required_text("jobId", payload["jobId"])
80
+ if _JOB_ID.fullmatch(job_id) is None:
81
+ raise ValueError("jobId 只能包含字母、数字、下划线和连字符")
82
+ duration = payload["durationMs"]
83
+ if type(duration) is not int or duration <= 0:
84
+ raise ValueError("durationMs 必须是正整数")
85
+ cover_value = payload["coverPath"]
86
+ if cover_value is not None:
87
+ cover_value = _public_path("coverPath", cover_value, job_id)
88
+ lyrics_value = payload["lyrics"]
89
+ if lyrics_value is not None and not isinstance(lyrics_value, list):
90
+ raise ValueError("lyrics 必须是数组或 null")
91
+ return cls(
92
+ job_id=job_id,
93
+ audio_path=_public_path("audioPath", payload["audioPath"], job_id),
94
+ background_path=_public_path(
95
+ "backgroundPath", payload["backgroundPath"], job_id
96
+ ),
97
+ cover_path=cover_value,
98
+ font_path=_public_path("fontPath", payload["fontPath"], job_id),
99
+ title=_required_text("title", payload["title"]),
100
+ artist=_required_text("artist", payload["artist"]),
101
+ duration_ms=duration,
102
+ lyrics=None if lyrics_value is None else tuple(_cue(item) for item in lyrics_value),
103
+ )
104
+
105
+ def to_dict(self) -> dict[str, Any]:
106
+ return {
107
+ "jobId": self.job_id,
108
+ "audioPath": self.audio_path,
109
+ "backgroundPath": self.background_path,
110
+ "coverPath": self.cover_path,
111
+ "fontPath": self.font_path,
112
+ "title": self.title,
113
+ "artist": self.artist,
114
+ "durationMs": self.duration_ms,
115
+ "lyrics": None
116
+ if self.lyrics is None
117
+ else [cue.as_caption() for cue in self.lyrics],
118
+ }
mpup/video.py ADDED
@@ -0,0 +1,160 @@
1
+ """Validate and publish Remotion-generated song videos."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import math
7
+ import os
8
+ import subprocess
9
+ import tempfile
10
+ from collections.abc import Callable
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+
15
+ Runner = Callable[..., subprocess.CompletedProcess[str]]
16
+
17
+
18
+ def build_faststart_command(
19
+ rendered_path: Path,
20
+ output_path: Path,
21
+ ffmpeg: str = "ffmpeg",
22
+ ) -> list[str]:
23
+ """Build a stream-copy remux command that moves MP4 metadata to the front."""
24
+ return [
25
+ ffmpeg,
26
+ "-hide_banner",
27
+ "-loglevel",
28
+ "error",
29
+ "-y",
30
+ "-i",
31
+ str(rendered_path),
32
+ "-map",
33
+ "0",
34
+ "-c",
35
+ "copy",
36
+ "-movflags",
37
+ "+faststart",
38
+ str(output_path),
39
+ ]
40
+
41
+
42
+ def _probe_payload(
43
+ path: Path,
44
+ ffprobe: str,
45
+ runner: Runner,
46
+ ) -> dict[str, Any]:
47
+ command = [
48
+ ffprobe,
49
+ "-v",
50
+ "error",
51
+ "-show_entries",
52
+ "stream=codec_type,codec_name,width,height,pix_fmt,duration",
53
+ "-of",
54
+ "json",
55
+ str(path),
56
+ ]
57
+ result = runner(command, capture_output=True, text=True, check=False)
58
+ if result.returncode != 0:
59
+ detail = result.stderr.strip() or "未知错误"
60
+ raise RuntimeError(f"输出验证失败:{detail[-2000:]}")
61
+ try:
62
+ payload = json.loads(result.stdout)
63
+ except (json.JSONDecodeError, TypeError) as error:
64
+ raise RuntimeError("输出验证失败:FFprobe 返回无效 JSON") from error
65
+ if not isinstance(payload, dict):
66
+ raise RuntimeError("输出验证失败:FFprobe 返回无效 JSON 对象")
67
+ return payload
68
+
69
+
70
+ def _stream_duration(stream: dict[str, Any], kind: str) -> float:
71
+ try:
72
+ duration = float(stream.get("duration"))
73
+ except (TypeError, ValueError) as error:
74
+ raise RuntimeError(f"输出验证失败:{kind}流缺少有效时长") from error
75
+ if not math.isfinite(duration) or duration <= 0:
76
+ raise RuntimeError(f"输出验证失败:{kind}流缺少有效时长")
77
+ return duration
78
+
79
+
80
+ def validate_video(
81
+ path: Path,
82
+ ffprobe: str = "ffprobe",
83
+ runner: Runner = subprocess.run,
84
+ ) -> None:
85
+ """Require a compatible 1080P H.264/AAC MP4."""
86
+ payload = _probe_payload(path, ffprobe, runner)
87
+ raw_streams = payload.get("streams", [])
88
+ streams = raw_streams if isinstance(raw_streams, list) else []
89
+ video = next(
90
+ (
91
+ stream
92
+ for stream in streams
93
+ if isinstance(stream, dict) and stream.get("codec_type") == "video"
94
+ ),
95
+ None,
96
+ )
97
+ audio = next(
98
+ (
99
+ stream
100
+ for stream in streams
101
+ if isinstance(stream, dict) and stream.get("codec_type") == "audio"
102
+ ),
103
+ None,
104
+ )
105
+ if video is None:
106
+ raise RuntimeError("输出验证失败:缺少 H.264 视频流")
107
+ if video.get("codec_name") != "h264":
108
+ raise RuntimeError("输出验证失败:视频必须使用 H.264")
109
+ if video.get("width") != 1920 or video.get("height") != 1080:
110
+ raise RuntimeError("输出验证失败:视频尺寸必须为 1920x1080")
111
+ if video.get("pix_fmt") != "yuv420p":
112
+ raise RuntimeError(
113
+ "输出验证失败:像素格式必须为 yuv420p,"
114
+ f"实际为 {video.get('pix_fmt')!r}"
115
+ )
116
+ if audio is None or audio.get("codec_name") != "aac":
117
+ raise RuntimeError("输出验证失败:音频必须使用 AAC")
118
+
119
+ video_duration = _stream_duration(video, "视频")
120
+ audio_duration = _stream_duration(audio, "音频")
121
+ if abs(video_duration - audio_duration) > 0.1:
122
+ raise RuntimeError("输出验证失败:音画时长差必须不超过 0.1 秒")
123
+
124
+
125
+ def publish_remotion_video(
126
+ rendered_path: Path,
127
+ output_path: Path,
128
+ ffmpeg: str,
129
+ ffprobe: str,
130
+ overwrite: bool = False,
131
+ runner: Runner = subprocess.run,
132
+ ) -> None:
133
+ """Fast-start remux, validate, and atomically publish a Remotion MP4."""
134
+ if output_path.exists() and not overwrite:
135
+ raise FileExistsError(f"视频已存在:{output_path}")
136
+ if not rendered_path.is_file():
137
+ raise RuntimeError(f"Remotion 未生成临时视频:{rendered_path}")
138
+ output_path.parent.mkdir(parents=True, exist_ok=True)
139
+ temporary = tempfile.NamedTemporaryFile(
140
+ prefix=f".{output_path.stem}.",
141
+ suffix=".tmp.mp4",
142
+ dir=output_path.parent,
143
+ delete=False,
144
+ )
145
+ temporary_path = Path(temporary.name)
146
+ temporary.close()
147
+ try:
148
+ result = runner(
149
+ build_faststart_command(rendered_path, temporary_path, ffmpeg),
150
+ capture_output=True,
151
+ text=True,
152
+ check=False,
153
+ )
154
+ if result.returncode != 0:
155
+ detail = result.stderr.strip() or "未知错误"
156
+ raise RuntimeError(f"FFmpeg fast-start 封装失败:{detail[-2000:]}")
157
+ validate_video(temporary_path, ffprobe, runner)
158
+ os.replace(temporary_path, output_path)
159
+ finally:
160
+ temporary_path.unlink(missing_ok=True)
mpup/workspace.py ADDED
@@ -0,0 +1,92 @@
1
+ """Lifecycle-managed public asset workspace for a Remotion batch."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import shutil
7
+ import tempfile
8
+ from collections.abc import Callable, Sequence
9
+ from pathlib import Path
10
+
11
+ from mpup.lyrics import LyricCue
12
+ from mpup.metadata import AudioMetadata
13
+ from mpup.render_job import RenderJob
14
+
15
+
16
+ Copier = Callable[[Path, Path], object]
17
+
18
+
19
+ class BatchWorkspace:
20
+ """Stage per-song assets and remove the whole temporary batch on exit."""
21
+
22
+ def __init__(self, output_dir: Path, *, copier: Copier = shutil.copyfile) -> None:
23
+ self._output_dir = output_dir
24
+ self._copier = copier
25
+ self._temporary: tempfile.TemporaryDirectory[str] | None = None
26
+
27
+ def __enter__(self) -> BatchWorkspace:
28
+ self._output_dir.parent.mkdir(parents=True, exist_ok=True)
29
+ self._temporary = tempfile.TemporaryDirectory(
30
+ prefix=".mpup-v2-",
31
+ dir=self._output_dir.parent,
32
+ )
33
+ self.root = Path(self._temporary.name)
34
+ self.public_dir = self.root / "public"
35
+ (self.public_dir / "jobs").mkdir(parents=True)
36
+ return self
37
+
38
+ def __exit__(self, *_error: object) -> None:
39
+ if self._temporary is not None:
40
+ self._temporary.cleanup()
41
+ self._temporary = None
42
+
43
+ def stage_job(
44
+ self,
45
+ job_id: str,
46
+ audio_path: Path,
47
+ background_path: Path,
48
+ cover_path: Path | None,
49
+ font_path: Path,
50
+ metadata: AudioMetadata,
51
+ lyrics: Sequence[LyricCue] | None,
52
+ ) -> RenderJob:
53
+ """Copy one song's assets into an isolated job and write its JSON props."""
54
+ if self._temporary is None:
55
+ raise RuntimeError("BatchWorkspace 必须在 with 语句中使用")
56
+
57
+ job_dir = self.public_dir / "jobs" / job_id
58
+ try:
59
+ job_dir.mkdir(parents=False)
60
+ audio_name = f"audio{audio_path.suffix.lower()}"
61
+ background_name = f"background{background_path.suffix.lower()}"
62
+ font_name = f"font{font_path.suffix.lower()}"
63
+ self._copier(audio_path, job_dir / audio_name)
64
+ self._copier(background_path, job_dir / background_name)
65
+ staged_cover: str | None = None
66
+ if cover_path is not None:
67
+ self._copier(cover_path, job_dir / "cover.png")
68
+ staged_cover = f"jobs/{job_id}/cover.png"
69
+ self._copier(font_path, job_dir / font_name)
70
+
71
+ job = RenderJob(
72
+ job_id=job_id,
73
+ audio_path=f"jobs/{job_id}/{audio_name}",
74
+ background_path=f"jobs/{job_id}/{background_name}",
75
+ cover_path=staged_cover,
76
+ font_path=f"jobs/{job_id}/{font_name}",
77
+ title=metadata.title or "",
78
+ artist=metadata.artist or "",
79
+ duration_ms=round(metadata.duration * 1000),
80
+ lyrics=None if lyrics is None else tuple(lyrics),
81
+ )
82
+ # Reuse the deserializer as the single Python-side contract validator.
83
+ job = RenderJob.from_dict(job.to_dict())
84
+ (job_dir / "job.json").write_text(
85
+ json.dumps(job.to_dict(), ensure_ascii=False, separators=(",", ":")),
86
+ encoding="utf-8",
87
+ )
88
+ return job
89
+ except BaseException:
90
+ if job_dir.exists():
91
+ shutil.rmtree(job_dir)
92
+ raise
@@ -0,0 +1,135 @@
1
+ Metadata-Version: 2.5
2
+ Name: mpup
3
+ Version: 0.1.0
4
+ Summary: Generate song background images and MP4 videos from audio metadata
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: pillow<13,>=11
7
+ Requires-Dist: tqdm<5,>=4.67
8
+ Description-Content-Type: text/markdown
9
+
10
+ # MPUp
11
+
12
+ MPUp 从 MP3/M4A 文件头读取歌名和歌手,解析封面与同步歌词,先在原音频目录写入带元数据的 `<歌名>-<歌手>.mp3`,再用该 MP3 作为 Remotion 的视频音轨。旧版 `*-background.png` 仅作为可复用的中间产物,视频成功发布后会删除。
13
+
14
+ ## 环境与安装
15
+
16
+ - Python 3.11 或更高版本
17
+ - [uv](https://docs.astral.sh/uv/)
18
+ - Node.js 22 或更高版本与 npm
19
+ - FFmpeg 与 FFprobe(需要 H.264/AAC 支持)
20
+ - 带中文字符的 TrueType/OpenType 字体;也可通过 `--font` 显式指定
21
+
22
+ ```bash
23
+ uv sync
24
+ cd renderer
25
+ npm ci
26
+ cd ..
27
+ ```
28
+
29
+ 所有 `remotion` 与 `@remotion/*` 包都锁定为同一版本。首次渲染会准备 Remotion 使用的 Chromium,下载体积和首次启动时间会明显高于后续批次;缓存位于项目 `.remotion/`。
30
+
31
+ ## 使用
32
+
33
+ ```bash
34
+ uv run mpup assets \
35
+ --background assets/background.png \
36
+ --config assets/bg-cfg.json \
37
+ --output-dir output
38
+
39
+ uv run mpup assets/春.m4a --output-dir output
40
+ ```
41
+
42
+ 完整参数:
43
+
44
+ ```text
45
+ mpup INPUT
46
+ [--background PATH]
47
+ [--config PATH]
48
+ [--output-dir PATH]
49
+ [--font PATH]
50
+ [--overwrite]
51
+ ```
52
+
53
+ 默认背景为 `assets/background.png`,默认布局为 `assets/bg-cfg.json`,默认输出目录为 `output`。增强 MP3 和背景 PNG 已存在时会直接复用;背景 PNG 会在对应视频成功发布后删除。已有最终视频不会被覆盖,需要重新生成视频时添加 `--overwrite`。
54
+
55
+ ## 音频元数据
56
+
57
+ 每个音频必须在 MP3 ID3 或 M4A 容器标签中同时包含 `title`(歌名)和 `artist`(歌手)。MPUp 不会根据文件名猜测;缺少字段的歌曲会被跳过,其余歌曲继续。支持 `.mp3` 和 `.m4a`,目录模式只扫描直接子文件并稳定排序。
58
+
59
+ 命令行会在缺少 FFmpeg、FFprobe、Node.js 或 Remotion 渲染入口时于批次开始前报错。
60
+
61
+ ## V2 画面布局
62
+
63
+ - 画布固定为 1920×1080、30 fps;默认背景铺满全帧。
64
+ - 有封面时保持原比例,优先占满 880 px 高度。宽图可跨过半屏,但最大宽度为 1280 px(总画面三分之二),不会裁切或拉伸。
65
+ - 右侧内容固定在 x=1080–1800。歌名位于上方,歌手在其右下方轻微错位;遮罩保证封面跨中线时文字仍可读。
66
+ - 歌词窗口固定在 y=300–840,当前句居中高亮,相邻句降低透明度,切句约用 0.35 秒逐帧移动。
67
+ - 没有同步歌词时布局不移动,只在歌词窗口中央显示“暂无歌词”。
68
+
69
+ ## 封面来源与 iTunes 回退
70
+
71
+ 封面按以下固定顺序解析,并写入生成 MP3 的 ID3 attached-picture:
72
+
73
+ 1. 音频 attached-picture 内嵌封面;
74
+ 2. 现有 `search_track(title, artist)` iTunes 查询返回的 600×600 artwork;
75
+ 3. 默认背景,不生成空白封面卡片。
76
+
77
+ 同一批次会按规范化歌名/歌手缓存查询,并按 artwork URL 缓存下载。iTunes 查询、限流重试最终失败、下载超过 10 MiB 或图片无法完整解码时,只输出警告并回退默认背景,不会把歌曲标记为失败。内嵌封面有效时不会访问 iTunes。
78
+
79
+ iTunes 接口遇到 HTTP `429 Too Many Requests` 时最多重试 3 次,按 1、2、4 秒退避。详见 [Apple iTunes Search API 文档](https://performance-partners.apple.com/resources/documentation/itunes-store-web-service-search-api/)。
80
+
81
+ ## 同步歌词
82
+
83
+ 歌词按“同目录同主名 `.lrc`/`.LRC`、音频内嵌歌词、可注入远程 Provider”解析。默认 Provider 是空实现,不访问歌词网络服务。解析到同步歌词后会写入生成 MP3 的 `syncedlyrics` 标签;没有同步歌词时不会写入该标签。
84
+
85
+ LRC 支持 `[mm:ss]`、两位/三位小数、一行多个时间戳和 `[offset:+/-毫秒]`;本地文件依次支持 UTF-8、UTF-8 BOM 与 GB18030。无时间戳纯文本不会被伪造成同步歌词。最后一句最长显示 6 秒且不会超过音频结尾。
86
+
87
+ ## 输出与媒体规格
88
+
89
+ 输入 `assets/春.m4a`(歌名“春”、歌手“黄龄”)会生成:
90
+
91
+ ```text
92
+ assets/春-黄龄.mp3
93
+ output/春-黄龄.mp4
94
+ ```
95
+
96
+ 渲染期间会生成 `output/春-黄龄-background.png` 作为可复用的中间文件;MP4 成功发布后会自动删除,渲染或发布失败时则保留,以便下次重试。
97
+
98
+ 背景 PNG 继续使用 `bg-cfg.json` 的旧版布局。V2 MP4 直接读取默认背景、封面、字体、歌词 props 和带元数据的 MP3。
99
+
100
+ - H.264(CRF 20)
101
+ - AAC
102
+ - 1920×1080,30 fps,`yuv420p`
103
+ - MP4 fast-start
104
+
105
+ Remotion 先生成临时 MP4,随后 FFmpeg 使用 `-c copy -movflags +faststart` 封装,不重复编码。FFprobe 验证尺寸、编码、像素格式及不超过 0.1 秒的音画时长差后原子发布。失败或中断时会清理批次 public 工作集和临时媒体。
106
+
107
+ ## 批处理结果和退出码
108
+
109
+ ```text
110
+ 完成 assets/春.m4a -> assets/春-黄龄.mp3 -> output/春-黄龄.mp4(封面=itunes,歌词=local)
111
+ 警告 assets/啊.mp3:iTunes 封面查询失败:429 Too Many Requests
112
+ 完成:5,跳过:3,失败:0
113
+ ```
114
+
115
+ - `0`:全部歌曲成功
116
+ - `1`:至少一首歌曲跳过或失败,其余歌曲已继续
117
+ - `2`:输入、配置、字体或工具错误导致批次无法开始
118
+ - `130`:用户中断
119
+
120
+ ## 测试
121
+
122
+ ```bash
123
+ uv run -m unittest discover -s tests -v
124
+ cd renderer
125
+ npm test
126
+ npm run typecheck
127
+ ```
128
+
129
+ 真实集成测试会合成“内嵌封面 + 同名 LRC”“无封面 + 无歌词”“超宽内嵌封面”三类音频,批量生成 MP4,并校验关键帧布局、歌词切句、编解码、尺寸、像素格式、音画时长和临时文件清理。
130
+
131
+ ## 字体、许可与内容权利
132
+
133
+ 渲染会把选定字体暂存到每首歌曲的隔离目录,并在加载完成后测量文字。要获得可重复画面,请在不同机器显式传入同一字体文件。
134
+
135
+ Remotion 使用自己的双层许可;商用前请根据组织类型和规模核对锁定版本附带的 `renderer/node_modules/remotion/LICENSE.md` 以及 [Remotion License](https://www.remotion.dev/license)。使用者还需自行确认音频、歌词、封面、iTunes artwork 和字体的下载、复制、合成与发布权利。
@@ -0,0 +1,21 @@
1
+ mpup/__init__.py,sha256=o77gKDJG6GeysLgRFdS9JV9y-XY4WmU72DXyz6G5kc4,71
2
+ mpup/__main__.py,sha256=Z3hULgGc5tGvs1YzlSaftKGDumxDxFzxbkz0smro088,78
3
+ mpup/background.py,sha256=oS4kmr02aaDFtTnDSVX096NcXsYvbmiGOLiBZSuvhq4,4211
4
+ mpup/cli.py,sha256=RufEqPEB4KROBavcWXK6VP4fE52YRI67wpElruyxPZ4,5269
5
+ mpup/config.py,sha256=xmQSyAtiJxaSPyGZSHpHUGGITmQF32EjNLTdLDVSzyM,5877
6
+ mpup/cover.py,sha256=eUsKTX6wvEAoyRzJ8L7vCGKyNk0PKRqaRdhsk1FTJ3w,8111
7
+ mpup/discovery.py,sha256=qkxaqUlkiPZPh_RSqjKwCyqDNQ1sYd3FuV3Gu9Wq1iw,1121
8
+ mpup/enriched_audio.py,sha256=OtBrGqzbzTB9SlOSWK5tCqcDovJFH1PbQ6dlOQZt8U0,4529
9
+ mpup/itunes.py,sha256=wtb2iw8cw9jqEj0SwIsp1q5tPvEkAa6cqtFTykF2JJo,3458
10
+ mpup/lyrics.py,sha256=NsBUPippA-b7IBCVyXjKs1_oODzk0JrHyHtWGRGx_yQ,6319
11
+ mpup/lyrics_from_qq.py,sha256=s588DukOCPsZr5pQfiJYqNKlCpXSjkKYTnIp91fWprM,3093
12
+ mpup/metadata.py,sha256=kz49S5HNHDTe_3fEdRKAxue1jqfbt9ZAUdIacDZu8Ao,5114
13
+ mpup/pipeline.py,sha256=gda8EhMstzAiBb79cfKh-L2gEKq5T_Mlew_ETLFDVwE,11239
14
+ mpup/remotion_bridge.py,sha256=pZK73CweMRHOXmHNhPzKKdLPHbNCO4Ouu6CSIIaFJd8,4296
15
+ mpup/render_job.py,sha256=ACEX1IiNbF7ERWNFDJzy04N2-uhvvL4-Yrn2dhYsymE,4138
16
+ mpup/video.py,sha256=9lj-Xnj7JqgQMPfjQronoIfYEWrxloLYIze-2hOOj0k,5086
17
+ mpup/workspace.py,sha256=ubMVUCSxXAvkjky02rW_SunCxwoUMaxvQY8Try7wlLc,3418
18
+ mpup-0.1.0.dist-info/METADATA,sha256=JKdP5lH6XBtcX_S8ycPi-Wt0q7CbKmJMG06xYDQvMKM,6357
19
+ mpup-0.1.0.dist-info/WHEEL,sha256=zOwg4jB6zX2kU910N-cMawjivD6tO8NEWvE12je1bVk,87
20
+ mpup-0.1.0.dist-info/entry_points.txt,sha256=kVBcRx8pJdkJ3wDExZPb-Lpu4laRKVqvDdUkGRCp9XU,45
21
+ mpup-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.32.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mpup = mpup.cli:entrypoint