A replaced slice shipped as pixels only, so the LilyPond behind it died with the project file — and a project is spent once exported. Correcting one wrong note meant retyping the system. Slices gain an optional `engraving` object: the language, key, time and one entry per voice holding the notes and lyrics verbatim. Structured per staff rather than one blob of source, because that is what both a later edit and a MIDI render want; song-level key and time are resolved per slice so reading one needs no context from its neighbours. Additive and ignorable, so the format stays at version 1.
158 lines
5.5 KiB
Python
158 lines
5.5 KiB
Python
"""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",
|
|
"tempo",
|
|
"voices",
|
|
)
|
|
|
|
# Beats per minute, exported as a JSON number. A figure is worth more than a
|
|
# word here: "Andante" cannot drive a metronome and two people will not agree
|
|
# what it means.
|
|
NUMERIC_FIELDS = frozenset({"tempo"})
|
|
|
|
|
|
def _engraving(project: Project, page: int, slot: int) -> dict | None:
|
|
"""The notation behind a re-engraved slice, or None for a scanned one.
|
|
|
|
The slice image stays the presentation; this is the notation it was made
|
|
from, carried so the music can be edited again or turned into sound. Key
|
|
and time are resolved against the song defaults here — a consumer reading
|
|
one slice should not have to know what the rest of the song inherited.
|
|
"""
|
|
replacement = project.pages[page].replacements[slot]
|
|
if not replacement or not replacement.voices:
|
|
return None
|
|
return {
|
|
"lang": "lilypond",
|
|
"key": replacement.key or project.key,
|
|
"time": replacement.time or project.time,
|
|
"print_time": replacement.print_time,
|
|
"voices": [
|
|
{"clef": v.clef, "notes": v.notes.strip()}
|
|
| ({"lyrics": v.lyrics.strip()} if v.lyrics.strip() else {})
|
|
for v in replacement.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) or "").strip()
|
|
if not value:
|
|
continue
|
|
if field in NUMERIC_FIELDS:
|
|
try:
|
|
payload[field] = int(value)
|
|
except ValueError:
|
|
continue # not a number, so not worth exporting as one
|
|
else:
|
|
payload[field] = value
|
|
|
|
kept = project.kept_slices()
|
|
# Markers reference slices by (page, slot) while editing, because that is
|
|
# what survives adding and removing cuts. In the bundle they become the
|
|
# array index, which is the only cross-reference the format has.
|
|
index_of = {position: i for i, position in enumerate(kept)}
|
|
|
|
slices: list[dict] = []
|
|
for name, (page, slot) in zip(files, kept):
|
|
entry: dict = {"file": name}
|
|
engraving = _engraving(project, page, slot)
|
|
if engraving:
|
|
entry["engraving"] = engraving
|
|
markers = []
|
|
for marker in project.pages[page].markers[slot]:
|
|
item: dict = {"type": marker.type}
|
|
if marker.label:
|
|
item["label"] = marker.label
|
|
if marker.destination is not None:
|
|
target = index_of.get(tuple(marker.destination))
|
|
# A jump whose target was discarded or re-cut away is dropped
|
|
# rather than exported dangling: noteman would have nothing to
|
|
# resolve it to.
|
|
if target is None:
|
|
continue
|
|
item["destination"] = target
|
|
markers.append(item)
|
|
if markers:
|
|
entry["markers"] = markers
|
|
slices.append(entry)
|
|
|
|
payload["slices"] = slices
|
|
return payload
|
|
|
|
|
|
def write(project: Project, source: Source, path: Path) -> Path:
|
|
"""Render the song and write the bundle. Returns the zip path.
|
|
|
|
A title is required; every other metadata field is optional. noteman's own
|
|
rule is that a song needs a title and at least one slice, and a bundle that
|
|
cannot become a song is not worth writing.
|
|
"""
|
|
if not project.metadata.get("title", "").strip():
|
|
raise ValueError("a title is required before a song can be exported")
|
|
|
|
images = render_song(project, source)
|
|
if not images:
|
|
raise ValueError("no slices to export — every slice is discarded")
|
|
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():
|
|
pdf = project.source.read_bytes()
|
|
if project.optimise_pdf:
|
|
import pymupdf
|
|
|
|
from .pdfopt import optimise
|
|
|
|
shrunk, _ = optimise(pymupdf.open(project.source), len(pdf))
|
|
pdf = shrunk or pdf # empty means it found no saving
|
|
zf.writestr("original.pdf", pdf, zipfile.ZIP_STORED)
|
|
for name, data in zip(names, images):
|
|
zf.writestr(name, data, zipfile.ZIP_STORED)
|
|
|
|
# The project is spent once its song has been exported: the next edit
|
|
# session starts fresh from detection rather than resuming these decisions.
|
|
# Recorded here so no caller can forget it.
|
|
project.exported = True
|
|
project.save()
|
|
return path
|