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