feat(report): Report Builder — sidebar, combined figures, MDI layering, exports, movie (phases 1–4)#8
Merged
Merged
Conversation
… builder New spyde/actions/report/ package: the .spyde-report zip container (report.md with YAML front-matter + figures/<id>.yaml FigureSpec recipes + assets/<id>.png baked snapshots — human-readable, no JSON), 15 report_* staged handlers (new/open/save/close, cell CRUD + move + caption + title, add/refresh figure with snapshot-at-drop semantics, renderer-snapshot delivery with a 3s baked-PNG fallback so saves never hang), and the FigureSpec -> anyplotlib figure builder emitting host:"report" cell figures behind ReportFigureController (WindowController protocol). SignalRef rebinds figure cells on reload: open-tree uid first, then title, then file fingerprint; unresolved cells ship a capped data-URL of the baked PNG and flag data_offline. 47+1 new tests (model round-trips incl. code-fence traps, atomic zip write, every handler vs captured messages, controller teardown).
… dialogs ReportSidebar as a third in-flow dock (Pointer-Capture left-edge resize, 420px default) with an AppBar toggle; markdown cells rendered via marked+DOMPurify with double-click textarea editing and Ctrl/Cmd-Enter commit; figure cells host live report iframes (existing figId replay machinery), click-to-edit captions, refresh-from-live, offline-PNG badge, and dashed template placeholders; body-level drop of window/figure pills inserts figure cells at the drop position. Substrate: FIGURE_DRAG_MIME stamped by window pills (payload gains figId/title/view), report open/save file dialogs in main+preload, host:"report" figure routing kept out of the MDI, report_state / report_need_snapshots / report_saved protocol types, and the requestFigurePng postMessage harvest that answers snapshot requests (1.5s per-cell timeout; backend bakes anything missing). deps: marked, dompurify.
Report figure cells are cold standalone embeds with no PLOTBIN channel, so under APL_BINARY_TRANSPORT=1 imshow left a \x00bin: token where the pixels belong and every report figure rendered blank (and the save baked the tiny fallback PNG instead of the WYSIWYG harvest). figure_builder now re-pushes each panel with binary transport suppressed before building the HTML, so the embed carries real base64 pixels. Also gate the sidebar body on report.open (report_close emits open:false rather than clearing the doc), restoring the empty-state instead of a dangling Save button and drop zone. report_sidebar.spec.ts drives the full phase-1 loop on real Dask + si_grains: sidebar toggle, markdown cell edit, pill drag -> live figure cell (pixel-checked), caption, raw mode, save (zip contents asserted: report.md, FigureSpec YAML, >5KB WYSIWYG PNG), close, reopen from disk with live rebind. 8/8 passing.
Combined report figures (spyde/actions/report/compose.py): repfig_query_compose answers which modes a drop supports (same-shape -> overlay, navigator/signal pair of one tree -> callout, tiles always); repfig_compose snapshots the source and grows the cell's FigureSpec (overlay appends a LayerSpec with a cycled cmap at alpha 0.5, tile-* grows the grid on that side, callout adds an inset panel with an indicate_region connector when the local anyplotlib has it); layer/panel/annotation CRUD collapses cleanly (last layer removes the panel, last panel returns the cell to a placeholder). The builder consumes the full spec — grids, per-panel add_layer overlays, annotations mapped to anyplotlib markers, callout insets — and report_state figure cells now carry the pixel-free FigureSpec dict for the renderer's edit toolbar. ReportManager snapshots are keyed per (cell, panel, layer). MDI live layering (spyde/actions/overlay.py): overlay_add/set/remove/query put PlotLayer entries on a Plot (same-(H,W) validation, tile-mode refusal) and emit layers_state. Live refresh rides the existing read/paint split: a guarded zero-cost hook after the base enqueue_paint in _run_update reads each visible layer's source frame cheaply + synchronously on the dispatcher thread (skipping expensive-tier moves — the settle re-fire catches up) and pushes via the painter thread. update_functions gains _prepare_nav_indices, a pure extraction of the swap/mean/clamp index prep, so layers resolve the SAME nav position as the base frame. report_add_figure serializes live MDI layers into the cell spec. Tests: test_report_compose.py + test_overlay_layers.py (27 new). Full migrated suite: 910 passed; only the two known environmental update-channel failures.
…bar, MDI overlay drop Figure cells grow a 5-zone compose overlay during window/figure drags: edge drops tile immediately (repfig_compose tile-*), a center drop asks the backend which modes fit (repfig_query_compose) and offers Overlay (same pixel shape) / Callout (navigator-signal pair) / Tile in an anchored prompt. The per-cell Edit toolbar (driven by the pixel-free FigureSpec now shipped in report_state) lists panels with their layers (cmap, debounced alpha, visibility, remove) and annotations (add Text/Circle/Rect/Arrow with data-coord defaults, click-to-edit text, delete). Annotation payloads use the real anyplotlib marker kwargs (offsets/texts/radius/U-V), matching what figure_builder passes through. MDI: while a window drag is in flight (global drag tracking on the spyde MIME types), other windows shield their iframe with a centered dashed 'Overlay images' zone; dropping confirms then sends overlay_add. Plot Control gains a Layers section (per-layer cmap select, alpha slider, visibility eye, remove) fed by layers_state and overlay_query. Also: resolve System32 tar.exe explicitly in report_sidebar.spec.ts — a bare 'tar' can hit git's GNU tar, which reads 'C:\...' as a remote host. Verified in-app by report_phase2_probe.spec.ts (edit toolbar, annotation render, tile compose; 3/3 green).
mdi_overlay.spec.ts (7 steps): two same-shape si_grains trees, pill drop onto the second signal window's overlay shield -> confirm -> composited magma layer (colored-pixel count 1.2k -> 55k), dock Layers section drives alpha/visibility/remove, nav scrub keeps the layer composited, and an incompatible navigator drop surfaces the backend refusal in the status bar (PLOTAPP statuses never reach Playwright stdout) without a crash. report_compose.spec.ts (4 steps): center-drop prompts — same-shape pair offers Overlay (cell becomes a two-layer blend, edit toolbar lists both layers), a signal's own navigator offers Callout (cell rebuilds with an inset panel). All screenshots reviewed; no Python tracebacks. 14/14 across the three phase-2 specs; phase-1 report_sidebar still 8/8.
report_export_html writes a self-contained article-styled page: static mode inlines every figure as a data-URL <img> with its caption (this exact file is what printToPDF consumes — print-safe styling included); interactive mode embeds each cell's live anyplotlib figure in a sandboxed srcdoc iframe (binary tokens resolved), falling back to <img> for offline cells. A temp:true static branch writes to the OS temp dir for the renderer's PDF chain. report_export_markdown writes the unzipped container (report.md + figures/ + assets/) into a directory, refusing non-empty targets that aren't a prior export. Both run the same snapshot-harvest handshake as save. Markdown cells now carry an optional renderer-supplied html fragment (marked+DOMPurify — the single markdown engine): cached on commit, cleared when absent, never persisted; export falls back to escaped <pre> source. report_paste_cell inserts a serialized cell with fresh ids, rebinding figure layers like report_open (live rebuild or offline via the provided PNG). toolbars.yaml gains Copy to Report on 2-D plots (wrapper -> report_cell_from_window, auto-opens a report). test_report_export.py: 14 tests; report suites 77 passing.
ReportSidebar gains an Export dropdown — Interactive HTML, Static HTML, PDF, Markdown folder — using new main-process dialogs, awaiting the report_exported event, and showing a transient result note. PDF runs the two-leg chain: backend writes the static page to a temp file, a hidden sandboxed BrowserWindow prints it (printBackground, A4) and writes the buffer. clipboard:write-png mirrors copied figures to the OS clipboard via nativeImage. Cells gain Copy/Duplicate hover chrome and the header a Paste button backed by a tiny module-scope clipboard store (useSyncExternalStore): markdown copies carry source+rendered html, figure copies harvest a fresh WYSIWYG PNG from the live iframe (offline cells reuse their baked PNG). Every markdown commit now ships the sanitized rendered fragment (shared kernel/markdown.ts) so static export embeds real HTML.
…e spec finalize_figure_html swaps the inlined JS bundle for a machine-local file:// shared-ESM import (a V8 code-cache optimization for the live MDI iframes) — inside an exported page's sandboxed srcdoc iframe (opaque origin, no filesystem) that import is blocked and the figure never mounted. finalize_figure_html gains a keyword-only standalone=True mode that keeps the ESM fully inlined; the interactive exporter uses it. All other callers unchanged. report_export.spec.ts (9 steps, dialogs stubbed via ipcMain handler swap): static HTML (real rendered markdown, one data-URL img, caption, no pre fallback / iframes / binary tokens), interactive HTML rendered in a throwaway real Chromium with pixel evidence, PDF (%PDF magic, ~36KB), markdown-folder contents, figure Copy -> OS clipboard PNG + Paste (second live cell), markdown Duplicate, and Copy-to-Report from both open and closed report states. All three earlier report specs still green. Also dropped a stale contract comment around report_set_title (the handler has existed since phase 1).
…er seam New spyde/actions/movie_export/ package behind the mvx_* staged wizard: mvx_open gates on an insitu tree, probes the bundled imageio-ffmpeg binary, and seeds fps/cmap/clim from the plot and the real time axis (playback conventions); mvx_tune drives fps, spatial downsample, temporal stride, time range, timestamp/scalebar toggles, and time-gated annotations (text/circle/rect/arrow in image coords); mvx_add_trace captures any 1-D plot's curve and resamples it onto the movie time base. The pipeline is memory-safe by construction: each frame is one lazy raw[t].compute() slice (a full-dataset compute guard is enforced in tests), LUT-colored via a 256-entry table built once, annotated with PIL, and appended through a one-module encoder seam (H.264 via the already-bundled imageio-ffmpeg, macro_block_size=1 + even-crop; .gif routes to a Pillow writer). The trace inset renders ONCE with matplotlib Agg; each frame pastes a copy and draws the moving time cursor. Runs on the worker with generation guard, per-frame cancellation (mvx_cancel + tree cancel registry) and partial-file cleanup; progress via emit_progress and completion via mvx_done. toolbars.yaml gains Export Movie (insitu-gated, signal window). 23 pipeline/handler tests + mvx wizard-schema lockstep; report suites unaffected (77 passing).
MovieExportWizard caret (WizardShell/useWizardLifecycle/debounced-tune idioms, mounted via FloatingToolbar's WIZARD_ACTIONS for the insitu Export Movie button): format tab (fps, downsample, stride, computed output-frames/duration line), time-range tab in real time units, overlays tab (timestamp/scalebar toggles + time-gated annotation rows), a trace drop slot accepting 1-D window pills, an ffmpeg-missing guard that disables export, and the export flow (mp4/gif save dialog -> mvx_run -> progress -> mvx_done note, with cancel while running). The progress message finally has a renderer surface: emit_progress had zero consumers anywhere — progress now drives the existing StatusBar busy affordance (percent text, cleared on completion) and re-broadcasts as a CustomEvent for the wizard footer. report:export-dialog gains an mp4 kind.
… spec Dropping the 1-D time navigator's pill on the wizard's trace slot also bubbled to the MDI area's drop handler, which read NAVIGATOR_DRAG_MIME and fired extract_navigator — erroring on the 1-D navigator. The slot now stops propagation on dragover/drop. Annotation time_range is gated in SECONDS by the pipeline, but the wizard seeded new annotations with frame indices and labeled the inputs '(frame)' — defaults now convert via scale_s and the labels read (s). movie_export.spec.ts (9 steps, real Dask + load_test_data_movie): wizard open/tune, seconds time-range, time-gated Rect annotation, mp4 export with progress + cancel affordance, imageio validation of the bytes (6 frames, 512x512 even-cropped, per-frame diffs, annotation gating, timestamp band), the GIF path, and the trace-drop positive path (chip + inset pixel evidence vs a no-trace control). All four report specs re-run green.
Live layering: the pending layer-frame handoff is now an atomic take-and-clear under a micro-lock (a write landing between the painter's read and clear was silently dropped, leaving the overlay stale at rest); non-resident derived-view source reads no longer block the dispatcher (residency-gated via a new _NavChunkCache.is_resident probe, with a newest-wins off-thread warm so the settle re-fire converges — cached-array sources stay inline to avoid racing the cache bookkeeping); layers carry a dead flag set before every removal path so teardown can't race an in-flight refresh; the eager region read returns the un-rounded mean, matching the base exactly; and a shape-changing paint on a layered plot drops its layers cleanly (status + empty layers_state) now that anyplotlib refuses to stretch them. Movie export: error/cancel state resets marshal to the main loop like the success path; ragged frames are letterbox-padded to the writer's fixed size; cancellation is polled around the auto-contrast probe. Report: code fences close per CommonMark (same char, >= opener length — a 4-backtick fence no longer corrupts on round-trip); an empty harvested PNG falls back to the bake instead of silently writing no asset; rebind requires a shape match on title candidates and goes offline on ambiguity; callout regions come from the selector actually linked to the source and are converted from nav-index to parent data coords (connector skipped when the base panel is the signal); the interactive-export pixel resolve no longer toggles the process-global transport env (tokens are resolved directly into the panel traits); forward drag-reorder lands where dropped (pre-removal index semantics); the dead legacy save branch is gone; an empty rebuild tears down the stale window. Renderer/main: exports are token-correlated and the menu locks while one runs; printToPDF is bounded by a 30s timeout with guaranteed window teardown; report figure shadow state (latestStates/binary/iframeRefs/ reportFigures) is evicted on refresh, cell removal, and close; NumInput never propagates non-finite values; clipboard PNG writes are size-capped; shared COLORMAPS, a keyed-debounce hook, and a CellChrome component replace three copy-pasted implementations (all test-ids preserved). 175 tests green across the affected suites incl. the hot-path guards; all six e2e specs pass (report_sidebar 8/8, compose 4/4, probe 3/3, mdi_overlay 7/7, export 9/9, movie 9/9).
Owner
Author
|
Code review of the branch surfaced 32 candidate findings; 29 survived adversarial verification and ALL are now fixed in Highlights of what was fixed here:
Verification after fixes: 175 tests green across all affected pytest suites including the live-display guard suites, and all six e2e specs pass (report_sidebar 8/8, compose 4/4, probe 3/3, mdi_overlay 7/7, export 9/9, movie 9/9). |
…riven edit UI - Shift+Enter commits a markdown cell (alongside Ctrl/Cmd+Enter) - annotation coords: DATA->pixel conversion at render time (new coords.py) — anyplotlib markers interpret offsets as image pixels, so calibrated panels drew every annotation near the upper-left; new annotations now land at the image center and the spec/YAML stays calibration-aware - edit mode (repfig_set_edit_mode): annotations rebuild as draggable anyplotlib widgets (text/circle/rect/arrow, hidden handles); drags persist pixel->data on pointer_up with no figure rebuild; every export path stays static-markers; PNG harvest passes includeWidgets so edit-mode saves keep annotations - tiling: repfig_compose gains target_panel_id — fill a free neighbor cell or insert a row/column at the hovered panel's edge (interior grid cells were previously unreachable: only [0,cols]/[rows,0] ever assigned); the drop shield shows per-grid-cell zones on multi-panel figures - selection UI: interactive builds enable edit_chrome (hover outline); panel click / figure-background click emit report_panel_selected; the edit dock is selection-driven (A/B/... + Figure chips; per-panel controls vs figure-level layout + spacing + annotations view) - figure-level annotations: FigureSpec.annotations in figure-fraction coords, repfig_add/update/remove_fig_annotation + repfig_set_layout (hspace/wspace), draggable via the anyplotlib figure-marker layer, included in exports - tests: test_report_annotation_coords, test_report_edit_mode, TestTileTargeted; e2e report_tiling + report_annotations specs + Shift+Enter case; _spyde_test_report dev-only hook
… flash-free edits Round-2 edit-UX feedback: - edit mode shows resize NODES (show_handles=true for circle/rect/arrow; circle radius node, rect corners, arrow tail reshape with head pinned); resize results persist px->data (radius, center+dims, offsets+U/V) - color swatches on panel + figure annotation rows -> debounced update - panel drag-swap: anyplotlib grip event -> swap the two panels' grid_pos - layout presets (row/column/grid schematics) via repfig_apply_layout_preset - flash reduction: SeamlessFigureFrame double-buffers iframe rebuilds (promote on load+replay+2rAF); fig-annotation add/update/remove go in-place via set_figure_markers, edit-mode panel-annotation updates via widget.set — no rebuild at all on those paths; ResizeObserver -> resizeFigure so the CSS aspect-ratio cell relayouts with sidebar width - per-panel refresh (repfig_refresh_panel + per-panel button); refresh-all now reuses the same path per panel — no longer collapses a composed multi-panel figure to a single panel - overlay execute guard: repfig_compose mode=overlay refuses mismatched image sizes (query gate was correct; the execute path trusted the UI) - e2e: report_edit2.spec.ts (9 cases incl. live-recolor pixel assert inside the figure iframe) + report_compose chip-count fix
…lp + torch triage First-launch of every packaged install crashed (user bug report): setuptools_scm cannot version the shipped payload (no .git), uv sync fails, and the dev fallback masked it with 'No module named spyde'. - bundle-python.mjs stages a .spyde-version marker (from the tag-verified electron/package.json version); pythonEnv.ts feeds it to uv sync via SETUPTOOLS_SCM_PRETEND_VERSION_FOR_SPYDE - packaged builds no longer run the doomed dev fallback — the real env-setup failure is surfaced (backend_exited reason + raw output), dev unchanged - per-machine torch (win32/linux): uv sync --frozen --no-install-package torch, then uv pip install torch==<locked> --torch-backend=auto into the managed env (driver-matched CUDA or CPU wheels — no more unconditional 2.4 GiB cu124 download); ANY failure falls back to the full locked sync - raw process output is finally visible: backend-exited overlay shows the last stream lines (stderr tinted) and the Log panel gains a Raw output toggle — a startup crash is now self-diagnosing in-app - Help > GPU & CUDA dialog: requirements (driver >= ~550, Maxwell+, no toolkit install), CPU fallback, first-run download size, env location; plus a PyTorch triage — nvidia-smi + backend torch status -> plain verdict, and a one-click 'Fix PyTorch install' running the auto-backend step against the managed env with streamed progress (guarded against concurrent setup; report-only in dev)
anyplotlib 0.3.0b1 is released to PyPI with everything this branch depends on (edit-chrome/widgets, ArrowWidget, figure_markers, panel swap, the standalone -page targeted-push recolor fix, and the empty-overlay/tile fix). Bump the floor past the released 0.2.0 (a pre-release specifier so uv will resolve it) and relock against the PyPI registry instead of the local editable checkout, so CI and packaged installs (uv sync --frozen) build against the real wheel.
The orientation-mapping IPF views drew thousands of polygons; the refine correlation heatmap recolored ~9k of them on every navigator move. Adopt anyplotlib's raster/WebGPU fast paths: - refine correlation heatmap: one add_raster image (clipped to the curved sector) whose pixels swap live via raster.set(image_b64=...) per nav move, instead of per-cell polygon facecolors (_corr_rgba flips rows for the raster top=max-y origin) - IPF colour-key triangle: single add_raster RGBA (was ~14k polygons — it fed colour STRINGS to pcolormesh, which blocked the raster fast path) - IPF density heatmap: resample orix's equal-area grid onto a regular 256x256 raster (scipy griddata nearest) + add_raster clipped to the sector - 3D IPF sphere scatter: render all nav pixels (cap 20000 -> 1_000_000) and scatter3d(gpu=True) for the WebGPU instanced-points pipeline (was pinned under the 20k auto-threshold -> always Canvas2D) Verified in the real app: colour-key, density, and refine (incl. live recolor on nav move) render correctly as rasters. NOTE: the 3D scatter still falls back to Canvas2D in-app because anyplotlib skips GPU init on a panel mounted display:none (the IPF 3D window mounts hidden); a lifecycle fix is landing in anyplotlib. gpu=True + the uncapped points are correct and inert until that ships.
anyplotlib 0.3.0 is now released on PyPI (was pinned to the 0.3.0b1 beta). Bump the dependency specifier and re-lock so CI and fresh clones resolve the release from PyPI. uv.lock now points at the released sdist/wheel (registry source, no editable path). Verified: uv sync --frozen builds a clean venv, anyplotlib 0.3.0 + spyde backend + report-builder modules import cleanly, and the full report-builder pytest suite passes (171 passed).
…n-zip) These specs were red on the feat/report-phase1 baseline, independent of the anyplotlib bump: - ipf_perf + movie_export: the backend-error audits tripped on '[spyde RENDERER-ERROR] Failed to create WebGPU Context Provider', a benign warning Chromium emits from the figure iframe whenever the runner has no usable WebGPU adapter (every hosted CI runner under xvfb). anyplotlib falls back to Canvas2D and the render is still correct. Exclude this line in backendErrorLines (_harness.cjs) and in ipf_perf's inline traceback filter — matching the existing pattern for CSP / willReadFrequently / Chromium .cc(line) noise. A real Python traceback is never this renderer line, so real errors still fail the audit (verified against the exact CI log lines). - report_sidebar #6: extracted the .spyde-report with 'tar -xf', but the container is a ZIP (PK magic) — GNU tar on the Linux runner rejects it ('does not look like a tar archive'). Use 'unzip' on Linux/macOS (bsdtar reads zip on Windows, so that branch is unchanged). Verified end-to-end: a real write_report() zip extracts to the expected report.md/figures/ assets/ layout via the exact 'unzip -o -q' command.
build: bump anyplotlib pin to released 0.3.0
…page probe The `electron-e2e (ubuntu, shard 1/3)` job failed on PR #8 because both gpu_image_parity tests hung the full 600s test timeout + 120s worker teardown on the hosted (GPU-less) runner, turning a would-be skip into a shard FAILURE (the retry passed -> reported "1 flaky", but the teardown timeout still fails the shard). Root cause: the skip probe ran navigator.gpu.requestAdapter() on the TOP page, a different browsing context from the figure iframe that does the real render. On the runner the top-page probe could hand back a software adapter/device while the iframe's actual WebGPU context creation still failed ("Failed to create WebGPU Context Provider"), so the test did NOT skip and then hung waiting for an activation that never came. Fix: make the authoritative check the iframe's own GPU activation (__apl_gpu2d[pid].active, set by figure_esm _gpuDevice: adapter + device + GPUImage init) -- the only context that agrees with the render path. Wrap the existing 30s activation wait in try/catch: on timeout, close the app and test.skip(...). A runner without a usable WebGPU device now skips in ~30s instead of hanging ~12min. Verified locally (this box HAS a GPU): both tests still run fully and pass (parity fracOver 0, pan direction correct, scrub repaints).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The complete Report Builder: compose markdown + live-figure documents in a slide-out sidebar, build combined figures by dragging figures onto each other (overlay / tile / callout + annotations), layer images live in the MDI (per-layer colormap + alpha), export to HTML (static + interactive) / PDF / markdown folder, copy/paste figures (incl. OS clipboard), and export in-situ movies to mp4/gif with overlays and scalar traces.
Phase 1 — report sidebar
spyde/actions/report/: the.spyde-reportzip — human-readable inside (no JSON):report.mdwith YAML front-matter (pandoc-ready unzipped),figures/<id>.yamlFigureSpec recipes,assets/<id>.pngbaked WYSIWYG snapshots. Figures snapshot at drop with refresh-from-live;SignalRefrebinds on reload (open-tree uid → title → file fingerprint) with offline fallback. Saves harvest WYSIWYG PNGs via anyplotlibexportPNG(CSSFrancis/anyplotlib#28) with a 3s bake fallback. Renderer: ReportSidebar dock, marked+DOMPurify cells with double-click editing, captions, raw↔rendered toggle, template placeholders, pill drag-embed.Phase 2 — combined figures + MDI layering
compose.py: edge drops tile; a center drop asks which modes fit — same shape offers Overlay, navigator↔signal pair offers Callout (inset + connector, CSSFrancis/anyplotlib#29/#30). Per-cell edit toolbar: layers + annotation CRUD.overlay.py: window-onto-window drop → live layers with a Plot Control Layers section; refresh rides the existing read/paint split via a zero-cost guarded hook (no dispatcher/painter restructuring).Phase 3 — exports + copy/paste
Static HTML (article-styled, data-URL images, print-safe — the input to PDF), interactive HTML (figures fully self-contained in sandboxed srcdoc iframes; fixed the machine-local
file://shared-ESM swap that broke portability), markdown-folder export, PDF via hidden-windowprintToPDF, cell Copy/Duplicate/Paste with OS-clipboard PNG mirror, and a Copy-to-Report toolbar button on 2-D plots.Phase 4 — movie export
spyde/actions/movie_export/behind themvx_*wizard: memory-safe per-frame loop (one lazy slice per frame, guarded by tests), 256-entry LUT, PIL-drawn time-gated annotations + timestamp + scale bar, matplotlib-Agg-once trace inset with per-frame cursor, encoder seam on the already-bundled imageio-ffmpeg (H.264, even-crop;.gifvia Pillow through the same seam). Wizard: fps/downsample/stride, seconds time range, overlays, 1-D-pill trace drop, progress + cancel (partial-file cleanup).emit_progressfinally has a renderer surface (StatusBar busy %).Tests
report_sidebar8/8 ·report_phase2_probe3/3 ·mdi_overlay7/7 ·report_compose4/4 ·report_export9/9 (PDF %PDF magic; interactive export pixel-verified in a plain Chromium) ·movie_export9/9 (mp4 decoded: frame count, downsampled dims, annotation time-gating, trace-inset pixels; GIF path).Requires
anyplotlib #28 (exportPNG) → #29 (image layers) → #30 (callout connectors). Verified against the local editable checkout; bump the anyplotlib pin (or release) before merging, per the usual co-dev flow.