diff --git a/benchmark/demos/official30_lab_meeting.py b/benchmark/demos/official30_lab_meeting.py index 0579832fc..d05bd685f 100644 --- a/benchmark/demos/official30_lab_meeting.py +++ b/benchmark/demos/official30_lab_meeting.py @@ -23,6 +23,7 @@ JSONValue, ManifestCasePayload, ) +from openhcs.constants.constants import AllComponents from openhcs.core.aligned_image_payload import AlignedImageSliceContext from openhcs.core.artifacts import ArtifactType, MeasurementsArtifactType from openhcs.core.config import ( @@ -30,7 +31,11 @@ LazyPathPlanningConfig, LazyWellFilterConfig, ) -from openhcs.core.input_workspace import InputWorkspacePreparationRequest +from openhcs.core.input_workspace import ( + InputWorkspacePreparationRequest, + InputWorkspacePreparationResult, +) +from openhcs.core.source_matching import source_component_metadata_values from openhcs.core.steps.function_output_manifest import ( FunctionStepOutputProducerIdentityRequest, ) @@ -212,6 +217,33 @@ def _stream_only_declared_step( return streamed_steps +def _materialized_well_labels( + prepared: InputWorkspacePreparationResult, +) -> str | list[str]: + """Project exact execution wells from the prepared workspace authority.""" + + if prepared.materialization is None: + raise ValueError( + "Official30 lab-meeting inputs must materialize a source-binding " + "workspace with exact component metadata." + ) + labels = sorted( + { + value + for metadata in prepared.materialization.source_metadata.values() + for value in source_component_metadata_values( + metadata, + AllComponents.WELL, + ) + } + ) + if not labels: + raise ValueError( + "Official30 lab-meeting workspace declares no exact well labels." + ) + return labels[0] if len(labels) == 1 else labels + + def official30_lab_meeting_demo_contributions( *, session_root: Path, @@ -259,7 +291,7 @@ def official30_lab_meeting_demo_contributions( pipeline_config = replace( pipeline_config, well_filter_config=LazyWellFilterConfig( - well_filter=well_filter_config.well_filter, + well_filter=_materialized_well_labels(prepared), well_filter_mode=well_filter_config.well_filter_mode, ), path_planning_config=LazyPathPlanningConfig( diff --git a/docs/media_gallery_capture.md b/docs/media_gallery_capture.md new file mode 100644 index 000000000..fac239a9e --- /dev/null +++ b/docs/media_gallery_capture.md @@ -0,0 +1,239 @@ +# Media gallery capture and delivery + +`scripts/capture_media_gallery.py` captures real X11 application windows and +derives bounded website media from immutable source captures. It does not draw, +blur, replace, synthesize, or otherwise fabricate application content. Changes +to visible UI state must happen in the application before capture. + +The workflow keeps three concerns separate: + +1. OpenHCS and its integrations create the real session. +2. A lossless PNG or FFV1 Matroska file records that session. +3. One typed JSON manifest owns every crop, trim, output name, resolution bound, + size bound, frame rate, and poster timestamp. + +Captions, scenario descriptions, feature claims, and website layout do not +belong in the manifest. + +## Host requirements + +Run the capability report before a capture session: + +```bash +python scripts/capture_media_gallery.py doctor +``` + +The report includes executable paths and versions so the exact toolchain can be +preserved with the capture report. + +- Building and validating media requires FFmpeg and FFprobe. +- `capture-still` additionally requires ImageMagick. +- `record-window` additionally requires `xdotool` and an X11 display. + +Wayland-only hosts can record a lossless PNG or Matroska source with their +compositor's capture tool, then use the same manifest-based plan, build, and +validate commands. Missing optional tools produce an actionable error rather +than an import failure. + +## Capture immutable sources + +List visible X11 windows and note the ID of the intended window: + +```bash +wmctrl -lx +``` + +Capture a still: + +```bash +python scripts/capture_media_gallery.py capture-still \ + --source-root /absolute/private/gallery-captures \ + --window-id 0x04600007 \ + --output raw/interface.png +``` + +Record a fixed window rectangle as lossless FFV1: + +```bash +python scripts/capture_media_gallery.py record-window \ + --source-root /absolute/private/gallery-captures \ + --window-id 0x04600007 \ + --output raw/interaction.mkv \ + --duration-seconds 18 \ + --fps 30 \ + --display "${DISPLAY}" +``` + +Keep the selected window stationary while recording. The command resolves its +geometry once so that another window cannot enter the frame if the target is +moved. Both capture commands refuse to overwrite an existing source. + +## Declare derivatives once + +The manifest has no separate format field. The validated filename suffix selects +the authoritative WebP, MP4, WebM, or GIF encoding declaration, so a format +string cannot drift from its codec. + +```json +{ + "schema_version": 1, + "captures": [ + { + "source": "raw/interface.png", + "crop": { + "x": 20, + "y": 40, + "width": 1800, + "height": 1080 + }, + "outputs": [ + { + "filename": "interface-overview.webp", + "max_width": 1600, + "max_height": 1000, + "max_bytes": 1200000 + } + ] + }, + { + "source": "raw/interaction.mkv", + "crop": { + "x": 20, + "y": 40, + "width": 1800, + "height": 1080 + }, + "trim": { + "start_seconds": 3.25, + "duration_seconds": 10 + }, + "outputs": [ + { + "filename": "interaction-poster.webp", + "max_width": 1600, + "max_height": 1000, + "max_bytes": 1200000, + "frame_at_seconds": 1.5 + }, + { + "filename": "interaction.webm", + "max_width": 1600, + "max_height": 1000, + "max_bytes": 6000000, + "fps": 24 + }, + { + "filename": "interaction.mp4", + "max_width": 1600, + "max_height": 1000, + "max_bytes": 8000000, + "fps": 24 + }, + { + "filename": "interaction.gif", + "max_width": 960, + "max_height": 600, + "max_bytes": 12000000, + "fps": 12 + } + ] + } + ] +} +``` + +Motion trims are limited to 30 seconds. Derived filenames must be lowercase +basenames made from letters, numbers, and single hyphens. This keeps URLs stable +and prevents an output from escaping the destination root. + +Use WebM as the primary website animation, MP4 as the broad compatibility +fallback, and the poster WebP for reduced-motion and unloaded states. GIF is +optional and intended for sharing where video is unavailable; it is usually +larger than either video encoding. + +## Plan, build, and verify + +Inspect exact commands before writing: + +```bash +python scripts/capture_media_gallery.py plan \ + --manifest /absolute/private/gallery-captures/manifest.json \ + --source-root /absolute/private/gallery-captures \ + --output-root website/assets/gallery +``` + +`plan` validates paths and typed records but does not require source files, probe +media, create directories, or write outputs. + +Build each derivative atomically and preserve the JSON report: + +```bash +python scripts/capture_media_gallery.py build \ + --manifest /absolute/private/gallery-captures/manifest.json \ + --source-root /absolute/private/gallery-captures \ + --output-root website/assets/gallery \ + > /absolute/private/gallery-captures/build-report.json +``` + +The report records source and derivative SHA-256 hashes, dimensions, byte sizes, +codecs, and durations. Existing derivatives are rejected by default. After +reviewing the manifest and source, `--force` may atomically replace derivatives; +it never permits replacing a source capture. + +Re-run verification independently before staging website assets: + +```bash +python scripts/capture_media_gallery.py validate \ + --manifest /absolute/private/gallery-captures/manifest.json \ + --source-root /absolute/private/gallery-captures \ + --output-root website/assets/gallery \ + > /absolute/private/gallery-captures/validation-report.json +``` + +Validation fails if an output is missing, uses the wrong codec, exceeds its +dimensions or byte budget, or has a duration inconsistent with the declared +trim. Encoder versions can change exact compressed bytes, so reproducibility +means preserving the manifest, source hash, command plan, toolchain report, and +build report together. + +## Privacy and scientific-integrity review + +Complete this review before capture: + +- Use redistributable example data or a session whose visible metadata is safe + to publish. +- Close unrelated windows, terminals, notification surfaces, file managers, and + private browser tabs. +- Use public-safe plate names and paths in the actual running application. +- Disable desktop notifications for the recording period. +- Avoid showing credentials, tokens, usernames, hostnames, patient or subject + identifiers, unpublished measurements, and private local paths. + +Complete this review after capture: + +- Inspect every lossless source at full resolution before deriving outputs. +- Inspect each poster, the first and last frame, and the full animation. +- Confirm that cropping excludes only irrelevant window chrome or surrounding + desktop space. Cropping is not a substitute for redaction. +- If private content appears inside the intended application frame, correct the + real session and capture it again. Do not paint over the source. +- Confirm that the final caption describes only behavior visible in the media + and verified in the running software. +- Keep lossless sources and reports in controlled storage. Publish only reviewed + derivatives. + +## Reproducible capture record + +For each accepted media group, retain: + +- the source PNG or FFV1 Matroska file; +- the JSON manifest; +- the `doctor` toolchain report; +- the dry-run command plan; +- the build and validation reports; +- dataset provenance and redistribution evidence; +- the OpenHCS commit and package version; +- the human privacy and claim-verification sign-off. + +These records are capture provenance, not a second registry of OpenHCS +scenarios or website claims. diff --git a/openhcs/mcp/dev_client_core.py b/openhcs/mcp/dev_client_core.py index 5acc835ee..b50e7600f 100644 --- a/openhcs/mcp/dev_client_core.py +++ b/openhcs/mcp/dev_client_core.py @@ -44,7 +44,10 @@ ) from openhcs.serialization.json import to_jsonable from openhcs.constants.constants import AllComponents, OrchestratorState -from openhcs.core.execution_state import TerminalExecutionStatus +from openhcs.core.execution_state import ( + ManagerExecutionState, + TerminalExecutionStatus, +) from openhcs.core.native_threading import native_thread_count_environment_keys from openhcs.core.plate_file_inventory import ALL_PLATE_FILE_KINDS from openhcs.mcp.control_timeout import ( @@ -230,15 +233,17 @@ class McpDevServerSpec: def environment(self) -> dict[str, str]: """Environment entries inherited by the fresh MCP subprocess.""" - return AgentRuntimePlatformAuthority.current().project_child_process_environment( - os.environ, - include_graphical_session=True, - additional_keys=( - *self.mcp_environment_keys, - *AgentPathPolicy.environment_keys(), - *OpenHCSProcessEnvironment.child_process_environment_keys(), - *native_thread_count_environment_keys(), - ), + return ( + AgentRuntimePlatformAuthority.current().project_child_process_environment( + os.environ, + include_graphical_session=True, + additional_keys=( + *self.mcp_environment_keys, + *AgentPathPolicy.environment_keys(), + *OpenHCSProcessEnvironment.child_process_environment_keys(), + *native_thread_count_environment_keys(), + ), + ) ) def process_args(self) -> tuple[str, ...]: @@ -1852,6 +1857,8 @@ def workflow_poll_has_reached_terminal_state( target_scope_ids: tuple[str, ...], policy: WorkflowStatePollPolicy, ) -> bool: + if not workflow_poll_manager_is_idle(result): + return False rows = workflow_poll_target_rows( result, target_scope_ids=target_scope_ids, @@ -1868,6 +1875,8 @@ def workflow_poll_terminal_status( target_scope_ids: tuple[str, ...], policy: WorkflowStatePollPolicy, ) -> WorkflowPollSummaryStatus | None: + if not workflow_poll_manager_is_idle(result): + return None rows = workflow_poll_target_rows( result, target_scope_ids=target_scope_ids, @@ -1881,6 +1890,17 @@ def workflow_poll_terminal_status( return None +def workflow_poll_manager_is_idle(result: McpDevToolResult) -> bool: + """Return whether the workflow owner has completed batch finalization.""" + + state_payload = state_surface_payload(result) + manager_state = optional_str(state_payload.get("manager_execution_state")) + try: + return ManagerExecutionState(manager_state) is ManagerExecutionState.IDLE + except (TypeError, ValueError): + return False + + def workflow_poll_target_rows( result: McpDevToolResult, *, diff --git a/openhcs/processing/backends/analysis/skeletonize_and_save.py b/openhcs/processing/backends/analysis/skeletonize_and_save.py index 403ed0171..2bac9f680 100644 --- a/openhcs/processing/backends/analysis/skeletonize_and_save.py +++ b/openhcs/processing/backends/analysis/skeletonize_and_save.py @@ -36,20 +36,19 @@ class SkeletonizationResult: foreground_area_pixels: int threshold: float + @numpy @artifact_outputs( ArtifactSpec( "skeleton_measurements", MeasurementsArtifactType, - materialization=MaterializationSpec( - CsvOptions(filename_suffix="_details.csv") - ), + materialization=MaterializationSpec(CsvOptions(filename_suffix="_details.csv")), relations=(ArtifactMeasurementSubjectRelation(),), ), ArtifactSpec( "skeleton_rois", ObjectLabelsArtifactType, - materialization=MaterializationSpec(ROIOptions()), + materialization=MaterializationSpec(ROIOptions(min_area=1)), ), ) def skeletonize_and_save( @@ -91,8 +90,7 @@ def skeletonize_and_save( image_array = np.asarray(image) if image_array.ndim != 3: raise ValueError( - "skeletonize_and_save expects a 3D array with shape (Z, Y, X) " - "or (C, Y, X)" + "skeletonize_and_save expects a 3D array with shape (Z, Y, X) or (C, Y, X)" ) if image_array.shape[1] == 0 or image_array.shape[2] == 0: raise ValueError("skeletonize_and_save requires non-empty image planes") diff --git a/openhcs/pyqt_gui/app.py b/openhcs/pyqt_gui/app.py index 0eb79fbd0..0f8b7a3b8 100644 --- a/openhcs/pyqt_gui/app.py +++ b/openhcs/pyqt_gui/app.py @@ -130,7 +130,9 @@ def init_function_registry_background(): from openhcs.core.config import GlobalPipelineConfig # Set for editing (UI placeholders) - this uses threading.local() storage - set_global_config_for_editing(GlobalPipelineConfig, self.pipeline_runtime_config) + set_global_config_for_editing( + GlobalPipelineConfig, self.pipeline_runtime_config + ) # ALSO ensure context for orchestrator creation (required by orchestrator.__init__) ensure_global_config_context(GlobalPipelineConfig, self.pipeline_runtime_config) @@ -196,7 +198,8 @@ def show_main_window( self, *, on_deferred_initialization_complete: Callable[[], None] | None = None, - on_deferred_initialization_failed: Callable[[BaseException], None] | None = None, + on_deferred_initialization_failed: Callable[[BaseException], None] + | None = None, ): """Show the main window and schedule its authoritative ready boundary.""" if self.main_window is None: @@ -210,10 +213,8 @@ def show_main_window( # This includes log viewer and default windows (pipeline editor) from PyQt6.QtCore import QTimer - def _run_deferred_initialization() -> None: + def _report_deferred_initialization_complete() -> None: try: - self.main_window.deferred_initialization() - self.processEvents() if on_deferred_initialization_complete is not None: on_deferred_initialization_complete() except Exception as error: @@ -221,6 +222,19 @@ def _run_deferred_initialization() -> None: raise on_deferred_initialization_failed(error) + def _run_deferred_initialization() -> None: + try: + self.main_window.deferred_initialization() + # Return control to Qt before reporting readiness. A nested + # processEvents() call can be kept alive indefinitely by + # continuously firing timers, preventing the application's + # normal event loop and UI bridge dispatch from progressing. + QTimer.singleShot(0, _report_deferred_initialization_complete) + except Exception as error: + if on_deferred_initialization_failed is None: + raise + on_deferred_initialization_failed(error) + QTimer.singleShot(100, _run_deferred_initialization) def on_config_changed(self, new_config: GlobalPipelineConfig): diff --git a/openhcs/pyqt_gui/services/ui_agent_bridge.py b/openhcs/pyqt_gui/services/ui_agent_bridge.py index 99eac29dc..8ce4f140e 100644 --- a/openhcs/pyqt_gui/services/ui_agent_bridge.py +++ b/openhcs/pyqt_gui/services/ui_agent_bridge.py @@ -202,6 +202,9 @@ def validate(self, source: str) -> tuple[AgentError, ...]: visitor = DeclarativeCodeDocumentAstValidator( allowed_import_roots=self.allowed_import_roots, + allowed_external_value_types=( + PlateManagerCodeDocumentAuthority.declared_external_value_types() + ), allowed_builtin_references=self.allowed_builtin_references, ) visitor.visit(tree) @@ -215,9 +218,11 @@ def __init__( self, *, allowed_import_roots: frozenset[str], + allowed_external_value_types: frozenset[type[object]], allowed_builtin_references: frozenset[str], ) -> None: self._allowed_import_roots = allowed_import_roots + self._allowed_external_value_types = allowed_external_value_types self._allowed_builtin_references = allowed_builtin_references self._imported_names: set[str] = set() self._path_constructor_names: set[str] = set() @@ -251,13 +256,22 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: self._path_constructor_names.add(imported_name) return root_name = module_name.split(".", maxsplit=1)[0] - if root_name not in self._allowed_import_roots: - self._error("unsafe_import", f"Import is not allowed: {module_name}") - return for alias in node.names: if alias.name == "*": self._error("unsafe_import", "Wildcard imports are not allowed.") continue + if ( + root_name not in self._allowed_import_roots + and not self._is_declared_external_value_import( + module_name, + alias.name, + ) + ): + self._error( + "unsafe_import", + f"Import is not allowed: {module_name}.{alias.name}", + ) + continue self._imported_names.add(alias.asname or alias.name) def visit_Assign(self, node: ast.Assign) -> None: @@ -416,6 +430,17 @@ def _is_path_expression(self, node: ast.expr) -> bool: def _error(self, code: str, message: str) -> None: self.errors.append(AgentError(code=code, message=message)) + def _is_declared_external_value_import( + self, + module_name: str, + imported_name: str, + ) -> bool: + return any( + declared_type.__module__ == module_name + and declared_type.__name__ == imported_name + for declared_type in self._allowed_external_value_types + ) + class UiCodeDocumentExecutionService: """Executes validated code-mode source through existing manager hooks.""" diff --git a/openhcs/ui/shared/plate_manager_code_document.py b/openhcs/ui/shared/plate_manager_code_document.py index c4f5555ce..cb9e47acb 100644 --- a/openhcs/ui/shared/plate_manager_code_document.py +++ b/openhcs/ui/shared/plate_manager_code_document.py @@ -3,10 +3,11 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from dataclasses import dataclass +from dataclasses import dataclass, fields, is_dataclass from enum import Enum +from functools import cache from pathlib import Path -from typing import Self +from typing import Any, Self, get_args, get_origin, get_type_hints from openhcs.core.config import GlobalPipelineConfig, PipelineConfig from openhcs.core.function_step_transport import FunctionStepTransportAuthority @@ -57,6 +58,18 @@ class PlateManagerCodeDocumentAuthority: HEADER = "# Edit this orchestrator configuration and save to apply changes" + @classmethod + def declared_external_value_types(cls) -> frozenset[type[object]]: + """Return external value types reachable from the document config roots. + + The document renderer emits the defining import for enum and dataclass + values. Derive those trusted external types from the same config classes + accepted by :meth:`from_namespace`, so UI source validation cannot drift + into a separately maintained dependency allowlist. + """ + + return _declared_external_value_types() + @classmethod def from_values( cls, @@ -250,3 +263,49 @@ def _scope_id(value: object, *, field_name: str) -> str: if not isinstance(value, (str, Path)): raise TypeError(f"{field_name} must be a plate path string or Path.") return PlateScopeIdentity.from_scope_id(str(value)).scope_id + + +@cache +def _declared_external_value_types() -> frozenset[type[object]]: + declared_types = _reachable_declared_types((GlobalPipelineConfig, PipelineConfig)) + return frozenset( + declared_type + for declared_type in declared_types + if declared_type.__module__.split(".", maxsplit=1)[0] + not in {"builtins", "openhcs"} + ) + + +def _reachable_declared_types( + roots: Sequence[type[object]], +) -> frozenset[type[object]]: + pending = list(roots) + visited: set[type[object]] = set() + while pending: + declared_type = pending.pop() + if declared_type in visited: + continue + visited.add(declared_type) + if not is_dataclass(declared_type): + continue + annotations = get_type_hints(declared_type, include_extras=True) + for declared_field in fields(declared_type): + annotation = annotations.get(declared_field.name, declared_field.type) + pending.extend(_annotation_types(annotation)) + return frozenset(visited) + + +def _annotation_types(annotation: object) -> tuple[type[object], ...]: + if annotation is Any: + return () + if isinstance(annotation, type): + return (annotation,) + return ( + tuple( + nested_type + for member in get_args(annotation) + for nested_type in _annotation_types(member) + ) + if get_origin(annotation) is not None + else () + ) diff --git a/scripts/build_website.py b/scripts/build_website.py index c8fb64061..d0606b359 100644 --- a/scripts/build_website.py +++ b/scripts/build_website.py @@ -41,7 +41,6 @@ "assets/logos/openhcs-stacked.svg": ( "openhcs/resources/assets/openhcs-lockup-stacked.svg" ), - "assets/ui.png": "docs/source/_static/ui.png", } REQUIRED_COPY = ( "PyPI", @@ -160,12 +159,42 @@ def handle_starttag( if element_id in self.ids: self.duplicate_ids.add(element_id) self.ids.add(element_id) - for attribute in ("href", "src"): + for attribute in ("href", "src", "poster"): value = attributes.get(attribute) if value: self.references.append((attribute, value)) +def referenced_source_files(source_dir: Path) -> tuple[str, ...]: + """Return local files selected by the website documents themselves. + + HTML owns which downloadable and browser-loaded media ship. Deriving the + copy set from those references prevents a second asset inventory and avoids + publishing unrelated files merely because they share an asset directory. + """ + + source_dir = source_dir.resolve() + referenced: set[str] = set() + for relative_name in HTML_SOURCE_FILES: + document_path = source_dir / relative_name + collector = _ReferenceCollector() + collector.feed(document_path.read_text(encoding="utf-8")) + for _attribute, reference in collector.references: + parsed = urlsplit(reference) + if ( + parsed.scheme + or reference.startswith("//") + or parsed.path.startswith("/") + or not parsed.path + ): + continue + candidate = (document_path.parent / unquote(parsed.path)).resolve() + if not candidate.is_relative_to(source_dir) or not candidate.is_file(): + continue + referenced.add(candidate.relative_to(source_dir).as_posix()) + return tuple(sorted(referenced)) + + def _safe_output(repo_root: Path, output_dir: Path) -> Path: repo_root = repo_root.resolve() output_dir = output_dir.resolve() @@ -276,7 +305,10 @@ def build_site(repo_root: Path, output_dir: Path) -> tuple[str, ...]: shutil.rmtree(output_dir) output_dir.mkdir(parents=True) - for relative_name in SOURCE_FILES: + source_files = tuple( + dict.fromkeys((*SOURCE_FILES, *referenced_source_files(source_dir))) + ) + for relative_name in source_files: source = source_dir / relative_name destination = output_dir / relative_name if not source.is_file(): diff --git a/scripts/capture_media_gallery.py b/scripts/capture_media_gallery.py new file mode 100644 index 000000000..9f67e3091 --- /dev/null +++ b/scripts/capture_media_gallery.py @@ -0,0 +1,1379 @@ +#!/usr/bin/env python3 +"""Capture real application windows and derive validated web gallery media. + +The tool never changes pixels to fabricate UI state. It can capture a real +window, crop a source capture, trim a recording, transcode it, and validate the +derived media against one typed JSON manifest. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import math +import os +import re +import shutil +import subprocess +import sys +import tempfile +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import Any, Mapping, Sequence + +SCHEMA_VERSION = 1 +MAX_DERIVED_MOTION_SECONDS = 30.0 +MAX_RAW_RECORDING_SECONDS = 180.0 +OUTPUT_NAME_PATTERN = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*\.(?:gif|mp4|webm|webp)$") +WINDOW_ID_PATTERN = re.compile(r"^(?:0x[0-9a-fA-F]+|[1-9][0-9]*)$") +DISPLAY_PATTERN = re.compile(r"^(?:[A-Za-z0-9_.-]+)?:\d+(?:\.\d+)?$") + + +class MediaGalleryError(RuntimeError): + """Raised when capture input or generated media violates the contract.""" + + +class MediaCategory(Enum): + """Whether a media file is a still image or a time-varying recording.""" + + STILL = "still" + MOTION = "motion" + + +@dataclass(frozen=True) +class HostToolDeclaration: + """One external executable and its non-mutating version command.""" + + executable: str + version_arguments: tuple[str, ...] + + +class HostTool(Enum): + """External tools used by capture and transcoding operations.""" + + FFMPEG = HostToolDeclaration("ffmpeg", ("-version",)) + FFPROBE = HostToolDeclaration("ffprobe", ("-version",)) + MAGICK = HostToolDeclaration("magick", ("-version",)) + XDOTOOL = HostToolDeclaration("xdotool", ("version",)) + + +@dataclass(frozen=True) +class SourceFormatDeclaration: + """Source suffix and its media category.""" + + suffix: str + category: MediaCategory + + +class SourceFormat(Enum): + """Lossless and common source-capture formats accepted by the tool.""" + + PNG = SourceFormatDeclaration(".png", MediaCategory.STILL) + TIFF = SourceFormatDeclaration(".tif", MediaCategory.STILL) + TIFF_LONG = SourceFormatDeclaration(".tiff", MediaCategory.STILL) + WEBP = SourceFormatDeclaration(".webp", MediaCategory.STILL) + MATROSKA = SourceFormatDeclaration(".mkv", MediaCategory.MOTION) + QUICKTIME = SourceFormatDeclaration(".mov", MediaCategory.MOTION) + MP4 = SourceFormatDeclaration(".mp4", MediaCategory.MOTION) + WEBM = SourceFormatDeclaration(".webm", MediaCategory.MOTION) + + @classmethod + def for_path(cls, path: Path) -> SourceFormat: + """Return the declaration selected by a capture's file suffix.""" + + suffix = path.suffix.lower() + for source_format in cls: + if source_format.value.suffix == suffix: + return source_format + choices = ", ".join(item.value.suffix for item in cls) + raise MediaGalleryError( + f"Unsupported source format {suffix or ''!r}; expected one of " + f"{choices}." + ) + + +@dataclass(frozen=True) +class OutputEncodingDeclaration: + """Authoritative web encoding declaration for one output suffix.""" + + suffix: str + category: MediaCategory + codec_name: str + ffmpeg_arguments: tuple[str, ...] + map_arguments: tuple[str, ...] = ("-map", "0:v:0") + filter_option: str = "-vf" + filter_template: str = "{filters}" + + def render_filter(self, filters: str) -> tuple[str, str]: + """Render the encoding's filter option without caller-side dispatch.""" + + return self.filter_option, self.filter_template.format(filters=filters) + + +class OutputEncoding(Enum): + """Supported web derivatives and their deterministic FFmpeg contracts.""" + + WEBP = OutputEncodingDeclaration( + suffix=".webp", + category=MediaCategory.STILL, + codec_name="webp", + ffmpeg_arguments=( + "-frames:v", + "1", + "-c:v", + "libwebp", + "-preset", + "picture", + "-quality", + "84", + "-compression_level", + "6", + "-threads", + "1", + ), + ) + MP4 = OutputEncodingDeclaration( + suffix=".mp4", + category=MediaCategory.MOTION, + codec_name="h264", + ffmpeg_arguments=( + "-an", + "-c:v", + "libx264", + "-preset", + "slow", + "-crf", + "20", + "-pix_fmt", + "yuv420p", + "-movflags", + "+faststart", + "-threads", + "1", + ), + ) + WEBM = OutputEncodingDeclaration( + suffix=".webm", + category=MediaCategory.MOTION, + codec_name="vp9", + ffmpeg_arguments=( + "-an", + "-c:v", + "libvpx-vp9", + "-crf", + "30", + "-b:v", + "0", + "-pix_fmt", + "yuv420p", + "-row-mt", + "0", + "-threads", + "1", + ), + ) + GIF = OutputEncodingDeclaration( + suffix=".gif", + category=MediaCategory.MOTION, + codec_name="gif", + ffmpeg_arguments=("-an", "-loop", "0", "-threads", "1"), + map_arguments=("-map", "[gallery_output]"), + filter_option="-filter_complex", + filter_template=( + "{filters},split[gallery_a][gallery_b];" + "[gallery_a]palettegen=max_colors=192:" + "reserve_transparent=0:stats_mode=diff[gallery_palette];" + "[gallery_b][gallery_palette]paletteuse=" + "dither=sierra2_4a:diff_mode=rectangle[gallery_output]" + ), + ) + + @classmethod + def for_path(cls, path: Path) -> OutputEncoding: + """Return the single encoding authority selected by output suffix.""" + + suffix = path.suffix.lower() + for encoding in cls: + if encoding.value.suffix == suffix: + return encoding + choices = ", ".join(item.value.suffix for item in cls) + raise MediaGalleryError( + f"Unsupported output format {suffix or ''!r}; expected one of " + f"{choices}." + ) + + +@dataclass(frozen=True) +class Crop: + """A rectangular crop in source pixels.""" + + x: int + y: int + width: int + height: int + + def __post_init__(self) -> None: + if self.x < 0 or self.y < 0: + raise MediaGalleryError("Crop x and y must be non-negative.") + if self.width <= 0 or self.height <= 0: + raise MediaGalleryError("Crop width and height must be positive.") + + @property + def ffmpeg_filter(self) -> str: + """Return the exact FFmpeg crop expression.""" + + return f"crop={self.width}:{self.height}:{self.x}:{self.y}" + + +@dataclass(frozen=True) +class Trim: + """A bounded interval selected from a source recording.""" + + start_seconds: float + duration_seconds: float + + def __post_init__(self) -> None: + if not math.isfinite(self.start_seconds) or self.start_seconds < 0: + raise MediaGalleryError( + "Trim start_seconds must be a finite non-negative number." + ) + if ( + not math.isfinite(self.duration_seconds) + or self.duration_seconds <= 0 + or self.duration_seconds > MAX_DERIVED_MOTION_SECONDS + ): + raise MediaGalleryError( + "Trim duration_seconds must be greater than zero and no more than " + f"{MAX_DERIVED_MOTION_SECONDS:g}." + ) + + @property + def end_seconds(self) -> float: + """Return the exclusive end of the selected source interval.""" + + return self.start_seconds + self.duration_seconds + + +@dataclass(frozen=True) +class Derivative: + """One output file and all bounds required to validate it.""" + + filename: str + max_width: int + max_height: int + max_bytes: int + fps: int | None = None + frame_at_seconds: float | None = None + + def __post_init__(self) -> None: + if not OUTPUT_NAME_PATTERN.fullmatch(self.filename): + raise MediaGalleryError( + "Output filename must be a lowercase caption-safe basename using " + "letters, numbers, and single hyphens." + ) + if self.max_width <= 0 or self.max_height <= 0: + raise MediaGalleryError("Output dimensions must be positive.") + if self.max_bytes <= 0: + raise MediaGalleryError("Output max_bytes must be positive.") + if self.fps is not None and not 1 <= self.fps <= 60: + raise MediaGalleryError("Output fps must be between 1 and 60.") + if self.frame_at_seconds is not None and ( + not math.isfinite(self.frame_at_seconds) or self.frame_at_seconds < 0 + ): + raise MediaGalleryError( + "Output frame_at_seconds must be a finite non-negative number." + ) + + @property + def path(self) -> Path: + """Return the validated output basename as a Path.""" + + return Path(self.filename) + + @property + def encoding(self) -> OutputEncoding: + """Resolve web encoding from the authoritative output suffix.""" + + return OutputEncoding.for_path(self.path) + + @property + def scale_filter(self) -> str: + """Fit within bounds without upscaling and keep codec-safe dimensions.""" + + return ( + f"scale=w='min(iw,{self.max_width})':" + f"h='min(ih,{self.max_height})':" + "force_original_aspect_ratio=decrease:force_divisible_by=2" + ) + + +@dataclass(frozen=True) +class CaptureRecord: + """One immutable source capture and all authorized derivatives.""" + + source: Path + outputs: tuple[Derivative, ...] + crop: Crop | None = None + trim: Trim | None = None + + def __post_init__(self) -> None: + _validate_relative_path(self.source, "Capture source") + if not self.outputs: + raise MediaGalleryError("Each capture must declare at least one output.") + output_names = tuple(output.filename for output in self.outputs) + if len(set(output_names)) != len(output_names): + raise MediaGalleryError( + f"Capture {self.source} declares duplicate output filenames." + ) + + source_category = self.source_format.value.category + if source_category is MediaCategory.STILL and self.trim is not None: + raise MediaGalleryError("Still captures cannot declare a trim interval.") + if source_category is MediaCategory.MOTION and self.trim is None: + raise MediaGalleryError( + "Motion captures must declare an explicit bounded trim interval." + ) + + for output in self.outputs: + output_category = output.encoding.value.category + if source_category is MediaCategory.STILL: + if output_category is not MediaCategory.STILL: + raise MediaGalleryError( + "Still captures can only produce still WebP derivatives." + ) + if output.fps is not None or output.frame_at_seconds is not None: + raise MediaGalleryError( + "Still derivatives cannot declare fps or frame_at_seconds." + ) + continue + + if output_category is MediaCategory.MOTION: + if output.fps is None: + raise MediaGalleryError( + f"Motion output {output.filename} must declare fps." + ) + if output.frame_at_seconds is not None: + raise MediaGalleryError( + "Motion outputs cannot declare frame_at_seconds." + ) + continue + + if output.fps is not None or output.frame_at_seconds is None: + raise MediaGalleryError( + f"Poster output {output.filename} must declare " + "frame_at_seconds and no fps." + ) + assert self.trim is not None + if output.frame_at_seconds > self.trim.duration_seconds: + raise MediaGalleryError( + f"Poster frame for {output.filename} falls outside the trim." + ) + + @property + def source_format(self) -> SourceFormat: + """Resolve source semantics from its suffix.""" + + return SourceFormat.for_path(self.source) + + +@dataclass(frozen=True) +class CaptureManifest: + """The sole authority for source transformations and output constraints.""" + + captures: tuple[CaptureRecord, ...] + schema_version: int = SCHEMA_VERSION + + def __post_init__(self) -> None: + if self.schema_version != SCHEMA_VERSION: + raise MediaGalleryError( + f"Unsupported schema_version {self.schema_version}; expected " + f"{SCHEMA_VERSION}." + ) + if not self.captures: + raise MediaGalleryError("The manifest must contain at least one capture.") + source_names = tuple(str(capture.source) for capture in self.captures) + if len(set(source_names)) != len(source_names): + raise MediaGalleryError("Manifest capture source paths must be unique.") + output_names = tuple( + output.filename for capture in self.captures for output in capture.outputs + ) + if len(set(output_names)) != len(output_names): + raise MediaGalleryError( + "Manifest output filenames must be globally unique." + ) + + +@dataclass(frozen=True) +class MediaProbe: + """Relevant FFprobe facts for one source or derivative.""" + + width: int + height: int + codec_name: str + duration_seconds: float | None + + +@dataclass(frozen=True) +class WindowGeometry: + """Absolute X11 geometry for a selected visible window.""" + + x: int + y: int + width: int + height: int + + def __post_init__(self) -> None: + if self.width <= 0 or self.height <= 0: + raise MediaGalleryError("Selected window has invalid dimensions.") + + +@dataclass(frozen=True) +class PreparedCapture: + """A source capture that has passed path, existence, probe, and crop checks.""" + + record: CaptureRecord + source_path: Path + output_paths: tuple[Path, ...] + probe: MediaProbe + + +def _expect_object(value: Any, context: str) -> Mapping[str, Any]: + if not isinstance(value, dict): + raise MediaGalleryError(f"{context} must be a JSON object.") + if not all(isinstance(key, str) for key in value): + raise MediaGalleryError(f"{context} keys must be strings.") + return value + + +def _expect_array(value: Any, context: str) -> Sequence[Any]: + if not isinstance(value, list): + raise MediaGalleryError(f"{context} must be a JSON array.") + return value + + +def _validate_keys( + payload: Mapping[str, Any], + *, + required: frozenset[str], + optional: frozenset[str], + context: str, +) -> None: + keys = frozenset(payload) + missing = sorted(required - keys) + unknown = sorted(keys - required - optional) + if missing: + raise MediaGalleryError(f"{context} is missing fields: {', '.join(missing)}.") + if unknown: + raise MediaGalleryError( + f"{context} contains unknown fields: {', '.join(unknown)}." + ) + + +def _number(value: Any, context: str) -> float: + if isinstance(value, bool) or not isinstance(value, (int, float)): + raise MediaGalleryError(f"{context} must be a number.") + return float(value) + + +def _integer(value: Any, context: str) -> int: + if isinstance(value, bool) or not isinstance(value, int): + raise MediaGalleryError(f"{context} must be an integer.") + return value + + +def _optional_integer( + payload: Mapping[str, Any], + field: str, + context: str, +) -> int | None: + if field not in payload: + return None + return _integer(payload[field], f"{context}.{field}") + + +def _optional_number( + payload: Mapping[str, Any], + field: str, + context: str, +) -> float | None: + if field not in payload: + return None + return _number(payload[field], f"{context}.{field}") + + +def _parse_crop(value: Any, context: str) -> Crop: + payload = _expect_object(value, context) + fields = frozenset({"x", "y", "width", "height"}) + _validate_keys(payload, required=fields, optional=frozenset(), context=context) + return Crop( + x=_integer(payload["x"], f"{context}.x"), + y=_integer(payload["y"], f"{context}.y"), + width=_integer(payload["width"], f"{context}.width"), + height=_integer(payload["height"], f"{context}.height"), + ) + + +def _parse_trim(value: Any, context: str) -> Trim: + payload = _expect_object(value, context) + fields = frozenset({"start_seconds", "duration_seconds"}) + _validate_keys(payload, required=fields, optional=frozenset(), context=context) + return Trim( + start_seconds=_number(payload["start_seconds"], f"{context}.start_seconds"), + duration_seconds=_number( + payload["duration_seconds"], + f"{context}.duration_seconds", + ), + ) + + +def _parse_derivative(value: Any, context: str) -> Derivative: + payload = _expect_object(value, context) + _validate_keys( + payload, + required=frozenset({"filename", "max_width", "max_height", "max_bytes"}), + optional=frozenset({"fps", "frame_at_seconds"}), + context=context, + ) + filename = payload["filename"] + if not isinstance(filename, str): + raise MediaGalleryError(f"{context}.filename must be a string.") + return Derivative( + filename=filename, + max_width=_integer(payload["max_width"], f"{context}.max_width"), + max_height=_integer(payload["max_height"], f"{context}.max_height"), + max_bytes=_integer(payload["max_bytes"], f"{context}.max_bytes"), + fps=_optional_integer(payload, "fps", context), + frame_at_seconds=_optional_number(payload, "frame_at_seconds", context), + ) + + +def _parse_capture(value: Any, context: str) -> CaptureRecord: + payload = _expect_object(value, context) + _validate_keys( + payload, + required=frozenset({"source", "outputs"}), + optional=frozenset({"crop", "trim"}), + context=context, + ) + source = payload["source"] + if not isinstance(source, str): + raise MediaGalleryError(f"{context}.source must be a string.") + outputs = tuple( + _parse_derivative(output, f"{context}.outputs[{index}]") + for index, output in enumerate( + _expect_array(payload["outputs"], f"{context}.outputs") + ) + ) + crop = ( + _parse_crop(payload["crop"], f"{context}.crop") if "crop" in payload else None + ) + trim = ( + _parse_trim(payload["trim"], f"{context}.trim") if "trim" in payload else None + ) + return CaptureRecord( + source=Path(source), + outputs=outputs, + crop=crop, + trim=trim, + ) + + +def load_manifest(path: Path) -> CaptureManifest: + """Load and fully validate one capture manifest.""" + + try: + document = json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise MediaGalleryError(f"Could not read manifest {path}: {error}") from error + payload = _expect_object(document, "Manifest") + _validate_keys( + payload, + required=frozenset({"schema_version", "captures"}), + optional=frozenset(), + context="Manifest", + ) + return CaptureManifest( + schema_version=_integer(payload["schema_version"], "Manifest.schema_version"), + captures=tuple( + _parse_capture(capture, f"Manifest.captures[{index}]") + for index, capture in enumerate( + _expect_array(payload["captures"], "Manifest.captures") + ) + ), + ) + + +def _validate_relative_path(path: Path, context: str) -> None: + if path.is_absolute() or path == Path(".") or ".." in path.parts: + raise MediaGalleryError( + f"{context} must be a contained relative path without '..': {path}" + ) + + +def resolve_contained_path( + root: Path, + relative_path: Path, + *, + context: str, +) -> Path: + """Resolve a relative path and reject lexical or symlink root escapes.""" + + _validate_relative_path(relative_path, context) + root = root.resolve() + candidate = root.joinpath(relative_path) + if candidate.is_symlink(): + raise MediaGalleryError(f"{context} cannot be a symbolic link: {relative_path}") + resolved_parent = candidate.parent.resolve() + try: + resolved_parent.relative_to(root) + except ValueError as error: + raise MediaGalleryError( + f"{context} escapes its declared root: {relative_path}" + ) from error + return resolved_parent / candidate.name + + +def _require_tool(tool: HostTool) -> str: + executable = shutil.which(tool.value.executable) + if executable is None: + raise MediaGalleryError( + f"Required host tool {tool.value.executable!r} is unavailable. " + "Install FFmpeg for " + "ffmpeg/ffprobe, ImageMagick for magick, or xdotool for X11 window " + "geometry." + ) + return executable + + +def run_checked(command: Sequence[str]) -> subprocess.CompletedProcess[str]: + """Run a command without a shell and return its captured output.""" + + process = subprocess.run( + command, + check=False, + capture_output=True, + text=True, + ) + if process.returncode != 0: + detail = process.stderr.strip() or process.stdout.strip() or "no output" + raise MediaGalleryError( + f"Command failed ({process.returncode}): {' '.join(command)}\n{detail}" + ) + return process + + +def probe_media(path: Path) -> MediaProbe: + """Read dimensions, codec, and duration with FFprobe.""" + + ffprobe = _require_tool(HostTool.FFPROBE) + process = run_checked( + ( + ffprobe, + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=codec_name,width,height,duration:format=duration", + "-of", + "json", + str(path), + ) + ) + try: + payload = json.loads(process.stdout) + stream = payload["streams"][0] + width = int(stream["width"]) + height = int(stream["height"]) + codec_name = str(stream["codec_name"]) + if "duration" in stream: + raw_duration = stream["duration"] + elif "duration" in payload["format"]: + raw_duration = payload["format"]["duration"] + else: + raw_duration = None + duration = None if raw_duration in (None, "N/A") else float(raw_duration) + except (IndexError, KeyError, TypeError, ValueError, json.JSONDecodeError) as error: + raise MediaGalleryError( + f"FFprobe returned incomplete media metadata for {path}." + ) from error + return MediaProbe( + width=width, + height=height, + codec_name=codec_name, + duration_seconds=duration, + ) + + +def _format_seconds(value: float) -> str: + return f"{value:.6f}".rstrip("0").rstrip(".") + + +def _filters(record: CaptureRecord, output: Derivative) -> str: + filters = [] + if record.crop is not None: + filters.append(record.crop.ffmpeg_filter) + filters.append(output.scale_filter) + if output.fps is not None: + filters.append(f"fps={output.fps}") + filters.append("setsar=1") + return ",".join(filters) + + +def build_transcode_command( + record: CaptureRecord, + output: Derivative, + source_path: Path, + target_path: Path, + *, + ffmpeg_executable: str | None = None, +) -> tuple[str, ...]: + """Project a typed record into one deterministic FFmpeg command.""" + + ffmpeg = ( + ffmpeg_executable + if ffmpeg_executable is not None + else _require_tool(HostTool.FFMPEG) + ) + command: list[str] = [ + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-y", + "-i", + str(source_path), + ] + if record.trim is not None: + seek_seconds = record.trim.start_seconds + if output.frame_at_seconds is not None: + seek_seconds += output.frame_at_seconds + command.extend(("-ss", _format_seconds(seek_seconds))) + if output.encoding.value.category is MediaCategory.MOTION: + command.extend(("-t", _format_seconds(record.trim.duration_seconds))) + command.extend(output.encoding.value.render_filter(_filters(record, output))) + command.extend(output.encoding.value.map_arguments) + command.extend(output.encoding.value.ffmpeg_arguments) + command.extend( + ( + "-bitexact", + "-map_metadata", + "-1", + "-metadata", + "creation_time=1970-01-01T00:00:00Z", + ) + ) + command.append(str(target_path)) + return tuple(command) + + +def _validate_source( + record: CaptureRecord, + source_path: Path, + probe: MediaProbe, +) -> None: + if record.crop is not None and ( + record.crop.x + record.crop.width > probe.width + or record.crop.y + record.crop.height > probe.height + ): + raise MediaGalleryError( + f"Crop for {record.source} exceeds its {probe.width}x{probe.height} " + "source bounds." + ) + if record.trim is not None: + if probe.duration_seconds is None: + raise MediaGalleryError( + f"Motion source {source_path} has no probeable duration." + ) + if record.trim.end_seconds > probe.duration_seconds + 0.02: + raise MediaGalleryError( + f"Trim ending at {record.trim.end_seconds:g}s exceeds source " + f"duration {probe.duration_seconds:g}s." + ) + + +def validate_derivative( + record: CaptureRecord, + output: Derivative, + target_path: Path, + probe: MediaProbe | None = None, +) -> dict[str, Any]: + """Validate one derivative and return reproducibility facts.""" + + if not target_path.is_file(): + raise MediaGalleryError(f"Missing derived media: {target_path}") + size_bytes = target_path.stat().st_size + if size_bytes > output.max_bytes: + raise MediaGalleryError( + f"{output.filename} is {size_bytes} bytes, above its manifest bound " + f"of {output.max_bytes}." + ) + probe = probe if probe is not None else probe_media(target_path) + if probe.width > output.max_width or probe.height > output.max_height: + raise MediaGalleryError( + f"{output.filename} is {probe.width}x{probe.height}, above its " + f"{output.max_width}x{output.max_height} manifest bounds." + ) + if probe.codec_name != output.encoding.value.codec_name: + raise MediaGalleryError( + f"{output.filename} codec is {probe.codec_name!r}, expected " + f"{output.encoding.value.codec_name!r}." + ) + if output.encoding.value.category is MediaCategory.MOTION: + if probe.duration_seconds is None: + raise MediaGalleryError( + f"Motion derivative {output.filename} has no duration." + ) + assert record.trim is not None + tolerance = max(0.1, 2.0 / (output.fps or 1)) + if abs(probe.duration_seconds - record.trim.duration_seconds) > tolerance: + raise MediaGalleryError( + f"{output.filename} duration is {probe.duration_seconds:g}s; " + f"expected {record.trim.duration_seconds:g}s within " + f"{tolerance:g}s." + ) + return { + "path": output.filename, + "bytes": size_bytes, + "sha256": sha256_file(target_path), + "width": probe.width, + "height": probe.height, + "codec": probe.codec_name, + "duration_seconds": probe.duration_seconds, + } + + +def sha256_file(path: Path) -> str: + """Hash one source or derivative without loading it into memory.""" + + digest = hashlib.sha256() + with path.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + return digest.hexdigest() + + +def _paths_for_capture( + record: CaptureRecord, + source_root: Path, + output_root: Path, +) -> tuple[Path, tuple[Path, ...]]: + source_path = resolve_contained_path( + source_root, + record.source, + context="Capture source", + ) + output_paths = tuple( + resolve_contained_path( + output_root, + output.path, + context="Derived output", + ) + for output in record.outputs + ) + if source_path in output_paths: + raise MediaGalleryError( + f"Refusing to overwrite source capture with a derivative: {source_path}" + ) + return source_path, output_paths + + +def plan_manifest( + manifest: CaptureManifest, + source_root: Path, + output_root: Path, +) -> tuple[dict[str, Any], ...]: + """Return exact commands without writing or probing media.""" + + plans = [] + for record in manifest.captures: + source_path, output_paths = _paths_for_capture( + record, + source_root, + output_root, + ) + ffmpeg_name = HostTool.FFMPEG.value.executable + ffmpeg = shutil.which(ffmpeg_name) or ffmpeg_name + commands = [ + list( + build_transcode_command( + record, + output, + source_path, + target_path, + ffmpeg_executable=ffmpeg, + ) + ) + for output, target_path in zip( + record.outputs, + output_paths, + strict=True, + ) + ] + plans.append( + { + "source": str(source_path), + "outputs": [str(path) for path in output_paths], + "commands": commands, + } + ) + return tuple(plans) + + +def _temporary_target(target_path: Path) -> Path: + target_path.parent.mkdir(parents=True, exist_ok=True) + file_descriptor, temporary_name = tempfile.mkstemp( + prefix=f".{target_path.stem}-", + suffix=target_path.suffix, + dir=target_path.parent, + ) + os.close(file_descriptor) + temporary_path = Path(temporary_name) + temporary_path.unlink() + return temporary_path + + +def _prepare_captures( + manifest: CaptureManifest, + source_root: Path, + output_root: Path, +) -> tuple[PreparedCapture, ...]: + prepared = [] + for record in manifest.captures: + source_path, output_paths = _paths_for_capture( + record, + source_root, + output_root, + ) + if not source_path.is_file(): + raise MediaGalleryError(f"Source capture does not exist: {source_path}") + source_probe = probe_media(source_path) + _validate_source(record, source_path, source_probe) + prepared.append( + PreparedCapture( + record=record, + source_path=source_path, + output_paths=output_paths, + probe=source_probe, + ) + ) + return tuple(prepared) + + +def build_manifest( + manifest: CaptureManifest, + source_root: Path, + output_root: Path, + *, + force: bool = False, +) -> tuple[dict[str, Any], ...]: + """Build all derivatives atomically and validate every manifest bound.""" + + prepared_captures = _prepare_captures(manifest, source_root, output_root) + if not force: + existing_outputs = tuple( + target_path + for prepared in prepared_captures + for target_path in prepared.output_paths + if target_path.exists() + ) + if existing_outputs: + formatted_paths = "\n".join(f"- {path}" for path in existing_outputs) + raise MediaGalleryError( + "Derived outputs already exist. Pass --force only after reviewing " + f"the sources and manifest:\n{formatted_paths}" + ) + + report = [] + for prepared in prepared_captures: + record = prepared.record + source_path = prepared.source_path + source_probe = prepared.probe + source_report = { + "path": str(record.source), + "sha256": sha256_file(source_path), + "width": source_probe.width, + "height": source_probe.height, + "duration_seconds": source_probe.duration_seconds, + } + outputs_report = [] + for output, target_path in zip( + record.outputs, + prepared.output_paths, + strict=True, + ): + temporary_path = _temporary_target(target_path) + try: + command = build_transcode_command( + record, + output, + source_path, + temporary_path, + ) + run_checked(command) + output_report = validate_derivative( + record, + output, + temporary_path, + ) + os.replace(temporary_path, target_path) + finally: + temporary_path.unlink(missing_ok=True) + outputs_report.append(output_report) + report.append({"source": source_report, "outputs": outputs_report}) + return tuple(report) + + +def validate_manifest_outputs( + manifest: CaptureManifest, + source_root: Path, + output_root: Path, +) -> tuple[dict[str, Any], ...]: + """Validate existing sources and derivatives without modifying them.""" + + report = [] + for prepared in _prepare_captures(manifest, source_root, output_root): + record = prepared.record + source_path = prepared.source_path + outputs_report = tuple( + validate_derivative(record, output, target_path) + for output, target_path in zip( + record.outputs, + prepared.output_paths, + strict=True, + ) + ) + report.append( + { + "source": { + "path": str(record.source), + "sha256": sha256_file(source_path), + }, + "outputs": outputs_report, + } + ) + return tuple(report) + + +def _validate_window_id(window_id: str) -> str: + if not WINDOW_ID_PATTERN.fullmatch(window_id): + raise MediaGalleryError( + "Window ID must be a positive decimal X11 ID or hexadecimal 0x ID." + ) + return window_id + + +def read_window_geometry(window_id: str) -> WindowGeometry: + """Read the selected X11 window's absolute geometry through xdotool.""" + + xdotool = _require_tool(HostTool.XDOTOOL) + process = run_checked( + (xdotool, "getwindowgeometry", "--shell", _validate_window_id(window_id)) + ) + values: dict[str, int] = {} + for line in process.stdout.splitlines(): + key, separator, raw_value = line.partition("=") + if separator and key in {"X", "Y", "WIDTH", "HEIGHT"}: + try: + values[key] = int(raw_value) + except ValueError as error: + raise MediaGalleryError( + f"xdotool returned invalid {key} geometry." + ) from error + missing = sorted({"X", "Y", "WIDTH", "HEIGHT"} - values.keys()) + if missing: + raise MediaGalleryError( + f"xdotool did not report complete window geometry: {', '.join(missing)}." + ) + return WindowGeometry( + x=values["X"], + y=values["Y"], + width=values["WIDTH"], + height=values["HEIGHT"], + ) + + +def _capture_target( + source_root: Path, + output: Path, + expected_suffix: str, +) -> Path: + target_path = resolve_contained_path( + source_root, + output, + context="Raw capture output", + ) + if target_path.suffix.lower() != expected_suffix: + raise MediaGalleryError( + f"Raw capture output must use {expected_suffix}: {output}" + ) + if target_path.exists(): + raise MediaGalleryError(f"Refusing to overwrite source capture: {target_path}") + return target_path + + +def capture_window_still( + source_root: Path, + output: Path, + window_id: str, +) -> dict[str, Any]: + """Capture a real X11 window losslessly without overwriting any source.""" + + magick = _require_tool(HostTool.MAGICK) + target_path = _capture_target(source_root, output, ".png") + temporary_path = _temporary_target(target_path) + try: + run_checked( + ( + magick, + "import", + "-window", + _validate_window_id(window_id), + str(temporary_path), + ) + ) + probe = probe_media(temporary_path) + os.replace(temporary_path, target_path) + finally: + temporary_path.unlink(missing_ok=True) + return { + "path": str(output), + "sha256": sha256_file(target_path), + "width": probe.width, + "height": probe.height, + } + + +def record_window( + source_root: Path, + output: Path, + window_id: str, + *, + duration_seconds: float, + fps: int, + display: str, +) -> dict[str, Any]: + """Record a fixed real X11 window rectangle into a lossless FFV1 source.""" + + if ( + not math.isfinite(duration_seconds) + or duration_seconds <= 0 + or duration_seconds > MAX_RAW_RECORDING_SECONDS + ): + raise MediaGalleryError( + "Raw recording duration must be greater than zero and no more than " + f"{MAX_RAW_RECORDING_SECONDS:g} seconds." + ) + if not 1 <= fps <= 60: + raise MediaGalleryError("Raw recording fps must be between 1 and 60.") + if not DISPLAY_PATTERN.fullmatch(display): + raise MediaGalleryError(f"Invalid X11 DISPLAY value: {display!r}") + target_path = _capture_target(source_root, output, ".mkv") + geometry = read_window_geometry(window_id) + ffmpeg = _require_tool(HostTool.FFMPEG) + temporary_path = _temporary_target(target_path) + try: + run_checked( + ( + ffmpeg, + "-hide_banner", + "-loglevel", + "error", + "-nostdin", + "-y", + "-f", + "x11grab", + "-draw_mouse", + "1", + "-framerate", + str(fps), + "-video_size", + f"{geometry.width}x{geometry.height}", + "-i", + f"{display}+{geometry.x},{geometry.y}", + "-t", + _format_seconds(duration_seconds), + "-an", + "-c:v", + "ffv1", + "-level", + "3", + "-g", + "1", + "-threads", + "1", + str(temporary_path), + ) + ) + probe = probe_media(temporary_path) + os.replace(temporary_path, target_path) + finally: + temporary_path.unlink(missing_ok=True) + return { + "path": str(output), + "sha256": sha256_file(target_path), + "width": probe.width, + "height": probe.height, + "duration_seconds": probe.duration_seconds, + } + + +def doctor() -> dict[str, Any]: + """Report availability and versions from the typed host-tool authority.""" + + capabilities = {} + for tool in HostTool: + executable = shutil.which(tool.value.executable) + version = None + if executable is not None: + process = run_checked((executable, *tool.value.version_arguments)) + lines = process.stdout.splitlines() or process.stderr.splitlines() + version = lines[0] if lines else "version command returned no text" + capabilities[tool.value.executable] = { + "available": executable is not None, + "path": executable, + "version": version, + } + return {"schema_version": SCHEMA_VERSION, "tools": capabilities} + + +def _write_json(value: Any) -> None: + print(json.dumps(value, indent=2, sort_keys=True)) + + +def _manifest_arguments(parser: argparse.ArgumentParser) -> None: + parser.add_argument("--manifest", type=Path, required=True) + parser.add_argument("--source-root", type=Path, required=True) + parser.add_argument("--output-root", type=Path, required=True) + + +def _doctor_operation(_arguments: argparse.Namespace) -> dict[str, Any]: + return doctor() + + +def _plan_operation(arguments: argparse.Namespace) -> tuple[dict[str, Any], ...]: + return plan_manifest( + load_manifest(arguments.manifest), + arguments.source_root, + arguments.output_root, + ) + + +def _build_operation(arguments: argparse.Namespace) -> tuple[dict[str, Any], ...]: + return build_manifest( + load_manifest(arguments.manifest), + arguments.source_root, + arguments.output_root, + force=arguments.force, + ) + + +def _validate_operation(arguments: argparse.Namespace) -> tuple[dict[str, Any], ...]: + return validate_manifest_outputs( + load_manifest(arguments.manifest), + arguments.source_root, + arguments.output_root, + ) + + +def _capture_still_operation(arguments: argparse.Namespace) -> dict[str, Any]: + return capture_window_still( + arguments.source_root, + arguments.output, + arguments.window_id, + ) + + +def _record_window_operation(arguments: argparse.Namespace) -> dict[str, Any]: + return record_window( + arguments.source_root, + arguments.output, + arguments.window_id, + duration_seconds=arguments.duration_seconds, + fps=arguments.fps, + display=arguments.display, + ) + + +def build_parser() -> argparse.ArgumentParser: + """Build the command-line parser.""" + + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + doctor_parser = subparsers.add_parser( + "doctor", + help="Report capture and encoding tools.", + ) + doctor_parser.set_defaults(operation=_doctor_operation) + + plan_parser = subparsers.add_parser( + "plan", + help="Validate the manifest and print exact commands without writing.", + ) + _manifest_arguments(plan_parser) + plan_parser.set_defaults(operation=_plan_operation) + + build_parser_instance = subparsers.add_parser( + "build", + help="Build and validate manifest derivatives atomically.", + ) + _manifest_arguments(build_parser_instance) + build_parser_instance.add_argument( + "--force", + action="store_true", + help="Atomically replace existing derived outputs, never source captures.", + ) + build_parser_instance.set_defaults(operation=_build_operation) + + validate_parser = subparsers.add_parser( + "validate", + help="Probe and validate existing source and derivative media.", + ) + _manifest_arguments(validate_parser) + validate_parser.set_defaults(operation=_validate_operation) + + still_parser = subparsers.add_parser( + "capture-still", + help="Capture one real X11 window into a lossless PNG source.", + ) + still_parser.add_argument("--source-root", type=Path, required=True) + still_parser.add_argument("--output", type=Path, required=True) + still_parser.add_argument("--window-id", required=True) + still_parser.set_defaults(operation=_capture_still_operation) + + record_parser = subparsers.add_parser( + "record-window", + help="Record one fixed real X11 window rectangle into lossless FFV1 MKV.", + ) + record_parser.add_argument("--source-root", type=Path, required=True) + record_parser.add_argument("--output", type=Path, required=True) + record_parser.add_argument("--window-id", required=True) + record_parser.add_argument("--duration-seconds", type=float, required=True) + record_parser.add_argument("--fps", type=int, default=30) + record_parser.add_argument( + "--display", + default=os.environ.get("DISPLAY", ":0"), + help="X11 display used by FFmpeg x11grab.", + ) + record_parser.set_defaults(operation=_record_window_operation) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + """Run the selected capture/transcoding command.""" + + arguments = build_parser().parse_args(argv) + try: + _write_json(arguments.operation(arguments)) + return 0 + except MediaGalleryError as error: + print(f"error: {error}", file=sys.stderr) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/master_multi_plate_demo.py b/scripts/master_multi_plate_demo.py index a844fc933..74d14152b 100644 --- a/scripts/master_multi_plate_demo.py +++ b/scripts/master_multi_plate_demo.py @@ -873,7 +873,10 @@ def prepare_all(self, schedule: Sequence[ScheduledDemo]) -> None: ), "--json", ), - timeout_seconds=30.0, + timeout_seconds=max( + 30.0, + self.workflow_timeout_seconds, + ), retry_window_seconds=30.0, ), tool_name="openhcs_inspect_pipeline_source_artifact_plan", @@ -1006,6 +1009,8 @@ def _wait_for_operation( sort_keys=True, ), "--json", + "--timeout-seconds", + str(timeout_seconds + 5.0), ), timeout_seconds=timeout_seconds + 5.0, ) diff --git a/scripts/mcp_assay_showcase.py b/scripts/mcp_assay_showcase.py index 1d4f3e9a8..a566b6b11 100644 --- a/scripts/mcp_assay_showcase.py +++ b/scripts/mcp_assay_showcase.py @@ -445,7 +445,6 @@ def _segmentation_step_source(name: str, *, stream_to_napari: bool) -> str: "min_cell_area": 20, "max_cell_area": 800, "remove_border_cells": False, - "return_segmentation_mask": True, }}, ), processing_config=LazyProcessingConfig( @@ -536,18 +535,18 @@ def _dual_channel_source(plate_path: Path, output_path: Path) -> str: { "w1": MetaXpressWavelengthSettings( channel_index=0, - approx_min_width=6.0, - approx_max_width=20.0, + approx_min_width=3.0, + approx_max_width=12.0, intensity_above_local_background=2500.0, ), "w2": MetaXpressW2Settings( channel_index=1, - approx_min_width=6.0, - approx_max_width=20.0, - intensity_above_local_background=1500.0, + approx_min_width=3.0, + approx_max_width=12.0, + intensity_above_local_background=100.0, stained_area=StainedArea.NUCLEUS, ), - "minimum_stained_area": 20.0, + "minimum_stained_area": 10.0, }, ), processing_config=LazyProcessingConfig( @@ -577,6 +576,7 @@ def _colocalization_source(plate_path: Path, output_path: Path) -> str: measure_colocalization, ) from openhcs.processing.backends.processors.numpy_processor import ( + NumpyStackProjectionMethod, create_projection, ) from openhcs.processing.backends.cellprofiler.spreadsheet_export import ( @@ -613,7 +613,7 @@ def _colocalization_source(plate_path: Path, output_path: Path) -> str: func=( create_projection, { - "method": "min_projection", + "method": NumpyStackProjectionMethod.MIN, }, ), processing_config=LazyProcessingConfig( diff --git a/tests/unit/agent/test_mcp_server.py b/tests/unit/agent/test_mcp_server.py index 389ab686d..fd5c2b5e8 100644 --- a/tests/unit/agent/test_mcp_server.py +++ b/tests/unit/agent/test_mcp_server.py @@ -285,9 +285,7 @@ def test_mcp_server_publishes_canonical_instructions(): assert "complete selected surface" in built.instructions health_index = built.instructions.index("openhcs_health_check") first_use_index = built.instructions.index("kind='first_use'") - capability_search_index = built.instructions.index( - "openhcs_search_capabilities" - ) + capability_search_index = built.instructions.index("openhcs_search_capabilities") capability_index = built.instructions.index("openhcs_list_capabilities") assert health_index < first_use_index < capability_search_index < capability_index assert "kind='first_use' before choosing tools" in built.instructions @@ -712,9 +710,7 @@ def test_mcp_tool_descriptions_expose_debugging_result_contracts(): assert "include_response" in viewer_payload_properties assert "axis_indices" in viewer_payload_properties assert "array_slices" in viewer_payload_properties - assert "bounded image records" in descriptions[ - "openhcs_sample_viewer_window_image" - ] + assert "bounded image records" in descriptions["openhcs_sample_viewer_window_image"] viewer_sample_properties = schemas["openhcs_sample_viewer_window_image"][ "properties" ] @@ -6477,8 +6473,7 @@ def test_mcp_dev_client_sample_plate_image_command_renders_compact_summary(): "resolution_shape=1x24x24 downsample_yx=4.0x4.0" ) in rendered assert ( - "Statistics: scope=bounded_sample dtype=uint16 min=0 max=65535 " - "mean=123.457" + "Statistics: scope=bounded_sample dtype=uint16 min=0 max=65535 mean=123.457" ) in rendered assert "Sample: origin_yx=0x0 shape=1x2x2 included=True" in rendered assert "[\n [\n [\n 1," in rendered @@ -8134,9 +8129,7 @@ def test_mcp_dev_client_code_documents_command_renders_compact_summary(): import openhcs.mcp.dev_client as dev_client parser = dev_client._build_parser() - args = parser.parse_args( - ("code-documents", "--contains", "plate", "--limit", "1") - ) + args = parser.parse_args(("code-documents", "--contains", "plate", "--limit", "1")) response = { "errors": [], "results": [ @@ -10144,9 +10137,7 @@ def test_mcp_dev_client_state_surfaces_renders_compact_summary(): import openhcs.mcp.dev_client as dev_client parser = dev_client._build_parser() - args = parser.parse_args( - ("state-surfaces", "--contains", "plate", "--limit", "1") - ) + args = parser.parse_args(("state-surfaces", "--contains", "plate", "--limit", "1")) response = { "errors": [], "results": [ @@ -10718,6 +10709,7 @@ def test_mcp_dev_client_workflow_poll_filters_target_scope_ids(): payloads=( { "payload": { + "manager_execution_state": "idle", "rows": [ { "plate_scope_id": "scope-a", @@ -10729,7 +10721,7 @@ def test_mcp_dev_client_workflow_poll_filters_target_scope_ids(): "compile_pending": True, "compiled": False, }, - ] + ], } }, ), @@ -10753,6 +10745,44 @@ def test_mcp_dev_client_workflow_poll_filters_target_scope_ids(): ) +def test_mcp_dev_client_workflow_poll_waits_for_manager_finalization(): + import openhcs.mcp.dev_client as dev_client + + result = dev_client.McpDevToolResult( + tool="openhcs_ui_get_state_surface", + mcp_error=False, + payloads=( + { + "payload": { + "manager_execution_state": "running", + "rows": [ + { + "plate_scope_id": "scope-a", + "compile_pending": False, + "compiled": True, + } + ], + } + }, + ), + ) + policy = dev_client.WorkflowStatePollPolicy.from_workflow_text("compile_plate") + + assert not dev_client.workflow_poll_has_reached_terminal_state( + result, + target_scope_ids=("scope-a",), + policy=policy, + ) + assert ( + dev_client.workflow_poll_terminal_status( + result, + target_scope_ids=("scope-a",), + policy=policy, + ) + is None + ) + + def test_mcp_dev_client_workflow_poll_reports_failed_terminal_rows(): import openhcs.mcp.dev_client as dev_client @@ -10766,6 +10796,7 @@ def test_mcp_dev_client_workflow_poll_reports_failed_terminal_rows(): payloads=( { "payload": { + "manager_execution_state": "idle", "rows": [ { "plate_scope_id": "scope-a", @@ -10773,7 +10804,7 @@ def test_mcp_dev_client_workflow_poll_reports_failed_terminal_rows(): "queue_position": None, "terminal_status": "failed", } - ] + ], } }, ), @@ -10784,6 +10815,7 @@ def test_mcp_dev_client_workflow_poll_reports_failed_terminal_rows(): payloads=( { "payload": { + "manager_execution_state": "idle", "rows": [ { "plate_scope_id": "scope-a", @@ -10791,7 +10823,7 @@ def test_mcp_dev_client_workflow_poll_reports_failed_terminal_rows(): "compiled": False, "orchestrator_state": "compile_failed", } - ] + ], } }, ), @@ -10852,6 +10884,7 @@ async def fake_call_tool(session, call, timeout_seconds): { "current_revision_token": f"rev-{state_call_count}", "payload": { + "manager_execution_state": ("idle" if compiled else "running"), "object_state_token": state_call_count, "rows": [ { @@ -10973,6 +11006,7 @@ async def fake_call_tool(session, call, timeout_seconds): { "current_revision_token": f"rev-{state_call_count}", "payload": { + "manager_execution_state": ("idle" if compiled else "running"), "object_state_token": state_call_count, "rows": [ { @@ -11082,6 +11116,7 @@ async def fake_call_tool(session, call, timeout_seconds): { "current_revision_token": "terminal", "payload": { + "manager_execution_state": "idle", "object_state_token": 2, "rows": [ { @@ -11267,6 +11302,7 @@ async def fake_call_tool(session, call, timeout_seconds): { "current_revision_token": f"rev-{state_call_count}", "payload": { + "manager_execution_state": ("idle" if failed else "running"), "object_state_token": state_call_count, "rows": [ { diff --git a/tests/unit/pyqt_gui/test_gui_startup_progress.py b/tests/unit/pyqt_gui/test_gui_startup_progress.py index 0c82476b4..403e93c03 100644 --- a/tests/unit/pyqt_gui/test_gui_startup_progress.py +++ b/tests/unit/pyqt_gui/test_gui_startup_progress.py @@ -51,10 +51,7 @@ def _popen(command, **kwargs): ) controller.fail("Startup failed", "traceback") - events = [ - json.loads(line) - for line in process.stdin.getvalue().splitlines() - ] + events = [json.loads(line) for line in process.stdin.getvalue().splitlines()] assert captured["command"] == [ sys.executable, "-m", @@ -178,8 +175,8 @@ def run(self, *, on_main_window_ready, on_startup_failure): app_module = ModuleType("openhcs.pyqt_gui.app") app_module.OpenHCSPyQtApp = _Application window_utils_module = ModuleType("pyqt_reactive.utils.window_utils") - window_utils_module.install_global_window_bounds_filter = ( - lambda app: events.append(("bounds", app)) + window_utils_module.install_global_window_bounds_filter = lambda app: events.append( + ("bounds", app) ) monkeypatch.setitem(sys.modules, config_module.__name__, config_module) @@ -261,10 +258,7 @@ def main(*, arguments, startup_progress): monkeypatch.setattr(gui_startup.sys, "argv", ["openhcs"]) assert gui_startup.main() == 0 - events = [ - json.loads(line) - for line in process.stdin.getvalue().splitlines() - ] + events = [json.loads(line) for line in process.stdin.getvalue().splitlines()] assert events == [ { "kind": "output", @@ -452,9 +446,6 @@ def deferred_initialization(self): class _ApplicationHarness: main_window = _MainWindow() - def processEvents(self): - events.append("process") - monkeypatch.setattr(QtCore, "QTimer", _ImmediateTimer) OpenHCSPyQtApp.show_main_window( _ApplicationHarness(), @@ -467,7 +458,7 @@ def processEvents(self): "activate", ("timer", 100), "deferred", - "process", + ("timer", 0), "ready", ] diff --git a/tests/unit/pyqt_gui/test_ui_agent_bridge.py b/tests/unit/pyqt_gui/test_ui_agent_bridge.py index 15df01598..3b95f0297 100644 --- a/tests/unit/pyqt_gui/test_ui_agent_bridge.py +++ b/tests/unit/pyqt_gui/test_ui_agent_bridge.py @@ -51,6 +51,7 @@ from openhcs.constants.constants import OrchestratorState from openhcs.core.config import ( GlobalPipelineConfig, + LazyNapariStreamingConfig, LazyPathPlanningConfig, PipelineConfig, ) @@ -101,6 +102,10 @@ PycodifiedObjectDocumentSpec, ) from openhcs.ui.shared.plate_scope_identity import PipelineScopeIdentity +from openhcs.ui.shared.plate_manager_code_document import ( + PlateManagerCodeDocumentAuthority, +) +from zmqruntime.config import TransportMode from openhcs.pyqt_gui.services.ui_bridge_pipeline_editor import ( PipelineEditorBridgeProviderSet, ) @@ -3825,6 +3830,41 @@ def test_source_policy_allows_public_function_steps_not_runtime_factories() -> N assert any(error.code == "unsafe_call" for error in errors) +def test_source_policy_accepts_rendered_external_config_value_types() -> None: + payload = PlateManagerCodeDocumentAuthority.from_values( + plate_paths=(PLATE_SCOPE_ID,), + global_pipeline_config=GlobalPipelineConfig(), + per_plate_configs={ + PLATE_SCOPE_ID: PipelineConfig( + napari_streaming_config=LazyNapariStreamingConfig( + enabled=True, + transport_mode=TransportMode.IPC, + ) + ) + }, + pipeline_data={PLATE_SCOPE_ID: []}, + ) + + source = PlateManagerCodeDocumentAuthority.render(payload) + + assert "from zmqruntime.config import TransportMode" in source + assert UiCodeDocumentSourcePolicy().validate(source) == () + + +def test_source_policy_rejects_undeclared_external_constructor_type() -> None: + source = ( + "from zmqruntime.config import ZMQConfig\n" + f"plate_paths = ['{PLATE_SCOPE_ID}']\n" + "global_config = None\n" + f"per_plate_configs = {{'{PLATE_SCOPE_ID}': ZMQConfig()}}\n" + f"pipeline_data = {{'{PLATE_SCOPE_ID}': []}}\n" + ) + + errors = UiCodeDocumentSourcePolicy().validate(source) + + assert any(error.code == "unsafe_import" for error in errors) + + def test_source_policy_allows_safe_builtin_type_references_only() -> None: source = ( "from openhcs.core.runtime_tabular_values import FieldSpec\n" diff --git a/tests/unit/test_build_website.py b/tests/unit/test_build_website.py index 4ef3660a4..8e2da5dc0 100644 --- a/tests/unit/test_build_website.py +++ b/tests/unit/test_build_website.py @@ -1,6 +1,7 @@ from __future__ import annotations import re +from html.parser import HTMLParser from pathlib import Path import pytest @@ -18,7 +19,48 @@ REPO_ROOT = Path(__file__).resolve().parents[2] -def test_build_site_stages_authoritative_screenshot_and_valid_references( +class _GalleryMarkupCollector(HTMLParser): + """Collect the semantic media surface without a browser dependency.""" + + def __init__(self) -> None: + super().__init__(convert_charrefs=True) + self.in_gallery = False + self.figures = 0 + self.figcaptions = 0 + self.images: list[dict[str, str | None]] = [] + self.videos: list[dict[str, str | None]] = [] + self.sources: list[dict[str, str | None]] = [] + self.links: list[dict[str, str | None]] = [] + + def handle_starttag( + self, + tag: str, + attrs: list[tuple[str, str | None]], + ) -> None: + attributes = dict(attrs) + if tag == "section" and attributes.get("id") == "gallery": + self.in_gallery = True + if not self.in_gallery: + return + if tag == "figure": + self.figures += 1 + elif tag == "figcaption": + self.figcaptions += 1 + elif tag == "img": + self.images.append(attributes) + elif tag == "video": + self.videos.append(attributes) + elif tag == "source": + self.sources.append(attributes) + elif tag == "a": + self.links.append(attributes) + + def handle_endtag(self, tag: str) -> None: + if self.in_gallery and tag == "section": + self.in_gallery = False + + +def test_build_site_stages_authoritative_media_and_valid_references( tmp_path: Path, ): site_dir = tmp_path / "site" @@ -26,9 +68,10 @@ def test_build_site_stages_authoritative_screenshot_and_valid_references( local_targets = build_site(REPO_ROOT, site_dir) assert (site_dir / ".nojekyll").is_file() - assert (site_dir / "assets/ui.png").read_bytes() == ( - REPO_ROOT / "docs/source/_static/ui.png" - ).read_bytes() + gallery_sources = tuple(sorted((REPO_ROOT / "website/assets/gallery").iterdir())) + for source in gallery_sources: + relative_name = source.relative_to(REPO_ROOT / "website") + assert (site_dir / relative_name).read_bytes() == source.read_bytes() for logo_name in ( "bioformats.svg", "cellprofiler.png", @@ -48,7 +91,7 @@ def test_build_site_stages_authoritative_screenshot_and_valid_references( assert (site_dir / output_name).read_bytes() == ( REPO_ROOT / source_name ).read_bytes() - assert local_targets == ( + expected_non_gallery_targets = ( "assets/logos/bioformats.svg", "assets/logos/cellprofiler.png", "assets/logos/cupy.svg", @@ -61,7 +104,6 @@ def test_build_site_stages_authoritative_screenshot_and_valid_references( "assets/logos/pyclesperanto.png", "assets/logos/pytorch.svg", "assets/logos/tensorflow.svg", - "assets/ui.png", "globals.css", "index.html", "privacy.html", @@ -69,6 +111,28 @@ def test_build_site_stages_authoritative_screenshot_and_valid_references( "support.html", "terms.html", ) + assert ( + tuple( + target + for target in local_targets + if not target.startswith("assets/gallery/") + ) + == expected_non_gallery_targets + ) + collector = _GalleryMarkupCollector() + collector.feed((site_dir / "index.html").read_text(encoding="utf-8")) + gallery_references = { + attributes["src"] for attributes in (*collector.images, *collector.sources) + } + gallery_references.add(collector.videos[0]["poster"]) + gallery_references.update( + link["href"] + for link in collector.links + if link.get("href", "").startswith("assets/gallery/") + ) + assert { + target for target in local_targets if target.startswith("assets/gallery/") + } == gallery_references assert validate_site(site_dir) == local_targets @@ -133,7 +197,8 @@ def test_shipping_copy_projects_current_release_and_keeps_boundaries_explicit( assert "https://openhcs.readthedocs.io/en/latest/api/" in html assert ">Install local MCP" in html assert "https://github.com/OpenHCSDev/OpenHCS/releases" in html - assert 'src="assets/ui.png"' in html + assert 'id="gallery"' in html + assert 'src="assets/gallery/' in html assert "CellProfiler" in html and package_version in html assert "GPU libraries + custom functions" in html assert "Compute with" in html @@ -189,11 +254,11 @@ def test_landing_page_uses_factual_copy_and_readable_proportions(): assert html.count('src="assets/logos/openhcs-horizontal.svg"') == 1 assert html.count('src="assets/logos/openhcs-stacked.svg"') == 1 assert 'href="assets/logos/openhcs-favicon.svg"' in html - assert 'OpenHCS' not in html + assert "OpenHCS" not in html assert '' not in html assert 'class="hero-grid"' in html assert 'class="release-summary"' in html - assert "Plate, pipeline, and result management." in html + assert "Microscopy workflows in view." in html assert "Agent access to pipeline and runtime state." in html for removed_slogan in ( "without the black box", @@ -216,6 +281,87 @@ def test_landing_page_uses_factual_copy_and_readable_proportions(): ) in styles +def test_gallery_uses_semantic_accessible_media_and_stable_paths(): + html = (REPO_ROOT / "website/index.html").read_text(encoding="utf-8") + collector = _GalleryMarkupCollector() + collector.feed(html) + + assert 'href="#gallery"' in html + assert 'aria-labelledby="gallery-title"' in html + assert "A real imported Comet Assay run moves through compilation" in html + assert "time-lapse" not in html + assert "five-phase compilation" not in html + assert "selecting a native ROI updates its linked feature row" in html + assert "BSD-3-Clause" in html + assert ( + "https://github.com/CellProfiler/examples/tree/" + "4972b59e670a4ae96c3d453803c92eeff378d054" in html + ) + assert collector.figures == 8 + assert collector.figcaptions == collector.figures + assert len(collector.images) == 8 + for image in collector.images: + assert image["src"].startswith("assets/gallery/") + assert image["src"].endswith(".webp") + assert image.get("alt", "").strip() + assert image.get("loading") == "lazy" + assert image.get("decoding") == "async" + assert image.get("width", "").isdigit() + assert image.get("height", "").isdigit() + + motion_stems = ("lazy-inheritance", "execution-progress", "result-review") + assert len(collector.videos) == len(motion_stems) + for video in collector.videos: + for boolean_attribute in ("controls", "muted", "loop", "playsinline"): + assert boolean_attribute in video + assert "autoplay" not in video + assert video["preload"] == "metadata" + assert video["poster"].startswith("assets/gallery/") + assert video["poster"].endswith("-poster.webp") + assert video.get("aria-describedby", "").strip() + assert { + Path(video["poster"]).stem.removesuffix("-poster") for video in collector.videos + } == set(motion_stems) + assert {Path(source["src"]).stem for source in collector.sources} == set( + motion_stems + ) + assert [source["type"] for source in collector.sources] == [ + media_type for _ in motion_stems for media_type in ("video/webm", "video/mp4") + ] + + full_resolution_targets = { + link["href"] + for link in collector.links + if link.get("class") and "gallery-media-link" in link["class"].split() + } + assert full_resolution_targets == {image["src"] for image in collector.images} + assert 'href="assets/gallery/lazy-inheritance.gif"' in html + for link in collector.links: + if link.get("class") and "gallery-media-link" in link["class"].split(): + assert link.get("aria-label", "").strip() + + +def test_gallery_layout_is_responsive_and_has_reduced_motion_fallback(): + styles = (REPO_ROOT / "website/styles.css").read_text(encoding="utf-8") + + assert "grid-template-columns: repeat(12, minmax(0, 1fr));" in styles + assert "align-items: start;" in styles + gallery_card_rule = styles.split(".gallery-card {", 1)[1].split("}", 1)[0] + assert "margin: 0;" in gallery_card_rule + assert ".gallery-card-wide { grid-column: span 7; }" in styles + assert ".gallery-card-compact { grid-column: span 5; }" in styles + assert ".gallery-card-viewer { grid-column: span 6; }" in styles + assert "@media (max-width: 900px)" in styles + assert ( + ".gallery-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }" in styles + ) + assert "@media (max-width: 600px)" in styles + assert ".gallery-grid { grid-template-columns: 1fr;" in styles + reduced_motion = styles.split("@media (prefers-reduced-motion: reduce)", 1)[1] + assert ".gallery-motion video { display: none; }" in reduced_motion + assert ".gallery-motion-fallback { display: block;" in reduced_motion + + def test_public_policy_pages_are_staged_with_truthful_hosted_boundaries( tmp_path: Path, ): @@ -288,12 +434,7 @@ def test_website_source_and_workflow_follow_package_metadata_authorities(): assert "0.5.21" not in source_html assert "0.5.22" not in source_html assert workflow.count(' - "openhcs/__init__.py"') == 2 - assert ( - workflow.count( - ' - "openhcs/resources/assets/openhcs-*.svg"' - ) - == 2 - ) + assert workflow.count(' - "openhcs/resources/assets/openhcs-*.svg"') == 2 for page_name in ("privacy.html", "support.html", "terms.html"): page_source = (REPO_ROOT / "website" / page_name).read_text(encoding="utf-8") assert CONTACT_EMAIL_TOKEN in page_source @@ -317,13 +458,28 @@ def test_validation_checks_fragments_across_public_pages(tmp_path: Path): validate_site(site_dir) +def test_validation_checks_video_poster_references(tmp_path: Path): + site_dir = tmp_path / "site" + build_site(REPO_ROOT, site_dir) + index_path = site_dir / "index.html" + index_path.write_text( + index_path.read_text(encoding="utf-8").replace( + 'poster="assets/gallery/lazy-inheritance-poster.webp"', + 'poster="assets/gallery/missing-poster.webp"', + 1, + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match=r"poster=.*missing-poster"): + validate_site(site_dir) + + def test_readme_does_not_link_unpublished_coverage_site(): readme = (REPO_ROOT / "README.md").read_text(encoding="utf-8") assert "trissim.github.io/openhcs/coverage" not in readme - assert ( - 'src="openhcs/resources/assets/openhcs-lockup-stacked.svg"' in readme - ) + assert 'src="openhcs/resources/assets/openhcs-lockup-stacked.svg"' in readme def test_build_site_refuses_to_replace_source_or_repository_root(): diff --git a/tests/unit/test_capture_media_gallery.py b/tests/unit/test_capture_media_gallery.py new file mode 100644 index 000000000..38fb6ceca --- /dev/null +++ b/tests/unit/test_capture_media_gallery.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +from pathlib import Path + +import pytest + +from scripts.capture_media_gallery import ( + CaptureManifest, + CaptureRecord, + Crop, + Derivative, + MediaGalleryError, + MediaProbe, + Trim, + WindowGeometry, + _capture_target, + build_manifest, + build_transcode_command, + capture_window_still, + load_manifest, + plan_manifest, + record_window, + resolve_contained_path, + validate_derivative, + validate_manifest_outputs, +) + + +def _still_output(filename: str = "interface-overview.webp") -> Derivative: + return Derivative( + filename=filename, + max_width=1600, + max_height=1000, + max_bytes=2_000_000, + ) + + +def _motion_record(*outputs: Derivative) -> CaptureRecord: + return CaptureRecord( + source=Path("raw/session.mkv"), + crop=Crop(x=4, y=6, width=1200, height=760), + trim=Trim(start_seconds=1.25, duration_seconds=8.0), + outputs=outputs, + ) + + +def test_load_manifest_builds_typed_authority_and_rejects_unknown_fields( + tmp_path: Path, +) -> None: + manifest_path = tmp_path / "manifest.json" + manifest_path.write_text( + json.dumps( + { + "schema_version": 1, + "captures": [ + { + "source": "raw/interface.png", + "crop": {"x": 10, "y": 12, "width": 800, "height": 500}, + "outputs": [ + { + "filename": "interface-overview.webp", + "max_width": 800, + "max_height": 500, + "max_bytes": 500000, + } + ], + } + ], + } + ), + encoding="utf-8", + ) + + manifest = load_manifest(manifest_path) + + assert manifest.schema_version == 1 + assert manifest.captures[0].crop == Crop(10, 12, 800, 500) + assert manifest.captures[0].outputs[0].filename == "interface-overview.webp" + + document = json.loads(manifest_path.read_text(encoding="utf-8")) + document["captures"][0]["caption"] = "This belongs to the website." + manifest_path.write_text(json.dumps(document), encoding="utf-8") + with pytest.raises(MediaGalleryError, match="unknown fields: caption"): + load_manifest(manifest_path) + + +@pytest.mark.parametrize( + "source", + ( + Path("../private.png"), + Path("/tmp/private.png"), + Path("."), + ), +) +def test_capture_source_must_be_contained_relative_path(source: Path) -> None: + with pytest.raises(MediaGalleryError, match="contained relative path"): + CaptureRecord(source=source, outputs=(_still_output(),)) + + +def test_containment_rejects_symlink_escape(tmp_path: Path) -> None: + root = tmp_path / "root" + root.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + (root / "escape").symlink_to(outside, target_is_directory=True) + + with pytest.raises(MediaGalleryError, match="escapes its declared root"): + resolve_contained_path( + root, + Path("escape/private.png"), + context="Capture source", + ) + + +@pytest.mark.parametrize( + "filename", + ( + "Has Spaces.webp", + "camelCase.webp", + "../escape.webp", + "double--hyphen.webp", + "unsupported.png", + ), +) +def test_output_names_are_caption_safe_and_format_owned(filename: str) -> None: + with pytest.raises(MediaGalleryError): + _still_output(filename) + + +def test_motion_manifest_requires_bounded_trim_and_typed_output_fields() -> None: + motion = Derivative( + filename="lazy-update.webm", + max_width=1280, + max_height=800, + max_bytes=3_000_000, + fps=24, + ) + with pytest.raises(MediaGalleryError, match="explicit bounded trim"): + CaptureRecord(source=Path("raw/session.mkv"), outputs=(motion,)) + + with pytest.raises(MediaGalleryError, match="must declare fps"): + _motion_record( + Derivative( + filename="lazy-update.mp4", + max_width=1280, + max_height=800, + max_bytes=3_000_000, + ) + ) + + with pytest.raises(MediaGalleryError, match="frame_at_seconds"): + _motion_record(_still_output("lazy-update-poster.webp")) + + +def test_plan_is_a_write_free_exact_command_projection(tmp_path: Path) -> None: + poster = Derivative( + filename="lazy-update-poster.webp", + max_width=1280, + max_height=800, + max_bytes=1_000_000, + frame_at_seconds=2.5, + ) + webm = Derivative( + filename="lazy-update.webm", + max_width=1280, + max_height=800, + max_bytes=3_000_000, + fps=24, + ) + manifest = CaptureManifest(captures=(_motion_record(poster, webm),)) + source_root = tmp_path / "sources" + output_root = tmp_path / "outputs" + + plan = plan_manifest(manifest, source_root, output_root) + + assert not source_root.exists() + assert not output_root.exists() + poster_command, webm_command = plan[0]["commands"] + assert "-ss" in poster_command + assert poster_command[poster_command.index("-ss") + 1] == "3.75" + assert "-t" not in poster_command + assert "-t" in webm_command + assert webm_command[webm_command.index("-t") + 1] == "8" + poster_filters = poster_command[poster_command.index("-vf") + 1] + assert "crop=1200:760:4:6" in poster_filters + assert "force_original_aspect_ratio=decrease" in poster_filters + assert "fps=" not in poster_filters + webm_filters = webm_command[webm_command.index("-vf") + 1] + assert "fps=24" in webm_filters + + +def test_gif_encoding_owns_palette_filter_and_output_mapping() -> None: + output = Derivative( + filename="lazy-update.gif", + max_width=960, + max_height=600, + max_bytes=8_000_000, + fps=12, + ) + record = _motion_record(output) + + command = build_transcode_command( + record, + output, + Path("/captures/session.mkv"), + Path("/gallery/lazy-update.gif"), + ffmpeg_executable="ffmpeg", + ) + + assert "-filter_complex" in command + filter_graph = command[command.index("-filter_complex") + 1] + assert "palettegen=max_colors=192" in filter_graph + assert filter_graph.endswith("[gallery_output]") + assert command[command.index("-map") + 1] == "[gallery_output]" + + +def test_manifest_refuses_to_overwrite_source_capture(tmp_path: Path) -> None: + record = CaptureRecord( + source=Path("same.webp"), + outputs=(_still_output("same.webp"),), + ) + manifest = CaptureManifest(captures=(record,)) + + with pytest.raises(MediaGalleryError, match="overwrite source capture"): + plan_manifest(manifest, tmp_path, tmp_path) + + +def test_raw_capture_never_overwrites_existing_source(tmp_path: Path) -> None: + target = tmp_path / "raw" / "session.png" + target.parent.mkdir() + target.write_bytes(b"original") + + with pytest.raises(MediaGalleryError, match="Refusing to overwrite source"): + _capture_target(tmp_path, Path("raw/session.png"), ".png") + assert target.read_bytes() == b"original" + + +def test_capture_still_projects_real_window_without_pixel_editing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + commands: list[tuple[str, ...]] = [] + + def fake_run(command: tuple[str, ...]) -> subprocess.CompletedProcess[str]: + commands.append(tuple(command)) + Path(command[-1]).write_bytes(b"lossless-png") + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr( + "scripts.capture_media_gallery._require_tool", + lambda _tool: "magick", + ) + monkeypatch.setattr("scripts.capture_media_gallery.run_checked", fake_run) + monkeypatch.setattr( + "scripts.capture_media_gallery.probe_media", + lambda _path: MediaProbe(1200, 800, "png", None), + ) + + report = capture_window_still( + tmp_path, + Path("raw/interface.png"), + "0x123", + ) + + assert commands[0][1:4] == ("import", "-window", "0x123") + assert (tmp_path / "raw" / "interface.png").read_bytes() == b"lossless-png" + assert report["width"] == 1200 + + +def test_record_window_projects_fixed_geometry_and_lossless_ffv1( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + commands: list[tuple[str, ...]] = [] + + def fake_run(command: tuple[str, ...]) -> subprocess.CompletedProcess[str]: + commands.append(tuple(command)) + Path(command[-1]).write_bytes(b"lossless-ffv1") + return subprocess.CompletedProcess(command, 0, "", "") + + monkeypatch.setattr( + "scripts.capture_media_gallery._require_tool", + lambda _tool: "ffmpeg", + ) + monkeypatch.setattr( + "scripts.capture_media_gallery.read_window_geometry", + lambda _window_id: WindowGeometry(x=11, y=17, width=1400, height=900), + ) + monkeypatch.setattr("scripts.capture_media_gallery.run_checked", fake_run) + monkeypatch.setattr( + "scripts.capture_media_gallery.probe_media", + lambda _path: MediaProbe(1400, 900, "ffv1", 12.0), + ) + + report = record_window( + tmp_path, + Path("raw/interaction.mkv"), + "0x123", + duration_seconds=12.0, + fps=30, + display=":9.0", + ) + + command = commands[0] + assert command[command.index("-f") + 1] == "x11grab" + assert command[command.index("-video_size") + 1] == "1400x900" + assert command[command.index("-i") + 1] == ":9.0+11,17" + assert command[command.index("-c:v") + 1] == "ffv1" + assert (tmp_path / "raw" / "interaction.mkv").read_bytes() == b"lossless-ffv1" + assert report["duration_seconds"] == 12.0 + + +def test_build_preflights_every_existing_output_before_writing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + source_root = tmp_path / "sources" + output_root = tmp_path / "outputs" + source_path = source_root / "raw" / "session.png" + source_path.parent.mkdir(parents=True) + source_path.write_bytes(b"lossless-source") + output_root.mkdir() + existing_output = output_root / "second.webp" + existing_output.write_bytes(b"accepted-output") + manifest = CaptureManifest( + captures=( + CaptureRecord( + source=Path("raw/session.png"), + outputs=( + _still_output("first.webp"), + _still_output("second.webp"), + ), + ), + ) + ) + monkeypatch.setattr( + "scripts.capture_media_gallery.probe_media", + lambda _path: MediaProbe(800, 500, "png", None), + ) + + with pytest.raises(MediaGalleryError, match="Derived outputs already exist"): + build_manifest(manifest, source_root, output_root) + + assert not (output_root / "first.webp").exists() + assert existing_output.read_bytes() == b"accepted-output" + + +def test_derivative_validation_enforces_size_dimensions_codec_and_duration( + tmp_path: Path, +) -> None: + output = Derivative( + filename="lazy-update.webm", + max_width=1280, + max_height=800, + max_bytes=100, + fps=20, + ) + record = _motion_record(output) + target = tmp_path / output.filename + target.write_bytes(b"x" * 50) + + report = validate_derivative( + record, + output, + target, + probe=MediaProbe( + width=1200, + height=760, + codec_name="vp9", + duration_seconds=8.0, + ), + ) + assert report["bytes"] == 50 + assert report["width"] == 1200 + + with pytest.raises(MediaGalleryError, match="above its 1280x800 manifest bounds"): + validate_derivative( + record, + output, + target, + probe=MediaProbe( + width=1300, + height=760, + codec_name="vp9", + duration_seconds=8.0, + ), + ) + with pytest.raises(MediaGalleryError, match="expected 'vp9'"): + validate_derivative( + record, + output, + target, + probe=MediaProbe( + width=1200, + height=760, + codec_name="h264", + duration_seconds=8.0, + ), + ) + with pytest.raises(MediaGalleryError, match="duration is"): + validate_derivative( + record, + output, + target, + probe=MediaProbe( + width=1200, + height=760, + codec_name="vp9", + duration_seconds=6.0, + ), + ) + + +@pytest.mark.skipif( + not shutil.which("ffmpeg") or not shutil.which("ffprobe"), + reason="FFmpeg is an optional host capture dependency", +) +def test_real_ffmpeg_derivatives_are_bounded_and_revalidate(tmp_path: Path) -> None: + source_root = tmp_path / "sources" + output_root = tmp_path / "outputs" + source_path = source_root / "raw" / "session.mkv" + source_path.parent.mkdir(parents=True) + subprocess.run( + ( + shutil.which("ffmpeg") or "ffmpeg", + "-hide_banner", + "-loglevel", + "error", + "-f", + "lavfi", + "-i", + "testsrc2=size=320x180:rate=20:duration=2", + "-c:v", + "ffv1", + "-threads", + "1", + str(source_path), + ), + check=True, + ) + outputs = ( + Derivative( + filename="session-poster.webp", + max_width=240, + max_height=140, + max_bytes=500_000, + frame_at_seconds=0.25, + ), + Derivative( + filename="session.mp4", + max_width=240, + max_height=140, + max_bytes=1_000_000, + fps=10, + ), + Derivative( + filename="session.webm", + max_width=240, + max_height=140, + max_bytes=1_000_000, + fps=10, + ), + Derivative( + filename="session.gif", + max_width=240, + max_height=140, + max_bytes=2_000_000, + fps=10, + ), + ) + manifest = CaptureManifest( + captures=( + CaptureRecord( + source=Path("raw/session.mkv"), + trim=Trim(start_seconds=0.25, duration_seconds=1.0), + outputs=outputs, + ), + ) + ) + + build_report = build_manifest(manifest, source_root, output_root) + validation_report = validate_manifest_outputs( + manifest, + source_root, + output_root, + ) + rebuilt_report = build_manifest( + manifest, + source_root, + output_root, + force=True, + ) + + assert {item["path"] for item in build_report[0]["outputs"]} == { + output.filename for output in outputs + } + assert {item["path"]: item["sha256"] for item in build_report[0]["outputs"]} == { + item["path"]: item["sha256"] for item in rebuilt_report[0]["outputs"] + } + assert validation_report[0]["source"]["sha256"] + assert all((output_root / output.filename).is_file() for output in outputs) diff --git a/tests/unit/test_master_multi_plate_demo.py b/tests/unit/test_master_multi_plate_demo.py index b0a289fa8..96a00fbbf 100644 --- a/tests/unit/test_master_multi_plate_demo.py +++ b/tests/unit/test_master_multi_plate_demo.py @@ -971,6 +971,11 @@ def test_mcp_operations_wait_for_long_ui_apply_through_operation_owner(tmp_path) ) ] assert len(wait_calls) == 2 + for wait_call in wait_calls: + arguments = json.loads(wait_call[wait_call.index("--arguments") + 1]) + request_timeout = arguments["timeout_seconds"] + client_timeout = float(wait_call[wait_call.index("--timeout-seconds") + 1]) + assert client_timeout == request_timeout + 5.0 def test_mcp_operations_retry_read_only_document_without_replaying_apply(tmp_path): diff --git a/tests/unit/test_mcp_assay_showcase.py b/tests/unit/test_mcp_assay_showcase.py index 7f286d83c..80a6dd763 100644 --- a/tests/unit/test_mcp_assay_showcase.py +++ b/tests/unit/test_mcp_assay_showcase.py @@ -154,6 +154,7 @@ def test_showcase_sources_are_seven_bounded_distinct_pipeline_documents(tmp_path assert "materialize_runtime_artifacts=True" in source assert "SourceBinding" not in source assert ".cppipe" not in source + assert '"return_segmentation_mask"' not in source assert str(plate_path) in source assert str(output_path) in source if blueprint_index >= 3: @@ -185,9 +186,16 @@ def test_showcase_sources_are_seven_bounded_distinct_pipeline_documents(tmp_path _callable, streamed_kwargs = streamed_steps[0].func assert streamed_kwargs["retain_neighbor_count_image"] is True assert streamed_kwargs["retain_percent_touching_image"] is False + if blueprint.scenario_id == "dual_channel_phenotype": + assert source.count("approx_min_width=3.0") == 2 + assert source.count("approx_max_width=12.0") == 2 + assert "intensity_above_local_background=2500.0" in source + assert "intensity_above_local_background=100.0" in source + assert '"minimum_stained_area": 10.0' in source if blueprint.scenario_id == "image_colocalization": assert "create_projection" in source - assert '"method": "min_projection"' in source + assert "NumpyStackProjectionMethod.MIN" in source + assert '"method": "min_projection"' not in source assert "image_math" not in source assert len(namespace["pipeline_steps"]) == 3 diff --git a/tests/unit/test_official30_lab_meeting_demo.py b/tests/unit/test_official30_lab_meeting_demo.py index cbfdccb23..b12327215 100644 --- a/tests/unit/test_official30_lab_meeting_demo.py +++ b/tests/unit/test_official30_lab_meeting_demo.py @@ -10,6 +10,9 @@ InputWorkspacePreparationRequest, InputWorkspacePreparationResult, ) +from openhcs.core.source_binding_workspace import ( + SourceBindingWorkspaceMaterialization, +) from openhcs.core.steps.function_step import FunctionStep from openhcs.processing.backends.processors.numpy_processor import percentile_normalize from openhcs.processing.presets.demo_contribution import PipelineDemoContribution @@ -60,6 +63,16 @@ def fake_prepare( FunctionStep(name=target_name, func=percentile_normalize), ], pipeline_config=PipelineConfig(), + materialization=SourceBindingWorkspaceMaterialization( + source_root=request.selected_path, + workspace_root=execution_plate_path, + metadata_path=execution_plate_path / "openhcs_metadata.json", + plane_mappings={}, + artifact_mappings={}, + source_metadata={ + "A01_s001_w1_z001_t001.tif": {"well": "A01"}, + }, + ), ) monkeypatch.setenv("CELLPROFILER_EXAMPLES_ROOT", str(examples_root)) @@ -84,7 +97,7 @@ def fake_prepare( for contribution in contributions ) assert len(preparation_requests) == 2 - expected_axis_filters = (1, 1) + expected_axis_filters = ("A01", "A01") for contribution, expected_axis_filter in zip( contributions, expected_axis_filters, diff --git a/tests/unit/test_skeletonize_and_save.py b/tests/unit/test_skeletonize_and_save.py index 5945713b3..ab6ee1919 100644 --- a/tests/unit/test_skeletonize_and_save.py +++ b/tests/unit/test_skeletonize_and_save.py @@ -82,6 +82,7 @@ def test_skeletonize_and_save_declares_csv_and_roi_materialization(): assert csv_options.filename_suffix == "_details.csv" assert csv_options.fields is None assert isinstance(roi_options, ROIOptions) + assert roi_options.min_area == 1 assert measurement_spec.artifact_type is MeasurementsArtifactType assert measurement_spec.relations[0].measurement_subject() is not None assert label_spec.artifact_type is ObjectLabelsArtifactType @@ -89,9 +90,11 @@ def test_skeletonize_and_save_declares_csv_and_roi_materialization(): def test_skeleton_measurements_materialize_as_csv(): filemanager = FileManager({"memory": MemoryStorageBackend()}) - spec = CallableContract.from_callable( - skeletonize_and_save - ).artifact_outputs[0].materialization + spec = ( + CallableContract.from_callable(skeletonize_and_save) + .artifact_outputs[0] + .materialization + ) measurement = SkeletonizationResult( slice_index=0, skeleton_count=2, @@ -115,9 +118,35 @@ def test_skeleton_measurements_materialize_as_csv(): assert output_path == "/tmp/A01_skeleton_measurements_step1_details.csv" csv_frame = pd.read_csv(StringIO(filemanager.load(output_path, "memory"))) - assert csv_frame.to_dict(orient="records") == [ - dict(measurements.row_mappings()[0]) - ] + assert csv_frame.to_dict(orient="records") == [dict(measurements.row_mappings()[0])] + + +def test_skeleton_roi_materialization_retains_thin_components(): + image = np.zeros((1, 12, 12), dtype=np.float32) + image[0, 2:7, 2] = 1.0 + _, _, masks = _skeletonize_and_save_impl()( + image, + threshold=0.5, + min_component_size=1, + ) + filemanager = FileManager({"memory": MemoryStorageBackend()}) + spec = ( + CallableContract.from_callable(skeletonize_and_save) + .artifact_outputs[1] + .materialization + ) + + output_path = materialize( + spec, + data=masks, + path="/tmp/A01_skeleton_rois_step1", + filemanager=filemanager, + backends=["memory"], + backend_kwargs={}, + ) + + assert output_path == "/tmp/A01_skeleton_rois_step1_rois.roi.zip" + assert len(filemanager.load(output_path, "memory")) == 1 def test_skeleton_measurements_generate_metaxpress_style_summary(tmp_path): diff --git a/website/assets/gallery/execution-progress-poster.webp b/website/assets/gallery/execution-progress-poster.webp new file mode 100644 index 000000000..cc5e7e83d Binary files /dev/null and b/website/assets/gallery/execution-progress-poster.webp differ diff --git a/website/assets/gallery/execution-progress.mp4 b/website/assets/gallery/execution-progress.mp4 new file mode 100644 index 000000000..89bbfeab9 Binary files /dev/null and b/website/assets/gallery/execution-progress.mp4 differ diff --git a/website/assets/gallery/execution-progress.webm b/website/assets/gallery/execution-progress.webm new file mode 100644 index 000000000..0cfa9c2ab Binary files /dev/null and b/website/assets/gallery/execution-progress.webm differ diff --git a/website/assets/gallery/fiji-review.webp b/website/assets/gallery/fiji-review.webp new file mode 100644 index 000000000..bef3b5eb2 Binary files /dev/null and b/website/assets/gallery/fiji-review.webp differ diff --git a/website/assets/gallery/lazy-inheritance-poster.webp b/website/assets/gallery/lazy-inheritance-poster.webp new file mode 100644 index 000000000..1f090989d Binary files /dev/null and b/website/assets/gallery/lazy-inheritance-poster.webp differ diff --git a/website/assets/gallery/lazy-inheritance.gif b/website/assets/gallery/lazy-inheritance.gif new file mode 100644 index 000000000..02fd536b1 Binary files /dev/null and b/website/assets/gallery/lazy-inheritance.gif differ diff --git a/website/assets/gallery/lazy-inheritance.mp4 b/website/assets/gallery/lazy-inheritance.mp4 new file mode 100644 index 000000000..228877d8a Binary files /dev/null and b/website/assets/gallery/lazy-inheritance.mp4 differ diff --git a/website/assets/gallery/lazy-inheritance.webm b/website/assets/gallery/lazy-inheritance.webm new file mode 100644 index 000000000..73627527d Binary files /dev/null and b/website/assets/gallery/lazy-inheritance.webm differ diff --git a/website/assets/gallery/mcp-control.webp b/website/assets/gallery/mcp-control.webp new file mode 100644 index 000000000..b74bf99cc Binary files /dev/null and b/website/assets/gallery/mcp-control.webp differ diff --git a/website/assets/gallery/multi-plate-overview.webp b/website/assets/gallery/multi-plate-overview.webp new file mode 100644 index 000000000..76578dd99 Binary files /dev/null and b/website/assets/gallery/multi-plate-overview.webp differ diff --git a/website/assets/gallery/napari-review.webp b/website/assets/gallery/napari-review.webp new file mode 100644 index 000000000..b23f40ad3 Binary files /dev/null and b/website/assets/gallery/napari-review.webp differ diff --git a/website/assets/gallery/pipeline-editor.webp b/website/assets/gallery/pipeline-editor.webp new file mode 100644 index 000000000..f4034aaac Binary files /dev/null and b/website/assets/gallery/pipeline-editor.webp differ diff --git a/website/assets/gallery/result-review-poster.webp b/website/assets/gallery/result-review-poster.webp new file mode 100644 index 000000000..7b90eb441 Binary files /dev/null and b/website/assets/gallery/result-review-poster.webp differ diff --git a/website/assets/gallery/result-review.mp4 b/website/assets/gallery/result-review.mp4 new file mode 100644 index 000000000..019dc2a04 Binary files /dev/null and b/website/assets/gallery/result-review.mp4 differ diff --git a/website/assets/gallery/result-review.webm b/website/assets/gallery/result-review.webm new file mode 100644 index 000000000..a80987bb4 Binary files /dev/null and b/website/assets/gallery/result-review.webm differ diff --git a/website/index.html b/website/index.html index 15231f85d..3728e6a8f 100644 --- a/website/index.html +++ b/website/index.html @@ -35,6 +35,7 @@ OpenHCS