A named group of SASpro views and / or disk files that can be targeted together with a single drop: drop a shortcut tile onto a bundle and that shortcut runs against every view in the bundle; drop a Function Bundle onto the group and every step runs against every target in order. Views are added by dragging their tab onto the dialog or a chip; files are added through a file picker, a recursive directory scan, or by dropping them from the OS. Each bundle has a persistent UUID so chips survive bundle reordering, and the file-path path runs headlessly — no view opens, no DocManager entry is created, so you can batch-process a hundred files without touching the MDI.
The signature behaviour of this tool is mixed on-disk + in-memory targeting. A single bundle can carry both open views (identified by their document pointer) and disk files (identified by path), and when a shortcut lands on the bundle, SASpro handles each kind correctly: views get the shortcut dispatched to their MDI subwindow and process in place; files are loaded headlessly into a transient ImageDocument, processed through the same dispatcher via a fake-subwindow shim, and saved back to disk — without ever appearing in the MDI or the DocManager. The file path is the critical bit: it lets you batch 100 raw frames through a pipeline overnight without opening 100 tabs.
A View Bundle is a named group of two kinds of targets:
id() of an open ImageDocument. These live in the bundle's doc_ptrs list and are resolved to their MDI subwindow at apply time (via DocManager's resolve_doc_from_drag, which handles proxies, ROI docs, and uid/path lookup).file_paths list and are processed headlessly on apply.Each bundle also has a persistent UUID (uuid.uuid4()) that identifies it independently of its name or position in the bundle list. Chips bind to the UUID, so renaming or reordering bundles never breaks a chip's binding. Bundle data lives in QSettings under viewbundles/v3 as a JSON array; older viewbundles/v2 and viewbundles/v1 stores are automatically read on first launch.
The two panes mirror this: the left pane lists bundles, the right pane shows the targets in the currently selected bundle — views labelled [view] with their window title, files labelled [file] with their absolute path. Resolved view titles update automatically as the underlying windows are renamed.
Three ways to add open views to the current bundle:
MIME_VIEWSTATE record (JSON with doc_ptr and doc_uid).Views are stored by their current id(). If a view is closed and reopened, its pointer changes and the old entry becomes unresolvable (shown as (unresolved) [<number>] in the list); drag the new tab in to refresh. The alternative — binding by file path — is covered by the Files path (next section), where files are explicitly re-loadable on demand.
Duplicate adds are silently ignored: the bundle's view list dedupes on insert, preserving insertion order.
Files — raw calibration frames, stacked lights, masters to update — can live in a bundle without ever being opened. Three ways to add them:
.fits, .fit, .fts, .fits.gz, .fit.gz, .fz, .xisf, .tif, .tiff, .png, .jpg, .jpeg.Files are stored as absolute paths and deduplicated on insert. The “last opened folder” QSetting is updated so the next picker opens where you were.
The Views list supports multi-select and standard removal:
Per-target actions in the right pane (via right-click on a row):
Drop a shortcut tile onto the dialog background, the Views list, or the bundle's chip. SASpro parses the payload and applies the shortcut to every target in the bundle:
mw._handle_command_drop(payload, target_sw=sw) runs the shortcut in-place against that view, exactly as if the user had dropped the tile on that view directly.Views are processed first, files second. All errors (per-view and per-file) are collected and summarised at the end in a single dialog. The summary distinguishes view errors from file errors so you can see where things went wrong.
Dropping a nested bundle payload (command_id = "bundle") onto a View Bundle is ignored — bundle recursion isn't allowed, since the semantics (apply this bundle's views to that bundle's views?) are undefined. A function_bundle drop is the correct way to fan a multi-step sequence out across the view bundle; see the next section.
Dropping a Function Bundle (command_id = "function_bundle") onto the dialog or chip runs its whole step sequence against every target in the view bundle, in order:
The waiter between steps (described in the Function Bundles doc) applies to view targets but is skipped for file targets — the headless file path runs each step synchronously on the transient document, so there's no asynchronous completion to wait for.
A function bundle dropped from the Function Bundle Chip carries its full step payload inline (the chip was designed to be a dependency-free drop target); dropping from the Function Bundles dialog works the same way. If the function bundle is empty, nothing happens.
Compress to Chip spawns an orange widget — the view-bundle chip — on the shortcut canvas. The chip is bound to the bundle's UUID (not its position), so it survives bundle reordering, renaming, duplication, and .sass import.
Chip behaviours:
bundle payload ({"command_id": "bundle", "steps": […], "bundle_uuid": "…"}). This isn't meant to be dropped on a target to execute — it's a reference payload for chaining into other systems.The badge shows the total target count (views + files combined) and recolours by composition: brown-orange when the bundle has open views, dark amber when it has only files, grey when empty. The title tracks the bundle's name live.
Multiple chips can live on the canvas at once (one per bundle). On first spawn, chips land near the upper-centre of the canvas, offset slightly per chip, so function-bundle chips (which land upper-right) don't collide.
Internal mechanisms numbered continuing from the usage sections.
The file-path target is the technically interesting part. Rather than opening each file in the MDI (which would create a visible window, a DocManager entry, and a documentAdded signal cascade), SASpro runs each file through a shim chain that reuses the normal command dispatcher on a transient document.
For each file target, the chain is:
The two shim classes are small by design:
_HeadlessView — wraps the transient ImageDocument. Exposes .document (so command handlers that read sw.widget().document work) and a best-effort .apply_command(cid, preset) fallback that calls the main-window's apply_command_to_view hook if the normal dispatch path isn't taken for some reason._FakeSubWindow — wraps a _HeadlessView and exposes .widget() and .windowTitle(). Any command handler that calls sw.widget().document gets the transient doc; any that calls sw.windowTitle() gets the document's display name.This design means nearly every SASpro command works headlessly out of the box, because they already go through the target_sw interface. The transient document is never added to the DocManager, so no signals fire, no UI updates, and the garbage collector can reclaim it once the save completes.
After all steps have run against the transient document, the result is saved back to disk via the legacy save_image writer, which mirrors DocManager's parameter mapping:
The default target path is the source path (overwrite). An alternate out_dir can be passed through the UI-based save path (_apply_payload_to_single_file_via_ui) if a non-destructive copy is wanted; the headless path currently always overwrites.
Format is inferred from the target path's extension. JPEG is normalised to jpg. Original header, bit depth, and mono flag flow through from the loaded document's metadata so round-tripping preserves what matters.
Views are identified by id(), which is specific to a particular ImageDocument instance. Resolving a stored doc_ptr back to the live doc and its subwindow goes through two layers:
DocManager.resolve_doc_from_drag(doc_ptr=…). The DocManager knows about proxies (undo-safe clones), ROI documents (crops of a parent), and uid / file-path lookup — so if the user closed and reopened the file, resolve can still find it by the stable uid or path.id(sw.widget().document) against the stored pointer, and also comparing the pointer against any _doc / _document / _base underlying attribute the doc might expose.If both layers fail, the view is marked (unresolved) in the targets list and skipped at apply time (not an error, just not applied).
Some older code paths and some third-party scripts wrap command payloads a layer too deep, like:
The _unwrap_cmd_payload helper detects this shape (outer command_id is itself a dict containing command_id) and returns the inner dict. All drop handlers run their incoming payload through unwrap first, so wrapped payloads apply correctly without changes elsewhere.
Chip positions are stored in QSettings under viewbundles/chips_v1 as a JSON array of { "uuid": …, "x": …, "y": … } records. Each record is keyed by the bundle UUID (not its index), so chips survive bundle reordering and renaming.
Writes happen on every drag release, bundle delete, and compress-to-chip call. Restore runs at dialog construction time: for each saved record, the matching bundle is looked up by UUID; if it still exists, a chip is spawned on the shortcut canvas with the saved position. Missing bundles (deleted outside the UI, or imported-away) are silently skipped.
Startup chip restore is driven by restore_view_bundle_chips(parent), which constructs the dialog singleton with auto_spawn_only=True — the dialog stays hidden, but its chips land on the canvas.
Each bundle gets a uuid.uuid4() on creation. The UUID is what makes chips independent of bundle ordering and renaming: when a chip looks up its bundle, it does so by UUID, not by index. Duplicating a bundle generates a fresh UUID (so the duplicate gets its own chip if compressed); importing bundles from a .sass file preserves the saved UUIDs unless they collide with existing ones (in which case the imported bundle keeps its data but may conflict — current import does not resolve collisions, which is a known edge case for cross-user bundle sharing).
The older viewbundles/v2 and viewbundles/v1 schemas didn't carry UUIDs; the loader back-fills a fresh UUID per bundle on first read, which is why upgrading SASpro doesn't lose existing bundles but may re-anchor any pre-existing chip layout.
MIME_CMD), the view-state tab-drag protocol (MIME_VIEWSTATE), the main-window drop dispatcher (_handle_command_drop), DocManager's resolve_doc_from_drag, the legacy image I/O (load_image / save_image), and the shortcut-canvas chip layer. The headless file-execution shim is new work for this tool; there are no external algorithmic references — this tool orchestrates SASpro's existing commands rather than introducing new image-processing primitives. Qt and PyQt6 provide the UI framework, drag-and-drop infrastructure, and QSettings persistence.