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
+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)