Skip to content

Add CSV notes export - #19

Closed
emlcpfx wants to merge 8 commits into
D-Mad:mainfrom
emlcpfx:pr2-csv-export
Closed

Add CSV notes export#19
emlcpfx wants to merge 8 commits into
D-Mad:mainfrom
emlcpfx:pr2-csv-export

Conversation

@emlcpfx

@emlcpfx emlcpfx commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

File > Export Notes as CSV... writes every comment and drawing to a flat CSV - one row per note - so a supervisor can take their notes out of FrameDeck into a spreadsheet, a tracker import, or an email.

Columns: frame, timecode, type, content, color, x, y, done, timestamp

frame,timecode,type,content,color,x,y,done,timestamp
1,00:00:00:00,comment,plate looks warm,,0.4,0.6,no,2026-07-14 11:58:33:AM
1,00:00:00:00,pencil,,#ffaa00,0.1,0.2,,
25,00:00:01:00,comment,soften this edge,,,,yes,2026-07-14 11:59:02:AM
25,00:00:01:00,rectangle,,#ff0000,0.5,0.5,,

Design notes

  • utils/notescsv.py is pure - no Qt, no window - so the row building is tested directly rather than through the UI.
  • Ordering is deterministic: frames ascending, and within a frame the comments come first (they carry the words), then the drawings in the order they were made.
  • Coordinates are the normalized 0-1 values the annotations are already stored in, so a note stays meaningful regardless of the resolution it was made at. Blank means not applicable: a scribble has no text, a frame-level comment has no pin.
  • Text annotations carry their words into the content column. Pencil strokes anchor on their first point (where the reviewer started drawing - the thing they were pointing at); shapes anchor on their start corner.

Two failure modes it handles

Bad frame-rate metadata must not cost someone their notes. An unusable fps degrades the timecode column to a frame label (f0042) instead of raising. Exporting notes is the last thing a reviewer does before handing off; it is the worst possible moment to fail.

Windows CRLF doubling. The csv module writes its own line terminator, so the file must be opened with an empty newline argument - otherwise every other line of the exported file is blank when opened on Windows. test_written_csv_has_no_blank_lines pins it.

Tests

14 new tests, 110 on the branch, all passing headless: ordering, pin/no-pin, done state, timecode mapping, the fps fallback, comma and quote escaping, malformed colours, a pencil with no points, and an empty sketch (header only, no rows).

Verified end to end through a real MainWindow: the menu action is present and the written file parses back through csv.DictReader with the notes intact.

Depends on #12 (timecode) and #17 (comments), both of which are in this branch base.

emlcpfx and others added 8 commits July 14, 2026 08:17
Introduce the first automated test infrastructure for FrameDeck. There were
previously no tests; CI only ran compileall.

- tests/conftest.py forces Qt's offscreen platform plugin and a single shared
  QApplication so widgets can be constructed, rendered and pixel-probed with no
  display server. FRAMEDECK_PROFILE_ROOT is redirected to a temp dir so tests
  do not write into the user's Documents folder.
- tests/helpers.py provides render_widget_to_image / probe_pixel plus synthetic
  media generators (solid MP4 via PyAV, numbered PNG sequence, flat EXR via
  OIIO) so later tests need no external assets.
- tests/test_smoke.py verifies the harness end to end: offscreen render + pixel
  probe, FrameDeck package imports, and each media generator produces a
  decodable/readable file. 10 tests pass locally.
- requirements-dev.txt pins pytest and pypdf.
- pytest.ini sets testpaths.
- ci.yml gains a headless pytest job (installs Qt offscreen system libs plus
  runtime + dev requirements).

Also adds doc/PLAN-reviewapp-parity.md: the staged plan for porting reviewapp
features into FrameDeck across separate PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177bi2WkPjrGytrarheKFDc
Claude-Session-Id: 45a11a50-85fe-4349-b853-e1db9cd20276
The Sketch annotation store had undo (create/move/erase) but no redo. Add a
snapshot-based redo that reapplies the most recently undone action.

- widgets/annotations.py: add redo_history; route the four action-record sites
  through _record_action(), which appends to the undo stack and clears redo (a
  fresh edit invalidates redo, standard semantics). undo() now snapshots the
  full pre-undo stroke state onto the redo stack; redo() restores it in place
  (mutating the existing dict so held references stay valid) and re-pushes the
  action onto the undo stack.
- widgets/viewer.py: redo_strokes() mirrors undo_strokes().
- widgets/__init__.py: "Redo Note" edit-menu action bound to Ctrl+Shift+Z and
  Ctrl+Y.

Tests: tests/test_annotation_redo.py covers create redo, multi-step
undo-then-redo ordering, redo-stack invalidation on a new edit, erase
undo/redo, and empty-stack no-op. 5 tests pass (15 with the harness suite).

Depends on the test harness (PR D-Mad#7); branched on top of it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177bi2WkPjrGytrarheKFDc
Claude-Session-Id: 45a11a50-85fe-4349-b853-e1db9cd20276
FrameDeck kept annotations only in memory, so pencil/text notes were lost when
switching sources or closing the app. Persist them to JSON sidecars so they
survive a reload -- the first piece of stronger session/project state, and the
storage foundation the comment sidebar (planned) will build on.

- widgets/annotations.py: Sketch.serialize() returns a JSON-safe snapshot
  (frame -> strokes, empty frames omitted); deserialize() restores it, converting
  list coordinates/colours back to tuples and resetting undo/redo history.
- widgets/notestore.py: read/write sidecars under the FrameDeck profile dir
  (<profile>/framedeck/notes/<stem>_<hash>.fdnotes.json), keyed by a hash of the
  absolute source path. Empty notes remove a stale sidecar; unreadable/foreign
  files load as empty.
- widgets/__init__.py: openMedia saves the outgoing source's notes before the
  viewer is cleared and loads the incoming source's notes after it opens;
  closeEvent saves on exit. All hooks are defensive (a notes error is logged,
  never breaks media loading).

Tests: tests/test_notes_persistence.py covers JSON serialize/deserialize
round-trip, history reset on load, sidecar save/load round-trip, empty-removes-
sidecar, missing-clears-sketch, and deterministic profile-scoped paths. 6 tests
pass (21 with the branch suite).

Depends on the test harness (PR D-Mad#7) and annotation redo (PR D-Mad#9, for the redo
stack reset in deserialize).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177bi2WkPjrGytrarheKFDc
Claude-Session-Id: 45a11a50-85fe-4349-b853-e1db9cd20276
FrameDeck showed frame numbers only. Add a timecode readout to the review
toolbar and a reusable conversion module (the CSV/PDF exports will use it).

- utils/timecode.py: non-drop, frame-count based conversion. Frames are counted
  at the nominal integer rate (23.976 counts at 24), which is what editorial
  tools display and makes HH:MM:SS:FF <-> frame an exact round trip. A
  wall-clock conversion drifts on fractional rates and cannot be inverted
  reliably, so it is deliberately not used. Unusable rates degrade to a frame
  label (f0042) instead of raising.
- widgets/__init__.py: a TC status label in the review toolbar showing
  "TC | HH:MM:SS:FF   F <frame>", refreshed on every frame change and reset when
  media is removed. The timeline is 1-based, so the 0-based timecode index
  subtracts VL_START_FRAME.

Tests: tests/test_timecode.py covers zero/second boundaries, fractional rates
counting at nominal, H:M:S:F composition, exact round trips across 6 rates x 6
frames, negative frames, bad-rate fallback, and malformed-input rejection.
48 tests pass (58 with the harness suite).

Depends on the test harness (PR D-Mad#7).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177bi2WkPjrGytrarheKFDc
Claude-Session-Id: 45a11a50-85fe-4349-b853-e1db9cd20276
Foundation for the Frame.io-style review workflow: per-frame text comments that
can optionally be pinned to a point on the image. This is the model, rendering,
persistence and navigation layer; the comment sidebar UI follows separately.

- widgets/annotations.py: Sketch gains a comments store (frame -> comments) with
  add/get/delete/toggle-done, commented_frames() and comment_count(). A comment
  holds text, a timestamp, a done flag and optional normalized x/y.
  draw_comment_pins() renders numbered markers for the current frame's pinned
  comments (numbering matches list order; done comments use a distinct fill).
  annotated_frames() now returns the union of stroke and comment frames, and
  clear()/clear_all() cover both.
- widgets/notestore.py: sidecars persist comments alongside strokes. A sketch
  holding only comments is still written; sidecars predating comments (no
  "comments" key) still load.
- widgets/__init__.py: jump_to_annotation() with "[" / "]" shortcuts and Edit
  menu actions, seeking to the previous/next annotated frame. Annotations are
  keyed by the player's local frame while the timeline is global during playlist
  playback, so the jump maps local -> timeline explicitly.
- constants: COMMENT_PIN_RADIUS and the pin colours.

Tests: tests/test_comments.py covers comment CRUD, blank-text rejection,
done-toggling, the stroke/comment frame union, per-frame and global clear, pin
rendering (pixel-probing the marker fill, and the distinct done colour),
unpinned comments drawing nothing, sidecar round-trip, a comment-only sketch
still writing a sidecar, legacy sidecars without a comments key, and a missing
sidecar clearing stale comments. 12 tests pass (33 with the branch suite).

Depends on annotation persistence (PR D-Mad#10), which is on PR D-Mad#9 -> PR D-Mad#7.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0177bi2WkPjrGytrarheKFDc
Claude-Session-Id: 45a11a50-85fe-4349-b853-e1db9cd20276
Turns the comment model into a usable review workflow: a Comments panel
listing every note grouped by frame, and a viewer tool that pins a note to
a point on the frame.

- widgets/commentpanel.py: frame-grouped comment tree. Click a row to seek,
  tick to mark done, delete the selection, add a frame-level note. The panel
  is a pure view over Sketch.comments and owns no state, so it cannot drift
  from the markers drawn on the frame. Pin numbers in the list match the
  numbers inside the on-frame markers.
- Viewer gains a Comment tool. Clicking the frame emits comment_requested
  with the normalized hit point; the window prompts for text and pins it.
- View > Comments Panel (Ctrl+M) toggles the sidebar, which opens to a
  usable width the first time it is shown.
- Sketch.mousePressEvent now returns early for the comment tool. A
  {type: comment} stroke would render as nothing and be silently dropped
  by erase(), which only re-appends the stroke types it recognizes.

Note toggling a comment done restyles its row in place. Rebuilding the tree
from inside itemChanged destroys the item Qt is still emitting the signal
for, which is a use-after-free that takes the process down; the tests caught
it as an intermittent access violation.

15 tests, including the in-place-restyle regression guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TexnzYbmCjjTDB8zzZuUPb
File > Export Notes as CSV... writes every comment and drawing to a flat
CSV, one row per note: frame, timecode, type, content, color, x, y, done,
timestamp.

- utils/notescsv.py is pure (no Qt), so the row building is tested on its
  own. Rows are ordered by frame ascending; within a frame the comments come
  first, then the drawings in the order they were made.
- Coordinates are the normalized 0-1 image-space values the annotations are
  already stored in, so a note stays meaningful regardless of the resolution
  it was made at. Blank means not applicable: a scribble has no text, a
  frame-level comment has no pin.
- Text annotations carry their words into the content column; pencil strokes
  anchor on their first point, shapes on their start corner.
- An unusable frame rate degrades the timecode to a frame label (f0042)
  rather than failing the export. Bad metadata must not cost a supervisor
  their notes.
- The file is opened with an empty newline argument, else the csv module
  emits CRLF twice per row on Windows and every other line of the exported
  file comes out blank.

14 tests, including comma and quote escaping, malformed colours, a pencil
with no points, and the blank-line regression.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TexnzYbmCjjTDB8zzZuUPb
@D-Mad

D-Mad commented Jul 14, 2026

Copy link
Copy Markdown
Owner

Thank you, Eric. The CSV feature commit has been preserved with your authorship and rebased cleanly onto the current comment-sidebar implementation in #22. That replacement also adds Excel-friendly Unicode output and malformed-coordinate hardening. Closing this stacked/conflicting branch in favor of #22.

@D-Mad D-Mad closed this Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants