Treat a project as spent once its song has been exported

Opening an exported song starts a fresh session from detection instead
of resuming: cuts, discards and metadata do not carry over, so a re-cut
never inherits decisions that have already shipped. --resume overrides
it on edit, export and project.

This reverses what was agreed in planning and written into docs/spec.md
and CONTEXT.md, which promised resume-across-sessions and re-export.
Both are corrected. The cost is deliberate and worth stating: changing
the width cap or adding the SVG renderer later now means re-cutting each
song by hand rather than regenerating every bundle from its project
file.

Export records the flag in bundle.write, so no caller can forget it.

Also removed --refit and the Auto-fit buttons, which were added without
being asked for and whose only purpose - migrating projects made before
the content rectangle was proposed - disappears once exported projects
start fresh. Reset now restores detection's proposal rather than the
whole page: clearing to full width would undo the thing the rectangle
exists for, so one button covers it.

open_project() replaces four copies of load-or-detect across the CLI
and the editor.
This commit is contained in:
Esa Kataja
2026-07-28 23:49:03 +03:00
parent 54b8e37657
commit b6847a06ee
9 changed files with 122 additions and 97 deletions
+6 -3
View File
@@ -51,9 +51,12 @@ two repos.
**Project**:
The persistent state of slicing one song: the source PDF it points at, its cuts,
discards, content rectangle, levels, staff-height overrides, markers and
metadata. Autosaved beside the PDF; the bundle is generated from it, so any
export can be regenerated without repeating human work. One PDF, one song, one
project, one bundle — never a many-to-one in any direction.
metadata. Autosaved beside the PDF; the bundle is generated from it. One PDF,
one song, one project, one bundle — never a many-to-one in any direction.
**Spent** once its song has been exported: opening the PDF again begins a fresh
session from detection rather than resuming, so a re-cut never inherits
decisions that have already shipped.
_Avoid_: session, document, edit list
**Bundle**:
+13 -3
View File
@@ -263,9 +263,19 @@ discards, content rectangle, skew angles, levels, staff-height overrides, marker
and metadata. The bundle is *generated* from it, so export is a pure function of
the project file plus the PDF.
It buys crash safety, resume across sessions (authoring is trickle-in), and
**re-export** — change the 1920 cap, fix one cut, or add the SVG renderer later,
and every song's bundle regenerates without repeating any human work.
It buys crash safety and resume across sessions, since authoring is trickle-in:
a session interrupted halfway through a 12-page scan picks up exactly where it
stopped.
**A project is spent once its song has been exported.** Export records that in
the file, and opening the PDF again starts a *fresh session from detection*
rather than resuming. A re-cut therefore never inherits decisions that have
already shipped. `--resume` overrides it on the `edit`, `export` and `project`
commands when the old state really is wanted.
The cost is deliberate: re-export is no longer free. Changing the width cap or
adding the SVG renderer later means re-cutting each song by hand rather than
regenerating every bundle from its project file.
The project file references the PDF and never contains it; the hash lets the
editor warn if the PDF changed underneath.
+6
View File
@@ -62,4 +62,10 @@ def write(project: Project, source: Source, path: Path) -> Path:
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
+23 -38
View File
@@ -51,32 +51,18 @@ def _detect(args: argparse.Namespace) -> int:
def _project(args: argparse.Namespace) -> int:
from .project import Project, default_path
from .project import default_path, open_project
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path)
if path.exists() and not args.force:
project = Project.load(path)
print(f"{path.name}: loaded")
project = open_project(source, resume=args.resume and not args.force)
if project.path is None:
print(f"{path.name}: fresh session from detection")
else:
print(f"{path.name}: resumed")
if project.source_changed():
print(" WARNING: the PDF has changed since these cuts were made")
if args.refit:
# Re-propose the content rectangle without disturbing cuts,
# discards or metadata — for projects made before detection
# proposed one, or whose rectangle was dragged wrong.
for i in range(len(project.pages)):
gray = page_raster(source, i)
project.pages[i].content_rect = detect_page(gray).content
print(f" content rectangle re-fitted on {len(project.pages)} pages")
else:
detections, heights = [], []
for i in range(len(source)):
gray = page_raster(source, i)
detections.append(detect_page(gray))
heights.append(gray.shape[0])
project = Project.from_detection(source.path, detections, heights)
print(f"{path.name}: created from detection")
kept = project.kept_slices()
for i, page in enumerate(project.pages):
@@ -92,23 +78,14 @@ def _project(args: argparse.Namespace) -> int:
def _export(args: argparse.Namespace) -> int:
from . import bundle
from .project import Project, default_path
from .project import open_project
source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path)
if path.exists():
project = Project.load(path)
if project.source_changed():
print("WARNING: the PDF has changed since these cuts were made")
else:
detections, heights = [], []
for i in range(len(source)):
gray = page_raster(source, i)
detections.append(detect_page(gray))
heights.append(gray.shape[0])
project = Project.from_detection(source.path, detections, heights)
print("no project file; exporting straight from detection")
project = open_project(source, resume=args.resume)
if project.path is None:
print("no unspent project state; exporting straight from detection")
elif project.source_changed():
print("WARNING: the PDF has changed since these cuts were made")
out = Path(args.out) if args.out else source.path.with_suffix(".zip")
bundle.write(project, source, out)
@@ -122,7 +99,9 @@ def _export(args: argparse.Namespace) -> int:
def _edit(args: argparse.Namespace) -> int:
from .editor import launch
return launch(Path(args.pdf), SourceType(args.type) if args.type else None)
return launch(
Path(args.pdf), SourceType(args.type) if args.type else None, resume=args.resume
)
def main(argv: list[str] | None = None) -> int:
@@ -154,9 +133,9 @@ def main(argv: list[str] | None = None) -> int:
proj.add_argument("--save", action="store_true", help="write the project file")
proj.add_argument("--force", action="store_true", help="re-detect, discarding existing state")
proj.add_argument(
"--refit",
"--resume",
action="store_true",
help="re-propose the content rectangle, keeping cuts and metadata",
help="reopen an already-exported project instead of starting fresh",
)
proj.add_argument("--type", choices=[t.value for t in SourceType])
proj.set_defaults(func=_project)
@@ -164,11 +143,17 @@ def main(argv: list[str] | None = None) -> int:
exp = sub.add_parser("export", help="render the song and write a bundle")
exp.add_argument("pdf")
exp.add_argument("--out", help="output zip (default: alongside the PDF)")
exp.add_argument("--resume", action="store_true", help="use already-exported project state")
exp.add_argument("--type", choices=[t.value for t in SourceType])
exp.set_defaults(func=_export)
ed = sub.add_parser("edit", help="open the editor")
ed.add_argument("pdf")
ed.add_argument(
"--resume",
action="store_true",
help="reopen an already-exported project instead of starting fresh",
)
ed.add_argument("--type", choices=[t.value for t in SourceType])
ed.set_defaults(func=_edit)
+27 -48
View File
@@ -48,7 +48,7 @@ from . import bundle
from .bundle import METADATA_FIELDS
from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster
from .project import Cut, Project, default_path
from .project import Cut, Project, open_project
from .render import apply_levels
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
@@ -356,17 +356,10 @@ class Editor(QMainWindow):
discard = QPushButton("Toggle discard (D)")
discard.clicked.connect(self.view.toggle_discard)
form.addRow(discard)
rect_row = QHBoxLayout()
fit = QPushButton("Auto-fit")
fit.setToolTip("Propose the content rectangle from the staff lines on this page")
fit.clicked.connect(lambda: self._autofit_rect(False))
fit_all = QPushButton("Auto-fit all")
fit_all.clicked.connect(lambda: self._autofit_rect(True))
reset = QPushButton("Reset")
reset = QPushButton("Reset content rectangle")
reset.setToolTip("Back to the rectangle detection proposed for this page")
reset.clicked.connect(self._reset_rect)
for button in (fit, fit_all, reset):
rect_row.addWidget(button)
form.addRow("Content rect", rect_row)
form.addRow(reset)
box.addWidget(page_box)
meta_box = QGroupBox("Song")
@@ -482,25 +475,16 @@ class Editor(QMainWindow):
}
self.autosave.start()
def _autofit_rect(self, every_page: bool) -> None:
"""Re-propose the content rectangle from the staff lines.
Detection does this for new projects; this is how a project made before
it existed, or one whose rectangle was dragged wrong, gets it back.
"""
pages = range(len(self.project.pages)) if every_page else [self.index]
for i in pages:
# The preview is already deskewed, so the sweep is skipped.
proposal = detect_page(self._preview(i), skew=0.0)
self.project.pages[i].content_rect = proposal.content
self.view.redraw()
self._touched()
self.statusBar().showMessage(
f"content rectangle fitted on {len(list(pages))} page(s)", 2000
)
def _reset_rect(self) -> None:
self.project.pages[self.index].content_rect = None
"""Back to what detection proposed for this page.
Not to the whole page: the proposal is what excludes the scan-edge
junk, so clearing to full width would undo the thing the rectangle
exists for. The preview is already deskewed, so the sweep is skipped.
"""
self.project.pages[self.index].content_rect = detect_page(
self._preview(self.index), skew=0.0
).content
self.view.redraw()
self._touched()
@@ -524,7 +508,9 @@ class Editor(QMainWindow):
QMessageBox.information(
self,
"Exported",
f"{out.name}\n{len(self.project.kept_slices())} slices, {size:.0f} KB",
f"{out.name}\n{len(self.project.kept_slices())} slices, {size:.0f} KB\n\n"
"This project is now spent — opening the PDF again starts a fresh "
"session from detection.",
)
def closeEvent(self, event) -> None:
@@ -532,27 +518,20 @@ class Editor(QMainWindow):
super().closeEvent(event)
def launch(pdf: Path, source_type=None) -> int:
def launch(pdf: Path, source_type=None, resume: bool = False) -> int:
app = QApplication(sys.argv[:1])
source = open_source(pdf, source_type)
path = default_path(source.path)
if path.exists():
project = Project.load(path)
if project.source_changed():
QMessageBox.warning(
None,
"Source changed",
"The PDF has changed since these cuts were made.\n"
"Cuts may no longer line up with the music.",
)
else:
detections, heights = [], []
for i in range(len(source)):
gray = page_raster(source, i)
detections.append(detect_page(gray))
heights.append(gray.shape[0])
project = Project.from_detection(source.path, detections, heights)
# An exported project is spent: this opens a fresh session from detection
# rather than resuming decisions that have already been shipped.
project = open_project(source, resume=resume)
if project.path is not None and project.source_changed():
QMessageBox.warning(
None,
"Source changed",
"The PDF has changed since these cuts were made.\n"
"Cuts may no longer line up with the music.",
)
window = Editor(source, project)
window.resize(1500, 950)
+31
View File
@@ -113,6 +113,11 @@ class Project:
levels: tuple[int, int] = (0, 255)
metadata: dict[str, str] = field(default_factory=dict)
path: Path | None = None
# Set once the song has been exported. A project is spent at that point:
# opening the PDF again starts a fresh session from detection rather than
# resuming, so a re-cut never begins from stale decisions. `--resume`
# overrides it when the old state really is wanted.
exported: bool = False
# -- geometry helpers -------------------------------------------------
@@ -184,6 +189,7 @@ class Project:
"v": FORMAT_VERSION,
"source": self.source.name,
"source_hash": self.source_hash,
"exported": self.exported,
"content_rect": list(self.content_rect),
"levels": list(self.levels),
"metadata": self.metadata,
@@ -229,6 +235,7 @@ class Project:
levels=tuple(data["levels"]),
metadata=data.get("metadata", {}),
path=path,
exported=data.get("exported", False),
)
def source_changed(self) -> bool:
@@ -236,6 +243,30 @@ class Project:
return self.source.exists() and hash_file(self.source) != self.source_hash
def open_project(source, *, resume: bool = False) -> Project:
"""The project for a PDF: resumed, or a fresh session from detection.
A project that has been exported is spent. Opening the PDF again starts
over from detection rather than resuming, so a re-cut never inherits stale
decisions. `resume` overrides that when the old state really is wanted.
"""
from .detect import detect_page
from .pdf import page_raster
path = default_path(source.path)
if path.exists():
existing = Project.load(path)
if resume or not existing.exported:
return existing
detections, heights = [], []
for i in range(len(source)):
gray = page_raster(source, i)
detections.append(detect_page(gray))
heights.append(gray.shape[0])
return Project.from_detection(source.path, detections, heights)
def default_path(source: Path) -> Path:
return Path(source).with_suffix(SUFFIX)
+6 -2
View File
@@ -85,11 +85,15 @@ def main() -> int:
editor.metadata["composer"].setText("trad.")
assert project.metadata["title"] == "Ketun joululaulu"
# Content rectangle edits and reset.
# Content rectangle edits, and reset going back to detection's proposal for
# the page as it now stands — not to the whole page, which would undo the
# thing the rectangle exists for.
expected = detect_page(editor._preview(0), skew=0.0).content
page.content_rect = (0.05, 0.02, 0.95, 0.98)
assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98)
editor._reset_rect()
assert project.page_content_rect(0) == project.content_rect
assert project.page_content_rect(0) == expected, (project.page_content_rect(0), expected)
assert project.page_content_rect(0) != (0.0, 0.0, 1.0, 1.0)
# Autosave target, then a round-trip through disk.
editor._save()
+6
View File
@@ -67,6 +67,12 @@ def main() -> int:
assert reloaded.source_hash == project.source_hash
assert not reloaded.source_changed()
# A project is spent once exported: reopening starts fresh.
assert not reloaded.exported
reloaded.exported = True
reloaded.save()
assert Project.load(saved).exported
# A PDF edited underneath must be reported, not silently re-cut.
pdf.write_bytes(b"%PDF-1.7 different bytes entirely")
assert reloaded.source_changed()
+4 -3
View File
@@ -16,7 +16,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from noteman_slicer import bundle # noqa: E402
from noteman_slicer.detect import detect_page # noqa: E402
from noteman_slicer.pdf import open_source, page_raster # noqa: E402
from noteman_slicer.project import Cut, Project # noqa: E402
from noteman_slicer.project import Cut, Project, default_path # noqa: E402
from noteman_slicer.render import ( # noqa: E402
ALPHA_LEVELS,
apply_levels,
@@ -151,8 +151,9 @@ def main() -> int:
assert all(f in names for f in files)
source.close()
for f in (pdf, out):
f.unlink()
# Exporting marks the project spent, which writes the project file.
for f in (pdf, out, default_path(pdf)):
f.unlink(missing_ok=True)
tmp.rmdir()
print("ok")
return 0