Add the render pipeline and bundle export
Project state plus PDF in, finished slice images out. Slices are cut as polygons rather than row ranges, so a stepped cut yields a slice with a transparent notch instead of one that covers its neighbour. Masking paints white, which the ink-to-alpha step turns into full transparency — the same outcome the spec asks for, one step earlier. Scale normalises every slice to the median staff height before fitting the song to 1920px, so a rescanned page sits at the same note size as its neighbours. The cap only ever shrinks: a song narrower than 1920 stays narrower. Alpha quantisation rounds to 16 values spanning 0-255 inclusive. Flooring, as first written, capped full ink at 240 and left every note 6% transparent — caught by decoding an exported slice rather than by reading the code. Ketun joululaulu exports 24 slices at a uniform 1489px, under the cap and correctly not upscaled from its 200 DPI source; Feliz Navidad 20; Elaman nalka 18. Closes #21 Closes #22 Closes #23 Closes #24 Closes #25 Closes #27
This commit is contained in:
@@ -0,0 +1,65 @@
|
||||
"""Bundle export — the only channel to noteman (ADR 0001).
|
||||
|
||||
song.zip
|
||||
song.json
|
||||
original.pdf
|
||||
001.webp 002.webp …
|
||||
|
||||
Array order in `song.json` *is* slice order: one ordering, not two. Markers
|
||||
nest inside the slice they sit on, so an index appears in exactly one place —
|
||||
a jump source's `destination`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from .pdf import Source
|
||||
from .project import Project
|
||||
from .render import render_song
|
||||
|
||||
FORMAT_VERSION = 1
|
||||
METADATA_FIELDS = (
|
||||
"title",
|
||||
"subtitle",
|
||||
"composer",
|
||||
"original_artist",
|
||||
"arranger",
|
||||
"lyricist",
|
||||
"translator",
|
||||
"voices",
|
||||
)
|
||||
|
||||
|
||||
def song_json(project: Project, files: list[str]) -> dict:
|
||||
payload: dict = {"v": FORMAT_VERSION}
|
||||
for field in METADATA_FIELDS:
|
||||
value = project.metadata.get(field)
|
||||
if value:
|
||||
payload[field] = value
|
||||
payload["slices"] = [{"file": name} for name in files]
|
||||
return payload
|
||||
|
||||
|
||||
def write(project: Project, source: Source, path: Path) -> Path:
|
||||
"""Render the song and write the bundle. Returns the zip path."""
|
||||
images = render_song(project, source)
|
||||
names = [f"{i + 1:03}.webp" for i in range(len(images))]
|
||||
|
||||
path = Path(path)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
# ZIP_STORED for the images: WebP is already compressed, so deflating it
|
||||
# only costs time. The JSON is small enough not to care.
|
||||
with zipfile.ZipFile(path, "w") as zf:
|
||||
zf.writestr(
|
||||
"song.json",
|
||||
json.dumps(song_json(project, names), indent=2, ensure_ascii=False),
|
||||
zipfile.ZIP_DEFLATED,
|
||||
)
|
||||
if project.source.exists():
|
||||
zf.write(project.source, "original.pdf")
|
||||
for name, data in zip(names, images):
|
||||
zf.writestr(name, data, zipfile.ZIP_STORED)
|
||||
return path
|
||||
Reference in New Issue
Block a user