The bundle is documented as a standalone format rather than as a note between two programs: producers and consumers are generic, the marker vocabulary is defined musically rather than by what a viewer does with it, and image properties are stated as guarantees with the reasoning where it is not obvious. Anything that reads scores can implement it without knowing this tool exists. Taking that view changed the substance in three places. Marker types now carry their musical meaning rather than a UI mapping. The rule against re-importing became a statement about identity - indices mean something only within one bundle, so two bundles of a piece are independent documents. And forward-compatibility rules were added, which a protocol needs and a handover note did not: ignore unknown fields and marker types, refuse an unknown version. Tempo is now an integer, beats per minute, exported as a JSON number and omitted when blank; the editor accepts digits only. A figure can drive a metronome or a click track where a verbal marking cannot, and readers do not agree on what Andante means. This needs the consumer's column changed from free-form text, which the format document flags. Every figure in the document comes from a real export.
123 lines
4.1 KiB
Python
123 lines
4.1 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 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}
|
|
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():
|
|
zf.write(project.source, "original.pdf")
|
|
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
|