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**: **Project**:
The persistent state of slicing one song: the source PDF it points at, its cuts, The persistent state of slicing one song: the source PDF it points at, its cuts,
discards, content rectangle, levels, staff-height overrides, markers and discards, content rectangle, levels, staff-height overrides, markers and
metadata. Autosaved beside the PDF; the bundle is generated from it, so any metadata. Autosaved beside the PDF; the bundle is generated from it. One PDF,
export can be regenerated without repeating human work. One PDF, one song, one one song, one project, one bundle — never a many-to-one in any direction.
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 _Avoid_: session, document, edit list
**Bundle**: **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 and metadata. The bundle is *generated* from it, so export is a pure function of
the project file plus the PDF. the project file plus the PDF.
It buys crash safety, resume across sessions (authoring is trickle-in), and It buys crash safety and resume across sessions, since authoring is trickle-in:
**re-export** — change the 1920 cap, fix one cut, or add the SVG renderer later, a session interrupted halfway through a 12-page scan picks up exactly where it
and every song's bundle regenerates without repeating any human work. 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 The project file references the PDF and never contains it; the hash lets the
editor warn if the PDF changed underneath. 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") zf.write(project.source, "original.pdf")
for name, data in zip(names, images): for name, data in zip(names, images):
zf.writestr(name, data, zipfile.ZIP_STORED) 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 return path
+22 -37
View File
@@ -51,32 +51,18 @@ def _detect(args: argparse.Namespace) -> int:
def _project(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) source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path) path = default_path(source.path)
if path.exists() and not args.force: project = open_project(source, resume=args.resume and not args.force)
project = Project.load(path) if project.path is None:
print(f"{path.name}: loaded") print(f"{path.name}: fresh session from detection")
else:
print(f"{path.name}: resumed")
if project.source_changed(): if project.source_changed():
print(" WARNING: the PDF has changed since these cuts were made") 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() kept = project.kept_slices()
for i, page in enumerate(project.pages): for i, page in enumerate(project.pages):
@@ -92,23 +78,14 @@ def _project(args: argparse.Namespace) -> int:
def _export(args: argparse.Namespace) -> int: def _export(args: argparse.Namespace) -> int:
from . import bundle 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) source = open_source(args.pdf, SourceType(args.type) if args.type else None)
path = default_path(source.path) project = open_project(source, resume=args.resume)
if project.path is None:
if path.exists(): print("no unspent project state; exporting straight from detection")
project = Project.load(path) elif project.source_changed():
if project.source_changed():
print("WARNING: the PDF has changed since these cuts were made") 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")
out = Path(args.out) if args.out else source.path.with_suffix(".zip") out = Path(args.out) if args.out else source.path.with_suffix(".zip")
bundle.write(project, source, out) bundle.write(project, source, out)
@@ -122,7 +99,9 @@ def _export(args: argparse.Namespace) -> int:
def _edit(args: argparse.Namespace) -> int: def _edit(args: argparse.Namespace) -> int:
from .editor import launch 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: 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("--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("--force", action="store_true", help="re-detect, discarding existing state")
proj.add_argument( proj.add_argument(
"--refit", "--resume",
action="store_true", 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.add_argument("--type", choices=[t.value for t in SourceType])
proj.set_defaults(func=_project) 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 = sub.add_parser("export", help="render the song and write a bundle")
exp.add_argument("pdf") exp.add_argument("pdf")
exp.add_argument("--out", help="output zip (default: alongside the 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.add_argument("--type", choices=[t.value for t in SourceType])
exp.set_defaults(func=_export) exp.set_defaults(func=_export)
ed = sub.add_parser("edit", help="open the editor") ed = sub.add_parser("edit", help="open the editor")
ed.add_argument("pdf") 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.add_argument("--type", choices=[t.value for t in SourceType])
ed.set_defaults(func=_edit) ed.set_defaults(func=_edit)
+21 -42
View File
@@ -48,7 +48,7 @@ from . import bundle
from .bundle import METADATA_FIELDS from .bundle import METADATA_FIELDS
from .detect import deskew, detect_page from .detect import deskew, detect_page
from .pdf import Source, open_source, page_raster 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 from .render import apply_levels
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
@@ -356,17 +356,10 @@ class Editor(QMainWindow):
discard = QPushButton("Toggle discard (D)") discard = QPushButton("Toggle discard (D)")
discard.clicked.connect(self.view.toggle_discard) discard.clicked.connect(self.view.toggle_discard)
form.addRow(discard) form.addRow(discard)
rect_row = QHBoxLayout() reset = QPushButton("Reset content rectangle")
fit = QPushButton("Auto-fit") reset.setToolTip("Back to the rectangle detection proposed for this page")
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.clicked.connect(self._reset_rect) reset.clicked.connect(self._reset_rect)
for button in (fit, fit_all, reset): form.addRow(reset)
rect_row.addWidget(button)
form.addRow("Content rect", rect_row)
box.addWidget(page_box) box.addWidget(page_box)
meta_box = QGroupBox("Song") meta_box = QGroupBox("Song")
@@ -482,25 +475,16 @@ class Editor(QMainWindow):
} }
self.autosave.start() 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: 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.view.redraw()
self._touched() self._touched()
@@ -524,7 +508,9 @@ class Editor(QMainWindow):
QMessageBox.information( QMessageBox.information(
self, self,
"Exported", "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: def closeEvent(self, event) -> None:
@@ -532,27 +518,20 @@ class Editor(QMainWindow):
super().closeEvent(event) 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]) app = QApplication(sys.argv[:1])
source = open_source(pdf, source_type) source = open_source(pdf, source_type)
path = default_path(source.path)
if path.exists(): # An exported project is spent: this opens a fresh session from detection
project = Project.load(path) # rather than resuming decisions that have already been shipped.
if project.source_changed(): project = open_project(source, resume=resume)
if project.path is not None and project.source_changed():
QMessageBox.warning( QMessageBox.warning(
None, None,
"Source changed", "Source changed",
"The PDF has changed since these cuts were made.\n" "The PDF has changed since these cuts were made.\n"
"Cuts may no longer line up with the music.", "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)
window = Editor(source, project) window = Editor(source, project)
window.resize(1500, 950) window.resize(1500, 950)
+31
View File
@@ -113,6 +113,11 @@ class Project:
levels: tuple[int, int] = (0, 255) levels: tuple[int, int] = (0, 255)
metadata: dict[str, str] = field(default_factory=dict) metadata: dict[str, str] = field(default_factory=dict)
path: Path | None = None 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 ------------------------------------------------- # -- geometry helpers -------------------------------------------------
@@ -184,6 +189,7 @@ class Project:
"v": FORMAT_VERSION, "v": FORMAT_VERSION,
"source": self.source.name, "source": self.source.name,
"source_hash": self.source_hash, "source_hash": self.source_hash,
"exported": self.exported,
"content_rect": list(self.content_rect), "content_rect": list(self.content_rect),
"levels": list(self.levels), "levels": list(self.levels),
"metadata": self.metadata, "metadata": self.metadata,
@@ -229,6 +235,7 @@ class Project:
levels=tuple(data["levels"]), levels=tuple(data["levels"]),
metadata=data.get("metadata", {}), metadata=data.get("metadata", {}),
path=path, path=path,
exported=data.get("exported", False),
) )
def source_changed(self) -> bool: def source_changed(self) -> bool:
@@ -236,6 +243,30 @@ class Project:
return self.source.exists() and hash_file(self.source) != self.source_hash 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: def default_path(source: Path) -> Path:
return Path(source).with_suffix(SUFFIX) return Path(source).with_suffix(SUFFIX)
+6 -2
View File
@@ -85,11 +85,15 @@ def main() -> int:
editor.metadata["composer"].setText("trad.") editor.metadata["composer"].setText("trad.")
assert project.metadata["title"] == "Ketun joululaulu" 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) 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) assert project.page_content_rect(0) == (0.05, 0.02, 0.95, 0.98)
editor._reset_rect() 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. # Autosave target, then a round-trip through disk.
editor._save() editor._save()
+6
View File
@@ -67,6 +67,12 @@ def main() -> int:
assert reloaded.source_hash == project.source_hash assert reloaded.source_hash == project.source_hash
assert not reloaded.source_changed() 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. # A PDF edited underneath must be reported, not silently re-cut.
pdf.write_bytes(b"%PDF-1.7 different bytes entirely") pdf.write_bytes(b"%PDF-1.7 different bytes entirely")
assert reloaded.source_changed() 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 import bundle # noqa: E402
from noteman_slicer.detect import detect_page # 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.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 from noteman_slicer.render import ( # noqa: E402
ALPHA_LEVELS, ALPHA_LEVELS,
apply_levels, apply_levels,
@@ -151,8 +151,9 @@ def main() -> int:
assert all(f in names for f in files) assert all(f in names for f in files)
source.close() source.close()
for f in (pdf, out): # Exporting marks the project spent, which writes the project file.
f.unlink() for f in (pdf, out, default_path(pdf)):
f.unlink(missing_ok=True)
tmp.rmdir() tmp.rmdir()
print("ok") print("ok")
return 0 return 0