From 854674934e0ca5da94f8f138a360d196e3466636 Mon Sep 17 00:00:00 2001 From: Eric Levy Date: Tue, 14 Jul 2026 15:08:17 -0400 Subject: [PATCH 1/2] Add PDF review report File > Export Review PDF... (Ctrl+Alt+P) renders every annotated frame with its notes into a single document: a cover identifying the shot, source, frame rate and note count with a contents list, then one page per annotated frame showing the burned-in frame above the notes that go with it. Written with Qt QPdfWriter, so there is no new dependency and the text is real embedded text rather than pixels baked into the image: the delivered file stays selectable and searchable. Pinned notes are numbered to match the markers drawn on the frame above them, so a note can be traced back to the exact spot it refers to. widgets/pdfreport.py takes plain page dicts, not a player or a reader, so a report can be laid out and verified without decoding anything. Notes are given the room they need and the frame takes what is left: a report whose text is cut off is worse than one whose image is a little smaller. A frame that fails to decode still gets its page, because the note has to reach the recipient even when the picture does not. The tests read the written PDF back with pypdf and assert on the extracted text, so they check what a recipient actually gets rather than what the builder believed it drew. Also fixes the test environment, which could not render text at all: Qt no longer ships fonts and the offscreen platform plugin finds none by itself, so QFontDatabase came back EMPTY and every drawText() silently drew nothing. No glyphs in a rendered widget, no text operators in a generated PDF. Any test asserting on drawn text failed for reasons unrelated to the code. The suite now registers a system font, and CI installs fonts-dejavu-core, so text rendering is exercised for real on both platforms. 11 tests, 249 on the branch. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01TexnzYbmCjjTDB8zzZuUPb --- .github/workflows/ci.yml | 7 +- tests/conftest.py | 39 +++++ tests/test_pdf_report.py | 231 +++++++++++++++++++++++++++ widgets/__init__.py | 149 ++++++++++++++++++ widgets/pdfreport.py | 331 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 756 insertions(+), 1 deletion(-) create mode 100644 tests/test_pdf_report.py create mode 100644 widgets/pdfreport.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9d8bbd..83a741c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,7 +40,12 @@ jobs: run: | sudo apt-get update sudo apt-get install -y --no-install-recommends \ - libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 libpulse0 + libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \ + fonts-dejavu-core + # Qt no longer ships fonts. Without a font on the box, QFontDatabase + # is empty and every drawText() silently draws nothing, so tests that + # assert on rendered text or on text inside a generated PDF fail for + # reasons unrelated to the code. libpulse0 - name: Install Python dependencies run: | python -m pip install --upgrade pip diff --git a/tests/conftest.py b/tests/conftest.py index 63d38b9..791c68e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -31,6 +31,36 @@ import pytest +# Qt no longer ships fonts, and the offscreen platform plugin finds none on its +# own: QFontDatabase.families() comes back EMPTY. Every drawText() then silently +# draws nothing -- no glyphs in a rendered widget, no text operators in a +# generated PDF -- so any test asserting on drawn text fails for reasons that +# have nothing to do with the code under test. Register a real font so text +# rendering is exercised for real. +_FONT_CANDIDATES = ( + r"C:\Windows\Fonts\arial.ttf", + r"C:\Windows\Fonts\segoeui.ttf", + "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Regular.ttf", + "/System/Library/Fonts/Helvetica.ttc", +) + + +def _ensure_font(): + """Give Qt at least one usable font family. Returns True when it has one.""" + from PySide6.QtGui import QFontDatabase + + if QFontDatabase.families(): + return True + + for candidate in _FONT_CANDIDATES: + if os.path.exists(candidate): + QFontDatabase.addApplicationFont(candidate) + if QFontDatabase.families(): + return True + + return bool(QFontDatabase.families()) + @pytest.fixture(scope="session") def qapp(): @@ -38,5 +68,14 @@ def qapp(): from PySide6.QtWidgets import QApplication app = QApplication.instance() or QApplication([]) + _ensure_font() yield app # Do not call app.quit(): a session-wide instance is reused across tests. + + +@pytest.fixture(scope="session") +def qfont(qapp): + """Skip a text-rendering test when the environment has no font at all.""" + if not _ensure_font(): + pytest.skip("no font available to Qt; text cannot be rendered") + return True diff --git a/tests/test_pdf_report.py b/tests/test_pdf_report.py new file mode 100644 index 0000000..cf68f0e --- /dev/null +++ b/tests/test_pdf_report.py @@ -0,0 +1,231 @@ +"""Tests for the PDF review report. + +The report is verified by reading the written PDF back with pypdf, so the +assertions are about what a recipient actually gets -- not about what the +builder believed it drew. +""" + +import re + +import pytest + +from PySide6.QtGui import QColor, QImage + +import constants + +from widgets import pdfreport + + +def _image(width=320, height=180, color=(200, 40, 40)): + image = QImage(width, height, QImage.Format.Format_RGB888) + image.fill(QColor(*color)) + return image + + +def _page(frame, comments=None, strokes=0, timecode="00:00:01:00"): + return { + "frame": frame, + "timecode": timecode, + "image": _image(), + "comments": comments or [], + "stroke_count": strokes, + } + + +def _comment(text, done=False, pinned=False): + comment = {"id": text, "text": text, "done": done, "timestamp": "now"} + if pinned: + comment["x"] = 0.5 + comment["y"] = 0.5 + return comment + + +def _read(path): + """Return (reader, text) with whitespace normalized. + + Qt positions each word with its own text-showing operator, so pypdf renders + the inter-word gaps as tabs rather than spaces. That is a property of how the + glyphs are laid out, not of the words themselves, so the runs are collapsed + before asserting on them. + """ + from pypdf import PdfReader + + reader = PdfReader(str(path)) + raw = "\n".join(page.extract_text() or "" for page in reader.pages) + return reader, re.sub(r"\s+", " ", raw) + + +# --------------------------------------------------------------------------- # +# Structure +# --------------------------------------------------------------------------- # +def test_report_has_a_cover_plus_one_page_per_frame(tmp_path, qapp): + path = tmp_path / "review.pdf" + + written = pdfreport.build_report( + path, + [_page(10), _page(25), _page(41)], + meta={"shot": "KP_010_020_comp_v003"}, + ) + + assert written == 4 # cover + 3 frames + + reader, _text = _read(path) + assert len(reader.pages) == 4 + + +def test_a_report_with_no_pages_is_still_a_valid_pdf(tmp_path, qapp): + path = tmp_path / "empty.pdf" + + assert pdfreport.build_report(path, [], meta={"shot": "empty"}) == 1 + + reader, _text = _read(path) + assert len(reader.pages) == 1 + + +def test_the_pdf_carries_a_title(tmp_path, qapp): + path = tmp_path / "titled.pdf" + pdfreport.build_report(path, [_page(1)], meta={"shot": "KP_010_020"}) + + reader, _text = _read(path) + + assert "KP_010_020" in (reader.metadata.title or "") + assert constants.VL_TOOL_NAME in (reader.metadata.title or "") + + +# --------------------------------------------------------------------------- # +# Content that a recipient actually reads +# --------------------------------------------------------------------------- # +def test_comments_survive_into_the_pdf_text(tmp_path, qapp, qfont): + path = tmp_path / "notes.pdf" + + pdfreport.build_report( + path, + [ + _page(12, comments=[_comment("warm the key light")]), + _page(30, comments=[_comment("soften the matte edge")]), + ], + meta={"shot": "KP_010_020"}, + ) + + _reader, text = _read(path) + + # Text is drawn with embedded fonts rather than baked into the image, so it + # stays selectable and searchable in the delivered file. + assert "warm the key light" in text + assert "soften the matte edge" in text + + +def test_the_cover_identifies_the_shot_and_counts_the_notes(tmp_path, qapp, qfont): + path = tmp_path / "cover.pdf" + + pdfreport.build_report( + path, + [ + _page(12, comments=[_comment("one"), _comment("two")]), + _page(30, comments=[_comment("three")]), + ], + meta={"shot": "KP_010_020_comp_v003", "source": "/show/kp/plate.exr", + "fps": "24"}, + ) + + _reader, text = _read(path) + + assert "KP_010_020_comp_v003" in text + assert "/show/kp/plate.exr" in text + assert "Annotated frames" in text + assert "Comments" in text + + +def test_frames_are_labelled_with_number_and_timecode(tmp_path, qapp, qfont): + path = tmp_path / "frames.pdf" + + pdfreport.build_report( + path, + [_page(42, timecode="00:00:01:18", comments=[_comment("check this")])], + meta={"shot": "shot"}, + ) + + _reader, text = _read(path) + + assert "0042" in text # zero-padded to the project's frame padding + assert "00:00:01:18" in text + + +def test_resolved_comments_are_marked(tmp_path, qapp, qfont): + path = tmp_path / "resolved.pdf" + + pdfreport.build_report( + path, + [_page(5, comments=[_comment("fixed already", done=True)])], + meta={"shot": "shot"}, + ) + + _reader, text = _read(path) + + assert "fixed already" in text + assert "resolved" in text + + +def test_pinned_comments_are_numbered_to_match_their_markers(tmp_path, qapp, qfont): + path = tmp_path / "pins.pdf" + + pdfreport.build_report( + path, + [ + _page( + 7, + comments=[ + _comment("frame level note"), + _comment("first pin", pinned=True), + _comment("second pin", pinned=True), + ], + ) + ], + meta={"shot": "shot"}, + ) + + _reader, text = _read(path) + + # The numbers must line up with the pins drawn on the frame above, or a note + # cannot be traced back to the spot it refers to. + assert "1." in text + assert "2." in text + assert "first pin" in text + assert "second pin" in text + + +def test_a_frame_with_only_drawings_still_gets_a_page(tmp_path, qapp, qfont): + path = tmp_path / "drawings.pdf" + + written = pdfreport.build_report( + path, [_page(9, strokes=3)], meta={"shot": "shot"} + ) + + assert written == 2 + + _reader, text = _read(path) + assert "3 drawn note(s)" in text # summarized on the cover + + +def test_a_missing_image_does_not_break_the_page(tmp_path, qapp, qfont): + path = tmp_path / "noimage.pdf" + + page = _page(3, comments=[_comment("the frame failed to decode")]) + page["image"] = None + + written = pdfreport.build_report(path, [page], meta={"shot": "shot"}) + + assert written == 2 + + _reader, text = _read(path) + # The note still has to reach the recipient even if the frame did not. + assert "the frame failed to decode" in text + + +def test_an_unwritable_path_raises_rather_than_failing_silently(tmp_path, qapp): + with pytest.raises(OSError): + pdfreport.build_report( + tmp_path / "no such directory" / "out.pdf", + [_page(1)], + meta={"shot": "shot"}, + ) diff --git a/widgets/__init__.py b/widgets/__init__.py index e0c8e48..fddafb6 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -26,6 +26,7 @@ import os import json +import datetime import utils import logger @@ -40,6 +41,8 @@ from playback import stats as statsmath from utils import notescsv +from widgets import pdfreport + from PySide6 import QtGui from PySide6 import QtCore from PySide6 import QtWidgets @@ -445,6 +448,11 @@ def setup_review_chrome(self): self.actionExportNotes.setIcon(NamePixmapIcon("recaps")) self.actionExportNotes.triggered.connect(self.export_notes) file_menu.addAction(self.actionExportNotes) + self.actionExportPdf = QtGui.QAction("Export Review PDF...", self) + self.actionExportPdf.setIcon(NamePixmapIcon("export")) + self.actionExportPdf.setShortcut(QtGui.QKeySequence("Ctrl+Alt+P")) + self.actionExportPdf.triggered.connect(self.export_pdf_report) + file_menu.addAction(self.actionExportPdf) self.actionExportNotesCsv = QtGui.QAction("Export Notes as CSV...", self) self.actionExportNotesCsv.setIcon(NamePixmapIcon("export")) self.actionExportNotesCsv.triggered.connect(self.export_notes_csv) @@ -2669,6 +2677,147 @@ def export_notes_csv(self): 5000, ) + def export_pdf_report(self): + """Render every annotated frame and its notes into a review PDF.""" + annotations = self.viewframe.viewer.annotations + frames = annotations.annotated_frames() + + if not frames: + QtWidgets.QMessageBox.information( + self, + "Export Review PDF", + "There are no notes to export yet.", + ) + return + if not self.current_source_filepath: + return + + shot_name = os.path.splitext( + os.path.basename(self.current_source_filepath) + )[0] + + filepath, _selected = QtWidgets.QFileDialog.getSaveFileName( + self, + "Export review PDF", + os.path.join( + self.browsepath or os.path.dirname(self.current_source_filepath), + f"{shot_name}_review.pdf", + ), + "PDF files (*.pdf)", + ) + if not filepath: + return + + if self.player.player is not None: + self.player.player.pause() + if self.compare_player.player is not None: + self.compare_player.player.pause() + self.viewframe.timelineToolbarLayout.playPauseButton.switch(False) + + source = self.media_cache.resolve(self.current_source_filepath) + reader = None + pages = list() + failed = list() + + progress = QtWidgets.QProgressDialog( + "Rendering review pages...", "Cancel", 0, len(frames), self + ) + progress.setWindowModality(QtCore.Qt.WindowModality.WindowModal) + progress.setMinimumDuration(0) + + try: + extension = os.path.splitext(source)[1].lower() + reader = ( + SequenceReader(source, review_proxy=False) + if extension in PlaylistWidget.IMAGE_EXTENSIONS + else MovieReader(source) + ) + fps = max(0.001, reader.get_fps()) + + for index, frame_number in enumerate(frames, start=1): + if progress.wasCanceled(): + break + progress.setLabelText(f"Rendering frame {frame_number}...") + progress.setValue(index - 1) + QtWidgets.QApplication.processEvents() + + if reader.media_type == "sequence": + image = reader.get_frame(frame_number, aov="rgb") + else: + seconds = (frame_number - constants.VL_START_FRAME) / fps + video_frame = reader.seek_time(seconds) + image = ( + video_frame.to_ndarray(format="rgb24") + if video_frame is not None + else None + ) + + if image is None: + failed.append(frame_number) + continue + + rendered = self.viewframe.viewer.render_annotated_frame( + image, frame_number + ) + if rendered is None: + failed.append(frame_number) + continue + + pages.append( + { + "frame": frame_number, + "timecode": timecode.frame_to_timecode( + max(0, frame_number - constants.VL_START_FRAME), + self._current_fps(), + ), + "image": rendered, + "comments": annotations.get_comments(frame_number), + "stroke_count": len(annotations.strokes.get(frame_number, [])), + } + ) + + progress.setValue(len(frames)) + + if not pages: + QtWidgets.QMessageBox.warning( + self, + "Export Review PDF", + "None of the annotated frames could be rendered.", + ) + return + + written = pdfreport.build_report( + filepath, + pages, + meta={ + "shot": shot_name, + "source": self.current_source_filepath, + "date": datetime.datetime.now().strftime( + constants.DATE_TIME_FORMAT + ), + "fps": f"{self._current_fps():g}" if self._current_fps() else "", + }, + ) + except (OSError, RuntimeError, ValueError): + LOGGER.exception("Unable to write review PDF") + QtWidgets.QMessageBox.warning( + self, + "Export Review PDF", + f"Could not export the review PDF to:\n{filepath}", + ) + return + finally: + progress.close() + if reader is not None: + reader.close() + + message = f"Exported {written} page review PDF to {filepath}" + if failed: + # Say which frames are missing rather than shipping a quietly + # incomplete report. + message = f"{message} ({len(failed)} frame(s) could not be rendered)" + self.statusBar().showMessage(message, 6000) + def export_notes(self): """Export every annotated frame as a PNG with notes burned in.""" frames = self.viewframe.viewer.annotations.annotated_frames() diff --git a/widgets/pdfreport.py b/widgets/pdfreport.py new file mode 100644 index 0000000..ef5355f --- /dev/null +++ b/widgets/pdfreport.py @@ -0,0 +1,331 @@ +"""Render a review report to PDF. + +The artifact a supervisor actually hands over: the annotated frames, the notes +that go with them, and enough identification that the recipient knows which shot +and which version they are looking at. + +Written with Qt's own QPdfWriter, so there is no new dependency -- Qt is already +required by the app, and it embeds its own fonts, so the text stays selectable +and searchable in the delivered file rather than being baked into the image. + +The builder takes plain page dicts, not a player or a reader, so a report can be +laid out and verified without decoding anything. +""" + +from __future__ import absolute_import + +from PySide6 import QtCore +from PySide6 import QtGui + +import constants + +# Rendered at print resolution: a note pinned to a 4K plate has to stay legible +# when someone prints the page and marks it up by hand. +RESOLUTION_DPI = 150 + +# Page furniture, in device pixels at RESOLUTION_DPI. +MARGIN = 60 +HEADER_HEIGHT = 70 +FOOTER_HEIGHT = 40 + +ACCENT = QtGui.QColor(224, 174, 74) +INK = QtGui.QColor(24, 24, 24) +MUTED = QtGui.QColor(110, 110, 110) +RULE = QtGui.QColor(205, 205, 205) + + +def _font(size, bold=False): + font = QtGui.QFont("Helvetica") + font.setPointSizeF(size) + font.setBold(bold) + return font + + +def _page_rect(writer): + """Return the printable area in device pixels.""" + return writer.pageLayout().paintRectPixels(writer.resolution()) + + +def _draw_header(painter, rect, left_text, right_text): + """Draw the running header and return the y below it.""" + painter.setFont(_font(13, bold=True)) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left(), rect.top(), rect.width() * 0.7, HEADER_HEIGHT), + QtCore.Qt.AlignmentFlag.AlignVCenter | QtCore.Qt.AlignmentFlag.AlignLeft, + left_text, + ) + + painter.setFont(_font(10)) + painter.setPen(MUTED) + painter.drawText( + QtCore.QRectF( + rect.left() + rect.width() * 0.7, + rect.top(), + rect.width() * 0.3, + HEADER_HEIGHT, + ), + QtCore.Qt.AlignmentFlag.AlignVCenter | QtCore.Qt.AlignmentFlag.AlignRight, + right_text, + ) + + baseline = rect.top() + HEADER_HEIGHT + painter.setPen(QtGui.QPen(ACCENT, 2)) + painter.drawLine(rect.left(), baseline, rect.right(), baseline) + + return baseline + 24 + + +def _draw_footer(painter, rect, text): + painter.setFont(_font(8)) + painter.setPen(MUTED) + painter.drawText( + QtCore.QRectF( + rect.left(), + rect.bottom() - FOOTER_HEIGHT, + rect.width(), + FOOTER_HEIGHT, + ), + QtCore.Qt.AlignmentFlag.AlignVCenter | QtCore.Qt.AlignmentFlag.AlignRight, + text, + ) + + +def _draw_cover(painter, rect, meta, pages): + """Draw the summary page: what this is, and what is in it.""" + y = _draw_header(painter, rect, constants.VL_TOOL_NAME + " Review Notes", meta.get("date", "")) + + painter.setFont(_font(24, bold=True)) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left(), y, rect.width(), 50), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + meta.get("shot", "Untitled"), + ) + y += 66 + + rows = [ + ("Source", meta.get("source", "")), + ("Frame rate", meta.get("fps", "")), + ("Annotated frames", str(len(pages))), + ("Comments", str(sum(len(page.get("comments") or []) for page in pages))), + ] + + painter.setFont(_font(10)) + for label, value in rows: + if not value: + continue + painter.setPen(MUTED) + painter.drawText( + QtCore.QRectF(rect.left(), y, 150, 26), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + label, + ) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left() + 160, y, rect.width() - 160, 26), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + str(value), + ) + y += 26 + + y += 20 + painter.setPen(QtGui.QPen(RULE, 1)) + painter.drawLine(rect.left(), y, rect.right(), y) + y += 20 + + # Contents: every annotated frame, so the reader can find a note without + # paging through the whole report. + painter.setFont(_font(11, bold=True)) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left(), y, rect.width(), 26), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + "Contents", + ) + y += 30 + + painter.setFont(_font(9)) + for index, page in enumerate(pages, start=2): # page 1 is this cover + if y > rect.bottom() - FOOTER_HEIGHT - 20: + break + summary = _first_comment(page) or "{0} drawn note(s)".format( + page.get("stroke_count", 0) + ) + painter.setPen(MUTED) + painter.drawText( + QtCore.QRectF(rect.left(), y, 220, 22), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + "p{0} Frame {1} {2}".format( + index, + str(page["frame"]).zfill(constants.VL_FRAME_PADDING), + page.get("timecode", ""), + ), + ) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left() + 240, y, rect.width() - 240, 22), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + summary, + ) + y += 22 + + +def _first_comment(page): + comments = page.get("comments") or [] + return comments[0].get("text", "") if comments else "" + + +def _draw_frame_page(painter, rect, meta, page, number, total): + """Draw one annotated frame with its notes underneath.""" + y = _draw_header( + painter, + rect, + "{0} Frame {1}".format( + meta.get("shot", ""), + str(page["frame"]).zfill(constants.VL_FRAME_PADDING), + ), + page.get("timecode", ""), + ) + + comments = page.get("comments") or [] + + # The notes get the room they need; the frame takes what is left. A report + # whose text is cut off is worse than one whose image is a little smaller. + note_height = min( + rect.height() * 0.4, + 30 + len(comments) * 30 if comments else 0, + ) + image_area = QtCore.QRectF( + rect.left(), + y, + rect.width(), + rect.bottom() - FOOTER_HEIGHT - 20 - note_height - y, + ) + + image = page.get("image") + if image is not None and not image.isNull() and image_area.height() > 40: + scaled = image.scaled( + int(image_area.width()), + int(image_area.height()), + QtCore.Qt.AspectRatioMode.KeepAspectRatio, + QtCore.Qt.TransformationMode.SmoothTransformation, + ) + target = QtCore.QRectF( + image_area.left() + (image_area.width() - scaled.width()) / 2.0, + image_area.top(), + scaled.width(), + scaled.height(), + ) + painter.drawImage(target, scaled) + painter.setPen(QtGui.QPen(RULE, 1)) + painter.drawRect(target) + + if comments: + y = rect.bottom() - FOOTER_HEIGHT - 20 - note_height + painter.setFont(_font(10, bold=True)) + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left(), y, rect.width(), 24), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + "Notes", + ) + y += 26 + + painter.setFont(_font(9)) + pin = 0 + for comment in comments: + if y > rect.bottom() - FOOTER_HEIGHT: + break + + pinned = "x" in comment and "y" in comment + if pinned: + pin += 1 + + # The marker number matches the pin drawn on the frame above, so a + # note can be traced back to the exact spot it refers to. + marker = "{0}.".format(pin) if pinned else "-" + painter.setPen(ACCENT if pinned else MUTED) + painter.drawText( + QtCore.QRectF(rect.left(), y, 30, 24), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + marker, + ) + + text = comment.get("text", "") + if comment.get("done"): + text = "{0} [resolved]".format(text) + + painter.setPen(INK) + painter.drawText( + QtCore.QRectF(rect.left() + 32, y, rect.width() - 32, 24), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + text, + ) + y += 26 + + _draw_footer(painter, rect, "Page {0} of {1}".format(number, total)) + + +def build_report(filepath, pages, meta=None): + """Write a review PDF. + + Args: + filepath (str): + Destination path. + + pages (list[dict]): + One entry per annotated frame: + ``{"frame": int, "timecode": str, "image": QImage, + "comments": [comment dicts], "stroke_count": int}`` + + meta (dict): + ``{"shot": str, "source": str, "date": str, "fps": str}`` + + Returns: + int: The number of pages written (cover included). + """ + + meta = meta or dict() + pages = list(pages or []) + + writer = QtGui.QPdfWriter(str(filepath)) + writer.setPageSize(QtGui.QPageSize(QtGui.QPageSize.PageSizeId.A4)) + writer.setPageOrientation(QtGui.QPageLayout.Orientation.Landscape) + writer.setResolution(RESOLUTION_DPI) + writer.setTitle( + "{0} review notes - {1}".format( + constants.VL_TOOL_NAME, meta.get("shot", "Untitled") + ) + ) + writer.setPageMargins( + QtCore.QMarginsF(10, 10, 10, 10), QtGui.QPageLayout.Unit.Millimeter + ) + + painter = QtGui.QPainter() + if not painter.begin(writer): + raise OSError("Could not open the PDF for writing: {0}".format(filepath)) + + total = len(pages) + 1 + + try: + painter.setRenderHint(QtGui.QPainter.RenderHint.Antialiasing, True) + painter.setRenderHint(QtGui.QPainter.RenderHint.TextAntialiasing, True) + painter.setRenderHint(QtGui.QPainter.RenderHint.SmoothPixmapTransform, True) + + rect = QtCore.QRectF(_page_rect(writer)) + + _draw_cover(painter, rect, meta, pages) + _draw_footer(painter, rect, "Page 1 of {0}".format(total)) + + for index, page in enumerate(pages, start=2): + writer.newPage() + _draw_frame_page(painter, rect, meta, page, index, total) + finally: + painter.end() + + return total + + +if __name__ == "__main__": + pass From f0a30413dd38083b0e44c059119522eda8d20b54 Mon Sep 17 00:00:00 2001 From: D-Mad <8207507+D-Mad@users.noreply.github.com> Date: Wed, 15 Jul 2026 11:23:57 +0700 Subject: [PATCH 2/2] Harden PDF review export and CI runtime --- .github/workflows/ci.yml | 6 +--- README.md | 2 ++ tests/test_pdf_report.py | 17 +++++++++++ widgets/__init__.py | 51 +++++++++++++++++++++----------- widgets/pdfreport.py | 63 +++++++++++++++++++++++++++++----------- 5 files changed, 100 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83a741c..a251666 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,11 +41,7 @@ jobs: sudo apt-get update sudo apt-get install -y --no-install-recommends \ libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0 \ - fonts-dejavu-core - # Qt no longer ships fonts. Without a font on the box, QFontDatabase - # is empty and every drawText() silently draws nothing, so tests that - # assert on rendered text or on text inside a generated PDF fail for - # reasons unrelated to the code. libpulse0 + libpulse0 fonts-dejavu-core - name: Install Python dependencies run: | python -m pip install --upgrade pip diff --git a/README.md b/README.md index d7d7a62..d77ffec 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,8 @@ expanded production workflow: - Full-resolution JPG/PNG extraction from the current display view. - Excel-friendly UTF-8 CSV export for comments, text and drawing notes, including frame, SMPTE timecode, normalized pin position and resolved state. +- Searchable PDF review reports with a shot cover, annotated full-resolution + frames, wrapped comments, matching pin numbers and per-frame timecode. - High-quality MP4 conversion from movies or image sequences, preserving movie FPS/audio and allowing user-defined image-sequence FPS. - PyInstaller-based Windows portable packaging and Ubuntu AppImage packaging. diff --git a/tests/test_pdf_report.py b/tests/test_pdf_report.py index cf68f0e..bd192fc 100644 --- a/tests/test_pdf_report.py +++ b/tests/test_pdf_report.py @@ -115,6 +115,23 @@ def test_comments_survive_into_the_pdf_text(tmp_path, qapp, qfont): assert "soften the matte edge" in text +def test_long_comments_wrap_without_losing_the_tail(tmp_path, qapp, qfont): + path = tmp_path / "long-note.pdf" + long_note = ( + "Reduce the bright edge around the foreground character and preserve " + "the fine hair detail through the final delivery render." + ) + + pdfreport.build_report( + path, + [_page(12, comments=[_comment(long_note)])], + meta={"shot": "KP_010_020"}, + ) + + _reader, text = _read(path) + assert "fine hair detail through the final delivery render" in text + + def test_the_cover_identifies_the_shot_and_counts_the_notes(tmp_path, qapp, qfont): path = tmp_path / "cover.pdf" diff --git a/widgets/__init__.py b/widgets/__init__.py index fddafb6..f7ddef7 100644 --- a/widgets/__init__.py +++ b/widgets/__init__.py @@ -27,6 +27,7 @@ import os import json import datetime +import numpy import utils import logger @@ -2733,6 +2734,8 @@ def export_pdf_report(self): else MovieReader(source) ) fps = max(0.001, reader.get_fps()) + ocio_processor = self.player.ocio_processor + active_aov = getattr(self.player.player, "current_aov", "rgb") for index, frame_number in enumerate(frames, start=1): if progress.wasCanceled(): @@ -2741,28 +2744,42 @@ def export_pdf_report(self): progress.setValue(index - 1) QtWidgets.QApplication.processEvents() - if reader.media_type == "sequence": - image = reader.get_frame(frame_number, aov="rgb") - else: - seconds = (frame_number - constants.VL_START_FRAME) / fps - video_frame = reader.seek_time(seconds) - image = ( - video_frame.to_ndarray(format="rgb24") - if video_frame is not None - else None + rendered = None + try: + if reader.media_type == "sequence": + image = reader.get_frame( + frame_number, + aov=active_aov, + ocio_processor=ocio_processor, + ) + else: + seconds = (frame_number - constants.VL_START_FRAME) / fps + video_frame = reader.seek_time(seconds) + image = ( + video_frame.to_ndarray(format="rgb24") + if video_frame is not None + else None + ) + if image is not None and ocio_processor is not None: + image = image.astype(numpy.float32) / 255.0 + image = ocio_processor.process_image(image) + image = ( + numpy.clip(image, 0.0, 1.0) * 255.0 + ).astype(numpy.uint8) + + rendered = self.viewframe.viewer.render_annotated_frame( + image, frame_number + ) + except Exception: + LOGGER.exception( + "Unable to render PDF frame %s", frame_number ) - if image is None: - failed.append(frame_number) - continue - - rendered = self.viewframe.viewer.render_annotated_frame( - image, frame_number - ) if rendered is None: failed.append(frame_number) - continue + # Keep the review note even when its picture cannot be decoded; + # silently dropping the entire page loses the handoff content. pages.append( { "frame": frame_number, diff --git a/widgets/pdfreport.py b/widgets/pdfreport.py index ef5355f..c9e36f1 100644 --- a/widgets/pdfreport.py +++ b/widgets/pdfreport.py @@ -176,6 +176,26 @@ def _first_comment(page): return comments[0].get("text", "") if comments else "" +def _comment_text(comment): + text = str(comment.get("text", "")) + if comment.get("done"): + text = "{0} [resolved]".format(text) + return text + + +def _comment_height(font, text, width): + """Return enough vertical space to preserve a wrapped review comment.""" + flags = ( + int(QtCore.Qt.AlignmentFlag.AlignLeft) + | int(QtCore.Qt.AlignmentFlag.AlignTop) + | int(QtCore.Qt.TextFlag.TextWordWrap) + ) + bounds = QtGui.QFontMetricsF(font).boundingRect( + QtCore.QRectF(0, 0, max(1.0, width), 10000), flags, text + ) + return max(26.0, bounds.height() + 8.0) + + def _draw_frame_page(painter, rect, meta, page, number, total): """Draw one annotated frame with its notes underneath.""" y = _draw_header( @@ -190,11 +210,19 @@ def _draw_frame_page(painter, rect, meta, page, number, total): comments = page.get("comments") or [] - # The notes get the room they need; the frame takes what is left. A report - # whose text is cut off is worse than one whose image is a little smaller. + # The notes get the room they need; the frame takes what is left. Measure + # wrapped text instead of assuming every note fits on one 24-pixel line. + note_font = _font(9) + comment_width = rect.width() - 32 + comment_heights = [ + _comment_height(note_font, _comment_text(comment), comment_width) + for comment in comments + ] + available_height = rect.bottom() - FOOTER_HEIGHT - 20 - y + desired_note_height = 30 + sum(comment_heights) if comments else 0 note_height = min( - rect.height() * 0.4, - 30 + len(comments) * 30 if comments else 0, + max(0.0, available_height - 60.0), + desired_note_height, ) image_area = QtCore.QRectF( rect.left(), @@ -232,10 +260,15 @@ def _draw_frame_page(painter, rect, meta, page, number, total): ) y += 26 - painter.setFont(_font(9)) + painter.setFont(note_font) pin = 0 - for comment in comments: - if y > rect.bottom() - FOOTER_HEIGHT: + text_flags = ( + int(QtCore.Qt.AlignmentFlag.AlignLeft) + | int(QtCore.Qt.AlignmentFlag.AlignTop) + | int(QtCore.Qt.TextFlag.TextWordWrap) + ) + for comment, row_height in zip(comments, comment_heights): + if y + row_height > rect.bottom() - FOOTER_HEIGHT: break pinned = "x" in comment and "y" in comment @@ -247,22 +280,18 @@ def _draw_frame_page(painter, rect, meta, page, number, total): marker = "{0}.".format(pin) if pinned else "-" painter.setPen(ACCENT if pinned else MUTED) painter.drawText( - QtCore.QRectF(rect.left(), y, 30, 24), - QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, + QtCore.QRectF(rect.left(), y, 30, row_height), + QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignTop, marker, ) - text = comment.get("text", "") - if comment.get("done"): - text = "{0} [resolved]".format(text) - painter.setPen(INK) painter.drawText( - QtCore.QRectF(rect.left() + 32, y, rect.width() - 32, 24), - QtCore.Qt.AlignmentFlag.AlignLeft | QtCore.Qt.AlignmentFlag.AlignVCenter, - text, + QtCore.QRectF(rect.left() + 32, y, comment_width, row_height), + text_flags, + _comment_text(comment), ) - y += 26 + y += row_height _draw_footer(painter, rect, "Page {0} of {1}".format(number, total))