Files
noteman-slicer/noteman_slicer/editor.py
T
Esa Kataja b6847a06ee 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.
2026-07-28 23:49:03 +03:00

540 lines
20 KiB
Python

"""The editor: the human-in-the-loop half of the tool.
Detection proposes; everything here is how you dispose (ADR 0004). Cuts can be
authored entirely by hand with detection producing nothing.
Geometry is edited in normalised page coordinates, so what the screen shows and
what the renderer uses are the same numbers at a different zoom.
"""
from __future__ import annotations
import sys
from pathlib import Path
import numpy as np
from PySide6.QtCore import QPointF, QRectF, Qt, QTimer, Signal
from PySide6.QtGui import (
QAction,
QBrush,
QColor,
QImage,
QKeySequence,
QPainter,
QPen,
QPixmap,
QPolygonF,
)
from PySide6.QtWidgets import (
QApplication,
QDoubleSpinBox,
QFileDialog,
QFormLayout,
QGraphicsScene,
QGraphicsView,
QGroupBox,
QHBoxLayout,
QLabel,
QLineEdit,
QMainWindow,
QMessageBox,
QPushButton,
QSlider,
QVBoxLayout,
QWidget,
)
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, open_project
from .render import apply_levels
PREVIEW_MAX = 1800 # display resolution; geometry stays normalised
HIT = 6 # grab distance in screen pixels
AUTOSAVE_MS = 800
_CUT = QColor(220, 40, 40)
_CUT_ACTIVE = QColor(255, 120, 0)
_VERTEX = QColor(255, 200, 0)
_DISCARD = QColor(120, 120, 140, 90)
_RECT = QColor(40, 140, 220)
class PageView(QGraphicsView):
"""Pan, zoom, and direct manipulation of cuts and the content rectangle."""
changed = Signal()
selection_changed = Signal()
def __init__(self) -> None:
super().__init__()
self.setScene(QGraphicsScene(self))
self.setRenderHint(QPainter.Antialiasing)
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.setTransformationAnchor(QGraphicsView.AnchorUnderMouse)
self.project: Project | None = None
self.page_index = 0
self.pixmap: QPixmap | None = None
self.selected_cut: int | None = None
self.selected_slice = 0
self._drag: tuple[str, int, int] | None = None
# -- state ------------------------------------------------------------
def show_page(self, project: Project, index: int, image: np.ndarray) -> None:
self.project = project
self.page_index = index
h, w = image.shape
qimage = QImage(image.data, w, h, w, QImage.Format_Grayscale8).copy()
self.pixmap = QPixmap.fromImage(qimage)
self.selected_cut = None
self.selected_slice = 0
self.scene().setSceneRect(0, 0, w, h)
self.redraw()
self.fitInView(self.scene().sceneRect(), Qt.KeepAspectRatio)
@property
def page(self):
return self.project.pages[self.page_index]
def redraw(self) -> None:
scene = self.scene()
scene.clear()
if self.pixmap is None:
return
scene.addPixmap(self.pixmap)
w, h = self.pixmap.width(), self.pixmap.height()
for slot in range(self.page.slice_count):
if self.page.discards[slot]:
scene.addPolygon(
self._slice_polygon(slot, w, h), QPen(Qt.NoPen), QBrush(_DISCARD)
)
# The selected slice, outlined so trim anomalies are visible.
pen = QPen(QColor(0, 170, 0), 2)
pen.setCosmetic(True)
scene.addPolygon(self._slice_polygon(self.selected_slice, w, h), pen)
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
pen = QPen(_RECT, 2, Qt.DashLine)
pen.setCosmetic(True)
scene.addRect(QRectF(x0 * w, y0 * h, (x1 - x0) * w, (y1 - y0) * h), pen)
for i, cut in enumerate(self.page.cuts):
colour = _CUT_ACTIVE if i == self.selected_cut else _CUT
pen = QPen(colour, 2)
pen.setCosmetic(True)
points = [QPointF(x * w, y * h) for x, y in cut.points]
for a, b in zip(points, points[1:]):
scene.addLine(a.x(), a.y(), b.x(), b.y(), pen)
if i == self.selected_cut:
r = HIT * 1.5 / max(self.transform().m11(), 1e-6)
for p in points:
scene.addEllipse(
p.x() - r, p.y() - r, r * 2, r * 2, QPen(Qt.NoPen), QBrush(_VERTEX)
)
def _slice_polygon(self, slot: int, w: int, h: int) -> QPolygonF:
above, below = self.page.bounds(slot)
top = [(0.0, 0.0), (1.0, 0.0)] if above is None else above.points
bottom = [(0.0, 1.0), (1.0, 1.0)] if below is None else below.points
pts = [QPointF(x * w, y * h) for x, y in top]
pts += [QPointF(x * w, y * h) for x, y in reversed(bottom)]
return QPolygonF(pts)
# -- hit testing ------------------------------------------------------
def _norm(self, pos) -> tuple[float, float]:
p = self.mapToScene(pos)
return p.x() / self.pixmap.width(), p.y() / self.pixmap.height()
def _tolerance(self) -> tuple[float, float]:
scale = max(self.transform().m11(), 1e-6)
return HIT / scale / self.pixmap.width(), HIT / scale / self.pixmap.height()
def _hit_cut(self, x: float, y: float) -> tuple[int, int | None] | None:
"""(cut index, vertex index or None) under the cursor."""
tx, ty = self._tolerance()
for i, cut in enumerate(self.page.cuts):
for v, (vx, vy) in enumerate(cut.points):
if abs(vx - x) <= tx * 2 and abs(vy - y) <= ty * 2:
return i, v
if abs(cut.y_at(x) - y) <= ty:
return i, None
return None
def _hit_rect_edge(self, x: float, y: float) -> str | None:
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
tx, ty = self._tolerance()
if y0 - ty <= y <= y1 + ty:
if abs(x - x0) <= tx:
return "left"
if abs(x - x1) <= tx:
return "right"
if x0 - tx <= x <= x1 + tx:
if abs(y - y0) <= ty:
return "top"
if abs(y - y1) <= ty:
return "bottom"
return None
# -- interaction ------------------------------------------------------
def mousePressEvent(self, event) -> None:
if self.project is None or self.pixmap is None:
return super().mousePressEvent(event)
x, y = self._norm(event.position().toPoint())
if event.button() == Qt.RightButton:
hit = self._hit_cut(x, y)
if hit:
index, vertex = hit
if vertex is not None and len(self.page.cuts[index].points) > 2:
self.page.cuts[index].points.pop(vertex)
else:
self.page.remove_cut(index)
self.selected_cut = None
self.redraw()
self.changed.emit()
return
if event.button() == Qt.LeftButton:
edge = self._hit_rect_edge(x, y)
hit = self._hit_cut(x, y)
if hit and event.modifiers() & Qt.ControlModifier and hit[1] is None:
# Ctrl-click on a cut inserts a vertex: this is how a straight
# cut becomes a stepped one.
cut = self.page.cuts[hit[0]]
at = next(i for i, p in enumerate(cut.points) if p[0] > x)
cut.points.insert(at, (x, cut.y_at(x)))
self.selected_cut = hit[0]
self._drag = ("vertex", hit[0], at)
elif hit:
self.selected_cut = hit[0]
self._drag = ("vertex" if hit[1] is not None else "cut", hit[0], hit[1] or 0)
elif edge:
self._drag = ("rect", 0, 0)
self._edge = edge
else:
self.selected_cut = None
self.selected_slice = self._slice_at(x, y)
self.selection_changed.emit()
self.setDragMode(
QGraphicsView.NoDrag if self._drag else QGraphicsView.ScrollHandDrag
)
self.redraw()
super().mousePressEvent(event)
def mouseMoveEvent(self, event) -> None:
if self._drag and self.pixmap is not None:
x, y = self._norm(event.position().toPoint())
kind, index, vertex = self._drag
if kind == "cut":
cut = self.page.cuts[index]
shift = y - cut.y_at(x)
cut.points = [(px, min(1.0, max(0.0, py + shift))) for px, py in cut.points]
elif kind == "vertex":
cut = self.page.cuts[index]
lo = cut.points[vertex - 1][0] if vertex > 0 else 0.0
hi = cut.points[vertex + 1][0] if vertex + 1 < len(cut.points) else 1.0
px = cut.points[vertex][0] if vertex in (0, len(cut.points) - 1) else min(
max(x, lo), hi
)
cut.points[vertex] = (px, min(1.0, max(0.0, y)))
else:
x0, y0, x1, y1 = self.project.page_content_rect(self.page_index)
x, y = min(max(x, 0.0), 1.0), min(max(y, 0.0), 1.0)
box = {
"left": (x, y0, x1, y1),
"right": (x0, y0, x, y1),
"top": (x0, y, x1, y1),
"bottom": (x0, y0, x1, y),
}[self._edge]
self.project.pages[self.page_index].content_rect = box
self.redraw()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event) -> None:
if self._drag:
self._drag = None
self.setDragMode(QGraphicsView.ScrollHandDrag)
self.page.cuts.sort(key=lambda c: c.points[0][1])
self.changed.emit()
super().mouseReleaseEvent(event)
def mouseDoubleClickEvent(self, event) -> None:
if self.project is None or self.pixmap is None:
return
x, y = self._norm(event.position().toPoint())
if self._hit_cut(x, y) is None:
self.selected_cut = self.page.add_cut(Cut.straight(y))
self.redraw()
self.changed.emit()
def wheelEvent(self, event) -> None:
factor = 1.15 if event.angleDelta().y() > 0 else 1 / 1.15
self.scale(factor, factor)
self.redraw()
def _slice_at(self, x: float, y: float) -> int:
return sum(1 for cut in self.page.cuts if cut.y_at(x) < y)
def toggle_discard(self) -> None:
self.page.discards[self.selected_slice] = not self.page.discards[self.selected_slice]
self.redraw()
self.changed.emit()
class Editor(QMainWindow):
def __init__(self, source: Source, project: Project) -> None:
super().__init__()
self.source = source
self.project = project
self.index = 0
self._raw: dict[int, np.ndarray] = {}
self.setWindowTitle(f"noteman-slicer — {source.path.name}")
self.view = PageView()
self.view.changed.connect(self._touched)
self.view.selection_changed.connect(self._sync)
self.autosave = QTimer(self)
self.autosave.setSingleShot(True)
self.autosave.setInterval(AUTOSAVE_MS)
self.autosave.timeout.connect(self._save)
central = QWidget()
layout = QHBoxLayout(central)
layout.addWidget(self.view, 1)
layout.addWidget(self._panel())
self.setCentralWidget(central)
self._shortcuts()
self._load_page(0)
# -- ui ---------------------------------------------------------------
def _panel(self) -> QWidget:
panel = QWidget()
panel.setFixedWidth(320)
box = QVBoxLayout(panel)
nav = QHBoxLayout()
self.page_label = QLabel()
prev, nxt = QPushButton("◀"), QPushButton("▶")
prev.clicked.connect(lambda: self._load_page(self.index - 1))
nxt.clicked.connect(lambda: self._load_page(self.index + 1))
nav.addWidget(prev)
nav.addWidget(self.page_label, 1)
nav.addWidget(nxt)
box.addLayout(nav)
page_box = QGroupBox("Page")
form = QFormLayout(page_box)
self.skew = QDoubleSpinBox()
self.skew.setRange(-15.0, 15.0)
self.skew.setSingleStep(0.1)
self.skew.setDecimals(2)
self.skew.setSuffix("°")
self.skew.valueChanged.connect(self._skew_changed)
form.addRow("Skew", self.skew)
self.black = QSlider(Qt.Horizontal)
self.black.setRange(0, 255)
self.white = QSlider(Qt.Horizontal)
self.white.setRange(0, 255)
self.white.setValue(255)
for s in (self.black, self.white):
s.valueChanged.connect(self._levels_changed)
form.addRow("Black point", self.black)
form.addRow("White point", self.white)
discard = QPushButton("Toggle discard (D)")
discard.clicked.connect(self.view.toggle_discard)
form.addRow(discard)
reset = QPushButton("Reset content rectangle")
reset.setToolTip("Back to the rectangle detection proposed for this page")
reset.clicked.connect(self._reset_rect)
form.addRow(reset)
box.addWidget(page_box)
meta_box = QGroupBox("Song")
meta_form = QFormLayout(meta_box)
self.metadata: dict[str, QLineEdit] = {}
for field in METADATA_FIELDS:
edit = QLineEdit(self.project.metadata.get(field, ""))
edit.textChanged.connect(self._metadata_changed)
self.metadata[field] = edit
meta_form.addRow(field.replace("_", " ").title(), edit)
box.addWidget(meta_box)
self.summary = QLabel()
self.summary.setWordWrap(True)
box.addWidget(self.summary)
export = QPushButton("Export bundle…")
export.clicked.connect(self._export)
box.addWidget(export)
box.addStretch(1)
help_text = QLabel(
"Double-click: add cut\n"
"Drag: move cut · Ctrl-click: add vertex\n"
"Right-click: delete cut or vertex\n"
"Click a slice, then D to discard\n"
"Drag the blue edges: content rectangle"
)
help_text.setStyleSheet("color: palette(mid);")
box.addWidget(help_text)
return panel
def _shortcuts(self) -> None:
for key, slot in (
(QKeySequence("D"), self.view.toggle_discard),
(QKeySequence(Qt.Key_PageDown), lambda: self._load_page(self.index + 1)),
(QKeySequence(Qt.Key_PageUp), lambda: self._load_page(self.index - 1)),
(QKeySequence.Save, self._save),
):
action = QAction(self)
action.setShortcut(key)
action.triggered.connect(slot)
self.addAction(action)
# -- page handling ----------------------------------------------------
def _raster(self, index: int) -> np.ndarray:
"""Page pixels at preview resolution, cached — the PDF is slow to read."""
if index not in self._raw:
import cv2
gray = page_raster(self.source, index)
if gray.shape[1] > PREVIEW_MAX:
k = PREVIEW_MAX / gray.shape[1]
gray = cv2.resize(gray, None, fx=k, fy=k, interpolation=cv2.INTER_AREA)
self._raw[index] = gray
return self._raw[index]
def _preview(self, index: int) -> np.ndarray:
page = self.project.pages[index]
black, white = self.project.page_levels(index)
return np.ascontiguousarray(
apply_levels(deskew(self._raster(index), page.skew), black, white)
)
def _load_page(self, index: int) -> None:
if not 0 <= index < len(self.project.pages):
return
self.index = index
self.view.show_page(self.project, index, self._preview(index))
self._sync()
def _sync(self) -> None:
page = self.project.pages[self.index]
self.page_label.setText(f"Page {self.index + 1} / {len(self.project.pages)}")
for widget, value in ((self.skew, page.skew),):
widget.blockSignals(True)
widget.setValue(value)
widget.blockSignals(False)
black, white = self.project.page_levels(self.index)
for widget, value in ((self.black, black), (self.white, white)):
widget.blockSignals(True)
widget.setValue(value)
widget.blockSignals(False)
kept = len(self.project.kept_slices())
total = sum(p.slice_count for p in self.project.pages)
state = "discarded" if page.discards[self.view.selected_slice] else "kept"
self.summary.setText(
f"{page.slice_count} slices on this page · slice "
f"{self.view.selected_slice + 1} is {state}\n"
f"{kept} of {total} slices kept in the song"
)
# -- edits ------------------------------------------------------------
def _touched(self) -> None:
self._sync()
self.autosave.start()
def _skew_changed(self, value: float) -> None:
self.project.pages[self.index].skew = value
self.view.show_page(self.project, self.index, self._preview(self.index))
self._touched()
def _levels_changed(self) -> None:
self.project.pages[self.index].levels = (self.black.value(), self.white.value())
self.view.show_page(self.project, self.index, self._preview(self.index))
self._touched()
def _metadata_changed(self) -> None:
self.project.metadata = {
field: edit.text().strip() for field, edit in self.metadata.items() if edit.text().strip()
}
self.autosave.start()
def _reset_rect(self) -> 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()
def _save(self) -> None:
path = self.project.save()
self.statusBar().showMessage(f"saved {path.name}", 2000)
def _export(self) -> None:
self._save()
target, _ = QFileDialog.getSaveFileName(
self, "Export bundle", str(self.source.path.with_suffix(".zip")), "Bundle (*.zip)"
)
if not target:
return
try:
out = bundle.write(self.project, self.source, Path(target))
except Exception as error: # noqa: BLE001 - surfaced to the user
QMessageBox.critical(self, "Export failed", str(error))
return
size = out.stat().st_size / 1024
QMessageBox.information(
self,
"Exported",
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:
self._save()
super().closeEvent(event)
def launch(pdf: Path, source_type=None, resume: bool = False) -> int:
app = QApplication(sys.argv[:1])
source = open_source(pdf, source_type)
# 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)
window.show()
return app.exec()