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:
+6
-3
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user