Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ 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 \
libpulse0 fonts-dejavu-core
- name: Install Python dependencies
run: |
python -m pip install --upgrade pip
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
39 changes: 39 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,12 +31,51 @@

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():
"""A single ``QApplication`` shared by every test that touches Qt widgets."""
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
248 changes: 248 additions & 0 deletions tests/test_pdf_report.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
"""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_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"

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"},
)
Loading
Loading